mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
875 lines
29 KiB
TypeScript
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,
|
|
}
|
|
|
|
|