Skip to content
· 9 min EXPERIMENT

Checkout 2.0, Audited Before It Sold Anything: Commerce Catalog, Dual Rails, and the 20-Finding Fix

Checkout rebuilt on the EPIC-220 payments core: a commerce catalog that owns product metadata schemas, one Payment → Attempt → instructions → status → receipt API serving Stripe card and USDC (Arc default), a fulfillment registry with durable claim/done markers — and an adversarial audit that blocked release with 3 Critical / 9 High / 8 Medium findings, all fixed and regression-tested before the first real sale.

AB
AgentBadge Team
Agency for the Agentic Web
Checkout 2.0, Audited Before It Sold Anything: Commerce Catalog, Dual Rails, and the 20-Finding Fix
🤖 For AI agents: Machine-readable: JSON Markdown llms.txt Agent Guide

We rebuilt checkout as a catalog-driven flow on the EPIC-220 payments core: POST /api/v1/payments → attempt (Stripe redirect or USDC instructions, Arc default) → status → receipt, with a fulfillment registry joining 'paid' to 'delivered'. An adversarial audit blocked it — 3 Critical, 9 High, 8 Medium — and all twenty findings got root-cause fixes plus 17 regression tests: durable event-store claim/done markers, webhook session-to-attempt binding, idempotency fingerprint conflicts, schema-first metadata validation, a feature-gated USDC rail, and unique per-payment micro-unit amounts. Live dogfood on eip155:1874: 77/77 payment-checkout tests green.

Every checkout we've audited — ours included — has the same scar tissue: a form posts to a legacy endpoint, a Stripe session appears, a webhook fires, and somewhere between "paid" and "fulfilled" a product falls out of a hat. The flow works until it doesn't, and when it doesn't there is no durable record of why.

This EPIC rebuilt the whole path. A commerce catalog owns what we sell and what each product needs from the buyer. A payments API — sitting on the EPIC-220 domain layer — turns an order into Payment → PaymentAttempt → instructions → status → PaymentReceipt. A fulfillment registry maps product kind to handler, so "paid" and "delivered" are joined by a contract, not by a code path someone forgot to finish. Then we did the uncomfortable part: pointed an adversarial AI audit at the checkout before release. It came back BLOCK — 3 Critical, 9 High, 8 Medium. All twenty findings are now root-cause fixed with regression tests.

The AgentBadge pricing page showing four passport tiers — Bronze $9.99, Silver $49.99, Gold $199.99, Platinum $499.99 — each with a Card (Stripe) or USDC selector, copy reading 'passports mint on Arc mainnet'
The storefront the catalog renders: four tiers, two rails, Arc mainnet where the passports live.

What the checkout actually is now

The flow is catalog-driven, and the catalog is boring on purpose. Each product declares a USD price, a fulfillment kind, and a metadata schema — the fields the fulfillment handler needs. For passport tiers: accountId (the EVM wallet the passport mints to), name, capabilities, and optional endpoint/skills/imageUrl. Undeclared keys are rejected. Strings are length-bounded, arrays are item-bounded, and the validator runs before any payment row exists — bad metadata can no longer buy a broken fulfillment.

One API path serves both rails:

POST /api/v1/payments          → { payment, deduped }
POST /api/v1/payments/:id/attempts
  { provider: "STRIPE" }       → redirectUrl → Stripe Checkout
  { provider: "DIRECT_USDC",
    network: "eip155:1874" }   → instructions: amount, recipient,
                                  tokenContract, reference, expiresAt
GET  /api/v1/payments/:id      → status + attempts
GET  /api/v1/payments/:id/receipt → receipt, once confirmed
Sequence diagram of one API serving two rails: buyer picks a tier on /pricing and fills catalog-declared fields on /checkout; POST /api/v1/payments returns the payment with Idempotency-Key dedupe; the STRIPE attempt returns a redirectUrl to Stripe Checkout and the webhook confirms by binding session.id to the stored PaymentAttempt; the DIRECT_USDC attempt on eip155:1874 returns instructions — unique amount 9.990044, recipient, tokenContract, reference, expiresAt; both rails converge on confirm → dispatchFulfillment → mint passport on Arc, with the fulfillment-done marker written after the handler returns
One endpoint, two rails — and both end in the same durable fulfillment path.

The buyer picks a tier on /pricing, lands on /checkout, fills the fields the catalog declared, and chooses Card or USDC. Card produces a Stripe redirect; USDC produces instructions and a QR — Arc by default, Base and Solana selectable. HBAR stays where it belongs: a legacy rail for Hedera-era minters, a side note — not the storefront.

The pricing page storefront the buyer starts from: four tiers with USD as the primary price and HBAR noted only as a legacy Hedera rail
The storefront: USD primary, Card or USDC — HBAR kept only as the legacy footnote.
The checkout form for a Bronze passport: catalog-declared fields — EVM wallet address (accountId), name, capabilities — with Card (Stripe) and USDC (Arc) payment method selector
The form renders exactly what the catalog declared — wallet address, name, capabilities — plus the rail choice.
Live checkout instructions: QR code, amount 9.990044 USDC, network eip155:1874, recipient 0xcdd2…869d, token contract 0x036C…CF7e, reference pay_412f32ed…, expires timestamp — 'Send USDC to complete payment'
Live dogfood: a real DIRECT_USDC attempt on eip155:1874 — Arc mainnet. Note the amount: 9.990044, not 9.99. More on that suffix below.

The audit that blocked it

We ran Arc Studio's adversarial review on the implementation before shipping (SLICE-221-6). Verdict: BLOCK. The findings were not stylistic:

