Skip to content

Latest commit

 

History

History
511 lines (404 loc) · 13.5 KB

File metadata and controls

511 lines (404 loc) · 13.5 KB

SettlementRouter Contract API Documentation

Contract Addresses

Network Address Status
Base Sepolia TBD To be deployed
Base Mainnet TBD To be deployed

Core Interfaces

settleAndExecute

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
) external

Description: Atomically execute payment verification and business logic with commitment-based security

Parameters:

  • token: ERC-3009 token contract address (e.g., USDC)
  • from: Payer address
  • value: 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 timestamp
  • nonce: EIP-3009 unique nonce (32 bytes) - must equal commitment hash
  • signature: EIP-712 signature
  • salt: 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 mismatch
  • AlreadySettled(bytes32 contextKey): contextKey already used
  • TransferFailed(address token, uint256 expected, uint256 actual): Transfer failed
  • HubShouldNotHoldFunds(address token, uint256 balance): Hub holds balance
  • HookExecutionFailed(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.

isSettled

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

calculateContextKey

function calculateContextKey(
    address from,
    address token,
    bytes32 nonce
) external pure returns (bytes32)

Description: Calculate contextKey

Parameters:

  • from: Payer address
  • token: Token contract address
  • nonce: EIP-3009 nonce

Returns:

  • bytes32: contextKey = keccak256(abi.encodePacked(from, token, nonce))

getPendingFees

function getPendingFees(address facilitator, address token) external view returns (uint256)

Description: Query accumulated fees for a facilitator

Parameters:

  • facilitator: Facilitator address
  • token: Token contract address

Returns:

  • uint256: Pending fee amount in token's atomic units

claimFees

function claimFees(address[] calldata tokens) external

Description: 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.

setFeeOperator

function setFeeOperator(address operator, bool approved) external

Description: 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 status
  • approved: true to approve, false to 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

isFeeOperator

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 address
  • operator: The operator address to check

Returns:

  • bool: Whether the operator is approved

claimFeesFor

function claimFeesFor(
    address facilitator,
    address[] calldata tokens,
    address recipient
) external

Description: Claim accumulated fees on behalf of a facilitator (requires operator approval or being the facilitator)

Parameters:

  • facilitator: The facilitator whose fees to claim
  • tokens: Array of token addresses to claim
  • recipient: Address to receive the fees (if address(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.

State Variables

settled

mapping(bytes32 => bool) public settled

Description: Settlement marker (idempotency guarantee)

pendingFees

mapping(address => mapping(address => uint256)) public pendingFees

Description: Accumulated facilitator fees (facilitator => token => amount)

feeOperators

mapping(address => mapping(address => bool)) public feeOperators

Description: Operator approvals for fee claims (facilitator => operator => approved)

Execution Flow

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

Security Mechanisms

  1. Commitment-Based Security: All parameters bound to Client signature via hash
  2. Reentrancy Protection: Uses OpenZeppelin ReentrancyGuard
  3. Idempotency: Prevents duplicate settlement through settled mapping
  4. CEI Pattern: Modify state first, then call external contracts
  5. Balance Verification:
    • Verify balance ≥ value after transfer
    • Verify balance == 0 after Hook execution
  6. Error Propagation: Hook failure causes entire transaction to revert

Gas Consumption

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)

Usage Examples

Calling from Facilitator

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);

Query Settlement Status

const contextKey = ethers.keccak256(
  ethers.solidityPacked(
    ['address', 'address', 'bytes32'],
    [from, token, nonce]
  )
);

const isSettled = await settlementRouter.isSettled(contextKey);
console.log('Is settled:', isSettled);

Claim Facilitator Fees

// 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');

Set Fee Operator

// 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);

Batch Collection Scenario

// 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');

Governance Contract Scenario

// 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');

Event Listening

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
  });
});

Error Handling

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);
  }
}

On-chain Verification

Verify contract using Etherscan or Basescan:

forge verify-contract <address> SettlementRouter \
  --chain-id 84532 \
  --watch

Audit Status

  • Internal audit
  • Third-party audit
  • Bug bounty

Changelog

v1.1.0 (Current)

  • 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

v1.0.0 (To be released)

  • Initial version
  • Support for EIP-3009 authorization
  • Hook extension mechanism
  • Idempotency guarantee