Compare commits

..

12 Commits

Author SHA1 Message Date
Michilis
bd18271957 Update README.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 03:50:03 +00:00
Michilis
50e5787ec2 Refactor: move services to components, add route modules
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 03:46:46 +00:00
Michilis
e2a13d009f Update README.md 2025-07-19 09:52:10 +02:00
Michilis
a5c32b574c Update README.md 2025-07-19 09:50:30 +02:00
Michilis
4685a5e42a Merge pull request #2 from Michilis/dev
Updates
2025-07-16 00:08:38 +02:00
Michilis
aa93c50341 docs: remove empty General category from Swagger tags
- Remove the General tag definition from swagger.config.js
- Clean up the tags array to only include active categories
- Eliminates empty General category from Swagger UI
2025-07-15 22:07:29 +00:00
Michilis
184f28fe5d docs: remove GET / endpoint from Swagger documentation
- Remove the General category and GET / endpoint from API docs
- Keep the endpoint functional but hidden from Swagger UI
- Clean up documentation to focus on core API endpoints
2025-07-15 22:05:42 +00:00
Michilis
bf1f368a24 fix: enhance CORS configuration for production with Nginx proxy
- Add enhanced CORS headers for Swagger UI compatibility
- Add OPTIONS method support for preflight requests
- Add additional middleware for CORS preflight handling
- Add debug endpoint /api/cors-test for CORS troubleshooting
- Improve Swagger UI configuration for production deployment
2025-07-15 21:56:55 +00:00
Michilis
91259e98c6 feat: add API_DOMAIN environment variable for production configuration
- Add API_DOMAIN env var to set domain/IP for API in production
- Update swagger.config.js to use dynamic server URLs
- Update server.js CORS configuration to use API_DOMAIN
- Update env.example with new API_DOMAIN field
- Add comprehensive documentation in README.md
- Fixes CORS issues in Swagger docs for production deployments
2025-07-15 21:45:40 +00:00
Michilis
05cdee8236 Merge pull request #1 from Michilis/dev
Fee calculation fix
2025-07-15 20:05:42 +02:00
Michilis
ba36f96f4c Remove redeemId field from redeem response
- Remove redeemId from successful redemption response
- Remove redeemId from error response
- Update swagger documentation to remove redeemId field
- Update README examples to remove redeemId field
- Clean up API response format for better simplicity
2025-07-15 18:03:56 +00:00
Michilis
52d4735712 Update package.json with improved metadata and version bump
- Bump version to 1.1.0
- Update description to be more comprehensive
- Update repository URLs to correct GitHub username
- Add funding information
- Add new keywords (bolt11, lightning-network, payment, redemption)
- Add docs script for API documentation
- Improve overall package metadata
2025-07-15 17:43:32 +00:00
14 changed files with 1127 additions and 1464 deletions

343
README.md
View File

@@ -1,35 +1,28 @@
# Cashu Redeem API 🪙⚡ # Cashu Redeem API
A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol. A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the [cashu-ts](https://github.com/cashubtc/cashu-ts) library and LNURLp protocol.
## 🚀 Features ## Features
- **Decode Cashu tokens** - Parse and validate token content - **Decode Cashu tokens** - Parse and validate token content, check spendability at the mint
- **Redeem to Lightning addresses** - Convert ecash to Lightning payments via LNURLp - **Redeem to Lightning addresses** - Convert ecash to Lightning payments via LNURLp with exact fee calculation
- **Security features** - Domain restrictions, rate limiting, input validation - **Lightning address validation** - Verify addresses and query LNURLp capabilities
- **Robust error handling** - Comprehensive error messages - **Duplicate detection** - In-memory tracking prevents double-spend attempts
- **In-memory caching** - Fast mint and wallet instances with connection pooling - **Security** - Domain restrictions, rate limiting, input validation, CORS protection
- **Interactive API Documentation** - Complete Swagger/OpenAPI documentation at `/docs` - **Interactive API docs** - Complete Swagger/OpenAPI documentation at `/docs`
## API Documentation
## 📖 API Documentation Interactive Swagger documentation is available at `/docs` when the server is running.
**Interactive Swagger Documentation**: Visit `/docs` when running the server for a complete, interactive API reference. - Local: `http://localhost:3000/docs`
- OpenAPI spec: `http://localhost:3000/openapi.json`
Example: `https://cashu-redeem.azzamo.net/docs/` ## API Endpoints
The documentation includes: ### `POST /api/decode`
- Complete endpoint specifications
- Request/response schemas
- Try-it-out functionality
- Example requests and responses
- Authentication requirements
- Error code documentation
## 📡 API Endpoints Decode a Cashu token and return its content. Supports both v3 (`cashuB`) and v1 (`cashuA`) token formats. Checks spendability against the mint.
### 1. `POST /api/decode`
Decode a Cashu token and return its content. Supports both v1 and v3 token formats.
**Request:** **Request:**
```json ```json
@@ -43,25 +36,33 @@ Decode a Cashu token and return its content. Supports both v1 and v3 token forma
{ {
"success": true, "success": true,
"decoded": { "decoded": {
"mint": "https://mint.azzamo.net", "mint": "https://kashu.me",
"totalAmount": 21000, "totalAmount": 16,
"numProofs": 3, "numProofs": 5,
"denominations": [1000, 10000, 10000], "denominations": [8, 4, 2, 1, 1],
"format": "cashuA", "format": "cashuB",
"spent": false "spent": false
}, },
"mint_url": "https://mint.azzamo.net" "mint_url": "https://kashu.me"
} }
``` ```
### 2. `POST /api/redeem` ### `POST /api/redeem`
Redeem a Cashu token to a Lightning address. Lightning address is optional - if not provided, uses the default address from configuration.
Redeem a Cashu token to a Lightning address. The Lightning address is optional if a default is configured.
The redemption process:
1. Token validation and parsing
2. Spendability check at the mint
3. Melt quote to determine exact fees
4. Invoice creation for the net amount (token amount minus fees)
5. Token melting and Lightning payment
**Request:** **Request:**
```json ```json
{ {
"token": "cashuB...", "token": "cashuB...",
"lightningAddress": "user@ln.tips" "lightningAddress": "user@twentyone.tips"
} }
``` ```
@@ -76,53 +77,37 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if
```json ```json
{ {
"success": true, "success": true,
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762",
"paid": true, "paid": true,
"amount": 21000, "amount": 16,
"invoiceAmount": 20580, "invoiceAmount": 14,
"to": "user@ln.tips", "to": "user@twentyone.tips",
"fee": 1000, "fee": 2,
"actualFee": 420, "netAmount": 14,
"netAmount": 20000, "mint_url": "https://kashu.me",
"mint_url": "https://mint.azzamo.net", "format": "cashuB",
"format": "cashuA", "preimage": "d5af74a3..."
"preimage": "abc123..."
} }
``` ```
**Success Response (using default address):** **Response fields:**
```json
{
"success": true,
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762",
"paid": true,
"amount": 21000,
"invoiceAmount": 20580,
"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"
}
```
| Field | Description |
|-------|-------------|
| `amount` | Total token amount in sats |
| `invoiceAmount` | Amount sent to the Lightning address (after fees) |
| `fee` | Exact fee charged by the mint (from melt quote) |
| `netAmount` | Net amount received |
| `preimage` | Lightning payment preimage (proof of payment) |
| `usingDefaultAddress` | Present and `true` when the default address was used |
**Payment Verification**: ### `POST /api/validate-address`
The API uses multiple indicators to verify payment success:
- `paid` flag from mint response
- Presence of payment preimage
- Payment state indicators
### 3. `POST /api/validate-address` Validate a Lightning address without performing a redemption. Tests LNURLp resolution and returns address capabilities.
Validate a Lightning address without redemption.
**Request:** **Request:**
```json ```json
{ {
"lightningAddress": "user@ln.tips" "lightningAddress": "user@twentyone.tips"
} }
``` ```
@@ -131,225 +116,205 @@ Validate a Lightning address without redemption.
{ {
"success": true, "success": true,
"valid": true, "valid": true,
"domain": "ln.tips", "domain": "twentyone.tips",
"minSendable": 1, "minSendable": 1,
"maxSendable": 100000000, "maxSendable": 1000000,
"commentAllowed": 144 "commentAllowed": 2000
} }
``` ```
### 4. `GET /api/health` ### `GET /api/health`
Health check endpoint.
Health check endpoint returning server status, uptime, and memory usage.
**Response:** **Response:**
```json ```json
{ {
"status": "ok", "status": "ok",
"timestamp": "2025-01-14T12:00:00Z", "timestamp": "2026-02-17T03:36:40.987Z",
"uptime": 3600, "uptime": 170.23,
"memory": {...}, "memory": { "rss": 83873792, "heapTotal": 20520960, "heapUsed": 17170824 },
"version": "1.0.0" "version": "2.0.0"
} }
``` ```
## 🛠 Setup & Installation ### `GET /openapi.json`
Returns the full OpenAPI 3.0 specification as JSON.
## Setup & Installation
### Prerequisites ### Prerequisites
- Node.js >= 18.0.0 - Node.js >= 18.0.0
- npm or yarn - npm >= 8.0.0
### Installation ### Installation
1. **Clone and install dependencies:** 1. **Clone and install dependencies:**
```bash ```bash
git clone <your-repo> git clone https://github.com/Michilis/cashu-redeem-api.git
cd cashu-redeem-api cd cashu-redeem-api
npm install npm install
``` ```
2. **Setup environment variables:** 2. **Configure environment variables:**
```bash ```bash
cp env.example .env cp env.example .env
``` ```
Edit `.env` file: Edit `.env`:
```bash ```bash
# Server Configuration # Server Configuration
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development
# API Domain/IP Configuration (for Swagger docs and CORS)
API_DOMAIN=localhost:3000
# Security Configuration # Security Configuration
ALLOW_REDEEM_DOMAINS=ln.tips,getalby.com,wallet.mutinywallet.com ALLOW_REDEEM_DOMAINS=*
API_SECRET=your-secret-key-here
# Default Lightning Address (used when no address is provided in redeem requests) # Default Lightning Address (used when no address is provided in redeem requests)
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com
# Rate Limiting (requests per minute) # Rate Limiting (requests per minute per IP)
RATE_LIMIT=100 RATE_LIMIT=30
# Logging
LOG_LEVEL=info
# CORS Configuration # CORS Configuration
ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com ALLOWED_ORIGINS=*
``` ```
3. **Start the server:** 3. **Start the server:**
```bash ```bash
# Development # Development (with auto-reload)
npm run dev npm run dev
# Production # Production
npm start npm start
``` ```
The API will be available at `http://localhost:3000` ## Configuration
## 🔧 Configuration
### Environment Variables ### Environment Variables
| Variable | Description | Default | Required | | Variable | Description | Default | Required |
|----------|-------------|---------|----------| |----------|-------------|---------|----------|
| `PORT` | Server port | `3000` | No | | `PORT` | Server port | `3000` | No |
| `NODE_ENV` | Environment | `development` | No | | `NODE_ENV` | Environment (`development` / `production`) | `development` | No |
| `ALLOW_REDEEM_DOMAINS` | Comma-separated allowed domains | All allowed | No | | `API_DOMAIN` | Domain for Swagger docs and CORS | `localhost:3000` | No |
| `ALLOW_REDEEM_DOMAINS` | Comma-separated allowed Lightning address domains (`*` for all) | All allowed | No |
| `DEFAULT_LIGHTNING_ADDRESS` | Default Lightning address for redemptions | None | No | | `DEFAULT_LIGHTNING_ADDRESS` | Default Lightning address for redemptions | None | No |
| `RATE_LIMIT` | Requests per minute per IP | `100` | No | | `RATE_LIMIT` | Requests per minute per IP | `100` | No |
| `ALLOWED_ORIGINS` | CORS allowed origins | `http://localhost:3000` | No | | `LOG_LEVEL` | Logging level | `info` | No |
| `ALLOWED_ORIGINS` | CORS allowed origins (`*` for all) | `http://localhost:3000` | No |
### Domain Restrictions ### Domain Restrictions
To restrict redemptions to specific Lightning address domains, set: Restrict redemptions to specific Lightning address domains:
```bash ```bash
ALLOW_REDEEM_DOMAINS=ln.tips,getalby.com,wallet.mutinywallet.com ALLOW_REDEEM_DOMAINS=twentyone.tips,getalby.com,walletofsatoshi.com
``` ```
If not set, all domains are allowed. If set to `*` or not set, all domains are allowed.
### Default Lightning Address ### Default Lightning Address
To set a default Lightning address that will be used when no address is provided in redemption requests: Set a fallback address used when no address is provided in redemption requests:
```bash ```bash
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com 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. If no default is configured, the `lightningAddress` field becomes required for all redemption requests.
## 🏗 Architecture ## Project Structure
### Services ```
cashu-redeem-api/
server.js # Express app, middleware, route mounting
swagger.config.js # OpenAPI/Swagger configuration and schemas
components/
cashu.js # Token parsing, spendability checks, melt operations
lightning.js # LNURLp resolution, invoice creation, address validation
redemption.js # Orchestrates the full redemption flow
routes/
cashu.js # POST /api/decode
redemption.js # POST /api/redeem
lightning.js # POST /api/validate-address
health.js # GET /api/health
```
#### `services/cashu.js` ## Redemption Flow
- 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` 1. **Token Parsing** - Validate and decode the Cashu token structure
- Validates Lightning address formats 2. **Spendability Check** - Verify proofs are unspent at the mint
- Resolves LNURLp endpoints 3. **Melt Quote** - Get exact fee from the mint before creating the invoice
- Generates Lightning invoices 4. **Address Resolution** - Resolve Lightning address via LNURLp
- Handles domain restrictions 5. **Invoice Generation** - Create a Lightning invoice for the net amount (total minus fee)
6. **Token Melting** - Melt the token at the mint to pay the invoice
#### `services/redemption.js` ## Internal Status Codes
- Manages redemption status tracking
- Handles duplicate token detection
### 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
## 🔒 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
## 🚦 Status Codes
| Status | Description | | Status | Description |
|--------|-------------| |--------|-------------|
| `processing` | Redemption is in progress | | `processing` | Redemption initiated |
| `parsing_token` | Validating and parsing the token | | `parsing_token` | Validating and parsing the token |
| `resolving_invoice` | Resolving Lightning address to invoice | | `checking_spendability` | Checking proofs at the mint |
| `getting_melt_quote` | Requesting fee quote from mint |
| `resolving_invoice` | Creating Lightning invoice via LNURLp |
| `melting_token` | Performing the melt operation | | `melting_token` | Performing the melt operation |
| `paid` | Successfully paid and completed | | `paid` | Successfully completed |
| `failed` | Redemption failed (see error details) | | `failed` | Redemption failed (see error details) |
## 📊 Monitoring ## Security Features
### Health Check - **Input validation** - All inputs are sanitized and validated
- **Rate limiting** - Configurable per-IP request limits
- **Domain restrictions** - Limit allowed Lightning address domains
- **CORS protection** - Configurable allowed origins
- **Duplicate detection** - Prevents resubmission of already-redeemed tokens
- **Error handling** - Comprehensive error messages without sensitive data leaks
## Testing
The easiest way to test the API is using the interactive Swagger documentation:
1. Start the server: `npm run dev`
2. Visit `http://localhost:3000/docs`
3. Click "Try it out" on any endpoint
4. Fill in the request parameters and execute
Or use curl:
```bash ```bash
# Health check
curl http://localhost:3000/api/health curl http://localhost:3000/api/health
```
### Logs # Decode a token
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 \ curl -X POST http://localhost:3000/api/decode \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"token":"your-cashu-token-here"}' -d '{"token": "cashuB..."}'
```
**Redeem a token to specific address:** # Validate a Lightning address
```bash curl -X POST http://localhost:3000/api/validate-address \
-H "Content-Type: application/json" \
-d '{"lightningAddress": "user@twentyone.tips"}'
# Redeem a token
curl -X POST http://localhost:3000/api/redeem \ curl -X POST http://localhost:3000/api/redeem \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{"token": "cashuB...", "lightningAddress": "user@twentyone.tips"}'
"token": "your-cashu-token-here",
"lightningAddress": "user@ln.tips"
}'
``` ```
**Redeem a token to default address:** ## Contributing
```bash
curl -X POST http://localhost:3000/api/redeem \
-H "Content-Type: application/json" \
-d '{
"token": "your-cashu-token-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
## 🤝 Contributing
1. Fork the repository 1. Fork the repository
2. Create a feature branch 2. Create a feature branch
3. Make your changes 3. Make your changes
4. Add tests if applicable 4. Submit a pull request
5. Submit a pull request
## 📝 License ## License
MIT License - see LICENSE file for details. MIT License - see LICENSE file for details.

369
components/cashu.js Normal file
View File

@@ -0,0 +1,369 @@
const { Wallet, Mint, getDecodedToken, CheckStateEnum } = require('@cashu/cashu-ts');
class CashuComponent {
constructor() {
this.mints = new Map();
this.wallets = new Map();
}
/**
* Validate token format (supports both cashuA and cashuB formats)
*/
isValidTokenFormat(token) {
return /^cashu[abAB][a-zA-Z0-9-_]+$/.test(token);
}
/**
* Get token mint URL from decoded token
*/
async getTokenMintUrl(token) {
try {
const decoded = getDecodedToken(token);
if (!decoded) return null;
return decoded.mint || null;
} catch (error) {
console.error('Error getting token mint URL:', error);
return null;
}
}
/**
* Decode token structure (v3/v4 format always has { mint, proofs } at top level)
*/
async decodeTokenStructure(token) {
try {
const decoded = getDecodedToken(token);
if (!decoded) throw new Error('Failed to decode token');
if (!decoded.proofs || !Array.isArray(decoded.proofs)) {
throw new Error('Invalid token structure - no proofs found');
}
return {
proofs: decoded.proofs,
mint: decoded.mint,
unit: decoded.unit || 'sat'
};
} catch (error) {
throw new Error(`Token decoding failed: ${error.message}`);
}
}
/**
* Calculate fee according to NUT-05 specification
*/
calculateFee(amount) {
const fee = Math.ceil(amount * 0.02);
return Math.max(1, fee);
}
/**
* Parse and validate a Cashu token
*/
async parseToken(token) {
try {
if (!token || typeof token !== 'string') {
throw new Error('Invalid token format');
}
token = token.trim();
if (!this.isValidTokenFormat(token)) {
throw new Error('Invalid token format. Must be a valid Cashu token');
}
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');
}
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,
unit: decoded.unit || 'sat',
format: token.startsWith('cashuA') ? 'cashuA' : 'cashuB'
};
} catch (error) {
throw new Error(`Token parsing failed: ${error.message}`);
}
}
/**
* Get total amount from a token
*/
async getTotalAmount(token) {
const parsed = await this.parseToken(token);
return parsed.totalAmount;
}
/**
* Get or create a Mint instance
*/
async getMint(mintUrl) {
if (!this.mints.has(mintUrl)) {
try {
const mint = new Mint(mintUrl);
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
*/
async getWallet(mintUrl) {
if (!this.wallets.has(mintUrl)) {
try {
const wallet = new Wallet(mintUrl);
await wallet.loadMint();
this.wallets.set(mintUrl, wallet);
} catch (error) {
throw new Error(`Failed to create wallet for mint ${mintUrl}: ${error.message}`);
}
}
return this.wallets.get(mintUrl);
}
/**
* Get melt quote for a Cashu token and Lightning invoice
*/
async getMeltQuote(token, bolt11) {
try {
const parsed = await this.parseToken(token);
const wallet = await this.getWallet(parsed.mint);
const meltQuote = await wallet.createMeltQuoteBolt11(bolt11);
console.log('Melt quote created:', {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
});
return {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
};
} catch (error) {
throw new Error(`Failed to get melt quote: ${error.message}`);
}
}
/**
* Melt a Cashu token to pay a Lightning invoice
*/
async meltToken(token, bolt11) {
try {
const parsed = await this.parseToken(token);
const wallet = await this.getWallet(parsed.mint);
const decoded = await this.decodeTokenStructure(token);
const proofs = decoded.proofs;
const meltQuote = await wallet.createMeltQuoteBolt11(bolt11);
console.log('Melt quote created:', {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
});
console.log('Paying invoice:', bolt11.substring(0, 50) + '...');
const total = meltQuote.amount + meltQuote.fee_reserve;
console.log('Total required:', total, 'sats (amount:', meltQuote.amount, '+ fee:', meltQuote.fee_reserve, ')');
console.log('Available in token:', parsed.totalAmount, 'sats');
if (total > parsed.totalAmount) {
throw new Error(`Insufficient funds. Required: ${total} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`);
}
console.log('Selecting proofs with includeFees: true for', total, 'sats');
const { send: proofsToSend } = await wallet.send(total, proofs, {
includeFees: true,
});
console.log('Selected', proofsToSend.length, 'proofs for melting');
console.log('Performing melt operation...');
const meltResponse = await wallet.meltProofs(meltQuote, proofsToSend);
console.log('Melt response:', JSON.stringify(meltResponse, null, 2));
const quote = meltResponse.quote || {};
const paymentSuccessful = quote.state === 'PAID' ||
quote.payment_preimage ||
meltResponse.paid === true;
if (!paymentSuccessful) {
console.warn('Payment verification - state:', quote.state);
}
const preimage = quote.payment_preimage || meltResponse.preimage;
const actualFeeCharged = quote.fee_reserve || meltQuote.fee_reserve;
const actualNetAmount = parsed.totalAmount - actualFeeCharged;
return {
success: true,
paid: paymentSuccessful,
preimage,
change: meltResponse.change || [],
amount: meltQuote.amount,
fee: actualFeeCharged,
netAmount: actualNetAmount,
quote: meltQuote.quote,
rawMeltResponse: meltResponse
};
} catch (error) {
if (error.message.includes('Insufficient funds') ||
error.message.includes('Payment failed') ||
error.message.includes('Quote not found')) {
throw error;
}
if (error.status === 422 ||
error.message.includes('already spent') ||
error.message.includes('not spendable') ||
error.message.includes('invalid proofs')) {
throw new Error('This token has already been spent and cannot be redeemed again');
}
throw new Error(`Melt operation failed: ${error.message}`);
}
}
/**
* Validate if a token is properly formatted and has valid proofs
*/
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
*/
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 using NUT-07 state check
*/
async checkTokenSpendable(token) {
try {
const parsed = await this.parseToken(token);
const wallet = await this.getWallet(parsed.mint);
console.log(`Checking spendability for ${parsed.proofs.length} proofs at mint: ${parsed.mint}`);
const proofStates = await wallet.checkProofsStates(parsed.proofs);
console.log('Proof states:', proofStates);
const spendable = [];
const pending = [];
const spent = [];
for (let i = 0; i < proofStates.length; i++) {
const state = proofStates[i];
if (state.state === CheckStateEnum.UNSPENT) {
spendable.push(parsed.proofs[i]);
} else if (state.state === CheckStateEnum.PENDING) {
pending.push(parsed.proofs[i]);
} else if (state.state === CheckStateEnum.SPENT) {
spent.push(parsed.proofs[i]);
}
}
return {
spendable,
pending,
spent,
mintUrl: parsed.mint,
totalAmount: parsed.totalAmount
};
} catch (error) {
console.error('Spendability check error details:', {
errorType: error.constructor.name,
errorMessage: error.message,
errorStatus: error.status
});
let errorMessage = 'Unknown error occurred';
if (error.constructor.name === 'HttpResponseError' || error.constructor.name === 'MintOperationError') {
const status = error.status || error.response?.status;
if (status === 422) {
const detail = error.response?.data?.detail || error.detail;
if (detail) {
errorMessage = `Token validation failed: ${detail}`;
} else {
errorMessage = 'Token proofs are not spendable - they may have already been used or are invalid';
}
} else if (status === 404 || status === 405 || status === 501) {
errorMessage = 'This mint does not support spendability checking';
} else {
errorMessage = error.message && error.message !== '[object Object]'
? error.message
: `Mint returned HTTP ${status}`;
}
} else if (error.message && error.message !== '[object Object]') {
errorMessage = error.message;
}
console.log('Final extracted error message:', errorMessage);
if (errorMessage.includes('not supported') ||
errorMessage.includes('404') ||
errorMessage.includes('405') ||
errorMessage.includes('501') ||
errorMessage.includes('endpoint not found') ||
errorMessage.includes('not implemented')) {
throw new Error('This mint does not support spendability checking. Token may still be valid.');
}
const status = error.status || error.response?.status;
if (status === 422) {
if (errorMessage.includes('already been used') ||
errorMessage.includes('already spent') ||
errorMessage.includes('not spendable')) {
throw new Error('TOKEN_SPENT: Token proofs are not spendable - they have already been used');
} else {
throw new Error(`Token validation failed at mint: ${errorMessage}`);
}
} else if (errorMessage.includes('Token proofs are not spendable') ||
errorMessage.includes('already been used') ||
errorMessage.includes('invalid proofs')) {
throw new Error('TOKEN_SPENT: Token proofs are not spendable - they have already been used');
}
throw new Error(`Failed to check token spendability: ${errorMessage}`);
}
}
}
module.exports = new CashuComponent();

View File

@@ -1,7 +1,7 @@
const axios = require('axios'); const axios = require('axios');
const bolt11 = require('bolt11'); const bolt11 = require('bolt11');
class LightningService { class LightningComponent {
constructor() { constructor() {
this.allowedDomains = process.env.ALLOW_REDEEM_DOMAINS this.allowedDomains = process.env.ALLOW_REDEEM_DOMAINS
? process.env.ALLOW_REDEEM_DOMAINS.split(',').map(d => d.trim()) ? process.env.ALLOW_REDEEM_DOMAINS.split(',').map(d => d.trim())
@@ -11,7 +11,6 @@ class LightningService {
/** /**
* Get the default Lightning address from environment * Get the default Lightning address from environment
* @returns {string|null} Default Lightning address or null if not set
*/ */
getDefaultLightningAddress() { getDefaultLightningAddress() {
return this.defaultLightningAddress || null; return this.defaultLightningAddress || null;
@@ -19,8 +18,6 @@ class LightningService {
/** /**
* Get Lightning address to use - provided address or default * 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) { getLightningAddressToUse(providedAddress) {
if (providedAddress && providedAddress.trim()) { if (providedAddress && providedAddress.trim()) {
@@ -37,40 +34,26 @@ class LightningService {
/** /**
* Validate Lightning Address format * Validate Lightning Address format
* @param {string} lightningAddress - The Lightning address (user@domain.com)
* @returns {boolean} Whether the address is valid
*/ */
validateLightningAddress(lightningAddress) { validateLightningAddress(lightningAddress) {
if (!lightningAddress || typeof lightningAddress !== 'string') { if (!lightningAddress || typeof lightningAddress !== 'string') {
return false; return false;
} }
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(lightningAddress); return emailRegex.test(lightningAddress);
} }
/** /**
* Check if a domain is allowed for redemption * Check if a domain is allowed for redemption
* @param {string} domain - The domain to check
* @returns {boolean} Whether the domain is allowed
*/ */
isDomainAllowed(domain) { isDomainAllowed(domain) {
if (this.allowedDomains.length === 0) { if (this.allowedDomains.length === 0) return true;
return true; // If no restrictions, allow all if (this.allowedDomains.includes('*')) return true;
}
// Check for wildcard allowing all domains
if (this.allowedDomains.includes('*')) {
return true;
}
return this.allowedDomains.includes(domain.toLowerCase()); return this.allowedDomains.includes(domain.toLowerCase());
} }
/** /**
* Parse Lightning Address into username and domain * Parse Lightning Address into username and domain
* @param {string} lightningAddress - The Lightning address
* @returns {Object} Parsed address components
*/ */
parseLightningAddress(lightningAddress) { parseLightningAddress(lightningAddress) {
if (!this.validateLightningAddress(lightningAddress)) { if (!this.validateLightningAddress(lightningAddress)) {
@@ -88,8 +71,6 @@ class LightningService {
/** /**
* Resolve LNURLp endpoint from Lightning address * Resolve LNURLp endpoint from Lightning address
* @param {string} lightningAddress - The Lightning address
* @returns {string} LNURLp endpoint URL
*/ */
getLNURLpEndpoint(lightningAddress) { getLNURLpEndpoint(lightningAddress) {
const { username, domain } = this.parseLightningAddress(lightningAddress); const { username, domain } = this.parseLightningAddress(lightningAddress);
@@ -98,16 +79,12 @@ class LightningService {
/** /**
* Fetch LNURLp response from endpoint * Fetch LNURLp response from endpoint
* @param {string} lnurlpUrl - The LNURLp endpoint URL
* @returns {Object} LNURLp response data
*/ */
async fetchLNURLpResponse(lnurlpUrl) { async fetchLNURLpResponse(lnurlpUrl) {
try { try {
const response = await axios.get(lnurlpUrl, { const response = await axios.get(lnurlpUrl, {
timeout: 10000, timeout: 10000,
headers: { headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' }
'User-Agent': 'Cashu-Redeem-API/1.0.0'
}
}); });
if (response.status !== 200) { if (response.status !== 200) {
@@ -135,10 +112,6 @@ class LightningService {
/** /**
* Get Lightning invoice from LNURLp callback * 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 = '') { async getInvoice(callbackUrl, amount, comment = '') {
try { try {
@@ -146,14 +119,12 @@ class LightningService {
url.searchParams.set('amount', amount.toString()); url.searchParams.set('amount', amount.toString());
if (comment && comment.length > 0) { if (comment && comment.length > 0) {
url.searchParams.set('comment', comment.substring(0, 144)); // LN comment limit url.searchParams.set('comment', comment.substring(0, 144));
} }
const response = await axios.get(url.toString(), { const response = await axios.get(url.toString(), {
timeout: 10000, timeout: 10000,
headers: { headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' }
'User-Agent': 'Cashu-Redeem-API/1.0.0'
}
}); });
if (response.status !== 200) { if (response.status !== 200) {
@@ -182,8 +153,6 @@ class LightningService {
/** /**
* Convert satoshis to millisatoshis * Convert satoshis to millisatoshis
* @param {number} sats - Amount in satoshis
* @returns {number} Amount in millisatoshis
*/ */
satsToMillisats(sats) { satsToMillisats(sats) {
return sats * 1000; return sats * 1000;
@@ -191,8 +160,6 @@ class LightningService {
/** /**
* Convert millisatoshis to satoshis * Convert millisatoshis to satoshis
* @param {number} msats - Amount in millisatoshis
* @returns {number} Amount in satoshis
*/ */
millisatsToSats(msats) { millisatsToSats(msats) {
return Math.floor(msats / 1000); return Math.floor(msats / 1000);
@@ -200,34 +167,24 @@ class LightningService {
/** /**
* Validate amount against LNURLp constraints * 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) { validateAmount(amount, lnurlpResponse) {
const amountMsats = this.satsToMillisats(amount); const amountMsats = this.satsToMillisats(amount);
const minSendable = parseInt(lnurlpResponse.minSendable); const minSendable = parseInt(lnurlpResponse.minSendable);
const maxSendable = parseInt(lnurlpResponse.maxSendable); const maxSendable = parseInt(lnurlpResponse.maxSendable);
return amountMsats >= minSendable && amountMsats <= maxSendable; return amountMsats >= minSendable && amountMsats <= maxSendable;
} }
/** /**
* Full Lightning address to invoice resolution * 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') { async resolveInvoice(lightningAddress, amount, comment = 'Cashu token redemption') {
try { try {
console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`); console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`);
// Get LNURLp endpoint
const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress); const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress);
console.log(`LNURLp endpoint: ${lnurlpUrl}`); console.log(`LNURLp endpoint: ${lnurlpUrl}`);
// Fetch LNURLp response
const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl); const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl);
console.log('LNURLp response:', { console.log('LNURLp response:', {
callback: lnurlpResponse.callback, callback: lnurlpResponse.callback,
@@ -235,14 +192,12 @@ class LightningService {
maxSendable: lnurlpResponse.maxSendable maxSendable: lnurlpResponse.maxSendable
}); });
// Validate amount
if (!this.validateAmount(amount, lnurlpResponse)) { if (!this.validateAmount(amount, lnurlpResponse)) {
const minSats = this.millisatsToSats(lnurlpResponse.minSendable); const minSats = this.millisatsToSats(lnurlpResponse.minSendable);
const maxSats = this.millisatsToSats(lnurlpResponse.maxSendable); const maxSats = this.millisatsToSats(lnurlpResponse.maxSendable);
throw new Error(`Amount ${amount} sats is outside allowed range: ${minSats}-${maxSats} sats`); throw new Error(`Amount ${amount} sats is outside allowed range: ${minSats}-${maxSats} sats`);
} }
// Get invoice
const amountMsats = this.satsToMillisats(amount); const amountMsats = this.satsToMillisats(amount);
console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`); console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`);
console.log(`Using callback URL: ${lnurlpResponse.callback}`); console.log(`Using callback URL: ${lnurlpResponse.callback}`);
@@ -273,19 +228,15 @@ class LightningService {
/** /**
* Decode Lightning invoice (basic parsing) * Decode Lightning invoice (basic parsing)
* @param {string} bolt11 - Lightning invoice
* @returns {Object} Basic invoice info
*/ */
parseInvoice(bolt11) { parseInvoice(bolt11Invoice) {
try { try {
// This is a simplified parser - for production use a proper library like bolt11 if (!bolt11Invoice.toLowerCase().startsWith('lnbc') && !bolt11Invoice.toLowerCase().startsWith('lntb')) {
if (!bolt11.toLowerCase().startsWith('lnbc') && !bolt11.toLowerCase().startsWith('lntb')) {
throw new Error('Invalid Lightning invoice format'); throw new Error('Invalid Lightning invoice format');
} }
return { return {
bolt11, bolt11: bolt11Invoice,
network: bolt11.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet' network: bolt11Invoice.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet'
}; };
} catch (error) { } catch (error) {
throw new Error(`Invoice parsing failed: ${error.message}`); throw new Error(`Invoice parsing failed: ${error.message}`);
@@ -294,20 +245,14 @@ class LightningService {
/** /**
* Verify that a Lightning invoice is valid and for the expected amount * Verify that a Lightning invoice is valid and for the expected amount
* @param {string} bolt11Invoice - The Lightning invoice to verify
* @param {string} expectedLightningAddress - The expected Lightning address (for logging)
* @param {number} expectedAmount - Expected amount in satoshis (optional)
* @returns {boolean} Whether the invoice is valid
*/ */
verifyInvoiceDestination(bolt11Invoice, expectedLightningAddress, expectedAmount = null) { verifyInvoiceDestination(bolt11Invoice, expectedLightningAddress, expectedAmount = null) {
try { try {
console.log(`Verifying invoice destination for: ${expectedLightningAddress}`); console.log(`Verifying invoice destination for: ${expectedLightningAddress}`);
console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`); console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`);
// Decode the invoice using the bolt11 library
const decoded = bolt11.decode(bolt11Invoice); const decoded = bolt11.decode(bolt11Invoice);
// Basic validation checks
if (!decoded.complete) { if (!decoded.complete) {
console.error('Invoice verification failed: Invoice is incomplete'); console.error('Invoice verification failed: Invoice is incomplete');
return false; return false;
@@ -318,13 +263,11 @@ class LightningService {
return false; return false;
} }
// Check if the invoice has expired
if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) { if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) {
console.error('Invoice verification failed: Invoice has expired'); console.error('Invoice verification failed: Invoice has expired');
return false; return false;
} }
// Verify amount if provided
if (expectedAmount !== null) { if (expectedAmount !== null) {
const invoiceAmount = decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0); const invoiceAmount = decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0);
if (invoiceAmount !== expectedAmount) { if (invoiceAmount !== expectedAmount) {
@@ -349,4 +292,4 @@ class LightningService {
} }
} }
module.exports = new LightningService(); module.exports = new LightningComponent();

View File

@@ -1,29 +1,23 @@
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const cashuService = require('./cashu'); const crypto = require('crypto');
const lightningService = require('./lightning'); const cashu = require('./cashu');
const lightning = require('./lightning');
class RedemptionService { class RedemptionComponent {
constructor() { constructor() {
// In-memory storage for redemption status
// In production, use Redis or a proper database
this.redemptions = new Map(); this.redemptions = new Map();
this.tokenHashes = new Map(); // Map token hashes to redemption IDs this.tokenHashes = new Map();
} }
/** /**
* Generate a simple hash for a token (for duplicate detection) * Generate a simple hash for a token (for duplicate detection)
* @param {string} token - The Cashu token
* @returns {string} Hash of the token
*/ */
generateTokenHash(token) { generateTokenHash(token) {
const crypto = require('crypto');
return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16); return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16);
} }
/** /**
* Store redemption status * Store redemption status
* @param {string} redeemId - The redemption ID
* @param {Object} status - The redemption status object
*/ */
storeRedemption(redeemId, status) { storeRedemption(redeemId, status) {
this.redemptions.set(redeemId, { this.redemptions.set(redeemId, {
@@ -35,8 +29,6 @@ class RedemptionService {
/** /**
* Update redemption status * Update redemption status
* @param {string} redeemId - The redemption ID
* @param {Object} updates - Updates to apply
*/ */
updateRedemption(redeemId, updates) { updateRedemption(redeemId, updates) {
const existing = this.redemptions.get(redeemId); const existing = this.redemptions.get(redeemId);
@@ -51,8 +43,6 @@ class RedemptionService {
/** /**
* Get redemption status by ID * Get redemption status by ID
* @param {string} redeemId - The redemption ID
* @returns {Object|null} Redemption status or null if not found
*/ */
getRedemption(redeemId) { getRedemption(redeemId) {
return this.redemptions.get(redeemId) || null; return this.redemptions.get(redeemId) || null;
@@ -60,8 +50,6 @@ class RedemptionService {
/** /**
* Get redemption ID by token hash * Get redemption ID by token hash
* @param {string} tokenHash - The token hash
* @returns {string|null} Redemption ID or null if not found
*/ */
getRedemptionByTokenHash(tokenHash) { getRedemptionByTokenHash(tokenHash) {
const redeemId = this.tokenHashes.get(tokenHash); const redeemId = this.tokenHashes.get(tokenHash);
@@ -70,8 +58,6 @@ class RedemptionService {
/** /**
* Check if a token has already been redeemed * Check if a token has already been redeemed
* @param {string} token - The Cashu token
* @returns {Object|null} Existing redemption or null
*/ */
checkExistingRedemption(token) { checkExistingRedemption(token) {
const tokenHash = this.generateTokenHash(token); const tokenHash = this.generateTokenHash(token);
@@ -80,43 +66,36 @@ class RedemptionService {
/** /**
* Validate redemption request * Validate redemption request
* @param {string} token - The Cashu token
* @param {string} lightningAddress - The Lightning address (optional)
* @returns {Object} Validation result
*/ */
async validateRedemptionRequest(token, lightningAddress) { async validateRedemptionRequest(token, lightningAddress) {
const errors = []; const errors = [];
// Validate token format
if (!token || typeof token !== 'string') { if (!token || typeof token !== 'string') {
errors.push('Token is required and must be a 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; let addressToUse = null;
try { try {
addressToUse = lightningService.getLightningAddressToUse(lightningAddress); addressToUse = lightning.getLightningAddressToUse(lightningAddress);
if (!lightningService.validateLightningAddress(addressToUse)) { if (!lightning.validateLightningAddress(addressToUse)) {
errors.push('Invalid Lightning address format'); errors.push('Invalid Lightning address format');
} }
} catch (error) { } catch (error) {
errors.push(error.message); errors.push(error.message);
} }
// Check for existing redemption
if (token) { if (token) {
const existing = this.checkExistingRedemption(token); const existing = this.checkExistingRedemption(token);
if (existing) { if (existing && existing.status === 'paid') {
errors.push('Token has already been redeemed'); errors.push('Token has already been redeemed');
} }
} }
// Try to parse token
let tokenData = null; let tokenData = null;
if (token && errors.length === 0) { if (token && errors.length === 0) {
try { try {
tokenData = await cashuService.parseToken(token); tokenData = await cashu.parseToken(token);
if (tokenData.totalAmount <= 0) { if (tokenData.totalAmount <= 0) {
errors.push('Token has no value'); errors.push('Token has no value');
} }
@@ -135,23 +114,18 @@ class RedemptionService {
/** /**
* Perform the complete redemption process * 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) { async performRedemption(token, lightningAddress) {
const redeemId = uuidv4(); const redeemId = uuidv4();
const tokenHash = this.generateTokenHash(token); const tokenHash = this.generateTokenHash(token);
try { try {
// Determine which Lightning address to use const lightningAddressToUse = lightning.getLightningAddressToUse(lightningAddress);
const lightningAddressToUse = lightningService.getLightningAddressToUse(lightningAddress);
const isUsingDefault = !lightningAddress || !lightningAddress.trim(); const isUsingDefault = !lightningAddress || !lightningAddress.trim();
// Store initial status
this.storeRedemption(redeemId, { this.storeRedemption(redeemId, {
status: 'processing', status: 'processing',
token: token.substring(0, 50) + '...', // Store partial token for reference token: token.substring(0, 50) + '...',
tokenHash, tokenHash,
lightningAddress: lightningAddressToUse, lightningAddress: lightningAddressToUse,
usingDefaultAddress: isUsingDefault, usingDefaultAddress: isUsingDefault,
@@ -160,12 +134,11 @@ class RedemptionService {
error: null error: null
}); });
// Also map token hash to redemption ID
this.tokenHashes.set(tokenHash, redeemId); this.tokenHashes.set(tokenHash, redeemId);
// Step 1: Parse and validate token // Step 1: Parse and validate token
this.updateRedemption(redeemId, { status: 'parsing_token' }); this.updateRedemption(redeemId, { status: 'parsing_token' });
const tokenData = await cashuService.parseToken(token); const tokenData = await cashu.parseToken(token);
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
amount: tokenData.totalAmount, amount: tokenData.totalAmount,
@@ -177,20 +150,17 @@ class RedemptionService {
// Check if token is spendable // Check if token is spendable
this.updateRedemption(redeemId, { status: 'checking_spendability' }); this.updateRedemption(redeemId, { status: 'checking_spendability' });
try { try {
const spendabilityCheck = await cashuService.checkTokenSpendable(token); const spendabilityCheck = await cashu.checkTokenSpendable(token);
if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) { if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) {
throw new Error('Token proofs are not spendable - they have already been used or are invalid'); throw new Error('Token proofs are not spendable - they have already been used or are invalid');
} }
} catch (spendError) { } catch (spendError) {
// Check if the error indicates tokens are already spent (422 status)
if (spendError.message.includes('not spendable') || if (spendError.message.includes('not spendable') ||
spendError.message.includes('already been used') || spendError.message.includes('already been used') ||
spendError.message.includes('invalid proofs') || spendError.message.includes('invalid proofs') ||
spendError.message.includes('422')) { spendError.message.includes('422')) {
// This is likely an already-spent token - fail the redemption with clear message
throw new Error('This token has already been spent and cannot be redeemed again'); throw new Error('This token has already been spent and cannot be redeemed again');
} }
// For other errors, log but continue - some mints might not support this check
console.warn('Spendability check failed:', spendError.message); console.warn('Spendability check failed:', spendError.message);
console.log('Continuing with redemption despite spendability check failure...'); console.log('Continuing with redemption despite spendability check failure...');
} }
@@ -198,22 +168,20 @@ class RedemptionService {
// Step 2: Get melt quote first to determine exact fees // Step 2: Get melt quote first to determine exact fees
this.updateRedemption(redeemId, { status: 'getting_melt_quote' }); this.updateRedemption(redeemId, { status: 'getting_melt_quote' });
// Create a temporary invoice to get the melt quote (we'll create the real one after)
console.log(`Getting melt quote for ${tokenData.totalAmount} sats to determine exact fees`); console.log(`Getting melt quote for ${tokenData.totalAmount} sats to determine exact fees`);
const tempInvoiceData = await lightningService.resolveInvoice( const tempInvoiceData = await lightning.resolveInvoice(
lightningAddressToUse, lightningAddressToUse,
tokenData.totalAmount, // Use full amount initially tokenData.totalAmount,
'Cashu redemption' 'Cashu redemption'
); );
// Get melt quote to determine exact fee const meltQuote = await cashu.getMeltQuote(token, tempInvoiceData.bolt11);
const meltQuote = await cashuService.getMeltQuote(token, tempInvoiceData.bolt11);
const exactFee = meltQuote.fee_reserve; const exactFee = meltQuote.fee_reserve;
const finalInvoiceAmount = tokenData.totalAmount - exactFee; const finalInvoiceAmount = tokenData.totalAmount - exactFee;
console.log(`Melt quote: amount=${meltQuote.amount}, fee=${exactFee}, net to user=${finalInvoiceAmount}`); console.log(`Melt quote: amount=${meltQuote.amount}, fee=${exactFee}, net to user=${finalInvoiceAmount}`);
// Step 3: Create final invoice for the correct amount (total - exact fee) // Step 3: Create final invoice for the correct amount
this.updateRedemption(redeemId, { status: 'resolving_invoice' }); this.updateRedemption(redeemId, { status: 'resolving_invoice' });
if (finalInvoiceAmount <= 0) { if (finalInvoiceAmount <= 0) {
@@ -222,9 +190,9 @@ class RedemptionService {
console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`); console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`);
const invoiceData = await lightningService.resolveInvoice( const invoiceData = await lightning.resolveInvoice(
lightningAddressToUse, lightningAddressToUse,
finalInvoiceAmount, // Use amount minus exact fee finalInvoiceAmount,
'Cashu redemption' 'Cashu redemption'
); );
@@ -232,20 +200,18 @@ class RedemptionService {
bolt11: invoiceData.bolt11.substring(0, 50) + '...', bolt11: invoiceData.bolt11.substring(0, 50) + '...',
domain: invoiceData.domain, domain: invoiceData.domain,
invoiceAmount: finalInvoiceAmount, invoiceAmount: finalInvoiceAmount,
exactFee: exactFee exactFee
}); });
// Verify the invoice is valid and for the correct amount const invoiceVerified = lightning.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
const invoiceVerified = lightningService.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
if (!invoiceVerified) { if (!invoiceVerified) {
throw new Error('Invoice verification failed - invalid invoice or amount mismatch'); throw new Error('Invoice verification failed - invalid invoice or amount mismatch');
} }
// Step 4: Melt the token to pay the invoice // Step 4: Melt the token to pay the invoice
this.updateRedemption(redeemId, { status: 'melting_token' }); this.updateRedemption(redeemId, { status: 'melting_token' });
const meltResult = await cashuService.meltToken(token, invoiceData.bolt11); const meltResult = await cashu.meltToken(token, invoiceData.bolt11);
// Log melt result for debugging
console.log(`Redemption ${redeemId}: Melt result:`, { console.log(`Redemption ${redeemId}: Melt result:`, {
paid: meltResult.paid, paid: meltResult.paid,
hasPreimage: !!meltResult.preimage, hasPreimage: !!meltResult.preimage,
@@ -253,11 +219,8 @@ class RedemptionService {
fee: meltResult.fee fee: meltResult.fee
}); });
// Determine if payment was successful
// Consider it successful if we have a preimage, even if 'paid' flag is unclear
const paymentSuccessful = meltResult.paid || !!meltResult.preimage; const paymentSuccessful = meltResult.paid || !!meltResult.preimage;
// Step 4: Update final status
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
status: paymentSuccessful ? 'paid' : 'failed', status: paymentSuccessful ? 'paid' : 'failed',
paid: paymentSuccessful, paid: paymentSuccessful,
@@ -267,7 +230,7 @@ class RedemptionService {
netAmount: meltResult.netAmount, netAmount: meltResult.netAmount,
change: meltResult.change, change: meltResult.change,
paidAt: paymentSuccessful ? new Date().toISOString() : null, paidAt: paymentSuccessful ? new Date().toISOString() : null,
rawMeltResponse: meltResult.rawMeltResponse // Store for debugging rawMeltResponse: meltResult.rawMeltResponse
}); });
return { return {
@@ -275,12 +238,12 @@ class RedemptionService {
redeemId, redeemId,
paid: paymentSuccessful, paid: paymentSuccessful,
amount: tokenData.totalAmount, amount: tokenData.totalAmount,
invoiceAmount: finalInvoiceAmount, // Amount actually sent in the invoice invoiceAmount: finalInvoiceAmount,
to: lightningAddressToUse, to: lightningAddressToUse,
usingDefaultAddress: isUsingDefault, usingDefaultAddress: isUsingDefault,
fee: exactFee, // Use the exact fee from the melt quote fee: exactFee,
actualFee: meltResult.actualFee, actualFee: meltResult.actualFee,
netAmount: finalInvoiceAmount, // This is the net amount the user receives netAmount: finalInvoiceAmount,
preimage: meltResult.preimage, preimage: meltResult.preimage,
change: meltResult.change, change: meltResult.change,
mint: tokenData.mint, mint: tokenData.mint,
@@ -288,13 +251,15 @@ class RedemptionService {
}; };
} catch (error) { } catch (error) {
// Update redemption with error
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
status: 'failed', status: 'failed',
paid: false, paid: false,
error: error.message error: error.message
}); });
// Remove token hash so the token can be retried after a failed redemption
this.tokenHashes.delete(tokenHash);
return { return {
success: false, success: false,
redeemId, redeemId,
@@ -305,15 +270,11 @@ class RedemptionService {
/** /**
* Get redemption status for API response * Get redemption status for API response
* @param {string} redeemId - The redemption ID
* @returns {Object|null} Status response or null if not found
*/ */
getRedemptionStatus(redeemId) { getRedemptionStatus(redeemId) {
const redemption = this.getRedemption(redeemId); const redemption = this.getRedemption(redeemId);
if (!redemption) { if (!redemption) return null;
return null;
}
const response = { const response = {
success: true, success: true,
@@ -327,32 +288,17 @@ class RedemptionService {
} }
}; };
if (redemption.paidAt) { if (redemption.paidAt) response.details.paidAt = 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.fee) { if (redemption.domain) response.details.domain = redemption.domain;
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; return response;
} }
/** /**
* Get all redemptions (for admin/debugging) * Get all redemptions (for admin/debugging)
* @returns {Array} All redemptions
*/ */
getAllRedemptions() { getAllRedemptions() {
return Array.from(this.redemptions.entries()).map(([id, data]) => ({ return Array.from(this.redemptions.entries()).map(([id, data]) => ({
@@ -363,16 +309,14 @@ class RedemptionService {
/** /**
* Clean up old redemptions (should be called periodically) * Clean up old redemptions (should be called periodically)
* @param {number} maxAgeMs - Maximum age in milliseconds
*/ */
cleanupOldRedemptions(maxAgeMs = 24 * 60 * 60 * 1000) { // 24 hours default cleanupOldRedemptions(maxAgeMs = 24 * 60 * 60 * 1000) {
const cutoff = new Date(Date.now() - maxAgeMs); const cutoff = new Date(Date.now() - maxAgeMs);
for (const [redeemId, redemption] of this.redemptions.entries()) { for (const [redeemId, redemption] of this.redemptions.entries()) {
const createdAt = new Date(redemption.createdAt); const createdAt = new Date(redemption.createdAt);
if (createdAt < cutoff) { if (createdAt < cutoff) {
this.redemptions.delete(redeemId); this.redemptions.delete(redeemId);
// Also clean up token hash mapping
if (redemption.tokenHash) { if (redemption.tokenHash) {
this.tokenHashes.delete(redemption.tokenHash); this.tokenHashes.delete(redemption.tokenHash);
} }
@@ -381,4 +325,4 @@ class RedemptionService {
} }
} }
module.exports = new RedemptionService(); module.exports = new RedemptionComponent();

View File

@@ -2,9 +2,11 @@
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development
# API Domain/IP Configuration (for Swagger docs and CORS)
API_DOMAIN=localhost:3000
# Security Configuration # Security Configuration
ALLOW_REDEEM_DOMAINS=* ALLOW_REDEEM_DOMAINS=*
API_SECRET=your-secret-key-here
# Default Lightning Address (used when no address is provided in redeem requests) # Default Lightning Address (used when no address is provided in redeem requests)
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com

167
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{ {
"name": "cashu-redeem-api", "name": "cashu-redeem-api",
"version": "1.0.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cashu-redeem-api", "name": "cashu-redeem-api",
"version": "1.0.0", "version": "2.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cashu/cashu-ts": "^1.1.0", "@cashu/cashu-ts": "^3.4.1",
"axios": "^1.7.7", "axios": "^1.8.1",
"bolt11": "^1.4.1", "bolt11": "^1.4.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -26,6 +26,10 @@
"engines": { "engines": {
"node": ">=18.0.0", "node": ">=18.0.0",
"npm": ">=8.0.0" "npm": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/Michilis"
} }
}, },
"node_modules/@apidevtools/json-schema-ref-parser": { "node_modules/@apidevtools/json-schema-ref-parser": {
@@ -73,30 +77,30 @@
} }
}, },
"node_modules/@cashu/cashu-ts": { "node_modules/@cashu/cashu-ts": {
"version": "1.2.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.4.1.tgz",
"integrity": "sha512-B0+e02S8DQA8KBt2FgKHgGYvtHQokXJ3sZcTyAdHqvb0T0jfo1zF7nHn19eU9iYcfk8VSWf5xNBTocpTfj1aNg==", "integrity": "sha512-d8bgYbYIKCsT7Hs8BsoENrRehQmuA8qYscQuAGuCk4FsT4a+OGPYAJuxgCApwBhKCokclBjZzVnkS19/CygQ0g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cashu/crypto": "^0.2.7", "@noble/curves": "^2.0.1",
"@noble/curves": "^1.3.0", "@noble/hashes": "^2.0.1",
"@noble/hashes": "^1.3.3", "@scure/base": "^2.0.0",
"@scure/bip32": "^1.3.3", "@scure/bip32": "^2.0.1"
"@scure/bip39": "^1.2.2", },
"buffer": "^6.0.3" "engines": {
"node": ">=22.4.0"
} }
}, },
"node_modules/@cashu/crypto": { "node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
"version": "0.2.7", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.2.7.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==", "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT", "license": "MIT",
"dependencies": { "engines": {
"@noble/curves": "^1.3.0", "node": ">= 20.19.0"
"@noble/hashes": "^1.3.3", },
"@scure/bip32": "^1.3.3", "funding": {
"@scure/bip39": "^1.2.2", "url": "https://paulmillr.com/funding/"
"buffer": "^6.0.3"
} }
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
@@ -363,15 +367,27 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@noble/curves": { "node_modules/@noble/curves": {
"version": "1.9.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/hashes": "1.8.0" "@noble/hashes": "2.0.1"
}, },
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
@@ -397,36 +413,35 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@scure/base": { "node_modules/@scure/base": {
"version": "1.2.6", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip32": { "node_modules/@scure/bip32": {
"version": "1.7.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/curves": "~1.9.0", "@noble/curves": "2.0.1",
"@noble/hashes": "~1.8.0", "@noble/hashes": "2.0.1",
"@scure/base": "~1.2.5" "@scure/base": "2.0.0"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip39": { "node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.6.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT", "license": "MIT",
"dependencies": { "engines": {
"@noble/hashes": "~1.8.0", "node": ">= 20.19.0"
"@scure/base": "~1.2.5"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
@@ -598,26 +613,6 @@
"resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz",
"integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==" "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw=="
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bech32": { "node_modules/bech32": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz",
@@ -754,30 +749,6 @@
"bs58": "^5.0.0" "bs58": "^5.0.0"
} }
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1880,26 +1851,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",

View File

@@ -1,14 +1,15 @@
{ {
"name": "cashu-redeem-api", "name": "cashu-redeem-api",
"version": "1.0.0", "version": "2.0.0",
"description": "Redeem ecash (Cashu tokens) to Lightning Address using cashu-ts library and LNURLp", "description": "A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix" "lint:fix": "eslint . --fix",
"docs": "echo \"API documentation available at http://localhost:3000/docs\""
}, },
"keywords": [ "keywords": [
"cashu", "cashu",
@@ -19,13 +20,21 @@
"lnurl", "lnurl",
"lnurlp", "lnurlp",
"mint", "mint",
"satoshi" "satoshi",
"bolt11",
"lightning-network",
"payment",
"redemption"
], ],
"author": "", "author": "Michilis",
"license": "MIT", "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/Michilis"
},
"dependencies": { "dependencies": {
"@cashu/cashu-ts": "^1.1.0", "@cashu/cashu-ts": "^3.4.1",
"axios": "^1.7.7", "axios": "^1.8.1",
"bolt11": "^1.4.1", "bolt11": "^1.4.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -44,10 +53,10 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/yourusername/cashu-redeem-api.git" "url": "git+https://github.com/Michilis/cashu-redeem-api.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/yourusername/cashu-redeem-api/issues" "url": "https://github.com/Michilis/cashu-redeem-api/issues"
}, },
"homepage": "https://github.com/yourusername/cashu-redeem-api#readme" "homepage": "https://github.com/Michilis/cashu-redeem-api#readme"
} }

100
routes/cashu.js Normal file
View File

@@ -0,0 +1,100 @@
const express = require('express');
const router = express.Router();
const cashu = require('../components/cashu');
/**
* @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'
*/
router.post('/decode', async (req, res) => {
const { token } = req.body;
if (!token) {
return res.status(400).json({
success: false,
error: 'Token is required'
});
}
try {
if (!cashu.isValidTokenFormat(token)) {
return res.status(400).json({
success: false,
error: 'Invalid token format. Must be a valid Cashu token'
});
}
const decoded = await cashu.parseToken(token);
const mintUrl = await cashu.getTokenMintUrl(token);
let spent = false;
try {
const spendabilityCheck = await cashu.checkTokenSpendable(token);
spent = !spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0;
} catch (error) {
console.warn('Spendability check failed:', error.message);
const errorString = error.message || error.toString();
if (errorString.includes('TOKEN_SPENT:')) {
console.log('Token determined to be spent by CashuComponent');
spent = true;
} else if (errorString.includes('Token validation failed at mint:')) {
console.log('Token validation failed at mint - assuming token is still valid (might be invalid format)');
spent = false;
} else if (errorString.includes('not supported') ||
errorString.includes('endpoint not found') ||
errorString.includes('may still be valid') ||
errorString.includes('does not support spendability checking')) {
console.log('Mint does not support spendability checking - assuming token is valid');
spent = false;
} else {
console.log('Unknown error - assuming token is valid');
spent = false;
}
}
res.json({
success: true,
decoded: {
mint: decoded.mint,
totalAmount: decoded.totalAmount,
numProofs: decoded.numProofs,
denominations: decoded.denominations,
format: decoded.format,
spent
},
mint_url: mintUrl
});
} catch (error) {
res.status(400).json({
success: false,
error: error.message
});
}
});
module.exports = router;

44
routes/health.js Normal file
View File

@@ -0,0 +1,44 @@
const express = require('express');
const router = express.Router();
/**
* @swagger
* /api/health:
* get:
* summary: Health check endpoint
* description: |
* Check the health and status of the API server.
* Returns server information including uptime, memory usage, and version.
* tags: [Status & Monitoring]
* responses:
* 200:
* description: Server is healthy
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/HealthResponse'
* 500:
* $ref: '#/components/responses/InternalServerError'
*/
router.get('/health', async (req, res) => {
try {
const packageJson = require('../package.json');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
version: packageJson.version
});
} catch (error) {
console.error('Health check error:', error);
res.status(500).json({
status: 'error',
timestamp: new Date().toISOString(),
error: 'Health check failed'
});
}
});
module.exports = router;

86
routes/lightning.js Normal file
View File

@@ -0,0 +1,86 @@
const express = require('express');
const router = express.Router();
const lightning = require('../components/lightning');
/**
* @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'
*/
router.post('/validate-address', async (req, res) => {
const { lightningAddress } = req.body;
if (!lightningAddress) {
return res.status(400).json({
success: false,
error: 'Lightning address is required'
});
}
try {
const isValid = lightning.validateLightningAddress(lightningAddress);
if (!isValid) {
return res.json({
success: false,
valid: false,
error: 'Invalid Lightning address format'
});
}
const { domain } = lightning.parseLightningAddress(lightningAddress);
const lnurlpUrl = lightning.getLNURLpEndpoint(lightningAddress);
try {
const lnurlpResponse = await lightning.fetchLNURLpResponse(lnurlpUrl);
res.json({
success: true,
valid: true,
domain,
minSendable: lightning.millisatsToSats(lnurlpResponse.minSendable),
maxSendable: lightning.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
});
}
});
module.exports = router;

146
routes/redemption.js Normal file
View File

@@ -0,0 +1,146 @@
const express = require('express');
const router = express.Router();
const redemption = require('../components/redemption');
/**
* @swagger
* /api/redeem:
* post:
* summary: Redeem a Cashu token to Lightning address
* description: |
* Redeem a Cashu token to a Lightning address (optional - uses default if not provided).
*
* The redemption process includes:
* 1. Token validation and parsing
* 2. Getting exact melt quote from mint to determine precise fees
* 3. Invoice creation for net amount (token amount - exact fees)
* 4. Spendability checking at the mint
* 5. Token melting and Lightning payment
*
* **Important**: The system gets the exact fee from the mint before creating the invoice.
* The `invoiceAmount` field shows the actual amount sent to the Lightning address.
* No sats are lost to fee estimation errors.
* tags: [Token Operations]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RedeemRequest'
* responses:
* 200:
* description: Token redeemed successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RedeemResponse'
* 400:
* $ref: '#/components/responses/BadRequest'
* 409:
* description: Token already spent
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* error:
* type: string
* example: "This token has already been spent and cannot be redeemed again"
* errorType:
* type: string
* example: "token_already_spent"
* 422:
* description: Insufficient funds or unprocessable token
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* error:
* type: string
* example: "Token amount is insufficient to cover the minimum fee"
* errorType:
* type: string
* example: "insufficient_funds"
* 429:
* $ref: '#/components/responses/TooManyRequests'
* 500:
* $ref: '#/components/responses/InternalServerError'
*/
router.post('/redeem', async (req, res) => {
const { token, lightningAddress } = req.body;
const validation = await redemption.validateRedemptionRequest(token, lightningAddress);
if (!validation.valid) {
return res.status(400).json({
success: false,
error: validation.errors.join(', ')
});
}
try {
const result = await redemption.performRedemption(token, lightningAddress);
if (result.success) {
const response = {
success: true,
paid: result.paid,
amount: result.amount,
invoiceAmount: result.invoiceAmount,
to: result.to,
fee: result.fee,
actualFee: result.actualFee,
netAmount: result.netAmount,
mint_url: result.mint,
format: result.format
};
if (result.usingDefaultAddress) {
response.usingDefaultAddress = true;
response.message = `Redeemed to default Lightning address: ${result.to}`;
}
if (result.preimage) {
response.preimage = result.preimage;
}
res.json(response);
} else {
let statusCode = 400;
if (result.error && (
result.error.includes('cannot be redeemed') ||
result.error.includes('already been used') ||
result.error.includes('not spendable') ||
result.error.includes('already spent') ||
result.error.includes('invalid proofs')
)) {
statusCode = 409;
} else if (result.error && result.error.includes('insufficient')) {
statusCode = 422;
}
res.status(statusCode).json({
success: false,
error: result.error,
errorType: statusCode === 409 ? 'token_already_spent' :
statusCode === 422 ? 'insufficient_funds' : 'validation_error'
});
}
} catch (error) {
console.error('Error in redemption:', error);
res.status(500).json({
success: false,
error: 'Internal server error during redemption'
});
}
});
module.exports = router;

537
server.js
View File

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

View File

@@ -1,520 +0,0 @@
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);
}
/**
* Get melt quote for a Cashu token and Lightning invoice
* @param {string} token - The encoded Cashu token
* @param {string} bolt11 - The Lightning invoice
* @returns {Object} Melt quote
*/
async getMeltQuote(token, bolt11) {
try {
const parsed = await this.parseToken(token);
const wallet = await this.getWallet(parsed.mint);
// Create melt quote to get fee estimate
const meltQuote = await wallet.createMeltQuote(bolt11);
console.log('Melt quote created:', {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
});
return {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
};
} catch (error) {
throw new Error(`Failed to get melt quote: ${error.message}`);
}
}
/**
* 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;
// Step 1: Create melt quote to get fee estimate
const meltQuote = await wallet.createMeltQuote(bolt11);
console.log('Melt quote created:', {
amount: meltQuote.amount,
fee_reserve: meltQuote.fee_reserve,
quote: meltQuote.quote
});
console.log('Paying invoice:', bolt11.substring(0, 50) + '...');
console.log('Full invoice being paid:', bolt11);
// Step 2: Calculate total required (amount + fee_reserve)
const total = meltQuote.amount + meltQuote.fee_reserve;
console.log('Total required:', total, 'sats (amount:', meltQuote.amount, '+ fee:', meltQuote.fee_reserve, ')');
console.log('Available in token:', parsed.totalAmount, 'sats');
// Check if we have sufficient funds
if (total > parsed.totalAmount) {
throw new Error(`Insufficient funds. Required: ${total} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`);
}
// Step 3: Send tokens with includeFees: true to get the right proofs
console.log('Selecting proofs with includeFees: true for', total, 'sats');
const { send: proofsToSend } = await wallet.send(total, proofs, {
includeFees: true,
});
console.log('Selected', proofsToSend.length, 'proofs for melting');
// Step 4: Perform the melt operation using the quote and selected proofs
console.log('Performing melt operation...');
const meltResponse = await wallet.meltTokens(meltQuote, proofsToSend);
// Debug: Log the melt response structure
console.log('Melt response:', JSON.stringify(meltResponse, null, 2));
// Verify payment was successful - check multiple possible indicators
const paymentSuccessful = meltResponse.paid === true ||
meltResponse.payment_preimage ||
meltResponse.preimage ||
(meltResponse.state && meltResponse.state === 'PAID');
if (!paymentSuccessful) {
console.warn('Payment verification failed. Response structure:', meltResponse);
// Don't throw error immediately - the payment might have succeeded
// but the response structure is different than expected
}
// Get the actual fee charged from the melt response
// The actual fee might be in meltResponse.fee_paid, meltResponse.fee, or calculated from change
const actualFeeCharged = meltResponse.fee_paid ||
meltResponse.fee ||
meltQuote.fee_reserve; // fallback to quote fee
// Calculate net amount based on actual fee charged
const actualNetAmount = parsed.totalAmount - actualFeeCharged;
return {
success: true,
paid: paymentSuccessful,
preimage: meltResponse.payment_preimage || meltResponse.preimage,
change: meltResponse.change || [],
amount: meltQuote.amount,
fee: actualFeeCharged, // Use actual fee from melt response
netAmount: actualNetAmount, // Use net amount based on actual fee
quote: meltQuote.quote,
rawMeltResponse: meltResponse // Include raw response for debugging
};
} 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
}
// Check if it's an already-spent token error
if (error.status === 422 ||
error.message.includes('already spent') ||
error.message.includes('not spendable') ||
error.message.includes('invalid proofs')) {
throw new Error('This token has already been spent and cannot be redeemed again');
}
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);
// Extract secrets from proofs
const secrets = parsed.proofs.map(proof => proof.secret);
// Log the attempt for debugging
console.log(`Checking spendability for ${secrets.length} proofs at mint: ${parsed.mint}`);
// Perform the check
const checkResult = await mint.check({ secrets });
console.log('Spendability check result:', checkResult);
return {
spendable: checkResult.spendable || [],
pending: checkResult.pending || [],
mintUrl: parsed.mint,
totalAmount: parsed.totalAmount
};
} catch (error) {
// Enhanced error logging for debugging
console.error('Spendability check error details:', {
errorType: error.constructor.name,
errorMessage: error.message,
errorCode: error.code,
errorStatus: error.status,
errorResponse: error.response,
errorData: error.data,
errorStack: error.stack,
errorString: String(error)
});
// Handle different types of errors
let errorMessage = 'Unknown error occurred';
// Handle cashu-ts HttpResponseError specifically
if (error.constructor.name === 'HttpResponseError') {
// Extract status code first
const status = error.status || error.response?.status || error.statusCode;
// For 422 errors, we need to be more specific about the reason
if (status === 422) {
// Try to get more details about the 422 error
let responseBody = null;
try {
responseBody = error.response?.data || error.data || error.body;
console.log('HTTP 422 response body:', responseBody);
} catch (e) {
console.log('Could not extract response body');
}
// 422 can mean different things, let's be more specific
if (responseBody && typeof responseBody === 'object' && responseBody.detail) {
errorMessage = `Token validation failed: ${responseBody.detail}`;
console.log('422 error with detail:', responseBody.detail);
} else {
errorMessage = 'Token proofs are not spendable - they may have already been used or are invalid';
console.log('Detected 422 status - token validation failed');
}
} else {
// Try to extract useful information from the HTTP response error
if (error.response) {
const statusText = error.response.statusText;
if (status === 404) {
errorMessage = 'This mint does not support spendability checking (endpoint not found)';
} else if (status === 405) {
errorMessage = 'This mint does not support spendability checking (method not allowed)';
} else if (status === 501) {
errorMessage = 'This mint does not support spendability checking (not implemented)';
} else {
errorMessage = `Mint returned HTTP ${status}${statusText ? ': ' + statusText : ''}`;
}
} else if (error.message && error.message !== '[object Object]') {
errorMessage = error.message;
} else {
// Try to extract error details from the error object structure
console.log('Attempting to extract error details from object structure...');
try {
// Check if there's additional error data in the response
const errorData = error.data || error.response?.data;
if (errorData && typeof errorData === 'string') {
errorMessage = errorData;
} else if (errorData && errorData.detail) {
errorMessage = `Mint error: ${errorData.detail}`;
} else if (errorData && errorData.message) {
errorMessage = `Mint error: ${errorData.message}`;
} else {
// Check if we can extract status from anywhere in the error
if (status) {
if (status === 422) {
errorMessage = 'Token proofs are not spendable - they have already been used or are invalid';
} else {
errorMessage = `Mint returned HTTP ${status} - spendability checking may not be supported`;
}
} else {
errorMessage = 'This mint does not support spendability checking or returned an invalid response';
}
}
} catch (extractError) {
console.log('Failed to extract error details:', extractError);
errorMessage = 'This mint does not support spendability checking or returned an invalid response';
}
}
}
} else if (error && typeof error === 'object') {
if (error.message && error.message !== '[object Object]') {
errorMessage = error.message;
} else if (error.toString && typeof error.toString === 'function') {
const stringError = error.toString();
if (stringError !== '[object Object]') {
errorMessage = stringError;
} else {
errorMessage = 'Invalid response from mint - spendability checking may not be supported';
}
} else {
errorMessage = 'Invalid response from mint - spendability checking may not be supported';
}
} else if (typeof error === 'string') {
errorMessage = error;
}
// Log the final extracted error message for debugging
console.log('Final extracted error message:', errorMessage);
// Check if it's a known error pattern indicating unsupported operation
if (errorMessage.includes('not supported') ||
errorMessage.includes('404') ||
errorMessage.includes('405') ||
errorMessage.includes('501') ||
errorMessage.includes('Method not allowed') ||
errorMessage.includes('endpoint not found') ||
errorMessage.includes('not implemented') ||
errorMessage.includes('Invalid response from mint')) {
throw new Error('This mint does not support spendability checking. Token may still be valid.');
}
// Check if the error indicates the token is spent (HTTP 422 or specific messages)
const status = error.status || error.response?.status || error.statusCode;
if (status === 422) {
// For 422 errors, we need to be more careful about determining if it's "spent" vs "invalid"
// Only mark as spent if we have clear indicators
if (errorMessage.includes('already been used') ||
errorMessage.includes('already spent') ||
errorMessage.includes('not spendable')) {
throw new Error('TOKEN_SPENT: Token proofs are not spendable - they have already been used');
} else {
// For other 422 errors, it might be invalid but not necessarily spent
console.log('HTTP 422 but not clearly indicating spent token - treating as validation error');
throw new Error(`Token validation failed at mint: ${errorMessage}`);
}
} else if (errorMessage.includes('Token proofs are not spendable') ||
errorMessage.includes('already been used') ||
errorMessage.includes('invalid proofs')) {
throw new Error('TOKEN_SPENT: Token proofs are not spendable - they have already been used');
}
throw new Error(`Failed to check token spendability: ${errorMessage}`);
}
}
}
module.exports = new CashuService();

View File

@@ -1,11 +1,22 @@
require('dotenv').config();
const swaggerJsdoc = require('swagger-jsdoc'); const swaggerJsdoc = require('swagger-jsdoc');
// Get the API domain from environment variable, default to localhost:3000
const apiDomain = process.env.API_DOMAIN || 'localhost:3000';
const isProduction = process.env.NODE_ENV === 'production';
const protocol = isProduction ? 'https' : 'http';
// For production behind Nginx, we need to ensure the URL doesn't include the internal port
const serverUrl = isProduction
? `${protocol}://${apiDomain}`
: `${protocol}://${apiDomain}`;
const options = { const options = {
definition: { definition: {
openapi: '3.0.0', openapi: '3.0.0',
info: { info: {
title: 'Cashu Redeem API', title: 'Cashu Redeem API',
version: '1.0.0', version: '2.0.0',
description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol.', description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol.',
contact: { contact: {
name: 'API Support', name: 'API Support',
@@ -18,12 +29,8 @@ const options = {
}, },
servers: [ servers: [
{ {
url: 'http://localhost:3000', url: serverUrl,
description: 'Development server' description: isProduction ? 'Production server' : 'Development server'
},
{
url: 'https://api.example.com',
description: 'Production server'
} }
], ],
components: { components: {
@@ -138,12 +145,6 @@ const options = {
type: 'boolean', type: 'boolean',
example: true example: true
}, },
redeemId: {
type: 'string',
format: 'uuid',
description: 'Unique redemption ID for tracking',
example: '8e99101e-d034-4d2e-9ccf-dfda24d26762'
},
paid: { paid: {
type: 'boolean', type: 'boolean',
description: 'Whether the payment was successful', description: 'Whether the payment was successful',
@@ -329,10 +330,6 @@ const options = {
} }
}, },
tags: [ tags: [
{
name: 'General',
description: 'General API information and utilities'
},
{ {
name: 'Token Operations', name: 'Token Operations',
description: 'Operations for decoding and redeeming Cashu tokens' description: 'Operations for decoding and redeeming Cashu tokens'
@@ -340,10 +337,14 @@ const options = {
{ {
name: 'Validation', name: 'Validation',
description: 'Validation utilities for tokens and Lightning addresses' description: 'Validation utilities for tokens and Lightning addresses'
},
{
name: 'Status & Monitoring',
description: 'Health check and API status endpoints'
} }
] ]
}, },
apis: ['./server.js'], // paths to files containing OpenAPI definitions apis: ['./server.js', './routes/*.js'], // paths to files containing OpenAPI definitions
}; };
const specs = swaggerJsdoc(options); const specs = swaggerJsdoc(options);