Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 182 additions & 46 deletions contracts/src/AntseedBuyerOperator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U

interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);

function transferFrom(address from, address to, uint256 amount) external returns (bool);

function approve(address spender, uint256 amount) external returns (bool);

function balanceOf(address account) external view returns (uint256);
}

import { IAntseedChannels } from "./interfaces/IAntseedChannels.sol";
import { IAntseedDeposits } from "./interfaces/IAntseedDeposits.sol";
import { IAntseedRegistry } from "./interfaces/IAntseedRegistry.sol";
import {IAntseedChannels} from "./interfaces/IAntseedChannels.sol";
import {IAntseedDeposits} from "./interfaces/IAntseedDeposits.sol";
import {IAntseedRegistry} from "./interfaces/IAntseedRegistry.sol";

contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
IAntseedRegistry public immutable registry;
Expand All @@ -25,15 +28,18 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
mapping(bytes32 => bool) public usedDepositIds;
mapping(address => uint256) public totalPrincipalDeposited;
mapping(address => uint256) public totalBonusDeposited;
mapping(address => uint256) public totalWithdrawn;
mapping(address => uint256) public principalRemaining;
mapping(address => uint256) public bonusRemaining;
Comment thread
sirpy marked this conversation as resolved.
Outdated
mapping(address => uint256) public totalPrincipalWithdrawn;
mapping(address => uint256) public totalBonusWithdrawn;
mapping(address => uint256) public lastAccountedBalance;
mapping(address => bool) public buyerAccountingMigrated;

bytes32 public DOMAIN_SEPARATOR;
bytes32 public constant WITHDRAW_TYPEHASH =
keccak256("WithdrawPrincipal(address buyer,uint256 amount,address recipient,uint256 timestamp)");
bytes32 public constant REQUEST_CLOSE_TYPEHASH =
keccak256("RequestClose(bytes32 channelId,uint256 timestamp)");
bytes32 public constant WITHDRAW_CHANNEL_TYPEHASH =
keccak256("WithdrawChannel(bytes32 channelId,uint256 timestamp)");
bytes32 public constant WITHDRAW_TYPEHASH = keccak256("WithdrawPrincipal(address buyer,uint256 amount,address recipient,uint256 timestamp)");
bytes32 public constant REQUEST_CLOSE_TYPEHASH = keccak256("RequestClose(bytes32 channelId,uint256 timestamp)");
bytes32 public constant WITHDRAW_CHANNEL_TYPEHASH = keccak256("WithdrawChannel(bytes32 channelId,uint256 timestamp)");
bytes32 public constant REVOKE_OPERATOR_TYPEHASH = keccak256("RevokeOperator(address buyer,uint256 timestamp)");

uint256[50] private __gap;

Expand All @@ -44,6 +50,9 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
event BuyerDepositFundedWithId(address indexed buyer, uint256 principal, uint256 bonus, string id);
event BuyerDepositWithdrawn(address indexed buyer, address indexed recipient, uint256 amount);
event BuyerPrincipalWithdrawn(address indexed buyer, address indexed recipient, uint256 amount);
event BuyerBonusWithdrawn(address indexed buyer, uint256 amount);
event BuyerAccountingMigrated(address indexed buyer, uint256 principalRemaining, uint256 bonusRemaining, uint256 accountedBalance);
event BuyerOperatorRevoked(address indexed buyer);
event BuyerOperatorTransferred(address indexed buyer, address indexed newOperator);
event ChannelCloseRequested(bytes32 indexed channelId, address indexed buyer, address indexed caller);
event ChannelWithdrawn(bytes32 indexed channelId, address indexed buyer, address indexed caller);
Expand All @@ -60,6 +69,7 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
error InsufficientPrincipal();
error InvalidSignature();
error ExpiredSignature();
error AlreadyMigrated();

modifier onlyOwner() {
if (msg.sender != owner && msg.sender != admin) revert NotOwner();
Expand Down Expand Up @@ -100,13 +110,15 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
address depositsAddress = registry.deposits();
_forceApprove(usdc, depositsAddress, type(uint256).max);

DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("AntseedBuyerOperator"),
keccak256("1"),
block.chainid,
address(this)
));
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("AntseedBuyerOperator"),
keccak256("1"),
block.chainid,
address(this)
)
);
}

