diff --git a/.env.example b/.env.example index a23c6be6..2dc2f955 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,8 @@ REDIS_URL=redis://localhost:6379 PRICE_CACHE_TTL=10 # How long to cache GET /snapshot responses per network in seconds (default: 15) SNAPSHOT_CACHE_TTL=15 +# Maximum age in seconds before /premium price data is considered stale and rejected (default: 60) +PREMIUM_STALENESS_SECONDS=60 # Default timeout for outbound HTTP requests in milliseconds (default: 10000) HTTP_TIMEOUT_MS=10000 diff --git a/openapi.json b/openapi.json index c9f1adbe..bdffe663 100644 --- a/openapi.json +++ b/openapi.json @@ -349,6 +349,89 @@ } } }, + "/premium/{assetA}/{assetB}": { + "get": { + "operationId": "getPremium", + "summary": "Get on-chain vs CEX price premium and spread", + "description": "Returns the on-chain price, the best CEX price, the spread in basis points, both sources, and both timestamps. Refuses to answer with a 400 when either side is older than the configured staleness bound.", + "security": [], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "XLM" + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "USDC" + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Premium and spread calculation", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/CacheControl" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PremiumResponse" + } + } + } + }, + "400": { + "description": "Stale price data or unrecognised network", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PremiumErrorResponse" + } + } + } + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error", + "code" + ], + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + } + } + } + } + } + } + } + }, "/price/{assetA}/{assetB}/route": { "get": { "operationId": "getBestRoute", @@ -4059,6 +4142,91 @@ } } } + }, + "PremiumPricePoint": { + "type": "object", + "required": [ + "price", + "source", + "timestamp" + ], + "properties": { + "price": { + "type": "number" + }, + "source": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "PremiumResponse": { + "type": "object", + "required": [ + "assetA", + "assetB", + "network", + "onChain", + "cex", + "spreadBps" + ], + "properties": { + "assetA": { + "type": "string" + }, + "assetB": { + "type": "string" + }, + "network": { + "type": "string" + }, + "onChain": { + "$ref": "#/components/schemas/PremiumPricePoint" + }, + "cex": { + "$ref": "#/components/schemas/PremiumPricePoint" + }, + "spreadBps": { + "type": "number" + } + } + }, + "PremiumErrorResponse": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + }, + "stale": { + "type": "string", + "enum": [ + "onChain", + "cex", + "both" + ] + }, + "ageSeconds": { + "type": "integer", + "nullable": true + }, + "onChainAgeSeconds": { + "type": "integer", + "nullable": true + }, + "cexAgeSeconds": { + "type": "integer", + "nullable": true + } + } } } } diff --git a/openapi.yaml b/openapi.yaml index 96a83621..55ba29ba 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -256,6 +256,60 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' + /premium/{assetA}/{assetB}: + get: + operationId: getPremium + summary: Get on-chain vs CEX price premium and spread + description: >- + Returns the on-chain price, the best CEX price, the spread in basis + points, both sources, and both timestamps. Refuses to answer with a 400 + when either side is older than the configured staleness bound. + security: [] + parameters: + - name: assetA + in: path + required: true + schema: + type: string + example: XLM + - name: assetB + in: path + required: true + schema: + type: string + example: USDC + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Premium and spread calculation + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/json: + schema: + $ref: '#/components/schemas/PremiumResponse' + '400': + description: Stale price data or unrecognised network + content: + application/json: + schema: + $ref: '#/components/schemas/PremiumErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + type: object + required: [error, code] + properties: + error: + type: string + code: + type: string + '429': + $ref: '#/components/responses/TooManyRequests' + /price/{assetA}/{assetB}/route: get: operationId: getBestRoute @@ -2596,3 +2650,53 @@ components: properties: message: type: string + + PremiumPricePoint: + type: object + required: [price, source, timestamp] + properties: + price: + type: number + source: + type: string + timestamp: + type: string + format: date-time + + PremiumResponse: + type: object + required: [assetA, assetB, network, onChain, cex, spreadBps] + properties: + assetA: + type: string + assetB: + type: string + network: + type: string + onChain: + $ref: '#/components/schemas/PremiumPricePoint' + cex: + $ref: '#/components/schemas/PremiumPricePoint' + spreadBps: + type: number + + PremiumErrorResponse: + type: object + required: [error] + properties: + error: + type: string + code: + type: string + stale: + type: string + enum: [onChain, cex, both] + ageSeconds: + type: integer + nullable: true + onChainAgeSeconds: + type: integer + nullable: true + cexAgeSeconds: + type: integer + nullable: true diff --git a/src/__tests__/premium.test.ts b/src/__tests__/premium.test.ts new file mode 100644 index 00000000..52ada88e --- /dev/null +++ b/src/__tests__/premium.test.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockFindFirst, mockFindMany } = vi.hoisted(() => ({ + mockFindFirst: vi.fn(), + mockFindMany: vi.fn(), +})) + +vi.mock('../db', () => ({ + prisma: { + pricePoint: { + findFirst: mockFindFirst, + findMany: mockFindMany, + }, + }, +})) + +import { registerPremiumRoutes } from '../routes/premium' +import { registerNetworkSelector } from '../middleware/network' + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await registerPremiumRoutes(app) + await app.ready() + return app +} + +describe('GET /premium/:assetA/:assetB', () => { + beforeEach(() => { + vi.clearAllMocks() + delete process.env.PREMIUM_STALENESS_SECONDS + }) + + it('fresh data: returns 200 with correct prices, sources, timestamps, and spreadBps', async () => { + const now = new Date() + const onChainTime = new Date(now.getTime() - 10_000) // 10s ago + const cexTime = new Date(now.getTime() - 5_000) // 5s ago + + mockFindFirst.mockResolvedValue({ + id: 'pt-1', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'SDEX', + price: 0.10, + timestamp: onChainTime, + }) + + mockFindMany.mockResolvedValue([ + { + id: 'pt-2', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'binance', + price: 0.105, + timestamp: cexTime, + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC?network=testnet', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.assetA).toBe('XLM') + expect(body.assetB).toBe('USDC') + expect(body.network).toBe('testnet') + + expect(body.onChain.price).toBe(0.10) + expect(body.onChain.source).toBe('SDEX') + expect(body.onChain.timestamp).toBe(onChainTime.toISOString()) + + expect(body.cex.price).toBe(0.105) + expect(body.cex.source).toBe('binance') + expect(body.cex.timestamp).toBe(cexTime.toISOString()) + + // ((0.105 - 0.10) / 0.10) * 10000 = 500 bps + expect(body.spreadBps).toBe(500) + }) + + it('spreadBps calculation: asserts arithmetic is correct for known prices', async () => { + const now = new Date() + + mockFindFirst.mockResolvedValue({ + id: 'pt-1', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'AMM', + price: 1.00, + timestamp: now, + }) + + mockFindMany.mockResolvedValue([ + { + id: 'pt-2', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'coinbase', + price: 1.0234, + timestamp: now, + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + // ((1.0234 - 1.00) / 1.00) * 10000 = 234 bps + expect(body.spreadBps).toBe(234) + }) + + it('negative spreadBps calculation: when CEX is lower than on-chain', async () => { + const now = new Date() + + mockFindFirst.mockResolvedValue({ + id: 'pt-1', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'SDEX', + price: 1.00, + timestamp: now, + }) + + mockFindMany.mockResolvedValue([ + { + id: 'pt-2', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'kraken', + price: 0.985, + timestamp: now, + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + // ((0.985 - 1.00) / 1.00) * 10000 = -150 bps + expect(body.spreadBps).toBe(-150) + }) + + it('picks the CEX with the highest price (best bid) across multiple CEX sources', async () => { + const now = new Date() + const freshTime = new Date(now.getTime() - 5_000) + + mockFindFirst.mockResolvedValue({ + id: 'pt-onchain', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'SDEX', + price: 0.10, + timestamp: freshTime, + }) + + // Three CEX sources: Binance at 0.104, Coinbase at 0.108, Kraken at 0.102 + mockFindMany.mockResolvedValue([ + { + id: 'pt-cb-new', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'coinbase', + price: 0.108, + timestamp: freshTime, + }, + { + id: 'pt-binance-new', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'binance', + price: 0.104, + timestamp: freshTime, + }, + { + id: 'pt-kraken-new', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'kraken', + price: 0.102, + timestamp: freshTime, + }, + // older row for binance with a higher historical price that should be ignored because it is not its most recent + { + id: 'pt-binance-old', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'binance', + price: 0.200, + timestamp: new Date(now.getTime() - 30_000), + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.cex.source).toBe('coinbase') + expect(body.cex.price).toBe(0.108) + }) + + it('on-chain side stale: on-chain price older than the bound, CEX fresh — returns 400 naming onChain as stale', async () => { + const now = new Date() + const oldTime = new Date(now.getTime() - 120_000) // 120s ago (> 60s default) + const freshTime = new Date(now.getTime() - 10_000) // 10s ago + + mockFindFirst.mockResolvedValue({ + id: 'pt-1', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'SDEX', + price: 0.10, + timestamp: oldTime, + }) + + mockFindMany.mockResolvedValue([ + { + id: 'pt-2', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'binance', + price: 0.105, + timestamp: freshTime, + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC', + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.code).toBe('STALE_DATA') + expect(body.stale).toBe('onChain') + expect(body.ageSeconds).toBeGreaterThanOrEqual(119) + expect(body.onChainAgeSeconds).toBeGreaterThanOrEqual(119) + expect(body.cexAgeSeconds).toBeLessThan(60) + }) + + it('CEX side stale: CEX price older than the bound, on-chain fresh — returns 400 naming cex as stale', async () => { + const now = new Date() + const freshTime = new Date(now.getTime() - 10_000) // 10s ago + const oldTime = new Date(now.getTime() - 150_000) // 150s ago + + mockFindFirst.mockResolvedValue({ + id: 'pt-1', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'AMM', + price: 0.10, + timestamp: freshTime, + }) + + mockFindMany.mockResolvedValue([ + { + id: 'pt-2', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'binance', + price: 0.105, + timestamp: oldTime, + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC', + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.code).toBe('STALE_DATA') + expect(body.stale).toBe('cex') + expect(body.ageSeconds).toBeGreaterThanOrEqual(149) + expect(body.cexAgeSeconds).toBeGreaterThanOrEqual(149) + expect(body.onChainAgeSeconds).toBeLessThan(60) + }) + + it('both stale: both older than the bound — returns 400 naming both', async () => { + const now = new Date() + const oldOnChainTime = new Date(now.getTime() - 90_000) // 90s ago + const oldCexTime = new Date(now.getTime() - 180_000) // 180s ago + + mockFindFirst.mockResolvedValue({ + id: 'pt-1', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'SDEX', + price: 0.10, + timestamp: oldOnChainTime, + }) + + mockFindMany.mockResolvedValue([ + { + id: 'pt-2', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'binance', + price: 0.105, + timestamp: oldCexTime, + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC', + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.code).toBe('STALE_DATA') + expect(body.stale).toBe('both') + expect(body.onChainAgeSeconds).toBeGreaterThanOrEqual(89) + expect(body.cexAgeSeconds).toBeGreaterThanOrEqual(179) + }) + + it('unknown network: returns 400', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC?network=fakenet', + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.error).toContain('Invalid network "fakenet"') + }) + + it('respects configurable PREMIUM_STALENESS_SECONDS env var', async () => { + process.env.PREMIUM_STALENESS_SECONDS = '10' // 10s staleness threshold + const now = new Date() + const time = new Date(now.getTime() - 15_000) // 15s ago (> 10s, but < 60s default) + + mockFindFirst.mockResolvedValue({ + id: 'pt-1', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'SDEX', + price: 0.10, + timestamp: time, + }) + + mockFindMany.mockResolvedValue([ + { + id: 'pt-2', + network: 'testnet', + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + source: 'binance', + price: 0.105, + timestamp: now, + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/premium/XLM/USDC', + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.stale).toBe('onChain') + }) +}) diff --git a/src/app.ts b/src/app.ts index 405e4c48..db5caea5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,6 +18,7 @@ import { registerAdminRoutes } from './api/admin' import { registerUsageRoutes } from './api/usage' import { registerFacilitatorRoutes } from './api/facilitator' import { registerPriceRoutes } from './routes/price' +import { registerPremiumRoutes } from './routes/premium' import { registerVolumeRoutes } from './routes/volumes' import { registerBookRoutes } from './routes/book' import { registerSpreadsRoutes } from './routes/spreads' @@ -171,6 +172,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise(ON_CHAIN_SOURCES) + try { + for (const adapter of venueAdapters) { + if (adapter.source) { + sources.add(adapter.source) + } + } + } catch { + // fallback to default ON_CHAIN_SOURCES + } + return Array.from(sources) +} + +function findWatchedPair(assetA: string, assetB: string, network: NetworkName) { + const parse = (a: string) => { + if (a.toLowerCase() === 'native') return { code: 'XLM', issuer: null as string | null } + const [code, issuer] = a.split(':') + return { code: (code ?? '').toUpperCase(), issuer: issuer ?? null } + } + const qA = parse(assetA) + const qB = parse(assetB) + + const matches = ( + q: { code: string; issuer: string | null }, + side: { code: string; issuer?: string | null }, + ) => { + if (q.code !== side.code.toUpperCase()) return false + if (!q.issuer || !side.issuer) return true + return q.issuer === side.issuer + } + + try { + return getNetworkConfig(network).pairs.find( + p => + (matches(qA, p.assetA) && matches(qB, p.assetB)) || + (matches(qA, p.assetB) && matches(qB, p.assetA)), + ) + } catch { + return undefined + } +} + +function getCandidatePairKeys(assetA: string, assetB: string, network: NetworkName): string[] { + const keys = new Set() + keys.add([assetA, assetB].sort().join('/')) + keys.add(`${assetA}/${assetB}`) + keys.add(`${assetB}/${assetA}`) + keys.add([assetA.toUpperCase(), assetB.toUpperCase()].sort().join('/')) + + const matched = findWatchedPair(assetA, assetB, network) + if (matched?.pairKey) { + keys.add(matched.pairKey) + } + + return Array.from(keys) +} + +export async function registerPremiumRoutes(app: FastifyInstance) { + app.get<{ + Params: { assetA: string; assetB: string } + Querystring: { network?: string } + }>('/premium/:assetA/:assetB', async (req, reply) => { + const log = req.log.child({ component: 'route:premium' }) + const { assetA, assetB } = req.params + + const requestedNetwork = req.query.network + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + return reply.status(400).send({ + error: `Invalid network "${requestedNetwork}" — expected one of: testnet, mainnet`, + code: 'INVALID_NETWORK', + }) + } + + const network: NetworkName = req.network ?? (requestedNetwork as NetworkName) ?? activeNetwork + + const candidateKeys = getCandidatePairKeys(assetA, assetB, network) + const pairKeyCondition = candidateKeys.length === 1 ? candidateKeys[0] : { in: candidateKeys } + const onChainSources = getOnChainSources() + + try { + // 1. Most recent on-chain price point + const onChain = await prisma.pricePoint.findFirst({ + where: { + network, + pairKey: pairKeyCondition, + source: { in: onChainSources }, + }, + orderBy: { timestamp: 'desc' }, + }) + + // 2. Most recent CEX price points across all CEX sources + const cexRows = await prisma.pricePoint.findMany({ + where: { + network, + pairKey: pairKeyCondition, + source: { notIn: onChainSources }, + }, + orderBy: { timestamp: 'desc' }, + }) + + // For each CEX source, pick its most recent price point (first encountered due to desc order) + const latestByCex = new Map() + for (const row of cexRows) { + if (!latestByCex.has(row.source)) { + latestByCex.set(row.source, row) + } + } + + // Pick the CEX with the highest price (best bid) + let bestCex: typeof cexRows[0] | null = null + for (const pt of latestByCex.values()) { + if (!bestCex || Number(pt.price) > Number(bestCex.price)) { + bestCex = pt + } + } + + // 3. Check staleness against configurable bound + const stalenessBoundSeconds = + config.premium?.stalenessSeconds ?? + parseInt(process.env.PREMIUM_STALENESS_SECONDS ?? '60', 10) + const now = Date.now() + + if (!onChain || !bestCex) { + const staleSide: 'onChain' | 'cex' | 'both' = + !onChain && !bestCex ? 'both' : !onChain ? 'onChain' : 'cex' + log.warn( + { assetA, assetB, network, staleSide, stalenessBoundSeconds }, + 'Missing price data rejected as stale', + ) + return reply.status(400).send({ + error: 'Price data is stale', + code: 'STALE_DATA', + stale: staleSide, + ageSeconds: null, + onChainAgeSeconds: onChain + ? Math.max(0, Math.floor((now - new Date(onChain.timestamp).getTime()) / 1000)) + : null, + cexAgeSeconds: bestCex + ? Math.max(0, Math.floor((now - new Date(bestCex.timestamp).getTime()) / 1000)) + : null, + }) + } + + const onChainAgeSeconds = Math.max( + 0, + Math.floor((now - new Date(onChain.timestamp).getTime()) / 1000), + ) + const cexAgeSeconds = Math.max( + 0, + Math.floor((now - new Date(bestCex.timestamp).getTime()) / 1000), + ) + + const onChainStale = onChainAgeSeconds > stalenessBoundSeconds + const cexStale = cexAgeSeconds > stalenessBoundSeconds + + if (onChainStale || cexStale) { + const staleSide: 'onChain' | 'cex' | 'both' = + onChainStale && cexStale ? 'both' : onChainStale ? 'onChain' : 'cex' + + const reportedAge = + staleSide === 'onChain' + ? onChainAgeSeconds + : staleSide === 'cex' + ? cexAgeSeconds + : Math.max(onChainAgeSeconds, cexAgeSeconds) + + log.warn( + { + assetA, + assetB, + network, + staleSide, + onChainAgeSeconds, + cexAgeSeconds, + stalenessBoundSeconds, + }, + 'Stale price data rejected', + ) + + return reply.status(400).send({ + error: 'Price data is stale', + code: 'STALE_DATA', + stale: staleSide, + ageSeconds: reportedAge, + onChainAgeSeconds, + cexAgeSeconds, + }) + } + + const onChainPrice = Number(onChain.price) + const cexPrice = Number(bestCex.price) + + if (onChainPrice <= 0) { + log.error({ onChainPrice, cexPrice }, 'Invalid on-chain price') + return reply.status(500).send({ + error: 'Internal server error', + code: 'INVALID_PRICE', + }) + } + + const spreadBps = Number((((cexPrice - onChainPrice) / onChainPrice) * 10000).toFixed(2)) + + return reply.status(200).send({ + assetA, + assetB, + network, + onChain: { + price: onChainPrice, + source: onChain.source, + timestamp: new Date(onChain.timestamp).toISOString(), + }, + cex: { + price: cexPrice, + source: bestCex.source, + timestamp: new Date(bestCex.timestamp).toISOString(), + }, + spreadBps, + }) + } catch (err) { + log.error({ err, assetA, assetB, network }, 'Premium computation failed') + return reply.status(500).send({ + error: 'Internal server error', + code: 'INTERNAL_ERROR', + }) + } + }) +}