mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
import {isAlive} from 'mobx-state-tree'
|
|
import {Amount} from '@cashu/cashu-ts'
|
|
import {Proof, ProofState} from '../../models/Proof'
|
|
import {CashuProof} from '../cashu/cashuUtils'
|
|
import {TransactionStatus} from '../../models/Transaction'
|
|
import {log} from '../logService'
|
|
import {SQLBatchTuple} from './connection'
|
|
import {getInstance} from './instance'
|
|
import {dbError} from './errors'
|
|
import {buildCounterUpsert} from './countersRepo'
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Proof reservations (Phase 5 of refactoring).
|
|
//
|
|
// A reservation snapshots the pre-operation state of a set of proofs and locks
|
|
// them as PENDING in a single SQLite transaction. The reservation row stays in
|
|
// the DB for the entire lifetime of the operation so that an orphan (a row left
|
|
// behind by a process that died mid-operation) can be detected on next startup
|
|
// and rolled back deterministically.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type LockedProofSnapshot = {
|
|
secret: string
|
|
originalState: ProofState
|
|
/**
|
|
* The proof's tId AT RESERVE TIME — i.e. the transaction that previously
|
|
* owned this proof (typically the original RECEIVE/TOPUP that minted it).
|
|
*
|
|
* When the reservation opens, the proof's tId is reassigned to the NEW
|
|
* operation's transactionId so downstream sync sweeps can correctly group
|
|
* spent proofs under the right transaction. On rollback, originalTId is
|
|
* restored.
|
|
*
|
|
* `null` for proofs that had no prior transaction reference.
|
|
*/
|
|
originalTId: number | null
|
|
}
|
|
|
|
export type ReservationRow = {
|
|
id: string
|
|
/**
|
|
* Stable id (Mint.id) of the owning mint — resolve this, not `mintUrl`, to reach
|
|
* the mint. Null only on a row opened before v33.
|
|
*/
|
|
mintId: string | null
|
|
/** Where the operation started. Historical/debug; not followed. */
|
|
mintUrl: string
|
|
transactionId: number
|
|
unit: string
|
|
operationType: string
|
|
lockedProofs: LockedProofSnapshot[]
|
|
/** Counter range a swap under this reservation derived its outputs from (v36+). */
|
|
counters: ReservationCounters | null
|
|
createdAt: Date
|
|
}
|
|
|
|
/** Derivation-counter range used by a swap's outputs: [start, start + count). */
|
|
export type ReservationCounters = {
|
|
keysetId: string
|
|
start: number
|
|
count: number
|
|
next: number
|
|
}
|
|
|
|
/**
|
|
* Open a reservation: insert the reservation row and move the locked proofs to
|
|
* PENDING — all in a single SQLite transaction (via executeBatch).
|
|
*
|
|
* If the batch fails, SQLite rolls back automatically and no partial state
|
|
* exists in the database.
|
|
*/
|
|
export const openReservation = function (
|
|
reservation: {
|
|
id: string
|
|
transactionId: number
|
|
mintId?: string
|
|
mintUrl: string
|
|
unit: string
|
|
operationType: string
|
|
lockedProofs: LockedProofSnapshot[]
|
|
},
|
|
proofsToLock: Proof[],
|
|
): void {
|
|
try {
|
|
const now = new Date().toISOString()
|
|
const batch: SQLBatchTuple[] = []
|
|
|
|
batch.push([
|
|
`INSERT INTO reservations (id, transactionId, mintId, mintUrl, unit, operationType, lockedProofs, createdAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
reservation.id,
|
|
reservation.transactionId,
|
|
reservation.mintId ?? null,
|
|
reservation.mintUrl,
|
|
reservation.unit,
|
|
reservation.operationType,
|
|
JSON.stringify(reservation.lockedProofs),
|
|
now,
|
|
],
|
|
])
|
|
|
|
for (const proof of proofsToLock) {
|
|
if (!isAlive(proof)) continue
|
|
batch.push([
|
|
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
proof.id,
|
|
proof.amount,
|
|
proof.secret,
|
|
proof.C,
|
|
proof.dleq ? proof.dleq.r : null,
|
|
proof.dleq ? proof.dleq.s : null,
|
|
proof.dleq ? proof.dleq.e : null,
|
|
proof.unit,
|
|
// Reassign tId to the new operation. The previous tId (which may
|
|
// point to e.g. the original RECEIVE that minted this proof) is
|
|
// captured in lockedProofs[i].originalTId for rollback restoration.
|
|
reservation.transactionId,
|
|
proof.mintUrl,
|
|
'PENDING',
|
|
now,
|
|
],
|
|
])
|
|
}
|
|
|
|
const db = getInstance()
|
|
db.executeBatch(batch)
|
|
|
|
log.debug('[openReservation] Reservation opened', {
|
|
id: reservation.id,
|
|
transactionId: reservation.transactionId,
|
|
lockedCount: proofsToLock.length,
|
|
operationType: reservation.operationType,
|
|
})
|
|
} catch (e: any) {
|
|
throw dbError('Could not open proof reservation', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optional transaction-row update atomically batched with a reservation commit.
|
|
*
|
|
* Only the named columns can be set; this is intentionally narrower than the
|
|
* full Transaction shape so the API is predictable and only covers fields that
|
|
* legitimately need to land atomically with a proof-state finalize.
|
|
*/
|
|
export type ReservationTransactionUpdate = {
|
|
id: number
|
|
status?: TransactionStatus
|
|
data?: string
|
|
amount?: number
|
|
fee?: number
|
|
balanceAfter?: number
|
|
outputToken?: string
|
|
keysetId?: string
|
|
proof?: string
|
|
}
|
|
|
|
/**
|
|
* Commit a reservation: apply the supplied state transitions, optionally a
|
|
* transaction-row update, and delete the reservation row — all in a single
|
|
* SQLite transaction.
|
|
*
|
|
* Passing `transactionUpdate` closes the proofs-table vs transactions-table
|
|
* atomicity window: a crash between proof-state finalize and tx-status update
|
|
* would otherwise leave a transaction stuck in PENDING/PREPARED while its
|
|
* underlying proofs are SPENT.
|
|
*/
|
|
export const commitReservation = function (
|
|
reservationId: string,
|
|
changes: {
|
|
toSpent?: Proof[]
|
|
toUnspent?: Proof[]
|
|
newProofs?: Array<{
|
|
proofs: Proof[] | CashuProof[]
|
|
state: ProofState
|
|
mintUrl: string
|
|
unit: string
|
|
tId: number
|
|
}>
|
|
transactionUpdate?: ReservationTransactionUpdate
|
|
/**
|
|
* Per-keyset derivation counters to persist atomically with the proof
|
|
* writes — the "W2" backstop to the write-through in Mint.persistCounter
|
|
* ("W1"). W1 already persists this value the instant cashu derives, BEFORE
|
|
* this commit, so on the normal path this upsert is a monotonic no-op. Its
|
|
* job is the failure case: if W1's write was dropped (logged, not thrown),
|
|
* folding the counter into the SAME transaction as the proofs guarantees a
|
|
* committed proof can never outlive its counter advance — which would let the
|
|
* next derivation reuse a blinded secret. Each upsert is monotonic.
|
|
*/
|
|
counterUpdate?: Array<{
|
|
keysetId: string
|
|
unit?: string
|
|
counter: number
|
|
}>
|
|
},
|
|
): void {
|
|
try {
|
|
const now = new Date().toISOString()
|
|
const batch: SQLBatchTuple[] = []
|
|
|
|
for (const proof of changes.toSpent ?? []) {
|
|
if (!isAlive(proof)) continue
|
|
batch.push([
|
|
`UPDATE proofs SET state = ?, updatedAt = ? WHERE secret = ?`,
|
|
['SPENT', now, proof.secret],
|
|
])
|
|
}
|
|
|
|
for (const proof of changes.toUnspent ?? []) {
|
|
if (!isAlive(proof)) continue
|
|
batch.push([
|
|
`UPDATE proofs SET state = ?, updatedAt = ? WHERE secret = ?`,
|
|
['UNSPENT', now, proof.secret],
|
|
])
|
|
}
|
|
|
|
for (const group of changes.newProofs ?? []) {
|
|
for (const proof of group.proofs) {
|
|
// proof.amount may be a cashu-ts `Amount` class instance (when the
|
|
// group came straight from `cashuWallet.send/mint/melt` responses)
|
|
// rather than a plain number. Coerce explicitly — SQLite's JSI
|
|
// binding can't bind non-primitive objects to an INTEGER column and
|
|
// would silently drop the row, leaving the proof in MST but absent
|
|
// from the database (lost on the next restart).
|
|
const amount = typeof proof.amount === 'number' ? proof.amount : Amount.from(proof.amount as any).toNumber()
|
|
batch.push([
|
|
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
proof.id,
|
|
amount,
|
|
proof.secret,
|
|
proof.C,
|
|
proof.dleq ? proof.dleq.r : null,
|
|
proof.dleq ? proof.dleq.s : null,
|
|
proof.dleq ? proof.dleq.e : null,
|
|
group.unit,
|
|
group.tId,
|
|
group.mintUrl,
|
|
group.state,
|
|
now,
|
|
],
|
|
])
|
|
}
|
|
}
|
|
|
|
if (changes.transactionUpdate) {
|
|
const tu = changes.transactionUpdate
|
|
const setClauses: string[] = []
|
|
const params: (string | number | null)[] = []
|
|
|
|
// Whitelist of fields that can be set atomically. Order matters only for
|
|
// readability — params must match clause order.
|
|
const setIfDefined = (col: string, value: string | number | undefined) => {
|
|
if (value !== undefined) {
|
|
setClauses.push(`${col} = ?`)
|
|
params.push(value)
|
|
}
|
|
}
|
|
setIfDefined('status', tu.status)
|
|
setIfDefined('data', tu.data)
|
|
setIfDefined('amount', tu.amount)
|
|
setIfDefined('fee', tu.fee)
|
|
setIfDefined('balanceAfter', tu.balanceAfter)
|
|
setIfDefined('outputToken', tu.outputToken)
|
|
setIfDefined('keysetId', tu.keysetId)
|
|
setIfDefined('proof', tu.proof)
|
|
|
|
if (setClauses.length > 0) {
|
|
params.push(tu.id)
|
|
batch.push([
|
|
`UPDATE transactions SET ${setClauses.join(', ')} WHERE id = ?`,
|
|
params,
|
|
])
|
|
}
|
|
}
|
|
|
|
for (const cu of changes.counterUpdate ?? []) {
|
|
batch.push(buildCounterUpsert(cu.keysetId, cu.unit, cu.counter, now))
|
|
}
|
|
|
|
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
|
|
|
|
const db = getInstance()
|
|
db.executeBatch(batch)
|
|
|
|
log.debug('[commitReservation] Reservation committed to DB', {
|
|
id: reservationId,
|
|
toSpent: changes.toSpent?.length ?? 0,
|
|
toUnspent: changes.toUnspent?.length ?? 0,
|
|
newGroups: changes.newProofs?.length ?? 0,
|
|
txUpdate: changes.transactionUpdate ? changes.transactionUpdate.id : undefined,
|
|
counterUpdates: changes.counterUpdate?.length ?? 0,
|
|
})
|
|
} catch (e: any) {
|
|
throw dbError('Could not commit proof reservation', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rollback a reservation: restore each locked proof to its originalState AND
|
|
* originalTId, then delete the reservation row — all in a single SQLite
|
|
* transaction.
|
|
*/
|
|
export const rollbackReservation = function (
|
|
reservationId: string,
|
|
lockedProofs: LockedProofSnapshot[],
|
|
): void {
|
|
try {
|
|
const now = new Date().toISOString()
|
|
const batch: SQLBatchTuple[] = []
|
|
|
|
// A proof that went SPENT while locked (sync saw the mint spend it) is never
|
|
// restored: un-spending it would put ecash the mint has already consumed back
|
|
// into the balance. Matches the MST mirror, which skips SPENT nodes.
|
|
for (const snap of lockedProofs) {
|
|
batch.push([
|
|
`UPDATE proofs SET state = ?, tId = ?, updatedAt = ? WHERE secret = ? AND state != 'SPENT'`,
|
|
[snap.originalState, snap.originalTId, now, snap.secret],
|
|
])
|
|
}
|
|
|
|
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
|
|
|
|
const db = getInstance()
|
|
db.executeBatch(batch)
|
|
|
|
log.info('[rollbackReservation]', 'Reservation rolled back', {
|
|
id: reservationId,
|
|
restoredCount: lockedProofs.length,
|
|
})
|
|
} catch (e: any) {
|
|
throw dbError('Could not rollback proof reservation', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return all reservations currently in the DB. Used at startup to roll back
|
|
* orphans (operations whose process died before they could commit or rollback).
|
|
*/
|
|
/**
|
|
* One-time backfill of `mintId` from the url a reservation was opened at (the v38
|
|
* seed). See backfillOnchainMintQuoteMintIds — same reasoning, and only fills
|
|
* NULLs, so it is idempotent.
|
|
*
|
|
* Almost always a no-op in practice: a reservation lives only for the duration of
|
|
* an operation, so an upgrade rarely catches one open. It exists for the upgrade
|
|
* that lands right after a crash left an orphan behind.
|
|
*/
|
|
export const backfillReservationMintIds = function (
|
|
mints: Array<{id: string; mintUrl: string}>,
|
|
): {updated: number} {
|
|
if (mints.length === 0) return {updated: 0}
|
|
try {
|
|
const db = getInstance()
|
|
let updated = 0
|
|
|
|
for (const mint of mints) {
|
|
const {rowsAffected} = db.execute(
|
|
`UPDATE reservations SET mintId = ? WHERE mintUrl = ? AND mintId IS NULL`,
|
|
[mint.id, mint.mintUrl],
|
|
)
|
|
updated += rowsAffected ?? 0
|
|
}
|
|
|
|
log.info('[backfillReservationMintIds]', 'Backfilled reservation mint ids', {updated})
|
|
return {updated}
|
|
} catch (e: any) {
|
|
throw dbError('Reservation mintIds could not be backfilled', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record the counter range a swap is about to derive its outputs from. Called
|
|
* synchronously from cashu-ts's onCountersReserved, which fires after the counters
|
|
* are allocated and BEFORE the swap request is sent — so the range is durable
|
|
* even if the process dies with the request in flight.
|
|
*/
|
|
export const setReservationCounters = function (
|
|
reservationId: string,
|
|
counters: ReservationCounters,
|
|
): void {
|
|
try {
|
|
const {keysetId, start, count, next} = counters
|
|
getInstance().execute(`UPDATE reservations SET counters = ? WHERE id = ?`, [
|
|
JSON.stringify({keysetId, start, count, next}),
|
|
reservationId,
|
|
])
|
|
} catch (e: any) {
|
|
throw dbError('Could not record reservation counters', e)
|
|
}
|
|
}
|
|
|
|
const _parseCounters = function (row: any): ReservationCounters | null {
|
|
if (!row.counters) return null
|
|
try {
|
|
return JSON.parse(row.counters)
|
|
} catch {
|
|
log.warn('[getOpenReservations] Could not parse counters JSON', {id: row.id})
|
|
return null
|
|
}
|
|
}
|
|
|
|
export const getOpenReservations = function (): ReservationRow[] {
|
|
try {
|
|
const db = getInstance()
|
|
const {rows} = db.execute(`SELECT * FROM reservations`)
|
|
if (!rows) return []
|
|
|
|
const result: ReservationRow[] = []
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows.item(i)
|
|
let lockedProofs: LockedProofSnapshot[] = []
|
|
try {
|
|
lockedProofs = JSON.parse(row.lockedProofs)
|
|
} catch (e) {
|
|
log.warn('[getOpenReservations] Could not parse lockedProofs JSON', {id: row.id})
|
|
}
|
|
result.push({
|
|
id: row.id,
|
|
transactionId: row.transactionId,
|
|
mintId: row.mintId ?? null,
|
|
mintUrl: row.mintUrl,
|
|
unit: row.unit,
|
|
operationType: row.operationType,
|
|
lockedProofs,
|
|
counters: _parseCounters(row),
|
|
createdAt: new Date(row.createdAt),
|
|
})
|
|
}
|
|
return result
|
|
} catch (e: any) {
|
|
throw dbError('Could not read open reservations', e)
|
|
}
|
|
}
|