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 f434ffd..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 && \ @@ -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..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 = 0xeAC1f2C7099CdaFfB91Aa3b8Ffd653Ef16935798; + address public constant USDC_SEPOLIA = 0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0; 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..2499985 100644 --- a/src/MockUSDC.sol +++ b/src/MockUSDC.sol @@ -2,24 +2,137 @@ pragma solidity ^0.8.20; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +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"; -/** - * @title MockUSDC - * @dev Simple ERC20 for POM Demo. 6 decimals to match real USDC. - * Public minting allowed for sandbox testing. - */ -contract MockUSDC is ERC20 { - constructor() ERC20("Mock USDC", "mUSDC") {} +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..e28bbb6 --- /dev/null +++ b/test/MockUSDC_EIP3009.t.sol @@ -0,0 +1,131 @@ +// 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..5abc064 100644 --- a/test/PayNodeRouter.t.sol +++ b/test/PayNodeRouter.t.sol @@ -120,4 +120,56 @@ 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)); + } }