Better error handling
This commit is contained in:
80
README.md
80
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
|
- **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.
|
||||||
61
server.js
61
server.js
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
if (status === 404) {
|
// For 422 errors, we know it's about already spent tokens
|
||||||
errorMessage = 'This mint does not support spendability checking (endpoint not found)';
|
if (status === 422) {
|
||||||
} else if (status === 405) {
|
errorMessage = 'Token proofs are not spendable - they have already been used or are invalid';
|
||||||
errorMessage = 'This mint does not support spendability checking (method not allowed)';
|
if (!isExpected422Error) {
|
||||||
} else if (status === 501) {
|
console.log('Detected 422 status - token already spent');
|
||||||
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') ||
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user