diff --git a/src/AuthCaptureEscrow.sol b/src/AuthCaptureEscrow.sol index b2fc077..7c1887e 100644 --- a/src/AuthCaptureEscrow.sol +++ b/src/AuthCaptureEscrow.sol @@ -1,536 +1,536 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {ReentrancyGuardTransient} from "solady/utils/ReentrancyGuardTransient.sol"; -import {LibClone} from "solady/utils/LibClone.sol"; - -import {TokenStore} from "./TokenStore.sol"; -import {TokenCollector} from "./collectors/TokenCollector.sol"; - -/// @title AuthCaptureEscrow -/// -/// @notice Facilitate payments through an escrow. -/// -/// @dev By escrowing payment, this contract can mimic the 2-step payment pattern of "authorization" and "capture". -/// @dev Authorization is defined as placing a hold on a payer's funds temporarily. -/// @dev Capture is defined as distributing payment to the end recipient. -/// @dev An Operator plays the role of facilitating state transitions associated with a payment, constrained by cryptographic authorization -/// from a payer and confirmation signals from the merchant. -/// -/// @author Coinbase (https://github.com/base/commerce-payments) -contract AuthCaptureEscrow is ReentrancyGuardTransient { - using SafeERC20 for IERC20; - - /// @notice Payment info, contains all information required to authorize and capture a unique payment - struct PaymentInfo { - /// @dev Entity responsible for driving payment flow - address operator; - /// @dev The payer's address authorizing the payment - address payer; - /// @dev Address that receives the payment (minus fees) - address receiver; - /// @dev The token contract address - address token; - /// @dev The amount of tokens that can be authorized - uint120 maxAmount; - /// @dev Timestamp when the payer's pre-approval can no longer authorize payment - uint48 preApprovalExpiry; - /// @dev Timestamp when an authorization can no longer be captured and the payer can reclaim from escrow - uint48 authorizationExpiry; - /// @dev Timestamp when a successful payment can no longer be refunded - uint48 refundExpiry; - /// @dev Minimum fee rate in basis points; bounds the absolute fee at capture as amount * minFeeBps / 10_000 - uint16 minFeeBps; - /// @dev Maximum fee rate in basis points; bounds the absolute fee at capture as amount * maxFeeBps / 10_000 - uint16 maxFeeBps; - /// @dev Address that receives the fee portion of payments, if 0 then operator can set at capture - address feeReceiver; - /// @dev A source of entropy to ensure unique hashes across different payments - uint256 salt; - } - - /// @notice State for tracking payments through lifecycle - struct PaymentState { - /// @dev True if payment has been authorized or charged - bool hasCollectedPayment; - /// @dev Amount of tokens currently on hold in escrow that can be captured - uint120 capturableAmount; - /// @dev Amount of tokens previously captured that can be refunded - uint120 refundableAmount; - } - - /// @notice Typehash used for hashing PaymentInfo structs - bytes32 public constant PAYMENT_INFO_TYPEHASH = keccak256( - "PaymentInfo(address operator,address payer,address receiver,address token,uint120 maxAmount,uint48 preApprovalExpiry,uint48 authorizationExpiry,uint48 refundExpiry,uint16 minFeeBps,uint16 maxFeeBps,address feeReceiver,uint256 salt)" - ); - - uint16 internal constant _MAX_FEE_BPS = 10_000; - - /// @notice Implementation contract for operator token stores - address public immutable tokenStoreImplementation; - - /// @notice State per unique payment - mapping(bytes32 paymentInfoHash => PaymentState state) public paymentState; - - /// @notice Emitted when a payment is charged and immediately captured - event PaymentCharged( - bytes32 indexed paymentInfoHash, - PaymentInfo paymentInfo, - uint256 amount, - address tokenCollector, - uint256 feeAmount, - address feeReceiver - ); - - /// @notice Emitted when authorized (escrowed) amount is increased - event PaymentAuthorized( - bytes32 indexed paymentInfoHash, PaymentInfo paymentInfo, uint256 amount, address tokenCollector - ); - - /// @notice Emitted when payment is captured from escrow - event PaymentCaptured(bytes32 indexed paymentInfoHash, uint256 amount, uint256 feeAmount, address feeReceiver); - - /// @notice Emitted when an authorized payment is voided, returning any escrowed funds to the payer - event PaymentVoided(bytes32 indexed paymentInfoHash, uint256 amount); - - /// @notice Emitted when an authorized payment is reclaimed, returning any escrowed funds to the payer - event PaymentReclaimed(bytes32 indexed paymentInfoHash, uint256 amount); - - /// @notice Emitted when a captured payment is refunded - event PaymentRefunded(bytes32 indexed paymentInfoHash, uint256 amount, address tokenCollector); - - /// @notice Event emitted when new token store is created - event TokenStoreCreated(address indexed operator, address tokenStore); - - /// @notice Sender for a function call does not follow access control requirements - error InvalidSender(address sender, address expected); - - /// @notice Amount is zero - error ZeroAmount(); - - /// @notice Amount overflows allowed storage size of uint120 - error AmountOverflow(uint256 amount, uint256 limit); - - /// @notice Requested authorization amount exceeds `PaymentInfo.maxAmount` - error ExceedsMaxAmount(uint256 amount, uint256 maxAmount); - - /// @notice Authorization attempted after pre-approval expiry - error AfterPreApprovalExpiry(uint48 timestamp, uint48 expiry); - - /// @notice Expiry timestamps violate preApproval <= authorization <= refund - error InvalidExpiries(uint48 preApproval, uint48 authorization, uint48 refund); - - /// @notice Fee bips overflows 10_000 maximum - error FeeBpsOverflow(uint16 feeBps); - - /// @notice Fee bps range invalid due to min > max - error InvalidFeeBpsRange(uint16 minFeeBps, uint16 maxFeeBps); - - /// @notice Fee amount outside of payer-approved bounds - error FeeAmountOutOfRange(uint256 feeAmount, uint256 minFee, uint256 maxFee); - - /// @notice Fee receiver is zero address with a non-zero fee - error ZeroFeeReceiver(); - - /// @notice Fee recipient cannot be changed - error InvalidFeeReceiver(address attempted, address expected); - - /// @notice Token collector is not valid for the operation - error InvalidCollectorForOperation(); - - /// @notice Token pull failed - error TokenCollectionFailed(); - - /// @notice Charge or authorize attempted on a payment has already been collected - error PaymentAlreadyCollected(bytes32 paymentInfoHash); - - /// @notice Capture attempted at or after authorization expiry - error AfterAuthorizationExpiry(uint48 timestamp, uint48 expiry); - - /// @notice Capture attempted with insufficient authorization amount - error InsufficientAuthorization(bytes32 paymentInfoHash, uint256 authorizedAmount, uint256 requestedAmount); - - /// @notice Void or reclaim attempted with zero authorization amount - error ZeroAuthorization(bytes32 paymentInfoHash); - - /// @notice Reclaim attempted before authorization expiry - error BeforeAuthorizationExpiry(uint48 timestamp, uint48 expiry); - - /// @notice Refund attempted at or after refund expiry - error AfterRefundExpiry(uint48 timestamp, uint48 expiry); - - /// @notice Refund attempted with amount exceeding previous non-refunded captures - error RefundExceedsCapture(uint256 refund, uint256 captured); - - /// @notice Check call sender is specified address - /// - /// @param sender Address to enforce is the call sender - modifier onlySender(address sender) { - if (msg.sender != sender) revert InvalidSender(msg.sender, sender); - _; - } - - /// @notice Ensures amount is non-zero and does not overflow storage - /// - /// @param amount Quantity of tokens being requested for a given operation - modifier validAmount(uint256 amount) { - if (amount == 0) revert ZeroAmount(); - if (amount > type(uint120).max) revert AmountOverflow(amount, type(uint120).max); - _; - } - - /// @notice Constructor that auto-deploys TokenStore implementation to clone - constructor() { - tokenStoreImplementation = address(new TokenStore(address(this))); - } - - /// @notice Transfers funds from payer to receiver in one step - /// - /// @dev If amount is less than the authorized amount, only amount is taken from payer - /// @dev Reverts if the authorization has been voided or expired - /// - /// @param paymentInfo PaymentInfo struct - /// @param amount Amount to charge and capture - /// @param tokenCollector Address of the token collector - /// @param collectorData Data to pass to the token collector - /// @param feeAmount Absolute fee in token units (must fall within payer-approved bounds) - /// @param feeReceiver Address to receive fees (should match the paymentInfo.feeReceiver unless that is 0 in which case it can be any address) - function charge( - PaymentInfo calldata paymentInfo, - uint256 amount, - address tokenCollector, - bytes calldata collectorData, - uint256 feeAmount, - address feeReceiver - ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) { - // Check payment info valid - _validatePayment(paymentInfo, amount); - - // Check fee parameters valid - _validateFee(paymentInfo, amount, feeAmount, feeReceiver); - - // Check payment not already collected - bytes32 paymentInfoHash = getHash(paymentInfo); - if (paymentState[paymentInfoHash].hasCollectedPayment) revert PaymentAlreadyCollected(paymentInfoHash); - - // Set payment state with refundable amount - paymentState[paymentInfoHash] = - PaymentState({hasCollectedPayment: true, capturableAmount: 0, refundableAmount: uint120(amount)}); - emit PaymentCharged(paymentInfoHash, paymentInfo, amount, tokenCollector, feeAmount, feeReceiver); - - // Transfer tokens into escrow - _collectTokens(paymentInfo, amount, tokenCollector, collectorData, TokenCollector.CollectorType.Payment); - - // Transfer tokens to receiver and fee receiver - _distributeTokens(paymentInfo.token, paymentInfo.receiver, amount, feeAmount, feeReceiver); - } - - /// @notice Transfers funds from payer to escrow - /// - /// @param paymentInfo PaymentInfo struct - /// @param amount Amount to authorize - /// @param tokenCollector Address of the token collector - /// @param collectorData Data to pass to the token collector - function authorize( - PaymentInfo calldata paymentInfo, - uint256 amount, - address tokenCollector, - bytes calldata collectorData - ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) { - // Check payment info valid - _validatePayment(paymentInfo, amount); - - // Check payment not already collected - bytes32 paymentInfoHash = getHash(paymentInfo); - if (paymentState[paymentInfoHash].hasCollectedPayment) revert PaymentAlreadyCollected(paymentInfoHash); - - // Set payment state with capturable amount - paymentState[paymentInfoHash] = - PaymentState({hasCollectedPayment: true, capturableAmount: uint120(amount), refundableAmount: 0}); - emit PaymentAuthorized(paymentInfoHash, paymentInfo, amount, tokenCollector); - - // Transfer tokens into escrow - _collectTokens(paymentInfo, amount, tokenCollector, collectorData, TokenCollector.CollectorType.Payment); - } - - /// @notice Transfer previously-escrowed funds to receiver - /// - /// @dev Can be called multiple times up to cumulative authorized amount - /// @dev Can only be called by the operator - /// - /// @param paymentInfo PaymentInfo struct - /// @param amount Amount to capture - /// @param feeAmount Absolute fee in token units (must fall within payer-approved bounds) - /// @param feeReceiver Address to receive fees (should match the paymentInfo.feeReceiver unless that is 0 in which case it can be any address) - function capture(PaymentInfo calldata paymentInfo, uint256 amount, uint256 feeAmount, address feeReceiver) - external - nonReentrant - onlySender(paymentInfo.operator) - validAmount(amount) - { - // Check fee parameters valid - _validateFee(paymentInfo, amount, feeAmount, feeReceiver); - - // Check before authorization expiry - if (block.timestamp >= paymentInfo.authorizationExpiry) { - revert AfterAuthorizationExpiry(uint48(block.timestamp), paymentInfo.authorizationExpiry); - } - - // Check sufficient escrow to capture - bytes32 paymentInfoHash = getHash(paymentInfo); - PaymentState memory state = paymentState[paymentInfoHash]; - if (state.capturableAmount < amount) { - revert InsufficientAuthorization(paymentInfoHash, state.capturableAmount, amount); - } - - // Update payment state, converting capturable amount to refundable amount - state.capturableAmount -= uint120(amount); - state.refundableAmount += uint120(amount); - paymentState[paymentInfoHash] = state; - emit PaymentCaptured(paymentInfoHash, amount, feeAmount, feeReceiver); - - // Transfer tokens to receiver and fee receiver - _distributeTokens(paymentInfo.token, paymentInfo.receiver, amount, feeAmount, feeReceiver); - } - - /// @notice Permanently voids a payment authorization - /// - /// @dev Returns any escrowed funds to payer - /// @dev Can only be called by the operator - /// - /// @param paymentInfo PaymentInfo struct - function void(PaymentInfo calldata paymentInfo) external nonReentrant onlySender(paymentInfo.operator) { - // Check authorization non-zero - bytes32 paymentInfoHash = getHash(paymentInfo); - uint256 authorizedAmount = paymentState[paymentInfoHash].capturableAmount; - if (authorizedAmount == 0) revert ZeroAuthorization(paymentInfoHash); - - // Clear capturable amount state - paymentState[paymentInfoHash].capturableAmount = 0; - emit PaymentVoided(paymentInfoHash, authorizedAmount); - - // Transfer tokens to payer from token store - _sendTokens(paymentInfo.operator, paymentInfo.token, paymentInfo.payer, authorizedAmount); - } - - /// @notice Returns any escrowed funds to payer - /// - /// @dev Can only be called by the payer and only after the authorization expiry - /// - /// @param paymentInfo PaymentInfo struct - function reclaim(PaymentInfo calldata paymentInfo) external nonReentrant onlySender(paymentInfo.payer) { - // Check not before authorization expiry - if (block.timestamp < paymentInfo.authorizationExpiry) { - revert BeforeAuthorizationExpiry(uint48(block.timestamp), paymentInfo.authorizationExpiry); - } - - // Check authorization non-zero - bytes32 paymentInfoHash = getHash(paymentInfo); - uint256 authorizedAmount = paymentState[paymentInfoHash].capturableAmount; - if (authorizedAmount == 0) revert ZeroAuthorization(paymentInfoHash); - - // Clear capturable amount state - paymentState[paymentInfoHash].capturableAmount = 0; - emit PaymentReclaimed(paymentInfoHash, authorizedAmount); - - // Transfer tokens to payer from token store - _sendTokens(paymentInfo.operator, paymentInfo.token, paymentInfo.payer, authorizedAmount); - } - - /// @notice Return previously-captured tokens to payer - /// - /// @dev Can be called by operator - /// @dev Funds are transferred from the caller or from the escrow if token collector retrieves external liquidity - /// - /// @param paymentInfo PaymentInfo struct - /// @param amount Amount to refund - /// @param tokenCollector Address of the token collector - /// @param collectorData Data to pass to the token collector - function refund( - PaymentInfo calldata paymentInfo, - uint256 amount, - address tokenCollector, - bytes calldata collectorData - ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) { - // Check refund has not expired - if (block.timestamp >= paymentInfo.refundExpiry) { - revert AfterRefundExpiry(uint48(block.timestamp), paymentInfo.refundExpiry); - } - - // Limit refund amount to previously captured - bytes32 paymentInfoHash = getHash(paymentInfo); - uint120 captured = paymentState[paymentInfoHash].refundableAmount; - if (captured < amount) revert RefundExceedsCapture(amount, captured); - - // Update refundable amount - paymentState[paymentInfoHash].refundableAmount = captured - uint120(amount); - emit PaymentRefunded(paymentInfoHash, amount, tokenCollector); - - // Transfer tokens into escrow and forward to payer - _collectTokens(paymentInfo, amount, tokenCollector, collectorData, TokenCollector.CollectorType.Refund); - _sendTokens(paymentInfo.operator, paymentInfo.token, paymentInfo.payer, amount); - } - - /// @notice Get hash of PaymentInfo struct - /// - /// @dev Includes chainId and verifyingContract in hash for cross-chain and cross-contract uniqueness - /// - /// @param paymentInfo PaymentInfo struct - /// - /// @return Hash of payment info for the current chain and contract address - function getHash(PaymentInfo calldata paymentInfo) public view returns (bytes32) { - bytes32 paymentInfoHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, paymentInfo)); - return keccak256(abi.encode(block.chainid, address(this), paymentInfoHash)); - } - - /// @notice Get the token store address for an operator - /// - /// @param operator The operator to get the token store for - /// - /// @return The operator's token store address - function getTokenStore(address operator) public view returns (address) { - return LibClone.predictDeterministicAddress({ - implementation: tokenStoreImplementation, - salt: bytes32(bytes20(operator)), - deployer: address(this) - }); - } - - /// @notice Transfer tokens into this contract - /// - /// @param paymentInfo PaymentInfo struct - /// @param amount Amount of tokens to collect - /// @param tokenCollector Address of the token collector - /// @param collectorData Data to pass to the token collector - /// @param collectorType Type of collector to enforce (payment or refund) - function _collectTokens( - PaymentInfo calldata paymentInfo, - uint256 amount, - address tokenCollector, - bytes calldata collectorData, - TokenCollector.CollectorType collectorType - ) internal { - // Check token collector matches required type - if (TokenCollector(tokenCollector).collectorType() != collectorType) revert InvalidCollectorForOperation(); - - // Measure balance change of token store to enforce as equal to expected amount - address token = paymentInfo.token; - address tokenStore = getTokenStore(paymentInfo.operator); - uint256 tokenStoreBalanceBefore = IERC20(token).balanceOf(tokenStore); - TokenCollector(tokenCollector).collectTokens(paymentInfo, tokenStore, amount, collectorData); - uint256 tokenStoreBalanceAfter = IERC20(token).balanceOf(tokenStore); - if (tokenStoreBalanceAfter != tokenStoreBalanceBefore + amount) revert TokenCollectionFailed(); - } - - /// @notice Send tokens from an operator's token store - /// - /// @param operator The operator whose token store to use - /// @param token The token to send - /// @param recipient Address to receive the tokens - /// @param amount Amount of tokens to send - function _sendTokens(address operator, address token, address recipient, uint256 amount) internal { - // Attempt to transfer tokens - address tokenStore = getTokenStore(operator); - bytes memory callData = abi.encodeWithSelector(TokenStore.sendTokens.selector, token, recipient, amount); - (bool success, bytes memory returnData) = tokenStore.call(callData); - if (success && returnData.length == 32 && abi.decode(returnData, (bool))) { - return; - } else if (tokenStore.code.length == 0) { - // Call failed from undeployed TokenStore, deploy and try again - tokenStore = LibClone.cloneDeterministic({ - implementation: tokenStoreImplementation, - salt: bytes32(bytes20(operator)) - }); - emit TokenStoreCreated(operator, tokenStore); - TokenStore(tokenStore).sendTokens(token, recipient, amount); - } else { - // Call failed from revert, bubble up data - assembly ("memory-safe") { - let returnDataSize := mload(returnData) - revert(add(32, returnData), returnDataSize) - } - } - } - - /// @notice Sends tokens to receiver and/or feeReceiver - /// - /// @param token Token to transfer - /// @param receiver Address to receive payment - /// @param amount Total amount to split between payment and fees - /// @param feeAmount Absolute fee in token units - /// @param feeReceiver Address to receive fees - function _distributeTokens(address token, address receiver, uint256 amount, uint256 feeAmount, address feeReceiver) - internal - { - // Send fee portion if non-zero - if (feeAmount > 0) _sendTokens(msg.sender, token, feeReceiver, feeAmount); - - // Send remaining amount to receiver - if (amount > feeAmount) _sendTokens(msg.sender, token, receiver, amount - feeAmount); - } - - /// @notice Validates required properties of a payment - /// - /// @param paymentInfo PaymentInfo struct - /// @param amount Token amount to validate against - function _validatePayment(PaymentInfo calldata paymentInfo, uint256 amount) internal view { - uint120 maxAmount = paymentInfo.maxAmount; - uint48 preApprovalExp = paymentInfo.preApprovalExpiry; - uint48 authorizationExp = paymentInfo.authorizationExpiry; - uint48 refundExp = paymentInfo.refundExpiry; - uint16 minFeeBps = paymentInfo.minFeeBps; - uint16 maxFeeBps = paymentInfo.maxFeeBps; - uint48 currentTime = uint48(block.timestamp); - - // Check amount does not exceed maximum - if (amount > maxAmount) revert ExceedsMaxAmount(amount, maxAmount); - - // Timestamp comparisons cannot overflow uint48 - if (currentTime >= preApprovalExp) revert AfterPreApprovalExpiry(currentTime, preApprovalExp); - - // Check expiry timestamps properly ordered - if (preApprovalExp > authorizationExp || authorizationExp > refundExp) { - revert InvalidExpiries(preApprovalExp, authorizationExp, refundExp); - } - - // Check fee bps do not exceed maximum value - if (maxFeeBps > _MAX_FEE_BPS) revert FeeBpsOverflow(maxFeeBps); - - // Check min fee bps does not exceed max fee - if (minFeeBps > maxFeeBps) revert InvalidFeeBpsRange(minFeeBps, maxFeeBps); - } - - /// @notice Validates attempted fee adheres to constraints set by payment info - /// - /// @param paymentInfo PaymentInfo struct - /// @param amount Capture or charge amount used to compute fee bounds - /// @param feeAmount Absolute fee in token units - /// @param feeReceiver Address to receive fees - function _validateFee(PaymentInfo calldata paymentInfo, uint256 amount, uint256 feeAmount, address feeReceiver) - internal - pure - { - address configuredFeeReceiver = paymentInfo.feeReceiver; - - uint256 minFee = amount * paymentInfo.minFeeBps / _MAX_FEE_BPS; - uint256 maxFee = amount * paymentInfo.maxFeeBps / _MAX_FEE_BPS; - - // Check fee amount within payer-approved bounds - if (feeAmount < minFee || feeAmount > maxFee) revert FeeAmountOutOfRange(feeAmount, minFee, maxFee); - - // Check fee recipient only zero address if zero fee - if (feeReceiver == address(0) && feeAmount > 0) revert ZeroFeeReceiver(); - - // Check fee receiver matches payment info if non-zero - if (configuredFeeReceiver != address(0) && configuredFeeReceiver != feeReceiver) { - revert InvalidFeeReceiver(feeReceiver, configuredFeeReceiver); - } - } - - /// @dev Override to use transient reentrancy guard on all chains - function _useTransientReentrancyGuardOnlyOnMainnet() internal view virtual override returns (bool) { - return false; - } -} +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ReentrancyGuardTransient} from "solady/utils/ReentrancyGuardTransient.sol"; +import {LibClone} from "solady/utils/LibClone.sol"; + +import {TokenStore} from "./TokenStore.sol"; +import {TokenCollector} from "./collectors/TokenCollector.sol"; + +/// @title AuthCaptureEscrow +/// +/// @notice Facilitate payments through an escrow. +/// +/// @dev By escrowing payment, this contract can mimic the 2-step payment pattern of "authorization" and "capture". +/// @dev Authorization is defined as placing a hold on a payer's funds temporarily. +/// @dev Capture is defined as distributing payment to the end recipient. +/// @dev An Operator plays the role of facilitating state transitions associated with a payment, constrained by cryptographic authorization +/// from a payer and confirmation signals from the merchant. +/// +/// @author Coinbase (https://github.com/base/commerce-payments) +contract AuthCaptureEscrow is ReentrancyGuardTransient { + using SafeERC20 for IERC20; + + /// @notice Payment info, contains all information required to authorize and capture a unique payment + struct PaymentInfo { + /// @dev Entity responsible for driving payment flow + address operator; + /// @dev The payer's address authorizing the payment + address payer; + /// @dev Address that receives the payment (minus fees) + address receiver; + /// @dev The token contract address + address token; + /// @dev The amount of tokens that can be authorized + uint120 maxAmount; + /// @dev Timestamp when the payer's pre-approval can no longer authorize payment + uint48 preApprovalExpiry; + /// @dev Timestamp when an authorization can no longer be captured and the payer can reclaim from escrow + uint48 authorizationExpiry; + /// @dev Timestamp when a successful payment can no longer be refunded + uint48 refundExpiry; + /// @dev Minimum fee rate in basis points; bounds the absolute fee at capture as amount * minFeeBps / 10_000 + uint16 minFeeBps; + /// @dev Maximum fee rate in basis points; bounds the absolute fee at capture as amount * maxFeeBps / 10_000 + uint16 maxFeeBps; + /// @dev Address that receives the fee portion of payments, if 0 then operator can set at capture + address feeReceiver; + /// @dev A source of entropy to ensure unique hashes across different payments + uint256 salt; + } + + /// @notice State for tracking payments through lifecycle + struct PaymentState { + /// @dev True if payment has been authorized or charged + bool hasCollectedPayment; + /// @dev Amount of tokens currently on hold in escrow that can be captured + uint120 capturableAmount; + /// @dev Amount of tokens previously captured that can be refunded + uint120 refundableAmount; + } + + /// @notice Typehash used for hashing PaymentInfo structs + bytes32 public constant PAYMENT_INFO_TYPEHASH = keccak256( + "PaymentInfo(address operator,address payer,address receiver,address token,uint120 maxAmount,uint48 preApprovalExpiry,uint48 authorizationExpiry,uint48 refundExpiry,uint16 minFeeBps,uint16 maxFeeBps,address feeReceiver,uint256 salt)" + ); + + uint16 internal constant _MAX_FEE_BPS = 10_000; + + /// @notice Implementation contract for operator token stores + address public immutable tokenStoreImplementation; + + /// @notice State per unique payment + mapping(bytes32 paymentInfoHash => PaymentState state) public paymentState; + + /// @notice Emitted when a payment is charged and immediately captured + event PaymentCharged( + bytes32 indexed paymentInfoHash, + PaymentInfo paymentInfo, + uint256 amount, + address tokenCollector, + uint256 feeAmount, + address feeReceiver + ); + + /// @notice Emitted when authorized (escrowed) amount is increased + event PaymentAuthorized( + bytes32 indexed paymentInfoHash, PaymentInfo paymentInfo, uint256 amount, address tokenCollector + ); + + /// @notice Emitted when payment is captured from escrow + event PaymentCaptured(bytes32 indexed paymentInfoHash, uint256 amount, uint256 feeAmount, address feeReceiver); + + /// @notice Emitted when an authorized payment is voided, returning any escrowed funds to the payer + event PaymentVoided(bytes32 indexed paymentInfoHash, uint256 amount); + + /// @notice Emitted when an authorized payment is reclaimed, returning any escrowed funds to the payer + event PaymentReclaimed(bytes32 indexed paymentInfoHash, uint256 amount); + + /// @notice Emitted when a captured payment is refunded + event PaymentRefunded(bytes32 indexed paymentInfoHash, uint256 amount, address tokenCollector); + + /// @notice Event emitted when new token store is created + event TokenStoreCreated(address indexed operator, address tokenStore); + + /// @notice Sender for a function call does not follow access control requirements + error InvalidSender(address sender, address expected); + + /// @notice Amount is zero + error ZeroAmount(); + + /// @notice Amount overflows allowed storage size of uint120 + error AmountOverflow(uint256 amount, uint256 limit); + + /// @notice Requested authorization amount exceeds `PaymentInfo.maxAmount` + error ExceedsMaxAmount(uint256 amount, uint256 maxAmount); + + /// @notice Authorization attempted after pre-approval expiry + error AfterPreApprovalExpiry(uint48 timestamp, uint48 expiry); + + /// @notice Expiry timestamps violate preApproval <= authorization <= refund + error InvalidExpiries(uint48 preApproval, uint48 authorization, uint48 refund); + + /// @notice Fee bips overflows 10_000 maximum + error FeeBpsOverflow(uint16 feeBps); + + /// @notice Fee bps range invalid due to min > max + error InvalidFeeBpsRange(uint16 minFeeBps, uint16 maxFeeBps); + + /// @notice Fee amount outside of payer-approved bounds + error FeeAmountOutOfRange(uint256 feeAmount, uint256 minFee, uint256 maxFee); + + /// @notice Fee receiver is zero address with a non-zero fee + error ZeroFeeReceiver(); + + /// @notice Fee recipient cannot be changed + error InvalidFeeReceiver(address attempted, address expected); + + /// @notice Token collector is not valid for the operation + error InvalidCollectorForOperation(); + + /// @notice Token pull failed + error TokenCollectionFailed(); + + /// @notice Charge or authorize attempted on a payment has already been collected + error PaymentAlreadyCollected(bytes32 paymentInfoHash); + + /// @notice Capture attempted at or after authorization expiry + error AfterAuthorizationExpiry(uint48 timestamp, uint48 expiry); + + /// @notice Capture attempted with insufficient authorization amount + error InsufficientAuthorization(bytes32 paymentInfoHash, uint256 authorizedAmount, uint256 requestedAmount); + + /// @notice Void or reclaim attempted with zero authorization amount + error ZeroAuthorization(bytes32 paymentInfoHash); + + /// @notice Reclaim attempted before authorization expiry + error BeforeAuthorizationExpiry(uint48 timestamp, uint48 expiry); + + /// @notice Refund attempted at or after refund expiry + error AfterRefundExpiry(uint48 timestamp, uint48 expiry); + + /// @notice Refund attempted with amount exceeding previous non-refunded captures + error RefundExceedsCapture(uint256 refund, uint256 captured); + + /// @notice Check call sender is specified address + /// + /// @param sender Address to enforce is the call sender + modifier onlySender(address sender) { + if (msg.sender != sender) revert InvalidSender(msg.sender, sender); + _; + } + + /// @notice Ensures amount is non-zero and does not overflow storage + /// + /// @param amount Quantity of tokens being requested for a given operation + modifier validAmount(uint256 amount) { + if (amount == 0) revert ZeroAmount(); + if (amount > type(uint120).max) revert AmountOverflow(amount, type(uint120).max); + _; + } + + /// @notice Constructor that auto-deploys TokenStore implementation to clone + constructor() { + tokenStoreImplementation = address(new TokenStore(address(this))); + } + + /// @notice Transfers funds from payer to receiver in one step + /// + /// @dev If amount is less than the authorized amount, only amount is taken from payer + /// @dev Reverts if the authorization has been voided or expired + /// + /// @param paymentInfo PaymentInfo struct + /// @param amount Amount to charge and capture + /// @param tokenCollector Address of the token collector + /// @param collectorData Data to pass to the token collector + /// @param feeAmount Absolute fee in token units (must fall within payer-approved bounds) + /// @param feeReceiver Address to receive fees (should match the paymentInfo.feeReceiver unless that is 0 in which case it can be any address) + function charge( + PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData, + uint256 feeAmount, + address feeReceiver + ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) { + // Check payment info valid + _validatePayment(paymentInfo, amount); + + // Check fee parameters valid + _validateFee(paymentInfo, amount, feeAmount, feeReceiver); + + // Check payment not already collected + bytes32 paymentInfoHash = getHash(paymentInfo); + if (paymentState[paymentInfoHash].hasCollectedPayment) revert PaymentAlreadyCollected(paymentInfoHash); + + // Set payment state with refundable amount + paymentState[paymentInfoHash] = + PaymentState({hasCollectedPayment: true, capturableAmount: 0, refundableAmount: uint120(amount)}); + emit PaymentCharged(paymentInfoHash, paymentInfo, amount, tokenCollector, feeAmount, feeReceiver); + + // Transfer tokens into escrow + _collectTokens(paymentInfo, amount, tokenCollector, collectorData, TokenCollector.CollectorType.Payment); + + // Transfer tokens to receiver and fee receiver + _distributeTokens(paymentInfo.token, paymentInfo.receiver, amount, feeAmount, feeReceiver); + } + + /// @notice Transfers funds from payer to escrow + /// + /// @param paymentInfo PaymentInfo struct + /// @param amount Amount to authorize + /// @param tokenCollector Address of the token collector + /// @param collectorData Data to pass to the token collector + function authorize( + PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData + ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) { + // Check payment info valid + _validatePayment(paymentInfo, amount); + + // Check payment not already collected + bytes32 paymentInfoHash = getHash(paymentInfo); + if (paymentState[paymentInfoHash].hasCollectedPayment) revert PaymentAlreadyCollected(paymentInfoHash); + + // Set payment state with capturable amount + paymentState[paymentInfoHash] = + PaymentState({hasCollectedPayment: true, capturableAmount: uint120(amount), refundableAmount: 0}); + emit PaymentAuthorized(paymentInfoHash, paymentInfo, amount, tokenCollector); + + // Transfer tokens into escrow + _collectTokens(paymentInfo, amount, tokenCollector, collectorData, TokenCollector.CollectorType.Payment); + } + + /// @notice Transfer previously-escrowed funds to receiver + /// + /// @dev Can be called multiple times up to cumulative authorized amount + /// @dev Can only be called by the operator + /// + /// @param paymentInfo PaymentInfo struct + /// @param amount Amount to capture + /// @param feeAmount Absolute fee in token units (must fall within payer-approved bounds) + /// @param feeReceiver Address to receive fees (should match the paymentInfo.feeReceiver unless that is 0 in which case it can be any address) + function capture(PaymentInfo calldata paymentInfo, uint256 amount, uint256 feeAmount, address feeReceiver) + external + nonReentrant + onlySender(paymentInfo.operator) + validAmount(amount) + { + // Check fee parameters valid + _validateFee(paymentInfo, amount, feeAmount, feeReceiver); + + // Check before authorization expiry + if (block.timestamp >= paymentInfo.authorizationExpiry) { + revert AfterAuthorizationExpiry(uint48(block.timestamp), paymentInfo.authorizationExpiry); + } + + // Check sufficient escrow to capture + bytes32 paymentInfoHash = getHash(paymentInfo); + PaymentState memory state = paymentState[paymentInfoHash]; + if (state.capturableAmount < amount) { + revert InsufficientAuthorization(paymentInfoHash, state.capturableAmount, amount); + } + + // Update payment state, converting capturable amount to refundable amount + state.capturableAmount -= uint120(amount); + state.refundableAmount += uint120(amount); + paymentState[paymentInfoHash] = state; + emit PaymentCaptured(paymentInfoHash, amount, feeAmount, feeReceiver); + + // Transfer tokens to receiver and fee receiver + _distributeTokens(paymentInfo.token, paymentInfo.receiver, amount, feeAmount, feeReceiver); + } + + /// @notice Permanently voids a payment authorization + /// + /// @dev Returns any escrowed funds to payer + /// @dev Can only be called by the operator + /// + /// @param paymentInfo PaymentInfo struct + function void(PaymentInfo calldata paymentInfo) external nonReentrant onlySender(paymentInfo.operator) { + // Check authorization non-zero + bytes32 paymentInfoHash = getHash(paymentInfo); + uint256 authorizedAmount = paymentState[paymentInfoHash].capturableAmount; + if (authorizedAmount == 0) revert ZeroAuthorization(paymentInfoHash); + + // Clear capturable amount state + paymentState[paymentInfoHash].capturableAmount = 0; + emit PaymentVoided(paymentInfoHash, authorizedAmount); + + // Transfer tokens to payer from token store + _sendTokens(paymentInfo.operator, paymentInfo.token, paymentInfo.payer, authorizedAmount); + } + + /// @notice Returns any escrowed funds to payer + /// + /// @dev Can only be called by the payer and only after the authorization expiry + /// + /// @param paymentInfo PaymentInfo struct + function reclaim(PaymentInfo calldata paymentInfo) external nonReentrant onlySender(paymentInfo.payer) { + // Check not before authorization expiry + if (block.timestamp < paymentInfo.authorizationExpiry) { + revert BeforeAuthorizationExpiry(uint48(block.timestamp), paymentInfo.authorizationExpiry); + } + + // Check authorization non-zero + bytes32 paymentInfoHash = getHash(paymentInfo); + uint256 authorizedAmount = paymentState[paymentInfoHash].capturableAmount; + if (authorizedAmount == 0) revert ZeroAuthorization(paymentInfoHash); + + // Clear capturable amount state + paymentState[paymentInfoHash].capturableAmount = 0; + emit PaymentReclaimed(paymentInfoHash, authorizedAmount); + + // Transfer tokens to payer from token store + _sendTokens(paymentInfo.operator, paymentInfo.token, paymentInfo.payer, authorizedAmount); + } + + /// @notice Return previously-captured tokens to payer + /// + /// @dev Can be called by operator + /// @dev Funds are transferred from the caller or from the escrow if token collector retrieves external liquidity + /// + /// @param paymentInfo PaymentInfo struct + /// @param amount Amount to refund + /// @param tokenCollector Address of the token collector + /// @param collectorData Data to pass to the token collector + function refund( + PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData + ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) { + // Check refund has not expired + if (block.timestamp >= paymentInfo.refundExpiry) { + revert AfterRefundExpiry(uint48(block.timestamp), paymentInfo.refundExpiry); + } + + // Limit refund amount to previously captured + bytes32 paymentInfoHash = getHash(paymentInfo); + uint120 captured = paymentState[paymentInfoHash].refundableAmount; + if (captured < amount) revert RefundExceedsCapture(amount, captured); + + // Update refundable amount + paymentState[paymentInfoHash].refundableAmount = captured - uint120(amount); + emit PaymentRefunded(paymentInfoHash, amount, tokenCollector); + + // Transfer tokens into escrow and forward to payer + _collectTokens(paymentInfo, amount, tokenCollector, collectorData, TokenCollector.CollectorType.Refund); + _sendTokens(paymentInfo.operator, paymentInfo.token, paymentInfo.payer, amount); + } + + /// @notice Get hash of PaymentInfo struct + /// + /// @dev Includes chainId and verifyingContract in hash for cross-chain and cross-contract uniqueness + /// + /// @param paymentInfo PaymentInfo struct + /// + /// @return Hash of payment info for the current chain and contract address + function getHash(PaymentInfo calldata paymentInfo) public view returns (bytes32) { + bytes32 paymentInfoHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, paymentInfo)); + return keccak256(abi.encode(block.chainid, address(this), paymentInfoHash)); + } + + /// @notice Get the token store address for an operator + /// + /// @param operator The operator to get the token store for + /// + /// @return The operator's token store address + function getTokenStore(address operator) public view returns (address) { + return LibClone.predictDeterministicAddress({ + implementation: tokenStoreImplementation, + salt: bytes32(bytes20(operator)), + deployer: address(this) + }); + } + + /// @notice Transfer tokens into this contract + /// + /// @param paymentInfo PaymentInfo struct + /// @param amount Amount of tokens to collect + /// @param tokenCollector Address of the token collector + /// @param collectorData Data to pass to the token collector + /// @param collectorType Type of collector to enforce (payment or refund) + function _collectTokens( + PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData, + TokenCollector.CollectorType collectorType + ) internal { + // Check token collector matches required type + if (TokenCollector(tokenCollector).collectorType() != collectorType) revert InvalidCollectorForOperation(); + + // Measure balance change of token store to enforce as equal to expected amount + address token = paymentInfo.token; + address tokenStore = getTokenStore(paymentInfo.operator); + uint256 tokenStoreBalanceBefore = IERC20(token).balanceOf(tokenStore); + TokenCollector(tokenCollector).collectTokens(paymentInfo, tokenStore, amount, collectorData); + uint256 tokenStoreBalanceAfter = IERC20(token).balanceOf(tokenStore); + if (tokenStoreBalanceAfter != tokenStoreBalanceBefore + amount) revert TokenCollectionFailed(); + } + + /// @notice Send tokens from an operator's token store + /// + /// @param operator The operator whose token store to use + /// @param token The token to send + /// @param recipient Address to receive the tokens + /// @param amount Amount of tokens to send + function _sendTokens(address operator, address token, address recipient, uint256 amount) internal { + // Attempt to transfer tokens + address tokenStore = getTokenStore(operator); + bytes memory callData = abi.encodeWithSelector(TokenStore.sendTokens.selector, token, recipient, amount); + (bool success, bytes memory returnData) = tokenStore.call(callData); + if (success && returnData.length == 32 && abi.decode(returnData, (bool))) { + return; + } else if (tokenStore.code.length == 0) { + // Call failed from undeployed TokenStore, deploy and try again + tokenStore = LibClone.cloneDeterministic({ + implementation: tokenStoreImplementation, + salt: bytes32(bytes20(operator)) + }); + emit TokenStoreCreated(operator, tokenStore); + TokenStore(tokenStore).sendTokens(token, recipient, amount); + } else { + // Call failed from revert, bubble up data + assembly ("memory-safe") { + let returnDataSize := mload(returnData) + revert(add(32, returnData), returnDataSize) + } + } + } + + /// @notice Sends tokens to receiver and/or feeReceiver + /// + /// @param token Token to transfer + /// @param receiver Address to receive payment + /// @param amount Total amount to split between payment and fees + /// @param feeAmount Absolute fee in token units + /// @param feeReceiver Address to receive fees + function _distributeTokens(address token, address receiver, uint256 amount, uint256 feeAmount, address feeReceiver) + internal + { + // Send fee portion if non-zero + if (feeAmount > 0) _sendTokens(msg.sender, token, feeReceiver, feeAmount); + + // Send remaining amount to receiver + if (amount > feeAmount) _sendTokens(msg.sender, token, receiver, amount - feeAmount); + } + + /// @notice Validates required properties of a payment + /// + /// @param paymentInfo PaymentInfo struct + /// @param amount Token amount to validate against + function _validatePayment(PaymentInfo calldata paymentInfo, uint256 amount) internal view { + uint120 maxAmount = paymentInfo.maxAmount; + uint48 preApprovalExp = paymentInfo.preApprovalExpiry; + uint48 authorizationExp = paymentInfo.authorizationExpiry; + uint48 refundExp = paymentInfo.refundExpiry; + uint16 minFeeBps = paymentInfo.minFeeBps; + uint16 maxFeeBps = paymentInfo.maxFeeBps; + uint48 currentTime = uint48(block.timestamp); + + // Check amount does not exceed maximum + if (amount > maxAmount) revert ExceedsMaxAmount(amount, maxAmount); + + // Timestamp comparisons cannot overflow uint48 + if (currentTime >= preApprovalExp) revert AfterPreApprovalExpiry(currentTime, preApprovalExp); + + // Check expiry timestamps properly ordered + if (preApprovalExp > authorizationExp || authorizationExp > refundExp) { + revert InvalidExpiries(preApprovalExp, authorizationExp, refundExp); + } + + // Check fee bps do not exceed maximum value + if (maxFeeBps > _MAX_FEE_BPS) revert FeeBpsOverflow(maxFeeBps); + + // Check min fee bps does not exceed max fee + if (minFeeBps > maxFeeBps) revert InvalidFeeBpsRange(minFeeBps, maxFeeBps); + } + + /// @notice Validates attempted fee adheres to constraints set by payment info + /// + /// @param paymentInfo PaymentInfo struct + /// @param amount Capture or charge amount used to compute fee bounds + /// @param feeAmount Absolute fee in token units + /// @param feeReceiver Address to receive fees + function _validateFee(PaymentInfo calldata paymentInfo, uint256 amount, uint256 feeAmount, address feeReceiver) + internal + pure + { + address configuredFeeReceiver = paymentInfo.feeReceiver; + + uint256 minFee = amount * paymentInfo.minFeeBps / _MAX_FEE_BPS; + uint256 maxFee = amount * paymentInfo.maxFeeBps / _MAX_FEE_BPS; + + // Check fee amount within payer-approved bounds + if (feeAmount < minFee || feeAmount > maxFee) revert FeeAmountOutOfRange(feeAmount, minFee, maxFee); + + // Check fee recipient only zero address if zero fee + if (feeReceiver == address(0) && feeAmount > 0) revert ZeroFeeReceiver(); + + // Check fee receiver matches payment info if non-zero + if (configuredFeeReceiver != address(0) && configuredFeeReceiver != feeReceiver) { + revert InvalidFeeReceiver(feeReceiver, configuredFeeReceiver); + } + } + + /// @dev Override to use transient reentrancy guard on all chains + function _useTransientReentrancyGuardOnlyOnMainnet() internal view virtual override returns (bool) { + return false; + } +} diff --git a/src/collectors/Permit2PaymentCollector.sol b/src/collectors/Permit2PaymentCollector.sol index e4ced64..b1963ec 100644 --- a/src/collectors/Permit2PaymentCollector.sol +++ b/src/collectors/Permit2PaymentCollector.sol @@ -1,54 +1,54 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {ISignatureTransfer} from "permit2/interfaces/ISignatureTransfer.sol"; - -import {TokenCollector} from "./TokenCollector.sol"; -import {ERC6492SignatureHandler} from "./ERC6492SignatureHandler.sol"; -import {AuthCaptureEscrow} from "../AuthCaptureEscrow.sol"; - -/// @title Permit2PaymentCollector -/// -/// @notice Collect payments using Permit2 signatures -/// -/// @author Coinbase (https://github.com/base/commerce-payments) -contract Permit2PaymentCollector is TokenCollector, ERC6492SignatureHandler { - /// @inheritdoc TokenCollector - TokenCollector.CollectorType public constant override collectorType = TokenCollector.CollectorType.Payment; - - /// @notice Permit2 singleton - ISignatureTransfer public immutable permit2; - - /// @notice Constructor - /// - /// @param authCaptureEscrow_ AuthCaptureEscrow singleton that calls to collect tokens - /// @param permit2_ Permit2 singleton - /// @param multicall3_ Public Multicall3 singleton for safe ERC-6492 external calls - constructor(address authCaptureEscrow_, address permit2_, address multicall3_) - TokenCollector(authCaptureEscrow_) - ERC6492SignatureHandler(multicall3_) - { - permit2 = ISignatureTransfer(permit2_); - } - - /// @inheritdoc TokenCollector - /// - /// @dev Use Permit2 signature transfer to collect any ERC-20 from payers - function _collectTokens( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - address tokenStore, - uint256 amount, - bytes calldata collectorData - ) internal override { - permit2.permitTransferFrom({ - permit: ISignatureTransfer.PermitTransferFrom({ - permitted: ISignatureTransfer.TokenPermissions({token: paymentInfo.token, amount: paymentInfo.maxAmount}), - nonce: uint256(_getHashPayerAgnostic(paymentInfo)), - deadline: paymentInfo.preApprovalExpiry - }), - transferDetails: ISignatureTransfer.SignatureTransferDetails({to: tokenStore, requestedAmount: amount}), - owner: paymentInfo.payer, - signature: _handleERC6492Signature(collectorData) - }); - } -} +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ISignatureTransfer} from "permit2/interfaces/ISignatureTransfer.sol"; + +import {TokenCollector} from "./TokenCollector.sol"; +import {ERC6492SignatureHandler} from "./ERC6492SignatureHandler.sol"; +import {AuthCaptureEscrow} from "../AuthCaptureEscrow.sol"; + +/// @title Permit2PaymentCollector +/// +/// @notice Collect payments using Permit2 signatures +/// +/// @author Coinbase (https://github.com/base/commerce-payments) +contract Permit2PaymentCollector is TokenCollector, ERC6492SignatureHandler { + /// @inheritdoc TokenCollector + TokenCollector.CollectorType public constant override collectorType = TokenCollector.CollectorType.Payment; + + /// @notice Permit2 singleton + ISignatureTransfer public immutable permit2; + + /// @notice Constructor + /// + /// @param authCaptureEscrow_ AuthCaptureEscrow singleton that calls to collect tokens + /// @param permit2_ Permit2 singleton + /// @param multicall3_ Public Multicall3 singleton for safe ERC-6492 external calls + constructor(address authCaptureEscrow_, address permit2_, address multicall3_) + TokenCollector(authCaptureEscrow_) + ERC6492SignatureHandler(multicall3_) + { + permit2 = ISignatureTransfer(permit2_); + } + + /// @inheritdoc TokenCollector + /// + /// @dev Use Permit2 signature transfer to collect any ERC-20 from payers + function _collectTokens( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + address tokenStore, + uint256 amount, + bytes calldata collectorData + ) internal override { + permit2.permitTransferFrom({ + permit: ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: paymentInfo.token, amount: paymentInfo.maxAmount}), + nonce: uint256(_getHashPayerAgnostic(paymentInfo)), + deadline: paymentInfo.preApprovalExpiry + }), + transferDetails: ISignatureTransfer.SignatureTransferDetails({to: tokenStore, requestedAmount: amount}), + owner: paymentInfo.payer, + signature: _handleERC6492Signature(collectorData) + }); + } +} diff --git a/src/collectors/SpendPermissionPaymentCollector.sol b/src/collectors/SpendPermissionPaymentCollector.sol index 8b52b8a..5241e80 100644 --- a/src/collectors/SpendPermissionPaymentCollector.sol +++ b/src/collectors/SpendPermissionPaymentCollector.sol @@ -1,77 +1,77 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {SpendPermissionManager} from "spend-permissions/SpendPermissionManager.sol"; -import {MagicSpend} from "magicspend/MagicSpend.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -import {TokenCollector} from "./TokenCollector.sol"; -import {AuthCaptureEscrow} from "../AuthCaptureEscrow.sol"; - -/// @title SpendPermissionPaymentCollector -/// -/// @notice Collect payments using Spend Permissions -/// -/// @author Coinbase (https://github.com/base/commerce-payments) -contract SpendPermissionPaymentCollector is TokenCollector { - /// @inheritdoc TokenCollector - TokenCollector.CollectorType public constant override collectorType = TokenCollector.CollectorType.Payment; - - /// @notice SpendPermissionManager singleton - SpendPermissionManager public immutable spendPermissionManager; - - /// @notice Spend permission approval failed - error SpendPermissionApprovalFailed(); - - /// @notice Constructor - /// - /// @param authCaptureEscrow_ AuthCaptureEscrow singleton that calls to collect tokens - /// @param spendPermissionManager_ SpendPermissionManager singleton - constructor(address authCaptureEscrow_, address spendPermissionManager_) TokenCollector(authCaptureEscrow_) { - spendPermissionManager = SpendPermissionManager(payable(spendPermissionManager_)); - } - - /// @inheritdoc TokenCollector - /// - /// @dev Supports Spend Permission approval signatures and MagicSpend WithdrawRequests (both optional) - function _collectTokens( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - address tokenStore, - uint256 amount, - bytes calldata collectorData - ) internal override { - address token = paymentInfo.token; - SpendPermissionManager.SpendPermission memory permission = SpendPermissionManager.SpendPermission({ - account: paymentInfo.payer, - spender: address(this), - token: token, - allowance: uint160(paymentInfo.maxAmount), - period: type(uint48).max, - start: 0, - end: paymentInfo.preApprovalExpiry, - salt: uint256(_getHashPayerAgnostic(paymentInfo)), - extraData: hex"" - }); - - (bytes memory signature, bytes memory encodedWithdrawRequest) = abi.decode(collectorData, (bytes, bytes)); - - // Approve spend permission with signature if provided - if (signature.length > 0) { - bool approved = spendPermissionManager.approveWithSignature(permission, signature); - if (!approved) revert SpendPermissionApprovalFailed(); - } - - // Transfer tokens into collector, first using a MagicSpend WithdrawRequest if provided - if (encodedWithdrawRequest.length == 0) { - spendPermissionManager.spend(permission, uint160(amount)); - } else { - MagicSpend.WithdrawRequest memory withdrawRequest = - abi.decode(encodedWithdrawRequest, (MagicSpend.WithdrawRequest)); - spendPermissionManager.spendWithWithdraw(permission, uint160(amount), withdrawRequest); - } - - // Transfer tokens from collector to token store - SafeERC20.safeTransfer(IERC20(token), tokenStore, amount); - } -} +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {SpendPermissionManager} from "spend-permissions/SpendPermissionManager.sol"; +import {MagicSpend} from "magicspend/MagicSpend.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {TokenCollector} from "./TokenCollector.sol"; +import {AuthCaptureEscrow} from "../AuthCaptureEscrow.sol"; + +/// @title SpendPermissionPaymentCollector +/// +/// @notice Collect payments using Spend Permissions +/// +/// @author Coinbase (https://github.com/base/commerce-payments) +contract SpendPermissionPaymentCollector is TokenCollector { + /// @inheritdoc TokenCollector + TokenCollector.CollectorType public constant override collectorType = TokenCollector.CollectorType.Payment; + + /// @notice SpendPermissionManager singleton + SpendPermissionManager public immutable spendPermissionManager; + + /// @notice Spend permission approval failed + error SpendPermissionApprovalFailed(); + + /// @notice Constructor + /// + /// @param authCaptureEscrow_ AuthCaptureEscrow singleton that calls to collect tokens + /// @param spendPermissionManager_ SpendPermissionManager singleton + constructor(address authCaptureEscrow_, address spendPermissionManager_) TokenCollector(authCaptureEscrow_) { + spendPermissionManager = SpendPermissionManager(payable(spendPermissionManager_)); + } + + /// @inheritdoc TokenCollector + /// + /// @dev Supports Spend Permission approval signatures and MagicSpend WithdrawRequests (both optional) + function _collectTokens( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + address tokenStore, + uint256 amount, + bytes calldata collectorData + ) internal override { + address token = paymentInfo.token; + SpendPermissionManager.SpendPermission memory permission = SpendPermissionManager.SpendPermission({ + account: paymentInfo.payer, + spender: address(this), + token: token, + allowance: uint160(paymentInfo.maxAmount), + period: type(uint48).max, + start: 0, + end: paymentInfo.preApprovalExpiry, + salt: uint256(_getHashPayerAgnostic(paymentInfo)), + extraData: hex"" + }); + + (bytes memory signature, bytes memory encodedWithdrawRequest) = abi.decode(collectorData, (bytes, bytes)); + + // Approve spend permission with signature if provided + if (signature.length > 0) { + bool approved = spendPermissionManager.approveWithSignature(permission, signature); + if (!approved) revert SpendPermissionApprovalFailed(); + } + + // Transfer tokens into collector, first using a MagicSpend WithdrawRequest if provided + if (encodedWithdrawRequest.length == 0) { + spendPermissionManager.spend(permission, uint160(amount)); + } else { + MagicSpend.WithdrawRequest memory withdrawRequest = + abi.decode(encodedWithdrawRequest, (MagicSpend.WithdrawRequest)); + spendPermissionManager.spendWithWithdraw(permission, uint160(amount), withdrawRequest); + } + + // Transfer tokens from collector to token store + SafeERC20.safeTransfer(IERC20(token), tokenStore, amount); + } +} diff --git a/src/collectors/TokenCollector.sol b/src/collectors/TokenCollector.sol index 92d9e7b..9b7f626 100644 --- a/src/collectors/TokenCollector.sol +++ b/src/collectors/TokenCollector.sol @@ -1,78 +1,78 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {AuthCaptureEscrow} from "../AuthCaptureEscrow.sol"; - -/// @title TokenCollector -/// -/// @notice Abstract contract for shared token collector utilities -/// -/// @author Coinbase (https://github.com/base/commerce-payments) -abstract contract TokenCollector { - /// @notice Type differentiation between payment and refund collection flows - enum CollectorType { - Payment, - Refund - } - - /// @notice AuthCaptureEscrow singleton - AuthCaptureEscrow public immutable authCaptureEscrow; - - /// @notice Call sender is not AuthCaptureEscrow - error OnlyAuthCaptureEscrow(); - - /// @notice Constructor - /// - /// @param authCaptureEscrow_ AuthCaptureEscrow singleton that calls to collect tokens - constructor(address authCaptureEscrow_) { - authCaptureEscrow = AuthCaptureEscrow(authCaptureEscrow_); - } - - /// @notice Pull tokens from payer to escrow using token collector-specific authorization logic - /// - /// @param paymentInfo Payment info struct - /// @param tokenStore Address to collect tokens into - /// @param amount Amount of tokens to pull - /// @param collectorData Data to pass to the token collector - function collectTokens( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - address tokenStore, - uint256 amount, - bytes calldata collectorData - ) external { - if (msg.sender != address(authCaptureEscrow)) revert OnlyAuthCaptureEscrow(); - _collectTokens(paymentInfo, tokenStore, amount, collectorData); - } - - /// @notice Get the type of token collector - /// - /// @return CollectorType Type of token collector - function collectorType() external view virtual returns (CollectorType); - - /// @notice Pull tokens from payer to escrow using token collector-specific authorization logic - /// - /// @param paymentInfo Payment info struct - /// @param tokenStore Address to collect tokens into - /// @param amount Amount of tokens to pull - /// @param collectorData Data to pass to the token collector - function _collectTokens( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - address tokenStore, - uint256 amount, - bytes calldata collectorData - ) internal virtual; - - /// @notice Get hash for PaymentInfo with null payer address - /// - /// @param paymentInfo PaymentInfo struct with non-null payer address - /// - /// @return hash Hash of PaymentInfo with payer replaced with zero address - function _getHashPayerAgnostic(AuthCaptureEscrow.PaymentInfo memory paymentInfo) internal view returns (bytes32) { - address payer = paymentInfo.payer; - paymentInfo.payer = address(0); - bytes32 hashPayerAgnostic = authCaptureEscrow.getHash(paymentInfo); - // Proactively setting payer back to original value covers accidental bugs if memory location is then used elsewhere - paymentInfo.payer = payer; - return hashPayerAgnostic; - } -} +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {AuthCaptureEscrow} from "../AuthCaptureEscrow.sol"; + +/// @title TokenCollector +/// +/// @notice Abstract contract for shared token collector utilities +/// +/// @author Coinbase (https://github.com/base/commerce-payments) +abstract contract TokenCollector { + /// @notice Type differentiation between payment and refund collection flows + enum CollectorType { + Payment, + Refund + } + + /// @notice AuthCaptureEscrow singleton + AuthCaptureEscrow public immutable authCaptureEscrow; + + /// @notice Call sender is not AuthCaptureEscrow + error OnlyAuthCaptureEscrow(); + + /// @notice Constructor + /// + /// @param authCaptureEscrow_ AuthCaptureEscrow singleton that calls to collect tokens + constructor(address authCaptureEscrow_) { + authCaptureEscrow = AuthCaptureEscrow(authCaptureEscrow_); + } + + /// @notice Pull tokens from payer to escrow using token collector-specific authorization logic + /// + /// @param paymentInfo Payment info struct + /// @param tokenStore Address to collect tokens into + /// @param amount Amount of tokens to pull + /// @param collectorData Data to pass to the token collector + function collectTokens( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + address tokenStore, + uint256 amount, + bytes calldata collectorData + ) external { + if (msg.sender != address(authCaptureEscrow)) revert OnlyAuthCaptureEscrow(); + _collectTokens(paymentInfo, tokenStore, amount, collectorData); + } + + /// @notice Get the type of token collector + /// + /// @return CollectorType Type of token collector + function collectorType() external view virtual returns (CollectorType); + + /// @notice Pull tokens from payer to escrow using token collector-specific authorization logic + /// + /// @param paymentInfo Payment info struct + /// @param tokenStore Address to collect tokens into + /// @param amount Amount of tokens to pull + /// @param collectorData Data to pass to the token collector + function _collectTokens( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + address tokenStore, + uint256 amount, + bytes calldata collectorData + ) internal virtual; + + /// @notice Get hash for PaymentInfo with null payer address + /// + /// @param paymentInfo PaymentInfo struct with non-null payer address + /// + /// @return hash Hash of PaymentInfo with payer replaced with zero address + function _getHashPayerAgnostic(AuthCaptureEscrow.PaymentInfo memory paymentInfo) internal view returns (bytes32) { + address payer = paymentInfo.payer; + paymentInfo.payer = address(0); + bytes32 hashPayerAgnostic = authCaptureEscrow.getHash(paymentInfo); + // Proactively setting payer back to original value covers accidental bugs if memory location is then used elsewhere + paymentInfo.payer = payer; + return hashPayerAgnostic; + } +}