mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
fix(melt): recover NUT-08 change when mint reorders signatures
Some mints (nutshell < 0.20.1) return paid-melt-quote change[]
signatures in an order that does not match the blank outputs the
wallet sent. cashu-ts OutputData.toProof assumes positional pairing
and throws on its DLEQ check, so mapping over the array aborted on the
first mismatch and the catch discarded ALL change — recording it as
fee. A user lost ~100k sats of change on a single transfer this way.
Add CashuUtils.recoverMeltChange, a resilient helper that:
- matches each signature to the blank whose blinded message makes the
mint's DLEQ proof verify (verifyDLEQProof as alignment oracle),
correctly re-pairing reordered change while keeping DLEQ as a hard
guarantee;
- on a genuine DLEQ failure (no blank verifies) falls back to
unblinding the positional blank WITHOUT DLEQ so funds are recovered
rather than dropped, logged at error level;
- returns stats so callers can surface anomalies.
Both melt finalize paths (transferOperationApi._finalizePaid and
meltOperations.handlePendingMeltTask) use the helper and append a
COMPLETED-status tx.data entry with the recovery stats when a genuine
error occurred (no-DLEQ fallback / unmatched) — benign reordering is
not flagged.
Covered by __tests__/recoverMeltChange.test.ts (in-order, shuffled,
fewer-sigs-than-blanks, duplicate denominations, no-DLEQ fallback,
unmatched).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bbe8688249
commit
3caf54fa35
@@ -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,
|
Amount,
|
||||||
OutputData,
|
OutputData,
|
||||||
hasValidDleq,
|
hasValidDleq,
|
||||||
|
pointFromHex,
|
||||||
|
verifyDLEQProof,
|
||||||
} from '@cashu/cashu-ts'
|
} from '@cashu/cashu-ts'
|
||||||
import type {
|
import type {
|
||||||
Token,
|
Token,
|
||||||
@@ -14,6 +16,8 @@ import type {
|
|||||||
TokenMetadata,
|
TokenMetadata,
|
||||||
OutputDataLike,
|
OutputDataLike,
|
||||||
MeltPreview,
|
MeltPreview,
|
||||||
|
SerializedBlindedSignature,
|
||||||
|
HasKeysetKeys,
|
||||||
} from '@cashu/cashu-ts'
|
} from '@cashu/cashu-ts'
|
||||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||||
import AppError, {Err} from '../../utils/AppError'
|
import AppError, {Err} from '../../utils/AppError'
|
||||||
@@ -528,6 +532,134 @@ const serializeMeltPreview = (meltPreview: MeltPreview): StoredMeltPreview => ({
|
|||||||
outputData: serializeOutputData(meltPreview.outputData),
|
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 = {
|
export const CashuUtils = {
|
||||||
findEncodedCashuToken,
|
findEncodedCashuToken,
|
||||||
findEncodedCashuPaymentRequest,
|
findEncodedCashuPaymentRequest,
|
||||||
@@ -553,6 +685,8 @@ export const CashuUtils = {
|
|||||||
serializeOutputData,
|
serializeOutputData,
|
||||||
deserializeOutputData,
|
deserializeOutputData,
|
||||||
serializeMeltPreview,
|
serializeMeltPreview,
|
||||||
|
recoverMeltChange,
|
||||||
|
meltChangeRecoveryHasError,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -238,13 +238,17 @@ const unblindPendingMeltChange = async function (params: {
|
|||||||
const keyset = cashuWallet.getKeyset(meltPreview.keysetId)
|
const keyset = cashuWallet.getKeyset(meltPreview.keysetId)
|
||||||
|
|
||||||
const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData)
|
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)
|
Database.removeMeltRecovery(transaction.id)
|
||||||
|
|
||||||
return {change}
|
return {change, stats}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
log.error('[handlePendingMelt] change recovery failed, completing without change', {
|
log.error('[handlePendingMelt] change recovery failed, completing without change', {
|
||||||
message: e.message,
|
message: e.message,
|
||||||
@@ -318,6 +322,19 @@ const handlePendingMeltTask = async (params: {
|
|||||||
createdAt: new Date(),
|
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, {
|
const reservation = proofsStore.reserve(proofsToMeltFrom, {
|
||||||
transactionId,
|
transactionId,
|
||||||
mintUrl,
|
mintUrl,
|
||||||
|
|||||||
@@ -947,6 +947,19 @@ async function _finalizePaid(
|
|||||||
createdAt: new Date(),
|
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, {
|
const reservation = proofsStore.reserve(pendingProofs, {
|
||||||
transactionId,
|
transactionId,
|
||||||
mintUrl,
|
mintUrl,
|
||||||
@@ -1012,18 +1025,21 @@ async function _unblindMeltChange(params: {
|
|||||||
const keyset = cashuWallet.getKeyset(meltPreview.keysetId)
|
const keyset = cashuWallet.getKeyset(meltPreview.keysetId)
|
||||||
|
|
||||||
const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData)
|
const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData)
|
||||||
const change = quoteChange.map((sig, i) =>
|
const {change, stats} = CashuUtils.recoverMeltChange({
|
||||||
reconstructedOutputData[i].toProof(sig, keyset),
|
outputData: reconstructedOutputData,
|
||||||
)
|
quoteChange,
|
||||||
|
keyset,
|
||||||
|
})
|
||||||
|
|
||||||
log.trace('[TransferOperationApi._unblindMeltChange] Change unblinded', {
|
log.trace('[TransferOperationApi._unblindMeltChange] Change unblinded', {
|
||||||
transactionId: transaction.id,
|
transactionId: transaction.id,
|
||||||
quoteId,
|
quoteId,
|
||||||
|
stats,
|
||||||
change,
|
change,
|
||||||
})
|
})
|
||||||
|
|
||||||
Database.removeMeltRecovery(transaction.id)
|
Database.removeMeltRecovery(transaction.id)
|
||||||
return {change}
|
return {change, stats}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
log.error(
|
log.error(
|
||||||
'[TransferOperationApi._unblindMeltChange] Change recovery failed; completing without change',
|
'[TransferOperationApi._unblindMeltChange] Change recovery failed; completing without change',
|
||||||
|
|||||||
Reference in New Issue
Block a user