Refactor: move services to components, add route modules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
100
routes/cashu.js
Normal file
100
routes/cashu.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const cashu = require('../components/cashu');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/decode:
|
||||
* post:
|
||||
* summary: Decode a Cashu token
|
||||
* description: Decode a Cashu token and return its content. Supports both v1 and v3 token formats.
|
||||
* tags: [Token Operations]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/DecodeRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Token decoded successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/DecodeResponse'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequest'
|
||||
* 429:
|
||||
* $ref: '#/components/responses/TooManyRequests'
|
||||
* 500:
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post('/decode', async (req, res) => {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Token is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!cashu.isValidTokenFormat(token)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid token format. Must be a valid Cashu token'
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = await cashu.parseToken(token);
|
||||
const mintUrl = await cashu.getTokenMintUrl(token);
|
||||
|
||||
let spent = false;
|
||||
try {
|
||||
const spendabilityCheck = await cashu.checkTokenSpendable(token);
|
||||
spent = !spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0;
|
||||
} catch (error) {
|
||||
console.warn('Spendability check failed:', error.message);
|
||||
|
||||
const errorString = error.message || error.toString();
|
||||
|
||||
if (errorString.includes('TOKEN_SPENT:')) {
|
||||
console.log('Token determined to be spent by CashuComponent');
|
||||
spent = true;
|
||||
} else if (errorString.includes('Token validation failed at mint:')) {
|
||||
console.log('Token validation failed at mint - assuming token is still valid (might be invalid format)');
|
||||
spent = false;
|
||||
} else if (errorString.includes('not supported') ||
|
||||
errorString.includes('endpoint not found') ||
|
||||
errorString.includes('may still be valid') ||
|
||||
errorString.includes('does not support spendability checking')) {
|
||||
console.log('Mint does not support spendability checking - assuming token is valid');
|
||||
spent = false;
|
||||
} else {
|
||||
console.log('Unknown error - assuming token is valid');
|
||||
spent = false;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
decoded: {
|
||||
mint: decoded.mint,
|
||||
totalAmount: decoded.totalAmount,
|
||||
numProofs: decoded.numProofs,
|
||||
denominations: decoded.denominations,
|
||||
format: decoded.format,
|
||||
spent
|
||||
},
|
||||
mint_url: mintUrl
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
44
routes/health.js
Normal file
44
routes/health.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/health:
|
||||
* get:
|
||||
* summary: Health check endpoint
|
||||
* description: |
|
||||
* Check the health and status of the API server.
|
||||
* Returns server information including uptime, memory usage, and version.
|
||||
* tags: [Status & Monitoring]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is healthy
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/HealthResponse'
|
||||
* 500:
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get('/health', async (req, res) => {
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
86
routes/lightning.js
Normal file
86
routes/lightning.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const lightning = require('../components/lightning');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/validate-address:
|
||||
* post:
|
||||
* summary: Validate a Lightning address
|
||||
* description: |
|
||||
* Validate a Lightning address without performing a redemption.
|
||||
* Checks format validity and tests LNURLp resolution.
|
||||
*
|
||||
* Returns information about the Lightning address capabilities
|
||||
* including min/max sendable amounts and comment allowance.
|
||||
* tags: [Validation]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ValidateAddressRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Validation completed (check 'valid' field for result)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ValidateAddressResponse'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequest'
|
||||
* 429:
|
||||
* $ref: '#/components/responses/TooManyRequests'
|
||||
*/
|
||||
router.post('/validate-address', async (req, res) => {
|
||||
const { lightningAddress } = req.body;
|
||||
|
||||
if (!lightningAddress) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Lightning address is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = lightning.validateLightningAddress(lightningAddress);
|
||||
|
||||
if (!isValid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: 'Invalid Lightning address format'
|
||||
});
|
||||
}
|
||||
|
||||
const { domain } = lightning.parseLightningAddress(lightningAddress);
|
||||
const lnurlpUrl = lightning.getLNURLpEndpoint(lightningAddress);
|
||||
|
||||
try {
|
||||
const lnurlpResponse = await lightning.fetchLNURLpResponse(lnurlpUrl);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
valid: true,
|
||||
domain,
|
||||
minSendable: lightning.millisatsToSats(lnurlpResponse.minSendable),
|
||||
maxSendable: lightning.millisatsToSats(lnurlpResponse.maxSendable),
|
||||
commentAllowed: lnurlpResponse.commentAllowed || 0
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: `Lightning address resolution failed: ${error.message}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
146
routes/redemption.js
Normal file
146
routes/redemption.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const redemption = require('../components/redemption');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/redeem:
|
||||
* post:
|
||||
* summary: Redeem a Cashu token to Lightning address
|
||||
* description: |
|
||||
* Redeem a Cashu token to a Lightning address (optional - uses default if not provided).
|
||||
*
|
||||
* The redemption process includes:
|
||||
* 1. Token validation and parsing
|
||||
* 2. Getting exact melt quote from mint to determine precise fees
|
||||
* 3. Invoice creation for net amount (token amount - exact fees)
|
||||
* 4. Spendability checking at the mint
|
||||
* 5. Token melting and Lightning payment
|
||||
*
|
||||
* **Important**: The system gets the exact fee from the mint before creating the invoice.
|
||||
* The `invoiceAmount` field shows the actual amount sent to the Lightning address.
|
||||
* No sats are lost to fee estimation errors.
|
||||
* tags: [Token Operations]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/RedeemRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Token redeemed successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/RedeemResponse'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequest'
|
||||
* 409:
|
||||
* description: Token already spent
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: string
|
||||
* example: "This token has already been spent and cannot be redeemed again"
|
||||
* errorType:
|
||||
* type: string
|
||||
* example: "token_already_spent"
|
||||
* 422:
|
||||
* description: Insufficient funds or unprocessable token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: string
|
||||
* example: "Token amount is insufficient to cover the minimum fee"
|
||||
* errorType:
|
||||
* type: string
|
||||
* example: "insufficient_funds"
|
||||
* 429:
|
||||
* $ref: '#/components/responses/TooManyRequests'
|
||||
* 500:
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post('/redeem', async (req, res) => {
|
||||
const { token, lightningAddress } = req.body;
|
||||
|
||||
const validation = await redemption.validateRedemptionRequest(token, lightningAddress);
|
||||
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: validation.errors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await redemption.performRedemption(token, lightningAddress);
|
||||
|
||||
if (result.success) {
|
||||
const response = {
|
||||
success: true,
|
||||
paid: result.paid,
|
||||
amount: result.amount,
|
||||
invoiceAmount: result.invoiceAmount,
|
||||
to: result.to,
|
||||
fee: result.fee,
|
||||
actualFee: result.actualFee,
|
||||
netAmount: result.netAmount,
|
||||
mint_url: result.mint,
|
||||
format: result.format
|
||||
};
|
||||
|
||||
if (result.usingDefaultAddress) {
|
||||
response.usingDefaultAddress = true;
|
||||
response.message = `Redeemed to default Lightning address: ${result.to}`;
|
||||
}
|
||||
|
||||
if (result.preimage) {
|
||||
response.preimage = result.preimage;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} else {
|
||||
let statusCode = 400;
|
||||
|
||||
if (result.error && (
|
||||
result.error.includes('cannot be redeemed') ||
|
||||
result.error.includes('already been used') ||
|
||||
result.error.includes('not spendable') ||
|
||||
result.error.includes('already spent') ||
|
||||
result.error.includes('invalid proofs')
|
||||
)) {
|
||||
statusCode = 409;
|
||||
} else if (result.error && result.error.includes('insufficient')) {
|
||||
statusCode = 422;
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
errorType: statusCode === 409 ? 'token_already_spent' :
|
||||
statusCode === 422 ? 'insufficient_funds' : 'validation_error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in redemption:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error during redemption'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user