- Add API_DOMAIN env var to set domain/IP for API in production - Update swagger.config.js to use dynamic server URLs - Update server.js CORS configuration to use API_DOMAIN - Update env.example with new API_DOMAIN field - Add comprehensive documentation in README.md - Fixes CORS issues in Swagger docs for production deployments
602 lines
18 KiB
JavaScript
602 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;
|
|
|
|
// Get API domain for CORS configuration
|
|
const apiDomain = process.env.API_DOMAIN || 'localhost:3000';
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
const protocol = isProduction ? 'https' : 'http';
|
|
|
|
// Middleware
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.use(cors({
|
|
origin: process.env.ALLOWED_ORIGINS
|
|
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
|
|
: [`${protocol}://${apiDomain}`],
|
|
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"
|
|
* 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"
|
|
* 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,
|
|
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,
|
|
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;
|