examples(scenarios): HTTP-wire capture — cross-package integration test for the @x402 ↔ @x402r/evm seam#133
Conversation
Adds a single CI scenario exercising the real HTTP 402 wire end-to-end:
payer fetch (x402Client + wrapFetchWithPayment) → resource server (Express +
paymentMiddleware + x402ResourceServer) → facilitator (Express + x402Facilitator
+ AuthCaptureFacilitatorScheme) → on-chain settle against Anvil.
Targets the integration seam between upstream @x402/{core,evm,express,fetch}
at 2.12.0 and our @x402r/evm at 0.2.0-alpha.0 — the bug class no existing
test catches. Inline Anvil bootstrap + facilitator + resource server in one
file (~180 LOC); reuses the shared prool orchestrator via SCENARIO_RPC_URL.
autoCapture is left unset (authorize-only path); post-conditions assert:
- settle tx targets the canonical AuthCaptureEscrow singleton;
- payer USDC ↓ PAYMENT_AMOUNT;
- receiver USDC unchanged (no capture occurred);
- settleResponse from x-payment-response header matches payer.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
SDK ReviewFound 1 issue (axes: conventions):
Generated with Claude Code using review-sdk skill |
…ead of hardcoding chainConfig.authCaptureEscrow is already exposed by @x402r/core and matches the literal previously hardcoded for Base Sepolia. Drift risk: if x402rChains later registers a chain whose authCaptureEscrow diverges from the legacy literal, the hardcoded form would silently assert against the wrong contract. Resolving through chainConfig keeps the wire-side assertion target tracking the source-of-truth address table. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Thanks for catching the drift risk — fixed in 3bdc403. |
SDK Review — Round 2Found 4 new issues since commit 3bdc403 (axes: tests, conventions, example alignment): Resolved since last round: hardcoded
Generated with Claude Code using review-sdk skill |
…ttp-wire-capture
Folds three cleanup findings from the second-round review:
1. Reuse `testAccounts` from `shared/anvil-setup.ts` (now exported) instead
of redefining deployer / payer / receiver inline in the HTTP-wire
scenario. Drops ~15 lines and keeps the role mapping single-sourced.
2. Promote `USDC_BALANCE_SLOT` + `getBalanceSlot` to exports of the same
shared module so the scenario imports both instead of carrying its own
copies. The test-workspace duplicate in
packages/core/tests/setup/deploy-fixtures.ts is intentionally untouched
(different scope; tracked separately).
3. Drop the inner `price.extra: { name, version }` block. Upstream
x402ResourceServer merges via `extra: { ...parsedPrice.extra,
...resourceConfig.extra }` — top-level wins, so the inner copy was
silently shadowed today. Comment realigned to call out the merge order
instead of misclaiming `price.extra` as the canonical domain location.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…rocess express servers
Express's `app.listen(port)` returns the http.Server synchronously, but the
underlying 'listening' event fires asynchronously. Returning the URL right
after the call meant `paidFetch` could race against the socket bind on cold
CI runners — observable as a transient `ECONNREFUSED` on the first request,
unobservable on warm local runs.
Wrap both express boot sites (facilitator + resource server) in an
await-promise that resolves on 'listening' and rejects on 'error' (with a
clear "failed to bind ${PORT}" message). Sync API isn't broken; only the
ordering relative to the URL hand-off changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Thanks for the round-3 sweep — all four folded. Finding 1 (reuse testAccounts): c3b5378. Finding 2 (extract balance-slot helpers): c3b5378 — same commit. Finding 3 (drop dead Finding 4 (await 'listening'): 77b9c04. Both express boot sites (facilitator + resource server) now await a promise that resolves on |
SDK Review — Round 3Found 4 new issues since commit Resolved since last round: hardcoded
Generated with Claude Code using review-sdk skill |
…ng concat
Drops the `as `0x${string}`` cast and the manual `0x${x.toString(16)}` build —
`numberToHex(amount, { size: 32 })` returns the same 32-byte padded hex
value directly. Pre-existing duplicates in shared/anvil-setup.ts are out
of scope here.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… (defensive cleanup) When startFacilitator/startResourceServer reject on a bind failure, main()'s finally block can't reach the server (the helper never returned it), so any internal state on the http.Server is orphaned. Node releases the underlying socket itself on EADDRINUSE — the listener doesn't survive — so this isn't a SIGKILL-class leak, but server.close() is idempotent on an unbound socket and makes the cleanup explicit. Both express boot sites updated. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… wall-clock
The captureDeadline / refundDeadline values published in route extra are
compared against block.timestamp inside the AuthCaptureEscrow contract.
Computing `now` from `Date.now()` worked today because a freshly-forked
Anvil's block.timestamp tracks wall-clock and the 3600s buffer absorbs any
drift, but it's the wrong reference frame — any future scenario that
fast-forwards the chain (testClient.increaseTime) would silently mis-set
the deadlines.
Switch to `publicClient.getBlock({ blockTag: 'latest' }).timestamp` so
the source matches the on-chain check. startResourceServer now takes the
publicClient as a parameter to surface this dependency on the call site.
Cross-scenario timestamp pollution isn't reachable here — per-subpath
prool isolates each scenario into its own Anvil child — so this is purely
about using the right clock.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…uthorize (catches state-update regressions) Token-balance checks prove value moved, but they don't prove the escrow's internal `_paymentState` mapping was updated. A contract bug or upgrade that moves tokens via ERC-3009 but leaves capturableAmount / hasCollectedPayment unset would pass everything above and only surface when a downstream capture tried to operate on the missing state. Parse the PaymentAuthorized event from the settle receipt to get the paymentInfoHash (avoids reconstructing PaymentInfo from the wire), then read paymentState(hash) on the escrow and assert hasCollectedPayment === true, capturableAmount === PAYMENT_AMOUNT, refundableAmount === 0n. Note on framing: the "off-by-one paymentInfoHash" failure mode isn't reachable in practice — that hash is the ERC-3009 nonce, so a bad hash would either revert the tx (signature recovery fails) or debit the wrong payer (which the existing payer-↓ check catches). The new step covers a different bug class — escrow internal state corruption. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Thanks for the round-4 sweep — all four folded, with a couple of framing corrections worth flagging since the underlying fixes are still valid. Finding 1 (paymentState assertion): 367723b. The off-by-one-hash failure mode the comment described isn't actually reachable — that same hash is the ERC-3009 nonce, so a wrong hash either fails signature recovery (tx reverts) or debits the wrong payer (which the existing Finding 2 (close-before-reject): 76ad609. The SIGKILL / EADDRINUSE framing isn't quite right — Node releases the underlying socket itself on bind errors, so the listener doesn't survive. But the defensive close before reject is still good practice (idempotent on an unbound socket, surfaces the cleanup explicitly), and on a reject path Finding 3 (block.timestamp deadlines): 22ea6df. Cross-scenario timestamp pollution isn't reachable here — per-subpath prool isolates each scenario into its own Anvil child. But Finding 4 (numberToHex): aaaf179. Trivial swap of All 6 scenario steps now pass standalone, scenarios:ci end-to-end, and back-to-back scenarios:ci with no port collision. |
SDK Review — Round 4Found 2 new issues since commit Resolved since last round: missing
Generated with Claude Code using review-sdk skill |
|
Follow-up observations after re-reading the final state. PR looks good to land; these are scope-judgment calls — happy with you taking, deferring, or pushing back on any of them. 1. USDC slot-fallback divergence between 2. Possible event-arg assertions in step 6. 3. The two 4. Cross-ref for the `@x402/core` version split. `examples/package.json` now pins `@x402/[email protected]` while `packages/helpers/package.json` devDep is at `2.5.0`. Both satisfy the helpers peer-dep range, but this PR makes the gap more legible. Already tracked in #156 item 4 — flagging only so the cross-ref is here. Bigger follow-up (not for this PR): the inline `bootstrapAnvil` (L86–151) duplicates ~50 LOC of `anvil-setup.ts`'s `setup()`. Defensible today because there's exactly one HTTP-wire caller; if a second one shows up, worth extracting a shared `bootstrapAnvilFork()`. I'll file an issue covering that + the leftover third copy of `getBalanceSlot` in `packages/core/tests/setup/deploy-fixtures.ts` + the remaining `pad → numberToHex` cleanup in `anvil-setup.ts:195,209`. |
…-literal cast
The settle-tx receipt fetch was casting `settleResponse.transaction` to an
inline `0x${string}` literal. viem already exports `Hash` as the same
literal alias, and other example files (shared/types.ts, scenarios/runner.ts)
use it consistently. Swap to `as Hash` for symmetry.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds entries for both http-wire-capture (this PR) and permit2-charge (pre-existing but undocumented) so the README inventory matches the actual scenarios folder. The http-wire-capture write-up calls out that it's the only scenario exercising the @x402/express ↔ @x402r/evm integration seam — wire-format mismatches and signer-interface drift between upstream and our scheme surface here and nowhere else in the suite. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ttp-wire-capture shared/anvil-setup.ts already falls back to slot 0 if a slot 9 storage write doesn't show up as a balanceOf read — defensive against a future USDC upgrade that shifts the balance mapping. http-wire-capture skipped this fallback. If the slot ever moves, the direct-SDK scenarios would keep working while http-wire-capture silently broke. Cheap to align (~10 LOC mirrored from anvil-setup.ts). Both code paths now react the same way to a slot drift. Slot 9 is still hit first; the fallback is a defensive default. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…t match expected (catches wire-encoding regressions)
The prior assertions proved value moved (payer ↓) and the escrow's internal
state updated correctly via paymentState(hash). They did NOT prove the
wire format the resource server published matched what the facilitator
settled with. If the resource server ever emitted requirements with the
wrong payer / receiver / token / amount, the facilitator would faithfully
settle to those values, paymentState would line up with the wrong hash,
and every prior check would still pass — the bug would only surface
downstream at capture-time.
Destructure the full PaymentAuthorized event payload and assert each
identity field independently:
- paymentInfo.payer matches the actual signer
- paymentInfo.receiver matches the resource server's payTo
- paymentInfo.token matches USDC
- paymentInfo.operator matches the captureAuthorizer (deployer EOA)
- amount matches PAYMENT_AMOUNT
- tokenCollector matches the canonical EIP-3009 collector (default
assetTransferMethod for the authCapture scheme; pinning the value
catches a scheme regression that silently switched the collector,
e.g. accidentally routed through Permit2's collector)
Closes the wire-encoding regression class at the resource-server →
facilitator boundary — the bug class no other assertion catches today.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…cess server boots The facilitator and resource-server boot paths each carried a near- identical 8-line "await listening + close-on-error" promise block, differing only in the port label inside the rejection message. Extract a small file-local waitForListening(server, label) helper above bootstrapAnvil and delegate to it from both bind sites — two call sites, one file scope, so inline is cleaner than a separate module. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
# Conflicts: # pnpm-lock.yaml
|
Round-5 sweep folded — merge resolved and all five findings shipped. Merge: fb2b696. Brought origin/main forward (one extra commit landed during the round: chore(deps) bump #155). Only conflict was pnpm-lock.yaml; regenerated via F1 — Hash alias: 8dfe17b. Swapped F2 — README: b65920e. Added http-wire-capture and permit2-charge entries to Available Scenarios + appended both to the Running block. The http-wire-capture write-up calls out that it's the only scenario exercising the @x402/express ↔ @x402r/evm seam. Obs 1 (slot-fallback divergence): 8143d48. Went with the defensive-keep option — mirrored the slot-0 fallback from anvil-setup.ts into http-wire-capture so both code paths react the same way to a future USDC slot drift. Slot 9 is still hit first; fallback is only triggered if the balanceOf read returns 0. Obs 2 (event-arg assertions — the strongest finding): fcd24fc. Destructured the full PaymentAuthorized payload and pinned payer / receiver / token / operator / amount / tokenCollector independently. This closes the wire-encoding regression class at the resource-server → facilitator boundary that no other assertion caught: a resource server emitting requirements with the wrong identity field would settle with those values, paymentState would line up with the wrong hash, and every prior check would still pass. Bug would only surface at capture-time downstream — exactly the class of failure that ships and hits customers first. Obs 3 (waitForListening helper): 1d7137e. Extracted the duplicated 8-line block to a file-local helper; both bind sites now Obs 4 (version split): noted as tracked in #156 per your earlier pointer — out of scope here. Bigger follow-up (bootstrapAnvil duplicates setup): noted, separate issue per your own comment. Verification: standalone passes 6 steps, scenarios:ci end-to-end passes, back-to-back scenarios:ci passes (no port collision). PR status is now MERGEABLE / BLOCKED. |
SDK Review — Round 5Found 0 new/unresolved issues since commit Resolved since last round: Additional clean changes verified:
Generated with Claude Code using review-sdk skill |
What
New scenario `http-wire-capture` (added to `scenarios:ci`) doing a real `fetch` → 402 → re-fetch with payment header → on-chain settlement against an Anvil fork of Base Sepolia. First CI test in this repo that exercises the actual HTTP 402 wire end-to-end (the existing scenarios drive the SDK directly).
The scenario inlines three pieces in one file:
`autoCapture` is intentionally left unset (authorize-only path). Post-conditions:
Why
The only bug class no existing test catches — does our `@x402r/[email protected]` correctly plug into upstream `@x402/{core,express,fetch}@2.12.0`'s modern resource-server pattern? If something breaks at the cross-package seam (wrong field name, scheme registration mismatch, EVM signer interface drift), publishing 0.3.0 means a customer support ticket is the first signal. This catches it in CI.
Scope
~430 LOC single scenario file. Inline Anvil bootstrap (doesn't reuse `shared/anvil-setup.ts` because the HTTP path needs `paymentInfo.operator = facilitator EOA`, not a deployed MarketplaceOperator). Reuses `PAYMENT_AMOUNT` from `shared/constants.ts` and the shared prool orchestrator via `SCENARIO_RPC_URL`.
Reference
Mirrors the three example apps from the open upstream PR adding authCapture support to x402's TypeScript SDK (`facilitator/authCapture`, `servers/authCapture`, `clients/authCapture`), and the `mechanisms/evm/test/integrations/authCapture-evm.test.ts` pattern, adapted for Anvil + CI.
What this does NOT cover (intentionally)
Context
PR 3 of 3 in `x402r-notes/plans/PHASE_3_PUBLISH_READINESS.md`.
Test plan
🤖 Generated with Claude Code