Skip to content

feat(spark): UI/UX launch polish across settings and flows#3769

Merged
grimen merged 144 commits into
mainfrom
feat--self-custodial-launch-polish
May 20, 2026
Merged

feat(spark): UI/UX launch polish across settings and flows#3769
grimen merged 144 commits into
mainfrom
feat--self-custodial-launch-polish

Conversation

@esaugomez31

@esaugomez31 esaugomez31 commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

UI/UX launch polish for the self-custodial (Spark) experience: stable-balance gating, settings reorganization, account redesign, delete flow, default-account preference, receive flow QR by default, single-screen backup phrase, dynamic success messaging, stablecoins-aware Dollar modal, removal of the legacy "You Are in Control" trust-model modal, country-aware account-type gating, a comprehensive multi-account stack with auto-convert UX polish, and SDK amount-adjust block on the convert-details screen.

Primary ticket: blinkbitcoin/blink-wip#692 — feat(spark/ui): Review and fixes. Also addresses blinkbitcoin/blink-wip#664 (Stablesats/Dollar popup modal — non-custodial variant).

Ticket blinkbitcoin/blink-wip#692 — Tasks

  • 1. Hide Stable Balance Setting (Feature Flag)stableBalanceEnabled remote flag (default false) gates isStableBalanceActive. Stable-balance settings hidden when off.
  • 2. Change Stable Balance Default Flag to False — new wallets no longer auto-activate stable balance on creation.
  • 3. Move "Backup Phrase" to "Security & Privacy" — relocated from Advanced.
  • 4. Hide "Advanced" Section for Non-Custodial Users — Advanced group hidden when active wallet is SC; empty SettingsGroup no longer renders.
  • 5. Remove Modal with Alert in Transfer Flow (First Time Only) — first-time convert modal dropped; unused i18n removed.
  • 6. Remove orange text from Review Transfer screen — the silent SDK amount-adjust path is gone; the convert-details screen now blocks the Review-transfer button when the SDK would auto-adjust the requested amount and surfaces an inline error explaining the user must use the full balance. The confirm screen no longer shows orange adjustment text.
  • 7. Hide "Bitcoin Deposit Address" from "Ways to Get Paid" — row hidden for all users; unused i18n removed.
  • 8. Add "Delete Account and Data" Button in Your Account Section — Account screen bifurcated for SC. SC layout matches the Figma: avatar + LN address + "Non-custodial" subtitle, then Wallet identifier / Lightning address / Backup status boxed read-only fields (shared with the AccountInformation sub-screen via SelfCustodialAccountFields), then the danger zone with the delete flow (balance check + warning screen + SDK storage wipe + activeAccountId clear). Custodial layout untouched.
  • 9. Remove "You Are in Control" Modal + Add Test CoverageTrustModelModal, useTrustModelSeen hook, trust-model-screen re-export, and the four BackupNudge.trustModel* i18n keys deleted across 28 locales. Home-screen integration removed; regression test asserts the modal no longer renders even for SC users with positive balance (the previous trigger condition).
  • 10. Backup Phrase — Show All Mnemonics on One Screen — new SparkViewBackupPhraseScreen renders all 12 words in a 2-column grid. Success screen accepts a dynamic message route param ("Your backup phrase is correct" for the test-backup flow). Onboarding 2-step flow untouched.
  • 11. IP-Based Country MappinguseAccountTypeOptions hides the Custodial card when the detected country isn't in CUSTODIAL_ALLOWED_COUNTRIES, and the SC card when nonCustodialEnabled is off. Loading skeleton while detection runs; banner explains the state when SC is temporarily disabled.
  • 12. Add LN Address QR by Default (Custodial Parity) — Receive defaults to PayCode/LN-address QR when on Bitcoin mode and an LN address exists, mirroring custodial behavior.

Ticket blinkbitcoin/blink-wip#664 — Stablesats/Dollar popup modal

  • Non-custodial variantStableSatsModal accepts a variant prop. SC variant renders close-X, Dollar pill, centered title, centered body explaining stablecoins, primary "Back home", secondary "Learn more" → https://www.blink.sv/en/dollar-account. Custodial variant unchanged.

Additional work (beyond ticket scope)

Account & default preferences

  • New persistent state selfCustodialDefaultWalletCurrency (BTC | USD), scoped per account; settings picker; honored in send and receive flows; currency list always loaded so SC users can pick.
  • Multi-account profile row shows the user's Lightning address as the title for SC accounts.
  • Account Information and Transaction Limits rows removed from non-custodial settings (data already lives on the new SC Account screen; Transaction Limits is custodial-only).
  • "Dollar (Stablecoin)" label on the SC Default account picker via a dedicated dollarStablecoin i18n key. Custodial keeps "Dollar (Stablesats)".

