diff --git a/__tests__/recoverMeltChange.test.ts b/__tests__/recoverMeltChange.test.ts new file mode 100644 index 00000000..bd5dcb16 --- /dev/null +++ b/__tests__/recoverMeltChange.test.ts @@ -0,0 +1,217 @@ +import { + Amount, + OutputData, + blindMessage, + createBlindSignature, + createDLEQProof, + getPubKeyFromPrivKey, + pointFromBytes, +} from '@cashu/cashu-ts' +import type {SerializedBlindedSignature, HasKeysetKeys} from '@cashu/cashu-ts' +import {bytesToHex, hexToBytes} from '@noble/curves/utils.js' + +jest.mock('../src/services/logService', () => ({ + log: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }, +})) + +jest.mock('../src/services/nostrService', () => ({ + NostrClient: { + getFirstTagValue: jest.fn(), + }, +})) + +import {CashuUtils} from '../src/services/cashu/cashuUtils' + +const KEYSET_ID = '00abcdef00abcdef' + +// Each denomination is signed by the mint with a distinct private key. +const privkeyForAmount = (amount: number): Uint8Array => + hexToBytes((amount + 1).toString(16).padStart(64, '0')) + +const pubkeyHexForAmount = (amount: number): string => + pointFromBytes(getPubKeyFromPrivKey(privkeyForAmount(amount))).toHex(true) + +interface Blank { + blankIndex: number + assignedAmount: number + outputData: OutputData + sig: SerializedBlindedSignature +} + +/** + * Build a blank output (what the wallet stored at melt time) together with the + * change signature the mint produces from it. The blank's secret is the human + * readable label `blank-` so recovered proofs can be traced back to the + * exact blank that produced them. + */ +const makeBlank = function (blankIndex: number, assignedAmount: number): Blank { + const secret = new TextEncoder().encode(`blank-${blankIndex}`) + const r = BigInt('0x' + (blankIndex + 1).toString(16).padStart(64, '0')) + const bm = blindMessage(secret, r) + + const priv = privkeyForAmount(assignedAmount) + const blindSig = createBlindSignature(bm.B_, priv, KEYSET_ID) + const dleq = createDLEQProof(bm.B_, priv) + + const outputData = new OutputData( + {amount: Amount.from(0), B_: bm.B_.toHex(true), id: KEYSET_ID}, + r, + secret, + ) + + const sig: SerializedBlindedSignature = { + id: KEYSET_ID, + amount: Amount.from(assignedAmount), + C_: blindSig.C_.toHex(true), + dleq: {e: bytesToHex(dleq.e), s: bytesToHex(dleq.s)}, + } + + return {blankIndex, assignedAmount, outputData, sig} +} + +const keysetFor = (amounts: number[]): HasKeysetKeys => { + const keys: Record = {} + for (const a of amounts) keys[a.toString()] = pubkeyHexForAmount(a) + return {id: KEYSET_ID, keys} +} + +const secretOf = (proof: {secret: string}) => proof.secret + +describe('CashuUtils.recoverMeltChange', () => { + it('recovers in-order change with full DLEQ verification', () => { + const blanks = [makeBlank(0, 8), makeBlank(1, 4)] + const keyset = keysetFor([8, 4]) + + const {change, stats} = CashuUtils.recoverMeltChange({ + outputData: blanks.map(b => b.outputData), + quoteChange: blanks.map(b => b.sig), + keyset, + }) + + expect(stats).toEqual({recovered: 2, reordered: 0, noDleqFallback: 0, unmatched: 0}) + expect(change.map(p => Number(p.amount))).toEqual([8, 4]) + expect(change.map(secretOf)).toEqual(['blank-0', 'blank-1']) + expect(change.every(p => p.dleq)).toBe(true) + expect(CashuUtils.meltChangeRecoveryHasError(stats)).toBe(false) + }) + + it('re-pairs shuffled change to the correct blanks (the nutshell <0.20.1 bug)', () => { + const blanks = [makeBlank(0, 16), makeBlank(1, 8), makeBlank(2, 4), makeBlank(3, 2)] + const keyset = keysetFor([16, 8, 4, 2]) + + // Mint returns the signatures in a different order than the blanks were sent. + const shuffled = [blanks[2].sig, blanks[0].sig, blanks[3].sig, blanks[1].sig] + + const {change, stats} = CashuUtils.recoverMeltChange({ + outputData: blanks.map(b => b.outputData), + quoteChange: shuffled, + keyset, + }) + + expect(stats.recovered).toBe(4) + expect(stats.reordered).toBeGreaterThan(0) + expect(stats.noDleqFallback).toBe(0) + expect(stats.unmatched).toBe(0) + + // Output order follows the (shuffled) quoteChange order, but each proof must + // carry the secret + amount of the blank the mint actually signed. + expect(change.map(p => Number(p.amount))).toEqual([4, 16, 2, 8]) + expect(change.map(secretOf)).toEqual(['blank-2', 'blank-0', 'blank-3', 'blank-1']) + expect(change.every(p => p.dleq)).toBe(true) + }) + + it('handles fewer returned signatures than blanks (NUT-08 blank overshoot)', () => { + const blanks = [ + makeBlank(0, 32), + makeBlank(1, 16), + makeBlank(2, 8), + makeBlank(3, 4), + makeBlank(4, 2), + ] + const keyset = keysetFor([32, 16, 8, 4, 2]) + + // Only two outputs were actually needed, returned out of order. + const change = [blanks[3].sig, blanks[1].sig] + + const result = CashuUtils.recoverMeltChange({ + outputData: blanks.map(b => b.outputData), + quoteChange: change, + keyset, + }) + + expect(result.stats).toEqual({recovered: 2, reordered: 1, noDleqFallback: 0, unmatched: 0}) + expect(result.change.map(secretOf)).toEqual(['blank-3', 'blank-1']) + expect(result.change.map(p => Number(p.amount))).toEqual([4, 16]) + }) + + it('disambiguates blanks that share the same denomination via DLEQ', () => { + const blanks = [makeBlank(0, 8), makeBlank(1, 8)] + const keyset = keysetFor([8]) + + const shuffled = [blanks[1].sig, blanks[0].sig] + + const {change, stats} = CashuUtils.recoverMeltChange({ + outputData: blanks.map(b => b.outputData), + quoteChange: shuffled, + keyset, + }) + + expect(stats.recovered).toBe(2) + expect(stats.noDleqFallback).toBe(0) + // Each signature is matched to the exact blank it was signed from. + expect(change.map(secretOf)).toEqual(['blank-1', 'blank-0']) + }) + + it('falls back to no-DLEQ recovery when no blank verifies (genuine DLEQ failure)', () => { + const blanks = [makeBlank(0, 8), makeBlank(1, 4)] + const keyset = keysetFor([8, 4]) + + // Corrupt the first signature's DLEQ so it verifies against no blank. + const corrupted: SerializedBlindedSignature = { + ...blanks[0].sig, + dleq: {e: '00'.repeat(32), s: blanks[0].sig.dleq!.s}, + } + + const {change, stats} = CashuUtils.recoverMeltChange({ + outputData: blanks.map(b => b.outputData), + quoteChange: [corrupted, blanks[1].sig], + keyset, + }) + + expect(stats.recovered).toBe(2) + expect(stats.noDleqFallback).toBe(1) + expect(stats.unmatched).toBe(0) + expect(CashuUtils.meltChangeRecoveryHasError(stats)).toBe(true) + + // Funds are still recovered (positional blank), but the proof has no DLEQ. + expect(change).toHaveLength(2) + expect(change[0].dleq).toBeUndefined() + expect(secretOf(change[0])).toBe('blank-0') + expect(change[1].dleq).toBeTruthy() + }) + + it('reports unmatched signatures when a blank is missing and fallback is exhausted', () => { + const present = makeBlank(0, 8) + const missing = makeBlank(1, 4) // signed from a blank we do NOT provide + + const keyset = keysetFor([8, 4]) + + const {change, stats} = CashuUtils.recoverMeltChange({ + outputData: [present.outputData], + quoteChange: [present.sig, missing.sig], + keyset, + }) + + expect(stats.recovered).toBe(1) + expect(stats.unmatched).toBe(1) + expect(CashuUtils.meltChangeRecoveryHasError(stats)).toBe(true) + expect(change).toHaveLength(1) + expect(secretOf(change[0])).toBe('blank-0') + }) +}) diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index aa1c3044..4b423c0e 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -3,6 +3,8 @@ import { Amount, OutputData, hasValidDleq, + pointFromHex, + verifyDLEQProof, } from '@cashu/cashu-ts' import type { Token, @@ -14,6 +16,8 @@ import type { TokenMetadata, OutputDataLike, MeltPreview, + SerializedBlindedSignature, + HasKeysetKeys, } from '@cashu/cashu-ts' import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import AppError, {Err} from '../../utils/AppError' @@ -528,6 +532,134 @@ const serializeMeltPreview = (meltPreview: MeltPreview): StoredMeltPreview => ({ outputData: serializeOutputData(meltPreview.outputData), }) +export interface MeltChangeRecoveryStats { + /** Signatures turned into spendable proofs (matched or fallback). */ + recovered: number + /** Signatures whose matched blank was at a different index than received. */ + reordered: number + /** Signatures recovered WITHOUT a passing DLEQ proof (best-effort). */ + noDleqFallback: number + /** Signatures that could not be assigned a blank at all (lost). */ + unmatched: number +} + +/** True when the recovery hit a genuine error (not just benign reordering). */ +const meltChangeRecoveryHasError = (s: MeltChangeRecoveryStats): boolean => + s.noDleqFallback > 0 || s.unmatched > 0 + +/** + * Reconstruct spendable NUT-08 change proofs from a melt quote's blinded + * signatures, using the blank `outputData` captured in the meltPreview at melt + * time. Resilient and NEVER throws — it recovers as much as possible. + * + * Why this exists: some mints (e.g. nutshell < 0.20.1) return the `change[]` + * signatures of a paid melt quote in an order that does NOT match the blank + * outputs the wallet sent. cashu-ts `OutputData.toProof` assumes positional + * pairing (`change[i] ↔ outputData[i]`) and runs a DLEQ check that THROWS on + * mismatch. Mapped over the whole array, a single reorder aborted everything and + * the change was silently discarded — recorded as fee (real funds lost). + * + * Strategy, per returned signature: + * 1+2. Find the blank whose blinded message makes the mint's DLEQ proof + * verify. `verifyDLEQProof` is the alignment oracle: a signature verifies + * against exactly one blank, so this both REORDERS correctly and keeps + * DLEQ as a hard guarantee. Handles in-order and shuffled change alike. + * 3. If no blank verifies (a genuine DLEQ failure — mint/lib bug, not a + * reorder), unblind the positional blank WITHOUT DLEQ so the (still very + * likely valid) proof is recovered rather than dropped. Such proofs get + * validated naturally the next time they are spent. + * + * @returns recovered proofs plus stats the caller can log / persist to tx.data. + */ +const recoverMeltChange = function (params: { + outputData: OutputData[] + quoteChange: SerializedBlindedSignature[] + keyset: HasKeysetKeys +}): {change: CashuDecodedProof[]; stats: MeltChangeRecoveryStats} { + const {outputData, quoteChange, keyset} = params + const pool = outputData.map((od, idx) => ({od, idx, used: false})) + const change: CashuDecodedProof[] = [] + const stats: MeltChangeRecoveryStats = { + recovered: 0, + reordered: 0, + noDleqFallback: 0, + unmatched: 0, + } + + quoteChange.forEach((sig, sigIndex) => { + const amount = sig.amount.toString() + const pubkeyHex = keyset.keys[amount] + + // ── Layers 1+2: DLEQ-matched pairing (in-order or reordered change) ── + if (sig.dleq && pubkeyHex) { + let A: ReturnType | undefined + let C_: ReturnType | undefined + try { + A = pointFromHex(pubkeyHex) + C_ = pointFromHex(sig.C_) + } catch { + A = undefined + } + + if (A && C_) { + const dleq = {s: hexToBytes(sig.dleq.s), e: hexToBytes(sig.dleq.e)} + const match = pool.find(p => { + if (p.used) return false + try { + return verifyDLEQProof( + dleq, + pointFromHex(p.od.blindedMessage.B_), + C_!, + A!, + ) + } catch { + return false + } + }) + + if (match) { + match.used = true + if (match.idx !== sigIndex) stats.reordered++ + change.push(match.od.toProof(sig, keyset)) + stats.recovered++ + return + } + } + } + + // ── Layer 3: no blank's DLEQ verifies → not a reorder. Recover the proof + // WITHOUT DLEQ from the positional blank (best guess) so funds aren't + // lost. Prefer the same-index blank; fall back to any remaining one. + const fallback = + pool.find(p => !p.used && p.idx === sigIndex) ?? + pool.find(p => !p.used) + + if (!fallback) { + stats.unmatched++ + log.error( + '[CashuUtils.recoverMeltChange] No blank left for change signature; funds for this output are lost', + {sigIndex, amount, keysetId: keyset.id}, + ) + return + } + + // Logged at ERROR: a genuine DLEQ failure (mint/lib bug), not a mere + // reorder. The proof is still recovered, but the mint's honesty for this + // output is unverified — surface it for investigation. + log.error( + '[CashuUtils.recoverMeltChange] DLEQ unverifiable for change signature; recovering proof without DLEQ', + {sigIndex, amount, keysetId: keyset.id, fallbackBlankIndex: fallback.idx}, + ) + + fallback.used = true + stats.noDleqFallback++ + stats.recovered++ + change.push(fallback.od.toProof({...sig, dleq: undefined}, keyset)) + }) + + return {change, stats} +} + export const CashuUtils = { findEncodedCashuToken, findEncodedCashuPaymentRequest, @@ -553,6 +685,8 @@ export const CashuUtils = { serializeOutputData, deserializeOutputData, serializeMeltPreview, + recoverMeltChange, + meltChangeRecoveryHasError, } diff --git a/src/services/wallet/operations/meltOperations.ts b/src/services/wallet/operations/meltOperations.ts index 063492ce..e409a5e6 100644 --- a/src/services/wallet/operations/meltOperations.ts +++ b/src/services/wallet/operations/meltOperations.ts @@ -238,13 +238,17 @@ const unblindPendingMeltChange = async function (params: { const keyset = cashuWallet.getKeyset(meltPreview.keysetId) const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData) - const change = quoteChange.map((sig, i) => reconstructedOutputData[i].toProof(sig, keyset)) + const {change, stats} = CashuUtils.recoverMeltChange({ + outputData: reconstructedOutputData, + quoteChange, + keyset, + }) - log.trace('[handlePendingMelt] Change unblinded', {transactionId: transaction.id, quoteId, change}) + log.trace('[handlePendingMelt] Change unblinded', {transactionId: transaction.id, quoteId, stats, change}) Database.removeMeltRecovery(transaction.id) - return {change} + return {change, stats} } catch (e: any) { log.error('[handlePendingMelt] change recovery failed, completing without change', { message: e.message, @@ -318,6 +322,19 @@ const handlePendingMeltTask = async (params: { createdAt: new Date(), }) + // Surface change-recovery anomalies onto the (still COMPLETED) tx. Only + // genuine errors (proofs recovered without a verifiable DLEQ, or change + // that couldn't be reconstructed) are recorded — benign reordering + // against pre-0.20.1 mints is not an error. + if (unblinded.stats && CashuUtils.meltChangeRecoveryHasError(unblinded.stats)) { + txData.push({ + status: TransactionStatus.COMPLETED, + error: 'Melt change recovery completed with unverifiable DLEQ', + meltChangeRecovery: unblinded.stats, + createdAt: new Date(), + }) + } + const reservation = proofsStore.reserve(proofsToMeltFrom, { transactionId, mintUrl, diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index 406fd64b..2309d690 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -947,6 +947,19 @@ async function _finalizePaid( createdAt: new Date(), }) + // Surface change-recovery anomalies onto the (still COMPLETED) tx so they are + // visible per-transaction. Only genuine errors (proofs recovered without a + // verifiable DLEQ, or change that couldn't be reconstructed at all) are + // recorded — benign reordering against pre-0.20.1 mints is not an error. + if (unblinded.stats && CashuUtils.meltChangeRecoveryHasError(unblinded.stats)) { + txData.push({ + status: TransactionStatus.COMPLETED, + error: 'Melt change recovery completed with unverifiable DLEQ', + meltChangeRecovery: unblinded.stats, + createdAt: new Date(), + }) + } + const reservation = proofsStore.reserve(pendingProofs, { transactionId, mintUrl, @@ -1012,18 +1025,21 @@ async function _unblindMeltChange(params: { const keyset = cashuWallet.getKeyset(meltPreview.keysetId) const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData) - const change = quoteChange.map((sig, i) => - reconstructedOutputData[i].toProof(sig, keyset), - ) + const {change, stats} = CashuUtils.recoverMeltChange({ + outputData: reconstructedOutputData, + quoteChange, + keyset, + }) log.trace('[TransferOperationApi._unblindMeltChange] Change unblinded', { transactionId: transaction.id, quoteId, + stats, change, }) Database.removeMeltRecovery(transaction.id) - return {change} + return {change, stats} } catch (e: any) { log.error( '[TransferOperationApi._unblindMeltChange] Change recovery failed; completing without change',