Refactor: move services to components, add route modules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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