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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user