function _authorizeUpgrade(address) internal override onlyAdmin {}
Expand All @@ -129,29 +141,57 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
emit BuyerOperatorAccepted(buyer, nonce);
}

function _accountForUsage(address buyer) internal {
(uint256 available, uint256 reserved, ) = _deposits().getBuyerBalance(buyer);
uint256 currentBalance = available + reserved;
uint256 previousBalance = lastAccountedBalance[buyer];

if (previousBalance > currentBalance) {
uint256 used = previousBalance - currentBalance;
uint256 principalUsed = _min(used, principalRemaining[buyer]);
principalRemaining[buyer] -= principalUsed;
used -= principalUsed;

if (used > 0) {
bonusRemaining[buyer] -= _min(used, bonusRemaining[buyer]);
}
}

lastAccountedBalance[buyer] = currentBalance;
}

function _recordDeposit(address buyer, uint256 principal, uint256 bonus) internal {
_accountForUsage(buyer);
totalPrincipalDeposited[buyer] += principal;
totalBonusDeposited[buyer] += bonus;
principalRemaining[buyer] += principal;
bonusRemaining[buyer] += bonus;
lastAccountedBalance[buyer] += principal + bonus;
}

function depositFor(address buyer, uint256 principal, uint256 bonus) external nonReentrant onlyOwner {
if (buyer == address(0)) revert InvalidAddress();
//revoke bonus if we are not operators
if (_deposits().getOperator(buyer) != address(this)) bonus = 0;
uint256 total = principal + bonus;
if (total == 0) revert InvalidAmount();
_requireDepositsOperator(buyer);

totalPrincipalDeposited[buyer] += principal;
totalBonusDeposited[buyer] += bonus;
_recordDeposit(buyer, principal, bonus);
_deposits().deposit(buyer, total);
emit BuyerDepositFunded(buyer, principal, bonus);
}

function depositForWithId(address buyer, uint256 principal, uint256 bonus, string calldata id) external nonReentrant onlyOwner {
if (buyer == address(0)) revert InvalidAddress();
//revoke bonus if we are not operators
if (_deposits().getOperator(buyer) != address(this)) bonus = 0;
uint256 total = principal + bonus;
if (total == 0) revert InvalidAmount();
bytes32 idHash = keccak256(bytes(id));
if (usedDepositIds[idHash]) revert DuplicateDepositId();
usedDepositIds[idHash] = true;
_requireDepositsOperator(buyer);

totalPrincipalDeposited[buyer] += principal;
totalBonusDeposited[buyer] += bonus;
_recordDeposit(buyer, principal, bonus);
_deposits().deposit(buyer, total);
emit BuyerDepositFundedWithId(buyer, principal, bonus, id);
}
Expand All @@ -172,34 +212,130 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
if (signer != buyer) revert InvalidSignature();

_requireDepositsOperator(buyer);
_accountForUsage(buyer);

if (amount > withdrawablePrincipal(buyer)) revert InsufficientPrincipal();

totalWithdrawn[buyer] += amount;

uint256 beforeBalance = usdc.balanceOf(address(this));
_deposits().withdraw(buyer, amount);
uint256 received = usdc.balanceOf(address(this)) - beforeBalance;
if (received < amount) revert InvalidAmount();

principalRemaining[buyer] -= amount;
totalPrincipalWithdrawn[buyer] += amount;
lastAccountedBalance[buyer] -= amount;

_safeTransfer(usdc, recipient, amount);
emit BuyerPrincipalWithdrawn(buyer, recipient, amount);
}

