Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,34 @@
"validate:openapi": "swagger-cli validate openapi.yaml"
},
"dependencies": {
"axios": "^1.7.2"
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.57.2",
"@opentelemetry/instrumentation-express": "^0.46.0",
"@opentelemetry/instrumentation-http": "^0.57.2",
"@opentelemetry/instrumentation-pg": "^0.48.0",
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-node": "^0.57.2",
"@opentelemetry/semantic-conventions": "^1.41.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dataloader": "^2.2.2",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^8.5.2",
"graphql": "^16.8.1",
"graphql-yoga": "^5.0.0",
"helmet": "^7.1.0",
"joi": "^17.11.0",
"js-yaml": "^4.2.0",
"jsonwebtoken": "^9.0.3",
"node-cache": "^5.1.2",
"pg": "^8.21.0",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.0",
"uuid": "^9.0.0",
"pdfkit": "^0.15.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@pact-foundation/pact": "^13.1.0",
Expand All @@ -28,6 +55,7 @@
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.0",
"@types/pdfkit": "^0.13.9",
"@types/pg": "^8.10.9",
"@types/supertest": "^7.2.0",
"@types/swagger-ui-express": "^4.1.6",
Expand Down
4 changes: 4 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createAdminRouter } from './routes/admin';
import { createAnalyticsRouter } from './routes/analytics';
import { createAgentsRouter } from './routes/agents';
import { createAuthRouter } from './routes/auth';
import { createAccountsRouter } from './routes/accounts';
import { ErrorResponse } from './types';
import { AnchorStore } from './db/anchorStore';
import { Server as SocketIOServer } from 'socket.io';
Expand Down Expand Up @@ -186,6 +187,9 @@ export function createApp(options: AppOptions = {}): Application {
// Agents — registration and management (Issue #880)
app.use('/api/agents', createAgentsRouter());

// Accounts — Stellar fee estimation and XLM balance (Issue #949)
app.use('/api/accounts', createAccountsRouter());

// WebSocket health endpoint (development only — guarded inside the router)
if (options.io) {
app.use('/ws/health', createWsHealthRouter(options.io));
Expand Down
157 changes: 157 additions & 0 deletions api/src/routes/accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* GET /api/accounts/:address/stellar-fees
*
* Returns the current Stellar base fee, the account's native XLM balance,
* and the estimated XLM required for the next N operations (Issue #949).
*
* Query parameters:
* operations {number} - Number of operations to estimate fees for (default: 1, max: 100)
*/

import { Router, Request, Response } from 'express';
import { ErrorResponse } from '../types';

const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{54}$/;
const XLM_STROOPS_PER_XLM = 10_000_000;
const DEFAULT_BASE_FEE_STROOPS = 100;
const LOW_XLM_THRESHOLD = 2;

function timestamp(): string {
return new Date().toISOString();
}

function sendError(res: Response, status: number, message: string, code: string): Response<ErrorResponse> {
return res.status(status).json({ success: false, error: { message, code }, timestamp: timestamp() });
}

function getHorizonUrl(): string {
if (process.env.HORIZON_URL) return process.env.HORIZON_URL;
const network = (process.env.STELLAR_NETWORK ?? 'testnet').toLowerCase();
return network === 'mainnet' || network === 'public'
? 'https://horizon.stellar.org'
: 'https://horizon-testnet.stellar.org';
}

function getTopUpLink(): string {
const network = (process.env.STELLAR_NETWORK ?? 'testnet').toLowerCase();
if (network === 'mainnet' || network === 'public') {
return 'https://www.stellarterm.com/exchange/XLM-native/USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
}
return 'https://laboratory.stellar.org/#account-creator?network=testnet';
}

export function createAccountsRouter(): Router {
const router = Router();

/**
* @openapi
* /api/accounts/{address}/stellar-fees:
* get:
* summary: Get Stellar network fee info and XLM balance for an account
* tags:
* - Accounts
* parameters:
* - name: address
* in: path
* required: true
* description: Stellar account address (G...)
* schema:
* type: string
* - name: operations
* in: query
* required: false
* description: Number of operations to estimate fees for (default 1, max 100)
* schema:
* type: integer
* minimum: 1
* maximum: 100
* default: 1
* responses:
* 200:
* description: Stellar fee and balance information
* 400:
* description: Invalid address or parameters
* 404:
* description: Account not found on the network
*/
router.get('/:address/stellar-fees', async (req: Request, res: Response) => {
const { address } = req.params;
const operationsStr = (req.query.operations as string | undefined) ?? '1';

if (!STELLAR_ADDRESS_RE.test(address)) {
return sendError(res, 400, 'Invalid Stellar address format', 'INVALID_ADDRESS');
}

const operationCount = parseInt(operationsStr, 10);
if (isNaN(operationCount) || operationCount < 1 || operationCount > 100) {
return sendError(res, 400, '`operations` must be between 1 and 100', 'INVALID_OPERATIONS');
}

const horizonUrl = getHorizonUrl();

// Fetch fee stats and account in parallel
const [feeStatsRes, accountRes] = await Promise.allSettled([
fetch(`${horizonUrl}/fee_stats`),
fetch(`${horizonUrl}/accounts/${address}`),
]);

// Parse base fee
let baseFeeStroops = DEFAULT_BASE_FEE_STROOPS;
if (feeStatsRes.status === 'fulfilled' && feeStatsRes.value.ok) {
try {
const feeData = await feeStatsRes.value.json() as { fee_charged?: { mode?: string } };
const mode = feeData?.fee_charged?.mode;
if (mode) baseFeeStroops = parseInt(mode, 10) || DEFAULT_BASE_FEE_STROOPS;
} catch {
// fall back to default
}
}

// Parse XLM balance
let xlmBalance: number | null = null;
let accountFound = true;

if (accountRes.status === 'fulfilled') {
if (accountRes.value.status === 404) {
accountFound = false;
} else if (accountRes.value.ok) {
try {
const accountData = await accountRes.value.json() as {
balances?: Array<{ asset_type: string; balance: string }>;
};
const nativeBalance = accountData?.balances?.find(b => b.asset_type === 'native');
if (nativeBalance) xlmBalance = parseFloat(nativeBalance.balance);
} catch {
// account data unavailable
}
}
}

if (!accountFound) {
return sendError(res, 404, 'Account not found on the Stellar network', 'ACCOUNT_NOT_FOUND');
}

const estimatedFeeStroops = baseFeeStroops * operationCount;
const estimatedFeeXlm = estimatedFeeStroops / XLM_STROOPS_PER_XLM;
const lowBalance = xlmBalance !== null && xlmBalance < LOW_XLM_THRESHOLD;

return res.json({
success: true,
data: {
address,
base_fee_stroops: baseFeeStroops,
base_fee_xlm: baseFeeStroops / XLM_STROOPS_PER_XLM,
xlm_balance: xlmBalance,
estimated_fee_stroops: estimatedFeeStroops,
estimated_fee_xlm: estimatedFeeXlm,
operation_count: operationCount,
low_balance: lowBalance,
...(lowBalance && { top_up_link: getTopUpLink() }),
horizon_url: horizonUrl,
},
timestamp: timestamp(),
});
});

return router;
}
80 changes: 80 additions & 0 deletions api/src/routes/remittances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Router, Request, Response } from 'express';
import { ErrorResponse } from '../types';
import { RemittanceStore } from '../db/remittanceStore';
import { createRemittanceSchema, validateRequest } from '../schemas/requestValidation';
import { generateReceiptPdf } from '../services/receiptGenerator';

export type RemittanceStatus = 'Pending' | 'Processing' | 'Completed' | 'Cancelled' | 'Failed' | 'Disputed';

Expand Down Expand Up @@ -227,6 +228,85 @@ export function createRemittancesRouter(options: RemittancesRouterOptions = {}):
}
});

