diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 7d11cd91..9cc6f0a8 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -16,6 +16,7 @@ import { type ProofState, type OperationCounters, type MeltPreview, + MeltQuoteState, } from '@cashu/cashu-ts' import { JS_BUNDLE_VERSION } from '@env' import {KeyChain, MinibitsClient, WalletKeys} from '../services' @@ -974,8 +975,10 @@ export const WalletStoreModel = types // Step 2: Complete the melt (sends to mint and constructs change proofs) const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview, undefined, options?.preferAsync) - // Remove the stored preview on success - currentCounter.removeMeltCounterValue(transactionId) + // Keep the preview for PENDING async melts — handlePendingMeltTask needs it to unbind change later + if (meltResponse.quote.state !== MeltQuoteState.PENDING) { + currentCounter.removeMeltCounterValue(transactionId) + } log.trace('[payLightningMelt]', {meltResponse}) return meltResponse diff --git a/src/screens/RecoveryOptionsScreen.tsx b/src/screens/RecoveryOptionsScreen.tsx index 3ac1b656..80e46798 100644 --- a/src/screens/RecoveryOptionsScreen.tsx +++ b/src/screens/RecoveryOptionsScreen.tsx @@ -206,7 +206,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_: const onPasteMintQuote = async function () { const quote = await Clipboard.getString() - if (!quote || quote.length !== 40) { + if (!quote) { setInfo(translate('recoveryInvalidMintQuote')) return } @@ -215,7 +215,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_: const onPasteMeltQuote = async function () { const quote = await Clipboard.getString() - if (!quote || quote.length !== 40) { + if (!quote) { setInfo(translate('recoveryInvalidMeltQuote')) return } @@ -261,10 +261,6 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_: throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.') } - if(meltQuote.length !== 40) { - throw new AppError(Err.VALIDATION_ERROR, 'Melt quote must have 40 characters.') - } - const result = await WalletTask.recoverMeltQuoteChange({ mintUrl: mintBalanceToRecoverFrom.mintUrl, meltQuote diff --git a/src/services/wallet/transferTask.ts b/src/services/wallet/transferTask.ts index af2aa333..662a5662 100644 --- a/src/services/wallet/transferTask.ts +++ b/src/services/wallet/transferTask.ts @@ -109,7 +109,7 @@ export const transferTask = async function ( amountToTransfer, unit, meltQuote, - encodedInvoice, + //encodedInvoice, isNwc: nwcEvent ? true : false, createdAt: new Date(), }) diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 93fd233a..c5f5edaa 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -15,7 +15,7 @@ import {CashuProof, CashuUtils} from './cashu/cashuUtils' import {LightningUtils} from './lightning/lightningUtils' import AppError, {Err} from '../utils/AppError' import {MintBalance, MintStatus} from '../models/Mint' -import {MeltQuoteBaseResponse, MeltQuoteBolt11Response, MeltQuoteResponse, MeltQuoteState, MintQuoteState, PaymentRequestPayload, Token, TokenMetadata, getDecodedToken, getEncodedToken, getTokenMetadata} from '@cashu/cashu-ts' +import {MeltQuoteBaseResponse, MeltQuoteBolt11Response, MeltQuoteResponse, MeltQuoteState, MintQuoteState, OutputData, PaymentRequestPayload, Token, TokenMetadata, getDecodedToken, getEncodedToken, getTokenMetadata} from '@cashu/cashu-ts' import {Mint} from '../models/Mint' import {pollerExists, stopPolling} from '../utils/poller' import { NostrClient, NostrEvent, NostrProfile } from './nostrService' @@ -219,7 +219,7 @@ const transferQueueAwaitable = function ( mintBalanceToTransferFrom: MintBalance, amountToTransfer: number, unit: MintUnit, - meltQuote: MeltQuoteResponse, + meltQuote: MeltQuoteBolt11Response, memo: string, invoiceExpiry: Date, encodedInvoice: string, @@ -2250,45 +2250,53 @@ const recoverMintQuote = async ( if (!change || change.length === 0) { throw new AppError(Err.VALIDATION_ERROR, `No change available for melt quote ${meltQuoteResponse.quote}`) } - - let txData: TransactionData = tx.data ? JSON.parse(tx.data) : [] - - try { - // Recover blind signatures requires original counter value to produce valid proofs - // This is stored in meltPreview now - const change = await walletStore.recoverMeltQuoteChange(mintUrl, tx) - // Make sure we do not recover already received change - const newChange = change.filter(proof => !proofsStore.alreadyExists(proof)) + let txData: TransactionData = tx.data ? JSON.parse(tx.data) : [] + + try { + if (!tx.keysetId) { + throw new AppError(Err.VALIDATION_ERROR, 'Missing keysetId on transaction', { meltQuote }) + } + + const currentCounter = mintInstance.getProofsCounterByKeysetId!(tx.keysetId) + const meltCounterValue = currentCounter?.getMeltCounterValue(tx.id) + + if (!meltCounterValue?.meltPreview) { + throw new AppError(Err.VALIDATION_ERROR, 'MeltPreview not found – this transaction may be from an older version', { meltQuote }) + } + + const meltPreview = meltCounterValue.meltPreview + const cashuWallet = await walletStore.getWallet(mintUrl, unit, { withSeed: true, keysetId: meltPreview.keysetId }) + const keyset = cashuWallet.getKeyset(meltPreview.keysetId) + + const reconstructedOutputData = meltPreview.outputData.map((od: any) => { + const blindingFactor = BigInt(od.blindingFactor) + const secretValues: number[] = typeof od.secret === 'object' && !ArrayBuffer.isView(od.secret) + ? Object.values(od.secret as Record) + : Array.from(od.secret as Uint8Array) + const secret = new Uint8Array(secretValues) + return new OutputData(od.blindedMessage, blindingFactor, secret) + }) + + const recoveredChange = change.map((sig, i) => reconstructedOutputData[i].toProof(sig, keyset)) + currentCounter.removeMeltCounterValue(tx.id) + + const newChange = recoveredChange.filter(proof => !proofsStore.alreadyExists(proof)) if (newChange.length === 0) { - throw new AppError(Err.MINT_ERROR, `No new ecash proofs to recover from melt quote ${meltQuoteResponse.quote}, ${change.length} proofs already in wallet.`) + throw new AppError(Err.MINT_ERROR, `No new ecash proofs to recover from melt quote ${meltQuoteResponse.quote}, ${recoveredChange.length} proofs already in wallet.`) } - - // Force zero-value swap to validate and receive change proofs - const { returnedProofs } = await walletStore.send( - mintUrl, - 0, - unit, - newChange as Proof[], - tx.id, - { increaseCounterBy: newChange.length } // ? - ) - - if (!returnedProofs || returnedProofs.length === 0) { - throw new AppError(Err.MINT_ERROR, 'Mint returned no proofs during change recovery') - } - - const { updatedAmount: recoveredAmount } = proofsStore.addOrUpdate(returnedProofs, { + + const { updatedAmount: recoveredAmount } = proofsStore.addOrUpdate(newChange, { mintUrl, unit, tId: tx.id, isPending: false, isSpent: false, }) - + const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance - const outputToken = getEncodedToken({ mint: mintUrl, proofs: returnedProofs, unit }) + const outputToken = getEncodedToken({ mint: mintUrl, proofs: newChange, unit }) txData.push({ status: TransactionStatus.RECOVERED, @@ -2358,18 +2366,38 @@ const handlePendingMeltTask = async (params: { let returnedAmount = 0 let outputToken: string | undefined - // Call completeMelt with the stored MeltPreview to unbind the change blind signatures. - // This is a POST to the mint; different from the GET quote check above. + // Unblind the change blind signatures returned by the GET quote check using the + // stored MeltPreview — avoids a second POST which would fail with "already spent". try { const mintInstance = mintsStore.findByUrl(mintUrl) + if (mintInstance && transaction.keysetId) { const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId) const meltCounterValue = currentCounter?.getMeltCounterValue(transactionId) - if (meltCounterValue?.meltPreview) { + + if (meltCounterValue?.meltPreview && quote.change?.length) { + log.trace('[handlePendingMelt] Unblinding change from quote response', { transactionId, quoteId, changeCount: quote.change.length }) + + const meltPreview = meltCounterValue.meltPreview const cashuWallet = await walletStore.getWallet(mintUrl, unit, - { withSeed: true, keysetId: transaction.keysetId }) - const { change } = await cashuWallet.completeMelt(meltCounterValue.meltPreview) + { withSeed: true, keysetId: meltPreview.keysetId }) + const keyset = cashuWallet.getKeyset(meltPreview.keysetId) + + // Reconstruct OutputData instances: serializeMeltPreview converts bigint→string + // and Uint8Array→plain-object, so we must restore the types before calling toProof. + const reconstructedOutputData = meltPreview.outputData.map((od: any) => { + const blindingFactor = BigInt(od.blindingFactor) + const secretValues: number[] = typeof od.secret === 'object' && !ArrayBuffer.isView(od.secret) + ? Object.values(od.secret as Record) + : Array.from(od.secret as Uint8Array) + const secret = new Uint8Array(secretValues) + return new OutputData(od.blindedMessage, blindingFactor, secret) + }) + + const change = quote.change.map((sig, i) => reconstructedOutputData[i].toProof(sig, keyset)) + currentCounter.removeMeltCounterValue(transactionId) + if (change.length > 0) { proofsStore.addOrUpdate(change, { mintUrl, tId: transactionId, unit, isPending: false, isSpent: false,