Files
minibits_wallet/src/models/ProofsStore.ts
T

856 lines
38 KiB
TypeScript

import {
Instance,
SnapshotOut,
types,
flow,
isAlive,
} from 'mobx-state-tree'
import { withSetPropAction } from './helpers/withSetPropAction'
import { ProofModel, Proof, ProofRecord, ProofState } from './Proof'
import { log } from '../services/logService'
import { getRootStore } from './helpers/getRootStore'
import AppError, { Err } from '../utils/AppError'
import { Mint, MintBalance } from './Mint'
// 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/generateId'
import { INTERRUPTIBLE_OPERATION_TYPES, ProofReservation } from '../services/wallet/proofReservation'
export const ProofsStoreModel = types
.model('ProofsStore', {
proofs: types.optional(types.map(ProofModel), {}),
// Tracks secrets that the mint itself reports as PENDING (lightning in-flight).
// Distinct from proof.state === 'PENDING' (local lock): all mint-pending proofs are
// locally PENDING, but not all locally-PENDING proofs are confirmed pending at the mint.
pendingByMintSecrets: types.array(types.string),
})
.actions(withSetPropAction)
// Reservations a previous process left open mid-way through an interruptible
// operation (see INTERRUPTIBLE_OPERATION_TYPES). Collected at startup, drained by
// the interrupted-operation resolver. In memory only: the rows themselves are in
// SQLite, and the next launch rebuilds this list from them.
.volatile(() => ({
interruptedReservationIds: new Set<string>(),
}))
// ───────────────────── VIEWS ─────────────────────
.views(self => ({
getBySecret(secret: string): Proof | undefined {
return self.proofs.get(secret)
},
getByTransactionId(tId: number): Proof[] {
return Array.from(self.proofs.values()).filter(p => p.tId === tId)
},
alreadyExists(proof: Proof | { secret: string }): boolean {
const secret = typeof proof === 'object' ? proof.secret : proof
return self.proofs.has(secret)
},
getProofInstance(proof: Proof | { secret: string }): Proof | undefined {
return self.proofs.get(typeof proof === 'object' ? proof.secret : proof)
},
get unspentProofs() {
return Array.from(self.proofs.values()).filter(p => p.state === 'UNSPENT')
},
get pendingProofs() {
return Array.from(self.proofs.values()).filter(p => p.state === 'PENDING')
},
get spentProofs() {
return Array.from(self.proofs.values()).filter(p => p.state === 'SPENT')
},
}))
.views(self => ({
getMintFromProof(proof: Proof): Mint | undefined {
const rootStore = getRootStore(self)
const { mintsStore } = rootStore
for (const mint of mintsStore.allMints) {
for (const counter of mint.proofsCounters) {
if (counter.keyset === proof.id) {
return mint
}
}
}
return undefined
},
/**
* Spendable proofs whose `mintUrl` matches no mint in the wallet, grouped by
* that url.
*
* This should always be empty, and is now a residual safety net rather than a
* live hazard. `proofs.mintUrl` is a denormalized copy of a mint's LOCATOR,
* joined to `mint.mintUrl` by string equality. That copy used to be able to
* drift: the mint's url lived in the MMKV snapshot while its proofs lived in
* SQLite, so a mint-url edit spanned two engines with no transaction between
* them, and a crash in the gap left proofs owned by no mint. Both now live in
* SQLite and the rename is a single transaction (mintsRepo.updateMintUrl), so
* that path is closed — this stays because the consequence is severe enough to
* keep watching for: the balance view counts such proofs in the unit total
* while attributing them to no mint (see `balances`), and they cannot be
* spent, 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: {
state?: ProofState
unit?: MintUnit
keysetIds?: string[]
ascending?: boolean
} = {}
): Proof[] {
// Default to UNSPENT — the primary spendable pool
const targetState = options.state ?? 'UNSPENT'
let proofs = Array.from(self.proofs.values())
.filter(p => p.state === targetState && p.mintUrl === mintUrl)
if (options.keysetIds?.length) {
proofs = proofs.filter(p => options.keysetIds!.includes(p.id))
}
if (options.unit) {
proofs = proofs.filter(p => p.unit === options.unit)
}
return proofs
.slice()
.sort((a, b) =>
options.ascending ? a.amount - b.amount : b.amount - a.amount
)
}
}))
// ───────────────────── ACTIONS ─────────────────────
.actions(self => ({
loadProofsFromDatabase: flow(function* loadProofsFromDatabase(includeSpent: boolean = false) {
const proofRecords: ProofRecord[] = yield Database.getProofs(
true, // includeUnspent
true, // includePending
includeSpent
)
self.proofs.clear()
for (const record of proofRecords) {
const {
state,
dleq_e,
dleq_r,
dleq_s,
updatedAt,
...coreProof
} = record
const dleq = dleq_e && dleq_s
? { e: dleq_e as string, r: dleq_r as string, s: dleq_s as string }
: undefined
self.proofs.put(
ProofModel.create({
...coreProof,
state: state ?? 'UNSPENT',
dleq,
})
)
}
log.trace('[loadProofsFromDatabase]', {
loaded: self.proofs.size,
unspent: Array.from(self.proofs.values()).filter(p => p.state === 'UNSPENT').length,
pending: Array.from(self.proofs.values()).filter(p => p.state === 'PENDING').length,
spent: Array.from(self.proofs.values()).filter(p => p.state === 'SPENT').length,
})
}),
/**
* 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[]) {
const liveProofs = proofs.filter(p => isAlive(p))
if (liveProofs.length === 0) return
Database.addOrUpdateProofs(liveProofs, 'PENDING')
for (const p of liveProofs) {
p.state = 'PENDING'
}
},
// Called when the mint explicitly reports PENDING (lightning in-flight).
// The only place that adds to pendingByMintSecrets during normal operation
// (importPendingByMintSecrets below restores it from a backup).
registerAsPendingAtMint(proofs: Proof[]) {
for (const p of proofs) {
if (!self.pendingByMintSecrets.includes(p.secret)) {
self.pendingByMintSecrets.push(p.secret)
}
}
},
/**
* Restore the mint-pending registry from a backup.
*
* Separate from registerAsPendingAtMint because the import holds bare
* secrets decoded from JSON, not Proof instances — and it has to be an
* ACTION: ImportBackupScreen used to push onto this array directly, which
* MST rejects on a protected tree ("the object is protected and can only be
* modified by using an action"). That threw in the middle of the import, so
* a backup taken while a payment was pending at the mint could not be
* restored at all.
*/
importPendingByMintSecrets(secrets: string[]) {
for (const secret of secrets ?? []) {
if (!self.pendingByMintSecrets.includes(secret)) {
self.pendingByMintSecrets.push(secret)
}
}
},
// Called when the mint no longer reports PENDING (payment settled or failed).
unregisterFromPendingAtMint(secrets: string[] | Set<string>) {
const set = secrets instanceof Set ? secrets : new Set(secrets)
self.pendingByMintSecrets.replace(
self.pendingByMintSecrets.filter(s => !set.has(s)))
},
moveToSpent(proofs: Proof[]) {
const liveProofs = proofs.filter(p => isAlive(p))
if (liveProofs.length === 0) return
Database.addOrUpdateProofs(liveProofs, 'SPENT')
for (const p of liveProofs) {
p.state = 'SPENT'
}
// Clean mint-pending registry for any proofs that are now definitively spent
const secrets = new Set(liveProofs.map(p => p.secret))
self.pendingByMintSecrets.replace(
self.pendingByMintSecrets.filter(s => !secrets.has(s))
)
},
revertToSpendable(proofs: Proof[]) {
const liveProofs = proofs.filter(p => isAlive(p))
if (liveProofs.length === 0) return
Database.addOrUpdateProofs(liveProofs, 'UNSPENT')
for (const p of liveProofs) {
p.state = 'UNSPENT'
}
},
/**
* Mirror a mint-url edit onto the in-memory proofs — MEMORY ONLY.
*
* The SQLite write is deliberately not here. It belongs in the same
* transaction as the mint's own row (Database.updateMintUrlWithProofs, called
* from Mint.setMintUrl), because those two writes must not be separable: a
* crash between them leaves proofs pointing at a url no mint owns, and the
* money then counts toward the total while belonging to no mint — and cannot
* be spent, since send and melt select proofs by mint.
*
* Call this only AFTER that transaction commits.
*/
updateMintUrlInMemory(currentMintUrl: string, updatedMintUrl: string) {
const updateInMap = (map: typeof self.proofs) => {
for (const proof of map.values()) {
if (proof.mintUrl === currentMintUrl) {
if (!isAlive(proof)) {
log.error('[updateMintUrl]', 'Proof instance is not alive, aborting state update', { secret: proof.secret })
continue
}
proof.setMintUrl(updatedMintUrl)
}
}
}
updateInMap(self.proofs)
log.trace('[updateMintUrlInMemory] Mirrored mint url onto proofs')
},
// Import proofs from backup without validation or side effects
/**
* Add a backup's proofs to the wallet's own.
*
* A secret already here is SKIPPED, not overwritten: the local copy carries
* this device's state and transaction id, and a backup — which may be
* months old — must not reset a proof to how it looked then.
*
* @returns the proofs actually added, which is what the import writes its
* RECEIVE_IMPORT transactions from. Counting the whole input instead would
* claim ecash the wallet already had.
*/
importProofs(proofs: Proof[]): Proof[] {
const added: Proof[] = []
for (const proof of proofs ?? []) {
if (!proof?.secret || self.proofs.has(proof.secret)) continue
self.proofs.put(ProofModel.create(proof))
const instance = self.proofs.get(proof.secret)
if (instance) added.push(instance)
}
log.trace('[importProofs]', `Imported ${added.length} of ${proofs?.length ?? 0} proofs from backup`)
return added
},
// ─────────────────────────────────────────────────────────────
// Proof reservations (Phase 5)
//
// Wraps a sequence of state transitions in a single SQLite transaction
// with deterministic rollback. See [src/services/wallet/proofReservation.ts]
// for usage patterns.
// ─────────────────────────────────────────────────────────────
/**
* Lock `proofs` as PENDING under a new reservation id. Atomic in SQLite
* (reservation row + state updates in one batch). MST is updated only
* after SQLite commit succeeds.
*/
reserve(
proofs: Proof[],
opts: {
transactionId: number
mintUrl: string
unit: MintUnit
operationType: string
/**
* What state to restore the locked proofs to on rollback.
* REQUIRED — every reservation site must declare its rollback
* intent explicitly. This protects against silent bugs when
* refactoring (e.g. an earlier commit changes proof state, so
* the "restore current state" default would mis-rollback).
*
* - A `ProofState` value ('UNSPENT' | 'PENDING' | 'SPENT'):
* restore ALL locked proofs to this state uniformly.
* Use when the entire batch should reach the same state
* on failure (the common case — e.g. release sent ecash
* back to spendable: `'UNSPENT'`).
*
* - `'preserve'`: restore each proof to its individual state
* at reserve time. Use when the batch is mixed (some
* UNSPENT, some PENDING) and you literally want "undo".
*/
rollbackTo: ProofState | 'preserve'
},
): ProofReservation {
const liveProofs = proofs.filter(p => isAlive(p))
const reservationId = generateId(16)
const lockedProofs = liveProofs.map(p => ({
secret: p.secret,
originalState: opts.rollbackTo === 'preserve' ? p.state : opts.rollbackTo,
originalTId: p.tId ?? null,
}))
// Resolved here rather than asked of every caller: they all identify the
// mint by url, but the reservation must survive that url changing while
// the operation is open (see ProofReservation.mintId).
const mintId = getRootStore(self).mintsStore.findByUrl(opts.mintUrl)?.id ?? null
// ATOMIC: write reservation row + lock proofs to PENDING in one batch.
Database.openReservation(
{
id: reservationId,
transactionId: opts.transactionId,
mintId: mintId ?? undefined,
mintUrl: opts.mintUrl,
unit: opts.unit,
operationType: opts.operationType,
lockedProofs,
},
liveProofs,
)
// SQLite is durable — mirror into MST. Both state AND tId are
// reassigned: the operation now "owns" these proofs for the
// duration of the reservation, so any sync sweep that sees them
// SPENT will correctly attribute the spend to opts.transactionId.
for (const p of liveProofs) {
if (isAlive(p) && p.state !== 'SPENT') {
p.setProp('state', 'PENDING')
p.setProp('tId', opts.transactionId)
}
}
return {
id: reservationId,
transactionId: opts.transactionId,
mintId,
mintUrl: opts.mintUrl,
unit: opts.unit,
operationType: opts.operationType,
lockedProofs,
}
},
/**
* Commit a reservation: apply final state transitions + add new proofs +
* delete the reservation row, all in one SQLite transaction. MST is
* mirrored after SQLite commit.
*/
commitReservation(
reservation: ProofReservation,
changes: {
toSpent?: Proof[]
toUnspent?: Proof[]
newProofs?: Array<{
proofs: CashuProof[]
state: ProofState
tId: number
}>
/**
* Atomically apply a transaction-row update inside the same
* SQLite batch as the proof-state finalize. Closes the
* proofs-table ↔ transactions-table atomicity window.
*/
transactionUpdate?: ReservationTransactionUpdate
} = {},
): { added: Proof[] } {
const mintsStore = getRootStore(self).mintsStore
// Resolve by stable id, not by the url captured when the reservation
// opened: a mint-url edit may have landed while this operation was in
// flight, and a url lookup would then find nothing and abort the commit
// of an operation the mint has already performed. Falls back to the url
// for a pre-v33 reservation, which carries no mintId.
const mintInstance =
(reservation.mintId ? mintsStore.findById(reservation.mintId) : undefined) ??
mintsStore.findByUrl(reservation.mintUrl)
if (!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint not found for reservation', {
mintId: reservation.mintId,
mintUrl: reservation.mintUrl,
reservationId: reservation.id,
})
}
// The mint's url NOW, which is not necessarily reservation.mintUrl. Every
// write below — SQLite and the MST mirror alike — uses this: filing the
// new proofs under a url no mint owns makes the balance simply vanish.
const commitMintUrl = mintInstance.mintUrl
// Snapshot the current derivation counter for every keyset the new
// proofs were derived under (a cashu proof's `id` IS its keyset id).
// WalletStore already advanced the model counter to
// `reservedCounters.next` and wrote it through to SQLite (W1); this
// folds the same value into the proof-commit batch (W2) as an atomic
// backstop, so a committed proof can never outlive its counter even if
// the W1 write-through was dropped. Monotonic, so the normal-path
// double write is a harmless no-op.
const counterUpdate: Array<{keysetId: string; unit?: string; counter: number}> = []
const seenKeysets = new Set<string>()
for (const group of changes.newProofs ?? []) {
for (const proof of group.proofs) {
if (seenKeysets.has(proof.id)) continue
seenKeysets.add(proof.id)
const counter = mintInstance.getProofsCounter(proof.id)
if (counter) {
counterUpdate.push({
keysetId: proof.id,
unit: counter.unit,
counter: counter.counter,
})
}
}
}
// ATOMIC SQLite write of every state transition + (optional)
// transaction-row update + counter advance + reservation deletion.
Database.commitReservation(reservation.id, {
toSpent: changes.toSpent,
toUnspent: changes.toUnspent,
newProofs: changes.newProofs?.map(group => ({
proofs: group.proofs,
state: group.state,
mintUrl: commitMintUrl,
unit: reservation.unit,
tId: group.tId,
})),
transactionUpdate: changes.transactionUpdate,
counterUpdate,
})
// Mirror to MST now that SQLite is durable.
const added: Proof[] = []
for (const p of changes.toSpent ?? []) {
if (isAlive(p)) p.setProp('state', 'SPENT')
}
for (const p of changes.toUnspent ?? []) {
if (isAlive(p)) p.setProp('state', 'UNSPENT')
}
// Clean pendingByMintSecrets for anything that just became SPENT.
if (changes.toSpent && changes.toSpent.length > 0) {
const spentSecrets = new Set(changes.toSpent.map(p => p.secret))
self.pendingByMintSecrets.replace(
self.pendingByMintSecrets.filter(s => !spentSecrets.has(s)),
)
}
for (const group of changes.newProofs ?? []) {
for (const proof of group.proofs) {
const existing = self.getBySecret(proof.secret)
if (existing) {
if (existing.state === 'SPENT') continue
if (isAlive(existing)) {
existing.setProp('mintUrl', commitMintUrl)
existing.setProp('tId', group.tId)
existing.setProp('unit', reservation.unit)
existing.setProp('state', group.state)
added.push(existing)
}
} else {
const node = ProofModel.create({
...proof,
amount: Number(proof.amount),
mintUrl: commitMintUrl,
tId: group.tId,
unit: reservation.unit,
state: group.state,
})
self.proofs.put(node)
added.push(node)
}
}
}
// The keyset counter is NOT advanced here. Under cashu-ts v3.x the
// operation already advanced it to `reservedCounters.next` (via
// WalletStore.setProofsCounter), covering every index these proofs
// consumed — and that value was persisted atomically with the proofs
// in the commit batch above (counterUpdate). The old post-commit
// `increaseProofsCounter(addedProofs.length)` here double-advanced the
// counter (a pre-v3.x leftover) and was removed.
// Mirror the (already-durable) transaction update to MST so the
// in-memory model reflects the new tx state immediately. Uses
// setProp to avoid re-writing SQLite (Database.commitReservation
// already wrote the UPDATE atomically with the proof batch).
if (changes.transactionUpdate) {
const tu = changes.transactionUpdate
const transactionsStore = getRootStore(self).transactionsStore
const tx = transactionsStore.findById(tu.id)
if (tx && isAlive(tx)) {
if (tu.status !== undefined) tx.setProp('status', tu.status)
if (tu.data !== undefined) tx.setProp('data', tu.data)
if (tu.amount !== undefined) tx.setProp('amount', tu.amount)
if (tu.fee !== undefined) tx.setProp('fee', tu.fee)
if (tu.balanceAfter !== undefined) tx.setProp('balanceAfter', tu.balanceAfter)
if (tu.outputToken !== undefined) tx.setProp('outputToken', tu.outputToken)
if (tu.keysetId !== undefined) tx.setProp('keysetId', tu.keysetId)
if (tu.proof !== undefined) tx.setProp('proof', tu.proof)
}
}
log.trace('[commitReservation] ', 'Reservation committed', {
id: reservation.id,
toSpent: changes.toSpent?.length ?? 0,
toUnspent: changes.toUnspent?.length ?? 0,
addedProofsCount: added.length,
txId: changes.transactionUpdate?.id,
})
return { added }
},
/**
* Rollback a reservation: restore each locked proof to its originalState
* and delete the reservation row. Safe to call multiple times; the second
* call is a no-op because the reservation row has already been deleted.
*/
rollbackReservation(reservation: ProofReservation): void {
Database.rollbackReservation(reservation.id, reservation.lockedProofs)
// Mirror to MST: restore BOTH state and tId from the pre-reserve
// snapshot so the proof goes back to "owned by its prior tx in its
// prior state" — matches the SQL UPDATE done above.
for (const snap of reservation.lockedProofs) {
const node = self.getBySecret(snap.secret)
if (node && isAlive(node) && node.state !== 'SPENT') {
node.setProp('state', snap.originalState)
if (snap.originalTId !== null) {
node.setProp('tId', snap.originalTId)
}
}
}
log.trace('[rollbackReservation]', 'Reservation rolled back', {
id: reservation.id,
restored: reservation.lockedProofs.length,
})
},
/**
* Detect orphan reservations (rows left behind by a process that died
* before it could commit or rollback) and roll each one back — except
* interruptible ones, whose proofs the mint may already have consumed.
* Those stay open with their proofs PENDING and are handed to the
* interrupted-operation resolver, which asks the mint before settling.
*
* Intended to run once at startup, after proofs have been loaded from
* the database. Idempotent.
*/
recoverOrphanReservations(): { recoveredCount: number; heldCount: number } {
const orphans = Database.getOpenReservations()
if (orphans.length === 0) return { recoveredCount: 0, heldCount: 0 }
log.warn(
`[recoverOrphanReservations] Found ${orphans.length} orphan reservations`,
)
let recoveredCount = 0
for (const orphan of orphans) {
if (INTERRUPTIBLE_OPERATION_TYPES.has(orphan.operationType)) {
self.interruptedReservationIds.add(orphan.id)
log.warn('[recoverOrphanReservations] Holding interrupted operation for mint check', {
id: orphan.id,
transactionId: orphan.transactionId,
operationType: orphan.operationType,
})
continue
}
try {
Database.rollbackReservation(orphan.id, orphan.lockedProofs)
// Mirror into MST: restore BOTH state and tId from the
// pre-reserve snapshot to match the SQL UPDATE above.
for (const snap of orphan.lockedProofs) {
const node = self.getBySecret(snap.secret)
if (node && isAlive(node) && node.state !== 'SPENT') {
node.setProp('state', snap.originalState)
if (snap.originalTId !== null) {
node.setProp('tId', snap.originalTId)
}
}
}
recoveredCount++
} catch (e: any) {
log.error('[recoverOrphanReservations] rollback failed', {
id: orphan.id,
error: e.message,
})
}
}
return { recoveredCount, heldCount: self.interruptedReservationIds.size }
},
/** The resolver settled this interrupted reservation; stop tracking it. */
releaseInterruptedReservation(reservationId: string): void {
self.interruptedReservationIds.delete(reservationId)
},
}))
.actions(self => ({
/**
* Lazily initialize the proof subsystem when it was NOT hydrated at
* startup — i.e. a lean background NWC wake (setupRootStore skipProofs).
* Loads proofs from SQLite and rolls back orphan reservations. No-op once
* proofs are already in memory (warm session, or a full foreground setup).
* Mutating NWC commands call this before selecting proofs.
*/
ensureProofsLoaded: flow(function* ensureProofsLoaded() {
if (self.proofs.size > 0) return
log.trace('[ensureProofsLoaded] Lean wake — loading proofs on demand')
yield self.loadProofsFromDatabase()
self.recoverOrphanReservations()
}),
}))
// ───────────────────── DERIVED VIEWS ─────────────────────
.views(self => ({
get proofsCount() { return self.unspentProofs.length },
get pendingProofsCount() { return self.pendingProofs.length },
get spentProofsCount() { return self.spentProofs.length },
get allProofs() { return self.unspentProofs },
get allPendingProofs() { return self.pendingProofs },
get allSpentProofs() { return self.spentProofs },
}))
.views(self => ({
get balances() {
const mintBalancesMap = new Map<string, MintBalance>()
const unitBalancesMap = new Map<MintUnit, number>()
const mintPendingMap = new Map<string, MintBalance>()
const unitPendingMap = new Map<MintUnit, number>()
const mints = getRootStore(self).mintsStore.allMints
const allUnits = new Set<MintUnit>()
for (const mint of mints) {
if (mint.units) {
for (const unit of mint.units) {
allUnits.add(unit)
}
}
}
for (const unit of allUnits) {
unitBalancesMap.set(unit, 0)
unitPendingMap.set(unit, 0)
}
for (const mint of mints) {
const zero = Object.fromEntries(
(mint.units ?? []).map(u => [u, 0])
) as Record<MintUnit, number>
mintBalancesMap.set(mint.mintUrl, {
mintUrl: mint.mintUrl,
balances: { ...zero },
})
mintPendingMap.set(mint.mintUrl, {
mintUrl: mint.mintUrl,
balances: { ...zero },
})
}
for (const proof of self.proofs.values()) {
if (proof.state === 'SPENT') continue
const isPending = proof.state === 'PENDING'
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
}
targetUnitMap.set(proof.unit, targetUnitMap.get(proof.unit)! + proof.amount)
}
return {
mintBalances: Array.from(mintBalancesMap.values()),
mintPendingBalances: Array.from(mintPendingMap.values()),
unitBalances: Array.from(unitBalancesMap.entries()).map(([unit, unitBalance]) => ({
unit,
unitBalance,
})),
unitPendingBalances: Array.from(unitPendingMap.entries()).map(([unit, unitBalance]) => ({
unit,
unitBalance,
})),
}
}
}))
.views(self => ({
getMintBalance: (mintUrl: string) => self.balances.mintBalances.find(b => b.mintUrl.replace(/\/$/, '') === mintUrl.replace(/\/$/, '')),
getMintBalancesWithEnoughBalance: (amount: number, unit: MintUnit) =>
self.balances.mintBalances
.filter(b => (b.balances[unit] || 0) >= amount)
.sort((a, b) => (b.balances[unit] || 0) - (a.balances[unit] || 0)),
getMintBalancesWithUnit: (unit: MintUnit) =>
self.balances.mintBalances
.filter(b => unit in b.balances)
.sort((a, b) => (b.balances[unit] || 0) - (a.balances[unit] || 0)),
// Highest-balance mint that actually holds the unit (undefined if none do).
// Only mints that list the unit are candidates, so a unit whose sole mint
// has a zero balance still resolves to that mint rather than an unrelated one.
getMintBalanceWithMaxBalance: (unit: MintUnit): MintBalance | undefined =>
self.balances.mintBalances
.filter(b => unit in b.balances)
.sort((a, b) => (b.balances[unit] || 0) - (a.balances[unit] || 0))[0],
getUnitBalance: (unit: MintUnit) =>
self.balances.unitBalances.find(b => b.unit === unit) || { unit, unitBalance: 0 },
getProofsSubset: (proofs: Proof[], proofsToRemove: Proof[]) => {
const removeSecrets = new Set(proofsToRemove.map(p => p.secret))
return proofs.filter(p => !removeSecrets.has(p.secret))
},
}))
// Proofs are loaded from DB on startup; only persist the mint-pending secrets list.
.postProcessSnapshot(snapshot => ({
proofs: {},
pendingByMintSecrets: snapshot.pendingByMintSecrets,
}))
export interface ProofsStore extends Instance<typeof ProofsStoreModel> {}
export interface ProofsStoreSnapshot extends SnapshotOut<typeof ProofsStoreModel> {}