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