Better error handling
This commit is contained in:
82
README.md
82
README.md
@@ -7,10 +7,11 @@ A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses
|
||||
- **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 and status codes
|
||||
- **Robust error handling** - Comprehensive error messages
|
||||
- **In-memory caching** - Fast mint and wallet instances with connection pooling
|
||||
- **Interactive API Documentation** - Complete Swagger/OpenAPI documentation at `/docs`
|
||||
|
||||
|
||||
## 📖 API Documentation
|
||||
|
||||
**Interactive Swagger Documentation**: Visit `/docs` when running the server for a complete, interactive API reference.
|
||||
@@ -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**:
|
||||
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
|
||||
|
||||
### 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.
|
||||
61
server.js
61
server.js
@@ -180,6 +180,44 @@ app.post('/api/decode', asyncHandler(async (req, res) => {
|
||||
* $ref: '#/components/schemas/RedeemResponse'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequest'
|
||||
* 409:
|
||||
* description: Token already spent
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: string
|
||||
* example: "This token has already been spent and cannot be redeemed again"
|
||||
* redeemId:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* errorType:
|
||||
* type: string
|
||||
* example: "token_already_spent"
|
||||
* 422:
|
||||
* description: Insufficient funds or unprocessable token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: string
|
||||
* example: "Token amount is insufficient to cover the minimum fee"
|
||||
* redeemId:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* errorType:
|
||||
* type: string
|
||||
* example: "insufficient_funds"
|
||||
* 429:
|
||||
* $ref: '#/components/responses/TooManyRequests'
|
||||
* 500:
|
||||
@@ -230,10 +268,29 @@ app.post('/api/redeem', asyncHandler(async (req, res) => {
|
||||
|
||||
res.json(response);
|
||||
} 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,
|
||||
error: result.error,
|
||||
redeemId: result.redeemId
|
||||
redeemId: result.redeemId,
|
||||
errorType: statusCode === 409 ? 'token_already_spent' :
|
||||
statusCode === 422 ? 'insufficient_funds' : 'validation_error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -257,6 +257,15 @@ class CashuService {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -321,43 +330,89 @@ class CashuService {
|
||||
totalAmount: parsed.totalAmount
|
||||
};
|
||||
} catch (error) {
|
||||
// Enhanced error logging
|
||||
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)
|
||||
});
|
||||
// Check if it's a known 422 error (already spent token) - log less verbosely
|
||||
const isExpected422Error = (error.status === 422 || error.response?.status === 422) &&
|
||||
error.constructor.name === 'HttpResponseError';
|
||||
|
||||
if (isExpected422Error) {
|
||||
console.log('Token spendability check: 422 status detected - token already spent');
|
||||
} else {
|
||||
// Enhanced error logging for unexpected errors
|
||||
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') {
|
||||
console.log('HttpResponseError detected, extracting details...');
|
||||
if (!isExpected422Error) {
|
||||
console.log('HttpResponseError detected, extracting details...');
|
||||
}
|
||||
|
||||
// Try to extract useful information from the HTTP response error
|
||||
if (error.response) {
|
||||
const status = error.response.status || error.status;
|
||||
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 : ''}`;
|
||||
// Extract status code first
|
||||
const status = error.status || error.response?.status || error.statusCode;
|
||||
|
||||
// For 422 errors, we know it's about already spent tokens
|
||||
if (status === 422) {
|
||||
errorMessage = 'Token proofs are not spendable - they have already been used or are invalid';
|
||||
if (!isExpected422Error) {
|
||||
console.log('Detected 422 status - token already spent');
|
||||
}
|
||||
} else if (error.message && error.message !== '[object Object]') {
|
||||
errorMessage = error.message;
|
||||
} 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') {
|
||||
if (error.message && error.message !== '[object Object]') {
|
||||
@@ -376,6 +431,11 @@ class CashuService {
|
||||
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
|
||||
if (errorMessage.includes('not supported') ||
|
||||
errorMessage.includes('404') ||
|
||||
|
||||
@@ -192,10 +192,18 @@ class RedemptionService {
|
||||
try {
|
||||
const spendabilityCheck = await cashuService.checkTokenSpendable(token);
|
||||
if (!spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0) {
|
||||
throw new Error('Token proofs are not spendable - may have already been used');
|
||||
throw new Error('Token proofs are not spendable - they have already been used or are invalid');
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user