diff --git a/components/cashu.js b/components/cashu.js new file mode 100644 index 0000000..d108a53 --- /dev/null +++ b/components/cashu.js @@ -0,0 +1,369 @@ +const { Wallet, Mint, getDecodedToken, CheckStateEnum } = require('@cashu/cashu-ts'); + +class CashuComponent { + constructor() { + this.mints = new Map(); + this.wallets = new Map(); + } + + /** + * Validate token format (supports both cashuA and cashuB formats) + */ + isValidTokenFormat(token) { + return /^cashu[abAB][a-zA-Z0-9-_]+$/.test(token); + } + + /** + * Get token mint URL from decoded token + */ + async getTokenMintUrl(token) { + try { + const decoded = getDecodedToken(token); + if (!decoded) return null; + return decoded.mint || null; + } catch (error) { + console.error('Error getting token mint URL:', error); + return null; + } + } + + /** + * Decode token structure (v3/v4 format always has { mint, proofs } at top level) + */ + async decodeTokenStructure(token) { + try { + const decoded = getDecodedToken(token); + if (!decoded) throw new Error('Failed to decode token'); + if (!decoded.proofs || !Array.isArray(decoded.proofs)) { + throw new Error('Invalid token structure - no proofs found'); + } + return { + proofs: decoded.proofs, + mint: decoded.mint, + unit: decoded.unit || 'sat' + }; + } catch (error) { + throw new Error(`Token decoding failed: ${error.message}`); + } + } + + /** + * Calculate fee according to NUT-05 specification + */ + calculateFee(amount) { + const fee = Math.ceil(amount * 0.02); + return Math.max(1, fee); + } + + /** + * Parse and validate a Cashu token + */ + async parseToken(token) { + try { + if (!token || typeof token !== 'string') { + throw new Error('Invalid token format'); + } + + token = token.trim(); + + if (!this.isValidTokenFormat(token)) { + throw new Error('Invalid token format. Must be a valid Cashu token'); + } + + const decoded = await this.decodeTokenStructure(token); + + if (!decoded.proofs || !Array.isArray(decoded.proofs) || decoded.proofs.length === 0) { + throw new Error('Invalid token structure - no proofs found'); + } + + const totalAmount = decoded.proofs.reduce((sum, proof) => sum + (proof.amount || 0), 0); + + if (totalAmount <= 0) { + throw new Error('Token has no value'); + } + + const denominations = decoded.proofs.map(proof => proof.amount); + + return { + mint: decoded.mint, + totalAmount, + numProofs: decoded.proofs.length, + denominations, + proofs: decoded.proofs, + unit: decoded.unit || 'sat', + format: token.startsWith('cashuA') ? 'cashuA' : 'cashuB' + }; + } catch (error) { + throw new Error(`Token parsing failed: ${error.message}`); + } + } + + /** + * Get total amount from a token + */ + async getTotalAmount(token) { + const parsed = await this.parseToken(token); + return parsed.totalAmount; + } + + /** + * Get or create a Mint instance + */ + async getMint(mintUrl) { + if (!this.mints.has(mintUrl)) { + try { + const mint = new Mint(mintUrl); + await mint.getInfo(); + this.mints.set(mintUrl, mint); + } catch (error) { + throw new Error(`Failed to connect to mint ${mintUrl}: ${error.message}`); + } + } + return this.mints.get(mintUrl); + } + + /** + * Get or create a Wallet instance for a specific mint + */ + async getWallet(mintUrl) { + if (!this.wallets.has(mintUrl)) { + try { + const wallet = new Wallet(mintUrl); + await wallet.loadMint(); + this.wallets.set(mintUrl, wallet); + } catch (error) { + throw new Error(`Failed to create wallet for mint ${mintUrl}: ${error.message}`); + } + } + return this.wallets.get(mintUrl); + } + + /** + * Get melt quote for a Cashu token and Lightning invoice + */ + async getMeltQuote(token, bolt11) { + try { + const parsed = await this.parseToken(token); + const wallet = await this.getWallet(parsed.mint); + + const meltQuote = await wallet.createMeltQuoteBolt11(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 + */ + async meltToken(token, bolt11) { + try { + const parsed = await this.parseToken(token); + const wallet = await this.getWallet(parsed.mint); + + const decoded = await this.decodeTokenStructure(token); + const proofs = decoded.proofs; + + const meltQuote = await wallet.createMeltQuoteBolt11(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) + '...'); + + 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'); + + if (total > parsed.totalAmount) { + throw new Error(`Insufficient funds. Required: ${total} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`); + } + + 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'); + + console.log('Performing melt operation...'); + const meltResponse = await wallet.meltProofs(meltQuote, proofsToSend); + + console.log('Melt response:', JSON.stringify(meltResponse, null, 2)); + + const quote = meltResponse.quote || {}; + const paymentSuccessful = quote.state === 'PAID' || + quote.payment_preimage || + meltResponse.paid === true; + + if (!paymentSuccessful) { + console.warn('Payment verification - state:', quote.state); + } + + const preimage = quote.payment_preimage || meltResponse.preimage; + const actualFeeCharged = quote.fee_reserve || meltQuote.fee_reserve; + const actualNetAmount = parsed.totalAmount - actualFeeCharged; + + return { + success: true, + paid: paymentSuccessful, + preimage, + change: meltResponse.change || [], + amount: meltQuote.amount, + fee: actualFeeCharged, + netAmount: actualNetAmount, + quote: meltQuote.quote, + rawMeltResponse: meltResponse + }; + } catch (error) { + if (error.message.includes('Insufficient funds') || + error.message.includes('Payment failed') || + error.message.includes('Quote not found')) { + throw error; + } + + if (error.status === 422 || + error.message.includes('already spent') || + error.message.includes('not spendable') || + error.message.includes('invalid proofs')) { + throw new Error('This token has already been spent and cannot be redeemed again'); + } + + throw new Error(`Melt operation failed: ${error.message}`); + } + } + + /** + * Validate if a token is properly formatted and has valid proofs + */ + async validateToken(token) { + try { + if (!this.isValidTokenFormat(token)) return false; + const parsed = await this.parseToken(token); + return parsed.totalAmount > 0 && parsed.proofs.length > 0; + } catch (error) { + return false; + } + } + + /** + * Get mint info for a given mint URL + */ + async getMintInfo(mintUrl) { + try { + const mint = await this.getMint(mintUrl); + return await mint.getInfo(); + } catch (error) { + throw new Error(`Failed to get mint info: ${error.message}`); + } + } + + /** + * Check if proofs are spendable at the mint using NUT-07 state check + */ + async checkTokenSpendable(token) { + try { + const parsed = await this.parseToken(token); + const wallet = await this.getWallet(parsed.mint); + + console.log(`Checking spendability for ${parsed.proofs.length} proofs at mint: ${parsed.mint}`); + + const proofStates = await wallet.checkProofsStates(parsed.proofs); + + console.log('Proof states:', proofStates); + + const spendable = []; + const pending = []; + const spent = []; + + for (let i = 0; i < proofStates.length; i++) { + const state = proofStates[i]; + if (state.state === CheckStateEnum.UNSPENT) { + spendable.push(parsed.proofs[i]); + } else if (state.state === CheckStateEnum.PENDING) { + pending.push(parsed.proofs[i]); + } else if (state.state === CheckStateEnum.SPENT) { + spent.push(parsed.proofs[i]); + } + } + + return { + spendable, + pending, + spent, + mintUrl: parsed.mint, + totalAmount: parsed.totalAmount + }; + } catch (error) { + console.error('Spendability check error details:', { + errorType: error.constructor.name, + errorMessage: error.message, + errorStatus: error.status + }); + + let errorMessage = 'Unknown error occurred'; + + if (error.constructor.name === 'HttpResponseError' || error.constructor.name === 'MintOperationError') { + const status = error.status || error.response?.status; + + if (status === 422) { + const detail = error.response?.data?.detail || error.detail; + if (detail) { + errorMessage = `Token validation failed: ${detail}`; + } else { + errorMessage = 'Token proofs are not spendable - they may have already been used or are invalid'; + } + } else if (status === 404 || status === 405 || status === 501) { + errorMessage = 'This mint does not support spendability checking'; + } else { + errorMessage = error.message && error.message !== '[object Object]' + ? error.message + : `Mint returned HTTP ${status}`; + } + } else if (error.message && error.message !== '[object Object]') { + errorMessage = error.message; + } + + console.log('Final extracted error message:', errorMessage); + + if (errorMessage.includes('not supported') || + errorMessage.includes('404') || + errorMessage.includes('405') || + errorMessage.includes('501') || + errorMessage.includes('endpoint not found') || + errorMessage.includes('not implemented')) { + throw new Error('This mint does not support spendability checking. Token may still be valid.'); + } + + const status = error.status || error.response?.status; + if (status === 422) { + 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 { + 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}`); + } + } +} + +module.exports = new CashuComponent(); diff --git a/services/lightning.js b/components/lightning.js similarity index 74% rename from services/lightning.js rename to components/lightning.js index efd613e..a0eafff 100644 --- a/services/lightning.js +++ b/components/lightning.js @@ -1,7 +1,7 @@ const axios = require('axios'); const bolt11 = require('bolt11'); -class LightningService { +class LightningComponent { constructor() { this.allowedDomains = process.env.ALLOW_REDEEM_DOMAINS ? process.env.ALLOW_REDEEM_DOMAINS.split(',').map(d => d.trim()) @@ -11,7 +11,6 @@ class LightningService { /** * Get the default Lightning address from environment - * @returns {string|null} Default Lightning address or null if not set */ getDefaultLightningAddress() { return this.defaultLightningAddress || null; @@ -19,8 +18,6 @@ class LightningService { /** * Get Lightning address to use - provided address or default - * @param {string|null} providedAddress - The provided Lightning address - * @returns {string} Lightning address to use */ getLightningAddressToUse(providedAddress) { if (providedAddress && providedAddress.trim()) { @@ -37,40 +34,26 @@ class LightningService { /** * Validate Lightning Address format - * @param {string} lightningAddress - The Lightning address (user@domain.com) - * @returns {boolean} Whether the address is valid */ validateLightningAddress(lightningAddress) { if (!lightningAddress || typeof lightningAddress !== 'string') { return false; } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(lightningAddress); } /** * Check if a domain is allowed for redemption - * @param {string} domain - The domain to check - * @returns {boolean} Whether the domain is allowed */ isDomainAllowed(domain) { - if (this.allowedDomains.length === 0) { - return true; // If no restrictions, allow all - } - - // Check for wildcard allowing all domains - if (this.allowedDomains.includes('*')) { - return true; - } - + if (this.allowedDomains.length === 0) return true; + if (this.allowedDomains.includes('*')) return true; return this.allowedDomains.includes(domain.toLowerCase()); } /** * Parse Lightning Address into username and domain - * @param {string} lightningAddress - The Lightning address - * @returns {Object} Parsed address components */ parseLightningAddress(lightningAddress) { if (!this.validateLightningAddress(lightningAddress)) { @@ -88,8 +71,6 @@ class LightningService { /** * Resolve LNURLp endpoint from Lightning address - * @param {string} lightningAddress - The Lightning address - * @returns {string} LNURLp endpoint URL */ getLNURLpEndpoint(lightningAddress) { const { username, domain } = this.parseLightningAddress(lightningAddress); @@ -98,16 +79,12 @@ class LightningService { /** * Fetch LNURLp response from endpoint - * @param {string} lnurlpUrl - The LNURLp endpoint URL - * @returns {Object} LNURLp response data */ async fetchLNURLpResponse(lnurlpUrl) { try { const response = await axios.get(lnurlpUrl, { timeout: 10000, - headers: { - 'User-Agent': 'Cashu-Redeem-API/1.0.0' - } + headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' } }); if (response.status !== 200) { @@ -135,10 +112,6 @@ class LightningService { /** * Get Lightning invoice from LNURLp callback - * @param {string} callbackUrl - The callback URL from LNURLp response - * @param {number} amount - Amount in millisatoshis - * @param {string} comment - Optional comment - * @returns {Object} Invoice response */ async getInvoice(callbackUrl, amount, comment = '') { try { @@ -146,14 +119,12 @@ class LightningService { url.searchParams.set('amount', amount.toString()); if (comment && comment.length > 0) { - url.searchParams.set('comment', comment.substring(0, 144)); // LN comment limit + url.searchParams.set('comment', comment.substring(0, 144)); } const response = await axios.get(url.toString(), { timeout: 10000, - headers: { - 'User-Agent': 'Cashu-Redeem-API/1.0.0' - } + headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' } }); if (response.status !== 200) { @@ -182,8 +153,6 @@ class LightningService { /** * Convert satoshis to millisatoshis - * @param {number} sats - Amount in satoshis - * @returns {number} Amount in millisatoshis */ satsToMillisats(sats) { return sats * 1000; @@ -191,8 +160,6 @@ class LightningService { /** * Convert millisatoshis to satoshis - * @param {number} msats - Amount in millisatoshis - * @returns {number} Amount in satoshis */ millisatsToSats(msats) { return Math.floor(msats / 1000); @@ -200,34 +167,24 @@ class LightningService { /** * Validate amount against LNURLp constraints - * @param {number} amount - Amount in satoshis - * @param {Object} lnurlpResponse - LNURLp response data - * @returns {boolean} Whether amount is valid */ validateAmount(amount, lnurlpResponse) { const amountMsats = this.satsToMillisats(amount); const minSendable = parseInt(lnurlpResponse.minSendable); const maxSendable = parseInt(lnurlpResponse.maxSendable); - return amountMsats >= minSendable && amountMsats <= maxSendable; } /** * Full Lightning address to invoice resolution - * @param {string} lightningAddress - The Lightning address - * @param {number} amount - Amount in satoshis - * @param {string} comment - Optional comment - * @returns {Object} Invoice and metadata */ 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, @@ -235,14 +192,12 @@ class LightningService { maxSendable: lnurlpResponse.maxSendable }); - // Validate amount if (!this.validateAmount(amount, lnurlpResponse)) { const minSats = this.millisatsToSats(lnurlpResponse.minSendable); const maxSats = this.millisatsToSats(lnurlpResponse.maxSendable); throw new Error(`Amount ${amount} sats is outside allowed range: ${minSats}-${maxSats} sats`); } - // Get invoice const amountMsats = this.satsToMillisats(amount); console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`); console.log(`Using callback URL: ${lnurlpResponse.callback}`); @@ -273,19 +228,15 @@ class LightningService { /** * Decode Lightning invoice (basic parsing) - * @param {string} bolt11 - Lightning invoice - * @returns {Object} Basic invoice info */ - parseInvoice(bolt11) { + parseInvoice(bolt11Invoice) { try { - // This is a simplified parser - for production use a proper library like bolt11 - if (!bolt11.toLowerCase().startsWith('lnbc') && !bolt11.toLowerCase().startsWith('lntb')) { + if (!bolt11Invoice.toLowerCase().startsWith('lnbc') && !bolt11Invoice.toLowerCase().startsWith('lntb')) { throw new Error('Invalid Lightning invoice format'); } - return { - bolt11, - network: bolt11.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet' + bolt11: bolt11Invoice, + network: bolt11Invoice.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet' }; } catch (error) { throw new Error(`Invoice parsing failed: ${error.message}`); @@ -294,20 +245,14 @@ class LightningService { /** * 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; @@ -318,13 +263,11 @@ class LightningService { 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) { @@ -349,4 +292,4 @@ class LightningService { } } -module.exports = new LightningService(); \ No newline at end of file +module.exports = new LightningComponent(); diff --git a/services/redemption.js b/components/redemption.js similarity index 63% rename from services/redemption.js rename to components/redemption.js index 62b085f..3f472b5 100644 --- a/services/redemption.js +++ b/components/redemption.js @@ -1,29 +1,23 @@ const { v4: uuidv4 } = require('uuid'); -const cashuService = require('./cashu'); -const lightningService = require('./lightning'); +const crypto = require('crypto'); +const cashu = require('./cashu'); +const lightning = require('./lightning'); -class RedemptionService { +class RedemptionComponent { 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 + this.tokenHashes = new Map(); } /** * 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, { @@ -35,8 +29,6 @@ class RedemptionService { /** * Update redemption status - * @param {string} redeemId - The redemption ID - * @param {Object} updates - Updates to apply */ updateRedemption(redeemId, updates) { const existing = this.redemptions.get(redeemId); @@ -51,8 +43,6 @@ class RedemptionService { /** * 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; @@ -60,8 +50,6 @@ class RedemptionService { /** * 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); @@ -70,8 +58,6 @@ class RedemptionService { /** * 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); @@ -80,43 +66,36 @@ class RedemptionService { /** * 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); + addressToUse = lightning.getLightningAddressToUse(lightningAddress); - if (!lightningService.validateLightningAddress(addressToUse)) { + if (!lightning.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) { + if (existing && existing.status === 'paid') { 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); + tokenData = await cashu.parseToken(token); if (tokenData.totalAmount <= 0) { errors.push('Token has no value'); } @@ -135,23 +114,18 @@ class RedemptionService { /** * 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 lightningAddressToUse = lightning.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 + token: token.substring(0, 50) + '...', tokenHash, lightningAddress: lightningAddressToUse, usingDefaultAddress: isUsingDefault, @@ -160,12 +134,11 @@ class RedemptionService { 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); + const tokenData = await cashu.parseToken(token); this.updateRedemption(redeemId, { amount: tokenData.totalAmount, @@ -177,20 +150,17 @@ class RedemptionService { // Check if token is spendable this.updateRedemption(redeemId, { status: 'checking_spendability' }); try { - const spendabilityCheck = await cashuService.checkTokenSpendable(token); + 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) { - // 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...'); } @@ -198,22 +168,20 @@ class RedemptionService { // 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( + const tempInvoiceData = await lightning.resolveInvoice( lightningAddressToUse, - tokenData.totalAmount, // Use full amount initially + tokenData.totalAmount, 'Cashu redemption' ); - // Get melt quote to determine exact fee - const meltQuote = await cashuService.getMeltQuote(token, tempInvoiceData.bolt11); + 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 (total - exact fee) + // Step 3: Create final invoice for the correct amount this.updateRedemption(redeemId, { status: 'resolving_invoice' }); if (finalInvoiceAmount <= 0) { @@ -222,9 +190,9 @@ class RedemptionService { console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`); - const invoiceData = await lightningService.resolveInvoice( + const invoiceData = await lightning.resolveInvoice( lightningAddressToUse, - finalInvoiceAmount, // Use amount minus exact fee + finalInvoiceAmount, 'Cashu redemption' ); @@ -232,20 +200,18 @@ class RedemptionService { bolt11: invoiceData.bolt11.substring(0, 50) + '...', domain: invoiceData.domain, invoiceAmount: finalInvoiceAmount, - exactFee: exactFee + exactFee }); - // Verify the invoice is valid and for the correct amount - const invoiceVerified = lightningService.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount); + 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 cashuService.meltToken(token, invoiceData.bolt11); + const meltResult = await cashu.meltToken(token, invoiceData.bolt11); - // Log melt result for debugging console.log(`Redemption ${redeemId}: Melt result:`, { paid: meltResult.paid, hasPreimage: !!meltResult.preimage, @@ -253,11 +219,8 @@ class RedemptionService { 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, @@ -267,7 +230,7 @@ class RedemptionService { netAmount: meltResult.netAmount, change: meltResult.change, paidAt: paymentSuccessful ? new Date().toISOString() : null, - rawMeltResponse: meltResult.rawMeltResponse // Store for debugging + rawMeltResponse: meltResult.rawMeltResponse }); return { @@ -275,12 +238,12 @@ class RedemptionService { redeemId, paid: paymentSuccessful, amount: tokenData.totalAmount, - invoiceAmount: finalInvoiceAmount, // Amount actually sent in the invoice + invoiceAmount: finalInvoiceAmount, to: lightningAddressToUse, usingDefaultAddress: isUsingDefault, - fee: exactFee, // Use the exact fee from the melt quote + fee: exactFee, actualFee: meltResult.actualFee, - netAmount: finalInvoiceAmount, // This is the net amount the user receives + netAmount: finalInvoiceAmount, preimage: meltResult.preimage, change: meltResult.change, mint: tokenData.mint, @@ -288,13 +251,15 @@ class RedemptionService { }; } catch (error) { - // Update redemption with 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, @@ -305,15 +270,11 @@ class RedemptionService { /** * 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; - } + if (!redemption) return null; const response = { success: true, @@ -327,32 +288,17 @@ class RedemptionService { } }; - 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; - } + 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]) => ({ @@ -363,16 +309,14 @@ class RedemptionService { /** * Clean up old redemptions (should be called periodically) - * @param {number} maxAgeMs - Maximum age in milliseconds */ - cleanupOldRedemptions(maxAgeMs = 24 * 60 * 60 * 1000) { // 24 hours default + 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); - // Also clean up token hash mapping if (redemption.tokenHash) { this.tokenHashes.delete(redemption.tokenHash); } @@ -381,4 +325,4 @@ class RedemptionService { } } -module.exports = new RedemptionService(); \ No newline at end of file +module.exports = new RedemptionComponent(); diff --git a/package-lock.json b/package-lock.json index bc7f36a..ff93b39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,20 @@ { "name": "cashu-redeem-api", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashu-redeem-api", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@cashu/cashu-ts": "^1.1.0", - "axios": "^1.7.7", + "@cashu/cashu-ts": "^3.4.1", + "axios": "^1.8.1", "bolt11": "^1.4.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "^8.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "uuid": "^10.0.0" @@ -78,30 +77,30 @@ } }, "node_modules/@cashu/cashu-ts": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-1.2.1.tgz", - "integrity": "sha512-B0+e02S8DQA8KBt2FgKHgGYvtHQokXJ3sZcTyAdHqvb0T0jfo1zF7nHn19eU9iYcfk8VSWf5xNBTocpTfj1aNg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.4.1.tgz", + "integrity": "sha512-d8bgYbYIKCsT7Hs8BsoENrRehQmuA8qYscQuAGuCk4FsT4a+OGPYAJuxgCApwBhKCokclBjZzVnkS19/CygQ0g==", "license": "MIT", "dependencies": { - "@cashu/crypto": "^0.2.7", - "@noble/curves": "^1.3.0", - "@noble/hashes": "^1.3.3", - "@scure/bip32": "^1.3.3", - "@scure/bip39": "^1.2.2", - "buffer": "^6.0.3" + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@scure/base": "^2.0.0", + "@scure/bip32": "^2.0.1" + }, + "engines": { + "node": ">=22.4.0" } }, - "node_modules/@cashu/crypto": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.2.7.tgz", - "integrity": "sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==", + "node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "license": "MIT", - "dependencies": { - "@noble/curves": "^1.3.0", - "@noble/hashes": "^1.3.3", - "@scure/bip32": "^1.3.3", - "@scure/bip39": "^1.2.2", - "buffer": "^6.0.3" + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/@eslint-community/eslint-utils": { @@ -368,15 +367,27 @@ "license": "MIT" }, "node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "2.0.1" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -402,36 +413,35 @@ "license": "Apache-2.0" }, "node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", "license": "MIT", "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, - "node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" + "engines": { + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -603,26 +613,6 @@ "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/bech32": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", @@ -759,30 +749,6 @@ "bs58": "^5.0.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1469,23 +1435,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.0.tgz", - "integrity": "sha512-FXEAp2ccTeN1ZSO+sPHRHWB0/CrTP5asFBjUaNeD9A0v3iPmgFbLu24vqPjiM9utszI58VGlMokjXQ0W9Dbmjw==", - "dependencies": { - "ip": "2.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1902,26 +1851,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1983,11 +1912,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", - "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index c2dba2b..a4fe1c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cashu-redeem-api", - "version": "1.1.0", + "version": "2.0.0", "description": "A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol", "main": "server.js", "scripts": { @@ -33,8 +33,8 @@ "url": "https://github.com/Michilis" }, "dependencies": { - "@cashu/cashu-ts": "^1.1.0", - "axios": "^1.7.7", + "@cashu/cashu-ts": "^3.4.1", + "axios": "^1.8.1", "bolt11": "^1.4.1", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/routes/cashu.js b/routes/cashu.js new file mode 100644 index 0000000..dd3da50 --- /dev/null +++ b/routes/cashu.js @@ -0,0 +1,100 @@ +const express = require('express'); +const router = express.Router(); +const cashu = require('../components/cashu'); + +/** + * @swagger + * /api/decode: + * post: + * summary: Decode a Cashu token + * description: Decode a Cashu token and return its content. Supports both v1 and v3 token formats. + * tags: [Token Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DecodeRequest' + * responses: + * 200: + * description: Token decoded successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DecodeResponse' + * 400: + * $ref: '#/components/responses/BadRequest' + * 429: + * $ref: '#/components/responses/TooManyRequests' + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.post('/decode', async (req, res) => { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ + success: false, + error: 'Token is required' + }); + } + + try { + if (!cashu.isValidTokenFormat(token)) { + return res.status(400).json({ + success: false, + error: 'Invalid token format. Must be a valid Cashu token' + }); + } + + const decoded = await cashu.parseToken(token); + const mintUrl = await cashu.getTokenMintUrl(token); + + let spent = false; + try { + const spendabilityCheck = await cashu.checkTokenSpendable(token); + spent = !spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0; + } catch (error) { + console.warn('Spendability check failed:', error.message); + + const errorString = error.message || error.toString(); + + if (errorString.includes('TOKEN_SPENT:')) { + console.log('Token determined to be spent by CashuComponent'); + spent = true; + } else if (errorString.includes('Token validation failed at mint:')) { + console.log('Token validation failed at mint - assuming token is still valid (might be invalid format)'); + spent = false; + } else if (errorString.includes('not supported') || + errorString.includes('endpoint not found') || + errorString.includes('may still be valid') || + errorString.includes('does not support spendability checking')) { + console.log('Mint does not support spendability checking - assuming token is valid'); + spent = false; + } else { + console.log('Unknown error - assuming token is valid'); + spent = false; + } + } + + res.json({ + success: true, + decoded: { + mint: decoded.mint, + totalAmount: decoded.totalAmount, + numProofs: decoded.numProofs, + denominations: decoded.denominations, + format: decoded.format, + spent + }, + mint_url: mintUrl + }); + } catch (error) { + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +module.exports = router; diff --git a/routes/health.js b/routes/health.js new file mode 100644 index 0000000..2d7a8b8 --- /dev/null +++ b/routes/health.js @@ -0,0 +1,44 @@ +const express = require('express'); +const router = express.Router(); + +/** + * @swagger + * /api/health: + * get: + * summary: Health check endpoint + * description: | + * Check the health and status of the API server. + * Returns server information including uptime, memory usage, and version. + * tags: [Status & Monitoring] + * responses: + * 200: + * description: Server is healthy + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/HealthResponse' + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.get('/health', async (req, res) => { + try { + const packageJson = require('../package.json'); + + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memory: process.memoryUsage(), + version: packageJson.version + }); + } catch (error) { + console.error('Health check error:', error); + res.status(500).json({ + status: 'error', + timestamp: new Date().toISOString(), + error: 'Health check failed' + }); + } +}); + +module.exports = router; diff --git a/routes/lightning.js b/routes/lightning.js new file mode 100644 index 0000000..e722190 --- /dev/null +++ b/routes/lightning.js @@ -0,0 +1,86 @@ +const express = require('express'); +const router = express.Router(); +const lightning = require('../components/lightning'); + +/** + * @swagger + * /api/validate-address: + * post: + * summary: Validate a Lightning address + * description: | + * Validate a Lightning address without performing a redemption. + * Checks format validity and tests LNURLp resolution. + * + * Returns information about the Lightning address capabilities + * including min/max sendable amounts and comment allowance. + * tags: [Validation] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ValidateAddressRequest' + * responses: + * 200: + * description: Validation completed (check 'valid' field for result) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ValidateAddressResponse' + * 400: + * $ref: '#/components/responses/BadRequest' + * 429: + * $ref: '#/components/responses/TooManyRequests' + */ +router.post('/validate-address', async (req, res) => { + const { lightningAddress } = req.body; + + if (!lightningAddress) { + return res.status(400).json({ + success: false, + error: 'Lightning address is required' + }); + } + + try { + const isValid = lightning.validateLightningAddress(lightningAddress); + + if (!isValid) { + return res.json({ + success: false, + valid: false, + error: 'Invalid Lightning address format' + }); + } + + const { domain } = lightning.parseLightningAddress(lightningAddress); + const lnurlpUrl = lightning.getLNURLpEndpoint(lightningAddress); + + try { + const lnurlpResponse = await lightning.fetchLNURLpResponse(lnurlpUrl); + + res.json({ + success: true, + valid: true, + domain, + minSendable: lightning.millisatsToSats(lnurlpResponse.minSendable), + maxSendable: lightning.millisatsToSats(lnurlpResponse.maxSendable), + commentAllowed: lnurlpResponse.commentAllowed || 0 + }); + } catch (error) { + res.json({ + success: false, + valid: false, + error: `Lightning address resolution failed: ${error.message}` + }); + } + } catch (error) { + res.status(400).json({ + success: false, + valid: false, + error: error.message + }); + } +}); + +module.exports = router; diff --git a/routes/redemption.js b/routes/redemption.js new file mode 100644 index 0000000..f9c250f --- /dev/null +++ b/routes/redemption.js @@ -0,0 +1,146 @@ +const express = require('express'); +const router = express.Router(); +const redemption = require('../components/redemption'); + +/** + * @swagger + * /api/redeem: + * post: + * summary: Redeem a Cashu token to Lightning address + * description: | + * Redeem a Cashu token to a Lightning address (optional - uses default if not provided). + * + * The redemption process includes: + * 1. Token validation and parsing + * 2. Getting exact melt quote from mint to determine precise fees + * 3. Invoice creation for net amount (token amount - exact fees) + * 4. Spendability checking at the mint + * 5. Token melting and Lightning payment + * + * **Important**: The system gets the exact fee from the mint before creating the invoice. + * The `invoiceAmount` field shows the actual amount sent to the Lightning address. + * No sats are lost to fee estimation errors. + * tags: [Token Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RedeemRequest' + * responses: + * 200: + * description: Token redeemed successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RedeemResponse' + * 400: + * $ref: '#/components/responses/BadRequest' + * 409: + * description: Token already spent + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: string + * example: "This token has already been spent and cannot be redeemed again" + * errorType: + * type: string + * example: "token_already_spent" + * 422: + * description: Insufficient funds or unprocessable token + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: string + * example: "Token amount is insufficient to cover the minimum fee" + * errorType: + * type: string + * example: "insufficient_funds" + * 429: + * $ref: '#/components/responses/TooManyRequests' + * 500: + * $ref: '#/components/responses/InternalServerError' + */ +router.post('/redeem', async (req, res) => { + const { token, lightningAddress } = req.body; + + const validation = await redemption.validateRedemptionRequest(token, lightningAddress); + + if (!validation.valid) { + return res.status(400).json({ + success: false, + error: validation.errors.join(', ') + }); + } + + try { + const result = await redemption.performRedemption(token, lightningAddress); + + if (result.success) { + const response = { + success: true, + paid: result.paid, + amount: result.amount, + invoiceAmount: result.invoiceAmount, + to: result.to, + fee: result.fee, + actualFee: result.actualFee, + netAmount: result.netAmount, + mint_url: result.mint, + format: result.format + }; + + if (result.usingDefaultAddress) { + response.usingDefaultAddress = true; + response.message = `Redeemed to default Lightning address: ${result.to}`; + } + + if (result.preimage) { + response.preimage = result.preimage; + } + + res.json(response); + } else { + let statusCode = 400; + + if (result.error && ( + result.error.includes('cannot be redeemed') || + result.error.includes('already been used') || + result.error.includes('not spendable') || + result.error.includes('already spent') || + result.error.includes('invalid proofs') + )) { + statusCode = 409; + } else if (result.error && result.error.includes('insufficient')) { + statusCode = 422; + } + + res.status(statusCode).json({ + success: false, + error: result.error, + errorType: statusCode === 409 ? 'token_already_spent' : + statusCode === 422 ? 'insufficient_funds' : 'validation_error' + }); + } + } catch (error) { + console.error('Error in redemption:', error); + res.status(500).json({ + success: false, + error: 'Internal server error during redemption' + }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 06e4cad..634b5ec 100644 --- a/server.js +++ b/server.js @@ -3,9 +3,13 @@ const express = require('express'); const cors = require('cors'); const swaggerUi = require('swagger-ui-express'); const swaggerSpecs = require('./swagger.config'); -const cashuService = require('./services/cashu'); -const lightningService = require('./services/lightning'); -const redemptionService = require('./services/redemption'); +const redemption = require('./components/redemption'); + +// Route imports +const cashuRoutes = require('./routes/cashu'); +const redemptionRoutes = require('./routes/redemption'); +const lightningRoutes = require('./routes/lightning'); +const healthRoutes = require('./routes/health'); const app = express(); const PORT = process.env.PORT || 3000; @@ -33,8 +37,8 @@ app.use(cors({ app.use((req, res, next) => { if (req.method === 'OPTIONS') { res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Origin, X-Requested-With'); + res.header('Access-Control-Allow-Methods', 'GET', 'POST', 'OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type', 'Authorization', 'Accept', 'Origin', 'X-Requested-With'); res.status(200).end(); return; } @@ -52,6 +56,27 @@ app.get('/api/cors-test', (req, res) => { }); }); +/** + * @swagger + * /openapi.json: + * get: + * summary: OpenAPI specification + * description: Returns the full OpenAPI 3.0 specification for this API as JSON. + * tags: [Status & Monitoring] + * responses: + * 200: + * description: OpenAPI specification + * content: + * application/json: + * schema: + * type: object + * description: OpenAPI 3.0 specification document + */ +app.get('/openapi.json', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.json(swaggerSpecs); +}); + // Swagger Documentation app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, { customCss: '.swagger-ui .topbar { display: none }', @@ -65,20 +90,18 @@ app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, { // Basic rate limiting (simple in-memory implementation) const rateLimitMap = new Map(); -const RATE_LIMIT = parseInt(process.env.RATE_LIMIT) || 100; // requests per minute +const RATE_LIMIT = parseInt(process.env.RATE_LIMIT) || 100; function rateLimit(req, res, next) { const clientId = req.ip || req.connection.remoteAddress; const now = Date.now(); - const windowStart = now - 60000; // 1 minute window + const windowStart = now - 60000; if (!rateLimitMap.has(clientId)) { rateLimitMap.set(clientId, []); } const requests = rateLimitMap.get(clientId); - - // Remove old requests outside the window const validRequests = requests.filter(time => time > windowStart); rateLimitMap.set(clientId, validRequests); @@ -103,21 +126,14 @@ app.use((req, res, next) => { next(); }); -// Error handling middleware -function asyncHandler(fn) { - return (req, res, next) => { - Promise.resolve(fn(req, res, next)).catch(next); - }; -} - -// API Routes - +// Root endpoint app.get('/', (req, res) => { res.json({ name: 'Cashu Redeem API', - version: '1.0.0', + version: '2.0.0', description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol', documentation: '/docs', + openapi: '/openapi.json', endpoints: { decode: 'POST /api/decode', redeem: 'POST /api/redeem', @@ -136,384 +152,11 @@ app.get('/', (req, res) => { }); }); -// API Routes - -/** - * @swagger - * /api/decode: - * post: - * summary: Decode a Cashu token - * description: Decode a Cashu token and return its content. Supports both v1 and v3 token formats. - * tags: [Token Operations] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/DecodeRequest' - * responses: - * 200: - * description: Token decoded successfully - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/DecodeResponse' - * 400: - * $ref: '#/components/responses/BadRequest' - * 429: - * $ref: '#/components/responses/TooManyRequests' - * 500: - * $ref: '#/components/responses/InternalServerError' - */ -app.post('/api/decode', asyncHandler(async (req, res) => { - const { token } = req.body; - - if (!token) { - return res.status(400).json({ - success: false, - error: 'Token is required' - }); - } - - try { - // Validate token format first - if (!cashuService.isValidTokenFormat(token)) { - return res.status(400).json({ - success: false, - error: 'Invalid token format. Must be a valid Cashu token' - }); - } - - const decoded = await cashuService.parseToken(token); - const mintUrl = await cashuService.getTokenMintUrl(token); - - // Check if token is spent - let spent = false; - try { - const spendabilityCheck = await cashuService.checkTokenSpendable(token); - // Token is spent if no proofs are spendable - spent = !spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0; - } catch (error) { - // If spendability check fails, analyze the error to determine if token is spent - console.warn('Spendability check failed:', error.message); - - // Check if error indicates proofs are already spent - const errorString = error.message || error.toString(); - - // Check for specific error indicators - if (errorString.includes('TOKEN_SPENT:')) { - // CashuService has determined the token is spent based on clear indicators - console.log('Token determined to be spent by CashuService'); - spent = true; - } else if (errorString.includes('Token validation failed at mint:')) { - // This is a 422 error but not clearly indicating the token is spent - // It might be invalid/malformed but not necessarily spent - console.log('Token validation failed at mint - assuming token is still valid (might be invalid format)'); - spent = false; - } else if (errorString.includes('not supported') || - errorString.includes('endpoint not found') || - errorString.includes('may still be valid') || - errorString.includes('does not support spendability checking')) { - // Mint doesn't support spendability checking - assume token is still valid - console.log('Mint does not support spendability checking - assuming token is valid'); - spent = false; - } else { - // For other errors (network, server issues), assume token is still valid - // This is safer than assuming it's spent - console.log('Unknown error - assuming token is valid'); - spent = false; - } - } - - res.json({ - success: true, - decoded: { - mint: decoded.mint, - totalAmount: decoded.totalAmount, - numProofs: decoded.numProofs, - denominations: decoded.denominations, - format: decoded.format, - spent: spent - }, - mint_url: mintUrl - }); - } catch (error) { - res.status(400).json({ - success: false, - error: error.message - }); - } -})); - -/** - * @swagger - * /api/redeem: - * post: - * summary: Redeem a Cashu token to Lightning address - * description: | - * Redeem a Cashu token to a Lightning address (optional - uses default if not provided). - * - * The redemption process includes: - * 1. Token validation and parsing - * 2. Getting exact melt quote from mint to determine precise fees - * 3. Invoice creation for net amount (token amount - exact fees) - * 4. Spendability checking at the mint - * 5. Token melting and Lightning payment - * - * **Important**: The system gets the exact fee from the mint before creating the invoice. - * The `invoiceAmount` field shows the actual amount sent to the Lightning address. - * No sats are lost to fee estimation errors. - * tags: [Token Operations] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RedeemRequest' - * responses: - * 200: - * description: Token redeemed successfully - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RedeemResponse' - * 400: - * $ref: '#/components/responses/BadRequest' - * 409: - * description: Token already spent - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * example: false - * error: - * type: string - * example: "This token has already been spent and cannot be redeemed again" - * errorType: - * type: string - * example: "token_already_spent" - * 422: - * description: Insufficient funds or unprocessable token - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * example: false - * error: - * type: string - * example: "Token amount is insufficient to cover the minimum fee" - * errorType: - * type: string - * example: "insufficient_funds" - * 429: - * $ref: '#/components/responses/TooManyRequests' - * 500: - * $ref: '#/components/responses/InternalServerError' - */ -app.post('/api/redeem', asyncHandler(async (req, res) => { - const { token, lightningAddress } = req.body; - - // Validate request (lightningAddress is now optional) - const validation = await redemptionService.validateRedemptionRequest(token, lightningAddress); - - if (!validation.valid) { - return res.status(400).json({ - success: false, - error: validation.errors.join(', ') - }); - } - - // Perform redemption - try { - const result = await redemptionService.performRedemption(token, lightningAddress); - - if (result.success) { - const response = { - success: true, - paid: result.paid, - amount: result.amount, - invoiceAmount: result.invoiceAmount, - to: result.to, - fee: result.fee, - actualFee: result.actualFee, - netAmount: result.netAmount, - mint_url: result.mint, - format: result.format - }; - - // Include info about whether default address was used - if (result.usingDefaultAddress) { - response.usingDefaultAddress = true; - response.message = `Redeemed to default Lightning address: ${result.to}`; - } - - // Include preimage if available - if (result.preimage) { - response.preimage = result.preimage; - } - - res.json(response); - } else { - // Determine appropriate status code based on error type - let statusCode = 400; - - if (result.error && ( - result.error.includes('cannot be redeemed') || - result.error.includes('already been used') || - result.error.includes('not spendable') || - result.error.includes('already spent') || - result.error.includes('invalid proofs') - )) { - // Use 409 Conflict for already-spent tokens to distinguish from generic bad requests - statusCode = 409; - } else if (result.error && result.error.includes('insufficient')) { - // Use 422 for insufficient funds - statusCode = 422; - } - - res.status(statusCode).json({ - success: false, - error: result.error, - errorType: statusCode === 409 ? 'token_already_spent' : - statusCode === 422 ? 'insufficient_funds' : 'validation_error' - }); - } - } catch (error) { - console.error('Error in redemption:', error); - res.status(500).json({ - success: false, - error: 'Internal server error during redemption' - }); - } -})); - -/** - * @swagger - * /api/health: - * get: - * summary: Health check endpoint - * description: | - * Check the health and status of the API server. - * Returns server information including uptime, memory usage, and version. - * tags: [Status & Monitoring] - * responses: - * 200: - * description: Server is healthy - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/HealthResponse' - * 500: - * $ref: '#/components/responses/InternalServerError' - */ -app.get('/api/health', asyncHandler(async (req, res) => { - try { - const packageJson = require('./package.json'); - - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - memory: process.memoryUsage(), - version: packageJson.version - }); - } catch (error) { - console.error('Health check error:', error); - res.status(500).json({ - status: 'error', - timestamp: new Date().toISOString(), - error: 'Health check failed' - }); - } -})); - -/** - * @swagger - * /api/validate-address: - * post: - * summary: Validate a Lightning address - * description: | - * Validate a Lightning address without performing a redemption. - * Checks format validity and tests LNURLp resolution. - * - * Returns information about the Lightning address capabilities - * including min/max sendable amounts and comment allowance. - * tags: [Validation] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ValidateAddressRequest' - * responses: - * 200: - * description: Validation completed (check 'valid' field for result) - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ValidateAddressResponse' - * 400: - * $ref: '#/components/responses/BadRequest' - * 429: - * $ref: '#/components/responses/TooManyRequests' - */ -app.post('/api/validate-address', asyncHandler(async (req, res) => { - const { lightningAddress } = req.body; - - if (!lightningAddress) { - return res.status(400).json({ - success: false, - error: 'Lightning address is required' - }); - } - - try { - const isValid = lightningService.validateLightningAddress(lightningAddress); - - if (!isValid) { - return res.json({ - success: false, - valid: false, - error: 'Invalid Lightning address format' - }); - } - - // Test resolution - const { domain } = lightningService.parseLightningAddress(lightningAddress); - const lnurlpUrl = lightningService.getLNURLpEndpoint(lightningAddress); - - try { - const lnurlpResponse = await lightningService.fetchLNURLpResponse(lnurlpUrl); - - res.json({ - success: true, - valid: true, - domain, - minSendable: lightningService.millisatsToSats(lnurlpResponse.minSendable), - maxSendable: lightningService.millisatsToSats(lnurlpResponse.maxSendable), - commentAllowed: lnurlpResponse.commentAllowed || 0 - }); - } catch (error) { - res.json({ - success: false, - valid: false, - error: `Lightning address resolution failed: ${error.message}` - }); - } - } catch (error) { - res.status(400).json({ - success: false, - valid: false, - error: error.message - }); - } -})); +// Mount API routes +app.use('/api', cashuRoutes); +app.use('/api', redemptionRoutes); +app.use('/api', lightningRoutes); +app.use('/api', healthRoutes); // 404 handler app.use('*', (req, res) => { @@ -543,12 +186,12 @@ app.use((error, req, res, next) => { // Cleanup old redemptions periodically (every hour) setInterval(() => { try { - redemptionService.cleanupOldRedemptions(); + redemption.cleanupOldRedemptions(); console.log('Cleaned up old redemptions'); } catch (error) { console.error('Error cleaning up redemptions:', error); } -}, 60 * 60 * 1000); // 1 hour +}, 60 * 60 * 1000); // Graceful shutdown process.on('SIGTERM', () => { @@ -563,8 +206,9 @@ process.on('SIGINT', () => { // Start server app.listen(PORT, () => { - console.log(`🚀 Cashu Redeem API running on port ${PORT}`); + console.log(`🚀 Cashu Redeem API v2.0.0 running on port ${PORT}`); console.log(`📖 API Documentation: http://localhost:${PORT}/docs`); + console.log(`📋 OpenAPI spec: http://localhost:${PORT}/openapi.json`); console.log(`📍 Health check: http://localhost:${PORT}/api/health`); console.log(`🔒 Environment: ${process.env.NODE_ENV || 'development'}`); @@ -581,4 +225,4 @@ app.listen(PORT, () => { } }); -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/services/cashu.js b/services/cashu.js deleted file mode 100644 index 1b8451b..0000000 --- a/services/cashu.js +++ /dev/null @@ -1,520 +0,0 @@ -const { CashuMint, CashuWallet, getEncodedToken, getDecodedToken } = require('@cashu/cashu-ts'); - -class CashuService { - constructor() { - this.mints = new Map(); // Cache mint instances - this.wallets = new Map(); // Cache wallet instances - } - - /** - * Validate token format (supports both v1 and v3 formats) - * @param {string} token - The Cashu token - * @returns {boolean} Whether the token format is valid - */ - isValidTokenFormat(token) { - // Match both v1 and v3 token formats - return /^cashu[abAB][a-zA-Z0-9-_]+$/.test(token); - } - - /** - * Get token mint URL from decoded token - * @param {string} token - The encoded Cashu token - * @returns {string|null} Mint URL or null if not found - */ - async getTokenMintUrl(token) { - try { - const decoded = getDecodedToken(token); - if (!decoded) { - return null; - } - - // Handle both v1 and v3 token formats - if (decoded.mint) { - // v3 format - return decoded.mint; - } else if (decoded.token && decoded.token[0] && decoded.token[0].mint) { - // v1 format - return decoded.token[0].mint; - } - - return null; - } catch (error) { - console.error('Error getting token mint URL:', error); - return null; - } - } - - /** - * Decode token handling both v1 and v3 formats - * @param {string} token - The encoded Cashu token - * @returns {Object} Decoded token data - */ - async decodeTokenStructure(token) { - try { - const decoded = getDecodedToken(token); - if (!decoded) { - throw new Error('Failed to decode token'); - } - - // Handle both v1 and v3 token formats - if (decoded.proofs) { - // v3 format - return { - proofs: decoded.proofs, - mint: decoded.mint - }; - } else if (decoded.token && decoded.token[0]) { - // v1 format - return { - proofs: decoded.token[0].proofs, - mint: decoded.token[0].mint - }; - } - - throw new Error('Invalid token structure'); - } catch (error) { - throw new Error(`Token decoding failed: ${error.message}`); - } - } - - /** - * Calculate fee according to NUT-05 specification - * @param {number} amount - Amount in satoshis - * @returns {number} Fee amount - */ - calculateFee(amount) { - // Calculate 2% of the amount, rounded up - const fee = Math.ceil(amount * 0.02); - // Return the greater of 1 sat or the calculated fee - return Math.max(1, fee); - } - - /** - * Parse and validate a Cashu token - * @param {string} token - The encoded Cashu token - * @returns {Object} Parsed token data - */ - async parseToken(token) { - try { - if (!token || typeof token !== 'string') { - throw new Error('Invalid token format'); - } - - // Remove any whitespace and validate basic format - token = token.trim(); - - // Validate token format - if (!this.isValidTokenFormat(token)) { - throw new Error('Invalid token format. Must be a valid Cashu token'); - } - - // Decode token structure - const decoded = await this.decodeTokenStructure(token); - - if (!decoded.proofs || !Array.isArray(decoded.proofs) || decoded.proofs.length === 0) { - throw new Error('Invalid token structure - no proofs found'); - } - - // Calculate total amount - const totalAmount = decoded.proofs.reduce((sum, proof) => sum + (proof.amount || 0), 0); - - if (totalAmount <= 0) { - throw new Error('Token has no value'); - } - - const denominations = decoded.proofs.map(proof => proof.amount); - - return { - mint: decoded.mint, - totalAmount, - numProofs: decoded.proofs.length, - denominations, - proofs: decoded.proofs, - format: token.startsWith('cashuA') ? 'cashuA' : 'cashuB' - }; - } catch (error) { - throw new Error(`Token parsing failed: ${error.message}`); - } - } - - /** - * Get total amount from a token - * @param {string} token - The encoded Cashu token - * @returns {number} Total amount in satoshis - */ - async getTotalAmount(token) { - const parsed = await this.parseToken(token); - return parsed.totalAmount; - } - - /** - * Get or create a mint instance - * @param {string} mintUrl - The mint URL - * @returns {CashuMint} Mint instance - */ - async getMint(mintUrl) { - if (!this.mints.has(mintUrl)) { - try { - const mint = new CashuMint(mintUrl); - // Test connectivity - await mint.getInfo(); - this.mints.set(mintUrl, mint); - } catch (error) { - throw new Error(`Failed to connect to mint ${mintUrl}: ${error.message}`); - } - } - return this.mints.get(mintUrl); - } - - /** - * Get or create a wallet instance for a specific mint - * @param {string} mintUrl - The mint URL - * @returns {CashuWallet} Wallet instance - */ - async getWallet(mintUrl) { - if (!this.wallets.has(mintUrl)) { - try { - const mint = await this.getMint(mintUrl); - const wallet = new CashuWallet(mint); - this.wallets.set(mintUrl, wallet); - } catch (error) { - throw new Error(`Failed to create wallet for mint ${mintUrl}: ${error.message}`); - } - } - 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 - * @param {string} bolt11 - The Lightning invoice - * @returns {Object} Melt result - */ - async meltToken(token, bolt11) { - try { - const parsed = await this.parseToken(token); - const wallet = await this.getWallet(parsed.mint); - - // Get the decoded token structure - const decoded = await this.decodeTokenStructure(token); - const proofs = decoded.proofs; - - // 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); - - // 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 - if (total > parsed.totalAmount) { - throw new Error(`Insufficient funds. Required: ${total} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`); - } - - // 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)); - - // Verify payment was successful - check multiple possible indicators - const paymentSuccessful = meltResponse.paid === true || - meltResponse.payment_preimage || - meltResponse.preimage || - (meltResponse.state && meltResponse.state === 'PAID'); - - if (!paymentSuccessful) { - console.warn('Payment verification failed. Response structure:', meltResponse); - // Don't throw error immediately - the payment might have succeeded - // but the response structure is different than expected - } - - // Get the actual fee charged from the melt response - // The actual fee might be in meltResponse.fee_paid, meltResponse.fee, or calculated from change - const actualFeeCharged = meltResponse.fee_paid || - meltResponse.fee || - meltQuote.fee_reserve; // fallback to quote fee - - // Calculate net amount based on actual fee charged - const actualNetAmount = parsed.totalAmount - actualFeeCharged; - - return { - success: true, - paid: paymentSuccessful, - preimage: meltResponse.payment_preimage || meltResponse.preimage, - change: meltResponse.change || [], - amount: meltQuote.amount, - fee: actualFeeCharged, // Use actual fee from melt response - netAmount: actualNetAmount, // Use net amount based on actual fee - quote: meltQuote.quote, - rawMeltResponse: meltResponse // Include raw response for debugging - }; - } catch (error) { - // Check if it's a cashu-ts specific error - if (error.message.includes('Insufficient funds') || - error.message.includes('Payment failed') || - error.message.includes('Quote not found')) { - throw error; // Re-throw specific cashu errors - } - - // Check if it's an already-spent token error - if (error.status === 422 || - error.message.includes('already spent') || - error.message.includes('not spendable') || - error.message.includes('invalid proofs')) { - throw new Error('This token has already been spent and cannot be redeemed again'); - } - - throw new Error(`Melt operation failed: ${error.message}`); - } - } - - /** - * Validate if a token is properly formatted and has valid proofs - * @param {string} token - The encoded Cashu token - * @returns {boolean} Whether the token is valid - */ - async validateToken(token) { - try { - if (!this.isValidTokenFormat(token)) { - return false; - } - - const parsed = await this.parseToken(token); - return parsed.totalAmount > 0 && parsed.proofs.length > 0; - } catch (error) { - return false; - } - } - - /** - * Get mint info for a given mint URL - * @param {string} mintUrl - The mint URL - * @returns {Object} Mint information - */ - async getMintInfo(mintUrl) { - try { - const mint = await this.getMint(mintUrl); - return await mint.getInfo(); - } catch (error) { - throw new Error(`Failed to get mint info: ${error.message}`); - } - } - - /** - * Check if proofs are spendable at the mint - * @param {string} token - The encoded Cashu token - * @returns {Object} Spendability check result - */ - async checkTokenSpendable(token) { - try { - const parsed = await this.parseToken(token); - const mint = await this.getMint(parsed.mint); - - // Extract secrets from proofs - const secrets = parsed.proofs.map(proof => proof.secret); - - // Log the attempt for debugging - console.log(`Checking spendability for ${secrets.length} proofs at mint: ${parsed.mint}`); - - // Perform the check - const checkResult = await mint.check({ secrets }); - - console.log('Spendability check result:', checkResult); - - return { - spendable: checkResult.spendable || [], - pending: checkResult.pending || [], - mintUrl: parsed.mint, - totalAmount: parsed.totalAmount - }; - } catch (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') { - // Extract status code first - const status = error.status || error.response?.status || error.statusCode; - - // For 422 errors, we need to be more specific about the reason - if (status === 422) { - // 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 - if (error.response) { - const statusText = error.response.statusText; - - if (status === 404) { - errorMessage = 'This mint does not support spendability checking (endpoint not found)'; - } else if (status === 405) { - errorMessage = 'This mint does not support spendability checking (method not allowed)'; - } else if (status === 501) { - errorMessage = 'This mint does not support spendability checking (not implemented)'; - } else { - errorMessage = `Mint returned HTTP ${status}${statusText ? ': ' + statusText : ''}`; - } - } else if (error.message && error.message !== '[object Object]') { - errorMessage = error.message; - } else { - // Try to extract error details from the error object structure - console.log('Attempting to extract error details from object structure...'); - try { - // Check if there's additional error data in the response - const errorData = error.data || error.response?.data; - if (errorData && typeof errorData === 'string') { - errorMessage = errorData; - } else if (errorData && errorData.detail) { - errorMessage = `Mint error: ${errorData.detail}`; - } else if (errorData && errorData.message) { - errorMessage = `Mint error: ${errorData.message}`; - } else { - // Check if we can extract status from anywhere in the error - if (status) { - if (status === 422) { - errorMessage = 'Token proofs are not spendable - they have already been used or are invalid'; - } else { - errorMessage = `Mint returned HTTP ${status} - spendability checking may not be supported`; - } - } else { - errorMessage = 'This mint does not support spendability checking or returned an invalid response'; - } - } - } catch (extractError) { - console.log('Failed to extract error details:', extractError); - errorMessage = 'This mint does not support spendability checking or returned an invalid response'; - } - } - } - } else if (error && typeof error === 'object') { - if (error.message && error.message !== '[object Object]') { - errorMessage = error.message; - } else if (error.toString && typeof error.toString === 'function') { - const stringError = error.toString(); - if (stringError !== '[object Object]') { - errorMessage = stringError; - } else { - errorMessage = 'Invalid response from mint - spendability checking may not be supported'; - } - } else { - errorMessage = 'Invalid response from mint - spendability checking may not be supported'; - } - } else if (typeof error === 'string') { - errorMessage = error; - } - - // Log the final extracted error message for debugging - console.log('Final extracted error message:', errorMessage); - - // Check if it's a known error pattern indicating unsupported operation - if (errorMessage.includes('not supported') || - errorMessage.includes('404') || - errorMessage.includes('405') || - errorMessage.includes('501') || - errorMessage.includes('Method not allowed') || - errorMessage.includes('endpoint not found') || - errorMessage.includes('not implemented') || - errorMessage.includes('Invalid response from mint')) { - 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}`); - } - } -} - -module.exports = new CashuService(); \ No newline at end of file diff --git a/swagger.config.js b/swagger.config.js index 1ba5137..c0283c4 100644 --- a/swagger.config.js +++ b/swagger.config.js @@ -16,7 +16,7 @@ const options = { openapi: '3.0.0', info: { title: 'Cashu Redeem API', - version: '1.0.0', + version: '2.0.0', description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol.', contact: { name: 'API Support', @@ -337,10 +337,14 @@ const options = { { name: 'Validation', description: 'Validation utilities for tokens and Lightning addresses' + }, + { + name: 'Status & Monitoring', + description: 'Health check and API status endpoints' } ] }, - apis: ['./server.js'], // paths to files containing OpenAPI definitions + apis: ['./server.js', './routes/*.js'], // paths to files containing OpenAPI definitions }; const specs = swaggerJsdoc(options);