feat(spark): stable-sats receive auto-convert and send-flow fixes#3764
Conversation
1d89661 to
8e3715b
Compare
6ac3787 to
ece95dc
Compare
8e3715b to
c930670
Compare
c930670 to
8646588
Compare
a918f3a to
fffa4ca
Compare
8646588 to
5bf699b
Compare
grimen
left a comment
There was a problem hiding this comment.
Review — bugs + test coverage
Important
This PR (and its stack) needs a major refactor pass. Findings include:
- SOLID violations —
AutoConvertOutcomeis a 5-status discriminated union (Converted | AlreadyConverted | SkippedStableBalanceActive | SkippedBelowMin | Failed) where the listener (use-auto-convert-listener.ts:145-156) and executor (auto-convert/executor.ts:131-176) both encode which statuses mean "remove record" vs "retry" vs "drop silently." A new status requires lockstep updates across both files. Open/Closed violation. The executor's own comment — "Failed falls through so the next trigger retries" — is the leak made explicit.runAutoConvert(use-auto-convert-listener.ts:115-160) takes a 10-fieldRunAutoConvertParamsbag — Parameter Coupling smell, and the function is doing attempt-cap, polling, fee-discovery, dispatch, and outcome-routing in one body. - High cyclomatic complexity in the bridge layer —
prepareConversioninbridge/convert.ts:181-263is an 80-line function with three nestedtry/catchblocks, four early-return discovery-fallback paths, two empty} catch {}swallows (lines 252, 261), and a discovery-then-correction-then-fallback sequence with no testable seam between phases. McCabe ~12. The silent-half-convert bug (Critical #3) is a direct symptom of this density. - Listener over-scope —
use-auto-convert-listener.tsis 308 LOC for a single hook, containing 8 internal helpers (reportError,extractLightningInvoiceFromPayment,fetchPaymentById,convertSatsToUsdCents,isRetryableNow,showConvertedToast,runAutoConvert,findPaidAmountForInvoice), threeuseRefs coordinating cross-effect state, and at least three interleaved effects (live trigger, mount-replay, prune). The concurrency bugs (Critical #1, I1, I2) are direct symptoms — theinFlightInvoicesRefset is the only barrier between live and replay, and ad-hoc refs are how you spell "missing state machine." - Magic numbers scattered across files —
RETRY_COOLDOWN_MS = 30_000andORPHAN_TIMEOUT_MS = 2 * 60 * 1000(use-auto-convert-listener.ts:39-43);DEFAULT_AMOUNT_MATCH_TOLERANCE_BPS = 500,AMOUNT_MATCH_TOLERANCE_FLOOR = 50(auto-convert/executor.ts:91-94);DISCOVERY_MIN_INPUT_MARGIN_BPS = 1000(bridge/convert.ts:160). All knobs of the same auto-convert subsystem, declared as inline constants in three different files instead of a single config module. Some are remote-config-overridable (autoConvertMaxAttempts,autoConvertPollMaxAttempts), some aren't — no rationale for the split. - Duplicated
crashlytics().recordError(err instanceof Error ? err : new Error(…))pattern atexecutor.ts:84-86,use-auto-convert-listener.ts:48-50,bridge/convert.ts:289-291and several more sites. Same shape every time; should be a singlereportError(err, context)utility inapp/utils/crashlytics.ts. - Linear-scan classifier where a switch belongs —
INNER_HINTS: Array<[string, SelfCustodialErrorCode]>(sdk-error.ts:38-43) is a 4-entry order-dependent array of substring tuples. The order is load-bearing ("minimum"before"insufficient") but nothing in the code documents this. Aswitchover a single regex match, or an explicit precedence comment + a test that locks the order, would be safer (this is the source of test-coverage gap #9 and Critical #9). - Naming smells — the listener's branch at
use-auto-convert-listener.ts:148-156has to enumerate three of the fiveAutoConvertStatusnames in oneifto express "remove the record on any non-retry outcome" — a sign the enum shape doesn't match how callers consume it.executeAutoConvertvsrunAutoConvertare adjacent functions with synonymous verbs.useNonCustodialConversionLimits("non-custodial" = "self-custodial" elsewhere — same concept, opposite naming convention).RETRY_COOLDOWN_MSandORPHAN_TIMEOUT_MSare both timeouts; the relationship between them isn't surfaced. Hook prefix is inconsistent:useReceiveAssetMode(verb-noun) vsuseAutoConvertListener(compound-noun) vsuseTranslateSdkError(verb-noun) vsusePaymentRequest(noun). Three refs for related dedup state in one hook with no shared pattern:processedPaymentIdsRef,inFlightInvoicesRef,initialReplayDoneRef. - Status-enum proliferation —
PaymentResultStatus.Success | Failed(the bridge return) andAutoConvertStatus.Converted | Failed | SkippedStableBalanceActive | SkippedBelowMin | AlreadyConverted(the executor return) are two separate enums for "outcome of one auto-convert attempt." The executor unwraps the first into the second (executor.ts:166-176) and the listener then unwraps the second into "remove vs retry vs drop." Three enums where one tagged union of{ kind: "success" } | { kind: "skip"; retry: false } | { kind: "fail"; retry: true }would do the job and remove the cross-file coupling.
Why this is not in scope for this PR: this branch is stacked on fix--spark-settings-and-onboarding-ui with downstream PRs likely depending on it. Refactoring the auto-convert module's shape, the listener's effects, and the cross-file enum/naming surface would force a multi-day restack across the stack and create combinatorial conflict pain — particularly the rename passes. Refactoring against main after the stack settles is materially cheaper.
Decision: this review covers correctness only. The structural / SOLID / complexity / naming review will be tracked as a separate cleanup ticket to land once both:
- The full stack is approved for functionality, and
- All Critical bugs and missing tests below are resolved.
Cleanup will then land as a sequence of small, focused PRs against main — no restack pain, each independently mergeable. Production rollout should be gated on the cleanup ticket reaching at least the auto-convert state-machine + adapter-shape unification milestones, otherwise the structural debt ships with the feature and gets normalised.
Real strengths first: the SDK error classifier's exhaustive Record<SdkErrorTags, …> shape is genuinely typed (sdk-error.ts:18-31); the persistent-queue design in auto-convert/storage.ts correctly handles schema-widening for records persisted before attempts/lastAttemptAtMs existed; executor.spec.ts covers the tolerance-band edges (4% match, 100% no-match) and the listPayments rejection fail-open; use-payment-request.spec.ts drives the full Created→Paid→re-mount lifecycle with the baseline paymentId ref guard. The shape is right — the issues below are concentrated in the listener's concurrency/replay paths and in the screen-level integration tests for the headline send-flow fixes.
Severity:
- Critical — fund-stranding, mis-conversion, or silent failure that misleads the user. Must fix before merge.
- Important — correctness/reliability that doesn't lose funds but degrades UX or hides incidents.
- Test — coverage gap or test that passes while a confirmed bug is alive.
Critical
1. Auto-convert attempts counter incremented before convert runs
app/self-custodial/hooks/use-auto-convert-listener.ts:122-132
await recordAutoConvertAttempt(record.paymentRequest, Date.now())
const settled = await waitForPaymentCompleted(sdk, paymentId, waitOptions)
if (!settled) returnwaitForPaymentCompleted exhausting its bounded poll without observing Completed (slow operator, network hiccup longer than autoConvertPollMaxAttempts * autoConvertPollIntervalMs) bumps the attempt counter on a phantom failure where the convert never even ran. After autoConvertMaxAttempts such phantoms, the record.attempts >= maxAttempts branch (line 123) silently removes the record. The receive succeeded, the user's BTC stays as BTC, no UI feedback (silent-by-design).
Fix: stamp recordAutoConvertAttempt only after settled === true. The polling failure mode is a transient SDK condition unrelated to the convert's own retry budget.
2. Dedup collision on equal-amount invoices silently skips legitimate convert
app/self-custodial/auto-convert/executor.ts:124-164
hasAlreadyConverted matches against the SDK's payment history on (timestamp >= recordCreatedAtMs, amount within ±5% / 50-sat tolerance). Timeline:
- T0: invoice A created,
recordCreatedAtMs_A = T0 - T1: invoice A paid
- T2: invoice B created,
recordCreatedAtMs_B = T2 - T3: invoice B paid
- T4: convert for A completes —
payment.timestamp ≈ T4(which is> T2) - T5: listener fires for B.
hasAlreadyConverted({recordCreatedAtMs: T2, satsAmount: amount_B, toleranceBps: 500})finds convert_A (timestamp ≥ T2, amount within tolerance) → returns true → B's record removed without converting.
User receives twice, only one auto-convert runs; the second receive's BTC stays as BTC silently. The 5% / 50-sat tolerance window makes the collision likely for round-number invoices users tend to issue.
Fix: correlate by SDK paymentId or per-attempt session id rather than amount window. The tolerance check is the only safety net against retrying a successful prior attempt, so it can't be removed — replace it with a stronger key.
3. Convert pipeline silently executes a half-convert on correction failure
app/self-custodial/bridge/convert.ts:198-262
When prepareConversionWithDestination overshoots (finalAmountIn > inputAmount), the code computes correctedTarget = (initialTarget * inputAmount) / finalAmountIn and re-quotes. If the corrected re-quote throws (line 259 catch), the function returns the discovery prepared response — but discovery's amountIn was constructed for halfDestination, which may be roughly half of inputAmount. The SDK then converts ~half of the user's funds and reports Success. For auto-convert this is invisible — the executor sees Success and removes the pending record.
Fix: in the correction catch, fall back to prepared (the overshoot quote, which executePrepared would reject) or rethrow. Never silently execute the discovery quote.
4. Confirmation-screen fee-currency conversion is the actual bug — and untested
app/screens/send-bitcoin-screen/send-bitcoin-confirmation-screen.tsx:295-302
const feeInSettlementCurrency = fee.amount
? paymentDetail.convertMoneyAmount(fee.amount, settlementAmount.currency)
: zeroSettlementAmount
const totalAmount = addMoneyAmounts({ a: settlementAmount, b: feeInSettlementCurrency })The PR description identifies this as a fix: previously a BTC fee got summed into a USD settlement, breaking the balance check. The fix is correct in code, but no test renders the screen with a USD settlementAmount and a BTC fee.amount and asserts the balance check outcome. The existing send-confirmation.spec.tsx only exercises the LNURL story, never crossing the settlement-currency-≠-fee-currency boundary. A regression here silently re-introduces the original bug.
Fix: screen-level test: USD($9.99) + BTC(50 sats) at $10.00 balance → no amountExceed; USD($9.999) + BTC(500 sats) at a rate that pushes total > balance → amountExceed renders.
5. skipBalanceCheck guard has no regression test
app/screens/send-bitcoin-screen/send-bitcoin-confirmation-screen.tsx:304
skipBalanceCheck = isSendingMax || hasAttemptedSend is the right guard, but no test verifies (a) over-balance with hasAttemptedSend=false still blocks, (b) isSendingMax=true legitimately allows over-balance, (c) hasAttemptedSend=true correctly bypasses. The path is currently safe at the UI layer (<GaloySliderButton> is disabled={!validAmount || hasAttemptedSend}, useSendPayment short-circuits with hasAttemptedSend && return undefined) — but neither of those guards is asserted in this PR. Any future refactor of either layer can silently open a real over-spend hole.
Fix: drive the screen through the three states above and assert (a) slider disabled state and (b) errorMessage text.
6. Listener replay can loop forever on busy wallets
app/self-custodial/hooks/use-auto-convert-listener.ts:260-296
processRecord calls findPaidAmountForInvoice; if it returns undefined (e.g. the matching record has aged off the most-recent 50 in listPayments), the function returns without ever calling recordAutoConvertAttempt and without removing the record. The same record gets picked up by every cold-start replay until pruned at TTL=24h. Combined with findPaidAmountForInvoice only scanning offset:0, limit:50, a wallet processing >50 transactions in 24h leaves a paid-but-listener-missed invoice in a permanent loop where every app launch re-scans payments and never converts. Funds stay BTC despite user opting in.
Fix: in the no-payment-found branch, either page through listPayments until the record's createdAtMs falls off the cursor, or recordAutoConvertAttempt so the cap engages.
7. useReceiveAssetMode returns Bitcoin during SDK boot window
app/self-custodial/hooks/use-receive-asset-mode.ts:23-35
useSelfCustodialWallet().isStableBalanceActive defaults to false until getUserSettings resolves in use-sdk-lifecycle.ts:128-137. The hook initialises assetMode = Bitcoin on this default. A stable-balance-active user opening receive during boot generates a BTC invoice with no auto-convert record persisted (use-payment-request.ts:101 if (assetMode === Dollar) is false). When isStableBalanceActive resolves to true afterwards, the useEffect re-aligns the toggle to Dollar but the existing invoice isn't regenerated and isn't queued. The user sees Dollar in the UI yet the receive lands as BTC (without auto-convert) once paid.
Fix: expose a loading state from useReceiveAssetMode and have use-payment-request.ts defer invoice creation until settings are known.
8. Active-wallet flip USD→BTC between receive and execute strands funds
app/self-custodial/hooks/use-auto-convert-listener.ts:229 + app/self-custodial/auto-convert/executor.ts (SkippedStableBalanceActive branch)
The listener captures isStableBalanceActive per-effect-run and forwards it to executeAutoConvert. If the user receives a Dollar invoice (record persisted), then toggles stable-balance off before the convert fires, the executor's first guard returns SkippedStableBalanceActive and the record is removed (use-auto-convert-listener.ts:153). Funds remain in BTC despite the user having opted in at receive time.
Fix: decouple the convert-time gate from receive-time intent — either re-check at execute time using a fresh wallet snapshot, or treat SkippedStableBalanceActive as keep-record-and-skip rather than remove-record.
9. SDK error classifier doesn't refine InvalidInput / InsufficientFunds
app/self-custodial/sdk-error.ts:32-61
Only SparkError and Generic are in INNER_REFINEMENT_TAGS. The SDK can return InvalidInput with inner text "amount below minimum" — exactly the pattern the hint set was designed to catch. Today this classifies as InvalidInput and shows "Invalid input" instead of "Below minimum". This isn't only a UX issue: BelowMinimum triggers SkippedBelowMin in executeAutoConvert (record deleted, convert abandoned), while InvalidInput yields Failed (record retried). Misclassification diverges queue state from user reality.
Fix: add InvalidInput and InsufficientFunds to INNER_REFINEMENT_TAGS, or redesign the refinement to apply unconditionally when an inner string exists and a tag-level mapping isn't already specific enough.
10. Convert reads pre-receive balance via ensureSynced:false
app/self-custodial/bridge/convert.ts:162-197
fetchFromBalanceBaseUnits calls getInfo({ ensureSynced: false }). The auto-convert listener invokes executeAutoConvert immediately after waitForPaymentCompleted returns true — there's a real window where getInfo(ensureSynced:false).balanceSats returns the pre-receive balance (the same SDK staleness skipBalanceCheck exists to mitigate on the send side). If actualBalance is non-zero but lower than requestedInput (the just-received sats), the convert proceeds with the smaller actualBalance — converting only the prior balance and leaving freshly-received sats unconverted.
Fix: ensureSynced: true for auto-convert callers, or accept requestedInput whenever it equals the just-completed payment amount.
Important
| # | Item | Location |
|---|---|---|
| I1 | Live-effect + mount-replay can race the same paymentRequest past the inFlightInvoicesRef check (live adds after findPendingAutoConvert, replay adds after findPaidAmountForInvoice — both async gaps) |
use-auto-convert-listener.ts:204, 220-222, 255, 267-273 |
| I2 | recordAutoConvertAttempt cap is checked against snapshotted record.attempts, not live storage value — two concurrent runAutoConvert invocations both pass the cap and both attempt convert |
use-auto-convert-listener.ts:123-126 |
| I3 | Mount-replay enforces sequential processing via reduce. Comment explains it; no test asserts non-parallelism. A "let's Promise.all this" optimisation would hammer the SDK and double-process |
use-auto-convert-listener.ts:292 |
| I4 | findPaidAmountForInvoice does exact-string match on invoice. SDK normalising case/padding silently breaks every replay |
use-auto-convert-listener.ts:169 |
| I5 | useReceiveAssetMode re-aligns to Dollar on isStableBalanceActive flipping ON but does nothing on OFF (by design — user keeps the mode they were in). Not asserted; a future "fix" that resets to BTC would silently regress intent |
use-receive-asset-mode.ts:31-35 |
| I6 | Storage withWriteLock chain absorbs both fulfilled and rejected outcomes; not exercised with a write that throws synchronously before producing a promise |
app/self-custodial/auto-convert/storage.ts:41-48 |
| I7 | SkippedBelowMin correctly removes the record without a toast, but no test guards that — a refactor that adds a misleading "converted" toast would not be caught |
use-auto-convert-listener.ts:156 (mount-replay path) |
| I8 | prepareConversion discovery clamp tested only against minFromAmount; no test where minToAmount dominates |
bridge/convert.ts:211 |
| I9 | waitForPaymentCompleted maxAttempts:1 (degenerate case) — no setTimeout should fire, behaviour not asserted |
executor.ts:67-69 |
| I10 | auto-convert-listener-mount.spec.tsx is shallow — asserts mount and null render only; if the wrapper ever swallowed errors from the hook, the test wouldn't notice |
__tests__/self-custodial/components/auto-convert-listener-mount.spec.tsx |
Test coverage
Estimated behavioural coverage of the new auto-convert + send-fee + SDK-error layer: ~70%, concentrated on storage, executor unit logic, and the SDK classifier; sparse on the listener's concurrency/replay branches and on the screen-level integration of the headline send-flow fixes.
Tier 1 — production code with no test file
| File | LOC | Notes |
|---|---|---|
app/self-custodial/auto-convert/types.ts |
42 | Type-only; no runtime tests needed, but ensures shape integrity if treated as schema |
app/self-custodial/auto-convert/index.ts |
16 | Barrel; minor |
app/self-custodial/components/index.ts |
1 | Barrel only |
| (none of the new hooks/executor/storage are uncovered — this PR adds tests for each) |
The new units are individually covered. The gaps below are at the integration / branch-combination level, not at the unit level.
Tier 2 — tests that pass while a Critical bug is alive
These create false confidence. Each should be made to fail on the current code first, then fixed alongside the production fix.
| Test file | What it should catch but doesn't | Bug |
|---|---|---|
__tests__/self-custodial/payment-details/lightning.spec.ts:266-279 |
Asserts only tag: "ToBitcoin" via expect.objectContaining. If buildConversionType were silently changed to new ConversionType.ToBitcoin({}) (no token id), the test still passes. The assertion the user-asked-for fix needs is inner.fromTokenIdentifier === "usdb-token-id" |
regression of the original tokenIdentifier bug |
__tests__/self-custodial/sdk-error.spec.ts:37-42 |
Substring precedence ["minimum", BelowMinimum] precedes ["insufficient", InsufficientFunds]. No test asserts this is intentional; reorder = silent semantic flip and queue-state divergence |
#9 |
__tests__/self-custodial/sdk-error.spec.ts:54 |
if (inner) falsy-check means "" falls through to tag-level mapping. sdkError("SparkError", [""]) is a separate path, not asserted. A refactor to if (inner !== undefined) would silently flip behaviour |
#9 (defensive) |
__tests__/self-custodial/bridge/convert.spec.ts |
Never asserts sdk.syncWallet is called on success (line 273-279 in production). The TODO comment explains the call is load-bearing for getInfo alignment until Breez materialises token balances on insert. A refactor that drops the call breaks every USD balance after convert |
#3 (adjacent) |
__tests__/self-custodial/hooks/use-auto-convert-listener.spec.ts:292 |
"Deduplicates the same paymentId across rerenders" only varies the live effect's dependency. Doesn't simulate live + replay racing the same paymentRequest |
I1 |
__tests__/self-custodial/hooks/use-payment-request.spec.ts:401-421 |
Drives Dollar mode by mocking useReceiveAssetMode. Production wiring depends on the real hook reading isStableBalanceActive from the wallet provider — the boot-window race (#7) is invisible because the mock skips that path |
#7 |
__tests__/screens/send-confirmation.spec.tsx |
Only LNURL story; never enters settlement-currency-≠-fee-currency path; never asserts validAmount outcome. The headline fee-currency fix is unverified |
#4 |
Tier 3 — Critical bugs with no test coverage anywhere
| Critical | Recommended test |
|---|---|
| #1 | runAutoConvert invoked with waitForPaymentCompleted returning false → assert recordAutoConvertAttempt is not called |
| #2 | Two equal-amount invoices, A converts then B's listener fires → assert executeAutoConvert runs for both, removePendingAutoConvert is not called for B without a convert |
| #3 | prepareConversionWithDestination overshoots, corrected re-quote rejects → assert function rethrows or returns the overshoot quote, not the discovery quote |
| #4 | Render SendBitcoinConfirmationScreen with USD settlement + BTC fee at boundary balance → assert balance check fires/does-not-fire correctly |
| #5 | Three-state matrix on (isSendingMax, hasAttemptedSend) → assert slider disabled state and errorMessage |
| #6 | Listener mount with persisted record whose payment isn't in the most-recent 50 → assert recordAutoConvertAttempt is called (so the cap engages) or paging is performed |
| #7 | useReceiveAssetMode mounted with isStableBalanceActive initially undefined/loading → assert assetMode is not committed to Bitcoin and no invoice is generated until settings resolve |
| #8 | Pending Dollar record + isStableBalanceActive flips true between receive and execute → assert record is not removed (or that an explicit user notification fires) |
| #9 | classifySdkError(sdkError("InvalidInput", ["amount below minimum"])) → assert result is BelowMinimum, not InvalidInput |
| #10 | Auto-convert immediately post-receive with getInfo(ensureSynced:false) returning stale balance → assert requestedInput is honoured rather than truncated to stale balance |
Suggested order of operations
- Land Critical #1 first — it's a one-line move (
recordAutoConvertAttemptafter thesettledcheck) and prevents silent fund-stranding. Add the regression test from Tier 3. - Critical #3 — the
bridge/convert.tscorrection-catch fallback. One-line behavioural change; high-value test. - Critical #2 — replace the amount-tolerance dedup key with a per-attempt session id or SDK paymentId correlation. Requires a small schema bump to the persisted record (acceptable: storage already handles schema-widening).
- Critical #4 + #5 — add the screen-level fee-currency and
skipBalanceCheckregression tests. These lock in the PR's headline fixes. - Critical #7 — expose a loading state from
useReceiveAssetMode; defer invoice creation until settings resolve. - Critical #10 —
ensureSynced: truefor the auto-convert callers offetchFromBalanceBaseUnits. - Critical #6, #8, #9 — replay-loop bound, active-wallet-flip handling, classifier refinement table.
- Tier 2 tightenings — un-loose the
lightning.spec.tsassertion, lock substring precedence insdk-error.spec.ts, addsyncWalletassertion inconvert.spec.ts. These are cheap and prevent silent regressions. - Important I1–I10 — most fold into the same commits as the Criticals; race tests (I1, I2) are worth adding once the Critical fixes are in.
Structural / state-machine refactor for the listener and the convert pipeline will follow as a separate note against main after this stack settles, so it doesn't restack-bomb downstream PRs.
5bf699b to
77345a0
Compare
fffa4ca to
617d25b
Compare
77345a0 to
c655288
Compare
Critical1. Auto-convert
|
…onvert fee to settlement currency
…lling back to discovery quote
…een balance check
…d hasAttemptedSend
b8f5803 to
b221e04
Compare
63fd45b to
0f63f72
Compare
0f63f72 to
abee102
Compare

Spark: stable-sats receive auto-convert and send-flow fixes
What this PR does
Completes the USD wallet experience for self-custodial Spark. Users can now receive payments as stable sats (incoming BTC is automatically converted to USDB right after a Lightning receive) and send from a USD wallet through the Spark SDK's built-in USDB→BTC swap. It also fixes several UX bugs on the confirmation and transaction-detail screens that misrepresented Spark transactions, and introduces a structured SDK-error pipeline so failures show clear, translated messages instead of raw SDK strings or "Something went wrong".
Receive side
Added a new auto-convert module (
app/self-custodial/auto-convert/) with an executor, a persistent pending-conversion storage, a listener hook, and a mount component wired intoapp.tsx. It watches for completed Lightning receives and, when the active wallet is USD, converts the incoming BTC to USDB using the exact received amount. The storage makes the queue durable across app restarts so a conversion that fails mid-flight is retried on next launch. A post-convert sync is triggered so the new USDB balance shows up immediately rather than waiting for the next wallet poll, and the older balance-stale heuristic in the wallet provider has been removed in favor of this deterministic path.Smaller receive polish: the
lightning:URI prefix is stripped from the displayed invoice, the receive toggle icon hides at opacity 0 when the wallet is locked (keeps the layout from shifting), and a newuseReceiveAssetModehook centralizes the decision of whether to receive BTC or USDB.Convert flow (BTC ↔ USDB)
Reworked the convert bridge to support exact-input amounts and to surface the fee as a DisplayAmount so the UI can render it in the user's display currency cleanly. The
useNonCustodialConversionLimitshook now returns a typed min/max shape that the UI uses to validate before submitting. Existing tests were updated and new ones added for the convert bridge, stable-balance bridge, limits bridge, and token-balance bridge.Send side — three bugs fixed
Sending from a USD wallet over Lightning was routing wrong because the code was passing
tokenIdentifier: "usdb-token-id"to the SDK, which is for the destination asset. For a USD→BTC Lightning send the SDK expectsconversionOptions: ToBitcoin({ fromTokenIdentifier })instead. The lightning payment-details now passesconversionOptionsfor USD wallets and nothing for BTC wallets;tokenIdentifieris no longer used on this path.The fee returned by the SDK is always in sats, but the confirmation screen was summing it into the settlement amount without conversion — so a BTC fee got added to a USD balance, producing wrong totals and false "amount exceeds balance" warnings. The screen now converts the fee to the settlement currency before adding it, and the send-helpers always return the fee as a BTC money amount so the conversion boundary is explicit.
During a successful send, the SDK briefly reports a zero balance while the settlement broadcasts, which flagged the amount as exceeding balance mid-flight and blocked the confirm button. Added a
skipBalanceCheck = isSendingMax || hasAttemptedSendguard so balance validation is bypassed once the user has committed to sending.Structured SDK errors
A new
classifySdkErrorfunction maps all twelveSdkErrortags (SparkError,InsufficientFunds,NetworkError,ChainServiceError,MaxDepositClaimFeeExceeded,InvalidInput,InvalidUuid,LnurlError,MissingUtxo,StorageError,Signer,Generic) to a small stable set ofSelfCustodialErrorCodevalues (InsufficientFunds,BelowMinimum,NetworkError,InvalidInput,Generic). Wrapper tags likeSparkErrorandGenericare further refined by a case-insensitive.includes()check on the inner string for hints like "insufficient", "minimum", "network", "timeout", with the more specific hints evaluated first. TheTAG_TO_CODEmap is typed as an exhaustiveRecord<SdkErrorTags, …>so any future SDK tag will fail to compile until it's mapped.A new
useTranslateSdkErrorhook translates those codes into user-facing strings via a newSelfCustodialErrori18n namespace (insufficientFunds, belowMinimum, networkError, invalidInput, generic), added in all 28 locales with the appropriate diacritics. The confirmation screen uses the hook for any error message coming back from the send mutation, falling back to the generic "something went wrong" string only when the code isn't recognized. The old "Send failed: …" raw string fallback is gone.UI polish
The confirmation screen header was showing "Destination - " with an empty label for Spark sends because
transactionType()had no case forpaymentType === "spark". Added the case; it now readsLL.common.spark().The transaction detail screen was labeling self-custodial Spark transactions as "Lightning" because the self-custodial→GraphQL fragment mapper routes Spark through
SettlementViaLn. ThetypeDisplayhelper is now exported and takes an optionalselfCustodialPaymentType; when the active wallet is self-custodial and the transaction is Spark, the detail screen correctly shows "Spark". Custodial transactions are unchanged.Breaking notes
None for consumers. The self-custodial
createGetFee,createGetFeeOnchain, and related send-helpers lost theircurrencyparameter — the fee is now always returned in sats and the UI reconverts. All in-tree callers were updated in the same commits. The custodial send path is untouched.Tests
Full suite passes: 326 suites, 3403 tests. New specs cover the classifier (every tag branch plus inner-string refinement), the translator hook, the conversionOptions path on both
prepareSendand the send mutations, the auto-convert executor and its storage, the auto-convert listener hook and mount component, the receive-asset-mode hook, and thetypeDisplaySpark override. Existing payment-details, bridge, and mapper specs were updated for the new signatures and removed-mock surface.