Skip to content

Commit b108148

Browse files
authored
Merge pull request #6 from PayNodeLabs/develop
Develop
2 parents 5fc94b8 + abe952c commit b108148

7 files changed

Lines changed: 394 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# AGENTS.MD — CONTRACTS
2+
3+
## OVERVIEW
4+
Foundry-based smart contracts for the PayNode stateless payment router on Base L2.
5+
6+
## STRUCTURE
7+
- `src/`: Core logic (`PayNodeRouter.sol`, `MockUSDC.sol`).
8+
- `test/`: Unit, integration, and fuzz tests (`*.t.sol`).
9+
- `script/`: Deployment and configuration scripts (`*.s.sol`).
10+
- `lib/`: Forge standard library and OpenZeppelin dependencies.
11+
12+
## WHERE TO LOOK
13+
- **Logic:** `src/PayNodeRouter.sol` contains the `pay()` and `payWithPermit()` entries.
14+
- **Config:** `script/Config.s.sol` (auto-generated) holds protocol addresses and constants.
15+
- **Tests:** `test/PayNodeRouter.t.sol` provides examples of permit signature generation.
16+
- **Deployment:** `script/DeploySepolia.s.sol` for network-specific deployment logic.
17+
18+
## CONVENTIONS
19+
- **Testing:** Use `vm.expectEmit` for all `PaymentReceived` events.
20+
- **Permits:** Always test `payWithPermit` using `vm.sign` with known private keys.
21+
- **Gas:** Monitor contract sizes with `forge build --sizes` during PRs.
22+
- **Formatting:** Strict adherence to `forge fmt`.
23+
- **Fuzzing:** Use `uint256 amount` fuzzing in tests to verify fee calculation at scale.
24+
25+
## ANTI-PATTERNS
26+
- **No Storage:** Never add `SSTORE` operations to `PayNodeRouter`. Use events only.
27+
- **No Hardcoding:** Do not hardcode addresses in `src/`. Use `script/Config.s.sol`.
28+
- **Safe Transfer:** Never use `transfer()`. Use `SafeERC20` for all token movements.
29+
- **Permit Safety:** Don't ignore the `deadline` parameter in permit functions.

DEPLOYMENT.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This document provides standardized deployment commands for the PayNode Protocol
1111
## 🧪 1. Base Sepolia (Testnet)
1212
Deploy using the specialized deployment script for the testnet.
1313

14-
- **Current v1.1 Address:** `0xB587Bc36aaCf65962eCd6Ba59e2DA76f2f575408`
14+
- **Current v1.4 Address:** `0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F`
1515

1616
```bash
1717
cd packages/contracts && \
@@ -22,7 +22,23 @@ forge script script/DeploySepolia.s.sol:DeploySepolia \
2222
--broadcast \
2323
-vvvv
2424
```
25-
*Note: If the official RPC is slow, use `https://base-sepolia-rpc.publicnode.com`.*
25+
### 🧪 1.1 Deploying Mock USDC (Testnet Only)
26+
To test M2M payments with USDC on Base Sepolia, you can deploy a mock token for sandbox testing.
27+
28+
- **Mock USDC Address:** `<NEWLY_DEPLOYED_ADDRESS>` (Base Sepolia)
29+
30+
```bash
31+
cd packages/contracts && \
32+
forge script script/DeployMockUSDC.s.sol:DeployMockUSDC \
33+
--rpc-url https://sepolia.base.org \
34+
--private-key <YOUR_PRIVATE_KEY> \
35+
--broadcast \
36+
-vvvv
37+
```
38+
*Tip: After deployment, verify the contract on Basescan to enable easy `mint` calls via the web UI:*
39+
```bash
40+
forge verify-contract <DEPLOYED_ADDRESS> src/MockUSDC.sol:MockUSDC --rpc-url https://sepolia.base.org
41+
```
2642

2743
---
2844

