const { v4: uuidv4 } = require('uuid'); const crypto = require('crypto'); const cashu = require('./cashu'); const lightning = require('./lightning'); class RedemptionComponent { constructor() { this.redemptions = new Map(); this.tokenHashes = new Map(); } /** * Generate a simple hash for a token (for duplicate detection) */ generateTokenHash(token) { return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16); } /** * Store redemption status */ storeRedemption(redeemId, status) { this.redemptions.set(redeemId, { ...status, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); } /** * Update redemption status */ 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 */ getRedemption(redeemId) { return this.redemptions.get(redeemId) || null; } /** * Get redemption ID by token hash */ getRedemptionByTokenHash(tokenHash) { const redeemId = this.tokenHashes.get(tokenHash); return redeemId ? this.getRedemption(redeemId) : null; } /** * Check if a token has already been redeemed */ checkExistingRedemption(token) { const tokenHash = this.generateTokenHash(token); return this.getRedemptionByTokenHash(tokenHash); } /** * Validate redemption request */ async validateRedemptionRequest(token, lightningAddress) { const errors = []; if (!token || typeof token !== 'string') { errors.push('Token is required and must be a string'); } let addressToUse = null; try { addressToUse = lightning.getLightningAddressToUse(lightningAddress); if (!lightning.validateLightningAddress(addressToUse)) { errors.push('Invalid Lightning address format'); } } catch (error) { errors.push(error.message); } if (token) { const existing = this.checkExistingRedemption(token); if (existing && existing.status === 'paid') { errors.push('Token has already been redeemed'); } } let tokenData = null; if (token && errors.length === 0) { try { tokenData = await cashu.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 */ async performRedemption(token, lightningAddress) { const redeemId = uuidv4(); const tokenHash = this.generateTokenHash(token); try { const lightningAddressToUse = lightning.getLightningAddressToUse(lightningAddress); const isUsingDefault = !lightningAddress || !lightningAddress.trim(); this.storeRedemption(redeemId, { status: 'processing', token: token.substring(0, 50) + '...', tokenHash, lightningAddress: lightningAddressToUse, usingDefaultAddress: isUsingDefault, amount: null, paid: false, error: null }); this.tokenHashes.set(tokenHash, redeemId); // Step 1: Parse and validate token this.updateRedemption(redeemId, { status: 'parsing_token' }); const tokenData = await cashu.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 cashu.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) { if (spendError.message.includes('not spendable') || spendError.message.includes('already been used') || spendError.message.includes('invalid proofs') || spendError.message.includes('422')) { throw new Error('This token has already been spent and cannot be redeemed again'); } 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' }); console.log(`Getting melt quote for ${tokenData.totalAmount} sats to determine exact fees`); const tempInvoiceData = await lightning.resolveInvoice( lightningAddressToUse, tokenData.totalAmount, 'Cashu redemption' ); const meltQuote = await cashu.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 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 lightning.resolveInvoice( lightningAddressToUse, finalInvoiceAmount, 'Cashu redemption' ); this.updateRedemption(redeemId, { bolt11: invoiceData.bolt11.substring(0, 50) + '...', domain: invoiceData.domain, invoiceAmount: finalInvoiceAmount, exactFee }); const invoiceVerified = lightning.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 cashu.meltToken(token, invoiceData.bolt11); console.log(`Redemption ${redeemId}: Melt result:`, { paid: meltResult.paid, hasPreimage: !!meltResult.preimage, amount: meltResult.amount, fee: meltResult.fee }); const paymentSuccessful = meltResult.paid || !!meltResult.preimage; 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 }); return { success: true, redeemId, paid: paymentSuccessful, amount: tokenData.totalAmount, invoiceAmount: finalInvoiceAmount, to: lightningAddressToUse, usingDefaultAddress: isUsingDefault, fee: exactFee, actualFee: meltResult.actualFee, netAmount: finalInvoiceAmount, preimage: meltResult.preimage, change: meltResult.change, mint: tokenData.mint, format: tokenData.format }; } catch (error) { this.updateRedemption(redeemId, { status: 'failed', paid: false, error: error.message }); // Remove token hash so the token can be retried after a failed redemption this.tokenHashes.delete(tokenHash); return { success: false, redeemId, error: error.message }; } } /** * Get redemption status for API response */ 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) */ getAllRedemptions() { return Array.from(this.redemptions.entries()).map(([id, data]) => ({ redeemId: id, ...data })); } /** * Clean up old redemptions (should be called periodically) */ cleanupOldRedemptions(maxAgeMs = 24 * 60 * 60 * 1000) { 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); if (redemption.tokenHash) { this.tokenHashes.delete(redemption.tokenHash); } } } } } module.exports = new RedemptionComponent();