Skip to content

examples(scenarios): HTTP-wire capture — cross-package integration test for the @x402 ↔ @x402r/evm seam#133

Merged
vraspar merged 17 commits into
mainfrom
vraspar/http-wire-scenario
May 21, 2026
Merged

examples(scenarios): HTTP-wire capture — cross-package integration test for the @x402 ↔ @x402r/evm seam#133
vraspar merged 17 commits into
mainfrom
vraspar/http-wire-scenario

Conversation

@vraspar
Copy link
Copy Markdown
Contributor

@vraspar vraspar commented May 19, 2026

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:

  • Facilitator — Express server hosting `x402Facilitator` + `AuthCaptureFacilitatorScheme` (from `@x402r/evm/authCapture/facilitator`), backed by a viem wallet client wrapped via `toFacilitatorEvmSigner`.
  • Resource server — Express server using `paymentMiddleware` + `x402ResourceServer` + `AuthCaptureServerScheme` (from `@x402r/evm/authCapture/server`), pointing at the facilitator via `HTTPFacilitatorClient`.
  • Client — `x402Client` registered with `AuthCaptureEvmScheme` (from `@x402r/evm/authCapture/client`), wrapped via `wrapFetchWithPayment`.

`autoCapture` is intentionally left unset (authorize-only path). Post-conditions:

  • settle tx receipt status `success` and `to` == canonical AuthCaptureEscrow singleton;
  • payer USDC ↓ PAYMENT_AMOUNT (1 USDC);
  • receiver USDC unchanged (no capture occurred);
  • `settleResponse` decoded from `x-payment-response` header matches the payer.

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)

  • Per-action contract behavior (existing fork tests handle).
  • Unit-level wire-format parsing (existing unit tests handle).
  • Live testnet (upstream's `authCapture-evm.test.ts` does this when gated by env keys).

Context

PR 3 of 3 in `x402r-notes/plans/PHASE_3_PUBLISH_READINESS.md`.

Test plan

  • `pnpm install --frozen-lockfile` clean
  • `pnpm scenario:http-wire-capture` (standalone, spawns own prool)
  • `pnpm scenarios:ci` runs all 3 scenarios (dispute, permit2-charge, http-wire-capture)
  • `pnpm scenarios:ci && pnpm scenarios:ci` back-to-back — no port collision
  • `pnpm knip` clean (scenario traced via `scenario:http-wire-capture` script entry)
  • Pre-commit hooks pass (biome check + build + typecheck)

🤖 Generated with Claude Code

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]>
@vraspar vraspar requested a review from A1igator as a code owner May 19, 2026 22:20
@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@vraspar
Copy link
Copy Markdown
Contributor Author

vraspar commented May 19, 2026

SDK Review

Found 1 issue (axes: conventions):

  1. [Conventions] AUTH_CAPTURE_ESCROW is hardcoded as a string literal despite the comment claiming it is imported from @x402r/core's getChainConfig. chainConfig.authCaptureEscrow is already in scope (packages/core/src/config/index.ts:37,72), and chainConfig is read on the line just above for USDC. If x402rChains later registers a chain with a different authCaptureEscrow, this scenario silently asserts against the wrong contract on that chain. Replace with const AUTH_CAPTURE_ESCROW: Address = chainConfig.authCaptureEscrow.

    // Auth-capture canonical escrow address (universal CREATE2 deploy). Imported
    // from @x402r/core's getChainConfig — keeps the wire-side assertion target in
    // sync with the source-of-truth address table for Base + Base Sepolia.
    const chainConfig = getChainConfig(CHAIN_ID)
    const USDC: Address = chainConfig.usdc
    const AUTH_CAPTURE_ESCROW: Address =
    '0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff'


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]>
@vraspar
Copy link
Copy Markdown
Contributor Author

vraspar commented May 19, 2026

Thanks for catching the drift risk — fixed in 3bdc403. AUTH_CAPTURE_ESCROW now resolves through chainConfig.authCaptureEscrow (already exposed by @x402r/core at packages/core/src/config/index.ts:37,72) instead of the hardcoded literal. Runtime address is unchanged for Base Sepolia, but the assertion target now tracks the source-of-truth table — if x402rChains later registers a chain whose escrow diverges, this scenario follows automatically. Doc comment realigned in the same diff. pnpm scenario:http-wire-capture and pnpm scenarios:ci both green.

@A1igator
Copy link
Copy Markdown
Contributor

A1igator commented May 20, 2026

