@x402r/evm: implement authCapture (rename + new wire format + autoCapture + Permit2)#40
@x402r/evm: implement authCapture (rename + new wire format + autoCapture + Permit2)#40
Conversation
Mechanical, identifier-only rename. No behavior change. - Source dir: packages/evm/src/commerce/ -> src/authCapture/ - Tests: packages/evm/test/unit/commerce/ -> test/unit/authCapture/ - Spec docs: specs/schemes/commerce/ -> specs/schemes/authCapture/ (files renamed too) - tsup entrypoints + package.json exports subpaths updated - Identifier renames: scheme constant 'commerce' -> 'authCapture', CommerceEvmScheme/CommerceServerScheme/CommerceFacilitatorScheme classes, CommercePayload/CommerceExtra types + isCommerce* guards, computeCommerceNonce, registerCommerceEvmScheme, invalid_commerce_* - Upstream protocol references kept verbatim: COMMERCE_PAYMENTS_* constants, "commerce-payments" / "Base Commerce Payments" prose The scheme name now reflects the credit-card-style auth/capture pattern of the underlying AuthCaptureEscrow contract. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Phases B–G of the authCapture migration. Lands the new spec wire format, canonical address constants, autoCapture settle dispatch, and the Permit2 asset transfer method as a second supported path. ## extra shape (Phase B) Drop: - escrowAddress, operatorAddress, tokenCollector (now universal constants) - preApprovalExpirySeconds (client derives from maxTimeoutSeconds) - authorizationExpirySeconds, refundExpirySeconds (replaced by absolute deadlines) - settlementMethod (replaced by autoCapture bool) - feeReceiver (renamed feeRecipient) Add: - captureAuthorizer: address (formerly operatorAddress) - captureDeadline / refundDeadline: absolute Unix seconds (uint48) - 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 names (operator, authorizationExpiry, refundExpiry, feeReceiver) so the EIP-712 typehash matches the AuthCaptureEscrow contract byte-for-byte. ## salt placement Salt moves out of extra and onto PaymentPayload. Generated client-side at each createPaymentPayload() call, fresh per request. The facilitator reconstructs PaymentInfo using payload.salt + extra fields + payer. Freshness is mandatory: payer is zeroed in the payer-agnostic nonce derivation, so two payers under an identical extra+salt would collide. ## constants module (Phase C) - AUTH_CAPTURE_ESCROW_ADDRESS (was COMMERCE_PAYMENTS_ESCROW) - EIP3009_TOKEN_COLLECTOR_ADDRESS (was COMMERCE_PAYMENTS_TOKEN_COLLECTOR) - PERMIT2_TOKEN_COLLECTOR_ADDRESS (new) - PERMIT2_ADDRESS (new — canonical Uniswap Permit2) - ESCROW_ABI (was OPERATOR_ABI; calls now target AuthCaptureEscrow directly) - PERMIT2_TRANSFER_FROM_TYPES (new — EIP-712 types for Permit2) Facilitator's getExtra() now returns undefined: addresses are constants, captureAuthorizer/feeRecipient/deadlines are merchant-set. ## settle dispatch (Phase E) extra.autoCapture === true → AuthCaptureEscrow.charge() (atomic) extra.autoCapture !== true → AuthCaptureEscrow.authorize() (two-phase) ## Permit2 path (Phase F) New Permit2Payload variant in the discriminated union. Client signs Uniswap Permit2 PermitTransferFrom (no witness — merchant address bound through the deterministic nonce). Facilitator dispatches on assetTransferMethod, passes PERMIT2_TOKEN_COLLECTOR + ABI-encoded signature as collectorData. ## payer-zeroed nonce computePayerAgnosticPaymentInfoHash() zeroes the payer field before hashing. Same nonce regardless of payer; per-request freshness comes from salt. Facilitator recomputes and asserts wire nonce matches before settling. ## Tests 71 passing (was 64): added payer-agnosticism, salt freshness, autoCapture routing, Permit2 payload, deadline ordering, collector-mismatch, and payload-method-mismatch coverage. ## Spec docs (in lockstep) Both scheme_authCapture.md and scheme_authCapture_evm.md rewritten to match the new wire format. authCapture.md describes the two-phase / single-shot distinction via autoCapture; the EVM doc has full extra-field tables, both payload shapes, nonce derivation, verification + settle steps, and the spec→on-chain field-name mapping table. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Scheme Review — PR #40 (
|
…more tests Critical + correctness + tests + spec polish from PR #40 review. ## Critical (#1): ESCROW_ABI.charge wrong arity The on-chain charge() takes 6 args (paymentInfo, amount, tokenCollector, collectorData, feeBps, feeReceiver). Our ABI declared 4. Any settle with autoCapture: true would have hit a non-existent selector and reverted — masked by the catch-all simulation that swallowed the error as 'simulation_failed'. Fix: - Add feeBps + feeReceiver to ESCROW_ABI.charge - Facilitator settle/simulate branches on functionName: charge passes the 6-tuple, authorize stays at 4 - Defaults match the merchant's signed bounds: feeBps = paymentInfo.minFeeBps (smallest in [min,max]), feeReceiver = paymentInfo.feeReceiver (matches on-chain _validateFee constraint that actual must equal configured when configured != 0) ## Correctness (#9): missing preApprovalExpiry vs captureDeadline check AuthCaptureEscrow._validatePayment enforces preApprovalExp <= authorizationExp <= refundExp. Client picks preApprovalExpiry = now + maxTimeoutSeconds independently from extra.captureDeadline; a tight captureDeadline + generous maxTimeoutSeconds would revert with InvalidExpiries on settle. Fix: facilitator verify() now rejects upfront with invalid_deadline_ordering once preApprovalExpiry is known (after the EIP-3009/Permit2 branch). ## Conventions (#7): silent-failure footgun on non-Base chains ASSET_INFO advertised 9 networks but AuthCaptureEscrow is only deployed on Base mainnet + Base Sepolia. Settlement on the other 7 reverted silently. Fix: trim ASSET_INFO to Base mainnet + Sepolia and drop the dead BASE_CHAIN_IDS constant. Comment in constants.ts now points at ASSET_INFO as the chain-coverage source of truth. ## Spec (#8): hex addresses in scheme_authCapture_evm.md fabrice-cheng asked for the contract addresses in the spec. Added a table under the "Escrow + token-collector addresses are NOT in extra" callout with all four constants and their hex values. ## Tests (#2, #4, #6) Added 18 new tests (71 → 89): Nonce-binding regressions (the whole point of the payer-agnostic-nonce design): - mutate salt after sign → nonce_mismatch - mutate extra.captureAuthorizer after sign → nonce_mismatch - mutate amount after sign (Permit2) → amount_mismatch or nonce_mismatch Verify-path coverage (was 4 of 15+ invalidReason codes; now 14): - amount_mismatch - authorization_expired (validBefore in past) - authorization_not_yet_valid (validAfter in future) - unsupported_asset_transfer_method - network_mismatch (payload.accepted.network differs from requirements) - invalid_authCapture_signature - simulation_failed (with sufficient balance) - insufficient_balance (simulation fails AND balance short) - token_mismatch (Permit2) - token_collector_mismatch (Permit2 — was only EIP-3009 covered) - invalid_deadline_ordering when preApprovalExpiry > captureDeadline EIP-712 domain shape: - EIP-3009 client uses verifyingContract = requirements.asset (token domain) - Permit2 client uses verifyingContract = PERMIT2_ADDRESS (canonical), NOT the token. Common Permit2 bug class. charge ABI 6-arg correctness: - charge call passes 6 args with feeBps at [4] and feeReceiver at [5] - authorize stays at 4 args ## Formatting prettier --write across specs, READMEs, package.json, src, test, tsup.config — 2 spec files reformatted, all source already clean. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Thanks for the thorough review. Pushed 1. CRITICAL — 2. Nonce-binding regression test ✅ Fixed. Added 3 tests under 3. 4. Verify-path negative coverage ✅ Fixed (most). Added tests for 5. Fork tests ⏭️ Deferred. Real fix per #1 lands the broken 6. EIP-712 domain shape ✅ Fixed. Two new client tests assert
These explicitly catch the recurring Permit2 bug class. 7. 8. Hex addresses in spec ✅ Fixed. Added a constants table to 9. Out-of-scope follow-ups — noted for separate PRs:
Formatting: |
…anup Addresses every remaining item from PR #40 review (#3 + #5 + the out-of-scope follow-ups). 117 unit tests pass (was 89 → +28). ## #3: Typed simulation reverts via viem error walking simulateSettle now decodes ContractFunctionRevertedError via BaseError.walk() and maps the AuthCaptureEscrow custom-error name to a stable invalidReason. Unmapped reverts (token-collector errors, RPC failures) fall through to 'simulation_failed'. - New ESCROW_ERRORS_ABI: 17 custom-error definitions matching upstream AuthCaptureEscrow.sol byte-for-byte - New ESCROW_ERROR_TO_INVALID_REASON map covering all expiry/fee/payment- state errors (AfterPreApprovalExpiry → authorization_expired, PaymentAlreadyCollected → payment_already_collected, FeeBpsOutOfRange → fee_bps_out_of_range, etc.) - ESCROW_ABI_WITH_ERRORS spliced into the simulate call so viem decodes the revert against the right ABI - 7 new tests using real ContractFunctionRevertedError (built via encodeErrorResult against a single-error ABI) for known mappings + a fall-through case for unmapped reverts + a non-BaseError case ## #5: Fork tests against canonical AuthCaptureEscrow New opt-in test suite at packages/evm/test/fork/. Spawns anvil forked from Base Sepolia (BASE_SEPOLIA_RPC_URL env), gives a fresh payer keypair USDC balance via anvil_setStorageAt at slot 9, and exercises happy-path settle for {authorize, charge} × {eip3009, permit2} = 4 tests against the real deployed escrow + collectors. - test/fork/anvil-setup.ts: globalSetup that spawns/teardowns anvil - test/fork/eip3009.test.ts: authorize + charge against canonical escrow (the charge case is the regression for the 6-arg ABI fix) - test/fork/permit2.test.ts: same matrix via Permit2 collector, with USDC allowance for Permit2 wired via anvil_setStorageAt at slot 10 - vitest.fork.config.ts: separate config so fork tests stay opt-in - pnpm test:fork script Default `pnpm test` flow stays mock-only and network-free. ## Out-of-scope cleanup - workspace x402r-scheme/CLAUDE.md updated commerce/* paths to authCapture/*; reflects the renamed scheme + new shared/ subdir - spec _ADDRESS suffix consistency: scheme_authCapture_evm.md now uses EIP3009_TOKEN_COLLECTOR_ADDRESS / PERMIT2_TOKEN_COLLECTOR_ADDRESS in prose + verify-step list (matches the constants table) - paymentInfoToContractTuple refactored from re-listed fields to { ...p, maxAmount: BigInt, salt: BigInt } - 21 new direct unit tests for type guards (isAuthCaptureExtra, isEip3009Payload, isPermit2Payload, isAuthCapturePayload): positive cases, missing-field rejections, type rejections, the old commerce-era extra shape rejection, cross-variant rejections ## Spec doc updates - Added "Typed simulation reverts" subsection to scheme_authCapture_evm.md documenting the custom-error → invalidReason mapping table - Spec error-code table updated: simulation_failed now described as "Settlement simulation reverted with an unmapped error" ## Lint config Added `process: 'readonly'` to eslint globals (fork-test setup needs it). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Round 2 — pushed #3 — typed simulation reverts ✅ Implemented.
Kept #5 — fork tests ✅ Implemented.
Out-of-scope cleanup (all four):
Lint config: added Verification: |
Scheme Review — Round 2Verified all 9 round-1 issues against commit Resolved since round 1:
Out-of-scope follow-ups also addressed: workspace Still open from round 1: none. New (1 issue):
Out-of-scope polish (not posting): unhandled anvil-spawn Generated with Claude Code using a review-sdk-style protocol adapted for x402r-scheme |
… polish Addresses the round-2 review comment in full — the main "skip cleanly" fix plus all four out-of-scope polish items. ## Main: fork-test setup skips cleanly when env is unset `anvil-setup.ts:setup()` previously threw when BASE_SEPOLIA_RPC_URL was absent, aborting the suite before per-test it.skip guards could fire. Now it logs a warning and returns; per-test guards do the skipping. `pnpm test:fork` in an opt-in environment without the secret stays green-with- skips instead of red. ## Polish: ENOENT short-circuit Anvil-spawn `error` event was unhandled, so a missing binary surfaced as a 10-second wait-loop timeout. setup() now races the wait-loop against the spawn-error path: ENOENT (or any spawn failure) rejects immediately with an actionable message pointing at Foundry install / ANVIL_BIN override. ## Polish: signal cleanup Registers SIGINT/SIGTERM/exit handlers on first spawn so anvil is killed even on hard parent termination (Ctrl-C, kill, vitest crash). Each handler re-raises the signal so the parent exit code stays conventional. ## Polish: post-state assertions Both fork tests now read escrow.paymentState(getHash(paymentInfo)) after settle and assert the on-chain effect, not just non-revert: - authorize → hasCollectedPayment=true, capturableAmount=amount, refundableAmount=0 - charge → hasCollectedPayment=true, capturableAmount=0, refundableAmount=amount EIP-3009 charge test additionally reads receiver USDC balance and asserts funds actually landed (with minFeeBps=0 the receiver gets the full amount). Permit2 tests get the same paymentState assertions. New ESCROW_VIEW_ABI in shared/constants.ts declares getHash + paymentState view functions (kept separate from ESCROW_ABI which is just authorize + charge for the settle path). ## Polish: README test:fork docs Added a Testing section to packages/evm/README.md with the env-var matrix (BASE_SEPOLIA_RPC_URL, ANVIL_BIN, BASE_SEPOLIA_FORK_BLOCK, ANVIL_VERBOSE) and the unit-vs-fork distinction. ## Lint Added setInterval/clearInterval/NodeJS to the eslint global allowlist for the new fork-setup code paths. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Round 3 — pushed 1. Fork-test setup graceful skip ✅ Fixed.
Polish (all four landed): ENOENT short-circuit: Anvil-spawn await Promise.race([
waitForAnvilReady(rpcUrl),
new Promise<never>((_, reject) => {
const tick = setInterval(() => {
if (spawnError) { clearInterval(tick); reject(spawnError) }
}, 50)
}),
])With an actionable message: Signal cleanup: Registers Post-state assertions: Both fork tests now read
The EIP-3009 charge test also reads receiver USDC balance and asserts the funds actually landed (with New README docs: Added a Testing section to
Plus the unit-vs-fork distinction: Lint: Added Verification: |
…onds strict The current upstream PR diff still shows the pre-rewrite spec (where minFeeBps was optional with default 0), but fabrice-cheng's 2026-04-26 rewrite proposal explicitly marks minFeeBps as Required: Yes. Aligning the SDK + spec doc with the proposal — no implicit defaults on fee policy. ## Changes - `AuthCaptureExtra.minFeeBps`: optional (default 0) → required - `isAuthCaptureExtra` type guard now rejects extra without minFeeBps - Client validates minFeeBps + maxFeeBps presence with explicit errors - Removed `?? 0` fallback in client + facilitator — paymentInfo.minFeeBps comes straight from extra - Made `maxTimeoutSeconds` strict in client (was `?? 60` fallback); fabrice's proposal lists it as a required top-level x402 field, so the fallback was masking a missing-required-field bug ## Spec doc `scheme_authCapture_evm.md` extra-fields table: - minFeeBps: Required Yes (was No, default 0) - maxFeeBps row reordered for paired display with minFeeBps - Description clarifies "0 = no minimum" so devs know what to write when they don't care about a fee floor ## Tests - 2 new type-guard rejection tests (minFeeBps missing, maxFeeBps missing) - All existing fixtures updated to include minFeeBps: 0 - 119 unit tests pass (was 117 → +2) ## Audit pass — full alignment with fabrice's proposal + later resolutions Verified field-by-field against the 2026-04-26 comment table and the two follow-up resolutions (autoCapture: 2026-04-26 17:34, salt-on-payload: 2026-04-28): extra: assetTransferMethod No default 'eip3009' ✓ captureAuthorizer Yes ✓ name Yes ✓ version Yes ✓ captureDeadline Yes ✓ refundDeadline Yes ✓ minFeeBps Yes ✓ (this commit) maxFeeBps Yes ✓ feeRecipient Yes ✓ autoCapture No default false ✓ (later resolution) salt MOVED to payload ✓ (later resolution) EIP-3009 payload: from / to=collector / value / validAfter='0' / validBefore=preApprovalExpiry / nonce=payer-agnostic hash / signature / salt / domain.verifyingContract = asset ✓ Permit2 payload: from / permitted / spender=collector / nonce=uint256(hash) / deadline / signature / salt / domain.verifyingContract = PERMIT2 / no witness ✓ Nonce: keccak256(abi.encode(chainId, AUTH_CAPTURE_ESCROW_ADDRESS, keccak256(abi.encode(TYPEHASH, paymentInfoWithZeroPayer)))) ✓ Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Per A1igator's follow-up on PR #1425. The captureAuthorizer is the only address allowed to call ANY of the lifecycle methods on AuthCaptureEscrow — authorize, capture, void, refund, and charge are all gated by onlySender(paymentInfo.operator). Previous docs only mentioned capture/void/refund, missing authorize and charge (which are the entry points the facilitator itself uses on settle). Updated: - AuthCaptureExtra.captureAuthorizer field comment in shared/types.ts - scheme_authCapture_evm.md component bullet + extra-fields table - scheme_authCapture.md (network-agnostic) summary Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Per A1igator's followup. Every lifecycle method on AuthCaptureEscrow (authorize/capture/void/refund/charge) is gated by onlySender(paymentInfo.operator), so the captureAuthorizer must be msg.sender of the on-chain settle. In x402's facilitator-driven flow, that means: - the facilitator's EOA, or - a smart contract the facilitator controls (PaymentOperator, arbiter, etc.) The merchant address can't be the captureAuthorizer unless they self-host the facilitator (their own EOA submits the on-chain tx). Removed the incorrect "may be the merchant itself" wording from prior docs. The constraint is on the escrow's onlySender check — it's independent of assetTransferMethod. Both EIP-3009 and Permit2 paths route through the same authorize/charge entry points. Updated: - AuthCaptureExtra.captureAuthorizer field comment in shared/types.ts - scheme_authCapture_evm.md component bullet + extra-fields table description - scheme_authCapture.md (network-agnostic) summary Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Refines the previous wording. Two corrections: 1. The smart contract doesn't need to be "controlled by the facilitator" in any ownership sense — it's just any contract whose own access control ends up letting the right caller invoke escrow. PaymentOperator, permissionless arbiter, multisig, etc. all work. 2. The merchant CAN be the captureAuthorizer indirectly — through a smart contract whose access control grants them call rights. The constraint is only that the merchant's EOA isn't directly the captureAuthorizer (because the merchant's EOA isn't msg.sender of the on-chain settle in x402's facilitator-submits flow). Updated comment in shared/types.ts and both spec docs to match. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The exclusion of the merchant EOA is already implied by the rest of the description (must be msg.sender of the on-chain settle, which in x402's facilitator-submits flow is the facilitator or a contract). Stating it explicitly was redundant. Tightened all three locations. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…tical Two corrections per A1igator's followups: 1. "Every lifecycle method" was wrong — `reclaim` is payer-gated, not operator-gated. Tightened to "each of those methods" referring to the specific authorize/capture/void/refund/charge list. 2. Removed the "(per-network specifics in the per-network spec document)" parenthetical from the network-agnostic doc — the smart-contract types aren't spec'd per network, that wording implied they were. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The scheme spec is meant to be agnostic to specific x402r contracts. PaymentOperator is x402r-authored, not part of the spec's vocabulary. Replaced with generic "arbiter contract with dispute logic, a multisig, etc." which keeps the same point (any smart contract works) without naming our impl. Updated both shared/types.ts comment and scheme_authCapture_evm.md component bullet. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…all" Per A1igator. The phrase "on-chain settle call" was vague — every method on the escrow is an on-chain call. The actual operator-side entry point that matters here is "Authorize" (the first lifecycle step that gets recorded against the captureAuthorizer's identity). Tightened to that specific reference. Updated: - AuthCaptureExtra.captureAuthorizer comment in shared/types.ts - scheme_authCapture_evm.md component bullet + extra-fields table Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The "must be msg.sender of the 'Authorize' call (the facilitator EOA, or any smart contract that ends up calling escrow)" clarification is already implied by what the captureAuthorizer is authorized to call. Trimmed the table description to just the powers + on-chain mapping; the Summary bullet still has the full prose explanation for readers who want it. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
base/[email protected] redeployed at canonical CREATE2 addresses: AUTH_CAPTURE_ESCROW_ADDRESS: 0xF8211868187974a7Fb9d99b8fFB171AD70665Dc6 EIP3009_TOKEN_COLLECTOR_ADDRESS: 0x7561DC178D9aD5bc5fb103C01f448A510d2A36D0 PERMIT2_TOKEN_COLLECTOR_ADDRESS: 0xD8490609d2da0ee626b0e676941b225cbc1A8C08 Same address on every supported EVM chain. Spec gains a "Canonical Addresses" section in the appendix listing the salt labels and the deployed-chain table (11 mainnets + 3 testnets). ASSET_INFO expanded from Base mainnet/Sepolia to also cover Ethereum, Optimism, Arbitrum One, Polygon, Celo, Avalanche, Linea, Monad, plus Ethereum Sepolia and Arbitrum Sepolia. BSC and Tempo are intentionally absent — BSC's Binance-Peg "USDC" has different EIP-712 metadata than Circle-native v2, and Tempo uses pathUSD (TIP-20) rather than USDC, so both need separate registry entries with their own domain config. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
ASSET_INFO gains BSC (Binance-Peg USDC, 18 decimals, lacks ERC-3009) and Tempo (pathUSD TIP-20, 6 decimals, lacks ERC-3009) with a new `permit2Only: true` flag. Default money parsing now works on both chains; merchants must set `assetTransferMethod: "permit2"` since the canonical stable on those chains has no ERC-3009 path. Spec table augmented with a per-chain `assetTransferMethod` column so the constraint is visible at the spec layer. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Pulls every change under specs/ out of this PR so the implementation diff is easier to review on its own. The authCapture spec (new) and the commerce spec deletion (because authCapture replaces it) move to the spec-only follow-up PR. This PR is now strictly the @x402r/evm package implementation: constants, server scheme, facilitator scheme, client, tests, examples. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Scheme Review — Round 3Verified round-3 fixes against commit Resolved since round 2:
Still open from round 2: none. New (2 issues):
Out-of-scope polish (not posting): Generated with Claude Code using a review-sdk-style protocol adapted for x402r-scheme |
… redeploy Round-3 review feedback (PR #40): #2 (UX): defaultMoneyConversion now injects assetTransferMethod: "permit2" into the returned extra when the chain is flagged permit2Only (BSC, Tempo). Merchants on those chains no longer have to remember to set the field manually; forgetting it would have surfaced as a token-collection revert on simulate. 3 new tests cover BSC, Tempo, and the negative case (Base mainnet still omits the field). #1 (docs): tightened the constants.ts header comment to cite base/[email protected] + the 2026-04-29 CreateX redeploy + the deterministic salt namespace, and called out the prototype addresses as superseded so reviewers cross-checking against stale references know where to look. 122/122 unit tests pass (was 119). typecheck/lint/format/build clean.
|
Round 4 — pushed 1. Canonical address mismatch — non-issue, but tightened the docs. The new addresses ( To prevent this confusion next time, the constants.ts header now cites Fork tests already exercise the 6-arg 2. Took the friendlier of the two options the review proposed: const extra: Record<string, unknown> = { name: assetInfo.name, version: assetInfo.version }
if (assetInfo.permit2Only) {
extra.assetTransferMethod = 'permit2'
}3 new server tests cover it: BSC ($1.00 → 18-decimal Binance-Peg USDC, extra includes Comment block at the top of Verification: |
The contract's _validatePayment uses `preApprovalExp <= authorizationExp <= refundExp` (equal allowed). The facilitator was rejecting refundDeadline === captureDeadline with strict <= before letting the contract decide, which made the off-chain check stricter than on-chain. Switch to < so the off-chain boundary matches the contract's <=. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The server's defaultMoneyConversion was inspecting a per-chain permit2Only flag and injecting assetTransferMethod: 'permit2' for BSC and Tempo. That's wrong framing — whether a token implements receiveWithAuthorization is a token-level capability, not a chain property. The current behavior happened to be correct for the canonical default stables on those chains, but it pre-locked merchants out of making their own choice if they used a different token. Now: defaultMoneyConversion only emits the EIP-712 domain (name, version) for the canonical stable. assetTransferMethod is always the merchant's call. If a merchant pairs eip3009 with a token that lacks receiveWithAuthorization, the failure surfaces as simulation_failed at the facilitator (consistent with how upstream specs handle the analogous case). Tests updated: dropped the two "should inject permit2 on BSC/Tempo" cases; replaced the "should NOT inject on ERC-3009 chains" with a generic "should never inject" that walks all three chain categories. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Resolves the package.json conflict by stepping past the 0.1.0 bump on main. Keeps the AuthCaptureExtra description from this branch and pulls in the claude-code-review workflow removal from main. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
## Summary - Adds the **`authCapture`** scheme spec — cross-VM abstract (`scheme_authCapture.md`) + EVM implementation (`scheme_authCapture_evm.md`). - Retires the obsolete `commerce` scheme spec (`scheme_commerce.md` + `scheme_commerce_evm.md`). - Spec-only PR — no code changes. The companion @x402r/evm implementation lands separately in #40. ## 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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
|
Action items from a review pass: Cleanup
PR body
Spec follow-ups
FYI — Permit2 differs from upstream x402's exact/upto schemes (not a blocker) Just so it's a known deliberate divergence: the upstream This is structural, not stylistic: the commerce-payments collector reconstructs the destination from I think it's the right call for this model; flagging so anyone reading both codebases doesn't wonder why we don't witness. |
- Remove MAX_UINT48 / MAX_UINT32 from shared/constants.ts — exported but never imported anywhere. - Rename `escrowAddress` / `operatorAddress` in server.test.ts merge fixtures to opaque keys. The merge logic in enhancePaymentRequirements is key-agnostic, and the old names were commerce-era artifacts that no longer reflect the wire format (escrow address is a constant; operatorAddress is now captureAuthorizer at the wire layer). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Thanks — picked up the cleanup items. Cleanup
PR body Refreshed to "122 passing across 6 files" — was a stale carryover from the pre-rewrite commit message. Spec follow-ups
Permit2 vs upstream witness divergence Agreed — this is a structural consequence of using the canonical commerce-payments collector (which we don't own and can't modify) plus the payer-zeroed nonce. The PaymentInfo hash binds the destination cryptographically through the nonce, so a |
|
All items check out — verified the cleanup commits and re-ran the suite (122/122). On The Yes please add the "Comparison vs upstream |
Two-commit PR landing the full authCapture migration in the scheme package.
Commit 1: rename
commerce→authCapture(mechanical, identifier-only)packages/evm/src/commerce/→src/authCapture/packages/evm/test/unit/commerce/→test/unit/authCapture/specs/schemes/commerce/scheme_commerce*.md→specs/schemes/authCapture/scheme_authCapture*.md./commerce/*→./authCapture/*)*EvmScheme,*ServerScheme,*FacilitatorScheme), types (*Payload,*Extra), type guards, helpers (computeCommerceNonce,registerCommerceEvmScheme), error codesCOMMERCE_PAYMENTS_*constants andcommerce-payments/Base Commerce Paymentsprose kept verbatim — they refer to the underlying base/commerce-payments protocol, not our schemeCommit 2: new wire format, salt on payload, autoCapture, Permit2
extrashapeDrop:
escrowAddress,operatorAddress,tokenCollector— universal constants nowpreApprovalExpirySeconds— client derives from top-levelmaxTimeoutSecondsauthorizationExpirySeconds,refundExpirySeconds— replaced by absolute deadlinessettlementMethod— replaced byautoCaptureboolfeeReceiver— renamedfeeRecipientAdd:
captureAuthorizer: address(formerlyoperatorAddress)captureDeadline: uint48/refundDeadline: uint48(absolute Unix seconds)feeRecipient: addressautoCapture?: bool(defaultfalse)assetTransferMethod?: 'eip3009' | 'permit2'(default'eip3009')Spec field names live at the wire layer; the on-chain
PaymentInfostruct keeps canonical Solidity field names (operator,authorizationExpiry,refundExpiry,feeReceiver) so the EIP-712 typehash matchesAuthCaptureEscrow.PAYMENT_INFO_TYPEHASHbyte-for-byte.saltplacementMoved out of
extraand ontoPaymentPayload. Generated client-side at everycreatePaymentPayload()call, fresh per request. Facilitator reconstructsPaymentInfofrompayload.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(wasOPERATOR_ABI— the facilitator now callsAuthCaptureEscrowdirectly).AuthCaptureFacilitatorScheme.getExtra()now returnsundefined: there's nothing facilitator-injected to advertise — addresses are constants, captureAuthorizer/feeRecipient/deadlines are merchant-set.Settle dispatch
Permit2 path
New
Permit2Payloadvariant in a discriminated union. Client signs Uniswap Permit2PermitTransferFrom(no witness — merchant address is bound through the deterministic payer-agnostic nonce). Facilitator dispatches onassetTransferMethod; 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 fromsalt. Facilitator recomputes and asserts the wire nonce matches before settling.Tests
pnpm test— 122 passing across 6 files. Coverage:createPaymentPayloadcalls produce distinct salts)autoCapture: true/false/undefinedsettle routingto/spender)assetTransferMethod: 'eip3009'and vice-versa)Spec docs (in lockstep)
specs/schemes/authCapture/scheme_authCapture.mdandscheme_authCapture_evm.mdrewritten 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 buildingextrawith old field names won't deserialize. Acceptable at0.0.x. Downstream consumers (@x402r/core, examples) need lockstep updates in follow-up PRs.Test plan
pnpm typecheckcleanpnpm test— 122/122 passingpnpm build— tsup ESM + CJS + DTS cleanpnpm formatcleanpnpm lint:checkclean@x402r/core, examples, etc.)🤖 Generated with Claude Code