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

> Published: 2026-11-12 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/arc-c46-checkout-two

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](https://agentbadge.xyz/blog/payment-infrastructure-foundation) — 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'](/images/blog/arc-c46-checkout-two-hero.webp)
   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](/images/blog/arc-c46-checkout-two-d1.webp)
   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](/images/blog/arc-c46-checkout-two-pricing.webp)
   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](/images/blog/arc-c46-checkout-two-form.webp)
   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'](/images/blog/arc-c46-checkout-two-live-instructions.webp)
   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](/images/blog/arc-c46-checkout-two-audit.webp)
   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)](/images/blog/arc-c46-checkout-two-d2.webp)
   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](/images/blog/arc-c46-checkout-two-dedupe.webp)
   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. 

## 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](https://agentbadge.xyz/blog/payment-infrastructure-foundation) (domain foundation) → this (the storefront built on it). 

 *Don't certify. Measure.*

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/arc-c46-checkout-two
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
