Files
minibits_wallet/src/services/cashu/cashuUtils.ts
T

875 lines
29 KiB
TypeScript

import {Mint} from '../../models/Mint'
import {
Amount,
OutputData,
hasValidDleq,
pointFromHex,
verifyDLEQProof,
} from '@cashu/cashu-ts'
import type {
Token,
Proof as CashuDecodedProof,
ProofLike as CashuProof,
MintKeys as CashuMintKeys,
PaymentRequest as CashuPaymentRequest,
PaymentRequestPayload,
TokenMetadata,
OutputDataLike,
MeltPreview,
SerializedBlindedSignature,
HasKeysetKeys,
} from '@cashu/cashu-ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import AppError, {Err} from '../../utils/AppError'
import { getTokenMetadata } from '@cashu/cashu-ts'
import {Proof} from '../../models/Proof'
import { log } from '../logService'
import { decodePaymentRequest } from '@cashu/cashu-ts'
import { NostrClient } from '../nostrService'
import { getUnixTime } from 'date-fns/getUnixTime'
import { getSnapshot, isStateTreeNode } from 'mobx-state-tree'
export {CashuProof}
/**
* Type guard to check if a value is an object
*/
const isObj = function(v: unknown): v is object {
return typeof v === 'object'
}
/**
* An amount as a number. cashu-ts (4.x) types proof, token and payment-request
* amounts as `Amount` objects, while our own models hold plain numbers; `Number()`
* on an `Amount` is deprecated and throws in cashu-ts v5.
*/
const toNumberAmount = function(amount: number | Amount | bigint | string): number {
return typeof amount === 'number' ? amount : Amount.from(amount).toNumber()
}
/**
* Sum the amounts of an array of proofs
*/
const sumProofs = function(proofs: CashuProof[]): number {
return proofs.reduce((acc: number, proof: CashuProof) => acc + toNumberAmount(proof.amount as any), 0)
}
const CASHU_URI_PREFIXES = [
'https://wallet.nutstash.app/#',
'https://wallet.cashu.me/?token=',
'web+cashu://',
'cashu://',
'cashu:'
]
const CASHU_TOKEN_PREFIXES = [
'cashuA',
'cashuB'
]
const CASHU_PAYMENT_REQUEST_PREFIXES = [
'creqA',
'creqB'
]
const findEncodedCashuToken = function (content: string) {
const words = content.split(/\s+|\n+/) // Split text into words
const maybeToken = words.find(word => CASHU_TOKEN_PREFIXES.some(pref => word.includes(pref)))
return maybeToken || null
}
const findEncodedCashuPaymentRequest = function (content: string) {
const words = content.split(/\s+|\n+/) // Split text into words
const maybeRequest = words.find(word => CASHU_PAYMENT_REQUEST_PREFIXES.some(pref => word.includes(pref)))
return maybeRequest || null
}
const findEncodedCashuPaymentRequestPayload = function (content: string) {
try {
const decoded = JSON.parse(content)
if(decoded &&
decoded.mint &&
decoded.unit &&
Array.isArray(decoded.proofs) &&
decoded.proofs.length > 0) {
return decoded as PaymentRequestPayload
}
return null
} catch (e: any) {
return null
}
}
const extractEncodedCashuToken = function (maybeToken: string): string {
log.trace('[extractEncodedCashuToken] Extract token from', {maybeToken})
let encodedToken: string | undefined = undefined
let tokenInfo: TokenMetadata | undefined = undefined
if (maybeToken && CASHU_TOKEN_PREFIXES.some(pref => maybeToken.startsWith(pref))) {
tokenInfo = getTokenMetadata(maybeToken) // throws
return maybeToken
}
for (const prefix of CASHU_URI_PREFIXES) {
if (maybeToken && maybeToken.startsWith(prefix)) {
encodedToken = maybeToken.slice(prefix.length)
break // necessary
}
}
log.trace('[extractEncodedCashuToken] Token without prefix', {encodedToken})
// try to decode
if(encodedToken) {
tokenInfo = getTokenMetadata(encodedToken) // throws
return encodedToken
}
throw new AppError(Err.NOTFOUND_ERROR, 'Could not extract ecash token from the provided string', {maybeToken, caller: 'extractEncodedCashuToken'})
}
const extractEncodedCashuPaymentRequest = function (maybeRequest: string): string {
log.trace('[extractEncodedCashuPaymentRequest] Extract payment request from', {maybeRequest})
let encodedRequest: string | undefined = undefined
let decoded: CashuPaymentRequest | undefined = undefined
if (maybeRequest && CASHU_PAYMENT_REQUEST_PREFIXES.some(pref => maybeRequest.startsWith(pref))) {
decoded = decodePaymentRequest(maybeRequest) // throws
return maybeRequest
}
for (const prefix of CASHU_URI_PREFIXES) {
if (maybeRequest && maybeRequest.startsWith(prefix)) {
encodedRequest = maybeRequest.slice(prefix.length)
break // necessary
}
}
log.trace('[extractEncodedCashuToken] Token without prefix', {encodedRequest})
// try to decode
if(encodedRequest) {
decoded = decodePaymentRequest(encodedRequest) // throws
return encodedRequest
}
throw new AppError(Err.NOTFOUND_ERROR, 'Could not extract ecash payment request from the provided string', {maybeRequest, caller: 'extractEncodedCashuPaymentRequest'})
}
const getProofsAmount = function (proofs: Array<Proof | CashuProof>): number {
return sumProofs(proofs as CashuProof[])
}
// legacy method
const getMintsFromToken = function (token: Token): string[] {
return [token.mint]
}
/*
* Largest-first greedy: take every proof that still fits. Exact for Cashu's
* power-of-two amounts — each amount divides every larger one, so if any subset
* sums to the target, this finds one. (It is also the first path the former
* backtracker explored; the rest of that search could never succeed and, on a
* large wallet with no exact match, ran for tens of seconds and exhausted memory.)
* With non-power-of-two amounts it may miss a match and return null, which only
* costs a swap via the findMinExcess fallback.
*/
const findExactMatch = function (requestedAmount: number, proofs: Proof[]): Proof[] | null {
const result: Proof[] = []
let remaining = requestedAmount
for (const proof of [...proofs].sort((a, b) => b.amount - a.amount)) {
if (remaining === 0) break
if (proof.amount > remaining) continue
result.push(proof)
remaining -= proof.amount
}
return remaining === 0 ? result : null
}
const findMinExcess = function (requestedAmount: number, proofs: Proof[], preference: 'SMALL' | 'BIG' = 'SMALL'): Proof[] {
if(preference === 'SMALL') {
proofs.sort((a, b) => a.amount - b.amount);
} else {
proofs.sort((a, b) => b.amount - a.amount);
}
const selectedProofs: Proof[] = [];
let currentAmount = 0;
for (const proof of proofs) {
if (currentAmount >= requestedAmount) {
break;
}
selectedProofs.push(proof);
currentAmount += proof.amount;
}
return selectedProofs;
}
/*
* This function attempts to find exact match combination of proofs for a transaction amount.
* If not found, minimal number of proofs exceeding the amount is selected
* It is intended to minimize number of swaps and possible fees.
*/
const getProofsToSend = function (requestedAmount: number, proofs: Proof[]): Proof[] {
const proofsAmount = getProofsAmount(proofs)
if(requestedAmount > proofsAmount) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{requestedAmount, proofsAmount, caller: 'getProofsToSend'})
}
const exactMatch = findExactMatch(requestedAmount, proofs)
if (exactMatch) {
log.trace('[getProofsToSend] found exact match')
return exactMatch;
}
log.trace('[getProofsToSend] no exact match, fallback to findMinExcess')
return findMinExcess(requestedAmount, proofs)
}
/**
* Select proofs that cover `targetAmount` PLUS the mint's per-proof input fee on
* the selected proofs themselves.
*
* A mint charges an input fee that grows with the NUMBER of proofs spent
* (NUT-02: `fee = ceil(Σ input_fee_ppk / 1000)`). Selecting proofs for a larger,
* fee-inclusive target can pull in additional proofs, which raises the fee,
* which raises the required amount again. A single fee recompute is therefore
* not enough — the second selection's true fee can exceed the budgeted reserve,
* leaving the inputs short. The mint then rejects with "not enough inputs
* provided for melt" (melt) or cashu-ts throws "Not enough funds available for
* swap" (send, called with `includeFees:false`).
*
* This iterates to a fixed point so the returned set always satisfies:
*
* sum(proofsToSend) >= targetAmount + getFeesForProofs(proofsToSend)
*
* @param targetAmount Amount that must remain AFTER the input fee (e.g. send
* amount, or melt `amount + lightning fee_reserve`).
* @param proofs Spendable proofs to select from.
* @param getFeesForProofs Mint fee for a given proof set (wraps
* `cashuWallet.getFeesForProofs`).
* @param options.maxIterations Convergence guard (default 32).
* @param options.priorityProofs Proofs to spend first (e.g. proofs from
* inactive/legacy keysets, to rotate that ecash off per
* NUT-02). MUST be a subset of `proofs`. When they cover
* the fee-inclusive target on their own, selection stays
* entirely within them; otherwise ALL of them are spent
* and the rest of the pool tops up the remainder.
* @throws VALIDATION_ERROR if available proofs cannot cover the converged total.
*/
const selectProofsToSendWithFeeReserve = function (
targetAmount: number,
proofs: Proof[],
getFeesForProofs: (selected: Proof[]) => number,
options?: {maxIterations?: number; caller?: string; priorityProofs?: Proof[]},
): {proofsToSend: Proof[]; feeReserve: number} {
const maxIterations = options?.maxIterations ?? 32
const caller = options?.caller ?? 'selectProofsToSendWithFeeReserve'
const priorityProofs = options?.priorityProofs ?? []
// No priority set → plain lowest-count selection over the whole pool.
if (priorityProofs.length === 0) {
return selectFromPoolWithFeeReserve(targetAmount, proofs, getFeesForProofs, maxIterations, caller)
}
const priorityAmount = getProofsAmount(priorityProofs)
// 1) If the priority proofs can cover the fee-inclusive target on their own,
// spend ONLY from them (a minimal subset) — rotating that ecash off without
// touching the rest of the pool. This is the optimal, fee-lean case.
if (priorityAmount >= targetAmount) {
try {
return selectFromPoolWithFeeReserve(targetAmount, priorityProofs, getFeesForProofs, maxIterations, caller)
} catch {
// Priority alone can't cover target + its own input fee; fall through to
// draining all priority proofs and topping up from the rest.
}
}
// 2) Drain ALL priority proofs, then add the minimal set of remaining proofs
// needed to cover targetAmount + the input fee on the COMBINED set. Iterated
// to a fixed point because each added top-up proof can raise the fee (NUT-02).
const prioritySecrets = new Set(priorityProofs.map(p => p.secret))
const restProofs = proofs.filter(p => !prioritySecrets.has(p.secret))
const totalAvailable = getProofsAmount(proofs)
let proofsToSend = priorityProofs
let feeReserve = getFeesForProofs(proofsToSend)
let guard = 0
while (getProofsAmount(proofsToSend) < targetAmount + feeReserve && guard++ < maxIterations) {
const amountWithFees = targetAmount + feeReserve
if (totalAvailable < amountWithFees) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{totalAvailable, amountWithFees, caller},
)
}
// restTarget > 0 here: the loop condition means sum(proofsToSend) < amountWithFees,
// and sum(proofsToSend) >= priorityAmount, so amountWithFees > priorityAmount.
// The guard above also proves restTarget <= sum(restProofs), so getProofsToSend
// never throws for insufficiency here.
const restTarget = amountWithFees - priorityAmount
const topUp = getProofsToSend(restTarget, restProofs)
proofsToSend = [...priorityProofs, ...topUp]
feeReserve = getFeesForProofs(proofsToSend)
}
if (getProofsAmount(proofsToSend) < targetAmount + feeReserve) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{totalAvailable, amountWithFees: targetAmount + feeReserve, caller},
)
}
return {proofsToSend, feeReserve}
}
/**
* Lowest-count fee-reserve selection over a single pool (the un-prioritized core
* of `selectProofsToSendWithFeeReserve`). See that function's docblock.
*/
const selectFromPoolWithFeeReserve = function (
targetAmount: number,
proofs: Proof[],
getFeesForProofs: (selected: Proof[]) => number,
maxIterations: number,
caller: string,
): {proofsToSend: Proof[]; feeReserve: number} {
const totalAvailable = getProofsAmount(proofs)
let proofsToSend = getProofsToSend(targetAmount, proofs)
let feeReserve = getFeesForProofs(proofsToSend)
let amountWithFees = targetAmount + feeReserve
let guard = 0
while (getProofsAmount(proofsToSend) < amountWithFees && guard++ < maxIterations) {
if (totalAvailable < amountWithFees) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{totalAvailable, amountWithFees, caller},
)
}
proofsToSend = getProofsToSend(amountWithFees, proofs)
feeReserve = getFeesForProofs(proofsToSend)
amountWithFees = targetAmount + feeReserve
}
if (getProofsAmount(proofsToSend) < amountWithFees) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{totalAvailable, amountWithFees, caller},
)
}
return {proofsToSend, feeReserve}
}
/**
* removes a set of tokens from another set of tokens, and returns the remaining.
* @param proofs
* @param proofsToRemove
* @returns
*/
const getProofsSubset = function (
proofs: Array<Proof | CashuProof>,
proofsToRemove: Array<Proof | CashuProof>,
): Array<Proof | CashuProof> {
return proofs.filter(proof => !proofsToRemove.some(p => p.secret === proof.secret))
}
const verifyProofsDleqOrThrow = function (
proofs: CashuDecodedProof[],
mintKeys: CashuMintKeys[],
): void {
if (!proofs || proofs.length === 0) {
throw new AppError(
Err.VALIDATION_ERROR,
'This token does not contain ecash proofs to verify offline.',
{ caller: 'verifyProofsDleqOrThrow' },
)
}
if (!mintKeys || mintKeys.length === 0) {
throw new AppError(
Err.VALIDATION_ERROR,
'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.',
{ caller: 'verifyProofsDleqOrThrow' },
)
}
for (const [proofIndex, proof] of proofs.entries()) {
const amount = proof.amount.toString()
const params = {
caller: 'verifyProofsDleqOrThrow',
proofIndex,
keysetId: proof.id,
amount,
}
const keyset = mintKeys.find(k => k.id === proof.id)
if (!keyset || !keyset.keys || !keyset.keys[amount]) {
throw new AppError(
Err.VALIDATION_ERROR,
'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.',
params,
)
}
if (!proof.dleq) {
throw new AppError(
Err.VALIDATION_ERROR,
'This token does not include offline verification proof. Receive it online instead.',
params,
)
}
if (!proof.dleq.r) {
throw new AppError(
Err.VALIDATION_ERROR,
'This token is missing the DLEQ blinding factor needed for offline verification.',
params,
)
}
let isValid = false
try {
isValid = hasValidDleq(proof, keyset)
} catch {
isValid = false
}
if (!isValid) {
throw new AppError(
Err.VALIDATION_ERROR,
'Offline ecash verification failed. Do not accept this token.',
params,
)
}
}
}
const validateMintKeys = function (keys: object): boolean {
let isValid = true
try {
const allKeys = Object.keys(keys)
if (!allKeys) {
return false
}
if (allKeys.length < 1) {
return false
}
allKeys.forEach(k => {
//try parse int?
if (isNaN(Number(k))) {
isValid = false
}
if (!isPow2(Number(k))) {
isValid = false
}
})
return isValid
} catch (error) {
return false
}
}
function getKeysetIdInt(keysetId: string): bigint {
if (/^[0-9a-fA-F]+$/.test(keysetId)) {
return BigInt(`0x${keysetId}`) % BigInt(2 ** 31 - 1)
} else {
const bin = atob(keysetId)
const hex = bytesToHex(new TextEncoder().encode(bin))
return BigInt(`0x${hex}`) % BigInt(2 ** 31 - 1)
}
}
/**
* Whether a keyset id derives via the deprecated BIP-32 path (NUT-13).
*
* Mirrors cashu-ts `getDerivationKind`: legacy base64 ids and hex ids carrying the
* `00` version byte derive at `m/129372'/0'/{keysetIdInt}'/{counter}'`, where
* `keysetIdInt` is the id reduced mod 2^31-1 to fit a hardened BIP-32 index.
* NUT-02 v2 ids (`01`) instead derive by HMAC-SHA256 over the FULL id, so no
* integer is ever computed from them and that reduction cannot alias.
*/
function usesBip32Derivation(keysetId: string): boolean {
const isHex = /^[0-9a-fA-F]+$/.test(keysetId)
if (!isHex) return true // legacy base64 keyset id
return keysetId.startsWith('00')
}
const exportProofs = (proofs: Proof[]): CashuProof[] => {
const exported: CashuProof[] = proofs.map(proof => {
if (isStateTreeNode(proof)) {
const {mintUrl, unit, tId, ...rest} = getSnapshot(proof)
return rest
} else {
const {mintUrl, unit, tId, ...rest} = proof as Proof
return rest
}
})
return exported
}
/**
* Reject a keyset whose id collides with one the wallet already holds, per NUT-02:
* "Wallet implementations should reject any attempt at importing new keysets which
* IDs collide with any of the previously added keysets."
*
* `storedKeysetIds` is wallet-wide (every keyset of every mint), and upholding this
* across mints is what makes a keyset id a sound primary key for a derivation
* counter — see MINT_COUNTERS_COLUMNS.
*
* Two checks, guarding different things:
*
* - Exact id equality — always applies. One id means one NUT-13 derivation
* sequence, so two mints sharing an id would share (and burn through) one
* counter.
*
* - keysetIdInt equality — ONLY between ids that both derive via the deprecated
* BIP-32 path, where the id is reduced mod 2^31-1 to fit a hardened index and
* two distinct ids that are congruent therefore land on the SAME derivation
* path. NUT-02 v2 (`01`) ids derive by HMAC over the full 32-byte id and never
* compute that integer, so applying the check to them would reject a legitimate
* mint over a number nothing consumes — and would re-impose the ~2^31 birthday
* bound on ids whose whole point is full-width SHA-256 collision resistance.
* Ids of different derivation kinds can never share a path, so they are skipped.
*/
function isCollidingKeysetId(
newKeysetId: string,
storedKeysetIds: string[],
) {
const newUsesBip32 = usesBip32Derivation(newKeysetId)
const newKeysetIdInt = newUsesBip32 ? getKeysetIdInt(newKeysetId) : undefined
return storedKeysetIds.some((storedId) => {
if (storedId === newKeysetId) {
// Colliding keyset ID!
log.error('[isCollidingKeysetId] Colliding keyset ID', {
newKeysetId,
storedId,
})
return true
}
if (!newUsesBip32 || !usesBip32Derivation(storedId)) {
return false
}
const storedKeysetIdInt = getKeysetIdInt(storedId)
if (storedKeysetIdInt === newKeysetIdInt) {
// Colliding keyset ID integer!
log.error('[isCollidingKeysetId] Colliding keyset ID integer', {
newKeysetId,
storedId,
newKeysetIdInt: newKeysetIdInt!.toString(),
storedKeysetIdInt: storedKeysetIdInt.toString(),
})
return true
}
return false
})
}
const isPow2 = function (number: number) {
return Math.log2(number) % 1 === 0
}
const getMintFromProof = function (
proof: Proof,
mints: Array<Mint>,
): Mint | undefined {
let mint: Mint | undefined
mints.forEach(m => {
if (m.keysetIds?.includes(proof.id)) {
mint = m
}
})
return mint
}
const getP2PKPubkeySecret = function (secret: string): string | undefined {
try {
let secretObject = JSON.parse(secret)
if (secretObject[0] == "P2PK" && secretObject[1]["data"] != undefined) {
return secretObject[1]["data"]
}
} catch {}
return undefined
}
const getP2PKLocktime = function (secret: string): number | undefined {
try {
let secretObject = JSON.parse(secret)
if (secretObject[0] == "P2PK" && secretObject[1]["tags"] != undefined) {
return NostrClient.getFirstTagValue(secretObject[1]["tags"], 'locktime') as number
}
} catch {}
return undefined
}
const isTokenP2PKLocked = function (token: Token | TokenMetadata): boolean {
const proofs = 'proofs' in token ? token.proofs : token.incompleteProofs
const secrets = proofs.map((p) => p.secret)
for (const secret of secrets) {
try {
if (getP2PKPubkeySecret(secret)) {
const locktime = CashuUtils.getP2PKLocktime(secret)
const currentTimestamp = getUnixTime(new Date(Date.now()))
if(!locktime) {
return true
} else if (locktime > currentTimestamp) {
return true
}
}
} catch {}
}
return false
}
export interface StoredMeltPreview {
keysetId: string
outputData: SerializedOutputData[]
}
export interface SerializedOutputData {
blindedMessage: { amount: string | number; id: string; B_: string }
blindingFactor: string // hex
secret: string // hex
ephemeralE?: string
}
const serializeOutputData = (outputData: OutputDataLike[]): SerializedOutputData[] =>
outputData.map(od => ({
blindedMessage: {
amount: od.blindedMessage.amount.toString(),
id: od.blindedMessage.id,
B_: od.blindedMessage.B_,
},
blindingFactor: od.blindingFactor.toString(16),
secret: bytesToHex(od.secret),
ephemeralE: od.ephemeralE,
}))
const deserializeOutputData = (serialized: SerializedOutputData[]): OutputData[] =>
serialized.map(od => new OutputData(
{ amount: Amount.from(od.blindedMessage.amount), id: od.blindedMessage.id, B_: od.blindedMessage.B_ },
BigInt('0x' + od.blindingFactor),
hexToBytes(od.secret),
od.ephemeralE,
))
/** Serialize a cashu-ts MeltPreview into the JSON-safe shape stored for recovery. */
const serializeMeltPreview = (meltPreview: MeltPreview): StoredMeltPreview => ({
keysetId: meltPreview.keysetId,
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,
findEncodedCashuPaymentRequestPayload,
extractEncodedCashuToken,
extractEncodedCashuPaymentRequest,
getProofsAmount,
getMintsFromToken,
toNumberAmount,
findExactMatch,
findMinExcess,
getProofsToSend,
selectProofsToSendWithFeeReserve,
exportProofs,
getProofsSubset,
verifyProofsDleqOrThrow,
validateMintKeys,
getMintFromProof,
getP2PKPubkeySecret,
getP2PKLocktime,
isTokenP2PKLocked,
isCollidingKeysetId,
isObj,
sumProofs,
serializeOutputData,
deserializeOutputData,
serializeMeltPreview,
recoverMeltChange,
meltChangeRecoveryHasError,
}