feat: x402BatchSettlement contract#1950
feat: x402BatchSettlement contract#1950CarsonRoscoe wants to merge 40 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 |
contracts/evm/src/periphery/Permit2WithERC2612DepositCollector.sol
Outdated
Show resolved
Hide resolved
| 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
| if (amount > available) revert RefundExceedsAvailable(); | ||
|
|
||
| ch.balance -= amount; |
There was a problem hiding this comment.
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
| 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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
think we can just in-line this logic in the if (isNew) { and add a comment
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