feat(spark): self-custodial payments and transaction history #3758
feat(spark): self-custodial payments and transaction history
#3758esaugomez31 wants to merge 76 commits intofeat--spark-backup-and-recoveryfrom
Conversation
be1bb8e to
ef45a9a
Compare
69ec015 to
73cbca0
Compare
eefb0dc to
a1f58d0
Compare
grimen
left a comment
There was a problem hiding this comment.
PR Review: Self-Custodial Payments and Transaction History
Overall this is a solid implementation delivering all spec requirements. The architecture is well-separated and test coverage is comprehensive.
Issues (6)
1. Potential race condition in deposit claiming
Problem: UnclaimedDepositBanner fetches deposits in useEffect without subscribing to wallet refresh. If a deposit is claimed elsewhere, the list becomes stale.
Solution: Invalidate deposit list on wallet refresh events or use shared state.
2. Missing i18n key validation across 28 locales
Problem: 28 locale files updated with identical +64/-1 changes. High likelihood of missing keys, typos, or mismatched placeholders ({count}, {sats}).
Solution: Add CI validation comparing all locales against en.json as source of truth.
3. WeakMap cache without size limit
Problem: withOfflineGate uses WeakMap to cache wrapped components. Grows unboundedly during session.
Solution: Replace HOC with wrapper component (see Code Smells #6) - eliminates need for cache entirely.
4. BigInt to Number conversion risk
Problem: Bridge layer converts SDK BigInt to Number:
// SDK returns
amountSats: BigInt(5000)
// App uses
result.amountSats // numberCould lose precision for values > Number.MAX_SAFE_INTEGER.
Solution: Keep as BigInt through the stack, or add runtime validation that values are within safe range.
5. Inconsistent error handling pattern
Problem: Bridge throws, adapters return result objects:
// Bridge - throws
await claimDeposit({ sdk, txid, vout }) // throws Error
// Adapter - returns
const result = await adapter.claimDeposit(...) // { status: 'failed', errors }Solution: Standardize on one pattern. Recommend adapters catch bridge errors and always return result objects.
6. Duplicate PaymentType import source
Problem:
// Some files
import { PaymentType } from "@blinkbitcoin/blink-client"
// Other files
import { PaymentType } from "@app/types/..."Solution: Use single source. Prefer internal types with re-export if needed.
Code Smells (6)
| Issue | Example | Proposed Fix |
|---|---|---|
| Long function chains | wrapDestinationForSC nested conditionals |
Extract payment-type handlers to separate functions |
| Excessive test mocking | wallet-provider.spec.tsx has 20+ mocks |
Create shared createMockSelfCustodialContext() fixture |
| String-based error classification | if (err.message.includes("SdkError.InsufficientFunds")) |
Use typed error classes or error codes |
| Inconsistent "SC" abbreviation | ScPaymentOfflineNotice vs useSelfCustodialWallet |
Use full SelfCustodial consistently |
| Utility in hooks directory | hooks/format-fee-tier-options.ts is pure function |
Move to utils/ |
| HOC pattern | hocs/with-offline-gate.tsx with WeakMap cache |
Replace with <OfflineGate> wrapper component |
Test Gaps (3)
- Edge cases: Network switching mid-transaction, concurrent payments, app backgrounding during execution
- Missing scenarios: Accessibility, 0-fee tiers, malformed SDK responses, duplicate transaction IDs
- Uncovered files:
deposit-error-message.tsx,use-onchain-resolver.ts
Review Response — Self-Custodial Payments and Transaction HistoryIssues (6)1. Potential race condition in deposit claiming — Addressed
Commit: 2. Missing i18n key validation across 28 locales — Partially addressedThe missing keys and placeholders for this PR are aligned across the 28 locales. The broader CI validation tool you suggested we'll land in a dedicated PR together with the cleanup of the ~1.1k drifts that already exist in legacy translations — adding it to 3. WeakMap cache without size limit — AddressedReplaced via Smell #6 (see below). The HOC + Commit: 4. BigInt to Number conversion risk — Addressed
if (!Number.isSafeInteger(parsed) && typeof value === "bigint") {
crashlytics().recordError(
new Error(`toNumber: bigint value ${value} exceeds Number.MAX_SAFE_INTEGER`),
)
}In practice all Bitcoin (≤ 21M × 10⁸ sats) and USDB base-unit amounts stay well within Commit: 5. Inconsistent error handling pattern — Acknowledged, intentionalThe adapter/bridge split is intentional and consistent within each layer:
The 5 hooks/screens that call bridge directly ( 6. Duplicate PaymentType import source — Won't fix (not duplicates)They model two different stages of a payment:
Merging would drop information (parsing needs Code Smells (6)1. Long function chains in
|
grimen
left a comment
There was a problem hiding this comment.
Review — bugs + test coverage
Important
This PR (and its stack) needs a major refactor pass. Findings include:
- SOLID violations —
usePaymentsreturns a mode-dependent shape (optional fields per mode); adapter contracts leakisSelfCustodial: booleanin their return types; the bridge layer mixes pass-through wrappers with real abstraction. - High cyclomatic complexity in the screen layer — 60+
isSelfCustodialbranches across 11 files;home-screen.tsxalone has 15 mode branches and a 17-hook prelude;send-bitcoin-destination-screen.tsxhas a 30-hook prelude. - Incomplete adapter pattern — mode leaks into call sites instead of being hidden behind a uniform Port. SC code fakes Apollo error shapes (
__typename: "GraphQLApplicationError"insend-helpers.ts:91, 116); shared types carry Apollo- and Breez-flavoured fields. - ~50 naming smells — mode prefixes baked into every export (
selfCustodialCreateWallet,mapSelfCustodialTransaction, etc.), container nouns (Info,Result,Fn,helpers,utils), lying constants (LIGHTNING_FEE_SATS = 0is the actual fee bug too), and inconsistent vocabulary (memo/description/message; fee/feeAmount/feeSats/feeRateSatPerVb).
Why this is not in scope for this PR: with 6 stacked PRs above this one (#3761–#3769), doing the refactor here would force a multi-day restack across all of them, break the stack's momentum, and create combinatorial conflict pain — particularly the rename passes. Refactoring against main after the stack settles is materially cheaper for everyone.
Decision: this review covers correctness only. The structural / SOLID / complexity / naming review will be shared as a separate cleanup ticket once both:
- The full stack is approved for functionality, and
- All Critical bugs and missing tests below are resolved.
Cleanup will then land as a sequence of small, focused PRs against main — no restack pain, each independently mergeable. Production rollout (#3768) should be gated on the cleanup ticket reaching at least the adapter-pattern unification milestone, otherwise the structural debt ships and gets normalised.
Real strengths first: bridge-layer DI on the Breez SDK, the selfCustodialCreateWallet rollback (bridge/lifecycle.ts:91-109), wallet-provider.spec.tsx driving real timers + AppState, is-online.spec.ts walking the full enum, the use-payment-request.spec.ts "full cycle" baseline-ref test, validate-network with proper Sentry instrumentation. The shape of the integration is right.
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. Hardcoded LIGHTNING_FEE_SATS = 0 in SC Lightning send
app/self-custodial/payment-details/send-helpers.ts:16, 43-57
createGetFee calls prepareSend then discards the quoted fee, returning the constant 0 regardless. Compare app/self-custodial/adapters/payment-adapter.ts:74-79, which correctly uses extractLightningFee(prepared). User authorises a Lightning send shown as free; SDK takes the actual fee.
Fix: delete the constant; read fee from prepared via extractLightningFee.
2. Refund accepts arbitrary fee rate, including 0
app/screens/unclaimed-deposits/hooks/use-deposit-actions.ts:119-150 + use-recommended-fee-tiers.ts:14-18, 29-47
useRecommendedFeeTiers defaults to feeSats: 0 and silently keeps zeros on fetch failure (catch { /* keep defaults */ }). getFeeRateSatPerVb returns those zeros. handleRefund passes them straight to claimDeposit.refundDeposit({ feeRateSatPerVb }) with no validation.
Fix: validate feeRateSatPerVb > 0 before submission; surface fetch failure as an error state rather than silently keeping zeros.
3. extractOnchainFees returning null silently shows 0-sat fees
app/screens/send-bitcoin-screen/hooks/use-onchain-fee-tiers.ts:79-82 + app/self-custodial/payment-details/send-helpers.ts:69
When prepare result isn't a BitcoinAddress tag, extractOnchainFees returns null and the hook returns silently with tiers = DEFAULT_TIERS (zeros) and error = null.
Fix: treat null as an error state; never default fee tiers to zero in user-visible code.
4. isOnline() collapses every SDK error into "offline"
app/self-custodial/providers/is-online.ts:5-12 + caller at use-sdk-lifecycle.ts:60-66 + app/self-custodial/components/offline-gate.tsx
Boolean conflates three states: confirmed online, confirmed offline, and "we couldn't tell because the SDK threw." Auth errors, malformed responses, mnemonic decryption errors all become Offline, gated behind a Retry button that re-runs the broken code. Also: Degraded is treated as online.
Fix: return a three-state result ("online" | "offline" | "unknown"); let the lifecycle hook decide what unknown means; surface Degraded distinctly so the UI can warn.
5. usePayments defaults to AccountType.Custodial during SC startup
app/hooks/use-payments.ts:46-72
accountType = activeAccount?.type ?? AccountType.Custodial. During SC startup activeAccount can be momentarily undefined; the hook hands back the custodial adapter shape (no sendPayment / getFee / receiveLightning / receiveOnchain). Consumers either no-op or take the wrong code path.
Fix: return undefined adapters in the loading window; don't default to a mode that doesn't match.
6. useOnchainFeeAlert always fires custodial GraphQL on SC
app/screens/send-bitcoin-screen/send-bitcoin-details-screen.tsx:220-224, 731-793
useOnChainTxFeeLazyQuery runs whenever isOnchainPayment, including when isSelfCustodial && paymentType === Onchain. Fires an authenticated mutation that doesn't apply to SC; the high-fee modal logic is meaningless because SC fees come from useOnchainFeeTiers.
Fix: gate on !isSelfCustodial.
7. refreshWallets lifecycle race + recursive finally re-call
app/self-custodial/providers/use-sdk-lifecycle.ts:51-88, 114, 122
- TOCTOU between
pendingRefreshRefcheck (line 53) and clear (line 84). - Recursive
refreshWallets()invocation infinally(line 85) is unawaited and uncaught. - Three independent triggers (refresh chain, AppState, 10s
setInterval) all fire-and-forget. - Status-fold logic at line 79 can transition
Loading → Readyon a refresh failure → an empty wallet looks like a successful empty wallet. - First-sync
.catch(() => {})(line 114) andgetUserSettings(...).catch(() => {})(line 122) silently latch failures with no Sentry record.
Fix: model the lifecycle as a state machine (reducer or xstate); replace empty .catch(() => {}) with structured failure handling that updates status and records to crashlytics.
8. Mapper silent fallbacks mis-attribute transactions
app/self-custodial/mappers/transaction-mapper.ts:22-37, 45-48
mapPaymentMethod: any unknown SDK enum value falls through toPaymentType.Lightning.mapDirection: any non-Sendvalue becomesReceive.mapCurrency: only checksdetails.tag === Token; future detail types silently default toWalletCurrency.Btc.
A future SDK enum or unanticipated direction silently mis-classifies tx history. No log, no Sentry.
Fix: make exhaustive (TypeScript never check on unhandled tag); on unknown input throw or record an error.
9. BIP-21 URI uses default JS number stringification
app/utils/bitcoin-uri.ts:27
${satsToBtc(amountSats)} produces "1e-8" for 1 sat — many BIP-21 wallet parsers reject exponential. Memo encoded via URLSearchParams, which percent-encodes spaces as + (BIP-21 expects %20).
Fix: format as fixed-decimal (.toFixed(8) then strip trailing zeros) and use encodeURIComponent for the memo.
10. Pagination hasMore over-counts after isKnownPayment filter
app/self-custodial/providers/wallet-snapshot.ts:50, 97, 120
hasMore = transactions.length >= TRANSACTIONS_PER_PAGE is computed after isKnownPayment filtering. If a page returns 20 raw items and 5 are filtered out, transactions.length === 15 and hasMore is wrongly false. Pagination silently truncates for token-heavy histories.
appendTransactions (lines 101-111) also doesn't dedupe by tx id; concurrent refresh between loadMore calls can duplicate entries.
Fix: track raw response length for hasMore; dedupe by id in appendTransactions (or move to cursor-based pagination).
11. Fund-recovery surface has zero tests
app/screens/unclaimed-deposits/unclaimed-deposits-screen.tsx (259 LOC), use-deposit-actions.ts (160 LOC), use-recommended-fee-tiers.ts (64 LOC), unclaimed-deposit-banner.tsx, utils.ts — all have no test files. This is the riskiest user flow added; broken refund logic costs users sats.
Fix: test files for each, prioritising use-deposit-actions (claim/refund + error classification + busy state) and use-recommended-fee-tiers (the source of Critical #2).
Important
| # | Item | Location |
|---|---|---|
| I1 | String-matched error classification (locale + version fragile) | bridge/deposits.ts:49, use-deposit-actions.ts:55-57, use-onchain-fee-tiers.ts:45-49 |
| I2 | OfflineGate only blocks on Offline — Error/Unavailable fall through with divergent in-screen guards |
app/self-custodial/components/offline-gate.tsx; consumers in receive-screen.tsx:42-54, send-bitcoin-destination-screen.tsx:730-748 |
| I3 | Conversion screens are OfflineGated but conversion is custodial-only; SC users can press the Transfer button on home-screen |
root-navigator.tsx:177-178 + home-screen.tsx:449-463 |
| I4 | useDisplayCurrency lost skip: !isAuthed — fires for unauthed/SC sessions |
app/hooks/use-display-currency.ts:119 |
| I5 | Race in unclaimed-deposit-banner between useFocusEffect and useEffect; cancelled flag is per-call and doesn't coordinate |
app/components/unclaimed-deposit-banner/unclaimed-deposit-banner.tsx:29-46 |
| I6 | ReceiveScreen invokes both usePaymentRequest() and useSelfCustodialPaymentRequest() on every render; cast strips type-safety |
app/screens/receive-bitcoin-screen/receive-screen.tsx:46-65 |
| I7 | use-payment-request.ts fire-and-forget adapter().then(...) with no .catch; onchain address never refreshes after first set even if SDK reconnects |
app/self-custodial/hooks/use-payment-request.ts:107-113 |
| I8 | Send mutations log raw SDK English text into the GraphQL error path; no crashlytics().recordError — failed sends never reach Sentry |
send-helpers.ts:80-98, 100-121, bridge/{convert,receive}.ts |
| I9 | extractOnchainFees always picks fees.fast, ignoring feeTier declared on SendPaymentParams |
app/self-custodial/adapters/payment-adapter.ts:58-72 |
| I10 | parseDepositId has no validation; malformed id → vout: NaN reaches SDK |
app/self-custodial/adapters/deposit-adapter.ts:55-61 |
| I11 | getClaimFee always returns feeAmount: 0 (placeholder pretending to be feature-complete) |
app/self-custodial/adapters/deposit-adapter.ts:91-94 |
| I12 | useOnchainFeeTiers doesn't abort in-flight prepareSend on dep change — race where stale tiers overwrite fresh |
app/screens/send-bitcoin-screen/hooks/use-onchain-fee-tiers.ts |
| I13 | transaction-history-screen writes every SC fragment to Apollo cache on every change of selfCustodialFragments; depends on LL so locale changes trigger render storms |
app/screens/transaction-history/transaction-history-screen.tsx:187-196 |
| I14 | selfCustodialRestoreWallet has no equivalent rollback or BIP39 validation (compare selfCustodialCreateWallet) |
app/self-custodial/bridge/lifecycle.ts:112-117 |
| I15 | parseSparkAddress swallows all errors as null — caller cannot distinguish "not a Spark address" from "SDK threw" |
app/self-custodial/bridge/parse.ts:36-38 |
Test coverage
Estimated behavioural coverage of the new SC layer: ~62%, concentrated on bridge / mappers / wallet-provider; sparse on the screen layer and adapters.
Tier 1 — production code with no test file
Verified against __tests__/:
| File | LOC | Notes |
|---|---|---|
app/screens/unclaimed-deposits/hooks/use-deposit-actions.ts |
160 | All claim/refund error classification + dust heuristic + busy state |
app/screens/unclaimed-deposits/unclaimed-deposits-screen.tsx |
259 | Refund flow: address input, fee tier selection, mempool linking, reset |
app/screens/unclaimed-deposits/hooks/use-recommended-fee-tiers.ts |
64 | Source of Critical #2 |
app/screens/unclaimed-deposits/utils.ts |
12 | Mempool URL builder + placeholder |
app/components/unclaimed-deposit-banner/unclaimed-deposit-banner.tsx |
94 | Race in I5; only entry point for unclaimed deposits |
app/screens/send-bitcoin-screen/fee-tier-selector.tsx |
160 | UI users tap when choosing a fee tier |
app/screens/send-bitcoin-screen/hooks/use-onchain-fee-tier-options.ts |
120 | Owns setFeeTier, rebuild-detail-on-tier-change logic |
app/self-custodial/bridge/convert.ts |
43 | No direct test; only transitively via payment-adapter.spec.ts happy path |
app/self-custodial/bridge/lifecycle.ts |
117 | createWallet rollback path is security-critical |
app/navigation/navigation-container-wrapper.tsx |
(modified) | canHandlePayments change for SC deep links |
app/screens/receive-bitcoin-screen/receive-screen.tsx |
(modified) | Dual-hook invocation + cast logic in I6 |
Tier 2 — tests that pass while a Critical bug is alive
These create false confidence. Each should be made to fail on the current code first, then fixed alongside the production fix.
| Test file | What it should catch but doesn't | Bug |
|---|---|---|
__tests__/utils/bitcoin-uri.spec.ts |
Smallest tested amount is 1000 sats. Never tests 1 sat (1e-8) where JS flips to exponential. Memo encoding (+ vs %20) unverified. |
#9 |
__tests__/self-custodial/payment-details/lightning.spec.ts |
Lines 9-16 mock send-helpers — including createGetFee. The LIGHTNING_FEE_SATS = 0 bug is invisible. Mocks return bare jest.fn() with no implementation. |
#1 |
__tests__/self-custodial/adapters/payment-adapter.spec.ts |
createGetFee onchain branch (feeTier, confirmationEtaMinutes, totalDebited) completely unasserted. Hardcoded feeTier: Fast survives. |
#6, I9 |
__tests__/self-custodial/payment-details/send-helpers.spec.ts |
Verify whether returned fee value matches prepareSend quote, or only call-count. |
#1 |
__tests__/self-custodial/mappers/transaction-mapper.spec.ts |
Silent fallback branches (unknown SDK enum → Lightning/Receive/Btc) not tested with unknown enum values. |
#8 |
__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts |
Mixed-unit-fee bug (fee path always uses currency: WalletCurrency.Btc) not exercised on a token tx. |
(mappers) |
__tests__/self-custodial/providers/is-online.spec.ts |
Walks every ServiceStatus enum but never tests the caller's mis-classification of "SDK threw" as "offline." |
#4 |
__tests__/self-custodial/providers/wallet-snapshot.spec.ts |
Verify: does it test post-filter hasMore over-counting and appendTransactions dedupe? If not, #10 uncovered. |
#10 |
__tests__/self-custodial/providers/wallet-provider.spec.tsx |
Recursive refreshWallets() in finally not directly tested. The suite acknowledges (lines 463-497) that Ready→Error via refresh is unreachable; the OFFLINE_EXEMPT_STATUSES Error preservation is effectively untested. |
#7 |
__tests__/self-custodial/hooks/use-payment-request.spec.ts |
Onchain receive adapter().then(...) with no .catch — rejected adapter throws unhandled rejection; not asserted. |
I7 |
__tests__/self-custodial/providers/sdk-events.spec.ts |
expect(REFRESH_EVENTS.size).toBeGreaterThanOrEqual(5) is tautological. Should assert specific tag membership. |
(test quality) |
Tier 3 — Critical bugs with no test coverage anywhere
For each, no existing test exercises the behaviour. Add a regression test alongside the fix.
| Critical | Recommended test |
|---|---|
| #1 | Direct createGetFee test (un-mocked) asserting fee value comes from extractLightningFee(prepared) |
| #2 | useRecommendedFeeTiers rejection → tiers stay zero AND surface error / refuse use |
| #3 | useOnchainFeeTiers with non-onchain prepared → tiers untouched, error explicit |
| #4 | Caller-level test: SDK throws non-status error → caller distinguishes unknown from offline |
| #5 | usePayments with activeAccount: undefined and SC mode → no SC operation routed to custodial |
| #6 | send-bitcoin-details-screen test: SC user + onchain → custodial GraphQL not called |
| #7 | wallet-provider test: pendingRefreshRef = true + recursive throw → outer error capture |
| #8 | Mapper tests with unknown SDK enum → assertion the fallback is explicit (logged/classified/thrown) |
| #10 | Full page that filters down → hasMore: true; back-to-back loadMore with overlapping ids → unique entries |
| Refund (#2) | use-deposit-actions test: feeRateSatPerVb: 0 → throws or refuses, NOT calls SDK |
Suggested order of operations
- Identify originating PR per Critical bug and route fixes via
gt absorbinto the right PR in the stack (several Critical items likely originated in #3746 "shared wallet abstractions" — specifically #5, #7, possibly #4 and #10). - In #3758: land the Critical bugs that originated here (#1, #2, #3, #6, #8, #9, #11) plus their regression tests.
- Write the missing test files (Tier 1) and the regression tests (Tier 3) alongside the production fixes.
- Un-mock
send-helpersinlightning.spec.tsand replace tautological cardinality assertions insdk-events.spec.ts(Tier 2 quality fixes). - Important items I1–I15 — most are small; some can fold into the same commits as the Critical fixes.
Structural and naming feedback (adapter-shape unification, mode-leakage cleanup, ~50 rename suggestions) will follow as a separate note, scoped as post-stack work against main so it doesn't restack-bomb your downstream PRs (#3761–#3769).
5affa3c to
9205cc7
Compare
d129a05 to
5e2acfa
Compare
…known SDK enum values
… surface init failures
…r, and receive screen
…nd dedupe tier maps
…annot init the SDK
…yped SDK errors, share ETA constant
…dundant tier-to-fee-key map
…stodial shape and report onchain adapter failures
|
Hi @grimen - the feedback is applied. Summary below per item with the commit hash where it landed; details follow. CriticalC1 -
|

PR Summary — Self-Custodial Payments and Transaction History
Feature: #647 — Payments and Transaction History
Epic: 4 — Bitcoin Payments & History
This PR delivers the full send, receive, routing, transaction-history and offline-blocking behavior for self-custodial accounts across Lightning, on-chain and Spark flows. It sits on top of
feat--spark-backup-and-recoveryand closes every task in #647.Task checklist
What's delivered
4.1 — Transaction normalizer
NormalizedTransactionwithmemo,lnAddress,tokenTicker,isConversionandsourceAccountType.mappers/transaction-mapper.tsnormalizes BreezPaymentintoNormalizedTransaction, with extractors for Lightning, Spark and Token details, plus currency inference (mapCurrency), direction, status and fee handling.amounts.tsgainstokenBaseUnitsToCentsandtoSatsAmounthelpers used across receive/send flows.PaymentMethod.DepositandPaymentMethod.Withdrawmap toPaymentType.Onchain.4.2 — Lightning send + receive
bridge/receive.ts—createReceiveLightning,createReceiveOnchain.bridge/send.ts—prepareSend,executeSend, lightning / onchain fee extractors.hooks/use-payment-request.ts— SC receive hook with Lightning invoice + on-chain address generation, memo/amount, payment detection via provider events, URI formatting through sharedbuildLightningUri/buildBitcoinUri. Generation is gated on active-wallet readiness and wrapped in try/catch so adapter throws surface asErrorstate.payment-details/lightning.ts+payment-details/send-helpers.ts— SC lightning send payment-detail factory consuming the bridge.receive-screen.tsxandsend-bitcoin-*screens wired throughuseOnchainResolver,use-send-wallets,use-onchain-fee-tier-optionsandwrap-destination.use-payments.tsregisters SC adapters (receiveLightning, receiveOnchain, sendLightning, sendOnchain, deposits, convert).use-display-currency.tsandgraphql/client.tsxfall through cleanly for SC users.4.3 — On-chain receive, claim and below-minimum guidance
bridge/deposits.ts— list / claim / refund unclaimed deposits, with error classification for below-minimum and other failure states.adapters/deposit-adapter.ts— adapter exposing claim/refund to the UI.components/unclaimed-deposit-banner/+ integration inhome-screen.tsx.screens/unclaimed-deposits/— full flow: list screen,use-deposit-actions,use-recommended-fee-tiers,deposit-error-messagefor below-min / fee-estimation errors.4.4 — On-chain send: fee tier selector
screens/send-bitcoin-screen/fee-tier-selector.tsx— 3-tier UI (Slow / Medium / Fast).hooks/use-onchain-fee-tiers.ts,use-onchain-fee-tier-options.ts,format-fee-tier-options.ts— fee quoting and formatting.payment-details/onchain.ts— SC onchain payment-detail factory with selected tier propagated through confirm/send.4.5 — Spark payment adapters
bridge/parse.ts+bridge/convert.ts— input parsing and stable-balance conversions.payment-destination/spark.tsandpayment-details/spark.ts— Spark destination resolver and payment-detail factory.payment-details/wrap-destination.ts— routes a resolved destination to the matching SC factory (lightning / onchain / spark).adapters/payment-adapter.ts— unifies send adapter behind the same shape the custodial flow uses.4.6 — Payment routing
navigation/navigation-container-wrapper.tsx—canHandlePayments = isAuthed || isSelfCustodial, so deep links open for SC users.navigation/root-navigator.tsx+stack-param-lists.ts— unclaimed-deposits route registered.send-bitcoin-destination-screen.tsx— destination wrapping applied for SC accounts before navigation.4.6 — Offline blocking (FR37)
Delivered in this PR's final 5 commits:
bridge/status.ts— wraps Breez SDK'sgetSparkStatus()returning the worst status across Spark Operators + SSP. No new deps; no third-party pings.providers/is-online.ts— mapsServiceStatus:Operational/Degraded→ online,Partial/Unknown/Majoror SDK throw → offline.providers/use-sdk-lifecycle.ts:OFFLINE_EXEMPT_STATUSES(Error,Unavailable) never transition toOffline.refreshWalletsperforms an online check before hitting the SDK; if offline, setsOfflinewithout touching local cache.refreshWalletsso offline detection does not require user interaction.AppState → activealso triggers a refresh for immediate recovery when returning from background.hocs/with-offline-gate.tsx— memoized HOC (WeakMap cache per Component → stable reference required by React Navigation, no intermediate constants). RendersScPaymentOfflineNoticewhenisSelfCustodial && status === Offline; passes through otherwise. Custodial users are untouched.components/sc-payment-offline-notice/— title + description + "Try again" button wired torefreshWallets.navigation/root-navigator.tsx— 9 payment routes wrapped inline (scanningQRCode, sendBitcoinDestination, sendBitcoinDetails, sendBitcoinConfirmation, receiveBitcoin, redeemBitcoinDetail, conversionDetails, conversionConfirmation, unclaimedDepositsScreen).getWalletInfotoensureSynced:falseso startup doesn't hang when Spark operators are unreachable — the polling detects offline instead.4.7 — All Transactions feed
mappers/to-transaction-fragment.ts— mapsNormalizedTransaction[]into ApolloTransactionFragment[]for cache compatibility.mappers/transaction-description.ts— human-readable description resolver.transaction-history-screen.tsx— SC and custodial transactions render in the same feed.transaction-detail-screen.tsx— hides "Blink Internal Id" for SC transactions.wallet-snapshot.ts— paginated fetch,isKnownPaymentfilter,getStableBalanceaccepts bothMapandRecord, plusappendTransactionshelper used byloadMore.SDK lifecycle hardening
providers/sdk-events.ts— extractedREFRESH_EVENTS,PAYMENT_RECEIVED_EVENTSandextractPaymentId.providers/validate-network.ts— extracted network-match guard (legacy wallets without a stored network are allowed).providers/use-sdk-lifecycle.ts— refactored around the extracted helpers;getUserSettingsis deferred off the critical init path,refreshWalletsis exposed,loadMorenow appends viaappendTransactions.logging.ts— suppresses the noisy "Received empty event" SDK log line.config.ts—SparkNetworkLabelexported and reused in bridge + validator.Infrastructure and shared utilities
app/utils/bitcoin-uri.ts— sharedbuildBitcoinUri,buildLightningUri,satsToBtc.app/screens/receive-bitcoin-screen/hooks/use-onchain-resolver.ts— resolves on-chain data from either SC or custodial hooks transparently.i18n— new keys for fee tiers, unclaimed deposits, SC transaction descriptions, andSelfCustodialOfflinenotice copy across every supported language (28 locales).__mocks__/@breeztech/breez-sdk-spark-react-native.js— mock kept in sync with new enum values (ServiceStatus), payment-detail tags andgetSparkStatus.Testing
~300 suites passing with full coverage added for every domain touched:
transaction-mapper,to-transaction-fragment,transaction-description.bridge.spec.ts,bridge/deposits,bridge/parse,bridge/send,bridge/wallet(ensureSynced:falsecontract),bridge/status(delegation + error propagation),bridge-mainnet-guard.adapters/deposit-adapter,adapters/payment-adapter.payment-details/lightning,payment-details/onchain,payment-details/send-helpers,payment-details/wrap-destination.providers/sdk-events,providers/validate-network.providers/wallet-snapshot—appendTransactionsfiltering + immutability.providers/is-online— one test perServiceStatusvariant + error path.providers/wallet-provider— refresh coalescing, payment-id propagation,loadMore,Ready↔Offlinetransitions,Error/Unavailablepreservation under offline,Loading→Offlineinitial path, 10s polling interval (fake timers), polling cleanup on unmount,AppStateactive triggers refresh / background does not.hocs/with-offline-gate— custodial pass-through, SC+Offline notice, SC+Ready pass-through, prop forwarding,displayName, cache stability per component.components/sc-payment-offline-notice— renders i18n copy, retry button callsrefreshWallets(fireEvent, idempotent).hooks/use-payment-request(create, error, adapter-throw, ready-gate, re-entry, full cycle),hooks/use-payments,hooks/use-send-wallets,hooks/use-onchain-fee-tiers,hooks/format-fee-tier-options.transaction-history-dates,payment-destination/spark,receive-bitcoin/helpers,utils/bitcoin-uri,types/to-sats-amount.config.spec.ts(network label + storage scoping),logging.spec.ts(level routing + empty-event suppression).Known follow-ups (out of scope for this PR)
getSparkStatusreportsDegraded/Partialbut the user remains online enough to reach services, the balance may be computed as 0 because the wallet-tree sync is incomplete. This affects multiple users and is independent of this PR. Tracked separately with analysis in~/Desktop/sc-balance-stale-issue.txt. Will land as a dedicated ticket.feature-flags-context.tsxdefaults,graphql/generated.tscodegen drift, settings-screen dev section, dev-reset-wallet) — those are not part of this feature's scope.