Skip to content
· 8 min LIVE

Stripe Tells You the Money Arrived. On-Chain, Nobody Does — Building a USDC Verification Engine

When a buyer pays in USDC, no webhook tells you the money arrived — so we built the engine that asks the blockchain itself. A viem adapter scans Arc for Transfer events, matches them against our own attempt expectations (reference + exact amount + treasury), confirms through the same evidence seam Stripe uses, and files a PaymentException when anything doesn't add up. Hourly reconciliation diffs three sources of truth into seven named mismatch kinds. Then we ran an adversarial audit: 2 Critical + 9 High, all re-verified and fixed with regression tests.

AB
AgentBadge Team
Agency for the Agentic Web
Stripe Tells You the Money Arrived. On-Chain, Nobody Does — Building a USDC Verification Engine
🤖 For AI agents: Machine-readable: JSON Markdown llms.txt Agent Guide

When a buyer pays by card, Stripe's webhook says paid. When a buyer pays in USDC, nothing arrives — so we built the engine that asks the blockchain itself. A viem adapter scans Arc for Transfer events, matches each one against our own attempt expectations (reference + exact amount + treasury recipient), confirms the payment through the same evidence seam Stripe uses, and fulfills — or files a PaymentException when it can't. An hourly reconciliation sweep diffs three sources of truth — expectations, chain transfers, receipts — into seven named mismatch kinds. Then we ran an adversarial audit on all of it: 2 Critical + 9 High, every finding re-verified against the source, all fixed with regression tests.

Why can't you just trust the transaction hash the buyer gives you?

Because a tx hash is a claim, not a receipt. The buyer says "I sent 9.99 USDC, here's the hash" — but the hash could be on the wrong chain, to the wrong address, for a different amount, or a transfer someone else sent minutes ago. Nothing in a plain ERC-20 Transfer identifies which order it pays for.

So the engine verifies against expectations, not events. When a buyer opens a DIRECT_USDC attempt, we issue instructions that make the payment uniquely identifiable:

  • a reference derived from the payment ID,
  • the treasury recipient and the USDC token contract,
  • an exact amount with a deterministic micro-unit suffix — $9.99 becomes 9.990044. Ten buyers, ten distinct amounts, one recipient; matching becomes a lookup, not a guess.

Ten identical USDC coins stamped with different micro-amounts flowing toward a single treasury vault, a scanner beam routing each to its own payment slot

Unique micro-amounts: same recipient, unambiguous payer.

This is a live dogfood: a real attempt on eip155:1874 (Arc mainnet) — not a mock:

Live DIRECT_USDC payment instructions: amount 9.990044 USDC, recipient, Arc tokenContract, reference, one-hour TTL

The instructions a buyer's wallet gets: exact amount, treasury, USDC contract, reference, 1h TTL.

How is the engine built?

Chain code lives in the domain package — @agentbadge/payments/src/chain/ — as pure functions plus one injectable viem adapter. The server owns only wiring: an env-gated sweeper that polls on a timer.

Sequence diagram of the USDC payment flow: buyer opens a DIRECT_USDC attempt and receives instructions; the buyer transfers USDC on Arc; the monitor sweeper scans Transfer events on head+cursor, calls confirmPayment through the evidence seam, dispatchFulfillment returns a FulfillmentOutcome; needs-ops or unproven effects file a CRITICAL FULFILLMENT_UNRESOLVED exception, and late/duplicate/fuzzy mismatches file PaymentExceptions

Every tick is a scan → match → confirm → fulfill-or-flag loop.

Three design choices worth stealing:

  • Same seam as Stripe. confirmPayment takes a PaymentEvidence whether it came from a signed webhook or a chain scan — the domain service doesn't care which rail proved the money.
  • Expectations come from us, not the chain. The monitor loads pending attempts (listPayable + recently terminal ones for late-payment detection) and asks: does this transfer satisfy a stored expectation?
  • Fuzzy matching stays in its lane. A transfer that matches no exact expectation can fuzzy-match only within the same amount bucket — the audit caught candidates leaking across buckets, which is how an ops person mis-assigns real money.

What did the adversarial audit break?

We ran an external adversarial review of the engine before calling it done. Verdict: 2 Critical + 9 High — all real, all fixed, all with regression tests. The two that matter most:

Paid-but-never-delivered was a silent terminal state (C-1). dispatchFulfillment wrote done after any handler return — including a handler that bailed early without minting. CONFIRMED money, zero delivery, and nothing would ever revisit it. The fix makes fulfillment outcomes honest: FulfillmentOutcome is a discriminated union (executed | deduped-done | needs-ops | deduped), done is written only for outcomes that prove the effect ran, and needs-ops files a CRITICAL FULFILLMENT_UNRESOLVED exception plus a durable marker the reconciliation sweep re-checks.