/// @notice Returns the amount of principal a buyer can still withdraw.
function withdrawablePrincipal(address buyer) public view returns (uint256) {
return totalPrincipalDeposited[buyer] > totalWithdrawn[buyer]
? totalPrincipalDeposited[buyer] - totalWithdrawn[buyer]
: 0;
(uint256 available, uint256 reserved, ) = _deposits().getBuyerBalance(buyer);
uint256 remaining = principalRemaining[buyer];
uint256 currentBalance = available + reserved;
uint256 previousBalance = lastAccountedBalance[buyer];

if (previousBalance > currentBalance) {
uint256 used = previousBalance - currentBalance;
remaining = used > remaining ? 0 : remaining - used;
}

return _min(remaining, available);
}

function _withdrawUnusedBonus(address buyer) internal returns (uint256 amount) {
_accountForUsage(buyer);
(uint256 available, , ) = _deposits().getBuyerBalance(buyer);
if (available <= principalRemaining[buyer]) return 0;

amount = _min(available - principalRemaining[buyer], bonusRemaining[buyer]);
if (amount == 0) return 0;

_deposits().withdraw(buyer, amount);
bonusRemaining[buyer] -= amount;
totalBonusWithdrawn[buyer] += amount;
lastAccountedBalance[buyer] -= amount;
Comment thread
Copilot marked this conversation as resolved.

emit BuyerBonusWithdrawn(buyer, amount);
}

function _revokeOperator(address buyer) internal {
_requireDepositsOperator(buyer);
_withdrawUnusedBonus(buyer);
_deposits().transferOperator(buyer, address(0));
emit BuyerOperatorRevoked(buyer);
}

function revokeOperator(address buyer, uint256 timestamp, bytes memory buyerSig) public nonReentrant {
if (buyerSig.length > 0) {
if (timestamp > block.timestamp || block.timestamp - timestamp > 5 minutes) revert ExpiredSignature();
bytes32 structHash = keccak256(abi.encode(REVOKE_OPERATOR_TYPEHASH, buyer, timestamp));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
if (_recoverSigner(digest, buyerSig) != buyer) revert InvalidSignature();
} else {
_requireBuyerOrOwner(buyer);
}
_revokeOperator(buyer);
}

function revokeOperator(address buyer) external {
revokeOperator(buyer, 0, "");
}

function _migrateBuyerAccounting(address buyer) internal {
if (buyer == address(0)) revert InvalidAddress();
if (buyerAccountingMigrated[buyer]) revert AlreadyMigrated();

(uint256 available, uint256 reserved, ) = _deposits().getBuyerBalance(buyer);
uint256 currentBalance = available + reserved;

uint256 principal = totalPrincipalDeposited[buyer];
uint256 bonus = totalBonusDeposited[buyer];
uint256 totalTracked = principal + bonus;

if (totalTracked > currentBalance) {
uint256 used = totalTracked - currentBalance;
uint256 principalUsed = _min(used, principal);
principal -= principalUsed;
used -= principalUsed;

if (used > 0) {
bonus -= _min(used, bonus);
}
}

principalRemaining[buyer] = principal;
bonusRemaining[buyer] = bonus;
lastAccountedBalance[buyer] = currentBalance;
buyerAccountingMigrated[buyer] = true;

emit BuyerAccountingMigrated(buyer, principal, bonus, currentBalance);
}

/// @notice One-time migration helper for a single buyer funded before remaining-balance accounting existed.
/// @dev Computes remaining principal/bonus from tracked totals and current deposit balance.
function migrateBuyerAccounting(address buyer) external onlyOwner {
_migrateBuyerAccounting(buyer);
}

/// @notice One-time migration helper for multiple buyers funded before remaining-balance accounting existed.
/// @dev Reverts if any buyer is invalid or already migrated.
function migrateBuyerAccounting(address[] calldata buyers) external onlyOwner {
uint256 len = buyers.length;
for (uint256 i = 0; i < len; i++) {
_migrateBuyerAccounting(buyers[i]);
}
}

function transferBuyerOperator(address buyer, address newOperator) external nonReentrant onlyOwner {
if (buyer == address(0)) revert InvalidAddress();
_withdrawUnusedBonus(buyer);
_deposits().transferOperator(buyer, newOperator);
emit BuyerOperatorTransferred(buyer, newOperator);
}
Comment on lines +350 to 355

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_revokeOperator added CloseChannelsBeforeRevoke to stop bonus being stranded in reserved channels, but the transfer path has no equivalent check - the operator role can be handed off with bonus still locked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thats expected. added a description to the method


