diff --git a/__mocks__/nostr-tools.js b/__mocks__/nostr-tools.js new file mode 100644 index 0000000..01d3d0c --- /dev/null +++ b/__mocks__/nostr-tools.js @@ -0,0 +1,46 @@ +/** + * Jest manual mock for nostr-tools and all of its subpaths. + * + * nostr-tools ships TypeScript SOURCE and vendors its own @noble/curves and + * @noble/hashes (also source) which import old-style subpaths jest cannot resolve. + * Mapping those would silently redirect the nested copies onto the top-level @noble + * packages — different versions, on crypto code. Not a trade worth making to run a + * test. + * + * This matters far beyond nostr: `services/keyChain` imports nostr-tools, the + * services barrel imports keyChain, and the MODEL layer imports that barrel — so + * nostr-tools loads for every store test even though none goes near nostr. + * + * Calls throw rather than returning plausible fakes: a test that silently derives a + * bogus key and asserts on it is worse than one that stops and explains. Code that + * genuinely needs nostr should mock the calling service (as the existing suites do + * with nostrService). + */ +const makeNamespace = path => { + const cache = new Map() + return new Proxy( + {}, + { + get(_target, prop) { + if (prop === '__esModule') return true + if (prop === 'then') return undefined + if (typeof prop === 'symbol') return undefined + + if (!cache.has(prop)) { + const name = `${path}.${String(prop)}` + const fn = () => { + throw new Error( + `[nostr-tools mock] ${name}() was called in a test. This mock exists ` + + `only so the module graph resolves — nostr-tools ships TS source ` + + `with vendored @noble packages. Mock the calling service instead.`, + ) + } + cache.set(prop, fn) + } + return cache.get(prop) + }, + }, + ) +} + +module.exports = makeNamespace('nostr-tools') diff --git a/__mocks__/op-sqlite.js b/__mocks__/op-sqlite.js new file mode 100644 index 0000000..ca1c44e --- /dev/null +++ b/__mocks__/op-sqlite.js @@ -0,0 +1,43 @@ +/** + * Jest manual mock for @op-engineering/op-sqlite. + * + * op-sqlite is a native module and cannot load under jest, yet the whole model + * layer imports it transitively (`services -> db -> connection`), which is what + * blocked instantiating MST stores in a test. + * + * This is an IMPORT-TIME shim, not a database. It exists so the module graph + * resolves; it deliberately does NOT emulate SQLite. Anything that actually + * executes a statement throws loudly rather than silently returning empty results, + * because a test that believes it wrote to a database and did not is worse than a + * test that fails. + * + * Two ways to test around it: + * - Model/view logic: stub the `Database` facade (`jest.mock('../src/services')`) + * and drive the MST tree directly. + * - Real SQL semantics: use node:sqlite and mirror the production statements, as + * the db suites do (see sqliteMigration*.test.ts). + */ +const notImplemented = name => () => { + throw new Error( + `[op-sqlite mock] ${name}() was called in a test. This shim only makes the ` + + `module graph resolve — it is not a database. Stub the Database facade, or ` + + `use node:sqlite with the production SQL (see the db test suites).`, + ) +} + +const open = () => ({ + execute: notImplemented('execute'), + executeSync: notImplemented('executeSync'), + executeAsync: notImplemented('executeAsync'), + executeBatch: notImplemented('executeBatch'), + executeBatchAsync: notImplemented('executeBatchAsync'), + close: () => {}, + delete: () => {}, +}) + +module.exports = { + open, + IOS_DOCUMENT_PATH: '/mock/ios/documents', + ANDROID_FILES_PATH: '/mock/android/files', +} +module.exports.__esModule = true diff --git a/__mocks__/react-native-mmkv.js b/__mocks__/react-native-mmkv.js new file mode 100644 index 0000000..6f58971 --- /dev/null +++ b/__mocks__/react-native-mmkv.js @@ -0,0 +1,56 @@ +/** + * Jest manual mock for react-native-mmkv. + * + * MMKV is a native module, so it cannot load under jest. Everything in the model + * layer reaches it transitively — `Mint -> theme -> useThemeColor -> services -> + * mmkvStorage` — which is what made MST stores impossible to instantiate in a test + * at all. + * + * Backed by a plain Map per instance id, so it behaves like real storage for the + * small surface mmkvStorage uses (getString/set/delete/clearAll). Tests that care + * about persistence can therefore round-trip; tests that do not can ignore it. + */ +class MMKV { + constructor(config = {}) { + // Keyed by id so two MMKV instances stay isolated, as they are natively. + const id = config.id ?? 'default' + if (!MMKV._stores.has(id)) MMKV._stores.set(id, new Map()) + this._store = MMKV._stores.get(id) + } + + getString(key) { + const value = this._store.get(key) + return typeof value === 'string' ? value : undefined + } + + set(key, value) { + this._store.set(key, value) + } + + delete(key) { + this._store.delete(key) + } + + clearAll() { + this._store.clear() + } + + getAllKeys() { + return Array.from(this._store.keys()) + } + + contains(key) { + return this._store.has(key) + } +} + +/** Every instance ever created, by id. Reset between tests via __resetMMKV(). */ +MMKV._stores = new Map() + +/** Test helper: drop all stored data so suites cannot leak state into each other. */ +const __resetMMKV = () => { + MMKV._stores.clear() +} + +module.exports = {MMKV, __resetMMKV} +module.exports.__esModule = true diff --git a/__mocks__/sentry-react-native.js b/__mocks__/sentry-react-native.js new file mode 100644 index 0000000..95a6119 --- /dev/null +++ b/__mocks__/sentry-react-native.js @@ -0,0 +1,40 @@ +/** + * Jest manual mock for @sentry/react-native. + * + * Sentry ships untransformed ESM, and `transformIgnorePatterns` does not cover it, + * so any module reaching it fails to parse. `services/logService` imports it at + * load time and the whole app imports logService — which is why every existing + * suite mocks logService wholesale just to avoid this. + * + * Mocking Sentry instead of logService lets the REAL logService load, so tests can + * exercise code that logs (and assert on it) rather than replacing the logger. + * + * A Proxy backs every property with a lazily-created jest.fn(), so this keeps + * working as the Sentry surface changes — no enumeration to drift. `logger` is a + * nested namespace and gets the same treatment. + */ +const makeNamespace = () => { + const fns = new Map() + return new Proxy( + {}, + { + get(_target, prop) { + // Jest/Node poke at these during interop and inspection; answering with a + // mock fn confuses both. + if (prop === '__esModule') return true + if (prop === 'then') return undefined + if (typeof prop === 'symbol') return undefined + + if (prop === 'logger') { + if (!fns.has('logger')) fns.set('logger', makeNamespace()) + return fns.get('logger') + } + + if (!fns.has(prop)) fns.set(prop, jest.fn()) + return fns.get(prop) + }, + }, + ) +} + +module.exports = makeNamespace() diff --git a/__tests__/orphanedProofs.test.ts b/__tests__/orphanedProofs.test.ts new file mode 100644 index 0000000..89e2657 --- /dev/null +++ b/__tests__/orphanedProofs.test.ts @@ -0,0 +1,184 @@ +/** + * Balance behaviour when a proof's mintUrl matches no mint (ProofsStore). + * + * `proofs.mintUrl` is a denormalized copy of a mint's LOCATOR, joined to + * `mint.mintUrl` by string equality across two persistence engines — proofs in + * SQLite, mint.mintUrl in the MMKV snapshot. A crash between those writes during a + * mint-url edit desyncs them. + * + * What must hold when that happens: + * - the sats stay VISIBLE in the unit total (they are real and the user's), and + * - the wallet reports it, rather than silently swallowing it. + * + * Hiding the money, refusing to start, or forcing a recovery would all be worse + * outcomes than a total that reads a little high, and the state self-heals once a + * mint is (re-)added at that url. + * + * This is the first suite to instantiate MST stores. It uses a MINIMAL root + * (mintsStore + proofsStore) rather than the real RootStore, which additionally + * pulls AuthStore/NwcStore/WalletProfileStore and, through them, the whole service + * layer. getRootStore only needs the root to expose the stores actually used. + */ +jest.mock('../src/services/nostrService', () => ({ + // cashuUtils -> nostrService -> minibitsService -> models is an import CYCLE; + // mocking nostrService cuts it, as every other suite here does. + NostrClient: {getFirstTagValue: jest.fn()}, +})) + +// The real logger would work here (jest maps Sentry to a mock), but react-native-logs +// dispatches to its transport on a timer, so it logs after teardown — and a real +// `log.error` is not a spy to assert on. +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +import {types} from 'mobx-state-tree' +import {MintsStoreModel} from '../src/models/MintsStore' +import {ProofsStoreModel} from '../src/models/ProofsStore' +import {log} from '../src/services/logService' + +// Named 'RootStore' so getRootStore's typing lines up with the real tree. +const TestRoot = types.model('RootStore', { + mintsStore: types.optional(MintsStoreModel, {}), + proofsStore: types.optional(ProofsStoreModel, {}), +}) + +const MINT_URL = 'https://mint.test' +const MOVED_URL = 'https://moved.mint.test' + +const mintSnapshot = (mintUrl: string) => ({ + id: 'mint1111', + mintUrl, + units: ['sat'], +}) + +const proofSnapshot = (secret: string, amount: number, mintUrl: string, state = 'UNSPENT') => ({ + id: 'keyset1', + amount, + secret, + C: 'C' + secret, + unit: 'sat', + tId: 1, + mintUrl, + state, +}) + +const makeStore = (mintUrl: string, proofs: Array>) => + TestRoot.create({ + mintsStore: {mints: [mintSnapshot(mintUrl)]}, + proofsStore: {proofs: Object.fromEntries(proofs.map(p => [p.secret, p]))}, + }) + +describe('proofs whose mint is not in the wallet', () => { + describe('balances — the money stays visible', () => { + test('an attached proof counts toward BOTH its mint and the unit total', () => { + const root = makeStore(MINT_URL, [proofSnapshot('a', 100, MINT_URL)]) + const {balances} = root.proofsStore + + expect(balances.mintBalances[0].balances.sat).toBe(100) + expect(balances.unitBalances.find(u => u.unit === 'sat')!.unitBalance).toBe(100) + }) + + // The core promise: a desync must never hide the user's sats. + test('an orphaned proof still counts toward the unit total', () => { + const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)]) + const {balances} = root.proofsStore + + expect(balances.unitBalances.find(u => u.unit === 'sat')!.unitBalance).toBe(100) + }) + + test('but is attributed to no mint, so the total can exceed the sum of mints', () => { + const root = makeStore(MOVED_URL, [ + proofSnapshot('a', 100, MOVED_URL), // attached + proofSnapshot('b', 40, MINT_URL), // orphaned by a url edit + ]) + const {balances} = root.proofsStore + + expect(balances.mintBalances[0].balances.sat).toBe(100) + expect(balances.unitBalances.find(u => u.unit === 'sat')!.unitBalance).toBe(140) + }) + + test('reading balances does not throw', () => { + const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)]) + expect(() => root.proofsStore.balances).not.toThrow() + }) + }) + + describe('findOrphanedProofs', () => { + test('is empty in the normal case', () => { + const root = makeStore(MINT_URL, [proofSnapshot('a', 100, MINT_URL)]) + expect(root.proofsStore.findOrphanedProofs()).toEqual([]) + }) + + test('groups by url, with count and amount', () => { + const root = makeStore(MOVED_URL, [ + proofSnapshot('a', 100, MINT_URL), + proofSnapshot('b', 40, MINT_URL), + proofSnapshot('c', 7, MOVED_URL), // attached, not reported + ]) + + expect(root.proofsStore.findOrphanedProofs()).toEqual([ + {mintUrl: MINT_URL, count: 2, amount: 140}, + ]) + }) + + // Removing a mint leaves its proofs SPENT behind. Reporting those would fire on + // every legitimate removal. + test('ignores SPENT proofs', () => { + const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL, 'SPENT')]) + expect(root.proofsStore.findOrphanedProofs()).toEqual([]) + }) + + test('includes PENDING proofs — they are still the user\'s money', () => { + const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL, 'PENDING')]) + expect(root.proofsStore.findOrphanedProofs()).toEqual([ + {mintUrl: MINT_URL, count: 1, amount: 100}, + ]) + }) + }) + + describe('reportOrphanedProofs — observe, never act', () => { + beforeEach(() => jest.clearAllMocks()) + + test('stays silent when everything is attached', () => { + const root = makeStore(MINT_URL, [proofSnapshot('a', 100, MINT_URL)]) + expect(root.proofsStore.reportOrphanedProofs()).toEqual([]) + expect(log.error).not.toHaveBeenCalled() + }) + + test('logs at error (→ Sentry) with the url and stranded amount', () => { + const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)]) + const reported = root.proofsStore.reportOrphanedProofs() + + expect(reported).toEqual([{mintUrl: MINT_URL, count: 1, amount: 100}]) + expect(log.error).toHaveBeenCalledTimes(1) + + const params = (log.error as jest.Mock).mock.calls[0][2] + expect(params.totalAmount).toBe(100) + expect(params.orphaned).toEqual([{mintUrl: MINT_URL, count: 1, amount: 100}]) + }) + + test('changes nothing — the proofs and the balance are untouched', () => { + const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)]) + + root.proofsStore.reportOrphanedProofs() + + // Still present, still counted: reporting must never quarantine the money. + expect(root.proofsStore.proofs.size).toBe(1) + expect(root.proofsStore.getBySecret('a')!.mintUrl).toBe(MINT_URL) + const {balances} = root.proofsStore + expect(balances.unitBalances.find(u => u.unit === 'sat')!.unitBalance).toBe(100) + }) + + test('self-heals once a mint exists at that url again', () => { + const root = makeStore(MOVED_URL, [proofSnapshot('a', 100, MINT_URL)]) + expect(root.proofsStore.findOrphanedProofs()).toHaveLength(1) + + // The rename is completed (or reverted) and the mint answers there again. + root.mintsStore.mints[0].setProp('mintUrl', MINT_URL) + + expect(root.proofsStore.findOrphanedProofs()).toEqual([]) + expect(root.proofsStore.balances.mintBalances[0].balances.sat).toBe(100) + }) + }) +}) diff --git a/jest.config.js b/jest.config.js index 3c19c49..11509e6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,10 +7,32 @@ module.exports = { transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native|@react-native-community|@cashu|@noble|@scure|react-native-flash-message)/)', ], + // Several native or source-shipped packages are unusable under jest, and the + // MODEL layer reaches all of them at import time (Mint -> services barrel -> + // mmkvStorage / keyChain / db). That is what made MST stores impossible to + // instantiate in a test at all; these mappings are what make store tests possible. moduleNameMapper: { '^@noble/hashes/utils$': '@noble/hashes/utils.js', + // Same shape as the @noble mapping: the package only exports the explicit + // `.js` subpath, which jest's resolver will not infer. + '^@scure/bip39/wordlists/(.*)$': '@scure/bip39/wordlists/$1.js', // quick-crypto's native module is unavailable under jest; route to a // Node `crypto` shim so deps that import it at load time (e.g. bip32) work. '^react-native-quick-crypto$': '/__mocks__/react-native-quick-crypto.js', + // MMKV and op-sqlite are native. MMKV is Map-backed and behaves; op-sqlite is + // only an import-time shim and throws if a statement is actually executed — + // use node:sqlite with the production SQL for real DB semantics (see the db + // suites). + '^react-native-mmkv$': '/__mocks__/react-native-mmkv.js', + '^@op-engineering/op-sqlite$': '/__mocks__/op-sqlite.js', + // Sentry ships untransformed ESM, so anything importing logService fails to + // parse. Mocking Sentry rather than logService lets the REAL logger load, so + // tests can cover code that logs instead of stubbing the logger away. + '^@sentry/react-native$': '/__mocks__/sentry-react-native.js', + // nostr-tools ships TS source and vendors its own @noble/curves + @noble/hashes + // (also source), importing subpaths jest cannot resolve. Mapping those would + // cross the nested copies onto the top-level @noble versions — on crypto code. + // Mock the surface instead; nothing in the model layer needs real nostr. + '^nostr-tools(/.*)?$': '/__mocks__/nostr-tools.js', }, } diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 0d0d52d..3ee9e07 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -8,13 +8,23 @@ import { type SwapMethod, Mint as CashuMint, } from '@cashu/cashu-ts' -import {colors, getRandomIconColor} from '../theme' -import { log, Database } from '../services' +// From '../theme/colors', NOT the '../theme' barrel. The barrel re-exports +// useThemeColor, which imports '../services' — so a barrel import here would make +// this model transitively depend on mmkvStorage, keyChain (and nostr-tools) and the +// database, purely to read two colour constants. colors.ts imports nothing but +// react-native. +import {colors} from '../theme/colors' +// Direct imports, NOT the '../services' barrel: that barrel re-exports +// walletService -> syncQueueService -> notificationService (notifee) and keyChain +// (nostr-tools), so a barrel import here makes this model transitively depend on +// most of the app — and on native modules — to read a logger and the db facade. +import { log } from '../services/logService' +import { Database } from '../services/db' import AppError, { Err } from '../utils/AppError' import { MintUnit, MintUnits } from '../services/wallet/currency' import { getRootStore } from './helpers/getRootStore' -import { generateId } from '../utils/utils' +import { generateId } from '../utils/generateId' import { Proof } from './Proof' import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils' import { normalizeMintUrl } from '../services/cashu/mintUrl' @@ -528,9 +538,6 @@ export const MintModel = types self.shortname = shortname }), - setRandomColor() { - self.color = getRandomIconColor() - }, setColor(color: string) { self.color = color }, diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 846e4ee..b7c510c 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -11,7 +11,8 @@ import { import {MintModel, Mint, MintProofsCounter} from './Mint' import {normalizeMintUrl} from '../services/cashu/mintUrl' import {log} from '../services/logService' - import {Database} from '../services' + // Direct import, not the '../services' barrel — see the note in Mint.ts. + import {Database} from '../services/db' import AppError, { Err } from '../utils/AppError' import { Mint as CashuMint, diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 165ec7d..ea8d025 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -11,11 +11,12 @@ import { import { getRootStore } from './helpers/getRootStore' import AppError, { Err } from '../utils/AppError' import { Mint, MintBalance } from './Mint' - import { Database } from '../services' + // Direct import, not the '../services' barrel — see the note in Mint.ts. + import { Database } from '../services/db' import { ReservationTransactionUpdate } from '../services/sqlite' import { MintUnit } from '../services/wallet/currency' import { CashuProof } from '../services/cashu/cashuUtils' - import { generateId } from '../utils/utils' + import { generateId } from '../utils/generateId' import { ProofReservation } from '../services/wallet/proofReservation' export const ProofsStoreModel = types @@ -73,6 +74,38 @@ import { return undefined }, + /** + * Spendable proofs whose `mintUrl` matches no mint in the wallet, grouped by + * that url. + * + * This should always be empty. `proofs.mintUrl` is a denormalized copy of a + * mint's LOCATOR, and it is joined to `mint.mintUrl` by string equality + * across two persistence engines — proofs in SQLite, mint.mintUrl in the + * MMKV snapshot. A crash between those two writes during a mint-url edit + * desyncs them, and the balance view then counts the proofs in the unit total + * while attributing them to no mint (see `balances`), which also makes them + * unspendable, since send and melt select proofs by mint. + * + * SPENT proofs are excluded: they are outside the balance and are exactly + * what a removed mint leaves behind. + */ + findOrphanedProofs(): Array<{mintUrl: string; count: number; amount: number}> { + const knownMintUrls = new Set(getRootStore(self).mintsStore.allMints.map((m: Mint) => m.mintUrl)) + const byMintUrl = new Map() + + for (const proof of self.proofs.values()) { + if (proof.state === 'SPENT') continue + if (knownMintUrls.has(proof.mintUrl)) continue + + const entry = byMintUrl.get(proof.mintUrl) ?? {mintUrl: proof.mintUrl, count: 0, amount: 0} + entry.count += 1 + entry.amount += proof.amount + byMintUrl.set(proof.mintUrl, entry) + } + + return Array.from(byMintUrl.values()) + }, + getByMint( mintUrl: string, options: { @@ -146,6 +179,37 @@ import { }) }), + /** + * Report (never fix, never throw) proofs left pointing at a mint the wallet + * does not have — see findOrphanedProofs for how that can happen. + * + * Deliberately observe-only. The proofs are the user's money: hiding them, + * refusing to start, or forcing a recovery would all be worse outcomes than a + * total that reads a little high, and the state is self-healing once a mint + * is (re-)added at that url. This exists so we learn it happened at all — + * otherwise the balance view swallows it silently. + * + * Call at STARTUP, once proofs and mints are both loaded. Doing this from the + * `balances` computed instead would misfire: it re-runs constantly, and mint + * removal produces this exact state for a moment (MintsScreen destroys the + * mint in one action, moves its proofs to SPENT in the next). At startup the + * tree is settled, so anything found here is a genuine, persisted desync. + */ + reportOrphanedProofs(): Array<{mintUrl: string; count: number; amount: number}> { + const orphaned = self.findOrphanedProofs() + if (orphaned.length === 0) return orphaned + + // error → Sentry in prod: this should be unreachable, and if it is not we + // want to know which url and how much is stranded. + log.error('[reportOrphanedProofs]', 'Spendable proofs reference a mint not in the wallet', { + orphaned, + totalAmount: orphaned.reduce((sum, o) => sum + o.amount, 0), + knownMintUrls: getRootStore(self).mintsStore.allMints.map((m: Mint) => m.mintUrl), + }) + + return orphaned + }, + // Lock proofs locally during an outgoing operation (send, melt prepare, etc.) // Does NOT touch pendingByMintSecrets — that is mint-reported pending. moveToPending(proofs: Proof[]) { @@ -642,6 +706,20 @@ import { const targetMintMap = isPending ? mintPendingMap : mintBalancesMap const targetUnitMap = isPending ? unitPendingMap : unitBalancesMap + // A proof whose mintUrl matches no mint contributes to the UNIT total but + // to no mint bucket, so the total can exceed the sum of the mints. That is + // deliberate: the sats are real and the user's, and showing a few + // unreachable ones is a far better failure than hiding them, blocking + // access, or forcing a recovery. It is also self-healing — a mint + // (re-)added at that url re-attaches them. + // + // Detection is NOT done here. `balances` is a MobX computed that re-runs + // on every proof and mint change, and mint removal legitimately produces + // this state for a moment: MintsScreen destroys the mint in one action and + // moves its proofs to SPENT in the next, so reactions observe the gap in + // between. Reporting from here would fire on every removal AND on every + // recompute. The steady-state check runs once at startup instead — see + // reportOrphanedProofs. const mintBalance = targetMintMap.get(proof.mintUrl) if (mintBalance) { mintBalance.balances[proof.unit]! += proof.amount diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts index 28dec79..708ae97 100644 --- a/src/models/RootStore.ts +++ b/src/models/RootStore.ts @@ -9,7 +9,8 @@ import {RelaysStoreModel} from './RelaysStore' import {WalletStoreModel} from './WalletStore' import {NwcStoreModel} from './NwcStore' import {AuthStoreModel} from './AuthStore' -import { log } from '../services' +// Direct import, not the '../services' barrel — see the note in Mint.ts. +import { log } from '../services/logService' export const rootStoreModelVersion = 38 // Update this if model changes require migrations defined in setupRootStore.ts /** diff --git a/src/models/Transaction.ts b/src/models/Transaction.ts index 3432e81..7cd0721 100644 --- a/src/models/Transaction.ts +++ b/src/models/Transaction.ts @@ -1,7 +1,8 @@ import { flow, Instance, isAlive, SnapshotIn, SnapshotOut, types } from 'mobx-state-tree' import { log } from '../services/logService' import { MintUnit } from '../services/wallet/currency' -import { Database } from '../services' +// Direct import, not the '../services' barrel — see the note in Mint.ts. +import { Database } from '../services/db' import { withSetPropAction } from './helpers/withSetPropAction' diff --git a/src/models/TransactionsStore.ts b/src/models/TransactionsStore.ts index 350ef23..ca1a571 100644 --- a/src/models/TransactionsStore.ts +++ b/src/models/TransactionsStore.ts @@ -12,7 +12,8 @@ import { TransactionStatus, TransactionType, } from './Transaction' -import { Database } from '../services' +// Direct import, not the '../services' barrel — see the note in Mint.ts. +import { Database } from '../services/db' import { log } from '../services/logService' import { getRootStore } from './helpers/getRootStore' import { formatDistance } from 'date-fns' diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index b1ff3a5..dd013e0 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -95,6 +95,13 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO // hydrate unspent and pending ecash proofs to model from database if(!opts.skipProofs) { await proofsStore.loadProofsFromDatabase() + + // Both mints (applySnapshot, above) and proofs are now loaded, so the + // tree is settled — the one moment this check cannot produce a false + // positive. Observe-only: it reports a proofs/mint desync and changes + // nothing, because stranded sats reading in the total are a far better + // outcome for the user than a wallet that refuses to open. + proofsStore.reportOrphanedProofs() } const proofsHydrated = performance.now() diff --git a/src/services/logService.ts b/src/services/logService.ts index 58453f1..299eab9 100644 --- a/src/services/logService.ts +++ b/src/services/logService.ts @@ -12,13 +12,31 @@ import { } from '@env' import { lightFormat } from 'date-fns' import * as Sentry from '@sentry/react-native' -import { rootStoreInstance } from '../models' import { LogLevel } from './log/logTypes' import { Platform } from "react-native" import AppError, { Err } from "../utils/AppError" -const { userSettingsStore } = rootStoreInstance -// +/** + * The user's log settings, resolved on USE via a deferred require. + * + * Deliberately NOT a top-level `import { rootStoreInstance } from '../models'`. + * That import inverted the dependency graph — logService is a leaf that nearly + * everything imports, the model layer included (Mint -> theme -> services -> + * logService) — and `models/index` EAGERLY instantiates the root store at module + * scope (useStores.ts). So importing models from here re-entered that + * instantiation while RootStore.ts was still evaluating, read `RootStoreModel` as + * undefined, and threw during import. Whether that happened came down purely to + * which module the process loaded first: the app has an entry order that gets away + * with it, a test importing a model directly does not. That is why the model layer + * could not be instantiated in a test at all. + * + * `require` is memoized by the module system, so this costs nothing after the + * first call, and by the time anything logs the graph is fully built. + * + * The tidier end state is inversion — have startup hand the logger a settings + * provider, so a leaf service stops reaching for the root store. + */ +const userSettings = () => require('../models').rootStoreInstance.userSettingsStore if (!__DEV__) { Sentry.init({ @@ -133,7 +151,7 @@ const extractFromArray = (arr: any[]): { message: string; params: Record = async (props) => { - if (!userSettingsStore.isLoggerOn) return true + if (!userSettings().isLoggerOn) return true const level = props.level.text as LogLevel const rawMessage = props.rawMsg @@ -197,7 +215,7 @@ const customSentryTransport: transportFunctionType = async (pr // === Non-error levels: structured breadcrumbs via logger (or addBreadcrumb) === const currentPriority = levelPriority[level] - const minPriority = levelPriority[userSettingsStore.logLevel] ?? levelPriority[LogLevel.WARN] + const minPriority = levelPriority[userSettings().logLevel as LogLevel] ?? levelPriority[LogLevel.WARN] if (currentPriority < minPriority) return true switch (level) { diff --git a/src/services/wallet/currency.ts b/src/services/wallet/currency.ts index c20aed3..537800d 100644 --- a/src/services/wallet/currency.ts +++ b/src/services/wallet/currency.ts @@ -1,5 +1,13 @@ import numbro from 'numbro' -import { BtcIcon, EurIcon, UsdIcon, CadIcon, GbpIcon } from '../../components' +// Each icon from its own module, NOT the '../../components' barrel. The barrel +// pulls in every component (AmountInput and friends), so a barrel import here made +// this module — which the Mint model imports for MintUnit — transitively depend on +// the entire UI. ChfIcon below was already imported this way. +import { BtcIcon } from '../../components/BtcIcon' +import { EurIcon } from '../../components/EurIcon' +import { UsdIcon } from '../../components/UsdIcon' +import { CadIcon } from '../../components/CadIcon' +import { GbpIcon } from '../../components/GbpIcon' import AppError, { Err } from '../../utils/AppError' import { ExchangeRate } from '../../models/WalletStore' import { ChfIcon } from '../../components/ChfIcon' diff --git a/src/theme/colors.ts b/src/theme/colors.ts index 0145019..6dd2200 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -534,15 +534,3 @@ export const Themes: ThemeList = { }, } -export const getRandomIconColor = () => { - const options = [ - palette.iconBlue200, - palette.iconBlue300, - palette.iconGreen200, - palette.iconGreen300, - palette.iconYellow300, - ] - - const randomIndex = Math.floor(Math.random() * options.length) - return options[randomIndex] -} diff --git a/src/utils/generateId.ts b/src/utils/generateId.ts new file mode 100644 index 0000000..93a40ec --- /dev/null +++ b/src/utils/generateId.ts @@ -0,0 +1,20 @@ +import QuickCrypto from 'react-native-quick-crypto' +import { log } from '../services/logService' + +/** + * Random hex id of `lengthInBytes` bytes. + * + * Lives in its own module rather than in `utils.ts`, whose other exports are UI + * helpers — a toast built on react-native-flash-message and the theme barrel (and + * so, transitively, the service layer). The MODEL layer needs only this function, + * and importing it from that grab-bag made Mint and ProofsStore depend on the whole + * UI stack to generate an id. + */ +export const generateId = function (lengthInBytes: number) { + const random = QuickCrypto.randomBytes(lengthInBytes) + const uint8Array = new Uint8Array(random) + + const id: string = Buffer.from(uint8Array).toString('hex') + log.trace('[generateId]', {id}) + return id +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index a3a621e..9032733 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -46,13 +46,8 @@ export const infoMessage = function(message: string, description?: string) { } -export const generateId = function (lengthInBytes: number) { - const random = QuickCrypto.randomBytes(lengthInBytes) - const uint8Array = new Uint8Array(random) - - const id: string = Buffer.from(uint8Array).toString('hex') - log.trace('[generateId]', {id}) - return id -} +// Re-exported for convenience; defined in its own module so the model layer can +// import it without dragging in this file's UI helpers. See ./generateId. +export { generateId } from './generateId'