From d7fdba7c916c69a46e78ad3345c124d75e290067 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 11 Mar 2026 23:27:51 +0100 Subject: [PATCH] State management fixes --- firebase.json | 5 +++ src/models/Mint.ts | 30 +++++++------- src/models/ProofsStore.ts | 50 ++++++++++------------ src/models/Transaction.ts | 8 ++-- src/models/helpers/setupRootStore.ts | 62 +++++++++++++++++----------- 5 files changed, 82 insertions(+), 73 deletions(-) create mode 100644 firebase.json diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..10aa4fb0 --- /dev/null +++ b/firebase.json @@ -0,0 +1,5 @@ +{ + "react-native": { + "messaging_ios_auto_register_for_remote_messages": false + } +} diff --git a/src/models/Mint.ts b/src/models/Mint.ts index f8000f5a..631232ca 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -125,7 +125,7 @@ export const MintProofsCounterModel = types removeInFlightRequest(transactionId: number) { if (!isAlive(self)) { - log.error('[removeInFlightRequest]', 'ProofsCounter is not alive', { keyset: self.keyset }) + log.error('[removeInFlightRequest]', 'ProofsCounter is not alive') return } const key = transactionId.toString() @@ -137,7 +137,7 @@ export const MintProofsCounterModel = types clearAllInFlightRequests() { if (!isAlive(self)) { - log.error('[clearAllInFlightRequests]', 'ProofsCounter is not alive', { keyset: self.keyset }) + log.error('[clearAllInFlightRequests]', 'ProofsCounter is not alive') return } const count = self.inFlightRequests.size @@ -176,7 +176,7 @@ export const MintProofsCounterModel = types removeMeltCounterValue(transactionId: number) { if (!isAlive(self)) { - log.error('[removeMeltCounterValue]', 'ProofsCounter is not alive', { keyset: self.keyset }) + log.error('[removeMeltCounterValue]', 'ProofsCounter is not alive') return } @@ -189,7 +189,7 @@ export const MintProofsCounterModel = types clearAllMeltCounterValues() { if (!isAlive(self)) { - log.error('[clearAllMeltCounterValues]', 'ProofsCounter is not alive', { keyset: self.keyset }) + log.error('[clearAllMeltCounterValues]', 'ProofsCounter is not alive') return } @@ -369,7 +369,7 @@ export const MintModel = types const index = self.proofsCounters.findIndex(p => p.keyset === counter.keyset) if(index !== -1) { - self.proofsCounters.splice(index, 0) + self.proofsCounters.splice(index, 1) self.proofsCounters = cast(self.proofsCounters) } }, @@ -567,18 +567,19 @@ export const MintModel = types try { const cashuMint = new CashuMint(self.mintUrl) - const info: GetInfoResponse = yield cashuMint.getInfo() + const info: GetInfoResponse = yield cashuMint.getInfo() if(info.name.length > 0) { - shortname = info.name + shortname = info.name } - - self.shortname = shortname - } catch (e: any) { log.warn('[setShortname]', {error: e.message}) - self.shortname = shortname } + + // Mint may have been removed while the network call was in flight + if (!isAlive(self)) return + + self.shortname = shortname }), setRandomColor() { self.color = getRandomIconColor() @@ -636,11 +637,8 @@ export const MintModel = types const counters = self.proofsCounters.filter(c => c.inFlightRequests && c.inFlightRequests.size > 0) return counters || [] }, - get allInFlightRequests() { - const requests = self.proofsCounters // Get all counters as an array - .flatMap((counter) => counter.inFlightRequests) // Combine all `inFlightRequests` arrays - - return requests + get allInFlightRequests() { + return self.proofsCounters.flatMap((counter) => counter.allInFlightRequests) }, get balances(): MintBalance | undefined { const mintBalance: MintBalance | undefined = getRootStore(self).proofsStore.getMintBalance(self.mintUrl) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 4eafbeca..43936d90 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -220,8 +220,8 @@ import { proofsByKeyset.set(proof.id, (proofsByKeyset.get(proof.id) || []).concat(proofNode)) } - // Update counters only for non-pending proofs - if (!isPending) { + // Update counters only for newly received, spendable proofs + if (!isPending && !isSpent) { for (const [keysetId, proofs] of proofsByKeyset) { const counter = mintInstance.getProofsCounterByKeysetId(keysetId) counter?.increaseProofsCounter(proofs.length) @@ -238,17 +238,15 @@ import { // Only call this when proofs are locally pending (ecash send, melt prepare, etc.) // Does NOT touch pendingByMintSecrets - moveToPending(proofs: Proof[]) { - Database.addOrUpdateProofs(proofs, true, false) + moveToPending(proofs: Proof[]) { + const liveProofs = proofs.filter(p => isAlive(p)) + if (liveProofs.length === 0) return - for (const p of proofs) { - if (!isAlive(p)) { - log.error('[moveToPending]', 'Proof instance is not alive, aborting state update', { secret: p.secret }) - continue - } + Database.addOrUpdateProofs(liveProofs, true, false) + for (const p of liveProofs) { p.isPending = true - p.isSpent = false + p.isSpent = false } }, @@ -270,35 +268,31 @@ import { }, moveToSpent(proofs: Proof[]) { - Database.addOrUpdateProofs(proofs, false, true) + const liveProofs = proofs.filter(p => isAlive(p)) + if (liveProofs.length === 0) return - for (const p of proofs) { - if (!isAlive(p)) { - log.error('[moveToSpent]', 'Proof instance is not alive, aborting state update', { secret: p.secret }) - continue - } + Database.addOrUpdateProofs(liveProofs, false, true) - p.isPending = false - p.isSpent = true + for (const p of liveProofs) { + p.isPending = false + p.isSpent = true } // Automatically clean if any were in mint-pending list - const secrets = new Set(proofs.map(p => p.secret)) + const secrets = new Set(liveProofs.map(p => p.secret)) self.pendingByMintSecrets.replace( - self.pendingByMintSecrets.filter(s => !secrets.has(s)) + self.pendingByMintSecrets.filter(s => !secrets.has(s)) ) }, revertToSpendable(proofs: Proof[]) { - Database.addOrUpdateProofs(proofs, false, false) + const liveProofs = proofs.filter(p => isAlive(p)) + if (liveProofs.length === 0) return - for (const p of proofs) { - if (!isAlive(p)) { - log.error('[revertToSpendable]', 'Proof instance is not alive, aborting state update', { secret: p.secret }) - continue - } + Database.addOrUpdateProofs(liveProofs, false, false) - p.isPending = false - p.isSpent = false + for (const p of liveProofs) { + p.isPending = false + p.isSpent = false } }, diff --git a/src/models/Transaction.ts b/src/models/Transaction.ts index e0f35927..2ffe9cb1 100644 --- a/src/models/Transaction.ts +++ b/src/models/Transaction.ts @@ -72,14 +72,14 @@ export const TransactionModel = types }, update(fields: Partial) { // log.trace('[update]', {fields}) - // Update multiple fields in database with a single query - const updatedTransaction = Database.updateTransaction(self.id, fields) - if (!isAlive(self)) { - log.error('[update]', 'Transaction instance is not alive, aborting state update', { id: self.id }) + log.error('[update]', 'Transaction instance is not alive, aborting update') return } + // Update multiple fields in database with a single query + const updatedTransaction = Database.updateTransaction(self.id, fields) + // Update the model to keep store in sync Object.keys(updatedTransaction).forEach(key => { ;(self as any)[key] = (updatedTransaction as any)[key] diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 35b6f039..382f6ae9 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -34,29 +34,35 @@ export const ROOT_STORAGE_KEY = 'minibits-root-storage' export async function setupRootStore(rootStore: RootStore) { let restoredState: any - let _disposer: IDisposer + let _disposer: IDisposer | undefined // let latestSnapshot: any + // Guards the onSnapshot installation below. If applySnapshot throws, rootStore + // stays at empty defaults. Installing the listener in that state would save those + // defaults back to MMKV on the first mutation, silently overwriting valid data. + let snapshotApplied = false + try { // load the last known state from storage const start = performance.now() - restoredState = MMKVStorage.load(ROOT_STORAGE_KEY) || {} - const mmkvLoaded = performance.now() - const dataSize = Buffer.byteLength(JSON.stringify(restoredState), 'utf8') - + restoredState = MMKVStorage.load(ROOT_STORAGE_KEY) || {} + const mmkvLoaded = performance.now() + const dataSize = Buffer.byteLength(JSON.stringify(restoredState), 'utf8') + // log.trace({restoredState}) - log.trace(`Loading ${dataSize.toLocaleString()} bytes of state from MMKV took ${(mmkvLoaded - start).toLocaleString()} ms.`, {caller: 'setupRootStore'}) - + log.trace(`Loading ${dataSize.toLocaleString()} bytes of state from MMKV took ${(mmkvLoaded - start).toLocaleString()} ms.`, {caller: 'setupRootStore'}) + // temp dirty migration of proofStore from array to map - if(restoredState?.proofsStore?.proofs && Array.isArray(restoredState.proofsStore.proofs)) { + if(restoredState?.proofsStore?.proofs && Array.isArray(restoredState.proofsStore.proofs)) { restoredState.proofsStore.proofs = {} } - applySnapshot(rootStore, restoredState) - + applySnapshot(rootStore, restoredState) + snapshotApplied = true + const stateHydrated = performance.now() log.trace(`Hydrating rooStoreModel took ${stateHydrated - mmkvLoaded} ms.`, {caller: 'setupRootStore'}) - + const {proofsStore, walletProfileStore, authStore, userSettingsStore, transactionsStore} = rootStore if(walletProfileStore.walletId) { @@ -72,34 +78,40 @@ export async function setupRootStore(rootStore: RootStore) { await proofsStore.loadProofsFromDatabase() // hydrate last transactions from database await transactionsStore.loadRecentFromDatabase() - + const proofsLoaded = performance.now() log.trace(`Loading proofs and transactions from DB and hydrating took ${proofsLoaded - stateHydrated} ms.`, { caller: 'setupRootStore' }) - - } catch (e: any) { + + } catch (e: any) { log.error(Err.STORAGE_ERROR, {message: e.message, params: e.params, caller: 'setupRootStore'}) } // stop tracking state changes if we've already setup if (_disposer) { _disposer() - } + } - _disposer = onSnapshot(rootStore, snapshot => { - MMKVStorage.save(ROOT_STORAGE_KEY, snapshot) - }) + if (snapshotApplied) { + _disposer = onSnapshot(rootStore, snapshot => { + MMKVStorage.save(ROOT_STORAGE_KEY, snapshot) + }) + } else { + log.error('[setupRootStore]', 'State restore failed — skipping onSnapshot to preserve MMKV data', {caller: 'setupRootStore'}) + } // run migrations if needed, needs to be after onSnapshot to be persisted - try { - log.info(`RootStore loaded from MMKV, version is: ${rootStore.version}`, {caller: 'setupRootStore'}) + if (snapshotApplied) { + try { + log.info(`RootStore loaded from MMKV, version is: ${rootStore.version}`, {caller: 'setupRootStore'}) - if(rootStore.version < rootStoreModelVersion) { - await _runMigrations(rootStore) - } - } catch (e: any) { - log.error(Err.STORAGE_ERROR, e.message) + if(rootStore.version < rootStoreModelVersion) { + await _runMigrations(rootStore) + } + } catch (e: any) { + log.error(Err.STORAGE_ERROR, e.message) + } } const unsubscribe = () => {