Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

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

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

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:

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

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.

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 } + optional Idempotency-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_USDC instructions 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, not 9.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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.