Better error handling

This commit is contained in:
michilis
2025-05-31 16:31:54 +02:00
parent 076743b417
commit 7370952308
4 changed files with 229 additions and 42 deletions

View File

@@ -7,10 +7,11 @@ A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses
- **Decode Cashu tokens** - Parse and validate token content - **Decode Cashu tokens** - Parse and validate token content
- **Redeem to Lightning addresses** - Convert ecash to Lightning payments via LNURLp - **Redeem to Lightning addresses** - Convert ecash to Lightning payments via LNURLp
- **Security features** - Domain restrictions, rate limiting, input validation - **Security features** - Domain restrictions, rate limiting, input validation
- **Robust error handling** - Comprehensive error messages and status codes - **Robust error handling** - Comprehensive error messages
- **In-memory caching** - Fast mint and wallet instances with connection pooling - **In-memory caching** - Fast mint and wallet instances with connection pooling
- **Interactive API Documentation** - Complete Swagger/OpenAPI documentation at `/docs` - **Interactive API Documentation** - Complete Swagger/OpenAPI documentation at `/docs`
## 📖 API Documentation ## 📖 API Documentation
**Interactive Swagger Documentation**: Visit `/docs` when running the server for a complete, interactive API reference. **Interactive Swagger Documentation**: Visit `/docs` when running the server for a complete, interactive API reference.
@@ -107,14 +108,6 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if
} }
``` ```
**Important Note on Fees**:
- Fees are calculated according to NUT-05 (2% of token amount, minimum 1 satoshi)
- **Fees are subtracted from the token amount before creating the Lightning invoice**
- `amount`: Original token amount
- `invoiceAmount`: Actual amount sent to Lightning address (amount - expected fees)
- `fee`: Actual fee charged by the mint (from melt response)
- `actualFee`: Calculated expected fee (for comparison)
- `netAmount`: Final amount after all deductions
**Payment Verification**: **Payment Verification**:
The API uses multiple indicators to verify payment success: The API uses multiple indicators to verify payment success:
@@ -289,4 +282,73 @@ This allows users to redeem tokens without specifying a Lightning address - the
## 📊 Monitoring ## 📊 Monitoring
### Health Check ### Health Check
```
```bash
curl http://localhost:3000/api/health
```
### Logs
The server logs all requests and errors to console. In production, consider using a proper logging solution like Winston.
## 🧪 Testing
### Interactive Testing with Swagger
The easiest way to test the API is using the interactive Swagger documentation at `/docs`:
- Visit `http://localhost:3000/docs`
- Click "Try it out" on any endpoint
- Fill in the request parameters
- Execute the request directly from the browser
### Example cURL commands
**Decode a token:**
```bash
curl -X POST http://localhost:3000/api/decode \
-H "Content-Type: application/json" \
-d '{"token":"your-cashu-token-here"}'
```
**Redeem a token to specific address:**
```bash
curl -X POST http://localhost:3000/api/redeem \
-H "Content-Type: application/json" \
-d '{
"token": "your-cashu-token-here",
"lightningAddress": "user@ln.tips"
}'
```
**Redeem a token to default address:**
```bash
curl -X POST http://localhost:3000/api/redeem \
-H "Content-Type: application/json" \
-d '{
"token": "your-cashu-token-here"
}'
```
## 🚀 Production Deployment
### Recommendations
1. **Use a process manager** (PM2, systemd)
2. **Set up reverse proxy** (nginx, Apache)
3. **Enable HTTPS** with SSL certificates
4. **Use Redis** for persistent storage instead of in-memory
5. **Set up monitoring** (Prometheus, Grafana)
6. **Configure logging** (Winston, structured logs)
7. **Set resource limits** and health checks
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## 📝 License
MIT License - see LICENSE file for details.

View File

