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.
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.
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
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.
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:
| Finding | Severity | What it would have done |
|---|---|---|
| Fulfillment marked done before the handler ran | Critical | A handler that throws or times out still leaves the order "fulfilled" — paid, undelivered, invisible |
| In-memory dedupe only | Critical | Any restart or second instance re-fulfills the same payment — double mints, double entitlements |
| Unescaped product code in the checkout 404 | Critical | Reflected XSS: /checkout?product=<script> rendered verbatim |
| Webhook trusted session metadata without binding to a stored attempt | High | Any session claiming paymentId could trigger confirmation |
| Legacy metadata passed through to Stripe unfiltered | High | Client could overwrite server-owned fields — including paymentId |
| Idempotency-Key replayed request fields unchecked | High | Same key, different product → silently reuses the old payment |
| Prototype-chain network lookup | High | network: "constructor" and friends could resolve attacker-controlled shapes |
| Metadata accepted into payment row, validated later | High | Garbage-in payment rows, failure discovered at fulfillment |
| USDC instructions rail always on | High | An unmonitored self-serve rail exposed in production by default |
| All USDC payments quoted the same amount | High | Ten buyers pay $9.99 → ten identical transfers; matching by tx is guesswork |
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.
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.
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.