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); } diff --git a/src/Payments.sol b/src/Payments.sol index 98b6bb9f..083d82c7 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 { @@ -91,9 +91,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, bool usedPermit - ); + 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 { @@ -225,8 +223,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)); _; } @@ -365,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)); @@ -384,7 +382,7 @@ contract Payments is ReentrancyGuard { approval.lockupAllowance += lockupAllowanceIncrease; emit OperatorApprovalUpdated( - token, + address(token), msg.sender, operator, approval.isApproved, @@ -475,7 +473,7 @@ contract Payments is ReentrancyGuard { account.funds += actualAmount; - emit DepositRecorded(token, msg.sender, to, actualAmount, false); + emit DepositRecorded(token, msg.sender, to, actualAmount); } /** @@ -524,7 +522,7 @@ contract Payments is ReentrancyGuard { account.funds += actualAmount; - emit DepositRecorded(token, to, to, actualAmount, true); + emit DepositRecorded(token, to, to, actualAmount); } /** @@ -563,7 +561,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); @@ -600,13 +598,161 @@ contract Payments is ReentrancyGuard { nonReentrant validateNonZeroAddress(operator, "operator") validateNonZeroAddress(to, "to") - validatePermitRecipient(to) + 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); } + /** + * @notice Deposits tokens using an ERC-3009 authorization in a single transaction. + * @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. + * @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( + IERC3009 token, + address to, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 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-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. + * @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( + IERC3009 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(address(token), to, false) + { + _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-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. + * @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( + IERC3009 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(address(token), to, false) + { + _increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease); + _depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s); + } + + function _depositWithAuthorization( + IERC3009 token, + 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(address(token) != address(0), Errors.NativeTokenNotSupported()); + + // Use balance-before/balance-after accounting to correctly handle fee-on-transfer tokens + uint256 balanceBefore = token.balanceOf(address(this)); + + // 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); + + uint256 balanceAfter = token.balanceOf(address(this)); + uint256 actualAmount = balanceAfter - balanceBefore; + + // Credit the beneficiary's internal account + 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(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). /// @param token The ERC20 token address to withdraw. /// @param amount The amount of tokens to withdraw. diff --git a/src/interfaces/IERC3009.sol b/src/interfaces/IERC3009.sol new file mode 100644 index 00000000..7b670876 --- /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); +} diff --git a/test/DepositWithAuthorization.t.sol b/test/DepositWithAuthorization.t.sol new file mode 100644 index 00000000..918f2a0c --- /dev/null +++ b/test/DepositWithAuthorization.t.sol @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.27; + +import {Test} from "forge-std/Test.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"; +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.getReceiveWithAuthorizationSignature( + fromPrivateKey, + address(testToken), + from, + address(payments), // receiveWithAuthorization pays to Payments contract + amount, + validAfter, + validBefore, + nonce + ); + + // Execute deposit via authorization + vm.startPrank(from); + + payments.depositWithAuthorization(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.getReceiveWithAuthorizationSignature( + fromPrivateKey, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + 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(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.getReceiveWithAuthorizationSignature( + user2Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + vm.expectRevert("EIP3009: invalid signature"); + payments.depositWithAuthorization(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.getReceiveWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // Corrupt r + r = bytes32(uint256(r) ^ 1); + + vm.startPrank(from); + vm.expectRevert("EIP712: invalid signature"); // invalid signature should revert + payments.depositWithAuthorization(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.getReceiveWithAuthorizationSignature( + 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(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.getReceiveWithAuthorizationSignature( + 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(testToken, to, amount, validAfter, validBefore, nonce, v, r, s); + + // Now advance to validAfter + 1 and succeed + vm.warp(validAfter + 1); + payments.depositWithAuthorization(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_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.getReceiveWithAuthorizationSignature( + 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 + address relayer = vm.addr(user2Sk); + vm.startPrank(relayer); + payments.depositWithAuthorization(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 { + 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.getReceiveWithAuthorizationSignature( + user1Sk, address(otherToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + vm.expectRevert("EIP3009: invalid signature"); // domain mismatch + 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 new file mode 100644 index 00000000..c3902790 --- /dev/null +++ b/test/DepositWithAuthorizationAndOperatorApproval.t.sol @@ -0,0 +1,533 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.27; + +import {Test} from "forge-std/Test.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"; +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; + 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; + 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.getReceiveWithAuthorizationSignature( + user2Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + vm.startPrank(from); + + vm.expectRevert("EIP3009: invalid signature"); + payments.depositWithAuthorizationAndApproveOperator( + 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.getReceiveWithAuthorizationSignature( + user1Sk, address(testToken), from, address(payments), amount, validAfter, validBefore, nonce + ); + + // Corrupt r + r = bytes32(uint256(r) ^ 1); + + vm.startPrank(from); + vm.expectRevert("EIP712: invalid signature"); // invalid signature should revert + payments.depositWithAuthorizationAndApproveOperator( + 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.getReceiveWithAuthorizationSignature( + 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( + 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)); + + (uint8 v, bytes32 r, bytes32 s) = helper.getReceiveWithAuthorizationSignature( + 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( + testToken, + to, + amount, + validAfter, + validBefore, + nonce, + v, + r, + s, + OPERATOR, + RATE_ALLOWANCE, + LOCKUP_ALLOWANCE, + MAX_LOCKUP_PERIOD + ); + } + + 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( + 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); + 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.getReceiveWithAuthorizationSignature( + 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( + 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.getReceiveWithAuthorizationSignature( + 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( + 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.getReceiveWithAuthorizationSignature( + user2Sk, address(testToken), from, address(payments), additionalDeposit, validAfter, validBefore, nonce + ); + + vm.startPrank(USER1); + vm.expectRevert("EIP3009: invalid signature"); + payments.depositWithAuthorizationAndIncreaseOperatorApproval( + 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.getReceiveWithAuthorizationSignature( + 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( + 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 + } + + 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( + testToken, to, amount, validAfter, validBefore, nonce, v, r, s, OPERATOR, rateIncrease, lockupIncrease + ); + vm.stopPrank(); + } +} 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 ); diff --git a/test/PaymentsEvents.t.sol b/test/PaymentsEvents.t.sol index 37563fb8..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, false); // 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,7 +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, true); + emit Payments.DepositRecorded(address(testToken), signer, signer, depositAmount); // Deposit with permit payments.depositWithPermit(address(testToken), signer, depositAmount, deadline, v, r, s); diff --git a/test/helpers/PaymentsTestHelpers.sol b/test/helpers/PaymentsTestHelpers.sol index 3ea14d9c..f30e28e5 100644 --- a/test/helpers/PaymentsTestHelpers.sol +++ b/test/helpers/PaymentsTestHelpers.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import {Test} from "forge-std/Test.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"; @@ -131,7 +132,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 +155,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 +258,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 +849,130 @@ contract PaymentsTestHelpers is Test, BaseTestHelper { payments.depositWithPermit(address(testToken), to, amount, deadline, v, r, s); vm.stopPrank(); } + + function getReceiveWithAuthorizationSignature( + 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 for ERC-3009 (MockERC20 defines its own domainSeparator unrelated to ERC2612) + bytes32 DOMAIN_SEPARATOR = MockERC20(address(token)).domainSeparator(); + + // keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 TYPEHASH = keccak256( + "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)); + + 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) = 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) + ); + payments.depositWithAuthorization(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) = getReceiveWithAuthorizationSignature( + fromPrivateKey, + address(testToken), + from, + address(payments), // pay to Payments contract + amount, + validAfter, + validBefore, + nonce + ); + + // Execute deposit via authorization + vm.startPrank(from); + + payments.depositWithAuthorizationAndApproveOperator( + 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); + } } diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index 37491adb..b3d90b9b 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -1,13 +1,171 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT pragma solidity ^0.8.27; -import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.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"; -contract MockERC20 is ERC20Permit { - constructor(string memory name, string memory symbol) ERC20(name, symbol) ERC20Permit(name) {} +/** + * @title MockERC20 + * @dev A mock ERC20 token with permit (ERC-2612) and transferWithAuthorization (ERC-3009) functionality for testing purposes. + */ +contract MockERC20 is ERC20, ERC20Permit, IERC3009 { + // --- 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)" + ); + 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; + 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; + 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) { + _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 { _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 = _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); + } + + /** + * @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 (block.chainid == _CACHED_CHAIN_ID) { + return _CACHED_DOMAIN_SEPARATOR; + } else { + return _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION); + } + } + + function _buildDomainSeparator(bytes32 _typeHash, bytes32 _name, bytes32 _version) private view returns (bytes32) { + return keccak256(abi.encode(_typeHash, _name, _version, block.chainid, address(this))); + } }