authCapture TS sdk#2308
Conversation
Implements the authCapture scheme per spec x402-foundation#1425. Builds on the audited base/commerce-payments contract stack, so no new contracts ship with this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@A1igator is attempting to deploy a commit to the Coinbase Team on Vercel. A member of the Team first needs to authorize it. |
Required by the Check Paywall Template workflow on this PR (the first to touch `typescript/packages/mechanisms/evm/` since `dd6d7e6f`). The committed templates had drifted from what a fresh build produces: the diff is in esbuild's minified identifier names, not in semantic content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary Two facilitator bugs found while running the same `authCapture` code as the upstream x402 TypeScript implementation against live Base Sepolia ([x402-foundation/x402#2308](x402-foundation/x402#2308)). Both reverts only surface against the real escrow / Permit2 collectors and pure unit-test mocking misses them, which is why they sat undetected. ## Bug 1: `simulateSettle` missing `account` `simulateSettle` called `readContract` without `account`, so the underlying `eth_call` ran with `msg.sender = 0x0`. Escrow's `onlySender(paymentInfo.operator)` then reverted with `InvalidSender`, which we map to `invalid_capture_authorizer`. Every EOA-`captureAuthorizer` verify failed even when the real submitter would have matched. Fix: pass `account: this.signer.getAddresses()[0]` so escrow's `onlySender` is evaluated against the same `msg.sender` the real settle tx will have (EOA path: facilitator EOA; contract path: captureAuthorizer contract, which forwards as itself). The `FacilitatorEvmSigner` type in `@x402/evm` doesn't yet declare `account` on `readContract` — viem's underlying `readContract` accepts it. Carrying the value through with a local `Parameters<...>` cast; the upstream signer-type update is pending in [x402-foundation/x402#2308](x402-foundation/x402#2308) and once merged we can drop the cast. ## Bug 2: Permit2 `collectorData` was ABI-wrapped `unpackForSettle` wrapped the Permit2 signature with `encodeAbiParameters([{ type: "bytes" }], [sig])`. Permit2's collector validates `signature.length == 65` on the raw byte array and reverted with `InvalidSignatureLength()` for every Permit2 settle. Fix: pass `p.signature` directly. Matches the EIP-3009 path, which never wrapped. ## How they were missed Unit tests mock `readContract` and never exercise the on-chain side, so neither the `account`-on-simulate gap nor the Permit2 length check fired. They only surface in an end-to-end run against the real escrow + collector contracts. Adding an integration test against the canonical AuthCaptureEscrow on Base Sepolia (the same pattern upstream's `batch-settlement-evm.test.ts` follows) would catch them — worth a follow-up. ## Test plan - [x] `pnpm test` — 124/124 unit tests pass - [x] `pnpm lint:check` — clean - [x] `pnpm format:check` — clean - [x] Fixes validated end-to-end on Base Sepolia inside the upstream PR ([#2308](x402-foundation/x402#2308)): 4/4 integration test cases pass (ERC-3009 / Permit2 × autoCapture: false / true), 8/8 e2e harness scenarios pass across express / fastify / hono with fetch and axios clients. Real settle txs confirmed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| // Static deadlines: every authorization created by this server expires at | ||
| // the same absolute Unix second. Production servers typically compute these | ||
| // per request via custom middleware; static deadlines suffice for the demo. | ||
| const now = Math.floor(Date.now() / 1000); | ||
| const captureDeadline = now + 30 * 86400; | ||
| const refundDeadline = now + 60 * 86400; |
There was a problem hiding this comment.
this should be done in enhancePaymentRequirements, so its fresh for each request
There was a problem hiding this comment.
Added *Seconds variants of them that are filled by enhancePaymentRequirements. Let me know if that was not the intention.
| * @param _ - Unused FacilitatorContext (interface compatibility). | ||
| * @returns A `VerifyResponse` with `isValid` and, on failure, a stable `invalidReason`. | ||
| */ | ||
| async verify( |
There was a problem hiding this comment.
For the permit2 branch we should add support for the ERC-20 approval gas sponsoring extension
There was a problem hiding this comment.
Is there a base commerce deposit collector that supports EIP-2612?
There was a problem hiding this comment.
I don't believe so. I can build one and include here/contribute to base commerce-payments repo but not sure how audits and stuff work for that.
|
Thanks a lot for the contribution @A1igator, made a first pass and looks good! Please have a look at my comments above |
The paywall template changes in this PR were a CI workaround. The authCapture work doesn't touch paywall logic, but CI was failing on a paywall consistency check unrelated to this PR (template drift on main at the time). Regenerating the templates locally made CI green. Main has since moved on with its own paywall fixes, so the stale regen is now both unnecessary and the source of a merge conflict against upstream/main. Revert the 9 template files to the merge-base state so the merge from upstream/main applies cleanly. If CI fails on a paywall check again after this revert, the previous regen was masking a real issue and will be addressed in a separate PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address phdargen's four review comments on this PR plus a related hardening that emerged in the back-and-forth. - Drop local ASSET_INFO in favor of getDefaultAsset from ../../shared/defaultAssets. Merchants on chains not in DEFAULT_STABLECOINS (Ethereum mainnet, Optimism, Celo, Avalanche, Linea, Sepolia, BSC, Tempo) now pass an explicit AssetAmount instead of using the decimal fallback, matching how exact and upto handle the same situation. - Drop local string-math convertToTokenAmount in favor of the shared @x402/core/utils helpers. Same algorithm, one source of truth. - Drop the manually-set name/version from the example server's accepts.extra; parsePrice resolves them from the default-asset table. - Move boot-time deadline computation into enhancePaymentRequirements so each request gets a fresh window. Merchants set per-route relative offsets in extra.captureDeadlineSeconds / extra.refundDeadlineSeconds (matching the *Seconds suffix from maxTimeoutSeconds); the scheme converts to absolute captureDeadline / refundDeadline per request and strips the offset keys. Absolute deadlines are still accepted for externally-driven cases (e.g., tied to a delivery commit); absolute wins over relative, per-field mix supported. Per-route windows let a high-cost long-running job carry a different envelope from a cheap one-shot call; the scheme has no constructor configuration for deadlines, which would have forced one global policy onto every route using the scheme instance. Asymmetric fail-fast (matches batch-settlement's pattern): enhancePaymentRequirements throws when a merchant-set required field (captureAuthorizer, deadlines, feeRecipient, min/maxFeeBps) is missing or wrongly-typed, so misconfiguration lands in merchant logs instead of in 402s to payers. name/version are deliberately excluded because parsePrice auto-populates them; merchants supplying a custom AssetAmount who forget them still get caught by the facilitator's isAuthCaptureExtra guard. E2E wiring (express, fastify, hono, next-proxy + two next route files) updated to the *Seconds shape and dropped hardcoded name/version. 754/754 tests pass (+17 over baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
authCapture EVM Scheme — TypeScript SDK
At x402r.org, we've been working on refundable payments for x402. This PR is the TypeScript implementation of the
authCapturescheme whose spec was merged in #1425. It builds on the audited base/commerce-payments contract stack, so no new contracts ship with this PR.Scheme implementation: key design decisions
authCaptureis single-shot and stateless. Each paid request signs one authorization (ERC-3009 or Permit2) carrying universal canonical collector + escrow addresses. The facilitator then submits a single tx that invokesAuthCaptureEscrow.authorize()orAuthCaptureEscrow.charge()(perextra.autoCapture), either calling escrow directly or routing through a passthroughcaptureAuthorizercontract.Notable choices:
captureAuthorizeris merchant-set, never facilitator-injected — the facilitator'sgetExtra()returnsundefinedfor this scheme. Per spec,captureAuthorizeris the only address allowed to callauthorize/capture/void/refund/chargeagainst the escrow, gated on-chain byonlySender(paymentInfo.operator). That address can be the facilitator's submitter EOA, or a smart contract that ultimately calls escrow asmsg.sender(e.g., a refund arbiter). The facilitator advertises its submitter address viagetSigners()so merchants can decide.captureAuthorizerauto-detection — the facilitator probesgetCode(captureAuthorizer). Empty bytecode → EOA path, direct call to escrow withmsg.sender == captureAuthorizer. Non-empty → contract path, call routed through the contract.assetTransferMethodis per-request — defaults to"eip3009"; set to"permit2"for tokens that lack ERC-3009 (e.g., BSC's Binance-Peg USDC, some L2 USD stablecoins). The server does not auto-pick based on chain; the merchant declares the method explicitly.Spec observations from implementation
Small clarifications a follow-up doc pass could pick up:
Verify step 13 / Settlement step 5.
captureAuthorizercan be the facilitator EOA (call goes to escrow directly) or a passthrough contract (call goes to the contract, which forwards to escrow). The spec's "callAUTH_CAPTURE_ESCROW.authorize/charge" wording is technically correct for both since the contract path reaches escrow underneath, but a revert in the contract case can originate in the passthrough before reaching escrow.Relatedly,
invalid_capture_authorizer(escrow'sInvalidSender) only fires whenonlySender(operator)fails — i.e., when the address that called escrow (the facilitator EOA or passthrough contract) doesn't matchoperator(=extra.captureAuthorizerfrom PaymentInfo).Spelling these out in the verification/settlement logic steps would make the contract path easier to reason about.
Scheme names are inconsistent. Existing schemes are lowercase or kebab-case (
exact,upto,batch-settlement);authCaptureis camelCase. This breaks tooling that assumes one case.The e2e CLI
--schemesfilter, for instance, lowercases user input and silently dropped all authCapture matches — the case-insensitive compare added in this PR (e2e/src/cli/filters.ts) works around it.Picking one convention and applying it backwards (e.g., renaming
authCapturetoauth-captureif kebab-case wins) would make scheme identifiers uniform.Reference links are dead.
scheme_authCapture.mdlines 84-85 link to issues on the legacygithub.com/coinbase/x402repo that no longer resolve; canonical repo isgithub.com/x402-foundation/x402.The org segment should be swapped to
x402-foundationto fix them.What's included
@x402/evm/authCapture/{client,server,facilitator}— new subdirectory under the existing@x402/evmpackage, alongsideexact/,upto/, andbatch-settlement/. Three new subpath exports added totsup.config.tsandpackage.json.test/unit/authCapture/, covering client payload construction, serverparsePrice/enhancePaymentRequirements, facilitator verify + settle paths for both EIP-3009 and Permit2, nonce / salt derivation, type guards, and the EOA vs. contractcaptureAuthorizerrouting. All 735 package tests pass.test/integrations/authCapture-evm.test.ts, Base Sepolia live-testnet style matching thebatch-settlement-evm.test.tspattern. Skips withdescribe.skipunlessCLIENT_PRIVATE_KEYandFACILITATOR_PRIVATE_KEYare set. Covers ERC-3009 + Permit2 × autoCapture: false / true.examples/typescript/{clients,servers,facilitator}/authCapture/— three standalone apps mirroring the batch-settlement example layout.e2e/servers/next/app/api/authCapture/evm/{eip3009,permit2}/withx402/. Endpoint entries added to test configs.PaymentSchemetype extended ine2e/src/types.tsandPaymentSchemeKindine2e/src/cli/filters.ts.EVM_AUTHCAPTURE_CAPTURE_AUTHORIZERdeclared in optional env vars, with conditional registration: when unset, authCapture routes are skipped (matching theEVM_RECEIVER_AUTHORIZER_PRIVATE_KEYpattern used by batch-settlement).AuthCaptureEvmSchemealso registered on the fetch and axios e2e clients alongside the existing schemes.authCapturescheme specification #1425) and propagating the MIT attribution for the Agentokratia "x402-escrow" proposal (issue [Proposal] escrow scheme using Base Commerce Payments Protocol #834) whose code the implementation incorporates.@x402/evm: minor, "Implemented authCapture mechanism".Disclosure
This PR was written with AI assistance (Claude Code). Output was reviewed before submission per the AI guidance in
CONTRIBUTING.md.Links
Spec: #1425
Original proposals: #1011, #834
Tests
nextserver skipped due to a pre-existing Next 16 + Aptos SDK transitive build issue affecting all schemes, not specific to this PR — not surfaced by CI since.github/workflows/only runs unit tests, no e2e workflow exists upstream)Checklist