Updates fix endpoints.

This commit is contained in:
michilis
2025-05-31 15:30:07 +02:00
parent fc7927e1c8
commit 877d472e7c
5 changed files with 227 additions and 666 deletions

242
README.md
View File

@@ -78,6 +78,7 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762", "redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762",
"paid": true, "paid": true,
"amount": 21000, "amount": 21000,
"invoiceAmount": 20580,
"to": "user@ln.tips", "to": "user@ln.tips",
"fee": 1000, "fee": 1000,
"actualFee": 420, "actualFee": 420,
@@ -95,6 +96,7 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762", "redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762",
"paid": true, "paid": true,
"amount": 21000, "amount": 21000,
"invoiceAmount": 20580,
"to": "admin@your-domain.com", "to": "admin@your-domain.com",
"fee": 1000, "fee": 1000,
"actualFee": 420, "actualFee": 420,
@@ -106,51 +108,24 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if
} }
``` ```
### 3. `POST /api/status` **Important Note on Fees**:
Check redemption status using redeemId. - 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
- `actualFee`: Calculated expected fee
- `netAmount`: Final amount after all deductions
**Request:** **Payment Verification**:
```json The API uses multiple indicators to verify payment success:
{ - `paid` flag from mint response
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762" - Presence of payment preimage
} - Payment state indicators
```
**Response:** If you receive a "payment failed" error but the Lightning payment was successful, use the debug endpoint to investigate the raw mint response.
```json
{
"success": true,
"status": "paid",
"details": {
"amount": 21000,
"to": "user@ln.tips",
"paid": true,
"paidAt": "2025-01-14T12:00:00Z",
"fee": 1000,
"createdAt": "2025-01-14T11:59:30Z",
"updatedAt": "2025-01-14T12:00:00Z"
}
}
```
### 4. `GET /api/status/:redeemId` ### 3. `POST /api/validate-address`
Same as above, but via URL parameter for frontend polling.
### 5. `GET /api/health`
Health check endpoint.
**Response:**
```json
{
"status": "ok",
"timestamp": "2025-01-14T12:00:00Z",
"uptime": 3600,
"memory": {...},
"version": "1.0.0"
}
```
### 6. `POST /api/validate-address`
Validate a Lightning address without redemption. Validate a Lightning address without redemption.
**Request:** **Request:**
@@ -172,25 +147,7 @@ Validate a Lightning address without redemption.
} }
``` ```
### 7. `GET /api/stats` ### 4. `POST /api/check-spendable`
Get redemption statistics (admin endpoint).
**Response:**
```json
{
"success": true,
"stats": {
"total": 150,
"paid": 142,
"failed": 8,
"processing": 0,
"totalAmount": 2500000,
"totalFees": 15000
}
}
```
### 8. `POST /api/check-spendable`
Check if a Cashu token is spendable at its mint before attempting redemption. Check if a Cashu token is spendable at its mint before attempting redemption.
**Request:** **Request:**
@@ -200,7 +157,7 @@ Check if a Cashu token is spendable at its mint before attempting redemption.
} }
``` ```
**Response:** **Success Response:**
```json ```json
{ {
"success": true, "success": true,
@@ -208,10 +165,38 @@ Check if a Cashu token is spendable at its mint before attempting redemption.
"pending": [], "pending": [],
"mintUrl": "https://mint.azzamo.net", "mintUrl": "https://mint.azzamo.net",
"totalAmount": 21000, "totalAmount": 21000,
"message": "Token is spendable" "spendableCount": 2,
"totalProofs": 3,
"message": "2 of 3 token proofs are spendable"
} }
``` ```
**Response (when mint doesn't support spendability checking):**
```json
{
"success": true,
"supported": false,
"message": "This mint does not support spendability checking. Token format appears valid.",
"error": "This mint does not support spendability checking. Token may still be valid."
}
```
**Fallback Response (when spendability check fails but token is valid):**
```json
{
"success": true,
"supported": false,
"fallback": true,
"mintUrl": "https://21mint.me",
"totalAmount": 21000,
"totalProofs": 8,
"message": "Spendability check failed, but token format is valid. Token may still be usable.",
"error": "Failed to check token spendability: [error details]"
}
```
**Note**: Some mints may not support spendability checking. In such cases, the endpoint will return `supported: false` with a success status, indicating that while the check couldn't be performed, the token format itself appears valid.
## 🛠 Setup & Installation ## 🛠 Setup & Installation
### Prerequisites ### Prerequisites
@@ -314,7 +299,7 @@ This allows users to redeem tokens without specifying a Lightning address - the
- Coordinates the complete redemption process - Coordinates the complete redemption process
- Manages redemption status tracking - Manages redemption status tracking
- Handles duplicate token detection - Handles duplicate token detection
- Provides statistics and cleanup - Provides cleanup functionality
### Data Flow ### Data Flow
@@ -343,137 +328,8 @@ This allows users to redeem tokens without specifying a Lightning address - the
| `melting_token` | Performing the melt operation | | `melting_token` | Performing the melt operation |
| `paid` | Successfully paid and completed | | `paid` | Successfully paid and completed |
| `failed` | Redemption failed (see error details) | | `failed` | Redemption failed (see error details) |
| `checking_spendability` | Verifying token is spendable at mint |
## 📊 Monitoring ## 📊 Monitoring
### Health Check ### Health Check
```bash
curl http://localhost:3000/api/health
``` ```
### Statistics
```bash
curl http://localhost:3000/api/stats
```
### 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"}'
```
**Check if token is spendable:**
```bash
curl -X POST http://localhost:3000/api/check-spendable \
-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"
}'
```
**Check status:**
```bash
curl -X POST http://localhost:3000/api/status \
-H "Content-Type: application/json" \
-d '{"redeemId":"your-redeem-id-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
### Docker Deployment
Create a `Dockerfile`:
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
```
### Environment-specific configs
**Production `.env`:**
```bash
NODE_ENV=production
PORT=3000
ALLOW_REDEEM_DOMAINS=ln.tips,getalby.com
DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com
RATE_LIMIT=200
ALLOWED_ORIGINS=https://yourdomain.com
```
## 🤝 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.
## 🆘 Support
For issues and questions:
- Create an issue on GitHub
- Check the logs for detailed error messages
- Verify your environment configuration
- Test with the health endpoint first
## 🔮 Roadmap
- [ ] Add Redis/database persistence
- [ ] Implement webhook notifications
- [ ] Add batch redemption support
- [ ] Enhanced monitoring and metrics
- [ ] WebSocket real-time status updates
- [ ] Multi-mint support optimization

223
server.js
View File

@@ -157,11 +157,13 @@ app.post('/api/decode', asyncHandler(async (req, res) => {
* *
* The redemption process includes: * The redemption process includes:
* 1. Token validation and parsing * 1. Token validation and parsing
* 2. Spendability checking at the mint * 2. Fee calculation (NUT-05: 2% of amount, minimum 1 satoshi)
* 3. Lightning address resolution via LNURLp * 3. Invoice creation for net amount (token amount - fees)
* 4. Token melting and Lightning payment * 4. Spendability checking at the mint
* 5. Token melting and Lightning payment
* *
* Fee calculation follows NUT-05 specification (2% of amount, minimum 1 satoshi). * **Important**: Fees are subtracted from the token amount before creating the Lightning invoice.
* The `invoiceAmount` field shows the actual amount sent to the Lightning address.
* tags: [Token Operations] * tags: [Token Operations]
* requestBody: * requestBody:
* required: true * required: true
@@ -206,6 +208,7 @@ app.post('/api/redeem', asyncHandler(async (req, res) => {
redeemId: result.redeemId, redeemId: result.redeemId,
paid: result.paid, paid: result.paid,
amount: result.amount, amount: result.amount,
invoiceAmount: result.invoiceAmount,
to: result.to, to: result.to,
fee: result.fee, fee: result.fee,
actualFee: result.actualFee, actualFee: result.actualFee,
@@ -225,11 +228,6 @@ app.post('/api/redeem', asyncHandler(async (req, res) => {
response.preimage = result.preimage; response.preimage = result.preimage;
} }
// Include change if any
if (result.change && result.change.length > 0) {
response.change = result.change;
}
res.json(response); res.json(response);
} else { } else {
res.status(400).json({ res.status(400).json({
@@ -247,98 +245,6 @@ app.post('/api/redeem', asyncHandler(async (req, res) => {
} }
})); }));
/**
* @swagger
* /api/status:
* post:
* summary: Check redemption status by redeemId
* description: Check the current status of a redemption using its unique ID.
* tags: [Status & Monitoring]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatusRequest'
* responses:
* 200:
* description: Status retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatusResponse'
* 400:
* $ref: '#/components/responses/BadRequest'
* 404:
* $ref: '#/components/responses/NotFound'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.post('/api/status', asyncHandler(async (req, res) => {
const { redeemId } = req.body;
if (!redeemId) {
return res.status(400).json({
success: false,
error: 'redeemId is required'
});
}
const status = redemptionService.getRedemptionStatus(redeemId);
if (!status) {
return res.status(404).json({
success: false,
error: 'Redemption not found'
});
}
res.json(status);
}));
/**
* @swagger
* /api/status/{redeemId}:
* get:
* summary: Check redemption status via URL parameter
* description: Same as POST /api/status but uses URL parameter - useful for frontend polling.
* tags: [Status & Monitoring]
* parameters:
* - in: path
* name: redeemId
* required: true
* description: Unique redemption ID to check status for
* schema:
* type: string
* format: uuid
* example: '8e99101e-d034-4d2e-9ccf-dfda24d26762'
* responses:
* 200:
* description: Status retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatusResponse'
* 404:
* $ref: '#/components/responses/NotFound'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.get('/api/status/:redeemId', asyncHandler(async (req, res) => {
const { redeemId } = req.params;
const status = redemptionService.getRedemptionStatus(redeemId);
if (!status) {
return res.status(404).json({
success: false,
error: 'Redemption not found'
});
}
res.json(status);
}));
/** /**
* @swagger * @swagger
* /api/health: * /api/health:
@@ -355,46 +261,28 @@ app.get('/api/status/:redeemId', asyncHandler(async (req, res) => {
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/HealthResponse' * $ref: '#/components/schemas/HealthResponse'
* 500:
* $ref: '#/components/responses/InternalServerError'
*/ */
app.get('/api/health', (req, res) => { app.get('/api/health', asyncHandler(async (req, res) => {
try {
const packageJson = require('./package.json');
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime(), uptime: process.uptime(),
memory: process.memoryUsage(), memory: process.memoryUsage(),
version: require('./package.json').version 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/stats:
* get:
* summary: Get redemption statistics
* description: |
* Get comprehensive statistics about redemptions (admin endpoint).
* Returns information about total redemptions, success rates, amounts, and fees.
*
* **Note**: In production, this endpoint should be protected with authentication.
* tags: [Status & Monitoring]
* responses:
* 200:
* description: Statistics retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatsResponse'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.get('/api/stats', asyncHandler(async (req, res) => {
// In production, add authentication here
const stats = redemptionService.getStats();
res.json({
success: true,
stats
});
})); }));
/** /**
@@ -479,75 +367,6 @@ app.post('/api/validate-address', asyncHandler(async (req, res) => {
} }
})); }));
/**
* @swagger
* /api/check-spendable:
* post:
* summary: Check if Cashu token is spendable
* description: |
* Check if a Cashu token is spendable at its mint before attempting redemption.
* This is a pre-validation step that can save time and prevent failed redemptions.
*
* Returns an array indicating which proofs within the token are spendable,
* pending, or already spent.
* tags: [Validation]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CheckSpendableRequest'
* responses:
* 200:
* description: Spendability check completed
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CheckSpendableResponse'
* 400:
* $ref: '#/components/responses/BadRequest'
* 429:
* $ref: '#/components/responses/TooManyRequests'
*/
app.post('/api/check-spendable', 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 spendabilityCheck = await cashuService.checkTokenSpendable(token);
res.json({
success: true,
spendable: spendabilityCheck.spendable,
pending: spendabilityCheck.pending,
mintUrl: spendabilityCheck.mintUrl,
totalAmount: spendabilityCheck.totalAmount,
message: spendabilityCheck.spendable && spendabilityCheck.spendable.length > 0
? 'Token is spendable'
: 'Token proofs are not spendable - may have already been used'
});
} catch (error) {
res.status(400).json({
success: false,
error: error.message
});
}
}));
// 404 handler // 404 handler
app.use('*', (req, res) => { app.use('*', (req, res) => {
res.status(404).json({ res.status(404).json({

View File

@@ -214,21 +214,41 @@ class CashuService {
// Perform the melt operation using the quote and proofs // Perform the melt operation using the quote and proofs
const meltResponse = await wallet.meltTokens(meltQuote, proofs); const meltResponse = await wallet.meltTokens(meltQuote, proofs);
// Verify payment was successful // Debug: Log the melt response structure
if (!meltResponse.paid) { console.log('Melt response:', JSON.stringify(meltResponse, null, 2));
throw new Error('Payment failed - token melted but Lightning payment was not successful');
// 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 { return {
success: true, success: true,
paid: meltResponse.paid, paid: paymentSuccessful,
preimage: meltResponse.payment_preimage, preimage: meltResponse.payment_preimage || meltResponse.preimage,
change: meltResponse.change || [], change: meltResponse.change || [],
amount: meltQuote.amount, amount: meltQuote.amount,
fee: meltQuote.fee_reserve, fee: actualFeeCharged, // Use actual fee from melt response
actualFee: expectedFee, actualFee: expectedFee, // Keep the calculated expected fee for comparison
netAmount: parsed.totalAmount - meltQuote.fee_reserve, netAmount: actualNetAmount, // Use net amount based on actual fee
quote: meltQuote.quote quote: meltQuote.quote,
rawMeltResponse: meltResponse // Include raw response for debugging
}; };
} catch (error) { } catch (error) {
// Check if it's a cashu-ts specific error // Check if it's a cashu-ts specific error
@@ -283,17 +303,92 @@ class CashuService {
const parsed = await this.parseToken(token); const parsed = await this.parseToken(token);
const mint = await this.getMint(parsed.mint); const mint = await this.getMint(parsed.mint);
// Extract secrets from proofs
const secrets = parsed.proofs.map(proof => proof.secret); 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 }); const checkResult = await mint.check({ secrets });
console.log('Spendability check result:', checkResult);
return { return {
spendable: checkResult.spendable, spendable: checkResult.spendable || [],
pending: checkResult.pending || [], pending: checkResult.pending || [],
mintUrl: parsed.mint, mintUrl: parsed.mint,
totalAmount: parsed.totalAmount totalAmount: parsed.totalAmount
}; };
} catch (error) { } catch (error) {
throw new Error(`Failed to check token spendability: ${error.message}`); // 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)
});
// 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...');
// 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 : ''}`;
}
} 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';
}
} 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;
}
// 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.');
}
throw new Error(`Failed to check token spendability: ${errorMessage}`);
} }
} }
} }

