Add authCapture scheme specification#1425
Add authCapture scheme specification#1425A1igator wants to merge 4 commits intox402-foundation:mainfrom
authCapture scheme specification#1425Conversation
Introduces the `escrow` scheme for x402, built on Base's Commerce Payments Protocol. Supports two settlement paths: authorize (funds held in escrow) and charge (direct to receiver), both refundable post-settlement. Refs: x402-foundation#834, x402-foundation#1011 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
🟡 Heimdall Review Status
|
|
@A1igator is attempting to deploy a commit to the Coinbase Team on Vercel. A member of the Team first needs to authorize it. |
|
Good catch on the spec/implementation mismatch for simulation — that's exactly what #1377 tracks. The There's a community contributor looking at a fix for |
Yes verify should include tx simulation, we have a PR up to add it to the exact implementation here: https://github.com/coinbase/x402/pull/1474 |
|
Thanks for putting this together @A1igator! As a general comment, |
| | `escrowAddress` | Yes | `address` | AuthCaptureEscrow contract address | | ||
| | `operatorAddress` | Yes | `address` | Operator address | | ||
| | `tokenCollector` | Yes | `address` | Token collector contract address | | ||
| | `settlementMethod` | No | `"authorize" \| "charge"` | Settlement path. Default: `"authorize"` | |
There was a problem hiding this comment.
Not sure about this branching based on metadata in extra. The payment flow seems to be quite different between the options, so maybe these should be 2 separate schemes?
There was a problem hiding this comment.
For exact we have assetTransferMethod = {3009, permit2} in extra but the payment flow is identical, assetTransferMethod is just an implementation detail
There was a problem hiding this comment.
I'm open to splitting it into 2 but variable input and settlement is basically identical meaning the schemes would be very similar (only difference being what function the facilitator calls, charge or authorize). That's why I thought it might make sense to have them together
| 2. **Charge**: Facilitator calls `charge()` on the operator — funds go directly to receiver | ||
| 3. **Resource delivered**: Server returns the resource (HTTP 200) | ||
|
|
||
| Post-settlement, the operator can refund within `refundExpiry` if needed. Unlike the authorize path, the payer cannot `reclaim()` — funds are already with the receiver. |
There was a problem hiding this comment.
If the funds are directly send to the receiver (server), how are refunds supposed to work? How is it decided if a refund is needed? Can a client request a refund? What happens in case of disputes? If the funds are never held in the escrow, why is this under a escrow scheme?
There was a problem hiding this comment.
Exact already guarantees that no payment is made on server failure. So I suppose whats covered here would be if the client is not happy with the delivered service which is inherently subjective
There was a problem hiding this comment.
So base commerce-payments is unopinionated on most of those questions and it would be implementation specific. You can check out our implementation at https://www.x402r.org/ .
I agree the naming might be a bit confusing so maybe "commerce" scheme would be better?
Regarding server failure and subjectiveness, correct. I will say one thing objective the middleware currently doesn't do is double check the payload matches a merchant set scheme but that could be added later.
There was a problem hiding this comment.
yes I'd prefer "commerce" as scheme name aligning with the protocol it is build on
There was a problem hiding this comment.
Still not sure what the value add of the charge() mode is wrt exact. If funds are in the servers wallet, how do they flow back to the client? Can the operator claw back the funds? Or is it supposed to pay the refund out of its own pocket?
Seems to be strictly better and more straightforward to just keep the funds in the escrow until the challenge window is expired
There was a problem hiding this comment.
Charge (and refund as defined by base commerce-payments more specifically which is what charge is good for) is meant to be for scenarios where like a marketplace wants to guarantee certain amount of refunds as they only allow above certain reputation merchants for example. Another scenario is merchants themselves paying out of pocket or out of a bond they're supposed to put up.
Putting funds in the escrow for long periods of time is always strictly better for the client yes but it comes with the tradeoffs of merchant not getting their money fast even if they have a low refund rate that they could cover out of pocket.
Ditto on "commerce". Will rename next pass.
|
Could you please clarify who the operator is in the x402 context? Is it the facilitator or another separate entity? If its the facilitator, should it exposes additional endpoints: POST /capture, POST /void, POST /refund? |
|
Currently x402 is a stateless request-response protocol. Escrow introduces a long-lived lifecycle that extends beyond the original HTTP request. Could you clarify the state requirements for all the participants (client/server/facilitator)? Do we need to persists sth like a payment ID? The spec doesnt define the Can the server request a capture? How can the server correlate captured/refunded payments with the original request? |
|
Hey all! Thanks for the thoughtful responses! I will address and add more verification as per exact scheme upgrades. I will also generalize the escrow_scheme.md Regarding the operator, base commerce-payments is agnostic to who the operator could be. It could be the facilitator (although personally not sure I'd recommend that as the facilitator is meant to do activities on behalf of a user not be a trusted entity itself), smart contracts like our operator factory produces at x402r (examples here: https://docs.x402r.org/contracts/examples), or a merchant platform like Shopify in the original commerce-payments announcement. The paymentInfo and paymentInfo hash can work as payment id. I will investigate PAYMENT-RESPONSE to see if it makes sense to include! Thanks for the heads up! Server capture again depends on operator implementation. They can in our setup at x402r. |
- Generalize scheme_escrow.md to be network-agnostic - Tighten verification: strict amount equality, tokenCollector recipient check, settlement simulation - Add error codes section and assetTransferMethod note - Simplify nonce derivation explanation
|
Hey all!I think I replied to or resolved all of the comments and pushed an update to the spec to generalize scheme_escrow.md, and tighten verification I did check PAYMENT-RESPONSE and it does conceptually make sense to me. I checked the upto scheme and it seems to use SettlementResponse for that effect but SettlementResponse doesn't seem to be extendible for schemes currently? Only for extensions? So that seems to be blocked by another PR to fix that. I could add it to the spec but wanted to double check since implementation would be blocked. Also regarding state, PaymentInfo and/or PaymentInfoHash have to be somehow tracked. How they are tracked somewhat depends on the operator implementation because the operator entity/contract could keep track. Otherwise the parties have to keep track of it themselves manually. Can add this clarification to the spec if makes sense there. Thanks again for all the thoughtful responses! |
## Summary - **Escrow scheme spec rewritten** to align with [coinbase/x402 PR #1425](https://github.com/coinbase/x402/pull/1425) and [issue #1011](https://github.com/coinbase/x402/issues/1011) — adds settlement methods (authorize/charge), nonce derivation, 11-step verification logic, settlement logic, error codes, PaymentInfo struct with all fields, and expiry ordering - **All contract addresses updated** to unified CREATE3 addresses (same on every chain) - **Missing contracts added**: ArbiterRegistry, RefundRequestEvidence, ReceiverRefundCollector, SignatureCondition/SignatureRefundRequest factories, and all condition/combinator factories - **Supported chains expanded** from 2 (Base + Base Sepolia) to 11 chains - **PaymentInfo struct fixed** in architecture docs (was missing `preApprovalExpiry`, `refundExpiry`, `salt`, had wrong field ordering) - **Payment flow diagrams fixed** to route through operator (not escrow directly) - **Roadmap updated** with escrow scheme spec submission status and multi-chain deployment ## Test plan - [ ] Preview with `npx mint dev` and verify all pages render - [ ] Verify all contract addresses match `@x402r/core` config - [ ] Verify escrow scheme spec matches PR #1425 content - [ ] Check all internal links work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Thanks a lot for the update @A1igator! The changes look good but I think the spec still leaves too much out as implementation detail. I think a client needs to be able to fully understand the entire payment lifecycle based on the initial 402 response. The authorization is well speced out, but everything after the initial request remains operator specific. All the client knows is the operator address but how to infer from this alone how to interact with it after the initial request? If we leave all of this out of spec, a client needs to know out of band implementation specifics of each operator. Once a client deposited in the escrow, how can they claim a refund? How do they know if their deposit has been charged? How can the server request a charge and when? On resource delivery or after refundExpirySeconds has passed? I also think we should just define the facilitator as the operator. The facilitators role is the payment orchestration, so feels only natural (can I get your thoughts on this @fabrice-cheng?). This simplifies the spec and has it fully self-contained. Then define new API interfaces for /capture /refund etc including who can call them when and under which circumstances. Do client/server need gas or is there a way for the facilitator to sponsor all operations? |
|
Thanks for the suggestion @phdargen but I think providing information can be done without making facilitator the operator. I would recommend a separate extension (like this one we've been building here: BackTrackCo/x402r-scheme#27 and will send over when ready) to get all the information you would like to pass through the 402 flow to let the user know how to interact with the operator. Facilitator as the operator would a) Make the facilitator heavily trusted which goes against x402 principles as listed in the readme: "Trust minimizing: all payment schemes must not allow for the facilitator or resource server to move funds, other than in accordance with client intentions". b) Would break protocols like ours which let's you pick your third party arbiters which can even be chained together. c) Break smart contract operators (which we also have) which are important in trust minimizing the operator with things like escrow periods. There might be a version of this scheme that bundles the extension above and scheme together but I feel like we're ballooning the proposal then and opinionating it towards the refund usecase. (it can also be used for sessions for example like the Agentokratia proposal which has nothing to do with refunds: https://github.com/coinbase/x402/issues/834) Would also love to know @fabrice-cheng's thoughts! Thanks again for the review! |
|
I will say maybe we could have /capture /refund, etc... interfaces that simply enact a signature's request from the authorized party through to the contracts (same way settle works) if you'd like the facilitator to simply pay for gas for every operation. I need to spec this out properly though and figure out what signature scheme makes sense if wanted. I'm also not quite sure what the benefits are vs a paymaster but could be wrong. |
| "extra": { | ||
| "name": "USDC", | ||
| "version": "2", | ||
| "escrowAddress": "0xEscrowAddress", |
There was a problem hiding this comment.
thoughts on defaulting x402 spec to a unique Escrow/TokenCollector (i.e: like Permit2)
or allowing this flexibility?
The payload is really specific to AuthCaptureEscrow smart contract
There was a problem hiding this comment.
Firstly, I think the flexibility should definitely be allowed because while base commerce-payments contracts are pretty good and well designed, there's lots of innovation still left possible at the escrow layer without breaking this scheme. For example:
- Adding partialVoid to allow for partial refunds in an escrow.
- Adding AAVE or other defi for capital efficiency for funds in the escrow.
- Gas cost improvements.
We already do 1 as it was asked for. TokenCollector's also appear to be tied to an AuthCaptureEscrow hence why they need to both be optional.
Now whether they should be optional and just default to the canonical ones if not set is a different question. Since our protocol already had to fork to do 1, we have to set it either way so this doesn't affect us but can add it if wanted.
| "x402Version": 2, | ||
| "accepts": [ | ||
| { | ||
| "scheme": "escrow", |
There was a problem hiding this comment.
auth-capture vs escrow?
There was a problem hiding this comment.
Renamed to "commerce" as per Duke's suggestion above: #1425 (comment)
| "escrowAddress": "0xEscrowAddress", | ||
| "operatorAddress": "0xOperatorAddress", | ||
| "tokenCollector": "0xCollectorAddress", | ||
| "settlementMethod": "authorize", |
There was a problem hiding this comment.
shouldn't this always be "authorize"?
There was a problem hiding this comment.
There's also "charge" as I explained above: #1425 (comment)
Implementation is 95% identical (just function call difference) so thought made sense to include here.
| } | ||
| }, | ||
| "payload": { | ||
| "authorization": { |
There was a problem hiding this comment.
do we need to pass this?
There was a problem hiding this comment.
Technically no but then some values like validAfter would be assumed. Depends how verbose/informational we want to be
- Rename escrow → commerce per reviewer feedback - Add expiry fields to PaymentRequirements example - Align expiry field descriptions and example values Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Hi @A1igator , First of all, thanks for putting this together, and apologies for the back-and-forth. Tl;DR:
Two-phase payments: the client authorizes a hold on funds (escrowed on-chain), and the All EVM
Naming (Commerce Protocol → x402)
PaymentRequirementsTop-level fields (standard x402)
|
| Field | Type | Required | Description |
|---|---|---|---|
assetTransferMethod |
string |
No | "eip3009" (default) or "permit2" |
captureAuthorizer |
string |
Yes | Address authorized to capture/void/refund (server or facilitator) |
name |
string |
Yes | EIP-712 token name (for signing, EIP-3009 only) |
version |
string |
Yes | EIP-712 token version (for signing, EIP-3009 only) |
captureDeadline |
number |
Yes | Unix timestamp — must capture before this |
refundDeadline |
number |
Yes | Unix timestamp — refunds allowed until this |
minFeeBps |
number |
Yes | Minimum fee in basis points |
maxFeeBps |
number |
Yes | Maximum fee in basis points |
feeRecipient |
string |
Yes | Address that receives the fee portion |
salt |
string |
Yes | Unique entropy per payment (bytes32 hex) |
Example
{
"scheme": "authCapture",
"network": "eip155:8453",
"amount": "50000000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0xMerchantAddress",
"maxTimeoutSeconds": 10800,
"extra": {
"name": "USD Coin",
"version": "2",
"captureAuthorizer": "0xCaptureAuthorityAddress",
"captureDeadline": 1744243200,
"refundDeadline": 1746835200,
"minFeeBps": 0,
"maxFeeBps": 250,
"feeRecipient": "0xFeeRecipientAddress",
"salt": "0x0000000000000000000000000000000000000000000000000000000000000001"
}
}
captureAuthorizer: The server sets this to its own address to manage captures directly, or to the facilitator's address to delegate. Committed on-chain — only this address can callcapture(),void(), orrefund().
A server MAY advertise both eip3009 and permit2 in accepts[]. The client picks based on token support and existing approvals.
Nonce Construction
Unlike the exact scheme (which uses random nonces), authCapture nonces are deterministic — derived from a hash of the payment parameters. This binds the token transfer authorization to the specific payment, preventing reuse across different payments.
The nonce is the payer-agnostic PaymentInfo hash:
paymentInfoWithZeroPayer = PaymentInfo {
operator: extra.captureAuthorizer
payer: 0x0000000000000000000000000000000000000000
receiver: payTo
token: asset
maxAmount: amount
preApprovalExpiry: now + maxTimeoutSeconds ← client picks this
authorizationExpiry: extra.captureDeadline
refundExpiry: extra.refundDeadline
minFeeBps: extra.minFeeBps
maxFeeBps: extra.maxFeeBps
feeReceiver: extra.feeRecipient
salt: extra.salt
}
innerHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, paymentInfoWithZeroPayer))
nonce = keccak256(abi.encode(chainId, AUTH_CAPTURE_ESCROW_ADDRESS, innerHash))
The payer is zeroed so that the nonce is the same regardless of who pays — the client computes the nonce before including their own address.
preApprovalExpiry is chosen by the client as now + maxTimeoutSeconds. This value flows through the payload as validBefore (EIP-3009) or deadline (Permit2), allowing the facilitator to reconstruct the same PaymentInfo.
All other inputs come from PaymentRequirements + universal constants (PAYMENT_INFO_TYPEHASH, AUTH_CAPTURE_ESCROW_ADDRESS, chainId).
Client Payload Construction
The client derives the full payload from PaymentRequirements + universal constants + its own address + current timestamp. No RPC calls needed.
EIP-3009
| Payload field | Derived from |
|---|---|
authorization.from |
Client's own address |
authorization.to |
EIP3009_TOKEN_COLLECTOR_ADDRESS (constant) |
authorization.value |
requirements.amount |
authorization.validAfter |
0 (collector hardcodes this) |
authorization.validBefore |
now + requirements.maxTimeoutSeconds (= preApprovalExpiry) |
authorization.nonce |
Deterministic — see Nonce Construction |
| EIP-712 domain | name, version from extra; chainId from network; verifyingContract = asset |
Permit2
| Payload field | Derived from |
|---|---|
permit2Authorization.from |
Client's own address |
permitted.token |
requirements.asset |
permitted.amount |
requirements.amount |
spender |
PERMIT2_TOKEN_COLLECTOR_ADDRESS (constant) |
nonce |
Deterministic (as uint256) — see Nonce Construction |
deadline |
now + requirements.maxTimeoutSeconds (= preApprovalExpiry) |
| EIP-712 domain | Permit2 canonical contract (chainId from network) |
No witness field. Unlike
exact(which usespermitWitnessTransferFrom),authCaptureuses plainpermitTransferFrom. The merchant address (payTo) is cryptographically bound through the deterministic nonce, which encodes the full PaymentInfo includingreceiver.
PaymentPayload
Payload shape is determined by assetTransferMethod. Both define new types specific to authCapture — they do NOT reuse exact's payload types.
EIP-3009 Payload
authorization.to = EIP3009_TOKEN_COLLECTOR_ADDRESS (constant). The collector calls receiveWithAuthorization, forwards tokens to the captureAuthorizer's TokenStore.
{
"x402Version": 2,
"accepted": { "scheme": "authCapture", "...": "..." },
"payload": {
"signature": "0x...",
"authorization": {
"from": "0xPayerAddress",
"to": "0xEIP3009TokenCollectorAddress",
"value": "50000000",
"validAfter": "0",
"validBefore": "1744243200",
"nonce": "0xa1b2c3..."
}
}
}What the client signs (EIP-712):
Domain: { name: "USD Coin", version: "2", chainId: 8453, verifyingContract: <USDC address> }
ReceiveWithAuthorization {
from: <payer>
to: <EIP3009_TOKEN_COLLECTOR> ← universal constant
value: 50000000
validAfter: 0
validBefore: 1744243200 ← = preApprovalExpiry (now + maxTimeoutSeconds)
nonce: 0xa1b2c3... ← = payer-agnostic PaymentInfo hash
}
Permit2 Payload
spender = PERMIT2_TOKEN_COLLECTOR_ADDRESS (constant). Uses permitTransferFrom (no witness). The deterministic nonce binds all payment parameters including the merchant address.
{
"x402Version": 2,
"accepted": { "scheme": "authCapture", "...": "..." },
"payload": {
"signature": "0x...",
"permit2Authorization": {
"from": "0xPayerAddress",
"permitted": {
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "50000000"
},
"spender": "0xPermit2TokenCollectorAddress",
"nonce": "123456789",
"deadline": "1744243200"
}
}
}What the client signs (EIP-712):
Domain: Permit2 canonical contract (chainId: 8453)
PermitTransferFrom {
permitted: { token: <USDC>, amount: 50000000 }
spender: <PERMIT2_TOKEN_COLLECTOR> ← universal constant
nonce: 123456789 ← = uint256(payer-agnostic PaymentInfo hash)
deadline: 1744243200 ← = preApprovalExpiry (now + maxTimeoutSeconds)
}
|
Hello @fabrice-cheng ! Thanks a ton for the detailed response and feedback! This is workable with our protocol so I can handle it! A few notes though:
Thanks again for the thoughtful response! The rest looks good to me and I'm happy with it as it stands even with the points above. |
|
|
|
you might be right: In this case, I'm suggesting adding |
|
I think this naming convention is a bit confusing because I don't think it'll be obvious to people that disabling "authCapture" on the "authCapture" scheme would lead to "charge". Doing "charge: true/false" would probably be clearer. This preserves all the functionality though so I'm happy to go ahead with it! Can provide updated spec over the coming week. |
|
Authorization & Capture are typically 2 distinct steps in Payments:
When calling AuthCaptureEscrow.charge, it combine them into a single step, which is equivalent to
|
|
Ah sorry it's autoCapture not authCapture. I misread. Sounds good! |
|
Please add the contract Address in the spec as well! Thank you! |
|
Hi @fabrice-cheng ! This is not a pushback but I just needed some clarification for implementing. A bit confused what's happening with salt. It used to be derived by client automatically but now it is manually selected by the merchant since it's required in the extra? Wanted to make sure this is an intentional change. Since payer is always zero address in nonce calculation, the salt has to be fresh per request otherwise two different payers can have nonce collision. This means it should be automatically calculated and rotated by the server? I assume implementation wise that can be automatically filled via x402ResourceServer like name, version in exact? Would love to know your thoughts! |
|
@A1igator , I supposed it can be client-side generated, it's critical taht it's unique as you mentioned. I'm in favor of dropping it in the PaymentRequirements, and will have to be passed in via PaymentPayload then. cc: @avidreder |
|
Sorry one more thing. Are we sure about the name "captureAuthorizer"? This description: "Address authorized to capture/void/refund (server or facilitator)" is not really correct. It also has to do authorize. This means in the x402 flow, it practically either has to be the facilitator or a smart contract (since a smart contract operator can open up permissions to allow authorize and capture entity to be different). We have such smart contracts built out at x402r.org so it's fine for us but just wanted to flag the name/description being a bit misleading. |
|
The The The |
|
The thing is the ERC3009 signature has to identify the operator/captureAuthorizer and it can only be settled (money sent to/through contracts) by said entity. Only the operator/captureAuthorizer is allowed to "authorize" (in addition to only being allowed to capture/void/refund)
More reading: https://github.com/base/commerce-payments/blob/main/docs/operations/Authorize.md It has |
Restores the References section from scheme_commerce.md with the corrected "Escrow Scheme Proposal" titles (per #36, superseded by the commerce → authCapture rewrite). Convention matches the upstream submission in x402-foundation/x402#1425. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chema Renames the scheme directory from commerce/ to authCapture/ and rewrites the EVM spec to cover both ERC-3009 and Permit2 payment-collector paths. Key spec additions: - extra field schema (captureAuthorizer, captureDeadline, refundDeadline, feeRecipient, min/maxFeeBps, autoCapture, assetTransferMethod) - Wire format + EIP-712 derivation tables for ERC-3009 and Permit2 - Spec -> on-chain PaymentInfo field mapping (preserves canonical Solidity names so EIP-712 typehash matches the AuthCaptureEscrow contract) - Universal contract addresses block (CREATE2-deterministic) - Verification step list aligned with facilitator implementation: preApprovalExpiry <= captureDeadline <= refundDeadline (>= allowed, matching contract _validatePayment)
authCapture scheme specification
|
Hey @fabrice-cheng ! Just pushed an update implementing all the changes you requested and canonical addresses pre-deployed on Base mainnet and Sepolia and others. Anyone can also deploy with the same address on any chain as I provided the salts in the spec! Updated PR title and description as well to match. A few notes though:
We are happy with the spec as is though and would be happy to see it merged! We also almost have implementation of the scheme ready that we can push upstream as well: x402r-scheme. Thanks for the feedback and review! |

At x402r.org, we've been working on refundable payments for x402. This PR introduces the
authCapturescheme, built on Base's audited Commerce Payments Protocol. It supports two settlement paths (two-phase and single-shot), with client signing via either ERC-3009 or Permit2.Refs: #834, #1011
Description
Adds the authCapture scheme specification as two files:
scheme_authCapture.md: Cross-VM scheme overview. Covers settlement paths (two-phase vs single-shot viaautoCapture), lifecycle (authorize/capture/void/refund/reclaim), thecaptureAuthorizerrole, and security considerations.scheme_authCapture_evm.md: EVM implementation. Covers PaymentRequirements, PaymentPayload (ERC-3009 and Permit2 wire formats), verification logic, settlement logic, the on-chainPaymentInfostruct, fee system, universal CREATE2 contract addresses, and EIP-712 derivation tables.Reuses audited Commerce Payments Protocol contracts (AuthCaptureEscrow + token collectors). Client signs a single ERC-3009
receiveWithAuthorizationor Permit2PermitTransferFrom, depending on theassetTransferMethodselected by the server.Note on commit history: The first two commits in this PR introduced this scheme under the
escrowand thencommercenames. The latest commit renames the directory toauthCapture, which more precisely describes the authorize-then-capture lifecycle and parallels theAuthCaptureEscrowcontract name. The oldcommerce/directory is removed in the same commit.Related proposals: #839, #864, #946, #1247
Tests
Spec-only PR. No code changes. Verification and settlement logic validated against a reference implementation with passing E2E tests on Base.
Checklist