mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-07-22 07:48:28 +00:00
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:
217
__tests__/recoverMeltChange.test.ts
Normal file
217
__tests__/recoverMeltChange.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user