Multi-account preferences (display currency + language)

  • When the user has multiple self-custodial accounts, display currency and language are now persisted per account. Switching between SC accounts reflects both preferences instantly across Home, Settings, and the send/transfer flows — previously the change either lagged up to 5 minutes (waiting on the price poll) or required a full app reload.
  • Persistent state schema bumped from v10 to v11 with two additive optional fields (selfCustodialDisplayCurrencyByAccountId, selfCustodialLanguageByAccountId); migration is identity.
  • New useEffective* adapter hooks (useEffectiveDisplayCurrency, useEffectiveLanguage) unify custodial (GraphQL-backed) and self-custodial (persistent-state-backed) sources behind a single interface. Screens, LanguageSync, and usePriceConversion consume the adapter and never bifurcate by account type.
  • usePriceConversion is fixed at the root: displayCurrency now flows from the adapter rather than from Apollo's cached denominatorCurrency (which kept showing the previous account's currency on switch). A guard discards a cached price whose denominator disagrees with the active preference, and fetchPolicy: "cache-and-network" on both the authed and unauthed price queries forces an immediate refresh on account switches (custodial→custodial included).
  • Conversion fee no longer breaks for non-USD display currencies: ConvertQuote.feeAmount is typed as UsdMoneyAmount (the SDK returns the USDB swap fee in cents, pegged 1:1 to USD) and the conversion-flow hook converts it via convertMoneyAmount before formatting. Previously the fee row rendered "Currency issue. Refresh needed" for EUR, JPY, and any other non-USD locale.

Self-custodial contact dedup

  • bridge/addContact is now an upsert: when a contact with the same paymentIdentifier already exists (matched case-insensitive and trimmed via the existing normalizeString helper), it calls sdk.updateContact with the existing record's data — preserving any user-edited name (so a contact renamed "Mom" isn't clobbered back to its LN address) and bumping updatedAt for future "recent contacts" sorting. When no match exists, it falls through to sdk.addContact. Fixes the bug where every payment to the same LN address created another duplicate contact.

Account-type labels i18n alignment

  • The Custodial / Non-custodial labels shown under each account's title in the multi-account switcher were inconsistent across locales: the English source said Custodial / Non-custodial, but all 28 translation files said Blink / Spark (a brand-name swap, not a translation). All 28 locales were aligned to the English technical concept, with proper grammatical agreement per language (es: Custodiada / No custodiada; pt: Custodiada / Não custodiada; it: Custodito / Non custodito; de: Verwahrt / Selbstverwahrt; fr: Sous garde / Sans garde; and so on).

Convert flow

  • The convert-details screen runs a pre-flight SDK quote and blocks the Review-transfer button when the SDK would auto-adjust the requested amount (FlooredToMin or IncreasedToAvoidDust). For dust adjustments the block is suppressed when the user already requested the full balance, since asymmetric conversion math can flag it spuriously at 100%.
  • Inline error message ("Full balance has to be transferred.") shown inside the input area; the legacy orange "Amount increased…" text is removed from the Review Transfer screen.
  • Quote params passed as primitives so the memo stays referentially stable across renders, avoiding a setState/re-render loop in useConversionQuote.

Auto-convert UX

  • Inline converting state on the receive screen: spinner + "Please wait until the conversion is done" copy while BTC→USDB auto-convert runs. Per-invoice status tracked through a context provider so the screen reflects progress without polling.
  • Parallel pre-convert and post-send wallet syncs (~19s → ~9s end-to-end wait).

Multi-account hardening

  • Account registry made KeyStore-aware; stale-closure fix in setActiveAccountId.
  • Custodial token dropped from GraphQL requests when the active account is SC.
  • BackendFeatureGate returns blocked when the active account is SC, so feature gating respects the active account.
  • Backup nudge dismissal scoped per active account.
  • Restored wallets are marked as backed up using account-aware state.
  • Banner-triggered backup routes to the success screen instead of the test flow.
  • Dedup guard prevents duplicate wallet creation from concurrent useEffect runs.
  • create-wallet no longer clobbers the previously-active account's backup state.
  • Removing a self-custodial profile from the multi-account list now probes the account's balance via a short-lived SDK using its stored mnemonic (no active-account switch). When funds are present, the existing "has funds" warning modal opens instead of the confirm-removal modal. The X icon is replaced by a spinner while the probe runs.
  • Cloud backups now support multiple self-custodial wallets per Google Drive account. Each backup file is named blink-spark-backup-{network}-{walletIdentifier}.json so different wallets no longer overwrite each other. The payload now embeds walletIdentifier and (when set) lightningAddress as plaintext metadata; only the mnemonic stays encrypted. On restore, the app lists all backups by prefix, parses metadata without decrypting, and shows a picker when more than one is present (LN address as the row title with the identifier as subtitle when set, or the identifier alone). A single backup auto-restores; an encrypted backup proceeds straight to the password step.

Settings & UI polish for SC

  • POS and Printable static QR rows hidden in SC mode.
  • Login methods section hidden in SC.
  • Blink User fallback and empty Ways-to-get-paid groups hidden in SC.
  • Loading skeleton kept until the initial wallet sync completes.
  • Non-custodial subtitle restored in the multi-account profile row.
  • Pull-to-refresh on the Account screen no longer logs out SC users.
  • Confirm-account-removal modal title centered to match the warning modal layout (replaces the previous left-aligned wrap that broke the title visually).
  • Settings → Backup phrase now routes through the 3 security checks (existing onboarding gate) before showing the 12 words. Per designer feedback: the user must always acknowledge the same three security disclaimers before the phrase is revealed, whether they enter from onboarding or from Settings. The check screen is shared via an extracted BackupPhraseSecurityChecks pure component; the two flows (onboarding vs Settings) keep their distinct destinations and the onboarding-specific migration checkpoint stays owned by the onboarding screen only.

