Skip to content

spec: authCapture (replaces commerce)#42

Merged
A1igator merged 18 commits intomainfrom
A1igator/authcapture-spec
May 1, 2026
Merged

spec: authCapture (replaces commerce)#42
A1igator merged 18 commits intomainfrom
A1igator/authcapture-spec

Conversation

@A1igator
Copy link
Copy Markdown
Contributor

Summary

Why this is a separate PR

The original PR #40 bundled spec + implementation; this PR carves the spec out so reviewers can read the protocol surface without wading through the @x402r/evm code diff. PR #40 is now strictly the implementation.

What's in the EVM spec

  • extra field schema (captureAuthorizer, captureDeadline, refundDeadline, feeRecipient, min/maxFeeBps, autoCapture, assetTransferMethod)
  • Wire format for ERC-3009 and Permit2 payloads
  • Spec → on-chain PaymentInfo field mapping (preserves canonical Solidity names so the EIP-712 typehash matches the contract byte-for-byte)
  • Verification + settlement logic
  • Canonical Addresses appendix: deterministic CREATE2 addresses for AuthCaptureEscrow, ERC3009PaymentCollector, Permit2PaymentCollector — same address on every supported EVM chain, with salt labels so anyone can reproduce
  • 14-chain deployed-chain table with per-chain assetTransferMethod constraint (BSC + Tempo are Permit2-only because their canonical stables lack ERC-3009)

Test plan

  • Reviewer reads scheme_authCapture.md for the abstract scheme
  • Reviewer reads scheme_authCapture_evm.md for the EVM implementation contract
  • Cross-checks the Canonical Addresses table against the upstream base/[email protected] source repo's deploy script

🤖 Generated with Claude Code

Adds the `authCapture` scheme spec (cross-VM abstract + EVM
implementation) and removes the `commerce` scheme spec it replaces.

The EVM spec covers all 14 chains where the canonical commerce-payments
v1 primitives are deployed, with a per-chain `assetTransferMethod`
column noting which chains require Permit2 (BSC's Binance-Peg USDC
and Tempo's pathUSD lack ERC-3009 support).

Implementation in @x402r/evm tracks this spec one-to-one — see the
companion PR for that.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A1igator and others added 2 commits April 29, 2026 17:49
Drop the trailing "contract's own access control" + assetTransferMethod
caveats — both restate things implied by the rest of the section.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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) <[email protected]>
@vraspar
Copy link
Copy Markdown
Contributor

vraspar commented May 1, 2026

Reviewed both spec files end-to-end. Two verifications, twelve findings, and an overall take.

Verifications

1. captureAuthorizer description — already correct ✅

The spec already addresses the "captureAuthorizer also authorizes" concern. From the extra table:

captureAuthorizer | Yes | address | Address authorized to authorize/capture/void/refund/charge. Committed on-chain as PaymentInfo.operator.

And the bullet-list summary:

"CaptureAuthorizer: Address authorized to authorize, capture, void, refund, or charge a payment. Each of those methods on AuthCaptureEscrow is gated by onlySender(paymentInfo.operator), so this address must be msg.sender of the 'Authorize' call. In x402's facilitator-submits flow that means either the facilitator's EOA, or any smart contract that ends up calling the escrow…"

This captures the missing context cleanly.

2. PAYMENT-RESPONSE / lifecycle / capture-trigger — not addressed ❌

Nothing in either spec file talks about:

  • PAYMENT-RESPONSE / SettlementResponse shape for tracking
  • How a client learns its payment was captured / charged / refunded
  • How a merchant triggers a capture after resource delivery
  • Whether /capture, /refund, /void endpoints exist on the facilitator
  • State persistence requirements for client / server / facilitator

Cheapest fix is one explicit sentence: "Out-of-band lifecycle operations (capture / void / refund) are out of scope for this spec — see follow-up issue." Otherwise this question keeps getting re-derived by every reader.

