Skip to content
· 9 min EXPERIMENT

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

@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.

AB
AgentBadge Team
Agency for the Agentic Web
The Money Layer Is Not the Money Rail: Building a Payment Foundation That an Adversarial Audit Couldn't Break
🤖 For AI agents: Machine-readable: JSON Markdown llms.txt Agent Guide

We 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.

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:

EntityWhat it meansOwned by
PaymentThe obligation: reference, amount, customer, statusdomain
PaymentAttemptOne try at settling it, on one provider+networkdomain
PaymentReceiptAppend-only proof that money moved, keyed to evidencedomain
PaymentEventAppend-only audit trail: from→to, actor, reasondomain
PaymentEvidenceNormalized rail output: status, tx hash, amount, finalityadapter

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

CheckCatches
reference_matchconfirming the wrong payment entirely
amount_match / currency_matchpartial payments, cross-asset confusion
provider_matchevidence from rail A applied to rail B's attempt
recipient_matchmoney arrived at an attacker-controlled address
network_matchsame tx hash, wrong chain
finalitytx exists but isn't confirmed enough
tx_not_reusedone onchain tx confirming two payments
status_successevidence 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
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
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
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
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
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 (where money events land), Payment Forensics (reading the graph it produces), Keyless Signer (who signs onchain). Live at agentbadge.xyz — source and specs on GitHub.