- 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.
605 lines
18 KiB
JavaScript
605 lines
18 KiB
JavaScript
require('dotenv').config();
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
const swaggerUi = require('swagger-ui-express');
|
|
const swaggerSpecs = require('./swagger.config');
|
|
const cashuService = require('./services/cashu');
|
|
const lightningService = require('./services/lightning');
|
|
const redemptionService = require('./services/redemption');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Middleware
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.use(cors({
|
|
origin: process.env.ALLOWED_ORIGINS
|
|
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
|
|
: ['http://localhost:3000'],
|
|
methods: ['GET', 'POST'],
|
|
allowedHeaders: ['Content-Type', 'Authorization']
|
|
}));
|
|
|
|
// Swagger Documentation
|
|
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
|
|
customCss: '.swagger-ui .topbar { display: none }',
|
|
customSiteTitle: 'Cashu Redeem API Documentation',
|
|
swaggerOptions: {
|
|
filter: true,
|
|
showRequestHeaders: true
|
|
}
|
|
}));
|
|
|
|
// Basic rate limiting (simple in-memory implementation)
|
|
const rateLimitMap = new Map();
|
|
const RATE_LIMIT = parseInt(process.env.RATE_LIMIT) || 100; // requests per minute
|
|
|
|
function rateLimit(req, res, next) {
|
|
const clientId = req.ip || req.connection.remoteAddress;
|
|
const now = Date.now();
|
|
const windowStart = now - 60000; // 1 minute window
|
|
|
|
if (!rateLimitMap.has(clientId)) {
|
|
rateLimitMap.set(clientId, []);
|
|
}
|
|
|
|
const requests = rateLimitMap.get(clientId);
|
|
|
|
// Remove old requests outside the window
|
|
const validRequests = requests.filter(time => time > windowStart);
|
|
rateLimitMap.set(clientId, validRequests);
|
|
|
|
if (validRequests.length >= RATE_LIMIT) {
|
|
return res.status(429).json({
|
|
success: false,
|
|
error: 'Rate limit exceeded. Please try again later.'
|
|
});
|
|
}
|
|
|
|
validRequests.push(now);
|
|
next();
|
|
}
|
|
|
|
// Apply rate limiting to all routes
|
|
app.use(rateLimit);
|
|
|
|
// Request logging middleware
|
|
app.use((req, res, next) => {
|
|
const timestamp = new Date().toISOString();
|
|
console.log(`${timestamp} - ${req.method} ${req.path} - ${req.ip}`);
|
|
next();
|
|
});
|
|
|
|
// Error handling middleware
|
|
function asyncHandler(fn) {
|
|
return (req, res, next) => {
|
|
Promise.resolve(fn(req, res, next)).catch(next);
|
|
};
|
|
}
|
|
|
|
// API Routes
|
|
|
|
/**
|
|
* @swagger
|
|
* /:
|
|
* get:
|
|
* summary: API Information
|
|
* description: Get basic information about the Cashu Redeem API
|
|
* tags: [General]
|
|
* responses:
|
|
* 200:
|
|
* description: API information
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* example: "Cashu Redeem API"
|
|
* version:
|
|
* type: string
|
|
* example: "1.0.0"
|
|
* description:
|
|
* type: string
|
|
* example: "A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses"
|
|
* documentation:
|
|
* type: string
|
|
* example: "/docs"
|
|
* endpoints:
|
|
* type: object
|
|
* properties:
|
|
* decode:
|
|
* type: string
|
|
* example: "POST /api/decode"
|
|
* redeem:
|
|
* type: string
|
|
* example: "POST /api/redeem"
|
|
* validate:
|
|
* type: string
|
|
* example: "POST /api/validate-address"
|
|
* health:
|
|
* type: string
|
|
* example: "GET /api/health"
|
|
* github:
|
|
* type: string
|
|
* example: "https://github.com/yourusername/cashu-redeem-api"
|
|
*/
|
|
app.get('/', (req, res) => {
|
|
res.json({
|
|
name: 'Cashu Redeem API',
|
|
version: '1.0.0',
|
|
description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol',
|
|
documentation: '/docs',
|
|
endpoints: {
|
|
decode: 'POST /api/decode',
|
|
redeem: 'POST /api/redeem',
|
|
validate: 'POST /api/validate-address',
|
|
health: 'GET /api/health'
|
|
},
|
|
features: [
|
|
'Decode Cashu tokens',
|
|
'Redeem tokens to Lightning addresses',
|
|
'Lightning address validation',
|
|
'Domain restrictions',
|
|
'Rate limiting',
|
|
'Comprehensive error handling'
|
|
],
|
|
github: 'https://github.com/yourusername/cashu-redeem-api'
|
|
});
|
|
});
|
|
|
|
// API Routes
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/decode:
|
|
* post:
|
|
* summary: Decode a Cashu token
|
|
* description: Decode a Cashu token and return its content. Supports both v1 and v3 token formats.
|
|
* tags: [Token Operations]
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/DecodeRequest'
|
|
* responses:
|
|
* 200:
|
|
* description: Token decoded successfully
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/DecodeResponse'
|
|
* 400:
|
|
* $ref: '#/components/responses/BadRequest'
|
|
* 429:
|
|
* $ref: '#/components/responses/TooManyRequests'
|
|
* 500:
|
|
* $ref: '#/components/responses/InternalServerError'
|
|
*/
|
|
app.post('/api/decode', asyncHandler(async (req, res) => {
|
|
const { token } = req.body;
|
|
|
|
if (!token) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Token is required'
|
|
});
|
|
}
|
|
|
|
try {
|
|
// Validate token format first
|
|
if (!cashuService.isValidTokenFormat(token)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid token format. Must be a valid Cashu token'
|
|
});
|
|
}
|
|
|
|
const decoded = await cashuService.parseToken(token);
|
|
const mintUrl = await cashuService.getTokenMintUrl(token);
|
|
|
|
// Check if token is spent
|
|
let spent = false;
|
|
try {
|
|
const spendabilityCheck = await cashuService.checkTokenSpendable(token);
|
|
// Token is spent if no proofs are spendable
|
|
spent = !spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0;
|
|
} catch (error) {
|
|
// If spendability check fails, analyze the error to determine if token is spent
|
|
console.warn('Spendability check failed:', error.message);
|
|
|
|
// Check if error indicates proofs are already spent
|
|
const errorString = error.message || error.toString();
|
|
|
|
// Check for specific error indicators
|
|
if (errorString.includes('TOKEN_SPENT:')) {
|
|
// CashuService has determined the token is spent based on clear indicators
|
|
console.log('Token determined to be spent by CashuService');
|
|
spent = true;
|
|
} else if (errorString.includes('Token validation failed at mint:')) {
|
|
// This is a 422 error but not clearly indicating the token is spent
|
|
// It might be invalid/malformed but not necessarily spent
|
|
console.log('Token validation failed at mint - assuming token is still valid (might be invalid format)');
|
|
spent = false;
|
|
} else if (errorString.includes('not supported') ||
|
|
errorString.includes('endpoint not found') ||
|
|
errorString.includes('may still be valid') ||
|
|
errorString.includes('does not support spendability checking')) {
|
|
// Mint doesn't support spendability checking - assume token is still valid
|
|
console.log('Mint does not support spendability checking - assuming token is valid');
|
|
spent = false;
|
|
} else {
|
|
// For other errors (network, server issues), assume token is still valid
|
|
// This is safer than assuming it's spent
|
|
console.log('Unknown error - assuming token is valid');
|
|
spent = false;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
decoded: {
|
|
mint: decoded.mint,
|
|
totalAmount: decoded.totalAmount,
|
|
numProofs: decoded.numProofs,
|
|
denominations: decoded.denominations,
|
|
format: decoded.format,
|
|
spent: spent
|
|
},
|
|
mint_url: mintUrl
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/redeem:
|
|
* post:
|
|
* summary: Redeem a Cashu token to Lightning address
|
|
* description: |
|
|
* Redeem a Cashu token to a Lightning address (optional - uses default if not provided).
|
|
*
|
|
* The redemption process includes:
|
|
* 1. Token validation and parsing
|
|
* 2. Getting exact melt quote from mint to determine precise fees
|
|
* 3. Invoice creation for net amount (token amount - exact fees)
|
|
* 4. Spendability checking at the mint
|
|
* 5. Token melting and Lightning payment
|
|
*
|
|
* **Important**: The system gets the exact fee from the mint before creating the invoice.
|
|
* The `invoiceAmount` field shows the actual amount sent to the Lightning address.
|
|
* No sats are lost to fee estimation errors.
|
|
* tags: [Token Operations]
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/RedeemRequest'
|
|
* responses:
|
|
* 200:
|
|
* description: Token redeemed successfully
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/RedeemResponse'
|
|
* 400:
|
|
* $ref: '#/components/responses/BadRequest'
|
|
* 409:
|
|
* description: Token already spent
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* success:
|
|
* type: boolean
|
|
* example: false
|
|
* error:
|
|
* type: string
|
|
* example: "This token has already been spent and cannot be redeemed again"
|
|
* redeemId:
|
|
* type: string
|
|
* format: uuid
|
|
* errorType:
|
|
* type: string
|
|
* example: "token_already_spent"
|
|
* 422:
|
|
* description: Insufficient funds or unprocessable token
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* success:
|
|
* type: boolean
|
|
* example: false
|
|
* error:
|
|
* type: string
|
|
* example: "Token amount is insufficient to cover the minimum fee"
|
|
* redeemId:
|
|
* type: string
|
|
* format: uuid
|
|
* errorType:
|
|
* type: string
|
|
* example: "insufficient_funds"
|
|
* 429:
|
|
* $ref: '#/components/responses/TooManyRequests'
|
|
* 500:
|
|
* $ref: '#/components/responses/InternalServerError'
|
|
*/
|
|
app.post('/api/redeem', asyncHandler(async (req, res) => {
|
|
const { token, lightningAddress } = req.body;
|
|
|
|
// Validate request (lightningAddress is now optional)
|
|
const validation = await redemptionService.validateRedemptionRequest(token, lightningAddress);
|
|
|
|
if (!validation.valid) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: validation.errors.join(', ')
|
|
});
|
|
}
|
|
|
|
// Perform redemption
|
|
try {
|
|
const result = await redemptionService.performRedemption(token, lightningAddress);
|
|
|
|
if (result.success) {
|
|
const response = {
|
|
success: true,
|
|
redeemId: result.redeemId,
|
|
paid: result.paid,
|
|
amount: result.amount,
|
|
invoiceAmount: result.invoiceAmount,
|
|
to: result.to,
|
|
fee: result.fee,
|
|
actualFee: result.actualFee,
|
|
netAmount: result.netAmount,
|
|
mint_url: result.mint,
|
|
format: result.format
|
|
};
|
|
|
|
// Include info about whether default address was used
|
|
if (result.usingDefaultAddress) {
|
|
response.usingDefaultAddress = true;
|
|
response.message = `Redeemed to default Lightning address: ${result.to}`;
|
|
}
|
|
|
|
// Include preimage if available
|
|
if (result.preimage) {
|
|
response.preimage = result.preimage;
|
|
}
|
|
|
|
res.json(response);
|
|
} else {
|
|
// Determine appropriate status code based on error type
|
|
let statusCode = 400;
|
|
|
|
if (result.error && (
|
|
result.error.includes('cannot be redeemed') ||
|
|
result.error.includes('already been used') ||
|
|
result.error.includes('not spendable') ||
|
|
result.error.includes('already spent') ||
|
|
result.error.includes('invalid proofs')
|
|
)) {
|
|
// Use 409 Conflict for already-spent tokens to distinguish from generic bad requests
|
|
statusCode = 409;
|
|
} else if (result.error && result.error.includes('insufficient')) {
|
|
// Use 422 for insufficient funds
|
|
statusCode = 422;
|
|
}
|
|
|
|
res.status(statusCode).json({
|
|
success: false,
|
|
error: result.error,
|
|
redeemId: result.redeemId,
|
|
errorType: statusCode === 409 ? 'token_already_spent' :
|
|
statusCode === 422 ? 'insufficient_funds' : 'validation_error'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in redemption:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Internal server error during redemption'
|
|
});
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/health:
|
|
* get:
|
|
* summary: Health check endpoint
|
|
* description: |
|
|
* Check the health and status of the API server.
|
|
* Returns server information including uptime, memory usage, and version.
|
|
* tags: [Status & Monitoring]
|
|
* responses:
|
|
* 200:
|
|
* description: Server is healthy
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/HealthResponse'
|
|
* 500:
|
|
* $ref: '#/components/responses/InternalServerError'
|
|
*/
|
|
app.get('/api/health', asyncHandler(async (req, res) => {
|
|
try {
|
|
const packageJson = require('./package.json');
|
|
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
uptime: process.uptime(),
|
|
memory: process.memoryUsage(),
|
|
version: packageJson.version
|
|
});
|
|
} catch (error) {
|
|
console.error('Health check error:', error);
|
|
res.status(500).json({
|
|
status: 'error',
|
|
timestamp: new Date().toISOString(),
|
|
error: 'Health check failed'
|
|
});
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/validate-address:
|
|
* post:
|
|
* summary: Validate a Lightning address
|
|
* description: |
|
|
* Validate a Lightning address without performing a redemption.
|
|
* Checks format validity and tests LNURLp resolution.
|
|
*
|
|
* Returns information about the Lightning address capabilities
|
|
* including min/max sendable amounts and comment allowance.
|
|
* tags: [Validation]
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ValidateAddressRequest'
|
|
* responses:
|
|
* 200:
|
|
* description: Validation completed (check 'valid' field for result)
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ValidateAddressResponse'
|
|
* 400:
|
|
* $ref: '#/components/responses/BadRequest'
|
|
* 429:
|
|
* $ref: '#/components/responses/TooManyRequests'
|
|
*/
|
|
app.post('/api/validate-address', asyncHandler(async (req, res) => {
|
|
const { lightningAddress } = req.body;
|
|
|
|
if (!lightningAddress) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Lightning address is required'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const isValid = lightningService.validateLightningAddress(lightningAddress);
|
|
|
|
if (!isValid) {
|
|
return res.json({
|
|
success: false,
|
|
valid: false,
|
|
error: 'Invalid Lightning address format'
|
|
});
|
|
}
|
|
|
|
// Test resolution
|
|
const { domain } = lightningService.parseLightningAddress(lightningAddress);
|
|
const lnurlpUrl = lightningService.getLNURLpEndpoint(lightningAddress);
|
|
|
|
try {
|
|
const lnurlpResponse = await lightningService.fetchLNURLpResponse(lnurlpUrl);
|
|
|
|
res.json({
|
|
success: true,
|
|
valid: true,
|
|
domain,
|
|
minSendable: lightningService.millisatsToSats(lnurlpResponse.minSendable),
|
|
maxSendable: lightningService.millisatsToSats(lnurlpResponse.maxSendable),
|
|
commentAllowed: lnurlpResponse.commentAllowed || 0
|
|
});
|
|
} catch (error) {
|
|
res.json({
|
|
success: false,
|
|
valid: false,
|
|
error: `Lightning address resolution failed: ${error.message}`
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(400).json({
|
|
success: false,
|
|
valid: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}));
|
|
|
|
// 404 handler
|
|
app.use('*', (req, res) => {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: 'Endpoint not found'
|
|
});
|
|
});
|
|
|
|
// Global error handler
|
|
app.use((error, req, res, next) => {
|
|
console.error('Global error handler:', error);
|
|
|
|
if (error.type === 'entity.parse.failed') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid JSON payload'
|
|
});
|
|
}
|
|
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Internal server error'
|
|
});
|
|
});
|
|
|
|
// Cleanup old redemptions periodically (every hour)
|
|
setInterval(() => {
|
|
try {
|
|
redemptionService.cleanupOldRedemptions();
|
|
console.log('Cleaned up old redemptions');
|
|
} catch (error) {
|
|
console.error('Error cleaning up redemptions:', error);
|
|
}
|
|
}, 60 * 60 * 1000); // 1 hour
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', () => {
|
|
console.log('SIGTERM received, shutting down gracefully');
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('SIGINT received, shutting down gracefully');
|
|
process.exit(0);
|
|
});
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
console.log(`🚀 Cashu Redeem API running on port ${PORT}`);
|
|
console.log(`📖 API Documentation: http://localhost:${PORT}/docs`);
|
|
console.log(`📍 Health check: http://localhost:${PORT}/api/health`);
|
|
console.log(`🔒 Environment: ${process.env.NODE_ENV || 'development'}`);
|
|
|
|
if (process.env.ALLOW_REDEEM_DOMAINS) {
|
|
console.log(`🌐 Allowed domains: ${process.env.ALLOW_REDEEM_DOMAINS}`);
|
|
} else {
|
|
console.log('⚠️ No domain restrictions (ALLOW_REDEEM_DOMAINS not set)');
|
|
}
|
|
|
|
if (process.env.DEFAULT_LIGHTNING_ADDRESS) {
|
|
console.log(`⚡ Default Lightning address: ${process.env.DEFAULT_LIGHTNING_ADDRESS}`);
|
|
} else {
|
|
console.log('⚠️ No default Lightning address configured - Lightning address will be required for redemptions');
|
|
}
|
|
});
|
|
|
|
module.exports = app;
|