cd /news/ai-agents/the-money-layer-is-not-the-money-rai… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-167096] src=agentbadge.xyz β†— pub= topic=ai-agents verified=true sentiment=↑ positive

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

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.

read10 min views1 publishedNov 10, 2026
The Money Layer Is Not the Money Rail: Building a Payment Foundation That an Adversarial Audit Couldn't Break
Image: Agentbadge (auto-discovered)

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

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:

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.

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

Two details earned their place in the map:

  • FAILED is terminal. The audit found aFAILED β†’ 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 takenbefore 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 β†’ paymentDISPUTED . 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 orEXPIRED 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.

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 returningCONFIRMED at create skipped the entire verification pipeline. Now clamped toPROCESSING ; CONFIRMED exists only behind the 9-check verdict.
  • C-2 β€” a lost guarded-write race returnedstatus: "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 anisTestProvider marker.

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 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 = PaymentIntentpi_3UZzR1K84Am2sMrt1Y6iuI1j ), re-confirm deduped with no second receipt.
  • refund β†’REFUNDED (re_3UZzR1K84Am2sMrt1uxL6SzD ); twoconcurrent refund calls both returned REFUNDED and the rail shows exactlyone refund β€”railRefundCount:1 . Exactly-once, measured on the rail, not asserted in a mock.

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.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @@agentbadge/payments 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/the-money-layer-is-n…] indexed:0 read:10min 2026-11-10 Β· β€”