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