From e37c85ee4de115a2f5517807bdd21d86cf631615 Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Tue, 20 Oct 2026 11:22:42 +0100 Subject: [PATCH] fix: standardise range queries to half-open intervals [from, to) (#236) --- .changeset/half-open-window-boundaries.md | 28 ++ openapi.json | 4 + openapi.yaml | 28 ++ src/__tests__/candles.test.ts | 12 + src/__tests__/history.test.ts | 12 + src/__tests__/windowBoundaries.test.ts | 295 ++++++++++++++++++++++ src/api/history.ts | 11 +- src/api/rest.ts | 5 +- src/bazaar/lensListings.ts | 8 +- src/pricing/aggregate.ts | 8 +- src/pricing/depth.ts | 25 +- src/routes/basket.ts | 17 +- src/routes/benchmark.ts | 15 +- src/routes/candles.ts | 34 ++- 14 files changed, 471 insertions(+), 31 deletions(-) create mode 100644 .changeset/half-open-window-boundaries.md create mode 100644 src/__tests__/windowBoundaries.test.ts diff --git a/.changeset/half-open-window-boundaries.md b/.changeset/half-open-window-boundaries.md new file mode 100644 index 00000000..9624a50c --- /dev/null +++ b/.changeset/half-open-window-boundaries.md @@ -0,0 +1,28 @@ +--- +"lens": minor +--- + +**Breaking:** range endpoints now use half-open intervals — `[from, to)`, not +`[from, to]`. `from` is inclusive, `to` is exclusive. + +`GET /candles/:assetA/:assetB` and `GET /prices/history` previously returned +samples with `timestamp` in `from..to` inclusive at both ends. A caller paging +back-to-back windows (`[T0, T1]`, then `[T1, T2]`) got every sample landing +exactly on `T1` **twice** — once in each response — so the two windows +together double-counted the boundary and inflated any total, average or VWAP +computed over them. + +`to` is now exclusive. Adjacent windows `[T0, T1)` and `[T1, T2)` share no +boundary, and a sample landing exactly on `T1` appears in exactly one of the +two responses. + +Callers that relied on the inclusive end must widen their `to` by one tick +(the smallest interval they query) to keep the same rows — e.g. `[T0, T1]` is +now `[T0, T1 + 1s)` at `interval=1m`. + +Every `price_points` query now also carries the `network` predicate, so the +`(network, pair_key, timestamp)` index is usable and a pair's price is never a +blend of two chains. `GET /candles/:assetA/:assetB` and `GET /basket` were +reading both networks; `GET /benchmark/:asset` was computing its deviation +figures from both. They now default to the active network and honour +`?network=` / `x-network`. diff --git a/openapi.json b/openapi.json index 1afa5785..1176a7e3 100644 --- a/openapi.json +++ b/openapi.json @@ -688,6 +688,7 @@ "name": "from", "in": "query", "required": false, + "description": "Start of the time window (inclusive). Together with `to` this defines a half-open interval [from, to): samples whose timestamp is exactly `from` are included, samples whose timestamp is exactly `to` are excluded. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows.", "schema": { "type": "string", "format": "date-time" @@ -697,6 +698,7 @@ "name": "to", "in": "query", "required": false, + "description": "End of the time window (exclusive). Together with `from` this defines a half-open interval [from, to): samples whose timestamp is exactly `to` are excluded, samples whose timestamp is exactly `from` are included. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows.", "schema": { "type": "string", "format": "date-time" @@ -810,6 +812,7 @@ "name": "from", "in": "query", "required": false, + "description": "Start of the time window (inclusive). Together with `to` this defines a half-open interval [from, to): samples whose timestamp is exactly `from` are included, samples whose timestamp is exactly `to` are excluded. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows.", "schema": { "type": "string", "format": "date-time" @@ -819,6 +822,7 @@ "name": "to", "in": "query", "required": false, + "description": "End of the time window (exclusive). Together with `from` this defines a half-open interval [from, to): samples whose timestamp is exactly `to` are excluded, samples whose timestamp is exactly `from` are included. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows.", "schema": { "type": "string", "format": "date-time" diff --git a/openapi.yaml b/openapi.yaml index 50c63ee1..c5edf11b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -467,12 +467,26 @@ paths: - name: from in: query required: false + description: >- + Start of the time window (inclusive). Together with `to` this defines + a half-open interval [from, to): samples whose timestamp is exactly + `from` are included, samples whose timestamp is exactly `to` are + excluded. This means two adjacent windows [T0, T1) and [T1, T2) + share no boundary — a sample landing exactly on T1 appears in + exactly one of the two windows. schema: type: string format: date-time - name: to in: query required: false + description: >- + End of the time window (exclusive). Together with `from` this + defines a half-open interval [from, to): samples whose timestamp is + exactly `to` are excluded, samples whose timestamp is exactly + `from` are included. This means two adjacent windows [T0, T1) and + [T1, T2) share no boundary — a sample landing exactly on T1 appears + in exactly one of the two windows. schema: type: string format: date-time @@ -547,12 +561,26 @@ paths: - name: from in: query required: false + description: >- + Start of the time window (inclusive). Together with `to` this defines + a half-open interval [from, to): samples whose timestamp is exactly + `from` are included, samples whose timestamp is exactly `to` are + excluded. This means two adjacent windows [T0, T1) and [T1, T2) + share no boundary — a sample landing exactly on T1 appears in + exactly one of the two windows. schema: type: string format: date-time - name: to in: query required: false + description: >- + End of the time window (exclusive). Together with `from` this + defines a half-open interval [from, to): samples whose timestamp is + exactly `to` are excluded, samples whose timestamp is exactly + `from` are included. This means two adjacent windows [T0, T1) and + [T1, T2) share no boundary — a sample landing exactly on T1 appears + in exactly one of the two windows. schema: type: string format: date-time diff --git a/src/__tests__/candles.test.ts b/src/__tests__/candles.test.ts index 343044fa..b452dc5c 100644 --- a/src/__tests__/candles.test.ts +++ b/src/__tests__/candles.test.ts @@ -135,4 +135,16 @@ describe('GET /candles/:assetA/:assetB', () => { expect(new Date(params[2]).toISOString()).toBe(from) expect(new Date(params[3]).toISOString()).toBe(to) }) + + it('uses half-open interval [from, to) in SQL query', async () => { + const app = await buildApp() + mockQuery.mockResolvedValue({ rows: [] }) + + await app.inject({ method: 'GET', url: '/candles/XLM/USDC?interval=1h&from=2025-01-01T00:00:00Z&to=2025-01-01T01:00:00Z' }) + + // The SQL query should use < (exclusive) for the upper bound, not <= + const sql = mockQuery.mock.calls[0][0] + expect(sql).toMatch(/AND timestamp < \$4/) + expect(sql).not.toMatch(/AND timestamp <= \$4/) + }) }) diff --git a/src/__tests__/history.test.ts b/src/__tests__/history.test.ts index 0896c2b5..41d0e9f7 100644 --- a/src/__tests__/history.test.ts +++ b/src/__tests__/history.test.ts @@ -169,6 +169,18 @@ describe('GET /prices/history', () => { expect(new Date(params[3]).toISOString()).toBe(to) }) + it('uses half-open interval [from, to) in SQL query', async () => { + const app = await buildApp() + mockQuery.mockResolvedValue({ rows: [] }) + + await app.inject({ method: 'GET', url: '/prices/history?pair=XLM/USDC&from=2025-01-01T00:00:00Z&to=2025-01-01T01:00:00Z' }) + + // The SQL query should use < (exclusive) for the upper bound, not <= + const sql = mockQuery.mock.calls[0][0] + expect(sql).toMatch(/AND ts < \$4/) + expect(sql).not.toMatch(/AND ts <= \$4/) + }) + it('MAX_HISTORY_POINTS guard math is interval-aware', () => { // Sanity: the cap is points, not seconds — coarser intervals allow longer spans. expect(MAX_HISTORY_POINTS).toBeGreaterThan(0) diff --git a/src/__tests__/windowBoundaries.test.ts b/src/__tests__/windowBoundaries.test.ts new file mode 100644 index 00000000..a0365e0e --- /dev/null +++ b/src/__tests__/windowBoundaries.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +/** + * Window boundaries are half-open: [from, to). + * + * `from` is inclusive, `to` is exclusive. Two adjacent windows [T0, T1) and + * [T1, T2) therefore share no boundary, and a sample landing exactly on T1 is + * counted exactly once across the pair. The old convention was inclusive at + * both ends — [from, to] — which double-counted every sample that landed on a + * shared boundary. + * + * These tests drive the real route handlers and let the mock apply the range + * predicate the way Postgres would: the operator is read out of the SQL text + * the route actually sent, not hard-coded here. A route that regresses to + * `<=` therefore fails these tests, and a route that never sends a range + * predicate at all fails them too. + */ + +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) + +vi.mock('../db', () => ({ + prisma: {}, + pgPool: { query: mockQuery }, +})) + +import { registerCandleRoutes } from '../routes/candles' +import { registerHistoryRoutes } from '../api/history' +import { registerNetworkSelector } from '../middleware/network' + +// Real Stellar contract addresses, spelled out in full. The range predicates +// are about *when* a sample was taken, but the pair they are taken for has to +// be a real one: a truncated issuer would quietly match nothing. +const MAINNET_USDC = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' +const TESTNET_USDC = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + +// Three fixed instants: T0, the shared boundary T1, and T2. The sample that +// lands exactly on T1 is the one under test. +const T0 = new Date('2026-01-01T00:00:00.000Z') +const T1 = new Date('2026-01-01T01:00:00.000Z') +const T2 = new Date('2026-01-01T02:00:00.000Z') + +type Row = Record + +/** + * A range-aware stand-in for the price tables. + * + * The mock reads the bounds out of the SQL string — `AND $N` — and + * applies them to the seeded rows, so the behaviour under test is the query + * the route builds, not a filter this file hard-codes. `network = $N` is + * honoured too, for the same reason. + */ +function useRangeTable(table: Row[]) { + mockQuery.mockImplementation(async (sql: string, params: unknown[]) => { + const bounds: Array<{ col: string; op: string; at: number }> = [] + const rangeRe = /AND\s+(\w+)\s*(>=|<=|<|>)\s*\$(\d+)/g + let match: RegExpExecArray | null + while ((match = rangeRe.exec(sql)) !== null) { + bounds.push({ col: match[1], op: match[2], at: Number(match[3]) }) + } + + const networkMatch = /AND\s+network\s*=\s*\$(\d+)/.exec(sql) + const networkAt = networkMatch ? Number(networkMatch[1]) : null + + const rows = table.filter(row => { + if (networkAt !== null && row.network !== params[networkAt - 1]) return false + return bounds.every(b => { + const value = new Date(row[b.col] as string | Date).getTime() + const at = new Date(params[b.at - 1] as string | Date).getTime() + if (b.op === '>=') return value >= at + if (b.op === '>') return value > at + if (b.op === '<=') return value <= at + return value < at + }) + }) + + return { rows } + }) +} + +async function buildCandleApp() { + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await registerCandleRoutes(app) + await app.ready() + return app +} + +async function buildHistoryApp() { + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await registerHistoryRoutes(app) + await app.ready() + return app +} + +describe('adjacent candle windows share no boundary', () => { + beforeEach(() => { + mockQuery.mockReset() + }) + + // price_points rows. `timestamp` is the source column the range predicate + // filters on; `time` is the bucket the route reports back. + const table: Row[] = [ + { + network: 'testnet', + pair_key: 'XLM/USDC', + timestamp: new Date(T0.getTime() + 60_000), + time: new Date(T0.getTime() + 60_000), + open: '1.0', high: '1.0', low: '1.0', close: '1.0', volume: '10', + }, + { + // Lands exactly on the shared boundary T1. + network: 'testnet', + pair_key: 'XLM/USDC', + timestamp: T1, + time: T1, + open: '2.0', high: '2.0', low: '2.0', close: '2.0', volume: '20', + }, + { + network: 'testnet', + pair_key: 'XLM/USDC', + timestamp: new Date(T1.getTime() + 60_000), + time: new Date(T1.getTime() + 60_000), + open: '3.0', high: '3.0', low: '3.0', close: '3.0', volume: '30', + }, + ] + + it('counts a sample landing exactly on the boundary exactly once', async () => { + useRangeTable(table) + const app = await buildCandleApp() + + const windowA = await app.inject({ + method: 'GET', + url: `/candles/XLM/USDC?interval=1h&from=${T0.toISOString()}&to=${T1.toISOString()}`, + }) + const windowB = await app.inject({ + method: 'GET', + url: `/candles/XLM/USDC?interval=1h&from=${T1.toISOString()}&to=${T2.toISOString()}`, + }) + + expect(windowA.statusCode).toBe(200) + expect(windowB.statusCode).toBe(200) + + const a = windowA.json().candles as Array<{ time: string }> + const b = windowB.json().candles as Array<{ time: string }> + + const boundaryCount = + a.filter(c => new Date(c.time).getTime() === T1.getTime()).length + + b.filter(c => new Date(c.time).getTime() === T1.getTime()).length + + expect(boundaryCount).toBe(1) + }) + + it('excludes the boundary sample from the earlier window and includes it in the later one', async () => { + useRangeTable(table) + const app = await buildCandleApp() + + const windowA = await app.inject({ + method: 'GET', + url: `/candles/XLM/USDC?interval=1h&from=${T0.toISOString()}&to=${T1.toISOString()}`, + }) + const windowB = await app.inject({ + method: 'GET', + url: `/candles/XLM/USDC?interval=1h&from=${T1.toISOString()}&to=${T2.toISOString()}`, + }) + + const a = windowA.json().candles as Array<{ time: string }> + const b = windowB.json().candles as Array<{ time: string }> + + // `to` is exclusive: the sample at T1 is not in [T0, T1). + expect(a.map(c => new Date(c.time).getTime())).not.toContain(T1.getTime()) + // `from` is inclusive: the sample at T1 is in [T1, T2). + expect(b.map(c => new Date(c.time).getTime())).toContain(T1.getTime()) + }) + + it('keeps the network predicate so the pair_key/timestamp index is usable', async () => { + useRangeTable(table) + const app = await buildCandleApp() + + await app.inject({ + method: 'GET', + url: `/candles/XLM/USDC?interval=1h&from=${T0.toISOString()}&to=${T1.toISOString()}`, + }) + + const sql = mockQuery.mock.calls[0][0] as string + expect(sql).toMatch(/FROM price_points/) + expect(sql).toMatch(/AND network = \$5/) + }) +}) + +describe('adjacent price history windows share no boundary', () => { + beforeEach(() => { + mockQuery.mockReset() + }) + + // price_snapshots rows. `ts` is the source column the range predicate + // filters on; `bucket` is what the route reports back. + const table: Row[] = [ + { + network: 'testnet', + pair: 'XLM/USDC', + ts: new Date(T0.getTime() + 60_000), + bucket: new Date(T0.getTime() + 60_000), + price: '1.0', volume: '10', + }, + { + // Lands exactly on the shared boundary T1. + network: 'testnet', + pair: 'XLM/USDC', + ts: T1, + bucket: T1, + price: '2.0', volume: '20', + }, + { + network: 'testnet', + pair: 'XLM/USDC', + ts: new Date(T1.getTime() + 60_000), + bucket: new Date(T1.getTime() + 60_000), + price: '3.0', volume: '30', + }, + ] + + it('counts a sample landing exactly on the boundary exactly once', async () => { + useRangeTable(table) + const app = await buildHistoryApp() + + const windowA = await app.inject({ + method: 'GET', + url: `/prices/history?pair=XLM/USDC&interval=1m&from=${T0.toISOString()}&to=${T1.toISOString()}`, + }) + const windowB = await app.inject({ + method: 'GET', + url: `/prices/history?pair=XLM/USDC&interval=1m&from=${T1.toISOString()}&to=${T2.toISOString()}`, + }) + + expect(windowA.statusCode).toBe(200) + expect(windowB.statusCode).toBe(200) + + const a = windowA.json().points as Array<{ ts: string }> + const b = windowB.json().points as Array<{ ts: string }> + + const boundaryCount = + a.filter(p => new Date(p.ts).getTime() === T1.getTime()).length + + b.filter(p => new Date(p.ts).getTime() === T1.getTime()).length + + expect(boundaryCount).toBe(1) + }) + + it('excludes the boundary sample from the earlier window and includes it in the later one', async () => { + useRangeTable(table) + const app = await buildHistoryApp() + + const windowA = await app.inject({ + method: 'GET', + url: `/prices/history?pair=XLM/USDC&interval=1m&from=${T0.toISOString()}&to=${T1.toISOString()}`, + }) + const windowB = await app.inject({ + method: 'GET', + url: `/prices/history?pair=XLM/USDC&interval=1m&from=${T1.toISOString()}&to=${T2.toISOString()}`, + }) + + const a = windowA.json().points as Array<{ ts: string }> + const b = windowB.json().points as Array<{ ts: string }> + + expect(a.map(p => new Date(p.ts).getTime())).not.toContain(T1.getTime()) + expect(b.map(p => new Date(p.ts).getTime())).toContain(T1.getTime()) + }) + + it('keeps the network predicate on the snapshot table', async () => { + useRangeTable(table) + const app = await buildHistoryApp() + + await app.inject({ + method: 'GET', + url: `/prices/history?pair=XLM/USDC&interval=1m&from=${T0.toISOString()}&to=${T1.toISOString()}`, + }) + + const sql = mockQuery.mock.calls[0][0] as string + expect(sql).toMatch(/FROM price_snapshots/) + expect(sql).toMatch(/AND network = \$5/) + }) +}) + +describe('issuers and contract addresses are never truncated', () => { + it('uses the full mainnet USDC issuer', () => { + expect(MAINNET_USDC).toHaveLength(56) + expect(MAINNET_USDC).toBe('GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN') + }) + + it('uses the full testnet USDC issuer', () => { + expect(TESTNET_USDC).toHaveLength(56) + expect(TESTNET_USDC).toBe('GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5') + }) +}) diff --git a/src/api/history.ts b/src/api/history.ts index 7f8bff71..70d8f61e 100644 --- a/src/api/history.ts +++ b/src/api/history.ts @@ -71,6 +71,10 @@ export function historyToCsv(points: HistoryPoint[]): string { * bucket) and the summed volume. Buckets are aligned to the Unix epoch so that * e.g. a 1h bucket always starts on the hour, independent of the query window. * With intervalSecs=60 this returns the raw snapshots unchanged. + * + * The window is half-open — [from, to). `from` is inclusive, `to` is exclusive + * — so two adjacent windows [T0, T1) and [T1, T2) share no boundary and a + * snapshot landing exactly on T1 is counted exactly once across the pair. */ export async function queryHistory( pair: string, @@ -87,7 +91,7 @@ export async function queryHistory( FROM price_snapshots WHERE pair = $2 AND network = $5 AND ts >= $3 - AND ts <= $4 + AND ts < $4 GROUP BY floor(EXTRACT(EPOCH FROM ts) / $1) ORDER BY bucket ASC`, [intervalSecs, pair, from, to, network] @@ -103,8 +107,9 @@ export async function queryHistory( /** * GET /prices/history?pair=XLM/USDC&from=…&to=…&interval=1m|5m|1h * - * Returns historical price snapshots for a pair over [from, to], optionally - * aggregated into 5m or 1h buckets. Defaults to the last 24h at 1m resolution. + * Returns historical price snapshots for a pair over the half-open window + * [from, to) — `from` inclusive, `to` exclusive — optionally aggregated into 5m + * or 1h buckets. Defaults to the last 24h at 1m resolution. * * The network is resolved by the network selector (`?network=` / `x-network`, * defaulting to this deployment's active network) and echoed in the response. diff --git a/src/api/rest.ts b/src/api/rest.ts index e6d9905e..16a0b0c6 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -273,8 +273,11 @@ export async function registerRESTRoutes(app: FastifyInstance) { if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) + // Depth reads are network-scoped on both legs (pool_snapshots and the + // price_points subquery that names this pair's pools), so a mainnet pair + // is never priced off a testnet pool's reserves. const depthResult = await getDepth(pair.pairKey, amount, network) - + return { assetA: pair.assetA.code, assetB: pair.assetB.code, diff --git a/src/bazaar/lensListings.ts b/src/bazaar/lensListings.ts index 1300f775..3bad3020 100644 --- a/src/bazaar/lensListings.ts +++ b/src/bazaar/lensListings.ts @@ -101,11 +101,11 @@ export function lensListings(options: { 'Candle bucket size. Defaults to "1h" when omitted.', { required: false, default: '1h' }, ), - from: param.string('Window start, as an ISO 8601 timestamp or a Unix epoch in seconds. Defaults to the earliest candle available.', { + from: param.string('Window start, as an ISO 8601 timestamp or a Unix epoch in seconds. Inclusive. Defaults to the earliest candle available.', { required: false, example: '2026-08-01T00:00:00Z', }), - to: param.string('Window end, as an ISO 8601 timestamp or a Unix epoch in seconds. Defaults to now.', { + to: param.string('Window end, as an ISO 8601 timestamp or a Unix epoch in seconds. Exclusive — the window is the half-open interval [from, to), so a candle exactly at `to` is not returned. Defaults to now.', { required: false, example: '2026-08-31T00:00:00Z', }), @@ -150,8 +150,8 @@ export function lensListings(options: { /** Declared once so the same objects can be handed to `assertDeclaration`. */ const candlesQueryParams: Record = { interval: param.enumOf(['1m', '5m', '15m', '1h', '4h', '1d'], 'Candle bucket size. Defaults to "1h" when omitted.', { required: false, default: '1h' }), - from: param.string('Window start, as an ISO 8601 timestamp or a Unix epoch in seconds. Defaults to the earliest candle available.', { required: false }), - to: param.string('Window end, as an ISO 8601 timestamp or a Unix epoch in seconds. Defaults to now.', { required: false }), + from: param.string('Window start, as an ISO 8601 timestamp or a Unix epoch in seconds. Inclusive. Defaults to the earliest candle available.', { required: false }), + to: param.string('Window end, as an ISO 8601 timestamp or a Unix epoch in seconds. Exclusive — the window is the half-open interval [from, to), so a candle exactly at `to` is not returned. Defaults to now.', { required: false }), } const twapQueryParams: Record = { diff --git a/src/pricing/aggregate.ts b/src/pricing/aggregate.ts index f00f6ac7..6b8ef1d7 100644 --- a/src/pricing/aggregate.ts +++ b/src/pricing/aggregate.ts @@ -1,4 +1,5 @@ import { pgPool } from '../db' +import { activeNetwork, type NetworkName } from '../config' /** * Aggregate price for a pair across sources (SDEX, AMM) weighted by volume. @@ -7,11 +8,15 @@ import { pgPool } from '../db' * * @param pairKey The pair identifier (e.g., "XLM/USDC"). * @param deviationThreshold Fractional threshold (e.g., 0.05 for 5%). + * @param network The chain to read. `price_points` indexes `network` as the + * leading column of (network, pair_key, timestamp), and the same pair key + * exists on both chains — an unfiltered read blends two separate markets. * @returns Composite price and per‑source breakdown. */ export async function aggregatePrice( pairKey: string, deviationThreshold: number = 0.05, + network: NetworkName = activeNetwork, ): Promise<{ composite: number breakdown: Array<{ @@ -30,9 +35,10 @@ export async function aggregatePrice( SUM(base_volume::numeric) AS volume FROM price_points WHERE pair_key = $1 + AND network = $2 AND timestamp > NOW() - INTERVAL '1 hour' GROUP BY source`, - [pairKey] + [pairKey, network] ) const sources = result.rows.map((row: any) => ({ diff --git a/src/pricing/depth.ts b/src/pricing/depth.ts index 4f0acb65..7bd14818 100644 --- a/src/pricing/depth.ts +++ b/src/pricing/depth.ts @@ -1,5 +1,5 @@ import { pgPool } from '../db' -import { activeNetwork, NetworkName } from '../config' +import { activeNetwork, type NetworkName } from '../config' export interface DepthLevel { price: number @@ -208,21 +208,22 @@ export function generateAMMDepthLevels( export async function getAMMDepth( pairKey: string, amount: number, - network: NetworkName = activeNetwork + network: NetworkName = activeNetwork, ): Promise { - // Always network-scoped. `network` defaults to `activeNetwork`, so there is - // no caller that can ask for an unscoped read — which is the point: these - // tables carry rows for every network the process indexes, and an unfiltered - // read mixed testnet reserves into a mainnet depth answer. + // Both halves of the lookup are network-scoped: the pool_snapshots scan and + // the price_points subquery that names which pools hold this pair. Pool ids + // are only unique within a network, so filtering just the outer scan would + // happily price a mainnet pair off a testnet pool's reserves. const result = await pgPool.query( `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp - FROM pool_snapshots ps - WHERE ps.network = $2 AND ps.pool_id IN ( + FROM pool_snapshots ps + WHERE ps.network = $2 + AND ps.pool_id IN ( SELECT DISTINCT pool_id FROM price_points WHERE pair_key = $1 AND network = $2 AND source = 'AMM' AND pool_id IS NOT NULL ) - ORDER BY ps.pool_id, ps.timestamp DESC - LIMIT 1`, + ORDER BY ps.pool_id, ps.timestamp DESC + LIMIT 1`, [pairKey, network] ) if (!result.rows[0]) return null @@ -250,10 +251,10 @@ export async function getAMMDepth( export async function getDepth( pairKey: string, amount: number, - network: NetworkName = activeNetwork + network: NetworkName = activeNetwork, ): Promise { const ammDepth = await getAMMDepth(pairKey, amount, network) - + if (ammDepth) { return ammDepth } diff --git a/src/routes/basket.ts b/src/routes/basket.ts index 8c12ad07..02c2a778 100644 --- a/src/routes/basket.ts +++ b/src/routes/basket.ts @@ -1,17 +1,21 @@ + +import { formatValidationError } from "../validation" import type { FastifyInstance } from 'fastify' import { z } from 'zod' import { pgPool } from '../db' -import { formatValidationError } from '../validation' +import { activeNetwork, type NetworkName } from '../config' +import '../middleware/network' // declares req.network on the FastifyRequest type -async function fetchAssetVWAP(asset: string): Promise { +async function fetchAssetVWAP(asset: string, network: NetworkName): Promise { const result = await pgPool.query( `SELECT COALESCE(SUM(price::numeric * base_volume::numeric), 0) / NULLIF(SUM(base_volume::numeric), 0) AS vwap FROM price_points WHERE (asset_a = $1 OR asset_b = $1) + AND network = $2 AND timestamp > NOW() - INTERVAL '5 minutes'`, - [asset.toUpperCase()] + [asset.toUpperCase(), network] ) const vwap = result.rows[0]?.vwap if (vwap === null || vwap === undefined) return null @@ -77,7 +81,12 @@ export async function registerBasketRoutes(app: FastifyInstance) { const weightSum = rawWeightValues.reduce((a, b) => a + b, 0) const normalizedWeights = rawWeightValues.map((w) => w / weightSum) - const prices = await Promise.all(assets.map((a) => fetchAssetVWAP(a))) + // `price_points` indexes `network` as the leading column of + // (network, pair_key, timestamp): without the predicate the range scan + // cannot use the index, and a basket would blend two chains' prices. + const network: NetworkName = req.network ?? activeNetwork + + const prices = await Promise.all(assets.map(a => fetchAssetVWAP(a, network))) const components = assets.map((asset, i) => ({ asset: asset.toUpperCase(), diff --git a/src/routes/benchmark.ts b/src/routes/benchmark.ts index d9132bf7..b31abf7f 100644 --- a/src/routes/benchmark.ts +++ b/src/routes/benchmark.ts @@ -1,6 +1,8 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { getActivePairs } from '../pairsRegistry' +import { activeNetwork, type NetworkName } from '../config' +import '../middleware/network' // declares req.network on the FastifyRequest type import { benchmarkResponseSchema } from '../api/schemas' import { sendInternalError } from '../errors' @@ -38,10 +40,16 @@ export async function registerBenchmarkRoutes(app: FastifyInstance) { const isAssetA = pair.assetA.code.toUpperCase() === normAsset const priceExpr = isAssetA ? 'price::numeric' : '1.0 / NULLIF(price::numeric, 0)' - // Query latest price (overall) and rolling 24h stats + // Query latest price (overall) and rolling 24h stats. + // + // `network` is not optional here: `price_points` indexes `network` as the + // leading column of (network, pair_key, timestamp), and the same pair key + // exists on both chains — an unfiltered read blends two separate markets + // into a deviation figure nobody traded at. + const network: NetworkName = req.network ?? activeNetwork const query = ` SELECT - (SELECT ${priceExpr} FROM price_points WHERE pair_key = $1 ORDER BY timestamp DESC LIMIT 1) AS latest_price, + (SELECT ${priceExpr} FROM price_points WHERE pair_key = $1 AND network = $2 ORDER BY timestamp DESC LIMIT 1) AS latest_price, MAX(${priceExpr}) AS max_price, MIN(${priceExpr}) AS min_price, AVG(${priceExpr}) AS avg_price, @@ -51,11 +59,12 @@ export async function registerBenchmarkRoutes(app: FastifyInstance) { COUNT(*) AS sample_count FROM price_points WHERE pair_key = $1 + AND network = $2 AND timestamp > NOW() - INTERVAL '24 hours' ` try { - const result = await pgPool.query(query, [pair.pairKey]) + const result = await pgPool.query(query, [pair.pairKey, network]) const row = result.rows[0] const latestPriceRaw = row?.latest_price diff --git a/src/routes/candles.ts b/src/routes/candles.ts index 03e8c145..0ef20b6f 100644 --- a/src/routes/candles.ts +++ b/src/routes/candles.ts @@ -1,7 +1,27 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' -import { INTERVAL_SECONDS } from '../candleIntervals' +import { activeNetwork, type NetworkName } from '../config' +import '../middleware/network' // declares req.network on the FastifyRequest type +const INTERVAL_SECONDS: Record = { + '1m': 60, + '5m': 300, + '15m': 900, + '1h': 3600, + '4h': 14_400, + '1d': 86_400, +} + +/** + * Register the OHLCV candle endpoint. + * + * GET /candles/:assetA/:assetB?interval=1h&from=…&to=… + * + * The query window is half-open — [from, to). `from` is inclusive, `to` is + * exclusive — so two adjacent windows [T0, T1) and [T1, T2) share no boundary + * and a sample landing exactly on T1 is counted exactly once across the pair. + * An inclusive-at-both-ends window double-counted every such sample. + */ export async function registerCandleRoutes(app: FastifyInstance) { app.get<{ Params: { assetA: string; assetB: string } @@ -19,6 +39,12 @@ export async function registerCandleRoutes(app: FastifyInstance) { const pairKey = [assetA, assetB].sort().join('/') + // `price_points` indexes `network` as the leading column of + // (network, pair_key, timestamp), so the predicate has to be there for the + // range scan to be an index scan. It is also the difference between one + // chain's candles and a blend of two chains' candles. + const network: NetworkName = req.network ?? activeNetwork + const fromRaw = req.query.from const toRaw = req.query.to const from = fromRaw ? new Date(fromRaw) : new Date(Date.now() - 24 * 60 * 60 * 1000) @@ -41,16 +67,18 @@ export async function registerCandleRoutes(app: FastifyInstance) { SUM(base_volume::float) AS volume FROM price_points WHERE pair_key = $2 + AND network = $5 AND timestamp >= $3 - AND timestamp <= $4 + AND timestamp < $4 GROUP BY floor(EXTRACT(EPOCH FROM timestamp) / $1) ORDER BY time ASC`, - [intervalSecs, pairKey, from, to] + [intervalSecs, pairKey, from, to, network] ) return { pairKey, interval, + network, from: from.toISOString(), to: to.toISOString(), candles: result.rows.map(r => ({