-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Add authCapture scheme specification
#1425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
CarsonRoscoe
merged 5 commits into
x402-foundation:main
from
BackTrackCo:feature/escrow-scheme-spec
May 13, 2026
+382
−0
Merged
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e20a61f
Add escrow scheme specification
A1igator 374c4a0
Address review feedback on escrow scheme specs
A1igator cf556b3
Rename scheme to commerce and align spec with implementation
A1igator 3f43b52
spec: rename escrow/commerce scheme to authCapture, add EVM payload s…
A1igator 8f070c8
spec: use canonical base/commerce-payments addresses
A1igator File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| # Scheme: `escrow` | ||
|
|
||
| ## Summary | ||
|
|
||
| The `escrow` scheme transfers funds through an on-chain escrow contract, decoupling authorization from settlement. The client signs once to authorize a maximum amount, and the facilitator settles through the [Commerce Payments Protocol](https://github.com/base/commerce-payments) — routing funds into escrow (pre-settlement hold) or directly to the receiver (post-settlement refundable). | ||
|
|
||
| This scheme reuses audited commerce-payments contracts deployed on Base and other EVM chains. | ||
|
|
||
| ## Example Use Cases | ||
|
|
||
| - Refundable payments with buyer protection | ||
| - Post-settlement refunds via the charge path | ||
| - Subscription / session billing with periodic captures | ||
|
|
||
| ## Settlement Methods | ||
|
|
||
| The scheme supports two settlement paths through the commerce-payments operator: | ||
|
|
||
| | Method | Function | Behavior | | ||
| | :---------- | :------------ | :----------------------------------------------------------- | | ||
| | `authorize` | `authorize()` | Funds held in escrow. Can be captured, refunded, or voided. | | ||
| | `charge` | `charge()` | Funds sent directly to receiver. Refundable post-settlement. | | ||
|
|
||
| Both methods share identical function signatures and use the same operator, fee system, and token collector infrastructure. | ||
|
|
||
| ## Lifecycle | ||
|
|
||
| ### Authorize (default) | ||
|
|
||
| ``` | ||
| SIGN → AUTHORIZE → RESOURCE DELIVERED | ||
| ``` | ||
|
|
||
| 1. **Sign**: Client signs an ERC-3009 `receiveWithAuthorization` for the maximum amount | ||
| 2. **Authorize**: Facilitator calls `authorize()` on the operator — funds locked in escrow | ||
| 3. **Resource delivered**: Server returns the resource (HTTP 200) | ||
|
|
||
| Post-settlement, the commerce-payments contracts enable capture, refund, void, or reclaim — see [Commerce Payments Protocol](#commerce-payments-protocol). | ||
|
|
||
| ### Charge | ||
|
|
||
| ``` | ||
| SIGN → CHARGE → RESOURCE DELIVERED | ||
| ``` | ||
|
|
||
| 1. **Sign**: Client signs an ERC-3009 authorization (same as above) | ||
| 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. | ||
|
|
||
| ## Relationship to `exact` | ||
|
|
||
| | Aspect | `exact` | `escrow` | | ||
| | :----------------- | :----------------- | :------------------------------------------------- | | ||
| | Settlement | Immediate transfer | Via escrow contract (authorize) or direct (charge) | | ||
| | Refundable | No | Yes (both paths) | | ||
| | Fee system | None | Commerce-payments managed (min/max bps) | | ||
| | Gas payer | Facilitator | Facilitator | | ||
| | Signature | ERC-3009 / Permit2 | ERC-3009 | | ||
| | On-chain contracts | Token only | Token + Escrow + Operator + Collector | | ||
|
|
||
| The `charge` settlement method gives `escrow` a direct-settlement path (like `exact`) while retaining post-settlement refund capability through the commerce-payments infrastructure. | ||
|
|
||
| ## Security Considerations | ||
|
|
||
| ### Fund Safety | ||
|
|
||
| - Funds held in audited [AuthCaptureEscrow](https://github.com/base/commerce-payments) contract | ||
| - Cannot overcharge — `amount` capped by client-signed `maxAmount` | ||
| - Client can reclaim funds after `authorizationExpiry` if operator disappears | ||
| - Fee bounds (`minFeeBps`/`maxFeeBps`) are client-signed and enforced on-chain | ||
|
|
||
| ### Replay Prevention | ||
|
|
||
| - Nonces derived from `keccak256(chainId, escrowAddress, paymentInfoHash)` — unique per payment | ||
| - ERC-3009 nonce consumed on-chain by the token contract | ||
| - `salt` field provides additional entropy for session uniqueness | ||
|
|
||
| ### Expiry Enforcement | ||
|
|
||
| The contract enforces strict ordering: `preApprovalExpiry <= authorizationExpiry <= refundExpiry` | ||
|
|
||
| - `preApprovalExpiry`: Deadline for the ERC-3009 signature (doubles as `validBefore`) | ||
| - `authorizationExpiry`: Deadline for capturing escrowed funds | ||
| - `refundExpiry`: Deadline for requesting refunds on captured payments | ||
|
|
||
| ## Appendix | ||
|
|
||
| ### Commerce Payments Protocol | ||
|
|
||
| The escrow scheme is built on Base's [Commerce Payments Protocol](https://blog.base.dev/commerce-payments-protocol), which provides: | ||
|
|
||
| - **Escrow**: Singleton contract managing fund locking, capture, refund, and reclaim | ||
| - **Operators**: Route payments through escrow with configurable fees | ||
| - **Token Collectors**: Pluggable modules for different token authorization methods (ERC-3009, Permit2) | ||
|
|
||
| ### References | ||
|
|
||
| - [Commerce Payments Protocol](https://github.com/base/commerce-payments) | ||
| - [EIP-3009: Transfer With Authorization](https://eips.ethereum.org/EIPS/eip-3009) | ||
| - [Escrow Scheme Proposal — Agentokratia (Issue #834)](https://github.com/coinbase/x402/issues/834) | ||
| - [Escrow Scheme Proposal — x402r (Issue #1011)](https://github.com/coinbase/x402/issues/1011) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| # Scheme: `escrow` on `EVM` | ||
|
|
||
| ## Summary | ||
|
|
||
| The `escrow` scheme on EVM uses the [Commerce Payments Protocol](https://github.com/base/commerce-payments) contract stack: | ||
|
|
||
| - **Escrow** (`AuthCaptureEscrow`): Singleton — locks funds, enforces expiries, distributes on capture/refund | ||
| - **Operator**: Routes payments through escrow with fee management | ||
| - **Token Collector** (`ERC3009PaymentCollector`): Collects funds via `receiveWithAuthorization` signatures | ||
|
|
||
| The client signs a single ERC-3009 authorization. The facilitator submits it to the operator, which handles token collection, escrow locking, and fee distribution — all in one transaction. | ||
|
|
||
| ## PaymentRequirements | ||
|
|
||
| Escrow-accepting servers advertise with scheme `escrow`: | ||
|
|
||
| ```json | ||
| { | ||
| "x402Version": 2, | ||
| "accepts": [ | ||
| { | ||
| "scheme": "escrow", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. auth-capture vs escrow?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Renamed to "commerce" as per Duke's suggestion above: #1425 (comment) |
||
| "network": "eip155:8453", | ||
| "amount": "1000000", | ||
| "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", | ||
| "payTo": "0xReceiverAddress", | ||
| "maxTimeoutSeconds": 60, | ||
| "extra": { | ||
| "name": "USDC", | ||
| "version": "2", | ||
| "escrowAddress": "0xEscrowAddress", | ||
| "operatorAddress": "0xOperatorAddress", | ||
| "tokenCollector": "0xCollectorAddress", | ||
| "settlementMethod": "authorize", | ||
| "minFeeBps": 0, | ||
| "maxFeeBps": 1000, | ||
| "feeReceiver": "0xOperatorAddress" | ||
|
A1igator marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| ### `extra` Fields | ||
|
|
||
| | Field | Required | Type | Description | | ||
| | :--------------------------- | :------- | :------------------------ | :------------------------------------------------- | | ||
| | `name` | Yes | `string` | EIP-712 domain name for the token (e.g., `"USDC"`) | | ||
| | `version` | Yes | `string` | EIP-712 domain version (e.g., `"2"`) | | ||
| | `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"` | | ||
| | `minFeeBps` | No | `uint16` | Minimum fee in basis points. Default: `0` | | ||
| | `maxFeeBps` | No | `uint16` | Maximum fee in basis points. Default: `0` | | ||
| | `feeReceiver` | No | `address` | Fee recipient. Default: `address(0)` (no fees) | | ||
| | `preApprovalExpirySeconds` | No | `uint48` | ERC-3009 signature validity / pre-approval expiry | | ||
| | `authorizationExpirySeconds` | No | `uint48` | Deadline for capturing escrowed funds | | ||
| | `refundExpirySeconds` | No | `uint48` | Deadline for refund requests | | ||
|
|
||
| ## PaymentPayload | ||
|
|
||
| ```json | ||
| { | ||
| "x402Version": 2, | ||
| "resource": { | ||
| "url": "https://api.example.com/resource", | ||
| "method": "GET" | ||
| }, | ||
| "accepted": { | ||
| "scheme": "escrow", | ||
| "network": "eip155:8453", | ||
| "amount": "1000000", | ||
| "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", | ||
| "payTo": "0xReceiverAddress", | ||
| "maxTimeoutSeconds": 60, | ||
| "extra": { | ||
| "name": "USDC", | ||
| "version": "2", | ||
| "escrowAddress": "0xEscrowAddress", | ||
| "operatorAddress": "0xOperatorAddress", | ||
| "tokenCollector": "0xCollectorAddress", | ||
| "settlementMethod": "authorize", | ||
| "minFeeBps": 0, | ||
| "maxFeeBps": 1000, | ||
| "feeReceiver": "0xOperatorAddress" | ||
| } | ||
| }, | ||
| "payload": { | ||
| "authorization": { | ||
| "from": "0xPayerAddress", | ||
| "to": "0xCollectorAddress", | ||
| "value": "1000000", | ||
| "validAfter": "0", | ||
| "validBefore": "1740672154", | ||
| "nonce": "0xf374...3480" | ||
| }, | ||
| "signature": "0x2d6a...571c", | ||
| "paymentInfo": { | ||
| "operator": "0xOperatorAddress", | ||
| "receiver": "0xReceiverAddress", | ||
| "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", | ||
| "maxAmount": "1000000", | ||
| "preApprovalExpiry": 1740672154, | ||
| "authorizationExpiry": 4294967295, | ||
| "refundExpiry": 281474976710655, | ||
| "minFeeBps": 0, | ||
| "maxFeeBps": 1000, | ||
| "feeReceiver": "0xOperatorAddress", | ||
| "salt": "0x0000...0001" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Nonce Derivation | ||
|
|
||
| The ERC-3009 nonce is deterministically derived from the payment parameters. The inner hash uses the `PaymentInfo` typehash and sets `payer=address(0)` so the nonce is payer-agnostic (computed before the payer is known): | ||
|
|
||
| ``` | ||
| paymentInfoHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, operator, address(0), receiver, ...)) | ||
| nonce = keccak256(abi.encode(chainId, escrowAddress, paymentInfoHash)) | ||
| ``` | ||
|
|
||
| This ties the off-chain signature to the specific escrow contract and payment terms, preventing cross-chain or cross-contract replay. | ||
|
|
||
| ## Verification Logic | ||
|
|
||
| The facilitator performs these checks in order: | ||
|
|
||
| 1. **Type guard**: Verify `payload` contains `authorization`, `signature`, and `paymentInfo` fields | ||
| 2. **Scheme match**: Verify `requirements.scheme === "escrow"` and `payload.accepted.scheme === "escrow"` | ||
| 3. **Network match**: Verify `payload.accepted.network === requirements.network` and format is `eip155:<chainId>` | ||
| 4. **Extra validation**: Verify `requirements.extra` contains required escrow fields (`escrowAddress`, `operatorAddress`, `tokenCollector`) | ||
| 5. **Time window**: Verify `validBefore > now + 6s` (not expired) and `validAfter <= now` (active) | ||
| 6. **ERC-3009 signature**: Recover signer from EIP-712 typed data (`ReceiveWithAuthorization` primary type) and verify matches `authorization.from` | ||
| 7. **Amount**: Verify `authorization.value >= requirements.amount` | ||
|
A1igator marked this conversation as resolved.
Outdated
|
||
| 8. **Token match**: Verify `paymentInfo.token === requirements.asset` | ||
| 9. **Receiver match**: Verify `paymentInfo.receiver === requirements.payTo` | ||
|
A1igator marked this conversation as resolved.
Outdated
|
||
| 10. **Balance check**: Verify payer has sufficient token balance (soft check — skip on RPC failure) | ||
|
|
||
| ### EIP-6492 Support | ||
|
|
||
| For smart wallet clients, the signature may be EIP-6492 wrapped (containing deployment bytecode). The facilitator extracts the inner ECDSA signature for verification. The on-chain `ERC6492SignatureHandler` in the token collector handles wallet deployment during settlement. | ||
|
|
||
| ## Settlement Logic | ||
|
|
||
| Settlement is performed by the facilitator calling the operator: | ||
|
|
||
| 1. **Re-verify** the payload (catch expired/invalid payloads before spending gas) | ||
| 2. **Determine function**: `settlementMethod === "charge" ? "charge" : "authorize"` | ||
| 3. **Call operator**: `operator.<functionName>(paymentInfo, amount, tokenCollector, collectorData)` | ||
| 4. **Wait for receipt**: Confirm transaction success with 60s timeout | ||
| 5. **Return result**: Transaction hash, network, and payer address | ||
|
|
||
| The operator handles: | ||
|
|
||
| - Calling the token collector to execute `receiveWithAuthorization` with the client's signature (EIP-712 primary type: `ReceiveWithAuthorization`, not `TransferWithAuthorization`) | ||
| - Routing funds to escrow (authorize) or directly to receiver (charge) | ||
| - Validating fee bounds against the client-signed `PaymentInfo` | ||
|
|
||
| ## Appendix | ||
|
|
||
| ### PaymentInfo Struct | ||
|
|
||
| This struct is signed by the client and validated on-chain: | ||
|
|
||
| ```solidity | ||
| struct PaymentInfo { | ||
| address operator; // Operator address | ||
| address payer; // Client wallet (authorization.from) | ||
| address receiver; // Fund recipient (payTo) | ||
| address token; // ERC-20 token address | ||
| uint120 maxAmount; // Maximum authorized amount | ||
| uint48 preApprovalExpiry; // ERC-3009 validBefore / pre-approval deadline | ||
| uint48 authorizationExpiry;// Capture deadline (authorize path only) | ||
| uint48 refundExpiry; // Refund request deadline | ||
| uint16 minFeeBps; // Minimum acceptable fee (basis points) | ||
| uint16 maxFeeBps; // Maximum acceptable fee (basis points) | ||
| address feeReceiver; // Fee recipient (address(0) = flexible) | ||
| uint256 salt; // Client-provided entropy | ||
| } | ||
| ``` | ||
|
|
||
| ### Expiry Ordering | ||
|
|
||
| The contract enforces: `preApprovalExpiry <= authorizationExpiry <= refundExpiry` | ||
|
|
||
| | Expiry | Enforced At | Effect | | ||
| | :-------------------- | :------------------------- | :---------------------------------- | | ||
| | `preApprovalExpiry` | `authorize()` / `charge()` | Blocks settlement after this time | | ||
| | `authorizationExpiry` | `capture()` | Blocks capture; enables `reclaim()` | | ||
| | `refundExpiry` | `refund()` | Blocks refund requests | | ||
|
|
||
| ### Fee System | ||
|
|
||
| Fees are enforced on-chain by the escrow contract: | ||
|
|
||
| - `minFeeBps` and `maxFeeBps` set by the client in `PaymentInfo` (0–10,000 bps) | ||
| - `feeBps` at capture/charge must fall within `[minFeeBps, maxFeeBps]` | ||
| - If `feeReceiver` is set in `PaymentInfo`, actual `feeReceiver` at capture/charge must match | ||
| - If `feeReceiver` is `address(0)`, the caller can specify any non-zero address | ||
| - Fee distribution: `feeAmount = amount * feeBps / 10000`, remainder goes to receiver | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
escrowscheme?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes I'd prefer "commerce" as scheme name aligning with the protocol it is build on
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.