Fix fee calculation and improve token spendability detection

- Fix critical fee calculation bug: Now gets exact melt quote before creating invoice
- Improve spent token detection: Only marks as spent with clear indicators
- Add spent field to decode endpoint response (always boolean)
- Add informative root endpoint with API documentation
- Update documentation examples to use cashuB format
- Install bolt11 library for proper Lightning invoice verification
- Enhanced error handling and logging throughout

This fixes the issue where users lost sats due to fee estimation errors
and ensures accurate token spendability detection.
This commit is contained in:
Michilis
2025-07-15 17:41:57 +00:00
parent 4862196281
commit 961380dd88
8 changed files with 823 additions and 75 deletions

View File

@@ -184,6 +184,36 @@ class CashuService {
return this.wallets.get(mintUrl);
}
/**
* Get melt quote for a Cashu token and Lightning invoice
* @param {string} token - The encoded Cashu token
* @param {string} bolt11 - The Lightning invoice
* @returns {Object} Melt quote
*/
async getMeltQuote(token, bolt11) {
try {
const parsed = await this.parseToken(token);
const wallet = await this.getWallet(parsed.mint);
// Create melt quote to get fee estimate
const meltQuote = await wallet.createMeltQuote(bolt11);
console.log('Melt quote created:', {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
});
return {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
};
} catch (error) {
throw new Error(`Failed to get melt quote: ${error.message}`);
}
}
/**
* Melt a Cashu token to pay a Lightning invoice
* @param {string} token - The encoded Cashu token
@@ -199,20 +229,36 @@ class CashuService {
const decoded = await this.decodeTokenStructure(token);
const proofs = decoded.proofs;
// Create melt quote to get fee estimate
// Step 1: Create melt quote to get fee estimate
const meltQuote = await wallet.createMeltQuote(bolt11);
console.log('Melt quote created:', {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
});
console.log('Paying invoice:', bolt11.substring(0, 50) + '...');
console.log('Full invoice being paid:', bolt11);
// Calculate expected fee
const expectedFee = this.calculateFee(parsed.totalAmount);
// Step 2: Calculate total required (amount + fee_reserve)
const total = meltQuote.amount + meltQuote.fee_reserve;
console.log('Total required:', total, 'sats (amount:', meltQuote.amount, '+ fee:', meltQuote.fee_reserve, ')');
console.log('Available in token:', parsed.totalAmount, 'sats');
// Check if we have sufficient funds including fees
const totalRequired = meltQuote.amount + meltQuote.fee_reserve;
if (totalRequired > parsed.totalAmount) {
throw new Error(`Insufficient funds. Required: ${totalRequired} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`);
// Check if we have sufficient funds
if (total > parsed.totalAmount) {
throw new Error(`Insufficient funds. Required: ${total} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`);
}
// Perform the melt operation using the quote and proofs
const meltResponse = await wallet.meltTokens(meltQuote, proofs);
// Step 3: Send tokens with includeFees: true to get the right proofs
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');
// Step 4: Perform the melt operation using the quote and selected proofs
console.log('Performing melt operation...');
const meltResponse = await wallet.meltTokens(meltQuote, proofsToSend);
// Debug: Log the melt response structure
console.log('Melt response:', JSON.stringify(meltResponse, null, 2));
@@ -245,7 +291,6 @@ class CashuService {
change: meltResponse.change || [],
amount: meltQuote.amount,
fee: actualFeeCharged, // Use actual fee from melt response
actualFee: expectedFee, // Keep the calculated expected fee for comparison
netAmount: actualNetAmount, // Use net amount based on actual fee
quote: meltQuote.quote,
rawMeltResponse: meltResponse // Include raw response for debugging
@@ -330,43 +375,44 @@ class CashuService {
totalAmount: parsed.totalAmount
};
} catch (error) {
// Check if it's a known 422 error (already spent token) - log less verbosely
const isExpected422Error = (error.status === 422 || error.response?.status === 422) &&
error.constructor.name === 'HttpResponseError';
if (isExpected422Error) {
console.log('Token spendability check: 422 status detected - token already spent');
} else {
// Enhanced error logging for unexpected errors
console.error('Spendability check error details:', {
errorType: error.constructor.name,
errorMessage: error.message,
errorCode: error.code,
errorStatus: error.status,
errorResponse: error.response,
errorData: error.data,
errorStack: error.stack,
errorString: String(error)
});
}
// Enhanced error logging for debugging
console.error('Spendability check error details:', {
errorType: error.constructor.name,
errorMessage: error.message,
errorCode: error.code,
errorStatus: error.status,
errorResponse: error.response,
errorData: error.data,
errorStack: error.stack,
errorString: String(error)
});
// Handle different types of errors
let errorMessage = 'Unknown error occurred';
// Handle cashu-ts HttpResponseError specifically
if (error.constructor.name === 'HttpResponseError') {
if (!isExpected422Error) {
console.log('HttpResponseError detected, extracting details...');
}
// Extract status code first
const status = error.status || error.response?.status || error.statusCode;
// For 422 errors, we know it's about already spent tokens
// For 422 errors, we need to be more specific about the reason
if (status === 422) {
errorMessage = 'Token proofs are not spendable - they have already been used or are invalid';
if (!isExpected422Error) {
console.log('Detected 422 status - token already spent');
// Try to get more details about the 422 error
let responseBody = null;
try {
responseBody = error.response?.data || error.data || error.body;
console.log('HTTP 422 response body:', responseBody);
} catch (e) {
console.log('Could not extract response body');
}
// 422 can mean different things, let's be more specific
if (responseBody && typeof responseBody === 'object' && responseBody.detail) {
errorMessage = `Token validation failed: ${responseBody.detail}`;
console.log('422 error with detail:', responseBody.detail);
} else {
errorMessage = 'Token proofs are not spendable - they may have already been used or are invalid';
console.log('Detected 422 status - token validation failed');
}
} else {
// Try to extract useful information from the HTTP response error
@@ -432,9 +478,7 @@ class CashuService {
}
// Log the final extracted error message for debugging
if (!isExpected422Error) {
console.log('Final extracted error message:', errorMessage);
}
console.log('Final extracted error message:', errorMessage);
// Check if it's a known error pattern indicating unsupported operation
if (errorMessage.includes('not supported') ||
@@ -448,6 +492,26 @@ class CashuService {
throw new Error('This mint does not support spendability checking. Token may still be valid.');
}
// Check if the error indicates the token is spent (HTTP 422 or specific messages)
const status = error.status || error.response?.status || error.statusCode;
if (status === 422) {
// For 422 errors, we need to be more careful about determining if it's "spent" vs "invalid"
// Only mark as spent if we have clear indicators
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 {
// For other 422 errors, it might be invalid but not necessarily spent
console.log('HTTP 422 but not clearly indicating spent token - treating as validation error');
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}`);
}
}

View File

@@ -1,4 +1,5 @@
const axios = require('axios');
const bolt11 = require('bolt11');
class LightningService {
constructor() {
@@ -220,11 +221,19 @@ class LightningService {
*/
async resolveInvoice(lightningAddress, amount, comment = 'Cashu token redemption') {
try {
console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`);
// Get LNURLp endpoint
const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress);
console.log(`LNURLp endpoint: ${lnurlpUrl}`);
// Fetch LNURLp response
const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl);
console.log('LNURLp response:', {
callback: lnurlpResponse.callback,
minSendable: lnurlpResponse.minSendable,
maxSendable: lnurlpResponse.maxSendable
});
// Validate amount
if (!this.validateAmount(amount, lnurlpResponse)) {
@@ -235,7 +244,17 @@ class LightningService {
// Get invoice
const amountMsats = this.satsToMillisats(amount);
console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`);
console.log(`Using callback URL: ${lnurlpResponse.callback}`);
const invoiceResponse = await this.getInvoice(lnurlpResponse.callback, amountMsats, comment);
console.log('Invoice created successfully:', {
bolt11: invoiceResponse.bolt11.substring(0, 50) + '...',
lightningAddress,
amount,
amountMsats,
callback: lnurlpResponse.callback
});
return {
bolt11: invoiceResponse.bolt11,
@@ -247,6 +266,7 @@ class LightningService {
lnurlpResponse
};
} catch (error) {
console.error('Lightning address resolution failed:', error.message);
throw new Error(`Lightning address resolution failed: ${error.message}`);
}
}
@@ -271,6 +291,62 @@ class LightningService {
throw new Error(`Invoice parsing failed: ${error.message}`);
}
}
/**
* Verify that a Lightning invoice is valid and for the expected amount
* @param {string} bolt11Invoice - The Lightning invoice to verify
* @param {string} expectedLightningAddress - The expected Lightning address (for logging)
* @param {number} expectedAmount - Expected amount in satoshis (optional)
* @returns {boolean} Whether the invoice is valid
*/
verifyInvoiceDestination(bolt11Invoice, expectedLightningAddress, expectedAmount = null) {
try {
console.log(`Verifying invoice destination for: ${expectedLightningAddress}`);
console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`);
// Decode the invoice using the bolt11 library
const decoded = bolt11.decode(bolt11Invoice);
// Basic validation checks
if (!decoded.complete) {
console.error('Invoice verification failed: Invoice is incomplete');
return false;
}
if (!decoded.paymentRequest) {
console.error('Invoice verification failed: No payment request found');
return false;
}
// Check if the invoice has expired
if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) {
console.error('Invoice verification failed: Invoice has expired');
return false;
}
// Verify amount if provided
if (expectedAmount !== null) {
const invoiceAmount = decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0);
if (invoiceAmount !== expectedAmount) {
console.error(`Invoice verification failed: Amount mismatch. Expected: ${expectedAmount} sats, Got: ${invoiceAmount} sats`);
return false;
}
}
console.log('Invoice verification: All checks passed');
console.log('Invoice details:', {
amount: decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0),
timestamp: decoded.timestamp,
expiry: decoded.expiry,
description: decoded.tags?.find(tag => tag.tagName === 'description')?.data || 'No description'
});
return true;
} catch (error) {
console.error('Invoice verification failed:', error.message);
return false;
}
}
}
module.exports = new LightningService();

View File

@@ -167,23 +167,10 @@ class RedemptionService {
this.updateRedemption(redeemId, { status: 'parsing_token' });
const tokenData = await cashuService.parseToken(token);
// Calculate expected fee according to NUT-05
const expectedFee = cashuService.calculateFee(tokenData.totalAmount);
// Calculate net amount after subtracting fees
const netAmountAfterFee = tokenData.totalAmount - expectedFee;
// Ensure we have enough for the minimum payment after fees
if (netAmountAfterFee <= 0) {
throw new Error(`Token amount (${tokenData.totalAmount} sats) is insufficient to cover the minimum fee (${expectedFee} sats)`);
}
this.updateRedemption(redeemId, {
amount: tokenData.totalAmount,
mint: tokenData.mint,
numProofs: tokenData.numProofs,
expectedFee: expectedFee,
netAmountAfterFee: netAmountAfterFee,
format: tokenData.format
});
@@ -203,26 +190,58 @@ class RedemptionService {
// This is likely an already-spent token - fail the redemption with clear message
throw new Error('This token has already been spent and cannot be redeemed again');
}
// Log but don't fail for other errors - some mints might not support this check
// For other errors, log but continue - some mints might not support this check
console.warn('Spendability check failed:', spendError.message);
console.log('Continuing with redemption despite spendability check failure...');
}
// Step 2: Resolve Lightning address to invoice
// IMPORTANT: Create invoice for net amount (after subtracting expected fees)
// Step 2: Get melt quote first to determine exact fees
this.updateRedemption(redeemId, { status: 'getting_melt_quote' });
// Create a temporary invoice to get the melt quote (we'll create the real one after)
console.log(`Getting melt quote for ${tokenData.totalAmount} sats to determine exact fees`);
const tempInvoiceData = await lightningService.resolveInvoice(
lightningAddressToUse,
tokenData.totalAmount, // Use full amount initially
'Cashu redemption'
);
// Get melt quote to determine exact fee
const meltQuote = await cashuService.getMeltQuote(token, tempInvoiceData.bolt11);
const exactFee = meltQuote.fee_reserve;
const finalInvoiceAmount = tokenData.totalAmount - exactFee;
console.log(`Melt quote: amount=${meltQuote.amount}, fee=${exactFee}, net to user=${finalInvoiceAmount}`);
// Step 3: Create final invoice for the correct amount (total - exact fee)
this.updateRedemption(redeemId, { status: 'resolving_invoice' });
if (finalInvoiceAmount <= 0) {
throw new Error(`Token amount (${tokenData.totalAmount} sats) is insufficient to cover the fee (${exactFee} sats)`);
}
console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`);
const invoiceData = await lightningService.resolveInvoice(
lightningAddressToUse,
netAmountAfterFee, // Use net amount instead of full token amount
finalInvoiceAmount, // Use amount minus exact fee
'Cashu redemption'
);
this.updateRedemption(redeemId, {
bolt11: invoiceData.bolt11.substring(0, 50) + '...',
domain: invoiceData.domain,
invoiceAmount: netAmountAfterFee
invoiceAmount: finalInvoiceAmount,
exactFee: exactFee
});
// Step 3: Melt the token to pay the invoice
// Verify the invoice is valid and for the correct amount
const invoiceVerified = lightningService.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
if (!invoiceVerified) {
throw new Error('Invoice verification failed - invalid invoice or amount mismatch');
}
// Step 4: Melt the token to pay the invoice
this.updateRedemption(redeemId, { status: 'melting_token' });
const meltResult = await cashuService.meltToken(token, invoiceData.bolt11);
@@ -256,12 +275,12 @@ class RedemptionService {
redeemId,
paid: paymentSuccessful,
amount: tokenData.totalAmount,
invoiceAmount: netAmountAfterFee, // Amount actually sent in the invoice
invoiceAmount: finalInvoiceAmount, // Amount actually sent in the invoice
to: lightningAddressToUse,
usingDefaultAddress: isUsingDefault,
fee: meltResult.fee,
fee: exactFee, // Use the exact fee from the melt quote
actualFee: meltResult.actualFee,
netAmount: meltResult.netAmount,
netAmount: finalInvoiceAmount, // This is the net amount the user receives
preimage: meltResult.preimage,
change: meltResult.change,
mint: tokenData.mint,