Updates fix endpoints.
This commit is contained in:
242
README.md
242
README.md
@@ -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
235
server.js
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user