Skip to content

Add authCapture scheme specification#1425

Open
A1igator wants to merge 4 commits intox402-foundation:mainfrom
BackTrackCo:feature/escrow-scheme-spec
Open

Add authCapture scheme specification#1425
A1igator wants to merge 4 commits intox402-foundation:mainfrom
BackTrackCo:feature/escrow-scheme-spec

Conversation

@A1igator
Copy link
Copy Markdown

@A1igator A1igator commented Mar 3, 2026

At x402r.org, we've been working on refundable payments for x402. This PR introduces the authCapture scheme, 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 via autoCapture), lifecycle (authorize/capture/void/refund/reclaim), the captureAuthorizer role, and security considerations.
  • scheme_authCapture_evm.md: EVM implementation. Covers PaymentRequirements, PaymentPayload (ERC-3009 and Permit2 wire formats), verification logic, settlement logic, the on-chain PaymentInfo struct, 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 receiveWithAuthorization or Permit2 PermitTransferFrom, depending on the assetTransferMethod selected by the server.

Note on commit history: The first two commits in this PR introduced this scheme under the escrow and then commerce names. The latest commit renames the directory to authCapture, which more precisely describes the authorize-then-capture lifecycle and parallels the AuthCaptureEscrow contract name. The old commerce/ 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

  • I have formatted and linted my code
  • All new and existing tests pass
  • My commits are signed (required for merge)
  • I added a changelog fragment for user-facing changes (docs-only changes can skip). Spec-only, skipped.

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>
@cb-heimdall
Copy link
Copy Markdown

cb-heimdall commented Mar 3, 2026

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 3, 2026

@A1igator is attempting to deploy a commit to the Coinbase Team on Vercel.

A member of the Team first needs to authorize it.

@ryanRfox
Copy link
Copy Markdown
Contributor

ryanRfox commented Mar 4, 2026

Good catch on the spec/implementation mismatch for simulation — that's exactly what #1377 tracks. The exact EVM spec requires simulation (step 5 in scheme_exact_evm.md) but no facilitator actually implements it, leading to gas loss on reverts (see #961, #418 for real-world examples).

There's a community contributor looking at a fix for exact (#1377). Once that lands, the escrow scheme's verification logic should probably align — a soft balance check (your step 10) catches some failures but misses things like consumed nonces, domain separator mismatches, and allowance issues that simulation would catch.

@phdargen
Copy link
Copy Markdown
Collaborator

phdargen commented Mar 7, 2026

Good catch on the spec/implementation mismatch for simulation — that's exactly what #1377 tracks. The exact EVM spec requires simulation (step 5 in scheme_exact_evm.md) but no facilitator actually implements it, leading to gas loss on reverts (see #961, #418 for real-world examples).

There's a community contributor looking at a fix for exact (#1377). Once that lands, the escrow scheme's verification logic should probably align — a soft balance check (your step 10) catches some failures but misses things like consumed nonces, domain separator mismatches, and allowance issues that simulation would catch.

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

@phdargen
Copy link
Copy Markdown
Collaborator

phdargen commented Mar 7, 2026

Thanks for putting this together @A1igator!

As a general comment, scheme_escrow.md‎ should be network agnostic. Currently its too EVM specific and just reads as a lighter version of scheme_escrow_evm.md defying the point of splitting in 2 files. Instead scheme_escrow.md‎
should include abstract description, use cases and core properties without any network specifics

| `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"` |
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.

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?

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.

For exact we have assetTransferMethod = {3009, permit2} in extra but the payment flow is identical, assetTransferMethod is just an implementation detail

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

Comment thread specs/schemes/escrow/scheme_escrow_evm.md Outdated
Comment thread specs/schemes/escrow/scheme_escrow_evm.md Outdated
Comment thread specs/schemes/escrow/scheme_escrow.md Outdated
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.
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.

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?

Copy link
Copy Markdown
Collaborator

@phdargen phdargen Mar 7, 2026

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

Copy link
Copy Markdown
Author

@A1igator A1igator Mar 10, 2026

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.

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.

yes I'd prefer "commerce" as scheme name aligning with the protocol it is build on

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.

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

Copy link
Copy Markdown
Author

@A1igator A1igator Mar 23, 2026

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.

@phdargen
Copy link
Copy Markdown
Collaborator

phdargen commented Mar 7, 2026

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?

@phdargen
Copy link
Copy Markdown
Collaborator

phdargen commented Mar 7, 2026

