Skip to content

feat: x402BatchSettlement contract#1950

Draft
CarsonRoscoe wants to merge 11 commits intox402-foundation:mainfrom
coinbase:feat/evm-contracts-batch-settlement
Draft

feat: x402BatchSettlement contract#1950
CarsonRoscoe wants to merge 11 commits intox402-foundation:mainfrom
coinbase:feat/evm-contracts-batch-settlement

Conversation

@CarsonRoscoe
Copy link
Copy Markdown
Contributor

@CarsonRoscoe CarsonRoscoe commented Apr 7, 2026

Description

Implements x402BatchSettlement, the onchain escrow contract powering the batch-settlement x402 payment scheme. This scheme is designed for high-frequency API access, clients pre-fund a subchannel, sign off-chain cumulative vouchers per request, and the server batch-claims them onchain at its discretion. No per-request gas cost.

Deployed to Base Sepolia (0x40200e6f073aCB938e0Cf766B83f4E5286E60003) for parallel SDK development.

Contract Overview

The contract manages two layers: a service registry (one record per server) and subchannels (one per (serviceId, payer, token) triple).

Service lifecycle

Servers register a serviceId first-come-first-serve, specifying an initial payTo address, an initial authorizer, and a withdrawWindow (bounded 15 min – 30 days). Registration can be done directly or gaslessly via an EIP-712 signed registerFor. Admin operations (add/remove authorizer, update payTo, update withdraw window) are all gaslessly submittable — signed by an existing authorizer and consumed with an adminNonce to prevent replay. At least one authorizer must always remain.

Subchannel lifecycle

A subchannel is identified by (serviceId, payer, token), making services token-agnostic — any ERC-20 can be deposited. Three gasless deposit methods are supported:

  • EIP-3009 (receiveWithAuthorization) — ideal for USDC, fully off-chain
  • Permit2 (permitWitnessTransferFrom) — works for any ERC-20 with Permit2 approval, with a DepositWitness binding the deposit to a specific service
  • EIP-2612 + Permit2 — two signatures, one call; non-fatal permit failure falls through if approval already exists

Payment flow

Clients sign EIP-712 Voucher messages per request. Each voucher carries a monotonically increasing nonce and a cumulative cumulativeAmount. The server accumulates these off-chain and batches them into a single claim(serviceId, token, VoucherClaim[]) call. Claimed amounts accumulate in unsettled[serviceId][token] and are transferred to payTo via a separate settle(serviceId, token). The split lets servers amortize gas across arbitrarily many payers.

Voucher signature verification

Signatures are verified by pure ECDSA recovery — no EIP-1271. Smart contract wallets are supported via client signer delegation: a payer authorizes an EOA hot wallet to sign on their behalf (authorizeClientSigner / authorizeClientSignerFor). Delegation is per-service and uses a clientNonce for gasless replay protection.

Withdrawals

Three exit paths:

  • Cooperative (instant): Server signs an EIP-712 CooperativeWithdraw as authorizer; unclaimed deposit is refunded immediately, can batch multiple payers.
  • Gasless non-cooperative: Payer signs RequestWithdrawal (includes withdrawNonce to prevent replay after cooperative withdraw); facilitator submits requestWithdrawalFor. After the window, anyone calls withdraw.
  • Direct non-cooperative: Payer calls requestWithdrawal directly.

Both reset paths preserve the voucher nonce so old vouchers cannot be replayed after re-deposit. The withdrawNonce increments on each cooperative withdraw to prevent authorizer signature replay.

Tests

Test Results — 174/174 passed, 0 failed

Test Suite Type Tests Result
X402BatchSettlementTest Unit 98 ✅ all pass

Coverage — Production Contracts

Contract Lines Statements Branches Functions
x402BatchSettlement.sol 100% (245/245) 100% (292/292) 100% (54/54) 100% (35/35)

x402BatchSettlement is the primary new contract and hits 100% on all four coverage axes

Checklist

  • I have formatted and linted my code
  • All new and existing tests pass
  • My commits are signed (required for merge) -- you may need to rebase if you initially pushed unsigned commits

@github-actions github-actions bot added the specs Spec changes or additions label Apr 7, 2026
@CarsonRoscoe CarsonRoscoe changed the title Feat/evm contracts batch settlement feat: x402BatchSettlement contract Apr 7, 2026
Copy link
Copy Markdown

@ilikesymmetry ilikesymmetry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass gut says lots of code and would like to trim down and simplify where possible. Going to take more passes.

@ilikesymmetry
Copy link
Copy Markdown

Directionally, I think we should move towards more statelessness like this: https://gist.github.com/ilikesymmetry/b351bd6ca47a9419c91b195bce332952

@phdargen phdargen self-assigned this Apr 8, 2026
"payTo": "0xServerReceiverAddress",
"maxTimeoutSeconds": 3600,
"extra": {
"receiver": "0xServerReceiverAddress",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

receiver is redundant, should use payTo directly

/// @notice Finalize withdrawal after delay has elapsed.
/// Only the payer or receiverAuthorizer can call.
function finalizeWithdraw(ChannelConfig calldata config) external nonReentrant {
if (msg.sender != config.payer && msg.sender != config.receiverAuthorizer) {
Copy link
Copy Markdown
Collaborator

@phdargen phdargen Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to gate finalizeWithdraw? Should be fair game to be called by everyone once the grace period has passed. Then we could also get rid of finalizeWithdrawWithSignature including typehash

Comment on lines +272 to +273
| `"initiateWithdraw"` | Client requests withdrawal | Record withdrawal timestamp on channel |
| `"finalizeWithdraw"` | After delay elapses | Refund unclaimed deposit, reduce balance |
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove initiateWithdraw/finalizeWithdraw settleActions, these server as escape hatch only to be called by client directly

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

specs Spec changes or additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants