Agentic Trading
Decisions

0022 — USDC credit top-ups (crypto deposits via a non-custodial processor)

Status: Proposed (targeted for the next version)

Status: Proposed (targeted for the next version) Date: 2026-06-10 Supersedes: the deferred Stripe-only top-up path (the Stripe checkout/webhook code stays in the tree but is no longer the intended funding mechanism — see Alternatives).

Context

Seyra runs on a prepaid, pay-as-you-go credit ledger (migration 20260606020000_credits_billing.sql). The money primitives already exist and are production-grade:

  • user_credits.balance_micros — balance in micro-dollars (1e-6 USD, integer, no float drift).
  • credit_ledger — append-only audit log; signed amount_micros, a kind enum, a running balance_after_micros, and a unique idempotency_key.
  • apply_credit() — the only function that mutates a balance. It locks the row, applies a signed amount, appends the ledger entry, and is idempotent on idempotency_key, all in one transaction. It fails open.

Top-up (adding funds) was the one deferred piece. The existing Stripe path is literally just verify webhook signature → applyCredit({ kind: 'topup', idempotencyKey: event.id }). So the ledger already solves money-handling. Whatever we bolt on only has to produce a trustworthy (userId, amountMicros, reference) tuple and hand it to apply_credit().

We are choosing not to use Stripe and instead accept USDC. Two reasons specific to this product:

  1. Our users are already crypto-native. Seyra trades Hyperliquid (ADR-0001, 0016). HL deposits and withdrawals settle in USDC on Arbitrum — every user already holds USDC there and knows how to move it. A card rail is the foreign mechanism for this audience, not crypto.
  2. Crypto deposits have no chargebacks and avoid card-processing economics on a product whose unit cost is itself denominated in fractions of a dollar.

The hard part of crypto payments is not the ledger. It is the two things Stripe used to hide from us, both of which sit before the apply_credit() call:

  • Attribution — an on-chain USDC transfer carries no metadata field, so a raw transfer can't be tagged with a userId the way Stripe's client_reference_id is.
  • Finality — no chargebacks, but reorgs exist; we must only credit a payment that is irreversibly settled.

Decision

Accept USDC credit top-ups through thirdweb Universal Bridge (a non-custodial payment processor), with Arbitrum as the primary chain. Funds settle to a treasury wallet we control. The processor's webhook is the single trigger that credits the ledger.

Concretely:

  • Custody: non-custodial. thirdweb routes the payer's funds to our own treasury wallet; we are never an intermediary holding a third party's funds in transit (this matters for compliance — see below). thirdweb is a router/checkout layer, not a balance custodian.
  • Attribution: the payment carries our userId in its purchaseData, echoed back on the webhook — like Stripe's client_reference_id. No wallet-linking, per-user address derivation, or sender-matching.
  • Finality: we credit only on the processor's settled/completed event, which fires after on-chain confirmation. Reorg handling is the processor's problem, not ours — we act on a single authoritative webhook.
  • Conversion is exact and float-free. USDC has 6 decimals; its smallest raw unit is 1e-6 USDC ≈ 1e-6 USD = one micro-dollar. So amountMicros = rawUsdcUnits directly (treating the peg as 1:1). The ledger's denomination was, by luck, designed for this.

