mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
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:
co-authored by
Claude Opus 4.8
parent
3cce38a2bd
commit
276cece26a
+33
-103
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user