Exact works well for instant resource delivery like API responses. Its my understanding the primary use case of escrow would be delayed delivery (deferred settlement), for example e-commerce.

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 PAYMENT-RESPONSE, would this include sufficient info for the client to track the status of the payment or claim a refund?

Can the server request a capture? How can the server correlate captured/refunded payments with the original request?

@A1igator
Copy link
Copy Markdown
Author

A1igator commented Mar 10, 2026

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
@A1igator
Copy link
Copy Markdown
Author

A1igator commented Mar 11, 2026

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!

A1igator referenced this pull request in BackTrackCo/docs Mar 19, 2026
## 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>
@phdargen
Copy link
Copy Markdown
Collaborator

phdargen commented Mar 23, 2026

Thanks a lot for the update @A1igator!

The changes look good but I think the spec still leaves too much out as implementation detail.
The fact that the base commerce protocol is unopinionated about the operator doesnt necessarily mean this applies to a x402 specific implementation too.

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?

@A1igator
Copy link
Copy Markdown
Author

A1igator commented Mar 23, 2026

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!

@A1igator
Copy link
Copy Markdown
Author

A1igator commented Mar 23, 2026

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.

Comment thread specs/schemes/escrow/scheme_escrow_evm.md Outdated
"extra": {
"name": "USDC",
"version": "2",
"escrowAddress": "0xEscrowAddress",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

@A1igator A1igator Apr 9, 2026

Choose a reason for hiding this comment

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

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:

  1. Adding partialVoid to allow for partial refunds in an escrow.
  2. Adding AAVE or other defi for capital efficiency for funds in the escrow.
  3. 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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

auth-capture vs escrow?

Copy link
Copy Markdown
Author

@A1igator A1igator Apr 9, 2026

Choose a reason for hiding this comment

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

Renamed to "commerce" as per Duke's suggestion above: #1425 (comment)

"escrowAddress": "0xEscrowAddress",
"operatorAddress": "0xOperatorAddress",
"tokenCollector": "0xCollectorAddress",
"settlementMethod": "authorize",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

shouldn't this always be "authorize"?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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": {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we need to pass this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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>
@fabrice-cheng
Copy link
Copy Markdown
Contributor

fabrice-cheng commented Apr 26, 2026

Hi @A1igator ,

First of all, thanks for putting this together, and apologies for the back-and-forth.
After some more thoughts with the team, please find below some feedbacks building on your original proposal.
Let me know if you have time to get to it, or happy to take it over.

Tl;DR:

  • Trying to be consistent with x402 naming convention (instead of reusing the AuthCaptureEscrow smart contract protocol naming convention).
  • Defaulting to a canonical contract for simplicity (similar to Permit2 and Upto) x402 specs

Two-phase payments: the client authorizes a hold on funds (escrowed on-chain), and the captureAuthorizer later captures some or all of those funds. Mirrors the credit-card auth-capture pattern.

All EVM authCapture payments settle through a single, universal AuthCaptureEscrow contract (deployed via CREATE2, same address on all chains).

AssetTransferMethod Token Requirement Notes
eip3009 Must support receiveWithAuthorization (USDC, EURC) Client signs transfer to EIP3009_TOKEN_COLLECTOR_ADDRESS (constant)
permit2 Any ERC-20 Client signs Permit2 permitTransferFrom; spender = PERMIT2_TOKEN_COLLECTOR_ADDRESS (constant)

Naming (Commerce Protocol → x402)

Commerce Protocol x402 Rationale
operator captureAuthorizer Address authorized to capture/void/refund
authorizationExpiry captureDeadline Deadline by which capture must occur
refundExpiry refundDeadline Consistent with captureDeadline
preApprovalExpiry (derived by client) now + maxTimeoutSeconds; sent as validBefore / deadline in payload
feeReceiver feeRecipient Standard English
receiver / payer / maxAmount / token payTo / from / amount / asset Existing x402 fields
escrowContract / tokenCollector (constants) Universal addresses, not in payload

PaymentRequirements

Top-level fields (standard x402)

Field Type Description
scheme string "authCapture"
network string CAIP-2 (e.g., "eip155:8453")
amount string Max hold amount in atomic token units
asset string Token contract address
payTo string Merchant address (receives funds on capture)
maxTimeoutSeconds number How long the client's signature remains valid

extra fields

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 call capture(), void(), or refund().

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 uses permitWitnessTransferFrom), authCapture uses plain permitTransferFrom. The merchant address (payTo) is cryptographically bound through the deterministic nonce, which encodes the full PaymentInfo including receiver.


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)
}

