Initial commit
This commit is contained in:
133
.gitignore
vendored
Normal file
133
.gitignore
vendored
Normal 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
479
README.md
Normal 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
19
env.example
Normal 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
2860
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal 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
617
server.js
Normal 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
301
services/cashu.js
Normal 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
276
services/lightning.js
Normal 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
353
services/redemption.js
Normal 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
554
swagger.config.js
Normal 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;
|
||||
Reference in New Issue
Block a user