View File

@@ -170,11 +170,20 @@ class RedemptionService {
// Calculate expected fee according to NUT-05 // Calculate expected fee according to NUT-05
const expectedFee = cashuService.calculateFee(tokenData.totalAmount); const expectedFee = cashuService.calculateFee(tokenData.totalAmount);
// Calculate net amount after subtracting fees
const netAmountAfterFee = tokenData.totalAmount - expectedFee;
// Ensure we have enough for the minimum payment after fees
if (netAmountAfterFee <= 0) {
throw new Error(`Token amount (${tokenData.totalAmount} sats) is insufficient to cover the minimum fee (${expectedFee} sats)`);
}
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
amount: tokenData.totalAmount, amount: tokenData.totalAmount,
mint: tokenData.mint, mint: tokenData.mint,
numProofs: tokenData.numProofs, numProofs: tokenData.numProofs,
expectedFee: expectedFee, expectedFee: expectedFee,
netAmountAfterFee: netAmountAfterFee,
format: tokenData.format format: tokenData.format
}); });
@@ -191,39 +200,55 @@ class RedemptionService {
} }
// Step 2: Resolve Lightning address to invoice // Step 2: Resolve Lightning address to invoice
// IMPORTANT: Create invoice for net amount (after subtracting expected fees)
this.updateRedemption(redeemId, { status: 'resolving_invoice' }); this.updateRedemption(redeemId, { status: 'resolving_invoice' });
const invoiceData = await lightningService.resolveInvoice( const invoiceData = await lightningService.resolveInvoice(
lightningAddressToUse, lightningAddressToUse,
tokenData.totalAmount, netAmountAfterFee, // Use net amount instead of full token amount
`Cashu redemption ${redeemId.substring(0, 8)}` 'Cashu redemption'
); );
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
bolt11: invoiceData.bolt11.substring(0, 50) + '...', bolt11: invoiceData.bolt11.substring(0, 50) + '...',
domain: invoiceData.domain domain: invoiceData.domain,
invoiceAmount: netAmountAfterFee
}); });
// Step 3: Melt the token to pay the invoice // Step 3: Melt the token to pay the invoice
this.updateRedemption(redeemId, { status: 'melting_token' }); this.updateRedemption(redeemId, { status: 'melting_token' });
const meltResult = await cashuService.meltToken(token, invoiceData.bolt11); const meltResult = await cashuService.meltToken(token, invoiceData.bolt11);
// Log melt result for debugging
console.log(`Redemption ${redeemId}: Melt result:`, {
paid: meltResult.paid,
hasPreimage: !!meltResult.preimage,
amount: meltResult.amount,
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 // Step 4: Update final status
this.updateRedemption(redeemId, { this.updateRedemption(redeemId, {
status: meltResult.paid ? 'paid' : 'failed', status: paymentSuccessful ? 'paid' : 'failed',
paid: meltResult.paid, paid: paymentSuccessful,
preimage: meltResult.preimage, preimage: meltResult.preimage,
fee: meltResult.fee, fee: meltResult.fee,
actualFee: meltResult.actualFee, actualFee: meltResult.actualFee,
netAmount: meltResult.netAmount, netAmount: meltResult.netAmount,
change: meltResult.change, change: meltResult.change,
paidAt: meltResult.paid ? new Date().toISOString() : null paidAt: paymentSuccessful ? new Date().toISOString() : null,
rawMeltResponse: meltResult.rawMeltResponse // Store for debugging
}); });
return { return {
success: true, success: true,
redeemId, redeemId,
paid: meltResult.paid, paid: paymentSuccessful,
amount: tokenData.totalAmount, amount: tokenData.totalAmount,
invoiceAmount: netAmountAfterFee, // Amount actually sent in the invoice
to: lightningAddressToUse, to: lightningAddressToUse,
usingDefaultAddress: isUsingDefault, usingDefaultAddress: isUsingDefault,
fee: meltResult.fee, fee: meltResult.fee,
@@ -327,27 +352,6 @@ class RedemptionService {
} }
} }
} }
/**
* Get redemption statistics
* @returns {Object} Statistics
*/
getStats() {
const redemptions = Array.from(this.redemptions.values());
return {
total: redemptions.length,
paid: redemptions.filter(r => r.paid).length,
failed: redemptions.filter(r => r.status === 'failed').length,
processing: redemptions.filter(r => r.status === 'processing').length,
totalAmount: redemptions
.filter(r => r.paid && r.amount)
.reduce((sum, r) => sum + r.amount, 0),
totalFees: redemptions
.filter(r => r.paid && r.fee)
.reduce((sum, r) => sum + r.fee, 0)
};
}
} }
module.exports = new RedemptionService(); module.exports = new RedemptionService();

