From 7370952308cfd032a6fc3ecdd55ea9f2004cdf4b Mon Sep 17 00:00:00 2001 From: michilis Date: Sat, 31 May 2025 16:31:54 +0200 Subject: [PATCH] Better error handling --- README.md | 82 +++++++++++++++++++++++++---- server.js | 61 +++++++++++++++++++++- services/cashu.js | 116 +++++++++++++++++++++++++++++++---------- services/redemption.js | 12 ++++- 4 files changed, 229 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index aa885e1..3b2c76b 100644 --- a/README.md +++ b/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 -``` \ No newline at end of file + +```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. \ No newline at end of file diff --git a/server.js b/server.js index fe4d1a0..d275aa6 100644 --- a/server.js +++ b/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) { diff --git a/services/cashu.js b/services/cashu.js index ce9a699..84e7b05 100644 --- a/services/cashu.js +++ b/services/cashu.js @@ -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') || diff --git a/services/redemption.js b/services/redemption.js index be04787..00dd306 100644 --- a/services/redemption.js +++ b/services/redemption.js @@ -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); }