- Fix critical fee calculation bug: Now gets exact melt quote before creating invoice - Improve spent token detection: Only marks as spent with clear indicators - Add spent field to decode endpoint response (always boolean) - Add informative root endpoint with API documentation - Update documentation examples to use cashuB format - Install bolt11 library for proper Lightning invoice verification - Enhanced error handling and logging throughout This fixes the issue where users lost sats due to fee estimation errors and ensures accurate token spendability detection.
352 lines
11 KiB
JavaScript
352 lines
11 KiB
JavaScript
const axios = require('axios');
|
|
const bolt11 = require('bolt11');
|
|
|
|
class LightningService {
|
|
constructor() {
|
|
this.allowedDomains = process.env.ALLOW_REDEEM_DOMAINS
|
|
? process.env.ALLOW_REDEEM_DOMAINS.split(',').map(d => d.trim())
|
|
: [];
|
|
this.defaultLightningAddress = process.env.DEFAULT_LIGHTNING_ADDRESS;
|
|
}
|
|
|
|
/**
|
|
* Get the default Lightning address from environment
|
|
* @returns {string|null} Default Lightning address or null if not set
|
|
*/
|
|
getDefaultLightningAddress() {
|
|
return this.defaultLightningAddress || null;
|
|
}
|
|
|
|
/**
|
|
* 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()) {
|
|
return providedAddress.trim();
|
|
}
|
|
|
|
const defaultAddress = this.getDefaultLightningAddress();
|
|
if (!defaultAddress) {
|
|
throw new Error('No Lightning address provided and no default Lightning address configured');
|
|
}
|
|
|
|
return defaultAddress;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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)) {
|
|
throw new Error('Invalid Lightning address format');
|
|
}
|
|
|
|
const [username, domain] = lightningAddress.split('@');
|
|
|
|
if (!this.isDomainAllowed(domain)) {
|
|
throw new Error(`Domain ${domain} is not allowed for redemption`);
|
|
}
|
|
|
|
return { username, domain };
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
return `https://${domain}/.well-known/lnurlp/${username}`;
|
|
}
|
|
|
|
/**
|
|
* 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'
|
|
}
|
|
});
|
|
|
|
if (response.status !== 200) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = response.data;
|
|
|
|
if (data.status === 'ERROR') {
|
|
throw new Error(data.reason || 'LNURLp endpoint returned error');
|
|
}
|
|
|
|
if (!data.callback || !data.minSendable || !data.maxSendable) {
|
|
throw new Error('Invalid LNURLp response - missing required fields');
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
|
|
throw new Error('Unable to connect to Lightning address provider');
|
|
}
|
|
throw new Error(`LNURLp fetch failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
const url = new URL(callbackUrl);
|
|
url.searchParams.set('amount', amount.toString());
|
|
|
|
if (comment && comment.length > 0) {
|
|
url.searchParams.set('comment', comment.substring(0, 144)); // LN comment limit
|
|
}
|
|
|
|
const response = await axios.get(url.toString(), {
|
|
timeout: 10000,
|
|
headers: {
|
|
'User-Agent': 'Cashu-Redeem-API/1.0.0'
|
|
}
|
|
});
|
|
|
|
if (response.status !== 200) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = response.data;
|
|
|
|
if (data.status === 'ERROR') {
|
|
throw new Error(data.reason || 'Invoice generation failed');
|
|
}
|
|
|
|
if (!data.pr) {
|
|
throw new Error('No invoice returned from callback');
|
|
}
|
|
|
|
return {
|
|
bolt11: data.pr,
|
|
successAction: data.successAction,
|
|
verify: data.verify
|
|
};
|
|
} catch (error) {
|
|
throw new Error(`Invoice generation failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert satoshis to millisatoshis
|
|
* @param {number} sats - Amount in satoshis
|
|
* @returns {number} Amount in millisatoshis
|
|
*/
|
|
satsToMillisats(sats) {
|
|
return sats * 1000;
|
|
}
|
|
|
|
/**
|
|
* Convert millisatoshis to satoshis
|
|
* @param {number} msats - Amount in millisatoshis
|
|
* @returns {number} Amount in satoshis
|
|
*/
|
|
millisatsToSats(msats) {
|
|
return Math.floor(msats / 1000);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
minSendable: lnurlpResponse.minSendable,
|
|
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}`);
|
|
const invoiceResponse = await this.getInvoice(lnurlpResponse.callback, amountMsats, comment);
|
|
|
|
console.log('Invoice created successfully:', {
|
|
bolt11: invoiceResponse.bolt11.substring(0, 50) + '...',
|
|
lightningAddress,
|
|
amount,
|
|
amountMsats,
|
|
callback: lnurlpResponse.callback
|
|
});
|
|
|
|
return {
|
|
bolt11: invoiceResponse.bolt11,
|
|
amount,
|
|
amountMsats,
|
|
lightningAddress,
|
|
domain: this.parseLightningAddress(lightningAddress).domain,
|
|
successAction: invoiceResponse.successAction,
|
|
lnurlpResponse
|
|
};
|
|
} catch (error) {
|
|
console.error('Lightning address resolution failed:', error.message);
|
|
throw new Error(`Lightning address resolution failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode Lightning invoice (basic parsing)
|
|
* @param {string} bolt11 - Lightning invoice
|
|
* @returns {Object} Basic invoice info
|
|
*/
|
|
parseInvoice(bolt11) {
|
|
try {
|
|
// This is a simplified parser - for production use a proper library like bolt11
|
|
if (!bolt11.toLowerCase().startsWith('lnbc') && !bolt11.toLowerCase().startsWith('lntb')) {
|
|
throw new Error('Invalid Lightning invoice format');
|
|
}
|
|
|
|
return {
|
|
bolt11,
|
|
network: bolt11.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet'
|
|
};
|
|
} catch (error) {
|
|
throw new Error(`Invoice parsing failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
if (!decoded.paymentRequest) {
|
|
console.error('Invoice verification failed: No payment request found');
|
|
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) {
|
|
console.error(`Invoice verification failed: Amount mismatch. Expected: ${expectedAmount} sats, Got: ${invoiceAmount} sats`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
console.log('Invoice verification: All checks passed');
|
|
console.log('Invoice details:', {
|
|
amount: decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0),
|
|
timestamp: decoded.timestamp,
|
|
expiry: decoded.expiry,
|
|
description: decoded.tags?.find(tag => tag.tagName === 'description')?.data || 'No description'
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Invoice verification failed:', error.message);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = new LightningService();
|