@@ -51,11 +67,22 @@ forge script script/DeployPOM.s.sol:DeployPOM \
5167
forge flatten src/PayNodeRouter.sol > Flattened.sol
5268
```
5369

54-
2. **Update Ecosystem Config:**
55-
Update the `ROUTER_ADDRESS` in the following locations:
70+
2. **Update & Sync Ecosystem Config:**
71+
After deployment, update the `ROUTER_ADDRESS` and `USDC_ADDRESS` (Sandbox) in each sub-package.
72+
73+
**Option A: Manual Update (Legacy Locations)**
74+
Ensure the following locations are updated if necessary:
5675
- `packages/sdk-js/src/index.ts`
5776
- `packages/sdk-python/paynode_sdk/client.py`
5877
- `apps/paynode-web/.env` (`NEXT_PUBLIC_PAYNODE_ROUTER_ADDRESS`)
5978

79+
**Option B: Automated Sync (Recommended)**
80+
The project now uses a central `paynode-config.json`. To sync new addresses across the Web app and SDKs automatically:
81+
- Update `router` and `tokens.USDC` entries in `paynode-config.json`.
82+
- Run the sync script from the project root:
83+
```bash
84+
python3 scripts/sync-config.py
85+
```
86+
6087
3. **Transfer Ownership (Optional):**
6188
If deploying with a hot wallet, consider transferring ownership to a multisig (Gnosis Safe) using `transferOwnership`.

script/Config.s.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ library Config {
77
address public constant ROUTER_SEPOLIA = 0x24cD8b68aaC209217ff5a6ef1Bf55a59f2c8Ca6F;
88
address public constant TREASURY = 0x598bF63F5449876efafa7b36b77Deb2070621C0E;
99
address public constant USDC_MAINNET = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
10-
address public constant USDC_SEPOLIA = 0xeAC1f2C7099CdaFfB91Aa3b8Ffd653Ef16935798;
10+
address public constant USDC_SEPOLIA = 0x65c088EfBDB0E03185Dbe8e258Ad0cf4Ab7946b0;
1111
uint256 public constant MIN_PAYMENT_AMOUNT = 1000;
1212
uint256 public constant FEE_BPS = 100;
1313
}

script/DeployMockUSDC.s.sol

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {Script, console} from "forge-std/Script.sol";
5+
import {MockUSDC} from "../src/MockUSDC.sol";
6+
7+
/**
8+
* @title DeployMockUSDC
9+
* @notice Script for deploying MockUSDC to testnets.
10+
*/
11+
contract DeployMockUSDC is Script {
12+
function run() external {
13+
vm.startBroadcast();
14+
15+
MockUSDC usdc = new MockUSDC();
16+
17+
console.log("----------------------------------------------");
18+
console.log("Mock USDC Deployed to:", address(usdc));
19+
console.log("Name:", usdc.name());
20+
console.log("Symbol:", usdc.symbol());
21+
console.log("Initial Balance (Deployer):", usdc.balanceOf(msg.sender));
22+
console.log("----------------------------------------------");
23+
24+
vm.stopBroadcast();
25+
}
26+
}

src/MockUSDC.sol

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,137 @@
22
pragma solidity ^0.8.20;
33

44
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
6+
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
7+
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
8+
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
59

6-
/**
7-
* @title MockUSDC
8-
* @dev Simple ERC20 for POM Demo. 6 decimals to match real USDC.
9-
* Public minting allowed for sandbox testing.
10-
*/
11-
contract MockUSDC is ERC20 {
12-
constructor() ERC20("Mock USDC", "mUSDC") {}
10+
abstract contract ERC20PermitV2 is ERC20, IERC20Permit, EIP712, Nonces {
11+
bytes32 private constant PERMIT_TYPEHASH =
12+
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
13+
14+
constructor(string memory name) EIP712(name, "2") {}
15+
16+
function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
17+
public
18+
virtual
19+
override
20+
{
21+
require(block.timestamp <= deadline, "ERC20Permit: expired deadline");
22+
23+
bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));
24+
bytes32 digest = _hashTypedDataV4(structHash);
25+
26+
address recoveredAddress = ECDSA.recover(digest, v, r, s);
27+
require(recoveredAddress == owner, "ERC20Permit: invalid signature");
28+
29+
_approve(owner, spender, value);
30+
}
31+
32+
function nonces(address owner) public view virtual override(IERC20Permit, Nonces) returns (uint256) {
33+
return super.nonces(owner);
34+
}
35+
36+
function DOMAIN_SEPARATOR() external view returns (bytes32) {
37+
return _domainSeparatorV4();
38+
}
39+
}
40+
41+
contract MockUSDC is ERC20PermitV2 {
42+
mapping(address => mapping(bytes32 => bool)) private _authorizationStates;
43+
44+
bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256(
45+
"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
46+
);
47+
48+
bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = keccak256(
49+
"ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
50+
);
51+
52+
bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH =
53+
keccak256("CancelAuthorization(address authorizer,bytes32 nonce)");
54+
55+
event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);
56+
event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce);
57+
58+
constructor() ERC20("USD Coin", "USDC") ERC20PermitV2("USD Coin") {
59+
_mint(msg.sender, 1_000_000 * 10 ** decimals());
60+
}
1361

14-
// 6 decimals to match USDC on Base
1562
function decimals() public view virtual override returns (uint8) {
1663
return 6;
1764
}
1865

19-
/**
20-
* @dev Mint tokens for testing. Publicly available for sandbox faucets.
21-
*/
2266
function mint(address to, uint256 amount) external {
2367
_mint(to, amount);
2468
}
69+
70+
function transferWithAuthorization(
71+
address from,
72+
address to,
73+
uint256 value,
74+
uint256 validAfter,
75+
uint256 validBefore,
76+
bytes32 nonce,
77+
uint8 v,
78+
bytes32 r,
79+
bytes32 s
80+
) external {
81+
_transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s);
82+
}
83+
84+
function receiveWithAuthorization(
85+
address from,
86+
address to,
87+
uint256 value,
88+
uint256 validAfter,
89+
uint256 validBefore,
90+
bytes32 nonce,
91+
uint8 v,
92+
bytes32 r,
93+
bytes32 s
94+
) external {
95+
require(msg.sender == to, "caller must be the recipient");
96+
_transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s);
97+
}
98+
99+
function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external {
100+
bytes32 structHash = keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce));
101+
bytes32 digest = _hashTypedDataV4(structHash);
102+
address signer = ECDSA.recover(digest, v, r, s);
103+
require(signer == authorizer, "invalid signature");
104+
105+
require(!_authorizationStates[authorizer][nonce], "authorization already used");
106+
_authorizationStates[authorizer][nonce] = true;
107+
108+
emit AuthorizationCanceled(authorizer, nonce);
109+
}
110+
111+
function _transferWithAuthorization(
112+
address from,
113+
address to,
114+
uint256 value,
115+
uint256 validAfter,
116+
uint256 validBefore,
117+
bytes32 nonce,
118+
uint8 v,
119+
bytes32 r,
120+
bytes32 s
121+
) internal {
122+
require(block.timestamp > validAfter, "authorization is not yet valid");
123+
require(block.timestamp < validBefore, "authorization is expired");
124+
require(!_authorizationStates[from][nonce], "authorization already used");
125+
126+
bytes32 structHash = keccak256(
127+
abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)
128+
);
129+
bytes32 digest = _hashTypedDataV4(structHash);
130+
address signer = ECDSA.recover(digest, v, r, s);
131+
require(signer == from, "invalid signature");
132+
133+
_authorizationStates[from][nonce] = true;
134+
_transfer(from, to, value);
135+
136+
emit AuthorizationUsed(from, nonce);
137+
}
25138
}

0 commit comments

Comments
 (0)