diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 8c5b8a1b..079407b0 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -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. diff --git a/src/screens/SeedRecoveryScreen.tsx b/src/screens/SeedRecoveryScreen.tsx index 2fd09987..fd9be4d8 100644 --- a/src/screens/SeedRecoveryScreen.tsx +++ b/src/screens/SeedRecoveryScreen.tsx @@ -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), + }, }) } } diff --git a/src/services/wallet/operations/inFlightOperations.ts b/src/services/wallet/operations/inFlightOperations.ts index 9d54f3d4..0de8cfb7 100644 --- a/src/services/wallet/operations/inFlightOperations.ts +++ b/src/services/wallet/operations/inFlightOperations.ts @@ -90,25 +90,33 @@ const handleInFlightByMintTask = async (mint: Mint): Promise = {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 = {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 diff --git a/src/services/wallet/operations/meltOperations.ts b/src/services/wallet/operations/meltOperations.ts index 0a1a941a..053d2cf9 100644 --- a/src/services/wallet/operations/meltOperations.ts +++ b/src/services/wallet/operations/meltOperations.ts @@ -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}) diff --git a/src/services/wallet/operations/mintOperations.ts b/src/services/wallet/operations/mintOperations.ts index b2e66dee..6ac54e6b 100644 --- a/src/services/wallet/operations/mintOperations.ts +++ b/src/services/wallet/operations/mintOperations.ts @@ -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})