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

> Published: 2026-11-13 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/arc-c48-direct-usdc-engine

## 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](/images/blog/arc-c48-direct-usdc-engine-dedupe.webp)

* 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](/images/blog/arc-c48-direct-usdc-engine-instructions.webp)

* 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](/images/blog/arc-c48-direct-usdc-engine-d1.webp)

* 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](/images/blog/arc-c48-direct-usdc-engine-exceptions.webp)

* 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](/images/blog/arc-c48-direct-usdc-engine-recon.webp)

![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](/images/blog/arc-c48-direct-usdc-engine-d2.webp)

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

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/arc-c48-direct-usdc-engine
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