@A1igator
Copy link
Copy Markdown
Author

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:

  1. While I think defaulting to canonical makes sense, I believe this spec completely kills the optionality. escrowContract / tokenCollector don't seem to be settable at all not just defaulted. Wanted to make sure that is intentional as we'd have to drop user requested features like partial void if that's the case. I assume this was done to simplify AssetTransferMethod to set the tokenCollector and it's not the end of the world but wanted to make sure the tradeoffs are well understood.

  2. "charge" seems to be completely dropped without a reason being provided. As I mentioned above the implementation is 95% identical (just a function call change) and I don't think keeping it as an option changes much in UX. We already have protocols interested in building on top of this system/our sdk that would have better UX/lower gas costs if they go through "charge" as opposed to auth-capture every time, as they only care about the tracking for refunds after the escrow. Still not end of the world as can always auth-capture in rapid succession/atomically but wanted to make sure it's an intentional change.

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.

@fabrice-cheng
Copy link
Copy Markdown
Contributor

fabrice-cheng commented Apr 26, 2026

@A1igator

  1. Could you specify what "Partial void" would enable? Ultimately, void amount is (Authorized amount - Captured Amount), which allows full void or partial void amount.

  2. charge vs two-steps authorization/capture is server-side. It's up to the server to decide if they'd rather call (1) charge (2) authorization and then later capture.

@A1igator
Copy link
Copy Markdown
Author

@fabrice-cheng

  1. I think you may be right. I'm going to do some scoping to see if that works in every scenario and get back to you but that does seem workable.

  2. A bit confused here. By server do you mean facilitator? How would the merchant server decide if it's not a field they can set?

@fabrice-cheng
Copy link
Copy Markdown
Contributor

you might be right:

In this case, I'm suggesting adding autoCapture=true|false in extra, and if i's omitted, it means it's false.

{
  "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",
    "autoCapture": true
  }
}

@A1igator
Copy link
Copy Markdown
Author

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.

@fabrice-cheng
Copy link
Copy Markdown
Contributor

Authorization & Capture are typically 2 distinct steps in Payments:

  • authorization + manual capture (2 calls)

When calling AuthCaptureEscrow.charge, it combine them into a single step, which is equivalent to

  • authorization + autocapture (1 call)

@A1igator
Copy link
Copy Markdown
Author

Ah sorry it's autoCapture not authCapture. I misread. Sounds good!

@fabrice-cheng
Copy link
Copy Markdown
Contributor

Please add the contract Address in the spec as well! Thank you!

@A1igator
Copy link
Copy Markdown
Author

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!

@fabrice-cheng
Copy link
Copy Markdown
Contributor

@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

@A1igator
Copy link
Copy Markdown
Author

Hi @fabrice-cheng

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.

@fabrice-cheng
Copy link
Copy Markdown
Contributor

The Payment Authorization is the ERC3009 signature itself technically.

The captureAuthorizer identifies who has the permission to capture a payment or void a payment. Different profile

The Authorizer part of captureAuthorizer is to be consistent with the other x402 specs (x402BatchSettlement's naming of payerAuthorizer and receiverAuthorizer), which is super confusing because of payment authorization, but yeah..

@A1igator
Copy link
Copy Markdown
Author

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)

image

More reading: https://github.com/base/commerce-payments/blob/main/docs/operations/Authorize.md

It has onlySender(paymentInfo.operator) gate. In x402 case, that means without smart contract operators, only the facilitator can be the captureAuthorizer since it also does the "authorize". As I mentioned above we have said contracts so it's fine but the name/description might confuse people thinking it only handles capture.

A1igator added a commit to BackTrackCo/x402r-scheme that referenced this pull request May 1, 2026
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)
@A1igator A1igator changed the title Add escrow scheme specification Add authCapture scheme specification May 1, 2026
@A1igator
Copy link
Copy Markdown
Author

A1igator commented May 1, 2026

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:

  1. Kept the name captureAuthorizer for now but updated the description to "Address authorized to authorize/capture/void/refund/charge." to be more accurate. Let me know if you'd like a different name too knowing the description.
  2. I forgot to mention this because we don't use it but to be faithful to the original contracts, feeReciever can be set to address(0) to let the operator/captureAuthorizer specify any non-zero recipient at capture/charge time. Added that to the description but might want to consider making it optional.

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!

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.

6 participants