370 lines
11 KiB
JavaScript
370 lines
11 KiB
JavaScript
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();
|