Accept USDC and USDT at checkout, received as bitcoin (Spark SDK 0.26.0) - #85
Merged
Merged
Conversation
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.
28 tasks
sethforprivacy
marked this pull request as ready for review
September 28, 2026 13:35
This was referenced Sep 28, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
GET/PUT /api/v1/stores/{storeId}/spark/stablecoins.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:
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
StablecoinPaymentsto be added.How it works
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.StablecoinPaymentService.QuoteAsync, anonymous endpointUIStablecoinCheckoutController) callsReceivePayment(CrossChain, FeesExcluded)for the invoice's net due. The SDK sizes the deposit; the difference becomes the prompt'sPaymentMethodFeeand the credited payment's fee, so an exact payment settles exactly the due.StablecoinQuotestable + migration) with the quote-time fingerprint the provider freezes onto the payment, and every live ask is kept unique per route.StablecoinQuoteMatcherattributes an arrival to exactly one quote, or reports it for a human instead of guessing.PaymentMetadataUpdatedand backstopped byStablecoinReconciliationTask.Found on btcpay-dev, and fixed here
PluginExceptionHandlerdisables 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).truncate-centershows 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:
GetSparkStatusnow takes a request.acceptedAssets.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
EmbeddedFileProvider;Still to do
docs/stablecoin-payments.md, README, trust model, limitations, setup, API, changelog