SDK Review — Round 2

Found 4 new issues since commit 3bdc403 (axes: tests, conventions, example alignment):

Resolved since last round: hardcoded AUTH_CAPTURE_ESCROW.
Still open: none.
New:

  1. [Conventions] Anvil test accounts redefined inline. examples/shared/constants.ts:5-6 already exports PAYER_PRIVATE_KEY (used by happy-path-capture.ts:7), and examples/shared/anvil-setup.ts:33-59 declares testAccounts with the same deployer/payer/receiver triple. Reuse the shared constants (export testAccounts from anvil-setup.ts if needed); divergence later, e.g. a new account added to the shared list, would silently leave this scenario on the old set.

    const deployer = {
    address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as Address,
    privateKey:
    '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const,
    } as const
    const payer = {
    address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as Address,
    privateKey:
    '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const,
    } as const
    const receiver = {
    address: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as Address,
    } as const

  2. [Conventions] Third copy of getBalanceSlot + USDC_BALANCE_SLOT. Identical helper lives at examples/shared/anvil-setup.ts:70,82-89 and packages/core/tests/setup/deploy-fixtures.ts:51,57. Three copies of the same keccak256(encodeAbiParameters(...)) slot computation drift independently; export once from examples/shared/ and import from both example call sites.

    const USDC_BALANCE_SLOT = 9n
    // Auth-capture canonical escrow address + USDC sourced from @x402r/core's
    // chain-config so the wire-side assertion target stays in sync with the
    // source-of-truth address table for Base + Base Sepolia.
    const chainConfig = getChainConfig(CHAIN_ID)
    const USDC: Address = chainConfig.usdc
    const AUTH_CAPTURE_ESCROW: Address = chainConfig.authCaptureEscrow
    // Anvil test accounts — first two of the deterministic mnemonic.
    // deployer (account #0) doubles as the facilitator EOA + captureAuthorizer
    // so on-chain `onlySender(operator)` resolves to msg.sender == captureAuthorizer
    // for the EOA path (no contract bytecode at this address after setCode clears it).
    const deployer = {
    address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as Address,
    privateKey:
    '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const,
    } as const
    const payer = {
    address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as Address,
    privateKey:
    '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const,
    } as const
    const receiver = {
    address: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as Address,
    } as const
    function getBalanceSlot(account: Address, baseSlot: bigint): `0x${string}` {
    return keccak256(
    encodeAbiParameters(
    [{ type: 'address' }, { type: 'uint256' }],
    [account, baseSlot],
    ),
    )
    }

  3. [Example alignment] price.extra: { name, version } is dead config, overridden by the top-level extra on the merge. x402ResourceServer builds requirements with extra: { ...parsedPrice.extra, ...resourceConfig.extra } (x402/typescript/packages/core/src/server/x402ResourceServer.ts:725-728), so the resource-config extra.name/version on lines 293-294 win and the price.extra on line 272 is shadowed. Values happen to match here so there's no runtime drift, but the duplication is misleading (especially with the comment on lines 265-268 implying price.extra is the canonical EIP-712 domain location). Drop the inner extra or drop the top-level name/version so a future edit can't desync them.

    // don't rely on getDefaultAsset (which would also work but couples
    // the scenario to an extra default-asset lookup). EIP-712 domain
    // fields go inside extra.name/version, matching the upstream
    // example shape.
    price: {
    asset: USDC,
    amount: PAYMENT_AMOUNT.toString(),
    extra: { name: 'USDC', version: '2' },
    },
    network: NETWORK,
    payTo: receiver.address,
    // maxTimeoutSeconds is required by the client scheme (used to
    // derive preApprovalExpiry). Upstream example omits this because
    // it has a default; we set it explicitly for clarity.
    maxTimeoutSeconds: 600,
    extra: {
    captureAuthorizer,
    // Absolute deadlines instead of *Seconds offsets — easier to
    // reason about in a one-off scenario.
    captureDeadline: now + 3600,
    refundDeadline: now + 7200,
    // zeroAddress = deferred fee-recipient selection (per spec, the
    // captureAuthorizer picks any non-zero recipient at capture
    // time). Since the scenario only authorizes (autoCapture left
    // unset), no fee is actually paid out in this run.
    feeRecipient: zeroAddress,
    minFeeBps: 0,
    maxFeeBps: 100,
    name: 'USDC',
    version: '2',
    },

  4. [Tests] app.listen(port) returns before the socket is bound; the subsequent paidFetch is not gated on 'listening'. In tight in-process timing the next tick almost always lands after bind, but on a cold CI runner, or after a prior run leaked the port, this is the classic ECONNREFUSED flake source. Promisify both listens (await new Promise<void>((resolve, reject) => { server.once('listening', () => resolve()); server.once('error', reject); })) before returning the URL.

    const server = app.listen(PORT_FACILITATOR)
    return {
    url: `http://127.0.0.1:${PORT_FACILITATOR}`,
    stop: () => new Promise<void>((resolve) => server.close(() => resolve())),
    }
    }
    // ---------------------------------------------------------------------------
    // 3. Resource server setup (mirrors upstream
    // examples/typescript/servers/authCapture/index.ts).
    // ---------------------------------------------------------------------------
    async function startResourceServer(
    facilitatorUrl: string,
    captureAuthorizer: Address,
    ): Promise<{
    url: string
    stop: () => Promise<void>
    }> {
    const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl })
    const resourceServer = new x402ResourceServer(facilitatorClient).register(
    NETWORK,
    new AuthCaptureServerScheme(),
    )
    const now = Math.floor(Date.now() / 1000)
    const app = express()
    app.use(
    paymentMiddleware(
    {
    'GET /widget': {
    accepts: {
    scheme: 'authCapture',
    // Custom AssetAmount: pin USDC + base-unit amount directly so we
    // don't rely on getDefaultAsset (which would also work but couples
    // the scenario to an extra default-asset lookup). EIP-712 domain
    // fields go inside extra.name/version, matching the upstream
    // example shape.
    price: {
    asset: USDC,
    amount: PAYMENT_AMOUNT.toString(),
    extra: { name: 'USDC', version: '2' },
    },
    network: NETWORK,
    payTo: receiver.address,
    // maxTimeoutSeconds is required by the client scheme (used to
    // derive preApprovalExpiry). Upstream example omits this because
    // it has a default; we set it explicitly for clarity.
    maxTimeoutSeconds: 600,
    extra: {
    captureAuthorizer,
    // Absolute deadlines instead of *Seconds offsets — easier to
    // reason about in a one-off scenario.
    captureDeadline: now + 3600,
    refundDeadline: now + 7200,
    // zeroAddress = deferred fee-recipient selection (per spec, the
    // captureAuthorizer picks any non-zero recipient at capture
    // time). Since the scenario only authorizes (autoCapture left
    // unset), no fee is actually paid out in this run.
    feeRecipient: zeroAddress,
    minFeeBps: 0,
    maxFeeBps: 100,
    name: 'USDC',
    version: '2',
    },
    },
    description: 'A widget',
    mimeType: 'application/json',
    },
    },
    resourceServer,
    ),
    )
    app.get('/widget', (_req, res) => {
    res.send({ ok: true })
    })
    const server = app.listen(PORT_RESOURCE_SERVER)
    return {
    url: `http://127.0.0.1:${PORT_RESOURCE_SERVER}`,
    stop: () => new Promise<void>((resolve) => server.close(() => resolve())),
    }