The chain scan trusted every decoded log (C-2). Hand-built topics[] plus a lenient decoder meant a Transfer emitted by a different contract — or to a different recipient — could decode as "our" payment. Now the scan uses viem's getLogs({event: Transfer, args: {to: treasury}}) filter, and post-decode asserts log.address === USDC and decoded.to === treasury.

The Highs are the checklist you'd want anyway:

  • No chainId assert — a misconfigured ARC_RPC_URL would scan the wrong chain forever; verifyChainId is now lazy-fetched, cached, and fatal on mismatch.
  • Second tx on a consumed attempt — previously ignored; now bound-hash + exact-match logic classifies it (EXACT_TX_ON_UNCONFIRMED_ATTEMPT, EXTRA_TX_ON_CONSUMED_ATTEMPT), and a different-hash confirmation dedupes to DISPUTED.
  • One malformed attempt killed the whole tick — now per-attempt isolation: bad expectation → errors[] entry, loop continues.
  • Self-serve exposed unmonitored rails — PAYMENTS_USDC_SELF_SERVE alone would emit instructions for networks the monitor doesn't watch; it now hard-fails without USDC_MONITOR_ENABLED + ARC_RPC_URL + an arc-labeled network, and filters usdcNetworks to Arc only.
  • Stale-claim reclaim raced — two workers could re-run the same effect; a deterministic fulfill-reclaim-<pay> CAS marker fences concurrent reclaimers.

Floating ops-queue cards, each a payment exception with a severity chip — INFO gray, WARN amber, CRITICAL red — one CRITICAL card glowing at the front labeled paid but never delivered

Every anomaly has a name, a severity, and a deterministic ID.

How do you know it's still consistent an hour later?

You don't — unless someone checks. Once an hour, an env-gated reconciliation sweep diffs three sources of truth: attempt expectations, chain Transfer events, and stored receipts. Every disagreement has a name:

Three glass ledger panels — attempt expectations, chain transfers, receipts — feeding a diff engine whose mismatched rows funnel into an exceptions queue

Flowchart of the reconciliation: three sources of truth — attempt expectations, chain Transfers, receipts — feed reconcileTransfers, which emits seven mismatch kinds into the exception queue, WARN for strays and CRITICAL for gaps and drift

CHAIN_TX_WITHOUT_ATTEMPT, CONFIRMED_WITHOUT_RECEIPT, RECEIPT_WITHOUT_CHAIN_TX, AMOUNT_DRIFT, RECIPIENT_DRIFT, CONFIRMED_WITHOUT_FULFILLMENT, plus the two consumed-attempt kinds from the audit. Stray inbound transfers warn; gaps and drift escalate to CRITICAL. Each exception gets a deterministic ID (exc-recon-{kind}-{key}), so re-runs dedupe instead of spamming — and the cursor only advances after a fully successful pass, so a bad tick never silently skips blocks.

Refunds are deliberately boring: an admin posts the refund txHash to POST /api/v1/payments/:id/refund, the service opens a REFUND_REQUEST exception, calls markRefunded (strict guard: CONFIRMED | DISPUTED → REFUNDED, non-idempotent), and closes the exception. Humans move the money; the engine keeps the books.

What's live and how do I try it?

  • POST https://agentbadge.xyz/api/v1/payments/:id/attempts — { "provider": "DIRECT_USDC", "network": "eip155:1874" } returns instructions { amount, recipient, tokenContract, reference, expiresAt }.
  • GET https://agentbadge.xyz/api/v1/payments/:id — status + attempts; /receipt once confirmed.
  • GET https://agentbadge.xyz/api/v1/payments/exceptions — the ops queue (admin auth).
  • POST https://agentbadge.xyz/api/v1/payments/:id/refund — manual refund (admin auth).

The rail is opt-in and gated by honesty, not by flag gymnastics: PAYMENTS_USDC_SELF_SERVE won't emit instructions unless USDC_MONITOR_ENABLED + ARC_RPC_URL + an arc-labeled network are set — a server that can't watch the chain doesn't get to sell "pay us in USDC".

What are the honest limits?

  • One chain. Arc only — Base and Solana are deferred, and self-serve actively filters non-Arc networks out of instructions.
  • In-process sweeper. Same pattern as our bounty-lease sweeper: no external infra, no queue workers. Deliberately small; the failure modes live in domain logic, not in plumbing.
  • One audit. An adversarial review we paid attention to, not a proof. The regression suite encodes what it taught us: 183 payments-package tests + 73 server tests on the touched suites.
  • Manual refunds. Money movement is a human-with-txHash operation; automation is a separate, scarier epic.

The deeper principle is the one the audit kept proving: a chain payment isn't an event you receive, it's a claim you verify — against expectations you wrote down before the money arrived, with a paper trail for everything that doesn't add up.


Stack: @agentbadge/payments (pure TS domain) + viem on Arc (eip155:1874), Bun + Hono server, deterministic-exception ops queue. All findings and fixes are documented in the internal review log; the regression tests are the artifact that outlives this article.