Skip to content

feat: x402BatchSettlement contract#1950

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

feat: x402BatchSettlement contract#1950
CarsonRoscoe wants to merge 40 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
function finalizeWithdraw(
ChannelConfig calldata config
) external nonReentrant {
if (msg.sender != config.payer && msg.sender != config.payerAuthorizer) {
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.

Should this accept config.payer only? Or initiateWithdraw also accept config.payerAuthorizer?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good call, bad inconsistency.

I added both because, when I first only included payerAuthorizer, I thought about redundancy. If this is an escape hatch, what's the harm in giving a little extra support to the client in case they did lose their payerAuthorizer and needed to withdraw.

I think initiateWithdraw should also accept config.payerAuthorizer

Comment on lines +431 to +433
if (amount > available) revert RefundExceedsAvailable();

ch.balance -= amount;
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.

Would prefer if amount is capped and doesnt revert similar to withdraw, such that client doesnt need to know how much is available to claim and could just claim maxInt

Suggested change
if (amount > available) revert RefundExceedsAvailable();
ch.balance -= amount;
uint128 refundAmount = amount > available ? available : amount;
if (refundAmount == 0) return;
ch.balance -= refundAmount;

event Settled(address indexed receiver, address indexed token, address indexed sender, uint128 amount);
event Refunded(bytes32 indexed channelId, address indexed sender, uint128 amount);
event WithdrawInitiated(bytes32 indexed channelId, uint128 amount, uint40 finalizeAfter);
event WithdrawFinalized(bytes32 indexed channelId, uint128 amount, address sender);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

for parity with other event styles, should sender be indexed and before amount?

bytes32 channelId = getChannelId(config);
ChannelState storage ch = channels[channelId];

bool isNew = ch.balance == 0 && ch.totalClaimed == 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

think we can just in-line this logic in the if (isNew) { and add a comment

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