Send flow

  • Self-custodial contacts surfaced in destination search.
  • Multi-account-aware delete-account confirm modal reused across flows; balance check warns before deleting an account that still has funds.

Performance

  • Transaction history rendering moved off the UI thread for SC wallets.

Post-review polish & internal cleanup

  • Scan-context adapter for destination resolution. A per-account-type adapter now resolves QR-scan inputs behind a shared interface; unblocks LN address QR scans on self-custodial and surfaces the LN identifier on the destination-details screen.
  • Module naming alignment. Dropped the redundant .types suffix from app/types modules and the -provider suffix from provider modules; expanded the SC abbreviation to the full SelfCustodial word across code and tests; stripped review-numbering references from test names and comments. Relocated use-sdk-lifecycle out of providers/ into hooks/ to match its responsibility.
  • secureStorage surface trimmed. Deprecated single-account mnemonic methods removed in favor of the per-account variants already in use; spec extended to cover biometrics, PIN, PIN-attempts, and session-profile paths.
  • Migration-checkpoint error reporting moved out of the util and into the caller hook so the storage util stays side-effect free.
  • Patch and dead-code cleanup. Dropped the unused react-native-credentials-manager patch and other helpers/hooks with no remaining production callers.
  • Import organization. One-hop same-module imports now use relative paths (../sibling); cross-module imports continue to use the @app/* alias so module boundaries stay visible at a glance.

Out of scope

  • 13. Add custom LN Address (bonus) — listed as a bonus in blinkbitcoin/blink-wip#692, not part of this PR's scope.

Test plan

  • Enable / disable stableBalanceEnabled remotely; confirm stable-balance settings appear / disappear and that toggling off mid-session re-aligns Receive to Dollar.
  • Create a new SC wallet; confirm stable balance is not auto-activated.
  • Verify Backup Phrase row is in Security & Privacy and that Advanced is hidden for SC.
  • Open the Account screen as a SC user: confirm avatar + LN address + "Non-custodial" header, the three boxed fields (Wallet identifier, Lightning address, Backup status), copy buttons, and the danger zone with delete flow. With a custodial active account the layout stays as before.
  • Trigger Delete Account: with balance (blocked), without balance (warning → SDK storage wipe + activeAccountId cleared).
  • Set Default account = USD/BTC; confirm Send and Receive default to that currency.
  • On the SC Default account / Receive currency screen, the Dollar option label reads "Dollar (Stablecoin)"; on custodial it still reads "Dollar (Stablesats)".
  • In Receive (BTC mode, SC), the LN address QR is shown by default; toggling currency switches behavior.
  • Tap Settings → Backup phrase → confirm the 3 security checks appear first → check all three → tap Continue → see all 12 words on one screen → tap "Test your backup" → confirm the dynamic success message renders. Confirm that pressing the row never jumps straight to the phrase without the checks.
  • Tap the ? icon next to the Dollar pill on Home: in custodial → Stablesats modal; in SC → stablecoins modal with "Learn more" linking to blink.sv/en/dollar-account.
  • Open Home as a self-custodial user with positive balance; confirm the legacy "You Are in Control" trust-model modal never appears.
  • On the account-type selection screen, confirm the Custodial card hides when the detected country is restricted, and the SC card hides when nonCustodialEnabled is off (with the "temporarily disabled" banner).
  • Generate a USDB Lightning invoice and have it paid; confirm the spinner + "Please wait until the conversion is done" copy shows during the convert, then transitions to the paid state.
  • Switch between multiple accounts; confirm backup-nudge dismissals, default-currency preferences, and backed-up state stay scoped per account.
  • On the convert-details screen, type an amount that the SDK would auto-adjust (below minimum, or one that leaves dust); confirm the Review-transfer button is disabled and the inline error appears. Tap the 100% selector and confirm the block clears for the dust case.
  • On the multi-account list with a self-custodial profile that has a balance, tap the X button; confirm a spinner appears on the row while the balance is fetched, the warning modal opens with the correct balance, and no funds-less profile triggers the warning.
  • Back up wallet A, switch to wallet B and back it up; confirm Drive holds both files (blink-spark-backup-{network}-{pubkeyA}.json + {pubkeyB}.json) and neither overwrites the other.
  • Restore on a fresh device with multiple Drive backups; confirm the picker shows each entry with its LN address (or wallet identifier when no LN address) and that selecting one proceeds to either auto-restore or the password step depending on whether it was encrypted.
  • Restore with only one backup in Drive; confirm the picker is skipped and the flow goes straight to restore (or password if encrypted).
  • Restore with zero backups; confirm the existing "no backup found" UI still appears.
  • Open the confirm-removal modal (delete account from danger zone or from multi-account list with zero balance); confirm the title "Confirm account removal" is centered.
  • With two SC accounts holding different display currencies (e.g. EUR and USD), switch between them; Home, the Settings → Display currency row, and amount inputs must show the correct currency instantly.
  • With two SC accounts holding different languages, switch between them; the app locale must change immediately.
  • With two custodial accounts holding different display currencies, switch between them; the price must refresh instantly (previously it lagged the 5-minute poll).
  • On the "Confirm transfer" screen of the Convert flow with display currency ≠ USD (e.g. EUR), confirm the "Conversion fee" row shows the value converted to the active currency, not "Currency issue. Refresh needed".
  • Pay the same LN address three times in a row from a self-custodial account; open People → All Contacts and confirm a single entry appears (not three duplicates).
  • Rename a self-custodial contact to a custom alias, then make another payment to it; confirm the custom alias is preserved.
  • Open the multi-account switcher in Spanish, French, Italian, Portuguese, and German; confirm the label below each account name shows the correct translated equivalent of "Custodial" / "Non-custodial" (not "Blink" / "Spark").
  • Verify all 28 locales render the new copy without missing keys.

@esaugomez31 esaugomez31 changed the title feat(self-custodial): stop auto-activating stable balance on new wallet creation feat(self-custodial): launch polish Apr 29, 2026
@esaugomez31 esaugomez31 self-assigned this Apr 29, 2026
@esaugomez31 esaugomez31 changed the title feat(self-custodial): launch polish feat(self-custodial): UI/UX launch polish across settings and flows Apr 29, 2026
@designsats

designsats commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Additional small changes:

@esaugomez31

  1. Change "Stablesats" for "Stablecoin" on this screen
image
  1. Remove "Transaction limits" and "Account information" from non-custodial mode.
image
  1. Delete account button has wrong text,. Should be "Delete account and data"

  2. Im still able to delete a wallet with balance. Shouldnt be possible.

  3. "Add LN Address QR by Default (Custodial Parity)" - I dont see this done. I cant see LN address in "Ways to get paid" or under "Account"

@designsats

Copy link
Copy Markdown
Contributor

When restoring, there is no loading state for balance, instead I see $0.00 which is quite bad UX since I expect to see the full balance.

@esaugomez31

zero-balance-error.webm

@designsats designsats 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.

lgtm

@esaugomez31 esaugomez31 force-pushed the feat--self-custodial-launch-polish branch from 18d14a7 to 17e5ae9 Compare April 30, 2026 07:05
@esaugomez31 esaugomez31 marked this pull request as ready for review April 30, 2026 08:02
@designsats

designsats commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@esaugomez31

Reviewing on android emulator shows these still dont work:

  • Transfer alert "Full balance has to be transferred"
  • everything related to LN address
  • display currency
  • language
  • wait with checkbox on receive screen until conversion successfuly happens and only update balance once it does complete
  • bulletin briefly flickers when creating new wallet
  • bulletin shows on other accounts if a new trial account was created, these bulletins have to be separated by account
  • lets drop the symbols requirement in cloud backup additional password, for the future: reconsider making password mandatory or using passkey
  • no new transaction notification (bonus)
  • lets write "Anon" instead of "Anonymous user"

@esaugomez31 esaugomez31 force-pushed the feat--self-custodial-launch-polish branch from 9c9bf69 to f8fc5f1 Compare May 1, 2026 01:59
@esaugomez31 esaugomez31 requested a review from grimen May 4, 2026 16:56
@designsats

designsats commented May 5, 2026

Copy link
Copy Markdown
Contributor

@esaugomez31
Found a bug. When user wants to login into custodial account, they cant unless they are in permited country.
Login (not Signup) has to be allowed.

Screenshot_1777989003

@esaugomez31 esaugomez31 force-pushed the feat--self-custodial-launch-polish branch from b958e5f to d93e65a Compare May 6, 2026 02:05
@esaugomez31

Copy link
Copy Markdown
Collaborator Author

@esaugomez31 Found a bug. When user wants to login into custodial account, they cant unless they are in permited country. Login (not Signup) has to be allowed.
Screenshot_1777989003

Already fixed!

@esaugomez31 esaugomez31 force-pushed the feat--spark-rollout-and-hardening branch from 7bb7129 to d464d96 Compare May 7, 2026 15:54
@esaugomez31 esaugomez31 force-pushed the feat--self-custodial-launch-polish branch from 16e3919 to 5c7a7ea 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

The structural concerns flagged in the #3758 review continue to compound through this PR — same deferral applies. Findings layered on top of this PR:

  • SOLID violationsuseEffective{DisplayCurrency,Language} return mode-dependent shapes (the loading flag is meaningful for custodial, hardcoded false for SC); useEffectiveAuthToken returns "" instead of a typed Token | null; BackendFeatureGate now branches on activeAccount.type so the gate logic mode-leaks; useNonCustodialConversionGuard and useNonCustodialConversion both subscribe to useConversionQuote independently — duplicated quote-running logic across two consumers; useAccountTypeOptions mixes country detection, feature-flag reading, restore-vs-create mode, and UI-state into one hook.
  • Incomplete adapter pattern — the useEffective* ports are well-shaped, but the rest of the codebase keeps bifurcating at call sites. selfCustodialDefaultWalletCurrency has no useEffective* equivalent, so use-receive-asset-mode.ts reads persistent state directly. Probe vs. live-wallets logic is split by type at the call site (self-custodial-profile-row.tsx) instead of unified behind a getWalletsForAccount(accountId) port. Settings, home, conversion screens still branch on isSelfCustodial everywhere — the adapter wins haven't propagated.
  • High cyclomatic complexity in the screen layerhome-screen.tsx now references isSelfCustodial/activeIsCustodial in 17 places (was 15 at the time of #3758's review); contacts-card.tsx 13; send-bitcoin-destination-screen.tsx 12; receive-screen.tsx 11; transaction-history-screen.tsx 10; conversion-details-screen.tsx and conversion-confirmation-screen.tsx 9 each. 53 new mode-branching lines added in this PR alone. The bifurcation is expanding faster than the adapter pattern is absorbing it.
  • Code smells — error reporting is now four-way inconsistent: reportError, crashlytics().recordError, crashlytics().log (breadcrumb only), and bare console.error all appear in this PR's added code (see Critical #3, I6, I9). Empty .catch(() => {}) blocks remain in wallet-provider.tsx, use-payment-request.ts, and use-sdk-lifecycle.ts. The AutoConvertStatusProvider rebuilds a fresh Map per update (O(n) insert) and never prunes (I11). findContactByPaymentIdentifier is an O(n) linear scan against a hardcoded 100-item page (I13). String-matched error classification (if (err.message.includes(...))) flagged in #3758 still appears in deposit/onchain paths.
  • Naming smells49 files in this PR carry selfCustodial/SelfCustodial in their path; the mode prefix is now baked into ~36 new exports (selfCustodialDefaultWalletCurrencyByAccountId, selfCustodialDisplayCurrencyByAccountId, selfCustodialLanguageByAccountId, getSelfCustodialDefaultCurrency, withSelfCustodialDefaultCurrency, probeSelfCustodialAccountWallets, SelfCustodialAccountFields, …). The Effective prefix is imprecise — useEffectiveDisplayCurrency is just the resolved active value; if the bifurcating versions were renamed/removed it would be useDisplayCurrency. Spark brand-name still leaks into types (SparkSDK*, SparkViewBackupPhraseScreen). And the same concept is named four different ways across the codebase: Custodial / Non-custodial / SelfCustodial / SC.

Why this is not in scope for this PR: the cleanup ticket from #3758's review already covers the adapter-shape unification, mode-leakage cleanup, and rename passes. Doing them here would (a) require a multi-day restack across the launch-polish stack right before launch, (b) overlap with work that has to land against main post-stack anyway, and (c) duplicate effort already scoped on the cleanup ticket. #3768's production rollout was already gated on the cleanup ticket reaching the adapter-pattern-unification milestone — this PR doesn't change that gating.

Decision: this review covers correctness only. The structural / SOLID / complexity / naming items above are not raised as blockers here — they're recorded for the existing cleanup ticket so the deferred debt remains visible and counted. New items surfaced in this PR (mainly the 17-mode home-screen.tsx, the 53 added mode-branches, and the four-way error-reporting inconsistency) should be appended to that ticket rather than addressed in-stack.


Real strengths first: the useEffective{DisplayCurrency,Language} adapter pattern is the right shape — a port hiding the custodial-GraphQL vs SC-persistent-state divergence behind one interface, with a clean useEffectiveAuthToken test that pins the architectural guarantee (token dropped even when KeyStore still holds it). The v10→v11 migration is correctly identity-preserving (additive optional fields). The cache-and-network + denominator-guard fix in usePriceConversion solves a real bug. useAutoConvertListener.spec.ts is a comprehensive scenario matrix (multi-invoice, dedup-on-rerender, replay vs live-trigger, attempt cap). The probe-based delete flow design — checking balance via short-lived SDK without switching active accounts — is the right idea.

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. Probe failure routes user to delete-confirm modal → fund loss

app/self-custodial/probe-account-wallets.ts:13-26 + app/screens/settings-screen/account/multi-account/self-custodial-profile-row.tsx:82-84

probeSelfCustodialAccountWallets throws when initSdk or getSelfCustodialWalletSnapshot throws (storage corrupt, network blip, mnemonic-decrypt error, concurrent SDK contention). The caller catches and opens the plain delete confirm:

} catch (err) {
  reportError("self-custodial-profile-row probeBalance", err)
  setConfirmVisible(true)   // user sees "are you sure you want to delete?"
}

User taps yes → mnemonic wiped, balance still on Spark. The has-funds warning was the only barrier and it was silently skipped.

Compounding: when the row is the active account, the probe's initSdk(mnemonic, storageDirFor(accountId)) opens a second SDK against the same SQLite storage dir while useSdkLifecycle still holds the primary one — SQLite "database is locked", stale balance reads, mnemonic-mismatch panics.

Fix: discriminated result { status: "ok", wallets } | { status: "probe-failed" }. On failure, surface a "couldn't verify balance" toast and do not open the confirm modal. Short-circuit when accountId === activeAccount?.id — read live wallets from useSelfCustodialWallet() instead of probing.

2. iOS Keychain backup: only the most-recently saved wallet is recoverable

app/hooks/use-credential-backup.ts:87-99

const credentials = await Keychain.getInternetCredentials(BLINK_DOMAIN)

react-native-keychain's getInternetCredentials(server) returns one entry — iOS Keychain Internet Password keys on (server, account) but the SecItem query without kSecAttrAccount returns the first match the OS chooses. Multi-account password-manager backup on iOS therefore silently loses access to one wallet on restore.

Fix: disable Keychain backup as a multi-account method on iOS, OR enumerate via raw SecItemCopyMatching with kSecMatchLimitAll and surface a picker.

3. Migration failure silently resets every per-account preference

app/store/persistent-state/state-migrations.ts:204-209

} catch (err) {
  console.error({ err }, "error migrating persistent state")
}
return defaultPersistentState

console.error only — no Sentry/Crashlytics. A real-device migration throw silently logs the user out, resets selfCustodialDisplayCurrencyByAccountId, selfCustodialLanguageByAccountId, activeAccountId. No toast, no telemetry, no recovery.

Fix: crashlytics().recordError(err). Quarantine the raw input under a backup key before returning defaults so support can recover the state.

4. AsyncStorage transient failure → all SC accounts vanish from the registry

app/self-custodial/storage/account-index.ts:26-47

readIndex does catch { return [] } — any AsyncStorage hiccup makes the registry think the user has no SC accounts. The active wallet disappears from the home screen even though the mnemonic is still in keychain. Combined with #5 below, the user lands on USD pricing for a wallet that's actually still on Spark.

Fix: crashlytics().recordError on the catch; distinguish "empty index" from "could not read index" so callers can retry rather than treat it as "user has no accounts."

5. addContact upsert silently discards user-supplied name on duplicate

app/self-custodial/bridge/contacts.ts:31-42

return sdk.updateContact({
  id: existing.id,
  name: existing.name,             // <- always existing, ignores request.name
  paymentIdentifier: existing.paymentIdentifier,
})

The "preserve user-edited contact name" intent is right when the user has previously renamed the contact — but the same code drops request.name even when the user just typed a new one. The PR description implies the request name takes effect on first save and is preserved on subsequent payments; the code preserves the existing name unconditionally. The caller use-save-lnaddress-contact.ts:56 only catches exceptions, so the silent no-op is invisible.

Fix: decide explicitly. Either pass request.name through, or return { deduped: true, existingName } so the UI can surface "already saved as X — keep or rename?". Today's behavior is documentation/implementation skew.

6. Convert guard ignores quote-error and is disabled during SC SDK boot

app/screens/conversion-flow/hooks/use-non-custodial-conversion-guard.ts:24-35, 37-61 + app/screens/conversion-flow/hooks/use-conversion-quote.ts

useConversionQuote records errors to crashlytics and sets { status: QuoteStatus.Error, quote: null }. The guard destructures only { isQuoting, amountAdjustment } — when the quote fails, amountAdjustment === nullblockingReason === null → "no problem, allow review." User taps Review with no quote.

Adjacent: enabled: isSelfCustodial from useActiveWallet flips to false while the SC SDK is in Unavailable — during boot the guard is silently disabled. Same outcome, different trigger.

Fix: plumb hasQuoteError out of useConversionQuote into the guard; disable Review while either hasQuoteError || isQuoting. Gate enabled on !isReady rather than isSelfCustodial so the boot window is covered.

7. Default-currency fallback leaks legacy preference into new SC accounts

app/store/persistent-state/self-custodial-default-currency.ts:13-21

return fromMap ?? state.selfCustodialDefaultWalletCurrency ?? "BTC"

A user with the legacy selfCustodialDefaultWalletCurrency = "USD" who creates a second SC wallet sees it inherit "USD". display-currency and language correctly fall back to constants — only default-currency carries the legacy field. Inconsistent with the rest of the multi-account design.

Fix: drop the legacy fallback (return fromMap ?? "BTC"), OR migrate the legacy value into the active account's slot during v10→v11 and clear it.

8. Drive API errors → user told "no backups exist"

app/utils/google-drive-client.ts (throws on !response.ok) + app/hooks/use-google-drive-backup.ts:67-89 (only console.errors) + app/screens/spark-onboarding/restore/hooks/use-cloud-restore.ts:97-105

if (files.length === 0) { setStep(CloudStep.NotFound); ... }
const result = await downloadById(files[0].id, token)
if (!result.success) { setStep(CloudStep.NotFound); ... }

Auth-expired (401) and rate-limited (429) responses are indistinguishable from "no backups." Per-file failures (line 126) silently disappear from the picker. User thinks their backup is gone.

Fix: result type {success: false, reason: 'not-found' | 'transient' | 'auth'}. Only fall to NotFound for genuine 404s. Replace console.error with crashlytics().recordError.

9. Single-file cloud restore skips metadata validation

app/screens/spark-onboarding/restore/hooks/use-cloud-restore.ts:102-110

When files.length === 1, calls proceedWithBackup(content) immediately — no parseBackupMetadata check. Multi-file path correctly validates (line 119-126). A single corrupt or legacy-format file lands the user on step: Error via parseBackupPayload throwing inside proceedWithBackup, with no path forward.

Fix: apply the same parseBackupMetadata validation to the single-file path; fall to NotFound on parse failure.

10. Decrypt failure for any reason → "wrong password"

app/screens/spark-onboarding/restore/hooks/use-cloud-restore.ts:163-169

try {
  const { mnemonic } = parseEncryptedBackupPayload(backupContent, password)
  await restore(mnemonic)
} catch {
  setPasswordError(LL.RestoreScreen.wrongPassword())
}

Corrupt ciphertext, missing salt/iv, unsupported cipher, or restore failing for unrelated reasons (network, keystore) all collapse into "wrong password." User retypes the correct password ten times and gives up.

Adjacent: parseBackupPayload (app/utils/backup-payload.ts:129) doesn't validate parsed.mnemonic is a non-empty string. A misclassified-encrypted file (metadata claims encrypted but payload's encrypted field is missing) silently restores whatever (possibly empty) string is there.

Fix: catch only the AES-GCM auth-tag-mismatch path as "wrong password"; surface other errors distinctly. In parseBackupPayload, validate mnemonic is a non-empty string and throw a typed error.

11. SDK lifecycle and auto-convert provider have zero direct test coverage

app/self-custodial/providers/use-sdk-lifecycle.ts (305 LOC, 53 changed) + app/self-custodial/providers/auto-convert-status-provider.tsx (87 LOC, new)

The SDK init/reconnect/refresh state machine for the entire SC experience and the provider that drives the receive-screen converting spinner have no test files. wallet-provider.spec.tsx mocks the lifecycle entirely. Untested behaviors include reconnect backoff escalation, account-switch disconnect ordering (must disconnectSdk(prev) before initSdk(next) — payment events route to wrong account otherwise), pendingRefreshRef re-entrancy, multi-invoice isolation in the auto-convert provider, and identity preservation on no-op updates.

Fix: test files for both. Priority: account-switch disconnect ordering and multi-invoice converting-state isolation.


Important

# Item Location
I1 useEffectiveAuthToken ignores registry — orphaned activeAccountId strips Apollo auth, combined with BackendFeatureGate blocking SC, locks user out with no signal app/graphql/use-effective-auth-token.ts:7-14
I2 Apollo client rebuild on token swap silently drops in-flight queries; no log of the rebuild app/graphql/client.tsx:93-318 + use-effective-auth-token.ts
I3 Pull-to-refresh on Account screen — no try/finally around await work; rejection leaves spinner stuck forever app/screens/settings-screen/account/account-screen.tsx:36-48
I4 usePriceConversion denominator-guard sets convertMoneyAmount to undefined for the network-fetch window after a currency change → receive screen amountInSats undefined, conversion screen canExecute: false app/hooks/use-price-conversion.ts:32-52, 96-98
I5 Logged-out custodial users see USD instead of cached preference — useEffectiveDisplayCurrency skips the query when !isAuthed and falls back to DEFAULT_DISPLAY_CURRENCY app/hooks/use-effective-display-currency.ts:31-33, 62-67
I6 wallet-provider.tsx:107, 109-111, 129-131 — three opportunistic catches with no Crashlytics breadcrumb; stale Lightning address goes unobserved app/self-custodial/providers/wallet-provider.tsx
I7 Invoice generation empty catch — opaque Error UI, no Sentry trace of why app/self-custodial/hooks/use-payment-request.ts:136-138
I8 use-create-wallet sets Error status with no toast — stuck Create button, no explanation app/screens/spark-onboarding/hooks/use-create-wallet.ts:46-49
I9 Post-connect sync failure logged as crashlytics().log (breadcrumb only); finally block forces Ready regardless → user sees stale balance app/self-custodial/providers/use-sdk-lifecycle.ts:188-195
I10 Generic restoreFailed toast — invalid-mnemonic vs network failure indistinguishable app/screens/spark-onboarding/restore/hooks/use-restore-wallet.ts:70-75
I11 AutoConvertStatusProvider.statusByInvoice is never pruned — entries leak per session, every update rebuilds a fresh Map (O(n) per insert) app/self-custodial/providers/auto-convert-status-provider.tsx:34-46
I12 useAutoConvertListener.processedPaymentIdsRef and inFlightInvoicesRef Sets leak unboundedly per session app/self-custodial/hooks/use-auto-convert-listener.ts:192-193, 248-249
I13 addContact paginates to 100 — a user with >100 contacts gets duplicates because matches past index 100 are invisible to findContactByPaymentIdentifier app/self-custodial/bridge/contacts.ts:11, 22-29
I14 disconnectSdk(sdk).catch(() => undefined) swallows failure with no log; repeated probes accumulate SDK instances holding open SQLite handles app/self-custodial/probe-account-wallets.ts:24
I15 cloud-backup overwrite confirmation doesn't show the existing backup's lightningAddress / createdAt — user can't spot a wrong-wallet overwrite app/screens/spark-onboarding/hooks/use-cloud-backup.ts:49-52 + google-drive-client.ts:91-102

Test coverage

Estimated behavioural coverage of the new SC code added in this PR: ~71%, dense on hooks + bridge + persistent-state, sparse on the two providers and the screen wiring of the converting indicator.

Tier 1 — production code with no test file

Verified against __tests__/:

File LOC Notes
app/self-custodial/providers/auto-convert-status-provider.tsx 87 (new) Drives the receive-screen converting spinner; multi-invoice isolation never exercised
app/self-custodial/providers/use-sdk-lifecycle.ts 305 (53 changed) SDK init/reconnect/refresh state machine for the entire SC experience
app/screens/conversion-flow/build-convert-params.ts 25 (new) Pure function deciding direction/from/to for the SDK call — only exercised via mocked guard tests
app/screens/settings-screen/self-custodial/account-fields.tsx 159 (new) Loading/error states for the boxed Wallet identifier / Lightning address / Backup status fields

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/receive-self-custodial.spec.tsx:256-265 Mocks QRView and drops the converting prop entirely. The PR's headline "inline converting state" feature has zero verification. grep "converting" in the spec returns no matches. wires-up of #11 (auto-convert provider)
__tests__/screens/settings-screen/delete-account-confirm-modal.spec.tsx + .../delete-account-has-funds-modal.spec.tsx Both verify modal rendering in isolation. Neither verifies the routing decision based on probe result — i.e. balance > 0 → has-funds modal, probe-failed → no modal. #1 (fund loss)
__tests__/screens/spark-onboarding/restore/hooks/use-cloud-restore.spec.ts:318-355 Multi-pick test covers plain-text picks only. Encrypted-multi-pick → password screen → wrong password → pick-another flow uncovered. #10 (decrypt → wrong password)
__tests__/store/persistent-state/state-migrations.spec.ts Doesn't test the catch-all defaultPersistentState return path — a regression that swaps recordError for console.error ships invisibly. #3
__tests__/hooks/use-effective-display-currency.spec.ts:85-89 Uses loading: false with data: undefined — conflates "loading-with-no-data" and "ready-with-no-data." A consumer flicker on account switch is invisible. I5
__tests__/hooks/use-price-conversion.spec.ts Doesn't test the cross-currency convertMoneyAmount recordError safeguard at use-price-conversion.ts:114-125 — regression dropping the guard ships invisibly. (safeguard removal)
__tests__/screens/conversion-flow/use-non-custodial-conversion-guard.spec.ts convertMoneyAmount: undefined path uncovered — guard returns { blockingReason: null }, consumer screen behavior unverified. #6, I4
__tests__/self-custodial/bridge/contacts.spec.ts listContacts rejection uncovered. Pagination-cap behavior (>100 contacts) untested — bug is encoded by omission. I13
__tests__/self-custodial/hooks/use-auto-convert-listener.spec.ts recordAutoConvertAttempt.mockRejectedValue not exercised — if persistence rejects, markConverting skipped AND markSettled bypassed → spinner stuck indefinitely. (silent stall)
__tests__/self-custodial/probe-account-wallets.spec.ts Covers the API in isolation. Doesn't exercise the consumer path that turns probe-failure into a delete-confirm modal. #1

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 Render self-custodial-profile-row, mock probeSelfCustodialAccountWallets rejecting → assert no confirm modal opens (toast/error state instead)
#2 iOS-platform integration: save 2 wallets via Keychain → assert both retrievable via readIOS (current code returns only one)
#3 Mock state read to throw → assert recordError called and quarantine key written, NOT silent fall-through to defaults
#4 Mock AsyncStorage rejecting → assert recordError called and registry distinguishes "empty" from "failed-to-load"
#5 addContact test for request.name !== existing.name → assert documented behavior (today's code silently keeps existing)
#6 Mock useConversionQuote returning { status: Error } → assert blockingReason set or new quote-failed reason emitted
#7 Persistent state with legacy selfCustodialDefaultWalletCurrency: "USD" and a fresh SC account id not in the per-account map → assert getSelfCustodialDefaultCurrency returns "BTC" not "USD"
#8 Mock listAppDataFiles rejecting with 401 → assert step === CloudStep.Error not NotFound
#9 Single corrupt file in Drive → assert step === NotFound (or distinct Error), not crash via parseBackupPayload throw
#10 Mock decrypt throwing non-auth-tag error → assert distinct error message, not wrongPassword
#11 auto-convert-status-provider: markConverting(A) then markConverting(B) → assert isolated state per key, markSettled(A) doesn't clear B. use-sdk-lifecycle: account switch from sc-1 to sc-2 → assert disconnectSdk(sdk1) called before initSdk(...sc-2-storage-dir)

Suggested order of operations

  1. Land Criticals 1–10 in this PR with regression tests (Tier 3) co-located with each fix.
  2. Add the two missing spec files (Tier 1: auto-convert-status-provider, use-sdk-lifecycle) — these are the riskiest gaps for the launch surface; ~250 LOC of test code total.
  3. Make Tier 2 tests fail first on current code: pass converting through the QRView mock, add probe→modal-routing assertions, exercise the migration catch-all, exercise convertMoneyAmount: undefined in the guard.
  4. Important items I1–I15 — most are small; I1–I4 affect core flows and should fold into the Critical commits where adjacent.
  5. Tier 1 build-convert-params.ts and account-fields.tsx specs in a follow-up before public rollout — both small (~30–50 LOC each) and worth the regression net.

@esaugomez31 esaugomez31 force-pushed the feat--spark-rollout-and-hardening branch from d464d96 to ff7941a Compare May 9, 2026 01:35
… and drop Spark prefix from ViewBackupPhrase
@grimen grimen force-pushed the feat--self-custodial-launch-polish branch from e463ddd to 2babc7d Compare May 20, 2026 12:42
@grimen grimen force-pushed the graphite-base/3769 branch from 50b91f1 to f6f1d7b Compare May 20, 2026 12:42
@graphite-app graphite-app Bot changed the base branch from graphite-base/3769 to main May 20, 2026 12:43
@grimen grimen force-pushed the feat--self-custodial-launch-polish branch from 2babc7d to a8b65ce Compare May 20, 2026 12:43
@grimen grimen self-requested a review May 20, 2026 12:44
</ContextForScreen>,
)

expect(getByText(new RegExp(formattedDate.replace(/\//g, "\\/")))).toBeTruthy()
</ContextForScreen>,
)

expect(queryByText(new RegExp(legacyFormatted.replace(/\//g, "\\/")))).toBeNull()
@grimen grimen merged commit c276cf1 into main May 20, 2026
6 of 9 checks passed
@grimen grimen deleted the feat--self-custodial-launch-polish branch May 20, 2026 12:45
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.

4 participants