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();
|
||||
295
components/lightning.js
Normal file
295
components/lightning.js
Normal file
@@ -0,0 +1,295 @@
|
||||
const axios = require('axios');
|
||||
const bolt11 = require('bolt11');
|
||||
|
||||
class LightningComponent {
|
||||
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
|
||||
*/
|
||||
getDefaultLightningAddress() {
|
||||
return this.defaultLightningAddress || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Lightning address to use - provided address or default
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
isDomainAllowed(domain) {
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
getLNURLpEndpoint(lightningAddress) {
|
||||
const { username, domain } = this.parseLightningAddress(lightningAddress);
|
||||
return `https://${domain}/.well-known/lnurlp/${username}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch LNURLp response from endpoint
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
satsToMillisats(sats) {
|
||||
return sats * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert millisatoshis to satoshis
|
||||
*/
|
||||
millisatsToSats(msats) {
|
||||
return Math.floor(msats / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate amount against LNURLp constraints
|
||||
*/
|
||||
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
|
||||
*/
|
||||
async resolveInvoice(lightningAddress, amount, comment = 'Cashu token redemption') {
|
||||
try {
|
||||
console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`);
|
||||
|
||||
const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress);
|
||||
console.log(`LNURLp endpoint: ${lnurlpUrl}`);
|
||||
|
||||
const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl);
|
||||
console.log('LNURLp response:', {
|
||||
callback: lnurlpResponse.callback,
|
||||
minSendable: lnurlpResponse.minSendable,
|
||||
maxSendable: lnurlpResponse.maxSendable
|
||||
});
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
parseInvoice(bolt11Invoice) {
|
||||
try {
|
||||
if (!bolt11Invoice.toLowerCase().startsWith('lnbc') && !bolt11Invoice.toLowerCase().startsWith('lntb')) {
|
||||
throw new Error('Invalid Lightning invoice format');
|
||||
}
|
||||
return {
|
||||
bolt11: bolt11Invoice,
|
||||
network: bolt11Invoice.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
|
||||
*/
|
||||
verifyInvoiceDestination(bolt11Invoice, expectedLightningAddress, expectedAmount = null) {
|
||||
try {
|
||||
console.log(`Verifying invoice destination for: ${expectedLightningAddress}`);
|
||||
console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`);
|
||||
|
||||
const decoded = bolt11.decode(bolt11Invoice);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) {
|
||||
console.error('Invoice verification failed: Invoice has expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
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 LightningComponent();
|
||||
328
components/redemption.js
Normal file
328
components/redemption.js
Normal file
@@ -0,0 +1,328 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const crypto = require('crypto');
|
||||
const cashu = require('./cashu');
|
||||
const lightning = require('./lightning');
|
||||
|
||||
class RedemptionComponent {
|
||||
constructor() {
|
||||
this.redemptions = new Map();
|
||||
this.tokenHashes = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple hash for a token (for duplicate detection)
|
||||
*/
|
||||
generateTokenHash(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store redemption status
|
||||
*/
|
||||
storeRedemption(redeemId, status) {
|
||||
this.redemptions.set(redeemId, {
|
||||
...status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update redemption status
|
||||
*/
|
||||
updateRedemption(redeemId, updates) {
|
||||
const existing = this.redemptions.get(redeemId);
|
||||
if (existing) {
|
||||
this.redemptions.set(redeemId, {
|
||||
...existing,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redemption status by ID
|
||||
*/
|
||||
getRedemption(redeemId) {
|
||||
return this.redemptions.get(redeemId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redemption ID by token hash
|
||||
*/
|
||||
getRedemptionByTokenHash(tokenHash) {
|
||||
const redeemId = this.tokenHashes.get(tokenHash);
|
||||
return redeemId ? this.getRedemption(redeemId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token has already been redeemed
|
||||
*/
|
||||
checkExistingRedemption(token) {
|
||||
const tokenHash = this.generateTokenHash(token);
|
||||
return this.getRedemptionByTokenHash(tokenHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate redemption request
|
||||
*/
|
||||
async validateRedemptionRequest(token, lightningAddress) {
|
||||
const errors = [];
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
errors.push('Token is required and must be a string');
|
||||
}
|
||||
|
||||
let addressToUse = null;
|
||||
try {
|
||||
addressToUse = lightning.getLightningAddressToUse(lightningAddress);
|
||||
|
||||
if (!lightning.validateLightningAddress(addressToUse)) {
|
||||
errors.push('Invalid Lightning address format');
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error.message);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const existing = this.checkExistingRedemption(token);
|
||||
if (existing && existing.status === 'paid') {
|
||||
errors.push('Token has already been redeemed');
|
||||
}
|
||||
}
|
||||
|
||||
let tokenData = null;
|
||||
if (token && errors.length === 0) {
|
||||
try {
|
||||
tokenData = await cashu.parseToken(token);
|
||||
if (tokenData.totalAmount <= 0) {
|
||||
errors.push('Token has no value');
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Invalid token: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
tokenData,
|
||||
lightningAddressToUse: addressToUse
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the complete redemption process
|
||||
*/
|
||||
async performRedemption(token, lightningAddress) {
|
||||
const redeemId = uuidv4();
|
||||
const tokenHash = this.generateTokenHash(token);
|
||||
|
||||
try {
|
||||
const lightningAddressToUse = lightning.getLightningAddressToUse(lightningAddress);
|
||||
const isUsingDefault = !lightningAddress || !lightningAddress.trim();
|
||||
|
||||
this.storeRedemption(redeemId, {
|
||||
status: 'processing',
|
||||
token: token.substring(0, 50) + '...',
|
||||
tokenHash,
|
||||
lightningAddress: lightningAddressToUse,
|
||||
usingDefaultAddress: isUsingDefault,
|
||||
amount: null,
|
||||
paid: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
this.tokenHashes.set(tokenHash, redeemId);
|
||||
|
||||
// Step 1: Parse and validate token
|
||||
this.updateRedemption(redeemId, { status: 'parsing_token' });
|
||||
const tokenData = await cashu.parseToken(token);
|
||||
|
||||
this.updateRedemption(redeemId, {
|
||||
amount: tokenData.totalAmount,
|
||||
mint: tokenData.mint,
|
||||
numProofs: tokenData.numProofs,
|
||||
format: tokenData.format
|
||||
});
|
||||
|
||||
// Check if token is spendable
|
||||
this.updateRedemption(redeemId, { status: 'checking_spendability' });
|
||||
try {
|
||||
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) {
|
||||
if (spendError.message.includes('not spendable') ||
|
||||
spendError.message.includes('already been used') ||
|
||||
spendError.message.includes('invalid proofs') ||
|
||||
spendError.message.includes('422')) {
|
||||
throw new Error('This token has already been spent and cannot be redeemed again');
|
||||
}
|
||||
console.warn('Spendability check failed:', spendError.message);
|
||||
console.log('Continuing with redemption despite spendability check failure...');
|
||||
}
|
||||
|
||||
// Step 2: Get melt quote first to determine exact fees
|
||||
this.updateRedemption(redeemId, { status: 'getting_melt_quote' });
|
||||
|
||||
console.log(`Getting melt quote for ${tokenData.totalAmount} sats to determine exact fees`);
|
||||
const tempInvoiceData = await lightning.resolveInvoice(
|
||||
lightningAddressToUse,
|
||||
tokenData.totalAmount,
|
||||
'Cashu redemption'
|
||||
);
|
||||
|
||||
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
|
||||
this.updateRedemption(redeemId, { status: 'resolving_invoice' });
|
||||
|
||||
if (finalInvoiceAmount <= 0) {
|
||||
throw new Error(`Token amount (${tokenData.totalAmount} sats) is insufficient to cover the fee (${exactFee} sats)`);
|
||||
}
|
||||
|
||||
console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`);
|
||||
|
||||
const invoiceData = await lightning.resolveInvoice(
|
||||
lightningAddressToUse,
|
||||
finalInvoiceAmount,
|
||||
'Cashu redemption'
|
||||
);
|
||||
|
||||
this.updateRedemption(redeemId, {
|
||||
bolt11: invoiceData.bolt11.substring(0, 50) + '...',
|
||||
domain: invoiceData.domain,
|
||||
invoiceAmount: finalInvoiceAmount,
|
||||
exactFee
|
||||
});
|
||||
|
||||
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 cashu.meltToken(token, invoiceData.bolt11);
|
||||
|
||||
console.log(`Redemption ${redeemId}: Melt result:`, {
|
||||
paid: meltResult.paid,
|
||||
hasPreimage: !!meltResult.preimage,
|
||||
amount: meltResult.amount,
|
||||
fee: meltResult.fee
|
||||
});
|
||||
|
||||
const paymentSuccessful = meltResult.paid || !!meltResult.preimage;
|
||||
|
||||
this.updateRedemption(redeemId, {
|
||||
status: paymentSuccessful ? 'paid' : 'failed',
|
||||
paid: paymentSuccessful,
|
||||
preimage: meltResult.preimage,
|
||||
fee: meltResult.fee,
|
||||
actualFee: meltResult.actualFee,
|
||||
netAmount: meltResult.netAmount,
|
||||
change: meltResult.change,
|
||||
paidAt: paymentSuccessful ? new Date().toISOString() : null,
|
||||
rawMeltResponse: meltResult.rawMeltResponse
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
redeemId,
|
||||
paid: paymentSuccessful,
|
||||
amount: tokenData.totalAmount,
|
||||
invoiceAmount: finalInvoiceAmount,
|
||||
to: lightningAddressToUse,
|
||||
usingDefaultAddress: isUsingDefault,
|
||||
fee: exactFee,
|
||||
actualFee: meltResult.actualFee,
|
||||
netAmount: finalInvoiceAmount,
|
||||
preimage: meltResult.preimage,
|
||||
change: meltResult.change,
|
||||
mint: tokenData.mint,
|
||||
format: tokenData.format
|
||||
};
|
||||
|
||||
} catch (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,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redemption status for API response
|
||||
*/
|
||||
getRedemptionStatus(redeemId) {
|
||||
const redemption = this.getRedemption(redeemId);
|
||||
|
||||
if (!redemption) return null;
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
status: redemption.status,
|
||||
details: {
|
||||
amount: redemption.amount,
|
||||
to: redemption.lightningAddress,
|
||||
paid: redemption.paid,
|
||||
createdAt: redemption.createdAt,
|
||||
updatedAt: redemption.updatedAt
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
*/
|
||||
getAllRedemptions() {
|
||||
return Array.from(this.redemptions.entries()).map(([id, data]) => ({
|
||||
redeemId: id,
|
||||
...data
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old redemptions (should be called periodically)
|
||||
*/
|
||||
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);
|
||||
if (redemption.tokenHash) {
|
||||
this.tokenHashes.delete(redemption.tokenHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RedemptionComponent();
|
||||
Reference in New Issue
Block a user