mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Fix melt quote change recovery and async retrieval
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user