mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
931 lines
41 KiB
TypeScript
931 lines
41 KiB
TypeScript
import {
|
|
Instance,
|
|
SnapshotOut,
|
|
types,
|
|
flow,
|
|
isAlive,
|
|
} from 'mobx-state-tree'
|
|
import { Amount } from '@cashu/cashu-ts'
|
|
import { withSetPropAction } from './helpers/withSetPropAction'
|
|
import { ProofModel, Proof, ProofRecord, ProofState } from './Proof'
|
|
import { TransactionData, TransactionStatus } from './Transaction'
|
|
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'
|
|
|
|
const _heldEntry = (r: {transactionId: number; lockedProofs: Array<{secret: string}>}) => ({
|
|
transactionId: r.transactionId,
|
|
secrets: new Set(r.lockedProofs.map(p => p.secret)),
|
|
})
|
|
|
|
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(() => ({
|
|
interruptedReservations: new Map<string, {transactionId: number; secrets: Set<string>}>(),
|
|
}))
|
|
|
|
// ───────────────────── VIEWS ─────────────────────
|
|
.views(self => ({
|
|
/** Locked by an interrupted operation awaiting the resolver — no one else may settle it. */
|
|
isHeldByInterruptedOperation(secret: string): boolean {
|
|
for (const held of self.interruptedReservations.values()) {
|
|
if (held.secrets.has(secret)) return true
|
|
}
|
|
return false
|
|
},
|
|
isTransactionInterrupted(transactionId: number): boolean {
|
|
for (const held of self.interruptedReservations.values()) {
|
|
if (held.transactionId === transactionId) return true
|
|
}
|
|
return false
|
|
},
|
|
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: Amount.from(proof.amount as any).toNumber(),
|
|
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.interruptedReservations.set(orphan.id, _heldEntry(orphan))
|
|
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.interruptedReservations.size }
|
|
},
|
|
|
|
/**
|
|
* Close out outgoing operations a previous process abandoned before reaching
|
|
* the mint (see Database.getAbandonedDraftTransactions): tx → REVERTED with an
|
|
* `interrupted` audit entry, and any proofs still PENDING under it released.
|
|
* Those can only be a preemptive swap's outputs, committed PENDING just before
|
|
* the process died and before the melt reserved them — fresh, unspent ecash
|
|
* that nothing else would ever release.
|
|
*
|
|
* Startup only, after recoverOrphanReservations and before any operation can
|
|
* start. Writes the database directly: transactions are loaded afterwards.
|
|
*/
|
|
revertAbandonedDrafts(): { revertedCount: number } {
|
|
let revertedCount = 0
|
|
for (const draft of Database.getAbandonedDraftTransactions()) {
|
|
try {
|
|
const pending = self
|
|
.getByTransactionId(draft.id)
|
|
.filter(p => p.state === 'PENDING' && !self.isHeldByInterruptedOperation(p.secret))
|
|
if (pending.length > 0) {
|
|
Database.addOrUpdateProofs(pending, 'UNSPENT')
|
|
for (const p of pending) p.state = 'UNSPENT'
|
|
}
|
|
|
|
let data: TransactionData[] = []
|
|
try {
|
|
data = JSON.parse(draft.data)
|
|
} catch {}
|
|
data.push({
|
|
status: TransactionStatus.REVERTED,
|
|
interrupted: true,
|
|
message: 'Interrupted before reaching the mint. Nothing was paid.',
|
|
...(pending.length > 0 && {releasedAmount: pending.reduce((sum, p) => sum + p.amount, 0)}),
|
|
createdAt: new Date(),
|
|
})
|
|
Database.updateTransaction(draft.id, {
|
|
status: TransactionStatus.REVERTED,
|
|
data: JSON.stringify(data),
|
|
})
|
|
revertedCount++
|
|
} catch (e: any) {
|
|
log.error('[revertAbandonedDrafts] Could not revert', {transactionId: draft.id, error: e.message})
|
|
}
|
|
}
|
|
return { revertedCount }
|
|
},
|
|
|
|
/**
|
|
* Hand a reservation from THIS process to the resolver: its request reached
|
|
* (or may have reached) the mint, but no definitive answer came back. Left
|
|
* open with its proofs PENDING until the mint is asked what happened.
|
|
*/
|
|
holdInterruptedReservation(reservation: ProofReservation): void {
|
|
self.interruptedReservations.set(reservation.id, _heldEntry(reservation))
|
|
},
|
|
|
|
/** The resolver settled this interrupted reservation; stop tracking it. */
|
|
releaseInterruptedReservation(reservationId: string): void {
|
|
self.interruptedReservations.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> {}
|