Skip to content

feat(spark): self-custodial onboarding and wallet creation#3747

Merged
grimen merged 52 commits into
mainfrom
feat--spark-onboarding-wallet-creation
May 20, 2026
Merged

feat(spark): self-custodial onboarding and wallet creation#3747
grimen merged 52 commits into
mainfrom
feat--spark-onboarding-wallet-creation

Conversation

@esaugomez31

@esaugomez31 esaugomez31 commented Apr 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the self-custodial onboarding and wallet creation flow for Blink mobile, enabling new users to create a non-custodial wallet powered by Breez SDK (Spark).

What's done

SDK Integration (Story 2.1)

  • Installed @breeztech/breez-sdk-spark-react-native and react-native-fs
  • Enabled New Architecture (newArchEnabled=true) required by SDK turbo modules
  • Added mnemonic secure storage methods to KeyStoreWrapper (set, get, delete) with WHEN_UNLOCKED_THIS_DEVICE_ONLY accessibility
  • getMnemonic returns string | null — eliminates hasMnemonicgetMnemonic race, callers branch on null
  • Network metadata stored alongside mnemonic (mnemonic_network key) and validated on SDK init — network mismatch sets Error status and skips SDK init
  • Created app/self-custodial/config.ts — parses BREEZ_NETWORK env var with whitelist (mainnet, regtest), throws on unknown. storageDir scoped by network (breez-sdk-spark-mainnet/ vs breez-sdk-spark-regtest/)
  • Created app/self-custodial/bridge.ts — SDK init, wallet creation (Promise<void>, mnemonic not exposed through promise chain), wallet restore. Bridge-level atomicity: on failure, deletes mnemonic + reports to crashlytics. Guards: !__DEV__ blocks production, __DEV__ && Mainnet blocks mainnet in debug builds
  • Created app/self-custodial/mappers/transaction-mapper.ts — maps Breez SDK Payment to NormalizedTransaction using toWalletMoneyAmount consistently
  • Created app/self-custodial/providers/wallet-snapshot.ts — pure async getSelfCustodialWalletSnapshot(sdk) function, uses toWalletMoneyAmount consistently
  • Created app/self-custodial/providers/use-sdk-lifecycle.ts — SDK lifecycle hook: init, event subscription, refresh coalescing (refreshingRef + pendingRefreshRef), AppState foreground listener, network validation, crashlytics error reporting
  • Created app/self-custodial/providers/wallet-provider.tsx — thin context provider shell (53 lines) consuming useSdkLifecycle
  • Wired SelfCustodialWalletProvider into the app component tree
  • Added shared toNumber (BigInt conversion) and toWalletMoneyAmount helpers
  • Updated SDK logging to createSdkLogListener pattern for initLogging
  • Added noRetryOperations registration point in Apollo client for future self-custodial payment operations
  • 10 new test suites covering bridge, mainnet guard, config (network parsing, storageDir scoping), provider, wallet snapshot, mapper, logging, helpers, and secure storage (mnemonic + network metadata)

Account Type Selection (Story 2.2)

  • Created app/screens/account-type-selection/ with two-card grid (Custodial/Non-custodial) matching Figma design
  • Routes based on mode param: create → T & C → wallet creation, restore custodial → login, restore self-custodial → "Coming soon" placeholder (Epic 3)
  • Wired GetStartedScreen to route through account type selection when nonCustodialEnabled feature flag is active
  • All strings via LL.AccountTypeSelectionScreen.* with translations across 28 languages
  • 8 tests covering all navigation paths, selection states, and edge cases

Wallet Creation Flow (Story 2.3)

  • Created app/screens/spark-onboarding/wallet-creation-screen.tsx — pure UI over {status, create}, no business logic
  • Extracted useCreateWallet hook — owns create → register active account → on failure, crashlytics report + error status. CreationStatus exported as named const
  • On success: sets activeAccountId to DefaultAccountId.SelfCustodial, navigates to Home
  • On failure: bridge handles mnemonic cleanup atomically, hook reports to crashlytics
  • Added DefaultAccountId constants to app/types/wallet.types.ts derived from AccountType
  • 6 tests covering creation, navigation, error handling, non-Error rejection wrapping

Home Screen Self-Custodial Support (Story 2.4)

  • useActiveWallet() returns derived fields: isReady, isSelfCustodial, needsBackendAuth — screens consume without importing AccountType/ActiveWalletStatus
  • resolveBaseState pure function with sequential if guards (no nested ternaries)
  • Skips GraphQL queries (useHomeAuthedQuery, useRealtimePriceQuery) when isSelfCustodial
  • Maps wallet data from activeWallet.wallets for useTotalBalance
  • Extracted shouldShowTransferButton named boolean (was inline compound conditional)
  • Simplified onMenuClick with early-return pattern (was double-negation)
  • Removed as any cast by adding conversionDetails to Target type union
  • Self-custodial users navigate directly (no AccountCreationNeededModal)
  • Fixed initialRouteName in root-navigator.tsx to check persistentState.activeAccountId
  • Feature-flag rollback: provider initializes SDK regardless of flag state — useSelfCustodialRollback handles UI visibility, data preserved
  • 4 tests for custodial, self-custodial, unavailable states, and rollback safety

Infrastructure

  • Added CloudIcon to galoy-icon component
  • Added global Jest mocks for @breeztech/breez-sdk-spark-react-native (with Network enum values), react-native-fs, and react-native-config (with BREEZ_NETWORK=regtest)
  • Feature flag defaults set to false (controlled via Firebase Remote Config)