Code & schema delta (small, additive)

  • Ledger kind. Add 'usdc_topup' to the credit_ledger.kind check constraint (distinct from 'topup' so crypto vs. card revenue is separable in reporting). apply_credit() itself is unchanged.

    -- illustrative; new migration, additive
    alter table credit_ledger drop constraint credit_ledger_kind_check;
    alter table credit_ledger add constraint credit_ledger_kind_check
      check (kind in ('signup_grant','topup','usdc_topup',
                      'usage_debit','infra_debit','refund','adjustment'));
  • New route POST /api/crypto/webhook — a near-exact clone of app/api/stripe/webhook/route.ts. Runtime 'nodejs', force-dynamic, verifies the thirdweb webhook signature against THIRDWEB_WEBHOOK_SECRET over the raw body, filters for the settled-payment event, then:

    await applyCredit({
      userId,                       // from webhook payload metadata
      amountMicros: rawUsdcUnits,   // 1:1, see above
      kind: 'usdc_topup',
      refType: 'onchain_tx',
      refId: txHash,
      idempotencyKey: `usdc:thirdweb:${settlementId}`,
      description: 'USDC top-up (Arbitrum)',
    });

    Like the Stripe handler, it returns 500 on apply failure so the processor retries (at-least-once delivery is safe — apply_credit is idempotent). The credit decision is a pure, unit-tested parseUsdcTopup (see security note).

  • Billing UI — embedded CheckoutWidget, not a hosted redirect (updated 2026-06-10). thirdweb's hosted payment link has no post-payment redirect-back (confirmed against the OpenAPI spec: no redirectUrl/ successUrl field, no documented query param, no dashboard setting), so a same-tab redirect stranded the user on thirdweb's domain after paying. We instead embed thirdweb's React CheckoutWidget (thirdweb/react) directly in /billing: it renders its own connect+pay UI for the chosen amount, and its onSuccess refreshes the balance in place. The widget is inline-params only (it can't consume a server-created payment id), so there is no server-side create call / createCryptoTopup action — the widget prepares the payment client-side. The "Pay with USDC" rail is gated on NEXT_PUBLIC_THIRDWEB_CLIENT_ID && TREASURY_WALLET_ADDRESS; card stays behind STRIPE_SECRET_KEY.

  • EnvNEXT_PUBLIC_THIRDWEB_CLIENT_ID (public, used by the widget), THIRDWEB_WEBHOOK_SECRET (server, signs the webhook), and TREASURY_WALLET_ADDRESS (the Arbitrum address funds settle to — also the webhook's receiver guard, see below). No server secret key is needed.

Security note — client-set payment params (updated 2026-06-10)

Because CheckoutWidget prepares the payment in the browser, its seller (treasury) and purchaseData.userId are client-tamperable. The webhook is therefore the trust boundary: parseUsdcTopup credits only when

  • receiver === TREASURY_WALLET_ADDRESS — funds actually reached us (a tampered seller pointing at the payer's own wallet thus can't mint credits), and
  • the settled token is 6-dp USDC on Arbitrum at the expected scale, and
  • a userId is present and the amount is positive.

A tampered userId is not an exploit — at worst the payer gifts credit to another real account with their own money. Fails closed if the treasury env is unset. (This client-set-param exposure is the trade-off for the embedded-widget UX; the alternative — a server-created payment with server-authoritative purchaseData — isn't available because the widget can't consume a pre-created payment id.)

Optional: pending-intent table (deferred, nice-to-have)

To show "deposit pending…" before the webhook lands, a lightweight crypto_topup_intents(id, user_id, amount_usd, status, created_at) row can be written by createCryptoTopup and flipped to settled by the webhook. Not required — the webhook + idempotent apply_credit are sufficient on their own. Left out of v1 unless the UX demands it.

Why this is the right shape

  • The ledger never changes. Everything reduces to "produce a confirmed (userId, amount, txHash)apply_credit()." That is the same contract the Stripe path already satisfies, which is why a future move to self-custody (see Things we'll need to revisit) won't touch billing at all.
  • Attribution and finality are bought, not built. The two genuinely hard parts of crypto payments are delegated to the processor. We don't run an indexer, hold a hot key for deposit sweeping, fund per-address gas, or write reorg logic.
  • It is the smallest possible delta from working code — one ledger-kind value, one webhook route cloned from an existing one, one server action cloned from an existing one.
  • Economics fit the ticket size. thirdweb's fee is 1% on the crypto-to-crypto leg, borne by the payer (the developer rebate means we effectively keep 0.7% of it). On a $10–$50 top-up that is roughly equal to or cheaper than the all-in gas + ops of running our own per-user deposit-address sweeper ($0.20–$0.60 per deposit on Arbitrum once the "fund the address with ETH so it can pay its own sweep gas" problem is counted) — and the processor throws in everything else.
  • Arbitrum + thirdweb is the only fully-confirmed pairing. Of the candidates, thirdweb is the one we could confirm explicitly supports Arbitrum with webhooks; Coinbase Commerce went Base/Ethereum/Polygon-centric (Arbitrum unconfirmed) and Helio is 2% with Arbitrum unconfirmed.

The research underpinning this decision is summarised here so the rationale is auditable. None of it is legal or tax advice; the starred items should be confirmed with US (and, if we take EU customers, EU) counsel and a CPA.

  • We are a "user accepting payment for our own product," not a money transmitter. Under FinCEN's 2019 CVC guidance (FIN-2019-G001), accepting convertible virtual currency as payment for one's own goods/services is the textbook "user" case and is not money transmission / MSB activity. Money transmission is moving funds for a third party; taking USDC for our own credits is not that. ★ State money-transmitter law is separate and jurisdiction-specific, though it generally tracks the same "for others" test.
  • The GENIUS Act (signed July 2025) and EU MiCA regulate stablecoin issuers/custodians and exchanges/CASPs — not a merchant accepting USDC. They impose no new obligation on us as an acceptor; they are tailwinds that make USDC itself more legally legible. They are relevant to our processor's and to Circle's posture, not ours.
  • No KYC / Travel Rule obligation falls on us as a mere payment recipient.
  • The one non-delegable obligation is OFAC sanctions screening (strict liability; the SDN list includes crypto addresses; a violation can occur without knowledge). Choosing a screening-capable, non-custodial processor is the single strongest reason for this design: thirdweb performs the screening on the deposit leg. We should still retain the transaction records it gives us.
  • Tax/accounting: USDC received for services is ordinary income at USD fair market value on receipt (≈$1); de-peg variance is a separate, usually negligible, gain/loss to track. ★

Alternatives considered

Alt A — Stripe (the deferred path we are setting aside)

Already built and wired (apps/lib/stripe.ts, /api/stripe/webhook, createTopupCheckout). Not chosen as the primary rail because our audience is crypto-native and already funds Hyperliquid in USDC on Arbitrum; cards introduce chargeback risk and processing economics that are awkward on a sub-dollar unit cost. The code is left in place (cheap to keep, useful as a fallback rail and as the structural template the crypto path is cloned from); it is simply no longer the intended mechanism.

Alt B — Self-custodied per-user deposit addresses

HD-derive a unique Arbitrum address per user; watch it via an indexer/webhook provider (Alchemy/QuickNode); sweep to treasury. The correct end-state at scale — at large ticket sizes a flat ~$0.40 sweep beats a 1% percentage fee, and there is no processor in the money path. Not chosen for v1 because it adds real surface area we don't yet need: a hot key in a KMS, the per-address "fund-it-with-ETH-before-it-can-sweep" gas problem, reorg handling, treasury reconciliation, and — crucially — we would own OFAC screening rather than the processor. This is the documented graduation path, and it requires zero billing-ledger changes when we take it.

Alt C — Linked wallet + shared treasury (attribute by sender)

One treasury address; user proves wallet ownership once; attribute incoming transfers by their from. Rejected. It silently fails whenever a user tops up directly from an exchange withdrawal (the from is the exchange's hot wallet, not the user's), producing unattributed deposits and support tickets. Cheap to build, wrong to ship.

Alt D — Coinbase Commerce / Helio (other non-custodial processors)

Both viable in principle. Coinbase Commerce moved to a Base/Ethereum/ Polygon-centric onchain protocol and we could not confirm first-class Arbitrum settlement; its exact current fee (1% vs. "no-fee" on certain new onchain flows) we couldn't pin to a live source. Helio (now MoonPay Commerce) is 2% standard and its Arbitrum support was unconfirmed. thirdweb won on the one hard constraint — confirmed Arbitrum + webhooks at 1%.

Consequences

Positive

  • Funding works for our actual users on the chain they already use, with no chargebacks.
  • Net-new infra is one webhook route, one server action, one ledger-kind value, and a handful of env vars. The ledger, the idempotency guarantee, and the fail-open semantics are all reused unchanged.
  • Compliance stays in the "user accepting payment" box and the OFAC obligation is carried by the processor.
  • A clean upgrade path to self-custody exists that does not touch billing.

Negative / trade-offs

  • Vendor dependency. We rely on thirdweb's uptime, webhook reliability, and business continuity for the funding path. A processor outage blocks top-ups (but never trading on existing balance).
  • A fee exists (~1%, payer-borne). At high volume / large tickets this becomes the reason to graduate to self-custody.
  • We hold a treasury wallet. Even non-custodial-in, the destination is an address whose key we must secure. (Far less exposure than a sweeping hot wallet, but non-zero.)
  • We accept a small de-peg / FMV bookkeeping nuance on received USDC.

Things we'll need to revisit

  • Switch to self-custodied per-user deposit addresses (Alt B) once top-up volume makes the 1% fee material — explicitly designed to require no ledger change.
  • Add Base (and possibly Solana) as a second accepted chain if user demand appears; additive in the same webhook.
  • Whether to retire or keep the Stripe rail once the USDC path is proven.
  • Treasury operations / opsec — where the treasury key lives, signing policy, withdrawal procedure. Out of scope for this ADR; tracked separately.

Open questions to close at integration time

  • Confirm thirdweb's Arbitrum + USDC settlement and its exact webhook signature scheme and settled-event name against live docs (knowledge here is from secondary sources).
  • Confirm the fee and who bears it on our specific integration path.
  • Decide 'usdc_topup' as a distinct kind (chosen here) vs. reusing 'topup' with ref_type='onchain_tx'.

References

These compliance and tax notes are considerations only, not legal or tax advice. Items marked ★ are jurisdiction-sensitive or fact-specific and warrant confirmation with qualified counsel and a CPA before launch.

On this page