/**
* @openapi
* /api/remittances/{id}/receipt:
* get:
* summary: Download a PDF receipt for a completed remittance
* description: >
* Generates and returns a PDF receipt for the specified remittance.
* Requires authentication via X-API-Key (admin) or X-Sender-Address
* matching the remittance sender.
* tags:
* - Remittances
* parameters:
* - name: id
* in: path
* required: true
* description: Remittance ID
* schema:
* type: string
* security:
* - ApiKeyAuth: []
* - SenderAddressAuth: []
* responses:
* 200:
* description: PDF receipt file
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* 401:
* description: Missing or invalid authentication
* 403:
* description: Sender address does not match remittance owner
* 404:
* description: Remittance not found
* 503:
* description: Remittance store not configured
*/
router.get('/:id/receipt', async (req: Request, res: Response) => {
const { id } = req.params;

if (!remittanceStore) {
return sendError(res, 503, 'Remittance store not configured', 'SERVICE_UNAVAILABLE');
}

// Authentication: admin API key or sender address
const adminKey = process.env.ADMIN_API_KEY;
const providedApiKey = req.headers['x-api-key'] as string | undefined;
const senderAddress = req.headers['x-sender-address'] as string | undefined;
const isAdmin = adminKey && providedApiKey === adminKey;

if (!isAdmin && !senderAddress) {
return sendError(
res,
401,
'Authentication required. Provide X-API-Key (admin) or X-Sender-Address header.',
'UNAUTHORIZED',
);
}

const remittance = await remittanceStore.getById(id);
if (!remittance) {
return sendError(res, 404, `Remittance ${id} not found`, 'NOT_FOUND');
}

// Non-admin callers can only download their own receipts
if (!isAdmin && senderAddress !== remittance.sender_id) {
return sendError(res, 403, 'Sender address does not match remittance owner', 'FORBIDDEN');
}

const pdfBuffer = await generateReceiptPdf(remittance);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="receipt-${id}.pdf"`,
'Content-Length': String(pdfBuffer.length),
});
return res.send(pdfBuffer);
});

return router;
}

Expand Down
Loading
Loading