Findings (rough priority order)

  1. Verification step 6 enforces > where the contract enforces >=. Spec says refundDeadline > captureDeadline; contract enforces preApprovalExpiry <= authorizationExpiry <= refundExpiry. A merchant setting refundDeadline === captureDeadline would pass on-chain but fail off-chain verification. Either align (use >=) or note that the spec is intentionally stricter.

  2. Verification step 6 is missing the third leg of expiry ordering. Contract enforces preApprovalExpiry ≤ authorizationExpiry ≤ refundExpiry. Spec checks refundDeadline > captureDeadline and captureDeadline > now + 6s, but never now + maxTimeoutSeconds ≤ captureDeadline. A merchant with a generous maxTimeoutSeconds and tight captureDeadline would only fail at simulation. Cheap to add as step 6c.

  3. Capability discovery for assetTransferMethod not specified. The deployed-chains table shows BSC + Tempo are permit2-only, but the verification logic doesn't say "MUST reject eip3009 on chains where it's unsupported." Servers can advertise multiple accepts[] entries — that's how exact does it — but the spec doesn't mention this pattern.

  4. minFeeBps description reads weird. "Minimum fee in basis points (the fee floor the captureAuthorizer must take)" — naive readers will wonder why a merchant would force a fee. One-line example (e.g., "used for revenue-share arrangements with partners") would unblock comprehension.

  5. feeRecipient is now Yes / required, but a merchant who doesn't want fees has to set address(0) + 0/0 bps. Spec doesn't tell them this. Either make feeRecipient optional (default address(0)) or add a "to opt out of fees, set X" note.

  6. autoCapture naming reads ambiguously. "autoCapture: true means capture happens automatically as part of charge()" — the reader has to infer this. One sentence would fix it: "true skips the separate capture step — funds settle to the receiver in one transaction."

  7. PAYMENT_INFO_TYPEHASH referenced but never defined. Implementers need the actual EIP-712 typehash string to reproduce signatures. Add it to the appendix.

  8. Salt entropy requirement vague. "fresh bytes32 salt" — should say "MUST be cryptographically random (e.g., crypto.randomBytes(32))". Otherwise someone will use Date.now().

  9. Abstract doc says "Two absolute-timestamp deadlines" then parenthesizes the third as "network-specific." It isn't network-specific — the third deadline (preApprovalExpiry, derived from maxTimeoutSeconds) is universal to the EVM impl. Either commit to "two deadlines, plus a derived pre-approval window" or keep the abstract-vs-EVM separation cleaner.

  10. EIP-3009 to address — type guard vs runtime check. Step 8 verifies payload.to === EIP3009_TOKEN_COLLECTOR_ADDRESS, but if the wire format requires that constant, this should fail at the type guard (step 1). Either document it as defense-in-depth or move it.

  11. Title-case slip in the abstract doc. Line 29 of scheme_authCapture.md uses "CaptureAuthorizer" (PascalCase); everywhere else it's captureAuthorizer. Pick one.

  12. uint120 maxAmount in the on-chain struct will surprise readers. One-line note ("packed for gas — supports any reasonable token amount up to ~1.3×10³⁶") would head off questions.

Overall take

The strong parts:

  • The canonical-contracts move (escrow + collectors as universal CREATE2 constants) is the right call. It makes the wire format dramatically simpler, removes a whole class of merchant-side misconfiguration, and matches the precedent set by Permit2.
  • The wire→on-chain field-name mapping table is excellent — threads the needle between x402 naming consistency and not breaking the EIP-712 typehash.
  • The payer-zeroed nonce + payload-side salt design is clean. The freshness invariant is well-reasoned.
  • Typed simulation reverts (mapping AfterPreApprovalExpiry etc. to stable invalidReason codes) is a real improvement on exact and worth calling out as such — facilitators get actionable error reasons instead of opaque simulation_failed.
  • The deployed-chains table with per-chain assetTransferMethod constraints is a useful, concrete signal that this isn't just a Base spec.