Generated with Claude Code using review-sdk skill

vraspar and others added 2 commits May 19, 2026 22:25
…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]>
@vraspar
Copy link
Copy Markdown
Contributor Author

vraspar commented May 20, 2026

Thanks for the round-3 sweep — all four folded.

Finding 1 (reuse testAccounts): c3b5378. testAccounts is now exported from examples/shared/anvil-setup.ts and destructured in the scenario as const { deployer, payer, receiver } = testAccounts. Role mapping (deployer = #0 / facilitator EOA / captureAuthorizer, payer = #1, receiver = #2) is identical.

Finding 2 (extract balance-slot helpers): c3b5378 — same commit. USDC_BALANCE_SLOT and getBalanceSlot are now exports of anvil-setup.ts; the scenario imports both. Went with extending the existing module rather than creating anvil-storage.ts since it was a 3-line export annotation. The test-workspace duplicate in packages/core/tests/setup/deploy-fixtures.ts is left alone per the review note (different scope; tracked separately).

Finding 3 (drop dead price.extra): c3b5378 — same commit. Inner extra: { name, version } removed; name/version live only in top-level extra. Comment realigned to call out the upstream merge order (extra: { ...parsedPrice.extra, ...resourceConfig.extra } — top-level wins) instead of the previous misclaim.

Finding 4 (await 'listening'): 77b9c04. Both express boot sites (facilitator + resource server) now await a promise that resolves on 'listening' and rejects on 'error' with a failed to bind ${PORT} message before returning the URL. Standalone + scenarios:ci + back-to-back scenarios:ci all green; no port collision.

@vraspar
Copy link
Copy Markdown
Contributor Author

vraspar commented May 20, 2026

SDK Review — Round 3

Found 4 new issues since commit 77b9c04 (axes: tests, conventions):

Resolved since last round: hardcoded AUTH_CAPTURE_ESCROW, duplicated testAccounts, duplicated getBalanceSlot/USDC_BALANCE_SLOT, dead price.extra, missing await 'listening'.
Still open: none.
New:

  1. [Tests] Authorize-only assertions never tie the on-chain state back to the negotiated paymentInfoHash. The block comment at L390-399 acknowledges funds live in a hash-indexed mapping but the test then declines to read it. AuthCaptureEscrow.paymentState(paymentInfoHash) is exposed in the ABI (packages/core/src/abis/generated.ts:373); asserting paymentState(hash).capturableAmount === PAYMENT_AMOUNT && hasCollectedPayment === true would close the last hop. As written, a facilitator that off-by-ones the hash still debits the payer + targets the escrow and passes all four current assertions.

    runner.step('Verify on-chain settle landed (authorize-only)')
    // The authCapture escrow is the canonical CREATE2-deployed AuthCaptureEscrow
    // singleton. Funds for an authorized-but-not-captured payment don't sit at
    // the singleton — the escrow creates a per-payment vault via internal
    // accounting (commerce-payments uses a paymentInfoHash-indexed mapping).
    // The wire-level invariants we can check at the singleton level are:
    // 1. settle tx targeted the singleton escrow (not some other contract);
    // 2. payer balance ↓ PAYMENT_AMOUNT (tokens actually moved);
    // 3. receiver balance unchanged (no autoCapture occurred).
    // Together these prove the HTTP wire successfully drove a settle into the
    // right contract via the @x402/express ↔ @x402r/evm seam.
    if (!settleResponse.transaction) {
    runner.fail('settleResponse missing transaction hash')
    }
    const receipt = await ctx.publicClient.getTransactionReceipt({
    hash: settleResponse.transaction as `0x${string}`,
    })
    runner.assert(
    receipt.status === 'success',
    `settle tx receipt status === 'success'`,
    )
    runner.assert(
    (receipt.to ?? '').toLowerCase() === AUTH_CAPTURE_ESCROW.toLowerCase(),
    `settle tx targeted AuthCaptureEscrow (${AUTH_CAPTURE_ESCROW}); actual ${receipt.to}`,
    )
    const payerAfter = await readBalance(payer.address)
    const receiverAfter = await readBalance(receiver.address)
    runner.assert(
    payerBefore - payerAfter === PAYMENT_AMOUNT,
    `payer USDC ↓ PAYMENT_AMOUNT (${PAYMENT_AMOUNT}); actual ↓ ${payerBefore - payerAfter}`,
    )
    runner.assert(
    receiverAfter === receiverBefore,
    `receiver USDC unchanged (autoCapture left unset); actual delta ${receiverAfter - receiverBefore}`,
    )

  2. [Tests] 'error' branch of the listen-promise rejects without closing the server (L218-223, L302-309). On a port-collision bind failure, the rejection propagates to main()'s try while the outer facilitator/resourceServer vars are still undefined — so the finally block (L427-431) sees undefined and never calls stop(). The unclosed http.Server keeps the listener alive until SIGKILL. Call server.close() before reject(...) inside the 'error' handler.

    const server = app.listen(PORT_FACILITATOR)
    await new Promise<void>((resolve, reject) => {
    server.once('listening', () => resolve())
    server.once('error', (err) =>
    reject(new Error(`failed to bind ${PORT_FACILITATOR}: ${err.message}`)),
    )
    })

  3. [Tests] captureDeadline/refundDeadline derived from Date.now() (L247, L276-277), not chain time. Anvil's fork inherits the forked block's block.timestamp which trails wall-clock; the 3600s buffer is safe today but turns into a slow-burn flake if forkBlockNumber ever pins to a stale block or another scenario calls evm_setNextBlockTimestamp before this one. Other scenarios in this folder dodge the class entirely via FAR_FUTURE (shared/constants.ts:15) — either match that, or read block.timestamp via publicClient.getBlock() and offset from there.

    const now = Math.floor(Date.now() / 1000)

  4. [Conventions] pad(\0x${payerUsdcAmount.toString(16)}` as `0x${string}`)at L146 reinvents viem'snumberToHex(payerUsdcAmount, { size: 32 }). The as `0x${string}`cast exists only because string-concatenation drops viem's brandedHextype;numberToHexreturnsHex natively, no cast. Viem itself uses this idiom internally (src/utils/encoding/toHex.ts:178-181, src/utils/signature/parseSignature.ts:34-35). Pre-existing duplicates at anvil-setup.ts:195,209` are outside this PR's diff — fix at L146 here, batch the rest as follow-up.

    value: pad(`0x${payerUsdcAmount.toString(16)}` as `0x${string}`),


Generated with Claude Code using review-sdk skill

vraspar and others added 4 commits May 20, 2026 01:16
…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]>
@vraspar
Copy link
Copy Markdown
Contributor Author

vraspar commented May 20, 2026

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 payer ↓ PAYMENT_AMOUNT check would catch). The new step still closes a real bug class though: a contract bug or migration that moves tokens via ERC-3009 but leaves capturableAmount / hasCollectedPayment unset would pass every prior assertion and only surface when a downstream capture operated on the missing state. Now we parse PaymentAuthorized off the receipt for the hash and assert hasCollectedPayment === true, capturableAmount === PAYMENT_AMOUNT, refundableAmount === 0n.

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 main()'s finally block can't reach the helper-local server reference anyway. Both bind sites updated.

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 block.timestamp is still the right reference frame for deadlines the escrow checks on-chain; any future scenario using testClient.increaseTime would silently mis-set the windows under wall-clock. Switched now to publicClient.getBlock({ blockTag: 'latest' }).timestamp and threaded publicClient through startResourceServer.

