From e3ab56d5be1be218357daa9243a8850116e0a9f7 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Mon, 22 Jun 2026 15:41:55 -0700 Subject: [PATCH 01/12] replaced bps with feeAmount and added supporting logic --- src/AuthCaptureEscrow.sol | 57 +++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/AuthCaptureEscrow.sol b/src/AuthCaptureEscrow.sol index 9f374cf..4bc611c 100644 --- a/src/AuthCaptureEscrow.sol +++ b/src/AuthCaptureEscrow.sol @@ -41,9 +41,9 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { uint48 authorizationExpiry; /// @dev Timestamp when a successful payment can no longer be refunded uint48 refundExpiry; - /// @dev Minimum fee percentage in basis points + /// @dev Minimum fee rate in basis points; bounds the absolute fee at capture as amount * minFeeBps / 10_000 uint16 minFeeBps; - /// @dev Maximum fee percentage in basis points + /// @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; @@ -80,7 +80,7 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { PaymentInfo paymentInfo, uint256 amount, address tokenCollector, - uint16 feeBps, + uint256 feeAmount, address feeReceiver ); @@ -90,7 +90,7 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { ); /// @notice Emitted when payment is captured from escrow - event PaymentCaptured(bytes32 indexed paymentInfoHash, uint256 amount, uint16 feeBps, address feeReceiver); + 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); @@ -128,8 +128,8 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { /// @notice Fee bps range invalid due to min > max error InvalidFeeBpsRange(uint16 minFeeBps, uint16 maxFeeBps); - /// @notice Fee bps outside of allowed range - error FeeBpsOutOfRange(uint16 feeBps, 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(); @@ -195,21 +195,21 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { /// @param amount Amount to charge and capture /// @param tokenCollector Address of the token collector /// @param collectorData Data to pass to the token collector - /// @param feeBps Fee percentage to apply (must be within min/max range) + /// @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, - uint16 feeBps, + uint256 feeAmount, address feeReceiver ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) { // Check payment info valid _validatePayment(paymentInfo, amount); // Check fee parameters valid - _validateFee(paymentInfo, feeBps, feeReceiver); + _validateFee(paymentInfo, amount, feeAmount, feeReceiver); // Check payment not already collected bytes32 paymentInfoHash = getHash(paymentInfo); @@ -218,13 +218,13 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { // Set payment state with refundable amount paymentState[paymentInfoHash] = PaymentState({hasCollectedPayment: true, capturableAmount: 0, refundableAmount: uint120(amount)}); - emit PaymentCharged(paymentInfoHash, paymentInfo, amount, tokenCollector, feeBps, feeReceiver); + 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, feeBps, feeReceiver); + _distributeTokens(paymentInfo.token, paymentInfo.receiver, amount, feeAmount, feeReceiver); } /// @notice Transfers funds from payer to escrow @@ -262,16 +262,16 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { /// /// @param paymentInfo PaymentInfo struct /// @param amount Amount to capture - /// @param feeBps Fee percentage to apply (must be within min/max range) + /// @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, uint16 feeBps, address feeReceiver) + function capture(PaymentInfo calldata paymentInfo, uint256 amount, uint256 feeAmount, address feeReceiver) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) { // Check fee parameters valid - _validateFee(paymentInfo, feeBps, feeReceiver); + _validateFee(paymentInfo, amount, feeAmount, feeReceiver); // Check before authorization expiry if (block.timestamp >= paymentInfo.authorizationExpiry) { @@ -289,10 +289,10 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { state.capturableAmount -= uint120(amount); state.refundableAmount += uint120(amount); paymentState[paymentInfoHash] = state; - emit PaymentCaptured(paymentInfoHash, amount, feeBps, feeReceiver); + emit PaymentCaptured(paymentInfoHash, amount, feeAmount, feeReceiver); // Transfer tokens to receiver and fee receiver - _distributeTokens(paymentInfo.token, paymentInfo.receiver, amount, feeBps, feeReceiver); + _distributeTokens(paymentInfo.token, paymentInfo.receiver, amount, feeAmount, feeReceiver); } /// @notice Permanently voids a payment authorization @@ -459,13 +459,11 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { /// @param token Token to transfer /// @param receiver Address to receive payment /// @param amount Total amount to split between payment and fees - /// @param feeBps Fee percentage in basis points + /// @param feeAmount Absolute fee in token units /// @param feeReceiver Address to receive fees - function _distributeTokens(address token, address receiver, uint256 amount, uint16 feeBps, address feeReceiver) + function _distributeTokens(address token, address receiver, uint256 amount, uint256 feeAmount, address feeReceiver) internal { - uint256 feeAmount = amount * feeBps / _MAX_FEE_BPS; - // Send fee portion if non-zero if (feeAmount > 0) _sendTokens(msg.sender, token, feeReceiver, feeAmount); @@ -507,18 +505,25 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { /// @notice Validates attempted fee adheres to constraints set by payment info /// /// @param paymentInfo PaymentInfo struct - /// @param feeBps Fee percentage in basis points + /// @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, uint16 feeBps, address feeReceiver) internal pure { + function _validateFee(PaymentInfo calldata paymentInfo, uint256 amount, uint256 feeAmount, address feeReceiver) + internal + pure + { uint16 minFeeBps = paymentInfo.minFeeBps; uint16 maxFeeBps = paymentInfo.maxFeeBps; address configuredFeeReceiver = paymentInfo.feeReceiver; - // Check fee bps within [min, max] - if (feeBps < minFeeBps || feeBps > maxFeeBps) revert FeeBpsOutOfRange(feeBps, minFeeBps, maxFeeBps); + uint256 minFee = amount * minFeeBps / _MAX_FEE_BPS; + uint256 maxFee = amount * 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 bps - if (feeReceiver == address(0) && feeBps > 0) revert ZeroFeeReceiver(); + // 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) { From ef9155e6bf1dc5593c3d8f80bda072acce728229 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Mon, 22 Jun 2026 15:45:21 -0700 Subject: [PATCH 02/12] Update tests for absolute feeAmount capture and charge API. Co-authored-by: Cursor --- test/base/AuthCaptureEscrowBase.sol | 4 + test/gas/gasBenchmark.t.sol | 9 +- test/src/PaymentEscrow/capture.t.sol | 110 +++++++++++------- test/src/PaymentEscrow/charge.t.sol | 90 +++++++------- .../e2eCoinbaseSmartWallet.t.sol | 8 +- test/src/PaymentEscrow/reentrancy.t.sol | 4 +- test/src/PaymentEscrow/refund.t.sol | 6 +- 7 files changed, 136 insertions(+), 95 deletions(-) diff --git a/test/base/AuthCaptureEscrowBase.sol b/test/base/AuthCaptureEscrowBase.sol index 4d9c248..411d80c 100644 --- a/test/base/AuthCaptureEscrowBase.sol +++ b/test/base/AuthCaptureEscrowBase.sol @@ -203,4 +203,8 @@ contract AuthCaptureEscrowBase is Test, DeployPermit2 { paymentInfo.payer = payer; return hash; } + + function _feeAmount(uint256 amount, uint16 feeBps) internal pure returns (uint256) { + return amount * feeBps / 10_000; + } } diff --git a/test/gas/gasBenchmark.t.sol b/test/gas/gasBenchmark.t.sol index 6117916..6bcf057 100644 --- a/test/gas/gasBenchmark.t.sol +++ b/test/gas/gasBenchmark.t.sol @@ -8,6 +8,7 @@ import {AuthCaptureEscrowBase} from "../base/AuthCaptureEscrowBase.sol"; contract GasBenchmarkBase is AuthCaptureEscrowBase { uint120 internal constant BENCHMARK_AMOUNT = 100e6; uint16 internal constant BENCHMARK_FEE_BPS = 100; // 1% + uint256 internal constant BENCHMARK_FEE_AMOUNT = BENCHMARK_AMOUNT * BENCHMARK_FEE_BPS / 10_000; AuthCaptureEscrow.PaymentInfo internal paymentInfo; bytes internal signature; @@ -22,7 +23,7 @@ contract GasBenchmarkBase is AuthCaptureEscrowBase { mockERC3009Token.mint(payerEOA, 1e6); vm.startPrank(operator); authCaptureEscrow.authorize(warmupInfo, 1e6, address(erc3009PaymentCollector), warmupSignature); - authCaptureEscrow.capture(warmupInfo, 1e6, BENCHMARK_FEE_BPS, feeReceiver); // make sure token store is deployed before subsequent tests + authCaptureEscrow.capture(warmupInfo, 1e6, 1e6 * BENCHMARK_FEE_BPS / 10_000, feeReceiver); // make sure token store is deployed before subsequent tests vm.stopPrank(); // Create and sign payment info @@ -45,7 +46,7 @@ contract ChargeGasBenchmark is GasBenchmarkBase { function test_charge_benchmark() public { vm.prank(operator); authCaptureEscrow.charge( - paymentInfo, BENCHMARK_AMOUNT, address(erc3009PaymentCollector), signature, BENCHMARK_FEE_BPS, feeReceiver + paymentInfo, BENCHMARK_AMOUNT, address(erc3009PaymentCollector), signature, BENCHMARK_FEE_AMOUNT, feeReceiver ); } } @@ -61,7 +62,7 @@ contract CaptureGasBenchmark is GasBenchmarkBase { function test_capture_benchmark() public { vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, BENCHMARK_AMOUNT, BENCHMARK_FEE_BPS, feeReceiver); + authCaptureEscrow.capture(paymentInfo, BENCHMARK_AMOUNT, BENCHMARK_FEE_AMOUNT, feeReceiver); } } @@ -106,7 +107,7 @@ contract RefundGasBenchmark is GasBenchmarkBase { vm.prank(operator); authCaptureEscrow.authorize(paymentInfo, BENCHMARK_AMOUNT, address(erc3009PaymentCollector), signature); vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, BENCHMARK_AMOUNT, BENCHMARK_FEE_BPS, feeReceiver); + authCaptureEscrow.capture(paymentInfo, BENCHMARK_AMOUNT, BENCHMARK_FEE_AMOUNT, feeReceiver); // Give operator tokens for refund and approve collector mockERC3009Token.mint(operator, BENCHMARK_AMOUNT); diff --git a/test/src/PaymentEscrow/capture.t.sol b/test/src/PaymentEscrow/capture.t.sol index 76b7c1e..f018c8f 100644 --- a/test/src/PaymentEscrow/capture.t.sol +++ b/test/src/PaymentEscrow/capture.t.sol @@ -25,7 +25,7 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.prank(sender); vm.expectRevert(abi.encodeWithSelector(AuthCaptureEscrow.InvalidSender.selector, sender, paymentInfo.operator)); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); } function test_reverts_whenValueIsZero() public { @@ -33,7 +33,7 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert(AuthCaptureEscrow.ZeroAmount.selector); - authCaptureEscrow.capture(paymentInfo, 0, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, 0, _feeAmount(1, FEE_BPS), paymentInfo.feeReceiver); } function test_reverts_whenAmountOverflows(uint256 overflowValue) public { @@ -45,7 +45,7 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.expectRevert( abi.encodeWithSelector(AuthCaptureEscrow.AmountOverflow.selector, overflowValue, type(uint120).max) ); - authCaptureEscrow.capture(paymentInfo, overflowValue, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, overflowValue, _feeAmount(1, FEE_BPS), paymentInfo.feeReceiver); } function test_reverts_whenAfterAuthorizationExpiry( @@ -76,7 +76,7 @@ contract CaptureTest is AuthCaptureEscrowBase { AuthCaptureEscrow.AfterAuthorizationExpiry.selector, block.timestamp, authorizationExpiry ) ); - authCaptureEscrow.capture(paymentInfo, captureAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); } function test_reverts_whenInsufficientAuthorization(uint120 authorizedAmount) public { @@ -102,7 +102,7 @@ contract CaptureTest is AuthCaptureEscrowBase { AuthCaptureEscrow.InsufficientAuthorization.selector, paymentInfoHash, authorizedAmount, captureAmount ) ); - authCaptureEscrow.capture(paymentInfo, captureAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); } function test_reverts_receiverSender(uint120 authorizedAmount) public { @@ -123,7 +123,7 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.expectRevert( abi.encodeWithSelector(AuthCaptureEscrow.InvalidSender.selector, paymentInfo.receiver, paymentInfo.operator) ); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); } function test_succeeds_withFullAmount(uint120 authorizedAmount) public { @@ -144,7 +144,7 @@ contract CaptureTest is AuthCaptureEscrowBase { // Then capture the full amount vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); // Verify balances assertEq(mockERC3009Token.balanceOf(receiver), receiverExpectedBalance); @@ -171,7 +171,7 @@ contract CaptureTest is AuthCaptureEscrowBase { // Then capture partial amount vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, captureAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); // Verify balances and state address operatorTokenStore = authCaptureEscrow.getTokenStore(operator); @@ -197,11 +197,11 @@ contract CaptureTest is AuthCaptureEscrowBase { // First capture vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, firstCaptureAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, firstCaptureAmount, _feeAmount(firstCaptureAmount, FEE_BPS), paymentInfo.feeReceiver); // Second capture vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, secondCaptureAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, secondCaptureAmount, _feeAmount(secondCaptureAmount, FEE_BPS), paymentInfo.feeReceiver); // Calculate fees for each capture separately to match contract behavior uint256 firstFeesAmount = firstCaptureAmount * FEE_BPS / 10_000; @@ -235,25 +235,27 @@ contract CaptureTest is AuthCaptureEscrowBase { // Record expected event vm.expectEmit(true, false, false, true); emit AuthCaptureEscrow.PaymentCaptured( - paymentInfoHash, captureAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver + paymentInfoHash, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver ); // Execute capture vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, captureAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); } - function test_reverts_whenFeeBpsBelowMin( + function test_reverts_whenFeeAmountBelowMin( uint120 authorizedAmount, uint16 minFeeBps, - uint16 maxFeeBps, - uint16 captureFeeBps + uint16 maxFeeBps ) public { - // Assume reasonable bounds for fees vm.assume(authorizedAmount > 0); - vm.assume(minFeeBps > 0 && minFeeBps <= 5000); // Max 50% + vm.assume(minFeeBps > 0 && minFeeBps <= 5000); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps <= 5000); - vm.assume(captureFeeBps < minFeeBps); // Must be below min to trigger revert + + uint256 minFee = _feeAmount(authorizedAmount, minFeeBps); + vm.assume(minFee > 0); + uint256 captureFeeAmount = minFee - 1; + uint256 maxFee = _feeAmount(authorizedAmount, maxFeeBps); mockERC3009Token.mint(payerEOA, authorizedAmount); @@ -268,23 +270,26 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert( - abi.encodeWithSelector(AuthCaptureEscrow.FeeBpsOutOfRange.selector, captureFeeBps, minFeeBps, maxFeeBps) + abi.encodeWithSelector( + AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee + ) ); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeAmount, paymentInfo.feeReceiver); } - function test_reverts_whenFeeBpsAboveMax( + function test_reverts_whenFeeAmountAboveMax( uint120 authorizedAmount, uint16 minFeeBps, - uint16 maxFeeBps, - uint16 captureFeeBps + uint16 maxFeeBps ) public { - // Assume reasonable bounds for fees vm.assume(authorizedAmount > 0); - vm.assume(minFeeBps <= 5000); // Max 50% + vm.assume(minFeeBps <= 5000); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps <= 5000); - vm.assume(captureFeeBps > maxFeeBps); // Must be above max to trigger revert - vm.assume(captureFeeBps <= 10_000); // But still within uint16 reasonable bounds + + uint256 minFee = _feeAmount(authorizedAmount, minFeeBps); + uint256 maxFee = _feeAmount(authorizedAmount, maxFeeBps); + vm.assume(maxFee < type(uint256).max); + uint256 captureFeeAmount = maxFee + 1; mockERC3009Token.mint(payerEOA, authorizedAmount); @@ -299,9 +304,11 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert( - abi.encodeWithSelector(AuthCaptureEscrow.FeeBpsOutOfRange.selector, captureFeeBps, minFeeBps, maxFeeBps) + abi.encodeWithSelector( + AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee + ) ); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeAmount, paymentInfo.feeReceiver); } function test_reverts_whenFeeReceiverInvalid( @@ -311,7 +318,6 @@ contract CaptureTest is AuthCaptureEscrowBase { uint16 captureFeeBps, address invalidFeeReceiver ) public { - // Assume reasonable bounds for fees vm.assume(authorizedAmount > 0); vm.assume(minFeeBps > 0); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps < 10000); @@ -319,6 +325,8 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.assume(invalidFeeReceiver != address(0)); vm.assume(invalidFeeReceiver != feeReceiver); + uint256 captureFeeAmount = _feeAmount(authorizedAmount, captureFeeBps); + mockERC3009Token.mint(payerEOA, authorizedAmount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, authorizedAmount); @@ -335,7 +343,7 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.expectRevert( abi.encodeWithSelector(AuthCaptureEscrow.InvalidFeeReceiver.selector, invalidFeeReceiver, feeReceiver) ); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeBps, invalidFeeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeAmount, invalidFeeReceiver); } function test_reverts_ifSendTokensReverts_undeployedTokenStore(uint120 authorizedAmount, bytes calldata revertData) @@ -358,7 +366,7 @@ contract CaptureTest is AuthCaptureEscrowBase { authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(preApprovalPaymentCollector), ""); vm.expectRevert(abi.encodeWithSelector(MockRevertOnTransferToken.CustomRevert.selector, revertData)); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); vm.stopPrank(); } @@ -376,7 +384,7 @@ contract CaptureTest is AuthCaptureEscrowBase { authorizedAmount, address(erc3009PaymentCollector), initialSignature, - initialPaymentInfo.minFeeBps, + _feeAmount(authorizedAmount, FEE_BPS), initialPaymentInfo.feeReceiver ); @@ -397,7 +405,7 @@ contract CaptureTest is AuthCaptureEscrowBase { authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(preApprovalPaymentCollector), ""); vm.expectRevert(abi.encodeWithSelector(MockRevertOnTransferToken.CustomRevert.selector, revertData)); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); vm.stopPrank(); } @@ -418,7 +426,7 @@ contract CaptureTest is AuthCaptureEscrowBase { authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(preApprovalPaymentCollector), ""); vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, revertingToken)); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); vm.stopPrank(); } @@ -447,11 +455,35 @@ contract CaptureTest is AuthCaptureEscrowBase { address newFeeRecipient = address(0xdead); + uint256 captureFeeAmount = _feeAmount(authorizedAmount, captureFeeBps); + + vm.prank(operator); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeAmount, newFeeRecipient); + + assertEq(mockERC3009Token.balanceOf(newFeeRecipient), captureFeeAmount); + assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount - captureFeeAmount); + } + + /// @dev Capture $54.23 with 3% + $0.30 flat fee rounded to $1.93 (cent-aligned in 6-decimal USDC) + function test_succeeds_withCentAlignedFeeAmount() public { + uint120 authorizedAmount = 54_230_000; // $54.23 + uint256 centAlignedFee = 1_930_000; // $1.93 + + mockERC3009Token.mint(payerEOA, authorizedAmount); + + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, authorizedAmount); + paymentInfo.minFeeBps = 300; // 3% lower bound + paymentInfo.maxFeeBps = 400; // 4% upper bound accommodates 3% + $0.30 flat + + bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + + vm.prank(operator); + authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); + vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeBps, newFeeRecipient); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, centAlignedFee, paymentInfo.feeReceiver); - uint256 feeAmount = (uint256(authorizedAmount) * uint256(captureFeeBps)) / 10_000; - assertEq(mockERC3009Token.balanceOf(newFeeRecipient), feeAmount); - assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount - feeAmount); + assertEq(mockERC3009Token.balanceOf(feeReceiver), centAlignedFee); + assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount - centAlignedFee); } } diff --git a/test/src/PaymentEscrow/charge.t.sol b/test/src/PaymentEscrow/charge.t.sol index 8a59a23..e36a309 100644 --- a/test/src/PaymentEscrow/charge.t.sol +++ b/test/src/PaymentEscrow/charge.t.sol @@ -13,7 +13,7 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert(AuthCaptureEscrow.ZeroAmount.selector); authCaptureEscrow.charge( - paymentInfo, 0, address(erc3009PaymentCollector), signature, paymentInfo.minFeeBps, paymentInfo.feeReceiver + paymentInfo, 0, address(erc3009PaymentCollector), signature, _feeAmount(1, FEE_BPS), paymentInfo.feeReceiver ); } @@ -33,7 +33,7 @@ contract ChargeTest is AuthCaptureEscrowBase { overflowValue, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(overflowValue, FEE_BPS), paymentInfo.feeReceiver ); } @@ -58,7 +58,7 @@ contract ChargeTest is AuthCaptureEscrowBase { amount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(amount, FEE_BPS), paymentInfo.feeReceiver ); } @@ -81,7 +81,7 @@ contract ChargeTest is AuthCaptureEscrowBase { chargeAmount, address(erc3009PaymentCollector), "", - paymentInfo.minFeeBps, + _feeAmount(chargeAmount, FEE_BPS), paymentInfo.feeReceiver ); } @@ -113,7 +113,7 @@ contract ChargeTest is AuthCaptureEscrowBase { amount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(amount, FEE_BPS), paymentInfo.feeReceiver ); } @@ -150,7 +150,7 @@ contract ChargeTest is AuthCaptureEscrowBase { amount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(amount, FEE_BPS), paymentInfo.feeReceiver ); } @@ -187,7 +187,7 @@ contract ChargeTest is AuthCaptureEscrowBase { amount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(amount, FEE_BPS), paymentInfo.feeReceiver ); } @@ -214,7 +214,7 @@ contract ChargeTest is AuthCaptureEscrowBase { amount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(amount, FEE_BPS), paymentInfo.feeReceiver ); } @@ -237,11 +237,11 @@ contract ChargeTest is AuthCaptureEscrowBase { amount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(amount, FEE_BPS), paymentInfo.feeReceiver ); - uint256 feeAmount = amount * FEE_BPS / 10_000; + uint256 feeAmount = _feeAmount(amount, FEE_BPS); assertEq(mockERC3009Token.balanceOf(receiver), amount - feeAmount); assertEq(mockERC3009Token.balanceOf(feeReceiver), feeAmount); assertEq(mockERC3009Token.balanceOf(payerEOA), payerBalanceBefore - amount); @@ -267,11 +267,11 @@ contract ChargeTest is AuthCaptureEscrowBase { chargeAmount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(chargeAmount, FEE_BPS), paymentInfo.feeReceiver ); - uint256 feeAmount = chargeAmount * FEE_BPS / 10_000; + uint256 feeAmount = _feeAmount(chargeAmount, FEE_BPS); assertEq(mockERC3009Token.balanceOf(receiver), chargeAmount - feeAmount); assertEq(mockERC3009Token.balanceOf(feeReceiver), feeAmount); assertEq(mockERC3009Token.balanceOf(payerEOA), payerBalanceBefore - chargeAmount); @@ -297,7 +297,7 @@ contract ChargeTest is AuthCaptureEscrowBase { paymentInfo, valueToCharge, address(erc3009PaymentCollector), - paymentInfo.minFeeBps, + _feeAmount(valueToCharge, FEE_BPS), paymentInfo.feeReceiver ); @@ -308,7 +308,7 @@ contract ChargeTest is AuthCaptureEscrowBase { valueToCharge, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(valueToCharge, FEE_BPS), paymentInfo.feeReceiver ); } @@ -333,7 +333,7 @@ contract ChargeTest is AuthCaptureEscrowBase { chargeAmount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(chargeAmount, FEE_BPS), paymentInfo.feeReceiver ); @@ -362,14 +362,15 @@ contract ChargeTest is AuthCaptureEscrowBase { authCaptureEscrow.refund(paymentInfo, chargeAmount, address(operatorRefundCollector), ""); } - function test_reverts_whenFeeBpsBelowMin(uint120 amount, uint16 minFeeBps, uint16 maxFeeBps, uint16 captureFeeBps) - public - { - // Assume reasonable bounds for fees + function test_reverts_whenFeeAmountBelowMin(uint120 amount, uint16 minFeeBps, uint16 maxFeeBps) public { vm.assume(amount > 0); vm.assume(minFeeBps > 0); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps < 10000); - vm.assume(captureFeeBps < minFeeBps); // Must be below min to trigger revert + + uint256 minFee = _feeAmount(amount, minFeeBps); + vm.assume(minFee > 0); + uint256 captureFeeAmount = minFee - 1; + uint256 maxFee = _feeAmount(amount, maxFeeBps); mockERC3009Token.mint(payerEOA, amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = @@ -381,24 +382,24 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert( - abi.encodeWithSelector(AuthCaptureEscrow.FeeBpsOutOfRange.selector, captureFeeBps, minFeeBps, maxFeeBps) + abi.encodeWithSelector( + AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee + ) ); authCaptureEscrow.charge( - paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeBps, paymentInfo.feeReceiver + paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeAmount, paymentInfo.feeReceiver ); } - function test_charge_reverts_whenFeeBpsAboveMax( - uint120 amount, - uint16 minFeeBps, - uint16 maxFeeBps, - uint16 captureFeeBps - ) public { - // Assume reasonable bounds for fees + function test_charge_reverts_whenFeeAmountAboveMax(uint120 amount, uint16 minFeeBps, uint16 maxFeeBps) public { vm.assume(amount > 0); - vm.assume(maxFeeBps < 10000); // Keep maxFeeBps within valid range + vm.assume(maxFeeBps < 10000); vm.assume(minFeeBps <= maxFeeBps); - vm.assume(captureFeeBps > maxFeeBps && captureFeeBps <= 10000); // Must be above max but within bounds + + uint256 minFee = _feeAmount(amount, minFeeBps); + uint256 maxFee = _feeAmount(amount, maxFeeBps); + vm.assume(maxFee < type(uint256).max); + uint256 captureFeeAmount = maxFee + 1; AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({payer: payerEOA, maxAmount: amount, token: address(mockERC3009Token)}); @@ -409,10 +410,12 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert( - abi.encodeWithSelector(AuthCaptureEscrow.FeeBpsOutOfRange.selector, captureFeeBps, minFeeBps, maxFeeBps) + abi.encodeWithSelector( + AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee + ) ); authCaptureEscrow.charge( - paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeBps, paymentInfo.feeReceiver + paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeAmount, paymentInfo.feeReceiver ); } @@ -423,9 +426,11 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.assume(minFeeBps > 0); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps <= 10000); + uint256 captureFeeAmount = _feeAmount(amount, minFeeBps); + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({payer: payerEOA, maxAmount: amount, token: address(mockERC3009Token)}); - paymentInfo.feeReceiver = address(0); // Allow operator to set fee recipient + paymentInfo.feeReceiver = address(0); paymentInfo.minFeeBps = minFeeBps; paymentInfo.maxFeeBps = maxFeeBps; @@ -434,7 +439,7 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert(AuthCaptureEscrow.ZeroFeeReceiver.selector); authCaptureEscrow.charge( - paymentInfo, amount, address(erc3009PaymentCollector), signature, minFeeBps, address(0) + paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeAmount, address(0) ); } @@ -445,15 +450,13 @@ contract ChargeTest is AuthCaptureEscrowBase { uint16 captureFeeBps, address newFeeRecipient ) public { - // Assume reasonable bounds for fees vm.assume(amount > 0); vm.assume(minFeeBps > 0); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps <= 10000); - vm.assume(captureFeeBps >= minFeeBps && captureFeeBps <= maxFeeBps); // Must be within range + vm.assume(captureFeeBps >= minFeeBps && captureFeeBps <= maxFeeBps); mockERC3009Token.mint(payerEOA, amount); - // Ensure newFeeRecipient is not zero address or other special addresses assumePayable(newFeeRecipient); vm.assume(newFeeRecipient != address(0)); vm.assume(newFeeRecipient != address(authCaptureEscrow)); @@ -461,19 +464,20 @@ contract ChargeTest is AuthCaptureEscrowBase { AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({payer: payerEOA, maxAmount: amount, token: address(mockERC3009Token)}); - paymentInfo.feeReceiver = address(0); // Allow operator to set fee recipient + paymentInfo.feeReceiver = address(0); paymentInfo.minFeeBps = minFeeBps; paymentInfo.maxFeeBps = maxFeeBps; bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + uint256 captureFeeAmount = _feeAmount(amount, captureFeeBps); + vm.prank(operator); authCaptureEscrow.charge( - paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeBps, newFeeRecipient + paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeAmount, newFeeRecipient ); - uint256 feeAmount = (uint256(amount) * uint256(captureFeeBps)) / 10_000; - assertEq(mockERC3009Token.balanceOf(newFeeRecipient), feeAmount); - assertEq(mockERC3009Token.balanceOf(receiver), amount - feeAmount); + assertEq(mockERC3009Token.balanceOf(newFeeRecipient), captureFeeAmount); + assertEq(mockERC3009Token.balanceOf(receiver), amount - captureFeeAmount); } } diff --git a/test/src/PaymentEscrow/e2eCoinbaseSmartWallet.t.sol b/test/src/PaymentEscrow/e2eCoinbaseSmartWallet.t.sol index 328fd22..2024ce4 100644 --- a/test/src/PaymentEscrow/e2eCoinbaseSmartWallet.t.sol +++ b/test/src/PaymentEscrow/e2eCoinbaseSmartWallet.t.sol @@ -25,11 +25,11 @@ contract AuthCaptureEscrowSmartWalletE2ETest is AuthCaptureEscrowSmartWalletBase amount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(amount, paymentInfo.minFeeBps), paymentInfo.feeReceiver ); - uint256 feeAmount = uint256(amount) * FEE_BPS / 10_000; + uint256 feeAmount = _feeAmount(amount, FEE_BPS); assertEq(mockERC3009Token.balanceOf(receiver), amount - feeAmount); assertEq(mockERC3009Token.balanceOf(feeReceiver), feeAmount); } @@ -57,11 +57,11 @@ contract AuthCaptureEscrowSmartWalletE2ETest is AuthCaptureEscrowSmartWalletBase amount, address(erc3009PaymentCollector), signature, - paymentInfo.minFeeBps, + _feeAmount(amount, paymentInfo.minFeeBps), paymentInfo.feeReceiver ); - uint256 feeAmount = uint256(amount) * FEE_BPS / 10_000; + uint256 feeAmount = _feeAmount(amount, FEE_BPS); assertEq(mockERC3009Token.balanceOf(receiver), amount - feeAmount); assertEq(mockERC3009Token.balanceOf(feeReceiver), feeAmount); } diff --git a/test/src/PaymentEscrow/reentrancy.t.sol b/test/src/PaymentEscrow/reentrancy.t.sol index 36650fe..239d0c6 100644 --- a/test/src/PaymentEscrow/reentrancy.t.sol +++ b/test/src/PaymentEscrow/reentrancy.t.sol @@ -62,10 +62,10 @@ contract ReentrancyApproveTest is AuthCaptureEscrowSmartWalletBase { console.log("After authorize attempt"); vm.expectRevert(); // expect revert because authorize never happened - authCaptureEscrow.capture(paymentInfo, 10 ether, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, 10 ether, 0, paymentInfo.feeReceiver); paymentInfo.salt += 1; // set up the second unique paymentInfo vm.expectRevert(); // expect revert because we've fixed the reentrancy - authCaptureEscrow.capture(paymentInfo, 10 ether, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, 10 ether, 0, paymentInfo.feeReceiver); vm.stopPrank(); console.log("After attack Attacker Balance:", mockERC3009Token.balanceOf(attacker)); diff --git a/test/src/PaymentEscrow/refund.t.sol b/test/src/PaymentEscrow/refund.t.sol index f2f9396..63c4903 100644 --- a/test/src/PaymentEscrow/refund.t.sol +++ b/test/src/PaymentEscrow/refund.t.sol @@ -71,7 +71,7 @@ contract RefundTest is AuthCaptureEscrowBase { // First confirm and capture partial amount vm.startPrank(operator); authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); - authCaptureEscrow.capture(paymentInfo, captureAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); vm.stopPrank(); // Fund operator for refund @@ -100,7 +100,7 @@ contract RefundTest is AuthCaptureEscrowBase { // First confirm and capture the payment vm.startPrank(operator); authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); vm.stopPrank(); // Fund the operator for refund @@ -136,7 +136,7 @@ contract RefundTest is AuthCaptureEscrowBase { // First confirm and capture the payment vm.startPrank(operator); authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); vm.stopPrank(); // Fund operator for refund From 9660a17529db36c9821dd94d13244ce1e7c5c669 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Mon, 22 Jun 2026 15:56:34 -0700 Subject: [PATCH 03/12] testing config fixes --- test/gas/gasBenchmark.t.sol | 4 ++-- test/src/PaymentEscrow/charge.t.sol | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/test/gas/gasBenchmark.t.sol b/test/gas/gasBenchmark.t.sol index 6bcf057..fc36452 100644 --- a/test/gas/gasBenchmark.t.sol +++ b/test/gas/gasBenchmark.t.sol @@ -8,7 +8,7 @@ import {AuthCaptureEscrowBase} from "../base/AuthCaptureEscrowBase.sol"; contract GasBenchmarkBase is AuthCaptureEscrowBase { uint120 internal constant BENCHMARK_AMOUNT = 100e6; uint16 internal constant BENCHMARK_FEE_BPS = 100; // 1% - uint256 internal constant BENCHMARK_FEE_AMOUNT = BENCHMARK_AMOUNT * BENCHMARK_FEE_BPS / 10_000; + uint256 internal constant BENCHMARK_FEE_AMOUNT = uint256(BENCHMARK_AMOUNT) * uint256(BENCHMARK_FEE_BPS) / 10_000; AuthCaptureEscrow.PaymentInfo internal paymentInfo; bytes internal signature; @@ -23,7 +23,7 @@ contract GasBenchmarkBase is AuthCaptureEscrowBase { mockERC3009Token.mint(payerEOA, 1e6); vm.startPrank(operator); authCaptureEscrow.authorize(warmupInfo, 1e6, address(erc3009PaymentCollector), warmupSignature); - authCaptureEscrow.capture(warmupInfo, 1e6, 1e6 * BENCHMARK_FEE_BPS / 10_000, feeReceiver); // make sure token store is deployed before subsequent tests + authCaptureEscrow.capture(warmupInfo, 1e6, (uint256(1_000_000) * uint256(BENCHMARK_FEE_BPS)) / 10_000, feeReceiver); // make sure token store is deployed before subsequent tests vm.stopPrank(); // Create and sign payment info diff --git a/test/src/PaymentEscrow/charge.t.sol b/test/src/PaymentEscrow/charge.t.sol index e36a309..7b4fd01 100644 --- a/test/src/PaymentEscrow/charge.t.sol +++ b/test/src/PaymentEscrow/charge.t.sol @@ -33,7 +33,7 @@ contract ChargeTest is AuthCaptureEscrowBase { overflowValue, address(erc3009PaymentCollector), signature, - _feeAmount(overflowValue, FEE_BPS), + _feeAmount(1, FEE_BPS), paymentInfo.feeReceiver ); } @@ -426,7 +426,10 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.assume(minFeeBps > 0); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps <= 10000); - uint256 captureFeeAmount = _feeAmount(amount, minFeeBps); + uint256 minFee = _feeAmount(amount, minFeeBps); + uint256 maxFee = _feeAmount(amount, maxFeeBps); + vm.assume(maxFee > 0); + uint256 captureFeeAmount = minFee > 0 ? minFee : maxFee; AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({payer: payerEOA, maxAmount: amount, token: address(mockERC3009Token)}); From 980023c80a0762dbffae0c1488517f781b94e252 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Tue, 23 Jun 2026 09:59:07 -0700 Subject: [PATCH 04/12] Add ci Foundry profile and apply forge fmt --- foundry.toml | 6 ++ src/AuthCaptureEscrow.sol | 7 +- src/collectors/ERC3009PaymentCollector.sol | 3 +- src/collectors/Permit2PaymentCollector.sol | 4 +- src/interfaces/IMulticall3.sol | 5 +- test/base/AuthCaptureEscrowBase.sol | 5 +- .../base/AuthCaptureEscrowSmartWalletBase.sol | 6 +- test/gas/gasBenchmark.t.sol | 11 ++- test/src/PaymentEscrow/authorize.t.sol | 4 +- test/src/PaymentEscrow/capture.t.sol | 68 +++++++++++-------- test/src/PaymentEscrow/charge.t.sol | 8 +-- test/src/PaymentEscrow/refund.t.sol | 12 +++- .../SpendPermissionPaymentCollector.t.sol | 12 +--- 13 files changed, 82 insertions(+), 69 deletions(-) diff --git a/foundry.toml b/foundry.toml index 90dbd61..831be66 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,6 +4,12 @@ out = "out" libs = ["lib"] evm_version = "cancun" +[profile.ci] +src = "src" +out = "out" +libs = ["lib"] +evm_version = "cancun" + [profile.deploy] src = "src" out = "out" diff --git a/src/AuthCaptureEscrow.sol b/src/AuthCaptureEscrow.sol index 4bc611c..6068120 100644 --- a/src/AuthCaptureEscrow.sol +++ b/src/AuthCaptureEscrow.sol @@ -392,9 +392,7 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { /// @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) + implementation: tokenStoreImplementation, salt: bytes32(bytes20(operator)), deployer: address(this) }); } @@ -440,8 +438,7 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { } else if (tokenStore.code.length == 0) { // Call failed from undeployed TokenStore, deploy and try again tokenStore = LibClone.cloneDeterministic({ - implementation: tokenStoreImplementation, - salt: bytes32(bytes20(operator)) + implementation: tokenStoreImplementation, salt: bytes32(bytes20(operator)) }); emit TokenStoreCreated(operator, tokenStore); TokenStore(tokenStore).sendTokens(token, recipient, amount); diff --git a/src/collectors/ERC3009PaymentCollector.sol b/src/collectors/ERC3009PaymentCollector.sol index f143868..a0a455d 100644 --- a/src/collectors/ERC3009PaymentCollector.sol +++ b/src/collectors/ERC3009PaymentCollector.sol @@ -39,7 +39,8 @@ contract ERC3009PaymentCollector is TokenCollector, ERC6492SignatureHandler { uint256 maxAmount = paymentInfo.maxAmount; // Pull tokens into this contract - IERC3009(token).receiveWithAuthorization({ + IERC3009(token) + .receiveWithAuthorization({ from: payer, to: address(this), value: maxAmount, diff --git a/src/collectors/Permit2PaymentCollector.sol b/src/collectors/Permit2PaymentCollector.sol index e4ced64..7cb604d 100644 --- a/src/collectors/Permit2PaymentCollector.sol +++ b/src/collectors/Permit2PaymentCollector.sol @@ -42,7 +42,9 @@ contract Permit2PaymentCollector is TokenCollector, ERC6492SignatureHandler { ) internal override { permit2.permitTransferFrom({ permit: ISignatureTransfer.PermitTransferFrom({ - permitted: ISignatureTransfer.TokenPermissions({token: paymentInfo.token, amount: paymentInfo.maxAmount}), + permitted: ISignatureTransfer.TokenPermissions({ + token: paymentInfo.token, amount: paymentInfo.maxAmount + }), nonce: uint256(_getHashPayerAgnostic(paymentInfo)), deadline: paymentInfo.preApprovalExpiry }), diff --git a/src/interfaces/IMulticall3.sol b/src/interfaces/IMulticall3.sol index 57a27ea..f232db1 100644 --- a/src/interfaces/IMulticall3.sol +++ b/src/interfaces/IMulticall3.sol @@ -26,10 +26,7 @@ interface IMulticall3 { bytes returnData; } - function aggregate(Call[] calldata calls) - external - payable - returns (uint256 blockNumber, bytes[] memory returnData); + function aggregate(Call[] calldata calls) external payable returns (uint256 blockNumber, bytes[] memory returnData); function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); diff --git a/test/base/AuthCaptureEscrowBase.sol b/test/base/AuthCaptureEscrowBase.sol index 411d80c..304889e 100644 --- a/test/base/AuthCaptureEscrowBase.sol +++ b/test/base/AuthCaptureEscrowBase.sol @@ -161,8 +161,9 @@ contract AuthCaptureEscrowBase is Test, DeployPermit2 { uint256 validBefore, bytes32 nonce ) internal view returns (bytes32) { - bytes32 structHash = - keccak256(abi.encode(_RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)); + bytes32 structHash = keccak256( + abi.encode(_RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ); return keccak256(abi.encodePacked("\x19\x01", IERC3009(token).DOMAIN_SEPARATOR(), structHash)); } diff --git a/test/base/AuthCaptureEscrowSmartWalletBase.sol b/test/base/AuthCaptureEscrowSmartWalletBase.sol index 4da0435..ab9f00d 100644 --- a/test/base/AuthCaptureEscrowSmartWalletBase.sol +++ b/test/base/AuthCaptureEscrowSmartWalletBase.sol @@ -172,11 +172,7 @@ contract AuthCaptureEscrowSmartWalletBase is AuthCaptureEscrowBase { uint256 nonce = uint256(hashPortion); return MagicSpend.WithdrawRequest({ - asset: address(0), - amount: 0, - nonce: nonce, - expiry: type(uint48).max, - signature: new bytes(0) + asset: address(0), amount: 0, nonce: nonce, expiry: type(uint48).max, signature: new bytes(0) }); } diff --git a/test/gas/gasBenchmark.t.sol b/test/gas/gasBenchmark.t.sol index fc36452..c56d742 100644 --- a/test/gas/gasBenchmark.t.sol +++ b/test/gas/gasBenchmark.t.sol @@ -23,7 +23,9 @@ contract GasBenchmarkBase is AuthCaptureEscrowBase { mockERC3009Token.mint(payerEOA, 1e6); vm.startPrank(operator); authCaptureEscrow.authorize(warmupInfo, 1e6, address(erc3009PaymentCollector), warmupSignature); - authCaptureEscrow.capture(warmupInfo, 1e6, (uint256(1_000_000) * uint256(BENCHMARK_FEE_BPS)) / 10_000, feeReceiver); // make sure token store is deployed before subsequent tests + authCaptureEscrow.capture( + warmupInfo, 1e6, (uint256(1_000_000) * uint256(BENCHMARK_FEE_BPS)) / 10_000, feeReceiver + ); // make sure token store is deployed before subsequent tests vm.stopPrank(); // Create and sign payment info @@ -46,7 +48,12 @@ contract ChargeGasBenchmark is GasBenchmarkBase { function test_charge_benchmark() public { vm.prank(operator); authCaptureEscrow.charge( - paymentInfo, BENCHMARK_AMOUNT, address(erc3009PaymentCollector), signature, BENCHMARK_FEE_AMOUNT, feeReceiver + paymentInfo, + BENCHMARK_AMOUNT, + address(erc3009PaymentCollector), + signature, + BENCHMARK_FEE_AMOUNT, + feeReceiver ); } } diff --git a/test/src/PaymentEscrow/authorize.t.sol b/test/src/PaymentEscrow/authorize.t.sol index f24c56a..60cc1e6 100644 --- a/test/src/PaymentEscrow/authorize.t.sol +++ b/test/src/PaymentEscrow/authorize.t.sol @@ -321,9 +321,7 @@ contract AuthorizeTest is AuthCaptureEscrowSmartWalletBase { vm.assume(maxAmount >= amount && amount > 0); mockERC3009Token.mint(address(smartWalletDeployed), amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({ - payer: address(smartWalletDeployed), - maxAmount: maxAmount, - token: address(mockERC3009Token) + payer: address(smartWalletDeployed), maxAmount: maxAmount, token: address(mockERC3009Token) }); // Create and sign the spend permission diff --git a/test/src/PaymentEscrow/capture.t.sol b/test/src/PaymentEscrow/capture.t.sol index f018c8f..8ecbe85 100644 --- a/test/src/PaymentEscrow/capture.t.sol +++ b/test/src/PaymentEscrow/capture.t.sol @@ -25,7 +25,9 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.prank(sender); vm.expectRevert(abi.encodeWithSelector(AuthCaptureEscrow.InvalidSender.selector, sender, paymentInfo.operator)); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); } function test_reverts_whenValueIsZero() public { @@ -76,7 +78,9 @@ contract CaptureTest is AuthCaptureEscrowBase { AuthCaptureEscrow.AfterAuthorizationExpiry.selector, block.timestamp, authorizationExpiry ) ); - authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver + ); } function test_reverts_whenInsufficientAuthorization(uint120 authorizedAmount) public { @@ -102,7 +106,9 @@ contract CaptureTest is AuthCaptureEscrowBase { AuthCaptureEscrow.InsufficientAuthorization.selector, paymentInfoHash, authorizedAmount, captureAmount ) ); - authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver + ); } function test_reverts_receiverSender(uint120 authorizedAmount) public { @@ -123,7 +129,9 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.expectRevert( abi.encodeWithSelector(AuthCaptureEscrow.InvalidSender.selector, paymentInfo.receiver, paymentInfo.operator) ); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); } function test_succeeds_withFullAmount(uint120 authorizedAmount) public { @@ -144,7 +152,9 @@ contract CaptureTest is AuthCaptureEscrowBase { // Then capture the full amount vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); // Verify balances assertEq(mockERC3009Token.balanceOf(receiver), receiverExpectedBalance); @@ -171,7 +181,9 @@ contract CaptureTest is AuthCaptureEscrowBase { // Then capture partial amount vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver + ); // Verify balances and state address operatorTokenStore = authCaptureEscrow.getTokenStore(operator); @@ -197,11 +209,15 @@ contract CaptureTest is AuthCaptureEscrowBase { // First capture vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, firstCaptureAmount, _feeAmount(firstCaptureAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, firstCaptureAmount, _feeAmount(firstCaptureAmount, FEE_BPS), paymentInfo.feeReceiver + ); // Second capture vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, secondCaptureAmount, _feeAmount(secondCaptureAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, secondCaptureAmount, _feeAmount(secondCaptureAmount, FEE_BPS), paymentInfo.feeReceiver + ); // Calculate fees for each capture separately to match contract behavior uint256 firstFeesAmount = firstCaptureAmount * FEE_BPS / 10_000; @@ -240,14 +256,12 @@ contract CaptureTest is AuthCaptureEscrowBase { // Execute capture vm.prank(operator); - authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver + ); } - function test_reverts_whenFeeAmountBelowMin( - uint120 authorizedAmount, - uint16 minFeeBps, - uint16 maxFeeBps - ) public { + function test_reverts_whenFeeAmountBelowMin(uint120 authorizedAmount, uint16 minFeeBps, uint16 maxFeeBps) public { vm.assume(authorizedAmount > 0); vm.assume(minFeeBps > 0 && minFeeBps <= 5000); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps <= 5000); @@ -270,18 +284,12 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert( - abi.encodeWithSelector( - AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee - ) + abi.encodeWithSelector(AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee) ); authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeAmount, paymentInfo.feeReceiver); } - function test_reverts_whenFeeAmountAboveMax( - uint120 authorizedAmount, - uint16 minFeeBps, - uint16 maxFeeBps - ) public { + function test_reverts_whenFeeAmountAboveMax(uint120 authorizedAmount, uint16 minFeeBps, uint16 maxFeeBps) public { vm.assume(authorizedAmount > 0); vm.assume(minFeeBps <= 5000); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps <= 5000); @@ -304,9 +312,7 @@ contract CaptureTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert( - abi.encodeWithSelector( - AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee - ) + abi.encodeWithSelector(AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee) ); authCaptureEscrow.capture(paymentInfo, authorizedAmount, captureFeeAmount, paymentInfo.feeReceiver); } @@ -366,7 +372,9 @@ contract CaptureTest is AuthCaptureEscrowBase { authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(preApprovalPaymentCollector), ""); vm.expectRevert(abi.encodeWithSelector(MockRevertOnTransferToken.CustomRevert.selector, revertData)); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); } @@ -405,7 +413,9 @@ contract CaptureTest is AuthCaptureEscrowBase { authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(preApprovalPaymentCollector), ""); vm.expectRevert(abi.encodeWithSelector(MockRevertOnTransferToken.CustomRevert.selector, revertData)); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); } @@ -426,7 +436,9 @@ contract CaptureTest is AuthCaptureEscrowBase { authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(preApprovalPaymentCollector), ""); vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, revertingToken)); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); } diff --git a/test/src/PaymentEscrow/charge.t.sol b/test/src/PaymentEscrow/charge.t.sol index 7b4fd01..a6a0e21 100644 --- a/test/src/PaymentEscrow/charge.t.sol +++ b/test/src/PaymentEscrow/charge.t.sol @@ -382,9 +382,7 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert( - abi.encodeWithSelector( - AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee - ) + abi.encodeWithSelector(AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee) ); authCaptureEscrow.charge( paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeAmount, paymentInfo.feeReceiver @@ -410,9 +408,7 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.prank(operator); vm.expectRevert( - abi.encodeWithSelector( - AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee - ) + abi.encodeWithSelector(AuthCaptureEscrow.FeeAmountOutOfRange.selector, captureFeeAmount, minFee, maxFee) ); authCaptureEscrow.charge( paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeAmount, paymentInfo.feeReceiver diff --git a/test/src/PaymentEscrow/refund.t.sol b/test/src/PaymentEscrow/refund.t.sol index 63c4903..6e09727 100644 --- a/test/src/PaymentEscrow/refund.t.sol +++ b/test/src/PaymentEscrow/refund.t.sol @@ -71,7 +71,9 @@ contract RefundTest is AuthCaptureEscrowBase { // First confirm and capture partial amount vm.startPrank(operator); authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); - authCaptureEscrow.capture(paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, captureAmount, _feeAmount(captureAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); // Fund operator for refund @@ -100,7 +102,9 @@ contract RefundTest is AuthCaptureEscrowBase { // First confirm and capture the payment vm.startPrank(operator); authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); // Fund the operator for refund @@ -136,7 +140,9 @@ contract RefundTest is AuthCaptureEscrowBase { // First confirm and capture the payment vm.startPrank(operator); authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); - authCaptureEscrow.capture(paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); // Fund operator for refund diff --git a/test/src/collectors/SpendPermissionPaymentCollector.t.sol b/test/src/collectors/SpendPermissionPaymentCollector.t.sol index 81c6c5d..8a3c946 100644 --- a/test/src/collectors/SpendPermissionPaymentCollector.t.sol +++ b/test/src/collectors/SpendPermissionPaymentCollector.t.sol @@ -25,9 +25,7 @@ contract SpendPermissionPaymentCollectorTest is AuthCaptureEscrowSmartWalletBase MockERC3009Token(address(mockERC3009Token)).mint(address(smartWalletDeployed), amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({ - payer: address(smartWalletDeployed), - maxAmount: amount, - token: address(mockERC3009Token) + payer: address(smartWalletDeployed), maxAmount: amount, token: address(mockERC3009Token) }); address tokenStore = authCaptureEscrow.getTokenStore(paymentInfo.operator); @@ -47,9 +45,7 @@ contract SpendPermissionPaymentCollectorTest is AuthCaptureEscrowSmartWalletBase MockERC3009Token(address(mockERC3009Token)).mint(address(smartWalletDeployed), amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({ - payer: address(smartWalletDeployed), - maxAmount: amount, - token: address(mockERC3009Token) + payer: address(smartWalletDeployed), maxAmount: amount, token: address(mockERC3009Token) }); address tokenStore = authCaptureEscrow.getTokenStore(paymentInfo.operator); @@ -69,9 +65,7 @@ contract SpendPermissionPaymentCollectorTest is AuthCaptureEscrowSmartWalletBase mockERC3009Token.mint(address(magicSpend), amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({ - payer: address(smartWalletDeployed), - maxAmount: amount, - token: address(mockERC3009Token) + payer: address(smartWalletDeployed), maxAmount: amount, token: address(mockERC3009Token) }); address tokenStore = authCaptureEscrow.getTokenStore(paymentInfo.operator); From 66e9da4b8a1e29fea33e57970921955bd61b9597 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Wed, 24 Jun 2026 09:50:25 -0700 Subject: [PATCH 05/12] remove local variable --- src/AuthCaptureEscrow.sol | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/AuthCaptureEscrow.sol b/src/AuthCaptureEscrow.sol index 6068120..1845ede 100644 --- a/src/AuthCaptureEscrow.sol +++ b/src/AuthCaptureEscrow.sol @@ -509,12 +509,10 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { internal pure { - uint16 minFeeBps = paymentInfo.minFeeBps; - uint16 maxFeeBps = paymentInfo.maxFeeBps; address configuredFeeReceiver = paymentInfo.feeReceiver; - uint256 minFee = amount * minFeeBps / _MAX_FEE_BPS; - uint256 maxFee = amount * maxFeeBps / _MAX_FEE_BPS; + 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); From 6fa229d22645a470ea277462180f182aafc333cd Mon Sep 17 00:00:00 2001 From: Ruohan Date: Wed, 24 Jun 2026 09:53:40 -0700 Subject: [PATCH 06/12] formatting --- src/interfaces/IMulticall3.sol | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/interfaces/IMulticall3.sol b/src/interfaces/IMulticall3.sol index f232db1..57a27ea 100644 --- a/src/interfaces/IMulticall3.sol +++ b/src/interfaces/IMulticall3.sol @@ -26,7 +26,10 @@ interface IMulticall3 { bytes returnData; } - function aggregate(Call[] calldata calls) external payable returns (uint256 blockNumber, bytes[] memory returnData); + function aggregate(Call[] calldata calls) + external + payable + returns (uint256 blockNumber, bytes[] memory returnData); function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); From dd55346e373e76b392b2dc131be6715198053434 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Tue, 14 Jul 2026 10:21:44 -0700 Subject: [PATCH 07/12] added documentation fixes --- docs/Fees.md | 80 ++++++++++++++++++++------------------ docs/operations/Capture.md | 12 +++--- docs/operations/Charge.md | 10 ++--- 3 files changed, 53 insertions(+), 49 deletions(-) diff --git a/docs/Fees.md b/docs/Fees.md index 515600b..1c0ea24 100644 --- a/docs/Fees.md +++ b/docs/Fees.md @@ -1,14 +1,18 @@ # Fee System -The Commerce Payments Protocol implements a fee system that provides flexibility while maintaining security through pre-defined constraints. Fees are specified in the initial `PaymentInfo` struct and validated during `charge()` and `capture()` operations. +The Commerce Payments Protocol implements a fee system that provides flexibility while maintaining security through pre-defined constraints. Fee bounds are specified in the initial `PaymentInfo` struct (via `minFeeBps`/`maxFeeBps`), while the concrete absolute `feeAmount` is supplied and validated during `charge()` and `capture()` operations. ## Overview -Fees in the protocol are calculated as **basis points** (bps), where 10,000 basis points = 100%. For example: -- 250 bps = 2.5% -- 1000 bps = 10.0% +The operator supplies the fee at capture/charge time as an **absolute `feeAmount` denominated in raw token units** — it is no longer expressed in basis points. The `PaymentInfo` struct still bounds the allowable fee using basis points (`minFeeBps`/`maxFeeBps`, where 10,000 basis points = 100%), and the protocol validates that the provided `feeAmount` falls within the bounds those rates imply for the given capture/charge `amount`: -The fee amount is calculated as: `feeAmount = totalAmount * feeBps / 10000` +``` +minFee = amount * minFeeBps / 10000 +maxFee = amount * maxFeeBps / 10000 +require(minFee <= feeAmount <= maxFee) +``` + +For example, capturing `1000e6` USDC with `minFeeBps = maxFeeBps = 250` (2.5%) requires `feeAmount == 25e6` (25 USDC). ## Fee Parameters in `PaymentInfo` @@ -26,11 +30,11 @@ struct PaymentInfo { ### Fee Rate Range (`minFeeBps` and `maxFeeBps`) -These parameters establish the allowed fee range for the payment: +These parameters bound the allowed absolute fee for the payment (applied to the capture/charge `amount`): -- **Fixed Rate**: When `minFeeBps == maxFeeBps`, the operator must use exactly that fee rate -- **Variable Rate**: When `minFeeBps < maxFeeBps`, the operator can choose any rate within the range -- **Zero Fees**: When both are 0, no fees can be charged +- **Fixed Rate**: When `minFeeBps == maxFeeBps`, the operator must supply exactly `amount * feeBps / 10000` token units +- **Variable Rate**: When `minFeeBps < maxFeeBps`, the operator can supply any `feeAmount` between the two implied bounds +- **Zero Fees**: When both are 0, the only valid `feeAmount` is 0 ### Fee Receiver (`feeReceiver`) @@ -43,17 +47,17 @@ Controls who can receive the fee portion: During `charge()` and `capture()` operations, the protocol validates: -1. **Rate Range**: `minFeeBps ≤ feeBps ≤ maxFeeBps` +1. **Fee Bounds**: `amount * minFeeBps / 10000 ≤ feeAmount ≤ amount * maxFeeBps / 10000` 2. **Maximum Limit**: `maxFeeBps ≤ 10,000` (cannot exceed 100%) 3. **Range Validity**: `minFeeBps ≤ maxFeeBps` -4. **Zero Fee Receiver**: If `feeBps > 0`, then `feeReceiver` cannot be `address(0)` +4. **Zero Fee Receiver**: If `feeAmount > 0`, then `feeReceiver` cannot be `address(0)` 5. **Fixed Recipient**: If `PaymentInfo.feeReceiver != address(0)`, the provided `feeReceiver` must match exactly ## Fee Distribution When fees are applied: -1. **Fee Calculation**: `feeAmount = amount * feeBps / 10000` +1. **Fee Amount**: The operator-supplied absolute `feeAmount` (already validated against the bounds above) 2. **Fee Transfer**: If `feeAmount > 0`, transfer to `feeReceiver` 3. **Remaining Transfer**: Transfer `amount - feeAmount` to the merchant (`receiver`) @@ -70,10 +74,10 @@ PaymentInfo memory payment = PaymentInfo({ }); ``` -**Operator Options at Capture/Charge:** -- ✅ `feeBps: 250, feeReceiver: 0x123...456` -- ❌ `feeBps: 300, feeReceiver: 0x123...456` (exceeds max rate) -- ❌ `feeBps: 250, feeReceiver: 0x789...abc` (wrong recipient) +**Operator Options at Capture/Charge (for a `1000e6` capture ⇒ bounds are exactly `25e6`):** +- ✅ `feeAmount: 25e6, feeReceiver: 0x123...456` +- ❌ `feeAmount: 30e6, feeReceiver: 0x123...456` (exceeds max) +- ❌ `feeAmount: 25e6, feeReceiver: 0x789...abc` (wrong recipient) ### Example 2: Variable Fee Rate with Flexible Recipient @@ -87,13 +91,13 @@ PaymentInfo memory payment = PaymentInfo({ }); ``` -**Operator Options at Capture/Charge:** -- ✅ `feeBps: 100, feeReceiver: 0x123...456` (minimum rate) -- ✅ `feeBps: 350, feeReceiver: 0x789...abc` (mid-range rate) -- ✅ `feeBps: 500, feeReceiver: 0xdef...123` (maximum rate) -- ❌ `feeBps: 50, feeReceiver: 0x123...456` (below minimum) -- ❌ `feeBps: 600, feeReceiver: 0x123...456` (exceeds maximum) -- ❌ `feeBps: 300, feeReceiver: address(0)` (zero fee receiver with non-zero fee) +**Operator Options at Capture/Charge (for a `1000e6` capture ⇒ bounds are `10e6`–`50e6`):** +- ✅ `feeAmount: 10e6, feeReceiver: 0x123...456` (minimum) +- ✅ `feeAmount: 35e6, feeReceiver: 0x789...abc` (mid-range) +- ✅ `feeAmount: 50e6, feeReceiver: 0xdef...123` (maximum) +- ❌ `feeAmount: 5e6, feeReceiver: 0x123...456` (below minimum) +- ❌ `feeAmount: 60e6, feeReceiver: 0x123...456` (exceeds maximum) +- ❌ `feeAmount: 30e6, feeReceiver: address(0)` (zero fee receiver with non-zero fee) **Use Case**: Marketplace with tiered fee structure based on merchant volume @@ -109,9 +113,9 @@ PaymentInfo memory payment = PaymentInfo({ ``` **Operator Options at Capture/Charge:** -- ✅ `feeBps: 0, feeReceiver: address(0)` -- ✅ `feeBps: 0, feeReceiver: 0x123...456` (fee receiver ignored when fee is 0) -- ❌ `feeBps: 1, feeReceiver: 0x123...456` (any non-zero fee rejected) +- ✅ `feeAmount: 0, feeReceiver: address(0)` +- ✅ `feeAmount: 0, feeReceiver: 0x123...456` (fee receiver ignored when fee is 0) +- ❌ `feeAmount: 1, feeReceiver: 0x123...456` (any non-zero fee rejected) ### Example 4: Flexible Rate with Fixed Recipient @@ -125,27 +129,27 @@ PaymentInfo memory payment = PaymentInfo({ }); ``` -**Operator Options at Capture/Charge:** -- ✅ `feeBps: 0, feeReceiver: address(0)` (no fee, receiver ignored) -- ✅ `feeBps: 250, feeReceiver: 0x123...456` (2.5% to fixed recipient) -- ✅ `feeBps: 1000, feeReceiver: 0x123...456` (maximum fee) -- ❌ `feeBps: 250, feeReceiver: 0x789...abc` (wrong recipient) +**Operator Options at Capture/Charge (for a `1000e6` capture ⇒ bounds are `0`–`100e6`):** +- ✅ `feeAmount: 0, feeReceiver: address(0)` (no fee, receiver ignored) +- ✅ `feeAmount: 25e6, feeReceiver: 0x123...456` (2.5% to fixed recipient) +- ✅ `feeAmount: 100e6, feeReceiver: 0x123...456` (maximum fee) +- ❌ `feeAmount: 25e6, feeReceiver: 0x789...abc` (wrong recipient) ## Multiple Captures with Different Fees -For partial captures, operators can use different fee rates within the allowed range: +For partial captures, operators can supply different fee amounts, each validated against the bounds implied by that capture's `amount`: ```solidity // Initial authorization: 1000 USDC // PaymentInfo: minFeeBps=200, maxFeeBps=400, feeReceiver=address(0) -// First capture: 600 USDC at 2% fee -capture(paymentInfo, 600e6, 200, feeRecipient1); +// First capture: 600 USDC, 12 USDC fee (2% of 600, within 12e6-24e6 bounds) +capture(paymentInfo, 600e6, 12e6, feeRecipient1); // Fee: 12 USDC to feeRecipient1, 588 USDC to merchant -// Second capture: 400 USDC at 4% fee -capture(paymentInfo, 400e6, 400, feeRecipient2); +// Second capture: 400 USDC, 16 USDC fee (4% of 400, within 8e6-16e6 bounds) +capture(paymentInfo, 400e6, 16e6, feeRecipient2); // Fee: 16 USDC to feeRecipient2, 384 USDC to merchant ``` @@ -157,8 +161,8 @@ The protocol will revert with specific errors for invalid fee configurations: |-------|-----------|---------| | `FeeBpsOverflow` | `maxFeeBps > 10000` | Setting 150% fee rate | | `InvalidFeeBpsRange` | `minFeeBps > maxFeeBps` | min=500, max=200 | -| `FeeBpsOutOfRange` | Fee outside allowed range | 300 bps when range is 500-1000 | -| `ZeroFeeReceiver` | Non-zero fee with zero recipient | 250 bps fee, address(0) recipient | +| `FeeAmountOutOfRange` | `feeAmount` outside bounds implied by min/max fee bps | `30e6` fee when bounds are `50e6`–`100e6` | +| `ZeroFeeReceiver` | Non-zero fee with zero recipient | `25e6` fee, address(0) recipient | | `InvalidFeeReceiver` | Wrong recipient for fixed fee | Different address than PaymentInfo.feeReceiver | The protocol uses integer division which truncates decimals, slightly favoring the merchant in rounding scenarios. diff --git a/docs/operations/Capture.md b/docs/operations/Capture.md index 2e87bc8..20eef0a 100644 --- a/docs/operations/Capture.md +++ b/docs/operations/Capture.md @@ -22,7 +22,7 @@ Capture finalizes the payment by: function capture( PaymentInfo calldata paymentInfo, uint256 amount, - uint16 feeBps, + uint256 feeAmount, address feeReceiver ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) ``` @@ -32,14 +32,14 @@ function capture( 2. **Timing Check**: Verifies capture occurs before authorization expiry 3. **Availability Check**: Confirms sufficient authorized funds are available 4. **State Update**: Reduces `capturableAmount` and increases `refundableAmount` by captured `amount` -5. **Fee Calculation**: Calculates fee amount based on basis points +5. **Fee Bounds Check**: Confirms the provided absolute `feeAmount` falls within the bounds derived from `minFeeBps`/`maxFeeBps` 6. **Token Distribution**: Transfers fee to recipient and remaining amount to merchant 7. **Event Emission**: Emits `PaymentCaptured` for tracking ### Key Validations - Must be called before `paymentInfo.authorizationExpiry` - `amount` cannot exceed available `capturableAmount` -- `feeBps` must be within `[paymentInfo.minFeeBps, paymentInfo.maxFeeBps]` range +- `feeAmount` must be within `[amount * minFeeBps / 10000, amount * maxFeeBps / 10000]` - `feeReceiver` must match `paymentInfo.feeReceiver` if specified ## Parameters @@ -48,7 +48,7 @@ function capture( |-----------|------|-------------| | `paymentInfo` | `PaymentInfo` | Original payment configuration | | `amount` | `uint256` | Amount to capture from escrow | -| `feeBps` | `uint16` | Fee percentage in basis points (0-10000) | +| `feeAmount` | `uint256` | Absolute fee in raw token units (must fall within the payer-approved bounds derived from `minFeeBps`/`maxFeeBps`) | | `feeReceiver` | `address` | Address to receive fee portion | ## Access Control @@ -83,7 +83,7 @@ PaymentState { event PaymentCaptured( bytes32 indexed paymentInfoHash, uint256 amount, - uint16 feeBps, + uint256 feeAmount, address feeReceiver ); ``` @@ -97,7 +97,7 @@ Track captures to monitor payment settlement and fee distribution. | `InvalidSender` | Caller is not the designated operator | | `ZeroAmount` | Attempting to capture zero amount | | `AmountOverflow` | Amount exceeds uint120 maximum | -| `FeeBpsOutOfRange` | Fee outside min/max range | +| `FeeAmountOutOfRange` | `feeAmount` outside the bounds derived from min/max fee bps | | `ZeroFeeReceiver` | Fee recipient is zero address with non-zero fee | | `InvalidFeeReceiver` | Fee recipient doesn't match payment configuration | | `AfterAuthorizationExpiry` | Called after authorization expired | diff --git a/docs/operations/Charge.md b/docs/operations/Charge.md index 5294490..a48f187 100644 --- a/docs/operations/Charge.md +++ b/docs/operations/Charge.md @@ -24,7 +24,7 @@ function charge( uint256 amount, address tokenCollector, bytes calldata collectorData, - uint16 feeBps, + uint256 feeAmount, address feeReceiver ) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount) ``` @@ -46,7 +46,7 @@ function charge( | `amount` | `uint256` | Amount to charge (must be ≤ maxAmount) | | `tokenCollector` | `address` | Contract that will pull tokens from payer | | `collectorData` | `bytes` | Data passed to token collector | -| `feeBps` | `uint16` | Fee percentage in basis points | +| `feeAmount` | `uint256` | Absolute fee in raw token units (must fall within the payer-approved bounds derived from `minFeeBps`/`maxFeeBps`) | | `feeReceiver` | `address` | Address to receive fee portion | ## Access Control @@ -83,7 +83,7 @@ event PaymentCharged( PaymentInfo paymentInfo, uint256 amount, address tokenCollector, - uint16 feeBps, + uint256 feeAmount, address feeReceiver ); ``` @@ -100,9 +100,9 @@ The `PaymentCharged` event includes more details than `PaymentCaptured` since it | `ExceedsMaxAmount` | Amount exceeds paymentInfo.maxAmount | | `AfterPreApprovalExpiry` | Called after signature expiry | | `InvalidExpiries` | Expiry timestamps are improperly ordered | -| `FeeBpsOverFlow` | maxFeeBps exceeds maximum value | +| `FeeBpsOverflow` | maxFeeBps exceeds maximum value | | `InvalidFeeBpsRange` | minFeeBps exceeds maxFeeBps | -| `FeeBpsOutOfRange` | Fee outside min/max range | +| `FeeAmountOutOfRange` | `feeAmount` outside the bounds derived from min/max fee bps | | `ZeroFeeReceiver` | Fee recipient is zero address with non-zero fee | | `InvalidFeeReceiver` | Fee recipient doesn't match payment configuration | | `PaymentAlreadyCollected` | Payment already authorized or charged | From df1ce9415cd7b1f748a36eb71e0b8db1e01605dc Mon Sep 17 00:00:00 2001 From: Ruohan Date: Tue, 14 Jul 2026 14:26:47 -0700 Subject: [PATCH 08/12] forge fmt --- src/interfaces/IMulticall3.sol | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/interfaces/IMulticall3.sol b/src/interfaces/IMulticall3.sol index 57a27ea..f232db1 100644 --- a/src/interfaces/IMulticall3.sol +++ b/src/interfaces/IMulticall3.sol @@ -26,10 +26,7 @@ interface IMulticall3 { bytes returnData; } - function aggregate(Call[] calldata calls) - external - payable - returns (uint256 blockNumber, bytes[] memory returnData); + function aggregate(Call[] calldata calls) external payable returns (uint256 blockNumber, bytes[] memory returnData); function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); From eef397e6b85bcf394417f652dfb7647735dd6107 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Tue, 14 Jul 2026 15:28:07 -0700 Subject: [PATCH 09/12] ci: use base.publicnode.com to avoid rate limits on fork tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b73d43e..cb80d67 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ on: env: FOUNDRY_PROFILE: ci - BASE_RPC_URL: "https://mainnet.base.org" + BASE_RPC_URL: "https://base.publicnode.com" jobs: check: From b6e1478776aa8e1d4181a5b213b7e162ce499d53 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Wed, 15 Jul 2026 16:13:22 -0700 Subject: [PATCH 10/12] chore: re-run forge fmt (pinned v1.3.6) --- src/AuthCaptureEscrow.sol | 7 +++++-- src/collectors/ERC3009PaymentCollector.sol | 3 +-- src/collectors/Permit2PaymentCollector.sol | 4 +--- src/interfaces/IMulticall3.sol | 5 ++++- test/base/AuthCaptureEscrowBase.sol | 5 ++--- test/base/AuthCaptureEscrowSmartWalletBase.sol | 6 +++++- test/src/PaymentEscrow/authorize.t.sol | 4 +++- .../collectors/SpendPermissionPaymentCollector.t.sol | 12 +++++++++--- 8 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/AuthCaptureEscrow.sol b/src/AuthCaptureEscrow.sol index 1845ede..b2fc077 100644 --- a/src/AuthCaptureEscrow.sol +++ b/src/AuthCaptureEscrow.sol @@ -392,7 +392,9 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { /// @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) + implementation: tokenStoreImplementation, + salt: bytes32(bytes20(operator)), + deployer: address(this) }); } @@ -438,7 +440,8 @@ contract AuthCaptureEscrow is ReentrancyGuardTransient { } else if (tokenStore.code.length == 0) { // Call failed from undeployed TokenStore, deploy and try again tokenStore = LibClone.cloneDeterministic({ - implementation: tokenStoreImplementation, salt: bytes32(bytes20(operator)) + implementation: tokenStoreImplementation, + salt: bytes32(bytes20(operator)) }); emit TokenStoreCreated(operator, tokenStore); TokenStore(tokenStore).sendTokens(token, recipient, amount); diff --git a/src/collectors/ERC3009PaymentCollector.sol b/src/collectors/ERC3009PaymentCollector.sol index a0a455d..f143868 100644 --- a/src/collectors/ERC3009PaymentCollector.sol +++ b/src/collectors/ERC3009PaymentCollector.sol @@ -39,8 +39,7 @@ contract ERC3009PaymentCollector is TokenCollector, ERC6492SignatureHandler { uint256 maxAmount = paymentInfo.maxAmount; // Pull tokens into this contract - IERC3009(token) - .receiveWithAuthorization({ + IERC3009(token).receiveWithAuthorization({ from: payer, to: address(this), value: maxAmount, diff --git a/src/collectors/Permit2PaymentCollector.sol b/src/collectors/Permit2PaymentCollector.sol index 7cb604d..e4ced64 100644 --- a/src/collectors/Permit2PaymentCollector.sol +++ b/src/collectors/Permit2PaymentCollector.sol @@ -42,9 +42,7 @@ contract Permit2PaymentCollector is TokenCollector, ERC6492SignatureHandler { ) internal override { permit2.permitTransferFrom({ permit: ISignatureTransfer.PermitTransferFrom({ - permitted: ISignatureTransfer.TokenPermissions({ - token: paymentInfo.token, amount: paymentInfo.maxAmount - }), + permitted: ISignatureTransfer.TokenPermissions({token: paymentInfo.token, amount: paymentInfo.maxAmount}), nonce: uint256(_getHashPayerAgnostic(paymentInfo)), deadline: paymentInfo.preApprovalExpiry }), diff --git a/src/interfaces/IMulticall3.sol b/src/interfaces/IMulticall3.sol index f232db1..57a27ea 100644 --- a/src/interfaces/IMulticall3.sol +++ b/src/interfaces/IMulticall3.sol @@ -26,7 +26,10 @@ interface IMulticall3 { bytes returnData; } - function aggregate(Call[] calldata calls) external payable returns (uint256 blockNumber, bytes[] memory returnData); + function aggregate(Call[] calldata calls) + external + payable + returns (uint256 blockNumber, bytes[] memory returnData); function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); diff --git a/test/base/AuthCaptureEscrowBase.sol b/test/base/AuthCaptureEscrowBase.sol index 304889e..411d80c 100644 --- a/test/base/AuthCaptureEscrowBase.sol +++ b/test/base/AuthCaptureEscrowBase.sol @@ -161,9 +161,8 @@ contract AuthCaptureEscrowBase is Test, DeployPermit2 { uint256 validBefore, bytes32 nonce ) internal view returns (bytes32) { - bytes32 structHash = keccak256( - abi.encode(_RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) - ); + bytes32 structHash = + keccak256(abi.encode(_RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)); return keccak256(abi.encodePacked("\x19\x01", IERC3009(token).DOMAIN_SEPARATOR(), structHash)); } diff --git a/test/base/AuthCaptureEscrowSmartWalletBase.sol b/test/base/AuthCaptureEscrowSmartWalletBase.sol index ab9f00d..4da0435 100644 --- a/test/base/AuthCaptureEscrowSmartWalletBase.sol +++ b/test/base/AuthCaptureEscrowSmartWalletBase.sol @@ -172,7 +172,11 @@ contract AuthCaptureEscrowSmartWalletBase is AuthCaptureEscrowBase { uint256 nonce = uint256(hashPortion); return MagicSpend.WithdrawRequest({ - asset: address(0), amount: 0, nonce: nonce, expiry: type(uint48).max, signature: new bytes(0) + asset: address(0), + amount: 0, + nonce: nonce, + expiry: type(uint48).max, + signature: new bytes(0) }); } diff --git a/test/src/PaymentEscrow/authorize.t.sol b/test/src/PaymentEscrow/authorize.t.sol index 60cc1e6..f24c56a 100644 --- a/test/src/PaymentEscrow/authorize.t.sol +++ b/test/src/PaymentEscrow/authorize.t.sol @@ -321,7 +321,9 @@ contract AuthorizeTest is AuthCaptureEscrowSmartWalletBase { vm.assume(maxAmount >= amount && amount > 0); mockERC3009Token.mint(address(smartWalletDeployed), amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({ - payer: address(smartWalletDeployed), maxAmount: maxAmount, token: address(mockERC3009Token) + payer: address(smartWalletDeployed), + maxAmount: maxAmount, + token: address(mockERC3009Token) }); // Create and sign the spend permission diff --git a/test/src/collectors/SpendPermissionPaymentCollector.t.sol b/test/src/collectors/SpendPermissionPaymentCollector.t.sol index 8a3c946..81c6c5d 100644 --- a/test/src/collectors/SpendPermissionPaymentCollector.t.sol +++ b/test/src/collectors/SpendPermissionPaymentCollector.t.sol @@ -25,7 +25,9 @@ contract SpendPermissionPaymentCollectorTest is AuthCaptureEscrowSmartWalletBase MockERC3009Token(address(mockERC3009Token)).mint(address(smartWalletDeployed), amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({ - payer: address(smartWalletDeployed), maxAmount: amount, token: address(mockERC3009Token) + payer: address(smartWalletDeployed), + maxAmount: amount, + token: address(mockERC3009Token) }); address tokenStore = authCaptureEscrow.getTokenStore(paymentInfo.operator); @@ -45,7 +47,9 @@ contract SpendPermissionPaymentCollectorTest is AuthCaptureEscrowSmartWalletBase MockERC3009Token(address(mockERC3009Token)).mint(address(smartWalletDeployed), amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({ - payer: address(smartWalletDeployed), maxAmount: amount, token: address(mockERC3009Token) + payer: address(smartWalletDeployed), + maxAmount: amount, + token: address(mockERC3009Token) }); address tokenStore = authCaptureEscrow.getTokenStore(paymentInfo.operator); @@ -65,7 +69,9 @@ contract SpendPermissionPaymentCollectorTest is AuthCaptureEscrowSmartWalletBase mockERC3009Token.mint(address(magicSpend), amount); AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo({ - payer: address(smartWalletDeployed), maxAmount: amount, token: address(mockERC3009Token) + payer: address(smartWalletDeployed), + maxAmount: amount, + token: address(mockERC3009Token) }); address tokenStore = authCaptureEscrow.getTokenStore(paymentInfo.operator); From 44573aa4879ab0fc1ab2ca550220a5df4cc5d240 Mon Sep 17 00:00:00 2001 From: Ruohan Date: Wed, 15 Jul 2026 16:16:33 -0700 Subject: [PATCH 11/12] test: address Steve's review feedback on fee test coverage Add happy-path tests for feeAmount at the min/max bps-derived bounds and for minFee=maxFee=0, a cent-aligned fee happy path for charge() (mirroring capture's), and fuzz tests over feeAmount for both capture and charge. Also rename test_charge_reverts_whenFeeAmountAboveMax to test_reverts_whenFeeAmountAboveMax for naming consistency. --- test/src/PaymentEscrow/capture.t.sol | 107 +++++++++++++++++++++++++++ test/src/PaymentEscrow/charge.t.sol | 52 ++++++++++++- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/test/src/PaymentEscrow/capture.t.sol b/test/src/PaymentEscrow/capture.t.sol index 8ecbe85..4d778ff 100644 --- a/test/src/PaymentEscrow/capture.t.sol +++ b/test/src/PaymentEscrow/capture.t.sol @@ -498,4 +498,111 @@ contract CaptureTest is AuthCaptureEscrowBase { assertEq(mockERC3009Token.balanceOf(feeReceiver), centAlignedFee); assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount - centAlignedFee); } + + function test_succeeds_whenFeeAmountEqualsMin(uint120 authorizedAmount, uint16 minFeeBps, uint16 maxFeeBps) + public + { + vm.assume(authorizedAmount > 0); + vm.assume(maxFeeBps <= 10_000); + vm.assume(minFeeBps <= maxFeeBps); + + uint256 minFee = _feeAmount(authorizedAmount, minFeeBps); + + mockERC3009Token.mint(payerEOA, authorizedAmount); + + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, authorizedAmount); + paymentInfo.minFeeBps = minFeeBps; + paymentInfo.maxFeeBps = maxFeeBps; + + bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + + vm.prank(paymentInfo.operator); + authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); + + vm.prank(operator); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, minFee, paymentInfo.feeReceiver); + + assertEq(mockERC3009Token.balanceOf(feeReceiver), minFee); + assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount - minFee); + } + + function test_succeeds_whenFeeAmountEqualsMax(uint120 authorizedAmount, uint16 minFeeBps, uint16 maxFeeBps) + public + { + vm.assume(authorizedAmount > 0); + vm.assume(maxFeeBps <= 10_000); + vm.assume(minFeeBps <= maxFeeBps); + + uint256 maxFee = _feeAmount(authorizedAmount, maxFeeBps); + + mockERC3009Token.mint(payerEOA, authorizedAmount); + + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, authorizedAmount); + paymentInfo.minFeeBps = minFeeBps; + paymentInfo.maxFeeBps = maxFeeBps; + + bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + + vm.prank(paymentInfo.operator); + authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); + + vm.prank(operator); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, maxFee, paymentInfo.feeReceiver); + + assertEq(mockERC3009Token.balanceOf(feeReceiver), maxFee); + assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount - maxFee); + } + + function test_succeeds_whenMinFeeAndMaxFeeAreZero(uint120 authorizedAmount) public { + vm.assume(authorizedAmount > 0); + + mockERC3009Token.mint(payerEOA, authorizedAmount); + + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, authorizedAmount); + paymentInfo.minFeeBps = 0; + paymentInfo.maxFeeBps = 0; + + bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + + vm.prank(paymentInfo.operator); + authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); + + vm.prank(operator); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, 0, paymentInfo.feeReceiver); + + assertEq(mockERC3009Token.balanceOf(feeReceiver), 0); + assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount); + } + + function test_succeeds_withFuzzedFeeAmount( + uint120 authorizedAmount, + uint16 minFeeBps, + uint16 maxFeeBps, + uint256 feeAmount + ) public { + vm.assume(authorizedAmount > 0); + vm.assume(maxFeeBps <= 10_000); + vm.assume(minFeeBps <= maxFeeBps); + + uint256 minFee = _feeAmount(authorizedAmount, minFeeBps); + uint256 maxFee = _feeAmount(authorizedAmount, maxFeeBps); + feeAmount = bound(feeAmount, minFee, maxFee); + + mockERC3009Token.mint(payerEOA, authorizedAmount); + + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, authorizedAmount); + paymentInfo.minFeeBps = minFeeBps; + paymentInfo.maxFeeBps = maxFeeBps; + + bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + + vm.prank(paymentInfo.operator); + authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); + + vm.prank(operator); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, feeAmount, paymentInfo.feeReceiver); + + assertEq(mockERC3009Token.balanceOf(feeReceiver), feeAmount); + assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount - feeAmount); + } } diff --git a/test/src/PaymentEscrow/charge.t.sol b/test/src/PaymentEscrow/charge.t.sol index a6a0e21..19173da 100644 --- a/test/src/PaymentEscrow/charge.t.sol +++ b/test/src/PaymentEscrow/charge.t.sol @@ -389,7 +389,7 @@ contract ChargeTest is AuthCaptureEscrowBase { ); } - function test_charge_reverts_whenFeeAmountAboveMax(uint120 amount, uint16 minFeeBps, uint16 maxFeeBps) public { + function test_reverts_whenFeeAmountAboveMax(uint120 amount, uint16 minFeeBps, uint16 maxFeeBps) public { vm.assume(amount > 0); vm.assume(maxFeeBps < 10000); vm.assume(minFeeBps <= maxFeeBps); @@ -479,4 +479,54 @@ contract ChargeTest is AuthCaptureEscrowBase { assertEq(mockERC3009Token.balanceOf(newFeeRecipient), captureFeeAmount); assertEq(mockERC3009Token.balanceOf(receiver), amount - captureFeeAmount); } + + /// @dev Charge $54.23 with 3% + $0.30 flat fee rounded to $1.93 (cent-aligned in 6-decimal USDC) + function test_succeeds_withCentAlignedFeeAmount() public { + uint120 amount = 54_230_000; // $54.23 + uint256 centAlignedFee = 1_930_000; // $1.93 + + mockERC3009Token.mint(payerEOA, amount); + + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, amount); + paymentInfo.minFeeBps = 300; // 3% lower bound + paymentInfo.maxFeeBps = 400; // 4% upper bound accommodates 3% + $0.30 flat + + bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + + vm.prank(operator); + authCaptureEscrow.charge( + paymentInfo, amount, address(erc3009PaymentCollector), signature, centAlignedFee, paymentInfo.feeReceiver + ); + + assertEq(mockERC3009Token.balanceOf(feeReceiver), centAlignedFee); + assertEq(mockERC3009Token.balanceOf(receiver), amount - centAlignedFee); + } + + function test_succeeds_withFuzzedFeeAmount(uint120 amount, uint16 minFeeBps, uint16 maxFeeBps, uint256 feeAmount) + public + { + vm.assume(amount > 0); + vm.assume(maxFeeBps <= 10_000); + vm.assume(minFeeBps <= maxFeeBps); + + uint256 minFee = _feeAmount(amount, minFeeBps); + uint256 maxFee = _feeAmount(amount, maxFeeBps); + feeAmount = bound(feeAmount, minFee, maxFee); + + mockERC3009Token.mint(payerEOA, amount); + + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, amount); + paymentInfo.minFeeBps = minFeeBps; + paymentInfo.maxFeeBps = maxFeeBps; + + bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + + vm.prank(operator); + authCaptureEscrow.charge( + paymentInfo, amount, address(erc3009PaymentCollector), signature, feeAmount, paymentInfo.feeReceiver + ); + + assertEq(mockERC3009Token.balanceOf(feeReceiver), feeAmount); + assertEq(mockERC3009Token.balanceOf(receiver), amount - feeAmount); + } } From cf7ccab0caedf994102736dd6e3a44fc55dec5ab Mon Sep 17 00:00:00 2001 From: Ruohan Date: Wed, 15 Jul 2026 16:44:19 -0700 Subject: [PATCH 12/12] test: cover feeAmount>0 revert when minFee=maxFee=0 Complements the existing zero-fee happy path with the deterministic revert case at the same boundary, since the fuzzed above-max test only hits minFeeBps=maxFeeBps=0 probabilistically. --- test/src/PaymentEscrow/capture.t.sol | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/src/PaymentEscrow/capture.t.sol b/test/src/PaymentEscrow/capture.t.sol index 4d778ff..f2cb2d5 100644 --- a/test/src/PaymentEscrow/capture.t.sol +++ b/test/src/PaymentEscrow/capture.t.sol @@ -574,6 +574,28 @@ contract CaptureTest is AuthCaptureEscrowBase { assertEq(mockERC3009Token.balanceOf(receiver), authorizedAmount); } + function test_reverts_whenFeeAmountNonZero_withMinFeeAndMaxFeeZero(uint120 authorizedAmount, uint256 feeAmount) + public + { + vm.assume(authorizedAmount > 0); + feeAmount = bound(feeAmount, 1, type(uint256).max); + + mockERC3009Token.mint(payerEOA, authorizedAmount); + + AuthCaptureEscrow.PaymentInfo memory paymentInfo = _createPaymentInfo(payerEOA, authorizedAmount); + paymentInfo.minFeeBps = 0; + paymentInfo.maxFeeBps = 0; + + bytes memory signature = _signERC3009ReceiveWithAuthorizationStruct(paymentInfo, payer_EOA_PK); + + vm.prank(paymentInfo.operator); + authCaptureEscrow.authorize(paymentInfo, authorizedAmount, address(erc3009PaymentCollector), signature); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(AuthCaptureEscrow.FeeAmountOutOfRange.selector, feeAmount, 0, 0)); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, feeAmount, paymentInfo.feeReceiver); + } + function test_succeeds_withFuzzedFeeAmount( uint120 authorizedAmount, uint16 minFeeBps,