@@ -180,6 +180,44 @@ app.post('/api/decode', asyncHandler(async (req, res) => {
* $ref: '#/components/schemas/RedeemResponse' * $ref: '#/components/schemas/RedeemResponse'
* 400: * 400:
* $ref: '#/components/responses/BadRequest' * $ref: '#/components/responses/BadRequest'
* 409:
* description: Token already spent
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* error:
* type: string
* example: "This token has already been spent and cannot be redeemed again"
* redeemId:
* type: string
* format: uuid
* errorType:
* type: string
* example: "token_already_spent"
* 422:
* description: Insufficient funds or unprocessable token
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* error:
* type: string
* example: "Token amount is insufficient to cover the minimum fee"
* redeemId:
* type: string
* format: uuid
* errorType:
* type: string
* example: "insufficient_funds"
* 429: * 429:
* $ref: '#/components/responses/TooManyRequests' * $ref: '#/components/responses/TooManyRequests'
* 500: * 500:
@@ -230,10 +268,29 @@ app.post('/api/redeem', asyncHandler(async (req, res) => {
res.json(response); res.json(response);
} else { } else {
res.status(400).json({ // 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, success: false,
error: result.error, error: result.error,
redeemId: result.redeemId redeemId: result.redeemId,
errorType: statusCode === 409 ? 'token_already_spent' :
statusCode === 422 ? 'insufficient_funds' : 'validation_error'
}); });
} }
} catch (error) { } catch (error) {

View File

@@ -257,6 +257,15 @@ class CashuService {
error.message.includes('Quote not found')) { error.message.includes('Quote not found')) {
throw error; // Re-throw specific cashu errors 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}`); throw new Error(`Melt operation failed: ${error.message}`);
} }
} }
@@ -321,43 +330,89 @@ class CashuService {
totalAmount: parsed.totalAmount totalAmount: parsed.totalAmount
}; };
} catch (error) { } catch (error) {
// Enhanced error logging // Check if it's a known 422 error (already spent token) - log less verbosely
console.error('Spendability check error details:', { const isExpected422Error = (error.status === 422 || error.response?.status === 422) &&
errorType: error.constructor.name, error.constructor.name === 'HttpResponseError';
errorMessage: error.message,
errorCode: error.code, if (isExpected422Error) {
errorStatus: error.status, console.log('Token spendability check: 422 status detected - token already spent');
errorResponse: error.response, } else {
errorData: error.data, // Enhanced error logging for unexpected errors
errorStack: error.stack, console.error('Spendability check error details:', {
errorString: String(error) 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 // Handle different types of errors
let errorMessage = 'Unknown error occurred'; let errorMessage = 'Unknown error occurred';
// Handle cashu-ts HttpResponseError specifically // Handle cashu-ts HttpResponseError specifically
if (error.constructor.name === 'HttpResponseError') { if (error.constructor.name === 'HttpResponseError') {
console.log('HttpResponseError detected, extracting details...'); if (!isExpected422Error) {
console.log('HttpResponseError detected, extracting details...');
}
// Try to extract useful information from the HTTP response error // Extract status code first
if (error.response) { const status = error.status || error.response?.status || error.statusCode;
const status = error.response.status || error.status;
const statusText = error.response.statusText; // For 422 errors, we know it's about already spent tokens
if (status === 422) {
if (status === 404) { errorMessage = 'Token proofs are not spendable - they have already been used or are invalid';
errorMessage = 'This mint does not support spendability checking (endpoint not found)'; if (!isExpected422Error) {
} else if (status === 405) { console.log('Detected 422 status - token already spent');
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 { } else {
errorMessage = 'This mint does not support spendability checking or returned an invalid response'; // 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') { } else if (error && typeof error === 'object') {
if (error.message && error.message !== '[object Object]') { if (error.message && error.message !== '[object Object]') {
@@ -376,6 +431,11 @@ class CashuService {
errorMessage = error; errorMessage = error;
} }
// Log the final extracted error message for debugging
if (!isExpected422Error) {
console.log('Final extracted error message:', errorMessage);
}
// Check if it's a known error pattern indicating unsupported operation // Check if it's a known error pattern indicating unsupported operation
if (errorMessage.includes('not supported') || if (errorMessage.includes('not supported') ||
errorMessage.includes('404') || errorMessage.includes('404') ||

View File

@@ -192,10 +192,18 @@ class RedemptionService {
try { try {
const spendabilityCheck = await cashuService.checkTokenSpendable(token); const spendabilityCheck = await cashuService.checkTokenSpendable(token);
if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) { if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) {
throw new Error('Token proofs are not spendable - may have already been used'); throw new Error('Token proofs are not spendable - they have already been used or are invalid');
} }
} catch (spendError) { } catch (spendError) {
// Log but don't fail - some mints might not support this check // 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');
}
// Log but don't fail for other errors - some mints might not support this check
console.warn('Spendability check failed:', spendError.message); console.warn('Spendability check failed:', spendError.message);
} }