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

133
.gitignore vendored Normal file
View File

@@ -0,0 +1,133 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Database files
*.db
*.sqlite
*.sqlite3
# Redis dump file
dump.rdb

479
README.md Normal file
View File

@@ -0,0 +1,479 @@
# Cashu Redeem API 🪙⚡
A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol.
## 🚀 Features
- **Decode Cashu tokens** - Parse and validate token content
- **Redeem to Lightning addresses** - Convert ecash to Lightning payments via LNURLp
- **Real-time status tracking** - Monitor redemption progress with unique IDs
- **Security features** - Domain restrictions, rate limiting, input validation
- **Robust error handling** - Comprehensive error messages and status codes
- **In-memory caching** - Fast mint and wallet instances with connection pooling
- **Interactive API Documentation** - Complete Swagger/OpenAPI documentation at `/docs`
## 📖 API Documentation
**Interactive Swagger Documentation**: Visit `/docs` when running the server for a complete, interactive API reference.
Example: `http://localhost:3000/docs`
The documentation includes:
- Complete endpoint specifications
- Request/response schemas
- Try-it-out functionality
- Example requests and responses
- Authentication requirements
- Error code documentation
## 📡 API Endpoints
### 1. `POST /api/decode`
Decode a Cashu token and return its content. Supports both v1 and v3 token formats.
**Request:**
```json
{
"token": "cashuAeyJhbGciOi..."
}
```
**Response:**
```json
{
"success": true,
"decoded": {
"mint": "https://mint.azzamo.net",
"totalAmount": 21000,
"numProofs": 3,
"denominations": [1000, 10000, 10000],
"format": "cashuA"
},
"mint_url": "https://mint.azzamo.net"
}
```
### 2. `POST /api/redeem`
Redeem a Cashu token to a Lightning address. Lightning address is optional - if not provided, uses the default address from configuration.
**Request:**
```json
{
"token": "cashuAeyJhbGciOi...",
"lightningAddress": "user@ln.tips"
}
```
**Request (using default address):**
```json
{
"token": "cashuAeyJhbGciOi..."
}
```
**Success Response:**
```json
{
"success": true,
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762",
"paid": true,
"amount": 21000,
"to": "user@ln.tips",
"fee": 1000,
"actualFee": 420,
"netAmount": 20000,
"mint_url": "https://mint.azzamo.net",
"format": "cashuA",
"preimage": "abc123..."
}
```
**Success Response (using default address):**
```json
{
"success": true,
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762",
"paid": true,
"amount": 21000,
"to": "admin@your-domain.com",
"fee": 1000,
"actualFee": 420,
"netAmount": 20000,
"mint_url": "https://mint.azzamo.net",
"format": "cashuA",
"usingDefaultAddress": true,
"message": "Redeemed to default Lightning address: admin@your-domain.com"
}
```
### 3. `POST /api/status`
Check redemption status using redeemId.
**Request:**
```json
{
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762"
}
```
**Response:**
```json
{
"success": true,
"status": "paid",
"details": {
"amount": 21000,
"to": "user@ln.tips",
"paid": true,
"paidAt": "2025-01-14T12:00:00Z",
"fee": 1000,
"createdAt": "2025-01-14T11:59:30Z",
"updatedAt": "2025-01-14T12:00:00Z"
}
}
```
### 4. `GET /api/status/:redeemId`
Same as above, but via URL parameter for frontend polling.
### 5. `GET /api/health`
Health check endpoint.
**Response:**
```json
{
"status": "ok",
"timestamp": "2025-01-14T12:00:00Z",
"uptime": 3600,
"memory": {...},
"version": "1.0.0"
}
```
### 6. `POST /api/validate-address`
Validate a Lightning address without redemption.
**Request:**
```json
{
"lightningAddress": "user@ln.tips"
}
```
**Response:**
```json
{
"success": true,
"valid": true,
"domain": "ln.tips",
"minSendable": 1,
"maxSendable": 100000000,
"commentAllowed": 144
}
```
### 7. `GET /api/stats`
Get redemption statistics (admin endpoint).
**Response:**
```json
{
"success": true,
"stats": {
"total": 150,
"paid": 142,
"failed": 8,
"processing": 0,
"totalAmount": 2500000,
"totalFees": 15000
}
}
```
### 8. `POST /api/check-spendable`
Check if a Cashu token is spendable at its mint before attempting redemption.
**Request:**
```json
{
"token": "cashuAeyJhbGciOi..."
}
```
**Response:**
```json
{
"success": true,
"spendable": [true, true, false],
"pending": [],
"mintUrl": "https://mint.azzamo.net",
"totalAmount": 21000,
"message": "Token is spendable"
}
```
## 🛠 Setup & Installation
### Prerequisites
- Node.js >= 18.0.0
- npm or yarn
### Installation
1. **Clone and install dependencies:**
```bash
git clone <your-repo>
cd cashu-redeem-api
npm install
```
2. **Setup environment variables:**
```bash
cp env.example .env
```
Edit `.env` file:
```bash
# Server Configuration
PORT=3000
NODE_ENV=development
# Security Configuration
ALLOW_REDEEM_DOMAINS=ln.tips,getalby.com,wallet.mutinywallet.com
API_SECRET=your-secret-key-here
# Default Lightning Address (used when no address is provided in redeem requests)
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com
# Rate Limiting (requests per minute)
RATE_LIMIT=100
# CORS Configuration
ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
```
3. **Start the server:**
```bash
# Development
npm run dev
# Production
npm start
```
The API will be available at `http://localhost:3000`
## 🔧 Configuration
### Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `PORT` | Server port | `3000` | No |
| `NODE_ENV` | Environment | `development` | No |
| `ALLOW_REDEEM_DOMAINS` | Comma-separated allowed domains | All allowed | No |
| `DEFAULT_LIGHTNING_ADDRESS` | Default Lightning address for redemptions | None | No |
| `RATE_LIMIT` | Requests per minute per IP | `100` | No |
| `ALLOWED_ORIGINS` | CORS allowed origins | `http://localhost:3000` | No |
### Domain Restrictions
To restrict redemptions to specific Lightning address domains, set:
```bash
ALLOW_REDEEM_DOMAINS=ln.tips,getalby.com,wallet.mutinywallet.com
```
If not set, all domains are allowed.
### Default Lightning Address
To set a default Lightning address that will be used when no address is provided in redemption requests:
```bash
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com
```
This allows users to redeem tokens without specifying a Lightning address - the tokens will automatically be sent to your configured default address. If no default is set, Lightning address becomes required for all redemption requests.
## 🏗 Architecture
### Services
#### `services/cashu.js`
- Manages Cashu token parsing and validation
- Handles mint connections and wallet instances
- Performs token melting operations
- Caches mint/wallet connections for performance
#### `services/lightning.js`
- Validates Lightning address formats
- Resolves LNURLp endpoints
- Generates Lightning invoices
- Handles domain restrictions
#### `services/redemption.js`
- Coordinates the complete redemption process
- Manages redemption status tracking
- Handles duplicate token detection
- Provides statistics and cleanup
### Data Flow
1. **Token Validation** - Parse and validate Cashu token structure
2. **Address Resolution** - Resolve Lightning address to LNURLp endpoint
3. **Invoice Generation** - Create Lightning invoice for the amount
4. **Token Melting** - Use cashu-ts to melt token and pay invoice
5. **Status Tracking** - Store and update redemption status with UUID
## 🔒 Security Features
- **Input validation** - All inputs are sanitized and validated
- **Rate limiting** - 100 requests per minute per IP (configurable)
- **Domain restrictions** - Limit allowed Lightning address domains
- **CORS protection** - Configurable allowed origins
- **Error handling** - Comprehensive error messages without data leaks
- **Token deduplication** - Prevent double-spending with hash tracking
## 🚦 Status Codes
| Status | Description |
|--------|-------------|
| `processing` | Redemption is in progress |
| `parsing_token` | Validating and parsing the token |
| `resolving_invoice` | Resolving Lightning address to invoice |
| `melting_token` | Performing the melt operation |
| `paid` | Successfully paid and completed |
| `failed` | Redemption failed (see error details) |
| `checking_spendability` | Verifying token is spendable at mint |
## 📊 Monitoring
### Health Check
```bash
curl http://localhost:3000/api/health
```
### Statistics
```bash
curl http://localhost:3000/api/stats
```
### Logs
The server logs all requests and errors to console. In production, consider using a proper logging solution like Winston.
## 🧪 Testing
### Interactive Testing with Swagger
The easiest way to test the API is using the interactive Swagger documentation at `/docs`:
- Visit `http://localhost:3000/docs`
- Click "Try it out" on any endpoint
- Fill in the request parameters
- Execute the request directly from the browser
### Example cURL commands
**Decode a token:**
```bash
curl -X POST http://localhost:3000/api/decode \
-H "Content-Type: application/json" \
-d '{"token":"your-cashu-token-here"}'
```
**Check if token is spendable:**
```bash
curl -X POST http://localhost:3000/api/check-spendable \
-H "Content-Type: application/json" \
-d '{"token":"your-cashu-token-here"}'
```
**Redeem a token to specific address:**
```bash
curl -X POST http://localhost:3000/api/redeem \
-H "Content-Type: application/json" \
-d '{
"token": "your-cashu-token-here",
"lightningAddress": "user@ln.tips"
}'
```
**Redeem a token to default address:**
```bash
curl -X POST http://localhost:3000/api/redeem \
-H "Content-Type: application/json" \
-d '{
"token": "your-cashu-token-here"
}'
```
**Check status:**
```bash
curl -X POST http://localhost:3000/api/status \
-H "Content-Type: application/json" \
-d '{"redeemId":"your-redeem-id-here"}'
```
## 🚀 Production Deployment
### Recommendations
1. **Use a process manager** (PM2, systemd)
2. **Set up reverse proxy** (nginx, Apache)
3. **Enable HTTPS** with SSL certificates
4. **Use Redis** for persistent storage instead of in-memory
5. **Set up monitoring** (Prometheus, Grafana)
6. **Configure logging** (Winston, structured logs)
7. **Set resource limits** and health checks
### Docker Deployment
Create a `Dockerfile`:
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
```
### Environment-specific configs
**Production `.env`:**
```bash
NODE_ENV=production
PORT=3000
ALLOW_REDEEM_DOMAINS=ln.tips,getalby.com
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com
RATE_LIMIT=200
ALLOWED_ORIGINS=https://yourdomain.com
```
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## 📝 License
MIT License - see LICENSE file for details.
## 🆘 Support
For issues and questions:
- Create an issue on GitHub
- Check the logs for detailed error messages
- Verify your environment configuration
- Test with the health endpoint first
## 🔮 Roadmap
- [ ] Add Redis/database persistence
- [ ] Implement webhook notifications
- [ ] Add batch redemption support
- [ ] Enhanced monitoring and metrics
- [ ] WebSocket real-time status updates
- [ ] Multi-mint support optimization

19
env.example Normal file
View File

@@ -0,0 +1,19 @@
# Server Configuration
PORT=3000
NODE_ENV=development
# Security Configuration
ALLOW_REDEEM_DOMAINS=*
API_SECRET=your-secret-key-here
# Default Lightning Address (used when no address is provided in redeem requests)
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com
# Rate Limiting (requests per minute)
RATE_LIMIT=30
# Logging
LOG_LEVEL=info
# CORS Configuration
ALLOWED_ORIGINS=*

2860
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "cashu-redeem-api",
"version": "1.0.0",
"description": "Redeem ecash (Cashu tokens) to Lightning Address using cashu-ts library and LNURLp",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"keywords": [
"cashu",
"lightning",
"bitcoin",
"ecash",
"api",
"lnurl",
"lnurlp",
"mint",
"satoshi"
],
"author": "",
"license": "MIT",
"dependencies": {
"@cashu/cashu-ts": "^1.1.0",
"axios": "^1.7.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^10.0.0"
},
"devDependencies": {
"nodemon": "^3.1.4",
"eslint": "^9.9.1"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/yourusername/cashu-redeem-api.git"
},
"bugs": {
"url": "https://github.com/yourusername/cashu-redeem-api/issues"
},
"homepage": "https://github.com/yourusername/cashu-redeem-api#readme"
}

617
server.js Normal file
View File

@@ -0,0 +1,617 @@
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
* /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);
res.json({
success: true,
decoded: {
mint: decoded.mint,
totalAmount: decoded.totalAmount,
numProofs: decoded.numProofs,
denominations: decoded.denominations,
format: decoded.format
},
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. Spendability checking at the mint
* 3. Lightning address resolution via LNURLp
* 4. Token melting and Lightning payment
*
* Fee calculation follows NUT-05 specification (2% of amount, minimum 1 satoshi).
* 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'
* 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,
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;
}
// Include change if any
if (result.change && result.change.length > 0) {
response.change = result.change;
}
res.json(response);
} else {
res.status(400).json({
success: false,
error: result.error,
redeemId: result.redeemId
});
}
} catch (error) {
console.error('Error in redemption:', error);
res.status(500).json({
success: false,
error: 'Internal server error during redemption'
});
}
}));
/**
* @swagger
* /api/status:
* post:
* summary: Check redemption status by redeemId
* description: Check the current status of a redemption using its unique ID.
* tags: [Status & Monitoring]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatusRequest'
* responses:
* 200:
* description: Status retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatusResponse'
* 400:
* $ref: '#/components/responses/BadRequest'
* 404:
* $ref: '#/components/responses/NotFound'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.post('/api/status', asyncHandler(async (req, res) => {
const { redeemId } = req.body;
if (!redeemId) {
return res.status(400).json({
success: false,
error: 'redeemId is required'
});
}
const status = redemptionService.getRedemptionStatus(redeemId);
if (!status) {
return res.status(404).json({
success: false,
error: 'Redemption not found'
});
}
res.json(status);
}));
/**
* @swagger
* /api/status/{redeemId}:
* get:
* summary: Check redemption status via URL parameter
* description: Same as POST /api/status but uses URL parameter - useful for frontend polling.
* tags: [Status & Monitoring]
* parameters:
* - in: path
* name: redeemId
* required: true
* description: Unique redemption ID to check status for
* schema:
* type: string
* format: uuid
* example: '8e99101e-d034-4d2e-9ccf-dfda24d26762'
* responses:
* 200:
* description: Status retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatusResponse'
* 404:
* $ref: '#/components/responses/NotFound'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.get('/api/status/:redeemId', asyncHandler(async (req, res) => {
const { redeemId } = req.params;
const status = redemptionService.getRedemptionStatus(redeemId);
if (!status) {
return res.status(404).json({
success: false,
error: 'Redemption not found'
});
}
res.json(status);
}));
/**
* @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'
*/
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
version: require('./package.json').version
});
});
/**
* @swagger
* /api/stats:
* get:
* summary: Get redemption statistics
* description: |
* Get comprehensive statistics about redemptions (admin endpoint).
* Returns information about total redemptions, success rates, amounts, and fees.
*
* **Note**: In production, this endpoint should be protected with authentication.
* tags: [Status & Monitoring]
* responses:
* 200:
* description: Statistics retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatsResponse'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.get('/api/stats', asyncHandler(async (req, res) => {
// In production, add authentication here
const stats = redemptionService.getStats();
res.json({
success: true,
stats
});
}));
/**
* @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
});
}
}));
/**
* @swagger
* /api/check-spendable:
* post:
* summary: Check if Cashu token is spendable
* description: |
* Check if a Cashu token is spendable at its mint before attempting redemption.
* This is a pre-validation step that can save time and prevent failed redemptions.
*
* Returns an array indicating which proofs within the token are spendable,
* pending, or already spent.
* tags: [Validation]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CheckSpendableRequest'
* responses:
* 200:
* description: Spendability check completed
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CheckSpendableResponse'
* 400:
* $ref: '#/components/responses/BadRequest'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.post('/api/check-spendable', 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 spendabilityCheck = await cashuService.checkTokenSpendable(token);
res.json({
success: true,
spendable: spendabilityCheck.spendable,
pending: spendabilityCheck.pending,
mintUrl: spendabilityCheck.mintUrl,
totalAmount: spendabilityCheck.totalAmount,
message: spendabilityCheck.spendable && spendabilityCheck.spendable.length > 0
? 'Token is spendable'
: 'Token proofs are not spendable - may have already been used'
});
} catch (error) {
res.status(400).json({
success: 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;

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();

554
swagger.config.js Normal file
View File

@@ -0,0 +1,554 @@
const swaggerJsdoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: '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.',
contact: {
name: 'API Support',
email: 'support@example.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server'
},
{
url: 'https://api.example.com',
description: 'Production server'
}
],
components: {
schemas: {
// Error Response Schema
ErrorResponse: {
type: 'object',
required: ['success', 'error'],
properties: {
success: {
type: 'boolean',
example: false
},
error: {
type: 'string',
example: 'Error message description'
}
}
},
// Token Decode Schemas
DecodeRequest: {
type: 'object',
required: ['token'],
properties: {
token: {
type: 'string',
description: 'Cashu token to decode (supports v1 and v3 formats)',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
}
}
},
DecodeResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
decoded: {
type: 'object',
properties: {
mint: {
type: 'string',
format: 'uri',
example: 'https://mint.azzamo.net'
},
totalAmount: {
type: 'integer',
description: 'Total amount in satoshis',
example: 21000
},
numProofs: {
type: 'integer',
description: 'Number of proofs in the token',
example: 3
},
denominations: {
type: 'array',
items: {
type: 'integer'
},
description: 'Array of proof amounts',
example: [1000, 10000, 10000]
},
format: {
type: 'string',
enum: ['cashuA', 'cashuB'],
description: 'Token format version',
example: 'cashuA'
}
}
},
mint_url: {
type: 'string',
format: 'uri',
description: 'Mint URL extracted from token',
example: 'https://mint.azzamo.net'
}
}
},
// Redeem Schemas
RedeemRequest: {
type: 'object',
required: ['token'],
properties: {
token: {
type: 'string',
description: 'Cashu token to redeem',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
},
lightningAddress: {
type: 'string',
format: 'email',
description: 'Lightning address to send payment to (optional - uses default if not provided)',
example: 'user@ln.tips'
}
}
},
RedeemResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
redeemId: {
type: 'string',
format: 'uuid',
description: 'Unique redemption ID for tracking',
example: '8e99101e-d034-4d2e-9ccf-dfda24d26762'
},
paid: {
type: 'boolean',
description: 'Whether the payment was successful',
example: true
},
amount: {
type: 'integer',
description: 'Total amount redeemed in satoshis',
example: 21000
},
to: {
type: 'string',
description: 'Lightning address that received the payment',
example: 'user@ln.tips'
},
fee: {
type: 'integer',
description: 'Actual fee charged by mint in satoshis',
example: 1000
},
actualFee: {
type: 'integer',
description: 'Calculated fee according to NUT-05 (2% min 1 sat)',
example: 420
},
netAmount: {
type: 'integer',
description: 'Net amount after fees in satoshis',
example: 20000
},
mint_url: {
type: 'string',
format: 'uri',
description: 'Mint URL used for redemption',
example: 'https://mint.azzamo.net'
},
format: {
type: 'string',
enum: ['cashuA', 'cashuB'],
description: 'Token format that was redeemed',
example: 'cashuA'
},
preimage: {
type: 'string',
description: 'Lightning payment preimage (if available)',
example: 'abc123def456...'
},
change: {
type: 'array',
description: 'Change proofs returned (if any)',
items: {
type: 'object'
},
example: []
},
usingDefaultAddress: {
type: 'boolean',
description: 'Whether default Lightning address was used',
example: false
},
message: {
type: 'string',
description: 'Additional message (when using default address)',
example: 'Redeemed to default Lightning address: admin@example.com'
}
}
},
// Status Schemas
StatusRequest: {
type: 'object',
required: ['redeemId'],
properties: {
redeemId: {
type: 'string',
format: 'uuid',
description: 'Redemption ID to check status for',
example: '8e99101e-d034-4d2e-9ccf-dfda24d26762'
}
}
},
StatusResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
status: {
type: 'string',
enum: ['processing', 'parsing_token', 'checking_spendability', 'resolving_invoice', 'melting_token', 'paid', 'failed'],
description: 'Current redemption status',
example: 'paid'
},
details: {
type: 'object',
properties: {
amount: {
type: 'integer',
description: 'Amount in satoshis',
example: 21000
},
to: {
type: 'string',
description: 'Lightning address',
example: 'user@ln.tips'
},
paid: {
type: 'boolean',
example: true
},
createdAt: {
type: 'string',
format: 'date-time',
example: '2025-01-14T11:59:30Z'
},
updatedAt: {
type: 'string',
format: 'date-time',
example: '2025-01-14T12:00:00Z'
},
paidAt: {
type: 'string',
format: 'date-time',
example: '2025-01-14T12:00:00Z'
},
fee: {
type: 'integer',
description: 'Fee charged in satoshis',
example: 1000
},
error: {
type: 'string',
description: 'Error message if failed',
example: null
},
mint: {
type: 'string',
format: 'uri',
description: 'Mint URL',
example: 'https://mint.azzamo.net'
},
domain: {
type: 'string',
description: 'Lightning address domain',
example: 'ln.tips'
}
}
}
}
},
// Health Schema
HealthResponse: {
type: 'object',
properties: {
status: {
type: 'string',
example: 'ok'
},
timestamp: {
type: 'string',
format: 'date-time',
example: '2025-01-14T12:00:00Z'
},
uptime: {
type: 'number',
description: 'Server uptime in seconds',
example: 3600
},
memory: {
type: 'object',
description: 'Memory usage information',
example: {
"rss": 45678912,
"heapTotal": 12345678,
"heapUsed": 8765432
}
},
version: {
type: 'string',
example: '1.0.0'
}
}
},
// Stats Schema
StatsResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
stats: {
type: 'object',
properties: {
total: {
type: 'integer',
description: 'Total number of redemptions',
example: 150
},
paid: {
type: 'integer',
description: 'Number of successful redemptions',
example: 142
},
failed: {
type: 'integer',
description: 'Number of failed redemptions',
example: 8
},
processing: {
type: 'integer',
description: 'Number of currently processing redemptions',
example: 0
},
totalAmount: {
type: 'integer',
description: 'Total amount redeemed in satoshis',
example: 2500000
},
totalFees: {
type: 'integer',
description: 'Total fees collected in satoshis',
example: 15000
}
}
}
}
},
// Validate Address Schemas
ValidateAddressRequest: {
type: 'object',
required: ['lightningAddress'],
properties: {
lightningAddress: {
type: 'string',
format: 'email',
description: 'Lightning address to validate',
example: 'user@ln.tips'
}
}
},
ValidateAddressResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
valid: {
type: 'boolean',
example: true
},
domain: {
type: 'string',
example: 'ln.tips'
},
minSendable: {
type: 'integer',
description: 'Minimum sendable amount in satoshis',
example: 1
},
maxSendable: {
type: 'integer',
description: 'Maximum sendable amount in satoshis',
example: 100000000
},
commentAllowed: {
type: 'integer',
description: 'Maximum comment length allowed',
example: 144
},
error: {
type: 'string',
description: 'Error message if validation failed',
example: null
}
}
},
// Check Spendable Schemas
CheckSpendableRequest: {
type: 'object',
required: ['token'],
properties: {
token: {
type: 'string',
description: 'Cashu token to check spendability',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
}
}
},
CheckSpendableResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
spendable: {
type: 'array',
items: {
type: 'boolean'
},
description: 'Array indicating which proofs are spendable',
example: [true, true, false]
},
pending: {
type: 'array',
items: {
type: 'boolean'
},
description: 'Array indicating which proofs are pending',
example: []
},
mintUrl: {
type: 'string',
format: 'uri',
description: 'Mint URL where spendability was checked',
example: 'https://mint.azzamo.net'
},
totalAmount: {
type: 'integer',
description: 'Total amount of the token in satoshis',
example: 21000
},
message: {
type: 'string',
description: 'Human-readable status message',
example: 'Token is spendable'
}
}
}
},
responses: {
BadRequest: {
description: 'Bad request - invalid input',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/ErrorResponse'
}
}
}
},
NotFound: {
description: 'Resource not found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/ErrorResponse'
}
}
}
},
InternalServerError: {
description: 'Internal server error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/ErrorResponse'
}
}
}
},
TooManyRequests: {
description: 'Rate limit exceeded',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/ErrorResponse' },
{
properties: {
error: {
example: 'Rate limit exceeded. Please try again later.'
}
}
}
]
}
}
}
}
}
},
tags: [
{
name: 'Token Operations',
description: 'Operations for decoding and redeeming Cashu tokens'
},
{
name: 'Status & Monitoring',
description: 'Endpoints for checking redemption status and API health'
},
{
name: 'Validation',
description: 'Validation utilities for tokens and Lightning addresses'
}
]
},
apis: ['./server.js'], // paths to files containing OpenAPI definitions
};
const specs = swaggerJsdoc(options);
module.exports = specs;