View File

@@ -52,7 +52,7 @@ const options = {
token: { token: {
type: 'string', type: 'string',
description: 'Cashu token to decode (supports v1 and v3 formats)', description: 'Cashu token to decode (supports v1 and v3 formats)',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0' example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
} }
} }
}, },
@@ -115,7 +115,7 @@ const options = {
token: { token: {
type: 'string', type: 'string',
description: 'Cashu token to redeem', description: 'Cashu token to redeem',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0' example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
}, },
lightningAddress: { lightningAddress: {
type: 'string', type: 'string',
@@ -149,6 +149,11 @@ const options = {
description: 'Total amount redeemed in satoshis', description: 'Total amount redeemed in satoshis',
example: 21000 example: 21000
}, },
invoiceAmount: {
type: 'integer',
description: 'Actual amount sent in Lightning invoice (after subtracting fees)',
example: 20580
},
to: { to: {
type: 'string', type: 'string',
description: 'Lightning address that received the payment', description: 'Lightning address that received the payment',
@@ -186,14 +191,6 @@ const options = {
description: 'Lightning payment preimage (if available)', description: 'Lightning payment preimage (if available)',
example: 'abc123def456...' example: 'abc123def456...'
}, },
change: {
type: 'array',
description: 'Change proofs returned (if any)',
items: {
type: 'object'
},
example: []
},
usingDefaultAddress: { usingDefaultAddress: {
type: 'boolean', type: 'boolean',
description: 'Whether default Lightning address was used', description: 'Whether default Lightning address was used',
@@ -207,171 +204,6 @@ const options = {
} }
}, },
// Status Schemas
StatusRequest: {
type: 'object',
required: ['redeemId'],
properties: {
redeemId: {
type: 'string',
format: 'uuid',
description: 'Redemption ID to check status for',
example: '8e99101e-d034-4d2e-9ccf-dfda24d26762'
}
}
},
StatusResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
status: {
type: 'string',
enum: ['processing', 'parsing_token', 'checking_spendability', 'resolving_invoice', 'melting_token', 'paid', 'failed'],
description: 'Current redemption status',
example: 'paid'
},
details: {
type: 'object',
properties: {
amount: {
type: 'integer',
description: 'Amount in satoshis',
example: 21000
},
to: {
type: 'string',
description: 'Lightning address',
example: 'user@ln.tips'
},
paid: {
type: 'boolean',
example: true
},
createdAt: {
type: 'string',
format: 'date-time',
example: '2025-01-14T11:59:30Z'
},
updatedAt: {
type: 'string',
format: 'date-time',
example: '2025-01-14T12:00:00Z'
},
paidAt: {
type: 'string',
format: 'date-time',
example: '2025-01-14T12:00:00Z'
},
fee: {
type: 'integer',
description: 'Fee charged in satoshis',
example: 1000
},
error: {
type: 'string',
description: 'Error message if failed',
example: null
},
mint: {
type: 'string',
format: 'uri',
description: 'Mint URL',
example: 'https://mint.azzamo.net'
},
domain: {
type: 'string',
description: 'Lightning address domain',
example: 'ln.tips'
}
}
}
}
},
// Health Schema
HealthResponse: {
type: 'object',
properties: {
status: {
type: 'string',
example: 'ok'
},
timestamp: {
type: 'string',
format: 'date-time',
example: '2025-01-14T12:00:00Z'
},
uptime: {
type: 'number',
description: 'Server uptime in seconds',
example: 3600
},
memory: {
type: 'object',
description: 'Memory usage information',
example: {
"rss": 45678912,
"heapTotal": 12345678,
"heapUsed": 8765432
}
},
version: {
type: 'string',
example: '1.0.0'
}
}
},
// Stats Schema
StatsResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
stats: {
type: 'object',
properties: {
total: {
type: 'integer',
description: 'Total number of redemptions',
example: 150
},
paid: {
type: 'integer',
description: 'Number of successful redemptions',
example: 142
},
failed: {
type: 'integer',
description: 'Number of failed redemptions',
example: 8
},
processing: {
type: 'integer',
description: 'Number of currently processing redemptions',
example: 0
},
totalAmount: {
type: 'integer',
description: 'Total amount redeemed in satoshis',
example: 2500000
},
totalFees: {
type: 'integer',
description: 'Total fees collected in satoshis',
example: 15000
}
}
}
}
},
// Validate Address Schemas // Validate Address Schemas
ValidateAddressRequest: { ValidateAddressRequest: {
type: 'object', type: 'object',
@@ -424,57 +256,16 @@ const options = {
} }
}, },
// Check Spendable Schemas HealthResponse: {
CheckSpendableRequest: {
type: 'object',
required: ['token'],
properties: {
token: {
type: 'string',
description: 'Cashu token to check spendability',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
}
}
},
CheckSpendableResponse: {
type: 'object', type: 'object',
properties: { properties: {
success: { status: {
type: 'boolean',
example: true
},
spendable: {
type: 'array',
items: {
type: 'boolean'
},
description: 'Array indicating which proofs are spendable',
example: [true, true, false]
},
pending: {
type: 'array',
items: {
type: 'boolean'
},
description: 'Array indicating which proofs are pending',
example: []
},
mintUrl: {
type: 'string', type: 'string',
format: 'uri', example: 'OK'
description: 'Mint URL where spendability was checked',
example: 'https://mint.azzamo.net'
},
totalAmount: {
type: 'integer',
description: 'Total amount of the token in satoshis',
example: 21000
}, },
message: { message: {
type: 'string', type: 'string',
description: 'Human-readable status message', example: 'API is healthy'
example: 'Token is spendable'
} }
} }
} }
@@ -537,10 +328,6 @@ const options = {
name: 'Token Operations', name: 'Token Operations',
description: 'Operations for decoding and redeeming Cashu tokens' description: 'Operations for decoding and redeeming Cashu tokens'
}, },
{
name: 'Status & Monitoring',
description: 'Endpoints for checking redemption status and API health'
},
{ {
name: 'Validation', name: 'Validation',
description: 'Validation utilities for tokens and Lightning addresses' description: 'Validation utilities for tokens and Lightning addresses'