Skip to content

Buyer-side pending-authorization cache is process-local; crash/restart before response can double-charge #2

Description

@cv-scvd

createX402Fetch's buyer-side "unresolved authorization" recovery cache is in-process memory only, with no durable option — unlike budgetStore, which the same file requires to be durable on mainnet.

What I read (src/x402.ts, main branch):

  • Line 837: const pending = new Map<string, { auth: Authorization; sig: string; version: 1 | 2 }>(); — the comment above it explains the intent precisely: "The EIP-3009 nonce IS the idempotency key: it redeems exactly once on-chain. So the safe recovery is to RE-SEND THE SAME AUTHORIZATION, never to mint a new nonce."
  • Lines 1047–1049 and 1114/1127: on a 402, the fetch wrapper checks pending.get(vk) first; if a live unresolved authorization exists for that requirement it's re-sent verbatim (never re-signed). A fresh authorization is only ever minted when pending has no entry for that vk.
  • Lines 1009–1010: for budgetStore specifically, the constructor enforces durability — cfg.budgetStore.reserve() is required (or an explicit acknowledgeEphemeralBudget: true opt-out), with the comment: "totalBudget is per-instance and resets on restart / per Workers isolate."

The pending map gets exactly the crash/restart problem that reasoning already anticipates for budget, but doesn't get the same treatment. Concretely: process/isolate A signs an authorization, does pending.set(vk, …), sends the request, and is killed (OOM, isolate eviction, network partition) before the response is read. If the request actually reached the facilitator and settled, the buyer has no record of that — pending died with the process. A new process/isolate B (or the caller's own outer retry loop, unaware of A's death) hits the same 402 for the same requirement, finds nothing in its own empty pending map, and build() mints a fresh nonce at line ~843. That second authorization is a distinct, independently redeemable EIP-3009 authorization for the same logical purchase — exactly the double-charge the pending mechanism exists to prevent, just crossing a process boundary instead of an in-process retry boundary. On Cloudflare Workers this is closer to the common case than the edge case, since the README itself notes isolates are torn down per-request.

How we handled the identical failure mode (durable buyer-side admission, not just in-memory reuse-on-retry): our idempotency layer claims a durable, atomic admission record for a keyed purchase before attempting settlement, backed by a Durable Object (not an in-process Map), so recovery after a crash/restart re-attaches to the same claim instead of starting fresh. The claim is permanent for an unresolved purchase and is only released by the one code path that can prove a confirmed non-payment outcome — never by a caller inferring it. Source: src/lib/idempotency.ts in seancrecord/scvd-general-store-repo (public), see the idempotentPurchaseStore / PAID_RECOVERIES admission path and the comment block on claim lifecycle ("THE CLAIM IS PERMANENT... IN FLIGHT IS NOT USED... unknown is never released").

Given budgetStore already establishes the pattern (optional durable backend + explicit acknowledgeEphemeralBudget escape hatch for dev), the same shape — an optional pendingStore mirroring { auth, sig, version } by vk, checked/set exactly where the in-memory pending map is now, with an equivalent explicit opt-out for local/dev use — would close this without changing the existing in-process behavior for anyone who doesn't configure one.

Not filing this as a security report — the library is already unusually careful about this exact class of bug (verbatim re-send instead of re-mint, mandatory durable budget); this is the one spot where that carefulness doesn't yet extend across a process boundary.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions