Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 31 additions & 4 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 && \
Expand All @@ -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:** `<NEWLY_DEPLOYED_ADDRESS>` (Base Sepolia)

```bash
cd packages/contracts && \
forge script script/DeployMockUSDC.s.sol:DeployMockUSDC \
--rpc-url https://sepolia.base.org \
--private-key <YOUR_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 <DEPLOYED_ADDRESS> src/MockUSDC.sol:MockUSDC --rpc-url https://sepolia.base.org
```

---

Expand Down Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion script/Config.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
26 changes: 26 additions & 0 deletions script/DeployMockUSDC.s.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
135 changes: 124 additions & 11 deletions src/MockUSDC.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading