Files
Cashu-redeem-api/components/cashu.js
T
MichilisandCursor 449a8dda0d Upgrade to cashu-ts v4 and drop unused deps.
Adapt amount handling for v4 Amount objects, replace axios/uuid with fetch and crypto.randomUUID, and remove dead helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 03:46:59 +00:00

324 lines
11 KiB
JavaScript

const { Wallet, getDecodedToken, CheckStateEnum } = require('@cashu/cashu-ts');
/**
* Normalize a cashu-ts amount into a plain JavaScript number.
*
* As of cashu-ts v4, amounts returned by the library (proof amounts, melt quote
* `amount`/`fee_reserve`, etc.) are `Amount` class instances rather than plain
* numbers. `Amount` has no `valueOf()`, so arithmetic/comparison against numbers
* silently breaks (string concatenation or NaN). Always funnel library amounts
* through this before doing math with them.
*/
function toNumber(value) {
if (value == null) return 0;
if (typeof value === 'number') return value;
if (typeof value === 'bigint') return Number(value);
if (typeof value.toNumber === 'function') return value.toNumber();
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
class CashuComponent {
constructor() {
this.wallets = new Map();
}
/**
* Validate token format (supports both cashuA and cashuB formats)
*/
isValidTokenFormat(token) {
return /^cashu[abAB][a-zA-Z0-9-_]+$/.test(token);
}
/**
* Decode token structure (v3/v4 format always has { mint, proofs } at top level)
*/
async decodeTokenStructure(token) {
try {
// cashu-ts v4 requires a `keysetIds` argument used only to resolve short
// (NUT-16 v2) keyset IDs. Tokens with full v0 keyset IDs — the common case —
// need no mapping, so we pass an empty list. Passing nothing throws.
const decoded = getDecodedToken(token, []);
if (!decoded) throw new Error('Failed to decode token');
if (!decoded.proofs || !Array.isArray(decoded.proofs)) {
throw new Error('Invalid token structure - no proofs found');
}
return {
proofs: decoded.proofs,
mint: decoded.mint,
unit: decoded.unit || 'sat'
};
} catch (error) {
throw new Error(`Token decoding failed: ${error.message}`);
}
}
/**
* Parse and validate a Cashu token
*/
async parseToken(token) {
try {
if (!token || typeof token !== 'string') {
throw new Error('Invalid token format');
}
token = token.trim();
if (!this.isValidTokenFormat(token)) {
throw new Error('Invalid token format. Must be a valid Cashu token');
}
const decoded = await this.decodeTokenStructure(token);
if (!decoded.proofs || !Array.isArray(decoded.proofs) || decoded.proofs.length === 0) {
throw new Error('Invalid token structure - no proofs found');
}
const totalAmount = decoded.proofs.reduce((sum, proof) => sum + toNumber(proof.amount), 0);
if (totalAmount <= 0) {
throw new Error('Token has no value');
}
const denominations = decoded.proofs.map(proof => toNumber(proof.amount));
return {
mint: decoded.mint,
totalAmount,
numProofs: decoded.proofs.length,
denominations,
proofs: decoded.proofs,
unit: decoded.unit || 'sat',
format: token.startsWith('cashuA') ? 'cashuA' : 'cashuB'
};
} catch (error) {
throw new Error(`Token parsing failed: ${error.message}`);
}
}
/**
* Get or create a Wallet instance for a specific mint
*/
async getWallet(mintUrl) {
if (!this.wallets.has(mintUrl)) {
try {
const wallet = new Wallet(mintUrl);
await wallet.loadMint();
this.wallets.set(mintUrl, wallet);
} catch (error) {
throw new Error(`Failed to create wallet for mint ${mintUrl}: ${error.message}`);
}
}
return this.wallets.get(mintUrl);
}
/**
* Get melt quote for a Cashu token and Lightning invoice
*/
async getMeltQuote(token, bolt11) {
try {
const parsed = await this.parseToken(token);
const wallet = await this.getWallet(parsed.mint);
const meltQuote = await wallet.createMeltQuoteBolt11(bolt11);
const amount = toNumber(meltQuote.amount);
const feeReserve = toNumber(meltQuote.fee_reserve);
console.log('Melt quote created:', {
amount,
fee_reserve: feeReserve,
quote: meltQuote.quote
});
return {
amount,
fee_reserve: feeReserve,
quote: meltQuote.quote
};
} catch (error) {
throw new Error(`Failed to get melt quote: ${error.message}`);
}
}
/**
* Melt a Cashu token to pay a Lightning invoice
*/
async meltToken(token, bolt11) {
try {
const parsed = await this.parseToken(token);
const wallet = await this.getWallet(parsed.mint);
const decoded = await this.decodeTokenStructure(token);
const proofs = decoded.proofs;
const meltQuote = await wallet.createMeltQuoteBolt11(bolt11);
const quoteAmount = toNumber(meltQuote.amount);
const quoteFeeReserve = toNumber(meltQuote.fee_reserve);
console.log('Melt quote created:', {
amount: quoteAmount,
fee_reserve: quoteFeeReserve,
quote: meltQuote.quote
});
console.log('Paying invoice:', bolt11.substring(0, 50) + '...');
const total = quoteAmount + quoteFeeReserve;
console.log('Total required:', total, 'sats (amount:', quoteAmount, '+ fee:', quoteFeeReserve, ')');
console.log('Available in token:', parsed.totalAmount, 'sats');
if (total > parsed.totalAmount) {
throw new Error(`Insufficient funds. Required: ${total} sats (including ${quoteFeeReserve} sats fee), Available: ${parsed.totalAmount} sats`);
}
console.log('Selecting proofs with includeFees: true for', total, 'sats');
const { send: proofsToSend } = await wallet.send(total, proofs, {
includeFees: true,
});
console.log('Selected', proofsToSend.length, 'proofs for melting');
console.log('Performing melt operation...');
const meltResponse = await wallet.meltProofsBolt11(meltQuote, proofsToSend);
const quote = meltResponse.quote || {};
const paymentSuccessful = quote.state === 'PAID' ||
!!quote.payment_preimage ||
meltResponse.paid === true;
if (!paymentSuccessful) {
console.warn('Payment verification - state:', quote.state);
}
const preimage = quote.payment_preimage || meltResponse.preimage;
const actualFeeCharged = quote.fee_reserve != null
? toNumber(quote.fee_reserve)
: quoteFeeReserve;
const actualNetAmount = parsed.totalAmount - actualFeeCharged;
return {
success: true,
paid: paymentSuccessful,
preimage,
change: meltResponse.change || [],
amount: quoteAmount,
fee: actualFeeCharged,
netAmount: actualNetAmount,
quote: meltQuote.quote,
rawMeltResponse: meltResponse
};
} catch (error) {
if (error.message.includes('Insufficient funds') ||
error.message.includes('Payment failed') ||
error.message.includes('Quote not found')) {
throw error;
}
if (error.status === 422 ||
error.message.includes('already spent') ||
error.message.includes('not spendable') ||
error.message.includes('invalid proofs')) {
throw new Error('This token has already been spent and cannot be redeemed again');
}
throw new Error(`Melt operation failed: ${error.message}`);
}
}
/**
* Check if proofs are spendable at the mint using NUT-07 state check
*/
async checkTokenSpendable(token) {
try {
const parsed = await this.parseToken(token);
const wallet = await this.getWallet(parsed.mint);
console.log(`Checking spendability for ${parsed.proofs.length} proofs at mint: ${parsed.mint}`);
const proofStates = await wallet.checkProofsStates(parsed.proofs);
console.log('Proof states:', proofStates);
const spendable = [];
const pending = [];
const spent = [];
for (let i = 0; i < proofStates.length; i++) {
const state = proofStates[i];
if (state.state === CheckStateEnum.UNSPENT) {
spendable.push(parsed.proofs[i]);
} else if (state.state === CheckStateEnum.PENDING) {
pending.push(parsed.proofs[i]);
} else if (state.state === CheckStateEnum.SPENT) {
spent.push(parsed.proofs[i]);
}
}
return {
spendable,
pending,
spent,
mintUrl: parsed.mint,
totalAmount: parsed.totalAmount
};
} catch (error) {
console.error('Spendability check error details:', {
errorType: error.constructor.name,
errorMessage: error.message,
errorStatus: error.status
});
let errorMessage = 'Unknown error occurred';
if (error.constructor.name === 'HttpResponseError' || error.constructor.name === 'MintOperationError') {
const status = error.status || error.response?.status;
if (status === 422) {
const detail = error.response?.data?.detail || error.detail;
if (detail) {
errorMessage = `Token validation failed: ${detail}`;
} else {
errorMessage = 'Token proofs are not spendable - they may have already been used or are invalid';
}
} else if (status === 404 || status === 405 || status === 501) {
errorMessage = 'This mint does not support spendability checking';
} else {
errorMessage = error.message && error.message !== '[object Object]'
? error.message
: `Mint returned HTTP ${status}`;
}
} else if (error.message && error.message !== '[object Object]') {
errorMessage = error.message;
}
console.log('Final extracted error message:', errorMessage);
if (errorMessage.includes('not supported') ||
errorMessage.includes('404') ||
errorMessage.includes('405') ||
errorMessage.includes('501') ||
errorMessage.includes('endpoint not found') ||
errorMessage.includes('not implemented')) {
throw new Error('This mint does not support spendability checking. Token may still be valid.');
}
const status = error.status || error.response?.status;
if (status === 422) {
if (errorMessage.includes('already been used') ||
errorMessage.includes('already spent') ||
errorMessage.includes('not spendable')) {
throw new Error('TOKEN_SPENT: Token proofs are not spendable - they have already been used');
} else {
throw new Error(`Token validation failed at mint: ${errorMessage}`);
}
} else if (errorMessage.includes('Token proofs are not spendable') ||
errorMessage.includes('already been used') ||
errorMessage.includes('invalid proofs')) {
throw new Error('TOKEN_SPENT: Token proofs are not spendable - they have already been used');
}
throw new Error(`Failed to check token spendability: ${errorMessage}`);
}
}
}
module.exports = new CashuComponent();