Skip to content

Accept USDC and USDT at checkout, received as bitcoin (Spark SDK 0.26.0) - #85

Merged
sethforprivacy merged 7 commits into
mainfrom
sfp/flint-usdc-usdt-btcpay-03e659
Sep 28, 2026
Merged

sethforprivacy merged 7 commits into
mainfrom
sfp/flint-usdc-usdt-btcpay-03e659

Conversation

@sethforprivacy

@sethforprivacy sethforprivacy commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Lets a Flint store accept USDC and USDT from the networks the Spark SDK's cross-chain provider serves, while the merchant receives and keeps bitcoin: the stablecoin is converted on the way in and never held by the store. No extra plugin, no rate setup.

Built on Breez Spark SDK 0.26.0 (USDC/USDT receive). Supersedes the automated 0.25.0 bump in #80.

What merchants see

  • One switch on the Flint status page ("Accept USDC and USDT"), plus an optional step 3 at setup asking the same question. The copy leads with customers pay in USDC/USDT, you receive and keep bitcoin, and says when Stable Balance means it is kept in dollars instead.
  • The same switch through Greenfield: GET/PUT /api/v1/stores/{storeId}/spark/stablecoins.
  • Mainnet only (the provider has no test deployment); the switch explains that elsewhere.
  • Removing Flint from a store removes both payment methods.

What payers see

Two new checkout options, USDC and USDT. Picking one shows a grid of networks, each with its icon. Picking a network gets a live quote:

  • a QR code with the network's icon in the middle (EIP-681 on EVM chains, with Pay in wallet; a bare address on Solana/Tron);
  • the exact amount, with the coin's icon;
  • the deposit address, labelled "USDC address on [icon] Base".

The token contract is folded under More details, with no copy button and a "never send to it" warning, so it can't be pasted as the destination by mistake. The route's cost shows as BTCPay's usual Network Cost.

Only networks with a bundled icon are offered: Ethereum, Solana, Tron, Base, Arbitrum, Polygon, BNB Chain and Avalanche. The icons are Cake Wallet's MIT artwork, recorded in NOTICE. The provider also serves Optimism, HyperCore, HyperEVM, Monad, Tempo and Plasma; each needs only an icon and one line in StablecoinPayments to be added.

How it works

  • Payment methods USDC-FLINT / USDT-FLINT (Payments/), denominated in the coin at 6 dp, with default rate rules (USDC_USD = 1, crossed through BTC for other currencies) and currency data.
  • Quoting (StablecoinPaymentService.QuoteAsync, anonymous endpoint UIStablecoinCheckoutController) calls ReceivePayment(CrossChain, FeesExcluded) for the invoice's net due. The SDK sizes the deposit; the difference becomes the prompt's PaymentMethodFee and the credited payment's fee, so an exact payment settles exactly the due.
  • Attribution. The SDK returns no quote id, and an arrival carries no deposit address. Every quote is recorded (new StablecoinQuotes table + migration) with the quote-time fingerprint the provider freezes onto the payment, and every live ask is kept unique per route. StablecoinQuoteMatcher attributes an arrival to exactly one quote, or reports it for a human instead of guessing.
  • Crediting is exactly-once: a settle compare-and-set, one payment per quote via a partial unique index, and BTCPay's payments primary key. It is driven by PaymentMetadataUpdated and backstopped by StablecoinReconciliationTask.
  • Timing. The provider's ~2-minute expiry is the life of its price, not its address: it reprices late deposits, and the SDK watches an unpaid quote for 24h. So an address stays offered, and is reused, for an hour past it.
  • Abuse bounds on the anonymous endpoint: BTCPay's public rate limit, 10 quotes per invoice, 500 open quotes per store, and reuse of a live quote.

Found on btcpay-dev, and fixed here

  • A crash that disabled the plugin. BTCPay 2.4's PluginExceptionHandler disables a plugin and restarts the whole server on any unhandled plugin exception during a request. The USDC handler's serializer lacked NBitcoin's converters, so it couldn't read back the Unix-seconds date BTCPay's invoice serializer had stored. The Greenfield invoice payment-methods call threw, and btcpay-dev restarted with Flint disabled (0584a5f).
    • Fixed by using the invoice blob's own serializer, and by making every entry point BTCPay calls degrade instead of throw: the parses, the checkout model, the anonymous quote endpoint and the payments partial.
    • The checkout tests now read prompts back through BTCPay's real invoice storage; that round trip reproduces this bug, and the old in-memory tests couldn't.
    • Flint was re-enabled on btcpay-dev.
  • The provider's own error text ("Increase the input amount") was shown to payers. The typed refusals now get payer wording: "too small/too large for USDT on Tron, choose another network". A published bound is named only when it actually explains the refusal: Tron refused $3 while publishing an $0.80 floor. Routes whose published token bounds exclude the due are no longer offered.
  • BTCPay's truncate-center shows short strings wrongly in both Vue modes, rendering the amount as "3" or blank, so the amount is now plain text with a copy button.

