From 7a627638076bdae850a591baaa838ab3f2852d2f Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Fri, 15 Aug 2025 10:36:58 +0530 Subject: [PATCH 01/23] feat: add ERC-3009 support, modify DepositRecorded to support various auth types --- src/Payments.sol | 110 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index 98b6bb9f..9f34cf3d 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -33,6 +33,32 @@ interface IValidator { function railTerminated(uint256 railId, address terminator, uint256 endEpoch) external; } +interface IERC3009 { + /** + * @notice Execute a transfer with a signed authorization + * @param from Payer's address (Authorizer) + * @param to Payee's address + * @param value Amount to be transferred + * @param validAfter The time after which this is valid (unix time) + * @param validBefore The time before which this is valid (unix time) + * @param nonce Unique nonce + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} + // @title Payments contract. contract Payments is ReentrancyGuard { using SafeERC20 for IERC20; @@ -44,6 +70,12 @@ contract Payments is ReentrancyGuard { uint256 public constant NETWORK_FEE = 1300000 gwei; // equivalent to 130000 nFIL address payable private constant BURN_ADDRESS = payable(0xff00000000000000000000000000000000000063); + enum AuthType { + None, + Permit, + Authorization + } + // Events event AccountLockupSettled( address indexed token, @@ -92,7 +124,12 @@ contract Payments is ReentrancyGuard { event RailFinalized(uint256 indexed railId); event DepositRecorded( - address indexed token, address indexed from, address indexed to, uint256 amount, bool usedPermit + address indexed token, + address indexed from, + address indexed to, + uint256 amount, + AuthType authType, + bytes32 nonce ); event WithdrawRecorded(address indexed token, address indexed from, address indexed to, uint256 amount); @@ -475,7 +512,7 @@ contract Payments is ReentrancyGuard { account.funds += actualAmount; - emit DepositRecorded(token, msg.sender, to, actualAmount, false); + emit DepositRecorded(token, msg.sender, to, actualAmount, AuthType.None, bytes32(0)); } /** @@ -524,7 +561,7 @@ contract Payments is ReentrancyGuard { account.funds += actualAmount; - emit DepositRecorded(token, to, to, actualAmount, true); + emit DepositRecorded(token, to, to, actualAmount, AuthType.Permit, bytes32(0)); } /** @@ -607,6 +644,73 @@ contract Payments is ReentrancyGuard { _depositWithPermit(token, to, amount, deadline, v, r, s); } + /** + * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. + * @dev This allows a third party to submit a pre-signed transfer authorization to deposit tokens on behalf of a user. + * @param token The ERC-20 token address to deposit. Must conform to ERC-3009. + * @param from The address authorizing the transfer (the owner of the funds). + * @param to The address whose account within the contract will be credited. + * @param amount The amount of tokens to deposit. + * @param validAfter The timestamp after which the authorization is valid. + * @param validBefore The timestamp before which the authorization is valid. + * @param nonce A unique nonce for the authorization, used to prevent replay attacks. + * @param v,r,s The signature of the authorization. + */ + function depositWithAuthorization( + address token, + address from, + address to, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) + external + nonReentrant + validateNonZeroAddress(from, "from") + validateNonZeroAddress(to, "to") + settleAccountLockupBeforeAndAfter(token, to, false) + { + _depositWithAuthorization(token, from, to, amount, validAfter, validBefore, nonce, v, r, s); + } + + function _depositWithAuthorization( + address token, + address from, + address to, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) internal { + // Revert if token is address(0) as authorization is not supported for native tokens + require(token != address(0), Errors.NativeTokenNotSupported()); + + // Use balance-before/balance-after accounting to correctly handle fee-on-transfer tokens + uint256 balanceBefore = IERC20(token).balanceOf(address(this)); + + // Call ERC-3009 transferWithAuthorization. + // This will transfer 'amount' from 'from' to this contract. + // The token contract itself verifies the signature. + IERC3009(token).transferWithAuthorization(from, address(this), amount, validAfter, validBefore, nonce, v, r, s); + + uint256 balanceAfter = IERC20(token).balanceOf(address(this)); + uint256 actualAmount = balanceAfter - balanceBefore; + + // Credit the beneficiary's internal account + Account storage account = accounts[token][to]; + account.funds += actualAmount; + + // Emit an event to record the deposit, marking it as made via an off-chain signature. + emit DepositRecorded(token, from, to, actualAmount, AuthType.Authorization, nonce); + } + /// @notice Withdraws tokens from the caller's account to the caller's account, up to the amount of currently available tokens (the tokens not currently locked in rails). /// @param token The ERC20 token address to withdraw. /// @param amount The amount of tokens to withdraw. From 8970f8df010d6ed0de8cd078bb1d35a9736ea650 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 16 Aug 2025 18:44:19 +0530 Subject: [PATCH 02/23] refactor: generalize error to SignerMustBeMsgSender for self-recipient enforcement --- src/Errors.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Errors.sol b/src/Errors.sol index 0ace608b..356dc343 100644 --- a/src/Errors.sol +++ b/src/Errors.sol @@ -286,8 +286,9 @@ library Errors { /// @param sent The amount of native token sent with the transaction error InsufficientNativeTokenForBurn(uint256 required, uint256 sent); - /// @notice The 'to' address in permit functions must be the message sender + /// @notice The 'to' address must equal the transaction sender (self-recipient enforcement) + /// @dev Used by flows like permit and transfer-with-authorization to ensure only self-deposits /// @param expected The expected address (msg.sender) /// @param actual The actual 'to' address provided - error PermitRecipientMustBeMsgSender(address expected, address actual); + error SignerMustBeMsgSender(address expected, address actual); } From a8a76efb6fbe97c038607dcc50bb37e51d93043e Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 16 Aug 2025 19:20:40 +0530 Subject: [PATCH 03/23] refactor: replace PermitRecipientMustBeMsgSender check with SignerMustBeMsgSender and remove from arg from depositWithAuthorization --- src/Payments.sol | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index 9f34cf3d..61cb9b28 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -57,6 +57,8 @@ interface IERC3009 { bytes32 r, bytes32 s ) external; + + function authorizationState(address user, bytes32 nonce) external view returns (bool used); } // @title Payments contract. @@ -262,8 +264,8 @@ contract Payments is ReentrancyGuard { _; } - modifier validatePermitRecipient(address to) { - require(to == msg.sender, Errors.PermitRecipientMustBeMsgSender(msg.sender, to)); + modifier validateSignerIsRecipient(address to) { + require(to == msg.sender, Errors.SignerMustBeMsgSender(msg.sender, to)); _; } @@ -531,7 +533,12 @@ contract Payments is ReentrancyGuard { uint8 v, bytes32 r, bytes32 s - ) external nonReentrant validateNonZeroAddress(to, "to") settleAccountLockupBeforeAndAfter(token, to, false) { + ) + external + nonReentrant + validateNonZeroAddress(to, "to") + settleAccountLockupBeforeAndAfter(token, to, false) + { _depositWithPermit(token, to, amount, deadline, v, r, s); } @@ -600,7 +607,7 @@ contract Payments is ReentrancyGuard { nonReentrant validateNonZeroAddress(operator, "operator") validateNonZeroAddress(to, "to") - validatePermitRecipient(to) + validateSignerIsRecipient(to) settleAccountLockupBeforeAndAfter(token, to, false) { _setOperatorApproval(token, operator, true, rateAllowance, lockupAllowance, maxLockupPeriod); @@ -637,7 +644,7 @@ contract Payments is ReentrancyGuard { nonReentrant validateNonZeroAddress(operator, "operator") validateNonZeroAddress(to, "to") - validatePermitRecipient(to) + validateSignerIsRecipient(to) settleAccountLockupBeforeAndAfter(token, to, false) { _increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease); @@ -648,7 +655,6 @@ contract Payments is ReentrancyGuard { * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. * @dev This allows a third party to submit a pre-signed transfer authorization to deposit tokens on behalf of a user. * @param token The ERC-20 token address to deposit. Must conform to ERC-3009. - * @param from The address authorizing the transfer (the owner of the funds). * @param to The address whose account within the contract will be credited. * @param amount The amount of tokens to deposit. * @param validAfter The timestamp after which the authorization is valid. @@ -658,7 +664,6 @@ contract Payments is ReentrancyGuard { */ function depositWithAuthorization( address token, - address from, address to, uint256 amount, uint256 validAfter, @@ -670,16 +675,15 @@ contract Payments is ReentrancyGuard { ) external nonReentrant - validateNonZeroAddress(from, "from") validateNonZeroAddress(to, "to") + validateSignerIsRecipient(to) settleAccountLockupBeforeAndAfter(token, to, false) { - _depositWithAuthorization(token, from, to, amount, validAfter, validBefore, nonce, v, r, s); + _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); } function _depositWithAuthorization( address token, - address from, address to, uint256 amount, uint256 validAfter, @@ -696,9 +700,9 @@ contract Payments is ReentrancyGuard { uint256 balanceBefore = IERC20(token).balanceOf(address(this)); // Call ERC-3009 transferWithAuthorization. - // This will transfer 'amount' from 'from' to this contract. + // This will transfer 'amount' from 'to' to this contract. // The token contract itself verifies the signature. - IERC3009(token).transferWithAuthorization(from, address(this), amount, validAfter, validBefore, nonce, v, r, s); + IERC3009(token).transferWithAuthorization(to, address(this), amount, validAfter, validBefore, nonce, v, r, s); uint256 balanceAfter = IERC20(token).balanceOf(address(this)); uint256 actualAmount = balanceAfter - balanceBefore; @@ -708,7 +712,7 @@ contract Payments is ReentrancyGuard { account.funds += actualAmount; // Emit an event to record the deposit, marking it as made via an off-chain signature. - emit DepositRecorded(token, from, to, actualAmount, AuthType.Authorization, nonce); + emit DepositRecorded(token, to, to, actualAmount, AuthType.Authorization, nonce); } /// @notice Withdraws tokens from the caller's account to the caller's account, up to the amount of currently available tokens (the tokens not currently locked in rails). From eb197643bc532a43138cccb405563799c1be4a22 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 16 Aug 2025 19:21:27 +0530 Subject: [PATCH 04/23] feat: add ERC-3009 support to Mock ERC-20 --- test/mocks/MockERC20.sol | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index 37491adb..0642ed5b 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -2,12 +2,74 @@ pragma solidity ^0.8.27; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +/** + * @title MockERC20 + * @dev A mock ERC20 token with permit (ERC-2612) and transferWithAuthorization (ERC-3009) functionality for testing purposes. + */ contract MockERC20 is ERC20Permit { + // --- ERC-3009 State and Constants --- + mapping(address => mapping(bytes32 => bool)) private _authorizationStates; + + bytes32 private constant _TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256( + "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + + // --- ERC-3009 Event --- + event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); + constructor(string memory name, string memory symbol) ERC20(name, symbol) ERC20Permit(name) {} // Mint tokens for testing function mint(address to, uint256 amount) public { _mint(to, amount); } + + // --- ERC-3009 Implementation --- + + /** + * @notice Execute a transfer with a signed authorization + * @param from Payer's address (Authorizer) + * @param to Payee's address + * @param value Amount to be transferred + * @param validAfter The time after which this is valid (unix time) + * @param validBefore The time before which this is valid (unix time) + * @param nonce Unique nonce + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(block.timestamp > validAfter, "EIP3009: authorization not yet valid"); + require(block.timestamp < validBefore, "EIP3009: authorization expired"); + require(!_authorizationStates[from][nonce], "EIP3009: authorization already used"); + + bytes32 structHash = keccak256( + abi.encode(_TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ); + + bytes32 digest = EIP712._hashTypedDataV4(structHash); + address signer = ECDSA.recover(digest, v, r, s); + require(signer == from, "Invalid signature"); + + _authorizationStates[from][nonce] = true; + emit AuthorizationUsed(from, nonce); + + _transfer(from, to, value); + } + + function authorizationState(address authorizer, bytes32 nonce) external view returns (bool) { + return _authorizationStates[authorizer][nonce]; + } } From 16c056749ec361f75d92596da0ced5b4ef962cec Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 16 Aug 2025 19:22:21 +0530 Subject: [PATCH 05/23] refactor: updated error selector for PermitRecipient --- test/PaymentsEvents.t.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/PaymentsEvents.t.sol b/test/PaymentsEvents.t.sol index 37563fb8..02d39d34 100644 --- a/test/PaymentsEvents.t.sol +++ b/test/PaymentsEvents.t.sol @@ -309,7 +309,7 @@ contract PaymentsEventsTest is Test, BaseTestHelper { // Only check the first three indexed parameters vm.expectEmit(true, true, true, true); emit Payments.AccountLockupSettled(address(testToken), USER2, 0, 0, block.number); - emit Payments.DepositRecorded(address(testToken), USER1, USER2, 10 ether, false); // Amount not checked + emit Payments.DepositRecorded(address(testToken), USER1, USER2, 10 ether, Payments.AuthType.None, bytes32(0)); // Amount not checked // Deposit tokens payments.deposit(address(testToken), USER2, 10 ether); @@ -336,7 +336,9 @@ contract PaymentsEventsTest is Test, BaseTestHelper { // Expect the event to be emitted vm.expectEmit(true, true, false, true); emit Payments.AccountLockupSettled(address(testToken), signer, 0, 0, block.number); - emit Payments.DepositRecorded(address(testToken), signer, signer, depositAmount, true); + emit Payments.DepositRecorded( + address(testToken), signer, signer, depositAmount, Payments.AuthType.Permit, bytes32(0) + ); // Deposit with permit payments.depositWithPermit(address(testToken), signer, depositAmount, deadline, v, r, s); From 1a0733ae42dca8bdccfb1eff032c8799c83d47c7 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 16 Aug 2025 19:31:37 +0530 Subject: [PATCH 06/23] test: add tests for deposit with authorization --- test/DepositWithAuthorization.t.sol | 268 +++++++++++++++++++++++++++ test/helpers/PaymentsTestHelpers.sol | 53 +++++- 2 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 test/DepositWithAuthorization.t.sol diff --git a/test/DepositWithAuthorization.t.sol b/test/DepositWithAuthorization.t.sol new file mode 100644 index 00000000..0a369b1f --- /dev/null +++ b/test/DepositWithAuthorization.t.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {Payments, IERC3009} from "../src/Payments.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {PaymentsTestHelpers} from "./helpers/PaymentsTestHelpers.sol"; +import {BaseTestHelper} from "./helpers/BaseTestHelper.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {console} from "forge-std/console.sol"; +import {Errors} from "../src/Errors.sol"; + +contract DepositWithAuthorization is Test, BaseTestHelper { + MockERC20 testToken; + PaymentsTestHelpers helper; + Payments payments; + + uint256 constant DEPOSIT_AMOUNT = 1000 ether; + uint256 constant RATE_ALLOWANCE = 100 ether; + uint256 constant LOCKUP_ALLOWANCE = 1000 ether; + uint256 constant MAX_LOCKUP_PERIOD = 100; + uint256 internal constant INITIAL_BALANCE = 1000 ether; + + function setUp() public { + helper = new PaymentsTestHelpers(); + helper.setupStandardTestEnvironment(); + payments = helper.payments(); + + testToken = helper.testToken(); + } + + function testDepositWithAuthorization_HappyPath() public { + uint256 fromPrivateKey = user1Sk; + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validForSeconds = 60; + uint256 amount = DEPOSIT_AMOUNT; + + // Windows + uint256 validAfter = 0; // valid immediately + uint256 validBefore = block.timestamp + validForSeconds; + + // Nonce: generate a unique bytes32 per authorization + // For tests you can make it deterministic: + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + // Pre-state capture + uint256 fromBalanceBefore = helper._balanceOf(from, false); + uint256 paymentsBalanceBefore = helper._balanceOf(address(payments), false); + Payments.Account memory toAccountBefore = helper._getAccountData(to, false); + + // Build signature + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + fromPrivateKey, + address(testToken), + from, + address(payments), // pay to Payments contract which will credit 'to' + amount, + validAfter, + validBefore, + nonce + ); + + // Execute deposit via authorization + vm.startPrank(from); + + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + + vm.stopPrank(); + + // Post-state capture + uint256 fromBalanceAfter = helper._balanceOf(from, false); + uint256 paymentsBalanceAfter = helper._balanceOf(address(payments), false); + Payments.Account memory toAccountAfter = helper._getAccountData(from, false); + + // Assertions + helper._assertDepositBalances( + fromBalanceBefore, + fromBalanceAfter, + paymentsBalanceBefore, + paymentsBalanceAfter, + toAccountBefore, + toAccountAfter, + amount + ); + + // Verify authorization is consumed on the token + bool used = IERC3009(address(testToken)).authorizationState(from, nonce); + assertTrue(used); + } + + function testDepositWithAuthorization_Revert_ReplayNonceUsed() public { + uint256 fromPrivateKey = user1Sk; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validForSeconds = 60; + + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + validForSeconds; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + fromPrivateKey, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + // Second attempt with same nonce must revert + vm.expectRevert("EIP3009: authorization already used"); + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + vm.stopPrank(); + } + + function testDepositWithAuthorization_Revert_InvalidSignature_WrongSigner() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 60; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + // Generate signature with a different private key + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user2Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + vm.expectRevert("Invalid signature"); + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + vm.stopPrank(); + } + + function testDepositWithAuthorization_Revert_InvalidSignature_Corrupted() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 60; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // Corrupt r + r = bytes32(uint256(r) ^ 1); + + vm.startPrank(from); + vm.expectRevert("ECDSAInvalidSignature()"); // invalid signature should revert + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + vm.stopPrank(); + } + + function testDepositWithAuthorization_Revert_ExpiredAuthorization() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 1; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // advance beyond validBefore + vm.warp(validBefore + 1); + + vm.startPrank(from); + vm.expectRevert("EIP3009: authorization expired"); // expired window should revert + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + vm.stopPrank(); + } + + function testDepositWithAuthorization_Revert_NotYetValidAuthorization() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = block.timestamp + 60; + uint256 validBefore = validAfter + 300; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + // Pre-state capture + uint256 fromBalanceBefore = helper._balanceOf(from, false); + uint256 paymentsBalanceBefore = helper._balanceOf(address(payments), false); + Payments.Account memory toAccountBefore = helper._getAccountData(to, false); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + vm.expectRevert("EIP3009: authorization not yet valid"); // not yet valid + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + + // Now advance to validAfter + 1 and succeed + vm.warp(validAfter + 1); + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + vm.stopPrank(); + + // Post-state capture + uint256 fromBalanceAfter = helper._balanceOf(from, false); + uint256 paymentsBalanceAfter = helper._balanceOf(address(payments), false); + Payments.Account memory toAccountAfter = helper._getAccountData(from, false); + + // Assertions + helper._assertDepositBalances( + fromBalanceBefore, + fromBalanceAfter, + paymentsBalanceBefore, + paymentsBalanceAfter, + toAccountBefore, + toAccountAfter, + amount + ); + + // Verify authorization is consumed on the token + bool used = IERC3009(address(testToken)).authorizationState(from, nonce); + assertTrue(used); + } + + function testDepositWithAuthorization_Revert_SubmittedByDifferentSender() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 300; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // Attempt to submit as a different user + from = vm.addr(user2Sk); + vm.startPrank(from); + vm.expectRevert(abi.encodeWithSelector(Errors.SignerMustBeMsgSender.selector, from, to)); + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + vm.stopPrank(); + } + + function testDepositWithAuthorization_Revert_InsufficientBalance() public { + helper.depositWithAuthorizationInsufficientBalance(user1Sk); + } + + function testDepositWithAuthorization_Revert_DomainMismatchWrongToken() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 300; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + // Create a second token + MockERC20 otherToken = new MockERC20("OtherToken", "OTK"); + + // Sign against otherToken domain + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(otherToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + vm.expectRevert("Invalid signature"); // domain mismatch + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + vm.stopPrank(); + } +} diff --git a/test/helpers/PaymentsTestHelpers.sol b/test/helpers/PaymentsTestHelpers.sol index 3ea14d9c..c59432d9 100644 --- a/test/helpers/PaymentsTestHelpers.sol +++ b/test/helpers/PaymentsTestHelpers.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; import {Test} from "forge-std/Test.sol"; -import {Payments} from "../../src/Payments.sol"; +import {Payments, IERC3009} from "../../src/Payments.sol"; import {MockERC20} from "../mocks/MockERC20.sol"; import {BaseTestHelper} from "./BaseTestHelper.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -131,7 +131,7 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { Payments.Account memory toAccountBefore, Payments.Account memory toAccountAfter, uint256 amount - ) private pure { + ) public pure { assertEq(fromBalanceAfter, fromBalanceBefore - amount, "Sender's balance not reduced correctly"); assertEq( paymentsBalanceAfter, paymentsBalanceBefore + amount, "Payments contract balance not increased correctly" @@ -154,7 +154,7 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { return _getAccountData(user, true); } - function _getAccountData(address user, bool useNativeToken) private view returns (Payments.Account memory) { + function _getAccountData(address user, bool useNativeToken) public view returns (Payments.Account memory) { address token = useNativeToken ? address(0) : address(testToken); (uint256 funds, uint256 lockupCurrent, uint256 lockupRate, uint256 lockupLastSettledAt) = payments.accounts(token, user); @@ -257,7 +257,7 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { ); } - function _balanceOf(address addr, bool useNativeToken) private view returns (uint256) { + function _balanceOf(address addr, bool useNativeToken) public view returns (uint256) { if (useNativeToken) { return addr.balance; } else { @@ -848,4 +848,49 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { payments.depositWithPermit(address(testToken), to, amount, deadline, v, r, s); vm.stopPrank(); } + + function getTransferWithAuthorizationSignature( + uint256 privateKey, + address token, + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce + ) public view returns (uint8 v, bytes32 r, bytes32 s) { + // EIP-712 domain + bytes32 DOMAIN_SEPARATOR = MockERC20(address(token)).DOMAIN_SEPARATOR(); + + // keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 TYPEHASH = keccak256( + "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); // as per EIP-3009 + + bytes32 structHash = keccak256(abi.encode(TYPEHASH, from, to, value, validAfter, validBefore, nonce)); + + bytes32 digest = MessageHashUtils.toTypedDataHash(DOMAIN_SEPARATOR, structHash); + + (v, r, s) = vm.sign(privateKey, digest); + } + + function depositWithAuthorizationInsufficientBalance(uint256 fromPrivateKey) public { + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 300; + uint256 amount = INITIAL_BALANCE + 1; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = getTransferWithAuthorizationSignature( + fromPrivateKey, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + vm.expectRevert( + abi.encodeWithSignature("ERC20InsufficientBalance(address,uint256,uint256)", from, INITIAL_BALANCE, amount) + ); + payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + vm.stopPrank(); + } } From 272a08373356cbcc59da66b059343af31b4abaf1 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 16 Aug 2025 21:14:35 +0530 Subject: [PATCH 07/23] feat: add depositWithAuthorizationAndApproveOperator and depositWithAuthorizationAndIncreaseOperatorApproval --- src/Payments.sol | 87 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/Payments.sol b/src/Payments.sol index 61cb9b28..0d6c5987 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -653,7 +653,6 @@ contract Payments is ReentrancyGuard { /** * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. - * @dev This allows a third party to submit a pre-signed transfer authorization to deposit tokens on behalf of a user. * @param token The ERC-20 token address to deposit. Must conform to ERC-3009. * @param to The address whose account within the contract will be credited. * @param amount The amount of tokens to deposit. @@ -682,6 +681,92 @@ contract Payments is ReentrancyGuard { _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); } + /** + * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. + * while also setting operator approval. + * @param token The ERC-20 token address to deposit. Must conform to ERC-3009. + * @param to The address whose account within the contract will be credited. + * @param amount The amount of tokens to deposit. + * @param validAfter The timestamp after which the authorization is valid. + * @param validBefore The timestamp before which the authorization is valid. + * @param nonce A unique nonce for the authorization, used to prevent replay attacks. + * @param v,r,s The signature of the authorization. + * @param operator The address of the operator whose approval is being modified. + * @param rateAllowance The maximum payment rate the operator can set across all rails created by the operator + * on behalf of the message sender. If this is less than the current payment rate, the operator will + * only be able to reduce rates until they fall below the target. + * @param lockupAllowance The maximum amount of funds the operator can lock up on behalf of the message sender + * towards future payments. If this exceeds the current total amount of funds locked towards future payments, + * the operator will only be able to reduce future lockup. + * @param maxLockupPeriod The maximum number of epochs (blocks) the operator can lock funds for. If this is less than + * the current lockup period for a rail, the operator will only be able to reduce the lockup period. + */ + function depositWithAuthorizationAndApproveOperator( + address token, + address to, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s, + address operator, + uint256 rateAllowance, + uint256 lockupAllowance, + uint256 maxLockupPeriod + ) + external + nonReentrant + validateNonZeroAddress(operator, "operator") + validateNonZeroAddress(to, "to") + validateSignerIsRecipient(to) + settleAccountLockupBeforeAndAfter(token, to, false) + { + _setOperatorApproval(token, operator, true, rateAllowance, lockupAllowance, maxLockupPeriod); + _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); + } + + /** + * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. + * while also setting operator approval. + * @param token The ERC-20 token address to deposit. Must conform to ERC-3009. + * @param to The address whose account within the contract will be credited. + * @param amount The amount of tokens to deposit. + * @param validAfter The timestamp after which the authorization is valid. + * @param validBefore The timestamp before which the authorization is valid. + * @param nonce A unique nonce for the authorization, used to prevent replay attacks. + * @param v,r,s The signature of the authorization. + * @param operator The address of the operator whose allowances are being increased. + * @param rateAllowanceIncrease The amount to increase the rate allowance by. + * @param lockupAllowanceIncrease The amount to increase the lockup allowance by. + * @custom:constraint Operator must already be approved. + */ + function depositWithAuthorizationAndIncreaseOperatorApproval( + address token, + address to, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s, + address operator, + uint256 rateAllowanceIncrease, + uint256 lockupAllowanceIncrease + ) + external + nonReentrant + validateNonZeroAddress(operator, "operator") + validateNonZeroAddress(to, "to") + validateSignerIsRecipient(to) + settleAccountLockupBeforeAndAfter(token, to, false) + { + _increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease); + _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); + } + function _depositWithAuthorization( address token, address to, From 19cf5ba2d43cddb958fd3ebbe6a7bb5b8ca76459 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 16 Aug 2025 21:34:09 +0530 Subject: [PATCH 08/23] test: add tests for deposit with authorization + operator operations --- ...WithAuthorizationAndOperatorApproval.t.sol | 466 ++++++++++++++++++ test/helpers/PaymentsTestHelpers.sol | 80 +++ 2 files changed, 546 insertions(+) create mode 100644 test/DepositWithAuthorizationAndOperatorApproval.t.sol diff --git a/test/DepositWithAuthorizationAndOperatorApproval.t.sol b/test/DepositWithAuthorizationAndOperatorApproval.t.sol new file mode 100644 index 00000000..7b476523 --- /dev/null +++ b/test/DepositWithAuthorizationAndOperatorApproval.t.sol @@ -0,0 +1,466 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {Payments, IERC3009} from "../src/Payments.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {PaymentsTestHelpers} from "./helpers/PaymentsTestHelpers.sol"; +import {BaseTestHelper} from "./helpers/BaseTestHelper.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {console} from "forge-std/console.sol"; +import {Errors} from "../src/Errors.sol"; + +contract DepositWithAuthorization is Test, BaseTestHelper { + MockERC20 testToken; + PaymentsTestHelpers helper; + Payments payments; + + uint256 constant DEPOSIT_AMOUNT = 1000 ether; + uint256 constant RATE_ALLOWANCE = 100 ether; + uint256 constant LOCKUP_ALLOWANCE = 1000 ether; + uint256 constant MAX_LOCKUP_PERIOD = 100; + uint256 internal constant INITIAL_BALANCE = 1000 ether; + + function setUp() public { + helper = new PaymentsTestHelpers(); + helper.setupStandardTestEnvironment(); + payments = helper.payments(); + + testToken = helper.testToken(); + } + + function testDepositWithAuthorizationAndOperatorApproval_HappyPath() public { + uint256 fromPrivateKey = user1Sk; + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validForSeconds = 60; + uint256 amount = DEPOSIT_AMOUNT; + + helper.depositWithAuthorizationAndOperatorApproval( + fromPrivateKey, amount, validForSeconds, OPERATOR, RATE_ALLOWANCE, LOCKUP_ALLOWANCE, MAX_LOCKUP_PERIOD + ); + } + + function testDepositWithAuthorizationAndOperatorApproval_ZeroAmount() public { + uint256 fromPrivateKey = user1Sk; + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validForSeconds = 60; + uint256 amount = 0; // Zero amount + + helper.depositWithAuthorizationAndOperatorApproval( + fromPrivateKey, amount, validForSeconds, OPERATOR, RATE_ALLOWANCE, LOCKUP_ALLOWANCE, MAX_LOCKUP_PERIOD + ); + } + + function testDepositWithAuthorizationAndOperatorApproval_Revert_InvalidSignature() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 60; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + // Build signature with wrong private key + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user2Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + + vm.expectRevert("Invalid signature"); + payments.depositWithAuthorizationAndApproveOperator( + address(testToken), + to, + amount, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + RATE_ALLOWANCE, + LOCKUP_ALLOWANCE, + MAX_LOCKUP_PERIOD + ); + + vm.stopPrank(); + } + + function testDepositWithAuthorizationAndOperatorApproval_Revert_InvalidSignature_Corrupted() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 60; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // Corrupt r + r = bytes32(uint256(r) ^ 1); + + vm.startPrank(from); + vm.expectRevert("ECDSAInvalidSignature()"); // invalid signature should revert + payments.depositWithAuthorizationAndApproveOperator( + address(testToken), + to, + amount, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + RATE_ALLOWANCE, + LOCKUP_ALLOWANCE, + MAX_LOCKUP_PERIOD + ); + } + + function testDepositWithAuthorizationAndOperatorApproval_Revert_ExpiredAuthorization() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 1; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // advance beyond validBefore + vm.warp(validBefore + 1); + + vm.startPrank(from); + vm.expectRevert("EIP3009: authorization expired"); // expired window should revert + payments.depositWithAuthorizationAndApproveOperator( + address(testToken), + to, + amount, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + RATE_ALLOWANCE, + LOCKUP_ALLOWANCE, + MAX_LOCKUP_PERIOD + ); + } + + function testDepositWithAuthorizationAndOperatorApproval_Revert_NotYetValidAuthorization() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = block.timestamp + 60; + uint256 validBefore = validAfter + 300; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + // Pre-state capture + uint256 fromBalanceBefore = helper._balanceOf(from, false); + uint256 paymentsBalanceBefore = helper._balanceOf(address(payments), false); + Payments.Account memory toAccountBefore = helper._getAccountData(to, false); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + vm.expectRevert("EIP3009: authorization not yet valid"); // not yet valid + payments.depositWithAuthorizationAndApproveOperator( + address(testToken), + to, + amount, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + RATE_ALLOWANCE, + LOCKUP_ALLOWANCE, + MAX_LOCKUP_PERIOD + ); + } + + function testDepositWithAuthorizationAndIncreaseOperatorApproval_HappyPath() public { + uint256 fromPrivateKey = user1Sk; + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validForSeconds = 60 * 60; + uint256 amount = DEPOSIT_AMOUNT; + + // Step 1: First establish initial operator approval with deposit + helper.depositWithAuthorizationAndOperatorApproval( + fromPrivateKey, amount, validForSeconds, OPERATOR, RATE_ALLOWANCE, LOCKUP_ALLOWANCE, MAX_LOCKUP_PERIOD + ); + + // Step 2: Verify initial approval state + (bool isApproved, uint256 initialRateAllowance, uint256 initialLockupAllowance,,,) = + payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(isApproved, true); + assertEq(initialRateAllowance, RATE_ALLOWANCE); + assertEq(initialLockupAllowance, LOCKUP_ALLOWANCE); + + // Step 3: Prepare for the increase operation + uint256 additionalDeposit = 500 ether; + uint256 rateIncrease = 50 ether; + uint256 lockupIncrease = 500 ether; + + // Give USER1 more tokens for the additional deposit + testToken.mint(USER1, additionalDeposit); + + uint256 validAfter = 0; + uint256 validBefore = validAfter + validForSeconds; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, additionalDeposit, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce + ); + + // Record initial account state + (uint256 initialFunds,,,) = payments.accounts(address(testToken), USER1); + + // Step 4: Execute depositWithAuthorizationAndIncreaseOperatorApproval + vm.startPrank(USER1); + payments.depositWithAuthorizationAndIncreaseOperatorApproval( + address(testToken), + to, + additionalDeposit, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + rateIncrease, + lockupIncrease + ); + + vm.stopPrank(); + + // Step 5: Verify results + // Check deposit was successful + (uint256 finalFunds,,,) = payments.accounts(address(testToken), USER1); + assertEq(finalFunds, initialFunds + additionalDeposit); + + // Check operator approval was increased + (, uint256 finalRateAllowance, uint256 finalLockupAllowance,,,) = + payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(finalRateAllowance, initialRateAllowance + rateIncrease); + assertEq(finalLockupAllowance, initialLockupAllowance + lockupIncrease); + } + + function testDepositWithAuthorizationAndIncreaseOperatorApproval_ZeroIncrease() public { + uint256 fromPrivateKey = user1Sk; + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validForSeconds = 60 * 60; + uint256 amount = DEPOSIT_AMOUNT; + + // Step 1: First establish initial operator approval with deposit + helper.depositWithAuthorizationAndOperatorApproval( + fromPrivateKey, amount, validForSeconds, OPERATOR, RATE_ALLOWANCE, LOCKUP_ALLOWANCE, MAX_LOCKUP_PERIOD + ); + + // Step 2: Verify initial approval state + (bool isApproved, uint256 initialRateAllowance, uint256 initialLockupAllowance,,,) = + payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(isApproved, true); + assertEq(initialRateAllowance, RATE_ALLOWANCE); + assertEq(initialLockupAllowance, LOCKUP_ALLOWANCE); + + // Step 3: Prepare for the increase operation + uint256 additionalDeposit = 500 ether; + uint256 rateIncrease = 0; + uint256 lockupIncrease = 0; + + // Give USER1 more tokens for the additional deposit + testToken.mint(USER1, additionalDeposit); + + uint256 validAfter = 0; + uint256 validBefore = validAfter + validForSeconds; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, additionalDeposit, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce + ); + + // Record initial account state + (uint256 initialFunds,,,) = payments.accounts(address(testToken), USER1); + + // Step 4: Execute depositWithAuthorizationAndIncreaseOperatorApproval + vm.startPrank(USER1); + payments.depositWithAuthorizationAndIncreaseOperatorApproval( + address(testToken), + to, + additionalDeposit, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + rateIncrease, + lockupIncrease + ); + + vm.stopPrank(); + + // Step 5: Verify results + // Check deposit was successful + (uint256 finalFunds,,,) = payments.accounts(address(testToken), USER1); + assertEq(finalFunds, initialFunds + additionalDeposit); + + (, uint256 finalRateAllowance, uint256 finalLockupAllowance,,,) = + payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(finalRateAllowance, initialRateAllowance); // No change + assertEq(finalLockupAllowance, initialLockupAllowance); // No change + } + + function testDepositWithAuthorizationAndIncreaseOperatorApproval_InvalidSignature() public { + uint256 fromPrivateKey = user1Sk; + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validForSeconds = 60 * 60; + uint256 amount = DEPOSIT_AMOUNT; + + // First establish initial operator approval with deposit + helper.depositWithAuthorizationAndOperatorApproval( + fromPrivateKey, amount, validForSeconds, OPERATOR, RATE_ALLOWANCE, LOCKUP_ALLOWANCE, MAX_LOCKUP_PERIOD + ); + + // Verify initial approval state + (bool isApproved, uint256 initialRateAllowance, uint256 initialLockupAllowance,,,) = + payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(isApproved, true); + assertEq(initialRateAllowance, RATE_ALLOWANCE); + assertEq(initialLockupAllowance, LOCKUP_ALLOWANCE); + + // Prepare for the increase operation + uint256 additionalDeposit = 500 ether; + uint256 rateIncrease = 0; + uint256 lockupIncrease = 0; + + // Give USER1 more tokens for the additional deposit + testToken.mint(USER1, additionalDeposit); + + uint256 validAfter = 0; + uint256 validBefore = validAfter + validForSeconds; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, additionalDeposit, block.number)); + + // Create invalid permit signature (wrong private key) + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user2Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce + ); + + vm.startPrank(USER1); + vm.expectRevert("Invalid signature"); + payments.depositWithAuthorizationAndIncreaseOperatorApproval( + address(testToken), + to, + additionalDeposit, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + rateIncrease, + lockupIncrease + ); + vm.stopPrank(); + + (, uint256 finalRateAllowance, uint256 finalLockupAllowance,,,) = + payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(finalRateAllowance, initialRateAllowance); // No change + assertEq(finalLockupAllowance, initialLockupAllowance); // No change + } + + function testDepositWithAuthorizationAndIncreaseOperatorApproval_WithExistingUsage() public { + uint256 fromPrivateKey = user1Sk; + address from = vm.addr(fromPrivateKey); + address to = from; + uint256 validForSeconds = 60 * 60; + uint256 amount = DEPOSIT_AMOUNT; + + // First establish initial operator approval with deposit + helper.depositWithAuthorizationAndOperatorApproval( + fromPrivateKey, amount, validForSeconds, OPERATOR, RATE_ALLOWANCE, LOCKUP_ALLOWANCE, MAX_LOCKUP_PERIOD + ); + + // Create rail and use some allowance to establish existing usage + uint256 railId = helper.createRail(USER1, USER2, OPERATOR, address(0), SERVICE_FEE_RECIPIENT); + uint256 paymentRate = 30 ether; + uint256 lockupFixed = 200 ether; + + vm.startPrank(OPERATOR); + payments.modifyRailPayment(railId, paymentRate, 0); + payments.modifyRailLockup(railId, 0, lockupFixed); + vm.stopPrank(); + + // Verify some allowance is used + (, uint256 preRateAllowance, uint256 preLockupAllowance, uint256 preRateUsage, uint256 preLockupUsage,) = + payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(preRateUsage, paymentRate); + assertEq(preLockupUsage, lockupFixed); + + // Setup for additional deposit with increase + uint256 additionalDeposit = 500 ether; + uint256 rateIncrease = 70 ether; + uint256 lockupIncrease = 800 ether; + + testToken.mint(USER1, additionalDeposit); + + uint256 validAfter = 0; + uint256 validBefore = validAfter + validForSeconds; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, additionalDeposit, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce + ); + + (uint256 initialFunds,,,) = payments.accounts(address(testToken), USER1); + + // Execute increase with existing usage + vm.startPrank(USER1); + payments.depositWithAuthorizationAndIncreaseOperatorApproval( + address(testToken), + to, + additionalDeposit, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + rateIncrease, + lockupIncrease + ); + vm.stopPrank(); + + // Verify results + (uint256 finalFunds,,,) = payments.accounts(address(testToken), USER1); + assertEq(finalFunds, initialFunds + additionalDeposit); + + (, uint256 finalRateAllowance, uint256 finalLockupAllowance, uint256 finalRateUsage, uint256 finalLockupUsage,) + = payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(finalRateAllowance, preRateAllowance + rateIncrease); + assertEq(finalLockupAllowance, preLockupAllowance + lockupIncrease); + assertEq(finalRateUsage, preRateUsage); // Usage unchanged + assertEq(finalLockupUsage, preLockupUsage); // Usage unchanged + } +} diff --git a/test/helpers/PaymentsTestHelpers.sol b/test/helpers/PaymentsTestHelpers.sol index c59432d9..2cb57280 100644 --- a/test/helpers/PaymentsTestHelpers.sol +++ b/test/helpers/PaymentsTestHelpers.sol @@ -893,4 +893,84 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } + + function depositWithAuthorizationAndOperatorApproval( + uint256 fromPrivateKey, + uint256 amount, + uint256 validForSeconds, + address operator, + uint256 rateAllowance, + uint256 lockupAllowance, + uint256 maxLockupPeriod + ) public returns (bytes32 nonce) { + address from = vm.addr(fromPrivateKey); + address to = from; + + // Windows + uint256 validAfter = 0; // valid immediately + uint256 validBefore = block.timestamp + validForSeconds; + + // Unique nonce + nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + // Pre-state capture + uint256 fromBalanceBefore = _balanceOf(from, false); + uint256 paymentsBalanceBefore = _balanceOf(address(payments), false); + Payments.Account memory toAccountBefore = _getAccountData(to, false); + + // Build signature + (uint8 v, bytes32 r, bytes32 s) = getTransferWithAuthorizationSignature( + fromPrivateKey, + address(testToken), + from, + address(payments), // pay to Payments contract + amount, + validAfter, + validBefore, + nonce + ); + + // Execute deposit via authorization + vm.startPrank(from); + + payments.depositWithAuthorizationAndApproveOperator( + address(testToken), + to, + amount, + validAfter, + validBefore, + nonce, + v, + r, + s, + operator, + rateAllowance, + lockupAllowance, + maxLockupPeriod + ); + + vm.stopPrank(); + + // Post-state capture + uint256 fromBalanceAfter = _balanceOf(from, false); + uint256 paymentsBalanceAfter = _balanceOf(address(payments), false); + Payments.Account memory toAccountAfter = _getAccountData(from, false); + + // Assertions + _assertDepositBalances( + fromBalanceBefore, + fromBalanceAfter, + paymentsBalanceBefore, + paymentsBalanceAfter, + toAccountBefore, + toAccountAfter, + amount + ); + + // Verify authorization is consumed on the token + bool used = IERC3009(address(testToken)).authorizationState(from, nonce); + assertTrue(used); + + verifyOperatorAllowances(from, operator, true, rateAllowance, lockupAllowance, 0, 0, maxLockupPeriod); + } } From a15dc32c5783610f2a9994e7e7b322b844443602 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Thu, 21 Aug 2025 00:27:02 +0530 Subject: [PATCH 09/23] feat(erc3009): switch to receiveWithAuthorization - Replace transferWithAuthorization with receiveWithAuthorization - Rename helper to getReceiveWithAuthorizationSignature and update all call sites - Align EIP-712 signing to token.domainSeparator() and fix revert expectations --- src/Payments.sol | 8 +- test/DepositWithAuthorization.t.sol | 24 ++--- ...WithAuthorizationAndOperatorApproval.t.sol | 31 +++--- test/helpers/PaymentsTestHelpers.sol | 15 +-- test/mocks/MockERC20.sol | 96 +++++++++++++++++++ 5 files changed, 132 insertions(+), 42 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index 0d6c5987..3dd2cd77 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -35,7 +35,9 @@ interface IValidator { interface IERC3009 { /** - * @notice Execute a transfer with a signed authorization + * @notice Receive a transfer with a signed authorization from the payer + * @dev This has an additional check to ensure that the payee's address matches + * the caller of this function to prevent front-running attacks. * @param from Payer's address (Authorizer) * @param to Payee's address * @param value Amount to be transferred @@ -46,7 +48,7 @@ interface IERC3009 { * @param r r of the signature * @param s s of the signature */ - function transferWithAuthorization( + function receiveWithAuthorization( address from, address to, uint256 value, @@ -787,7 +789,7 @@ contract Payments is ReentrancyGuard { // Call ERC-3009 transferWithAuthorization. // This will transfer 'amount' from 'to' to this contract. // The token contract itself verifies the signature. - IERC3009(token).transferWithAuthorization(to, address(this), amount, validAfter, validBefore, nonce, v, r, s); + IERC3009(token).receiveWithAuthorization(to, address(this), amount, validAfter, validBefore, nonce, v, r, s); uint256 balanceAfter = IERC20(token).balanceOf(address(this)); uint256 actualAmount = balanceAfter - balanceBefore; diff --git a/test/DepositWithAuthorization.t.sol b/test/DepositWithAuthorization.t.sol index 0a369b1f..90e304d5 100644 --- a/test/DepositWithAuthorization.t.sol +++ b/test/DepositWithAuthorization.t.sol @@ -50,11 +50,11 @@ contract DepositWithAuthorization is Test, BaseTestHelper { Payments.Account memory toAccountBefore = helper._getAccountData(to, false); // Build signature - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( fromPrivateKey, address(testToken), from, - address(payments), // pay to Payments contract which will credit 'to' + address(payments), // receiveWithAuthorization pays to Payments contract amount, validAfter, validBefore, @@ -100,7 +100,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = block.timestamp + validForSeconds; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( fromPrivateKey, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); @@ -121,12 +121,12 @@ contract DepositWithAuthorization is Test, BaseTestHelper { bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); // Generate signature with a different private key - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user2Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); vm.startPrank(from); - vm.expectRevert("Invalid signature"); + vm.expectRevert("EIP3009: invalid signature"); payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } @@ -139,7 +139,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = block.timestamp + 60; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); @@ -147,7 +147,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { r = bytes32(uint256(r) ^ 1); vm.startPrank(from); - vm.expectRevert("ECDSAInvalidSignature()"); // invalid signature should revert + vm.expectRevert("EIP3009: invalid signature"); // invalid signature should revert payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } @@ -160,7 +160,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = block.timestamp + 1; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); @@ -186,7 +186,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 paymentsBalanceBefore = helper._balanceOf(address(payments), false); Payments.Account memory toAccountBefore = helper._getAccountData(to, false); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); @@ -228,7 +228,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = block.timestamp + 300; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); @@ -256,12 +256,12 @@ contract DepositWithAuthorization is Test, BaseTestHelper { MockERC20 otherToken = new MockERC20("OtherToken", "OTK"); // Sign against otherToken domain - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(otherToken), from, address(payments), amount, validAfter, validBefore, nonce ); vm.startPrank(from); - vm.expectRevert("Invalid signature"); // domain mismatch + vm.expectRevert("EIP3009: invalid signature"); // domain mismatch payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } diff --git a/test/DepositWithAuthorizationAndOperatorApproval.t.sol b/test/DepositWithAuthorizationAndOperatorApproval.t.sol index 7b476523..0a998af3 100644 --- a/test/DepositWithAuthorizationAndOperatorApproval.t.sol +++ b/test/DepositWithAuthorizationAndOperatorApproval.t.sol @@ -31,8 +31,6 @@ contract DepositWithAuthorization is Test, BaseTestHelper { function testDepositWithAuthorizationAndOperatorApproval_HappyPath() public { uint256 fromPrivateKey = user1Sk; - address from = vm.addr(fromPrivateKey); - address to = from; uint256 validForSeconds = 60; uint256 amount = DEPOSIT_AMOUNT; @@ -43,8 +41,6 @@ contract DepositWithAuthorization is Test, BaseTestHelper { function testDepositWithAuthorizationAndOperatorApproval_ZeroAmount() public { uint256 fromPrivateKey = user1Sk; - address from = vm.addr(fromPrivateKey); - address to = from; uint256 validForSeconds = 60; uint256 amount = 0; // Zero amount @@ -62,13 +58,13 @@ contract DepositWithAuthorization is Test, BaseTestHelper { bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); // Build signature with wrong private key - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user2Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); vm.startPrank(from); - vm.expectRevert("Invalid signature"); + vm.expectRevert("EIP3009: invalid signature"); payments.depositWithAuthorizationAndApproveOperator( address(testToken), to, @@ -96,7 +92,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = block.timestamp + 60; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); @@ -104,7 +100,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { r = bytes32(uint256(r) ^ 1); vm.startPrank(from); - vm.expectRevert("ECDSAInvalidSignature()"); // invalid signature should revert + vm.expectRevert("EIP3009: invalid signature"); // invalid signature should revert payments.depositWithAuthorizationAndApproveOperator( address(testToken), to, @@ -130,7 +126,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = block.timestamp + 1; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); @@ -164,12 +160,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = validAfter + 300; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); - // Pre-state capture - uint256 fromBalanceBefore = helper._balanceOf(from, false); - uint256 paymentsBalanceBefore = helper._balanceOf(address(payments), false); - Payments.Account memory toAccountBefore = helper._getAccountData(to, false); - - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); @@ -223,7 +214,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = validAfter + validForSeconds; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, additionalDeposit, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce ); @@ -292,7 +283,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = validAfter + validForSeconds; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, additionalDeposit, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce ); @@ -361,12 +352,12 @@ contract DepositWithAuthorization is Test, BaseTestHelper { bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, additionalDeposit, block.number)); // Create invalid permit signature (wrong private key) - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user2Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce ); vm.startPrank(USER1); - vm.expectRevert("Invalid signature"); + vm.expectRevert("EIP3009: invalid signature"); payments.depositWithAuthorizationAndIncreaseOperatorApproval( address(testToken), to, @@ -428,7 +419,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { uint256 validBefore = validAfter + validForSeconds; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, additionalDeposit, block.number)); - (uint8 v, bytes32 r, bytes32 s) = helper.getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( user1Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce ); diff --git a/test/helpers/PaymentsTestHelpers.sol b/test/helpers/PaymentsTestHelpers.sol index 2cb57280..0900ab84 100644 --- a/test/helpers/PaymentsTestHelpers.sol +++ b/test/helpers/PaymentsTestHelpers.sol @@ -849,7 +849,7 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { vm.stopPrank(); } - function getTransferWithAuthorizationSignature( + function getReceiveWithAuthorizationSignature( uint256 privateKey, address token, address from, @@ -859,12 +859,12 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { uint256 validBefore, bytes32 nonce ) public view returns (uint8 v, bytes32 r, bytes32 s) { - // EIP-712 domain - bytes32 DOMAIN_SEPARATOR = MockERC20(address(token)).DOMAIN_SEPARATOR(); + // EIP-712 domain for ERC-3009 (MockERC20 defines its own domainSeparator unrelated to ERC2612) + bytes32 DOMAIN_SEPARATOR = MockERC20(address(token)).domainSeparator(); - // keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + // keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") bytes32 TYPEHASH = keccak256( - "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); // as per EIP-3009 bytes32 structHash = keccak256(abi.encode(TYPEHASH, from, to, value, validAfter, validBefore, nonce)); @@ -882,11 +882,12 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { uint256 amount = INITIAL_BALANCE + 1; bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); - (uint8 v, bytes32 r, bytes32 s) = getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = getReceiveWithAuthorizationSignature( fromPrivateKey, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); vm.startPrank(from); + // Since signature is valid but balance is insufficient, MockERC20 will revert with ERC20InsufficientBalance vm.expectRevert( abi.encodeWithSignature("ERC20InsufficientBalance(address,uint256,uint256)", from, INITIAL_BALANCE, amount) ); @@ -919,7 +920,7 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { Payments.Account memory toAccountBefore = _getAccountData(to, false); // Build signature - (uint8 v, bytes32 r, bytes32 s) = getTransferWithAuthorizationSignature( + (uint8 v, bytes32 r, bytes32 s) = getReceiveWithAuthorizationSignature( fromPrivateKey, address(testToken), from, diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index 0642ed5b..d29ac72e 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -15,6 +15,20 @@ contract MockERC20 is ERC20Permit { bytes32 private constant _TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256( "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); + bytes32 private constant _RECEIVE_WITH_AUTHORIZATION_TYPEHASH = keccak256( + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + + bytes32 private _HASHED_NAME = keccak256("USD for Filecoin"); + bytes32 private _HASHED_VERSION = keccak256("1"); + + // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 private constant _PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 private constant _TYPE_HASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f; + + uint256 private _CACHED_CHAIN_ID = _chainID(); + bytes32 private _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION); // --- ERC-3009 Event --- event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); @@ -69,7 +83,89 @@ contract MockERC20 is ERC20Permit { _transfer(from, to, value); } + /** + * @notice Receive a transfer with a signed authorization from the payer + * @dev This has an additional check to ensure that the payee's address matches + * the caller of this function to prevent front-running attacks. (See security + * considerations) + * @param _from Payer's address (Authorizer) + * @param _to Payee's address + * @param _value Amount to be transferred + * @param _validAfter The time after which this is valid (unix time) + * @param _validBefore The time before which this is valid (unix time) + * @param _nonce Unique nonce + * @param _v v of the signature + * @param _r r of the signature + * @param _s s of the signature + */ + function receiveWithAuthorization( + address _from, + address _to, + uint256 _value, + uint256 _validAfter, + uint256 _validBefore, + bytes32 _nonce, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external { + require(_to == msg.sender, "EIP3009: caller must be the recipient"); + require(block.timestamp > _validAfter, "EIP3009: authorization not yet valid"); + require(block.timestamp < _validBefore, "EIP3009: authorization expired"); + require(!_authorizationStates[_from][_nonce], "EIP3009: authorization already used"); + _requireValidRecipient(_to); + + address recoveredAddress = _recover( + _v, + _r, + _s, + abi.encode(_RECEIVE_WITH_AUTHORIZATION_TYPEHASH, _from, _to, _value, _validAfter, _validBefore, _nonce) + ); + require(recoveredAddress == _from, "EIP3009: invalid signature"); + + _authorizationStates[_from][_nonce] = true; + emit AuthorizationUsed(_from, _nonce); + + _transfer(_from, _to, _value); + } + function authorizationState(address authorizer, bytes32 nonce) external view returns (bool) { return _authorizationStates[authorizer][nonce]; } + + function _requireValidRecipient(address _recipient) internal view { + require( + _recipient != address(0) && _recipient != address(this), + "DebtToken: Cannot transfer tokens directly to the Debt token contract or the zero address" + ); + } + + function _recover(uint8 _v, bytes32 _r, bytes32 _s, bytes memory _typeHashAndData) + internal + view + returns (address) + { + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator(), keccak256(_typeHashAndData))); + address recovered = ecrecover(digest, _v, _r, _s); + require(recovered != address(0), "EIP712: invalid signature"); + return recovered; + } + + function domainSeparator() public view returns (bytes32) { + if (_chainID() == _CACHED_CHAIN_ID) { + return _CACHED_DOMAIN_SEPARATOR; + } else { + return _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION); + } + } + + function _chainID() private view returns (uint256 chainID) { + assembly { + chainID := chainid() + } + } + + function _buildDomainSeparator(bytes32 _typeHash, bytes32 _name, bytes32 _version) private view returns (bytes32) { + return keccak256(abi.encode(_typeHash, _name, _version, _chainID(), address(this))); + } } From 4b1f3dde2e55c748244ac984a2643b1d98340c2d Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Mon, 25 Aug 2025 00:03:37 +0530 Subject: [PATCH 10/23] fix: removed hardcoded hashed name, _chainID() and explicitly imported ECDSA --- test/mocks/MockERC20.sol | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index d29ac72e..987f5fd0 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; -import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; /** * @title MockERC20 @@ -19,7 +19,7 @@ contract MockERC20 is ERC20Permit { "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); - bytes32 private _HASHED_NAME = keccak256("USD for Filecoin"); + bytes32 private _HASHED_NAME; bytes32 private _HASHED_VERSION = keccak256("1"); // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); @@ -27,13 +27,17 @@ contract MockERC20 is ERC20Permit { // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); bytes32 private constant _TYPE_HASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f; - uint256 private _CACHED_CHAIN_ID = _chainID(); - bytes32 private _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION); + uint256 private _CACHED_CHAIN_ID; + bytes32 private _CACHED_DOMAIN_SEPARATOR; // --- ERC-3009 Event --- event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); - constructor(string memory name, string memory symbol) ERC20(name, symbol) ERC20Permit(name) {} + constructor(string memory name, string memory symbol) ERC20(name, symbol) ERC20Permit(name) { + _HASHED_NAME = keccak256(abi.encode(name)); + _CACHED_CHAIN_ID = block.chainid; + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION); + } // Mint tokens for testing function mint(address to, uint256 amount) public { @@ -73,7 +77,7 @@ contract MockERC20 is ERC20Permit { abi.encode(_TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) ); - bytes32 digest = EIP712._hashTypedDataV4(structHash); + bytes32 digest = _hashTypedDataV4(structHash); address signer = ECDSA.recover(digest, v, r, s); require(signer == from, "Invalid signature"); @@ -152,20 +156,14 @@ contract MockERC20 is ERC20Permit { } function domainSeparator() public view returns (bytes32) { - if (_chainID() == _CACHED_CHAIN_ID) { + if (block.chainid == _CACHED_CHAIN_ID) { return _CACHED_DOMAIN_SEPARATOR; } else { return _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION); } } - function _chainID() private view returns (uint256 chainID) { - assembly { - chainID := chainid() - } - } - function _buildDomainSeparator(bytes32 _typeHash, bytes32 _name, bytes32 _version) private view returns (bytes32) { - return keccak256(abi.encode(_typeHash, _name, _version, _chainID(), address(this))); + return keccak256(abi.encode(_typeHash, _name, _version, block.chainid, address(this))); } } From 96724dec711670a4a7f9ce12d014492b5e0338c1 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Mon, 25 Aug 2025 00:05:09 +0530 Subject: [PATCH 11/23] fix: modified tests to reflect updated MockERC20 --- test/DepositWithAuthorization.t.sol | 2 +- test/DepositWithAuthorizationAndOperatorApproval.t.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/DepositWithAuthorization.t.sol b/test/DepositWithAuthorization.t.sol index 90e304d5..0991ae8a 100644 --- a/test/DepositWithAuthorization.t.sol +++ b/test/DepositWithAuthorization.t.sol @@ -147,7 +147,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { r = bytes32(uint256(r) ^ 1); vm.startPrank(from); - vm.expectRevert("EIP3009: invalid signature"); // invalid signature should revert + vm.expectRevert("EIP712: invalid signature"); // invalid signature should revert payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } diff --git a/test/DepositWithAuthorizationAndOperatorApproval.t.sol b/test/DepositWithAuthorizationAndOperatorApproval.t.sol index 0a998af3..7aadec9e 100644 --- a/test/DepositWithAuthorizationAndOperatorApproval.t.sol +++ b/test/DepositWithAuthorizationAndOperatorApproval.t.sol @@ -100,7 +100,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { r = bytes32(uint256(r) ^ 1); vm.startPrank(from); - vm.expectRevert("EIP3009: invalid signature"); // invalid signature should revert + vm.expectRevert("EIP712: invalid signature"); // invalid signature should revert payments.depositWithAuthorizationAndApproveOperator( address(testToken), to, From 58ad21cb1ef6dc699e36451792e628877ec81c3a Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Mon, 25 Aug 2025 00:10:16 +0530 Subject: [PATCH 12/23] fix: updated DepositRecorded event to remove auth type and nonce --- src/Payments.sol | 21 ++++----------------- test/PaymentsEvents.t.sol | 6 ++---- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index 3dd2cd77..61b0b17c 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -74,12 +74,6 @@ contract Payments is ReentrancyGuard { uint256 public constant NETWORK_FEE = 1300000 gwei; // equivalent to 130000 nFIL address payable private constant BURN_ADDRESS = payable(0xff00000000000000000000000000000000000063); - enum AuthType { - None, - Permit, - Authorization - } - // Events event AccountLockupSettled( address indexed token, @@ -127,14 +121,7 @@ contract Payments is ReentrancyGuard { event RailTerminated(uint256 indexed railId, address indexed by, uint256 endEpoch); event RailFinalized(uint256 indexed railId); - event DepositRecorded( - address indexed token, - address indexed from, - address indexed to, - uint256 amount, - AuthType authType, - bytes32 nonce - ); + event DepositRecorded(address indexed token, address indexed from, address indexed to, uint256 amount); event WithdrawRecorded(address indexed token, address indexed from, address indexed to, uint256 amount); struct Account { @@ -516,7 +503,7 @@ contract Payments is ReentrancyGuard { account.funds += actualAmount; - emit DepositRecorded(token, msg.sender, to, actualAmount, AuthType.None, bytes32(0)); + emit DepositRecorded(token, msg.sender, to, actualAmount); } /** @@ -570,7 +557,7 @@ contract Payments is ReentrancyGuard { account.funds += actualAmount; - emit DepositRecorded(token, to, to, actualAmount, AuthType.Permit, bytes32(0)); + emit DepositRecorded(token, to, to, actualAmount); } /** @@ -799,7 +786,7 @@ contract Payments is ReentrancyGuard { account.funds += actualAmount; // Emit an event to record the deposit, marking it as made via an off-chain signature. - emit DepositRecorded(token, to, to, actualAmount, AuthType.Authorization, nonce); + emit DepositRecorded(token, to, to, actualAmount); } /// @notice Withdraws tokens from the caller's account to the caller's account, up to the amount of currently available tokens (the tokens not currently locked in rails). diff --git a/test/PaymentsEvents.t.sol b/test/PaymentsEvents.t.sol index 02d39d34..0aec03c1 100644 --- a/test/PaymentsEvents.t.sol +++ b/test/PaymentsEvents.t.sol @@ -309,7 +309,7 @@ contract PaymentsEventsTest is Test, BaseTestHelper { // Only check the first three indexed parameters vm.expectEmit(true, true, true, true); emit Payments.AccountLockupSettled(address(testToken), USER2, 0, 0, block.number); - emit Payments.DepositRecorded(address(testToken), USER1, USER2, 10 ether, Payments.AuthType.None, bytes32(0)); // Amount not checked + emit Payments.DepositRecorded(address(testToken), USER1, USER2, 10 ether); // Amount not checked // Deposit tokens payments.deposit(address(testToken), USER2, 10 ether); @@ -336,9 +336,7 @@ contract PaymentsEventsTest is Test, BaseTestHelper { // Expect the event to be emitted vm.expectEmit(true, true, false, true); emit Payments.AccountLockupSettled(address(testToken), signer, 0, 0, block.number); - emit Payments.DepositRecorded( - address(testToken), signer, signer, depositAmount, Payments.AuthType.Permit, bytes32(0) - ); + emit Payments.DepositRecorded(address(testToken), signer, signer, depositAmount); // Deposit with permit payments.depositWithPermit(address(testToken), signer, depositAmount, deadline, v, r, s); From 10c77a797e11346d49a4f5e1575730059e2a6826 Mon Sep 17 00:00:00 2001 From: Aashish Date: Thu, 4 Sep 2025 00:58:42 +0530 Subject: [PATCH 13/23] feat: allow relayer to perform deposit with authorization on behalf of user --- src/Payments.sol | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index 61b0b17c..94bf05d9 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -660,13 +660,7 @@ contract Payments is ReentrancyGuard { uint8 v, bytes32 r, bytes32 s - ) - external - nonReentrant - validateNonZeroAddress(to, "to") - validateSignerIsRecipient(to) - settleAccountLockupBeforeAndAfter(token, to, false) - { + ) external nonReentrant validateNonZeroAddress(to, "to") settleAccountLockupBeforeAndAfter(token, to, false) { _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); } From 8418eb0a1679fb8e54419a1c751d72e30672c6d9 Mon Sep 17 00:00:00 2001 From: Aashish Date: Thu, 4 Sep 2025 01:07:22 +0530 Subject: [PATCH 14/23] test: add test to simulate different signer and relayer --- test/DepositWithAuthorization.t.sol | 32 +++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/test/DepositWithAuthorization.t.sol b/test/DepositWithAuthorization.t.sol index 0991ae8a..96313de1 100644 --- a/test/DepositWithAuthorization.t.sol +++ b/test/DepositWithAuthorization.t.sol @@ -220,7 +220,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { assertTrue(used); } - function testDepositWithAuthorization_Revert_SubmittedByDifferentSender() public { + function testDepositWithAuthorization_SubmittedByDifferentSender() public { address from = vm.addr(user1Sk); address to = from; uint256 amount = DEPOSIT_AMOUNT; @@ -232,12 +232,36 @@ contract DepositWithAuthorization is Test, BaseTestHelper { user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce ); + // Pre-state capture + uint256 fromBalanceBefore = helper._balanceOf(from, false); + uint256 paymentsBalanceBefore = helper._balanceOf(address(payments), false); + Payments.Account memory toAccountBefore = helper._getAccountData(to, false); + // Attempt to submit as a different user - from = vm.addr(user2Sk); - vm.startPrank(from); - vm.expectRevert(abi.encodeWithSelector(Errors.SignerMustBeMsgSender.selector, from, to)); + address relayer = vm.addr(user2Sk); + vm.startPrank(relayer); payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); + + // Post-state capture + uint256 fromBalanceAfter = helper._balanceOf(from, false); + uint256 paymentsBalanceAfter = helper._balanceOf(address(payments), false); + Payments.Account memory toAccountAfter = helper._getAccountData(to, false); + + // Assertions + helper._assertDepositBalances( + fromBalanceBefore, + fromBalanceAfter, + paymentsBalanceBefore, + paymentsBalanceAfter, + toAccountBefore, + toAccountAfter, + amount + ); + + // Verify authorization is consumed on the token + bool used = IERC3009(address(testToken)).authorizationState(from, nonce); + assertTrue(used); } function testDepositWithAuthorization_Revert_InsufficientBalance() public { From b1704e50de40b4d69fe78d786f72de504b969afd Mon Sep 17 00:00:00 2001 From: Aashish Date: Thu, 4 Sep 2025 01:08:19 +0530 Subject: [PATCH 15/23] test: add tests checking only to address can call deposit and operator operations --- ...WithAuthorizationAndOperatorApproval.t.sol | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/test/DepositWithAuthorizationAndOperatorApproval.t.sol b/test/DepositWithAuthorizationAndOperatorApproval.t.sol index 7aadec9e..b0b05817 100644 --- a/test/DepositWithAuthorizationAndOperatorApproval.t.sol +++ b/test/DepositWithAuthorizationAndOperatorApproval.t.sol @@ -183,6 +183,40 @@ contract DepositWithAuthorization is Test, BaseTestHelper { ); } + function testDepositWithAuthorizationAndOperatorApproval_Revert_DifferentSender() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 60; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // Attempt to submit as a different user + from = vm.addr(user2Sk); + vm.startPrank(from); + vm.expectRevert(abi.encodeWithSelector(Errors.SignerMustBeMsgSender.selector, from, to)); + payments.depositWithAuthorizationAndApproveOperator( + address(testToken), + to, + amount, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + RATE_ALLOWANCE, + LOCKUP_ALLOWANCE, + MAX_LOCKUP_PERIOD + ); + vm.stopPrank(); + } + function testDepositWithAuthorizationAndIncreaseOperatorApproval_HappyPath() public { uint256 fromPrivateKey = user1Sk; address from = vm.addr(fromPrivateKey); @@ -454,4 +488,56 @@ contract DepositWithAuthorization is Test, BaseTestHelper { assertEq(finalRateUsage, preRateUsage); // Usage unchanged assertEq(finalLockupUsage, preLockupUsage); // Usage unchanged } + + function testDepositWithAuthorizationAndIncreaseOperatorApproval_Revert_DifferentSender() public { + address from = vm.addr(user1Sk); + address to = from; + uint256 amount = DEPOSIT_AMOUNT; + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 60; + bytes32 nonce = keccak256(abi.encodePacked("auth-nonce", from, to, amount, block.number)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // First establish initial operator approval with deposit + helper.depositWithAuthorizationAndOperatorApproval( + user1Sk, amount, 60 * 60, OPERATOR, RATE_ALLOWANCE, LOCKUP_ALLOWANCE, MAX_LOCKUP_PERIOD + ); + + // Verify initial approval state + (bool isApproved, uint256 initialRateAllowance, uint256 initialLockupAllowance,,,) = + payments.operatorApprovals(address(testToken), USER1, OPERATOR); + assertEq(isApproved, true); + assertEq(initialRateAllowance, RATE_ALLOWANCE); + assertEq(initialLockupAllowance, LOCKUP_ALLOWANCE); + + // Prepare for the increase operation + uint256 rateIncrease = 10 ether; + uint256 lockupIncrease = 10 ether; + + // Give USER1 more tokens for the additional deposit + testToken.mint(USER1, amount); + + // Attempt to submit as a different user + from = vm.addr(user2Sk); + vm.startPrank(from); + vm.expectRevert(abi.encodeWithSelector(Errors.SignerMustBeMsgSender.selector, from, to)); + payments.depositWithAuthorizationAndIncreaseOperatorApproval( + address(testToken), + to, + amount, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + rateIncrease, + lockupIncrease + ); + vm.stopPrank(); + } } From d775697ea58192a8b81fc8a452844cf3b3134013 Mon Sep 17 00:00:00 2001 From: Aashish Date: Fri, 5 Sep 2025 17:16:05 +0530 Subject: [PATCH 16/23] refactor: moved IERC3009 to separate file --- src/Payments.sol | 32 +---------------- src/interfaces/IERC3009.sol | 34 +++++++++++++++++++ test/DepositWithAuthorization.t.sol | 3 +- ...WithAuthorizationAndOperatorApproval.t.sol | 3 +- test/helpers/PaymentsTestHelpers.sol | 3 +- 5 files changed, 41 insertions(+), 34 deletions(-) create mode 100644 src/interfaces/IERC3009.sol diff --git a/src/Payments.sol b/src/Payments.sol index 94bf05d9..c525e7db 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -6,9 +6,9 @@ import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/Strings.sol"; - import "./Errors.sol"; import "./RateChangeQueue.sol"; +import "./interfaces/IERC3009.sol"; interface IValidator { struct ValidationResult { @@ -33,36 +33,6 @@ interface IValidator { function railTerminated(uint256 railId, address terminator, uint256 endEpoch) external; } -interface IERC3009 { - /** - * @notice Receive a transfer with a signed authorization from the payer - * @dev This has an additional check to ensure that the payee's address matches - * the caller of this function to prevent front-running attacks. - * @param from Payer's address (Authorizer) - * @param to Payee's address - * @param value Amount to be transferred - * @param validAfter The time after which this is valid (unix time) - * @param validBefore The time before which this is valid (unix time) - * @param nonce Unique nonce - * @param v v of the signature - * @param r r of the signature - * @param s s of the signature - */ - function receiveWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external; - - function authorizationState(address user, bytes32 nonce) external view returns (bool used); -} - // @title Payments contract. contract Payments is ReentrancyGuard { using SafeERC20 for IERC20; diff --git a/src/interfaces/IERC3009.sol b/src/interfaces/IERC3009.sol new file mode 100644 index 00000000..d0124ab6 --- /dev/null +++ b/src/interfaces/IERC3009.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IERC3009 is IERC20 { + /** + * @notice Receive a transfer with a signed authorization from the payer + * @dev This has an additional check to ensure that the payee's address matches + * the caller of this function to prevent front-running attacks. + * @param from Payer's address (Authorizer) + * @param to Payee's address + * @param value Amount to be transferred + * @param validAfter The time after which this is valid (unix time) + * @param validBefore The time before which this is valid (unix time) + * @param nonce Unique nonce + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + function authorizationState(address user, bytes32 nonce) external view returns (bool used); +} \ No newline at end of file diff --git a/test/DepositWithAuthorization.t.sol b/test/DepositWithAuthorization.t.sol index 96313de1..bdfe95a5 100644 --- a/test/DepositWithAuthorization.t.sol +++ b/test/DepositWithAuthorization.t.sol @@ -2,7 +2,8 @@ pragma solidity ^0.8.27; import {Test} from "forge-std/Test.sol"; -import {Payments, IERC3009} from "../src/Payments.sol"; +import {Payments} from "../src/Payments.sol"; +import {IERC3009} from "../src/interfaces/IERC3009.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {PaymentsTestHelpers} from "./helpers/PaymentsTestHelpers.sol"; import {BaseTestHelper} from "./helpers/BaseTestHelper.sol"; diff --git a/test/DepositWithAuthorizationAndOperatorApproval.t.sol b/test/DepositWithAuthorizationAndOperatorApproval.t.sol index b0b05817..bad903ca 100644 --- a/test/DepositWithAuthorizationAndOperatorApproval.t.sol +++ b/test/DepositWithAuthorizationAndOperatorApproval.t.sol @@ -2,7 +2,8 @@ pragma solidity ^0.8.27; import {Test} from "forge-std/Test.sol"; -import {Payments, IERC3009} from "../src/Payments.sol"; +import {Payments} from "../src/Payments.sol"; +import {IERC3009} from "../src/interfaces/IERC3009.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {PaymentsTestHelpers} from "./helpers/PaymentsTestHelpers.sol"; import {BaseTestHelper} from "./helpers/BaseTestHelper.sol"; diff --git a/test/helpers/PaymentsTestHelpers.sol b/test/helpers/PaymentsTestHelpers.sol index 0900ab84..4d0c18f6 100644 --- a/test/helpers/PaymentsTestHelpers.sol +++ b/test/helpers/PaymentsTestHelpers.sol @@ -2,7 +2,8 @@ pragma solidity ^0.8.27; import {Test} from "forge-std/Test.sol"; -import {Payments, IERC3009} from "../../src/Payments.sol"; +import {Payments} from "../../src/Payments.sol"; +import {IERC3009} from "../../src/interfaces/IERC3009.sol"; import {MockERC20} from "../mocks/MockERC20.sol"; import {BaseTestHelper} from "./BaseTestHelper.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; From b7f573aa263f38ab688ba98850c741fdf7c80ed3 Mon Sep 17 00:00:00 2001 From: Aashish Date: Fri, 5 Sep 2025 23:09:32 +0530 Subject: [PATCH 17/23] style: fix formatting --- src/interfaces/IERC3009.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/IERC3009.sol b/src/interfaces/IERC3009.sol index d0124ab6..7b670876 100644 --- a/src/interfaces/IERC3009.sol +++ b/src/interfaces/IERC3009.sol @@ -31,4 +31,4 @@ interface IERC3009 is IERC20 { ) external; function authorizationState(address user, bytes32 nonce) external view returns (bool used); -} \ No newline at end of file +} From c6c443a6e9bd39968f9474d18b1d5ca141a18491 Mon Sep 17 00:00:00 2001 From: Aashish Date: Fri, 5 Sep 2025 23:17:45 +0530 Subject: [PATCH 18/23] fix: use parameter type IERC3009 instead of explicit casting --- src/Payments.sol | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index c525e7db..9ac993ac 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -631,7 +631,7 @@ contract Payments is ReentrancyGuard { bytes32 r, bytes32 s ) external nonReentrant validateNonZeroAddress(to, "to") settleAccountLockupBeforeAndAfter(token, to, false) { - _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); + _depositWithAuthorization(IERC3009(token), to, amount, validAfter, validBefore, nonce, v, r, s); } /** @@ -677,7 +677,7 @@ contract Payments is ReentrancyGuard { settleAccountLockupBeforeAndAfter(token, to, false) { _setOperatorApproval(token, operator, true, rateAllowance, lockupAllowance, maxLockupPeriod); - _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); + _depositWithAuthorization(IERC3009(token), to, amount, validAfter, validBefore, nonce, v, r, s); } /** @@ -717,11 +717,11 @@ contract Payments is ReentrancyGuard { settleAccountLockupBeforeAndAfter(token, to, false) { _increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease); - _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); + _depositWithAuthorization(IERC3009(token), to, amount, validAfter, validBefore, nonce, v, r, s); } function _depositWithAuthorization( - address token, + IERC3009 token, address to, uint256 amount, uint256 validAfter, @@ -732,25 +732,25 @@ contract Payments is ReentrancyGuard { bytes32 s ) internal { // Revert if token is address(0) as authorization is not supported for native tokens - require(token != address(0), Errors.NativeTokenNotSupported()); + require(address(token) != address(0), Errors.NativeTokenNotSupported()); // Use balance-before/balance-after accounting to correctly handle fee-on-transfer tokens - uint256 balanceBefore = IERC20(token).balanceOf(address(this)); + uint256 balanceBefore = token.balanceOf(address(this)); // Call ERC-3009 transferWithAuthorization. // This will transfer 'amount' from 'to' to this contract. // The token contract itself verifies the signature. - IERC3009(token).receiveWithAuthorization(to, address(this), amount, validAfter, validBefore, nonce, v, r, s); + token.receiveWithAuthorization(to, address(this), amount, validAfter, validBefore, nonce, v, r, s); - uint256 balanceAfter = IERC20(token).balanceOf(address(this)); + uint256 balanceAfter = token.balanceOf(address(this)); uint256 actualAmount = balanceAfter - balanceBefore; // Credit the beneficiary's internal account - Account storage account = accounts[token][to]; + Account storage account = accounts[address(token)][to]; account.funds += actualAmount; // Emit an event to record the deposit, marking it as made via an off-chain signature. - emit DepositRecorded(token, to, to, actualAmount); + emit DepositRecorded(address(token), to, to, actualAmount); } /// @notice Withdraws tokens from the caller's account to the caller's account, up to the amount of currently available tokens (the tokens not currently locked in rails). From e7773ed700510acd7f028dd3fcd867fed53a5397 Mon Sep 17 00:00:00 2001 From: Aashish Date: Fri, 5 Sep 2025 23:55:01 +0530 Subject: [PATCH 19/23] refactor: MockERC20 inherit ERC20 explicitly and add IERC3009 interface explicitly --- test/mocks/MockERC20.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index 987f5fd0..b3d90b9b 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -1,14 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT pragma solidity ^0.8.27; -import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; -import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {IERC3009} from "../../src/interfaces/IERC3009.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /** * @title MockERC20 * @dev A mock ERC20 token with permit (ERC-2612) and transferWithAuthorization (ERC-3009) functionality for testing purposes. */ -contract MockERC20 is ERC20Permit { +contract MockERC20 is ERC20, ERC20Permit, IERC3009 { // --- ERC-3009 State and Constants --- mapping(address => mapping(bytes32 => bool)) private _authorizationStates; From 6b025a9ff8aeafab3eee427a7e00e28bbd42dcfd Mon Sep 17 00:00:00 2001 From: Aashish Date: Sun, 7 Sep 2025 11:57:16 +0530 Subject: [PATCH 20/23] fix: shifted to SignerMustBeMsgSender custom error for newer tests after rebase --- test/DepositWithPermitAndOperatorApproval.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/DepositWithPermitAndOperatorApproval.t.sol b/test/DepositWithPermitAndOperatorApproval.t.sol index b96b95f3..89d04119 100644 --- a/test/DepositWithPermitAndOperatorApproval.t.sol +++ b/test/DepositWithPermitAndOperatorApproval.t.sol @@ -69,7 +69,7 @@ contract DepositWithPermitAndOperatorApproval is Test, BaseTestHelper { helper.getPermitSignature(user1Sk, from, address(payments), DEPOSIT_AMOUNT, deadline); vm.startPrank(RELAYER); - vm.expectRevert(abi.encodeWithSelector(Errors.PermitRecipientMustBeMsgSender.selector, RELAYER, from)); + vm.expectRevert(abi.encodeWithSelector(Errors.SignerMustBeMsgSender.selector, RELAYER, from)); payments.depositWithPermitAndApproveOperator( address(testToken), from, @@ -298,7 +298,7 @@ contract DepositWithPermitAndOperatorApproval is Test, BaseTestHelper { helper.getPermitSignature(user1Sk, USER1, address(payments), additionalDeposit, deadline); vm.startPrank(RELAYER); - vm.expectRevert(abi.encodeWithSelector(Errors.PermitRecipientMustBeMsgSender.selector, RELAYER, from)); + vm.expectRevert(abi.encodeWithSelector(Errors.SignerMustBeMsgSender.selector, RELAYER, from)); payments.depositWithPermitAndIncreaseOperatorApproval( address(testToken), USER1, additionalDeposit, deadline, v, r, s, OPERATOR, rateIncrease, lockupIncrease ); From 5d30d188160ccd2c0e89c57b6a26132a93439744 Mon Sep 17 00:00:00 2001 From: Aashish Date: Sun, 7 Sep 2025 11:59:26 +0530 Subject: [PATCH 21/23] style: fix formatting --- src/Payments.sol | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index 9ac993ac..9f12f18d 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -492,12 +492,7 @@ contract Payments is ReentrancyGuard { uint8 v, bytes32 r, bytes32 s - ) - external - nonReentrant - validateNonZeroAddress(to, "to") - settleAccountLockupBeforeAndAfter(token, to, false) - { + ) external nonReentrant validateNonZeroAddress(to, "to") settleAccountLockupBeforeAndAfter(token, to, false) { _depositWithPermit(token, to, amount, deadline, v, r, s); } From 61e7c24dac1564b96986b1ae89ebd408d7cf23e2 Mon Sep 17 00:00:00 2001 From: Aashish Date: Thu, 11 Sep 2025 12:56:04 +0530 Subject: [PATCH 22/23] fix: passed IERC3009 token instead of just address in depositWithAuthorization related functions --- src/Payments.sol | 35 +++++++++++-------- test/DepositWithAuthorization.t.sol | 20 +++++------ ...WithAuthorizationAndOperatorApproval.t.sol | 31 ++++++---------- test/helpers/PaymentsTestHelpers.sol | 4 +-- 4 files changed, 42 insertions(+), 48 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index 9f12f18d..c747027a 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -607,7 +607,7 @@ contract Payments is ReentrancyGuard { /** * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. - * @param token The ERC-20 token address to deposit. Must conform to ERC-3009. + * @param token The ERC-3009-compliant token contract. * @param to The address whose account within the contract will be credited. * @param amount The amount of tokens to deposit. * @param validAfter The timestamp after which the authorization is valid. @@ -616,7 +616,7 @@ contract Payments is ReentrancyGuard { * @param v,r,s The signature of the authorization. */ function depositWithAuthorization( - address token, + IERC3009 token, address to, uint256 amount, uint256 validAfter, @@ -625,14 +625,19 @@ contract Payments is ReentrancyGuard { uint8 v, bytes32 r, bytes32 s - ) external nonReentrant validateNonZeroAddress(to, "to") settleAccountLockupBeforeAndAfter(token, to, false) { - _depositWithAuthorization(IERC3009(token), to, amount, validAfter, validBefore, nonce, v, r, s); + ) + external + nonReentrant + validateNonZeroAddress(to, "to") + settleAccountLockupBeforeAndAfter(address(token), to, false) + { + _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); } /** * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. * while also setting operator approval. - * @param token The ERC-20 token address to deposit. Must conform to ERC-3009. + * @param token The ERC-3009-compliant token contract. * @param to The address whose account within the contract will be credited. * @param amount The amount of tokens to deposit. * @param validAfter The timestamp after which the authorization is valid. @@ -650,7 +655,7 @@ contract Payments is ReentrancyGuard { * the current lockup period for a rail, the operator will only be able to reduce the lockup period. */ function depositWithAuthorizationAndApproveOperator( - address token, + IERC3009 token, address to, uint256 amount, uint256 validAfter, @@ -669,16 +674,16 @@ contract Payments is ReentrancyGuard { validateNonZeroAddress(operator, "operator") validateNonZeroAddress(to, "to") validateSignerIsRecipient(to) - settleAccountLockupBeforeAndAfter(token, to, false) + settleAccountLockupBeforeAndAfter(address(token), to, false) { - _setOperatorApproval(token, operator, true, rateAllowance, lockupAllowance, maxLockupPeriod); - _depositWithAuthorization(IERC3009(token), to, amount, validAfter, validBefore, nonce, v, r, s); + _setOperatorApproval(address(token), operator, true, rateAllowance, lockupAllowance, maxLockupPeriod); + _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); } /** * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. * while also setting operator approval. - * @param token The ERC-20 token address to deposit. Must conform to ERC-3009. + * @param token The ERC-3009-compliant token contract. * @param to The address whose account within the contract will be credited. * @param amount The amount of tokens to deposit. * @param validAfter The timestamp after which the authorization is valid. @@ -691,7 +696,7 @@ contract Payments is ReentrancyGuard { * @custom:constraint Operator must already be approved. */ function depositWithAuthorizationAndIncreaseOperatorApproval( - address token, + IERC3009 token, address to, uint256 amount, uint256 validAfter, @@ -709,10 +714,10 @@ contract Payments is ReentrancyGuard { validateNonZeroAddress(operator, "operator") validateNonZeroAddress(to, "to") validateSignerIsRecipient(to) - settleAccountLockupBeforeAndAfter(token, to, false) + settleAccountLockupBeforeAndAfter(address(token), to, false) { - _increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease); - _depositWithAuthorization(IERC3009(token), to, amount, validAfter, validBefore, nonce, v, r, s); + _increaseOperatorApproval(address(token), operator, rateAllowanceIncrease, lockupAllowanceIncrease); + _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); } function _depositWithAuthorization( @@ -732,7 +737,7 @@ contract Payments is ReentrancyGuard { // Use balance-before/balance-after accounting to correctly handle fee-on-transfer tokens uint256 balanceBefore = token.balanceOf(address(this)); - // Call ERC-3009 transferWithAuthorization. + // Call ERC-3009 receiveWithAuthorization. // This will transfer 'amount' from 'to' to this contract. // The token contract itself verifies the signature. token.receiveWithAuthorization(to, address(this), amount, validAfter, validBefore, nonce, v, r, s); diff --git a/test/DepositWithAuthorization.t.sol b/test/DepositWithAuthorization.t.sol index bdfe95a5..918f2a0c 100644 --- a/test/DepositWithAuthorization.t.sol +++ b/test/DepositWithAuthorization.t.sol @@ -65,7 +65,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { // Execute deposit via authorization vm.startPrank(from); - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); @@ -106,10 +106,10 @@ contract DepositWithAuthorization is Test, BaseTestHelper { ); vm.startPrank(from); - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); // Second attempt with same nonce must revert vm.expectRevert("EIP3009: authorization already used"); - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } @@ -128,7 +128,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert("EIP3009: invalid signature"); - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } @@ -149,7 +149,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert("EIP712: invalid signature"); // invalid signature should revert - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } @@ -170,7 +170,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert("EIP3009: authorization expired"); // expired window should revert - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } @@ -193,11 +193,11 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert("EIP3009: authorization not yet valid"); // not yet valid - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); // Now advance to validAfter + 1 and succeed vm.warp(validAfter + 1); - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); // Post-state capture @@ -241,7 +241,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { // Attempt to submit as a different user address relayer = vm.addr(user2Sk); vm.startPrank(relayer); - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); // Post-state capture @@ -287,7 +287,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert("EIP3009: invalid signature"); // domain mismatch - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } } diff --git a/test/DepositWithAuthorizationAndOperatorApproval.t.sol b/test/DepositWithAuthorizationAndOperatorApproval.t.sol index bad903ca..c3902790 100644 --- a/test/DepositWithAuthorizationAndOperatorApproval.t.sol +++ b/test/DepositWithAuthorizationAndOperatorApproval.t.sol @@ -67,7 +67,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.expectRevert("EIP3009: invalid signature"); payments.depositWithAuthorizationAndApproveOperator( - address(testToken), + testToken, to, amount, validAfter, @@ -103,7 +103,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert("EIP712: invalid signature"); // invalid signature should revert payments.depositWithAuthorizationAndApproveOperator( - address(testToken), + testToken, to, amount, validAfter, @@ -137,7 +137,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert("EIP3009: authorization expired"); // expired window should revert payments.depositWithAuthorizationAndApproveOperator( - address(testToken), + testToken, to, amount, validAfter, @@ -168,7 +168,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert("EIP3009: authorization not yet valid"); // not yet valid payments.depositWithAuthorizationAndApproveOperator( - address(testToken), + testToken, to, amount, validAfter, @@ -201,7 +201,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert(abi.encodeWithSelector(Errors.SignerMustBeMsgSender.selector, from, to)); payments.depositWithAuthorizationAndApproveOperator( - address(testToken), + testToken, to, amount, validAfter, @@ -259,7 +259,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { // Step 4: Execute depositWithAuthorizationAndIncreaseOperatorApproval vm.startPrank(USER1); payments.depositWithAuthorizationAndIncreaseOperatorApproval( - address(testToken), + testToken, to, additionalDeposit, validAfter, @@ -328,7 +328,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { // Step 4: Execute depositWithAuthorizationAndIncreaseOperatorApproval vm.startPrank(USER1); payments.depositWithAuthorizationAndIncreaseOperatorApproval( - address(testToken), + testToken, to, additionalDeposit, validAfter, @@ -394,7 +394,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(USER1); vm.expectRevert("EIP3009: invalid signature"); payments.depositWithAuthorizationAndIncreaseOperatorApproval( - address(testToken), + testToken, to, additionalDeposit, validAfter, @@ -463,7 +463,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { // Execute increase with existing usage vm.startPrank(USER1); payments.depositWithAuthorizationAndIncreaseOperatorApproval( - address(testToken), + testToken, to, additionalDeposit, validAfter, @@ -526,18 +526,7 @@ contract DepositWithAuthorization is Test, BaseTestHelper { vm.startPrank(from); vm.expectRevert(abi.encodeWithSelector(Errors.SignerMustBeMsgSender.selector, from, to)); payments.depositWithAuthorizationAndIncreaseOperatorApproval( - address(testToken), - to, - amount, - validAfter, - validBefore, - nonce, - v, - r, - s, - OPERATOR, - rateIncrease, - lockupIncrease + testToken, to, amount, validAfter, validBefore, nonce, v, r, s, OPERATOR, rateIncrease, lockupIncrease ); vm.stopPrank(); } diff --git a/test/helpers/PaymentsTestHelpers.sol b/test/helpers/PaymentsTestHelpers.sol index 4d0c18f6..f30e28e5 100644 --- a/test/helpers/PaymentsTestHelpers.sol +++ b/test/helpers/PaymentsTestHelpers.sol @@ -892,7 +892,7 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { vm.expectRevert( abi.encodeWithSignature("ERC20InsufficientBalance(address,uint256,uint256)", from, INITIAL_BALANCE, amount) ); - payments.depositWithAuthorization(address(testToken), to, amount, validAfter, validBefore, nonce, v, r, s); + payments.depositWithAuthorization(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); vm.stopPrank(); } @@ -936,7 +936,7 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { vm.startPrank(from); payments.depositWithAuthorizationAndApproveOperator( - address(testToken), + testToken, to, amount, validAfter, From 588e3ba16377a2203943dd07ff5155d27386c3b5 Mon Sep 17 00:00:00 2001 From: Aashish Date: Fri, 12 Sep 2025 21:32:45 +0530 Subject: [PATCH 23/23] refactor: update _increaseOperatorApproval to use IERC20 interface --- src/Payments.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Payments.sol b/src/Payments.sol index c747027a..083d82c7 100644 --- a/src/Payments.sol +++ b/src/Payments.sol @@ -363,16 +363,16 @@ contract Payments is ReentrancyGuard { uint256 rateAllowanceIncrease, uint256 lockupAllowanceIncrease ) external nonReentrant validateNonZeroAddress(operator, "operator") { - _increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease); + _increaseOperatorApproval(IERC20(token), operator, rateAllowanceIncrease, lockupAllowanceIncrease); } function _increaseOperatorApproval( - address token, + IERC20 token, address operator, uint256 rateAllowanceIncrease, uint256 lockupAllowanceIncrease ) internal { - OperatorApproval storage approval = operatorApprovals[token][msg.sender][operator]; + OperatorApproval storage approval = operatorApprovals[address(token)][msg.sender][operator]; // Operator must already be approved require(approval.isApproved, Errors.OperatorNotApproved(msg.sender, operator)); @@ -382,7 +382,7 @@ contract Payments is ReentrancyGuard { approval.lockupAllowance += lockupAllowanceIncrease; emit OperatorApprovalUpdated( - token, + address(token), msg.sender, operator, approval.isApproved, @@ -601,7 +601,7 @@ contract Payments is ReentrancyGuard { validateSignerIsRecipient(to) settleAccountLockupBeforeAndAfter(token, to, false) { - _increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease); + _increaseOperatorApproval(IERC20(token), operator, rateAllowanceIncrease, lockupAllowanceIncrease); _depositWithPermit(token, to, amount, deadline, v, r, s); } @@ -716,7 +716,7 @@ contract Payments is ReentrancyGuard { validateSignerIsRecipient(to) settleAccountLockupBeforeAndAfter(address(token), to, false) { - _increaseOperatorApproval(address(token), operator, rateAllowanceIncrease, lockupAllowanceIncrease); + _increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease); _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); }