feat: x402BatchSettlement contract#1950
feat: x402BatchSettlement contract#1950CarsonRoscoe wants to merge 43 commits intox402-foundation:mainfrom
Conversation
ilikesymmetry
left a comment
There was a problem hiding this comment.
First pass gut says lots of code and would like to trim down and simplify where possible. Going to take more passes.
|
Directionally, I think we should move towards more statelessness like this: https://gist.github.com/ilikesymmetry/b351bd6ca47a9419c91b195bce332952 |
| function finalizeWithdraw( | ||
| ChannelConfig calldata config | ||
| ) external nonReentrant { | ||
| if (msg.sender != config.payer && msg.sender != config.payerAuthorizer) { |
There was a problem hiding this comment.
Should this accept config.payer only? Or initiateWithdraw also accept config.payerAuthorizer?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Makes me think that maybe _isReceiverSide could be _checkSenderOneOf?
|
|
||
| /// @dev The payer-signed data. Does not include claimAmount or signature. | ||
| struct Voucher { | ||
| ChannelConfig channel; |
There was a problem hiding this comment.
This takes more calldata than we need I think. Could theoretically remove two words of calldata per voucher that are unused (receiverAuthorizer, windowDelay, salt) but would require adding a new channelId
|
|
||
| /// @dev EIP-712 entry for one row in a signed claim batch (mirrors on-chain `VoucherClaim` fields used for authorization). | ||
| bytes32 public constant CLAIM_ENTRY_TYPEHASH = | ||
| keccak256("ClaimEntry(bytes32 channelId,uint128 maxClaimableAmount,uint128 totalClaimed)"); |
There was a problem hiding this comment.
Note: Not seeing clear reason we need to sign over uint128 maxClaimableAmount as this is buyer-related and authenticated by their signature. No action required, more just an observation and if we wanted maximum brevity, we could remove.
| address payer, | ||
| address token, | ||
| uint256 amount, | ||
| bytes32 channelId, |
There was a problem hiding this comment.
Observation: This is a subset of ChannelConfig, could consider just passing the entire config over. Are we comfortable with the possibility that in the future we could want to write a collector that takes in fields that are in the config but not in this current set?
Description
Implements
x402BatchSettlement, the onchain escrow contract powering thebatch-settlementx402 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
serviceIdfirst-come-first-serve, specifying an initialpayToaddress, an initial authorizer, and awithdrawWindow(bounded 15 min – 30 days). Registration can be done directly or gaslessly via an EIP-712 signedregisterFor. Admin operations (add/remove authorizer, updatepayTo, update withdraw window) are all gaslessly submittable — signed by an existing authorizer and consumed with anadminNonceto 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:receiveWithAuthorization) — ideal for USDC, fully off-chainpermitWitnessTransferFrom) — works for any ERC-20 with Permit2 approval, with aDepositWitnessbinding the deposit to a specific servicePayment flow
Clients sign EIP-712
Vouchermessages per request. Each voucher carries a monotonically increasingnonceand a cumulativecumulativeAmount. The server accumulates these off-chain and batches them into a singleclaim(serviceId, token, VoucherClaim[])call. Claimed amounts accumulate inunsettled[serviceId][token]and are transferred topayTovia a separatesettle(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 aclientNoncefor gasless replay protection.Withdrawals
Three exit paths:
CooperativeWithdrawas authorizer; unclaimed deposit is refunded immediately, can batch multiple payers.RequestWithdrawal(includeswithdrawNonceto prevent replay after cooperative withdraw); facilitator submitsrequestWithdrawalFor. After the window, anyone callswithdraw.requestWithdrawaldirectly.Both reset paths preserve the voucher
nonceso old vouchers cannot be replayed after re-deposit. ThewithdrawNonceincrements on each cooperative withdraw to prevent authorizer signature replay.Tests
Test Results — 174/174 passed, 0 failed
X402BatchSettlementTestCoverage — Production Contracts
x402BatchSettlement.solx402BatchSettlementis the primary new contract and hits 100% on all four coverage axesChecklist