FindingSeverityWhat it would have done
Fulfillment marked done before the handler ranCriticalA handler that throws or times out still leaves the order "fulfilled" — paid, undelivered, invisible
In-memory dedupe onlyCriticalAny restart or second instance re-fulfills the same payment — double mints, double entitlements
Unescaped product code in the checkout 404CriticalReflected XSS: /checkout?product=<script> rendered verbatim
Webhook trusted session metadata without binding to a stored attemptHighAny session claiming paymentId could trigger confirmation
Legacy metadata passed through to Stripe unfilteredHighClient could overwrite server-owned fields — including paymentId
Idempotency-Key replayed request fields uncheckedHighSame key, different product → silently reuses the old payment
Prototype-chain network lookupHighnetwork: "constructor" and friends could resolve attacker-controlled shapes
Metadata accepted into payment row, validated laterHighGarbage-in payment rows, failure discovered at fulfillment
USDC instructions rail always onHighAn unmonitored self-serve rail exposed in production by default
All USDC payments quoted the same amountHighTen buyers pay $9.99 → ten identical transfers; matching by tx is guesswork
Illustration of the audit outcome: a BLOCK clipboard — Critical findings (fulfilled before handler, in-memory dedupe, 404 XSS), High and Medium columns — every item stamped FIXED beside a magnifier showing 17 tests
All twenty findings stamped FIXED — 17 regression tests stand guard over the Critical and High list.

The fixes — root cause, not patches

Order of operations (C-1). dispatchFulfillment now reports an explicit status — fulfilled, deduped, or no-handler — and the completion marker is written after the handler returns. A thrown handler produces a failed event and a 5xx that lets the rail retry, instead of a silent "done".

Durable dedupe (C-2). In-memory flags survived one process. Fulfillment now claims through the event store: a fulfillment-claim event keyed by payment, a fulfillment-done event on success. Fresh claims dedupe; claims older than ten minutes are treated as stale and retried — a crashed worker no longer locks the order forever.

State diagram of durable fulfillment dedupe: a fulfillment-claim event keyed by paymentId enters CLAIMED; a handler that returns writes fulfillment-done AFTER it returns (C-1); a fresh claim that sees an existing done or claim resolves deduped; a thrown handler produces a failed event and 5xx so the rail retries; claims older than ten minutes are stale and retry — an event-store claim, not an in-memory flag, so restarts and second instances still dedupe (C-2)
The dedupe machine, exactly as it ships: claims live in the event store, so a restart can't unlock a double mint.

Binding, not trust (H-1/H-2). The Stripe webhook now resolves session.id → PaymentAttempt through the attempt store, verifies the session's payment status and amount against our own record, and only then confirms. Legacy checkout metadata is whitelisted — server-owned keys (paymentId, productId, productCode, tier) can no longer be injected by the client. Provider error details no longer leak into client responses.

Idempotency with a fingerprint (H-3). Replaying an Idempotency-Key returns the original payment — but only if the request fingerprint matches. Same key, different product or metadata → 409 idempotency_conflict. The header itself is schema-validated (8–128 chars, alphanumeric/underscore/hyphen) before it's ever used as a dedupe token.

Schema-first metadata (H-5). Validation moved upstream: validateProductMetadata runs before the payment row is created. The schema rejects undeclared fields, enforces per-field types and length limits, and projects only declared keys — sanitizeProductMetadata can't leak fields the product didn't ask for.

Rails that earn their way on (H-8). The direct-USDC instructions stub ships behind PAYMENTS_USDC_SELF_SERVE — off by default, opt-in when the monitoring story exists. An unmonitored rail should never be the default.

Unique-amount dedupe (H-9). The one visible in the screenshot: USDC instructions carry a deterministic micro-unit suffix derived from the payment ID — $9.99 becomes 9.990044. Ten buyers, ten distinct amounts, one recipient; on-chain matching becomes a lookup, not a guess. Attempts also reuse a pending attempt for the same payment/network instead of stacking siblings.

Two-panel illustration: left, an event-store ledger with fulfillment-claim and fulfillment-done entries, a 10-minute dedupe clock; right, the unique-amount funnel — USDC transfers with distinct micro-unit suffixes match payments exactly
Two fixes in one picture: the event-store claim that survives restarts, and the unique amount that turns matching into a lookup.

Full disclosure in the review artifact: docs/EPICS/221-pricing-checkout-2/reviews/checkout-REVIEW.md — every finding with its fix and regression test mapped.

Soft migration, not a flag day

The legacy /payment and /checkout endpoints still work — they now answer with Deprecation, Sunset, and Link headers pointing at /api/v1/payments, and a migration guide (docs/payments-v1-migration.md) maps payloads field by field. Webhook behavior hardened in place: dispatch failures surface as 5xx so Stripe retries instead of swallowing the order.

Receipts, not vibes

The dogfood run behind the screenshots: POST /api/v1/payments (Bronze tier, catalog metadata) → DIRECT_USDC attempt on eip155:1874 → instructions with the unique amount, Arc USDC contract, recipient treasury, reference, one-hour TTL → GET /api/v1/payments/:id polling PROCESSING. Seventeen new regression tests cover every Critical/High finding; the payment/checkout suite runs 77/77 green. The gate's shared baseline has unrelated drift from parallel EPICs — every file this EPIC touched type-checks and lints clean.

What we'd tell past us: the audit found what "it works in my manual test" never would — race windows, trust boundaries, and the XSS hiding in a 404. Shipping checkout code without an adversarial pass is a gamble on the one page that takes money.