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",
"paid": true,
"amount": 21000,
"invoiceAmount": 20580,
"to": "user@ln.tips",
"fee": 1000,
"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",
"paid": true,
"amount": 21000,
"invoiceAmount": 20580,
"to": "admin@your-domain.com",
"fee": 1000,
"actualFee": 420,
@@ -106,51 +108,24 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if
}
```
### 3. `POST /api/status`
Check redemption status using redeemId.
**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
- `actualFee`: Calculated expected fee
- `netAmount`: Final amount after all deductions
**Request:**
```json
{
"redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762"
}
```
**Payment Verification**:
The API uses multiple indicators to verify payment success:
- `paid` flag from mint response
- Presence of payment preimage
- Payment state indicators
**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"
}
}
```
If you receive a "payment failed" error but the Lightning payment was successful, use the debug endpoint to investigate the raw mint response.
### 4. `GET /api/status/:redeemId`
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`
### 3. `POST /api/validate-address`
Validate a Lightning address without redemption.
**Request:**
@@ -172,25 +147,7 @@ Validate a Lightning address without redemption.
}
```
### 7. `GET /api/stats`
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`
### 4. `POST /api/check-spendable`
Check if a Cashu token is spendable at its mint before attempting redemption.
**Request:**
@@ -200,7 +157,7 @@ Check if a Cashu token is spendable at its mint before attempting redemption.
}
```
**Response:**
**Success Response:**
```json
{
"success": true,
@@ -208,10 +165,38 @@ Check if a Cashu token is spendable at its mint before attempting redemption.
"pending": [],
"mintUrl": "https://mint.azzamo.net",
"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
### Prerequisites
@@ -314,7 +299,7 @@ This allows users to redeem tokens without specifying a Lightning address - the
- Coordinates the complete redemption process
- Manages redemption status tracking
- Handles duplicate token detection
- Provides statistics and cleanup
- Provides cleanup functionality
### Data Flow
@@ -343,137 +328,8 @@ This allows users to redeem tokens without specifying a Lightning address - the
| `melting_token` | Performing the melt operation |
| `paid` | Successfully paid and completed |
| `failed` | Redemption failed (see error details) |
| `checking_spendability` | Verifying token is spendable at mint |
## 📊 Monitoring
### 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

235
server.js
View File

@@ -157,11 +157,13 @@ app.post('/api/decode', asyncHandler(async (req, res) => {
*
* The redemption process includes:
* 1. Token validation and parsing
* 2. Spendability checking at the mint
* 3. Lightning address resolution via LNURLp
* 4. Token melting and Lightning payment
* 2. Fee calculation (NUT-05: 2% of amount, minimum 1 satoshi)
* 3. Invoice creation for net amount (token amount - fees)
* 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]
* requestBody:
* required: true
@@ -206,6 +208,7 @@ app.post('/api/redeem', asyncHandler(async (req, res) => {
redeemId: result.redeemId,
paid: result.paid,
amount: result.amount,
invoiceAmount: result.invoiceAmount,
to: result.to,
fee: result.fee,
actualFee: result.actualFee,
@@ -225,11 +228,6 @@ app.post('/api/redeem', asyncHandler(async (req, res) => {
response.preimage = result.preimage;
}
// Include change if any
if (result.change && result.change.length > 0) {
response.change = result.change;
}
res.json(response);
} else {
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
* /api/health:
@@ -355,46 +261,28 @@ app.get('/api/status/:redeemId', asyncHandler(async (req, res) => {
* application/json:
* schema:
* $ref: '#/components/schemas/HealthResponse'
* 500:
* $ref: '#/components/responses/InternalServerError'
*/
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
version: require('./package.json').version
});
});
app.get('/api/health', asyncHandler(async (req, res) => {
try {
const packageJson = require('./package.json');
/**
* @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
});
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
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'
});
}
}));
/**
@@ -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
app.use('*', (req, res) => {
res.status(404).json({

View File

@@ -214,21 +214,41 @@ class CashuService {
// Perform the melt operation using the quote and proofs
const meltResponse = await wallet.meltTokens(meltQuote, proofs);
// Verify payment was successful
if (!meltResponse.paid) {
throw new Error('Payment failed - token melted but Lightning payment was not successful');
// Debug: Log the melt response structure
console.log('Melt response:', JSON.stringify(meltResponse, null, 2));
// 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 {
success: true,
paid: meltResponse.paid,
preimage: meltResponse.payment_preimage,
paid: paymentSuccessful,
preimage: meltResponse.payment_preimage || meltResponse.preimage,
change: meltResponse.change || [],
amount: meltQuote.amount,
fee: meltQuote.fee_reserve,
actualFee: expectedFee,
netAmount: parsed.totalAmount - meltQuote.fee_reserve,
quote: meltQuote.quote
fee: actualFeeCharged, // Use actual fee from melt response
actualFee: expectedFee, // Keep the calculated expected fee for comparison
netAmount: actualNetAmount, // Use net amount based on actual fee
quote: meltQuote.quote,
rawMeltResponse: meltResponse // Include raw response for debugging
};
} catch (error) {
// Check if it's a cashu-ts specific error
@@ -283,17 +303,92 @@ class CashuService {
const parsed = await this.parseToken(token);
const mint = await this.getMint(parsed.mint);
// Extract secrets from proofs
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 });
console.log('Spendability check result:', checkResult);
return {
spendable: checkResult.spendable,
spendable: checkResult.spendable || [],
pending: checkResult.pending || [],
mintUrl: parsed.mint,
totalAmount: parsed.totalAmount
};
} 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
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, {
amount: tokenData.totalAmount,
mint: tokenData.mint,
numProofs: tokenData.numProofs,
expectedFee: expectedFee,
netAmountAfterFee: netAmountAfterFee,
format: tokenData.format
});
@@ -191,39 +200,55 @@ class RedemptionService {
}
// Step 2: Resolve Lightning address to invoice
// IMPORTANT: Create invoice for net amount (after subtracting expected fees)
this.updateRedemption(redeemId, { status: 'resolving_invoice' });
const invoiceData = await lightningService.resolveInvoice(
lightningAddressToUse,
tokenData.totalAmount,
`Cashu redemption ${redeemId.substring(0, 8)}`
netAmountAfterFee, // Use net amount instead of full token amount
'Cashu redemption'
);
this.updateRedemption(redeemId, {
bolt11: invoiceData.bolt11.substring(0, 50) + '...',
domain: invoiceData.domain
domain: invoiceData.domain,
invoiceAmount: netAmountAfterFee
});
// Step 3: Melt the token to pay the invoice
this.updateRedemption(redeemId, { status: 'melting_token' });
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
this.updateRedemption(redeemId, {
status: meltResult.paid ? 'paid' : 'failed',
paid: meltResult.paid,
status: paymentSuccessful ? 'paid' : 'failed',
paid: paymentSuccessful,
preimage: meltResult.preimage,
fee: meltResult.fee,
actualFee: meltResult.actualFee,
netAmount: meltResult.netAmount,
change: meltResult.change,
paidAt: meltResult.paid ? new Date().toISOString() : null
paidAt: paymentSuccessful ? new Date().toISOString() : null,
rawMeltResponse: meltResult.rawMeltResponse // Store for debugging
});
return {
success: true,
redeemId,
paid: meltResult.paid,
paid: paymentSuccessful,
amount: tokenData.totalAmount,
invoiceAmount: netAmountAfterFee, // Amount actually sent in the invoice
to: lightningAddressToUse,
usingDefaultAddress: isUsingDefault,
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();

View File

@@ -52,7 +52,7 @@ const options = {
token: {
type: 'string',
description: 'Cashu token to decode (supports v1 and v3 formats)',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
}
}
},
@@ -115,7 +115,7 @@ const options = {
token: {
type: 'string',
description: 'Cashu token to redeem',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
},
lightningAddress: {
type: 'string',
@@ -149,6 +149,11 @@ const options = {
description: 'Total amount redeemed in satoshis',
example: 21000
},
invoiceAmount: {
type: 'integer',
description: 'Actual amount sent in Lightning invoice (after subtracting fees)',
example: 20580
},
to: {
type: 'string',
description: 'Lightning address that received the payment',
@@ -186,14 +191,6 @@ const options = {
description: 'Lightning payment preimage (if available)',
example: 'abc123def456...'
},
change: {
type: 'array',
description: 'Change proofs returned (if any)',
items: {
type: 'object'
},
example: []
},
usingDefaultAddress: {
type: 'boolean',
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
ValidateAddressRequest: {
type: 'object',
@@ -424,57 +256,16 @@ const options = {
}
},
// Check Spendable Schemas
CheckSpendableRequest: {
type: 'object',
required: ['token'],
properties: {
token: {
type: 'string',
description: 'Cashu token to check spendability',
example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0'
}
}
},
CheckSpendableResponse: {
HealthResponse: {
type: 'object',
properties: {
success: {
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: {
status: {
type: 'string',
format: 'uri',
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
example: 'OK'
},
message: {
type: 'string',
description: 'Human-readable status message',
example: 'Token is spendable'
example: 'API is healthy'
}
}
}
@@ -537,10 +328,6 @@ const options = {
name: 'Token Operations',
description: 'Operations for decoding and redeeming Cashu tokens'
},
{
name: 'Status & Monitoring',
description: 'Endpoints for checking redemption status and API health'
},
{
name: 'Validation',
description: 'Validation utilities for tokens and Lightning addresses'