Skip to content

feat(spark): add Stable Balance and BTC - USD conversion flow#3761

Merged
grimen merged 71 commits into
mainfrom
feat--spark-stable-balance-and-usd-conversion
May 20, 2026
Merged

feat(spark): add Stable Balance and BTC - USD conversion flow#3761
grimen merged 71 commits into
mainfrom
feat--spark-stable-balance-and-usd-conversion

Conversation

@esaugomez31

@esaugomez31 esaugomez31 commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

PR Summary — Self-Custodial Stable Balance and USD Conversion

Epic: 5 — Stable Balance on Spark
Base branch: feat--spark-payments-and-transaction-history

This PR delivers the full Stable Balance experience for self-custodial accounts: activation/deactivation settings, dual BTC + USD balance display on home, one-time trust/dual-balance explanation, conversion minimum handling, and the manual BTC ↔ USD Convert flow. It closes every task in the Epic 5 brief (Stories 5.1, 5.2, 5.3).

Task checklist

Task Description Status
5.1 Stable Balance activation, deactivation, and balance display Done
5.2 Dual-balance model and conversion minimum handling Done
5.3 Manual Convert flow (BTC ↔ USD) Done

Requirements coverage

FR / NFR Where
FR41 — Balance header toggle Balance · SATS / Balance · USD balance-header.tsxshowStableBalanceToggle prop-driven; feature flag is read only at the call site (home-screen.tsx:274)
FR42 — One-time explanation on first Convert stable-balance-first-time-modal.tsx + use-stable-balance-first-time.ts; triggered from conversion-details-screen.tsx:527
FR69 — BTC and USDB as independent balances (manual conversion only) Wallet snapshot filters USDB via token identifier; Convert is the only path (bridge/convert.ts)
FR70 — Minimum conversion enforced with UX feedback use-non-custodial-conversion-limits.ts + min check in conversion-details-screen.tsx:482 using LL.StableBalance.minimumConversion
FR71 — Both balances rendered on home when active Home exposes SC wallets; USD row appears when isStableBalanceActive
FR72 — Mode + dismissal persisted locally AsyncStorage: selfCustodialBalanceMode (use-balance-mode.ts) + stableBalanceExplanationShown (use-stable-balance-first-time.ts)
FR73 — USDB balance displayed in USD transaction-mapper.ts mapCurrency maps PaymentDetails.Token → WalletCurrency.Usd; wallet-snapshot converts token base units to cents
FR74 — Manual BTC ↔ USD Convert via existing Convert action createConvert registered in use-payments.ts:63; consumed by the shared Convert screen via useNonCustodialConversion
NFR5 — Balance / controls update within 2s of user action Toggle calls activateStableBalance + refreshStableBalanceActive + refreshWallets in sequence; SDK event listener + AppState-active refresh keep snapshot fresh
NFR12 — Manual conversion must not lose funds Slippage bounded by SparkConfig.maxSlippageBps; bridge prepares a self Spark invoice and executes atomically; unit tests cover below-min / limits-unavailable / success paths

What's delivered

Story 5.1 — Activation, deactivation, balance display

  • Settings entry + screen: stable-balance setting row (gated by nonCustodialEnabled && stableBalanceEnabled && active account is SC) opens StableBalanceSettingsScreen which owns the activation toggle.
  • Toggle flow:
    • Activate from a zero BTC balance → applied immediately.
    • Activate with BTC → confirmation modal with a live fee quote (useStableBalanceToggleQuote).
    • Deactivate with zero USDB → applied immediately.
    • Deactivate with USDB → confirmation modal shows the StableBalance.deactivateWarningBody copy with the exact USD amount, plus the estimated conversion fee.
  • Bridge: bridge/stable-balance.ts wraps updateUserSettings({ stableBalanceActiveLabel: Set | Unset }).
  • Provider state: isStableBalanceActive + refreshStableBalanceActive exposed via wallet-provider.tsx; initial fetch + on-demand refresh after activation/deactivation.
  • Navigation: stableBalanceSettings route registered in root-navigator.tsx:769.
  • Balance header: new showStableBalanceToggle, mode, onModeChange props; tapping the label persists the chosen mode via useBalanceMode.
  • Home: formatted balance swaps between BTC (sats) and USD according to mode; USD row only visible when Stable Balance is active.

Story 5.2 — Dual-balance model + minimum handling

  • Wallet snapshot maps the configured USDB token into the USD wallet using fetchUsdbDecimals and tokenBaseUnitsToCents.
  • Unknown tokens are filtered out in wallet-snapshot.ts; raw pagination cursor tracked separately to keep loadMore correct.
  • fetchConversionLimits (bridge/limits.ts) normalizes min amounts into wallet-display units (sats for BTC, cents for USD) based on direction.
  • Conversion details screen blocks the "Next" CTA and shows LL.StableBalance.minimumConversion when the source amount is below the per-direction minimum.
  • Transaction mapper tags token payments with paymentType: "conversion" and extracts conversion metadata so the entry is recognisable in history.

Story 5.3 — Manual Convert flow

  • Shared types (payment.types.ts): ConvertParams, ConversionLimits, ConvertDirection, ConvertAdapter, ConvertQuote, GetConversionQuoteAdapter, ConvertAmountAdjustment, ConvertErrorCode, convertDirectionFromCurrency, oppositeWalletCurrency.
  • Self-custodial adapter (bridge/convert.ts):
    • createGetConversionQuote(sdk) — prepares a self Spark invoice, runs prepareSendPayment with conversionOptions + maxSlippageBps, returns a formatted fee + execute().
    • createConvert(sdk) — composes prepare + execute in one call, returning PaymentAdapterResult with explicit error codes for BelowMinimum / LimitsUnavailable.
  • Custodial adapter updated to match the new ConvertParams shape so both providers share the same interface.
  • usePayments() now returns convert + getConversionQuote — SC wire-up at use-payments.ts:63.
  • Convert screen UX:

i18n

  • New namespaces in en/index.ts:
    • StableBalance.* — home toggle labels, settings copy, activation modal, first-time modal (dual-balance + trust disclosure), minimum conversion message.
    • SelfCustodialBalance.* — stale indicator label + toast.
    • ConversionConfirmationScreen.amountFloored / amountDustBumped — surfacing SDK amount adjustments.
  • Propagated to all 28 locale JSON files (Python script preserving formatting + diacritics).

Tests

New specs added (and existing ones updated for the new SDK mocks):

  • Bridge: bridge/convert, bridge/limits, bridge/stable-balance, bridge/token-balance, bridge/status, bridge/wallet.
  • Providers: providers/detect-balance-stale, providers/is-online, extended providers/wallet-provider (Stable Balance refresh + AppState handling), wallet-snapshot token filter.
  • Hooks: use-balance-mode, use-stable-balance-first-time, use-non-custodial-conversion-limits, use-conversion-quote, use-non-custodial-conversion, use-stable-balance-toggle-quote, use-payments (getConversionQuote wiring).
  • Screens / components: balance-header, stable-balance-first-time-modal, stable-balance-settings-screen, settings row, conversion-details-screen, conversion-details-stable-balance-modal, conversion-fee-row, conversion-confirmation, home (Stable Balance toggle rendering).
  • Custodial regression: custodial/adapters/payment-adapter spec updated for the new ConvertParams shape.

Out of scope (lives here but not in the Epic 5 brief)

A few items ship in this PR because they touch the same surfaces, but they are not part of Epic 5 per the spec. Flagged here for reviewer awareness:

  • Stale balance indicator — heuristic in detect-balance-stale.ts + Spark service status via is-online.ts + StatusPill "STALE" badge in the balance header + warning toast. This is closer to FR44 (Epic 4.2) and FR65c (Epic 6.3) than Epic 5. Landed here so the Stable Balance UX doesn't silently show a stale USD/BTC split when Spark operators are unreachable.
  • Lifecycle polling + AppState refresh in use-sdk-lifecycle.ts: 10s interval calling getServiceStatus() + refreshWallets() while the app is foregrounded; refresh on AppState active. Supports the stale detector above; also serves FR37 / FR64 from earlier epics.
  • Transaction mapper fee handling — fees are now converted into the settlement currency for display (inherited from the PR's support for showing conversion transactions end-to-end).
  • SDK mock refresh (breez-sdk-spark-react-native.js) — kept in sync with Stable Balance, ConversionType, StableBalanceActiveLabel, AmountAdjustmentReason, ReceivePaymentMethod.SparkInvoice, ServiceStatus, and related enums used by the new tests.
  • app/graphql/generated.ts regenerated via codegen (required by the CI committer check).

Known deltas vs spec

  • Spec wording places the deactivation confirmation at components/stable-balance/stable-balance-settings.tsx; the actual file location is app/screens/stable-balance-settings-screen/ following the existing screens/components convention in the repo. Behaviour matches the acceptance criteria.
  • Spec says the settings screen hosts "a toggle"; this PR adds a live conversion-fee preview and amount-adjustment disclosures on top of the toggle for better UX. No spec requirement is broken.

@esaugomez31 esaugomez31 changed the title feat(self-custodial): detect stale balance and surface STALE pill + toast feat(self-custodial): add Stable Balance and BTC ↔ USD conversion flow Apr 22, 2026
@esaugomez31 esaugomez31 changed the title feat(self-custodial): add Stable Balance and BTC ↔ USD conversion flow feat(self-custodial): add Stable Balance and BTC - USD conversion flow Apr 22, 2026
@esaugomez31 esaugomez31 marked this pull request as ready for review April 22, 2026 05:24
@esaugomez31 esaugomez31 self-assigned this Apr 22, 2026
@esaugomez31 esaugomez31 marked this pull request as draft April 22, 2026 05:25
@esaugomez31 esaugomez31 force-pushed the feat--spark-stable-balance-and-usd-conversion branch 2 times, most recently from 3ad985d to e28d173 Compare April 22, 2026 22:14
@esaugomez31 esaugomez31 marked this pull request as ready for review April 23, 2026 00:59
@esaugomez31 esaugomez31 changed the base branch from feat--spark-payments-and-transaction-history to graphite-base/3761 April 28, 2026 16:27
@esaugomez31 esaugomez31 force-pushed the feat--spark-stable-balance-and-usd-conversion branch from e28d173 to 104549a Compare April 28, 2026 16:27
@esaugomez31 esaugomez31 changed the base branch from graphite-base/3761 to feat--spark-payments-and-transaction-history April 28, 2026 16:27
@grimen grimen changed the title feat(self-custodial): add Stable Balance and BTC - USD conversion flow feat(spark): add Stable Balance and BTC - USD conversion flow Apr 28, 2026
@esaugomez31 esaugomez31 force-pushed the feat--spark-payments-and-transaction-history branch from d129a05 to 5e2acfa Compare May 6, 2026 02:05
@esaugomez31 esaugomez31 force-pushed the feat--spark-stable-balance-and-usd-conversion branch 2 times, most recently from 6503c1f to c824aac Compare May 7, 2026 15:54

@grimen grimen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — bugs + test coverage

Important

This PR (and its stack) needs a major refactor pass. The structural critique from #3758 is inherited and amplified here. Findings include:

  • SOLID violationsuseNonCustodialConversion is a parallel hook pathway gated enabled: isSelfCustodial that mirrors the existing custodial flow rather than abstracting it; useConversionQuote exists alongside the existing custodial quote architecture; the ConvertParams shape change adds another adapter-contract knob (the PR description acknowledges "Custodial adapter updated to match the new ConvertParams shape so both providers share the same interface" — but the contract is leaky and mode-aware); bridge/convert.ts mixes pass-through wrappers (createConvert at lines 193-202 is dead code in this PR) with real abstraction (prepareConversion); presentation logic leaks into the bridge layer (formatUsdFromBaseUnits at bridge/convert.ts:32-43 hard-codes $ prefix and decimal point inside a non-UI module).

  • High cyclomatic complexity in the screen layer — verified branch counts on this PR's modified screens: home-screen.tsx has 19 mode branches (isSelfCustodial / isStableBalanceActive / stableBalanceEnabled / nonCustodialEnabled); conversion-details-screen.tsx has 10 mode branches plus a 31-call hook prelude mixing useState, useRef, and custom hooks; conversion-confirmation-screen.tsx has 9 mode branches in 446 LOC and forks the entire submit pathway between custodial GraphQL mutations and SC nonCustodialConversion.execute(); stable-balance-settings-screen.tsx is internally clean (3 mode branches) but composes a 7-hook prelude that owns state, fee-quote, refresh, AppState, and UI all in one component.

  • Incomplete adapter pattern — mode leaks into call sites instead of being hidden behind a uniform Port. app/types/payment.types.ts adds 8 new Convert-specific types (ConvertParams, ConversionLimits, ConvertDirection, ConvertErrorCode, ConvertAmountAdjustment, ConvertAdapter, ConvertQuote, GetConversionQuoteAdapter) — adapter-contract bloat; the SC convert path threads its own getConversionQuote separately from payments.convert (which is unused by the SC flow), so two parallel adapter shapes coexist; mode-prefixed exports proliferate (this PR adds 11+ selfCustodial* / nonCustodial* / StableBalance* exports across hooks/bridge/components/screens — useStableBalanceFirstTime, useNonCustodialConversion, useNonCustodialConversionLimits, StableBalanceConfirmModal, useStableBalanceToggleQuote, activateStableBalance, deactivateStableBalance, findUsdbToken, fetchUsdbDecimals, etc.).

  • Naming smells"lying" names: tokenBaseUnitsToCents silently rounds (lossy) while the exact version is buried as tokenBaseUnitsToCentsExact (amounts.ts:21-31) — the rounding lossiness is the actual root cause of Critical #5; formatUsdFromBaseUnits is locale-unsafe (hard-coded $X.YY); createConvert (bridge/convert.ts:193) is unreferenced — lying API surface. Inconsistent fee vocabulary across the PR diff: at least 9 distinct fee* names — feeAmount, feeCurrency, feeError, feeInSettlementCurrency, feeInWalletCurrency, feeLabel, feeMajor, feeRowWrapper, feeText. Two type families for the same concept: BalanceMode.Btc / BalanceMode.Usd (string literals "btc" / "usd" in use-balance-mode.ts) collides with WalletCurrency.Btc / WalletCurrency.Usd (graphql enum) used everywhere else — same concept, parallel vocabularies. Prefix proliferation: selfCustodial* / nonCustodial* / StableBalance* / usdb* — four prefix families for overlapping concerns. Convert vs Conversion used interchangeably in the same module (bridge/convert.ts exports ConversionLimits while the file is convert.ts; ConvertDirection describes a Conversion).

Why this is not in scope for this PR: with #3761 sitting under #3762#3769 (six stacked PRs above this one), doing the refactor here would force a multi-day restack across all of them, break the stack's momentum, and create combinatorial conflict pain — particularly the rename passes and adapter-shape unification. Refactoring against main after the stack settles is materially cheaper for everyone.

Decision: this review covers correctness only. The structural / SOLID / complexity / naming review will be shared as the same cleanup ticket opened against #3758 (this PR's findings extend it, not replace it). Cleanup will land as a sequence of small, focused PRs against main once the stack is approved — no restack pain, each independently mergeable. Production rollout (#3768) should be gated on the cleanup ticket reaching at least the adapter-pattern unification milestone, otherwise the structural debt ships and gets normalised.


Real strengths first: the bridge-layer DI on prepareConversion (bridge/convert.ts:71-149) is well-shaped and worth keeping when the wrapper is fixed; wallet-provider.spec.tsx (1128 LOC) genuinely exercises 10s polling, AppState transitions, sticky-stale, and refresh coalescing — model behavioural coverage; bridge/convert.spec.ts covers BelowMinimum / LimitsUnavailable / prepare/send error paths; transaction-mapper.ts's reportUnhandledEnum (records to crashlytics AND falls back deterministically) is the gold-standard pattern other mappers should follow; useConversionQuote's stale-resolve cancellation is correctly implemented and tested; the custodial regression risk from the ConvertParams shape change is real but actually mitigated — the custodial branch in conversion-confirmation-screen.tsx:244-327 uses intraLedgerPaymentSend directly and bypasses the new SC machinery. The shape of the integration is right; the gaps are concentrated at the toggle + Convert money paths.

Severity:

  • Critical — fund-loss, mis-attribution, 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. Stable Balance toggle has try/finally with no catch

app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx:67-83 + bridge at app/self-custodial/bridge/stable-balance.ts:6-19 (no catch either)

try {
  if (activate) await activateStableBalance(sdk, SparkToken.Label)
  else await deactivateStableBalance(sdk)
  await refreshStableBalanceActive()
  await refreshWallets()
} finally { setBusy(false); setPendingValue(null) }

If sdk.updateUserSettings(...) rejects (network, signing, persistence) the rejection bubbles to the event handler. Production: switch animates back to its previous position, no toast, no crashlytics, no message. The user has no signal whether it persisted server-side and the refresh failed, the SDK call rejected, or it silently succeeded but the UI rolled back. This is the central knob for the entire feature.

Fix: wrap in try/catch, record to crashlytics, show a toast, resync the switch. Also the confirm modal at stable-balance-confirm-modal.tsx:83 uses primaryButtonDisabled={isSubmitting || isLoading} — should also disable on hasError so a broken fee preview can't be Confirmed past.

2. createGetConversionQuote swallows every SDK error to null

app/self-custodial/bridge/convert.ts:182-191

async (params) => {
  try {
    const context = await prepareConversion(sdk, params)
    return toConvertQuote(sdk, context)
  } catch {
    return null
  }
}

The author throws typed ConvertError(LimitsUnavailable) and ConvertError(BelowMinimum) from prepareConversion, then discards both in the wrapper. Slippage failures, signing failures, network errors, dust-limit issues all map to a generic gray "fee unavailable" UI. Knock-on: use-conversion-quote.ts:48-60's .catch arm becomes dead code because the inner promise never rejects → crashlytics().recordError is never invoked for quote failures.

Fix: keep the catch, record to crashlytics with breadcrumbs (direction, fromAmount), and return a discriminated { ok: false, code } so the UI can render "Conversion limits temporarily unavailable" vs "Amount below minimum" vs generic. Either that, or re-throw and let the hook log.

3. Min-conversion gate falls open when fetchConversionLimits fails

app/screens/conversion-flow/conversion-details-screen.tsx:161-165, 482-501 + hook app/self-custodial/hooks/use-non-custodial-conversion-limits.ts:31-39

const { limits: scConversionLimits } = useNonCustodialConversionLimits(convertDirection)
const scMinFromAmount = isSelfCustodial ? scConversionLimits?.minFromAmount ?? null : null
...
const belowMinimum =
  isSelfCustodial && scMinFromAmount !== null && settlementSendAmount.amount > 0 &&
  settlementSendAmount.amount < scMinFromAmount

The hook exposes error: Error | null but the screen never reads it. When limits load fails, scMinFromAmount === nullbelowMinimum === false → the user can press Review on a 1-sat conversion. Confirmation will fail at prepareConversion (compounded by #2's swallowed error) and the user sees only a disabled slider with no actionable message. Combined with #2 they retry forever.

Fix: consume error from the hook; disable the Review button and toast a clear "Conversion temporarily unavailable" when non-null.

4. Confirmation re-runs full prepareSendPayment on every realtime-price tick — slider can fire a quote the user never saw

app/screens/conversion-flow/hooks/use-conversion-quote.ts:41-64, use-non-custodial-conversion.ts:43-51, conversion-confirmation-screen.tsx:110-114

quoteParams reference changes whenever priceOfCurrencyInCurrency updates (every realtime-price tick from use-price-conversion.ts:55-84). Each change re-runs createOwnSparkInvoice + prepareSendPayment, replacing quote.execute mid-screen. There is no "fee changed — reconfirm" guard, so the slider's visible fee text can be stale relative to the prepared payload that actually executes. Side effect: every keystroke on details and every price tick on confirmation leaks a freshly-minted Spark self-invoice + prepared payment session — dozens per minute on a slow typer.

Fix: snapshot the first Ready quote on entering confirmation; only re-quote on explicit retry. Debounce quoteParams upstream (≥ 500 ms) and abort cancelled prepares. Gate execute on a quote-id captured at slider-press, and re-confirm if quote regenerated since.

5. tokenBaseUnitsToCents rounds the minimum down — sub-cent gap lets UI green-light a sub-min amount

app/self-custodial/bridge/limits.ts:21-30 + app/utils/amounts.ts:21-26

toWalletUnit uses tokenBaseUnitsToCents = Math.round(...). If the SDK's minimum is 1_000_001 USDB base units (1.000001 ¢), it rounds to 1 ¢. The UI's belowMinimum check passes 1 ¢, the user submits, then the SDK rejects below-minimum at prepareSendPayment. Compounded by #2's swallowed errors, the user sees only a generic "fee unavailable" with no path forward.

Fix: Math.ceil for minimums (always conservative upward), Math.floor for maximums. Or carry the raw bigint through and compare in token base units.

6. Self-custodial transaction fees are unconditionally tagged as BTC sats

app/self-custodial/mappers/transaction-mapper.ts:174 + app/self-custodial/mappers/to-transaction-fragment.ts:128-140, 152-157

fee: toWalletMoneyAmount(Math.abs(toNumber(payment.fees)), WalletCurrency.Btc)

For USDB token payments, if payment.fees is in token base units (USDB has 6 decimals — factor of 10⁴ vs cents), the mapper labels it as sats and feeInSettlementCurrency later runs that "sats" through the BTC/USD price. Off-by-10⁴ in transaction history. Even if the SDK happens to express token fees in sats today, there's no documentation pin and no test asserting the unit semantics.

Fix: branch on payment.method / payment.details.tag. For Token, use tokenBaseUnitsToCents with the token's decimals. Add a unit test asserting fee currency for a token payment.

7. Config.SPARK_TOKEN_IDENTIFIER collapses to empty string when unset

app/self-custodial/config.ts:35, used in bridge/convert.ts:136, 143 and bridge/limits.ts:18, 40

tokenIdentifier: Config.SPARK_TOKEN_IDENTIFIER ?? ""

A misconfigured build silently passes "" to every conversion call. Best case the SDK errors and conversion fails mysteriously. Worst case it's interpreted as native asset and the conversion path no-ops (route reduces to a self-payment with no token side, executing differently than intended). The whole conversion feature can be broken with no warning.

Fix: throw at module-load if Config.SPARK_TOKEN_IDENTIFIER is missing in builds that ship the conversion feature, or guard each entry with a clear "Stable Balance not configured" error.

8. loadMore cursor wiped by background refresh — paginated transactions disappear

app/self-custodial/providers/use-sdk-lifecycle.ts:101-104, 213-220 + app/self-custodial/providers/wallet-snapshot.ts:85-118

refreshWallets (10s poll, AppState change, SDK event) calls getSelfCustodialWalletSnapshot which returns page 0 and resets rawTxOffsetRef.current = snapshot.rawTransactionCount (= 20). If the user has paginated to offset 60 (3 pages), the next refresh truncates the visible list to page 0 and resets the cursor — older txs the user just loaded vanish. Not money-loss, but data corruption / scroll-jumping while reading history.

Fix: refresh re-fetches up to current rawTxOffsetRef.current, merging by id; or keep loaded pages cached and reconcile new entries above offset 0.

9. executePrepared collapses every SDK error into a single message string

app/self-custodial/bridge/convert.ts:155-161

} catch (err) {
  return failed(err instanceof Error ? err.message : `Conversion failed: ${err}`)
}

Hidden distinct cases: slippage exceeded mid-flight (maxSlippageBps was passed to prepareSend, so the actual rejection happens at execute time), insufficient liquidity, signing key revoked, network mid-send. None tagged with a code, none reported to crashlytics. The base branch's recent send.ts work introduced crashlytics on send failures; convert is the equivalent money-losing path and reports nothing.

Fix: record to crashlytics with breadcrumbs (direction, fromAmount, toAmount); stamp a code (SlippageExceeded | InsufficientFunds | NetworkFailed) where parseable.

10. Token filter silently drops unknown tokens, no telemetry

app/self-custodial/providers/wallet-snapshot.ts:28-32 + matching findUsdbToken in bridge/token-balance.ts:14-17

const isKnownPayment = (payment: Payment): boolean => {
  if (payment.method !== PaymentMethod.Token) return true
  if (!payment.details || !PaymentDetails.Token.instanceOf(payment.details)) return false
  return payment.details.inner.metadata.identifier === SparkConfig.tokenIdentifier
}

If SparkConfig.tokenIdentifier ever differs from the on-chain identifier (case slip, regtest config leak, future USDB migration), every USDB payment silently disappears from history while getStableBalance still computes a balance from the same identifier comparison. The user sees a positive USD balance but zero conversion history and no warning — exactly the kind of bug that gets reported as "transactions disappeared after update."

Fix: when a token-method payment with an unrecognised identifier is dropped, emit a one-shot crashlytics breadcrumb (de-duped) so config mismatches surface in production. Same in findUsdbToken.


Important

# Item Location
I1 detect-balance-stale flags a legitimately-zero wallet as stale forever — heuristic is balance==0 AND any completed receive. Combined with the 10s poll + AppState reactivation, the toast spams on every refresh. app/self-custodial/providers/detect-balance-stale.ts:8-24
I2 Locale-blind deactivation warning — (usdBalanceAmount / 100).toFixed(2) injected into a translated message; no symbol, no thousands separator, no display-currency conversion. app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx:153-157
I3 fetchUsdbDecimals silent fallback to SparkToken.DefaultDecimals if token absent — subsequent centsToTokenBaseUnits math may be wrong by orders of magnitude. No telemetry. app/self-custodial/bridge/token-balance.ts:19-22
I4 is-online.ts swallows every SDK error to a sentinel (ServiceStatus.Major / OnlineState.Unknown) without crashlytics().recordError(err); upstream only logs the sentinel, stack trace lost. app/self-custodial/providers/is-online.ts:11-17, 33-40
I5 refreshStableBalanceActive swallows error with only a debug log — uses logSdkEvent(SdkLogLevel.Error, …) rather than crashlytics().recordError(...) like the rest of the file. After a successful toggle, a refresh failure leaves UI showing stale value silently. app/self-custodial/providers/use-sdk-lifecycle.ts:237-245
I6 10s polling has no back-off, no error recording; setInterval fire-and-forget with refreshWallets() rejection unguarded. Wedged SDK = silent permanent Offline screen with no crashlytics. app/self-custodial/providers/use-sdk-lifecycle.ts:212-220
I7 AppState "active" listener fires refreshWallets() with no .catch guard — same fire-and-forget shape as I6. app/self-custodial/providers/use-sdk-lifecycle.ts:205-210
I8 use-stable-balance-first-time re-shows modal on every launch if AsyncStorage.setItem fails — crashlytics is recorded but UX nags forever after a single flake. app/hooks/use-stable-balance-first-time.ts:19-29
I9 createConvert (bridge/convert.ts:193-202) is dead code in this PR — no caller routes through payments.convert for self-custodial; everything goes via getConversionQuote → quote.execute(). Future devs may use it and pay double-prepare cost (limits + full prepareSendPayment twice). app/self-custodial/bridge/convert.ts:193-202
I10 formatUsdFromBaseUnits hard-codes $ prefix and decimal point — locale-unsafe for the fee preview text. app/self-custodial/bridge/convert.ts:32-43
I11 Disconnect race: connectAndListen returns the SDK immediately, only checks mounted afterward; if initSdk resolves after unmount, the SDK is disconnected from a stale closure. Uncovered branch. app/self-custodial/providers/use-sdk-lifecycle.ts (init effect)

Test coverage

Estimated behavioural coverage of the new Stable Balance + Convert layer: ~70%, concentrated on bridge / hooks; the screen-level submit and refresh-sequencing paths (where the money actually moves) are the gap.

Tier 1 — production code with no test file

Verified against __tests__/:

File LOC Notes
app/utils/amounts.ts (new helpers) (modified) tokenBaseUnitsToCents, tokenBaseUnitsToCentsExact, centsToTokenBaseUnits are only exercised transitively (one wallet-snapshot assertion + limits.spec.ts). The existing __tests__/utils/amounts.spec.ts covers toSatsAmount only. Source of Critical #5.
app/self-custodial/providers/use-sdk-lifecycle.ts (modified, ~250 LOC) No __tests__/self-custodial/providers/use-sdk-lifecycle.spec.ts. Coverage is transitive via wallet-provider.spec.tsx; init/disconnect race (I11) and the back-off-on-poll-failure path (I6) are uncovered.
app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx 101 No dedicated spec — exercised only via the parent screen's spec. The hasError → button-disabled gate (Critical #1) is not asserted anywhere.
app/components/status-pill/status-pill.tsx 62 New component, no spec file. The "STALE" pill is the user-visible signal for the entire stale-balance heuristic.

Tier 2 — tests that pass while a Critical bug is alive

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__/screens/conversion-flow/conversion-confirmation.spec.tsx Describe block named generically ("conversion-confirmation-screen") but only renders the custodial branch. The SC submit (isSelfCustodial && useNonCustodialConversion(...) → execute()) is never executed. The <ConversionFeeRow feeText adjustmentText/> placement and navigation-on-success in the SC branch are completely uncovered. #1, #2, #4
__tests__/screens/conversion-details-screen.spec.tsx useActiveWallet mocked with isSelfCustodial: false so scMinFromAmount is never tested. The 60-scenario it.each table for the gate runs against the custodial path. The SC-specific min-conversion check is dead in this spec. #3, #5
__tests__/screens/conversion-details-stable-balance-modal.spec.tsx Self-custodial tests only assert the first-time modal renders — no amount typed, no belowMinimum exercised. #3, #5
__tests__/screens/stable-balance-settings-screen.spec.tsx Asserts mockActivate/mockDeactivate called, but never asserts mockRefreshStableBalanceActive or mockRefresh are called, let alone the order. The PR description's "Refresh sequencing" claim is unverified. Always uses mockActivate.mockResolvedValue(undefined) — the failure path is never exercised; if activation fails in production, the user gets no feedback (Critical #1). #1
__tests__/self-custodial/providers/wallet-snapshot.spec.ts The pagination test "loadMoreTransactions returns hasMore=true even when filtering reduces transactions below page size" is the right shape, but loadMoreTransactions is called with 0 then 20 as offsets — the test never demonstrates that on a second loadMore, rawTxOffsetRef.current increments by result.rawCount rather than result.transactions.length. That increment is the bug-prevention mechanism. #8
__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts Asserts execute() delegation; never re-quotes mid-test to verify the same quote returned at quote-time is the one executed at submit-time. A stale quote whose execute is still in state can fire at a price/fee the user didn't see. #4
__tests__/screens/conversion-details-stable-balance-modal.spec.tsx useStableBalanceFirstTime is mocked, so expect(mockMarkAsShown).toHaveBeenCalledTimes(1) proves the screen calls a callback — not that AsyncStorage receives stableBalanceExplanationShown=true. The hook's spec covers persistence, but no test verifies the storage keys match between writer and reader (classic "shown once" regression). (test quality)

Tier 3 — Critical bugs with no test coverage anywhere

For each, no existing test exercises the behaviour. Add a regression test alongside the fix.

Critical Recommended test
#1 stable-balance-settings-screen.spec.tsx: activate failure (mockActivate.mockRejectedValue(...)) → toast shown, switch resyncs to inactive, refreshWallets not called, crashlytics recorded. Plus it("after activate, calls refreshStableBalanceActive then refreshWallets in order") using mock.invocationCallOrder.
#2 bridge/convert.spec.ts: each prepare-throw path (LimitsUnavailable, BelowMinimum, network, slippage) → getConversionQuote returns a tagged failure (or re-throws), and crashlytics().recordError is called. The current spec only asserts the happy path returns a quote.
#3 conversion-details-screen.spec.tsx with isSelfCustodial: true and useNonCustodialConversionLimits returning { limits: null, error: new Error(...) } → Review button disabled, error message rendered. Mirror with { minFromAmount: 100 } → fromAmount=50 disables Next, fromAmount=100 enables.
#4 use-conversion-quote.spec.ts: render with quote A (Ready), tick priceOfCurrencyInCurrency, await re-quote → previous prepare aborted (no leaked state); explicit "snapshot on confirmation entry" semantics. Plus a useNonCustodialConversion test: render with params A, await Ready, change params to B, await re-quote, call execute() and assert it was quote-B's execute.
#5 New __tests__/utils/amounts.spec.ts cases: USDB 6-decimal min 1_000_001 → ≥ 2 cents (rounded up, never down); 0-decimal token; 1-decimal excess clamping; round-trip through centsToTokenBaseUnits; negative input behaviour; exact non-rounded tokenBaseUnitsToCentsExact.
#6 transaction-mapper.spec.ts and to-transaction-fragment.spec.ts: a PaymentMethod.Token fixture asserts fee.currency === WalletCurrency.Usd and the value matches tokenBaseUnitsToCents(payment.fees, decimals) rather than being passed straight as sats.
#7 bridge/convert.spec.ts (or a new config.spec.ts): with Config.SPARK_TOKEN_IDENTIFIER undefined, conversion entry points throw a clear configuration error rather than silently calling the SDK with "".
#8 wallet-snapshot.spec.ts: refresh during pagination (offset=60) → after refresh, rawTxOffsetRef.current preserved at 60; subsequent loadMore requests offset 60. Plus the second-loadMore raw-count vs filtered-count assertion.
#9 bridge/convert.spec.ts: each executePrepared failure type is recorded to crashlytics and surfaces a tagged code, not just err.message.
#10 wallet-snapshot.spec.ts: a token-method payment with mismatched metadata.identifier is dropped AND emits a (mocked) crashlytics breadcrumb.

Suggested order of operations

  1. Land the Critical fixes in #3761: all 10 critical items originated in this PR. The toggle no-catch (#1) and the createGetConversionQuote swallow (#2) are the "compose to silent failure" pair — fix together. #3 and #5 share root cause (limits-related under-check) — fix together. #6 and #7 are five-line fixes with high impact. #8 needs the rawTxOffsetRef preservation in the refresh path.
  2. Write the Tier 3 regression tests alongside the production fixes. Particularly #1, #2, #3, #4 — these are the money-handling tests that should exist before this PR merges.
  3. Repair the Tier 2 false-confidence tests. Either rename conversion-confirmation.spec.tsx to --custodial and add a --self-custodial sibling, or extend the existing file with the SC describe block. Add the missing mock.invocationCallOrder assertions to stable-balance-settings-screen.spec.tsx.
  4. Fill the Tier 1 gaps: new spec for app/utils/amounts.ts (the Critical #5 testbed); dedicated use-sdk-lifecycle.spec.ts covering disconnect race (I11) and poll-error back-off (I6); spec for stable-balance-confirm-modal.tsx (with hasError → disabled assertion); status-pill.spec.tsx.
  5. Important items I1–I11 — most are small (one-line crashlytics adds) and can fold into the same commits as the related Critical fixes. I1 (stale heuristic spam) and I2 (locale-blind deactivation warning) are user-visible and should ship in this PR.

Structural and naming feedback is held to the post-stack cleanup ticket noted in the IMPORTANT block above. Do not refactor in this PR — restack cost outweighs the cleanup benefit until #3768 is approved.

@esaugomez31

Copy link
Copy Markdown
Collaborator Author

@grimen Feedback applied, here's the summary.

Critical

1. Stable Balance toggle has try/finally with no catch

Wrapped the toggle in try/catch: rejections are recorded to crashlytics, an error toast is shown, and the switch is resynced to its previous position. Modal primaryButtonDisabled now also gates on hasError, so a broken fee preview can't be confirmed past.

2. createGetConversionQuote swallows every SDK error to null

The catch now records to crashlytics with breadcrumbs (direction, fromAmount, toAmount) and re-throws so the hook's .catch arm runs and sets QuoteStatus.Error. The previously dead error path now executes for every prepare-time failure.

3. Min-conversion gate falls open when fetchConversionLimits fails

The screen now consumes error from useNonCustodialConversionLimits. When non-null, the Review button is disabled and the error row renders the new StableBalance.conversionUnavailable translation, so the user no longer pushes Review on a 1-sat conversion the SDK would later reject.

4. Confirmation re-runs full prepareSendPayment on every realtime-price tick

useNonCustodialConversion now snapshots the first Ready quote and stops feeding liveQuoteParams to useConversionQuote until enabled, fromCurrency, or moneyAmount actually change, so a price tick on confirmation can no longer leak a Spark invoice or replace quote.execute mid-screen. The keystroke side is already covered upstream by the existing debounceMs={1000} on the conversion-details amount input (amount-input-screen.tsx debounces onAmountChange via useDebouncedEffect so setMoneyAmount only fires after 1 s of typing inactivity), so no second debounce was added in the hook.

5. tokenBaseUnitsToCents rounds the minimum down

Added tokenBaseUnitsToCentsCeil and switched bridge/limits.ts:toWalletUnit to it for SDK-imposed minimums. A 6-decimal token minimum of 1_000_001 base units now surfaces as 101¢ instead of 100¢, keeping the UI gate above the pool floor.

6. Self-custodial transaction fees are unconditionally tagged as BTC sats

mapSelfCustodialTransaction now reuses the same currency and tokenDecimals it already derives for the amount, so the fee is scaled through toDisplayAmount and tagged with the correct WalletCurrency. Token payments now surface fees in USD cents instead of fake BTC sats.

7. Config.SPARK_TOKEN_IDENTIFIER collapses to empty string when unset

Removed SparkConfig.tokenIdentifier (the silent empty-string fallback) and added requireSparkTokenIdentifier() which throws when the env var is missing. All entry points that need the identifier (bridge/convert.ts, bridge/limits.ts, bridge/token-balance.ts, bridge/send.ts, bridge/lifecycle.ts, providers/wallet-snapshot.ts) now go through the helper, so a misconfigured build fails fast instead of silently calling the SDK with "".

8. loadMore cursor wiped by background refresh

getSelfCustodialWalletSnapshot now accepts a targetRawCount parameter and iterates fetchAndMapPayments until it covers the requested raw offset. refreshWallets passes rawTxOffsetRef.current so a refresh during pagination re-fetches every page the user already loaded; the cursor is preserved end-to-end and loadMore continues from where the user left off instead of jumping back to offset 20.

9. executePrepared collapses every SDK error into a single message string

The catch now records to crashlytics with breadcrumbs (direction, fromAmount, toAmount) before returning the failed PaymentAdapterResult, so send-time failures (slippage, insufficient liquidity, network) are no longer silent.

10. Token filter silently drops unknown tokens, no telemetry

Added a small recordErrorOnce(dedupKey, error) helper to logging.ts (the existing SC observability module) that keeps a session-level dedup. isKnownPayment reports each unknown-identifier drop to crashlytics once per identifier; findUsdbToken reports a one-shot breadcrumb when the expected identifier is missing from the SDK's tokenBalances response.

Note on convert.ts exact-input algorithm

While restacking the upstream branches on top of this one, the exact-input algorithm in convert.ts was inadvertently dropped during a merge conflict and has been restored, with a small projectInputMinIntoDestination helper extracted from the previous nested ternary so the discovery margin reads cleanly. All the changes above are preserved on top.

Important

I1. detect-balance-stale flags a legitimately-zero wallet as stale forever

N/A on the merged stack. app/self-custodial/providers/detect-balance-stale.ts, the isBalanceStale state, and the SelfCustodialBalance.syncFailedToast translation were a temporary heuristic that was already removed in feat--spark-stable-receive-and-send-fixes (the branch right above this one). By the time #3761 reaches main through the stack, the offending code is gone — no fix needed in this PR.

I2. Locale-blind deactivation warning

The (usdBalanceAmount / 100).toFixed(2) literal is replaced by formatMoneyAmount(convertMoneyAmount(toUsdMoneyAmount(usdBalanceAmount), DisplayCurrency)), so the warning now respects the user's display currency, currency symbol, and thousands separators. The trailing standalone "USD" was removed from StableBalance.deactivateWarningBody across all 28 translations because the formatted amount now carries its own currency unit; the second "USD balance" later in the sentence (which describes the feature itself) is intentionally kept.

I3. fetchUsdbDecimals silent fallback to SparkToken.DefaultDecimals

fetchUsdbDecimals now records to crashlytics (deduped per session via the same recordErrorOnce helper) whenever it has to fall back to SparkToken.DefaultDecimals, regardless of whether the cause is the token being absent or the token lacking decimals metadata.

I4. is-online.ts swallows every SDK error to a sentinel without crashlytics().recordError(err)

Both getServiceStatus and getOnlineState now route their catch through recordErrorOnce (deduped under the spark-status-fetch-failed key) before returning the sentinel, so the underlying SDK error reaches crashlytics without spamming on each 10s poll tick.

I5. refreshStableBalanceActive swallows error with only a debug log

Added crashlytics().recordError(...) to the catch in use-sdk-lifecycle.ts alongside the existing debug log.

I6. 10s polling has no back-off, no error recording

The setInterval poll now invokes refreshWallets().catch(...) so any rejection that surfaces above runOnce's internal try/catch is recorded to crashlytics with a clear "Polling refresh failed" message instead of being silently dropped by the unhandled-promise default.

I7. AppState "active" listener fires refreshWallets() with no .catch guard

Same fix as I6 applied to the AppState "active" listener.

I8. use-stable-balance-first-time re-shows modal on every launch if AsyncStorage.setItem fails

N/A on the merged stack. The whole "first-time Convert" explainer was removed in feat--self-custodial-launch-polish: app/hooks/use-stable-balance-first-time.ts, the StableBalanceFirstTimeModal component (app/components/stable-balance-first-time-modal/), and the showStableBalanceFirstTimeModal render path in conversion-details-screen.tsx are all gone by the time #3761 lands on main. No fix needed in this PR.

I9. createConvert is dead code

Removed the unused createConvert adapter and its wiring in usePayments, and dropped the export from bridge/index.ts. Only the actually-used createGetConversionQuote → quote.execute() path remains.

I10. formatUsdFromBaseUnits hard-codes $ prefix and decimal point

Replaced with a structured feeAmount: MoneyAmount<Usd> on ConvertQuote. Consumers (use-conversion-quote, the toggle quote hook) now format the fee through useDisplayCurrency().formatMoneyAmount, so the locale-bound "$X.YY" literal is gone.

I11. Disconnect race: connectAndListen returns the SDK immediately

Added a mounted check after the await addSdkEventListener(...) step in connectAndListen so that an unmount happening during that await aborts the rest of the connection setup (skipping refreshWallets() and the getUserSettings call) instead of running them against an SDK that's about to be disconnected.

Test coverage

Tier 1 (production code with no test file)

  • app/utils/amounts.ts (new helpers): Extended __tests__/utils/amounts.spec.ts with full coverage of tokenBaseUnitsToCentsExact, the existing rounding helper, the new tokenBaseUnitsToCentsCeil, and centsToTokenBaseUnits, including the round-trip from cents to base units and back.
  • app/self-custodial/providers/use-sdk-lifecycle.ts: A standalone spec for the file is held back as part of the structural cleanup (a 250-LOC effect-heavy hook deserves its own setup harness). The two paths the feedback flagged as uncovered are now exercised through wallet-provider.spec.tsx: I11 (disconnect race) has a dedicated test that resolves initSdk after unmount and asserts the SDK is disconnected and the listener never registered; I6 (polling refresh failure) is a defensive .catch with no observable rejection path today (the inner runOnce already swallows everything), so a regression test would only cover code that cannot fire.
  • app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx: Added __tests__/screens/stable-balance-confirm-modal.spec.tsx covering rendered title/body/fee, deactivation warning, primary/secondary button callbacks, and the hasError and isLoading gates that disable the primary button.
  • app/components/status-pill/status-pill.tsx: Added __tests__/components/status-pill.spec.tsx covering label rendering, every StatusPillVariant, the testID prop, and the ghost variant (which intentionally omits the testID and hides itself from accessibility).

Tier 2 (tests that pass while a Critical bug is alive)

  • __tests__/screens/conversion-flow/conversion-confirmation.spec.tsx: Added a self-custodial submit describe with dynamic mocks for useActiveWallet and useNonCustodialConversion. Tests cover the SC fee row rendering feeText, the swipe path invoking execute() and dispatching conversionSuccess on success, and the failure path leaving navigation untouched. The custodial mutations are asserted to never fire on the SC path.
  • __tests__/screens/conversion-details-screen.spec.tsx: Converted useActiveWallet and useNonCustodialConversionLimits to dynamic mocks and added a new Self-custodial conversion limits gating describe that exercises the SC branch the existing 60-scenario table never reached. Tests cover the limits-error path (Review disabled + error message rendered) and a positive minFromAmount keeping Review disabled with no amount entered.
  • __tests__/screens/conversion-details-stable-balance-modal.spec.tsx: N/A on the merged stack. The spec exercises useStableBalanceFirstTime and the StableBalanceFirstTimeModal component, both of which were removed in feat--self-custodial-launch-polish (see I8). Extending it would only test code that no longer exists by the time feat(spark): add Stable Balance and BTC - USD conversion flow #3761 lands on main. The Critical Fix crash on slow or no connection #3 / switch from mst-gql to apollo for better network/request management #5 paths the feedback asks this spec to cover are already verified in the SC Self-custodial conversion limits gating describe added to conversion-details-screen.spec.tsx.
  • __tests__/screens/stable-balance-settings-screen.spec.tsx: Added the missing failure-path coverage. New tests assert the activate-then-refresh order via mock.invocationCallOrder, and that activation/deactivation rejections record to crashlytics, surface the error toast, and skip both refresh calls.
  • __tests__/self-custodial/providers/wallet-snapshot.spec.ts: Added a regression test asserting that an unknown token-method payment is dropped and reported to crashlytics exactly once (deduped) per identifier within a session. Added pagination tests for Critical add bitcoin: and lightning: as compatible link #8: snapshot fetches a single page by default, re-fetches every page up to a target raw count, and stops paginating early when the SDK returns fewer than a full page. Added the second-loadMore raw-count vs filtered-count assertion: a caller that advances the cursor by rawCount requests the next SDK page at offset 20, not at the filtered-count offset.
  • __tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts: Added a snapshot test (a price-tick re-render with a refreshed convertMoneyAmount does not trigger a second getConversionQuote) and a re-quote on moneyAmount change test (execute() runs the latest quote, never the stale one).

Tier 3 (Critical bugs with no test coverage anywhere)

  • testPR CI #1: Covered in stable-balance-settings-screen.spec.tsx: activation/deactivation rejection paths assert mockRecordError and mockToastShow are called, that mockRefresh and mockRefreshStableBalanceActive are skipped, and a separate test asserts the success-path call order via mock.invocationCallOrder.
  • show bitcoin address in sent onchain transaction #2: Covered in bridge/convert.spec.ts: each prepare-throw path (LimitsUnavailable, BelowMinimum, network) now asserts that createGetConversionQuote rejects with the typed error and that crashlytics records it.
  • Fix crash on slow or no connection #3: Covered in conversion-details-screen.spec.tsx: with isSelfCustodial: true and the limits hook returning { limits: null, error }, the test asserts the Next button is disabled and the unavailable message is rendered. A second test asserts that with valid limits but no amount entered, Next remains disabled by the existing validity gate.
  • MISSING_INSTANCEID_SERVICE issue #4: Covered in use-non-custodial-conversion.spec.ts (snapshot test: a price-tick re-render does not trigger a second getConversionQuote; latest-quote test: when moneyAmount changes the snapshot resets and execute() runs the new quote, never the stale one).
  • switch from mst-gql to apollo for better network/request management #5: Covered in __tests__/utils/amounts.spec.ts (ceil rounds 1_000_001 base units up to 101¢, never down) and in __tests__/self-custodial/bridge/limits.spec.ts (the ceil semantics propagate end-to-end through fetchConversionLimits).
  • add a "do you like the app? you can review" #6: Covered in __tests__/self-custodial/mappers/transaction-mapper.spec.ts: a Token-method payment with fees: 1_500_000 (6-decimal base units) now produces a fee of 150 cents tagged as USD; a BTC payment keeps the fee in sats and tagged as BTC.
  • Qrcode scanning of username doesn’t work for coldstart #7: Covered at three layers. __tests__/self-custodial/config.spec.ts asserts requireSparkTokenIdentifier() throws when SPARK_TOKEN_IDENTIFIER is empty. bridge/convert.spec.ts asserts createGetConversionQuote rejects with the configuration error and never reaches prepareSendPayment/sendPayment. bridge/limits.spec.ts asserts fetchConversionLimits rejects with the same error and never calls the SDK.
  • add bitcoin: and lightning: as compatible link #8: Covered in wallet-snapshot.spec.ts (snapshot pagination + raw-count vs filtered-count) and in wallet-provider.spec.tsx (a Synced SDK event after a loadMore triggers a refresh that calls getSelfCustodialWalletSnapshot with the advanced cursor — 40 — instead of resetting to 0).
  • non scannable QR code #9: Covered in bridge/convert.spec.ts: the executePrepared failure path asserts recordError is called and the failed result carries the SDK message.
  • make it possible to try to copy graphql error from RN for easier support #10: Covered in __tests__/self-custodial/providers/wallet-snapshot.spec.ts (unknown-token drop + deduped crashlytics) and in __tests__/self-custodial/bridge/token-balance.spec.ts (findUsdbToken records once per missing-identifier and fetchUsdbDecimals records once when decimals metadata is absent).

@esaugomez31 esaugomez31 requested a review from grimen May 9, 2026 01:36
… spark status errors, and guard against unmount mid-init
…ote (bridge already records with breadcrumbs)
@grimen grimen force-pushed the graphite-base/3761 branch from f56c1c2 to 2f37ef5 Compare May 20, 2026 12:09
@grimen grimen force-pushed the feat--spark-stable-balance-and-usd-conversion branch from 10a742a to 1904857 Compare May 20, 2026 12:09
@graphite-app graphite-app Bot changed the base branch from graphite-base/3761 to main May 20, 2026 12:10
…ument use-sdk-lifecycle race-condition disables
@grimen grimen force-pushed the feat--spark-stable-balance-and-usd-conversion branch from 1904857 to 1af9609 Compare May 20, 2026 12:10
@grimen grimen merged commit 71ae313 into main May 20, 2026
5 of 9 checks passed
@grimen grimen deleted the feat--spark-stable-balance-and-usd-conversion branch May 20, 2026 12:11
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.

2 participants