SDK bump (separate commit)

0.23.0 → 0.26.0:

  • Claim-deposit outcomes (settled / submitted / deferred).
  • GetSparkStatus now takes a request.
  • Routes use acceptedAssets.
  • New event and error variants.

Behaviour change: deposits can now be claimed early when the provider's spread fits the existing claim ceiling, and early-claimed deposits are no longer listed as unclaimed.

Testing

  • Unit suite: 1,390 passed, including:
    • the matcher, amount arithmetic (6/8/18 decimals) and the service (quoting, reuse and the offered window, caps, nudging, fee guard, refusal wording, crediting, reconciliation);
    • the checkout extension through BTCPay's invoice storage, never-throw parses, the quote endpoint's 503/aborted paths, and every icon resolving through BTCPay's EmbeddedFileProvider;
    • rate rules via BTCPay's own engine, the new API endpoints (swagger contract and store-scope coverage), and DI composition into BTCPay's real container.
  • Postgres contract suite: 110 passed against postgres:17 before the later commits. None of them touched the store or migrations.
  • Live on btcpay-dev (mainnet):
    • The plugin loads on SDK 0.26.0, the migration applies, and wallets sync.
    • A test store was provisioned through the API, and invoices list the provider's live networks.
    • Real Orchestra quotes:
      • $2 USDC on Base: 0.049953 cost, landing as BTC.
      • $3 USDC on Base: 0.058956.
      • $3 USDC on Solana: 0.028305.
      • $10 USDT on Tron: 3.119777.
      • $25 USDT on Tron: 3.228139.
      • $3 USDT on Tron: refused with the new wording.
    • Checkout was checked in a browser: the pills, the icon grid, the QR center icon, the address/amount lines, More details, and View Details showing Network Cost and Amount Due.
    • The previously crashing Greenfield call now returns 200.
    • Not yet done: a paid invoice. That needs real USDC/USDT sent to a quote.

Still to do

  • Live checkout verification on btcpay-dev, up to a live quote and a rendered checkout
  • Docs: docs/stablecoin-payments.md, README, trust model, limitations, setup, API, changelog
  • One real USDC or USDT payment, end to end (needs funds). This is the only unexercised path: matching, crediting, and the invoice page's payments partial.

0.26.0 is the release that ships USDC/USDT receive. The API changes the
plugin meets are absorbed at the SDK seam:

- ClaimDeposit returns an outcome (Settled / Submitted / Deferred) rather
  than a payment. Submitted is a success that settles asynchronously;
  Deferred is reported as a failure carrying its reason.
- GetSparkStatus takes a request (no proxy).
- CrossChainRoutePair describes its Spark side as acceptedAssets with
  per-asset amount limits, replacing supportedSources.
- The new PaymentMetadataUpdated event is forwarded and re-checked like a
  completion instead of being folded into Other.
- The typed cross-chain refusals and DepositClaimInProgress get
  merchant-facing descriptions.

Behaviour change: 0.26 claims a deposit before maturity when the
provider's spread fits the configured claim ceiling, and keeps such a
deposit in ListUnclaimedDeposits as Claimed until the provider spends the
output. Those are no longer listed as unclaimed.

Supersedes the automated 0.25.0 bump (#80).
Two new BTCPay payment methods, USDC-FLINT and USDT-FLINT, backed by the
Spark SDK 0.26 cross-chain receive (Orchestra). A payer picks the coin,
then the network they hold it on; Flint quotes that network and shows the
provider's deposit address, the exact amount and a QR code. The provider
bridges the funds into the store's Spark wallet as bitcoin (or as its
Stable Balance token when the store holds one), so a merchant never holds
the stablecoin.

- Prompts are denominated in the coin (6 dp) and priced by default rate
  rules registered by the plugin: USD parity, crossed through BTC for any
  other invoice currency, so no store rate setup is needed.
- The payer pays the route's cost: the quote is sized FeesExcluded and the
  difference is the prompt's network fee, recorded on the credited
  payment, so an exact payment settles exactly the due.
- The SDK returns no quote id and the arriving payment carries no deposit
  address, so every quote is recorded (new StablecoinQuotes table) with
  the quote-time fingerprint the provider freezes onto the payment, and
  each live ask is kept unique per route. StablecoinQuoteMatcher
  attributes an arrival to exactly one quote or reports it for a human.
- Credits are exactly-once (compare-and-set settle, one payment per
  quote, BTCPay payments primary key), event-driven through the new
  PaymentMetadataUpdated event, and backstopped by a reconciliation task.
- Admin UX: one switch on the Flint status page and an optional setup
  step; mainnet only. Removing Flint from a store removes both methods.
- Public quote endpoint is rate limited, capped per invoice and per
  store, and reuses a live quote for the same network and due.
