Skip to content
Closed
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
61 changes: 61 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# PayNode Protocol Contract Deployment Guide

This document provides standardized deployment commands for the PayNode Protocol.

## 🗂️ Deployment Constants
- **Compiler Version:** v0.8.20
- **Optimizer:** Enabled (200 runs)
- **Protocol Treasury:** `0x598bF63F5449876efafa7b36b77Deb2070621C0E`

---
## 🧪 1. Base Sepolia (Testnet)
Deploy using the specialized deployment script for the testnet.

- **Current v1.1 Address:** `0xB587Bc36aaCf65962eCd6Ba59e2DA76f2f575408`

```bash
cd packages/contracts && \
...
forge script script/DeploySepolia.s.sol:DeploySepolia \
--rpc-url https://sepolia.base.org \
--private-key <YOUR_PRIVATE_KEY> \
--broadcast \
-vvvv
```
*Note: If the official RPC is slow, use `https://base-sepolia-rpc.publicnode.com`.*

---

## 🚀 2. Base Mainnet (Production)
Deploy using the specialized deployment script for the production environment.

```bash
cd packages/contracts && \
forge script script/DeployPOM.s.sol:DeployPOM \
--rpc-url https://mainnet.base.org \
--private-key <YOUR_PRIVATE_KEY> \
--broadcast \
-vvvv
```

---

## 📝 3. Post-Deployment Checklist

1. **Verify on Basescan:**
- After deployment, note the `PayNodeRouter Deployed to:` address in the console output.
- Go to [Basescan](https://basescan.org/) and search for the address.
- Click "Contract" -> "Verify and Publish".
- Use `Solidity (Single File)` mode. If you need a flattened file, run:
```bash
forge flatten src/PayNodeRouter.sol > Flattened.sol
```

2. **Update Ecosystem Config:**
Update the `ROUTER_ADDRESS` in the following locations:
- `packages/sdk-js/src/index.ts`
- `packages/sdk-python/paynode_sdk/client.py`
- `apps/paynode-web/.env` (`NEXT_PUBLIC_PAYNODE_ROUTER_ADDRESS`)

3. **Transfer Ownership (Optional):**
If deploying with a hot wallet, consider transferring ownership to a multisig (Gnosis Safe) using `transferOwnership`.
11 changes: 11 additions & 0 deletions script/Config.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// Generated by scripts/sync-config.py
library Config {
address public constant ROUTER_MAINNET = 0x92e20164FC457a2aC35f53D06268168e6352b200;
address public constant ROUTER_SEPOLIA = 0xB587Bc36aaCf65962eCd6Ba59e2DA76f2f575408;
address public constant TREASURY = 0x598bF63F5449876efafa7b36b77Deb2070621C0E;
address public constant USDC_MAINNET = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
address public constant USDC_SEPOLIA = 0xeAC1f2C7099CdaFfB91Aa3b8Ffd653Ef16935798;
}
2 changes: 1 addition & 1 deletion script/DeploySepolia.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {PayNodeRouter} from "../src/PayNodeRouter.sol";
contract DeploySepolia is Script {
function run() external {
address treasury = 0x598bF63F5449876efafa7b36b77Deb2070621C0E;

vm.startBroadcast();
PayNodeRouter router = new PayNodeRouter(treasury);
console.log("PayNodeRouter Deployed to:", address(router));
Expand Down
2 changes: 1 addition & 1 deletion src/MockUSDC.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/**
* @title MockUSDC
Expand Down
80 changes: 39 additions & 41 deletions src/PayNodeRouter.sol
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
// SPDX-License-Identifier: BSL-1.1
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
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/access/Ownable.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";

/**
* @title PayNodeRouter
* @author AgentPay Protocol (PayNode Labs)
* @notice This contract is licensed under Business Source License 1.1.
* Commercial use by competitors is restricted for the first 2 years.
*
*
* @dev Non-custodial, multi-coin payment router for the Agentic Economy.
* Stateless design ensures minimal gas costs (no SSTORE for order state).
* Protocol takes a fixed 1% fee (100 BPS).
*/
contract PayNodeRouter is ReentrancyGuard, Ownable {
contract PayNodeRouter is Ownable2Step, Pausable {
using SafeERC20 for IERC20;

address public protocolTreasury;
Expand All @@ -26,20 +26,24 @@ contract PayNodeRouter is ReentrancyGuard, Ownable {
uint256 public constant PROTOCOL_FEE_BPS = 100;
uint256 public constant MAX_BPS = 10000;

// Redesigned event to match SDK requirements (indexed orderId, token verification)
error InvalidAddress();
error AmountMustBeGreaterThanZero();

// Redesigned event to match SDK requirements (indexed orderId, token verification, chainId)
event PaymentReceived(
bytes32 indexed orderId,
address indexed merchant,
address indexed payer,
address token,
uint256 amount,
uint256 fee
uint256 fee,
uint256 chainId
);

event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury);

constructor(address _protocolTreasury) Ownable(msg.sender) {
require(_protocolTreasury != address(0), "Invalid treasury address");
if (_protocolTreasury == address(0)) revert InvalidAddress();
protocolTreasury = _protocolTreasury;
}

Expand All @@ -48,25 +52,34 @@ contract PayNodeRouter is ReentrancyGuard, Ownable {
* @param _newTreasury The new address for fee collection.
*/
function updateTreasury(address _newTreasury) external onlyOwner {
require(_newTreasury != address(0), "Invalid treasury");
if (_newTreasury == address(0)) revert InvalidAddress();
address old = protocolTreasury;
protocolTreasury = _newTreasury;
emit TreasuryUpdated(old, _newTreasury);
}

/**
* @notice Pause the contract in case of emergencies
*/
function pause() external onlyOwner {
_pause();
}

/**
* @notice Unpause the contract
*/
function unpause() external onlyOwner {
_unpause();
}

/**
* @dev Process an M2M payment for any ERC20 token. Payer must have already approved this contract.
* @param token The ERC20 token address being used for payment (e.g. USDC, USDT).
* @param merchant The address of the merchant receiving 99% of the funds.
* @param amount The total payment amount.
* @param orderId External tracking ID from the merchant's system (e.g., UUID mapped to bytes32).
*/
function pay(
address token,
address merchant,
uint256 amount,
bytes32 orderId
) external nonReentrant {
function pay(address token, address merchant, uint256 amount, bytes32 orderId) external whenNotPaused {
_processPayment(msg.sender, token, merchant, amount, orderId);
}

Expand All @@ -75,6 +88,7 @@ contract PayNodeRouter is ReentrancyGuard, Ownable {
* Allows AI agents to sign locally and pay in a single on-chain transaction.
*/
function payWithPermit(
address payer,
address token,
address merchant,
uint256 amount,
Expand All @@ -83,49 +97,33 @@ contract PayNodeRouter is ReentrancyGuard, Ownable {
uint8 v,
bytes32 r,
bytes32 s
) external nonReentrant {
) external whenNotPaused {
// 1. Consume permit to grant allowance to this router
IERC20Permit(token).permit(
msg.sender,
address(this),
amount,
deadline,
v,
r,
s
);
IERC20Permit(token).permit(payer, address(this), amount, deadline, v, r, s);

// 2. Execute the payment split
_processPayment(msg.sender, token, merchant, amount, orderId);
_processPayment(payer, token, merchant, amount, orderId);
}

/**
* @dev Internal split logic
*/
function _processPayment(
address payer,
address token,
address merchant,
uint256 amount,
bytes32 orderId
) internal {
require(merchant != address(0), "Invalid merchant address");
require(token != address(0), "Invalid token address");
require(amount > 0, "Amount must be greater than 0");
function _processPayment(address payer, address token, address merchant, uint256 amount, bytes32 orderId) internal {
if (merchant == address(0) || token == address(0)) revert InvalidAddress();
if (amount == 0) revert AmountMustBeGreaterThanZero();

// Calculate 1% fee
uint256 fee = (amount * PROTOCOL_FEE_BPS) / MAX_BPS;
uint256 merchantAmount = amount - fee;

// Execute atomic non-custodial transfers
IERC20(token).safeTransferFrom(payer, merchant, merchantAmount);

if (fee > 0) {
IERC20(token).safeTransferFrom(payer, protocolTreasury, fee);
}

// Emit event for SDK webhook listeners
// The SDK MUST verify the 'token' address to prevent fake-token attacks.
emit PaymentReceived(orderId, merchant, payer, token, amount, fee);
emit PaymentReceived(orderId, merchant, payer, token, amount, fee, block.chainid);
}
}
43 changes: 24 additions & 19 deletions test/PayNodeRouter.t.sol
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/PayNodeRouter.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {Test} from "forge-std/Test.sol";
import {PayNodeRouter} from "../src/PayNodeRouter.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

// Mock USDC/USDT with Permit for testing
contract MockToken is ERC20, ERC20Permit {
Expand All @@ -18,7 +18,7 @@ contract MockToken is ERC20, ERC20Permit {
function mint(address to, uint256 amount) external {
_mint(to, amount);
}

function decimals() public view override returns (uint8) {
return _decimals;
}
Expand All @@ -31,7 +31,7 @@ contract PayNodeRouterTest is Test {

address public treasury = address(1);
address public merchant = address(2);

uint256 public payerPrivateKey = 0xA11CE;
address public payer;

Expand All @@ -41,21 +41,22 @@ contract PayNodeRouterTest is Test {
address indexed payer,
address token,
uint256 amount,
uint256 fee
uint256 fee,
uint256 chainId
);

function setUp() public {
payer = vm.addr(payerPrivateKey);

// Mock Base USDC (6 decimals)
usdc = new MockToken("Base USDC", "USDC", 6);
// Mock Tether USD (6 decimals)
usdt = new MockToken("Tether USD", "USDT", 6);

router = new PayNodeRouter(treasury);

// Mint initial balances
usdc.mint(payer, 1000 * 10 ** 6);
usdc.mint(payer, 1000 * 10 ** 6);
usdt.mint(payer, 1000 * 10 ** 6);
}

Expand All @@ -68,15 +69,15 @@ contract PayNodeRouterTest is Test {

uint256 expectedFee = 1 * 10 ** 6;
vm.expectEmit(true, true, true, true);
emit PaymentReceived(orderId, merchant, payer, address(usdc), paymentAmount, expectedFee);
emit PaymentReceived(orderId, merchant, payer, address(usdc), paymentAmount, expectedFee, block.chainid);

vm.prank(payer);
router.pay(address(usdc), merchant, paymentAmount, orderId);

assertEq(usdc.balanceOf(merchant), 99 * 10 ** 6);
assertEq(usdc.balanceOf(treasury), 1 * 10 ** 6);
}

function test_Pay_USDT() public {
uint256 paymentAmount = 50 * 10 ** 6;
bytes32 orderId = keccak256("order_agent_usdt_01");
Expand All @@ -86,7 +87,7 @@ contract PayNodeRouterTest is Test {

uint256 expectedFee = 5 * 10 ** 5; // 0.5 USDT
vm.expectEmit(true, true, true, true);
emit PaymentReceived(orderId, merchant, payer, address(usdt), paymentAmount, expectedFee);
emit PaymentReceived(orderId, merchant, payer, address(usdt), paymentAmount, expectedFee, block.chainid);

vm.prank(payer);
router.pay(address(usdt), merchant, paymentAmount, orderId);
Expand All @@ -100,18 +101,22 @@ contract PayNodeRouterTest is Test {
bytes32 orderId = keccak256("order_agent_002");
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), paymentAmount, 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), paymentAmount, 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 expectedFee = 1 * 10 ** 6;
vm.expectEmit(true, true, true, true);
emit PaymentReceived(orderId, merchant, payer, address(usdc), paymentAmount, expectedFee);
emit PaymentReceived(orderId, merchant, payer, address(usdc), paymentAmount, expectedFee, block.chainid);

vm.prank(payer);
router.payWithPermit(address(usdc), merchant, paymentAmount, orderId, deadline, v, r, s);
// We use an agent to send the transaction to test the Relayer functionality properly!
address agent = address(uint160(0x12345));
vm.prank(agent);
router.payWithPermit(payer, address(usdc), merchant, paymentAmount, orderId, deadline, v, r, s);

assertEq(usdc.balanceOf(merchant), 99 * 10 ** 6);
assertEq(usdc.balanceOf(treasury), 1 * 10 ** 6);
Expand Down
Loading