Initial commit

This commit is contained in:
michilis
2025-05-31 14:20:15 +02:00
commit fc7927e1c8
10 changed files with 5644 additions and 0 deletions

301
services/cashu.js Normal file
View File

@@ -0,0 +1,301 @@
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);
}
/**
* 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;
// Create melt quote to get fee estimate
const meltQuote = await wallet.createMeltQuote(bolt11);
// Calculate expected fee
const expectedFee = this.calculateFee(parsed.totalAmount);
// Check if we have sufficient funds including fees
const totalRequired = meltQuote.amount + meltQuote.fee_reserve;
if (totalRequired > parsed.totalAmount) {
throw new Error(`Insufficient funds. Required: ${totalRequired} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`);
}
// Perform the melt operation using the quote and proofs
const meltResponse = await wallet.meltTokens(meltQuote, proofs);
// Verify payment was successful
if (!meltResponse.paid) {
throw new Error('Payment failed - token melted but Lightning payment was not successful');
}
return {
success: true,
paid: meltResponse.paid,
preimage: meltResponse.payment_preimage,
change: meltResponse.change || [],
amount: meltQuote.amount,
fee: meltQuote.fee_reserve,
actualFee: expectedFee,
netAmount: parsed.totalAmount - meltQuote.fee_reserve,
quote: meltQuote.quote
};
} 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
}
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);
const secrets = parsed.proofs.map(proof => proof.secret);
const checkResult = await mint.check({ secrets });
return {
spendable: checkResult.spendable,
pending: checkResult.pending || [],
mintUrl: parsed.mint,
totalAmount: parsed.totalAmount
};
} catch (error) {
throw new Error(`Failed to check token spendability: ${error.message}`);
}
}
}
module.exports = new CashuService();

276
services/lightning.js Normal file
View File

@@ -0,0 +1,276 @@
const axios = require('axios');
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 {
// Get LNURLp endpoint
const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress);
// Fetch LNURLp response
const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl);
// 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);
const invoiceResponse = await this.getInvoice(lnurlpResponse.callback, amountMsats, comment);
return {
bolt11: invoiceResponse.bolt11,
amount,
amountMsats,
lightningAddress,
domain: this.parseLightningAddress(lightningAddress).domain,
successAction: invoiceResponse.successAction,
lnurlpResponse
};
} catch (error) {
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}`);
}
}
}
module.exports = new LightningService();

353
services/redemption.js Normal file
View File

@@ -0,0 +1,353 @@
const { v4: uuidv4 } = require('uuid');
const cashuService = require('./cashu');
const lightningService = require('./lightning');
class RedemptionService {
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
}
/**
* 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, {
...status,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
}
/**
* Update redemption status
* @param {string} redeemId - The redemption ID
* @param {Object} updates - Updates to apply
*/
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
* @param {string} redeemId - The redemption ID
* @returns {Object|null} Redemption status or null if not found
*/
getRedemption(redeemId) {
return this.redemptions.get(redeemId) || null;
}
/**
* 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);
return redeemId ? this.getRedemption(redeemId) : null;
}
/**
* 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);
return this.getRedemptionByTokenHash(tokenHash);
}
/**
* 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);
if (!lightningService.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) {
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);
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
* @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 isUsingDefault = !lightningAddress || !lightningAddress.trim();
// Store initial status
this.storeRedemption(redeemId, {
status: 'processing',
token: token.substring(0, 50) + '...', // Store partial token for reference
tokenHash,
lightningAddress: lightningAddressToUse,
usingDefaultAddress: isUsingDefault,
amount: null,
paid: false,
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);
// Calculate expected fee according to NUT-05
const expectedFee = cashuService.calculateFee(tokenData.totalAmount);
this.updateRedemption(redeemId, {
amount: tokenData.totalAmount,
mint: tokenData.mint,
numProofs: tokenData.numProofs,
expectedFee: expectedFee,
format: tokenData.format
});
// Check if token is spendable
this.updateRedemption(redeemId, { status: 'checking_spendability' });
try {
const spendabilityCheck = await cashuService.checkTokenSpendable(token);
if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) {
throw new Error('Token proofs are not spendable - may have already been used');
}
} catch (spendError) {
// Log but don't fail - some mints might not support this check
console.warn('Spendability check failed:', spendError.message);
}
// Step 2: Resolve Lightning address to invoice
this.updateRedemption(redeemId, { status: 'resolving_invoice' });
const invoiceData = await lightningService.resolveInvoice(
lightningAddressToUse,
tokenData.totalAmount,
`Cashu redemption ${redeemId.substring(0, 8)}`
);
this.updateRedemption(redeemId, {
bolt11: invoiceData.bolt11.substring(0, 50) + '...',
domain: invoiceData.domain
});
// Step 3: Melt the token to pay the invoice
this.updateRedemption(redeemId, { status: 'melting_token' });
const meltResult = await cashuService.meltToken(token, invoiceData.bolt11);
// Step 4: Update final status
this.updateRedemption(redeemId, {
status: meltResult.paid ? 'paid' : 'failed',
paid: meltResult.paid,
preimage: meltResult.preimage,
fee: meltResult.fee,
actualFee: meltResult.actualFee,
netAmount: meltResult.netAmount,
change: meltResult.change,
paidAt: meltResult.paid ? new Date().toISOString() : null
});
return {
success: true,
redeemId,
paid: meltResult.paid,
amount: tokenData.totalAmount,
to: lightningAddressToUse,
usingDefaultAddress: isUsingDefault,
fee: meltResult.fee,
actualFee: meltResult.actualFee,
netAmount: meltResult.netAmount,
preimage: meltResult.preimage,
change: meltResult.change,
mint: tokenData.mint,
format: tokenData.format
};
} catch (error) {
// Update redemption with error
this.updateRedemption(redeemId, {
status: 'failed',
paid: false,
error: error.message
});
return {
success: false,
redeemId,
error: error.message
};
}
}
/**
* 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;
}
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)
* @returns {Array} All redemptions
*/
getAllRedemptions() {
return Array.from(this.redemptions.entries()).map(([id, data]) => ({
redeemId: id,
...data
}));
}
/**
* Clean up old redemptions (should be called periodically)
* @param {number} maxAgeMs - Maximum age in milliseconds
*/
cleanupOldRedemptions(maxAgeMs = 24 * 60 * 60 * 1000) { // 24 hours default
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);
}
}
}
}
/**
* Get redemption statistics
* @returns {Object} Statistics
*/
getStats() {
const redemptions = Array.from(this.redemptions.values());
return {
total: redemptions.length,
paid: redemptions.filter(r => r.paid).length,
failed: redemptions.filter(r => r.status === 'failed').length,
processing: redemptions.filter(r => r.status === 'processing').length,
totalAmount: redemptions
.filter(r => r.paid && r.amount)
.reduce((sum, r) => sum + r.amount, 0),
totalFees: redemptions
.filter(r => r.paid && r.fee)
.reduce((sum, r) => sum + r.fee, 0)
};
}
}
module.exports = new RedemptionService();