Skip to content

feat(spark): shared wallet abstractions and provider foundation#3746

Merged
grimen merged 34 commits into
mainfrom
feat--spark-foundation-shared-abstractions
May 20, 2026
Merged

feat(spark): shared wallet abstractions and provider foundation#3746
grimen merged 34 commits into
mainfrom
feat--spark-foundation-shared-abstractions

Conversation

@esaugomez31

@esaugomez31 esaugomez31 commented Apr 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the foundation and shared wallet abstractions for self-custodial integration. This establishes the provider-agnostic layer that allows custodial and self-custodial accounts to coexist behind shared interfaces.

No UI changes. No Breez SDK dependency. All existing custodial behavior is unchanged.

What this PR does

  • Shared typesWalletState, ActiveWalletState, NormalizedTransaction, 7 payment adapter interfaces, AccountDescriptor, ContactAdapter in app/types/
  • Feature flagsnonCustodialEnabled and stableBalanceEnabled in Firebase Remote Config with cascade rule
  • Persistent state — Schema 6 → 7 migration adding activeAccountId for account selection
  • Custodial adapters — Wraps existing Apollo/GraphQL wallet data and transactions behind the shared interfaces in app/custodial/
  • CustodialWalletProvider — React context that maps HomeAuthed query to ActiveWalletState
  • Routing hooksuseActiveWallet(), usePayments(), useAccountRegistry(), useHasCustodialAccount(), useMonetaryPreferences()
  • SDK logginglogSdkEvent() and connectToSdkLogger() in app/self-custodial/ ready for Breez SDK integration
  • i18n — 6 new namespaces across 28 languages: AccountTypeSelectionScreen, BackupScreen, RestoreScreen, BackupNudge, StableBalance, BackendFeatureGate

Architecture

Screens → useActiveWallet() / usePayments()
              ↓
         Account Registry (activeAccountId)
              ↓                         ↓
    CustodialWalletProvider     SelfCustodialWalletProvider (Epic 2)
    (wraps Apollo/GraphQL)      (wraps Breez SDK)
              ↓                         ↓
         WalletState               WalletState  ← same shape

New files (17)

Directory Files
app/types/ wallet.types.ts, transaction.types.ts, payment.types.ts, contact.types.ts
app/custodial/ adapters/wallet-adapter.ts, adapters/payment-adapter.ts, mappers/transaction-mapper.ts, providers/wallet-provider.tsx
app/hooks/ use-account-registry.ts, use-active-wallet.ts, use-payments.ts, use-has-custodial-account.ts, use-monetary-preferences.ts
app/self-custodial/ logging.ts

Modified files (5)

  • app/config/feature-flags-context.tsx — 2 new flags
  • app/store/persistent-state/state-migrations.ts — schema 7
  • app/hooks/index.ts — barrel exports
  • app/app.tsxCustodialWalletProvider in component tree
  • .storybook/views/story-screen.tsx — schema version bump

@esaugomez31 esaugomez31 changed the title feat: add shared wallet, transaction, payment, and contact types feat: shared wallet abstractions and provider foundation Apr 2, 2026
@esaugomez31 esaugomez31 marked this pull request as ready for review April 2, 2026 15:16
@esaugomez31 esaugomez31 self-assigned this Apr 2, 2026
@esaugomez31 esaugomez31 requested review from Copilot and grimen and removed request for Copilot April 2, 2026 15:20

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

PR Review: Shared wallet abstractions and provider foundation

Solid foundation PR — clean layering, strong test coverage, low deployment risk. Requesting changes on a few items below; medium/low are suggestions.


🔴 High priority (address before merge)

1. use-active-wallet.ts:17 — rollback effect is fragile and untested
The hasRolledBack one-shot ref won't re-fire if nonCustodialEnabled toggles off→on→off mid-session. Drop the ref (the effect is idempotent once setActiveAccountId lands) or key it off the flag value so it re-arms on transitions. Add tests covering:

  • Rollback fires when the flag is disabled with a custodial fallback present.
  • Rollback does not mutate self-custodial persistent data (spec NFR15).
  • Note the no-fallback branch (spec architecture.md:300-302 — maintenance screen) as a known follow-up, not blocking this PR.

2. payment-adapter.ts:73,86 — empty-string sentinels on error

return { invoice: "", errors: [toPaymentError("Failed to create invoice")] }
return { address: "", errors: [toPaymentError("Failed to get address")] }

Callers that don't check errors first will render a QR code for "". Make invoice/address optional and omit on failure, or return a discriminated union.

3. Migration chain 3→4→5→6→7 is untested end-to-end
Only the direct 6→7 step is tested. Add one test starting from schema 3 that walks the full chain.


🟡 Medium priority (worth doing in this PR)

4. Split CustodialWalletProvider — extract pure mapper
wallet-provider.tsx:38-72 mixes status derivation, wallet filtering, and transaction partitioning in one useMemo. Extract mapHomeAuthedToActiveWalletState(data, {loading, error, isAuthed}) into app/custodial/mappers/. Provider becomes ~15 lines of glue; the mapper is trivially testable without renderHook or mocks.

5. Extract the rollback effect into its own hook
use-active-wallet.ts:19-29 — distinct concern from "return the active wallet state." Pull into useSelfCustodialRollback(...). Pairs naturally with fix #1 and makes the rollback independently testable.

6. transaction-mapper.ts — fee handling and test gaps

  • fee: toMoneyAmount(tx.settlementFee, ...) always sets a fee even when settlementFee === 0. Field is typed fee?, so omit on zero.
  • Add coverage for settlementFee === 0 and BTC-only / USD-only wallet configurations in the provider tests.

