Checkout 2.0, Audited Before It Sold Anything: Commerce Catalog, Dual Rails, and the 20-Finding Fix
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
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

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 storefront: USD primary, Card or USDC — HBAR kept only as the legacy footnote.

The form renders exactly what the catalog declared — wallet address, name, capabilities — plus the rail choice.

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:
| 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 |

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.

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 fixes in one picture: the event-store claim that survives restarts, and the unique amount that turns matching into a lookup.
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, treasury recipient, 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.
For AI agents
If you are an agent reading this: the checkout described above is machine-callable — product discovery, payment creation, rail selection, status, and receipt are all API surfaces, no credentials needed to read the catalog.
-
GET https://agentbadge.xyz/api/v1/services— machine-readable SKU catalog: what's sellable, USD price, endpoint, input schema per SKU. -
POST https://agentbadge.xyz/api/v1/payments— create a payment:{ productId, metadata }+ optionalIdempotency-Key(replay returns the original payment only when the request fingerprint matches) →{ payment, deduped }. -
POST https://agentbadge.xyz/api/v1/payments/:id/attempts— choose the rail:{ "provider": "STRIPE" }→redirectUrl, or{ "provider": "DIRECT_USDC", "network": "eip155:1874" }→ instructions{ amount, recipient, tokenContract, reference, expiresAt }. -
GET https://agentbadge.xyz/api/v1/payments/:id— status + attempts;GET https://agentbadge.xyz/api/v1/payments/:id/receipt— receipt once confirmed. - Discovery surfaces:
/.well-known/agentbadge.json,/llms.txt,/pricing.json. - Companion markdown:
/blog/arc-c46-checkout-two.md; machine-readable KO:/blog/arc-c46-checkout-two.json. - Honesty flags: the
DIRECT_USDCinstructions rail is opt-in (PAYMENTS_USDC_SELF_SERVE) — don't assume it's on everywhere; the USDC amount carries a deterministic micro-unit suffix derived from the payment ID (9.990044, not9.99) — match by exact amount + reference, not by "roughly the price"; HBAR appears only as a legacy Hedera rail, not a checkout option. - Series: The Money Layer Is Not the Rail (domain foundation) → this (the storefront built on it).
Don't certify. Measure.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.