From 5b4b96c4904c9244c48843df761eff5300a5d84a Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 9 Jan 2026 23:40:49 +0100 Subject: [PATCH] Reimplement v3 counters --- src/models/Mint.ts | 21 +-- src/models/WalletStore.ts | 271 +++++++++++++++++++++++--------------- 2 files changed, 175 insertions(+), 117 deletions(-) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 5f38774e..7a418821 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -1,10 +1,11 @@ import {cast, detach, flow, getParent, getRoot, getSnapshot, IAnyStateTreeNode, Instance, isAlive, IStateTreeNode, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree' import {withSetPropAction} from './helpers/withSetPropAction' -import { - type GetInfoResponse, - type MintKeys as CashuMintKeys, - type MintKeyset as CashuMintKeyset, - Mint as CashuMint, +import { + type GetInfoResponse, + type MintKeys as CashuMintKeys, + type MintKeyset as CashuMintKeyset, + Mint as CashuMint, + type MeltPreview, } from '@cashu/cashu-ts' import {colors, getRandomIconColor} from '../theme' import { log } from '../services' @@ -16,7 +17,6 @@ import { generateId } from '../utils/utils' import { Proof } from './Proof' import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils' -ß export type MintBalance = { mintUrl: string balances: { @@ -49,10 +49,11 @@ const InFlightRequestModel = types.model('InFlightRequest', { request: types.frozen(), // or replace `any` with your actual request type }) -// Sub-model for melt counter snapshots +// Sub-model for melt previews (v3.x uses MeltPreview instead of just counter) const MeltCounterValueModel = types.model('MeltCounterValue', { transactionId: types.number, - counterAtMelt: types.number, // the counter value when melt started + counterAtMelt: types.number, // the counter value when melt started (kept for backward compatibility) + meltPreview: types.maybe(types.frozen()), // v3.x MeltPreview object for recovery createdAt: types.optional(types.Date, () => new Date()), // optional: when it was added }) @@ -140,7 +141,7 @@ export const MintProofsCounterModel = types }, // === Melt counter tracking === - addMeltCounterValue(transactionId: number): number { + addMeltCounterValue(transactionId: number, meltPreview?: MeltPreview): number { const key = transactionId.toString() if (self.meltCounterValues.has(key)) { log.warn('[addMeltCounterValue]', 'Melt already tracked', { transactionId }) @@ -150,12 +151,14 @@ export const MintProofsCounterModel = types self.meltCounterValues.set(key, { transactionId, counterAtMelt: self.counter, + meltPreview, createdAt: new Date(), }) log.trace('[addMeltCounterValue]', { transactionId, counterAtMelt: self.counter, + hasMeltPreview: !!meltPreview, }) return self.counter diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 9765b576..33b146d8 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -14,6 +14,7 @@ import { MintQuoteBolt11Response, type ProofState, type OperationCounters, + type MeltPreview, } from '@cashu/cashu-ts' import { JS_BUNDLE_VERSION } from '@env' import {KeyChain, MinibitsClient, WalletKeys} from '../services' @@ -411,63 +412,86 @@ export const WalletStoreModel = types ) const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId) - + // outputs error healing if(options && options.increaseCounterBy) { currentCounter.increaseProofsCounter(options.increaseCounterBy) } log.debug('[WalletStore.receive] counter', currentCounter.counter) - + + // Sync wallet's internal counter with our stored counter (v3.x) + yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter) + // P2PK locked tokens can be received only by the wallet they are locked to (if lock has not expired) const isLocked = CashuUtils.isTokenP2PKLocked(decodedToken) let isLockedToWallet = false const walletKeys = yield self.getCachedWalletKeys() - + if(isLocked) { const lockedToPK = CashuUtils.getP2PKPubkeySecret(decodedToken.proofs[0].secret) const locktime = CashuUtils.getP2PKLocktime(decodedToken.proofs[0].secret) const walletP2PK = '02' + walletKeys.NOSTR.publicKey - isLockedToWallet = lockedToPK === walletP2PK - + isLockedToWallet = lockedToPK === walletP2PK + if(!isLockedToWallet) { throw new AppError(Err.VALIDATION_ERROR, 'Ecash token is locked to another wallet, can not receive it.', {lockedToPK, walletP2PK, locktime}) - } + } } - + const receiveParams: ReceiveParams = options?.inFlightRequest?.request || { token: decodedToken, options: { - keysetId: cashuWallet.keysetId, - counter: currentCounter.counter, - privkey: isLockedToWallet ? walletKeys.NOSTR.privateKey : undefined, + keysetId: cashuWallet.keysetId, + // Note: counter is no longer passed in v3.x, wallet manages it internally + privkey: isLockedToWallet ? walletKeys.NOSTR.privateKey : undefined, } - } - + } + + // @ts-ignore if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { currentCounter.addInFlightRequest(transactionId, receiveParams) - } + } + + let reservedCounters: OperationCounters | undefined try { const proofs = yield cashuWallet.receive( receiveParams.token, - receiveParams.options + { + ...receiveParams.options, + onCountersReserved: (info: OperationCounters) => { + reservedCounters = info + log.debug('[receive] Counters reserved', info) + } + } ) - + log.trace('[WalletStore.receive]', {proofs}) - - currentCounter.removeInFlightRequest(transactionId) - + + currentCounter.removeInFlightRequest(transactionId) + + // Update our counter to match what the wallet used (v3.x) + if (reservedCounters) { + currentCounter.counter = reservedCounters.next + log.debug('[receive] Updated counter', { + keysetId: reservedCounters.keysetId, + start: reservedCounters.start, + count: reservedCounters.count, + next: reservedCounters.next + }) + } + const receivedAmount: number = CashuUtils.getProofsAmount(proofs as Proof[]) const amountToReceive: number = CashuUtils.getProofsAmount(decodedToken.proofs) const swapFeePaid = amountToReceive - receivedAmount - + return { - proofs, + proofs, swapFeePaid } as { - proofs: CashuProof[], + proofs: CashuProof[], swapFeePaid: number } @@ -516,50 +540,74 @@ export const WalletStoreModel = types ) const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId) - + // outputs error healing if(options && options.increaseCounterBy) { currentCounter.increaseProofsCounter(options.increaseCounterBy) } - + log.debug('[WalletStore.send] counter', currentCounter.counter) + + // Sync wallet's internal counter with our stored counter (v3.x) + yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter) + const p2pk = options?.p2pk - + const sendParams: SendParams = options?.inFlightRequest?.request || { - amount: amountToSend, + amount: amountToSend, proofs: CashuUtils.exportProofs(proofsToSendFrom), options: { - keysetId: cashuWallet.keysetId, - counter: p2pk && p2pk.pubkey ? undefined : currentCounter.counter, // p2pk is not deterministic + keysetId: cashuWallet.keysetId, + // Note: counter is no longer passed in v3.x, wallet manages it internally includeFees: false, // fee reserve needs to be already in proofsToSendFrom p2pk } - } - + } + + // @ts-ignore if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { currentCounter.addInFlightRequest(transactionId, sendParams) } + let reservedCounters: OperationCounters | undefined + try { - + const {keep, send} = yield cashuWallet.swap( sendParams.amount, sendParams.proofs, - sendParams.options + { + ...sendParams.options, + onCountersReserved: (info: OperationCounters) => { + reservedCounters = info + log.debug('[send] Counters reserved', info) + } + } ) currentCounter.removeInFlightRequest(transactionId) - + + // Update our counter to match what the wallet used (v3.x) + if (reservedCounters) { + currentCounter.counter = reservedCounters.next + log.debug('[send] Updated counter', { + keysetId: reservedCounters.keysetId, + start: reservedCounters.start, + count: reservedCounters.count, + next: reservedCounters.next + }) + } + log.trace(`[WalletStore.send] ${keep.length} returnedProofs`, {keep}) - log.trace(`[WalletStore.send] ${send.length} proofsToSend`, {send}) - + log.trace(`[WalletStore.send] ${send.length} proofsToSend`, {send}) + const totalAmountToSendFrom: number = CashuUtils.getProofsAmount(proofsToSendFrom) - const returnedAmount: number = CashuUtils.getProofsAmount(keep) - const swapFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount - + const returnedAmount: number = CashuUtils.getProofsAmount(keep) + const swapFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount + return { returnedProofs: keep as CashuProof[], - proofsToSend: send as CashuProof[], + proofsToSend: send as CashuProof[], swapFeePaid } @@ -745,11 +793,12 @@ export const WalletStoreModel = types } } + // @ts-ignore if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { currentCounter.addInFlightRequest(transactionId, mintParams) } - let reservedCounters: OperationCounters | undefined = undefined + let reservedCounters: OperationCounters | undefined try { @@ -758,8 +807,8 @@ export const WalletStoreModel = types mintParams.quote, { keysetId: mintParams.options?.keysetId, - onCountersReserved: (info) => { - reservedCounters = info as OperationCounters + onCountersReserved: (info: OperationCounters) => { + reservedCounters = info log.debug('[mintProofsBolt11] Counters reserved', info) } } @@ -864,66 +913,71 @@ export const WalletStoreModel = types currentCounter.increaseProofsCounter(options.increaseCounterBy) } - // store local counter value to be able to recover the change if needed - const counterValueForMelt = currentCounter.addMeltCounterValue(transactionId) + // Sync wallet's internal counter with our stored counter (v3.x) + yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter) - // Preemptively increase local counter in case we miss response with change from mint - currentCounter.increaseProofsCounter(proofsToMeltFrom.length) // ? - - let countChangeOutputs = 0 - if (meltQuote.fee_reserve > 0) { - countChangeOutputs = Math.ceil(Math.log2(meltQuote.fee_reserve)) || 1 - currentCounter.increaseProofsCounter(countChangeOutputs) - } - - log.trace('[WalletStore.payLightningMelt] Counter state before melt', {localCounter: currentCounter.counter, counterValueForMelt}) - - const meltParams: MeltParams = options?.inFlightRequest?.request || { - meltQuote, - proofsToSend: CashuUtils.exportProofs(proofsToMeltFrom), - options: { - keysetId: cashuWallet.keysetId, - counter: counterValueForMelt + log.trace('[WalletStore.payLightningMelt] Preparing melt', { + localCounter: currentCounter.counter, + proofsCount: proofsToMeltFrom.length + }) + + let reservedCounters: OperationCounters | undefined + + // Step 1: Prepare the melt (creates change outputs deterministically) + const meltPreview: MeltPreview = yield cashuWallet.prepareMelt( + 'bolt11', + meltQuote, + CashuUtils.exportProofs(proofsToMeltFrom), + { + keysetId: cashuWallet.keysetId, + onCountersReserved: (info: OperationCounters) => { + reservedCounters = info + log.debug('[prepareMelt] Counters reserved', info) + } } - } - - if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { - currentCounter.addInFlightRequest(transactionId, meltParams) - } - + ) + + // Store the MeltPreview for potential recovery + currentCounter.addMeltCounterValue(transactionId, meltPreview) + + // Update our counter to match what the wallet used (v3.x) + if (reservedCounters) { + currentCounter.counter = reservedCounters.next + log.debug('[prepareMelt] Updated counter', { + keysetId: reservedCounters.keysetId, + start: reservedCounters.start, + count: reservedCounters.count, + next: reservedCounters.next + }) + } + try { + // Step 2: Complete the melt (sends to mint and constructs change proofs) + const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview) - const meltResponse = yield cashuWallet.meltProofsBolt11( - meltParams.meltQuote, - meltParams.proofsToSend, - meltParams.options - ) + // Remove the stored preview on success + currentCounter.removeMeltCounterValue(transactionId) - currentCounter.removeInFlightRequest(transactionId) - log.trace('[payLightningMelt]', {meltResponse}) - // we normalize naming of returned parameters - return meltResponse as MeltProofsResponse + return meltResponse } catch (e: any) { - if(!e.message.toLowerCase().includes('timeout') && - !e.message.toLowerCase().includes('network request failed')) { - // remove in-flight request only if it was not a timeout or network error - currentCounter.removeInFlightRequest(transactionId) - // we might roll back preemptive counter increase - // currentCounter.decreaseProofsCounter(countChangeOutputs) - } + // On error, keep the MeltPreview stored so we can attempt recovery later + log.error('[payLightningMelt] Melt failed, MeltPreview stored for recovery', { + transactionId, + error: e.message + }) let message = 'Lightning payment failed.' if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions; throw new AppError( - Err.MINT_ERROR, + Err.MINT_ERROR, message, { message: e.message, - caller: 'payLightningMelt', + caller: 'payLightningMelt', mintUrl, - code: e.code || undefined, + code: e.code || undefined, } ) } @@ -957,56 +1011,57 @@ export const WalletStoreModel = types } }), recoverMeltQuoteChange: flow(function* recoverMeltQuoteChange( - mintUrl: string, + mintUrl: string, meltQuote: MeltQuoteBolt11Response, - transactionId: number + transactionId: number ) { try { const mintInstance = self.getMintModelInstance(mintUrl) - + if(!mintInstance) { throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl}) } const cashuWallet = yield self.getWallet( - mintUrl, - 'sat', + mintUrl, + 'sat', { - withSeed: true, + withSeed: true, } ) const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId) - const counterValueForMelt = currentCounter.getMeltCounterValue(transactionId)?.counterAtMelt + const meltCounterValue = currentCounter.getMeltCounterValue(transactionId) - if(counterValueForMelt === undefined) { - throw new AppError(Err.VALIDATION_ERROR, 'Change already claimed - counterAtMelt not available for this transaction', {mintUrl, transactionId}) + if(!meltCounterValue) { + throw new AppError(Err.VALIDATION_ERROR, 'Change already claimed - melt data not available for this transaction', {mintUrl, transactionId}) } - const {change}: MeltProofsResponse = yield cashuWallet.recoverMeltQuoteChange( - meltQuote, - { - keysetId: cashuWallet.keysetId, - counter: counterValueForMelt - } - ) + const meltPreview = meltCounterValue.meltPreview + + if(!meltPreview) { + throw new AppError(Err.VALIDATION_ERROR, 'MeltPreview not found - this transaction may be from an older version', {mintUrl, transactionId}) + } + + // Use completeMelt with the stored MeltPreview to recover change + const {change}: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview) currentCounter.removeMeltCounterValue(transactionId) - + log.info('[recoverMeltQuoteChange]', {change}) - + return change } catch (e: any) { let message = 'The mint could not return change from a melt quote.' if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions; throw new AppError( - Err.MINT_ERROR, - message, + Err.MINT_ERROR, + message, { message: e.message, - caller: 'recoverMeltQuoteChange', - mintUrl, + caller: 'recoverMeltQuoteChange', + mintUrl, } ) } @@ -1094,4 +1149,4 @@ export const WalletStoreModel = types export interface WalletStore extends Instance {} export interface WalletStoreSnapshot - extends SnapshotOut {} + extends SnapshotOut {} \ No newline at end of file