7. Move account ID constants out of the hook
CUSTODIAL_DEFAULT_ID / SELF_CUSTODIAL_DEFAULT_ID in use-account-registry.ts:15-16 are system-level identifiers, not hook internals. Move to wallet.types.ts or a dedicated account-ids.ts so tests and other modules don't duplicate string literals.


🟢 Low priority (follow-up OK)

8. Extract createCustodialDescriptor / createSelfCustodialDescriptor factories from useAccountRegistry.
9. Extract markSelected(accounts, activeId) as a pure helper.
10. Move transaction partitioning (wallet-provider.tsx:52-60) into transaction-mapper.ts.
11. Add failed(message) helper in payment-adapter.ts to consolidate repeated failed-result construction.
12. Replace logSdkEvent if-ladder with a dispatch map (self-custodial/logging.ts:22-40).
13. logging.ts:20 — unknown log levels default to Error, sending unknown debug lines to Crashlytics. Default to Info or Debug.
14. Feature flag cascade test — lock the stableBalanceEnablednonCustodialEnabled invariant.


Spec alignment note

Verified suggestions against blink-specs/self-custodial/architecture.md:

  • create* naming convention is preserved — the spec mandates createSelfCustodial* symmetry across all seven payment adapters, so the custodial side keeps its create* prefix even on non-factory exports.
  • Rollback behavior in #1 respects NFR15 (no mutation of self-custodial local data).
  • Shared adapter interfaces in payment.types.ts match the spec's contract definitions 1:1.

Verdict

Approve after #1, #2, #3 are addressed. Medium items are cheap to fold into this PR since the affected files are all new. Low items are fine as follow-ups.

@grimen grimen changed the title feat: shared wallet abstractions and provider foundation feat(spark): shared wallet abstractions and provider foundation Apr 7, 2026
@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--account-migration-to-non-custodial-ui branch 2 times, most recently from 554835c to 88c7729 Compare April 8, 2026 20:22
@esaugomez31 esaugomez31 force-pushed the feat--spark-foundation-shared-abstractions branch 2 times, most recently from 8368d8e to a759b91 Compare April 8, 2026 22:56
@esaugomez31 esaugomez31 force-pushed the feat--account-migration-to-non-custodial-ui branch from 88c7729 to a043417 Compare April 8, 2026 22:56
@esaugomez31 esaugomez31 changed the base branch from feat--account-migration-to-non-custodial-ui to graphite-base/3746 April 8, 2026 23:05
@esaugomez31 esaugomez31 force-pushed the feat--spark-foundation-shared-abstractions branch from a759b91 to 3fe7201 Compare April 9, 2026 00:28
@esaugomez31 esaugomez31 changed the base branch from graphite-base/3746 to feat--account-migration-to-non-custodial-ui April 9, 2026 00:28
@esaugomez31 esaugomez31 requested a review from grimen April 9, 2026 02:23
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 review feedback has been addressed — nice work.

Minor note for follow-up: The createCustodial* prefix on payment adapters (createCustodialSendPayment, createCustodialClaimDeposit, etc.) leaks the concrete type into what should be an implementation-agnostic adapter interface. Once the routing layer (via usePayments / useActiveWallet) fully hides adapter selection from consumers, consider dropping the prefix to just createSendPayment, createClaimDeposit, etc. — each scoped by their module path. Not blocking, but worth aligning before the pattern solidifies across more adapters.

status: AccountStatus.Available,
}
const scAccount = {
id: "sc-default",

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.

Nit: scAccount isn't very readable — even in tests, prefer spelling out selfCustodialAccount. Not blocking this PR, but something to be aware of going forward.

@grimen grimen force-pushed the feat--spark-foundation-shared-abstractions branch from c6482ce to f14770a Compare May 20, 2026 10:53
@grimen grimen merged commit 8afc3c3 into main May 20, 2026
7 of 9 checks passed
@grimen grimen deleted the feat--spark-foundation-shared-abstractions branch May 20, 2026 10:59
grimen pushed a commit that referenced this pull request May 20, 2026
## 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 `hasMnemonic` → `getMnemonic` 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:

- **Crashlytics in cloud backup** (#3731): Sign-in and upload failures in `use-cloud-backup.ts` now report to crashlytics (was toast-only)
- **TransferringFundsScreen fake success** (#3742): Removed `setTimeout` + auto-navigation that unconditionally faked success after 2s. Screen now renders pending state without navigating
- **Checkpoint persistence** (#3742): `saveCheckpointToStorage` errors now caught and reported to crashlytics. `loadCheckpoint` catch block also reports to crashlytics (was double silent catch)
- **Backup confirm layer violation** (#3742): `backup-confirm-screen.tsx` uses `useActiveWallet()` instead of `useHomeAuthedQuery` — works in both custodial and self-custodial mode
- **Dead abstraction** (#3746): Removed `useHasCustodialAccount` (zero consumers, just wrapped `useIsAuthed`)
- **Inconsistent payment adapters** (#3746): `usePayments` adapters (`claimDeposit`, `listPendingDeposits`, `convert`) changed from required (with runtime `throw UnsupportedOperationError`) to optional (`undefined`) — consistent with other adapters
- **Abbreviated test names** (#3746): Renamed `scAccount` → `selfCustodialAccount`, `scReady` → `selfCustodialReady`, `scUnavailable` → `selfCustodialUnavailable`

## 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 (#648)
- `useWalletMnemonic` currently returns mock data — wiring to real keychain mnemonic is a separate ticket
- M8 (eager SDK init) — architectural change, deferred
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