From 784b15a1442b435d80b5c1253a141865ca5b653d Mon Sep 17 00:00:00 2001 From: geminilclaw Date: Tue, 24 Mar 2026 17:08:24 +0800 Subject: [PATCH 1/4] feat: Add EIP-2612 permit support to MockUSDC, include deployment script, and update testnet configuration. --- DEPLOYMENT.md | 33 ++++++++++++++++++++++++++++++--- script/Config.s.sol | 2 +- script/DeployMockUSDC.s.sol | 26 ++++++++++++++++++++++++++ src/MockUSDC.sol | 8 ++++++-- 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 script/DeployMockUSDC.s.sol diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index f434ffd..3e44bfd 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -22,7 +22,23 @@ forge script script/DeploySepolia.s.sol:DeploySepolia \ --broadcast \ -vvvv ``` -*Note: If the official RPC is slow, use `https://base-sepolia-rpc.publicnode.com`.* +### 🧪 1.1 Deploying Mock USDC (Testnet Only) +To test M2M payments with USDC on Base Sepolia, you can deploy a mock token for sandbox testing. + +- **Mock USDC Address:** `` (Base Sepolia) + +```bash +cd packages/contracts && \ +forge script script/DeployMockUSDC.s.sol:DeployMockUSDC \ + --rpc-url https://sepolia.base.org \ + --private-key \ + --broadcast \ + -vvvv +``` +*Tip: After deployment, verify the contract on Basescan to enable easy `mint` calls via the web UI:* +```bash +forge verify-contract src/MockUSDC.sol:MockUSDC --rpc-url https://sepolia.base.org +``` --- @@ -51,11 +67,22 @@ forge script script/DeployPOM.s.sol:DeployPOM \ forge flatten src/PayNodeRouter.sol > Flattened.sol ``` -2. **Update Ecosystem Config:** - Update the `ROUTER_ADDRESS` in the following locations: +2. **Update & Sync Ecosystem Config:** + After deployment, update the `ROUTER_ADDRESS` and `USDC_ADDRESS` (Sandbox) in each sub-package. + + **Option A: Manual Update (Legacy Locations)** + Ensure the following locations are updated if necessary: - `packages/sdk-js/src/index.ts` - `packages/sdk-python/paynode_sdk/client.py` - `apps/paynode-web/.env` (`NEXT_PUBLIC_PAYNODE_ROUTER_ADDRESS`) + **Option B: Automated Sync (Recommended)** + The project now uses a central `paynode-config.json`. To sync new addresses across the Web app and SDKs automatically: + - Update `router` and `tokens.USDC` entries in `paynode-config.json`. + - Run the sync script from the project root: + ```bash + python3 scripts/sync-config.py + ``` + 3. **Transfer Ownership (Optional):** If deploying with a hot wallet, consider transferring ownership to a multisig (Gnosis Safe) using `transferOwnership`. diff --git a/script/Config.s.sol b/script/Config.s.sol index 13ba006..80831e7 100644 --- a/script/Config.s.sol +++ b/script/Config.s.sol @@ -7,7 +7,7 @@ library Config { address public constant ROUTER_SEPOLIA = 0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F; address public constant TREASURY = 0x598bF63F5449876efafa7b36b77Deb2070621C0E; address public constant USDC_MAINNET = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; - address public constant USDC_SEPOLIA = 0xeAC1f2C7099CdaFfB91Aa3b8Ffd653Ef16935798; + address public constant USDC_SEPOLIA = 0x109AEddD656Ed2761d1e210E179329105039c784; uint256 public constant MIN_PAYMENT_AMOUNT = 1000; uint256 public constant FEE_BPS = 100; } diff --git a/script/DeployMockUSDC.s.sol b/script/DeployMockUSDC.s.sol new file mode 100644 index 0000000..4d6e987 --- /dev/null +++ b/script/DeployMockUSDC.s.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; + +/** + * @title DeployMockUSDC + * @notice Script for deploying MockUSDC to testnets. + */ +contract DeployMockUSDC is Script { + function run() external { + vm.startBroadcast(); + + MockUSDC usdc = new MockUSDC(); + + console.log("----------------------------------------------"); + console.log("Mock USDC Deployed to:", address(usdc)); + console.log("Name:", usdc.name()); + console.log("Symbol:", usdc.symbol()); + console.log("Initial Balance (Deployer):", usdc.balanceOf(msg.sender)); + console.log("----------------------------------------------"); + + vm.stopBroadcast(); + } +} diff --git a/src/MockUSDC.sol b/src/MockUSDC.sol index 8d56ab4..c74fac9 100644 --- a/src/MockUSDC.sol +++ b/src/MockUSDC.sol @@ -2,14 +2,18 @@ pragma solidity ^0.8.20; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; /** * @title MockUSDC * @dev Simple ERC20 for POM Demo. 6 decimals to match real USDC. * Public minting allowed for sandbox testing. + * Includes EIP-2612 Permit support. */ -contract MockUSDC is ERC20 { - constructor() ERC20("Mock USDC", "mUSDC") {} +contract MockUSDC is ERC20, ERC20Permit { + constructor() ERC20("USD Coin", "USDC") ERC20Permit("USD Coin") { + _mint(msg.sender, 1_000_000 * 10 ** decimals()); + } // 6 decimals to match USDC on Base function decimals() public view virtual override returns (uint8) { From f70e40381c8e31e235a9191d00db69f4a817dd21 Mon Sep 17 00:00:00 2001 From: geminilclaw Date: Fri, 27 Mar 2026 01:20:42 +0800 Subject: [PATCH 2/4] feat: Implement EIP-3009 `transferWithAuthorization` in MockUSDC, add dedicated tests, and update the Sepolia USDC address. --- AGENTS.md | 29 +++++++ DEPLOYMENT.md | 2 +- script/Config.s.sol | 2 +- src/MockUSDC.sol | 149 ++++++++++++++++++++++++++++++++---- test/MockUSDC_EIP3009.t.sol | 148 +++++++++++++++++++++++++++++++++++ test/PayNodeRouter.t.sol | 50 ++++++++++++ 6 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 AGENTS.md create mode 100644 test/MockUSDC_EIP3009.t.sol diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c126598 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS.MD — CONTRACTS + +## OVERVIEW +Foundry-based smart contracts for the PayNode stateless payment router on Base L2. + +## STRUCTURE +- `src/`: Core logic (`PayNodeRouter.sol`, `MockUSDC.sol`). +- `test/`: Unit, integration, and fuzz tests (`*.t.sol`). +- `script/`: Deployment and configuration scripts (`*.s.sol`). +- `lib/`: Forge standard library and OpenZeppelin dependencies. + +## WHERE TO LOOK +- **Logic:** `src/PayNodeRouter.sol` contains the `pay()` and `payWithPermit()` entries. +- **Config:** `script/Config.s.sol` (auto-generated) holds protocol addresses and constants. +- **Tests:** `test/PayNodeRouter.t.sol` provides examples of permit signature generation. +- **Deployment:** `script/DeploySepolia.s.sol` for network-specific deployment logic. + +## CONVENTIONS +- **Testing:** Use `vm.expectEmit` for all `PaymentReceived` events. +- **Permits:** Always test `payWithPermit` using `vm.sign` with known private keys. +- **Gas:** Monitor contract sizes with `forge build --sizes` during PRs. +- **Formatting:** Strict adherence to `forge fmt`. +- **Fuzzing:** Use `uint256 amount` fuzzing in tests to verify fee calculation at scale. + +## ANTI-PATTERNS +- **No Storage:** Never add `SSTORE` operations to `PayNodeRouter`. Use events only. +- **No Hardcoding:** Do not hardcode addresses in `src/`. Use `script/Config.s.sol`. +- **Safe Transfer:** Never use `transfer()`. Use `SafeERC20` for all token movements. +- **Permit Safety:** Don't ignore the `deadline` parameter in permit functions. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3e44bfd..8cb2629 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -11,7 +11,7 @@ This document provides standardized deployment commands for the PayNode Protocol ## 🧪 1. Base Sepolia (Testnet) Deploy using the specialized deployment script for the testnet. -- **Current v1.1 Address:** `0xB587Bc36aaCf65962eCd6Ba59e2DA76f2f575408` +- **Current v1.4 Address:** `0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F` ```bash cd packages/contracts && \ diff --git a/script/Config.s.sol b/script/Config.s.sol index 80831e7..a5d3829 100644 --- a/script/Config.s.sol +++ b/script/Config.s.sol @@ -7,7 +7,7 @@ library Config { address public constant ROUTER_SEPOLIA = 0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F; address public constant TREASURY = 0x598bF63F5449876efafa7b36b77Deb2070621C0E; address public constant USDC_MAINNET = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; - address public constant USDC_SEPOLIA = 0x109AEddD656Ed2761d1e210E179329105039c784; + address public constant USDC_SEPOLIA = 0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0; uint256 public constant MIN_PAYMENT_AMOUNT = 1000; uint256 public constant FEE_BPS = 100; } diff --git a/src/MockUSDC.sol b/src/MockUSDC.sol index c74fac9..719db24 100644 --- a/src/MockUSDC.sol +++ b/src/MockUSDC.sol @@ -2,28 +2,149 @@ pragma solidity ^0.8.20; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; - -/** - * @title MockUSDC - * @dev Simple ERC20 for POM Demo. 6 decimals to match real USDC. - * Public minting allowed for sandbox testing. - * Includes EIP-2612 Permit support. - */ -contract MockUSDC is ERC20, ERC20Permit { - constructor() ERC20("USD Coin", "USDC") ERC20Permit("USD Coin") { +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol"; + +abstract contract ERC20PermitV2 is ERC20, IERC20Permit, EIP712, Nonces { + bytes32 private constant PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + constructor(string memory name) EIP712(name, "2") {} + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual override { + require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); + + bytes32 structHash = keccak256( + abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline) + ); + bytes32 digest = _hashTypedDataV4(structHash); + + address recoveredAddress = ECDSA.recover(digest, v, r, s); + require(recoveredAddress == owner, "ERC20Permit: invalid signature"); + + _approve(owner, spender, value); + } + + function nonces(address owner) public view virtual override(IERC20Permit, Nonces) returns (uint256) { + return super.nonces(owner); + } + + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparatorV4(); + } +} + +contract MockUSDC is ERC20PermitV2 { + mapping(address => mapping(bytes32 => bool)) private _authorizationStates; + + bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = + keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"); + + bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"); + + bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH = + keccak256("CancelAuthorization(address authorizer,bytes32 nonce)"); + + event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); + event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); + + constructor() ERC20("USD Coin", "USDC") ERC20PermitV2("USD Coin") { _mint(msg.sender, 1_000_000 * 10 ** decimals()); } - // 6 decimals to match USDC on Base function decimals() public view virtual override returns (uint8) { return 6; } - /** - * @dev Mint tokens for testing. Publicly available for sandbox faucets. - */ function mint(address to, uint256 amount) external { _mint(to, amount); } + + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); + } + + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(msg.sender == to, "caller must be the recipient"); + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); + } + + function cancelAuthorization( + address authorizer, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + bytes32 structHash = keccak256( + abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce) + ); + bytes32 digest = _hashTypedDataV4(structHash); + address signer = ECDSA.recover(digest, v, r, s); + require(signer == authorizer, "invalid signature"); + + require(!_authorizationStates[authorizer][nonce], "authorization already used"); + _authorizationStates[authorizer][nonce] = true; + + emit AuthorizationCanceled(authorizer, nonce); + } + + function _transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) internal { + require(block.timestamp > validAfter, "authorization is not yet valid"); + require(block.timestamp < validBefore, "authorization is expired"); + require(!_authorizationStates[from][nonce], "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; + _transfer(from, to, value); + + emit AuthorizationUsed(from, nonce); + } } diff --git a/test/MockUSDC_EIP3009.t.sol b/test/MockUSDC_EIP3009.t.sol new file mode 100644 index 0000000..46a0282 --- /dev/null +++ b/test/MockUSDC_EIP3009.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; + +contract MockUSDC_EIP3009_Test is Test { + MockUSDC public token; + + uint256 constant ALICE_KEY = 0xA11CE; + uint256 constant BOB_KEY = 0xB0B; + + address public alice; + address public bob; + + function setUp() public { + alice = vm.addr(ALICE_KEY); + bob = vm.addr(BOB_KEY); + token = new MockUSDC(); + token.mint(alice, 1000e6); + } + + function test_transferWithAuthorization() public { + uint256 amount = 100e6; + bytes32 nonce = keccak256("nonce-1"); + uint256 validBefore = block.timestamp + 1 hours; + + (uint8 v, bytes32 r, bytes32 s) = _signAuth(ALICE_KEY, alice, bob, amount, 0, validBefore, nonce); + + assertEq(token.balanceOf(alice), 1000e6); + assertEq(token.balanceOf(bob), 0); + + token.transferWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v, r, s); + + assertEq(token.balanceOf(alice), 900e6); + assertEq(token.balanceOf(bob), 100e6); + } + + function test_receiveWithAuthorization_revertIfNotRecipient() public { + uint256 amount = 50e6; + bytes32 nonce = keccak256("nonce-2"); + uint256 validBefore = block.timestamp + 1 hours; + + (uint8 v, bytes32 r, bytes32 s) = _signAuth(ALICE_KEY, alice, bob, amount, 0, validBefore, nonce); + + address charlie = vm.addr(0xC); + vm.prank(charlie); + vm.expectRevert("caller must be the recipient"); + token.receiveWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v, r, s); + } + + function test_cancelAuthorization() public { + bytes32 nonce = keccak256("nonce-3"); + + (uint8 v, bytes32 r, bytes32 s) = _signCancel(ALICE_KEY, alice, nonce); + token.cancelAuthorization(alice, nonce, v, r, s); + + uint256 amount = 10e6; + uint256 validBefore = block.timestamp + 1 hours; + (uint8 v2, bytes32 r2, bytes32 s2) = _signAuth(ALICE_KEY, alice, bob, amount, 0, validBefore, nonce); + + vm.expectRevert("authorization already used"); + token.transferWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v2, r2, s2); + } + + function test_transferWithAuthorization_revertIfExpired() public { + uint256 amount = 10e6; + bytes32 nonce = keccak256("nonce-4"); + + vm.warp(block.timestamp + 2 hours); + uint256 validBefore = block.timestamp - 1 hours; + + (uint8 v, bytes32 r, bytes32 s) = _signAuth(ALICE_KEY, alice, bob, amount, 0, validBefore, nonce); + + vm.expectRevert("authorization is expired"); + token.transferWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v, r, s); + } + + function test_transferWithAuthorization_revertIfReused() public { + uint256 amount = 10e6; + bytes32 nonce = keccak256("nonce-5"); + uint256 validBefore = block.timestamp + 1 hours; + + (uint8 v, bytes32 r, bytes32 s) = _signAuth(ALICE_KEY, alice, bob, amount, 0, validBefore, nonce); + + token.transferWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v, r, s); + + vm.expectRevert("authorization already used"); + token.transferWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v, r, s); + } + + function test_transferWithAuthorization_revertIfWrongSigner() public { + uint256 amount = 10e6; + bytes32 nonce = keccak256("nonce-6"); + uint256 validBefore = block.timestamp + 1 hours; + + (uint8 v, bytes32 r, bytes32 s) = _signAuth(BOB_KEY, alice, bob, amount, 0, validBefore, nonce); + + vm.expectRevert("invalid signature"); + token.transferWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v, r, s); + } + + function _signAuth( + uint256 signerKey, + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce + ) internal view returns (uint8 v, bytes32 r, bytes32 s) { + bytes32 structHash = keccak256( + abi.encode( + token.TRANSFER_WITH_AUTHORIZATION_TYPEHASH(), + from, + to, + value, + validAfter, + validBefore, + nonce + ) + ); + return _signEIP712(signerKey, structHash); + } + + function _signCancel( + uint256 signerKey, + address authorizer, + bytes32 nonce + ) internal view returns (uint8 v, bytes32 r, bytes32 s) { + bytes32 structHash = keccak256( + abi.encode( + token.CANCEL_AUTHORIZATION_TYPEHASH(), + authorizer, + nonce + ) + ); + return _signEIP712(signerKey, structHash); + } + + function _signEIP712( + uint256 signerKey, + bytes32 structHash + ) internal view returns (uint8 v, bytes32 r, bytes32 s) { + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (v, r, s) = vm.sign(signerKey, digest); + } +} diff --git a/test/PayNodeRouter.t.sol b/test/PayNodeRouter.t.sol index b62fb30..e25e63f 100644 --- a/test/PayNodeRouter.t.sol +++ b/test/PayNodeRouter.t.sol @@ -120,4 +120,54 @@ contract PayNodeRouterTest is Test { assertEq(usdc.balanceOf(merchant), 99 * 10 ** 6); assertEq(usdc.balanceOf(treasury), 1 * 10 ** 6); } + + function testFuzz_Pay(uint256 amount, address fuzzMerchant) public { + vm.assume(fuzzMerchant != address(0) && fuzzMerchant != treasury && fuzzMerchant != payer); + vm.assume(amount >= 1000 && amount <= 1000000 * 10 ** 6); + + usdc.mint(payer, amount); + + bytes32 orderId = keccak256("fuzz_order"); + + uint256 payerBalanceBefore = usdc.balanceOf(payer); + uint256 merchantBalanceBefore = usdc.balanceOf(fuzzMerchant); + uint256 treasuryBalanceBefore = usdc.balanceOf(treasury); + + vm.prank(payer); + usdc.approve(address(router), amount); + + uint256 expectedFee = amount / 100; + uint256 expectedMerchantAmt = amount - expectedFee; + + vm.prank(payer); + router.pay(address(usdc), fuzzMerchant, amount, orderId); + + assertEq(usdc.balanceOf(fuzzMerchant), merchantBalanceBefore + expectedMerchantAmt); + assertEq(usdc.balanceOf(treasury), treasuryBalanceBefore + expectedFee); + assertEq(usdc.balanceOf(payer), payerBalanceBefore - amount); + } + + function testFuzz_PayWithPermit(uint256 amount, address fuzzMerchant) public { + vm.assume(fuzzMerchant != address(0) && fuzzMerchant != treasury && fuzzMerchant != payer); + vm.assume(amount >= 1000 && amount <= 1000000 * 10 ** 6); + + // Mint extra to ensure enough balance for high fuzz amounts + usdc.mint(payer, amount); + + bytes32 orderId = keccak256("fuzz_order_permit"); + uint256 deadline = block.timestamp + 1 hours; + + bytes32 permitTypehash = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 structHash = keccak256(abi.encode(permitTypehash, payer, address(router), amount, usdc.nonces(payer), deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", usdc.DOMAIN_SEPARATOR(), structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(payerPrivateKey, digest); + + uint256 treasuryBalanceBefore = usdc.balanceOf(treasury); + + vm.prank(payer); + router.payWithPermit(payer, address(usdc), fuzzMerchant, amount, orderId, deadline, v, r, s); + + assertEq(usdc.balanceOf(treasury), treasuryBalanceBefore + (amount / 100)); + } } From 003706f2ab553b721de88a517a1eeb192a461809 Mon Sep 17 00:00:00 2001 From: geminilclaw Date: Fri, 27 Mar 2026 05:33:12 +0800 Subject: [PATCH 3/4] fix: Corrected `validBefore` calculation in `MockUSDC_EIP3009` test and applied minor formatting adjustments across contract files. --- src/MockUSDC.sol | 48 ++++++++++++++----------------------- test/MockUSDC_EIP3009.t.sol | 39 +++++++++--------------------- 2 files changed, 29 insertions(+), 58 deletions(-) diff --git a/src/MockUSDC.sol b/src/MockUSDC.sol index 719db24..2499985 100644 --- a/src/MockUSDC.sol +++ b/src/MockUSDC.sol @@ -13,20 +13,14 @@ abstract contract ERC20PermitV2 is ERC20, IERC20Permit, EIP712, Nonces { constructor(string memory name) EIP712(name, "2") {} - function permit( - address owner, - address spender, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) public virtual override { + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + public + virtual + override + { require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); - bytes32 structHash = keccak256( - abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline) - ); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)); bytes32 digest = _hashTypedDataV4(structHash); address recoveredAddress = ECDSA.recover(digest, v, r, s); @@ -47,11 +41,13 @@ abstract contract ERC20PermitV2 is ERC20, IERC20Permit, EIP712, Nonces { contract MockUSDC is ERC20PermitV2 { mapping(address => mapping(bytes32 => bool)) private _authorizationStates; - bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = - keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"); - - bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = - keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"); + bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256( + "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + + bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = keccak256( + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH = keccak256("CancelAuthorization(address authorizer,bytes32 nonce)"); @@ -100,23 +96,15 @@ contract MockUSDC is ERC20PermitV2 { _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); } - function cancelAuthorization( - address authorizer, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external { - bytes32 structHash = keccak256( - abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce) - ); + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external { + bytes32 structHash = keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)); bytes32 digest = _hashTypedDataV4(structHash); address signer = ECDSA.recover(digest, v, r, s); require(signer == authorizer, "invalid signature"); - + require(!_authorizationStates[authorizer][nonce], "authorization already used"); _authorizationStates[authorizer][nonce] = true; - + emit AuthorizationCanceled(authorizer, nonce); } @@ -144,7 +132,7 @@ contract MockUSDC is ERC20PermitV2 { _authorizationStates[from][nonce] = true; _transfer(from, to, value); - + emit AuthorizationUsed(from, nonce); } } diff --git a/test/MockUSDC_EIP3009.t.sol b/test/MockUSDC_EIP3009.t.sol index 46a0282..e28bbb6 100644 --- a/test/MockUSDC_EIP3009.t.sol +++ b/test/MockUSDC_EIP3009.t.sol @@ -6,10 +6,10 @@ import {MockUSDC} from "../src/MockUSDC.sol"; contract MockUSDC_EIP3009_Test is Test { MockUSDC public token; - + uint256 constant ALICE_KEY = 0xA11CE; uint256 constant BOB_KEY = 0xB0B; - + address public alice; address public bob; @@ -66,7 +66,7 @@ contract MockUSDC_EIP3009_Test is Test { function test_transferWithAuthorization_revertIfExpired() public { uint256 amount = 10e6; bytes32 nonce = keccak256("nonce-4"); - + vm.warp(block.timestamp + 2 hours); uint256 validBefore = block.timestamp - 1 hours; @@ -110,38 +110,21 @@ contract MockUSDC_EIP3009_Test is Test { bytes32 nonce ) internal view returns (uint8 v, bytes32 r, bytes32 s) { bytes32 structHash = keccak256( - abi.encode( - token.TRANSFER_WITH_AUTHORIZATION_TYPEHASH(), - from, - to, - value, - validAfter, - validBefore, - nonce - ) + abi.encode(token.TRANSFER_WITH_AUTHORIZATION_TYPEHASH(), from, to, value, validAfter, validBefore, nonce) ); return _signEIP712(signerKey, structHash); } - function _signCancel( - uint256 signerKey, - address authorizer, - bytes32 nonce - ) internal view returns (uint8 v, bytes32 r, bytes32 s) { - bytes32 structHash = keccak256( - abi.encode( - token.CANCEL_AUTHORIZATION_TYPEHASH(), - authorizer, - nonce - ) - ); + function _signCancel(uint256 signerKey, address authorizer, bytes32 nonce) + internal + view + returns (uint8 v, bytes32 r, bytes32 s) + { + bytes32 structHash = keccak256(abi.encode(token.CANCEL_AUTHORIZATION_TYPEHASH(), authorizer, nonce)); return _signEIP712(signerKey, structHash); } - function _signEIP712( - uint256 signerKey, - bytes32 structHash - ) internal view returns (uint8 v, bytes32 r, bytes32 s) { + function _signEIP712(uint256 signerKey, bytes32 structHash) internal view returns (uint8 v, bytes32 r, bytes32 s) { bytes32 digest = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); (v, r, s) = vm.sign(signerKey, digest); } From abe952c1f89dfec6247818155c0614dc07d1be37 Mon Sep 17 00:00:00 2001 From: geminilclaw Date: Fri, 27 Mar 2026 05:35:11 +0800 Subject: [PATCH 4/4] style: reformat long lines in `PayNodeRouter.t.sol` for improved readability. --- test/PayNodeRouter.t.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/PayNodeRouter.t.sol b/test/PayNodeRouter.t.sol index e25e63f..5abc064 100644 --- a/test/PayNodeRouter.t.sol +++ b/test/PayNodeRouter.t.sol @@ -157,8 +157,10 @@ contract PayNodeRouterTest is Test { bytes32 orderId = keccak256("fuzz_order_permit"); uint256 deadline = block.timestamp + 1 hours; - bytes32 permitTypehash = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); - bytes32 structHash = keccak256(abi.encode(permitTypehash, payer, address(router), amount, usdc.nonces(payer), deadline)); + bytes32 permitTypehash = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 structHash = + keccak256(abi.encode(permitTypehash, payer, address(router), amount, usdc.nonces(payer), deadline)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", usdc.DOMAIN_SEPARATOR(), structHash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(payerPrivateKey, digest);