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

Unique micro-amounts: same recipient, unambiguous payer.
This is a live dogfood: a real attempt on eip155:1874 (Arc mainnet) — not a mock:

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.

Every tick is a scan → match → confirm → fulfill-or-flag loop.
Three design choices worth stealing:
- Same seam as Stripe.
confirmPaymenttakes aPaymentEvidencewhether 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_URLwould scan the wrong chain forever;verifyChainIdis 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 toDISPUTED. - 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_SERVEalone would emit instructions for networks the monitor doesn't watch; it now hard-fails withoutUSDC_MONITOR_ENABLED+ARC_RPC_URL+ an arc-labeled network, and filtersusdcNetworksto Arc only. - Stale-claim reclaim raced — two workers could re-run the same effect; a deterministic
fulfill-reclaim-<pay>CAS marker fences concurrent reclaimers.

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:


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;/receiptonce 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.