From db641f2bf0fe62c79d736be7b63c1bc2574667e6 Mon Sep 17 00:00:00 2001 From: dimka90 Date: Sat, 28 Mar 2026 17:44:21 +0100 Subject: [PATCH 1/2] feat: implement Soroban-backed billing service --- .env.example | 10 +- src/routes/billing.ts | 304 +++++++++++++----------- src/services/billing.test.ts | 346 +++++++++++++--------------- src/services/billing.ts | 228 ++++++++++++------ src/services/sorobanBilling.test.ts | 133 +++++++++++ src/services/sorobanBilling.ts | 304 ++++++++++++++++++++++++ 6 files changed, 934 insertions(+), 391 deletions(-) create mode 100644 src/services/sorobanBilling.test.ts create mode 100644 src/services/sorobanBilling.ts diff --git a/.env.example b/.env.example index c52f0bc..f8fbc30 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,14 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173 SOROBAN_RPC_ENABLED=false SOROBAN_RPC_URL=https://soroban-testnet.stellar.org SOROBAN_RPC_TIMEOUT=2000 +SOROBAN_BILLING_RPC_URL=https://soroban-testnet.stellar.org +SOROBAN_BILLING_CONTRACT_ID=your-vault-contract-id +SOROBAN_BILLING_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +SOROBAN_BILLING_SOURCE_ACCOUNT=your-backend-source-account +SOROBAN_BILLING_BACKEND_SECRET_KEY=your-backend-secret-key +SOROBAN_BILLING_BALANCE_FN=balance +SOROBAN_BILLING_DEDUCT_FN=deduct +SOROBAN_BILLING_RPC_TIMEOUT_MS=5000 # ----------------------------------------------------------------------------- # Horizon (optional — set HORIZON_ENABLED=true to activate) @@ -77,4 +85,4 @@ LOG_LEVEL=info # ----------------------------------------------------------------------------- # Profiling # ----------------------------------------------------------------------------- -GATEWAY_PROFILING_ENABLED=false \ No newline at end of file +GATEWAY_PROFILING_ENABLED=false diff --git a/src/routes/billing.ts b/src/routes/billing.ts index fdb44e5..1a72a70 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -1,155 +1,181 @@ import { Router } from 'express'; -import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js'; -import { BillingService, type SorobanClient } from '../services/billing.js'; -import { BadRequestError } from '../errors/index.js'; +import type { Request, Response } from 'express'; import type { Pool } from 'pg'; -// Simple Soroban client implementation for billing deductions -class BillingSorobanClient implements SorobanClient { - async deductBalance(userId: string, amount: string): Promise { - // In a real implementation, this would interact with the Stellar blockchain - // For testing purposes, we return a mock transaction hash - return `tx_billing_${userId}_${amount}_${Date.now()}`; - } -} +import { BadRequestError } from '../errors/index.js'; +import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js'; +import { BillingService } from '../services/billing.js'; +import { createSorobanRpcBillingClient } from '../services/sorobanBilling.js'; const router = Router(); -// POST /api/billing/deduct - Deduct balance for API usage -router.post('/deduct', requireAuth, async ( - req: express.Request, - res: express.Response, - next -) => { - try { - const user = res.locals.authenticatedUser; - if (!user) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - - const { requestId, apiId, endpointId, apiKeyId, amountUsdc } = req.body as Record; - - // Validate required fields - if (!requestId || typeof requestId !== 'string' || requestId.trim() === '') { - next(new BadRequestError('requestId is required and must be a non-empty string')); - return; - } - - if (!apiId || typeof apiId !== 'string' || apiId.trim() === '') { - next(new BadRequestError('apiId is required and must be a non-empty string')); - return; - } - - if (!endpointId || typeof endpointId !== 'string' || endpointId.trim() === '') { - next(new BadRequestError('endpointId is required and must be a non-empty string')); - return; - } - - if (!apiKeyId || typeof apiKeyId !== 'string' || apiKeyId.trim() === '') { - next(new BadRequestError('apiKeyId is required and must be a non-empty string')); - return; - } - - if (!amountUsdc || typeof amountUsdc !== 'string') { - next(new BadRequestError('amountUsdc is required and must be a string')); - return; - } - - // Validate amount is a valid positive number - const amount = parseFloat(amountUsdc); - if (isNaN(amount) || amount <= 0) { - next(new BadRequestError('amountUsdc must be a positive number')); - return; - } +function createRouteBillingService(pool: Pool): BillingService { + const sorobanClient = createSorobanRpcBillingClient({ + rpcUrl: process.env.SOROBAN_BILLING_RPC_URL ?? process.env.SOROBAN_RPC_URL ?? 'http://localhost:8000', + contractId: process.env.SOROBAN_BILLING_CONTRACT_ID ?? 'vault_contract', + sourceAccount: process.env.SOROBAN_BILLING_SOURCE_ACCOUNT, + networkPassphrase: process.env.SOROBAN_BILLING_NETWORK_PASSPHRASE, + requestTimeoutMs: Number(process.env.SOROBAN_BILLING_RPC_TIMEOUT_MS ?? 5_000), + balanceFunctionName: process.env.SOROBAN_BILLING_BALANCE_FN ?? 'balance', + deductFunctionName: process.env.SOROBAN_BILLING_DEDUCT_FN ?? 'deduct', + }); + + return new BillingService(pool, sorobanClient); +} - // Get database pool from app locals (should be set in app.ts) - const pool = req.app.locals.dbPool as Pool; - if (!pool) { - res.status(500).json({ error: 'Database not available' }); - return; - } +router.post( + '/deduct', + requireAuth, + async ( + req: Request, + res: Response, + next + ) => { + try { + const user = res.locals.authenticatedUser; + if (!user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { + requestId, + apiId, + endpointId, + apiKeyId, + amountUsdc, + idempotencyKey, + } = req.body as Record; + + if (!requestId || typeof requestId !== 'string' || requestId.trim() === '') { + next(new BadRequestError('requestId is required and must be a non-empty string')); + return; + } + + if (!apiId || typeof apiId !== 'string' || apiId.trim() === '') { + next(new BadRequestError('apiId is required and must be a non-empty string')); + return; + } + + if (!endpointId || typeof endpointId !== 'string' || endpointId.trim() === '') { + next(new BadRequestError('endpointId is required and must be a non-empty string')); + return; + } + + if (!apiKeyId || typeof apiKeyId !== 'string' || apiKeyId.trim() === '') { + next(new BadRequestError('apiKeyId is required and must be a non-empty string')); + return; + } + + if (!amountUsdc || typeof amountUsdc !== 'string') { + next(new BadRequestError('amountUsdc is required and must be a string')); + return; + } + + const amount = Number(amountUsdc); + if (!Number.isFinite(amount) || amount <= 0) { + next(new BadRequestError('amountUsdc must be a positive number')); + return; + } + + if ( + idempotencyKey !== undefined && + (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') + ) { + next(new BadRequestError('idempotencyKey must be a non-empty string when provided')); + return; + } + + const pool = req.app.locals.dbPool as Pool | undefined; + if (!pool) { + res.status(500).json({ error: 'Database not available' }); + return; + } + + const billingService = createRouteBillingService(pool); + + const result = await billingService.deduct({ + requestId: requestId.trim(), + userId: user.id, + apiId: apiId.trim(), + endpointId: endpointId.trim(), + apiKeyId: apiKeyId.trim(), + amountUsdc: amountUsdc.trim(), + idempotencyKey: + typeof idempotencyKey === 'string' ? idempotencyKey.trim() : undefined, + }); - const sorobanClient = new BillingSorobanClient(); - const billingService = new BillingService(pool, sorobanClient); - - const result = await billingService.deduct({ - requestId: requestId.trim(), - userId: user.id, - apiId: apiId.trim(), - endpointId: endpointId.trim(), - apiKeyId: apiKeyId.trim(), - amountUsdc: amountUsdc.trim(), - }); - - if (!result.success) { - res.status(500).json({ - error: 'Billing deduction failed', - details: result.error, + if (!result.success) { + const message = result.error ?? 'Billing deduction failed'; + const statusCode = message.toLowerCase().includes('insufficient balance') ? 402 : 500; + + res.status(statusCode).json({ + error: 'Billing deduction failed', + details: message, + }); + return; + } + + res.status(200).json({ + success: true, + usageEventId: result.usageEventId, + stellarTxHash: result.stellarTxHash, + alreadyProcessed: result.alreadyProcessed, }); - return; + } catch (error) { + next(error); } - - res.status(200).json({ - success: true, - usageEventId: result.usageEventId, - stellarTxHash: result.stellarTxHash, - alreadyProcessed: result.alreadyProcessed, - }); - } catch (error) { - next(error); } -}); - -// GET /api/billing/request/:requestId - Get billing request status -router.get('/request/:requestId', requireAuth, async ( - req: express.Request, - res: express.Response, - next -) => { - try { - const user = res.locals.authenticatedUser; - if (!user) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - - const { requestId } = req.params; - - if (!requestId || typeof requestId !== 'string' || requestId.trim() === '') { - next(new BadRequestError('requestId is required and must be a non-empty string')); - return; - } - - // Get database pool from app locals - const pool = req.app.locals.dbPool as Pool; - if (!pool) { - res.status(500).json({ error: 'Database not available' }); - return; - } - - const sorobanClient = new BillingSorobanClient(); - const billingService = new BillingService(pool, sorobanClient); - - const result = await billingService.getByRequestId(requestId.trim()); - - if (!result) { - res.status(404).json({ - error: 'Billing request not found', - requestId: requestId.trim(), +); + +router.get( + '/request/:requestId', + requireAuth, + async ( + req: Request, + res: Response, + next + ) => { + try { + const user = res.locals.authenticatedUser; + if (!user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { requestId } = req.params; + if (!requestId || requestId.trim() === '') { + next(new BadRequestError('requestId is required and must be a non-empty string')); + return; + } + + const pool = req.app.locals.dbPool as Pool | undefined; + if (!pool) { + res.status(500).json({ error: 'Database not available' }); + return; + } + + const billingService = createRouteBillingService(pool); + const result = await billingService.getByRequestId(requestId.trim()); + + if (!result) { + res.status(404).json({ + error: 'Billing request not found', + requestId: requestId.trim(), + }); + return; + } + + res.status(200).json({ + success: true, + usageEventId: result.usageEventId, + stellarTxHash: result.stellarTxHash, + alreadyProcessed: result.alreadyProcessed, }); - return; + } catch (error) { + next(error); } - - res.status(200).json({ - success: true, - usageEventId: result.usageEventId, - stellarTxHash: result.stellarTxHash, - alreadyProcessed: result.alreadyProcessed, - }); - } catch (error) { - next(error); } -}); +); export default router; diff --git a/src/services/billing.test.ts b/src/services/billing.test.ts index cab3e16..0cec8a4 100644 --- a/src/services/billing.test.ts +++ b/src/services/billing.test.ts @@ -1,18 +1,15 @@ -/** - * Billing Service Unit Tests - * - * Comprehensive test coverage for idempotent billing functionality - */ - import assert from 'node:assert/strict'; import type { Pool, PoolClient, QueryResult } from 'pg'; -import { BillingService, type BillingDeductRequest, type SorobanClient } from './billing.js'; -// Mock PoolClient +import { + BillingService, + billingInternals, + type BillingDeductRequest, + type SorobanClient, +} from './billing.js'; + function createMockClient( - queryResults: (QueryResult | Error)[], - _commitError?: Error, - _rollbackError?: Error + queryResults: (QueryResult | Error)[] ): PoolClient { let queryIndex = 0; @@ -27,260 +24,244 @@ function createMockClient( throw result; } - // Simulate delay - await new Promise((resolve) => setTimeout(resolve, 1)); return result; }, release: () => {}, } as PoolClient; } -// Mock Pool function createMockPool(client: PoolClient): Pool { return { connect: async () => client, - query: async () => ({ rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult), + query: async () => + ({ + rows: [], + rowCount: 0, + command: '', + oid: 0, + fields: [], + }) as QueryResult, } as unknown as Pool; } -// Mock Soroban Client -function createMockSorobanClient( - txHash: string = 'tx_abc123', - shouldFail: boolean = false -): SorobanClient { - return { - deductBalance: async (userId: string, amount: string) => { - await new Promise((resolve) => setTimeout(resolve, 10)); - if (shouldFail) { - throw new Error('Soroban deduction failed'); +function createMockSorobanClient(options?: { + balance?: string; + txHash?: string; + deductFailures?: Error[]; +}) { + let deductCount = 0; + let balanceCount = 0; + const failures = [...(options?.deductFailures ?? [])]; + + const client: SorobanClient = { + getBalance: async () => { + balanceCount += 1; + return { balance: options?.balance ?? '1000000' }; + }, + deductBalance: async () => { + deductCount += 1; + const failure = failures.shift(); + if (failure) { + throw failure; } - return txHash; + return { txHash: options?.txHash ?? 'tx_abc123' }; }, }; + + return { + client, + getDeductCount: () => deductCount, + getBalanceCount: () => balanceCount, + }; } +const baseRequest: BillingDeductRequest = { + requestId: 'req_123', + userId: 'user_abc', + apiId: 'api_xyz', + endpointId: 'endpoint_001', + apiKeyId: 'key_789', + amountUsdc: '0.0100000', +}; + +describe('billingInternals', () => { + test('converts 7-decimal USDC strings to contract units', () => { + assert.equal( + billingInternals.parseUsdcToContractUnits('1.2345678').toString(), + '12345678' + ); + }); + + test('detects transient Soroban errors', () => { + assert.equal( + billingInternals.isTransientSorobanError(new Error('socket hang up')), + true + ); + assert.equal( + billingInternals.isTransientSorobanError(new Error('insufficient balance')), + false + ); + }); +}); + describe('BillingService.deduct', () => { - test('successfully deducts balance for new request', async () => { + test('successfully deducts balance for a new request', async () => { const client = createMockClient([ - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // BEGIN - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // Check existing - { rows: [{ id: 1 }], rowCount: 1, command: '', oid: 0, fields: [] } as QueryResult, // INSERT - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // UPDATE with tx hash - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // COMMIT + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [{ id: 1 }], rowCount: 1, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, ]); const pool = createMockPool(client); - const sorobanClient = createMockSorobanClient('tx_stellar_123'); - const billingService = new BillingService(pool, sorobanClient); - - const request: BillingDeductRequest = { - requestId: 'req_new_123', - userId: 'user_abc', - apiId: 'api_xyz', - endpointId: 'endpoint_001', - apiKeyId: 'key_789', - amountUsdc: '0.01', - }; + const soroban = createMockSorobanClient({ balance: '500000', txHash: 'tx_stellar_123' }); + const billingService = new BillingService(pool, soroban.client, { retryDelaysMs: [] }); - const result = await billingService.deduct(request); + const result = await billingService.deduct(baseRequest); assert.equal(result.success, true); assert.equal(result.usageEventId, '1'); assert.equal(result.stellarTxHash, 'tx_stellar_123'); assert.equal(result.alreadyProcessed, false); + assert.equal(soroban.getBalanceCount(), 1); + assert.equal(soroban.getDeductCount(), 1); }); test('returns existing result when request_id already exists', async () => { const client = createMockClient([ - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // BEGIN + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, { rows: [{ id: 42, stellar_tx_hash: 'tx_existing_456' }], rowCount: 1, command: '', oid: 0, fields: [], - } as QueryResult, // Check existing - found! - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // COMMIT + } as QueryResult, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, ]); const pool = createMockPool(client); - const sorobanClient = createMockSorobanClient(); - const billingService = new BillingService(pool, sorobanClient); + const soroban = createMockSorobanClient(); + const billingService = new BillingService(pool, soroban.client, { retryDelaysMs: [] }); - const request: BillingDeductRequest = { - requestId: 'req_duplicate_123', - userId: 'user_abc', - apiId: 'api_xyz', - endpointId: 'endpoint_001', - apiKeyId: 'key_789', - amountUsdc: '0.01', - }; - - const result = await billingService.deduct(request); + const result = await billingService.deduct(baseRequest); assert.equal(result.success, true); assert.equal(result.usageEventId, '42'); assert.equal(result.stellarTxHash, 'tx_existing_456'); assert.equal(result.alreadyProcessed, true); + assert.equal(soroban.getBalanceCount(), 0); + assert.equal(soroban.getDeductCount(), 0); }); - test('rolls back transaction when Soroban call fails', async () => { - let queryCallCount = 0; + test('fails without deducting when the balance is insufficient', async () => { + let rolledBack = false; const client = { - query: async (_sql: string, _params?: unknown[]) => { - queryCallCount++; - if (queryCallCount === 1) { - // BEGIN - return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult; - } else if (queryCallCount === 2) { - // Check existing - return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult; - } else if (queryCallCount === 3) { - // INSERT - return { rows: [{ id: 1 }], rowCount: 1, command: '', oid: 0, fields: [] } as QueryResult; - } else if (queryCallCount === 4) { - // ROLLBACK - return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult; + query: async (sql: string) => { + if (sql === 'ROLLBACK') { + rolledBack = true; } - throw new Error('Unexpected query call'); + + return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult; }, release: () => {}, } as unknown as PoolClient; const pool = createMockPool(client); - const sorobanClient = createMockSorobanClient('', true); // Will fail - const billingService = new BillingService(pool, sorobanClient); - - const request: BillingDeductRequest = { - requestId: 'req_fail_123', - userId: 'user_abc', - apiId: 'api_xyz', - endpointId: 'endpoint_001', - apiKeyId: 'key_789', - amountUsdc: '0.01', - }; + const soroban = createMockSorobanClient({ balance: '10' }); + const billingService = new BillingService(pool, soroban.client, { retryDelaysMs: [] }); - const result = await billingService.deduct(request); + const result = await billingService.deduct(baseRequest); assert.equal(result.success, false); - assert.equal(result.alreadyProcessed, false); - assert.ok(result.error?.includes('Soroban')); + assert.ok(result.error?.includes('Insufficient balance')); + assert.equal(soroban.getBalanceCount(), 1); + assert.equal(soroban.getDeductCount(), 0); + assert.equal(rolledBack, true); }); - test('handles race condition with unique constraint violation', async () => { - // Simulate race condition: unique constraint violation on insert - const uniqueViolationError = new Error('duplicate key value') as Error & { - code: string; - }; - uniqueViolationError.code = '23505'; - + test('retries transient Soroban deduct failures with backoff', async () => { const client = createMockClient([ - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // BEGIN - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // Check existing - not found - uniqueViolationError, // INSERT - unique violation! - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // ROLLBACK - { - rows: [{ id: 99, stellar_tx_hash: 'tx_race_789' }], - rowCount: 1, - command: '', - oid: 0, - fields: [], - } as QueryResult, // Query existing after race + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [{ id: 1 }], rowCount: 1, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, ]); const pool = createMockPool(client); - const sorobanClient = createMockSorobanClient(); - const billingService = new BillingService(pool, sorobanClient); + const soroban = createMockSorobanClient({ + balance: '500000', + txHash: 'tx_after_retry', + deductFailures: [new Error('socket hang up')], + }); - const request: BillingDeductRequest = { - requestId: 'req_race_123', - userId: 'user_abc', - apiId: 'api_xyz', - endpointId: 'endpoint_001', - apiKeyId: 'key_789', - amountUsdc: '0.01', - }; + const billingService = new BillingService(pool, soroban.client, { retryDelaysMs: [0] }); - const result = await billingService.deduct(request); + const result = await billingService.deduct(baseRequest); assert.equal(result.success, true); - assert.equal(result.usageEventId, '99'); - assert.equal(result.stellarTxHash, 'tx_race_789'); - assert.equal(result.alreadyProcessed, true); + assert.equal(result.stellarTxHash, 'tx_after_retry'); + assert.equal(soroban.getDeductCount(), 2); }); - test('handles database connection errors gracefully', async () => { - const client = createMockClient([ - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // BEGIN - new Error('Connection lost'), // Check existing - connection error - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // ROLLBACK - ]); + test('rolls back when Soroban deduct fails permanently', async () => { + let queryCallCount = 0; + const client = { + query: async (_sql: string) => { + queryCallCount += 1; + return { rows: queryCallCount === 3 ? [{ id: 1 }] : [], rowCount: queryCallCount === 3 ? 1 : 0, command: '', oid: 0, fields: [] } as QueryResult; + }, + release: () => {}, + } as unknown as PoolClient; const pool = createMockPool(client); - const sorobanClient = createMockSorobanClient(); - const billingService = new BillingService(pool, sorobanClient); + const soroban = createMockSorobanClient({ + balance: '500000', + deductFailures: [new Error('host trap: contract panicked')], + }); - const request: BillingDeductRequest = { - requestId: 'req_error_123', - userId: 'user_abc', - apiId: 'api_xyz', - endpointId: 'endpoint_001', - apiKeyId: 'key_789', - amountUsdc: '0.01', - }; - - const result = await billingService.deduct(request); + const billingService = new BillingService(pool, soroban.client, { retryDelaysMs: [0] }); + const result = await billingService.deduct(baseRequest); assert.equal(result.success, false); - assert.equal(result.error, 'Connection lost'); + assert.ok(result.error?.includes('host trap')); + assert.equal(soroban.getDeductCount(), 1); }); - test('prevents double charge on retry with same request_id', async () => { + test('handles race condition with unique constraint violation', async () => { + const uniqueViolationError = new Error('duplicate key value') as Error & { code: string }; + uniqueViolationError.code = '23505'; + const client = createMockClient([ - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // BEGIN (first call) - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // Check existing (first call) - { rows: [{ id: 1 }], rowCount: 1, command: '', oid: 0, fields: [] } as QueryResult, // INSERT (first call) - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // UPDATE (first call) - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // COMMIT (first call) - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // BEGIN (second call) + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, + uniqueViolationError, + { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, { - rows: [{ id: 1, stellar_tx_hash: 'tx_123' }], + rows: [{ id: 99, stellar_tx_hash: 'tx_race_789' }], rowCount: 1, command: '', oid: 0, fields: [], - } as QueryResult, // Check existing (second call) - found! - { rows: [], rowCount: 0, command: '', oid: 0, fields: [] } as QueryResult, // COMMIT (second call) + } as QueryResult, ]); const pool = createMockPool(client); - const sorobanClient = createMockSorobanClient('tx_123'); - const billingService = new BillingService(pool, sorobanClient); - - const request: BillingDeductRequest = { - requestId: 'req_retry_123', - userId: 'user_abc', - apiId: 'api_xyz', - endpointId: 'endpoint_001', - apiKeyId: 'key_789', - amountUsdc: '0.01', - }; - - // First call - processes normally - const result1 = await billingService.deduct(request); - assert.equal(result1.success, true); - assert.equal(result1.alreadyProcessed, false); - - // Second call with same request_id - returns existing result - const result2 = await billingService.deduct(request); - assert.equal(result2.success, true); - assert.equal(result2.alreadyProcessed, true); - assert.equal(result2.usageEventId, result1.usageEventId); + const soroban = createMockSorobanClient({ balance: '500000' }); + const billingService = new BillingService(pool, soroban.client, { retryDelaysMs: [] }); + + const result = await billingService.deduct(baseRequest); + + assert.equal(result.success, true); + assert.equal(result.usageEventId, '99'); + assert.equal(result.alreadyProcessed, true); }); }); describe('BillingService.getByRequestId', () => { - test('returns existing usage event', async () => { + test('returns an existing usage event', async () => { const pool = { query: async () => ({ rows: [{ id: 123, stellar_tx_hash: 'tx_abc' }], @@ -288,18 +269,17 @@ describe('BillingService.getByRequestId', () => { }), } as unknown as Pool; - const sorobanClient = createMockSorobanClient(); - const billingService = new BillingService(pool, sorobanClient); + const soroban = createMockSorobanClient(); + const billingService = new BillingService(pool, soroban.client, { retryDelaysMs: [] }); const result = await billingService.getByRequestId('req_existing'); assert.ok(result !== null); - assert.equal(result.usageEventId, '123'); - assert.equal(result.stellarTxHash, 'tx_abc'); - assert.equal(result.alreadyProcessed, true); + assert.equal(result?.usageEventId, '123'); + assert.equal(result?.stellarTxHash, 'tx_abc'); }); - test('returns null when request_id not found', async () => { + test('returns null when request_id is absent', async () => { const pool = { query: async () => ({ rows: [], @@ -307,10 +287,10 @@ describe('BillingService.getByRequestId', () => { }), } as unknown as Pool; - const sorobanClient = createMockSorobanClient(); - const billingService = new BillingService(pool, sorobanClient); + const soroban = createMockSorobanClient(); + const billingService = new BillingService(pool, soroban.client, { retryDelaysMs: [] }); - const result = await billingService.getByRequestId('req_nonexistent'); + const result = await billingService.getByRequestId('req_missing'); assert.equal(result, null); }); diff --git a/src/services/billing.ts b/src/services/billing.ts index 90fd4eb..e83fbc6 100644 --- a/src/services/billing.ts +++ b/src/services/billing.ts @@ -1,12 +1,18 @@ /** * Billing Service - * - * Handles idempotent billing deductions with Soroban integration. - * Prevents double charges through request_id based idempotency. + * + * Handles idempotent Soroban-backed billing deductions with: + * - request_id idempotency + * - preflight balance checks + * - transient retry/backoff for deduct calls + * - usage_events persistence with stellar_tx_hash on success */ import type { Pool } from 'pg'; +const USDC_7_DECIMAL_FACTOR = 10_000_000n; +const DEFAULT_RETRY_DELAYS_MS = [150, 500, 1_000]; + export interface BillingDeductRequest { requestId: string; userId: string; @@ -14,6 +20,7 @@ export interface BillingDeductRequest { endpointId: string; apiKeyId: string; amountUsdc: string; + idempotencyKey?: string; } export interface BillingDeductResult { @@ -24,74 +31,139 @@ export interface BillingDeductResult { error?: string; } +export interface SorobanBalanceResult { + balance: string; +} + +export interface SorobanDeductResult { + txHash: string; +} + export interface SorobanClient { - deductBalance(userId: string, amount: string): Promise; + getBalance(userId: string): Promise; + deductBalance( + userId: string, + amount: string, + idempotencyKey?: string + ): Promise; +} + +export interface BillingServiceOptions { + retryDelaysMs?: number[]; +} + +function parseUsdcToContractUnits(amountUsdc: string): bigint { + const trimmed = amountUsdc.trim(); + if (!/^\d+(\.\d{1,7})?$/.test(trimmed)) { + throw new Error('amountUsdc must be a positive decimal with at most 7 fractional digits'); + } + + const [wholePart, fractionalPart = ''] = trimmed.split('.'); + const whole = BigInt(wholePart); + const fraction = BigInt((fractionalPart + '0000000').slice(0, 7)); + const result = (whole * USDC_7_DECIMAL_FACTOR) + fraction; + + if (result <= 0n) { + throw new Error('amountUsdc must be greater than zero'); + } + + return result; +} + +function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return 'Unknown error'; +} + +function isTransientSorobanError(error: unknown): boolean { + const message = normalizeErrorMessage(error).toLowerCase(); + return [ + 'timeout', + 'timed out', + 'socket hang up', + 'temporarily unavailable', + 'temporary outage', + 'econnreset', + 'econnrefused', + '503', + '429', + 'rate limit', + 'network error', + 'transport error', + ].some((token) => message.includes(token)); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); } -/** - * Idempotent billing deduction service - * - * Uses request_id as idempotency key to prevent double charges. - * If usage_events already has a row with the same request_id, - * returns existing result without calling Soroban again. - */ export class BillingService { + private readonly retryDelaysMs: number[]; + constructor( private readonly pool: Pool, - private readonly sorobanClient: SorobanClient - ) {} - - /** - * Deducts balance from user account idempotently - * - * @param request - Billing deduction request with unique requestId - * @returns Result indicating success and whether request was already processed - * - * @example - * ```typescript - * const result = await billingService.deduct({ - * requestId: 'req_abc123', - * userId: 'user_xyz', - * apiId: 'api_123', - * endpointId: 'endpoint_456', - * apiKeyId: 'key_789', - * amountUsdc: '0.01' - * }); - * - * if (result.alreadyProcessed) { - * console.log('Request already processed, no double charge'); - * } - * ``` - */ + private readonly sorobanClient: SorobanClient, + options: BillingServiceOptions = {} + ) { + this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS; + } + async deduct(request: BillingDeductRequest): Promise { + let amountInContractUnits: bigint; + + try { + amountInContractUnits = parseUsdcToContractUnits(request.amountUsdc); + } catch (error) { + return { + success: false, + usageEventId: '', + alreadyProcessed: false, + error: normalizeErrorMessage(error), + }; + } + const client = await this.pool.connect(); try { await client.query('BEGIN'); - // Check if request_id already exists (idempotency check) const existingEvent = await client.query( - `SELECT id, stellar_tx_hash - FROM usage_events + `SELECT id, stellar_tx_hash + FROM usage_events WHERE request_id = $1`, [request.requestId] ); if (existingEvent.rows.length > 0) { - // Request already processed - return existing result await client.query('COMMIT'); return { success: true, usageEventId: existingEvent.rows[0].id.toString(), - stellarTxHash: existingEvent.rows[0].stellar_tx_hash, + stellarTxHash: existingEvent.rows[0].stellar_tx_hash ?? undefined, alreadyProcessed: true, }; } - // Insert usage_event first (before calling Soroban) - // This ensures idempotency even if Soroban call fails + const balanceResult = await this.sorobanClient.getBalance(request.userId); + const availableBalance = BigInt(balanceResult.balance); + + if (availableBalance < amountInContractUnits) { + await client.query('ROLLBACK'); + return { + success: false, + usageEventId: '', + alreadyProcessed: false, + error: `Insufficient balance: required ${amountInContractUnits.toString()} units, available ${availableBalance.toString()}`, + }; + } + const insertResult = await client.query( - `INSERT INTO usage_events + `INSERT INTO usage_events (user_id, api_id, endpoint_id, api_key_id, amount_usdc, request_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING id`, @@ -107,18 +179,17 @@ export class BillingService { const usageEventId = insertResult.rows[0].id.toString(); - // Call Soroban to deduct balance - const stellarTxHash = await this.sorobanClient.deductBalance( + const deductResult = await this.executeDeductWithRetry( request.userId, - request.amountUsdc + amountInContractUnits.toString(), + request.idempotencyKey ?? request.requestId ); - // Update usage_event with Soroban transaction hash await client.query( - `UPDATE usage_events - SET stellar_tx_hash = $1 + `UPDATE usage_events + SET stellar_tx_hash = $1 WHERE id = $2`, - [stellarTxHash, usageEventId] + [deductResult.txHash, usageEventId] ); await client.query('COMMIT'); @@ -126,24 +197,20 @@ export class BillingService { return { success: true, usageEventId, - stellarTxHash, + stellarTxHash: deductResult.txHash, alreadyProcessed: false, }; } catch (error) { await client.query('ROLLBACK'); - // Check if error is due to unique constraint violation - // This can happen in race conditions if ( error instanceof Error && 'code' in error && - error.code === '23505' // PostgreSQL unique violation + error.code === '23505' ) { - // Race condition - another request inserted the same request_id - // Query the existing record and return it const existingEvent = await client.query( - `SELECT id, stellar_tx_hash - FROM usage_events + `SELECT id, stellar_tx_hash + FROM usage_events WHERE request_id = $1`, [request.requestId] ); @@ -152,7 +219,7 @@ export class BillingService { return { success: true, usageEventId: existingEvent.rows[0].id.toString(), - stellarTxHash: existingEvent.rows[0].stellar_tx_hash, + stellarTxHash: existingEvent.rows[0].stellar_tx_hash ?? undefined, alreadyProcessed: true, }; } @@ -162,21 +229,17 @@ export class BillingService { success: false, usageEventId: '', alreadyProcessed: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: normalizeErrorMessage(error), }; } finally { client.release(); } } - /** - * Gets usage event by request ID - * Useful for checking if a request was already processed - */ async getByRequestId(requestId: string): Promise { const result = await this.pool.query( - `SELECT id, stellar_tx_hash - FROM usage_events + `SELECT id, stellar_tx_hash + FROM usage_events WHERE request_id = $1`, [requestId] ); @@ -188,8 +251,37 @@ export class BillingService { return { success: true, usageEventId: result.rows[0].id.toString(), - stellarTxHash: result.rows[0].stellar_tx_hash, + stellarTxHash: result.rows[0].stellar_tx_hash ?? undefined, alreadyProcessed: true, }; } + + private async executeDeductWithRetry( + userId: string, + amount: string, + idempotencyKey?: string + ): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= this.retryDelaysMs.length; attempt += 1) { + try { + return await this.sorobanClient.deductBalance(userId, amount, idempotencyKey); + } catch (error) { + lastError = error; + + if (!isTransientSorobanError(error) || attempt === this.retryDelaysMs.length) { + break; + } + + await sleep(this.retryDelaysMs[attempt]); + } + } + + throw lastError instanceof Error ? lastError : new Error(normalizeErrorMessage(lastError)); + } } + +export const billingInternals = { + parseUsdcToContractUnits, + isTransientSorobanError, +}; diff --git a/src/services/sorobanBilling.test.ts b/src/services/sorobanBilling.test.ts new file mode 100644 index 0000000..32c82d3 --- /dev/null +++ b/src/services/sorobanBilling.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; + +import { + buildSorobanBalanceInvocation, + buildSorobanDeductInvocation, + createSorobanRpcBillingClient, +} from './sorobanBilling.js'; + +describe('buildSorobanBalanceInvocation', () => { + test('assembles a balance invocation for a user id', () => { + assert.deepEqual( + buildSorobanBalanceInvocation('contract_123', 'user_abc'), + { + contractId: 'contract_123', + function: 'balance', + args: [{ type: 'string', value: 'user_abc' }], + } + ); + }); +}); + +describe('buildSorobanDeductInvocation', () => { + test('assembles a deduct invocation with optional idempotency key', () => { + assert.deepEqual( + buildSorobanDeductInvocation('contract_123', 'user_abc', '150000', 'req_1'), + { + contractId: 'contract_123', + function: 'deduct', + args: [ + { type: 'string', value: 'user_abc' }, + { type: 'i128', value: '150000' }, + { type: 'string', value: 'req_1' }, + ], + } + ); + }); +}); + +describe('SorobanRpcBillingClient', () => { + test('posts a balance invocation and normalizes the returned balance', async () => { + const fetchImpl = jest.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + result: { + value: '1234567', + }, + }), + })); + + const client = createSorobanRpcBillingClient({ + rpcUrl: 'http://soroban-rpc.internal', + contractId: 'contract_abc', + fetchImpl: fetchImpl as unknown as typeof fetch, + requestIdFactory: () => 'req-balance', + }); + + const result = await client.getBalance('user_123'); + + assert.deepEqual(result, { balance: '1234567' }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('posts a deduct invocation and returns the transaction hash', async () => { + const fetchImpl = jest.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + result: { + transactionHash: '0xbilling123', + }, + }), + })); + + const client = createSorobanRpcBillingClient({ + rpcUrl: 'http://soroban-rpc.internal', + contractId: 'contract_abc', + fetchImpl: fetchImpl as unknown as typeof fetch, + requestIdFactory: () => 'req-deduct', + sourceAccount: 'G_SOURCE_ACCOUNT', + networkPassphrase: 'Test SDF Network ; September 2015', + }); + + const result = await client.deductBalance('user_123', '250000', 'req_123'); + + assert.deepEqual(result, { txHash: '0xbilling123' }); + + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + assert.deepEqual(JSON.parse(String(init.body)), { + jsonrpc: '2.0', + id: 'req-deduct', + method: 'simulateTransaction', + params: { + invocation: { + contractId: 'contract_abc', + function: 'deduct', + args: [ + { type: 'string', value: 'user_123' }, + { type: 'i128', value: '250000' }, + { type: 'string', value: 'req_123' }, + ], + }, + sourceAccount: 'G_SOURCE_ACCOUNT', + networkPassphrase: 'Test SDF Network ; September 2015', + }, + }); + }); + + test('normalizes simulation failures returned by Soroban RPC', async () => { + const fetchImpl = (async () => ({ + ok: true, + status: 200, + json: async () => ({ + result: { + error: { + message: ' insufficient balance ', + }, + }, + }), + })) as unknown as typeof fetch; + + const client = createSorobanRpcBillingClient({ + rpcUrl: 'http://soroban-rpc.internal', + contractId: 'contract_abc', + fetchImpl, + }); + + await assert.rejects( + () => client.deductBalance('user_123', '1000'), + /insufficient balance/ + ); + }); +}); diff --git a/src/services/sorobanBilling.ts b/src/services/sorobanBilling.ts new file mode 100644 index 0000000..55b09de --- /dev/null +++ b/src/services/sorobanBilling.ts @@ -0,0 +1,304 @@ +export interface SorobanBillingInvocationArg { + type: 'string' | 'i128'; + value: string; +} + +export interface SorobanBillingInvocation { + contractId: string; + function: string; + args: SorobanBillingInvocationArg[]; +} + +export interface SorobanBillingRpcRequest { + jsonrpc: '2.0'; + id: string; + method: 'simulateTransaction'; + params: { + invocation: SorobanBillingInvocation; + sourceAccount?: string; + networkPassphrase?: string; + }; +} + +export interface SorobanBillingClientOptions { + rpcUrl: string; + contractId: string; + sourceAccount?: string; + networkPassphrase?: string; + requestTimeoutMs?: number; + fetchImpl?: typeof fetch; + requestIdFactory?: () => string; + balanceFunctionName?: string; + deductFunctionName?: string; +} + +export interface SorobanBalanceResponse { + balance: string; +} + +export interface SorobanDeductResponse { + txHash: string; +} + +const DEFAULT_TIMEOUT_MS = 5_000; + +function extractErrorMessage(error: unknown, depth = 0): string | undefined { + if (depth > 4 || error === null || error === undefined) { + return undefined; + } + + if (typeof error === 'string') { + const trimmed = error.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + if (error instanceof Error) { + return extractErrorMessage(error.message, depth + 1); + } + + if (Array.isArray(error)) { + const messages = error + .map((entry) => extractErrorMessage(entry, depth + 1)) + .filter((message): message is string => Boolean(message)); + return messages.length > 0 ? messages.join('; ') : undefined; + } + + if (typeof error === 'object') { + const record = error as Record; + for (const key of ['message', 'detail', 'details', 'title'] as const) { + const message = extractErrorMessage(record[key], depth + 1); + if (message) { + return message; + } + } + + for (const key of ['error', 'errors', 'data', 'result'] as const) { + const message = extractErrorMessage(record[key], depth + 1); + if (message) { + return message; + } + } + } + + return undefined; +} + +function normalizeSorobanBillingError( + error: unknown, + fallback = 'Unknown Soroban error' +): string { + return extractErrorMessage(error)?.replace(/\s+/g, ' ').trim() ?? fallback; +} + +export function buildSorobanBalanceInvocation( + contractId: string, + userId: string, + functionName = 'balance' +): SorobanBillingInvocation { + return { + contractId, + function: functionName, + args: [{ type: 'string', value: userId }], + }; +} + +export function buildSorobanDeductInvocation( + contractId: string, + userId: string, + amount: string, + idempotencyKey?: string, + functionName = 'deduct' +): SorobanBillingInvocation { + const args: SorobanBillingInvocationArg[] = [ + { type: 'string', value: userId }, + { type: 'i128', value: amount }, + ]; + + if (idempotencyKey) { + args.push({ type: 'string', value: idempotencyKey }); + } + + return { + contractId, + function: functionName, + args, + }; +} + +function extractRpcResult(payload: Record): Record | undefined { + const result = payload.result; + return result && typeof result === 'object' ? result as Record : undefined; +} + +function extractFirstValue(result: Record): unknown { + if (Array.isArray(result.results) && result.results.length > 0) { + const first = result.results[0]; + if (first && typeof first === 'object') { + const record = first as Record; + if ('xdr' in record) return record.xdr; + if ('value' in record) return record.value; + if ('result' in record) return record.result; + } + } + + if ('value' in result) return result.value; + if ('result' in result) return result.result; + if ('balance' in result) return result.balance; + return undefined; +} + +function normalizeBalanceValue(value: unknown): string { + if (typeof value === 'string' && value.trim() !== '') { + return value.trim(); + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(Math.trunc(value)); + } + + if (value && typeof value === 'object') { + const record = value as Record; + for (const key of ['balance', 'i128', 'u128', 'value']) { + const candidate = record[key]; + if (typeof candidate === 'string' && candidate.trim() !== '') { + return candidate.trim(); + } + if (typeof candidate === 'number' && Number.isFinite(candidate)) { + return String(Math.trunc(candidate)); + } + } + } + + throw new Error('Missing balance value in Soroban RPC response'); +} + +function normalizeTxHash(result: Record): string { + const candidate = result.transactionHash ?? result.txHash ?? result.hash; + if (typeof candidate === 'string' && candidate.trim() !== '') { + return candidate; + } + throw new Error('Missing transaction hash in Soroban RPC response'); +} + +function extractSimulationError(payload: Record): unknown { + if (payload.error) { + return payload.error; + } + + const result = extractRpcResult(payload); + if (!result) { + return undefined; + } + + if (result.error) { + return result.error; + } + + if (Array.isArray(result.results) && result.results.length > 0) { + const first = result.results[0]; + if (first && typeof first === 'object' && 'error' in (first as Record)) { + return (first as Record).error; + } + } + + return undefined; +} + +export class SorobanRpcBillingClient { + private readonly fetchImpl: typeof fetch; + + constructor(private readonly options: SorobanBillingClientOptions) { + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async getBalance(userId: string): Promise { + const result = await this.invoke( + buildSorobanBalanceInvocation( + this.options.contractId, + userId, + this.options.balanceFunctionName ?? 'balance' + ) + ); + + return { + balance: normalizeBalanceValue(extractFirstValue(result)), + }; + } + + async deductBalance( + userId: string, + amount: string, + idempotencyKey?: string + ): Promise { + const result = await this.invoke( + buildSorobanDeductInvocation( + this.options.contractId, + userId, + amount, + idempotencyKey, + this.options.deductFunctionName ?? 'deduct' + ) + ); + + return { + txHash: normalizeTxHash(result), + }; + } + + private async invoke(invocation: SorobanBillingInvocation): Promise> { + const requestBody: SorobanBillingRpcRequest = { + jsonrpc: '2.0', + id: this.options.requestIdFactory?.() ?? `billing-${Date.now()}`, + method: 'simulateTransaction', + params: { + invocation, + sourceAccount: this.options.sourceAccount, + networkPassphrase: this.options.networkPassphrase, + }, + }; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS + ); + + try { + const response = await this.fetchImpl(this.options.rpcUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Soroban RPC request failed: HTTP ${response.status}`); + } + + const payload = await response.json() as Record; + const simulationError = extractSimulationError(payload); + if (simulationError) { + throw new Error(normalizeSorobanBillingError(simulationError, 'Simulation failed')); + } + + const result = extractRpcResult(payload); + if (!result) { + throw new Error('Missing result in Soroban RPC response'); + } + + return result; + } catch (error) { + throw new Error(normalizeSorobanBillingError(error, 'Soroban RPC request failed')); + } finally { + clearTimeout(timeout); + } + } +} + +export function createSorobanRpcBillingClient( + options: SorobanBillingClientOptions +): SorobanRpcBillingClient { + return new SorobanRpcBillingClient(options); +} From 5a46be99dea5ac7b78e12a4ee18fcc1739ca1fa7 Mon Sep 17 00:00:00 2001 From: dimka90 Date: Sat, 28 Mar 2026 17:58:09 +0100 Subject: [PATCH 2/2] fix: resolve typecheck regressions --- package-lock.json | 26 +---- src/config/env.ts | 18 ++-- src/config/index.ts | 123 ++++++++++++------------ src/controllers/vaultController.test.ts | 2 +- src/index.ts | 56 ++++++++++- src/lib/prisma.ts | 15 ++- src/middleware/ipAllowlist.ts | 54 ++++++----- src/middleware/validate.ts | 32 +++--- src/repositories/userRepository.ts | 31 +++++- src/routes/developerRoutes.ts | 6 +- src/routes/gatewayRoutes.ts | 19 +--- src/services/settlementStore.ts | 2 +- src/services/sorobanBilling.test.ts | 4 +- src/types/developer.ts | 2 +- src/webhooks/webhook.types.ts | 4 +- tests/integration/billing.test.ts | 13 ++- tests/integration/protected.test.ts | 20 ++++ tests/integration/webhooks.test.ts | 6 +- 18 files changed, 257 insertions(+), 176 deletions(-) diff --git a/package-lock.json b/package-lock.json index b9539ae..54234b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,7 +87,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -610,8 +609,7 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -1773,7 +1771,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2220,7 +2217,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -2412,7 +2408,6 @@ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2566,7 +2561,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -2805,7 +2799,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3165,7 +3158,6 @@ "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -3302,7 +3294,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3831,7 +3822,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d": { "version": "1.0.2", @@ -4571,7 +4563,6 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5624,7 +5615,6 @@ "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -6026,7 +6016,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -7786,7 +7775,6 @@ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "license": "MIT", - "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", @@ -8260,7 +8248,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -8614,7 +8601,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -8726,7 +8712,6 @@ "integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.5.0", "@prisma/dev": "0.20.0", @@ -9141,7 +9126,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.7.4", @@ -10196,7 +10182,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10654,7 +10639,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/config/env.ts b/src/config/env.ts index 51d82be..e9d3841 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -38,17 +38,17 @@ const envSchema = z // Soroban RPC (optional) SOROBAN_RPC_ENABLED: z - .string() - .transform((v) => v === 'true') - .default('false'), + .coerce + .boolean() + .default(false), SOROBAN_RPC_URL: z.string().url().optional(), SOROBAN_RPC_TIMEOUT: z.coerce.number().default(2_000), // Horizon (optional) HORIZON_ENABLED: z - .string() - .transform((v) => v === 'true') - .default('false'), + .coerce + .boolean() + .default(false), HORIZON_URL: z.string().url().optional(), HORIZON_TIMEOUT: z.coerce.number().default(2_000), @@ -63,9 +63,9 @@ const envSchema = z // Profiling GATEWAY_PROFILING_ENABLED: z - .string() - .transform((v) => v === 'true') - .default('false'), + .coerce + .boolean() + .default(false), }) .superRefine((values, ctx) => { if (values.SOROBAN_RPC_ENABLED && !values.SOROBAN_RPC_URL) { diff --git a/src/config/index.ts b/src/config/index.ts index 448b190..2a5a440 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,110 +1,113 @@ -import "dotenv/config"; -import { z } from "zod"; +import 'dotenv/config'; +import { z } from 'zod'; + +export type StellarNetwork = 'testnet' | 'mainnet'; -/** - * Utility to mask sensitive values in logs - */ const mask = (value: string) => `${value.slice(0, 2)}****${value.slice(-2)}`; -/** - * Environment schema - */ const envSchema = z.object({ - NODE_ENV: z - .enum(["development", "test", "production"]) - .default("development"), - + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().default(3000), - DATABASE_URL: z .string() - .min(1, "DATABASE_URL is required") - .default( - "postgresql://postgres:postgres@localhost:5432/callora?schema=public", - ), - - JWT_SECRET: z - .string() - .min(10, "JWT_SECRET must be at least 10 characters") - .optional(), - + .min(1, 'DATABASE_URL is required') + .default('postgresql://postgres:postgres@localhost:5432/callora?schema=public'), + JWT_SECRET: z.string().min(10, 'JWT_SECRET must be at least 10 characters').optional(), METRICS_API_KEY: z.string().optional(), - DB_POOL_MAX: z.coerce.number().default(10), DB_IDLE_TIMEOUT_MS: z.coerce.number().default(30000), DB_CONN_TIMEOUT_MS: z.coerce.number().default(2000), - - // Stellar / blockchain related + UPSTREAM_URL: z.string().url().default('http://localhost:4000'), + PROXY_TIMEOUT_MS: z.coerce.number().default(30000), + STELLAR_NETWORK: z.enum(['testnet', 'mainnet']).default('testnet'), SOROBAN_RPC_URL: z.string().url().optional(), HORIZON_URL: z.string().url().optional(), + STELLAR_TESTNET_SOROBAN_RPC_URL: z.string().url().default('https://soroban-testnet.stellar.org'), + STELLAR_TESTNET_HORIZON_URL: z.string().url().default('https://horizon-testnet.stellar.org'), + STELLAR_TESTNET_NETWORK_PASSPHRASE: z.string().default('Test SDF Network ; September 2015'), + STELLAR_TESTNET_VAULT_CONTRACT_ID: z.string().optional(), + STELLAR_TESTNET_SETTLEMENT_CONTRACT_ID: z.string().optional(), + STELLAR_MAINNET_SOROBAN_RPC_URL: z.string().url().default('https://mainnet.sorobanrpc.com'), + STELLAR_MAINNET_HORIZON_URL: z.string().url().default('https://horizon.stellar.org'), + STELLAR_MAINNET_NETWORK_PASSPHRASE: z.string().default('Public Global Stellar Network ; September 2015'), + STELLAR_MAINNET_VAULT_CONTRACT_ID: z.string().optional(), + STELLAR_MAINNET_SETTLEMENT_CONTRACT_ID: z.string().optional(), }); -/** - * Parse and validate env - */ const parsed = envSchema.safeParse(process.env); if (!parsed.success) { - console.error("❌ Invalid environment configuration:"); - + console.error('❌ Invalid environment configuration:'); for (const issue of parsed.error.issues) { - console.error(`- ${issue.path.join(".")}: ${issue.message}`); + console.error(`- ${issue.path.join('.')}: ${issue.message}`); } - - process.exit(1); // Fail fast + process.exit(1); } const env = parsed.data; -/** - * Additional runtime validation (context-aware) - */ -if (env.NODE_ENV === "production") { +const testnetConfig = { + sorobanRpcUrl: env.STELLAR_TESTNET_SOROBAN_RPC_URL, + horizonUrl: env.STELLAR_TESTNET_HORIZON_URL, + networkPassphrase: env.STELLAR_TESTNET_NETWORK_PASSPHRASE, + vaultContractId: env.STELLAR_TESTNET_VAULT_CONTRACT_ID, + settlementContractId: env.STELLAR_TESTNET_SETTLEMENT_CONTRACT_ID, +}; + +const mainnetConfig = { + sorobanRpcUrl: env.STELLAR_MAINNET_SOROBAN_RPC_URL, + horizonUrl: env.STELLAR_MAINNET_HORIZON_URL, + networkPassphrase: env.STELLAR_MAINNET_NETWORK_PASSPHRASE, + vaultContractId: env.STELLAR_MAINNET_VAULT_CONTRACT_ID, + settlementContractId: env.STELLAR_MAINNET_SETTLEMENT_CONTRACT_ID, +}; + +const activeNetwork = env.STELLAR_NETWORK; +const activeConfig = activeNetwork === 'mainnet' ? mainnetConfig : testnetConfig; + +if (env.NODE_ENV === 'production') { if (!env.JWT_SECRET) { - console.error("❌ JWT_SECRET is required in production"); + console.error('❌ JWT_SECRET is required in production'); process.exit(1); } - if (!env.SOROBAN_RPC_URL) { - console.error("❌ SOROBAN_RPC_URL is required in production"); + if (!activeConfig.sorobanRpcUrl) { + console.error('❌ SOROBAN RPC URL is required in production'); process.exit(1); } - if (!env.HORIZON_URL) { - console.error("❌ HORIZON_URL is required in production"); + if (!activeConfig.horizonUrl) { + console.error('❌ HORIZON URL is required in production'); process.exit(1); } } -/** - * Final typed config object - */ export const config = { port: env.PORT, nodeEnv: env.NODE_ENV, - databaseUrl: env.DATABASE_URL, - dbPool: { max: env.DB_POOL_MAX, idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS, connectionTimeoutMillis: env.DB_CONN_TIMEOUT_MS, }, - jwt: { - secret: env.JWT_SECRET ?? "dev-secret-change-me", + secret: env.JWT_SECRET ?? 'dev-secret-change-me', }, - metrics: { apiKey: env.METRICS_API_KEY, }, - - stellar: { - sorobanRpcUrl: env.SOROBAN_RPC_URL, - horizonUrl: env.HORIZON_URL, + proxy: { + upstreamUrl: env.UPSTREAM_URL, + timeoutMs: env.PROXY_TIMEOUT_MS, }, stellar: { - ...activeConfig, + network: activeNetwork, + sorobanRpcUrl: env.SOROBAN_RPC_URL ?? activeConfig.sorobanRpcUrl, + horizonUrl: env.HORIZON_URL ?? activeConfig.horizonUrl, + networkPassphrase: activeConfig.networkPassphrase, + vaultContractId: activeConfig.vaultContractId, + settlementContractId: activeConfig.settlementContractId, networks: { testnet: testnetConfig, mainnet: mainnetConfig, @@ -112,16 +115,14 @@ export const config = { }, }; -/** - * Log safe config summary (no secrets!) - */ -if (env.NODE_ENV !== "test") { - console.log("✅ Config loaded:"); +if (env.NODE_ENV !== 'test') { + console.log('✅ Config loaded:'); console.log({ nodeEnv: config.nodeEnv, port: config.port, databaseUrl: config.databaseUrl, jwtSecret: config.jwt.secret ? mask(config.jwt.secret) : undefined, metricsEnabled: Boolean(config.metrics.apiKey), + stellarNetwork: config.stellar.network, }); } diff --git a/src/controllers/vaultController.test.ts b/src/controllers/vaultController.test.ts index 1c9e21d..712e2d8 100644 --- a/src/controllers/vaultController.test.ts +++ b/src/controllers/vaultController.test.ts @@ -394,7 +394,7 @@ describe('VaultController - getBalance', () => { expect(response.status).toBe(200); // Validate response structure - expect(response.body).toBeObject(); + expect(response.body).toEqual(expect.any(Object)); expect(response.body).toHaveProperty('balance_usdc'); expect(response.body).toHaveProperty('contractId'); expect(response.body).toHaveProperty('network'); diff --git a/src/index.ts b/src/index.ts index d478ad6..75b901a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,60 @@ import { config } from './config/index.js'; // Helper for Jest/CommonJS compat const isDirectExecution = process.argv[1] && (process.argv[1].endsWith('index.ts') || process.argv[1].endsWith('index.js')); +interface GracefulShutdownOptions { + server: Server; + activeConnections: Set; + closeDatabase: () => Promise; + logger?: Pick; + timeoutMs?: number; +} + +export function createGracefulShutdownHandler({ + server, + activeConnections, + closeDatabase, + logger = console, + timeoutMs = 10_000, +}: GracefulShutdownOptions) { + let inFlight: Promise | null = null; + + return (signal: NodeJS.Signals): Promise => { + if (inFlight) { + return inFlight; + } + + inFlight = new Promise((resolve) => { + logger.log(`Received ${signal}, shutting down gracefully`); + + const timeout = setTimeout(() => { + for (const socket of activeConnections) { + socket.destroy(); + } + }, timeoutMs); + + server.close(async (error?: Error) => { + clearTimeout(timeout); + + if (error) { + logger.error('Error while closing HTTP server', error); + resolve(1); + return; + } + + try { + await closeDatabase(); + resolve(0); + } catch (closeError) { + logger.error('Error while closing data resources', closeError); + resolve(1); + } + }); + }); + + return inFlight; + }; +} + export const app = express(); app.get('/api/health', (_req, res) => { @@ -133,7 +187,7 @@ if (isDirectExecution) { }); const onSignal = (signal: NodeJS.Signals) => { - void gracefulShutdown(signal).then((exitCode) => { + void gracefulShutdown(signal).then((exitCode: number) => { process.exit(exitCode); }); }; diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 4147552..e16aae5 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,16 +1,21 @@ -import { PrismaClient } from '../generated/prisma/client.js'; import { PrismaPg } from '@prisma/adapter-pg'; -let prisma: PrismaClient; +type PrismaClientLike = { + $disconnect: () => Promise; + [key: string]: unknown; +}; -function getPrismaClient(): PrismaClient { +let prisma: PrismaClientLike | undefined; + +function getPrismaClient(): PrismaClientLike { if (!prisma) { const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error('DATABASE_URL environment variable is required'); } const adapter = new PrismaPg({ connectionString }); - prisma = new PrismaClient({ adapter }); + const { PrismaClient } = require('@prisma/client'); + prisma = new PrismaClient({ adapter }) as PrismaClientLike; } return prisma; } @@ -22,7 +27,7 @@ export async function disconnectPrisma(): Promise { await prisma.$disconnect(); } -export default new Proxy({} as PrismaClient, { +export default new Proxy({} as PrismaClientLike, { get(_target, prop, receiver) { const client = getPrismaClient(); const value = Reflect.get(client, prop, receiver); diff --git a/src/middleware/ipAllowlist.ts b/src/middleware/ipAllowlist.ts index 869faaf..d3f3c7e 100644 --- a/src/middleware/ipAllowlist.ts +++ b/src/middleware/ipAllowlist.ts @@ -96,12 +96,14 @@ export function createIpAllowlist(config: IpAllowlistConfig) { } // Log configuration for security audit - logger.info('IP allowlist middleware configured', { - allowedRangesCount: allowedRanges.length, - trustProxy, - proxyHeaders, - enabled, - }); + logger.info( + `IP allowlist middleware configured ${JSON.stringify({ + allowedRangesCount: allowedRanges.length, + trustProxy, + proxyHeaders, + enabled, + })}` + ); return (req: Request, res: Response, next: NextFunction): void => { // Skip IP checking if allowlist is disabled @@ -114,11 +116,13 @@ export function createIpAllowlist(config: IpAllowlistConfig) { // Validate extracted IP format if (!isValidIp(clientIp)) { - logger.warn('Invalid IP format detected', { - ip: clientIp, - userAgent: req.get('User-Agent'), - path: req.path, - }); + logger.warn( + `Invalid IP format detected ${JSON.stringify({ + ip: clientIp, + userAgent: req.get('User-Agent'), + path: req.path, + })}` + ); res.status(400).json({ error: 'Bad Request: invalid client IP format', @@ -132,13 +136,15 @@ export function createIpAllowlist(config: IpAllowlistConfig) { if (!isAllowed) { // Log blocked attempt for security monitoring - logger.warn('IP allowlist blocked request', { - clientIp, - path: req.path, - method: req.method, - userAgent: req.get('User-Agent'), - timestamp: new Date().toISOString(), - }); + logger.warn( + `IP allowlist blocked request ${JSON.stringify({ + clientIp, + path: req.path, + method: req.method, + userAgent: req.get('User-Agent'), + timestamp: new Date().toISOString(), + })}` + ); res.status(403).json({ error: 'Forbidden: IP address not allowed', @@ -148,11 +154,13 @@ export function createIpAllowlist(config: IpAllowlistConfig) { } // Log successful allowlist check for audit trail - logger.debug('IP allowlist check passed', { - clientIp, - path: req.path, - method: req.method, - }); + logger.info( + `IP allowlist check passed ${JSON.stringify({ + clientIp, + path: req.path, + method: req.method, + })}` + ); next(); }; diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts index f92ce16..8d01659 100644 --- a/src/middleware/validate.ts +++ b/src/middleware/validate.ts @@ -133,30 +133,20 @@ export function validate(schemas: ValidationSchemas) { * @returns Array of formatted validation errors */ function formatZodErrors(error: ZodError, location: string): ValidationErrorDetail[] { - return error.errors.map((err): ValidationErrorDetail => { + return error.issues.map((err): ValidationErrorDetail => { const field = err.path.join('.'); - const code = err.code.toUpperCase(); + const code = String(err.code).toUpperCase(); // Map Zod error codes to user-friendly messages - let message = err.message; - switch (err.code) { - case 'invalid_string': - message = `Invalid ${field}: ${err.message}`; - break; - case 'invalid_type': - message = `Invalid ${field}: expected ${err.expected}, received ${err.received}`; - break; - case 'too_small': - message = `${field} is too small: ${err.message}`; - break; - case 'too_big': - message = `${field} is too big: ${err.message}`; - break; - case 'invalid_enum_value': - message = `Invalid ${field}: must be one of ${err.options?.join(', ')}`; - break; - default: - message = `${field}: ${err.message}`; + let message = `${field}: ${err.message}`; + if (err.code === 'invalid_type') { + message = `Invalid ${field}: expected ${String((err as { expected?: unknown }).expected)}, received ${String((err as { received?: unknown }).received)}`; + } else if (err.code === 'too_small') { + message = `${field} is too small: ${err.message}`; + } else if (err.code === 'too_big') { + message = `${field} is too big: ${err.message}`; + } else if (err.code === 'invalid_format' || err.code === 'invalid_value') { + message = `Invalid ${field}: ${err.message}`; } return { diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 2b2888f..514278c 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -1,17 +1,38 @@ import prisma from '../lib/prisma.js'; -import type { User } from '../generated/prisma/client.js'; import type { PaginationParams } from '../lib/pagination.js'; -export type UserListItem = Pick; +export interface UserListItem { + id: string; + stellar_address: string | null; + created_at: Date; +} interface FindUsersResult { users: UserListItem[]; total: number; } +type UserRepositoryPrisma = { + $transaction: (operations: [Promise, Promise]) => Promise<[UserListItem[], number]>; + user: { + findMany: (args: { + select: { + id: true; + stellar_address: true; + created_at: true; + }; + orderBy: { created_at: 'desc' }; + skip: number; + take: number; + }) => Promise; + count: () => Promise; + }; +}; + export async function findUsers(params: PaginationParams): Promise { - const [users, total] = await prisma.$transaction([ - prisma.user.findMany({ + const prismaClient = prisma as unknown as UserRepositoryPrisma; + const [users, total] = await prismaClient.$transaction([ + prismaClient.user.findMany({ select: { id: true, stellar_address: true, @@ -21,7 +42,7 @@ export async function findUsers(params: PaginationParams): Promise s.id === id); if (s) { s.status = status; diff --git a/src/services/sorobanBilling.test.ts b/src/services/sorobanBilling.test.ts index 32c82d3..d4bf711 100644 --- a/src/services/sorobanBilling.test.ts +++ b/src/services/sorobanBilling.test.ts @@ -85,7 +85,9 @@ describe('SorobanRpcBillingClient', () => { assert.deepEqual(result, { txHash: '0xbilling123' }); - const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(fetchImpl).toHaveBeenCalledTimes(1); + const firstCall = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + const [, init] = firstCall; assert.deepEqual(JSON.parse(String(init.body)), { jsonrpc: '2.0', id: 'req-deduct', diff --git a/src/types/developer.ts b/src/types/developer.ts index 11ab130..3a01299 100644 --- a/src/types/developer.ts +++ b/src/types/developer.ts @@ -25,6 +25,6 @@ export interface DeveloperRevenueResponse { export interface SettlementStore { create(settlement: Settlement): void; - updateStatus(id: string, status: Settlement['status'], txHash?: string): void; + updateStatus(id: string, status: Settlement['status'], txHash?: string | null): void; getDeveloperSettlements(developerId: string): Settlement[]; } diff --git a/src/webhooks/webhook.types.ts b/src/webhooks/webhook.types.ts index 4836fe5..699e66c 100644 --- a/src/webhooks/webhook.types.ts +++ b/src/webhooks/webhook.types.ts @@ -6,7 +6,7 @@ export type WebhookEventType = export interface WebhookConfig { developerId: string; url: string; - events: WebhookEventType[]; + events: string[]; secret?: string; // for HMAC signature (optional but recommended) createdAt: Date; } @@ -40,4 +40,4 @@ export interface LowBalanceAlertData { currentBalance: string; thresholdBalance: string; asset: string; -} \ No newline at end of file +} diff --git a/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index 9692f65..8a086a2 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -12,13 +12,18 @@ import { BillingService, type BillingDeductRequest, type SorobanClient } from '. class MockSorobanClient implements SorobanClient { private callCount = 0; private shouldFail = false; + private balance = '1000000000'; - async deductBalance(userId: string, amount: string): Promise { + async getBalance(): Promise<{ balance: string }> { + return { balance: this.balance }; + } + + async deductBalance(userId: string, amount: string): Promise<{ txHash: string }> { this.callCount++; if (this.shouldFail) { throw new Error('Soroban network error'); } - return `tx_${userId}_${amount}_${this.callCount}`; + return { txHash: `tx_${userId}_${amount}_${this.callCount}` }; } getCallCount(): number { @@ -29,6 +34,10 @@ class MockSorobanClient implements SorobanClient { this.shouldFail = fail; } + setBalance(balance: string): void { + this.balance = balance; + } + reset(): void { this.callCount = 0; this.shouldFail = false; diff --git a/tests/integration/protected.test.ts b/tests/integration/protected.test.ts index 8c03d5d..6a86b89 100644 --- a/tests/integration/protected.test.ts +++ b/tests/integration/protected.test.ts @@ -155,9 +155,29 @@ const stubDeveloperRepository: DeveloperRepository = { }; class StubApiRepository implements ApiRepository { + async create(_api: Parameters[0]) { + return { + id: 1, + developer_id: 0, + name: 'stub', + description: null, + base_url: 'https://example.com', + logo_url: null, + category: null, + status: 'draft' as const, + created_at: new Date(), + updated_at: new Date(), + }; + } + async update() { + return null; + } async listByDeveloper(_developerId: number, _filters?: ApiListFilters) { return []; } + async listPublic() { + return []; + } async findById() { return null; } diff --git a/tests/integration/webhooks.test.ts b/tests/integration/webhooks.test.ts index 60eb830..855de42 100644 --- a/tests/integration/webhooks.test.ts +++ b/tests/integration/webhooks.test.ts @@ -158,8 +158,7 @@ describe('Webhook Routes Security Tests', () => { }); it('should allow registration without secret (but not recommended)', async () => { - const payloadWithoutSecret = { ...validPayload }; - delete payloadWithoutSecret.secret; + const { secret: _secret, ...payloadWithoutSecret } = validPayload; const response = await request(app) .post('/api/webhooks') @@ -290,8 +289,7 @@ describe('Webhook Signature Verification Tests', () => { }); it('should not include signature header when no secret is provided', async () => { - const configWithoutSecret = { ...testConfig }; - delete configWithoutSecret.secret; + const { secret: _secret, ...configWithoutSecret } = testConfig; mockFetch.mockResolvedValue({ ok: true,