Refactor: move services to components, add route modules

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-02-17 03:46:46 +00:00
parent e2a13d009f
commit 50e5787ec2
12 changed files with 902 additions and 1218 deletions

369
components/cashu.js Normal file
View File

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

View File

@@ -1,7 +1,7 @@
const axios = require('axios'); const axios = require('axios');
const bolt11 = require('bolt11'); const bolt11 = require('bolt11');
class LightningService { class LightningComponent {
constructor() { constructor() {
this.allowedDomains = process.env.ALLOW_REDEEM_DOMAINS this.allowedDomains = process.env.ALLOW_REDEEM_DOMAINS
? process.env.ALLOW_REDEEM_DOMAINS.split(',').map(d => d.trim()) ? process.env.ALLOW_REDEEM_DOMAINS.split(',').map(d => d.trim())
@@ -11,7 +11,6 @@ class LightningService {
/** /**
* Get the default Lightning address from environment * Get the default Lightning address from environment
* @returns {string|null} Default Lightning address or null if not set
*/ */
getDefaultLightningAddress() { getDefaultLightningAddress() {
return this.defaultLightningAddress || null; return this.defaultLightningAddress || null;
@@ -19,8 +18,6 @@ class LightningService {
/** /**
* Get Lightning address to use - provided address or default * 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) { getLightningAddressToUse(providedAddress) {
if (providedAddress && providedAddress.trim()) { if (providedAddress && providedAddress.trim()) {
@@ -37,40 +34,26 @@ class LightningService {
/** /**
* Validate Lightning Address format * Validate Lightning Address format
* @param {string} lightningAddress - The Lightning address (user@domain.com)
* @returns {boolean} Whether the address is valid
*/ */
validateLightningAddress(lightningAddress) { validateLightningAddress(lightningAddress) {
if (!lightningAddress || typeof lightningAddress !== 'string') { if (!lightningAddress || typeof lightningAddress !== 'string') {
return false; return false;
} }
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(lightningAddress); return emailRegex.test(lightningAddress);
} }
/** /**
* Check if a domain is allowed for redemption * Check if a domain is allowed for redemption
* @param {string} domain - The domain to check
* @returns {boolean} Whether the domain is allowed
*/ */
isDomainAllowed(domain) { isDomainAllowed(domain) {
if (this.allowedDomains.length === 0) { if (this.allowedDomains.length === 0) return true;
return true; // If no restrictions, allow all if (this.allowedDomains.includes('*')) return true;
}
// Check for wildcard allowing all domains
if (this.allowedDomains.includes('*')) {
return true;
}
return this.allowedDomains.includes(domain.toLowerCase()); return this.allowedDomains.includes(domain.toLowerCase());
} }
/** /**
* Parse Lightning Address into username and domain * Parse Lightning Address into username and domain
* @param {string} lightningAddress - The Lightning address
* @returns {Object} Parsed address components
*/ */
parseLightningAddress(lightningAddress) { parseLightningAddress(lightningAddress) {
if (!this.validateLightningAddress(lightningAddress)) { if (!this.validateLightningAddress(lightningAddress)) {
@@ -88,8 +71,6 @@ class LightningService {
/** /**
* Resolve LNURLp endpoint from Lightning address * Resolve LNURLp endpoint from Lightning address
* @param {string} lightningAddress - The Lightning address
* @returns {string} LNURLp endpoint URL
*/ */
getLNURLpEndpoint(lightningAddress) { getLNURLpEndpoint(lightningAddress) {
const { username, domain } = this.parseLightningAddress(lightningAddress); const { username, domain } = this.parseLightningAddress(lightningAddress);
@@ -98,16 +79,12 @@ class LightningService {
/** /**
* Fetch LNURLp response from endpoint * Fetch LNURLp response from endpoint
* @param {string} lnurlpUrl - The LNURLp endpoint URL
* @returns {Object} LNURLp response data
*/ */
async fetchLNURLpResponse(lnurlpUrl) { async fetchLNURLpResponse(lnurlpUrl) {
try { try {
const response = await axios.get(lnurlpUrl, { const response = await axios.get(lnurlpUrl, {
timeout: 10000, timeout: 10000,
headers: { headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' }
'User-Agent': 'Cashu-Redeem-API/1.0.0'
}
}); });
if (response.status !== 200) { if (response.status !== 200) {
@@ -135,10 +112,6 @@ class LightningService {
/** /**
* Get Lightning invoice from LNURLp callback * 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 = '') { async getInvoice(callbackUrl, amount, comment = '') {
try { try {
@@ -146,14 +119,12 @@ class LightningService {
url.searchParams.set('amount', amount.toString()); url.searchParams.set('amount', amount.toString());
if (comment && comment.length > 0) { 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(), { const response = await axios.get(url.toString(), {
timeout: 10000, timeout: 10000,
headers: { headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' }
'User-Agent': 'Cashu-Redeem-API/1.0.0'
}
}); });
if (response.status !== 200) { if (response.status !== 200) {
@@ -182,8 +153,6 @@ class LightningService {
/** /**
* Convert satoshis to millisatoshis * Convert satoshis to millisatoshis
* @param {number} sats - Amount in satoshis
* @returns {number} Amount in millisatoshis
*/ */
satsToMillisats(sats) { satsToMillisats(sats) {
return sats * 1000; return sats * 1000;
@@ -191,8 +160,6 @@ class LightningService {
/** /**
* Convert millisatoshis to satoshis * Convert millisatoshis to satoshis
* @param {number} msats - Amount in millisatoshis
* @returns {number} Amount in satoshis
*/ */
millisatsToSats(msats) { millisatsToSats(msats) {
return Math.floor(msats / 1000); return Math.floor(msats / 1000);
@@ -200,34 +167,24 @@ class LightningService {
/** /**
* Validate amount against LNURLp constraints * 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) { validateAmount(amount, lnurlpResponse) {
const amountMsats = this.satsToMillisats(amount); const amountMsats = this.satsToMillisats(amount);
const minSendable = parseInt(lnurlpResponse.minSendable); const minSendable = parseInt(lnurlpResponse.minSendable);
const maxSendable = parseInt(lnurlpResponse.maxSendable); const maxSendable = parseInt(lnurlpResponse.maxSendable);
return amountMsats >= minSendable && amountMsats <= maxSendable; return amountMsats >= minSendable && amountMsats <= maxSendable;
} }
/** /**
* Full Lightning address to invoice resolution * 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') { async resolveInvoice(lightningAddress, amount, comment = 'Cashu token redemption') {
try { try {
console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`); console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`);
// Get LNURLp endpoint
const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress); const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress);
console.log(`LNURLp endpoint: ${lnurlpUrl}`); console.log(`LNURLp endpoint: ${lnurlpUrl}`);
// Fetch LNURLp response
const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl); const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl);
console.log('LNURLp response:', { console.log('LNURLp response:', {
callback: lnurlpResponse.callback, callback: lnurlpResponse.callback,
@@ -235,14 +192,12 @@ class LightningService {
maxSendable: lnurlpResponse.maxSendable maxSendable: lnurlpResponse.maxSendable
}); });
// Validate amount
if (!this.validateAmount(amount, lnurlpResponse)) { if (!this.validateAmount(amount, lnurlpResponse)) {
const minSats = this.millisatsToSats(lnurlpResponse.minSendable); const minSats = this.millisatsToSats(lnurlpResponse.minSendable);
const maxSats = this.millisatsToSats(lnurlpResponse.maxSendable); const maxSats = this.millisatsToSats(lnurlpResponse.maxSendable);
throw new Error(`Amount ${amount} sats is outside allowed range: ${minSats}-${maxSats} sats`); throw new Error(`Amount ${amount} sats is outside allowed range: ${minSats}-${maxSats} sats`);
} }
// Get invoice
const amountMsats = this.satsToMillisats(amount); const amountMsats = this.satsToMillisats(amount);
console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`); console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`);
console.log(`Using callback URL: ${lnurlpResponse.callback}`); console.log(`Using callback URL: ${lnurlpResponse.callback}`);
@@ -273,19 +228,15 @@ class LightningService {
/** /**
* Decode Lightning invoice (basic parsing) * Decode Lightning invoice (basic parsing)
* @param {string} bolt11 - Lightning invoice
* @returns {Object} Basic invoice info
*/ */
parseInvoice(bolt11) { parseInvoice(bolt11Invoice) {
try { try {
// This is a simplified parser - for production use a proper library like bolt11 if (!bolt11Invoice.toLowerCase().startsWith('lnbc') && !bolt11Invoice.toLowerCase().startsWith('lntb')) {
if (!bolt11.toLowerCase().startsWith('lnbc') && !bolt11.toLowerCase().startsWith('lntb')) {
throw new Error('Invalid Lightning invoice format'); throw new Error('Invalid Lightning invoice format');
} }
return { return {
bolt11, bolt11: bolt11Invoice,
network: bolt11.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet' network: bolt11Invoice.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet'
}; };
} catch (error) { } catch (error) {
throw new Error(`Invoice parsing failed: ${error.message}`); 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 * 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) { verifyInvoiceDestination(bolt11Invoice, expectedLightningAddress, expectedAmount = null) {
try { try {
console.log(`Verifying invoice destination for: ${expectedLightningAddress}`); console.log(`Verifying invoice destination for: ${expectedLightningAddress}`);
console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`); console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`);
// Decode the invoice using the bolt11 library
const decoded = bolt11.decode(bolt11Invoice); const decoded = bolt11.decode(bolt11Invoice);
// Basic validation checks
if (!decoded.complete) { if (!decoded.complete) {
console.error('Invoice verification failed: Invoice is incomplete'); console.error('Invoice verification failed: Invoice is incomplete');
return false; return false;
@@ -318,13 +263,11 @@ class LightningService {
return false; return false;
} }
// Check if the invoice has expired
if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) { if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) {
console.error('Invoice verification failed: Invoice has expired'); console.error('Invoice verification failed: Invoice has expired');
return false; return false;
} }
// Verify amount if provided
if (expectedAmount !== null) { if (expectedAmount !== null) {
const invoiceAmount = decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0); const invoiceAmount = decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0);
if (invoiceAmount !== expectedAmount) { if (invoiceAmount !== expectedAmount) {
@@ -349,4 +292,4 @@ class LightningService {
} }
} }
module.exports = new LightningService(); module.exports = new LightningComponent();

View File

@@ -1,29 +1,23 @@
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const cashuService = require('./cashu'); const crypto = require('crypto');
const lightningService = require('./lightning'); const cashu = require('./cashu');
const lightning = require('./lightning');
class RedemptionService { class RedemptionComponent {
constructor() { constructor() {
// In-memory storage for redemption status
// In production, use Redis or a proper database
this.redemptions = new Map(); 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) * Generate a simple hash for a token (for duplicate detection)
* @param {string} token - The Cashu token
* @returns {string} Hash of the token
*/ */
generateTokenHash(token) { generateTokenHash(token) {
const crypto = require('crypto');
return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16); return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16);
} }
/** /**
* Store redemption status * Store redemption status
* @param {string} redeemId - The redemption ID
* @param {Object} status - The redemption status object
*/ */
storeRedemption(redeemId, status) { storeRedemption(redeemId, status) {
this.redemptions.set(redeemId, { this.redemptions.set(redeemId, {
@@ -35,8 +29,6 @@ class RedemptionService {
/** /**
* Update redemption status * Update redemption status
* @param {string} redeemId - The redemption ID
* @param {Object} updates - Updates to apply
*/ */
updateRedemption(redeemId, updates) { updateRedemption(redeemId, updates) {
const existing = this.redemptions.get(redeemId); const existing = this.redemptions.get(redeemId);
@@ -51,8 +43,6 @@ class RedemptionService {
/** /**
* Get redemption status by ID * Get redemption status by ID
* @param {string} redeemId - The redemption ID
* @returns {Object|null} Redemption status or null if not found
*/ */
getRedemption(redeemId) { getRedemption(redeemId) {
return this.redemptions.get(redeemId) || null; return this.redemptions.get(redeemId) || null;
@@ -60,8 +50,6 @@ class RedemptionService {
/** /**
* Get redemption ID by token hash * Get redemption ID by token hash
* @param {string} tokenHash - The token hash
* @returns {string|null} Redemption ID or null if not found
*/ */
getRedemptionByTokenHash(tokenHash) { getRedemptionByTokenHash(tokenHash) {
const redeemId = this.tokenHashes.get(tokenHash); const redeemId = this.tokenHashes.get(tokenHash);
@@ -70,8 +58,6 @@ class RedemptionService {
/** /**
* Check if a token has already been redeemed * Check if a token has already been redeemed
* @param {string} token - The Cashu token
* @returns {Object|null} Existing redemption or null
*/ */
checkExistingRedemption(token) { checkExistingRedemption(token) {
const tokenHash = this.generateTokenHash(token); const tokenHash = this.generateTokenHash(token);
@@ -80,43 +66,36 @@ class RedemptionService {
/** /**
* Validate redemption request * Validate redemption request
* @param {string} token - The Cashu token
* @param {string} lightningAddress - The Lightning address (optional)
* @returns {Object} Validation result
*/ */
async validateRedemptionRequest(token, lightningAddress) { async validateRedemptionRequest(token, lightningAddress) {
const errors = []; const errors = [];
// Validate token format
if (!token || typeof token !== 'string') { if (!token || typeof token !== 'string') {
errors.push('Token is required and must be a 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; let addressToUse = null;
try { try {
addressToUse = lightningService.getLightningAddressToUse(lightningAddress); addressToUse = lightning.getLightningAddressToUse(lightningAddress);
if (!lightningService.validateLightningAddress(addressToUse)) { if (!lightning.validateLightningAddress(addressToUse)) {
errors.push('Invalid Lightning address format'); errors.push('Invalid Lightning address format');
} }
} catch (error) { } catch (error) {
errors.push(error.message); errors.push(error.message);
} }
// Check for existing redemption
if (token) { if (token) {
const existing = this.checkExistingRedemption(token); const existing = this.checkExistingRedemption(token);
if (existing) { if (existing && existing.status === 'paid') {
errors.push('Token has already been redeemed'); errors.push('Token has already been redeemed');
} }
} }
// Try to parse token
let tokenData = null; let tokenData = null;
if (token && errors.length === 0) { if (token && errors.length === 0) {
try { try {
tokenData = await cashuService.parseToken(token); tokenData = await cashu.parseToken(token);
if (tokenData.totalAmount <= 0) { if (tokenData.totalAmount <= 0) {
errors.push('Token has no value'); errors.push('Token has no value');
} }
@@ -135,23 +114,18 @@ class RedemptionService {
/** /**
* Perform the complete redemption process * 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) { async performRedemption(token, lightningAddress) {
const redeemId = uuidv4(); const redeemId = uuidv4();
const tokenHash = this.generateTokenHash(token); const tokenHash = this.generateTokenHash(token);
try { try {
// Determine which Lightning address to use const lightningAddressToUse = lightning.getLightningAddressToUse(lightningAddress);
const lightningAddressToUse = lightningService.getLightningAddressToUse(lightningAddress);
const isUsingDefault = !lightningAddress || !lightningAddress.trim(); const isUsingDefault = !lightningAddress || !lightningAddress.trim();
// Store initial status
this.storeRedemption(redeemId, { this.storeRedemption(redeemId, {
status: 'processing', status: 'processing',
token: token.substring(0, 50) + '...', // Store partial token for reference token: token.substring(0, 50) + '...',
tokenHash, tokenHash,
lightningAddress: lightningAddressToUse, lightningAddress: lightningAddressToUse,
usingDefaultAddress: isUsingDefault, usingDefaultAddress: isUsingDefault,
@@ -160,12 +134,11 @@ class RedemptionService {
error: null error: null
}); });
// Also map token hash to redemption ID
this.tokenHashes.set(tokenHash, redeemId); this.tokenHashes.set(tokenHash, redeemId);
// Step 1: Parse and validate token // Step 1: Parse and validate token
this.updateRedemption(redeemId, { status: 'parsing_token' }); this.updateRedemption(redeemId, { status: 'parsing_token' });
const tokenData = await cashuService.parseToken(token); const tokenData = await cashu.parseToken(token);
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
amount: tokenData.totalAmount, amount: tokenData.totalAmount,
@@ -177,20 +150,17 @@ class RedemptionService {
// Check if token is spendable // Check if token is spendable
this.updateRedemption(redeemId, { status: 'checking_spendability' }); this.updateRedemption(redeemId, { status: 'checking_spendability' });
try { try {
const spendabilityCheck = await cashuService.checkTokenSpendable(token); const spendabilityCheck = await cashu.checkTokenSpendable(token);
if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) { if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) {
throw new Error('Token proofs are not spendable - they have already been used or are invalid'); throw new Error('Token proofs are not spendable - they have already been used or are invalid');
} }
} catch (spendError) { } catch (spendError) {
// Check if the error indicates tokens are already spent (422 status)
if (spendError.message.includes('not spendable') || if (spendError.message.includes('not spendable') ||
spendError.message.includes('already been used') || spendError.message.includes('already been used') ||
spendError.message.includes('invalid proofs') || spendError.message.includes('invalid proofs') ||
spendError.message.includes('422')) { 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'); 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.warn('Spendability check failed:', spendError.message);
console.log('Continuing with redemption despite spendability check failure...'); 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 // Step 2: Get melt quote first to determine exact fees
this.updateRedemption(redeemId, { status: 'getting_melt_quote' }); 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`); console.log(`Getting melt quote for ${tokenData.totalAmount} sats to determine exact fees`);
const tempInvoiceData = await lightningService.resolveInvoice( const tempInvoiceData = await lightning.resolveInvoice(
lightningAddressToUse, lightningAddressToUse,
tokenData.totalAmount, // Use full amount initially tokenData.totalAmount,
'Cashu redemption' 'Cashu redemption'
); );
// Get melt quote to determine exact fee const meltQuote = await cashu.getMeltQuote(token, tempInvoiceData.bolt11);
const meltQuote = await cashuService.getMeltQuote(token, tempInvoiceData.bolt11);
const exactFee = meltQuote.fee_reserve; const exactFee = meltQuote.fee_reserve;
const finalInvoiceAmount = tokenData.totalAmount - exactFee; const finalInvoiceAmount = tokenData.totalAmount - exactFee;
console.log(`Melt quote: amount=${meltQuote.amount}, fee=${exactFee}, net to user=${finalInvoiceAmount}`); 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' }); this.updateRedemption(redeemId, { status: 'resolving_invoice' });
if (finalInvoiceAmount <= 0) { if (finalInvoiceAmount <= 0) {
@@ -222,9 +190,9 @@ class RedemptionService {
console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`); console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`);
const invoiceData = await lightningService.resolveInvoice( const invoiceData = await lightning.resolveInvoice(
lightningAddressToUse, lightningAddressToUse,
finalInvoiceAmount, // Use amount minus exact fee finalInvoiceAmount,
'Cashu redemption' 'Cashu redemption'
); );
@@ -232,20 +200,18 @@ class RedemptionService {
bolt11: invoiceData.bolt11.substring(0, 50) + '...', bolt11: invoiceData.bolt11.substring(0, 50) + '...',
domain: invoiceData.domain, domain: invoiceData.domain,
invoiceAmount: finalInvoiceAmount, invoiceAmount: finalInvoiceAmount,
exactFee: exactFee exactFee
}); });
// Verify the invoice is valid and for the correct amount const invoiceVerified = lightning.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
const invoiceVerified = lightningService.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
if (!invoiceVerified) { if (!invoiceVerified) {
throw new Error('Invoice verification failed - invalid invoice or amount mismatch'); throw new Error('Invoice verification failed - invalid invoice or amount mismatch');
} }
// Step 4: Melt the token to pay the invoice // Step 4: Melt the token to pay the invoice
this.updateRedemption(redeemId, { status: 'melting_token' }); 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:`, { console.log(`Redemption ${redeemId}: Melt result:`, {
paid: meltResult.paid, paid: meltResult.paid,
hasPreimage: !!meltResult.preimage, hasPreimage: !!meltResult.preimage,
@@ -253,11 +219,8 @@ class RedemptionService {
fee: meltResult.fee 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; const paymentSuccessful = meltResult.paid || !!meltResult.preimage;
// Step 4: Update final status
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
status: paymentSuccessful ? 'paid' : 'failed', status: paymentSuccessful ? 'paid' : 'failed',
paid: paymentSuccessful, paid: paymentSuccessful,
@@ -267,7 +230,7 @@ class RedemptionService {
netAmount: meltResult.netAmount, netAmount: meltResult.netAmount,
change: meltResult.change, change: meltResult.change,
paidAt: paymentSuccessful ? new Date().toISOString() : null, paidAt: paymentSuccessful ? new Date().toISOString() : null,
rawMeltResponse: meltResult.rawMeltResponse // Store for debugging rawMeltResponse: meltResult.rawMeltResponse
}); });
return { return {
@@ -275,12 +238,12 @@ class RedemptionService {
redeemId, redeemId,
paid: paymentSuccessful, paid: paymentSuccessful,
amount: tokenData.totalAmount, amount: tokenData.totalAmount,
invoiceAmount: finalInvoiceAmount, // Amount actually sent in the invoice invoiceAmount: finalInvoiceAmount,
to: lightningAddressToUse, to: lightningAddressToUse,
usingDefaultAddress: isUsingDefault, usingDefaultAddress: isUsingDefault,
fee: exactFee, // Use the exact fee from the melt quote fee: exactFee,
actualFee: meltResult.actualFee, actualFee: meltResult.actualFee,
netAmount: finalInvoiceAmount, // This is the net amount the user receives netAmount: finalInvoiceAmount,
preimage: meltResult.preimage, preimage: meltResult.preimage,
change: meltResult.change, change: meltResult.change,
mint: tokenData.mint, mint: tokenData.mint,
@@ -288,13 +251,15 @@ class RedemptionService {
}; };
} catch (error) { } catch (error) {
// Update redemption with error
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
status: 'failed', status: 'failed',
paid: false, paid: false,
error: error.message error: error.message
}); });
// Remove token hash so the token can be retried after a failed redemption
this.tokenHashes.delete(tokenHash);
return { return {
success: false, success: false,
redeemId, redeemId,
@@ -305,15 +270,11 @@ class RedemptionService {
/** /**
* Get redemption status for API response * Get redemption status for API response
* @param {string} redeemId - The redemption ID
* @returns {Object|null} Status response or null if not found
*/ */
getRedemptionStatus(redeemId) { getRedemptionStatus(redeemId) {
const redemption = this.getRedemption(redeemId); const redemption = this.getRedemption(redeemId);
if (!redemption) { if (!redemption) return null;
return null;
}
const response = { const response = {
success: true, success: true,
@@ -327,32 +288,17 @@ class RedemptionService {
} }
}; };
if (redemption.paidAt) { if (redemption.paidAt) response.details.paidAt = 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.fee) { if (redemption.domain) response.details.domain = redemption.domain;
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; return response;
} }
/** /**
* Get all redemptions (for admin/debugging) * Get all redemptions (for admin/debugging)
* @returns {Array} All redemptions
*/ */
getAllRedemptions() { getAllRedemptions() {
return Array.from(this.redemptions.entries()).map(([id, data]) => ({ return Array.from(this.redemptions.entries()).map(([id, data]) => ({
@@ -363,16 +309,14 @@ class RedemptionService {
/** /**
* Clean up old redemptions (should be called periodically) * 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); const cutoff = new Date(Date.now() - maxAgeMs);
for (const [redeemId, redemption] of this.redemptions.entries()) { for (const [redeemId, redemption] of this.redemptions.entries()) {
const createdAt = new Date(redemption.createdAt); const createdAt = new Date(redemption.createdAt);
if (createdAt < cutoff) { if (createdAt < cutoff) {
this.redemptions.delete(redeemId); this.redemptions.delete(redeemId);
// Also clean up token hash mapping
if (redemption.tokenHash) { if (redemption.tokenHash) {
this.tokenHashes.delete(redemption.tokenHash); this.tokenHashes.delete(redemption.tokenHash);
} }
@@ -381,4 +325,4 @@ class RedemptionService {
} }
} }
module.exports = new RedemptionService(); module.exports = new RedemptionComponent();

186
package-lock.json generated
View File

@@ -1,21 +1,20 @@
{ {
"name": "cashu-redeem-api", "name": "cashu-redeem-api",
"version": "1.1.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cashu-redeem-api", "name": "cashu-redeem-api",
"version": "1.1.0", "version": "2.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cashu/cashu-ts": "^1.1.0", "@cashu/cashu-ts": "^3.4.1",
"axios": "^1.7.7", "axios": "^1.8.1",
"bolt11": "^1.4.1", "bolt11": "^1.4.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-rate-limit": "^8.0.0",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"uuid": "^10.0.0" "uuid": "^10.0.0"
@@ -78,30 +77,30 @@
} }
}, },
"node_modules/@cashu/cashu-ts": { "node_modules/@cashu/cashu-ts": {
"version": "1.2.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.4.1.tgz",
"integrity": "sha512-B0+e02S8DQA8KBt2FgKHgGYvtHQokXJ3sZcTyAdHqvb0T0jfo1zF7nHn19eU9iYcfk8VSWf5xNBTocpTfj1aNg==", "integrity": "sha512-d8bgYbYIKCsT7Hs8BsoENrRehQmuA8qYscQuAGuCk4FsT4a+OGPYAJuxgCApwBhKCokclBjZzVnkS19/CygQ0g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cashu/crypto": "^0.2.7", "@noble/curves": "^2.0.1",
"@noble/curves": "^1.3.0", "@noble/hashes": "^2.0.1",
"@noble/hashes": "^1.3.3", "@scure/base": "^2.0.0",
"@scure/bip32": "^1.3.3", "@scure/bip32": "^2.0.1"
"@scure/bip39": "^1.2.2", },
"buffer": "^6.0.3" "engines": {
"node": ">=22.4.0"
} }
}, },
"node_modules/@cashu/crypto": { "node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
"version": "0.2.7", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.2.7.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==", "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT", "license": "MIT",
"dependencies": { "engines": {
"@noble/curves": "^1.3.0", "node": ">= 20.19.0"
"@noble/hashes": "^1.3.3", },
"@scure/bip32": "^1.3.3", "funding": {
"@scure/bip39": "^1.2.2", "url": "https://paulmillr.com/funding/"
"buffer": "^6.0.3"
} }
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
@@ -368,15 +367,27 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@noble/curves": { "node_modules/@noble/curves": {
"version": "1.9.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/hashes": "1.8.0" "@noble/hashes": "2.0.1"
}, },
"engines": { "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": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
@@ -402,36 +413,35 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@scure/base": { "node_modules/@scure/base": {
"version": "1.2.6", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip32": { "node_modules/@scure/bip32": {
"version": "1.7.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/curves": "~1.9.0", "@noble/curves": "2.0.1",
"@noble/hashes": "~1.8.0", "@noble/hashes": "2.0.1",
"@scure/base": "~1.2.5" "@scure/base": "2.0.0"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip39": { "node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.6.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT", "license": "MIT",
"dependencies": { "engines": {
"@noble/hashes": "~1.8.0", "node": ">= 20.19.0"
"@scure/base": "~1.2.5"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
@@ -603,26 +613,6 @@
"resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz",
"integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==" "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": { "node_modules/bech32": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz",
@@ -759,30 +749,6 @@
"bs58": "^5.0.0" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1469,23 +1435,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1902,26 +1851,6 @@
"node": ">=0.10.0" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1983,11 +1912,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "cashu-redeem-api", "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", "description": "A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@@ -33,8 +33,8 @@
"url": "https://github.com/Michilis" "url": "https://github.com/Michilis"
}, },
"dependencies": { "dependencies": {
"@cashu/cashu-ts": "^1.1.0", "@cashu/cashu-ts": "^3.4.1",
"axios": "^1.7.7", "axios": "^1.8.1",
"bolt11": "^1.4.1", "bolt11": "^1.4.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",

100
routes/cashu.js Normal file
View File

@@ -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;

44
routes/health.js Normal file
View File

@@ -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;

86
routes/lightning.js Normal file
View File

@@ -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;

146
routes/redemption.js Normal file
View File

@@ -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;

444
server.js
View File

@@ -3,9 +3,13 @@ const express = require('express');
const cors = require('cors'); const cors = require('cors');
const swaggerUi = require('swagger-ui-express'); const swaggerUi = require('swagger-ui-express');
const swaggerSpecs = require('./swagger.config'); const swaggerSpecs = require('./swagger.config');
const cashuService = require('./services/cashu'); const redemption = require('./components/redemption');
const lightningService = require('./services/lightning');
const redemptionService = require('./services/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 app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -33,8 +37,8 @@ app.use(cors({
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); 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-Headers', 'Content-Type', 'Authorization', 'Accept', 'Origin', 'X-Requested-With');
res.status(200).end(); res.status(200).end();
return; 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 // Swagger Documentation
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, { app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
customCss: '.swagger-ui .topbar { display: none }', 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) // Basic rate limiting (simple in-memory implementation)
const rateLimitMap = new Map(); 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) { function rateLimit(req, res, next) {
const clientId = req.ip || req.connection.remoteAddress; const clientId = req.ip || req.connection.remoteAddress;
const now = Date.now(); const now = Date.now();
const windowStart = now - 60000; // 1 minute window const windowStart = now - 60000;
if (!rateLimitMap.has(clientId)) { if (!rateLimitMap.has(clientId)) {
rateLimitMap.set(clientId, []); rateLimitMap.set(clientId, []);
} }
const requests = rateLimitMap.get(clientId); const requests = rateLimitMap.get(clientId);
// Remove old requests outside the window
const validRequests = requests.filter(time => time > windowStart); const validRequests = requests.filter(time => time > windowStart);
rateLimitMap.set(clientId, validRequests); rateLimitMap.set(clientId, validRequests);
@@ -103,21 +126,14 @@ app.use((req, res, next) => {
next(); next();
}); });
// Error handling middleware // Root endpoint
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// API Routes
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.json({ res.json({
name: 'Cashu Redeem API', 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', description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol',
documentation: '/docs', documentation: '/docs',
openapi: '/openapi.json',
endpoints: { endpoints: {
decode: 'POST /api/decode', decode: 'POST /api/decode',
redeem: 'POST /api/redeem', redeem: 'POST /api/redeem',
@@ -136,384 +152,11 @@ app.get('/', (req, res) => {
}); });
}); });
// API Routes // Mount API routes
app.use('/api', cashuRoutes);
/** app.use('/api', redemptionRoutes);
* @swagger app.use('/api', lightningRoutes);
* /api/decode: app.use('/api', healthRoutes);
* 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
});
}
}));
// 404 handler // 404 handler
app.use('*', (req, res) => { app.use('*', (req, res) => {
@@ -543,12 +186,12 @@ app.use((error, req, res, next) => {
// Cleanup old redemptions periodically (every hour) // Cleanup old redemptions periodically (every hour)
setInterval(() => { setInterval(() => {
try { try {
redemptionService.cleanupOldRedemptions(); redemption.cleanupOldRedemptions();
console.log('Cleaned up old redemptions'); console.log('Cleaned up old redemptions');
} catch (error) { } catch (error) {
console.error('Error cleaning up redemptions:', error); console.error('Error cleaning up redemptions:', error);
} }
}, 60 * 60 * 1000); // 1 hour }, 60 * 60 * 1000);
// Graceful shutdown // Graceful shutdown
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
@@ -563,8 +206,9 @@ process.on('SIGINT', () => {
// Start server // Start server
app.listen(PORT, () => { 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(`📖 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(`📍 Health check: http://localhost:${PORT}/api/health`);
console.log(`🔒 Environment: ${process.env.NODE_ENV || 'development'}`); console.log(`🔒 Environment: ${process.env.NODE_ENV || 'development'}`);

View File

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

View File

@@ -16,7 +16,7 @@ const options = {
openapi: '3.0.0', openapi: '3.0.0',
info: { info: {
title: 'Cashu Redeem API', 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.', description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol.',
contact: { contact: {
name: 'API Support', name: 'API Support',
@@ -337,10 +337,14 @@ const options = {
{ {
name: 'Validation', name: 'Validation',
description: 'Validation utilities for tokens and Lightning addresses' 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); const specs = swaggerJsdoc(options);