From 41bcf8a1356d553589651b80ec4a55bd5503a1d9 Mon Sep 17 00:00:00 2001 From: MAC Date: Fri, 26 Jun 2026 01:15:02 +0100 Subject: [PATCH] fix(api): stop fabricating markPrice/indexPrice in live WS price ticks [BUG-101] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OraclePriceBroadcaster hardcoded markPriceE6 and indexPriceE6 to the same value as the single oracle push price on every oracle_prices INSERT, because that table only carries one price per row — there's no genuine mark/index price in this event source to report. ws.ts's flushPriceUpdate forwarded these fabricated values unchanged to every live WS subscriber, so every live price tick reported markPrice === indexPrice === oracle price, directly contradicting the correct, distinct values the same channel sends from market_stats on initial subscribe. On markets where mark/index price legitimately diverges from the oracle price (basis, funding skew), this was a real-time data-integrity bug, not just staleness — a client's first message after subscribing could show markPrice != indexPrice, then the very next live tick collapses them. Stop publishing markPriceE6/indexPriceE6 from this source entirely. flushPriceUpdate already has a `markPriceE6 ? ... : undefined` check (ws.ts) specifically meant to omit these fields when not genuinely known — it was just never reachable because the broadcaster always supplied a (wrong) value. No change needed in ws.ts; live ticks now correctly omit markPrice/indexPrice rather than reporting a fabricated one. Added tests verifying the published payload never carries markPriceE6/indexPriceE6, and that non-positive/non-finite prices are still ignored. Verified the test fails against the pre-fix code (received markPriceE6: 1500000 where it should be absent) and passes against the fix. Co-authored-by: Claude Sonnet 4.6 --- src/services/OraclePriceBroadcaster.ts | 11 ++- .../services/oracle-price-broadcaster.test.ts | 84 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 tests/services/oracle-price-broadcaster.test.ts diff --git a/src/services/OraclePriceBroadcaster.ts b/src/services/OraclePriceBroadcaster.ts index 54c138b..1f895c6 100644 --- a/src/services/OraclePriceBroadcaster.ts +++ b/src/services/OraclePriceBroadcaster.ts @@ -72,10 +72,17 @@ export class OraclePriceBroadcaster { slab: row.slab_address, priceE6, }); + // oracle_prices carries only a single push price per row — there is + // no separate mark/index price in this event source. Previously this + // hardcoded markPriceE6/indexPriceE6 to the same oracle price, so + // every live WS tick reported markPrice === indexPrice === oracle + // price, contradicting the genuinely distinct values the same + // channel sends from market_stats on initial subscribe (ws.ts + // flushPriceUpdate's own `markPriceE6 ? ... : undefined` check + // already exists to omit these fields when not genuinely known — + // it was just never reachable because this was always truthy). eventBus.publish("price.updated", row.slab_address, { priceE6, - markPriceE6: priceE6, - indexPriceE6: priceE6, source: "oracle_prices", tx_signature: row.tx_signature ?? undefined, }); diff --git a/tests/services/oracle-price-broadcaster.test.ts b/tests/services/oracle-price-broadcaster.test.ts new file mode 100644 index 0000000..1046ee9 --- /dev/null +++ b/tests/services/oracle-price-broadcaster.test.ts @@ -0,0 +1,84 @@ +/** + * Regression for BUG-101: oracle_prices carries only a single push price per + * row — there is no separate mark/index price in this event source. The + * broadcaster must not fabricate markPriceE6/indexPriceE6 values equal to the + * oracle price; doing so caused every live WS price tick to report + * markPrice === indexPrice === oracle price, contradicting the genuinely + * distinct values the same channel sends on initial subscribe. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { publishSpy } = vi.hoisted(() => ({ publishSpy: vi.fn() })); + +vi.mock("@percolator/shared", () => ({ + eventBus: { publish: publishSpy }, + getSupabase: vi.fn(), + getNetwork: vi.fn(() => "devnet"), + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +import { getSupabase } from "@percolator/shared"; +import { OraclePriceBroadcaster } from "../../src/services/OraclePriceBroadcaster.js"; + +describe("OraclePriceBroadcaster", () => { + let insertHandler: (payload: { new: unknown }) => void; + let mockChannel: any; + let mockSupabase: any; + + beforeEach(() => { + publishSpy.mockClear(); + mockChannel = { + on: vi.fn((_event: string, _filter: unknown, handler: typeof insertHandler) => { + insertHandler = handler; + return mockChannel; + }), + subscribe: vi.fn((cb?: (status: string) => void) => { + cb?.("SUBSCRIBED"); + return mockChannel; + }), + }; + mockSupabase = { + channel: vi.fn(() => mockChannel), + removeChannel: vi.fn(), + }; + vi.mocked(getSupabase).mockReturnValue(mockSupabase); + }); + + it("publishes price.updated WITHOUT fabricated markPriceE6/indexPriceE6 fields", async () => { + const broadcaster = new OraclePriceBroadcaster(); + await broadcaster.start(); + + insertHandler({ + new: { + slab_address: "SLAB1", + price_e6: "1500000", + timestamp: Date.now(), + tx_signature: "sig123", + network: "devnet", + }, + }); + + expect(publishSpy).toHaveBeenCalledTimes(1); + const [event, slab, data] = publishSpy.mock.calls[0]; + expect(event).toBe("price.updated"); + expect(slab).toBe("SLAB1"); + expect(data.priceE6).toBe(1500000); + expect(data).not.toHaveProperty("markPriceE6"); + expect(data).not.toHaveProperty("indexPriceE6"); + }); + + it("ignores a row with a non-positive or non-finite price", async () => { + const broadcaster = new OraclePriceBroadcaster(); + await broadcaster.start(); + + insertHandler({ new: { slab_address: "SLAB1", price_e6: "0", timestamp: Date.now(), tx_signature: null, network: "devnet" } }); + insertHandler({ new: { slab_address: "SLAB1", price_e6: "not-a-number", timestamp: Date.now(), tx_signature: null, network: "devnet" } }); + + expect(publishSpy).not.toHaveBeenCalled(); + }); +});