Fix melt quote change recovery and async retrieval

This commit is contained in:
minibits-cash
2026-05-10 23:17:58 +02:00
parent 27734a1032
commit 15c6fa7a43
4 changed files with 71 additions and 44 deletions
+5 -2
View File
@@ -16,6 +16,7 @@ import {
type ProofState, type ProofState,
type OperationCounters, type OperationCounters,
type MeltPreview, type MeltPreview,
MeltQuoteState,
} from '@cashu/cashu-ts' } from '@cashu/cashu-ts'
import { JS_BUNDLE_VERSION } from '@env' import { JS_BUNDLE_VERSION } from '@env'
import {KeyChain, MinibitsClient, WalletKeys} from '../services' 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) // Step 2: Complete the melt (sends to mint and constructs change proofs)
const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview, undefined, options?.preferAsync) const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview, undefined, options?.preferAsync)
// Remove the stored preview on success // Keep the preview for PENDING async melts — handlePendingMeltTask needs it to unbind change later
currentCounter.removeMeltCounterValue(transactionId) if (meltResponse.quote.state !== MeltQuoteState.PENDING) {
currentCounter.removeMeltCounterValue(transactionId)
}
log.trace('[payLightningMelt]', {meltResponse}) log.trace('[payLightningMelt]', {meltResponse})
return meltResponse return meltResponse
+2 -6
View File
@@ -206,7 +206,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_:
const onPasteMintQuote = async function () { const onPasteMintQuote = async function () {
const quote = await Clipboard.getString() const quote = await Clipboard.getString()
if (!quote || quote.length !== 40) { if (!quote) {
setInfo(translate('recoveryInvalidMintQuote')) setInfo(translate('recoveryInvalidMintQuote'))
return return
} }
@@ -215,7 +215,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_:
const onPasteMeltQuote = async function () { const onPasteMeltQuote = async function () {
const quote = await Clipboard.getString() const quote = await Clipboard.getString()
if (!quote || quote.length !== 40) { if (!quote) {
setInfo(translate('recoveryInvalidMeltQuote')) setInfo(translate('recoveryInvalidMeltQuote'))
return return
} }
@@ -261,10 +261,6 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_:
throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.') 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({ const result = await WalletTask.recoverMeltQuoteChange({
mintUrl: mintBalanceToRecoverFrom.mintUrl, mintUrl: mintBalanceToRecoverFrom.mintUrl,
meltQuote meltQuote
+1 -1
View File
@@ -109,7 +109,7 @@ export const transferTask = async function (
amountToTransfer, amountToTransfer,
unit, unit,
meltQuote, meltQuote,
encodedInvoice, //encodedInvoice,
isNwc: nwcEvent ? true : false, isNwc: nwcEvent ? true : false,
createdAt: new Date(), createdAt: new Date(),
}) })
+63 -35
View File
@@ -15,7 +15,7 @@ import {CashuProof, CashuUtils} from './cashu/cashuUtils'
import {LightningUtils} from './lightning/lightningUtils' import {LightningUtils} from './lightning/lightningUtils'
import AppError, {Err} from '../utils/AppError' import AppError, {Err} from '../utils/AppError'
import {MintBalance, MintStatus} from '../models/Mint' 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 {Mint} from '../models/Mint'
import {pollerExists, stopPolling} from '../utils/poller' import {pollerExists, stopPolling} from '../utils/poller'
import { NostrClient, NostrEvent, NostrProfile } from './nostrService' import { NostrClient, NostrEvent, NostrProfile } from './nostrService'
@@ -219,7 +219,7 @@ const transferQueueAwaitable = function (
mintBalanceToTransferFrom: MintBalance, mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number, amountToTransfer: number,
unit: MintUnit, unit: MintUnit,
meltQuote: MeltQuoteResponse, meltQuote: MeltQuoteBolt11Response,
memo: string, memo: string,
invoiceExpiry: Date, invoiceExpiry: Date,
encodedInvoice: string, encodedInvoice: string,
@@ -2250,45 +2250,53 @@ const recoverMintQuote = async (
if (!change || change.length === 0) { if (!change || change.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, `No change available for melt quote ${meltQuoteResponse.quote}`) 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 let txData: TransactionData = tx.data ? JSON.parse(tx.data) : []
const newChange = change.filter(proof => !proofsStore.alreadyExists(proof))
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<string, number>)
: 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) { 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 { updatedAmount: recoveredAmount } = proofsStore.addOrUpdate(newChange, {
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, {
mintUrl, mintUrl,
unit, unit,
tId: tx.id, tId: tx.id,
isPending: false, isPending: false,
isSpent: false, isSpent: false,
}) })
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
const outputToken = getEncodedToken({ mint: mintUrl, proofs: returnedProofs, unit }) const outputToken = getEncodedToken({ mint: mintUrl, proofs: newChange, unit })
txData.push({ txData.push({
status: TransactionStatus.RECOVERED, status: TransactionStatus.RECOVERED,
@@ -2358,18 +2366,38 @@ const handlePendingMeltTask = async (params: {
let returnedAmount = 0 let returnedAmount = 0
let outputToken: string | undefined let outputToken: string | undefined
// Call completeMelt with the stored MeltPreview to unbind the change blind signatures. // Unblind the change blind signatures returned by the GET quote check using the
// This is a POST to the mint; different from the GET quote check above. // stored MeltPreview — avoids a second POST which would fail with "already spent".
try { try {
const mintInstance = mintsStore.findByUrl(mintUrl) const mintInstance = mintsStore.findByUrl(mintUrl)
if (mintInstance && transaction.keysetId) { if (mintInstance && transaction.keysetId) {
const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId) const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId)
const meltCounterValue = currentCounter?.getMeltCounterValue(transactionId) 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, const cashuWallet = await walletStore.getWallet(mintUrl, unit,
{ withSeed: true, keysetId: transaction.keysetId }) { withSeed: true, keysetId: meltPreview.keysetId })
const { change } = await cashuWallet.completeMelt(meltCounterValue.meltPreview) 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<string, number>)
: 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) currentCounter.removeMeltCounterValue(transactionId)
if (change.length > 0) { if (change.length > 0) {
proofsStore.addOrUpdate(change, { proofsStore.addOrUpdate(change, {
mintUrl, tId: transactionId, unit, isPending: false, isSpent: false, mintUrl, tId: transactionId, unit, isPending: false, isSpent: false,