{"slug": "the-money-layer-is-not-the-money-rail-building-a-payment-foundation-that-an-t", "title": "The Money Layer Is Not the Money Rail: Building a Payment Foundation That an Adversarial Audit Couldn't Break", "summary": "A team built @agentbadge/payments, a payment domain model that separates Payment, PaymentAttempt, PaymentReceipt and PaymentEvent entities from the rail that moves money, and reported 114/114 tests green after two adversarial AI audits and a live Stripe test-mode run. The audits surfaced 5 Critical, 6 High and 11 Medium issues in the first pass and 1 Critical, 6 High and 8 Medium in the second; the design requires a 9-check verification verdict before any payment reaches CONFIRMED, uses two-level idempotency and decimal-string money, and made FAILED a terminal state after an audit found a FAILED → PENDING resurrection edge. In the Stripe test-mode run, CONFIRMED → REFUNDED worked and concurrent refunds produced exactly one rail refund.", "body_md": "# The Money Layer Is Not the Money Rail: Building a Payment Foundation That an Adversarial Audit Couldn't Break\n\n@agentbadge/payments — a payment domain model that separates Payment/Attempt/Receipt/Event entities from whatever rail moves the money. Guarded state transitions, a 9-check verification verdict as the only door to CONFIRMED, two-level idempotency, decimal-string money — stress-tested by two adversarial AI audits and a live Stripe test-mode dogfood: CONFIRMED → REFUNDED, concurrent refunds → exactly one rail refund. 114/114 tests green.\n\nWe built @agentbadge/payments — a payment domain model that treats Payment, PaymentAttempt, PaymentReceipt and PaymentEvent as durable entities separate from whatever rail actually moves the money. All state changes flow through two transition maps with guarded writes, every CONFIRMED requires a 9-check verification verdict, idempotency runs at two levels (request fingerprint + claim-first side effects), and money is a decimal string end-to-end. Two adversarial AI audits (5 Critical + 6 High + 11 Medium, then 1 Critical + 6 High + 8 Medium) and a live Stripe test-mode run later: CONFIRMED → REFUNDED, concurrent refunds → one rail refund, 114/114 tests green.\n\nEvery 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.\n\nThe 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.\n\n `@agentbadge/payments` (EPIC-220) draws the boundary explicitly: \n\n| Entity | What it means | Owned by | \n|---|---|---|\n| `Payment` | The obligation: reference, amount, customer, status | domain | \n| `PaymentAttempt` | One try at settling it, on one provider+network | domain | \n| `PaymentReceipt` | Append-only proof that money moved, keyed to evidence | domain | \n| `PaymentEvent` | Append-only audit trail: from→to, actor, reason | domain | \n| `PaymentEvidence` | Normalized rail output: status, tx hash, amount, finality | adapter | \n\n The rail (`PaymentProvider` interface: `createPayment`, `verifyPayment`, optional `refundPayment`) produces **evidence**, never verdicts. Only the domain writes domain state. \n\n## Why separate the money layer from the money rail\n\n 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. \n\n## Controlled transitions: two maps, no free-form UPDATEs\n\n 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. \n\nThe payment machine (post-audit):\n\n```\nPENDING ──→ PROCESSING ──→ CONFIRMED\n    │           │  ├──────→ DISPUTED ──→ CONFIRMED / REFUNDED\n    │           │  └──────→ FAILED / EXPIRED\n    └──────────→ EXPIRED\nFAILED, EXPIRED, REFUNDED, CONFIRMED = terminal\n```\n\nTwo details earned their place in the map:\n\n-  **`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.\n-  **`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.\n\n## Verification: nine checks between evidence and CONFIRMED\n\n `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: \n\n| Check | Catches | \n|---|---|\n| `reference_match` | confirming the wrong payment entirely | \n| `amount_match` /`currency_match` | partial payments, cross-asset confusion | \n| `provider_match` | evidence from rail A applied to rail B's attempt | \n| `recipient_match` | money arrived at an attacker-controlled address | \n| `network_match` | same tx hash, wrong chain | \n| `finality` | tx exists but isn't confirmed enough | \n| `tx_not_reused` | one onchain tx confirming two payments | \n| `status_success` | evidence claiming success it can't back | \n\n 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. \n\n## What happens when evidence and checks disagree\n\nThe subtle part: a failed check means different things depending on whether money moved.\n\n-  **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.\n-  **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\".\n-  **Failed check + no captured funds** →`FAILED` or`EXPIRED` per evidence.\n\n## Idempotency at two levels\n\n **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. \n\n **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. \n\n 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. \n\n## Decimal-string money\n\n 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. \n\n## The audit: 5 Critical, and what they taught us\n\n 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: \n\n-  **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.\n-  **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.\n-  **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.\n-  **C-4** — refund called the rail before claiming state: the classic double-refund.\n-  **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.\n\n 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`. \n\n## Then we ran it on real money (test mode)\n\n 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. \n\n 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. \n\n 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. \n\n 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. \n\n **The proof run, on the same Stripe test-mode rail:** \n\n-  `edge-fiat-network-guard` →`guardRejected:true` — the 220-10-F1 bug is now unreachable, rejected at create.\n-  `CONFIRMED` → 1 receipt (external ref = Checkout Session,`transactionHash` = PaymentIntent`pi_3UZzR1K84Am2sMrt1Y6iuI1j` ), re-confirm deduped with no second receipt.\n-  `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.\n\n 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. \n\n## Honest scope\n\n 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). \n\n What does **not** exist yet: \n\n-  **USDC / x402 rails** — the provider interface is ready for them; the adapters are the next epics.\n-  **HTTP routes / webhook ingestion** — the service is a library; the commerce epics on top will expose it.\n-  **Live checkout migration** — the existing Stripe checkout path is untouched (D-220-1); this is the foundation underneath, not a swap.\n-  **Refund ledger** — partial-refund accounting is a deferred follow-up (M-6 tail).\n-  **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.\n\n## What's next\n\n 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.* \n\n 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).", "url": "https://wpnews.pro/news/the-money-layer-is-not-the-money-rail-building-a-payment-foundation-that-an-t", "canonical_source": "https://agentbadge.xyz/blog/payment-infrastructure-foundation", "published_at": "2026-11-10 00:00:00+00:00", "updated_at": "2026-11-10 16:51:50.389297+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["@agentbadge/payments", "Stripe", "EPIC-220", "PaymentProvider", "Payment", "PaymentAttempt", "PaymentReceipt", "PaymentEvent"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-money-layer-is-not-the-money-rail-building-a-payment-foundation-that-an-t", "markdown": "https://wpnews.pro/news/the-money-layer-is-not-the-money-rail-building-a-payment-foundation-that-an-t.md", "text": "https://wpnews.pro/news/the-money-layer-is-not-the-money-rail-building-a-payment-foundation-that-an-t.txt", "jsonld": "https://wpnews.pro/news/the-money-layer-is-not-the-money-rail-building-a-payment-foundation-that-an-t.jsonld"}}