refactor(proofs): phase out addOrUpdate; single atomic write path (step 5)

ProofsStore.addOrUpdate was a second proof-creation path parallel to
commitReservation, with its own MST mirroring loop and its own
non-atomic Database.addOrUpdateProofs write + separate tx.update. That
left counter/proofs/tx as three separate writes on the recovery paths.

Convert all six add-only callers to the reservation pattern
(reserve([]) + commitReservation{newProofs, transactionUpdate}), so proofs
+ tx + keyset counter land in ONE SQLite transaction — same idiom topup
finalize already uses:
  - mintOperations.recoverMintQuote
  - meltOperations change-recovery
  - inFlightOperations receive-retry + topup-retry
  - SeedRecoveryScreen UNSPENT + PENDING

Then remove addOrUpdate entirely. Proof creation now flows through a
single, atomic, well-tested code path, shrinking the critical surface.
Multi-step seed-recovery tx.update() sequences collapse into one atomic
transactionUpdate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-06-04 10:53:05 +02:00
co-authored by Claude Opus 4.8
parent 3cce38a2bd
commit 276cece26a
5 changed files with 161 additions and 190 deletions
+33 -103
View File
@@ -105,116 +105,46 @@ import {
}))
// ───────────────────── ACTIONS ─────────────────────
.actions(self => ({
loadProofsFromDatabase: flow(function* loadProofsFromDatabase(includeSpent: boolean = false) {
const proofRecords: ProofRecord[] = yield Database.getProofs(
true, // includeUnspent
true, // includePending
includeSpent
)
.actions(self => ({
loadProofsFromDatabase: flow(function* loadProofsFromDatabase(includeSpent: boolean = false) {
const proofRecords: ProofRecord[] = yield Database.getProofs(
true, // includeUnspent
true, // includePending
includeSpent
)
self.proofs.clear()
self.proofs.clear()
for (const record of proofRecords) {
const {
state,
dleq_e,
dleq_r,
dleq_s,
updatedAt,
...coreProof
} = record
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
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,
self.proofs.put(
ProofModel.create({
...coreProof,
state: state ?? 'UNSPENT',
dleq,
})
}),
addOrUpdate(
proofs: CashuProof[] | Proof[],
update: {
mintUrl: string,
tId: number,
unit: MintUnit
state: ProofState,
}): { updatedAmount: number; updatedProofs: Proof[] } {
if (proofs.length === 0) return { updatedAmount: 0, updatedProofs: [] }
let updatedAmount = 0
const updatedProofs: Proof[] = []
const { state, tId, unit, mintUrl } = update
const mintsStore = getRootStore(self).mintsStore
const mintInstance = mintsStore.findByUrl(mintUrl)
if (!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint not found in the wallet', { mintUrl })
)
}
for (const proof of proofs) {
let proofNode = self.getBySecret(proof.secret)
if (proofNode) {
if (proofNode.state === 'SPENT') continue // never move a spent proof backward
if (!isAlive(proofNode)) {
log.error('[addOrUpdate]', 'Proof instance is not alive, aborting state update', { secret: proofNode.secret })
continue
}
proofNode?.setProp('mintUrl', mintUrl)
proofNode?.setProp('tId', tId)
proofNode?.setProp('unit', unit)
proofNode?.setProp('state', state)
} else {
proofNode = ProofModel.create({
...proof,
amount: Number(proof.amount),
mintUrl,
tId,
unit,
state,
})
self.proofs.put(proofNode)
}
updatedAmount += proofNode.amount
updatedProofs.push(proofNode)
}
// The keyset counter is NOT advanced here. Every caller already
// advanced it via the authoritative v3.x path before reaching this
// method: WalletStore.setProofsCounter(reservedCounters.next) for the
// inflight/mint/melt-recovery callers, and the whole-interval advance
// in SeedRecoveryScreen for seed recovery. The old
// `increaseProofsCounter(proofs.length)` here double-advanced the
// counter (a pre-v3.x leftover) and was removed.
if (updatedProofs.length > 0) {
Database.addOrUpdateProofs(updatedProofs, state)
}
log.trace('[addOrUpdate]', `Added or updated ${updatedProofs.length} ${state} proofs`)
return { updatedAmount, updatedProofs }
},
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,
})
}),
// Lock proofs locally during an outgoing operation (send, melt prepare, etc.)
// Does NOT touch pendingByMintSecrets — that is mint-reported pending.
+39 -32
View File
@@ -25,7 +25,7 @@ import { useStores } from '../models'
import { MintListItem } from './Mints/MintListItem'
import { Mint } from '../models/Mint'
import { MintKeyset } from '@cashu/cashu-ts'
import { CashuUtils } from '../services/cashu/cashuUtils'
import { CashuUtils, CashuProof } from '../services/cashu/cashuUtils'
import { Proof } from '../models/Proof'
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../models/Transaction'
import { ResultModalInfo } from './Wallet/ResultModalInfo'
@@ -289,32 +289,36 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
transaction = await transactionsStore.addTransaction(newTransaction)
const { updatedAmount: addedAmount } = proofsStore.addOrUpdate(proofStates.UNSPENT, {
mintUrl: recoveredMint.mintUrl,
unit: selectedKeyset.unit as MintUnit,
tId: transaction!.id,
state: 'UNSPENT',
})
recoveredAmount = amount
if (amount !== addedAmount) {
transaction!.update({amount: addedAmount})
recoveredAmount = addedAmount
}
const currentSpendable = proofsStore.getUnitBalance(selectedKeyset.unit as MintUnit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + amount
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
recoveredAmount,
createdAt: new Date(),
})
transaction!.update({
status: TransactionStatus.COMPLETED,
data: JSON.stringify(transactionData)
// Add recovered proofs + finalize the tx atomically (one
// SQLite txn, incl. the keyset counter). No inputs locked.
const reservation = proofsStore.reserve([], {
transactionId: transaction!.id,
mintUrl: recoveredMint.mintUrl,
unit: selectedKeyset.unit as MintUnit,
operationType: 'seed-recovery',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs: proofStates.UNSPENT as CashuProof[], state: 'UNSPENT', tId: transaction!.id}],
transactionUpdate: {
id: transaction!.id,
status: TransactionStatus.COMPLETED,
amount,
balanceAfter,
data: JSON.stringify(transactionData),
},
})
const balanceAfter = proofsStore.getUnitBalance(selectedKeyset.unit as MintUnit)?.unitBalance
transaction!.update({balanceAfter})
}
if(proofStates.PENDING.length > 0) {
@@ -344,26 +348,29 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
pendingTransaction = await transactionsStore.addTransaction(newTransaction)
const { updatedAmount: addedAmount } = proofsStore.addOrUpdate(proofStates.PENDING, {
mintUrl: recoveredMint.mintUrl,
unit: selectedKeyset.unit as MintUnit,
tId: pendingTransaction!.id,
state: 'PENDING',
})
if (pendingAmount !== addedAmount) {
pendingTransaction!.update({amount: addedAmount})
}
// Finally, update pending transaction
pendingTransactionData.push({
status: TransactionStatus.PENDING,
createdAt: new Date(),
})
pendingTransaction!.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(pendingTransactionData)
// Add recovered pending proofs + finalize the tx atomically
// (one SQLite txn, incl. the keyset counter). No inputs locked.
const reservation = proofsStore.reserve([], {
transactionId: pendingTransaction!.id,
mintUrl: recoveredMint.mintUrl,
unit: selectedKeyset.unit as MintUnit,
operationType: 'seed-recovery-pending',
rollbackTo: 'PENDING',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs: proofStates.PENDING as CashuProof[], state: 'PENDING', tId: pendingTransaction!.id}],
transactionUpdate: {
id: pendingTransaction!.id,
status: TransactionStatus.PENDING,
amount: pendingAmount,
data: JSON.stringify(pendingTransactionData),
},
})
}
}
@@ -90,25 +90,33 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
{inFlightRequest: inFlight},
)
const {updatedAmount: receivedAmount} = proofsStore.addOrUpdate(proofs, {
mintUrl,
tId: tx.id,
unit,
state: 'UNSPENT',
})
const receivedAmount = CashuUtils.getProofsAmount(proofs)
const outputToken = getEncodedToken({mint: mintUrl, proofs: normalizeProofAmounts(proofs), unit})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + receivedAmount
txData.push({status: TransactionStatus.COMPLETED, receivedAmount, swapFeePaid, createdAt: new Date()})
tx.update({
amount: receivedAmount,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
outputToken,
balanceAfter,
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
// Add received proofs + complete the tx atomically (one
// SQLite txn, incl. the keyset counter). No inputs locked.
const reservation = proofsStore.reserve([], {
transactionId: tx.id,
mintUrl,
unit,
operationType: 'receive-retry',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
transactionUpdate: {
id: tx.id,
amount: receivedAmount,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
outputToken,
balanceAfter,
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
},
})
break
@@ -214,23 +222,31 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
{inFlightRequest: inFlight},
)
proofsStore.addOrUpdate(proofs, {
mintUrl,
tId: tx.id,
unit,
state: 'UNSPENT',
})
const recoveredAmount = CashuUtils.getProofsAmount(proofs)
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + recoveredAmount
stopPolling(`handlePendingTopupPoller-${tx.paymentId}`)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
txData.push({status: TransactionStatus.COMPLETED, createdAt: new Date()})
tx.update({
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
balanceAfter,
// Add minted proofs + complete the tx atomically (one
// SQLite txn, incl. the keyset counter). No inputs locked.
const reservation = proofsStore.reserve([], {
transactionId: tx.id,
mintUrl,
unit,
operationType: 'topup-retry',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
balanceAfter,
},
})
break
@@ -136,14 +136,9 @@ const recoverMeltQuoteChange = async (
throw new MintError(`No new ecash proofs to recover from melt quote ${meltQuoteResponse.quote}, ${recoveredChange.length} proofs already in wallet.`)
}
const {updatedAmount: recoveredAmount} = proofsStore.addOrUpdate(newChange, {
mintUrl,
unit,
tId: tx.id,
state: 'UNSPENT',
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
const recoveredAmount = CashuUtils.getProofsAmount(newChange)
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + recoveredAmount
const outputToken = getEncodedToken({mint: mintUrl, proofs: newChange, unit})
txData.push({
@@ -152,12 +147,26 @@ const recoverMeltQuoteChange = async (
createdAt: new Date(),
})
tx.update({
status: TransactionStatus.RECOVERED,
amount: recoveredAmount,
balanceAfter,
outputToken,
data: JSON.stringify(txData),
// Add the recovered change + finalize the tx atomically (one
// SQLite txn, incl. the keyset counter). No inputs are locked
// here (the melt already spent them), so rollback is a no-op.
const reservation = proofsStore.reserve([], {
transactionId: tx.id,
mintUrl,
unit,
operationType: 'melt-change-recover',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs: newChange, state: 'UNSPENT', tId: tx.id}],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.RECOVERED,
amount: recoveredAmount,
balanceAfter,
outputToken,
data: JSON.stringify(txData),
},
})
log.debug('[recoverMeltQuoteChange] Success', {meltQuote, recoveredAmount})
@@ -222,23 +222,32 @@ const recoverMintQuote = async (
throw new MintError('Mint returned no proofs to recover')
}
const {updatedAmount: recoveredAmount} = proofsStore.addOrUpdate(proofs, {
mintUrl,
unit,
tId: tx.id,
state: 'UNSPENT',
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
const recoveredAmount = CashuUtils.getProofsAmount(proofs)
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + recoveredAmount
txData.push({status: TransactionStatus.RECOVERED, recoveredAmount, createdAt: new Date()})
tx.update({
status: TransactionStatus.RECOVERED,
amount: recoveredAmount,
keysetId: proofs[0].id,
balanceAfter,
data: JSON.stringify(txData),
// Add the recovered proofs + finalize the tx atomically (one SQLite
// txn, incl. the keyset counter). No input proofs are locked, so the
// empty reservation's rollback is a no-op.
const reservation = proofsStore.reserve([], {
transactionId: tx.id,
mintUrl,
unit,
operationType: 'topup-recover',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.RECOVERED,
amount: recoveredAmount,
keysetId: proofs[0].id,
balanceAfter,
data: JSON.stringify(txData),
},
})
log.debug('[recoverMintQuote] Success', {mintUrl, mintQuote, recoveredAmount})