Finding 4 (numberToHex): aaaf179. Trivial swap of pad(\0x${x.toString(16)}`)fornumberToHex(x, { size: 32 }). Pre-existing duplicates in shared/anvil-setup.ts` left for a follow-up per the scope note.

All 6 scenario steps now pass standalone, scenarios:ci end-to-end, and back-to-back scenarios:ci with no port collision.

@A1igator
Copy link
Copy Markdown
Contributor

SDK Review — Round 4

Found 2 new issues since commit 367723b (axes: conventions, dead code):

Resolved since last round: missing paymentState(hash) assertion, missing server.close() before reject on bind-error, Date.now()-derived deadlines, pad+toString(16) reinventing numberToHex.
Still open: none.
New:

  1. [Conventions] L423 inlines `0x${string}` instead of viem's Hash alias. The cast is needed (settleResponse.transaction is string), but viem already exports Hash = `0x${string}` (viem/src/types/misc.ts:5) and the examples folder already imports the named type elsewhere (examples/scenarios/runner.ts:1, examples/shared/types.ts:3). The file imports Address from viem at L14 and would be the only spot in examples/ using the inline template-literal cast form. Add Hash to the existing viem import block and write as Hash.

    const receipt = await ctx.publicClient.getTransactionReceipt({
    hash: settleResponse.transaction as `0x${string}`,
    })

  2. [Dead code] examples/scenarios/README.md is the canonical scenarios index (linked from examples/README.md with "See scenarios/README.md for details"), and the per-scenario section + the Running block both omit http-wire-capture. The README is otherwise up-to-date relative to this PR (lists the four other shipping scenarios). A new dev landing on the docs has no way to discover the scenario or its pnpm scenario:http-wire-capture invocation. Add a ### http-wire-capture block describing the cross-package HTTP wire integration and append the script line to the Running block.

    ## Available Scenarios
    ### happy-path-capture
    2-role flow: authorize → capture.
    Demonstrates the simplest payment lifecycle — merchant authorizes a payment, waits for escrow expiry, and captures.
    ### dispute-resolution
    3-role flow: full lifecycle with arbitration.
    Exercises the complete dispute flow:
    1. Deploy marketplace operator
    2. Authorize payment via SDK viem flow
    3. Payer requests refund
    4. Both parties submit evidence
    5. Arbiter reviews evidence and approves refund
    6. Verify refund amounts
    7. Verify zero protocol fees accrued
    ### atomic-charge
    2-role flow: payer signs once, merchant calls `payment.charge()` (single tx, no escrow).
    Demonstrates the atomic settlement path. In production the merchant advertises this intent via `PaymentRequirements.extra.autoCapture`; the facilitator reads the flag and dispatches to `escrow.charge()` vs `escrow.authorize()`. Asserts real ERC-20 balance deltas (payer ↓ amount, receiver + fee ↑ to total).
    ### partial-refund-flow
    2-role flow: authorize → capture(partial) → voidPayment().
    The new authCapture partial-refund pattern. Replaces the old single-tx `refundInEscrow(amount)` with a two-tx flow: merchant captures the amount they keep, then `voidPayment()` returns the remainder to the payer. No allowance setup, no ReceiverRefundCollector — the escrow handles it. Asserts payer net loss equals merchant-keep, receiver delta + fee delta equals merchant-keep.
    ## Running
    ```bash
    # From x402r-sdk root
    pnpm scenario:capture


Generated with Claude Code using review-sdk skill

@vraspar
Copy link
Copy Markdown
Contributor Author

vraspar commented May 21, 2026

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 anvil-setup.ts and the new scenario. examples/shared/anvil-setup.ts:204–211 writes balance at slot 9n, reads it back, and falls back to slot 0n if the write didn't take. http-wire-capture.ts:142–148 writes slot 9n and assumes success. The two locations now differ on whether the storage-layout fallback is needed. If the fallback is defensive-dead-code (slot 9 is provably safe for Base Sepolia USDC), worth deleting it in anvil-setup.ts and adding a one-line comment to both sites; if it's load-bearing, the new scenario silently misses it and will surface as a confusing "transfer reverts with zero balance" downstream of the wire setup.

2. Possible event-arg assertions in step 6. PaymentAuthorized carries the full paymentInfo tuple plus amount and tokenCollector in its event args. The current step extracts paymentInfoHash only. Adding args.amount === PAYMENT_AMOUNT, args.paymentInfo.payer === payer.address, args.paymentInfo.receiver === receiver.address, args.paymentInfo.token === USDC would close one more bug class that the balance check + paymentState(hash) together don't: a wire-encoding regression where what the resource server intended differs from what the contract parsed. The balance check would still catch the outcome, but not localize the fault to wire encoding vs settle path.

3. The two await 'listening' promise blocks are near-identical. L221–229 (facilitator) and L317–327 (resource server) differ only in the port label inside the error message. A small helper — waitForListening(server, port) returning a `Promise` — would dedupe ~16 lines without giving up the defensive `server.close()`. Not blocking; cleanup item.

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

vraspar and others added 6 commits May 20, 2026 22:00
…-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]>
@vraspar
Copy link
Copy Markdown
Contributor Author

vraspar commented May 21, 2026

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 pnpm install rather than hand-resolving. No source-file conflicts.

F1 — Hash alias: 8dfe17b. Swapped as \0x${string}`foras Hash` at the receipt fetch site. Matches the convention already used in shared/types.ts and runner.ts.

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 await waitForListening(server, label).

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.

@A1igator
Copy link
Copy Markdown
Contributor

SDK Review — Round 5

Found 0 new/unresolved issues since commit d9ef172 (axes: tests, conventions, dead code).

Resolved since last round: as `0x${string}` cast at L423 → switched to viem's Hash alias (8dfe17b); missing http-wire-capture entry in examples/scenarios/README.md → added (b65920e).
Still open: none.

Additional clean changes verified:

  • 1d7137ewaitForListening helper extraction. Faithful dedupe of the two inline app.listen + await 'listening' blocks; behavior identical.
  • 8143d48 — USDC slot-fallback now mirrors examples/shared/anvil-setup.ts:198-211 (write slot 9, probe balanceOf, fall back to slot 0). Parity restored across both code paths.
  • fcd24fcPaymentAuthorized event arg assertions on paymentInfo.payer/receiver/token/operator, amount, and tokenCollector. Closes the wire-format-mismatch failure mode the previous block-comment called out. Event arg shape verified against packages/core/src/abis/generated.ts:484-531.
  • EIP3009_TOKEN_COLLECTOR hardcode justified — verified @x402r/[email protected]'s package.json exports map only surfaces ./authCapture/{client,server,facilitator} + ./extensions/attestation, none of which re-export EIP3009_TOKEN_COLLECTOR_ADDRESS. Hardcoding with a Source: pointer comment is the right call until the next @x402r/evm release adds the constant to a public subpath.

Generated with Claude Code using review-sdk skill

@vraspar vraspar merged commit a314f5f into main May 21, 2026
9 checks passed
@vraspar vraspar deleted the vraspar/http-wire-scenario branch May 21, 2026 06:21
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