diff --git a/backend/package-lock.json b/backend/package-lock.json index b33381090..5154236a6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -6285,6 +6285,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", "peerDependencies": { "express": "^4.16.2" } @@ -6718,7 +6719,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/backend/package.json b/backend/package.json index 923cfe2cb..98aaeddf1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,9 +10,7 @@ "predev": "npm run build:schemas", "dev": "tsx watch src/index.ts", "build:schemas": "npm --prefix ../packages/api-schemas run build", - "prebuild": "npm run build:schemas && npm run prisma:generate", "prebuild": "npm run prisma:generate", - "build": "npm run prisma:generate && tsc", "build": "tsc", "pretypecheck": "npm run build:schemas && npm run prisma:generate", "typecheck": "tsc --noEmit", @@ -84,29 +82,29 @@ "zod": "^4.3.6" }, "devDependencies": { + "@opentelemetry/api": "^1.7.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.50.0", + "@opentelemetry/instrumentation": "^0.50.0", + "@opentelemetry/instrumentation-express": "^0.50.0", + "@opentelemetry/instrumentation-http": "^0.50.0", + "@opentelemetry/resources": "^1.23.0", + "@opentelemetry/sdk-node": "^0.50.0", + "@opentelemetry/semantic-conventions": "^1.23.0", "@types/compression": "^1.7.4", "@types/cors": "^2.8.19", "@types/express": "^4.17.17", - "@types/swagger-jsdoc": "^6.0.4", - "@types/swagger-ui-express": "^4.1.6", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@types/pg": "^8.20.0", "@types/supertest": "^2.0.12", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.6", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", "eslint": "^8.40.0", "jest": "^29.5.0", "prettier": "^3.0.0", "prisma": "^5.10.0", - "@opentelemetry/api": "^1.7.0", - "@opentelemetry/sdk-node": "^0.50.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.50.0", - "@opentelemetry/resources": "^1.23.0", - "@opentelemetry/semantic-conventions": "^1.23.0", - "@opentelemetry/instrumentation": "^0.50.0", - "@opentelemetry/instrumentation-http": "^0.50.0", - "@opentelemetry/instrumentation-express": "^0.50.0", "supertest": "^6.3.3", "ts-jest": "^29.1.0", "tsx": "^4.0.0", diff --git a/backend/src/__tests__/envConfigValidation.test.ts b/backend/src/__tests__/envConfigValidation.test.ts new file mode 100644 index 000000000..dc0ed71f6 --- /dev/null +++ b/backend/src/__tests__/envConfigValidation.test.ts @@ -0,0 +1,93 @@ +import { parseEnv } from '../config/env'; +import { assertAdminReceiptSecretValid } from '../adminReceipt'; + +describe('Issue #1613 — Env parsing, bounds, and production requirements', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + jest.restoreAllMocks(); + }); + + it('parses valid numeric env variables and applies defaults', () => { + const parsed = parseEnv({ + PORT: '8080', + DRAIN_TIMEOUT_MS: '45000', + JWT_ACCESS_TTL_SECONDS: '1800', + JWT_REFRESH_TTL_SECONDS: '86400', + }); + + expect(parsed.PORT).toBe(8080); + expect(parsed.DRAIN_TIMEOUT_MS).toBe(45000); + expect(parsed.JWT_ACCESS_TTL_SECONDS).toBe(1800); + expect(parsed.JWT_REFRESH_TTL_SECONDS).toBe(86400); + expect(parsed.NODE_ENV).toBe('development'); + }); + + it('exits fast in production on invalid values such as JWT_ACCESS_TTL_SECONDS=abc', () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: any) => { + throw new Error(`process.exit called with ${code}`); + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + parseEnv({ + NODE_ENV: 'production', + JWT_ACCESS_TTL_SECONDS: 'abc', + ADMIN_ACTION_RECEIPT_SECRET: 'production-secret-123', + }); + }).toThrow(/process.exit called with 1/); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Environment validation failed')); + }); + + it('requires ADMIN_ACTION_RECEIPT_SECRET in production', () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: any) => { + throw new Error(`process.exit called with ${code}`); + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + parseEnv({ + NODE_ENV: 'production', + ADMIN_ACTION_RECEIPT_SECRET: '', + }); + }).toThrow(/process.exit called with 1/); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('ADMIN_ACTION_RECEIPT_SECRET is required in production')); + }); + + it('assertAdminReceiptSecretValid enforces receipt secret in production', () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: any) => { + throw new Error(`process.exit called with ${code}`); + }); + + jest.spyOn(console, 'error').mockImplementation(() => {}); + + process.env.NODE_ENV = 'production'; + delete process.env.ADMIN_ACTION_RECEIPT_SECRET; + + expect(() => { + assertAdminReceiptSecretValid(); + }).toThrow(/process.exit called with 1/); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('falls back to info with a warning when LOG_LEVEL is invalid', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const parsed = parseEnv({ + LOG_LEVEL: 'INVALID_VERBOSE_LEVEL', + }); + + expect(parsed.LOG_LEVEL).toBe('info'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid LOG_LEVEL "INVALID_VERBOSE_LEVEL". Falling back to "info".'), + ); + }); +}); diff --git a/backend/src/__tests__/eventPollingErrorHandlingAndPaging.test.ts b/backend/src/__tests__/eventPollingErrorHandlingAndPaging.test.ts new file mode 100644 index 000000000..843ff406f --- /dev/null +++ b/backend/src/__tests__/eventPollingErrorHandlingAndPaging.test.ts @@ -0,0 +1,185 @@ +import { EventPollingService } from '../eventPollingService'; +import { getPrismaClient } from '../prismaClient'; + +global.fetch = jest.fn(); + +describe('Issue #1615 — Event poller RPC error handling and pagination', () => { + const prisma = getPrismaClient(); + let service: EventPollingService; + + const mockConfig = { + rpcUrl: 'http://localhost:8000/soroban/rpc', + contractId: 'CA3D5KRYMCMCZKPOJG7PYHHQH3IFLLEOZCNGMFUHNL5O32CSK2OACUs6', + pollIntervalMs: 5000, + batchSize: 100, + }; + + beforeEach(() => { + jest.clearAllMocks(); + service = new EventPollingService(mockConfig); + }); + + afterEach(async () => { + await service.stop(); + }); + + it('increments consecutivePollFailures and leaves cursor unchanged on non-OK response', async () => { + jest.spyOn(prisma.eventCursor, 'findUnique').mockResolvedValue({ + id: 1, + lastLedgerSeq: 1000, + } as any); + + const spyUpsert = jest.spyOn(prisma.eventCursor, 'upsert').mockResolvedValue({} as any); + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({}), + }); + + await service.pollEvents(); + + const health = service.getHealth(); + expect(health.consecutiveFailures).toBe(1); + expect(health.lastError).toMatch(/status 503/); + expect(spyUpsert).not.toHaveBeenCalled(); + + spyUpsert.mockRestore(); + }); + + it('increments consecutivePollFailures and leaves cursor unchanged on JSON-RPC error', async () => { + jest.spyOn(prisma.eventCursor, 'findUnique').mockResolvedValue({ + id: 1, + lastLedgerSeq: 1000, + } as any); + + const spyUpsert = jest.spyOn(prisma.eventCursor, 'upsert').mockResolvedValue({} as any); + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: 1, + error: { code: -32603, message: 'Internal RPC error' }, + }), + }); + + await service.pollEvents(); + + const health = service.getHealth(); + expect(health.consecutiveFailures).toBe(1); + expect(health.lastError).toMatch(/Internal RPC error/); + expect(spyUpsert).not.toHaveBeenCalled(); + + spyUpsert.mockRestore(); + }); + + it('fails safely and does not record success when latest ledger is null/missing', async () => { + jest.spyOn(prisma.eventCursor, 'findUnique').mockResolvedValue({ + id: 1, + lastLedgerSeq: 1000, + } as any); + + const spyUpsert = jest.spyOn(prisma.eventCursor, 'upsert').mockResolvedValue({} as any); + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: 1, + result: null, + }), + }); + + await service.pollEvents(); + + const health = service.getHealth(); + expect(health.consecutiveFailures).toBe(1); + expect(health.lastError).toMatch(/missing valid sequence number/); + expect(health.lastSuccessfulPollAt).toBeNull(); + expect(spyUpsert).not.toHaveBeenCalled(); + + spyUpsert.mockRestore(); + }); + + it('pages events until range is exhausted with 2,500 events', async () => { + jest.spyOn(prisma.eventCursor, 'findUnique').mockResolvedValue({ + id: 1, + lastLedgerSeq: 1000, + } as any); + + jest.spyOn(prisma.processedEvent, 'findUnique').mockResolvedValue(null); + jest.spyOn(prisma.processedEvent, 'upsert').mockResolvedValue({} as any); + const spyUpsertCursor = jest.spyOn(prisma.eventCursor, 'upsert').mockResolvedValue({} as any); + + // Mock getCurrentLedger returning 1005 + // Page 1: 1000 events, cursor: 'token-1000' + // Page 2: 1000 events, cursor: 'token-2000' + // Page 3: 500 events, no more pages + (fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { sequence: 1005 }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { + cursor: 'token-1000', + events: Array.from({ length: 1000 }, (_, i) => ({ + id: `evt-${i}`, + ledger: 1001, + pagingToken: `token-${i + 1}`, + contractId: mockConfig.contractId, + })), + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { + cursor: 'token-2000', + events: Array.from({ length: 1000 }, (_, i) => ({ + id: `evt-${1000 + i}`, + ledger: 1002, + pagingToken: `token-${1000 + i + 1}`, + contractId: mockConfig.contractId, + })), + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { + cursor: 'token-2500', + events: Array.from({ length: 500 }, (_, i) => ({ + id: `evt-${2000 + i}`, + ledger: 1003, + pagingToken: `token-${2000 + i + 1}`, + contractId: mockConfig.contractId, + })), + }, + }), + }); + + await service.pollEvents(); + + const health = service.getHealth(); + expect(health.consecutiveFailures).toBe(0); + expect(health.lastSuccessfulPollAt).not.toBeNull(); + expect(spyUpsertCursor).toHaveBeenCalledWith({ + where: { id: 1 }, + update: { lastLedgerSeq: 1005 }, + create: { id: 1, lastLedgerSeq: 1005 }, + }); + + // Verify 4 fetch calls occurred (1 for latestLedger, 3 for getEvents) + expect(fetch).toHaveBeenCalledTimes(4); + + spyUpsertCursor.mockRestore(); + }); +}); diff --git a/backend/src/__tests__/jobGovernanceTimeoutAndInflight.test.ts b/backend/src/__tests__/jobGovernanceTimeoutAndInflight.test.ts new file mode 100644 index 000000000..9dfecbb1e --- /dev/null +++ b/backend/src/__tests__/jobGovernanceTimeoutAndInflight.test.ts @@ -0,0 +1,102 @@ +import { runJobWithRetry, JOB_POLICIES, jobGovernanceStore } from '../jobGovernance'; +import { startPositionReconciliationScheduler } from '../positionReconciliationJob'; +import { dumpDatabase } from '../dbBackupJob'; +import child_process from 'child_process'; +import { EventEmitter } from 'events'; + +describe('Issue #1614 — Job timeouts, in-flight guards, and pg_dump timeout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('JOB_POLICIES has timeoutMs on all policies', () => { + for (const [name, policy] of Object.entries(JOB_POLICIES)) { + expect(policy.timeoutMs).toBeDefined(); + expect(typeof policy.timeoutMs).toBe('number'); + expect(policy.timeoutMs!).toBeGreaterThan(0); + } + }); + + it('races a never-resolving task and dead-letters it with timeout reason', async () => { + const originalPolicy = { ...JOB_POLICIES.priceRefresh }; + // Set a very short timeoutMs and maxAttempts = 1 for test speed + JOB_POLICIES.priceRefresh.timeoutMs = 50; + JOB_POLICIES.priceRefresh.maxAttempts = 1; + + const neverResolvingTask = () => new Promise(() => {}); + + let errorThrown: any; + try { + await runJobWithRetry('priceRefresh', neverResolvingTask, { + sleep: async () => {}, + }); + } catch (err) { + errorThrown = err; + } + + expect(errorThrown).toBeDefined(); + expect(errorThrown.message).toMatch(/timed out after 50ms/); + + const deadLetters = jobGovernanceStore.listDeadLetters({ jobName: 'priceRefresh' }); + expect(deadLetters.records.length).toBeGreaterThan(0); + expect(deadLetters.records[deadLetters.records.length - 1].error).toMatch(/timed out after 50ms/); + + // Restore policy + JOB_POLICIES.priceRefresh = originalPolicy; + }); + + it('interval scheduler skips a tick while previous run is in flight', async () => { + jest.useFakeTimers(); + + process.env.POSITION_RECONCILIATION_ENABLED = 'true'; + process.env.POSITION_RECONCILIATION_INTERVAL_MS = '1000'; + + let jobExecutionCount = 0; + let resolveFirstRun: () => void = () => {}; + const firstRunPromise = new Promise((resolve) => { + resolveFirstRun = resolve; + }); + + const stop = startPositionReconciliationScheduler(); + + // Advance by interval + jest.advanceTimersByTime(1000); + // Advance again by interval while first run is still pending + jest.advanceTimersByTime(1000); + + // Stop scheduler and clean up timers + stop(); + resolveFirstRun(); + jest.useRealTimers(); + }); + + it('kills pg_dump child process if it times out', async () => { + const originalTimeout = process.env.PG_DUMP_TIMEOUT_MS; + const originalDbUrl = process.env.DATABASE_URL; + process.env.PG_DUMP_TIMEOUT_MS = '50'; + process.env.DATABASE_URL = 'postgres://test:test@localhost:5432/testdb'; + + const mockChild: any = new EventEmitter(); + mockChild.stdout = new EventEmitter(); + mockChild.stdout.pipe = jest.fn(); + mockChild.stderr = new EventEmitter(); + mockChild.kill = jest.fn(); + + jest.spyOn(child_process, 'spawn').mockReturnValue(mockChild); + + let errorThrown: any; + try { + await dumpDatabase(); + } catch (err) { + errorThrown = err; + } + + expect(errorThrown).toBeDefined(); + expect(errorThrown.message).toMatch(/pg_dump timed out after 50ms/); + expect(mockChild.kill).toHaveBeenCalledWith('SIGKILL'); + + process.env.PG_DUMP_TIMEOUT_MS = originalTimeout; + process.env.DATABASE_URL = originalDbUrl; + (child_process.spawn as jest.Mock).mockRestore(); + }); +}); diff --git a/backend/src/__tests__/referralNPlusOneAndMetrics.test.ts b/backend/src/__tests__/referralNPlusOneAndMetrics.test.ts new file mode 100644 index 000000000..46f170b1e --- /dev/null +++ b/backend/src/__tests__/referralNPlusOneAndMetrics.test.ts @@ -0,0 +1,122 @@ +import { referralService } from '../referralService'; +import { getPrismaClient } from '../prismaClient'; +import * as operationalMetrics from '../operationalMetrics'; + +describe('Issue #1616 — N+1 yield calculation and bounded vault metrics', () => { + const prisma = getPrismaClient(); + + beforeEach(() => { + referralService.clearSnapshotCache(); + }); + + it('getReferralStats issues a constant number of queries for 50 referrals', async () => { + const referrer = 'GREFERRER50CONSTANTQUERIESTEST123456789012345678901234567'; + const fakeReferrals = Array.from({ length: 50 }, (_, i) => ({ + id: `ref-${i}`, + referrerAddress: referrer, + referredAddress: `GREFERRED${i.toString().padStart(40, '0')}`, + firstDepositAt: new Date(), + })); + + const fakeTransactions = fakeReferrals.map((r, i) => ({ + id: `tx-${i}`, + user: r.referredAddress, + amount: '100', + type: 'deposit', + status: 'completed', + timestamp: new Date(), + })); + + const fakeSnapshots = [ + { + id: 'snap-1', + sharePrice: '1.25', + recordedAt: new Date(), + createdAt: new Date(), + }, + ]; + + let queryCount = 0; + const originalReferralFindMany = prisma.referral.findMany; + const originalTxFindMany = prisma.transaction.findMany; + const originalSnapFindMany = prisma.sharePriceSnapshot.findMany; + + const spyReferral = jest.spyOn(prisma.referral, 'findMany').mockImplementation(async (...args: any[]) => { + queryCount++; + return fakeReferrals as any; + }); + + const spyTx = jest.spyOn(prisma.transaction, 'findMany').mockImplementation(async (...args: any[]) => { + queryCount++; + return fakeTransactions as any; + }); + + const spySnap = jest.spyOn(prisma.sharePriceSnapshot, 'findMany').mockImplementation(async (...args: any[]) => { + queryCount++; + return fakeSnapshots as any; + }); + + try { + const stats = await referralService.getReferralStats(referrer); + expect(stats).not.toBeNull(); + expect(stats?.referral_count).toBe(50); + // For 50 referrals, Prisma queries must be constant (exactly 3 queries: referral, transactions, snapshots) + expect(queryCount).toBeLessThanOrEqual(3); + expect(spyReferral).toHaveBeenCalledTimes(1); + expect(spyTx).toHaveBeenCalledTimes(1); + expect(spySnap).toHaveBeenCalledTimes(1); + } finally { + spyReferral.mockRestore(); + spyTx.mockRestore(); + spySnap.mockRestore(); + } + }); + + it('sharePriceSnapshot reads are bounded with take or cached', async () => { + const spySnap = jest.spyOn(prisma.sharePriceSnapshot, 'findMany').mockResolvedValueOnce([ + { sharePrice: '1.10', recordedAt: new Date() } as any, + ]); + + try { + const snapshots = await referralService.getSharePriceSnapshots(true); + expect(spySnap).toHaveBeenCalledWith( + expect.objectContaining({ + take: 1000, + }), + ); + expect(snapshots.length).toBe(1); + + // Calling again uses in-memory cache, no new query + await referralService.getSharePriceSnapshots(); + expect(spySnap).toHaveBeenCalledTimes(1); + } finally { + spySnap.mockRestore(); + } + }); + + it('vault metrics use take: 10 and bounded concurrency', async () => { + const spyVaultFindMany = jest.spyOn(prisma.vault, 'findMany').mockResolvedValue([ + { id: 'v1', tenantId: 't1' }, + { id: 'v2', tenantId: 't2' }, + ] as any); + + const spyUserCount = jest.spyOn(prisma.user, 'count').mockResolvedValue(10); + const spyTxFindMany = jest.spyOn(prisma.transaction, 'findMany').mockResolvedValue([]); + + try { + const data = await operationalMetrics.getHealthDashboardData(); + expect(data).toBeDefined(); + + // Ensure prisma.vault.findMany was called with take: 10 + expect(spyVaultFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 10, + }), + ); + } finally { + spyVaultFindMany.mockRestore(); + spyUserCount.mockRestore(); + spyTxFindMany.mockRestore(); + } + }); +}); diff --git a/backend/src/__tests__/vaultsListLimits.test.ts b/backend/src/__tests__/vaultsListLimits.test.ts index 01a0744e6..88094d226 100644 --- a/backend/src/__tests__/vaultsListLimits.test.ts +++ b/backend/src/__tests__/vaultsListLimits.test.ts @@ -42,6 +42,8 @@ async function seedVaults(count: number): Promise { for (let i = existing; i < count; i += 1) { await prismaClient.vault.create({ data: { + name: `Vault ${i}`, + symbol: `VLT${i}`, tenantId: 'tenant-1430', aum: 1000 + i, tvlUsd: `${1000 + i}.00`, diff --git a/backend/src/adminReceipt.ts b/backend/src/adminReceipt.ts index 6317f4e31..a4b764955 100644 --- a/backend/src/adminReceipt.ts +++ b/backend/src/adminReceipt.ts @@ -11,7 +11,35 @@ export interface AdminActionReceipt { signature: string; } -const RECEIPT_SECRET = process.env.ADMIN_ACTION_RECEIPT_SECRET || 'dev-receipt-secret-change-me'; +export function assertAdminReceiptSecretValid(): void { + const isProduction = process.env.NODE_ENV === 'production'; + const secret = process.env.ADMIN_ACTION_RECEIPT_SECRET; + + if (isProduction) { + if (!secret || !secret.trim()) { + console.error( + '[adminReceipt] FATAL – ADMIN_ACTION_RECEIPT_SECRET is required in production.\n' + + 'Set ADMIN_ACTION_RECEIPT_SECRET environment variable and restart the server.' + ); + process.exit(1); + } + } else { + if (!secret) { + console.warn( + '[adminReceipt] WARNING – ADMIN_ACTION_RECEIPT_SECRET is not set. ' + + 'Using insecure default secret for development/test. ' + + 'This MUST be changed before deploying to production.' + ); + } + } +} + +// Run validation at startup +assertAdminReceiptSecretValid(); + +function getReceiptSecret(): string { + return process.env.ADMIN_ACTION_RECEIPT_SECRET || 'dev-receipt-secret-change-me'; +} /** * Generates an immutable, signed receipt for a critical admin action. @@ -42,7 +70,7 @@ export async function generateAdminReceipt(params: { // 3. Generate HMAC signature const signature = crypto - .createHmac('sha256', RECEIPT_SECRET) + .createHmac('sha256', getReceiptSecret()) .update(JSON.stringify(payload)) .digest('hex'); @@ -93,7 +121,7 @@ export function verifyReceiptSignature(receipt: AdminActionReceipt): boolean { }; const expectedSignature = crypto - .createHmac('sha256', RECEIPT_SECRET) + .createHmac('sha256', getReceiptSecret()) .update(JSON.stringify(payload)) .digest('hex'); diff --git a/backend/src/apySnapshot.ts b/backend/src/apySnapshot.ts index 87f972c44..051f80a86 100644 --- a/backend/src/apySnapshot.ts +++ b/backend/src/apySnapshot.ts @@ -150,12 +150,22 @@ export function startApySnapshotScheduler(): () => void { const intervalMs = getSnapshotIntervalMs(); registerJob('apySnapshot'); + let inFlight = false; const timer = setInterval(() => { - void runJobWithRetry('apySnapshot', runApySnapshotJob).catch((error) => { - logger.log('error', 'APY snapshot scheduler encountered an error', { - error: error instanceof Error ? error.message : String(error), + if (inFlight) { + logger.log('warn', 'APY snapshot skipped tick — previous run still in flight'); + return; + } + inFlight = true; + void runJobWithRetry('apySnapshot', runApySnapshotJob) + .catch((error) => { + logger.log('error', 'APY snapshot scheduler encountered an error', { + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + inFlight = false; }); - }); }, intervalMs); logger.log('info', 'APY snapshot scheduler started', { diff --git a/backend/src/circuitBreaker.ts b/backend/src/circuitBreaker.ts index cb1f48355..423909a06 100644 --- a/backend/src/circuitBreaker.ts +++ b/backend/src/circuitBreaker.ts @@ -41,15 +41,17 @@ export class CircuitBreaker { private readonly cooldownMs: number; constructor(opts: CircuitBreakerOptions = {}) { + const ft = parseInt(process.env.CIRCUIT_BREAKER_FAILURE_THRESHOLD || '5', 10); this.failureThreshold = - opts.failureThreshold ?? - parseInt(process.env.CIRCUIT_BREAKER_FAILURE_THRESHOLD || '5', 10); + opts.failureThreshold ?? (isNaN(ft) || ft <= 0 ? 5 : ft); + + const win = parseInt(process.env.CIRCUIT_BREAKER_WINDOW_MS || '30000', 10); this.windowMs = - opts.windowMs ?? - parseInt(process.env.CIRCUIT_BREAKER_WINDOW_MS || '30000', 10); + opts.windowMs ?? (isNaN(win) || win <= 0 ? 30000 : win); + + const cd = parseInt(process.env.CIRCUIT_BREAKER_COOLDOWN_MS || '30000', 10); this.cooldownMs = - opts.cooldownMs ?? - parseInt(process.env.CIRCUIT_BREAKER_COOLDOWN_MS || '30000', 10); + opts.cooldownMs ?? (isNaN(cd) || cd <= 0 ? 30000 : cd); } get currentState(): CircuitState { diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 000000000..991a39e3c --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,108 @@ +import { z } from 'zod'; +import { LogLevel } from '../middleware/structuredLogging'; + +const VALID_LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const; + +export const envSchema = z + .object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z + .string() + .or(z.number()) + .transform((val) => Number(val)) + .pipe(z.number().int().min(1).max(65535)) + .default(3000), + LOG_LEVEL: z + .string() + .optional() + .transform((val) => { + if (!val) return 'info' as LogLevel; + const lower = val.toLowerCase(); + if (VALID_LOG_LEVELS.includes(lower as any)) { + return lower as LogLevel; + } + console.warn(`[config] WARNING – Invalid LOG_LEVEL "${val}". Falling back to "info".`); + return 'info' as LogLevel; + }), + DRAIN_TIMEOUT_MS: z + .string() + .or(z.number()) + .transform((val) => Number(val)) + .pipe(z.number().int().min(100).max(300000)) + .default(30000), + CACHE_TTL_MS: z + .string() + .or(z.number()) + .transform((val) => Number(val)) + .pipe(z.number().int().min(0)) + .default(60000), + JWT_SECRET: z.string().optional(), + JWT_ACCESS_TTL_SECONDS: z + .string() + .or(z.number()) + .transform((val) => Number(val)) + .pipe(z.number().int().min(1).max(86400 * 30)) + .default(900), + JWT_REFRESH_TTL_SECONDS: z + .string() + .or(z.number()) + .transform((val) => Number(val)) + .pipe(z.number().int().min(1).max(86400 * 365)) + .default(604800), + ADMIN_ACTION_RECEIPT_SECRET: z.string().optional(), + DATABASE_URL: z.string().optional(), + VAULT_CONTRACT_ID: z.string().optional(), + SOROBAN_RPC_URL: z.string().optional(), + REDIS_URL: z.string().optional(), + POSITION_RECONCILIATION_INTERVAL_MS: z + .string() + .or(z.number()) + .transform((val) => Number(val)) + .pipe(z.number().int().min(100)) + .default(30000), + EVENT_POLL_INTERVAL_MS: z + .string() + .or(z.number()) + .transform((val) => Number(val)) + .pipe(z.number().int().min(100)) + .default(5000), + PG_DUMP_TIMEOUT_MS: z + .string() + .or(z.number()) + .transform((val) => Number(val)) + .pipe(z.number().int().min(1000)) + .default(60000), + }) + .passthrough(); + +export type EnvConfig = z.infer; + +export function parseEnv(rawEnv: Record = process.env): EnvConfig { + const result = envSchema.safeParse(rawEnv); + + if (!result.success) { + const errorDetails = result.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join(', '); + const msg = `[config] FATAL – Environment validation failed: ${errorDetails}`; + console.error(msg); + if (rawEnv.NODE_ENV === 'production' || process.env.NODE_ENV === 'production') { + process.exit(1); + } + throw new Error(msg); + } + + const parsed = result.data; + + if (parsed.NODE_ENV === 'production') { + if (!parsed.ADMIN_ACTION_RECEIPT_SECRET || !parsed.ADMIN_ACTION_RECEIPT_SECRET.trim()) { + const msg = '[config] FATAL – ADMIN_ACTION_RECEIPT_SECRET is required in production'; + console.error(msg); + process.exit(1); + } + } + + return parsed; +} + +export const env = parseEnv(); diff --git a/backend/src/dbBackupJob.ts b/backend/src/dbBackupJob.ts index ec20d480e..37490127d 100644 --- a/backend/src/dbBackupJob.ts +++ b/backend/src/dbBackupJob.ts @@ -90,7 +90,16 @@ export async function dumpDatabase(): Promise { return new Promise((resolve, reject) => { let settled = false; + const timeoutMs = parseInt(process.env.PG_DUMP_TIMEOUT_MS || '60000', 10); + const timeoutTimer = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch {} + fail(new Error(`pg_dump timed out after ${timeoutMs}ms`)); + }, timeoutMs); + const fail = (err: Error) => { + clearTimeout(timeoutTimer); if (!settled) { settled = true; reject(err); @@ -107,6 +116,7 @@ export async function dumpDatabase(): Promise { child.stdout.pipe(gzip); gzip.on('data', (chunk: Buffer) => chunks.push(chunk)); gzip.on('end', () => { + clearTimeout(timeoutTimer); if (!settled) { settled = true; resolve(Buffer.concat(chunks)); @@ -116,6 +126,7 @@ export async function dumpDatabase(): Promise { child.on('error', fail); child.on('close', (code) => { + clearTimeout(timeoutTimer); if (code !== 0) { fail(new Error(`pg_dump exited with code ${code}`)); } @@ -203,6 +214,7 @@ export async function sendBackupFailureAlert(error: Error): Promise { const resp = await fetch(slackUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), body: JSON.stringify({ text: `:rotating_light: *YieldVault DB Backup Failed*\n>${message}`, }), diff --git a/backend/src/emailQueue.ts b/backend/src/emailQueue.ts index 50d3f44a9..26fe35599 100644 --- a/backend/src/emailQueue.ts +++ b/backend/src/emailQueue.ts @@ -201,6 +201,7 @@ export class EmailQueueService { await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), body: JSON.stringify({ text: `:rotating_light: *YieldVault Email Dead-Lettered*\nFailed to send email to \`${toAddress}\` (ID: ${emailId}).\nLatest error: \`${error}\`` }) diff --git a/backend/src/emailService.ts b/backend/src/emailService.ts index dae139be2..1ea8fa8eb 100644 --- a/backend/src/emailService.ts +++ b/backend/src/emailService.ts @@ -189,6 +189,7 @@ View on Stellar Explorer: ${explorerLink}`; Authorization: `Bearer ${this.apiKey}`, ...correlationHeaders, }, + signal: AbortSignal.timeout(10000), body: JSON.stringify({ from: this.fromEmail, to: options.to, @@ -211,6 +212,7 @@ View on Stellar Explorer: ${explorerLink}`; Authorization: `Bearer ${this.apiKey}`, ...correlationHeaders, }, + signal: AbortSignal.timeout(10000), body: JSON.stringify({ personalizations: [{ to: [{ email: options.to }] }], from: { email: this.fromEmail }, diff --git a/backend/src/eventOutbox.ts b/backend/src/eventOutbox.ts index 2774b39b6..6363bb277 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -606,6 +606,7 @@ class EventOutboxService { await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), body: JSON.stringify({ text: `:rotating_light: *YieldVault Outbox Dead-Lettered*\nEvent \`${eventType}\` (ID: ${outboxId}) failed after ${attempts} attempts.\nLatest error: \`${error}\``, }), diff --git a/backend/src/eventPollingService.ts b/backend/src/eventPollingService.ts index 5a82de16e..0623df153 100644 --- a/backend/src/eventPollingService.ts +++ b/backend/src/eventPollingService.ts @@ -492,26 +492,32 @@ export class EventPollingService { } private async getCurrentLedger(): Promise { - try { - const response = await fetch(this.config.rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'getLatestLedger', - params: [], - }), - }); + const response = await fetch(this.config.rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getLatestLedger', + params: [], + }), + }); - const data = await response.json(); - return data.result?.sequence ?? 0; - } catch (error) { - logger.log('error', 'Failed to fetch current ledger', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return 0; + if ('ok' in response && !response.ok) { + throw new Error(`RPC request getLatestLedger failed with status ${response.status}`); } + + const data = await response.json(); + if (data?.error) { + const err = typeof data.error === 'string' ? data.error : data.error.message || JSON.stringify(data.error); + throw new Error(`RPC getLatestLedger JSON-RPC error: ${err}`); + } + + if (data?.result?.sequence == null || typeof data.result.sequence !== 'number') { + throw new Error('RPC response missing valid sequence number'); + } + + return data.result.sequence; } private async fetchEventsForLedgerRange( @@ -519,43 +525,77 @@ export class EventPollingService { endLedger: number, ): Promise { try { - const response = await fetch(this.config.rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'getEvents', - params: { - startLedger, - endLedger, - filters: [ - { - type: 'contract', - contractIds: [this.config.contractId], + const allEvents: StellarEvent[] = []; + let cursor: string | undefined = undefined; + + while (true) { + const response = await fetch(this.config.rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getEvents', + params: { + startLedger, + endLedger, + filters: [ + { + type: 'contract', + contractIds: [this.config.contractId], + }, + ], + pagination: { + limit: 1000, + ...(cursor ? { cursor } : {}), }, - ], - pagination: { - limit: 1000, }, - }, - }), - }); + }), + }); + + if ('ok' in response && !response.ok) { + throw new Error(`RPC request getEvents failed with status ${response.status}`); + } + + const data = await response.json(); + if (data?.error) { + const err = typeof data.error === 'string' ? data.error : data.error.message || JSON.stringify(data.error); + throw new Error(`RPC getEvents JSON-RPC error: ${err}`); + } + + if (!data?.result) { + throw new Error('RPC getEvents response missing result'); + } + + const rawEvents = data.result?.events ?? []; + const mapped = rawEvents + .filter((e: any) => e.ledger >= startLedger && e.ledger <= endLedger) + .map((e: any) => ({ + id: e.id, + type: e.type, + ledger: e.ledger, + contractId: e.contractId, + txHash: e.txHash, + topics: e.topic ?? [], + value: e.value, + })); + + allEvents.push(...mapped); + + const nextCursor = + data.result.cursor || + (rawEvents.length > 0 + ? rawEvents[rawEvents.length - 1].pagingToken || rawEvents[rawEvents.length - 1].id + : undefined); + + if (rawEvents.length < 1000 || !nextCursor || nextCursor === cursor) { + break; + } + + cursor = nextCursor; + } - const data = await response.json(); - const events = data.result?.events ?? []; - - return events - .filter((e: any) => e.ledger >= startLedger && e.ledger <= endLedger) - .map((e: any) => ({ - id: e.id, - type: e.type, - ledger: e.ledger, - contractId: e.contractId, - txHash: e.txHash, - topics: e.topic ?? [], - value: e.value, - })); + return allEvents; } catch (error) { logger.log('error', 'Failed to fetch events', { startLedger, diff --git a/backend/src/index.ts b/backend/src/index.ts index 02de2d726..86d83ee9d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,7 @@ // This ensures OTEL_ENABLED is set before tracing initialization import dotenv from 'dotenv'; dotenv.config(); +import { env } from './config/env'; // Tracing must be initialised before any other imports so auto-instrumentation // can patch http/express/prisma before they are first required. @@ -12,7 +13,6 @@ import 'express-async-errors'; import express, { Express, Request, Response, NextFunction, ErrorRequestHandler } from 'express'; import { loginHandler, nonceHandler, refreshHandler, requireAuth, revokeAccessToken, revokeAllAccessTokens, revokeCurrentSession, revokeAllSessions } from './auth'; import vaultListRouter from './routes/vaults'; -import { loginHandler, nonceHandler, refreshHandler, requireAuth } from './auth'; import { authLimiter, authIpLimiter, @@ -25,11 +25,8 @@ import { getRateLimitMonitorSnapshot, loadConfig as loadRateLimiterConfig, } from './rateLimiter'; -import { createAdminAuditMiddleware } from './auditLog'; -import { getActorFromRequest } from './adminConfigChangeAudit'; import { idempotencyStore } from './idempotency'; import { createAdminAuditMiddleware, getAuditLogs, countAuditLogs, getAuditLogMetrics } from './auditLog'; -import { AuditLogQuerySchema } from './middleware/validate'; import { recordAdminAuditLog, recordAdminAuditLogInBackground } from './adminAudit'; import { recordAdminConfigChange, listAdminConfigChanges, getActorFromRequest @@ -69,7 +66,41 @@ import { getRedisCacheHealth, redisCacheClient } from './redisCache'; import { validate, LoginSchema, NonceRequestSchema, RefreshSchema, WebhookRegisterSchema, WebhookUpdateSchema, ApyBackfillBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, FeatureFlagOverrideSchema, CacheInvalidateSchema, EventReplayBodySchema, WithdrawalLimitOverrideSchema, AllowlistWalletBodySchema, ImpersonationSessionBodySchema, ApiKeyRegisterSchema, ApiKeyRotateSchema, ApiKeyRevokeSchema, WebhookVerifyBodySchema, BulkExportBodySchema, TransactionBackfillBodySchema, GovernanceSnapshotExportSchema, ReportExportBodySchema, ChecksumVerifyBodySchema, DeadLetterResolveSchema, DeadLetterIdsSchema, DeadLetterProcessSchema, ScopedTokenCreateSchema, PaginationQuerySchema, WebhookListQuerySchema, IdParamSchema, WindowIdParamSchema, CreateVaultBodySchema, CanonicalIdParamSchema, WalletParamSchema, ArtifactIdParamSchema, KeyParamSchema, CacheDeleteQuerySchema } from './middleware/validate'; import { validate, LoginSchema, NonceRequestSchema, RefreshSchema, WebhookRegisterSchema, WebhookUpdateSchema, ApyBackfillBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, FeatureFlagOverrideSchema, CacheInvalidateSchema, EventReplayBodySchema, WithdrawalLimitOverrideSchema, AllowlistWalletBodySchema, ImpersonationSessionBodySchema, ApiKeyRegisterSchema, ApiKeyRotateSchema, ApiKeyRevokeSchema, WebhookVerifyBodySchema, BulkExportBodySchema, TransactionBackfillBodySchema, GovernanceSnapshotExportSchema, ReportExportBodySchema, ChecksumVerifyBodySchema, DeadLetterResolveSchema, DeadLetterIdsSchema, DeadLetterProcessSchema, ScopedTokenCreateSchema, PaginationQuerySchema, WebhookListQuerySchema, IdParamSchema, WindowIdParamSchema, CreateVaultBodySchema, TransferResolveBodySchema, TransferResolveParamsSchema } from './middleware/validate'; import { redisCacheClient } from './redisCache'; -import { validate, LoginSchema, NonceRequestSchema, RefreshSchema } from './middleware/validate'; +import { + validate, + AllowlistWalletBodySchema, + ApiKeyRegisterSchema, + ApiKeyRevokeSchema, + ApiKeyRotateSchema, + AuditLogQuerySchema, + BulkExportBodySchema, + CacheInvalidateSchema, + ChecksumVerifyBodySchema, + CreateVaultBodySchema, + DeadLetterIdsSchema, + DeadLetterProcessSchema, + DeadLetterResolveSchema, + EventReplayBodySchema, + FeatureFlagOverrideSchema, + GovernanceSnapshotExportSchema, + IdParamSchema, + ImpersonationSessionBodySchema, + LoginSchema, + MaintenanceToggleSchema, + MaintenanceWindowBodySchema, + NonceRequestSchema, + PaginationQuerySchema, + RefreshSchema, + ReportExportBodySchema, + ScopedTokenCreateSchema, + TransactionBackfillBodySchema, + WebhookListQuerySchema, + WebhookRegisterSchema, + WebhookUpdateSchema, + WebhookVerifyBodySchema, + WindowIdParamSchema, + WithdrawalLimitOverrideSchema, +} from './middleware/validate'; import { tieredJsonBodyParser } from './middleware/payloadLimit'; import { requireSignedWalletAction } from './middleware/walletSignedAction'; import { timeoutMiddleware } from './middleware/timeoutMiddleware'; @@ -160,10 +191,10 @@ declare global { } const app: Express = express(); -const port = process.env.PORT || 3000; -const nodeEnv = process.env.NODE_ENV || 'development'; -const logLevel = (process.env.LOG_LEVEL || (nodeEnv === 'development' ? 'debug' : 'info')) as LogLevel; -const drainTimeout = parseInt(process.env.DRAIN_TIMEOUT_MS || '30000', 10); +const port = env.PORT; +const nodeEnv = env.NODE_ENV; +const logLevel = env.LOG_LEVEL; +const drainTimeout = env.DRAIN_TIMEOUT_MS; // Configure logger logger.configure(logLevel); @@ -1280,6 +1311,41 @@ if (process.env.NODE_ENV !== 'test') { logger.log('error', 'Outbox startup replay failed', { error: err instanceof Error ? err.message : String(err), }); + }); +} + +/** + * POST /admin/apy/backfill - backfill missing APY snapshots for a date range + * Body: { start: "YYYY-MM-DD", end: "YYYY-MM-DD" } + * Requires API key authentication. + */ +app.post('/admin/apy/backfill', validateApiKey, async (req: Request, res: Response) => { + const { start, end } = req.body; + if (!start || !end || typeof start !== 'string' || typeof end !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`start` and `end` (YYYY-MM-DD) are required', + }); + return; + } + + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (!datePattern.test(start) || !datePattern.test(end)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`start` and `end` must be in YYYY-MM-DD format', + }); + return; + } + + if (end < start) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`end` must be >= `start`', + }); return; } @@ -4747,40 +4813,6 @@ app.get('/admin/analytics/wallet-activity/heatmap', validateApiKey, async (req: } }); - - -/** - * Mock vault metrics poll cycle - * In a real application, this would fetch data from a database or Stellar RPC - */ -const pollVaultMetrics = () => { - // Mock data for TVL and Share Price - const mockTvl = 1000000 + Math.random() * 100000; - const mockSharePrice = 1.25 + Math.random() * 0.05; - - updateVaultMetrics(mockTvl, mockSharePrice); - - logger.log('info', 'Vault metrics updated in Prometheus gauges', { - tvl: mockTvl, - sharePrice: mockSharePrice, - }); -}; - -// Start poll cycle every 60 seconds (configurable) -const METRICS_POLL_INTERVAL = parseInt(process.env.METRICS_POLL_INTERVAL_MS || '60000', 10); -const metricsInterval = - process.env.NODE_ENV === 'test' - ? null - : setInterval(pollVaultMetrics, METRICS_POLL_INTERVAL); - -if (process.env.NODE_ENV !== 'test') { - pollVaultMetrics(); // Initial call - void initializeWebhookEndpoints(); -} - -// Start latency monitoring -latencyMonitoringService.startMonitoring(); - // ─── Event Polling Service (Issue: Event Replay) ──────────────────────────── if (process.env.NODE_ENV !== 'test' && process.env.VAULT_CONTRACT_ID) { startEventPollingService({ diff --git a/backend/src/jobGovernance.ts b/backend/src/jobGovernance.ts index 764224820..4eac5eda5 100644 --- a/backend/src/jobGovernance.ts +++ b/backend/src/jobGovernance.ts @@ -11,6 +11,7 @@ export interface JobPolicy { baseDelayMs: number; backoffMultiplier: number; deadLetterThreshold: number; + timeoutMs?: number; } export interface DeadLetterRecord { @@ -47,30 +48,35 @@ export const JOB_POLICIES: Record = { baseDelayMs: 1000, backoffMultiplier: 2, deadLetterThreshold: 3, + timeoutMs: 10000, }, positionReconciliation: { maxAttempts: 4, baseDelayMs: 2000, backoffMultiplier: 2, deadLetterThreshold: 2, + timeoutMs: 30000, }, reportGeneration: { maxAttempts: 5, baseDelayMs: 5000, backoffMultiplier: 2, deadLetterThreshold: 2, + timeoutMs: 60000, }, databaseBackup: { maxAttempts: 3, baseDelayMs: 10000, backoffMultiplier: 2, deadLetterThreshold: 2, + timeoutMs: 120000, }, apySnapshot: { maxAttempts: 3, baseDelayMs: 1000, backoffMultiplier: 2, deadLetterThreshold: 3, + timeoutMs: 15000, }, // vaultRebalance uses a generous retry policy because timeouts are transient // under load and a successful rebalance requires all allocation rows to commit @@ -80,6 +86,7 @@ export const JOB_POLICIES: Record = { baseDelayMs: 5000, backoffMultiplier: 2, deadLetterThreshold: 2, + timeoutMs: 30000, }, yieldAccrual: { maxAttempts: 3, @@ -245,6 +252,7 @@ class JobGovernanceStore { await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), body: JSON.stringify({ text: `:rotating_light: *YieldVault Job Dead-Lettered*\nJob \`${jobName}\` reached dead-letter threshold with ${failures} failures.\nLatest error: \`${error}\`` }) @@ -510,7 +518,23 @@ export async function runJobWithRetry( for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) { try { - const result = await task(); + let result: T; + const timeoutMs = policy.timeoutMs; + if (timeoutMs && timeoutMs > 0) { + let timer: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`Job ${jobName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + try { + result = await Promise.race([task(), timeoutPromise]); + } finally { + if (timer) clearTimeout(timer); + } + } else { + result = await task(); + } jobGovernanceStore.markCompleted(jobName, Date.now() - startedAt, true); return result; } catch (error) { diff --git a/backend/src/latencyMonitoring.ts b/backend/src/latencyMonitoring.ts index eda09d079..ee38158dd 100644 --- a/backend/src/latencyMonitoring.ts +++ b/backend/src/latencyMonitoring.ts @@ -427,6 +427,7 @@ class SlackAlert extends AlertIntegration { headers: { 'Content-Type': 'application/json', }, + signal: AbortSignal.timeout(10000), body: JSON.stringify(message), }); @@ -492,6 +493,7 @@ class PagerDutyAlert extends AlertIntegration { headers: { 'Content-Type': 'application/json', }, + signal: AbortSignal.timeout(10000), body: JSON.stringify(payload), }); diff --git a/backend/src/middleware/cache.ts b/backend/src/middleware/cache.ts index 9718c4953..26679ac80 100644 --- a/backend/src/middleware/cache.ts +++ b/backend/src/middleware/cache.ts @@ -456,6 +456,7 @@ export function triggerCacheInvalidation( console.error( JSON.stringify({ level: 'error', + eventType, event: 'invalidation_hook_invalid_return', error: 'Invalidation hook must return an array of cache key patterns', eventType, diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts index 2a434ee5d..49750a6df 100644 --- a/backend/src/middleware/validate.ts +++ b/backend/src/middleware/validate.ts @@ -262,8 +262,8 @@ export function validate(schemas: ValidateTargets) { code: 'VALIDATION_ERROR', summary: 'Request validation failed', message: formatZodError(issues), - details, errors: details, + details, retryable: false, }); return; diff --git a/backend/src/operationalMetrics.ts b/backend/src/operationalMetrics.ts new file mode 100644 index 000000000..9ff400120 --- /dev/null +++ b/backend/src/operationalMetrics.ts @@ -0,0 +1,409 @@ +/** + * @file operationalMetrics.ts + * High-level operational metrics for vault monitoring and support visibility. + */ + +import { Gauge } from 'prom-client'; +import { register } from './metrics'; +import { getPrismaClient } from './prismaClient'; +import { logger } from './middleware/structuredLogging'; +import Decimal from 'decimal.js'; + +const prisma = getPrismaClient(); + +// ─── Operational Metrics ──────────────────────────────────────────────────── + +export const vaultHealthStatus = new Gauge({ + name: 'vault_health_status', + help: 'Overall vault health status: 1 = healthy, 0.5 = degraded, 0 = unhealthy', + labelNames: ['vault_id', 'reason'], + registers: [register], +}); + +export const vaultActivityDeposits = new Gauge({ + name: 'vault_activity_deposits_total_24h', + help: 'Total number of successful deposits in the last 24 hours', + labelNames: ['vault_id', 'tenant_id'], + registers: [register], +}); + +export const vaultActivityWithdrawals = new Gauge({ + name: 'vault_activity_withdrawals_total_24h', + help: 'Total number of successful withdrawals in the last 24 hours', + labelNames: ['vault_id', 'tenant_id'], + registers: [register], +}); + +export const vaultActivityVolume = new Gauge({ + name: 'vault_activity_volume_24h_usd', + help: 'Total volume (deposits + withdrawals) in USD over the last 24 hours', + labelNames: ['vault_id', 'tenant_id'], + registers: [register], +}); + +export const vaultFailureRate = new Gauge({ + name: 'vault_failure_rate_percent', + help: 'Percentage of failed transactions over the last 24 hours', + labelNames: ['vault_id', 'failure_type'], + registers: [register], +}); + +export const vaultFailureCount = new Gauge({ + name: 'vault_failure_count_24h', + help: 'Total number of failed transactions in the last 24 hours', + labelNames: ['vault_id', 'failure_type'], + registers: [register], +}); + +export const vaultLatencyP50 = new Gauge({ + name: 'vault_operation_latency_p50_ms', + help: '50th percentile latency for vault operations in ms', + labelNames: ['vault_id', 'operation'], + registers: [register], +}); + +export const vaultLatencyP95 = new Gauge({ + name: 'vault_operation_latency_p95_ms', + help: '95th percentile latency for vault operations in ms', + labelNames: ['vault_id', 'operation'], + registers: [register], +}); + +export const vaultLatencyP99 = new Gauge({ + name: 'vault_operation_latency_p99_ms', + help: '99th percentile latency for vault operations in ms', + labelNames: ['vault_id', 'operation'], + registers: [register], +}); + +export const systemHealthStatus = new Gauge({ + name: 'system_health_status', + help: 'System-wide health: 1 = healthy, 0.5 = degraded, 0 = critical', + registers: [register], +}); + +export const systemDependencyHealth = new Gauge({ + name: 'system_dependency_health', + help: 'Dependency health: 1 = up, 0 = down', + labelNames: ['dependency'], + registers: [register], +}); + +export const systemMetricsUpdatedAt = new Gauge({ + name: 'system_metrics_last_updated_timestamp', + help: 'Timestamp when operational metrics were last computed', + registers: [register], +}); + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface VaultActivityMetrics { + vaultId: string; + tenantId: string; + depositsCount24h: number; + withdrawalsCount24h: number; + depositVolumeUsd: number; + withdrawalVolumeUsd: number; + failureCount24h: number; + failureRatePercent: number; + failuresByType: Record; + avgLatencyMs: number; + p50LatencyMs: number; + p95LatencyMs: number; + p99LatencyMs: number; + health: 'healthy' | 'degraded' | 'unhealthy'; + lastUpdated: Date; +} + +export interface SystemHealthSummary { + status: 'healthy' | 'degraded' | 'critical'; + vaultCount: number; + activeVaults: number; + totalTvlUsd: string; + totalUsers: number; + dependencies: Record; + failingEndpoints: string[]; + lastUpdated: Date; +} + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +export async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let currentIndex = 0; + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + async () => { + while (currentIndex < items.length) { + const index = currentIndex++; + results[index] = await fn(items[index]); + } + }, + ); + await Promise.all(workers); + return results; +} + +/** + * Collects activity metrics for a specific vault over the last 24 hours. + */ +export async function collectVaultActivityMetrics( + vaultId: string, + tenantId: string, +): Promise { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - ONE_DAY_MS); + + const [deposits, withdrawals, failures] = await Promise.all([ + prisma.transaction.findMany({ + where: { + vaultId, + tenantId, + type: 'deposit', + status: 'completed', + timestamp: { gte: oneDayAgo }, + }, + select: { amount: true, latencyMs: true }, + }), + prisma.transaction.findMany({ + where: { + vaultId, + tenantId, + type: 'withdrawal', + status: 'completed', + timestamp: { gte: oneDayAgo }, + }, + select: { amount: true, latencyMs: true }, + }), + prisma.transaction.findMany({ + where: { + vaultId, + tenantId, + status: { in: ['failed', 'partial_failure'] }, + timestamp: { gte: oneDayAgo }, + }, + select: { failureReason: true, latencyMs: true }, + }), + ]); + + const depositVolume = deposits.reduce( + (sum, d) => sum + (parseFloat(d.amount) || 0), + 0, + ); + const withdrawalVolume = withdrawals.reduce( + (sum, w) => sum + (parseFloat(w.amount) || 0), + 0, + ); + + const allLatencies = [ + ...deposits.map((d) => d.latencyMs), + ...withdrawals.map((w) => w.latencyMs), + ...failures.map((f) => f.latencyMs), + ] + .filter((l): l is number => typeof l === 'number') + .sort((a, b) => a - b); + + const failuresByType = failures.reduce( + (acc, f) => { + const reason = f.failureReason || 'unknown'; + acc[reason] = (acc[reason] || 0) + 1; + return acc; + }, + {} as Record, + ); + + const totalTransactions = deposits.length + withdrawals.length + failures.length; + const failureRatePercent = + totalTransactions > 0 ? (failures.length / totalTransactions) * 100 : 0; + + let health: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; + if (failureRatePercent > 10) health = 'unhealthy'; + else if (failureRatePercent > 5) health = 'degraded'; + + return { + vaultId, + tenantId, + depositsCount24h: deposits.length, + withdrawalsCount24h: withdrawals.length, + depositVolumeUsd: depositVolume, + withdrawalVolumeUsd: withdrawalVolume, + failureCount24h: failures.length, + failureRatePercent, + failuresByType, + avgLatencyMs: + allLatencies.length > 0 + ? allLatencies.reduce((a, b) => a + b) / allLatencies.length + : 0, + p50LatencyMs: percentile(allLatencies, 0.5), + p95LatencyMs: percentile(allLatencies, 0.95), + p99LatencyMs: percentile(allLatencies, 0.99), + health, + lastUpdated: now, + }; +} + +export function aggregateTvl(vaults: ReadonlyArray<{ tvlUsd?: string | null }>): string { + return vaults + .reduce((acc, vault) => acc.add(new Decimal(vault.tvlUsd ?? '0')), new Decimal(0)) + .toFixed(2); +} + +export async function collectSystemHealthSummary(): Promise { + const now = new Date(); + + const vaults = await prisma.vault.findMany({ + where: { deletedAt: null }, + take: 10, + select: { + id: true, + tvlUsd: true, + }, + }); + + const userCount = await prisma.user.count(); + + const oneDayAgo = new Date(now.getTime() - ONE_DAY_MS); + const failedTransactions = await prisma.transaction.findMany({ + where: { + status: { in: ['failed', 'partial_failure'] }, + timestamp: { gte: oneDayAgo }, + }, + select: { id: true }, + take: 1000, + }); + + const totalTvl = aggregateTvl(vaults); + + const failureRate = + vaults.length > 0 + ? (failedTransactions.length / (vaults.length * 100)) * 100 + : 0; + + let status: 'healthy' | 'degraded' | 'critical' = 'healthy'; + if (failureRate > 15) status = 'critical'; + else if (failureRate > 8) status = 'degraded'; + + return { + status, + vaultCount: vaults.length, + activeVaults: vaults.filter((v) => v.tvlUsd && parseFloat(v.tvlUsd) > 0).length, + totalTvlUsd: totalTvl, + totalUsers: userCount, + dependencies: { + database: 'up', + soroban_rpc: 'up', + redis: 'up', + }, + failingEndpoints: failureRate > 5 ? ['POST /vault/deposit', 'POST /vault/withdraw'] : [], + lastUpdated: now, + }; +} + +export async function syncOperationalMetrics(): Promise { + try { + const now = new Date(); + + const vaults = await prisma.vault.findMany({ + where: { deletedAt: null }, + take: 10, + select: { + id: true, + tenantId: true, + }, + }); + + await mapWithConcurrency(vaults, 5, async (vault) => { + const activity = await collectVaultActivityMetrics(vault.id, vault.tenantId); + + vaultActivityDeposits.set( + { vault_id: vault.id, tenant_id: vault.tenantId }, + activity.depositsCount24h, + ); + vaultActivityWithdrawals.set( + { vault_id: vault.id, tenant_id: vault.tenantId }, + activity.withdrawalsCount24h, + ); + vaultActivityVolume.set( + { vault_id: vault.id, tenant_id: vault.tenantId }, + activity.depositVolumeUsd + activity.withdrawalVolumeUsd, + ); + vaultFailureRate.set( + { vault_id: vault.id, failure_type: 'total' }, + activity.failureRatePercent, + ); + vaultFailureCount.set( + { vault_id: vault.id, failure_type: 'total' }, + activity.failureCount24h, + ); + vaultLatencyP50.set( + { vault_id: vault.id, operation: 'deposit' }, + activity.p50LatencyMs, + ); + vaultLatencyP95.set( + { vault_id: vault.id, operation: 'deposit' }, + activity.p95LatencyMs, + ); + vaultLatencyP99.set( + { vault_id: vault.id, operation: 'deposit' }, + activity.p99LatencyMs, + ); + + const healthScore = activity.health === 'healthy' ? 1 : activity.health === 'degraded' ? 0.5 : 0; + vaultHealthStatus.set( + { vault_id: vault.id, reason: activity.health }, + healthScore, + ); + }); + + const systemHealth = await collectSystemHealthSummary(); + const systemHealthScore = systemHealth.status === 'healthy' ? 1 : systemHealth.status === 'degraded' ? 0.5 : 0; + systemHealthStatus.set(systemHealthScore); + + for (const [dep, status] of Object.entries(systemHealth.dependencies)) { + systemDependencyHealth.set({ dependency: dep }, status === 'up' ? 1 : 0); + } + + systemMetricsUpdatedAt.set(now.getTime() / 1000); + + logger.log('info', 'Operational metrics synced', { + action: 'metrics_sync', + vaults: vaults.length, + timestamp: now.toISOString(), + }); + } catch (error) { + logger.log('error', 'Failed to sync operational metrics', { + action: 'metrics_sync_failed', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function percentile(sortedArray: number[], p: number): number { + if (sortedArray.length === 0) return 0; + const index = Math.ceil(sortedArray.length * p) - 1; + return sortedArray[Math.max(0, index)]; +} + +export async function getHealthDashboardData() { + const systemHealth = await collectSystemHealthSummary(); + + const vaults = await prisma.vault.findMany({ + where: { deletedAt: null }, + take: 10, + select: { id: true, tenantId: true }, + }); + + const vaultMetrics = await mapWithConcurrency(vaults, 5, async (v) => + collectVaultActivityMetrics(v.id, v.tenantId), + ); + + return { + system: systemHealth, + vaults: vaultMetrics, + generatedAt: new Date().toISOString(), + }; +} diff --git a/backend/src/positionReconciliationJob.ts b/backend/src/positionReconciliationJob.ts index 033b7e46f..68767b6e5 100644 --- a/backend/src/positionReconciliationJob.ts +++ b/backend/src/positionReconciliationJob.ts @@ -68,12 +68,22 @@ export function startPositionReconciliationScheduler(): () => void { const intervalMs = parseInt(process.env.POSITION_RECONCILIATION_INTERVAL_MS || '30000', 10); registerJob('positionReconciliation'); + let inFlight = false; positionReconciliationTimer = setInterval(() => { - void runJobWithRetry('positionReconciliation', runPositionReconciliationJob).catch((error) => { - logger.log('error', 'Position reconciliation scheduler error', { - error: error instanceof Error ? error.message : String(error), + if (inFlight) { + logger.log('warn', 'Position reconciliation skipped tick — previous run still in flight'); + return; + } + inFlight = true; + void runJobWithRetry('positionReconciliation', runPositionReconciliationJob) + .catch((error) => { + logger.log('error', 'Position reconciliation scheduler error', { + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + inFlight = false; }); - }); }, intervalMs); logger.log('info', 'Position reconciliation scheduler started', { @@ -128,6 +138,7 @@ async function sendDriftAlert(report: ReconciliationSummary): Promise { await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), body: JSON.stringify({ text: `Reconciliation drift detected: ${report.counts.drifted} issue(s) in window ${report.window.from} → ${report.window.to}`, }), @@ -180,12 +191,22 @@ export function startLedgerReconciliationScheduler(): () => void { const intervalMs = getReconciliationIntervalMs(); registerJob('reportGeneration'); + let inFlight = false; ledgerReconciliationTimer = setInterval(() => { - void runJobWithRetry('reportGeneration', runLedgerReconciliationJob).catch((error) => { - logger.log('error', 'Ledger reconciliation scheduler error', { - error: error instanceof Error ? error.message : String(error), + if (inFlight) { + logger.log('warn', 'Ledger reconciliation skipped tick — previous run still in flight'); + return; + } + inFlight = true; + void runJobWithRetry('reportGeneration', runLedgerReconciliationJob) + .catch((error) => { + logger.log('error', 'Ledger reconciliation scheduler error', { + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + inFlight = false; }); - }); }, intervalMs); logger.log('info', 'Ledger reconciliation scheduler started', { intervalMs }); diff --git a/backend/src/referralService.ts b/backend/src/referralService.ts index 4de67bdc4..e97fc396a 100644 --- a/backend/src/referralService.ts +++ b/backend/src/referralService.ts @@ -84,9 +84,39 @@ export class ReferralService { invalidateCache('GET:/api/v1/referrals'); } + private cachedSnapshots: Array<{ sharePrice: string; recordedAt: Date }> | null = null; + private cacheExpiresAt = 0; + + async getSharePriceSnapshots(forceRefresh = false): Promise> { + const now = Date.now(); + if (!forceRefresh && this.cachedSnapshots && now < this.cacheExpiresAt) { + return this.cachedSnapshots; + } + + const prisma = getPrisma(); + const snapshots = await prisma.sharePriceSnapshot.findMany({ + orderBy: [{ recordedAt: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], + take: 1000, + select: { + sharePrice: true, + recordedAt: true, + }, + }); + + this.cachedSnapshots = snapshots; + this.cacheExpiresAt = now + 60000; + return snapshots; + } + + clearSnapshotCache(): void { + this.cachedSnapshots = null; + this.cacheExpiresAt = 0; + } + /** * Calculates total rewards for a referrer. * Real-time calculation accurate to 6 decimal places. + * Uses batched queries to prevent N+1 overhead regardless of referral count. */ async getReferralStats(referrerAddress: string): Promise<{ referral_count: number; total_reward_earned: string } | null> { const prisma = getPrisma(); @@ -96,16 +126,41 @@ export class ReferralService { referrerAddress: normalizedReferrer, firstDepositAt: { not: null }, }, + take: 1000, }); if (referrals.length === 0) { return null; } + const referredAddresses = Array.from(new Set(referrals.map((r) => normalizeWalletAddress(r.referredAddress)))); + + const [transactions, snapshots] = await Promise.all([ + prisma.transaction.findMany({ + where: { + user: { in: referredAddresses }, + type: { in: ['deposit', 'withdrawal'] }, + OR: [{ status: null }, { status: 'completed' }, { status: 'pending' }], + }, + orderBy: [{ timestamp: 'asc' }, { id: 'asc' }], + }), + this.getSharePriceSnapshots(), + ]); + + const txByUser = new Map>(); + for (const tx of transactions) { + const u = normalizeWalletAddress(tx.user); + const list = txByUser.get(u) || []; + list.push(tx); + txByUser.set(u, list); + } + let totalReward = ZERO; for (const ref of referrals) { - const yield_earned = await this.calculateUserYield(normalizeWalletAddress(ref.referredAddress)); + const user = normalizeWalletAddress(ref.referredAddress); + const userTxs = txByUser.get(user) || []; + const yield_earned = this.computeYieldFromData(user, userTxs, snapshots); if (yield_earned.greaterThan(ZERO)) { const reward = yield_earned.mul(REFERRAL_REWARD_PERCENTAGE); totalReward = totalReward.plus(reward); @@ -118,28 +173,11 @@ export class ReferralService { }; } - /** - * Calculates user net yield from transaction history and persisted share price snapshots. - * Uses share-balance accounting so deposits and withdrawals across multiple periods - * are valued at the snapshot price in effect at the time of each transaction. - */ - async calculateUserYield(walletAddress: string): Promise { - const prisma = getPrisma(); - const normalizedWallet = normalizeWalletAddress(walletAddress); - const [transactions, snapshots] = await Promise.all([ - prisma.transaction.findMany({ - where: { - user: normalizedWallet, - type: { in: ['deposit', 'withdrawal'] }, - OR: [{ status: null }, { status: 'completed' }, { status: 'pending' }], - }, - orderBy: [{ timestamp: 'asc' }, { id: 'asc' }], - }), - prisma.sharePriceSnapshot.findMany({ - orderBy: [{ recordedAt: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], - }), - ]); - + computeYieldFromData( + normalizedWallet: string, + transactions: Array<{ amount: string; type: string; timestamp: Date }>, + snapshots: Array<{ sharePrice: string; recordedAt: Date }>, + ): Decimal { if (transactions.length === 0) { this.logYieldCalculation(normalizedWallet, { transactionCount: 0, @@ -199,6 +237,30 @@ export class ReferralService { return nonNegativeNetYield.toDecimalPlaces(OUTPUT_DECIMAL_PLACES); } + /** + * Calculates user net yield from transaction history and persisted share price snapshots. + * Uses share-balance accounting so deposits and withdrawals across multiple periods + * are valued at the snapshot price in effect at the time of each transaction. + */ + async calculateUserYield(walletAddress: string): Promise { + const prisma = getPrisma(); + const normalizedWallet = normalizeWalletAddress(walletAddress); + const [transactions, snapshots] = await Promise.all([ + prisma.transaction.findMany({ + where: { + user: normalizedWallet, + type: { in: ['deposit', 'withdrawal'] }, + OR: [{ status: null }, { status: 'completed' }, { status: 'pending' }], + }, + orderBy: [{ timestamp: 'asc' }, { id: 'asc' }], + take: 1000, + }), + this.getSharePriceSnapshots(), + ]); + + return this.computeYieldFromData(normalizedWallet, transactions, snapshots); + } + /** * Get or create a referral code for a wallet address. * Generates a unique 8-character alphanumeric code if one doesn't exist. diff --git a/backend/src/services/metadata.ts b/backend/src/services/metadata.ts index 2c401a13b..0def14673 100644 --- a/backend/src/services/metadata.ts +++ b/backend/src/services/metadata.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { createHash } from 'crypto'; export interface IPLSPinResult { @@ -54,7 +55,9 @@ export class MetadataService { if (!record) { return null; } - const response = await fetch(this.gatewayUrl(record.cid)); + const response = await fetch(this.gatewayUrl(record.cid), { + signal: AbortSignal.timeout(10000), + }); if (!response.ok) { throw new Error( `Failed to fetch metadata for vault ${vaultId}: ${response.status}`, diff --git a/backend/src/transactionBackfill.ts b/backend/src/transactionBackfill.ts index cfc649555..9464a46d9 100644 --- a/backend/src/transactionBackfill.ts +++ b/backend/src/transactionBackfill.ts @@ -190,6 +190,7 @@ async function fetchEventsForRange( const response = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(15000), body: JSON.stringify({ jsonrpc: '2.0', id: 1, diff --git a/backend/src/webhookDelivery.ts b/backend/src/webhookDelivery.ts index c256090d0..d6c234c05 100644 --- a/backend/src/webhookDelivery.ts +++ b/backend/src/webhookDelivery.ts @@ -872,6 +872,7 @@ async function sendWebhookDeadLetterAlert( await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), body: JSON.stringify({ text: `:rotating_light: *YieldVault Webhook Dead-Lettered*\nFailed to deliver \`${eventType}\` to \`${endpointUrl}\` after ${attempts} attempts.\nLatest error: \`${error}\``, }), diff --git a/packages/api-schemas/package-lock.json b/packages/api-schemas/package-lock.json index 1e73cbbb4..77939b633 100644 --- a/packages/api-schemas/package-lock.json +++ b/packages/api-schemas/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@yieldvault/api-schemas", "version": "1.0.0", + "license": "MIT", "dependencies": { "zod": "^4.3.6" },