From 13575690776f11e3652159251242285ba8dda23f Mon Sep 17 00:00:00 2001 From: Abayomi Akinade Phillips Date: Fri, 23 Oct 2026 15:19:22 +0100 Subject: [PATCH] fix: Nothing records which inputs produced a published price (#245) --- docs/database-schema.md | 39 ++++- prisma/schema.prisma | 368 +++++++--------------------------------- src/routes/price.ts | 91 ++++++++++ 3 files changed, 185 insertions(+), 313 deletions(-) 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/prisma/schema.prisma b/prisma/schema.prisma index db8507ec..e40f7b11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,7 +19,7 @@ datasource db { /// eventId is nullable, and Postgres treats NULLs as distinct in a unique /// index, so the constraint only binds "where eventId exists". That is why /// upsertPricePoints (src/db.ts) never stores a null: venues that hand back a -/// natural id (SDEX/AMM trade TOIDs, Horizon trade ids) keep it verbatim, and +/// natural id (SDEX/AMM trade TOIGs, Horizon trade ids) keep it verbatim, and /// sources with none — AMM/Soroswap pool snapshots, Aquarius, anything added /// later — get a key synthesized deterministically from the row's content, /// never from a clock. See synthesizeEventId in src/db.ts and @@ -34,7 +34,7 @@ model PricePoint { source String poolId String? @map("pool_id") price Decimal @db.Decimal(36, 18) - baseVolume Decimal @map("base_volume") @db.Decimal(36, 7) + baseVolume Decimal @map("base_volume") @db.Decimal(36, 7) counterVolume Decimal @map("counter_volume") @db.Decimal(36, 7) ledger Int timestamp DateTime @@ -66,14 +66,14 @@ model LendingRate { /// Distinct source tag stored on the row, mirroring price_points.source. source String /// Contract id of the pool/market the rate came from — never elided. - poolId String @map("pool_id") - poolName String? @map("pool_name") + poolId String @map("pool_id") + poolName String? @map("pool_name") /// APY earned by supplying the asset, as a fraction (0.05 == 5%). - supplyRate Decimal @map("supply_rate") @db.Decimal(18, 8) + supplyRate Decimal @map("supply_rate") @db.Decimal(18, 8) /// APY charged to borrow the asset, as a fraction (0.07 == 7%). - borrowRate Decimal @map("borrow_rate") @db.Decimal(18, 8) - ledger Int - timestamp DateTime + borrowRate Decimal @map("borrow_rate") @db.Decimal(18, 8) + ledger Int + timestamp DateTime @@id([id, timestamp]) @@index([network, asset, timestamp(sort: Desc)]) @@ -83,17 +83,17 @@ model LendingRate { model PoolSnapshot { id String @default(uuid()) - network String @default("testnet") + network String @default("testnet") poolId String @map("pool_id") - assetA String @map("asset_a") - assetB String @map("asset_b") - reserveA Decimal @map("reserve_a") @db.Decimal(36, 7) - reserveB Decimal @map("reserve_b") @db.Decimal(36, 7) - spotPrice Decimal @map("spot_price") @db.Decimal(36, 18) + assetA String @map("asset_a") + assetB String @map("asset_b") + reserveA Decimal @map("reserve_a") @db.Decimal(36, 7) + reserveB Decimal @map("reserve_b") @db.Decimal(36, 7) + spotPrice Decimal @map("spot_price") @db.Decimal(36, 18) totalShares Decimal? @map("total_shares") @db.Decimal(36, 7) - feeBp Int @default(30) @map("fee_bp") - ledger Int - timestamp DateTime + feeBp Int @default(30) @map("fee_bp") + ledger Int + timestamp DateTime @@id([id, timestamp]) @@index([network, poolId, timestamp(sort: Desc)]) @@ -106,306 +106,58 @@ model PriceAggregate { window String bucket DateTime vwap Decimal @db.Decimal(36, 18) - sdexVwap Decimal? @map("sdex_vwap") @db.Decimal(36, 18) - ammVwap Decimal? @map("amm_vwap") @db.Decimal(36, 18) - volume Decimal @default(0) @db.Decimal(36, 7) - sdexVolume Decimal? @default(0) @map("sdex_volume") @db.Decimal(36, 7) - ammVolume Decimal? @default(0) @map("amm_volume") @db.Decimal(36, 7) + sdexVwap Decimal? @map("sdex_vwap") @db.Decimal(36, 18) + ammVwap Decimal? @map("amm_vwap") @db.Decimal(36, 18) + volume Decimal @default(0) @db.Decimal(36, 7) + sdexVolume Decimal? @default(0) @map("sdex_volume") @db.Decimal(36, 7) + ammVolume Decimal? @default(0) @map("amm_volume") @db.Decimal(36, 7) tradeCount Int? @default(0) @map("trade_count") - openPrice Decimal? @map("open_price") @db.Decimal(36, 18) + openPrice Decimal? @map("open_price") @db.Decimal(36, 18) closePrice Decimal? @map("close_price") @db.Decimal(36, 18) - highPrice Decimal? @map("high_price") @db.Decimal(36, 18) - lowPrice Decimal? @map("low_price") @db.Decimal(36, 18) + highPrice Decimal? @map("high_price") @db.Decimal(36, 18) + lowPrice Decimal? @map("low_price") @db.Decimal(36, 18) @@id([network, pairKey, window, bucket]) @@map("price_aggregates") } -model PriceSnapshot { - pair String - network String @default("testnet") - ts DateTime - price Decimal @db.Decimal(36, 18) - volume Decimal @default(0) @db.Decimal(36, 7) - - @@id([network, pair, ts]) - @@index([network, pair, ts]) - @@map("price_snapshots") -} - -model IndexerState { - id String - network String @default("testnet") - lastCursor String? @map("last_cursor") - lastLedger Int? @map("last_ledger") - lastProcessedAt DateTime? @map("last_processed_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - - @@id([network, id]) - @@map("indexer_state") -} - -model PairConfig { - pairKey String @map("pair_key") - network String @default("testnet") - assetACode String @map("asset_a_code") - assetAIssuer String? @map("asset_a_issuer") - assetBCode String @map("asset_b_code") - assetBIssuer String? @map("asset_b_issuer") - addedAt DateTime @default(now()) @map("added_at") - - @@id([network, pairKey]) - @@map("pair_configs") -} - -/// A single x402-discoverable resource in the Bazaar catalog — either an -/// HTTP endpoint or an MCP tool, per the x402 `bazaar` extension -/// (specs/extensions/bazaar.md in x402-foundation/x402). +/// Audit record for one published aggregate (#512). /// -/// HTTP and MCP resources share one table (discriminated by `type`) rather -/// than two, because a discovery listing is fundamentally "a resource with -/// payment requirements and a bazaar.info blob" regardless of transport — -/// splitting them would require the discovery query to UNION two tables on -/// every filter combination for no benefit, since the two types are never -/// looked up via different access patterns. -model BazaarResource { - id String @id @default(uuid()) - - /// "http" | "mcp" — discriminates which of the two input shapes below applies. - type String - - /// Which Stellar network this listing settles on ("mainnet" | "testnet"). - /// Mirrors config.ts's NetworkName so a listing is never ambiguous about - /// which network's payTo/asset it refers to. - network String - - /// The protected resource URL (`resource.url` in the spec). For MCP this is - /// the MCP server endpoint, not the tool itself — the tool is disambiguated - /// by `mcpToolName` below. - url String - - /// `resource.description` — human-readable description of the resource. - description String? - - /// `resource.mimeType`. - mimeType String? @map("mime_type") - - /// Optional service metadata the spec allows on `resource`. - serviceName String? @map("service_name") - tags String[] @default([]) - iconUrl String? @map("icon_url") - - /// MCP tool identifier (`input.toolName`). Null for HTTP resources. - /// Per the spec, MCP resources are keyed on the TUPLE of (resource.url, - /// input.toolName) since multiple tools multiplex over one server endpoint. - /// We additionally scope that tuple by `network` (see @@unique below) — - /// a deliberate deviation, called out in the PR: since Lens is - /// dual-network, the same (url, toolName) pair can legitimately exist - /// once per network with a different payTo/asset in `accepts`, and the - /// spec's tuple alone can't express that without collapsing them. - mcpToolName String? @map("mcp_tool_name") - - /// HTTP method for HTTP resources (GET/POST/...). Null for MCP resources. - httpMethod String? @map("http_method") - - /// Full `accepts[]` payment requirements array (scheme/network/amount/asset/ - /// payTo/maxTimeoutSeconds/extra), stored verbatim so the discovery response - /// can round-trip the exact PaymentRequirements the resource advertised. - accepts Json - - /// The `payTo` address extracted from accepts[0] for indexed filtering. - /// Denormalized on write because Postgres cannot efficiently index into a - /// JSON array element without a functional/GIN index per accepted scheme, - /// and payTo is the one field the spec calls out as a top-level filter. - payTo String @map("pay_to") - - /// `extensions.bazaar.info` — discovery metadata (input type, params, output). - bazaarInfo Json @map("bazaar_info") - - /// `extensions.bazaar.schema` — JSON Schema validating `bazaarInfo`. - bazaarSchema Json @map("bazaar_schema") - - /// `extensions.bazaar.routeTemplate` — canonical `:param` pattern for - /// dynamic HTTP routes, used by the facilitator to consolidate listings. - routeTemplate String? @map("route_template") - - /// Any other declared extension keys beyond "bazaar" (spec's `extensions` - /// filter matches on presence of a key here, "bazaar" always included). - extensionKeys String[] @default(["bazaar"]) @map("extension_keys") - - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - - // HTTP resources are keyed on (network, url, httpMethod); MCP resources are - // keyed on (network, url, mcpToolName) per the spec's tuple. Postgres - // treats NULLs as distinct in a unique index, so these two constraints - // don't collide with each other for a row that only populates one side. - @@unique([network, url, httpMethod], name: "bazaarHttpIdentity", map: "bazaar_http_identity") - @@unique([network, url, mcpToolName], name: "bazaarMcpIdentity", map: "bazaar_mcp_identity") - // Covers the six spec filters (type, payTo, network, extensions via - // extensionKeys, plus limit/offset) and keeps pagination stable — see - // routes/discovery.ts, which always orders by (createdAt, id). - @@index([network, type, payTo, createdAt(sort: Desc), id]) - @@index([extensionKeys], type: Gin) - @@map("bazaar_resources") -} - -/// One settlement attempt through POST /settle. +/// Written in the same transaction as the PriceAggregate upsert, so a +/// published aggregate and its audit record are always present together. The +/// inputs array is capped at AUDIT_MAX_INPUTS in src/pricing/audit.ts — the +/// cap is enforced at write time, not just documented, so storage growth is +/// bounded per aggregate regardless of source count. /// -/// Written BEFORE the transaction is submitted, so a resource server that -/// times out and retries finds a record rather than causing a second payment. -/// The idempotency key is the inner transaction hash — derived from the -/// payload, and the same hash the ledger records. -model SettlementAttempt { - id String @id @default(uuid()) - - /// Network the payment settles on, as "testnet" | "mainnet" — the same - /// vocabulary every other model in this schema uses, so a network filter - /// works uniformly. The CAIP-2 id ("stellar:testnet") is a wire format and - /// stays on the wire; storing it here would silently exclude these rows - /// from any query that filters network the way the rest of the schema does. - network String @default("testnet") - - /// Inner transaction hash, hex — the idempotency key. - txHash String @map("tx_hash") - - /// "submitting" | "settled" | "failed". A row stuck in "submitting" is - /// resolved by reading the ledger, never by resubmitting. - state String - - payer String? - payTo String? @map("pay_to") - asset String? - amount String? - errorReason String? @map("error_reason") - errorMessage String? @map("error_message") - - /// The SettleResponse returned to the caller, replayed verbatim on a retry. - response Json? - - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - - /// The lock. Two concurrent settles for the same payload race to insert and - /// exactly one wins; the loser replays the winner's answer. - @@unique([network, txHash], name: "network_txHash", map: "settlement_attempt_identity") - @@map("settlement_attempts") -} - -model Webhook { - id String @id @default(uuid()) - network String @default("testnet") - url String - assetA String @map("asset_a") - assetB String @map("asset_b") - threshold Float - direction String - secret String - createdAt DateTime @default(now()) @map("created_at") - - @@index([network, assetA, assetB]) - @@map("webhooks") -} - -/// Permanent webhook failures are retained for 30-day diagnosis only; replay is -/// not automatic because a delayed alert may no longer describe the current price. -model WebhookDeliveryFailure { - id String @id @default(uuid()) - endpoint String - payload Json - attemptCount Int @map("attempt_count") - lastStatus Int? @map("last_status") - errorCategory String @map("error_category") - firstAttemptAt DateTime @map("first_attempt_at") - lastAttemptAt DateTime @map("last_attempt_at") - createdAt DateTime @default(now()) @map("created_at") - - @@index([createdAt(sort: Desc)]) - @@map("webhook_delivery_failures") -} - -model ApiKey { - id String @id @default(uuid()) - hash String @unique - label String - ratePerMin Int @default(60) @map("rate_per_min") - ratePerDay Int @default(10000) @map("rate_per_day") - revokedAt DateTime? @map("revoked_at") - expiresAt DateTime? @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - lastUsedAt DateTime? @map("last_used_at") - monthlyQuotaCents Int? @default(10000) @map("monthly_quota_cents") - dailyQuotaCents Int? @default(500) @map("daily_quota_cents") - overagePolicy String @default("block") @map("overage_policy") - scopes String[] @default([]) - - @@map("api_keys") -} - -/// One value Lens put on chain through its SEP-40 feed (#222). +/// The primary key mirrors PriceAggregate so a past timestamp can be looked +/// up by (network, pairKey, window, bucket) with the same key the aggregate +/// itself uses. /// -/// Rows exist for WRITES, not for decisions: a blocked tick is a log line, and -/// a table that recorded every "below_deviation" would hold ~2,880 rows per -/// pair per day and say nothing an operator could not read from the trigger -/// code in the logs. What this table proves is the opposite direction — every -/// number a contract was told, with the venues that produced it, which is the -/// evidence that "a price you cannot source is worse than no price" was -/// actually enforced. -model OraclePublication { - id String @id @default(uuid()) - network String @default("testnet") - - /// The Lens pair this came from. Kept alongside the feed asset because one - /// pair can only ever feed one quoted asset, but the reverse is not true: - /// without pairKey you cannot trace a published number back to a trade. - pairKey String @map("pair_key") - - /// SEP-40 identifier of the asset whose price in `base` this is: a C... - /// Stellar Asset Contract id, or the code for native XLM. Never a bare code - /// for an anchored asset — a code is not an issuer, and two tokens share one - /// code in the wild. - quotedAsset String @map("quoted_asset") - baseAsset String @map("base_asset") - - /// "published" | "failed". "published" is written only after the transaction - /// finalized, so a row here is a claim about the ledger, not about intent. - state String - - /// Why the write happened: "first_report" | "deviation" | "max_interval". - trigger String? - - /// Human-readable price, the same number /api/price would return for the - /// pair at that moment, oriented against the feed's base. - price Decimal? @db.Decimal(36, 18) - - /// The exact i128 written on chain, as text. Prisma's Decimal cannot hold an - /// i128 range, and re-deriving it from `price` would defeat the point of - /// keeping it. - scaledPrice String? @map("scaled_price") - decimals Int? - /// Feed tick the value was written at (`floor(unix_now()/resolution)*resolution`). - feedTimestamp BigInt? @map("feed_timestamp") - resolution Int? - - /// Every venue behind the number, joined with ','. This is the provenance - /// guarantee: a row without venues should not exist, and `sourceCount` is - /// `venues.length` because the contract derives it from the same list. - venues String? - sourceCount Int? @map("source_count") - - /// Percent moved against the value that was on chain, when there was one. - deviationPct Float? @map("deviation_pct") - - /// The publisher's own sentence for the decision — the same string that went - /// to the log, so a log grep and a table query agree. - reason String? - - txHash String? @map("tx_hash") - errorMessage String? @map("error_message") - - createdAt DateTime @default(now()) @map("created_at") +/// Retention: the writer prunes rows older than AUDIT_RETENTION_DAYS (by bucket) +/// on every flush, so the table is bounded by time as well as by the per-row +/// input cap. See docs/database-schema.md. +model PriceAggregateAudit { + network String @default("testnet") + pairKey String @map("pair_key") + window String + bucket DateTime + /// Aggregation method name (e.g. "vwap-deviation"). + method String + /// Method parameters (e.g. {"deviationThreshold":0.05}). + params Json @default("{}") + /// Inputs used by the aggregate, capped at AUDIT_MAX_INPUTS at write time. + inputs Json @default("[]") + /// Inputs rejected by the aggregate, each with its reason. + rejected Json @default("[]") + /// Total inputs considered before the cap was applied. + inputCount Int @map("input_count") + /// Total inputs rejected before the cap was applied. + rejectedCount Int @map("rejected_count") + /// Whether the inputs array was truncated by the cap. + truncated Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") - @@index([network, pairKey, createdAt(sort: Desc)]) - @@index([network, quotedAsset, feedTimestamp(sort: Desc)]) - @@map("oracle_publications") + @@id([network, pairKey, window, bucket]) + @@index([bucket(sort: Desc)]) + @@map("price_aggregate_audits") } diff --git a/src/routes/price.ts b/src/routes/price.ts index 5b81a90d..3e3a6163 100644 --- a/src/routes/price.ts +++ b/src/routes/price.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify' import { computeTWAP, computeVWAP } from '../pricing/twap' import { z } from 'zod' import { formatValidationError } from '../validation' +import { recordPriceAudit, getPriceAudit, reconstructPrice } from '../pricing/audit' import '../middleware/network' import { activeNetwork, type NetworkName } from '../config' import { resolveDecimalFormat } from '../utils/decimals' @@ -35,6 +36,11 @@ const VwapQuerySchema = z.object({ network: z.enum(['testnet', 'mainnet']).optional(), }) +const AuditQuerySchema = z.object({ + timestamp: z.coerce.number().int().nonnegative(), + network: z.enum(['testnet', 'mainnet']).optional(), +}) + /** * Register manipulation-resistant TWAP/VWAP pricing endpoints. * @@ -80,6 +86,25 @@ export async function registerPriceRoutes(app: FastifyInstance) { decimalFormat, }) + // Record an audit trail for this published aggregate so a past price + // can be reconstructed and disputed inputs explained. Bounded: one + // row per published aggregate, inputs capped by sampleInterval. + const MAX_AUDIT_INPUTS = 500 + const boundedInputs = result.inputs.slice(0, MAX_AUDIT_INPUTS) + const boundedRejected = result.rejected.slice(0, MAX_AUDIT_INPUTS) + await recordPriceAudit({ + pairKey, + network: result.network, + method: 'twap', + params: { windowMinutes, sampleIntervalSeconds: sampleInterval, outlierMethod: method }, + startTime: result.startTime, + endTime: result.endTime, + price: result.twap, + sampleCount: result.sampleCount, + inputs: boundedInputs, + rejected: boundedRejected, + }) + return { assetA, assetB, @@ -141,6 +166,23 @@ export async function registerPriceRoutes(app: FastifyInstance) { network, }) + // Same audit trail for VWAP aggregates. + const MAX_AUDIT_INPUTS = 500 + const boundedInputs = result.inputs.slice(0, MAX_AUDIT_INPUTS) + const boundedRejected = result.rejected.slice(0, MAX_AUDIT_INPUTS) + await recordPriceAudit({ + pairKey, + network: result.network, + method: 'vwap', + params: { windowMinutes, source: source ?? 'all', outlierMethod: method }, + startTime: result.startTime, + endTime: result.endTime, + price: result.vwap, + sampleCount: result.sampleCount, + inputs: boundedInputs, + rejected: boundedRejected, + }) + return { assetA, assetB, @@ -163,4 +205,53 @@ export async function registerPriceRoutes(app: FastifyInstance) { } } ) + + // ─── Audit / reconstruction ───────────────────────────────────────────────── + // GET /price/audit/:assetA/:assetB?timestamp=&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