# The Money Layer Is Not the Money Rail: Building a Payment Foundation That an Adversarial Audit Couldn't Break

> Published: 2026-11-10 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/payment-infrastructure-foundation

Every payment integration we've written or reviewed eventually grows the same knot: the business entity that means "this customer owes us $5" gets tangled with the adapter that talks to Stripe, or a blockchain, or a PSP. The rail's status enum leaks into your domain. Your "refund" code calls the rail before it records that a refund is happening. Your "confirmed" flag is whatever the rail said last. 

 The failure mode isn't hypothetical. It's the standard one: two state machines — yours and theirs — drifting apart, while your code pretends there's one. 

 @agentbadge/payments (EPIC-220) draws the boundary explicitly: 

| Entity | What it means | Owned by |
| --- | --- | --- |
| Payment | The obligation: reference, amount, customer, status | domain |
| PaymentAttempt | One try at settling it, on one provider+network | domain |
| PaymentReceipt | Append-only proof that money moved, keyed to evidence | domain |
| PaymentEvent | Append-only audit trail: from→to, actor, reason | domain |
| PaymentEvidence | Normalized rail output: status, tx hash, amount, finality | adapter |

 The rail (PaymentProvider interface: createPayment, verifyPayment, optional refundPayment) produces **evidence**, never verdicts. Only the domain writes domain state. 

  ![A rail carriage labeled RAIL hands an evidence envelope toward a glowing checkpoint bridge; above the gate a magnifier hovers over nine lit green checkmarks; on the far platform an emerald DOMAIN vault holds four stacked slots — Payment, Attempt, Receipt, Event — with an amber $5.00 capsule floating nearby](/images/blog/payment-infrastructure-foundation-hero.webp)
   The split, drawn: the rail produces evidence; the domain keeps verdicts — and nothing crosses the gate unchecked. 

## Why separate the money layer from the money rail

 Wait — that table *is* the why. So let me put the argument differently: the split isn't an abstraction fetish, it's a failure-isolation decision. When the rail's enum becomes your domain's truth, every retry, every race, every provider quirk becomes a domain bug you can't see. Keeping evidence and verdict in different hands is what makes the rest of this article testable at all. 

## Controlled transitions: two maps, no free-form UPDATEs

 Payment and Attempt each get a transition map. Every status change goes through a guarded applyStatus(expectedFrom, to) at the store level — a concurrent writer who moved the row first returns null, and the loser re-reads and reports what is actually persisted. 

 The payment machine (post-audit): 

```
PENDING ──→ PROCESSING ──→ CONFIRMED
    │           │  ├──────→ DISPUTED ──→ CONFIRMED / REFUNDED
    │           │  └──────→ FAILED / EXPIRED
    └──────────→ EXPIRED
FAILED, EXPIRED, REFUNDED, CONFIRMED = terminal
```

  ![The status machine as a conveyor: tokens move PENDING → PROCESSING → a nine-keyhole emerald checkpoint banner reads "9 checks" → CONFIRMED; side ramps drop tokens to FAILED (red seal, crossed-out return) and DISPUTED (amber warning, exits to CONFIRMED and REFUNDED); a sweeper clock feeds PENDING → EXPIRED](/images/blog/payment-infrastructure-foundation-2.webp)
   The same machine as a conveyor: tokens reach CONFIRMED only through the nine-keyhole gate; DISPUTED has exactly two exits — CONFIRMED after review or REFUNDED. 

  ![State diagram of the payment lifecycle as implemented: PENDING → PROCESSING → CONFIRMED only via confirmPayment's 9-check verdict; PROCESSING → DISPUTED on SUCCESS evidence + failed check; PROCESSING → FAILED/EXPIRED; CONFIRMED → DISPUTED as the refund claim; DISPUTED → REFUNDED on rail REFUNDED, DISPUTED → CONFIRMED on rollback. FAILED and EXPIRED are terminal](/images/blog/payment-infrastructure-foundation-d1.webp)
   The payment machine as it actually ships — every edge is a guarded write, and CONFIRMED has exactly one entrance. 

  ![The refund cycle as a state diagram: refundPayment claims CONFIRMED → DISPUTED before touching the rail; DISPUTED → REFUNDED on rail REFUNDED; DISPUTED → CONFIRMED on rail FAILED/UNSUPPORTED — the claim rolls back. A note summarizes the claim-first semantics and the re-confirm dedupe](/images/blog/payment-infrastructure-foundation-d1b.webp)
   The claim-first refund, as a machine: DISPUTED doubles as the refund lock — rail PENDING holds it, rail FAILED rolls it back, rail REFUNDED settles it. 

 Two details earned their place in the map: 

  -  **FAILED is terminal.** The audit found a FAILED → PENDING resurrection edge — a payment declared dead could quietly come back and hop to CONFIRMED with no attempt or receipt attached. Retry is now expressed as a new create, not a resurrection. 

  -  **DISPUTED does double duty, deliberately.** It's the review state for "rail says SUCCESS but our checks disagree" — captured funds that need a human — and it's the claim state for refunds: CONFIRMED → DISPUTED is taken *before* the rail refund call, so a concurrent second refund loses the claim instead of charging twice. 

## Verification: nine checks between evidence and CONFIRMED

 verifyPayment() in the service never trusts a rail status. It normalizes evidence and runs nine checks — reference, amount, currency, provider, recipient, network, finality, transaction-reuse, plus the rail's own terminal signal: 

| Check | Catches |
| --- | --- |
| reference_match | confirming the wrong payment entirely |
| amount_match / currency_match | partial payments, cross-asset confusion |
| provider_match | evidence from rail A applied to rail B's attempt |
| recipient_match | money arrived at an attacker-controlled address |
| network_match | same tx hash, wrong chain |
| finality | tx exists but isn't confirmed enough |
| tx_not_reused | one onchain tx confirming two payments |
| status_success | evidence claiming success it can't back |

 A clean verdict is the *only* path to CONFIRMED. Not a rail callback. Not a create-time status. Not a raw transitionPayment — the audit noticed those helpers were exported from the package barrel, so we un-exported them; the only public doors are the guarded persistence paths and the service. 

## What happens when evidence and checks disagree

 The subtle part: a failed check means different things depending on whether money moved. 

  -  **Retryable-only failures** (today: finality) → PENDING. The tx exists, it just isn't deep enough yet. Confirming this as FAILED would lose a good payment; confirming it as CONFIRMED would front-run consensus. 

  -  **Non-retryable failure on captured funds** → payment DISPUTED. The money is there but something is wrong — wrong recipient, replayed tx, mismatched amount. Marking FAILED would lie (the funds exist); confirming would compound it. DISPUTED says "review me". 

  -  **Failed check + no captured funds** → FAILED or EXPIRED per evidence. 

## Idempotency at two levels

 **Level 1 — entity dedupe.** createPayment looks up by reference then idempotencyKey before inserting. The audit's sharper point: the old code deduped on the key *only* — a caller reusing "ord-1" with a different amount silently got the first payment back. Now each row stores a JSON **request fingerprint** in payment.payload.request; a dedupe hit with a mismatched fingerprint returns conflict, not a silent swap. Rail metadata (checkout URL, session id) is persisted on the attempt and replayed on dedupe, so a lost first response doesn't lose the checkout link. 

 **Level 2 — claim-first side effects.** The refund path used to call the rail *then* try to update state. Two concurrent refunds = two rail charges. Now the service takes a guarded CONFIRMED → DISPUTED claim first, calls the rail with a deterministic idempotency key (refund_<paymentId>_<amount>), then resolves: REFUNDED on success, rollback to CONFIRMED on failure, claim held on PENDING for resume. 

 Same pattern in confirmPayment: the guarded write loses a race? Re-read and return the persisted status — never fabricate a CONFIRMED response. And if an attempt is CONFIRMED but the payment/receipt lagged (non-atomic triple write), the next confirm call self-heals: completes the payment transition and appends the missing receipt idempotently. 

  ![Sequence diagram of confirmPayment and refundPayment end-to-end: Caller → PaymentService → Persistence (guarded) → Rail (provider). confirmPayment verifies the rail evidence, runs the 9-check pipeline and replay lookup, then takes the guarded CONFIRMED write and appends a receipt — a lost race re-reads and reports the persisted status. refundPayment validates, claims CONFIRMED → DISPUTED, calls the rail with a deterministic idempotency key, then resolves REFUNDED / held claim / rollback to CONFIRMED](/images/blog/payment-infrastructure-foundation-d2.webp)
   Both flows end-to-end: evidence in, verdict out — and the refund claims CONFIRMED → DISPUTED before it ever touches the rail. 

## Decimal-string money

 Amounts are "5.00" strings everywhere in the domain — assertDecimalAmount guards format and scale, comparisons go through decimalAmountsEqual, and conversion to cents happens only at the adapter boundary (usdToCents/centsToUsd are BigInt-exact). The audit's M-4 sharpened this: zero amounts and >15 integer digits are rejected at create, and an unparseable evidence amount now *fails* the amount check instead of throwing. 

## The audit: 5 Critical, and what they taught us

 We pointed an adversarial reviewer (Arc Studio, external AI audit) at the package. Verdict: **BLOCK — 5 Critical, 6 High, 11 Medium**. The findings that made us glad we ran it before wiring this to money: 

  -  **C-1** — the create path drove the attempt to the rail-reported status, so a rail returning CONFIRMED at create skipped the entire verification pipeline. Now clamped to PROCESSING; CONFIRMED exists only behind the 9-check verdict. 

  -  **C-2** — a lost guarded-write race returned status: "CONFIRMED" without checking what actually won. A sweeper expiring the attempt in the same tick would be silently overwritten by the response. 

  -  **C-3** — attempt→payment→receipt were three separate writes; a crash between them left a CONFIRMED attempt on a PROCESSING payment forever. The self-healing completion path repairs it on the next read. 

  -  **C-4** — refund called the rail before claiming state: the classic double-refund. 

  -  **C-5** — FakeProvider defaulted to auto-confirm and echoed input back as evidence — a fail-open test rail that could be registered as a real provider. It now defaults to "pending" and carries an isTestProvider marker. 

  ![A glass audit shield between a clipboard and a tiny checkpoint bridge; the clipboard reads BLOCK with five stamped findings — C-1 rail self-confirms, C-2 lost race lies, C-3 split writes, C-4 double refund, C-5 fake rail fail-open — each stamped FIXED in green, plus "6 High" also FIXED; a ribbon on the shield reads 112/112; a robot-agent mascot polishes the shield](/images/blog/payment-infrastructure-foundation-3.webp)
   Round one of the adversarial audit: 5 Critical + 6 High stamped FIXED. (Rendered before round two — the suite is 114/114 now.) 

 The Highs were the same shape — places where the code *assumed* instead of *checking*: expiry that never asked the rail whether it was still alive, expectations re-supplied by the caller instead of persisted on the attempt, dedupe without fingerprints, raw transitions in the export surface. The full finding-by-finding resolution is in docs/EPICS/220-payment-infrastructure-foundation/reviews/220-payments-REVIEW.md. 

## Then we ran it on real money (test mode)

 Unit tests are hypothesis; the rail is the experiment. We pointed the service at **Stripe test mode** (sk_test_*), generated a real Checkout Session, paid it with 4242…, and let the full lifecycle run — create → dedupe edges → pay → confirm → receipt → refund → re-refund. 

  ![The live Stripe Checkout sandbox page for the dogfood run: "EPIC-220 dogfood (test mode)" line item, $1.00, dogfood@agentbadge.xyz prefilled](/images/blog/payment-infrastructure-foundation-live-checkout.webp)
   The hosted Stripe Checkout sandbox page the script generated — real session, $1.00, dogfood inbox prefilled. 

 The first run **caught a live bug the unit suite missed**: we passed a network expectation on a fiat rail. Stripe evidence carries no network tag, so network_match failed on a fully paid session → the payment landed in DISPUTED with captured funds — and had no API way out (the refund path expected a CONFIRMED attempt the dispute never produced). We refunded it manually via the Stripe API and logged it as finding 220-10-F1. This is exactly the class of failure the design was supposed to prevent — and the live rail found it in five minutes. 

 So we ran the adversarial audit **again**, this time against the live-verified code. Verdict: **REQUEST CHANGES — 1 Critical, 6 High, 8 Medium**. The second-round findings were nastier: the local expiry clock could bury a *paid-but-unconfirmed* session without ever asking the rail; caller input could override the expectations persisted at create (griefing on fiat, bypass on onchain); the refund claim key compared raw "5.00" vs "5.0" strings as different amounts; and a routine re-confirm could silently cancel an in-flight refund claim. 

 All of it got fixed and re-verified: the sweeper and lazy reads now consult the rail and defer on *any* uncertainty; providers declare which expectations they can satisfy and createPayment rejects the rest before a row exists; persisted expectations are authoritative; the refund path validates before claiming, canonicalizes the idempotency key, and rolls back on rail errors. 

 **The proof run, on the same Stripe test-mode rail:** 

  -  edge-fiat-network-guard → guardRejected:true — the 220-10-F1 bug is now unreachable, rejected at create. 

  -  CONFIRMED → 1 receipt (external ref = Checkout Session, transactionHash = PaymentIntent pi_3UZzR1K84Am2sMrt1Y6iuI1j), re-confirm deduped with no second receipt. 

  -  refund → REFUNDED (re_3UZzR1K84Am2sMrt1uxL6SzD); two *concurrent* refund calls both returned REFUNDED and the rail shows exactly **one** refund — railRefundCount:1. Exactly-once, measured on the rail, not asserted in a mock. 

  ![The post-fix success page: "Payment Successful!", $1.00, dogfood@agentbadge.xyz — the hardened run that reached CONFIRMED](/images/blog/payment-infrastructure-foundation-live-success.webp)
   The post-fix run: payment settled, receipt written, status CONFIRMED — on dogfood@agentbadge.xyz, not a personal inbox. 

  ![The Stripe receipt after the refund — proof the REFUNDED transition hit the rail exactly once](/images/blog/payment-infrastructure-foundation-live-refund.webp)
   The refund on the rail's own receipt — railRefundCount:1 made visible. 

 Transcripts and screenshots (test-mode ids, safe to publish) are in docs/EPICS/220-payment-infrastructure-foundation/evidence/; the finding-by-finding resolution is reviews/220-payments-REAUDIT.md. 114/114 tests green. 

## Honest scope

 What exists: the domain model, both state machines, the verification pipeline, guarded persistence ports (Map-backed in-memory + @agentbadge/database Prisma), the PaymentService facade, the expiry sweeper, FakeProvider, StripePaymentProvider (Checkout Sessions, evidence mapping, refunds with idempotency keys). 

 What does **not** exist yet: 

  -  **USDC / x402 rails** — the provider interface is ready for them; the adapters are the next epics. 

  -  **HTTP routes / webhook ingestion** — the service is a library; the commerce epics on top will expose it. 

  -  **Live checkout migration** — the existing Stripe checkout path is untouched (D-220-1); this is the foundation underneath, not a swap. 

  -  **Refund ledger** — partial-refund accounting is a deferred follow-up (M-6 tail). 

  -  **DB-level transition enforcement** — the maps are enforced at the persistence layer in app code; a CHECK/trigger in @agentbadge/database is the belt-and-suspenders follow-up. 

## What's next

 The foundation earns its keep when a second rail lands: USDC/x402 adapters, the payments-commerce epics (hosted checkout, webhook ingestion, agent-facing create/confirm flows), and refund accounting. The thesis we'll keep testing: *the rail reports evidence; the domain decides truth — and nothing reaches CONFIRMED that didn't survive all nine checks.* 

 Series: this is the payment-foundation writeup behind the agent commerce stack. Previously: [Economic Ledger](https://agentbadge.xyz/blog/economic-ledger) (where money events land), [Payment Forensics](https://agentbadge.xyz/blog/arc-c32-payment-forensics) (reading the graph it produces), [Keyless Signer](https://agentbadge.xyz/blog/keyless-signer) (who signs onchain). Live at [agentbadge.xyz](https://agentbadge.xyz) — source and specs on [GitHub](https://github.com/spreadzp/agentbadge).

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/payment-infrastructure-foundation
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
