- 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.
384 lines
12 KiB
JavaScript
384 lines
12 KiB
JavaScript
const { v4: uuidv4 } = require('uuid');
|
|
const cashuService = require('./cashu');
|
|
const lightningService = require('./lightning');
|
|
|
|
class RedemptionService {
|
|
constructor() {
|
|
// In-memory storage for redemption status
|
|
// In production, use Redis or a proper database
|
|
this.redemptions = new Map();
|
|
this.tokenHashes = new Map(); // Map token hashes to redemption IDs
|
|
}
|
|
|
|
/**
|
|
* Generate a simple hash for a token (for duplicate detection)
|
|
* @param {string} token - The Cashu token
|
|
* @returns {string} Hash of the token
|
|
*/
|
|
generateTokenHash(token) {
|
|
const crypto = require('crypto');
|
|
return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16);
|
|
}
|
|
|
|
/**
|
|
* Store redemption status
|
|
* @param {string} redeemId - The redemption ID
|
|
* @param {Object} status - The redemption status object
|
|
*/
|
|
storeRedemption(redeemId, status) {
|
|
this.redemptions.set(redeemId, {
|
|
...status,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update redemption status
|
|
* @param {string} redeemId - The redemption ID
|
|
* @param {Object} updates - Updates to apply
|
|
*/
|
|
updateRedemption(redeemId, updates) {
|
|
const existing = this.redemptions.get(redeemId);
|
|
if (existing) {
|
|
this.redemptions.set(redeemId, {
|
|
...existing,
|
|
...updates,
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get redemption status by ID
|
|
* @param {string} redeemId - The redemption ID
|
|
* @returns {Object|null} Redemption status or null if not found
|
|
*/
|
|
getRedemption(redeemId) {
|
|
return this.redemptions.get(redeemId) || null;
|
|
}
|
|
|
|
/**
|
|
* Get redemption ID by token hash
|
|
* @param {string} tokenHash - The token hash
|
|
* @returns {string|null} Redemption ID or null if not found
|
|
*/
|
|
getRedemptionByTokenHash(tokenHash) {
|
|
const redeemId = this.tokenHashes.get(tokenHash);
|
|
return redeemId ? this.getRedemption(redeemId) : null;
|
|
}
|
|
|
|
/**
|
|
* Check if a token has already been redeemed
|
|
* @param {string} token - The Cashu token
|
|
* @returns {Object|null} Existing redemption or null
|
|
*/
|
|
checkExistingRedemption(token) {
|
|
const tokenHash = this.generateTokenHash(token);
|
|
return this.getRedemptionByTokenHash(tokenHash);
|
|
}
|
|
|
|
/**
|
|
* Validate redemption request
|
|
* @param {string} token - The Cashu token
|
|
* @param {string} lightningAddress - The Lightning address (optional)
|
|
* @returns {Object} Validation result
|
|
*/
|
|
async validateRedemptionRequest(token, lightningAddress) {
|
|
const errors = [];
|
|
|
|
// Validate token format
|
|
if (!token || typeof token !== 'string') {
|
|
errors.push('Token is required and must be a string');
|
|
}
|
|
|
|
// Lightning address is now optional - we'll use default if not provided
|
|
let addressToUse = null;
|
|
try {
|
|
addressToUse = lightningService.getLightningAddressToUse(lightningAddress);
|
|
|
|
if (!lightningService.validateLightningAddress(addressToUse)) {
|
|
errors.push('Invalid Lightning address format');
|
|
}
|
|
} catch (error) {
|
|
errors.push(error.message);
|
|
}
|
|
|
|
// Check for existing redemption
|
|
if (token) {
|
|
const existing = this.checkExistingRedemption(token);
|
|
if (existing) {
|
|
errors.push('Token has already been redeemed');
|
|
}
|
|
}
|
|
|
|
// Try to parse token
|
|
let tokenData = null;
|
|
if (token && errors.length === 0) {
|
|
try {
|
|
tokenData = await cashuService.parseToken(token);
|
|
if (tokenData.totalAmount <= 0) {
|
|
errors.push('Token has no value');
|
|
}
|
|
} catch (error) {
|
|
errors.push(`Invalid token: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
tokenData,
|
|
lightningAddressToUse: addressToUse
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Perform the complete redemption process
|
|
* @param {string} token - The Cashu token
|
|
* @param {string} lightningAddress - The Lightning address (optional)
|
|
* @returns {Object} Redemption result
|
|
*/
|
|
async performRedemption(token, lightningAddress) {
|
|
const redeemId = uuidv4();
|
|
const tokenHash = this.generateTokenHash(token);
|
|
|
|
try {
|
|
// Determine which Lightning address to use
|
|
const lightningAddressToUse = lightningService.getLightningAddressToUse(lightningAddress);
|
|
const isUsingDefault = !lightningAddress || !lightningAddress.trim();
|
|
|
|
// Store initial status
|
|
this.storeRedemption(redeemId, {
|
|
status: 'processing',
|
|
token: token.substring(0, 50) + '...', // Store partial token for reference
|
|
tokenHash,
|
|
lightningAddress: lightningAddressToUse,
|
|
usingDefaultAddress: isUsingDefault,
|
|
amount: null,
|
|
paid: false,
|
|
error: null
|
|
});
|
|
|
|
// Also map token hash to redemption ID
|
|
this.tokenHashes.set(tokenHash, redeemId);
|
|
|
|
// Step 1: Parse and validate token
|
|
this.updateRedemption(redeemId, { status: 'parsing_token' });
|
|
const tokenData = await cashuService.parseToken(token);
|
|
|
|
this.updateRedemption(redeemId, {
|
|
amount: tokenData.totalAmount,
|
|
mint: tokenData.mint,
|
|
numProofs: tokenData.numProofs,
|
|
format: tokenData.format
|
|
});
|
|
|
|
// Check if token is spendable
|
|
this.updateRedemption(redeemId, { status: 'checking_spendability' });
|
|
try {
|
|
const spendabilityCheck = await cashuService.checkTokenSpendable(token);
|
|
if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) {
|
|
throw new Error('Token proofs are not spendable - they have already been used or are invalid');
|
|
}
|
|
} catch (spendError) {
|
|
// Check if the error indicates tokens are already spent (422 status)
|
|
if (spendError.message.includes('not spendable') ||
|
|
spendError.message.includes('already been used') ||
|
|
spendError.message.includes('invalid proofs') ||
|
|
spendError.message.includes('422')) {
|
|
// 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');
|
|
}
|
|
// 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: 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,
|
|
finalInvoiceAmount, // Use amount minus exact fee
|
|
'Cashu redemption'
|
|
);
|
|
|
|
this.updateRedemption(redeemId, {
|
|
bolt11: invoiceData.bolt11.substring(0, 50) + '...',
|
|
domain: invoiceData.domain,
|
|
invoiceAmount: finalInvoiceAmount,
|
|
exactFee: exactFee
|
|
});
|
|
|
|
// 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);
|
|
|
|
// Log melt result for debugging
|
|
console.log(`Redemption ${redeemId}: Melt result:`, {
|
|
paid: meltResult.paid,
|
|
hasPreimage: !!meltResult.preimage,
|
|
amount: meltResult.amount,
|
|
fee: meltResult.fee
|
|
});
|
|
|
|
// Determine if payment was successful
|
|
// Consider it successful if we have a preimage, even if 'paid' flag is unclear
|
|
const paymentSuccessful = meltResult.paid || !!meltResult.preimage;
|
|
|
|
// Step 4: Update final status
|
|
this.updateRedemption(redeemId, {
|
|
status: paymentSuccessful ? 'paid' : 'failed',
|
|
paid: paymentSuccessful,
|
|
preimage: meltResult.preimage,
|
|
fee: meltResult.fee,
|
|
actualFee: meltResult.actualFee,
|
|
netAmount: meltResult.netAmount,
|
|
change: meltResult.change,
|
|
paidAt: paymentSuccessful ? new Date().toISOString() : null,
|
|
rawMeltResponse: meltResult.rawMeltResponse // Store for debugging
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
redeemId,
|
|
paid: paymentSuccessful,
|
|
amount: tokenData.totalAmount,
|
|
invoiceAmount: finalInvoiceAmount, // Amount actually sent in the invoice
|
|
to: lightningAddressToUse,
|
|
usingDefaultAddress: isUsingDefault,
|
|
fee: exactFee, // Use the exact fee from the melt quote
|
|
actualFee: meltResult.actualFee,
|
|
netAmount: finalInvoiceAmount, // This is the net amount the user receives
|
|
preimage: meltResult.preimage,
|
|
change: meltResult.change,
|
|
mint: tokenData.mint,
|
|
format: tokenData.format
|
|
};
|
|
|
|
} catch (error) {
|
|
// Update redemption with error
|
|
this.updateRedemption(redeemId, {
|
|
status: 'failed',
|
|
paid: false,
|
|
error: error.message
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
redeemId,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get redemption status for API response
|
|
* @param {string} redeemId - The redemption ID
|
|
* @returns {Object|null} Status response or null if not found
|
|
*/
|
|
getRedemptionStatus(redeemId) {
|
|
const redemption = this.getRedemption(redeemId);
|
|
|
|
if (!redemption) {
|
|
return null;
|
|
}
|
|
|
|
const response = {
|
|
success: true,
|
|
status: redemption.status,
|
|
details: {
|
|
amount: redemption.amount,
|
|
to: redemption.lightningAddress,
|
|
paid: redemption.paid,
|
|
createdAt: redemption.createdAt,
|
|
updatedAt: redemption.updatedAt
|
|
}
|
|
};
|
|
|
|
if (redemption.paidAt) {
|
|
response.details.paidAt = redemption.paidAt;
|
|
}
|
|
|
|
if (redemption.fee) {
|
|
response.details.fee = redemption.fee;
|
|
}
|
|
|
|
if (redemption.error) {
|
|
response.details.error = redemption.error;
|
|
}
|
|
|
|
if (redemption.mint) {
|
|
response.details.mint = redemption.mint;
|
|
}
|
|
|
|
if (redemption.domain) {
|
|
response.details.domain = redemption.domain;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Get all redemptions (for admin/debugging)
|
|
* @returns {Array} All redemptions
|
|
*/
|
|
getAllRedemptions() {
|
|
return Array.from(this.redemptions.entries()).map(([id, data]) => ({
|
|
redeemId: id,
|
|
...data
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Clean up old redemptions (should be called periodically)
|
|
* @param {number} maxAgeMs - Maximum age in milliseconds
|
|
*/
|
|
cleanupOldRedemptions(maxAgeMs = 24 * 60 * 60 * 1000) { // 24 hours default
|
|
const cutoff = new Date(Date.now() - maxAgeMs);
|
|
|
|
for (const [redeemId, redemption] of this.redemptions.entries()) {
|
|
const createdAt = new Date(redemption.createdAt);
|
|
if (createdAt < cutoff) {
|
|
this.redemptions.delete(redeemId);
|
|
// Also clean up token hash mapping
|
|
if (redemption.tokenHash) {
|
|
this.tokenHashes.delete(redemption.tokenHash);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = new RedemptionService();
|