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: 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 | 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 9f374cf..b2fc077 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,23 @@ 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 { - uint16 minFeeBps = paymentInfo.minFeeBps; - uint16 maxFeeBps = paymentInfo.maxFeeBps; + function _validateFee(PaymentInfo calldata paymentInfo, uint256 amount, uint256 feeAmount, address feeReceiver) + internal + pure + { address configuredFeeReceiver = paymentInfo.feeReceiver; - // Check fee bps within [min, max] - if (feeBps < minFeeBps || feeBps > maxFeeBps) revert FeeBpsOutOfRange(feeBps, minFeeBps, maxFeeBps); + uint256 minFee = amount * paymentInfo.minFeeBps / _MAX_FEE_BPS; + uint256 maxFee = amount * paymentInfo.maxFeeBps / _MAX_FEE_BPS; + + // Check fee amount within payer-approved bounds + if (feeAmount < minFee || feeAmount > maxFee) revert FeeAmountOutOfRange(feeAmount, minFee, maxFee); - // Check fee recipient only zero address if zero fee 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) { 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..c56d742 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 = uint256(BENCHMARK_AMOUNT) * uint256(BENCHMARK_FEE_BPS) / 10_000; AuthCaptureEscrow.PaymentInfo internal paymentInfo; bytes internal signature; @@ -22,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, BENCHMARK_FEE_BPS, 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 @@ -45,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_BPS, feeReceiver + paymentInfo, + BENCHMARK_AMOUNT, + address(erc3009PaymentCollector), + signature, + BENCHMARK_FEE_AMOUNT, + feeReceiver ); } } @@ -61,7 +69,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 +114,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..f2cb2d5 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, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); } function test_reverts_whenValueIsZero() public { @@ -33,7 +35,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 +47,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 +78,9 @@ 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 +106,9 @@ 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 +129,9 @@ 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 +152,9 @@ 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 +181,9 @@ 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 +209,15 @@ 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 +251,25 @@ 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( - uint120 authorizedAmount, - uint16 minFeeBps, - uint16 maxFeeBps, - uint16 captureFeeBps - ) public { - // Assume reasonable bounds for fees + function test_reverts_whenFeeAmountBelowMin(uint120 authorizedAmount, uint16 minFeeBps, uint16 maxFeeBps) public { 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 +284,20 @@ 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( - uint120 authorizedAmount, - uint16 minFeeBps, - uint16 maxFeeBps, - uint16 captureFeeBps - ) public { - // Assume reasonable bounds for fees + function test_reverts_whenFeeAmountAboveMax(uint120 authorizedAmount, uint16 minFeeBps, uint16 maxFeeBps) public { 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 +312,9 @@ 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 +324,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 +331,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 +349,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 +372,9 @@ 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 +392,7 @@ contract CaptureTest is AuthCaptureEscrowBase { authorizedAmount, address(erc3009PaymentCollector), initialSignature, - initialPaymentInfo.minFeeBps, + _feeAmount(authorizedAmount, FEE_BPS), initialPaymentInfo.feeReceiver ); @@ -397,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, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); } @@ -418,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, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); } @@ -447,11 +467,164 @@ 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, centAlignedFee, paymentInfo.feeReceiver); + + 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, captureFeeBps, newFeeRecipient); + authCaptureEscrow.capture(paymentInfo, authorizedAmount, 0, paymentInfo.feeReceiver); + + assertEq(mockERC3009Token.balanceOf(feeReceiver), 0); + 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); - uint256 feeAmount = (uint256(authorizedAmount) * uint256(captureFeeBps)) / 10_000; - assertEq(mockERC3009Token.balanceOf(newFeeRecipient), feeAmount); + 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, + 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 8a59a23..19173da 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(1, 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,22 @@ 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_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 +408,10 @@ 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 +422,14 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.assume(minFeeBps > 0); vm.assume(maxFeeBps >= minFeeBps && maxFeeBps <= 10000); + 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)}); - paymentInfo.feeReceiver = address(0); // Allow operator to set fee recipient + paymentInfo.feeReceiver = address(0); paymentInfo.minFeeBps = minFeeBps; paymentInfo.maxFeeBps = maxFeeBps; @@ -434,7 +438,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 +449,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,7 +463,59 @@ 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, captureFeeAmount, newFeeRecipient + ); + + 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; @@ -469,11 +523,10 @@ contract ChargeTest is AuthCaptureEscrowBase { vm.prank(operator); authCaptureEscrow.charge( - paymentInfo, amount, address(erc3009PaymentCollector), signature, captureFeeBps, newFeeRecipient + paymentInfo, amount, address(erc3009PaymentCollector), signature, feeAmount, paymentInfo.feeReceiver ); - uint256 feeAmount = (uint256(amount) * uint256(captureFeeBps)) / 10_000; - assertEq(mockERC3009Token.balanceOf(newFeeRecipient), feeAmount); + assertEq(mockERC3009Token.balanceOf(feeReceiver), feeAmount); assertEq(mockERC3009Token.balanceOf(receiver), amount - feeAmount); } } 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..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, paymentInfo.minFeeBps, 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, paymentInfo.minFeeBps, 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, paymentInfo.minFeeBps, paymentInfo.feeReceiver); + authCaptureEscrow.capture( + paymentInfo, authorizedAmount, _feeAmount(authorizedAmount, FEE_BPS), paymentInfo.feeReceiver + ); vm.stopPrank(); // Fund operator for refund