Refactor: move services to components, add route modules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
369
components/cashu.js
Normal file
369
components/cashu.js
Normal 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();
|
||||
@@ -1,7 +1,7 @@
|
||||
const axios = require('axios');
|
||||
const bolt11 = require('bolt11');
|
||||
|
||||
class LightningService {
|
||||
class LightningComponent {
|
||||
constructor() {
|
||||
this.allowedDomains = process.env.ALLOW_REDEEM_DOMAINS
|
||||
? process.env.ALLOW_REDEEM_DOMAINS.split(',').map(d => d.trim())
|
||||
@@ -11,7 +11,6 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Get the default Lightning address from environment
|
||||
* @returns {string|null} Default Lightning address or null if not set
|
||||
*/
|
||||
getDefaultLightningAddress() {
|
||||
return this.defaultLightningAddress || null;
|
||||
@@ -19,8 +18,6 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Get Lightning address to use - provided address or default
|
||||
* @param {string|null} providedAddress - The provided Lightning address
|
||||
* @returns {string} Lightning address to use
|
||||
*/
|
||||
getLightningAddressToUse(providedAddress) {
|
||||
if (providedAddress && providedAddress.trim()) {
|
||||
@@ -37,40 +34,26 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Validate Lightning Address format
|
||||
* @param {string} lightningAddress - The Lightning address (user@domain.com)
|
||||
* @returns {boolean} Whether the address is valid
|
||||
*/
|
||||
validateLightningAddress(lightningAddress) {
|
||||
if (!lightningAddress || typeof lightningAddress !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(lightningAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is allowed for redemption
|
||||
* @param {string} domain - The domain to check
|
||||
* @returns {boolean} Whether the domain is allowed
|
||||
*/
|
||||
isDomainAllowed(domain) {
|
||||
if (this.allowedDomains.length === 0) {
|
||||
return true; // If no restrictions, allow all
|
||||
}
|
||||
|
||||
// Check for wildcard allowing all domains
|
||||
if (this.allowedDomains.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.allowedDomains.length === 0) return true;
|
||||
if (this.allowedDomains.includes('*')) return true;
|
||||
return this.allowedDomains.includes(domain.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Lightning Address into username and domain
|
||||
* @param {string} lightningAddress - The Lightning address
|
||||
* @returns {Object} Parsed address components
|
||||
*/
|
||||
parseLightningAddress(lightningAddress) {
|
||||
if (!this.validateLightningAddress(lightningAddress)) {
|
||||
@@ -88,8 +71,6 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Resolve LNURLp endpoint from Lightning address
|
||||
* @param {string} lightningAddress - The Lightning address
|
||||
* @returns {string} LNURLp endpoint URL
|
||||
*/
|
||||
getLNURLpEndpoint(lightningAddress) {
|
||||
const { username, domain } = this.parseLightningAddress(lightningAddress);
|
||||
@@ -98,16 +79,12 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Fetch LNURLp response from endpoint
|
||||
* @param {string} lnurlpUrl - The LNURLp endpoint URL
|
||||
* @returns {Object} LNURLp response data
|
||||
*/
|
||||
async fetchLNURLpResponse(lnurlpUrl) {
|
||||
try {
|
||||
const response = await axios.get(lnurlpUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Cashu-Redeem-API/1.0.0'
|
||||
}
|
||||
headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' }
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
@@ -135,10 +112,6 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Get Lightning invoice from LNURLp callback
|
||||
* @param {string} callbackUrl - The callback URL from LNURLp response
|
||||
* @param {number} amount - Amount in millisatoshis
|
||||
* @param {string} comment - Optional comment
|
||||
* @returns {Object} Invoice response
|
||||
*/
|
||||
async getInvoice(callbackUrl, amount, comment = '') {
|
||||
try {
|
||||
@@ -146,14 +119,12 @@ class LightningService {
|
||||
url.searchParams.set('amount', amount.toString());
|
||||
|
||||
if (comment && comment.length > 0) {
|
||||
url.searchParams.set('comment', comment.substring(0, 144)); // LN comment limit
|
||||
url.searchParams.set('comment', comment.substring(0, 144));
|
||||
}
|
||||
|
||||
const response = await axios.get(url.toString(), {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Cashu-Redeem-API/1.0.0'
|
||||
}
|
||||
headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' }
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
@@ -182,8 +153,6 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Convert satoshis to millisatoshis
|
||||
* @param {number} sats - Amount in satoshis
|
||||
* @returns {number} Amount in millisatoshis
|
||||
*/
|
||||
satsToMillisats(sats) {
|
||||
return sats * 1000;
|
||||
@@ -191,8 +160,6 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Convert millisatoshis to satoshis
|
||||
* @param {number} msats - Amount in millisatoshis
|
||||
* @returns {number} Amount in satoshis
|
||||
*/
|
||||
millisatsToSats(msats) {
|
||||
return Math.floor(msats / 1000);
|
||||
@@ -200,34 +167,24 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Validate amount against LNURLp constraints
|
||||
* @param {number} amount - Amount in satoshis
|
||||
* @param {Object} lnurlpResponse - LNURLp response data
|
||||
* @returns {boolean} Whether amount is valid
|
||||
*/
|
||||
validateAmount(amount, lnurlpResponse) {
|
||||
const amountMsats = this.satsToMillisats(amount);
|
||||
const minSendable = parseInt(lnurlpResponse.minSendable);
|
||||
const maxSendable = parseInt(lnurlpResponse.maxSendable);
|
||||
|
||||
return amountMsats >= minSendable && amountMsats <= maxSendable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Lightning address to invoice resolution
|
||||
* @param {string} lightningAddress - The Lightning address
|
||||
* @param {number} amount - Amount in satoshis
|
||||
* @param {string} comment - Optional comment
|
||||
* @returns {Object} Invoice and metadata
|
||||
*/
|
||||
async resolveInvoice(lightningAddress, amount, comment = 'Cashu token redemption') {
|
||||
try {
|
||||
console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`);
|
||||
|
||||
// Get LNURLp endpoint
|
||||
const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress);
|
||||
console.log(`LNURLp endpoint: ${lnurlpUrl}`);
|
||||
|
||||
// Fetch LNURLp response
|
||||
const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl);
|
||||
console.log('LNURLp response:', {
|
||||
callback: lnurlpResponse.callback,
|
||||
@@ -235,14 +192,12 @@ class LightningService {
|
||||
maxSendable: lnurlpResponse.maxSendable
|
||||
});
|
||||
|
||||
// Validate amount
|
||||
if (!this.validateAmount(amount, lnurlpResponse)) {
|
||||
const minSats = this.millisatsToSats(lnurlpResponse.minSendable);
|
||||
const maxSats = this.millisatsToSats(lnurlpResponse.maxSendable);
|
||||
throw new Error(`Amount ${amount} sats is outside allowed range: ${minSats}-${maxSats} sats`);
|
||||
}
|
||||
|
||||
// Get invoice
|
||||
const amountMsats = this.satsToMillisats(amount);
|
||||
console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`);
|
||||
console.log(`Using callback URL: ${lnurlpResponse.callback}`);
|
||||
@@ -273,19 +228,15 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Decode Lightning invoice (basic parsing)
|
||||
* @param {string} bolt11 - Lightning invoice
|
||||
* @returns {Object} Basic invoice info
|
||||
*/
|
||||
parseInvoice(bolt11) {
|
||||
parseInvoice(bolt11Invoice) {
|
||||
try {
|
||||
// This is a simplified parser - for production use a proper library like bolt11
|
||||
if (!bolt11.toLowerCase().startsWith('lnbc') && !bolt11.toLowerCase().startsWith('lntb')) {
|
||||
if (!bolt11Invoice.toLowerCase().startsWith('lnbc') && !bolt11Invoice.toLowerCase().startsWith('lntb')) {
|
||||
throw new Error('Invalid Lightning invoice format');
|
||||
}
|
||||
|
||||
return {
|
||||
bolt11,
|
||||
network: bolt11.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet'
|
||||
bolt11: bolt11Invoice,
|
||||
network: bolt11Invoice.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet'
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Invoice parsing failed: ${error.message}`);
|
||||
@@ -294,20 +245,14 @@ class LightningService {
|
||||
|
||||
/**
|
||||
* Verify that a Lightning invoice is valid and for the expected amount
|
||||
* @param {string} bolt11Invoice - The Lightning invoice to verify
|
||||
* @param {string} expectedLightningAddress - The expected Lightning address (for logging)
|
||||
* @param {number} expectedAmount - Expected amount in satoshis (optional)
|
||||
* @returns {boolean} Whether the invoice is valid
|
||||
*/
|
||||
verifyInvoiceDestination(bolt11Invoice, expectedLightningAddress, expectedAmount = null) {
|
||||
try {
|
||||
console.log(`Verifying invoice destination for: ${expectedLightningAddress}`);
|
||||
console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`);
|
||||
|
||||
// Decode the invoice using the bolt11 library
|
||||
const decoded = bolt11.decode(bolt11Invoice);
|
||||
|
||||
// Basic validation checks
|
||||
if (!decoded.complete) {
|
||||
console.error('Invoice verification failed: Invoice is incomplete');
|
||||
return false;
|
||||
@@ -318,13 +263,11 @@ class LightningService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the invoice has expired
|
||||
if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) {
|
||||
console.error('Invoice verification failed: Invoice has expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify amount if provided
|
||||
if (expectedAmount !== null) {
|
||||
const invoiceAmount = decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0);
|
||||
if (invoiceAmount !== expectedAmount) {
|
||||
@@ -349,4 +292,4 @@ class LightningService {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new LightningService();
|
||||
module.exports = new LightningComponent();
|
||||
@@ -1,29 +1,23 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const cashuService = require('./cashu');
|
||||
const lightningService = require('./lightning');
|
||||
const crypto = require('crypto');
|
||||
const cashu = require('./cashu');
|
||||
const lightning = require('./lightning');
|
||||
|
||||
class RedemptionService {
|
||||
class RedemptionComponent {
|
||||
constructor() {
|
||||
// In-memory storage for redemption status
|
||||
// In production, use Redis or a proper database
|
||||
this.redemptions = new Map();
|
||||
this.tokenHashes = new Map(); // Map token hashes to redemption IDs
|
||||
this.tokenHashes = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple hash for a token (for duplicate detection)
|
||||
* @param {string} token - The Cashu token
|
||||
* @returns {string} Hash of the token
|
||||
*/
|
||||
generateTokenHash(token) {
|
||||
const crypto = require('crypto');
|
||||
return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store redemption status
|
||||
* @param {string} redeemId - The redemption ID
|
||||
* @param {Object} status - The redemption status object
|
||||
*/
|
||||
storeRedemption(redeemId, status) {
|
||||
this.redemptions.set(redeemId, {
|
||||
@@ -35,8 +29,6 @@ class RedemptionService {
|
||||
|
||||
/**
|
||||
* Update redemption status
|
||||
* @param {string} redeemId - The redemption ID
|
||||
* @param {Object} updates - Updates to apply
|
||||
*/
|
||||
updateRedemption(redeemId, updates) {
|
||||
const existing = this.redemptions.get(redeemId);
|
||||
@@ -51,8 +43,6 @@ class RedemptionService {
|
||||
|
||||
/**
|
||||
* Get redemption status by ID
|
||||
* @param {string} redeemId - The redemption ID
|
||||
* @returns {Object|null} Redemption status or null if not found
|
||||
*/
|
||||
getRedemption(redeemId) {
|
||||
return this.redemptions.get(redeemId) || null;
|
||||
@@ -60,8 +50,6 @@ class RedemptionService {
|
||||
|
||||
/**
|
||||
* Get redemption ID by token hash
|
||||
* @param {string} tokenHash - The token hash
|
||||
* @returns {string|null} Redemption ID or null if not found
|
||||
*/
|
||||
getRedemptionByTokenHash(tokenHash) {
|
||||
const redeemId = this.tokenHashes.get(tokenHash);
|
||||
@@ -70,8 +58,6 @@ class RedemptionService {
|
||||
|
||||
/**
|
||||
* Check if a token has already been redeemed
|
||||
* @param {string} token - The Cashu token
|
||||
* @returns {Object|null} Existing redemption or null
|
||||
*/
|
||||
checkExistingRedemption(token) {
|
||||
const tokenHash = this.generateTokenHash(token);
|
||||
@@ -80,43 +66,36 @@ class RedemptionService {
|
||||
|
||||
/**
|
||||
* Validate redemption request
|
||||
* @param {string} token - The Cashu token
|
||||
* @param {string} lightningAddress - The Lightning address (optional)
|
||||
* @returns {Object} Validation result
|
||||
*/
|
||||
async validateRedemptionRequest(token, lightningAddress) {
|
||||
const errors = [];
|
||||
|
||||
// Validate token format
|
||||
if (!token || typeof token !== 'string') {
|
||||
errors.push('Token is required and must be a string');
|
||||
}
|
||||
|
||||
// Lightning address is now optional - we'll use default if not provided
|
||||
let addressToUse = null;
|
||||
try {
|
||||
addressToUse = lightningService.getLightningAddressToUse(lightningAddress);
|
||||
addressToUse = lightning.getLightningAddressToUse(lightningAddress);
|
||||
|
||||
if (!lightningService.validateLightningAddress(addressToUse)) {
|
||||
if (!lightning.validateLightningAddress(addressToUse)) {
|
||||
errors.push('Invalid Lightning address format');
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error.message);
|
||||
}
|
||||
|
||||
// Check for existing redemption
|
||||
if (token) {
|
||||
const existing = this.checkExistingRedemption(token);
|
||||
if (existing) {
|
||||
if (existing && existing.status === 'paid') {
|
||||
errors.push('Token has already been redeemed');
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse token
|
||||
let tokenData = null;
|
||||
if (token && errors.length === 0) {
|
||||
try {
|
||||
tokenData = await cashuService.parseToken(token);
|
||||
tokenData = await cashu.parseToken(token);
|
||||
if (tokenData.totalAmount <= 0) {
|
||||
errors.push('Token has no value');
|
||||
}
|
||||
@@ -135,23 +114,18 @@ class RedemptionService {
|
||||
|
||||
/**
|
||||
* Perform the complete redemption process
|
||||
* @param {string} token - The Cashu token
|
||||
* @param {string} lightningAddress - The Lightning address (optional)
|
||||
* @returns {Object} Redemption result
|
||||
*/
|
||||
async performRedemption(token, lightningAddress) {
|
||||
const redeemId = uuidv4();
|
||||
const tokenHash = this.generateTokenHash(token);
|
||||
|
||||
try {
|
||||
// Determine which Lightning address to use
|
||||
const lightningAddressToUse = lightningService.getLightningAddressToUse(lightningAddress);
|
||||
const lightningAddressToUse = lightning.getLightningAddressToUse(lightningAddress);
|
||||
const isUsingDefault = !lightningAddress || !lightningAddress.trim();
|
||||
|
||||
// Store initial status
|
||||
this.storeRedemption(redeemId, {
|
||||
status: 'processing',
|
||||
token: token.substring(0, 50) + '...', // Store partial token for reference
|
||||
token: token.substring(0, 50) + '...',
|
||||
tokenHash,
|
||||
lightningAddress: lightningAddressToUse,
|
||||
usingDefaultAddress: isUsingDefault,
|
||||
@@ -160,12 +134,11 @@ class RedemptionService {
|
||||
error: null
|
||||
});
|
||||
|
||||
// Also map token hash to redemption ID
|
||||
this.tokenHashes.set(tokenHash, redeemId);
|
||||
|
||||
// Step 1: Parse and validate token
|
||||
this.updateRedemption(redeemId, { status: 'parsing_token' });
|
||||
const tokenData = await cashuService.parseToken(token);
|
||||
const tokenData = await cashu.parseToken(token);
|
||||
|
||||
this.updateRedemption(redeemId, {
|
||||
amount: tokenData.totalAmount,
|
||||
@@ -177,20 +150,17 @@ class RedemptionService {
|
||||
// Check if token is spendable
|
||||
this.updateRedemption(redeemId, { status: 'checking_spendability' });
|
||||
try {
|
||||
const spendabilityCheck = await cashuService.checkTokenSpendable(token);
|
||||
const spendabilityCheck = await cashu.checkTokenSpendable(token);
|
||||
if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) {
|
||||
throw new Error('Token proofs are not spendable - they have already been used or are invalid');
|
||||
}
|
||||
} catch (spendError) {
|
||||
// Check if the error indicates tokens are already spent (422 status)
|
||||
if (spendError.message.includes('not spendable') ||
|
||||
spendError.message.includes('already been used') ||
|
||||
spendError.message.includes('invalid proofs') ||
|
||||
spendError.message.includes('422')) {
|
||||
// This is likely an already-spent token - fail the redemption with clear message
|
||||
throw new Error('This token has already been spent and cannot be redeemed again');
|
||||
}
|
||||
// For other errors, log but continue - some mints might not support this check
|
||||
console.warn('Spendability check failed:', spendError.message);
|
||||
console.log('Continuing with redemption despite spendability check failure...');
|
||||
}
|
||||
@@ -198,22 +168,20 @@ class RedemptionService {
|
||||
// Step 2: Get melt quote first to determine exact fees
|
||||
this.updateRedemption(redeemId, { status: 'getting_melt_quote' });
|
||||
|
||||
// Create a temporary invoice to get the melt quote (we'll create the real one after)
|
||||
console.log(`Getting melt quote for ${tokenData.totalAmount} sats to determine exact fees`);
|
||||
const tempInvoiceData = await lightningService.resolveInvoice(
|
||||
const tempInvoiceData = await lightning.resolveInvoice(
|
||||
lightningAddressToUse,
|
||||
tokenData.totalAmount, // Use full amount initially
|
||||
tokenData.totalAmount,
|
||||
'Cashu redemption'
|
||||
);
|
||||
|
||||
// Get melt quote to determine exact fee
|
||||
const meltQuote = await cashuService.getMeltQuote(token, tempInvoiceData.bolt11);
|
||||
const meltQuote = await cashu.getMeltQuote(token, tempInvoiceData.bolt11);
|
||||
const exactFee = meltQuote.fee_reserve;
|
||||
const finalInvoiceAmount = tokenData.totalAmount - exactFee;
|
||||
|
||||
console.log(`Melt quote: amount=${meltQuote.amount}, fee=${exactFee}, net to user=${finalInvoiceAmount}`);
|
||||
|
||||
// Step 3: Create final invoice for the correct amount (total - exact fee)
|
||||
// Step 3: Create final invoice for the correct amount
|
||||
this.updateRedemption(redeemId, { status: 'resolving_invoice' });
|
||||
|
||||
if (finalInvoiceAmount <= 0) {
|
||||
@@ -222,9 +190,9 @@ class RedemptionService {
|
||||
|
||||
console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`);
|
||||
|
||||
const invoiceData = await lightningService.resolveInvoice(
|
||||
const invoiceData = await lightning.resolveInvoice(
|
||||
lightningAddressToUse,
|
||||
finalInvoiceAmount, // Use amount minus exact fee
|
||||
finalInvoiceAmount,
|
||||
'Cashu redemption'
|
||||
);
|
||||
|
||||
@@ -232,20 +200,18 @@ class RedemptionService {
|
||||
bolt11: invoiceData.bolt11.substring(0, 50) + '...',
|
||||
domain: invoiceData.domain,
|
||||
invoiceAmount: finalInvoiceAmount,
|
||||
exactFee: exactFee
|
||||
exactFee
|
||||
});
|
||||
|
||||
// Verify the invoice is valid and for the correct amount
|
||||
const invoiceVerified = lightningService.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
|
||||
const invoiceVerified = lightning.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
|
||||
if (!invoiceVerified) {
|
||||
throw new Error('Invoice verification failed - invalid invoice or amount mismatch');
|
||||
}
|
||||
|
||||
// Step 4: Melt the token to pay the invoice
|
||||
this.updateRedemption(redeemId, { status: 'melting_token' });
|
||||
const meltResult = await cashuService.meltToken(token, invoiceData.bolt11);
|
||||
const meltResult = await cashu.meltToken(token, invoiceData.bolt11);
|
||||
|
||||
// Log melt result for debugging
|
||||
console.log(`Redemption ${redeemId}: Melt result:`, {
|
||||
paid: meltResult.paid,
|
||||
hasPreimage: !!meltResult.preimage,
|
||||
@@ -253,11 +219,8 @@ class RedemptionService {
|
||||
fee: meltResult.fee
|
||||
});
|
||||
|
||||
// Determine if payment was successful
|
||||
// Consider it successful if we have a preimage, even if 'paid' flag is unclear
|
||||
const paymentSuccessful = meltResult.paid || !!meltResult.preimage;
|
||||
|
||||
// Step 4: Update final status
|
||||
this.updateRedemption(redeemId, {
|
||||
status: paymentSuccessful ? 'paid' : 'failed',
|
||||
paid: paymentSuccessful,
|
||||
@@ -267,7 +230,7 @@ class RedemptionService {
|
||||
netAmount: meltResult.netAmount,
|
||||
change: meltResult.change,
|
||||
paidAt: paymentSuccessful ? new Date().toISOString() : null,
|
||||
rawMeltResponse: meltResult.rawMeltResponse // Store for debugging
|
||||
rawMeltResponse: meltResult.rawMeltResponse
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -275,12 +238,12 @@ class RedemptionService {
|
||||
redeemId,
|
||||
paid: paymentSuccessful,
|
||||
amount: tokenData.totalAmount,
|
||||
invoiceAmount: finalInvoiceAmount, // Amount actually sent in the invoice
|
||||
invoiceAmount: finalInvoiceAmount,
|
||||
to: lightningAddressToUse,
|
||||
usingDefaultAddress: isUsingDefault,
|
||||
fee: exactFee, // Use the exact fee from the melt quote
|
||||
fee: exactFee,
|
||||
actualFee: meltResult.actualFee,
|
||||
netAmount: finalInvoiceAmount, // This is the net amount the user receives
|
||||
netAmount: finalInvoiceAmount,
|
||||
preimage: meltResult.preimage,
|
||||
change: meltResult.change,
|
||||
mint: tokenData.mint,
|
||||
@@ -288,13 +251,15 @@ class RedemptionService {
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Update redemption with error
|
||||
this.updateRedemption(redeemId, {
|
||||
status: 'failed',
|
||||
paid: false,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
// Remove token hash so the token can be retried after a failed redemption
|
||||
this.tokenHashes.delete(tokenHash);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
redeemId,
|
||||
@@ -305,15 +270,11 @@ class RedemptionService {
|
||||
|
||||
/**
|
||||
* Get redemption status for API response
|
||||
* @param {string} redeemId - The redemption ID
|
||||
* @returns {Object|null} Status response or null if not found
|
||||
*/
|
||||
getRedemptionStatus(redeemId) {
|
||||
const redemption = this.getRedemption(redeemId);
|
||||
|
||||
if (!redemption) {
|
||||
return null;
|
||||
}
|
||||
if (!redemption) return null;
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
@@ -327,32 +288,17 @@ class RedemptionService {
|
||||
}
|
||||
};
|
||||
|
||||
if (redemption.paidAt) {
|
||||
response.details.paidAt = redemption.paidAt;
|
||||
}
|
||||
|
||||
if (redemption.fee) {
|
||||
response.details.fee = redemption.fee;
|
||||
}
|
||||
|
||||
if (redemption.error) {
|
||||
response.details.error = redemption.error;
|
||||
}
|
||||
|
||||
if (redemption.mint) {
|
||||
response.details.mint = redemption.mint;
|
||||
}
|
||||
|
||||
if (redemption.domain) {
|
||||
response.details.domain = redemption.domain;
|
||||
}
|
||||
if (redemption.paidAt) response.details.paidAt = redemption.paidAt;
|
||||
if (redemption.fee) response.details.fee = redemption.fee;
|
||||
if (redemption.error) response.details.error = redemption.error;
|
||||
if (redemption.mint) response.details.mint = redemption.mint;
|
||||
if (redemption.domain) response.details.domain = redemption.domain;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all redemptions (for admin/debugging)
|
||||
* @returns {Array} All redemptions
|
||||
*/
|
||||
getAllRedemptions() {
|
||||
return Array.from(this.redemptions.entries()).map(([id, data]) => ({
|
||||
@@ -363,16 +309,14 @@ class RedemptionService {
|
||||
|
||||
/**
|
||||
* Clean up old redemptions (should be called periodically)
|
||||
* @param {number} maxAgeMs - Maximum age in milliseconds
|
||||
*/
|
||||
cleanupOldRedemptions(maxAgeMs = 24 * 60 * 60 * 1000) { // 24 hours default
|
||||
cleanupOldRedemptions(maxAgeMs = 24 * 60 * 60 * 1000) {
|
||||
const cutoff = new Date(Date.now() - maxAgeMs);
|
||||
|
||||
for (const [redeemId, redemption] of this.redemptions.entries()) {
|
||||
const createdAt = new Date(redemption.createdAt);
|
||||
if (createdAt < cutoff) {
|
||||
this.redemptions.delete(redeemId);
|
||||
// Also clean up token hash mapping
|
||||
if (redemption.tokenHash) {
|
||||
this.tokenHashes.delete(redemption.tokenHash);
|
||||
}
|
||||
@@ -381,4 +325,4 @@ class RedemptionService {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RedemptionService();
|
||||
module.exports = new RedemptionComponent();
|
||||
186
package-lock.json
generated
186
package-lock.json
generated
@@ -1,21 +1,20 @@
|
||||
{
|
||||
"name": "cashu-redeem-api",
|
||||
"version": "1.1.0",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cashu-redeem-api",
|
||||
"version": "1.1.0",
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "^1.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"@cashu/cashu-ts": "^3.4.1",
|
||||
"axios": "^1.8.1",
|
||||
"bolt11": "^1.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.0.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^10.0.0"
|
||||
@@ -78,30 +77,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-1.2.1.tgz",
|
||||
"integrity": "sha512-B0+e02S8DQA8KBt2FgKHgGYvtHQokXJ3sZcTyAdHqvb0T0jfo1zF7nHn19eU9iYcfk8VSWf5xNBTocpTfj1aNg==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.4.1.tgz",
|
||||
"integrity": "sha512-d8bgYbYIKCsT7Hs8BsoENrRehQmuA8qYscQuAGuCk4FsT4a+OGPYAJuxgCApwBhKCokclBjZzVnkS19/CygQ0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cashu/crypto": "^0.2.7",
|
||||
"@noble/curves": "^1.3.0",
|
||||
"@noble/hashes": "^1.3.3",
|
||||
"@scure/bip32": "^1.3.3",
|
||||
"@scure/bip39": "^1.2.2",
|
||||
"buffer": "^6.0.3"
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@scure/bip32": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/crypto": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.2.7.tgz",
|
||||
"integrity": "sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==",
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.3.0",
|
||||
"@noble/hashes": "^1.3.3",
|
||||
"@scure/bip32": "^1.3.3",
|
||||
"@scure/bip39": "^1.2.2",
|
||||
"buffer": "^6.0.3"
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
@@ -368,15 +367,27 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
|
||||
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -402,36 +413,35 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
|
||||
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
|
||||
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.9.0",
|
||||
"@noble/hashes": "~1.8.0",
|
||||
"@scure/base": "~1.2.5"
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
|
||||
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "~1.8.0",
|
||||
"@scure/base": "~1.2.5"
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -603,26 +613,6 @@
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz",
|
||||
"integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw=="
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bech32": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz",
|
||||
@@ -759,30 +749,6 @@
|
||||
"bs58": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1469,23 +1435,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.0.tgz",
|
||||
"integrity": "sha512-FXEAp2ccTeN1ZSO+sPHRHWB0/CrTP5asFBjUaNeD9A0v3iPmgFbLu24vqPjiM9utszI58VGlMokjXQ0W9Dbmjw==",
|
||||
"dependencies": {
|
||||
"ip": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -1902,26 +1851,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -1983,11 +1912,6 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
|
||||
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ=="
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cashu-redeem-api",
|
||||
"version": "1.1.0",
|
||||
"version": "2.0.0",
|
||||
"description": "A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -33,8 +33,8 @@
|
||||
"url": "https://github.com/Michilis"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "^1.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"@cashu/cashu-ts": "^3.4.1",
|
||||
"axios": "^1.8.1",
|
||||
"bolt11": "^1.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
100
routes/cashu.js
Normal file
100
routes/cashu.js
Normal 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
44
routes/health.js
Normal 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
86
routes/lightning.js
Normal 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
146
routes/redemption.js
Normal 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
444
server.js
@@ -3,9 +3,13 @@ const express = require('express');
|
||||
const cors = require('cors');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerSpecs = require('./swagger.config');
|
||||
const cashuService = require('./services/cashu');
|
||||
const lightningService = require('./services/lightning');
|
||||
const redemptionService = require('./services/redemption');
|
||||
const redemption = require('./components/redemption');
|
||||
|
||||
// Route imports
|
||||
const cashuRoutes = require('./routes/cashu');
|
||||
const redemptionRoutes = require('./routes/redemption');
|
||||
const lightningRoutes = require('./routes/lightning');
|
||||
const healthRoutes = require('./routes/health');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -33,8 +37,8 @@ app.use(cors({
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Origin, X-Requested-With');
|
||||
res.header('Access-Control-Allow-Methods', 'GET', 'POST', 'OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type', 'Authorization', 'Accept', 'Origin', 'X-Requested-With');
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
@@ -52,6 +56,27 @@ app.get('/api/cors-test', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /openapi.json:
|
||||
* get:
|
||||
* summary: OpenAPI specification
|
||||
* description: Returns the full OpenAPI 3.0 specification for this API as JSON.
|
||||
* tags: [Status & Monitoring]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OpenAPI specification
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* description: OpenAPI 3.0 specification document
|
||||
*/
|
||||
app.get('/openapi.json', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json(swaggerSpecs);
|
||||
});
|
||||
|
||||
// Swagger Documentation
|
||||
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
@@ -65,20 +90,18 @@ app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
|
||||
|
||||
// Basic rate limiting (simple in-memory implementation)
|
||||
const rateLimitMap = new Map();
|
||||
const RATE_LIMIT = parseInt(process.env.RATE_LIMIT) || 100; // requests per minute
|
||||
const RATE_LIMIT = parseInt(process.env.RATE_LIMIT) || 100;
|
||||
|
||||
function rateLimit(req, res, next) {
|
||||
const clientId = req.ip || req.connection.remoteAddress;
|
||||
const now = Date.now();
|
||||
const windowStart = now - 60000; // 1 minute window
|
||||
const windowStart = now - 60000;
|
||||
|
||||
if (!rateLimitMap.has(clientId)) {
|
||||
rateLimitMap.set(clientId, []);
|
||||
}
|
||||
|
||||
const requests = rateLimitMap.get(clientId);
|
||||
|
||||
// Remove old requests outside the window
|
||||
const validRequests = requests.filter(time => time > windowStart);
|
||||
rateLimitMap.set(clientId, validRequests);
|
||||
|
||||
@@ -103,21 +126,14 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
// API Routes
|
||||
|
||||
// Root endpoint
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'Cashu Redeem API',
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol',
|
||||
documentation: '/docs',
|
||||
openapi: '/openapi.json',
|
||||
endpoints: {
|
||||
decode: 'POST /api/decode',
|
||||
redeem: 'POST /api/redeem',
|
||||
@@ -136,384 +152,11 @@ app.get('/', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/decode:
|
||||
* post:
|
||||
* summary: Decode a Cashu token
|
||||
* description: Decode a Cashu token and return its content. Supports both v1 and v3 token formats.
|
||||
* tags: [Token Operations]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/DecodeRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Token decoded successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/DecodeResponse'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequest'
|
||||
* 429:
|
||||
* $ref: '#/components/responses/TooManyRequests'
|
||||
* 500:
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
app.post('/api/decode', asyncHandler(async (req, res) => {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Token is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate token format first
|
||||
if (!cashuService.isValidTokenFormat(token)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid token format. Must be a valid Cashu token'
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = await cashuService.parseToken(token);
|
||||
const mintUrl = await cashuService.getTokenMintUrl(token);
|
||||
|
||||
// Check if token is spent
|
||||
let spent = false;
|
||||
try {
|
||||
const spendabilityCheck = await cashuService.checkTokenSpendable(token);
|
||||
// Token is spent if no proofs are spendable
|
||||
spent = !spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0;
|
||||
} catch (error) {
|
||||
// If spendability check fails, analyze the error to determine if token is spent
|
||||
console.warn('Spendability check failed:', error.message);
|
||||
|
||||
// Check if error indicates proofs are already spent
|
||||
const errorString = error.message || error.toString();
|
||||
|
||||
// Check for specific error indicators
|
||||
if (errorString.includes('TOKEN_SPENT:')) {
|
||||
// CashuService has determined the token is spent based on clear indicators
|
||||
console.log('Token determined to be spent by CashuService');
|
||||
spent = true;
|
||||
} else if (errorString.includes('Token validation failed at mint:')) {
|
||||
// This is a 422 error but not clearly indicating the token is spent
|
||||
// It might be invalid/malformed but not necessarily spent
|
||||
console.log('Token validation failed at mint - assuming token is still valid (might be invalid format)');
|
||||
spent = false;
|
||||
} else if (errorString.includes('not supported') ||
|
||||
errorString.includes('endpoint not found') ||
|
||||
errorString.includes('may still be valid') ||
|
||||
errorString.includes('does not support spendability checking')) {
|
||||
// Mint doesn't support spendability checking - assume token is still valid
|
||||
console.log('Mint does not support spendability checking - assuming token is valid');
|
||||
spent = false;
|
||||
} else {
|
||||
// For other errors (network, server issues), assume token is still valid
|
||||
// This is safer than assuming it's spent
|
||||
console.log('Unknown error - assuming token is valid');
|
||||
spent = false;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
decoded: {
|
||||
mint: decoded.mint,
|
||||
totalAmount: decoded.totalAmount,
|
||||
numProofs: decoded.numProofs,
|
||||
denominations: decoded.denominations,
|
||||
format: decoded.format,
|
||||
spent: spent
|
||||
},
|
||||
mint_url: mintUrl
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/redeem:
|
||||
* post:
|
||||
* summary: Redeem a Cashu token to Lightning address
|
||||
* description: |
|
||||
* Redeem a Cashu token to a Lightning address (optional - uses default if not provided).
|
||||
*
|
||||
* The redemption process includes:
|
||||
* 1. Token validation and parsing
|
||||
* 2. Getting exact melt quote from mint to determine precise fees
|
||||
* 3. Invoice creation for net amount (token amount - exact fees)
|
||||
* 4. Spendability checking at the mint
|
||||
* 5. Token melting and Lightning payment
|
||||
*
|
||||
* **Important**: The system gets the exact fee from the mint before creating the invoice.
|
||||
* The `invoiceAmount` field shows the actual amount sent to the Lightning address.
|
||||
* No sats are lost to fee estimation errors.
|
||||
* tags: [Token Operations]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/RedeemRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Token redeemed successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/RedeemResponse'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequest'
|
||||
* 409:
|
||||
* description: Token already spent
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: string
|
||||
* example: "This token has already been spent and cannot be redeemed again"
|
||||
* errorType:
|
||||
* type: string
|
||||
* example: "token_already_spent"
|
||||
* 422:
|
||||
* description: Insufficient funds or unprocessable token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: string
|
||||
* example: "Token amount is insufficient to cover the minimum fee"
|
||||
* errorType:
|
||||
* type: string
|
||||
* example: "insufficient_funds"
|
||||
* 429:
|
||||
* $ref: '#/components/responses/TooManyRequests'
|
||||
* 500:
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
app.post('/api/redeem', asyncHandler(async (req, res) => {
|
||||
const { token, lightningAddress } = req.body;
|
||||
|
||||
// Validate request (lightningAddress is now optional)
|
||||
const validation = await redemptionService.validateRedemptionRequest(token, lightningAddress);
|
||||
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: validation.errors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
// Perform redemption
|
||||
try {
|
||||
const result = await redemptionService.performRedemption(token, lightningAddress);
|
||||
|
||||
if (result.success) {
|
||||
const response = {
|
||||
success: true,
|
||||
paid: result.paid,
|
||||
amount: result.amount,
|
||||
invoiceAmount: result.invoiceAmount,
|
||||
to: result.to,
|
||||
fee: result.fee,
|
||||
actualFee: result.actualFee,
|
||||
netAmount: result.netAmount,
|
||||
mint_url: result.mint,
|
||||
format: result.format
|
||||
};
|
||||
|
||||
// Include info about whether default address was used
|
||||
if (result.usingDefaultAddress) {
|
||||
response.usingDefaultAddress = true;
|
||||
response.message = `Redeemed to default Lightning address: ${result.to}`;
|
||||
}
|
||||
|
||||
// Include preimage if available
|
||||
if (result.preimage) {
|
||||
response.preimage = result.preimage;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} else {
|
||||
// Determine appropriate status code based on error type
|
||||
let statusCode = 400;
|
||||
|
||||
if (result.error && (
|
||||
result.error.includes('cannot be redeemed') ||
|
||||
result.error.includes('already been used') ||
|
||||
result.error.includes('not spendable') ||
|
||||
result.error.includes('already spent') ||
|
||||
result.error.includes('invalid proofs')
|
||||
)) {
|
||||
// Use 409 Conflict for already-spent tokens to distinguish from generic bad requests
|
||||
statusCode = 409;
|
||||
} else if (result.error && result.error.includes('insufficient')) {
|
||||
// Use 422 for insufficient funds
|
||||
statusCode = 422;
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
errorType: statusCode === 409 ? 'token_already_spent' :
|
||||
statusCode === 422 ? 'insufficient_funds' : 'validation_error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in redemption:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error during redemption'
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/health:
|
||||
* get:
|
||||
* summary: Health check endpoint
|
||||
* description: |
|
||||
* Check the health and status of the API server.
|
||||
* Returns server information including uptime, memory usage, and version.
|
||||
* tags: [Status & Monitoring]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is healthy
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/HealthResponse'
|
||||
* 500:
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
app.get('/api/health', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
version: packageJson.version
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Health check failed'
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/validate-address:
|
||||
* post:
|
||||
* summary: Validate a Lightning address
|
||||
* description: |
|
||||
* Validate a Lightning address without performing a redemption.
|
||||
* Checks format validity and tests LNURLp resolution.
|
||||
*
|
||||
* Returns information about the Lightning address capabilities
|
||||
* including min/max sendable amounts and comment allowance.
|
||||
* tags: [Validation]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ValidateAddressRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Validation completed (check 'valid' field for result)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ValidateAddressResponse'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequest'
|
||||
* 429:
|
||||
* $ref: '#/components/responses/TooManyRequests'
|
||||
*/
|
||||
app.post('/api/validate-address', asyncHandler(async (req, res) => {
|
||||
const { lightningAddress } = req.body;
|
||||
|
||||
if (!lightningAddress) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Lightning address is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = lightningService.validateLightningAddress(lightningAddress);
|
||||
|
||||
if (!isValid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: 'Invalid Lightning address format'
|
||||
});
|
||||
}
|
||||
|
||||
// Test resolution
|
||||
const { domain } = lightningService.parseLightningAddress(lightningAddress);
|
||||
const lnurlpUrl = lightningService.getLNURLpEndpoint(lightningAddress);
|
||||
|
||||
try {
|
||||
const lnurlpResponse = await lightningService.fetchLNURLpResponse(lnurlpUrl);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
valid: true,
|
||||
domain,
|
||||
minSendable: lightningService.millisatsToSats(lnurlpResponse.minSendable),
|
||||
maxSendable: lightningService.millisatsToSats(lnurlpResponse.maxSendable),
|
||||
commentAllowed: lnurlpResponse.commentAllowed || 0
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: `Lightning address resolution failed: ${error.message}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}));
|
||||
// Mount API routes
|
||||
app.use('/api', cashuRoutes);
|
||||
app.use('/api', redemptionRoutes);
|
||||
app.use('/api', lightningRoutes);
|
||||
app.use('/api', healthRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
@@ -543,12 +186,12 @@ app.use((error, req, res, next) => {
|
||||
// Cleanup old redemptions periodically (every hour)
|
||||
setInterval(() => {
|
||||
try {
|
||||
redemptionService.cleanupOldRedemptions();
|
||||
redemption.cleanupOldRedemptions();
|
||||
console.log('Cleaned up old redemptions');
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up redemptions:', error);
|
||||
}
|
||||
}, 60 * 60 * 1000); // 1 hour
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
@@ -563,8 +206,9 @@ process.on('SIGINT', () => {
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Cashu Redeem API running on port ${PORT}`);
|
||||
console.log(`🚀 Cashu Redeem API v2.0.0 running on port ${PORT}`);
|
||||
console.log(`📖 API Documentation: http://localhost:${PORT}/docs`);
|
||||
console.log(`📋 OpenAPI spec: http://localhost:${PORT}/openapi.json`);
|
||||
console.log(`📍 Health check: http://localhost:${PORT}/api/health`);
|
||||
console.log(`🔒 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -16,7 +16,7 @@ const options = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Cashu Redeem API',
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol.',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
@@ -337,10 +337,14 @@ const options = {
|
||||
{
|
||||
name: 'Validation',
|
||||
description: 'Validation utilities for tokens and Lightning addresses'
|
||||
},
|
||||
{
|
||||
name: 'Status & Monitoring',
|
||||
description: 'Health check and API status endpoints'
|
||||
}
|
||||
]
|
||||
},
|
||||
apis: ['./server.js'], // paths to files containing OpenAPI definitions
|
||||
apis: ['./server.js', './routes/*.js'], // paths to files containing OpenAPI definitions
|
||||
};
|
||||
|
||||
const specs = swaggerJsdoc(options);
|
||||
|
||||
Reference in New Issue
Block a user