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();