Detect orphaned proofs; untangle the model layer so stores are testable

Two things, coupled because the first could not be tested without the second.

Detect the proofs/mint desync, and change nothing

`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, and the balance view then counts the proofs in the
unit total while attributing them to no mint, which also makes them unspendable
(send and melt select by mint). Money visible in the header, owned by nothing.

reportOrphanedProofs() reports that and does nothing else. Deliberate: the sats
are the user's, and hiding them, refusing to start, or forcing a recovery are
all worse outcomes than a total that reads slightly high. The state self-heals
once a mint is (re-)added at that url.

It runs at STARTUP, not from `balances`, for two reasons. `balances` is a MobX
computed that re-runs on every proof and mint change; and mint removal
legitimately produces this exact 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. Detecting there would have fired on every normal removal, which
is how an alert teaches you to ignore it. At startup the tree is settled, so
anything found is a genuine persisted desync.

Untangle the model layer

MST stores could not be instantiated in a test at all, which is why this repo
has no store tests — only mirrored SQL and pure functions. The cause was not
jest: the model layer transitively imported most of the app. Five real defects:

- logService imported `../models` and destructured rootStoreInstance at MODULE
  SCOPE, while models/index eagerly instantiates the root store. A leaf logging
  service pulling the root store inverted the graph, and whether it worked came
  down to which module loaded first — the app has an entry order that survives
  it, a test importing a model directly reads RootStoreModel as undefined and
  throws during import. Now a deferred require, resolved on use.
- Mint imported the `../theme` BARREL, which re-exports useThemeColor ->
  ../services — the whole service layer, to read two colour constants.
- Five models imported the `../services` BARREL for log/Database, dragging in
  walletService -> syncQueueService -> notificationService (notifee).
- currency.ts imported the `../../components` BARREL for currency icons: a
  service module depending on the entire UI. (ChfIcon was already imported
  directly — it was inconsistent as well as wrong.)
- generateId lived in utils.ts beside a react-native-flash-message toast, so
  generating an id pulled in the UI stack. Split into its own module; utils.ts
  re-exports it, so existing callers are unaffected.

None of these change behaviour: same modules, narrower paths.

Test harness

jest already used the react-native preset; the gap was native and
source-shipped packages that the model layer reaches at import time. Mocks for
MMKV (Map-backed, behaves), op-sqlite, Sentry and nostr-tools, plus resolver
mappings for @scure/bip39 wordlists and nostr-tools' subpaths — its vendored
@noble copies ship source and importing them would cross versions on crypto
code, so the surface is mocked instead. op-sqlite and nostr-tools THROW if
actually called: a test that silently derives a bogus key and asserts on it is
worse than one that stops and explains. Real DB semantics stay where they were,
on node:sqlite with the production SQL.

Mocking Sentry rather than logService means suites can let the real logger load.
The full RootStore still cannot instantiate (AuthStore/NwcStore/
WalletProfileStore pull the service layer), so orphanedProofs.test.ts uses a
minimal root — enough for the money models, and enough for what comes next.

Also removed: Mint.setRandomColor and theme's getRandomIconColor. The helper's
only caller was that action, and the action had no callers at all.

Tests: 433 pass, including 12 new ones against the real MST models — the first
store tests in the repo. They pin the promise above: an orphaned proof still
counts in the unit total, reporting mutates nothing, and it heals when the mint
returns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-07-16 22:49:38 +02:00
parent 2f7a2763f2
commit c56729aa38
18 changed files with 554 additions and 38 deletions

46
__mocks__/nostr-tools.js Normal file
View File

@@ -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')

43
__mocks__/op-sqlite.js Normal file
View File

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

56
__mocks__/react-native-mmkv.js vendored Normal file
View File

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

View File

@@ -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()

View File

@@ -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<ReturnType<typeof proofSnapshot>>) =>
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)
})
})
})

View File

@@ -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$': '<rootDir>/__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$': '<rootDir>/__mocks__/react-native-mmkv.js',
'^@op-engineering/op-sqlite$': '<rootDir>/__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$': '<rootDir>/__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(/.*)?$': '<rootDir>/__mocks__/nostr-tools.js',
},
}

View File

@@ -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
},

View File

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

View File

@@ -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<string, {mintUrl: string; count: number; amount: number}>()
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

View File

@@ -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
/**

View File

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

View File

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

View File

@@ -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()

View File

@@ -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<string,
const customSentryTransport: transportFunctionType<TransportOptions> = 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<TransportOptions> = 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) {

View File

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

View File

@@ -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]
}

20
src/utils/generateId.ts Normal file
View File

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

View File

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