function requestClose(bytes32 channelId, uint256 timestamp, bytes calldata buyerSig) public nonReentrant {
function requestClose(bytes32 channelId, uint256 timestamp, bytes memory buyerSig) public nonReentrant {
address buyer = _channelBuyer(channelId);
if (buyerSig.length > 0) {
if (timestamp > block.timestamp || block.timestamp - timestamp > 5 minutes) revert ExpiredSignature();
Expand All @@ -213,14 +349,11 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
emit ChannelCloseRequested(channelId, buyer, msg.sender);
}

function requestClose(bytes32 channelId) external nonReentrant {
address buyer = _channelBuyer(channelId);
_requireBuyerOrOwner(buyer);
_channels().requestClose(channelId);
emit ChannelCloseRequested(channelId, buyer, msg.sender);
function requestClose(bytes32 channelId) external {
requestClose(channelId, 0, "");
}

function withdrawChannel(bytes32 channelId, uint256 timestamp, bytes calldata buyerSig) external nonReentrant {
function withdrawChannel(bytes32 channelId, uint256 timestamp, bytes memory buyerSig) public nonReentrant {
address buyer = _channelBuyer(channelId);
if (buyerSig.length > 0) {
if (timestamp > block.timestamp || block.timestamp - timestamp > 5 minutes) revert ExpiredSignature();
Expand All @@ -234,11 +367,8 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
emit ChannelWithdrawn(channelId, buyer, msg.sender);
}

function withdrawChannel(bytes32 channelId) external nonReentrant {
address buyer = _channelBuyer(channelId);
_requireBuyerOrOwner(buyer);
_channels().withdraw(channelId);
emit ChannelWithdrawn(channelId, buyer, msg.sender);
function withdrawChannel(bytes32 channelId) external {
withdrawChannel(channelId, 0, "");
}

function sweepToken(address token, address recipient, uint256 amount) external onlyAdmin {
Expand All @@ -262,7 +392,7 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
}

function _channelBuyer(bytes32 channelId) internal view returns (address buyer) {
(buyer,,,,,,,,) = IAntseedChannelsState(address(_channels())).channels(channelId);
(buyer, , , , , , , , ) = IAntseedChannelsState(address(_channels())).channels(channelId);
if (buyer == address(0)) revert InvalidAddress();
}

Expand Down Expand Up @@ -293,24 +423,30 @@ contract AntseedBuyerOperator is Initializable, UUPSUpgradeable {
_safeApprove(token, spender, amount);
}

function _recoverSigner(bytes32 digest, bytes calldata sig) internal pure returns (address) {
function _recoverSigner(bytes32 digest, bytes memory sig) internal pure returns (address) {
if (sig.length != 65) return address(0);
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := calldataload(sig.offset)
s := calldataload(add(sig.offset, 32))
v := byte(0, calldataload(add(sig.offset, 64)))
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
if (v < 27) v += 27;
if (v != 27 && v != 28) return address(0);
return ecrecover(digest, v, r, s);
}

function _min(uint256 a, uint256 b) private pure returns (uint256) {
return a < b ? a : b;
}
}

interface IAntseedChannelsState {
function channels(bytes32 channelId)
function channels(
bytes32 channelId
)
external
view
returns (
Expand Down
1 change: 1 addition & 0 deletions contracts/src/interfaces/IAntseedDeposits.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface IAntseedDeposits {
function setOperator(address buyer, address operator, uint256 nonce, bytes calldata buyerSig) external;
function deposit(address buyer, uint256 amount) external;
function withdraw(address buyer, uint256 amount) external;
function getBuyerBalance(address buyer) external view returns (uint256 available, uint256 reserved, uint256 lastActivity);
function transferOperator(address buyer, address newOperator) external;
function getOperator(address buyer) external view returns (address);
}
Loading
Loading