State management fixes

This commit is contained in:
minibits-cash
2026-03-11 23:27:51 +01:00
parent 5ddc3de9e0
commit d7fdba7c91
5 changed files with 82 additions and 73 deletions

5
firebase.json Normal file
View File

@@ -0,0 +1,5 @@
{
"react-native": {
"messaging_ios_auto_register_for_remote_messages": false
}
}

View File

@@ -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)
}
},
@@ -572,13 +572,14 @@ export const MintModel = types
if(info.name.length > 0) {
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()
@@ -637,10 +638,7 @@ export const MintModel = types
return counters || []
},
get allInFlightRequests() {
const requests = self.proofsCounters // Get all counters as an array
.flatMap((counter) => counter.inFlightRequests) // Combine all `inFlightRequests` arrays
return requests
return self.proofsCounters.flatMap((counter) => counter.allInFlightRequests)
},
get balances(): MintBalance | undefined {
const mintBalance: MintBalance | undefined = getRootStore(self).proofsStore.getMintBalance(self.mintUrl)

View File

@@ -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)
@@ -239,14 +239,12 @@ 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)
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
}
@@ -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
}
},

View File

@@ -72,14 +72,14 @@ export const TransactionModel = types
},
update(fields: Partial<Transaction>) {
// 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]

View File

@@ -34,9 +34,14 @@ 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()
@@ -53,6 +58,7 @@ export async function setupRootStore(rootStore: RootStore) {
}
applySnapshot(rootStore, restoredState)
snapshotApplied = true
const stateHydrated = performance.now()
log.trace(`Hydrating rooStoreModel took ${stateHydrated - mmkvLoaded} ms.`, {caller: 'setupRootStore'})
@@ -87,19 +93,25 @@ export async function setupRootStore(rootStore: RootStore) {
_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)
if(rootStore.version < rootStoreModelVersion) {
await _runMigrations(rootStore)
}
} catch (e: any) {
log.error(Err.STORAGE_ERROR, e.message)
}
} catch (e: any) {
log.error(Err.STORAGE_ERROR, e.message)
}
const unsubscribe = () => {