Merge branch 'fix/melt-change-dleq-recovery'

fix(melt): recover NUT-08 change when mint reorders signatures

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-06-09 21:58:00 +02:00
4 changed files with 391 additions and 7 deletions

View File

@@ -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-<index>` 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<string, string> = {}
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')
})
})

View File

@@ -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<typeof pointFromHex> | undefined
let C_: ReturnType<typeof pointFromHex> | 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,
}

View File

@@ -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,

View File

@@ -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',