On btcpay-dev, reading an invoice's payment methods through Greenfield
after a quote had been shown threw from the USDC handler, and BTCPay 2.4
answers any exception from a plugin's code during a request by disabling
the plugin and restarting the server (PluginExceptionHandler).

The handler serialized its prompt details without NBitcoin's converters,
so it wrote the quote's expiry as an ISO date; BTCPay stores prompts with
its invoice serializer, which rewrites every date as Unix seconds, and the
handler could not read back what BTCPay stored. It now uses the invoice
blob's own serializer, which reads both.

Everything BTCPay calls into on a request is also made to degrade rather
than throw, since the checkout and the quote endpoint are anonymous:

- the details, payment and config parses fall back to the network list
  or an empty object, with a warning;
- the checkout model extension shows the method as unavailable;
- the quote endpoint answers 503 for any failure, and an aborted request
  gets an empty answer rather than an exception;
- the invoice page's payments partial skips a payment it cannot read.

The checkout tests now read their prompt back through BTCPay's invoice
storage, which is what would have caught this.
The network a payer sends on is the mistake that loses a payment
outright, so its icon is now the payer's visual check at every step:

- the network picker is a grid of buttons, each with its network's
  icon, collapsing to the chosen network with a Change button;
- the icon sits in the middle of the QR code, as BTCPay's own
  checkout does for Bitcoin, and on the "address on <network>" line;
- the coin's icon is on the "Send exactly" line.

Only networks the plugin ships an icon for are offered: Ethereum,
Solana, Tron, Base, Arbitrum, Polygon, BNB Chain and Avalanche. The
provider also serves Optimism, HyperCore, HyperEVM, Monad, Tempo and
Plasma; adding one is its icon and a line in StablecoinPayments. The
icons are Cake Wallet's MIT artwork, recorded in NOTICE, embedded and
served from BTCPay's web root.

The token contract moves behind "More details", without a copy button
and with a warning never to send to it, so it cannot be pasted as the
destination by mistake.

Found testing on mainnet:

- A quote's two-minute expiry is the life of its price, not of its
  address: the provider reprices a late deposit and the SDK watches an
  unpaid quote for a day. The address now stays on offer, and is
  reused for the same network and due, for an hour past it, instead of
  telling payers to fetch a new one before most wallets could send.
- The provider's refusals were shown verbatim ("Increase the input
  amount"). The two typed refusals now get the payer's own sentence:
  too small or too large for that network, naming the bound only when
  it explains the refusal (Tron refused $3 while publishing an 80-cent
  floor), or the route is unavailable. Routes whose published floor or
  ceiling in the payer's token excludes the due are no longer offered.
- BTCPay's truncate-center shows short strings wrongly in both of its
  Vue modes, which rendered the amount as "3" or not at all; the
  amount is now plain text with a copy button.
- The component template and the box it renders shared one id.
The API's promise is that everything the pages do can be scripted, and
the Flint page now has a USDC/USDT switch, so the API gets the same
one: GET and PUT /api/v1/stores/{storeId}/spark/stablecoins, calling the
StablecoinPaymentService the page's switch calls.

GET reports whether the server can offer them (mainnet only), whether
new invoices do, and the payment method ids an invoice lists them
under. PUT turns both on or off. An empty body means off, as a full
replacement should, and unlike Stable Balance's that default moves no
money: quotes already shown still settle. Enabling off mainnet is a
422, not a stored setting checkout would never honour.

Documented in the plugin's swagger fragment and held to it by the
contract tests; both actions are in the store-scope theory, and a
cross-store read and write are refused as for every other endpoint.
A page of its own (docs/stablecoin-payments.md) leading with what a
bitcoin-only merchant needs to hear: customers pay in USDC or USDT and
the store receives and keeps bitcoin. It covers what checkout shows,
why only networks with an icon are offered, what each network cost on
mainnet (Solana 0.9% and Base 2% of a $3 invoice; Tron about 3.1 USDT
flat, which it refused to take on $3), the price-versus-address
timing, and what matching by quote asks of the payer.

The trust model gains the conversion provider that holds the payer's
coin while it converts, and the limitations list gains the feature's
own: mainnet only and not yet proven by a paid invoice, one quote per
transaction, invisible provider refunds, the icon-limited network set,
custom rate scripts, stranded quotes after a phrase replacement, and
the quote bounds. README, setup, API and changelog point at it.
Brings in the log scrubber fix (#87). The one conflict was CHANGELOG.md,
where both sides opened sections under Unreleased; both are kept, in the
file's own order: Security, Added, Changed, Fixed.
sethforprivacy added a commit that referenced this pull request Sep 28, 2026
Stacks #84 on #85 so the two merge and release together: #84 was built
on #85's first three commits, and this brings in the rest of #85
(network icons, the Greenfield switch, docs, and its merge of main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sethforprivacy
sethforprivacy marked this pull request as ready for review September 28, 2026 13:35
@sethforprivacy
sethforprivacy merged commit 089f330 into main Sep 28, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant