Compare commits

...

2 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
13 changed files with 1058 additions and 1332 deletions

270
README.md
View File

@@ -1,26 +1,28 @@
# Cashu Redeem API 🪙⚡
# Cashu Redeem API
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
- **Redeem to Lightning addresses** - Convert ecash to Lightning payments via LNURLp
- **Security features** - Domain restrictions, rate limiting, input validation
- **Robust error handling** - Comprehensive error messages
- **Interactive API Documentation** - Complete Swagger/OpenAPI documentation at `/docs`
- **Decode Cashu tokens** - Parse and validate token content, check spendability at the mint
- **Redeem to Lightning addresses** - Convert ecash to Lightning payments via LNURLp with exact fee calculation
- **Lightning address validation** - Verify addresses and query LNURLp capabilities
- **Duplicate detection** - In-memory tracking prevents double-spend attempts
- **Security** - Domain restrictions, rate limiting, input validation, CORS protection
- **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
## 📡 API Endpoints
### `POST /api/decode`
### 1. `POST /api/decode`
Decode a Cashu token and return its content. Supports both v1 and v3 token formats.
Decode a Cashu token and return its content. Supports both v3 (`cashuB`) and v1 (`cashuA`) token formats. Checks spendability against the mint.
**Request:**
```json
@@ -34,25 +36,33 @@ Decode a Cashu token and return its content. Supports both v1 and v3 token forma
{
"success": true,
"decoded": {
"mint": "https://mint.azzamo.net",
"totalAmount": 21000,
"numProofs": 3,
"denominations": [1000, 10000, 10000],
"format": "cashuA",
"mint": "https://kashu.me",
"totalAmount": 16,
"numProofs": 5,
"denominations": [8, 4, 2, 1, 1],
"format": "cashuB",
"spent": false
},
"mint_url": "https://mint.azzamo.net"
"mint_url": "https://kashu.me"
}
```
### 2. `POST /api/redeem`
Redeem a Cashu token to a Lightning address. Lightning address is optional - if not provided, uses the default address from configuration.
### `POST /api/redeem`
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:**
```json
{
"token": "cashuB...",
"lightningAddress": "user@ln.tips"
"lightningAddress": "user@twentyone.tips"
}
```
@@ -68,50 +78,36 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if
{
"success": true,
"paid": true,
"amount": 21000,
"invoiceAmount": 20580,
"to": "user@ln.tips",
"fee": 1000,
"actualFee": 420,
"netAmount": 20000,
"mint_url": "https://mint.azzamo.net",
"format": "cashuA",
"preimage": "abc123..."
"amount": 16,
"invoiceAmount": 14,
"to": "user@twentyone.tips",
"fee": 2,
"netAmount": 14,
"mint_url": "https://kashu.me",
"format": "cashuB",
"preimage": "d5af74a3..."
}
```
**Success Response (using default address):**
```json
{
"success": true,
"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"
}
```
**Response fields:**
| 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**:
The API uses multiple indicators to verify payment success:
- `paid` flag from mint response
- Presence of payment preimage
- Payment state indicators
### `POST /api/validate-address`
### 3. `POST /api/validate-address`
Validate a Lightning address without redemption.
Validate a Lightning address without performing a redemption. Tests LNURLp resolution and returns address capabilities.
**Request:**
```json
{
"lightningAddress": "user@ln.tips"
"lightningAddress": "user@twentyone.tips"
}
```
@@ -120,48 +116,54 @@ Validate a Lightning address without redemption.
{
"success": true,
"valid": true,
"domain": "ln.tips",
"domain": "twentyone.tips",
"minSendable": 1,
"maxSendable": 100000000,
"commentAllowed": 144
"maxSendable": 1000000,
"commentAllowed": 2000
}
```
### 4. `GET /api/health`
Health check endpoint.
### `GET /api/health`
Health check endpoint returning server status, uptime, and memory usage.
**Response:**
```json
{
"status": "ok",
"timestamp": "2025-01-14T12:00:00Z",
"uptime": 3600,
"memory": {...},
"version": "1.0.0"
"timestamp": "2026-02-17T03:36:40.987Z",
"uptime": 170.23,
"memory": { "rss": 83873792, "heapTotal": 20520960, "heapUsed": 17170824 },
"version": "2.0.0"
}
```
## 🛠 Setup & Installation
### `GET /openapi.json`
Returns the full OpenAPI 3.0 specification as JSON.
## Setup & Installation
### Prerequisites
- Node.js >= 18.0.0
- npm or yarn
- npm >= 8.0.0
### Installation
1. **Clone and install dependencies:**
```bash
git clone <your-repo>
git clone https://github.com/Michilis/cashu-redeem-api.git
cd cashu-redeem-api
npm install
```
2. **Setup environment variables:**
2. **Configure environment variables:**
```bash
cp env.example .env
```
Edit `.env` file:
Edit `.env`:
```bash
# Server Configuration
PORT=3000
@@ -171,108 +173,148 @@ NODE_ENV=development
API_DOMAIN=localhost:3000
# Security Configuration
ALLOW_REDEEM_DOMAINS=ln.tips,getalby.com,wallet.mutinywallet.com
API_SECRET=your-secret-key-here
ALLOW_REDEEM_DOMAINS=*
# Default Lightning Address (used when no address is provided in redeem requests)
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com
# Rate Limiting (requests per minute)
RATE_LIMIT=100
# Rate Limiting (requests per minute per IP)
RATE_LIMIT=30
# Logging
LOG_LEVEL=info
# CORS Configuration
ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
ALLOWED_ORIGINS=*
```
3. **Start the server:**
```bash
# Development
# Development (with auto-reload)
npm run dev
# Production
npm start
```
## 🔧 Configuration
## Configuration
### Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `PORT` | Server port | `3000` | No |
| `NODE_ENV` | Environment | `development` | No |
| `ALLOW_REDEEM_DOMAINS` | Comma-separated allowed domains | All allowed | No |
| `NODE_ENV` | Environment (`development` / `production`) | `development` | 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 |
| `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
To restrict redemptions to specific Lightning address domains, set:
Restrict redemptions to specific Lightning address domains:
```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
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
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.
## Project Structure
### Data Flow
```
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
```
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
## Redemption Flow
## 🔒 Security Features
1. **Token Parsing** - Validate and decode the Cashu token structure
2. **Spendability Check** - Verify proofs are unspent at the mint
3. **Melt Quote** - Get exact fee from the mint before creating the invoice
4. **Address Resolution** - Resolve Lightning address via LNURLp
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
- **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
## Internal Status Codes
| Status | Description |
|--------|-------------|
| `processing` | Redemption is in progress |
| `processing` | Redemption initiated |
| `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 |
| `paid` | Successfully paid and completed |
| `paid` | Successfully completed |
| `failed` | Redemption failed (see error details) |
## Security Features
## 🧪 Testing
- **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
### Interactive Testing with Swagger
## Testing
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
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
# Health check
curl http://localhost:3000/api/health
## 🤝 Contributing
# Decode a token
curl -X POST http://localhost:3000/api/decode \
-H "Content-Type: application/json" \
-d '{"token": "cashuB..."}'
# Validate a Lightning address
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 \
-H "Content-Type: application/json" \
-d '{"token": "cashuB...", "lightningAddress": "user@twentyone.tips"}'
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
4. Submit a pull request
## 📝 License
## License
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 bolt11 = require('bolt11');
class LightningService {
class LightningComponent {
constructor() {
this.allowedDomains = process.env.ALLOW_REDEEM_DOMAINS
? process.env.ALLOW_REDEEM_DOMAINS.split(',').map(d => d.trim())
@@ -11,7 +11,6 @@ class LightningService {
/**
* Get the default Lightning address from environment
* @returns {string|null} Default Lightning address or null if not set
*/
getDefaultLightningAddress() {
return this.defaultLightningAddress || null;
@@ -19,8 +18,6 @@ class LightningService {
/**
* Get Lightning address to use - provided address or default
* @param {string|null} providedAddress - The provided Lightning address
* @returns {string} Lightning address to use
*/
getLightningAddressToUse(providedAddress) {
if (providedAddress && providedAddress.trim()) {
@@ -37,40 +34,26 @@ class LightningService {
/**
* Validate Lightning Address format
* @param {string} lightningAddress - The Lightning address (user@domain.com)
* @returns {boolean} Whether the address is valid
*/
validateLightningAddress(lightningAddress) {
if (!lightningAddress || typeof lightningAddress !== 'string') {
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(lightningAddress);
}
/**
* Check if a domain is allowed for redemption
* @param {string} domain - The domain to check
* @returns {boolean} Whether the domain is allowed
*/
isDomainAllowed(domain) {
if (this.allowedDomains.length === 0) {
return true; // If no restrictions, allow all
}
// Check for wildcard allowing all domains
if (this.allowedDomains.includes('*')) {
return true;
}
if (this.allowedDomains.length === 0) return true;
if (this.allowedDomains.includes('*')) return true;
return this.allowedDomains.includes(domain.toLowerCase());
}
/**
* Parse Lightning Address into username and domain
* @param {string} lightningAddress - The Lightning address
* @returns {Object} Parsed address components
*/
parseLightningAddress(lightningAddress) {
if (!this.validateLightningAddress(lightningAddress)) {
@@ -88,8 +71,6 @@ class LightningService {
/**
* Resolve LNURLp endpoint from Lightning address
* @param {string} lightningAddress - The Lightning address
* @returns {string} LNURLp endpoint URL
*/
getLNURLpEndpoint(lightningAddress) {
const { username, domain } = this.parseLightningAddress(lightningAddress);
@@ -98,16 +79,12 @@ class LightningService {
/**
* Fetch LNURLp response from endpoint
* @param {string} lnurlpUrl - The LNURLp endpoint URL
* @returns {Object} LNURLp response data
*/
async fetchLNURLpResponse(lnurlpUrl) {
try {
const response = await axios.get(lnurlpUrl, {
timeout: 10000,
headers: {
'User-Agent': 'Cashu-Redeem-API/1.0.0'
}
headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' }
});
if (response.status !== 200) {
@@ -135,10 +112,6 @@ class LightningService {
/**
* Get Lightning invoice from LNURLp callback
* @param {string} callbackUrl - The callback URL from LNURLp response
* @param {number} amount - Amount in millisatoshis
* @param {string} comment - Optional comment
* @returns {Object} Invoice response
*/
async getInvoice(callbackUrl, amount, comment = '') {
try {
@@ -146,14 +119,12 @@ class LightningService {
url.searchParams.set('amount', amount.toString());
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(), {
timeout: 10000,
headers: {
'User-Agent': 'Cashu-Redeem-API/1.0.0'
}
headers: { 'User-Agent': 'Cashu-Redeem-API/1.0.0' }
});
if (response.status !== 200) {
@@ -182,8 +153,6 @@ class LightningService {
/**
* Convert satoshis to millisatoshis
* @param {number} sats - Amount in satoshis
* @returns {number} Amount in millisatoshis
*/
satsToMillisats(sats) {
return sats * 1000;
@@ -191,8 +160,6 @@ class LightningService {
/**
* Convert millisatoshis to satoshis
* @param {number} msats - Amount in millisatoshis
* @returns {number} Amount in satoshis
*/
millisatsToSats(msats) {
return Math.floor(msats / 1000);
@@ -200,34 +167,24 @@ class LightningService {
/**
* Validate amount against LNURLp constraints
* @param {number} amount - Amount in satoshis
* @param {Object} lnurlpResponse - LNURLp response data
* @returns {boolean} Whether amount is valid
*/
validateAmount(amount, lnurlpResponse) {
const amountMsats = this.satsToMillisats(amount);
const minSendable = parseInt(lnurlpResponse.minSendable);
const maxSendable = parseInt(lnurlpResponse.maxSendable);
return amountMsats >= minSendable && amountMsats <= maxSendable;
}
/**
* Full Lightning address to invoice resolution
* @param {string} lightningAddress - The Lightning address
* @param {number} amount - Amount in satoshis
* @param {string} comment - Optional comment
* @returns {Object} Invoice and metadata
*/
async resolveInvoice(lightningAddress, amount, comment = 'Cashu token redemption') {
try {
console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`);
// Get LNURLp endpoint
const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress);
console.log(`LNURLp endpoint: ${lnurlpUrl}`);
// Fetch LNURLp response
const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl);
console.log('LNURLp response:', {
callback: lnurlpResponse.callback,
@@ -235,14 +192,12 @@ class LightningService {
maxSendable: lnurlpResponse.maxSendable
});
// Validate amount
if (!this.validateAmount(amount, lnurlpResponse)) {
const minSats = this.millisatsToSats(lnurlpResponse.minSendable);
const maxSats = this.millisatsToSats(lnurlpResponse.maxSendable);
throw new Error(`Amount ${amount} sats is outside allowed range: ${minSats}-${maxSats} sats`);
}
// Get invoice
const amountMsats = this.satsToMillisats(amount);
console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`);
console.log(`Using callback URL: ${lnurlpResponse.callback}`);
@@ -273,19 +228,15 @@ class LightningService {
/**
* Decode Lightning invoice (basic parsing)
* @param {string} bolt11 - Lightning invoice
* @returns {Object} Basic invoice info
*/
parseInvoice(bolt11) {
parseInvoice(bolt11Invoice) {
try {
// This is a simplified parser - for production use a proper library like bolt11
if (!bolt11.toLowerCase().startsWith('lnbc') && !bolt11.toLowerCase().startsWith('lntb')) {
if (!bolt11Invoice.toLowerCase().startsWith('lnbc') && !bolt11Invoice.toLowerCase().startsWith('lntb')) {
throw new Error('Invalid Lightning invoice format');
}
return {
bolt11,
network: bolt11.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet'
bolt11: bolt11Invoice,
network: bolt11Invoice.toLowerCase().startsWith('lnbc') ? 'mainnet' : 'testnet'
};
} catch (error) {
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
* @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) {
try {
console.log(`Verifying invoice destination for: ${expectedLightningAddress}`);
console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`);
// Decode the invoice using the bolt11 library
const decoded = bolt11.decode(bolt11Invoice);
// Basic validation checks
if (!decoded.complete) {
console.error('Invoice verification failed: Invoice is incomplete');
return false;
@@ -318,13 +263,11 @@ class LightningService {
return false;
}
// Check if the invoice has expired
if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) {
console.error('Invoice verification failed: Invoice has expired');
return false;
}
// Verify amount if provided
if (expectedAmount !== null) {
const invoiceAmount = decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0);
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 cashuService = require('./cashu');
const lightningService = require('./lightning');
const crypto = require('crypto');
const cashu = require('./cashu');
const lightning = require('./lightning');
class RedemptionService {
class RedemptionComponent {
constructor() {
// In-memory storage for redemption status
// In production, use Redis or a proper database
this.redemptions = new Map();
this.tokenHashes = new Map(); // Map token hashes to redemption IDs
this.tokenHashes = new Map();
}
/**
* Generate a simple hash for a token (for duplicate detection)
* @param {string} token - The Cashu token
* @returns {string} Hash of the token
*/
generateTokenHash(token) {
const crypto = require('crypto');
return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16);
}
/**
* Store redemption status
* @param {string} redeemId - The redemption ID
* @param {Object} status - The redemption status object
*/
storeRedemption(redeemId, status) {
this.redemptions.set(redeemId, {
@@ -35,8 +29,6 @@ class RedemptionService {
/**
* Update redemption status
* @param {string} redeemId - The redemption ID
* @param {Object} updates - Updates to apply
*/
updateRedemption(redeemId, updates) {
const existing = this.redemptions.get(redeemId);
@@ -51,8 +43,6 @@ class RedemptionService {
/**
* Get redemption status by ID
* @param {string} redeemId - The redemption ID
* @returns {Object|null} Redemption status or null if not found
*/
getRedemption(redeemId) {
return this.redemptions.get(redeemId) || null;
@@ -60,8 +50,6 @@ class RedemptionService {
/**
* Get redemption ID by token hash
* @param {string} tokenHash - The token hash
* @returns {string|null} Redemption ID or null if not found
*/
getRedemptionByTokenHash(tokenHash) {
const redeemId = this.tokenHashes.get(tokenHash);
@@ -70,8 +58,6 @@ class RedemptionService {
/**
* Check if a token has already been redeemed
* @param {string} token - The Cashu token
* @returns {Object|null} Existing redemption or null
*/
checkExistingRedemption(token) {
const tokenHash = this.generateTokenHash(token);
@@ -80,43 +66,36 @@ class RedemptionService {
/**
* Validate redemption request
* @param {string} token - The Cashu token
* @param {string} lightningAddress - The Lightning address (optional)
* @returns {Object} Validation result
*/
async validateRedemptionRequest(token, lightningAddress) {
const errors = [];
// Validate token format
if (!token || typeof token !== 'string') {
errors.push('Token is required and must be a string');
}
// Lightning address is now optional - we'll use default if not provided
let addressToUse = null;
try {
addressToUse = lightningService.getLightningAddressToUse(lightningAddress);
addressToUse = lightning.getLightningAddressToUse(lightningAddress);
if (!lightningService.validateLightningAddress(addressToUse)) {
if (!lightning.validateLightningAddress(addressToUse)) {
errors.push('Invalid Lightning address format');
}
} catch (error) {
errors.push(error.message);
}
// Check for existing redemption
if (token) {
const existing = this.checkExistingRedemption(token);
if (existing) {
if (existing && existing.status === 'paid') {
errors.push('Token has already been redeemed');
}
}
// Try to parse token
let tokenData = null;
if (token && errors.length === 0) {
try {
tokenData = await cashuService.parseToken(token);
tokenData = await cashu.parseToken(token);
if (tokenData.totalAmount <= 0) {
errors.push('Token has no value');
}
@@ -135,23 +114,18 @@ class RedemptionService {
/**
* Perform the complete redemption process
* @param {string} token - The Cashu token
* @param {string} lightningAddress - The Lightning address (optional)
* @returns {Object} Redemption result
*/
async performRedemption(token, lightningAddress) {
const redeemId = uuidv4();
const tokenHash = this.generateTokenHash(token);
try {
// Determine which Lightning address to use
const lightningAddressToUse = lightningService.getLightningAddressToUse(lightningAddress);
const lightningAddressToUse = lightning.getLightningAddressToUse(lightningAddress);
const isUsingDefault = !lightningAddress || !lightningAddress.trim();
// Store initial status
this.storeRedemption(redeemId, {
status: 'processing',
token: token.substring(0, 50) + '...', // Store partial token for reference
token: token.substring(0, 50) + '...',
tokenHash,
lightningAddress: lightningAddressToUse,
usingDefaultAddress: isUsingDefault,
@@ -160,12 +134,11 @@ class RedemptionService {
error: null
});
// Also map token hash to redemption ID
this.tokenHashes.set(tokenHash, redeemId);
// Step 1: Parse and validate token
this.updateRedemption(redeemId, { status: 'parsing_token' });
const tokenData = await cashuService.parseToken(token);
const tokenData = await cashu.parseToken(token);
this.updateRedemption(redeemId, {
amount: tokenData.totalAmount,
@@ -177,20 +150,17 @@ class RedemptionService {
// Check if token is spendable
this.updateRedemption(redeemId, { status: 'checking_spendability' });
try {
const spendabilityCheck = await cashuService.checkTokenSpendable(token);
const spendabilityCheck = await cashu.checkTokenSpendable(token);
if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) {
throw new Error('Token proofs are not spendable - they have already been used or are invalid');
}
} catch (spendError) {
// Check if the error indicates tokens are already spent (422 status)
if (spendError.message.includes('not spendable') ||
spendError.message.includes('already been used') ||
spendError.message.includes('invalid proofs') ||
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');
}
// For other errors, log but continue - some mints might not support this check
console.warn('Spendability check failed:', spendError.message);
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
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`);
const tempInvoiceData = await lightningService.resolveInvoice(
const tempInvoiceData = await lightning.resolveInvoice(
lightningAddressToUse,
tokenData.totalAmount, // Use full amount initially
tokenData.totalAmount,
'Cashu redemption'
);
// Get melt quote to determine exact fee
const meltQuote = await cashuService.getMeltQuote(token, tempInvoiceData.bolt11);
const meltQuote = await cashu.getMeltQuote(token, tempInvoiceData.bolt11);
const exactFee = meltQuote.fee_reserve;
const finalInvoiceAmount = tokenData.totalAmount - exactFee;
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' });
if (finalInvoiceAmount <= 0) {
@@ -222,9 +190,9 @@ class RedemptionService {
console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`);
const invoiceData = await lightningService.resolveInvoice(
const invoiceData = await lightning.resolveInvoice(
lightningAddressToUse,
finalInvoiceAmount, // Use amount minus exact fee
finalInvoiceAmount,
'Cashu redemption'
);
@@ -232,20 +200,18 @@ class RedemptionService {
bolt11: invoiceData.bolt11.substring(0, 50) + '...',
domain: invoiceData.domain,
invoiceAmount: finalInvoiceAmount,
exactFee: exactFee
exactFee
});
// Verify the invoice is valid and for the correct amount
const invoiceVerified = lightningService.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
const invoiceVerified = lightning.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount);
if (!invoiceVerified) {
throw new Error('Invoice verification failed - invalid invoice or amount mismatch');
}
// Step 4: Melt the token to pay the invoice
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:`, {
paid: meltResult.paid,
hasPreimage: !!meltResult.preimage,
@@ -253,11 +219,8 @@ class RedemptionService {
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;
// Step 4: Update final status
this.updateRedemption(redeemId, {
status: paymentSuccessful ? 'paid' : 'failed',
paid: paymentSuccessful,
@@ -267,7 +230,7 @@ class RedemptionService {
netAmount: meltResult.netAmount,
change: meltResult.change,
paidAt: paymentSuccessful ? new Date().toISOString() : null,
rawMeltResponse: meltResult.rawMeltResponse // Store for debugging
rawMeltResponse: meltResult.rawMeltResponse
});
return {
@@ -275,12 +238,12 @@ class RedemptionService {
redeemId,
paid: paymentSuccessful,
amount: tokenData.totalAmount,
invoiceAmount: finalInvoiceAmount, // Amount actually sent in the invoice
invoiceAmount: finalInvoiceAmount,
to: lightningAddressToUse,
usingDefaultAddress: isUsingDefault,
fee: exactFee, // Use the exact fee from the melt quote
fee: exactFee,
actualFee: meltResult.actualFee,
netAmount: finalInvoiceAmount, // This is the net amount the user receives
netAmount: finalInvoiceAmount,
preimage: meltResult.preimage,
change: meltResult.change,
mint: tokenData.mint,
@@ -288,13 +251,15 @@ class RedemptionService {
};
} catch (error) {
// Update redemption with error
this.updateRedemption(redeemId, {
status: 'failed',
paid: false,
error: error.message
});
// Remove token hash so the token can be retried after a failed redemption
this.tokenHashes.delete(tokenHash);
return {
success: false,
redeemId,
@@ -305,15 +270,11 @@ class RedemptionService {
/**
* Get redemption status for API response
* @param {string} redeemId - The redemption ID
* @returns {Object|null} Status response or null if not found
*/
getRedemptionStatus(redeemId) {
const redemption = this.getRedemption(redeemId);
if (!redemption) {
return null;
}
if (!redemption) return null;
const response = {
success: true,
@@ -327,32 +288,17 @@ class RedemptionService {
}
};
if (redemption.paidAt) {
response.details.paidAt = redemption.paidAt;
}
if (redemption.fee) {
response.details.fee = redemption.fee;
}
if (redemption.error) {
response.details.error = redemption.error;
}
if (redemption.mint) {
response.details.mint = redemption.mint;
}
if (redemption.domain) {
response.details.domain = redemption.domain;
}
if (redemption.paidAt) response.details.paidAt = redemption.paidAt;
if (redemption.fee) response.details.fee = redemption.fee;
if (redemption.error) response.details.error = redemption.error;
if (redemption.mint) response.details.mint = redemption.mint;
if (redemption.domain) response.details.domain = redemption.domain;
return response;
}
/**
* Get all redemptions (for admin/debugging)
* @returns {Array} All redemptions
*/
getAllRedemptions() {
return Array.from(this.redemptions.entries()).map(([id, data]) => ({
@@ -363,16 +309,14 @@ class RedemptionService {
/**
* 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);
for (const [redeemId, redemption] of this.redemptions.entries()) {
const createdAt = new Date(redemption.createdAt);
if (createdAt < cutoff) {
this.redemptions.delete(redeemId);
// Also clean up token hash mapping
if (redemption.tokenHash) {
this.tokenHashes.delete(redemption.tokenHash);
}
@@ -381,4 +325,4 @@ class RedemptionService {
}
}
module.exports = new RedemptionService();
module.exports = new RedemptionComponent();

186
package-lock.json generated
View File

@@ -1,21 +1,20 @@
{
"name": "cashu-redeem-api",
"version": "1.1.0",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cashu-redeem-api",
"version": "1.1.0",
"version": "2.0.0",
"license": "MIT",
"dependencies": {
"@cashu/cashu-ts": "^1.1.0",
"axios": "^1.7.7",
"@cashu/cashu-ts": "^3.4.1",
"axios": "^1.8.1",
"bolt11": "^1.4.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^8.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^10.0.0"
@@ -78,30 +77,30 @@
}
},
"node_modules/@cashu/cashu-ts": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-1.2.1.tgz",
"integrity": "sha512-B0+e02S8DQA8KBt2FgKHgGYvtHQokXJ3sZcTyAdHqvb0T0jfo1zF7nHn19eU9iYcfk8VSWf5xNBTocpTfj1aNg==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.4.1.tgz",
"integrity": "sha512-d8bgYbYIKCsT7Hs8BsoENrRehQmuA8qYscQuAGuCk4FsT4a+OGPYAJuxgCApwBhKCokclBjZzVnkS19/CygQ0g==",
"license": "MIT",
"dependencies": {
"@cashu/crypto": "^0.2.7",
"@noble/curves": "^1.3.0",
"@noble/hashes": "^1.3.3",
"@scure/bip32": "^1.3.3",
"@scure/bip39": "^1.2.2",
"buffer": "^6.0.3"
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@scure/base": "^2.0.0",
"@scure/bip32": "^2.0.1"
},
"engines": {
"node": ">=22.4.0"
}
},
"node_modules/@cashu/crypto": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.2.7.tgz",
"integrity": "sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==",
"node_modules/@cashu/cashu-ts/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",
"dependencies": {
"@noble/curves": "^1.3.0",
"@noble/hashes": "^1.3.3",
"@scure/bip32": "^1.3.3",
"@scure/bip39": "^1.2.2",
"buffer": "^6.0.3"
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@eslint-community/eslint-utils": {
@@ -368,15 +367,27 @@
"license": "MIT"
},
"node_modules/@noble/curves": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
"@noble/hashes": "2.0.1"
},
"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": {
"url": "https://paulmillr.com/funding/"
@@ -402,36 +413,35 @@
"license": "Apache-2.0"
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.9.0",
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
"node_modules/@scure/bip32/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",
"dependencies": {
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -603,26 +613,6 @@
"resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz",
"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": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz",
@@ -759,30 +749,6 @@
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1469,23 +1435,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.0.tgz",
"integrity": "sha512-FXEAp2ccTeN1ZSO+sPHRHWB0/CrTP5asFBjUaNeD9A0v3iPmgFbLu24vqPjiM9utszI58VGlMokjXQ0W9Dbmjw==",
"dependencies": {
"ip": "2.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1902,26 +1851,6 @@
"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": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1983,11 +1912,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "cashu-redeem-api",
"version": "1.1.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",
"main": "server.js",
"scripts": {
@@ -33,8 +33,8 @@
"url": "https://github.com/Michilis"
},
"dependencies": {
"@cashu/cashu-ts": "^1.1.0",
"axios": "^1.7.7",
"@cashu/cashu-ts": "^3.4.1",
"axios": "^1.8.1",
"bolt11": "^1.4.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",

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;

446
server.js
View File

@@ -3,9 +3,13 @@ const express = require('express');
const cors = require('cors');
const swaggerUi = require('swagger-ui-express');
const swaggerSpecs = require('./swagger.config');
const cashuService = require('./services/cashu');
const lightningService = require('./services/lightning');
const redemptionService = require('./services/redemption');
const redemption = require('./components/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 PORT = process.env.PORT || 3000;
@@ -33,8 +37,8 @@ app.use(cors({
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.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;
}
@@ -52,6 +56,27 @@ app.get('/api/cors-test', (req, res) => {
});
});
/**
* @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
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
customCss: '.swagger-ui .topbar { display: none }',
@@ -65,20 +90,18 @@ app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
// Basic rate limiting (simple in-memory implementation)
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) {
const clientId = req.ip || req.connection.remoteAddress;
const now = Date.now();
const windowStart = now - 60000; // 1 minute window
const windowStart = now - 60000;
if (!rateLimitMap.has(clientId)) {
rateLimitMap.set(clientId, []);
}
const requests = rateLimitMap.get(clientId);
// Remove old requests outside the window
const validRequests = requests.filter(time => time > windowStart);
rateLimitMap.set(clientId, validRequests);
@@ -103,21 +126,14 @@ app.use((req, res, next) => {
next();
});
// Error handling middleware
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// API Routes
// Root endpoint
app.get('/', (req, res) => {
res.json({
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',
documentation: '/docs',
openapi: '/openapi.json',
endpoints: {
decode: 'POST /api/decode',
redeem: 'POST /api/redeem',
@@ -136,384 +152,11 @@ app.get('/', (req, res) => {
});
});
// API Routes
/**
* @swagger
* /api/decode:
* post:
* summary: Decode a Cashu token
* description: Decode a Cashu token and return its content. Supports both v1 and v3 token formats.
* tags: [Token Operations]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/DecodeRequest'
* responses:
* 200:
* description: Token decoded successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/DecodeResponse'
* 400:
* $ref: '#/components/responses/BadRequest'
* 429:
* $ref: '#/components/responses/TooManyRequests'
* 500:
* $ref: '#/components/responses/InternalServerError'
*/
app.post('/api/decode', asyncHandler(async (req, res) => {
const { token } = req.body;
if (!token) {
return res.status(400).json({
success: false,
error: 'Token is required'
});
}
try {
// Validate token format first
if (!cashuService.isValidTokenFormat(token)) {
return res.status(400).json({
success: false,
error: 'Invalid token format. Must be a valid Cashu token'
});
}
const decoded = await cashuService.parseToken(token);
const mintUrl = await cashuService.getTokenMintUrl(token);
// Check if token is spent
let spent = false;
try {
const spendabilityCheck = await cashuService.checkTokenSpendable(token);
// Token is spent if no proofs are spendable
spent = !spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0;
} catch (error) {
// If spendability check fails, analyze the error to determine if token is spent
console.warn('Spendability check failed:', error.message);
// Check if error indicates proofs are already spent
const errorString = error.message || error.toString();
// Check for specific error indicators
if (errorString.includes('TOKEN_SPENT:')) {
// CashuService has determined the token is spent based on clear indicators
console.log('Token determined to be spent by CashuService');
spent = true;
} else if (errorString.includes('Token validation failed at mint:')) {
// This is a 422 error but not clearly indicating the token is spent
// It might be invalid/malformed but not necessarily spent
console.log('Token validation failed at mint - assuming token is still valid (might be invalid format)');
spent = false;
} else if (errorString.includes('not supported') ||
errorString.includes('endpoint not found') ||
errorString.includes('may still be valid') ||
errorString.includes('does not support spendability checking')) {
// Mint doesn't support spendability checking - assume token is still valid
console.log('Mint does not support spendability checking - assuming token is valid');
spent = false;
} else {
// For other errors (network, server issues), assume token is still valid
// This is safer than assuming it's spent
console.log('Unknown error - assuming token is valid');
spent = false;
}
}
res.json({
success: true,
decoded: {
mint: decoded.mint,
totalAmount: decoded.totalAmount,
numProofs: decoded.numProofs,
denominations: decoded.denominations,
format: decoded.format,
spent: spent
},
mint_url: mintUrl
});
} catch (error) {
res.status(400).json({
success: false,
error: error.message
});
}
}));
/**
* @swagger
* /api/redeem:
* post:
* summary: Redeem a Cashu token to Lightning address
* description: |
* Redeem a Cashu token to a Lightning address (optional - uses default if not provided).
*
* The redemption process includes:
* 1. Token validation and parsing
* 2. Getting exact melt quote from mint to determine precise fees
* 3. Invoice creation for net amount (token amount - exact fees)
* 4. Spendability checking at the mint
* 5. Token melting and Lightning payment
*
* **Important**: The system gets the exact fee from the mint before creating the invoice.
* The `invoiceAmount` field shows the actual amount sent to the Lightning address.
* No sats are lost to fee estimation errors.
* tags: [Token Operations]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RedeemRequest'
* responses:
* 200:
* description: Token redeemed successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RedeemResponse'
* 400:
* $ref: '#/components/responses/BadRequest'
* 409:
* description: Token already spent
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* error:
* type: string
* example: "This token has already been spent and cannot be redeemed again"
* errorType:
* type: string
* example: "token_already_spent"
* 422:
* description: Insufficient funds or unprocessable token
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* error:
* type: string
* example: "Token amount is insufficient to cover the minimum fee"
* errorType:
* type: string
* example: "insufficient_funds"
* 429:
* $ref: '#/components/responses/TooManyRequests'
* 500:
* $ref: '#/components/responses/InternalServerError'
*/
app.post('/api/redeem', asyncHandler(async (req, res) => {
const { token, lightningAddress } = req.body;
// Validate request (lightningAddress is now optional)
const validation = await redemptionService.validateRedemptionRequest(token, lightningAddress);
if (!validation.valid) {
return res.status(400).json({
success: false,
error: validation.errors.join(', ')
});
}
// Perform redemption
try {
const result = await redemptionService.performRedemption(token, lightningAddress);
if (result.success) {
const response = {
success: true,
paid: result.paid,
amount: result.amount,
invoiceAmount: result.invoiceAmount,
to: result.to,
fee: result.fee,
actualFee: result.actualFee,
netAmount: result.netAmount,
mint_url: result.mint,
format: result.format
};
// Include info about whether default address was used
if (result.usingDefaultAddress) {
response.usingDefaultAddress = true;
response.message = `Redeemed to default Lightning address: ${result.to}`;
}
// Include preimage if available
if (result.preimage) {
response.preimage = result.preimage;
}
res.json(response);
} else {
// Determine appropriate status code based on error type
let statusCode = 400;
if (result.error && (
result.error.includes('cannot be redeemed') ||
result.error.includes('already been used') ||
result.error.includes('not spendable') ||
result.error.includes('already spent') ||
result.error.includes('invalid proofs')
)) {
// Use 409 Conflict for already-spent tokens to distinguish from generic bad requests
statusCode = 409;
} else if (result.error && result.error.includes('insufficient')) {
// Use 422 for insufficient funds
statusCode = 422;
}
res.status(statusCode).json({
success: false,
error: result.error,
errorType: statusCode === 409 ? 'token_already_spent' :
statusCode === 422 ? 'insufficient_funds' : 'validation_error'
});
}
} catch (error) {
console.error('Error in redemption:', error);
res.status(500).json({
success: false,
error: 'Internal server error during redemption'
});
}
}));
/**
* @swagger
* /api/health:
* get:
* summary: Health check endpoint
* description: |
* Check the health and status of the API server.
* Returns server information including uptime, memory usage, and version.
* tags: [Status & Monitoring]
* responses:
* 200:
* description: Server is healthy
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/HealthResponse'
* 500:
* $ref: '#/components/responses/InternalServerError'
*/
app.get('/api/health', asyncHandler(async (req, res) => {
try {
const packageJson = require('./package.json');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
version: packageJson.version
});
} catch (error) {
console.error('Health check error:', error);
res.status(500).json({
status: 'error',
timestamp: new Date().toISOString(),
error: 'Health check failed'
});
}
}));
/**
* @swagger
* /api/validate-address:
* post:
* summary: Validate a Lightning address
* description: |
* Validate a Lightning address without performing a redemption.
* Checks format validity and tests LNURLp resolution.
*
* Returns information about the Lightning address capabilities
* including min/max sendable amounts and comment allowance.
* tags: [Validation]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ValidateAddressRequest'
* responses:
* 200:
* description: Validation completed (check 'valid' field for result)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ValidateAddressResponse'
* 400:
* $ref: '#/components/responses/BadRequest'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.post('/api/validate-address', asyncHandler(async (req, res) => {
const { lightningAddress } = req.body;
if (!lightningAddress) {
return res.status(400).json({
success: false,
error: 'Lightning address is required'
});
}
try {
const isValid = lightningService.validateLightningAddress(lightningAddress);
if (!isValid) {
return res.json({
success: false,
valid: false,
error: 'Invalid Lightning address format'
});
}
// Test resolution
const { domain } = lightningService.parseLightningAddress(lightningAddress);
const lnurlpUrl = lightningService.getLNURLpEndpoint(lightningAddress);
try {
const lnurlpResponse = await lightningService.fetchLNURLpResponse(lnurlpUrl);
res.json({
success: true,
valid: true,
domain,
minSendable: lightningService.millisatsToSats(lnurlpResponse.minSendable),
maxSendable: lightningService.millisatsToSats(lnurlpResponse.maxSendable),
commentAllowed: lnurlpResponse.commentAllowed || 0
});
} catch (error) {
res.json({
success: false,
valid: false,
error: `Lightning address resolution failed: ${error.message}`
});
}
} catch (error) {
res.status(400).json({
success: false,
valid: false,
error: error.message
});
}
}));
// Mount API routes
app.use('/api', cashuRoutes);
app.use('/api', redemptionRoutes);
app.use('/api', lightningRoutes);
app.use('/api', healthRoutes);
// 404 handler
app.use('*', (req, res) => {
@@ -543,12 +186,12 @@ app.use((error, req, res, next) => {
// Cleanup old redemptions periodically (every hour)
setInterval(() => {
try {
redemptionService.cleanupOldRedemptions();
redemption.cleanupOldRedemptions();
console.log('Cleaned up old redemptions');
} catch (error) {
console.error('Error cleaning up redemptions:', error);
}
}, 60 * 60 * 1000); // 1 hour
}, 60 * 60 * 1000);
// Graceful shutdown
process.on('SIGTERM', () => {
@@ -563,8 +206,9 @@ process.on('SIGINT', () => {
// Start server
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(`📋 OpenAPI spec: http://localhost:${PORT}/openapi.json`);
console.log(`📍 Health check: http://localhost:${PORT}/api/health`);
console.log(`🔒 Environment: ${process.env.NODE_ENV || 'development'}`);
@@ -581,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

@@ -16,7 +16,7 @@ const options = {
openapi: '3.0.0',
info: {
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.',
contact: {
name: 'API Support',
@@ -337,10 +337,14 @@ const options = {
{
name: 'Validation',
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);