Cross-stack audit fixes (from #3731, #3742, #3746)

Changes addressing code smells identified during cross-stack review that were consolidated under this PR:

Pending (not in scope)

  • FR1: Self-custodial visually recommended badge — no design in Figma
  • FR4: App Check device verification for self-custodial — needs product decision on what to do when device verification fails
  • FR44: Stale balance indicator when SDK offline — no Figma design yet
  • Backup/mnemonic display — Epic 3 (feat: Added address screen #648)
  • useWalletMnemonic currently returns mock data — wiring to real keychain mnemonic is a separate ticket
  • M8 (eager SDK init) — architectural change, deferred

@esaugomez31 esaugomez31 changed the title feat: add breez-sdk-spark-react-native and react-native-fs dependencies feat: self-custodial onboarding and wallet creation Apr 3, 2026
@esaugomez31 esaugomez31 marked this pull request as ready for review April 3, 2026 06:33
@esaugomez31 esaugomez31 self-assigned this Apr 3, 2026
@grimen grimen changed the title feat: self-custodial onboarding and wallet creation feat(spark): self-custodial onboarding and wallet creation Apr 7, 2026

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

Solid foundational PR with good structure, test scaffolding, and feature-flag gating. The code quality is largely there — what concerns me is shipping safety. This PR introduces real-fund custody on mainnet, flips New Architecture on, and leaves backup to a future epic. That combination needs guardrails before this can be enabled anywhere, even internally.

My comments below are organized by severity. High = I'd block on these. Medium = should be addressed before the feature flag is turned on, but don't block merge. Low = follow-up cleanups.


🔴 High — block on these

H1. No backup path = guaranteed fund loss if the flag is ever enabled

Epic 3 (backup/mnemonic display) is explicitly out of scope, but this PR creates wallets that can receive Bitcoin. A QA tester or internal user flipping nonCustodialEnabled on a device with real sats has no recovery path if they uninstall or lose the device.

Minimum bar before merge:

  • Code-gate this. Either (a) refuse to render Receive until hasBackup === true, or (b) hard-block nonCustodialEnabled from taking effect in mainnet builds until Epic 3 lands.
  • Document the constraint in the PR description as an explicit merge condition, not a soft norm.

This is the single highest-risk item in the PR.

H2. Network.Mainnet is hardcoded

const config = defaultConfig(Network.Mainnet)

Combined with H1, this means QA cannot test this feature end-to-end without risking real funds. Parameterize via react-native-config:

  • Add BREEZ_NETWORK to env, parse with a whitelist (throw on unknown — don't default to mainnet).
  • Scope storageDir by network (breez-mainnet/, breez-regtest/) — Breez persists local state to disk, and sharing directories across networks corrupts state.
  • Store network alongside the mnemonic ({mnemonic, network, createdAt}) and refuse to load a wallet whose stored network doesn't match the current config. The mnemonic is BIP39-entropy and network-agnostic, but the wallet's interpretation of it is not. Silent network mismatches eat funds.
  • Add if (__DEV__ && network === Mainnet) throw as a belt-and-suspenders guard against debug builds accidentally creating mainnet wallets.

This PR is the cheapest possible moment to add network metadata to stored mnemonics — there are no existing users to migrate.

H3. Mnemonic keychain accessibility is wrong

RNSecureKeyStore.set(MNEMONIC, mnemonic, { accessible: ACCESSIBLE.ALWAYS_THIS_DEVICE_ONLY })

ALWAYS_THIS_DEVICE_ONLY is the weakest iOS protection class and is deprecated by Apple — the seed is readable when the device is locked. For a root seed this should be WHEN_UNLOCKED_THIS_DEVICE_ONLY (or AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY if background SDK sync is genuinely required). Add a test that asserts the exact constant so this can't silently regress.

H4. newArchEnabled=true is a whole-app platform change buried in a feature PR

Flipping New Architecture on Android affects every native module, not just Breez SDK. The PR description frames it as "SDK Integration" but the blast radius is the entire app. Before merge:

  • Confirm react-native-quick-crypto + react-native-nitro-modules are on compatible versions (the architecture doc warns they're version-locked).
  • Full regression pass on a real Android device, not just CI unit tests — turbo module breakage is invisible to Jest.
  • Clarify iOS New Arch status (already on, or does this silently diverge platforms?).
  • Document the rollback plan: feature flag alone can't undo a native architecture change if a crash surfaces post-release.

Call this out as a platform change at the top of the PR description.

H5. The new home-screen self-custodial test doesn't actually test anything

it("skips GraphQL queries when self-custodial is active", async () => {
  jest.doMock("@app/hooks/use-active-wallet", () => ({...}))
  render(<HomeScreen />)
  await act(async () => {})
})

No expect, and jest.doMock after the top-level import doesn't re-mock the already-loaded module — the SUT is still using the real hook. This test passes regardless of behavior, which is worse than having no test. Replace with a top-level jest.mock and assert both the skip flags and the wallet data source. Add a negative test for the custodial path so the || isSelfCustodial addition can't flip the wrong way.

H6. Persistent state migration for activeAccountId

activeAccountId is added to persistent state with no migration for existing installs where the field is undefined. The root navigator's initialRouteName depends on this. Add:

  • An explicit test with an old-shape persistent state fixture (no activeAccountId) → assert it routes to the correct default, not undefined-crashes.
  • If your persistent-state system uses versioning, bump the version and add the migration.

This is a one-line regression waiting to happen for every existing user on first launch post-update.


🟡 Medium — address before the flag is enabled

M1. Wallet-creation orchestration is in the view layer

wallet-creation-screen.tsx directly calls selfCustodialCreateWallet(), writes activeAccountId via updateState, and calls KeyStoreWrapper.deleteMnemonic() in the catch block. The architecture doc is explicit that these are AccountLifecycleService responsibilities:

Token removal and mnemonic deletion are separate operations. Ordinary account switching must never call destructive lifecycle methods.

Extract useCreateSelfCustodialAccount() (or a service method) that owns: create → register active account → on failure, cleanup. Screen becomes a dumb view over {status, create}. Also fixes: swallowed error (currently catch {}), makes the crashlytics addition a one-line change, and keeps Send/Receive screens from copying the same pattern.

M2. home-screen.tsx branches on custody type

const isSelfCustodial = activeWallet.accountType === SelfCustodial && ...
// ...skip: !isAuthed || isSelfCustodial
// ...wallets = isSelfCustodial ? mapped : apollo
// ...if (isSelfCustodial || isAuthed)

The spec is explicit: "Screens consume useActiveWallet()... without knowing the provider." Every Send/Receive/Convert screen will copy this pattern if it isn't fixed now. Add derived fields to useActiveWallet() (isReady, needsBackendAuth, etc.), move the wallet-shape mapping into the hook, and remove AccountType/ActiveWalletStatus imports from the screen entirely.

M3. SelfCustodialWalletProvider lifecycle — multiple latent bugs

  • No debounce on refreshWallets. Synced + PaymentSucceeded + ClaimedDeposits fire in rapid succession during initial sync → concurrent getInfo/listPayments and racing setWallets. Coalesce with a pending-flag ref or short debounce.
  • StrictMode double-invoke can start a second SDK init while the first disconnectSdk is in flight. Add an abort token and explicit test under <StrictMode>.
  • Teardown doesn't await disconnectSdk (can't — cleanups are sync). Combined with the above, the sdkRef.current can be the wrong instance. Guard against overlapping initialize runs.
  • Event allowlist is hardcoded in the provider. Lock it in a test (PaymentFailed/Optimization/LightningAddressChanged must NOT trigger refresh) so future changes are conscious.
  • No AppState foreground handling. User backgrounds for 10 minutes, foregrounds, sees stale balance forever with no auto-refresh. Day-one bug. Architecture doc §6 explicitly requires reconnect on foreground (NFR13: backoff 1s/3s/9s). Minimum: foreground listener that calls refreshWallets. Full backoff can wait.

M4. bridge.ts atomicity and splitting

  • Atomicity: setMnemonic succeeds, connect fails → mnemonic is orphaned in keychain. The screen catches this today, but bridge-level atomicity is safer (deletes in its own catch) and doesn't rely on every caller remembering.
  • Splitting: bridge.ts currently owns config building, SDK init/teardown, logging singleton, wallet-creation orchestration, and stable-balance activation. Split into sdk/config.ts, sdk/client.ts, wallet/create.ts, wallet/restore.ts. Matches the spec's self-custodial/adapters/ structure and prevents the file from becoming a 500-line dumping ground as Send/Receive land.

M5. getMnemonic silently returns "" on failure

public static async getMnemonic(): Promise<string> {
  try { return await RNSecureKeyStore.get(MNEMONIC) }
  catch { return "" }
}

This conflates "wallet doesn't exist" with "keychain temporarily failed" — a transient error looks identical to "no wallet," which can silently drop a user to Unavailable (or worse). Change to Promise<string | null> and let callers distinguish. The provider's hasMnemonicgetMnemonic sequence is also a small race; call getMnemonic once and branch on null.

M6. Crashlytics on SDK init and wallet-creation paths

The bridge and provider only log to logSdkEvent (appears to be local-only). For a fund-custody feature:

  • SDK init failures must hit crashlytics with context (flag state, mnemonic present, network).
  • wallet-creation-screen's catch {} currently swallows the error entirely — at minimum log it.
  • refreshWallets failures are logged but not reported; users will see stale balances with zero telemetry.

Without this, the first production incident will be untriageable.

M7. Feature-flag rollback behavior is unspecified

if (!hasMnemonic || !nonCustodialEnabled) { setStatus(Unavailable); return }

If the flag is turned off remotely after wallets exist, users lose access to funds (SDK never initializes, incoming payments never surface). NFR15 says rollback must preserve self-custodial data — but preservation ≠ accessibility. Pick one and document/test:

  • Rollback hides creation but existing wallets keep working, OR
  • Rollback fully hides, data preserved until flag returns

Silent invisibility is the worst option for a fund-holding wallet.

M8. Breez SDK init is "eager" in name only

The architecture doc mandates eager init at launch within a 5s NFR1 budget. This PR inits inside SelfCustodialWalletProvider's useEffect — after React tree construction, after Apollo. Measure cold-start time-to-wallet-ready. If it's already near 5s with an empty wallet, it only gets worse with real tx history.

M9. noRetryOperations plumbing

The architecture doc: "Breez SDK payment operations must be added to noRetryOperations." Add the registration point now even if empty, so the first Send PR doesn't have to touch global Apollo config and get re-reviewed by a different team.

M10. Provider refresh pipeline is hard to test

refreshWallets is ~50 lines doing getInfo → token lookup → build wallets → listPayments → map → filter by currency → setState. Extract getSelfCustodialWalletSnapshot(sdk): Promise<WalletState[]> as a pure async function. Unit-testable without React, and the provider becomes just lifecycle.

M11. Critical provider test coverage missing

Beyond H5, the provider has thin tests for its highest-churn code. Add:

  • Unmount during initSdk in flight → disconnectSdk called, no state update, no warnings
  • StrictMode double-invoke → exactly one live SDK at the end
  • Event handler fired after unmount → no-op
  • nonCustodialEnabled=false with mnemonic → initSdk never called
  • Retry → old SDK disconnected, new init runs
  • Init failure → status Error, sdkRef.current === null
  • Rapid event burst → coalesced correctly (whatever you decide in M3)

M12. Wallet-creation failure-mode coverage

Add: setMnemonic succeeds but updateUserSettings fails → deleteMnemonic called; deleteMnemonic itself throws → screen still reaches error state; unmount mid-creation → no updateState on an abandoned user; call-order assertion (updateState before navigate).

M13. secureStorage mnemonic test hardening

  • Assert exact accessible constant (locks H3)
  • hasMnemonic returns false on keychain miss
  • getMnemonic distinguishes missing vs error (will fail until M5 is fixed — that's the point)
  • deleteMnemonic on already-absent key doesn't throw

🟢 Low — follow-up cleanups

L1. DefaultAccountId belongs in account-registry, not wallet.types.ts

Types should be dumb; IDs and factories belong in app/account-registry/ per the spec. TODO + follow-up is fine.

L2. Thin wrapper over KeyStoreWrapper for mnemonic access

A 10-line self-custodial/storage/mnemonic.ts interface decouples self-custodial code from a custodial-era utility name and makes test mocking a one-liner. Each current test mocks @app/utils/storage/secureStorage separately — that drift will grow.

L3. Extract resolveAccountTypeRoute({mode, custody}) as a pure function

Account-type selection screen encodes a routing table that will be referenced by deep-links and re-entry flows in Epic 3. Pure function, 4-row table test, reusable. Not this PR if time-constrained.

L4. Minor code hygiene

  • toUsdMoneyAmount(Number(usdBalance))usdBalance is already Number(...). Dead call.
  • CreationStatus const + type alias with same name — works, slightly confusing.
  • initializeLogging IIFE module singleton — a plain module-level let initialized = false is clearer and test-resettable.
  • Verify toNumber / toWalletMoneyAmount are actually shared with custodial code. If not, move to self-custodial/utils/.

L5. Transaction mapper edge cases

Unknown PaymentDetails_Tags handling, zero-amount payments, missing tokenMetadata, Send vs Receive for each detail tag. Table-driven tests.

L6. Spec-compliance audit in PR description

Map which Story 2.x items explicitly defer which architectural components (AccountRegistry, AccountLifecycleService, useHasCustodialAccount, BackendFeatureGate, usePayments, useContacts, noRetryOperations). Helps the next reviewer not have to re-derive the gap.

L7. Account-type selection test (table-driven)

Four rows: {mode, custody} → expected route. Cheap, prevents an entire class of regressions.

L8. root-navigator initialRouteName test

Fixture with self-custodial id → Home; custodial token → Home; neither → GetStarted. One-line regression guard.

L9. Minor test quality

  • Add a renderWithProviders helper — current tests re-mock @rn-vui/themed, i18n-react, testProps, navigation etc. in every file. Will drift.
  • Pick one SDK mock source. Currently both __mocks__/@breeztech/... auto-mock AND per-test jest.mock(...) with different shapes exist — drift risk.

Summary

What I'd actually block merge on: H1–H6.

What I'd block the feature flag being enabled on: all Highs + all Mediums, especially M3 (provider lifecycle), M6 (crashlytics), M7 (rollback semantics).

The code-quality bones are good. The refactoring notes (M1, M2, M4, M10) matter mostly because every future screen (Send/Receive/Convert) will copy whatever patterns this PR establishes — so fixing them now is much cheaper than later.

But the shipping-safety items are what concern me most: real funds + no backup + mainnet hardcoded + new architecture flipped on is a lot of risk surface area for a single PR, even behind a feature flag. I'd push hard for H1 and H2 to be resolved before this lands, or for the PR description to explicitly commit that nonCustodialEnabled will not be turned on in any mainnet build until Epic 3 ships — enforced in code, not policy.

Nice work overall. The architecture alignment is clearly intentional and the test scaffolding sets a good precedent. Let's get the safety rails in place before wiring up the fund-moving screens on top.

@esaugomez31 esaugomez31 force-pushed the feat--spark-onboarding-wallet-creation branch from 7fdc7ee to dac973f Compare April 7, 2026 23:13
@esaugomez31 esaugomez31 force-pushed the feat--spark-foundation-shared-abstractions branch from 860b015 to 5b2cb7e Compare April 7, 2026 23:13
@esaugomez31 esaugomez31 force-pushed the feat--spark-onboarding-wallet-creation branch from dac973f to 292c460 Compare April 8, 2026 20:22
@esaugomez31 esaugomez31 force-pushed the feat--spark-foundation-shared-abstractions branch from 5b2cb7e to 8368d8e Compare April 8, 2026 20:22
@esaugomez31 esaugomez31 force-pushed the feat--spark-onboarding-wallet-creation branch from 292c460 to 9d063f6 Compare April 8, 2026 22:56
@esaugomez31 esaugomez31 force-pushed the feat--spark-foundation-shared-abstractions branch from 8368d8e to a759b91 Compare April 8, 2026 22:56
@esaugomez31 esaugomez31 changed the base branch from feat--spark-foundation-shared-abstractions to graphite-base/3747 April 9, 2026 00:28
@esaugomez31 esaugomez31 force-pushed the feat--spark-onboarding-wallet-creation branch from 9d063f6 to 8660e92 Compare April 9, 2026 02:25
@esaugomez31 esaugomez31 changed the base branch from graphite-base/3747 to feat--spark-foundation-shared-abstractions April 9, 2026 02:26
@esaugomez31

Copy link
Copy Markdown
Collaborator Author

@grimen

PR — Review Response

High — Block on these

H1. No backup path = guaranteed fund loss

Status: Done

selfCustodialCreateWallet() now has a hard __DEV__ guard that throws in production builds. Wallet creation is impossible outside debug builds until Epic 3 (backup flow) ships.

// app/self-custodial/bridge.ts:56-60
if (!__DEV__) {
  throw new Error("Wallet creation is disabled in production builds until backup flow is available")
}

Test: bridge.spec.ts — "throws in production builds"


H2. Network.Mainnet is hardcoded

Status: Done

  • BREEZ_NETWORK env var parsed with whitelist (mainnet, regtest), throws on unknown value
  • storageDir scoped by network: breez-sdk-spark-mainnet/ vs breez-sdk-spark-regtest/
  • Network metadata stored alongside mnemonic (mnemonic_network key in keychain)
  • Provider validates stored network matches current config on load — mismatch sets Error status and skips SDK init
  • Legacy wallets with no stored network (null) pass through without validation (backwards-compatible)
  • Belt-and-suspenders guard: __DEV__ && Mainnet throws in selfCustodialCreateWallet
// app/self-custodial/config.ts
const NETWORK_MAP: Record<string, Network> = {
  mainnet: Network.Mainnet,
  regtest: Network.Regtest,
}
export const SparkNetwork = parseNetwork()
export const SparkConfig = {
  network: SparkNetwork,
  storageDir: `${DocumentDirectoryPath}/breez-sdk-spark-${networkSuffix}`,
  ...
}

Tests:

  • config.spec.ts — 7 tests (default mainnet, regtest parse, case-insensitive, unknown throws, storageDir scoping per network, env vars)
  • bridge-mainnet-guard.spec.ts — mainnet + __DEV__ guard
  • bridge.spec.ts — "stores network alongside mnemonic"
  • wallet-provider.spec.tsx — "sets error status on network mismatch", "allows null stored network (legacy wallets)"
  • secure-storage-mnemonic.spec.tsgetMnemonicNetwork, setMnemonicNetwork, deleteMnemonic also cleans network

H3. Mnemonic keychain accessibility is wrong

Status: Done

Changed from ALWAYS_THIS_DEVICE_ONLY to WHEN_UNLOCKED_THIS_DEVICE_ONLY. Network metadata key uses the same accessibility level.

// app/utils/storage/secureStorage.ts:158-159
await RNSecureKeyStore.set(KeyStoreWrapper.MNEMONIC, mnemonic, {
  accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
})

Test: secure-storage-mnemonic.spec.ts — "stores mnemonic with WHEN_UNLOCKED_THIS_DEVICE_ONLY accessibility", "stores network with WHEN_UNLOCKED_THIS_DEVICE_ONLY"


H4. newArchEnabled=true is a whole-app platform change

Status: Out of scope

This is a gradle/Xcode build config change, not TypeScript code. Requires real device regression testing and documentation — not addressable in this code review pass.


H5. Home-screen self-custodial test doesn't test anything

Status: Done

  • Replaced jest.doMock (which didn't work after top-level import) with top-level jest.mock + mockActiveWalletOverride
  • Both custodial and self-custodial tests now have real assertions (getByTestId("slide-up-handle"))
  • Self-custodial mock now includes all derived fields (isReady, isSelfCustodial, needsBackendAuth)

Test: home.spec.tsx — "renders home screen for custodial user", "renders home screen for self-custodial user"


H6. Persistent state migration for activeAccountId

Status: Already done (pre-existing)

Migration 6→7 adds activeAccountId as optional (?), so undefined is a valid state. No crash on existing installs.

// app/store/persistent-state/state-migrations.ts:33-38
type PersistentState_7 = {
  schemaVersion: 7
  galoyInstance: GaloyInstanceInput
  galoyAuthToken: string
  activeAccountId?: string
}

Medium — Address before the flag is enabled

M1. Wallet-creation orchestration is in the view layer

Status: Done

Extracted useCreateWallet() hook that owns: create → register active account → on failure, set error status. Screen is now a dumb view over {status, create}.

  • app/screens/spark-onboarding/hooks/use-create-wallet.ts — hook with CreationStatus enum
  • app/screens/spark-onboarding/wallet-creation-screen.tsx — pure UI, no business logic

Test: use-create-wallet.spec.ts — 6 tests (idle status, creating status, updateState on success, navigate on success, error status on failure, no state update on failure)


M2. home-screen.tsx branches on custody type

Status: Done

Added derived fields to useActiveWallet(): isReady, isSelfCustodial, needsBackendAuth. Home screen no longer imports AccountType or ActiveWalletStatus.

// app/hooks/use-active-wallet.ts
return {
  ...base,
  isReady: base.status === ActiveWalletStatus.Ready,
  isSelfCustodial: base.accountType === AccountType.SelfCustodial && base.status !== ActiveWalletStatus.Unavailable,
  needsBackendAuth: base.accountType === AccountType.Custodial,
}

Test: use-active-wallet.spec.ts — tests for isReady, isSelfCustodial, needsBackendAuth in custodial, self-custodial, and unavailable states


M3. SelfCustodialWalletProvider lifecycle — multiple latent bugs

Status: Done

  • Debounce: refreshingRef + pendingRefreshRef coalesce rapid events — if a refresh is in-flight, the next one is queued and fires after the current one finishes
  • AppState foreground handling: AppState.addEventListener("change") triggers refreshWallets() on foreground
  • REFRESH_EVENTS as Set: Cleaner than chain of ||, locked in the provider
// app/self-custodial/providers/wallet-provider.tsx
finally {
  refreshingRef.current = false
  if (pendingRefreshRef.current) {
    pendingRefreshRef.current = false
    refreshWallets()
  }
}

M4. bridge.ts atomicity and splitting

Status: Done (atomicity)

Bridge-level catch now owns cleanup: deletes mnemonic + reports to crashlytics. Callers don't need to remember cleanup.

// app/self-custodial/bridge.ts:88-95
} catch (err) {
  await KeyStoreWrapper.deleteMnemonic()
  crashlytics().recordError(
    err instanceof Error ? err : new Error(`Wallet creation failed: ${err}`),
  )
  throw err
}

Test: bridge.spec.ts — "deletes mnemonic and reports to crashlytics when SDK connect fails", "deletes mnemonic when updateUserSettings fails"

Splitting into separate files (config, client, create, restore) deferred — current file is still under 100 lines.


M5. getMnemonic silently returns "" on failure

Status: Done

Changed to Promise<string | null>. Provider calls getMnemonic() once and branches on null — eliminated the hasMnemonicgetMnemonic race.

// app/utils/storage/secureStorage.ts:148-153
public static async getMnemonic(): Promise<string | null> {
  try {
    return await RNSecureKeyStore.get(KeyStoreWrapper.MNEMONIC)
  } catch {
    return null
  }
}

Test: secure-storage-mnemonic.spec.ts — "returns null on keychain error", "returns null when key not found"


M6. Crashlytics on SDK init and wallet-creation paths

Status: Done

  • bridge.ts: selfCustodialCreateWallet catch → crashlytics().recordError()
  • wallet-provider.tsx: SDK init failure → crashlytics().recordError(), refresh failure → crashlytics().log()
  • use-create-wallet.ts: Error handling delegated to bridge (single error boundary, no double-reporting)

M7. Feature-flag rollback behavior is unspecified

Status: Done

The provider no longer checks nonCustodialEnabled — it only checks if a mnemonic exists. When the flag is off:

  • useSelfCustodialRollback switches the active account back to custodial (UI hides SC)
  • SelfCustodialWalletProvider stays mounted, SDK stays connected, data preserved
  • When flag returns, user's wallet is immediately available

Test: wallet-provider.spec.tsx — "initializes SDK regardless of feature flag state (rollback-safe)"


M8. Breez SDK init is "eager" in name only

Status: Not addressed

Architectural change — moving SDK init before the React tree requires significant refactoring. Deferred.


M9. noRetryOperations plumbing

Status: Done

Registration point restored in app/graphql/client.tsx with clear context:

// Self-custodial payment operations — add here when Send/Receive screens land.
// Breez SDK payment operations must not be retried by Apollo.

M10. Provider refresh pipeline is hard to test

Status: Done

Extracted getSelfCustodialWalletSnapshot(sdk): Promise<WalletState[]> as a pure async function. Provider is now just lifecycle management.

  • app/self-custodial/providers/wallet-snapshot.ts — SDK calls + mapping
  • wallet-snapshot.spec.ts — 4 tests (correct balances, zero USD, wallet IDs, transaction mapping)

M11. Critical provider test coverage missing

Status: Done

Added tests:

  • SDK init fails → status Error
  • No mnemonic → initSdk never called
  • LoadingReady on successful init
  • Unmount during init → disconnectSdk called
  • Network mismatch → Error status, SDK never called
  • Rollback-safe: SDK initializes regardless of feature flag

M12. Wallet-creation failure-mode coverage

Status: Done

Tests in bridge.spec.ts:

  • setMnemonic succeeds + connect fails → deleteMnemonic called + crashlytics
  • updateUserSettings fails → disconnectSdk + deleteMnemonic + crashlytics
  • Production build → throws before any operation
  • Mainnet in dev → throws before any operation

Tests in use-create-wallet.spec.ts:

  • Failure → error status, no updateState, no navigation
  • Non-Error rejection handled

M13. secureStorage mnemonic test hardening

Status: Done

  • Exact WHEN_UNLOCKED_THIS_DEVICE_ONLY constant asserted for mnemonic and network
  • getMnemonic returns null on missing and on keychain error
  • deleteMnemonic on already-absent key returns false
  • getMnemonicNetwork / setMnemonicNetwork fully tested

Low — Follow-up cleanups

L1. DefaultAccountId belongs in account-registry

Status: Not addressed — follow-up

L2. Thin wrapper over KeyStoreWrapper for mnemonic access

Status: Not addressed — follow-up

L3. Extract resolveAccountTypeRoute as pure function

Status: Not addressed — follow-up

L4. Minor code hygiene

Status: Partially done

  • initializeLogging IIFE: reverted to IIFE closure (encapsulated flag)
  • CreationStatus const + type: exported as named const, screen imports directly (cleaner)
  • toUsdMoneyAmount(Number(usdBalance)) double-Number: not addressed (cosmetic)

L5. Transaction mapper edge cases

Status: Done

Tests in transaction-mapper.spec.ts:

  • Zero-amount payment
  • Zero-fee payment
  • Deposit detail tag → Lightning
  • Withdraw detail tag → Lightning

L6–L9. Spec compliance audit, table-driven tests, test helpers

Status: Not addressed — follow-ups

@esaugomez31 esaugomez31 requested a review from grimen April 9, 2026 02:26
@grimen

grimen commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Code review — cross-stack audit (#3731#3742#3746#3747)

Reviewed all 4 stacked PRs for readability issues, code smells, and correctness concerns. Grouped by severity. None of these block merge, but they should be tracked and addressed before the feature flag is enabled.


🔴 Critical

C1. Swallowed error in useCreateWallet#3747
app/screens/spark-onboarding/hooks/use-create-wallet.ts — the catch block sets CreationStatus.Error but discards the error entirely. No logging, no crashlytics. Wallet creation failure is invisible to telemetry. Compare with bridge.ts in the same PR which properly calls crashlytics().recordError(...).

C2. TransferringFundsScreen fakes success#3742
app/screens/account-migration/to-non-custodial/transferring-funds-screen.tsx — hardcoded setTimeout(() => navigation.replace("sparkBackupSuccessScreen"), 2000). Unconditionally navigates to success after 2s regardless of whether any funds were transferred. The settings menu entry to reach this screen already ships in the same PR.

C3. Mnemonic returned through the call stack unnecessarily#3747
app/self-custodial/bridge.tsselfCustodialCreateWallet(): Promise<string> returns the mnemonic, but the caller (use-create-wallet.ts) discards it. The seed is already stored in the Keychain inside the bridge function. Returning it as a resolved promise value exposes it to global promise handlers and debugging tools for no reason. Change return type to Promise<void>.


🟡 Major — readability

R1. isSelfCustodial branching scattered across HomeScreen#3747
app/screens/home-screen/home-screen.tsx — 7 isSelfCustodial branches injected across the screen: query skip flags, loading derivation, wallet mapping, onMenuClick guard, and the upgrade-prompt early-exit. The screen now has two completely different data-fetching paths and divergent UI behavior. This is exactly what M2 in the original review flagged — the derived fields were added to useActiveWallet() but the screen still branches everywhere instead of consuming a unified interface.

R2. Double-negation in onMenuClick#3747

if (isSelfCustodial || isAuthed) {
  if (!isSelfCustodial && target === "receiveBitcoin" && ...)

Checks isSelfCustodial then immediately negates it one level deeper. Hard to reason about. Separate the self-custodial early-return from the custodial auth/prompt logic.

R3. Unreadable compound conditional#3747

if (
  isSelfCustodial ||
  !isIos ||
  dataUnauthed?.globals?.network !== "mainnet" ||
  levelAccount === AccountLevel.Two ||
  ...

Mixes custody type, platform, network, and account level in one guard. Extract to a named boolean like shouldSkipUpgradePrompt.

R4. Nested ternary in useActiveWallet#3747
app/hooks/use-active-wallet.tsactiveAccount ? activeAccount.type === Custodial ? custodialState : selfCustodialState : createPlaceholder(...). The previous version in #3746 used sequential if guards which were more readable.

R5. sc* abbreviated variable names in tests#3746, #3747

  • __tests__/hooks/use-self-custodial-rollback.spec.ts: scAccount (should be selfCustodialAccount)
  • __tests__/hooks/use-active-wallet.spec.ts: scReady, scUnavailable (should be selfCustodialReady, selfCustodialUnavailable)

The custodial counterparts are spelled out (custodialAccount, custodialReady), so the abbreviation is inconsistent. Even in tests, prefer readable names.


🟡 Major — code smells

S1. SelfCustodialWalletProvider is a god component (186 lines)#3747
app/self-custodial/providers/wallet-provider.tsx — manages SDK lifecycle, state, event subscription, refresh coalescing (3 refs), AppState listener, retry logic, and context provision in a single component. The eslint-disable require-atomic-updates suppression on refreshingRef is a red flag — if refreshWallets is called from both the event listener and the AppState listener simultaneously, the non-atomic read-then-write can cause concurrent refreshes. The recursive call in finally also risks unbounded recursion if events keep firing during each refresh.

S2. Cloud backup toast-only error handling#3731
app/screens/spark-onboarding/hooks/use-cloud-backup.ts — backup upload failure shows a toast that disappears in seconds. No crashlytics, no state change, no retry mechanism. For seed phrase backup, this is inadequate.

S3. Fire-and-forget checkpoint persistence#3742
app/screens/account-migration/hooks/use-migration-checkpoint.tssaveCheckpointToStorage() is async but called without await and the promise is uncaught. If AsyncStorage write fails, in-memory state silently diverges from persistent state.

S4. Double silent catch in checkpoint storage#3742
app/screens/account-migration/utils/migration-checkpoint-storage.tscatch { await remove(key).catch(() => {}) }. Both the load and the cleanup can fail with zero signal anywhere. No log, no crash report. Corrupted checkpoint data persists indefinitely.

S5. Backup confirm screen coupled to GraphQL#3742
app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx — reaches into useHomeAuthedQuery to check wallet balances. Layer violation — this breaks when the user is in self-custodial mode (no GraphQL data). Should come from the wallet abstraction.

S6. useHasCustodialAccount is dead abstraction#3746
app/hooks/use-has-custodial-account.ts — wraps useIsAuthed() with zero additional logic. The name "has custodial account" is misleading — it's not checking account type, just auth status.

S7. usePayments inconsistent missing-operation behavior#3746
app/hooks/use-payments.ts — some adapters are undefined (safe to check), others throw UnsupportedOperationError at runtime (unsafe). Consumers get no type-level distinction between the two.

S8. toWalletMoneyAmount inconsistently applied#3747
app/self-custodial/providers/wallet-snapshot.ts still uses raw toBtcMoneyAmount inline for balance construction, while the same PR introduces toWalletMoneyAmount to centralize this pattern and uses it in the transaction mapper.


Summary

Severity Count Key theme
🔴 Critical 3 Silent failures in wallet creation, fake transfer success, mnemonic exposure
🟡 Readability 5 Custody-type branching in views, abbreviations, nested ternaries
🟡 Code smells 8 God component, swallowed errors, layer violations, inconsistent patterns

The most impactful pattern is custody-type awareness leaking into view code (R1-R3). Every Send/Receive/Convert screen will copy whatever patterns HomeScreen establishes — fixing this now is much cheaper than later.

@esaugomez31

Copy link
Copy Markdown
Collaborator Author

@grimen Please check

Cross-stack audit response

Critical

C1. Swallowed error in useCreateWallet

Status: Done

The catch block now reports to crashlytics before setting the error status. Both Error and non-Error rejections are handled.

// app/screens/spark-onboarding/hooks/use-create-wallet.ts
} catch (err) {
  crashlytics().recordError(
    err instanceof Error ? err : new Error(`Wallet creation failed: ${err}`),
  )
  setStatus(CreationStatus.Error)
}

Test: use-create-wallet.spec.ts — "wraps non-Error rejection for crashlytics"


C2. TransferringFundsScreen fakes success

Status: Done

Removed the setTimeout + navigation.replace("sparkBackupSuccessScreen") that unconditionally faked success after 2s. The screen now renders the pending state without auto-navigating. The real transfer logic will be added when the funds migration feature lands.


C3. Mnemonic returned through the call stack unnecessarily

Status: Done

Changed selfCustodialCreateWallet() return type from Promise<string> to Promise<void>. The mnemonic is stored in the Keychain inside the bridge function and is no longer exposed through the promise chain.

Test: bridge.spec.ts — removed "returns the generated mnemonic" test


Major — readability

R1. isSelfCustodial branching scattered across HomeScreen

Status: Done

Extracted shouldShowTransferButton named boolean to replace the inline compound conditional. Simplified onMenuClick with an early-return pattern. The remaining isSelfCustodial branches (query skip flags, loading derivation, wallet mapping) are inherently necessary — they represent two genuinely different data sources.


R2. Double-negation in onMenuClick

Status: Done

Replaced nested if (isSelfCustodial || isAuthed) { if (!isSelfCustodial && ...) } with a flat early-return structure:

const onMenuClick = (target: Target) => {
  if (!isSelfCustodial && !isAuthed) {
    setModalVisible(true)
    return
  }

  if (!isSelfCustodial && target === "receiveBitcoin" && ...) {
    toggleSetDefaultAccountModal()
    return
  }

  navigation.navigate(target)
}

Also removed the as any cast by adding "conversionDetails" to the Target type union.


R3. Unreadable compound conditional

Status: Done

Extracted to a named boolean:

const shouldShowTransferButton =
  isSelfCustodial ||
  !isIos ||
  dataUnauthed?.globals?.network !== "mainnet" ||
  levelAccount === AccountLevel.Two ||
  levelAccount === AccountLevel.Three ||
  isIosWithBalance

if (shouldShowTransferButton) {
  buttons.unshift(...)
}

R4. Nested ternary in useActiveWallet

Status: Done

Extracted to resolveBaseState pure function with sequential if guards (matching the #3746 style):

const resolveBaseState = (
  activeAccount: { type: AccountType } | undefined,
  custodialState: ActiveWalletState,
  selfCustodialState: ActiveWalletState,
): ActiveWalletState => {
  if (!activeAccount) return createPlaceholder(AccountType.Custodial)
  if (activeAccount.type === AccountType.Custodial) return custodialState
  return selfCustodialState
}

R5. sc* abbreviated variable names in tests

Status: Done

Renamed all abbreviated variables to match their custodial counterparts:

  • scReadyselfCustodialReady
  • scUnavailableselfCustodialUnavailable
  • scAccountselfCustodialAccount

Major — code smells

S1. SelfCustodialWalletProvider is a god component

Status: Done

Extracted SDK lifecycle logic into useSdkLifecycle hook (139 lines). The provider is now 53 lines — only context creation, retry state, and memoized value.

  • app/self-custodial/providers/use-sdk-lifecycle.ts — SDK init, event subscription, refresh coalescing, AppState listener, network validation
  • app/self-custodial/providers/wallet-provider.tsx — context provider shell

Tests: "handles refresh error gracefully", "triggers refresh on SDK events", "does not refresh on non-refresh events", "coalesces rapid refresh calls"


S2. Cloud backup toast-only error handling

Status: Done

Both sign-in and upload failures now report to crashlytics in addition to showing a toast:

// Sign-in failure
crashlytics().recordError(
  err instanceof Error ? err : new Error(`Cloud backup sign-in failed: ${err}`),
)

// Upload failure
crashlytics().recordError(new Error(`Cloud backup upload failed: ${result.error}`))

S3. Fire-and-forget checkpoint persistence

Status: Done

saveCheckpointToStorage and clearCheckpointFromStorage are called with .catch(() => {}) to prevent unhandled rejections. Errors are captured by crashlytics inside the storage functions themselves (see S4).


S4. Double silent catch in checkpoint storage

Status: Done

Both loadCheckpoint and saveCheckpointToStorage now report errors to crashlytics:

// loadCheckpoint catch
crashlytics().recordError(
  err instanceof Error ? err : new Error(`Checkpoint load failed: ${err}`),
)

// saveCheckpointToStorage
try {
  await saveJson(storageKey, { step, savedAt: Date.now() })
} catch (err) {
  crashlytics().recordError(
    err instanceof Error ? err : new Error(`Checkpoint save failed: ${err}`),
  )
}

S5. Backup confirm screen coupled to GraphQL

Status: Done

Replaced useHomeAuthedQuery with useActiveWallet() from the wallet abstraction layer. Works in both custodial and self-custodial mode:

// Before
const { data: { me } = {} } = useHomeAuthedQuery({ fetchPolicy: "cache-first" })
const hasFunds = me?.defaultAccount?.wallets?.some((wallet) => wallet.balance > 0) ?? false

// After
const { wallets } = useActiveWallet()
const hasFunds = wallets.some((w) => w.balance.amount > 0)

S6. useHasCustodialAccount is dead abstraction

Status: Done

Deleted app/hooks/use-has-custodial-account.ts and removed its export from app/hooks/index.ts. Zero consumers existed.


S7. usePayments inconsistent missing-operation behavior

Status: Done

All adapters are now consistently optional (undefined) instead of mixing undefined (safe) with runtime throw UnsupportedOperationError (unsafe):

type PaymentsResult = {
  sendPayment?: SendPaymentAdapter
  getFee?: GetFeeAdapter
  receiveLightning?: ReceiveLightningAdapter
  receiveOnchain?: ReceiveOnchainAdapter
  listPendingDeposits?: ListPendingDepositsAdapter
  claimDeposit?: ClaimDepositAdapter
  convert?: ConvertAdapter
  accountType: AccountType
}

S8. toWalletMoneyAmount inconsistently applied

Status: Done

wallet-snapshot.ts now uses toWalletMoneyAmount consistently, matching the transaction mapper:

// Before
balance: toBtcMoneyAmount(btcBalance),
balance: toUsdMoneyAmount(usdBalance),

// After
balance: toWalletMoneyAmount(btcBalance, WalletCurrency.Btc),
balance: toWalletMoneyAmount(usdBalance, WalletCurrency.Usd),

grimen
grimen previously approved these changes Apr 9, 2026

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

All code review issues have been resolved. Approving.

grimen
grimen previously approved these changes Apr 11, 2026
@grimen grimen force-pushed the graphite-base/3747 branch from c6482ce to 8afc3c3 Compare May 20, 2026 11:00
@grimen grimen force-pushed the feat--spark-onboarding-wallet-creation branch from 2d9af35 to 092375b Compare May 20, 2026 11:00
@graphite-app graphite-app Bot changed the base branch from graphite-base/3747 to main May 20, 2026 11:01
@grimen grimen force-pushed the feat--spark-onboarding-wallet-creation branch from 092375b to d6e7ff4 Compare May 20, 2026 11:01
@grimen grimen self-requested a review May 20, 2026 11:04
@grimen grimen merged commit 820ab2b into main May 20, 2026
7 of 9 checks passed
@grimen grimen deleted the feat--spark-onboarding-wallet-creation branch May 20, 2026 11:10
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