The actual concern:

This spec nails the funds layer but punts on the lifecycle / orchestration layer in x402's stateless HTTP context. Scoping it that way is defensible — but downstream readers will keep asking the same questions until the spec explicitly says "lifecycle endpoints are out of scope, see follow-up X." Without that, every reviewer re-derives the same gap from scratch.

A more aggressive option: spec out a minimal /refund endpoint on the facilitator alongside this PR, since refund is the only lifecycle op that's universally meaningful (works for both autoCapture: true and false). That'd make the scheme feel complete in an HTTP context. But it expands scope, so probably better as a follow-up.

Naming friction is real but probably not blocking. captureAuthorizer is technically slightly misleading (the address also authorizes), autoCapture: false for "two-phase auth-capture" is a double-negative, and "authCapture scheme with autoCapture" reads a bit weirdly together. The captureAuthorizer naming was a deliberate consistency choice with payerAuthorizer / receiverAuthorizer elsewhere in x402 — defensible, but the spec's prose shouldn't lean on the naming for clarity.

Bottom line: Ship-ready once the verifications are addressed (#2 above is the substantive one), the lifecycle out-of-scope sentence added, and the verification-logic gaps closed (findings 1–3). Naming critiques are noise at this point.

Two correctness gaps where the spec's verification list undercut what
the facilitator actually checks:

- Step 4 omitted minFeeBps from the required-extra fields list, even
  though the facilitator's isAuthCaptureExtra type guard requires it.
- Step 6 listed only two deadline-ordering legs; the facilitator also
  enforces preApprovalExpiry <= captureDeadline (mirrors the contract's
  preApprovalExpiry <= authorizationExpiry leg of InvalidExpiries).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@A1igator
Copy link
Copy Markdown
Contributor Author

A1igator commented May 1, 2026

Pushed daf968f. Took the two findings that mapped to actual spec/impl mismatches:

  • Step 4: added minFeeBps to the required-extra list (the facilitator's type guard already requires it).
  • Step 6: added the missing third leg, validBefore / deadline <= captureDeadline (the facilitator already enforces this; the contract surfaces it as InvalidExpiries).

Skipped the rest as either stylistic or expansions beyond the original commerce-spec / Coinbase-redesign baseline:

@vraspar
Copy link
Copy Markdown
Contributor

vraspar commented May 1, 2026

Verified daf968f — both diffs read clean. Step 4 now lists minFeeBps in the required-extra set; step 6 picked up the third leg (payload.validBefore / deadline <= captureDeadline). Good.

Agree with the scoping calls on the rest — the strict > framing, the accepts[] convention for capability discovery, and the stylistic items are all reasonable to leave alone.

One thing I'd still gently push for, even within "keep the spec tight":

The one-sentence out-of-scope note for lifecycle ops. Not asking to spec the endpoints — just a line somewhere in scheme_authCapture.md like "Out-of-band lifecycle operations (capture / void / refund) are operator-implementation-specific and out of scope for this scheme spec." Cost is one sentence; benefit is every future reader stops re-deriving the gap. Without it, the next set of reviewers will ask the same questions that came up in the original review thread, and you'll answer them all over again.

Otherwise this looks ready.

vraspar
vraspar previously approved these changes May 1, 2026
The contract's _validatePayment uses `preApprovalExp <= authorizationExp
<= refundExp` (equal allowed). Spec was using strict `>` on the refund
side, rejecting refundDeadline === captureDeadline configs that the
contract itself accepts. Aligning to >= avoids the off-chain/on-chain
divergence; the impl change ships alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A1igator and others added 9 commits April 30, 2026 20:27
Replaces the trailing prose paragraphs under EIP-3009 / Permit2 examples
with explicit field-by-field tables. Closes the implementer gap on the
EIP-712 domain construction (verifyingContract = asset for ERC-3009;
canonical Permit2 contract for Permit2) and the validAfter = 0
convention, neither of which were spelled out before.

Drops the standalone "salt is not in extra" callout — the placement is
already shown in the JSON example and the PaymentInfo struct comments,
and the singling-out was inconsistent with other PaymentInfo fields
(payer / receiver / token / maxAmount / preApprovalExpiry) that are
also not in extra without dedicated callouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three reframings to make the spec read better cold (without prior commerce
or PR-review context):

- Drop the "addresses are not configurable per merchant" sentence in the
  Summary. The bullet list above already says the contracts have universal
  canonical addresses; restating it as a defensive aside only landed for
  readers comparing against an unstated alternative.

- Reframe the address callout from "NOT in extra" to a positive "Universal
  contract addresses" header. The table is useful right next to the extra
  field list (saves a scroll to the appendix); the framing should be "here
  are the constants you need" rather than "here's what isn't in extra".

- Rewrite the captureAuthorizer description to drop the
  `onlySender(paymentInfo.operator)` reference. The on-chain field name
  isn't introduced until the field-mapping table later in the doc, so the
  forward reference forced cold readers to skip ahead.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two changes:

- Replace the 14-row deployed-chains table with a brief per-method
  token-requirement note and a pointer to base/commerce-payments for
  current deploy status. Chain coverage is deployment status, not
  protocol; pinning it here means the spec drifts every time a chain
  comes online or stables get added. The constraint isn't actually
  per-chain either — it's per-token (eip3009 needs
  receiveWithAuthorization-capable tokens, permit2 works with any
  ERC-20). Other x402 EVM specs (exact, upto) don't list chains.

- Add a one-line note on assetTransferMethod that a server MAY include
  multiple accepts[] entries with different methods, so the client
  picks based on the tokens it holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The pointer paragraph claimed base/commerce-payments hosts the current
chain-deployment list, but the upstream README only lists Base mainnet
+ Sepolia with the old prototype CREATE3 addresses (the ones our
constants comment marks as superseded). The CREATE2 canonical deploys
in our table are not documented there; pointing readers at it would
hand them the wrong addresses.

Other x402 EVM specs (exact, upto) just declare a canonical address +
a one-line CREATE2 deploy intent. They do not link source repos for
deployment status, do not list chains, and do not describe redeployment
procedures. Following that convention.

Also folds the per-method token requirement (eip3009 needs
receiveWithAuthorization, permit2 works with any ERC-20) into the
assetTransferMethod field description where it naturally belongs,
instead of duplicating it in the appendix.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The table cell was three sentences. The eip3009 / permit2 capability
description is lookup-able (both are well-known specs), and the
multi-accepts hint is about accepts[] array usage more than about this
specific field. Cell back to a one-liner; multi-accepts moved to a
short note below the table.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two sentences in a cell is fine; lifting it out and back in was overkill.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@vraspar
Copy link
Copy Markdown
Contributor

vraspar commented May 1, 2026

Re-read the latest version. Three small bugs from the recent edits, plus one nuance worth surfacing.

1. Broken cross-reference (introduced by the appendix trim)

Under the universal-addresses table:

See Canonical Addresses for the deployed-chain list and the salt scheme.

The Canonical Addresses appendix no longer contains a deployed-chain list — that table was removed in 916bd37. The link target exists but the promise is broken. Fix: drop "the deployed-chain list and" from that sentence.

2. invalid_deadline_ordering error description is out of sync with step 6

Step 6 now enforces three legs:

  • refundDeadline >= captureDeadline
  • captureDeadline > now + 6s
  • payload.validBefore / payload.deadline <= captureDeadline

But the error-code table still describes invalid_deadline_ordering as just refundDeadline < captureDeadline. The third-leg failure (validBefore > captureDeadline) has no corresponding error code — it'd fall through to simulation_failed even though the facilitator catches it off-chain.

The contract surfaces both via InvalidExpiries, so the cleanest fix is to broaden the description: "Deadlines violate now + maxTimeoutSeconds <= captureDeadline <= refundDeadline."

3. Step 11 amount check is EIP-3009-only worded

Step 11 reads: "Authorization value matches requirements.amount." That phrasing only matches authorization.value (the EIP-3009 wire field). For Permit2 the field is permit2Authorization.permitted.amount. Fix: "Authorization value (authorization.value or permit2Authorization.permitted.amount) matches requirements.amount."

4. Worth highlighting (not a bug, but underexplained)

Step 12 ("Nonce match") implicitly enforces that receiver, token, feeRecipient, both deadlines, and fee bounds all agree with requirements + extra — because they're encoded into the payer-agnostic PaymentInfo hash. A careful reader scanning the verification list will wonder "where's the receiver match? token match for EIP-3009? fee bounds check?" — they're all consolidated into step 12 by construction.

One sentence after step 12 like "This step transitively enforces equality on every field encoded in PaymentInfo (receiver, token, deadlines, fee bounds, feeRecipient) — individual field-by-field checks are unnecessary." would head off that confusion and advertise a nice elegance of the payer-zeroed-nonce design.

- Drop the "deployed-chain list and" phrase from the cross-reference
  to the Canonical Addresses appendix; the chain table was removed in
  916bd37 so the link target no longer carries that.
- Broaden the invalid_deadline_ordering error description to cover
  all three legs of step 6, not just the refundDeadline < captureDeadline
  leg. The facilitator surfaces this for any of the three failure modes.
- Step 11 amount check phrasing was EIP-3009-leaning; spell out both
  authorization.value (EIP-3009) and permit2Authorization.permitted.amount
  (Permit2) so it's unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@A1igator
Copy link
Copy Markdown
Contributor Author

A1igator commented May 1, 2026

Pushed 5de3a56. Three fixes:

  • Stale cross-reference dropped: See [Canonical Addresses] for the salt scheme. (the deployed-chain list was removed earlier, so the link no longer carries that promise).
  • invalid_deadline_ordering description broadened to Deadlines violate \now + maxTimeoutSeconds <= captureDeadline <= refundDeadline`.` — covers all three legs the facilitator checks at step 6.
  • Step 11 amount check now spells out both authorization.value (EIP-3009) and permit2Authorization.permitted.amount (Permit2) instead of generic "authorization value".

Skipped #4 — you flagged it as not-a-bug, and the spec stays consistent without it. Leaving the verification list at field-level checks rather than annotating the implicit transitivity through the nonce hash.

A reader scanning the verification list might expect explicit checks
for receiver, token (EIP-3009), deadlines, fee bounds, and feeRecipient.
They aren't there because the payer-agnostic PaymentInfo hash already
encodes them: any mismatch surfaces as nonce_mismatch at step 12.
Calling that out so the absence isn't read as a gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@A1igator
Copy link
Copy Markdown
Contributor Author

A1igator commented May 1, 2026

Addressed #4 in 3c305d5. Step 12 now ends with: "This transitively enforces equality on every field encoded in `PaymentInfo` (receiver, token, deadlines, fee bounds, feeRecipient), so individual field-by-field checks for those values are unnecessary." Heads off the "where's the receiver/token/fee check?" reading and surfaces the design choice.

@vraspar
Copy link
Copy Markdown
Contributor

vraspar commented May 1, 2026

Re-read end-to-end. All four items from the last round landed cleanly in 5de3a56 + 3c305d5 — cross-reference fixed, error description broadened, step 11 covers both wire fields, step 12 has the transitive-equality note. Walked the whole spec against the agreed redesign decisions and it's consistent across the board (scheme rename, field renames, canonical addresses out of extra, autoCapture toggle, payer-zeroed nonce, salt on payload, multi-accepts[] for capability discovery, etc.). No drift.

Three small things worth flagging, then a step-back take.

Things worth flagging

1. feeRecipient required vs address(0) allowed — internal contradiction.

The extra table marks feeRecipient as Yes/required. The Fee System appendix says: "If feeReceiver is address(0), the caller can specify any non-zero address." So address(0) is a valid value (it's an opt-in to caller-chosen recipients), but the field description doesn't tell merchants this. A merchant who doesn't want to commit a fee recipient has no idea they can pass 0x0...0. Two ways to fix: either document the opt-out in the field description, or make feeRecipient optional with default address(0).

2. Permit2 EIP-712 domain underspecified.

The Permit2 field-derivation table just says "Canonical Permit2 contract; chainId from network". Compare to the EIP-3009 row which gives { name, version } from extra, chainId from network, verifyingContract = requirements.asset — fully reproducible. For Permit2, an implementer needs { name: "Permit2", chainId, verifyingContract: PERMIT2_ADDRESS }. Spelling it out costs one line and removes an "I have to go look this up" step.

3. Permit2 example nonce: "12345678901234567890" is unrealistically small.

That's ~6.4×10¹⁸ — the real nonce is uint256(paymentInfoHash), a full 78-digit decimal. The example understates field width and could mislead an implementer testing with the literal value. Replace with something that looks like a real uint256.

Step-back take

This spec is in genuinely good shape now. The verification list is tight, the field-derivation tables are a real readability win, the typed simulation reverts are unique value over exact, and the wire format is minimal because of the canonical-contracts move. Ship-ready as far as I can see.

Two structural choices that aren't bugs but worth knowing you're making:

The lifecycle scope decision is implicit, not explicit. No sentence anywhere says "out-of-band capture / void / refund operations are out of scope for this scheme spec." A first-time reader walking through the spec will get to the end and ask "wait, but how does the captureAuthorizer actually trigger a capture?" — and there's no breadcrumb. You decided to leave it implicit; just be ready to answer that question on every review pass. I'd still add the sentence, but I'm done pushing on it.

The "captureAuthorizer is also the authorizer" friction is real but well-handled. The naming is technically slightly inaccurate, but the bullet at the top of the EVM spec explains the on-chain gating clearly, so a careful reader gets it. The naming consistency with payerAuthorizer / receiverAuthorizer elsewhere in x402 is the right tradeoff. Not worth more cycles.

Bottom line: the only thing I'd still nudge on for survivability under wider review is the one-sentence lifecycle out-of-scope note. Everything else is polish.

… nonce

- Document the feeRecipient = address(0) opt-out in the field description.
  The Fee System appendix already says address(0) lets the caller pick a
  non-zero recipient at capture/charge time, but the field description
  marked feeRecipient as required without flagging this opt-in.
- Replace the Permit2 example nonce ("12345678901234567890", ~6.4×10¹⁸)
  with a realistic 78-digit uint256 value. The real wire value is
  uint256(payerAgnosticPaymentInfoHash); the previous placeholder
  understated the field width and could mislead implementers testing
  against the literal.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@A1igator
Copy link
Copy Markdown
Contributor Author

A1igator commented May 1, 2026

Pushed de84f9d.

  • Add Claude Code GitHub Workflow #1 (feeRecipient opt-out): field description now says "Set to `address(0)` to let the captureAuthorizer specify any non-zero recipient at capture/charge time." Resolves the contradiction with the Fee System appendix.
  • refactor: use x402 types and remove custom facilitator signer #3 (Permit2 example nonce): replaced the unrealistic "12345678901234567890" with a 78-digit decimal so it actually looks like uint256(payerAgnosticPaymentInfoHash).
  • fix: correct ERC-3009 signature verification for USDC #2 (Permit2 EIP-712 domain row): skipping. The current level of detail matches the redesign baseline our wire format is tracking; the EIP-3009 row spelling out verifyingContract = requirements.asset is a real implementer-blocker (per-token, not a constant), but Permit2's verifyingContract is the single canonical Permit2 deploy, which any reader using Permit2 already has at hand. Adding it would be expansion past what's actually load-bearing.

@vraspar
Copy link
Copy Markdown
Contributor

vraspar commented May 1, 2026

Verified de84f9dfeeRecipient opt-out documented, Permit2 example nonce now realistic. Agree with the skip on the Permit2 EIP-712 domain row: verifyingContract varies per token for EIP-3009 (real implementer-blocker), but is invariant for Permit2 and already in the universal-addresses table — spelling it out would be belt-and-suspenders. Principled call, consistent with the scope discipline you've held throughout.

That's a wrap from me — combed the spec three times, no other nits. Approve.

vraspar
vraspar previously approved these changes May 1, 2026
Copy link
Copy Markdown
Contributor

@vraspar vraspar left a comment

Choose a reason for hiding this comment

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

:shipit:

Two precisions in the role-attribution language:

- EVM spec settlement paragraph said "the facilitator submits it to
  AuthCaptureEscrow.authorize()", which collapsed the two valid call
  paths into a direct-call assumption. Reword to "The facilitator
  calls ..., either directly or through a smart contract set as the
  captureAuthorizer" — matches the captureAuthorizer definition
  above and the on-chain msg.sender == operator gate.

- Settlement-paths table in the abstract spec used passive
  "Refundable post-settlement" for autoCapture=true while the
  autoCapture=false row spelled out the actor ("CaptureAuthorizer
  can capture, void, refund"). Mirror the rows so a reader doesn't
  infer the client can self-refund — the actor is the
  captureAuthorizer in both paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@A1igator A1igator merged commit 320537d into main May 1, 2026
6 checks passed
@A1igator A1igator deleted the A1igator/authcapture-spec branch May 1, 2026 22:41
A1igator added a commit that referenced this pull request May 6, 2026
…ture + Permit2) (#40)

> **Spec moved to a separate PR**:
[#42](#42). This PR is
now strictly the @x402r/evm package implementation. Read #42 first for
the protocol surface; this PR is the code that implements it.

Two-commit PR landing the full authCapture migration in the scheme
package.

## Commit 1: rename `commerce` → `authCapture` (mechanical,
identifier-only)

- Source dir `packages/evm/src/commerce/` → `src/authCapture/`
- Tests `packages/evm/test/unit/commerce/` → `test/unit/authCapture/`
- Spec docs `specs/schemes/commerce/scheme_commerce*.md` →
`specs/schemes/authCapture/scheme_authCapture*.md`
- tsup entrypoints + package.json exports subpaths (`./commerce/*` →
`./authCapture/*`)
- Identifier renames: scheme constant, classes (`*EvmScheme`,
`*ServerScheme`, `*FacilitatorScheme`), types (`*Payload`, `*Extra`),
type guards, helpers (`computeCommerceNonce`,
`registerCommerceEvmScheme`), error codes
- `COMMERCE_PAYMENTS_*` constants and `commerce-payments` / `Base
Commerce Payments` prose kept verbatim — they refer to the underlying
base/commerce-payments protocol, not our scheme

## Commit 2: new wire format, salt on payload, autoCapture, Permit2

### `extra` shape

Drop:
- `escrowAddress`, `operatorAddress`, `tokenCollector` — universal
constants now
- `preApprovalExpirySeconds` — client derives from top-level
`maxTimeoutSeconds`
- `authorizationExpirySeconds`, `refundExpirySeconds` — replaced by
absolute deadlines
- `settlementMethod` — replaced by `autoCapture` bool
- `feeReceiver` — renamed `feeRecipient`

Add:
- `captureAuthorizer: address` (formerly `operatorAddress`)
- `captureDeadline: uint48` / `refundDeadline: uint48` (absolute Unix
seconds)
- `feeRecipient: address`
- `autoCapture?: bool` (default `false`)
- `assetTransferMethod?: 'eip3009' | 'permit2'` (default `'eip3009'`)

Spec field names live at the wire layer; the on-chain `PaymentInfo`
struct keeps canonical Solidity field names (`operator`,
`authorizationExpiry`, `refundExpiry`, `feeReceiver`) so the EIP-712
typehash matches `AuthCaptureEscrow.PAYMENT_INFO_TYPEHASH`
byte-for-byte.

### `salt` placement

Moved out of `extra` and onto `PaymentPayload`. Generated client-side at
every `createPaymentPayload()` call, fresh per request. Facilitator
reconstructs `PaymentInfo` from `payload.salt` + extra + payer +
top-level requirements. Freshness is mandatory because the
payer-agnostic nonce zeroes the payer field — two payers under identical
extra+salt would collide.

### Constants module

Renames: `COMMERCE_PAYMENTS_ESCROW` → `AUTH_CAPTURE_ESCROW_ADDRESS`,
`COMMERCE_PAYMENTS_TOKEN_COLLECTOR` → `EIP3009_TOKEN_COLLECTOR_ADDRESS`.
New: `PERMIT2_TOKEN_COLLECTOR_ADDRESS`, `PERMIT2_ADDRESS`,
`PERMIT2_TRANSFER_FROM_TYPES`, `ESCROW_ABI` (was `OPERATOR_ABI` — the
facilitator now calls `AuthCaptureEscrow` directly).

`AuthCaptureFacilitatorScheme.getExtra()` now returns `undefined`:
there's nothing facilitator-injected to advertise — addresses are
constants, captureAuthorizer/feeRecipient/deadlines are merchant-set.

### Settle dispatch

```
extra.autoCapture === true   → AuthCaptureEscrow.charge()    (atomic)
extra.autoCapture !== true   → AuthCaptureEscrow.authorize() (two-phase)
```

### Permit2 path

New `Permit2Payload` variant in a discriminated union. Client signs
Uniswap Permit2 `PermitTransferFrom` (no witness — merchant address is
bound through the deterministic payer-agnostic nonce). Facilitator
dispatches on `assetTransferMethod`; Permit2 collectorData is the
ABI-encoded signature.

### Payer-zeroed nonce

`computePayerAgnosticPaymentInfoHash()` zeroes the payer field before
hashing. Same nonce regardless of payer; per-request freshness comes
entirely from `salt`. Facilitator recomputes and asserts the wire nonce
matches before settling.

## Tests

`pnpm test` — 122 passing across 6 files. Coverage:
- payer-agnosticism (different payers produce identical nonces)
- salt freshness (consecutive `createPaymentPayload` calls produce
distinct salts)
- `autoCapture: true/false/undefined` settle routing
- Permit2 payload construction + facilitator settle target
- collector-mismatch (wrong `to` / `spender`)
- payload-method-mismatch (Permit2 payload with `assetTransferMethod:
'eip3009'` and vice-versa)
- deadline ordering enforcement

## Spec docs (in lockstep)

`specs/schemes/authCapture/scheme_authCapture.md` and
`scheme_authCapture_evm.md` rewritten to match the implementation. The
EVM doc has full extra-field tables, both EIP-3009 and Permit2 payload
shapes, the spec→on-chain field-name mapping table, payer-zeroed nonce
derivation, full verification step list, and settle dispatch.

## Public API break

Subpath imports change: `@x402r/evm/commerce/*` →
`@x402r/evm/authCapture/*`. Wire format breaks: any caller building
`extra` with old field names won't deserialize. Acceptable at `0.0.x`.
Downstream consumers (`@x402r/core`, examples) need lockstep updates in
follow-up PRs.

## Test plan

- [x] `pnpm typecheck` clean
- [x] `pnpm test` — 122/122 passing
- [x] `pnpm build` — tsup ESM + CJS + DTS clean
- [x] `pnpm format` clean
- [x] `pnpm lint:check` clean
- [ ] End-to-end Sepolia validation against canonical AuthCaptureEscrow
deploy (separate PR once deploy addresses are pinned)
- [ ] Update downstream consumers (`@x402r/core`, examples, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants