| Network | Address | Status |
|---|---|---|
| Base Sepolia | TBD | To be deployed |
| Base Mainnet | TBD | To be deployed |
function settleAndExecute(
address token,
address from,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
bytes calldata signature,
bytes32 salt,
address payTo,
uint256 facilitatorFee,
address hook,
bytes calldata hookData
) externalDescription: Atomically execute payment verification and business logic with commitment-based security
Parameters:
token: ERC-3009 token contract address (e.g., USDC)from: Payer addressvalue: Total amount including facilitatorFee (atomic units, e.g., 6 decimals for USDC)validAfter: EIP-3009 valid after timestamp (0 means immediately)validBefore: EIP-3009 expiration timestampnonce: EIP-3009 unique nonce (32 bytes) - must equal commitment hashsignature: EIP-712 signaturesalt: Unique identifier (32 bytes, generated by Resource Server)payTo: Final recipient address (for transparency)facilitatorFee: Facilitator fee amount (deducted from value)hook: Hook contract address (address(0) means no Hook)hookData: Hook parameters (encoded by resource server)
Events:
Settled(bytes32 contextKey, address payer, address token, uint256 amount, address hook, bytes32 salt, address payTo, uint256 facilitatorFee)HookExecuted(bytes32 contextKey, address hook, bytes returnData)FeeAccumulated(address facilitator, address token, uint256 amount)
Errors:
InvalidCommitment(bytes32 expected, bytes32 actual): Commitment hash mismatchAlreadySettled(bytes32 contextKey): contextKey already usedTransferFailed(address token, uint256 expected, uint256 actual): Transfer failedHubShouldNotHoldFunds(address token, uint256 balance): Hub holds balanceHookExecutionFailed(address hook, bytes reason): Hook execution failed
Commitment Calculation:
The nonce parameter must equal the commitment hash calculated as:
commitment = keccak256(abi.encodePacked(
"X402/settle/v1",
chainId,
hub, // Hub address (cross-hub replay protection)
token,
from,
value,
validAfter,
validBefore,
salt,
payTo,
facilitatorFee,
hook,
keccak256(hookData)
))This ensures all parameters are cryptographically bound to the Client's signature.
Note: The to parameter from EIP-3009 (always equals Hub address) is not included in the commitment hash to avoid redundancy, as the Hub address is already present for cross-hub replay protection.
function isSettled(bytes32 contextKey) external view returns (bool)Description: Check if contextKey has been settled
Parameters:
contextKey: Settlement context ID
Returns:
bool: Whether it has been settled
function calculateContextKey(
address from,
address token,
bytes32 nonce
) external pure returns (bytes32)Description: Calculate contextKey
Parameters:
from: Payer addresstoken: Token contract addressnonce: EIP-3009 nonce
Returns:
bytes32: contextKey = keccak256(abi.encodePacked(from, token, nonce))
function getPendingFees(address facilitator, address token) external view returns (uint256)Description: Query accumulated fees for a facilitator
Parameters:
facilitator: Facilitator addresstoken: Token contract address
Returns:
uint256: Pending fee amount in token's atomic units
function claimFees(address[] calldata tokens) externalDescription: Batch claim accumulated fees for multiple tokens (convenience method)
Parameters:
tokens: Array of token addresses to claim fees for
Events:
FeesClaimed(address facilitator, address token, uint256 amount)- Emitted for each token with non-zero balance
Note: Fees are sent to msg.sender (the facilitator). Only transfers non-zero amounts. Uses CEI pattern for security.
function setFeeOperator(address operator, bool approved) externalDescription: Authorize or revoke an operator to claim fees on behalf of the facilitator. Similar to ERC721's setApprovalForAll pattern.
Parameters:
operator: Address to grant/revoke operator statusapproved:trueto approve,falseto revoke
Events:
FeeOperatorSet(address facilitator, address operator, bool approved)
Errors:
InvalidOperator(): Operator address is zero
Use Cases:
- Batch collection of fees from multiple facilitator addresses
- Delegating fee management to governance contracts
- Multi-signature wallet management
function isFeeOperator(address facilitator, address operator) external view returns (bool)Description: Check if an address is an approved operator for a facilitator
Parameters:
facilitator: The facilitator addressoperator: The operator address to check
Returns:
bool: Whether the operator is approved
function claimFeesFor(
address facilitator,
address[] calldata tokens,
address recipient
) externalDescription: Claim accumulated fees on behalf of a facilitator (requires operator approval or being the facilitator)
Parameters:
facilitator: The facilitator whose fees to claimtokens: Array of token addresses to claimrecipient: Address to receive the fees (ifaddress(0), sends to facilitator)
Events:
FeesClaimed(address facilitator, address token, uint256 amount)- Emitted for each token with non-zero balance
Errors:
Unauthorized(): Caller is not the facilitator or an approved operator
Authorization:
- Facilitator can always claim their own fees
- Approved operators can claim on behalf of the facilitator
- Operators can specify custom recipient addresses
Note: Only transfers non-zero amounts. Uses CEI pattern for security.
mapping(bytes32 => bool) public settledDescription: Settlement marker (idempotency guarantee)
mapping(address => mapping(address => uint256)) public pendingFeesDescription: Accumulated facilitator fees (facilitator => token => amount)
mapping(address => mapping(address => bool)) public feeOperatorsDescription: Operator approvals for fee claims (facilitator => operator => approved)
1. Calculate commitment hash from all parameters
↓
2. Verify nonce equals commitment (prevents tampering)
↓
3. Calculate contextKey
↓
4. Check idempotency (require(!settled[contextKey]))
↓
5. Mark as settled (CEI pattern)
↓
6. Call token.transferWithAuthorization (funds enter Hub)
↓
7. Verify balance ≥ value
↓
8. Accumulate facilitator fee (pendingFees[msg.sender][token] += facilitatorFee)
↓
9. Approve and call hook.execute with (value - facilitatorFee)
↓
10. Verify balance == 0 (ensure no fund holding)
↓
11. Emit Settled event
- Commitment-Based Security: All parameters bound to Client signature via hash
- Reentrancy Protection: Uses OpenZeppelin ReentrancyGuard
- Idempotency: Prevents duplicate settlement through
settledmapping - CEI Pattern: Modify state first, then call external contracts
- Balance Verification:
- Verify balance ≥ value after transfer
- Verify balance == 0 after Hook execution
- Error Propagation: Hook failure causes entire transaction to revert
| Operation | First Time | Subsequent |
|---|---|---|
| Commitment verification | ~800 gas | ~800 gas |
| Storage write (settled) | ~20k gas | ~5k gas (repeat) |
| Fee accumulation (cold) | ~20k gas | - |
| Fee accumulation (hot) | - | ~5k gas |
| transferWithAuthorization | ~60k gas | - |
| Hook call | ~50k-200k gas | - |
| Total (first settlement) | ~151k-301k gas | - |
| Total (subsequent) | ~136k-286k gas | - |
| Overhead vs old version | +21k gas (first), +6k gas (subsequent) | - |
Facilitator Fee Claiming:
- Single token: ~50k gas
- Batch 3 tokens: ~120k gas
- Savings over 10 settlements: ~215k gas (vs immediate transfers)
const settlementRouter = new ethers.Contract(
SETTLEMENT_ROUTER_ADDRESS,
SETTLEMENT_ROUTER_ABI,
signer
);
const tx = await settlementRouter.settleAndExecute(
tokenAddress,
from,
value,
validAfter,
validBefore,
nonce,
signature,
hookAddress,
hookData,
{
gasLimit: 300000 // Adjust based on Hook complexity
}
);
const receipt = await tx.wait();
console.log('Transaction hash:', receipt.transactionHash);const contextKey = ethers.keccak256(
ethers.solidityPacked(
['address', 'address', 'bytes32'],
[from, token, nonce]
)
);
const isSettled = await settlementRouter.isSettled(contextKey);
console.log('Is settled:', isSettled);// Simple claim: facilitator claims their own fees
const tokens = [USDC_ADDRESS, USDT_ADDRESS];
const tx = await settlementRouter.claimFees(tokens);
await tx.wait();
console.log('Fees claimed');// Facilitator authorizes an operator to claim fees
const operatorAddress = '0x...';
const tx = await settlementRouter.setFeeOperator(operatorAddress, true);
await tx.wait();
console.log('Operator approved');
// Check if operator is approved
const isApproved = await settlementRouter.isFeeOperator(
facilitatorAddress,
operatorAddress
);
console.log('Is approved:', isApproved);// Operator collects fees from multiple facilitator addresses
const mainAddress = '0x...';
const facilitatorAddresses = ['0x...', '0x...', '0x...'];
const tokens = [USDC_ADDRESS];
// Each facilitator must approve mainAddress first
// (only done once per facilitator)
// Operator collects to main address
for (const facilitator of facilitatorAddresses) {
const tx = await settlementRouter.claimFeesFor(
facilitator,
tokens,
mainAddress // All fees go to mainAddress
);
await tx.wait();
}
console.log('All fees collected to main address');// Facilitator delegates fee management to governance contract
const governanceContract = '0x...';
const tx = await settlementRouter.setFeeOperator(governanceContract, true);
await tx.wait();
// Now governance contract can claim fees to treasury
// (called by governance contract)
const treasuryAddress = '0x...';
const claimTx = await settlementRouter.claimFeesFor(
facilitatorAddress,
tokens,
treasuryAddress
);
await claimTx.wait();
console.log('Fees claimed to treasury via governance');settlementRouter.on('Settled', (contextKey, payer, token, amount, hook, event) => {
console.log('Settlement completed:', {
contextKey,
payer,
token,
amount: amount.toString(),
hook,
txHash: event.transactionHash
});
});
settlementRouter.on('HookExecuted', (contextKey, hook, returnData, event) => {
console.log('Hook executed:', {
contextKey,
hook,
returnData,
txHash: event.transactionHash
});
});
settlementRouter.on('FeeOperatorSet', (facilitator, operator, approved, event) => {
console.log('Fee operator changed:', {
facilitator,
operator,
approved,
txHash: event.transactionHash
});
});
settlementRouter.on('FeesClaimed', (facilitator, token, amount, event) => {
console.log('Fees claimed:', {
facilitator,
token,
amount: amount.toString(),
txHash: event.transactionHash
});
});try {
const tx = await settlementRouter.settleAndExecute(...);
await tx.wait();
} catch (error) {
if (error.message.includes('AlreadySettled')) {
console.error('This payment has already been settled');
} else if (error.message.includes('TransferFailed')) {
console.error('Token transfer failed - check balance and allowance');
} else if (error.message.includes('HubShouldNotHoldFunds')) {
console.error('Hook did not consume all funds');
} else if (error.message.includes('HookExecutionFailed')) {
console.error('Hook execution failed:', error);
} else if (error.message.includes('InvalidOperator')) {
console.error('Cannot set zero address as operator');
} else if (error.message.includes('Unauthorized')) {
console.error('Not authorized to claim fees for this facilitator');
} else {
console.error('Unknown error:', error);
}
}Verify contract using Etherscan or Basescan:
forge verify-contract <address> SettlementRouter \
--chain-id 84532 \
--watch- Internal audit
- Third-party audit
- Bug bounty
- Added fee operator authorization mechanism
- New functions:
setFeeOperator,isFeeOperator,claimFeesFor - Enables batch collection of fees from multiple facilitator addresses
- Supports delegation of fee management to governance contracts
- New events:
FeeOperatorSet - New errors:
InvalidOperator,Unauthorized
- Initial version
- Support for EIP-3009 authorization
- Hook extension mechanism
- Idempotency guarantee