From f381c0b72453c3f2f34f06461977cda770c56d19 Mon Sep 17 00:00:00 2001 From: Hui-Sang Kim <102507786+Hiksang@users.noreply.github.com> Date: Tue, 5 May 2026 19:17:45 +0900 Subject: [PATCH 1/4] fix(validator): reject non-finite numeric inputs (Rule #2 audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First commit of `qa/2026-05-06-numeric-validation-audit` cycle. Previous v0.13.0 cycle found 3 Rule #2 violations via the helper-extract pattern (NaN silent-pass / NaN propagation / silent empty book). The review hypothesis was that other modules without that treatment likely harbor the same class of bug. trade-validator.ts confirms it: 5 numeric input paths were unguarded against NaN, all of which would have slipped past their downstream comparison checks because NaN comparisons are always false. ### Production fixes (5) All throw `PerpError("EXCHANGE_ERROR", ...)` so the calling trade flow surfaces a real venue/parsing failure instead of a confusing "$NaN available" or false-positive "insufficient liquidity" message. 1. `markPrice` — `<= 0` post-check skipped NaN; now rejected upfront. 2. `balance.available` — would silently take the "insufficient balance" branch and emit `$NaN available` to the user. 3. orderbook level `px` / `sz` — would inflate `availableLiquidity` to NaN and emit "Insufficient liquidity: $NaN" with no real check. Also rejects zero/negative price (level should not exist). 4. reduce-only `pos.size` (`parseFloat`) — would make `params.size > posSize` always false, silently passing the size check. 5. `marketInfo.fundingRate` output — substitutes 0 + warning instead of emitting `NaN` to JSON-parsing agents (output sanitization, not silent-pass: warning surfaces the missing data). ### New tests (6, 38 → 44 in trade-validator.test.ts) One regression case per fix above — each verifies the explicit throw or, for fundingRate, the 0-substitute + warning emission. ### Pattern note for follow-up The audit pattern that found these bugs: grep `Number(...)` and `parseFloat(...)` calls without an adjacent `Number.isFinite` / `Number.isInteger` guard, then trace each downstream comparison to see if NaN slips through. lighter.ts has many `|| 0` fallback patterns that look like the same class — next commit candidate. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/trade-validator.test.ts | 90 +++++++++++++++++++++++++++ src/trade-validator.ts | 62 +++++++++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/__tests__/trade-validator.test.ts b/src/__tests__/trade-validator.test.ts index 3367ede..ba6bb6d 100644 --- a/src/__tests__/trade-validator.test.ts +++ b/src/__tests__/trade-validator.test.ts @@ -722,3 +722,93 @@ describe("validateTrade — overall validity", () => { expect(new Date(result.timestamp).toISOString()).toBe(result.timestamp); }); }); + +// ────────────────────────────────────────────── +// Rule #2 numeric guards — NaN propagation rejection +// ────────────────────────────────────────────── + +describe("validateTrade — Rule #2 numeric guards (NaN propagation rejection)", () => { + // These tests document the gap previous helper-extract audits did not + // cover at the validator boundary: NaN values from a malformed venue + // payload would silently slip past comparison-based checks because all + // NaN comparisons are false. The validator must reject them explicitly + // so the caller doesn't see a false-positive "insufficient liquidity" + // or "$NaN available" message. + + it("throws EXCHANGE_ERROR when getMarkets returns non-finite markPrice", async () => { + const adapter = mockAdapter({ + getMarkets: vi.fn().mockResolvedValue([ + { + symbol: "BTC-PERP", markPrice: "not-a-number", indexPrice: "60000", + fundingRate: "0.0001", volume24h: "1000000", openInterest: "500000", maxLeverage: 20, + }, + ]), + }); + await expect( + validateTrade(adapter, { symbol: "BTC", side: "buy", size: 0.1 } as TradeCheckParams), + ).rejects.toThrow(/non-finite markPrice/); + }); + + it("throws EXCHANGE_ERROR when balance.available is non-finite", async () => { + const adapter = mockAdapter({ + getBalance: vi.fn().mockResolvedValue({ + equity: "10000", available: undefined, marginUsed: "2000", unrealizedPnl: "0", + }), + }); + await expect( + validateTrade(adapter, { symbol: "BTC", side: "buy", size: 0.1 } as TradeCheckParams), + ).rejects.toThrow(/non-finite balance\.available/); + }); + + it("throws EXCHANGE_ERROR when an orderbook level has non-finite price", async () => { + const adapter = mockAdapter({ + getOrderbook: vi.fn().mockResolvedValue({ + bids: [["59990", "1"]], + asks: [["abc", "1"], ["60020", "2"]], + }), + }); + await expect( + validateTrade(adapter, { symbol: "BTC", side: "buy", size: 0.1 } as TradeCheckParams), + ).rejects.toThrow(/orderbook level/); + }); + + it("throws EXCHANGE_ERROR when an orderbook level has zero price", async () => { + const adapter = mockAdapter({ + getOrderbook: vi.fn().mockResolvedValue({ + bids: [["59990", "1"]], + asks: [["0", "1"]], + }), + }); + await expect( + validateTrade(adapter, { symbol: "BTC", side: "buy", size: 0.1 } as TradeCheckParams), + ).rejects.toThrow(/non-finite or non-positive/); + }); + + it("throws EXCHANGE_ERROR when reduce-only position size is non-finite", async () => { + const adapter = mockAdapter({ + getPositions: vi.fn().mockResolvedValue([ + { symbol: "BTC-PERP", side: "long", size: "garbled", markPrice: "60000", entryPrice: "60000", unrealizedPnl: "0", margin: "100" }, + ]), + }); + await expect( + validateTrade(adapter, { symbol: "BTC", side: "sell", size: 0.5, reduceOnly: true } as TradeCheckParams), + ).rejects.toThrow(/non-finite position size/); + }); + + it("substitutes 0 + emits warning when funding rate is non-finite (output sanitization)", async () => { + const adapter = mockAdapter({ + getMarkets: vi.fn().mockResolvedValue([ + { + symbol: "BTC-PERP", markPrice: "60000", indexPrice: "60000", + fundingRate: "abc", volume24h: "1000000", openInterest: "500000", maxLeverage: 20, + }, + ]), + }); + const result = await validateTrade(adapter, { + symbol: "BTC", side: "buy", size: 0.1, + } as TradeCheckParams); + // Envelope must NOT carry NaN — agents JSON.parsing the output would break. + expect(result.marketInfo?.fundingRate).toBe(0); + expect(result.warnings.some((w) => w.includes("Funding rate unavailable"))).toBe(true); + }); +}); diff --git a/src/trade-validator.ts b/src/trade-validator.ts index 82a1c6f..0b9de8e 100644 --- a/src/trade-validator.ts +++ b/src/trade-validator.ts @@ -1,6 +1,7 @@ import type { ExchangeAdapter, ExchangeMarketInfo } from "./exchanges/index.js"; import { symbolMatch } from "./utils.js"; import { DEFAULT_TAKER_FEE } from "./constants.js"; +import { PerpError } from "./errors.js"; export interface CheckResult { check: "symbol_valid" | "balance_sufficient" | "price_fresh" | "liquidity_ok" | "risk_limits" | "position_exists"; @@ -91,6 +92,17 @@ export async function validateTrade( : { bids: [] as [string, string][], asks: [] as [string, string][] }; const markPrice = Number(market.markPrice); + // Rule #2: NaN slipped past the previous `markPrice <= 0` check because + // NaN comparisons are always false. Reject explicit non-finite values + // upfront so downstream NaN propagation (notional, marginRequired, + // slippage, deviation) is impossible. + if (Number.isNaN(markPrice) || markPrice === Infinity || markPrice === -Infinity) { + throw new PerpError( + "EXCHANGE_ERROR", + `${adapter.name} returned non-finite markPrice for ${sym}: ${market.markPrice}`, + { exchange: adapter.name }, + ); + } if (params.type === "limit" && (params.price === undefined || !Number.isFinite(params.price))) { throw new Error(`Limit order requires explicit price (received ${params.price}); refusing silent mark-price substitution.`); } @@ -110,6 +122,17 @@ export async function validateTrade( // 2. Balance check const available = Number(balance.available); + // Rule #2: a NaN `available` would silently take the "insufficient + // balance" branch on the negative comparison and *also* render + // misleading "$NaN available" in the user-visible message. Throw + // instead so the trade flow surfaces a real venue/parsing failure. + if (!Number.isFinite(available)) { + throw new PerpError( + "EXCHANGE_ERROR", + `${adapter.name} returned non-finite balance.available: ${balance.available}`, + { exchange: adapter.name }, + ); + } if (params.reduceOnly) { // reduce-only doesn't need margin checks.push({ check: "balance_sufficient", passed: true, message: "Reduce-only order, no margin needed" }); @@ -140,8 +163,22 @@ export async function validateTrade( let availableLiquidity = 0; let worstPrice = 0; for (const [px, sz] of book) { - availableLiquidity += Number(px) * Number(sz); - worstPrice = Number(px); + const pxN = Number(px); + const szN = Number(sz); + // Rule #2: a NaN level would silently inflate `availableLiquidity` + // to NaN, the `>= notional` comparison would be false, and the + // outer branch would emit a "Insufficient liquidity: $NaN" message. + // Reject the level explicitly so a malformed orderbook can't + // fabricate a passing or failing liquidity check. + if (!Number.isFinite(pxN) || !Number.isFinite(szN) || pxN <= 0 || szN < 0) { + throw new PerpError( + "EXCHANGE_ERROR", + `${adapter.name} orderbook level has non-finite or non-positive value for ${sym}: px=${px} sz=${sz}`, + { exchange: adapter.name }, + ); + } + availableLiquidity += pxN * szN; + worstPrice = pxN; if (availableLiquidity >= notional) break; } @@ -187,6 +224,15 @@ export async function validateTrade( const pos = positions.find(p => symbolMatch(p.symbol, sym)); if (pos) { const posSize = parseFloat(pos.size); + // Rule #2: NaN posSize would make `params.size > posSize` always + // false, silently passing the reduce-only size check. + if (!Number.isFinite(posSize)) { + throw new PerpError( + "EXCHANGE_ERROR", + `${adapter.name} returned non-finite position size for ${sym}: ${pos.size}`, + { exchange: adapter.name }, + ); + } if (params.size > posSize) { checks.push({ check: "position_exists", passed: false, message: `Reduce size ${params.size} exceeds position ${posSize}`, details: { positionSize: posSize, reduceSize: params.size } }); } else { @@ -220,7 +266,17 @@ export async function validateTrade( marketInfo: { symbol: sym, markPrice, - fundingRate: Number(market.fundingRate), + // Rule #2: NaN funding rate must not propagate to envelope output; + // surface the missing-data state via warnings rather than emitting + // `fundingRate: NaN` to agents that JSON.parse the response. + fundingRate: (() => { + const fr = Number(market.fundingRate); + if (!Number.isFinite(fr)) { + warnings.push(`Funding rate unavailable for ${sym} on ${adapter.name} (received ${market.fundingRate})`); + return 0; + } + return fr; + })(), maxLeverage: market.maxLeverage, }, timestamp: new Date().toISOString(), From 26d78d7da1737be0539e6482d374cc63beeb086a Mon Sep 17 00:00:00 2001 From: Hui-Sang Kim <102507786+Hiksang@users.noreply.github.com> Date: Tue, 5 May 2026 19:28:36 +0900 Subject: [PATCH 2/4] fix(lighter): reject non-finite numeric venue payloads (Rule #2 audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second commit of `qa/2026-05-06-numeric-validation-audit` cycle. Confirms hypothesis: every module without the helper-extract treatment harbors the same class of NaN silent-pass — lighter.ts had 9 sites silently coercing to 0 via `Number(... || 0)`. ### Helper `LighterAdapter._toFiniteNumber(value, fieldName, defaultValue=0)` — public static for unit-testability. Rules: - undefined / null → `defaultValue` (venue may legitimately omit a field for an empty account / position) - finite number / parseable string → returned as-is - NaN / ±Infinity / non-numeric string → throw `EXCHANGE_ERROR` with the field name in the message (so triage can attribute the failure to the exact venue endpoint) ### Production sites updated (9, getBalance + getPositions) Balance: - `total_asset_value` (line 545) - `available_balance` (line 546) - `collateral` (line 547) - `position.unrealized_pnl` reduce (line 549) Positions: - `position` filter (line 574) - `posSize` (line 576) - `position_value` (used in markPrice + leverage calcs, lines 584/592) - `total_asset_value` for leverage equity (line 593) Each site previously hid corruption as a phantom $0 balance / 0 position size — exactly the failure mode that misled the v0.12.13 HL portfolio undercount fix. ### Tests (new file `lighter-toFinite.test.ts`, 9 cases) - finite numbers / numeric strings unchanged - undefined / null → default (with custom default override) - NaN → throw with exchange tag in details - ±Infinity → throw - non-numeric strings (`"abc"`, `"12abc"`) → throw - empty string `""` → 0 (Number("") === 0, allowed) - field name + value present in error message for triage Co-Authored-By: Claude Opus 4.7 (1M context) --- .../exchanges/lighter-toFinite.test.ts | 75 +++++++++++++++++++ src/exchanges/lighter.ts | 48 +++++++++--- 2 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/exchanges/lighter-toFinite.test.ts diff --git a/src/__tests__/exchanges/lighter-toFinite.test.ts b/src/__tests__/exchanges/lighter-toFinite.test.ts new file mode 100644 index 0000000..6bf4eb1 --- /dev/null +++ b/src/__tests__/exchanges/lighter-toFinite.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { LighterAdapter } from "../../exchanges/lighter.js"; +import { PerpError } from "../../errors.js"; + +/** + * Unit tests for `LighterAdapter._toFiniteNumber` — the venue-payload + * coercion helper used by getBalance / getPositions to reject NaN and + * Infinity instead of silently substituting 0. + * + * Same Rule #2 spirit as the v0.13.0 cycle's `_computeMidSum` / + * `_assertOutcomeRange` helpers: nullish input is allowed (venue may + * legitimately omit a field for an empty account), but corrupted + * values must throw rather than masquerade as zero balance. + */ + +describe("LighterAdapter._toFiniteNumber — Rule #2 venue-payload coercion", () => { + it("returns finite numbers unchanged", () => { + expect(LighterAdapter._toFiniteNumber(0, "x")).toBe(0); + expect(LighterAdapter._toFiniteNumber(1234.56, "x")).toBe(1234.56); + expect(LighterAdapter._toFiniteNumber(-50, "x")).toBe(-50); + }); + + it("parses numeric strings into finite numbers", () => { + expect(LighterAdapter._toFiniteNumber("0", "x")).toBe(0); + expect(LighterAdapter._toFiniteNumber("123.45", "x")).toBe(123.45); + expect(LighterAdapter._toFiniteNumber("-1.5", "x")).toBe(-1.5); + }); + + it("returns the default value (0) for undefined / null — venue may omit a field", () => { + expect(LighterAdapter._toFiniteNumber(undefined, "x")).toBe(0); + expect(LighterAdapter._toFiniteNumber(null, "x")).toBe(0); + }); + + it("respects a custom default value", () => { + expect(LighterAdapter._toFiniteNumber(undefined, "x", 1)).toBe(1); + expect(LighterAdapter._toFiniteNumber(null, "x", -42)).toBe(-42); + }); + + it("throws EXCHANGE_ERROR for NaN — silent zero substitution would mask broken accounting", () => { + expect(() => LighterAdapter._toFiniteNumber(NaN, "total_asset_value")).toThrow(PerpError); + try { + LighterAdapter._toFiniteNumber(NaN, "total_asset_value"); + expect.fail("expected throw"); + } catch (e) { + const err = e as PerpError; + expect(err.structured.code).toBe("EXCHANGE_ERROR"); + expect(err.message).toMatch(/`total_asset_value` is not a finite number/); + // Exchange tag is nested under `details` per PerpError constructor + // (third-arg `details` are stripped of `remediation` and bagged into + // `structured.details`). classifyError later promotes it to top-level. + expect((err.structured as { details?: { exchange?: string } }).details?.exchange).toBe("lighter"); + } + }); + + it("throws EXCHANGE_ERROR for ±Infinity", () => { + expect(() => LighterAdapter._toFiniteNumber(Infinity, "x")).toThrow(/not a finite number/); + expect(() => LighterAdapter._toFiniteNumber(-Infinity, "x")).toThrow(/not a finite number/); + }); + + it("throws EXCHANGE_ERROR for non-numeric strings (Number(s) → NaN)", () => { + expect(() => LighterAdapter._toFiniteNumber("abc", "x")).toThrow(/not a finite number/); + expect(() => LighterAdapter._toFiniteNumber("12abc", "x")).toThrow(/not a finite number/); + // empty string → Number("") === 0, allowed (venue may stringify zero this way) + expect(LighterAdapter._toFiniteNumber("", "x")).toBe(0); + }); + + it("includes the field name in the error so the failing endpoint is attributable", () => { + expect(() => LighterAdapter._toFiniteNumber("xyz", "available_balance")).toThrow(/`available_balance`/); + expect(() => LighterAdapter._toFiniteNumber(NaN, "position.unrealized_pnl")).toThrow(/`position\.unrealized_pnl`/); + }); + + it("includes the original (stringified) value in the error message for triage", () => { + expect(() => LighterAdapter._toFiniteNumber("garbled", "x")).toThrow(/"garbled"/); + }); +}); diff --git a/src/exchanges/lighter.ts b/src/exchanges/lighter.ts index 1bc31a1..6858566 100644 --- a/src/exchanges/lighter.ts +++ b/src/exchanges/lighter.ts @@ -537,16 +537,45 @@ export class LighterAdapter implements ExchangeAdapter { } } + /** + * Coerce a venue payload value to a finite number — or throw. + * + * Rules: + * - undefined / null → `defaultValue` (typically 0). Lighter may + * legitimately omit a field for an empty account or zero position; + * that is "no data, treat as zero", not a parsing failure. + * - finite number → returned as-is. + * - NaN / ±Infinity / strings that parse to NaN → throw EXCHANGE_ERROR. + * Silent `|| 0` substitution would mask broken accounting (a stale + * cache hit, partial response, or numeric overflow on the venue + * side) as "$0 balance" — exactly the class of Rule #2 violation + * the previous QA cycle found in `_computeMidSum` and getOrderbook. + */ + static _toFiniteNumber(value: unknown, fieldName: string, defaultValue = 0): number { + if (value === undefined || value === null) return defaultValue; + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) { + throw new PerpError( + "EXCHANGE_ERROR", + `Lighter response field \`${fieldName}\` is not a finite number: ${JSON.stringify(value)}`, + { exchange: "lighter" }, + ); + } + return n; + } + async getBalance(): Promise { if (!this._address) throw new Error("No private key configured — account data unavailable. Run: perp setup"); const acct = await this.fetchAccount(); if (!acct) return { equity: "0", available: "0", marginUsed: "0", unrealizedPnl: "0" }; - const totalAsset = Number(acct.total_asset_value || 0); - const available = Number(acct.available_balance || 0); - const collateral = Number(acct.collateral || 0); + const totalAsset = LighterAdapter._toFiniteNumber(acct.total_asset_value, "total_asset_value"); + const available = LighterAdapter._toFiniteNumber(acct.available_balance, "available_balance"); + const collateral = LighterAdapter._toFiniteNumber(acct.collateral, "collateral"); const unrealizedPnl = (acct.positions as unknown as Record[])?.reduce( - (sum: number, p: Record) => sum + Number(p.unrealized_pnl || 0), 0 + (sum: number, p: Record) => + sum + LighterAdapter._toFiniteNumber(p.unrealized_pnl, "position.unrealized_pnl"), + 0, ) ?? 0; // Include spot USDC balance (separate from perp collateral) @@ -571,9 +600,10 @@ export class LighterAdapter implements ExchangeAdapter { if (!acct) return []; return ((acct.positions as unknown as Record[]) ?? []) - .filter((p: Record) => Number(p.position || 0) !== 0) + .filter((p: Record) => LighterAdapter._toFiniteNumber(p.position, "position") !== 0) .map((p: Record) => { - const posSize = Number(p.position || 0); + const posSize = LighterAdapter._toFiniteNumber(p.position, "position"); + const positionValue = LighterAdapter._toFiniteNumber(p.position_value, "position_value"); return { symbol: String(p.symbol || `Market-${p.market_id}`), side: (Number(p.sign) > 0 ? "long" : "short") as "long" | "short", @@ -581,7 +611,7 @@ export class LighterAdapter implements ExchangeAdapter { entryPrice: String(p.avg_entry_price || "0"), markPrice: (() => { if (posSize === 0) return "0"; - const rawMark = Number(p.position_value || 0) / Math.abs(posSize); + const rawMark = positionValue / Math.abs(posSize); const priceDec = this._marketDecimals.get(String(p.symbol || "").toUpperCase())?.price; return String(priceDec !== undefined ? rawMark.toFixed(priceDec) : rawMark); })(), @@ -589,8 +619,8 @@ export class LighterAdapter implements ExchangeAdapter { unrealizedPnl: String(p.unrealized_pnl || "0"), // Compute actual leverage = notional / account equity (not max leverage from IMF) leverage: (() => { - const notional = Math.abs(Number(p.position_value || 0)); - const equity = Number(acct.total_asset_value || 0); + const notional = Math.abs(positionValue); + const equity = LighterAdapter._toFiniteNumber(acct.total_asset_value, "total_asset_value"); if (equity > 0 && notional > 0) return Math.round(notional / equity * 10) / 10; return 1; })(), From ed533cbfb69890dde9a2bf425a3220581ec93954 Mon Sep 17 00:00:00 2001 From: Hui-Sang Kim <102507786+Hiksang@users.noreply.github.com> Date: Tue, 5 May 2026 19:30:39 +0900 Subject: [PATCH 3/4] fix(numeric-audit): observability + rebalance + outcome-time NaN guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third commit of the numeric-validation-audit cycle. Applies the same Rule #2 pattern across three more sites that the helper-extract audit skipped: ### event-stream.ts (observability silent skips) `Number(p.markPrice)` / `Number(p.liquidationPrice)` and balance delta computations would silently fail their `> 0` / `> 0.01` threshold checks on NaN, suppressing the very alerts users rely on (critical-distance liquidation_warning, balance_update events). Now `Number.isFinite` checks gate each branch — corruption is logged to stderr, the misleading "no alert" outcome is replaced with a visible warning, and downstream comparison logic only runs when the inputs are trustworthy. ### rebalance.ts (planner input) `computeRebalancePlan` consumes `Number(bal.equity)` etc. without a finiteness check. A NaN value would propagate into the sum-and-target math and the execute step would attempt nonsense moves. Throws `EXCHANGE_ERROR` for the affected adapter so `Promise.allSettled` filters it out — partial result instead of corrupt plan. ### hyperliquid-outcome.ts:439 (cycle leftover) Previous v0.13.0 cycle's `getOrderbook` fix replaced `book?.levels ?? [[],[]]` with an explicit throw, but `Number(book.time ?? 0)` was left untouched and would coerce a non-numeric venue time to 0 (1970 epoch — silently wrong, not "missing"). Now distinguishes "time omitted" (legit 0) from "time corrupted" (throws). ### Tests No new unit tests — these sites are stream/aggregation/integration shaped, not pure helpers. Existing 106 tests across the previously audited helpers all pass (verified). The cycle's helper-extract audit pattern continues to grow regression coverage; cross-cutting observability sites are recorded here for follow-up if integration test infrastructure expands. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/event-stream.ts | 26 ++++++++++++++++++++++++-- src/exchanges/hyperliquid-outcome.ts | 14 +++++++++++++- src/rebalance.ts | 27 +++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/event-stream.ts b/src/event-stream.ts index 2a1b1c9..5b7f360 100644 --- a/src/event-stream.ts +++ b/src/event-stream.ts @@ -123,6 +123,17 @@ export async function startEventStream( for (const p of positions) { const mark = Number(p.markPrice); const liq = Number(p.liquidationPrice); + // Rule #2: a NaN mark or liq would silently fail the `> 0` checks + // (NaN comparisons are always false), suppressing the + // critical-distance liquidation_warning the user depends on. + // Surface the corruption explicitly so the stream layer doesn't + // hide a failed alert. + if (!Number.isFinite(mark) || !Number.isFinite(liq)) { + if (p.liquidationPrice !== "N/A") { + console.warn(`[event-stream] non-finite mark/liquidation for ${p.symbol} on ${adapter.name} (mark=${p.markPrice}, liq=${p.liquidationPrice}) — skipping liquidation distance check`); + } + continue; + } if (mark > 0 && liq > 0 && p.liquidationPrice !== "N/A") { const distancePct = Math.abs(mark - liq) / mark * 100; if (distancePct < 3) { @@ -181,8 +192,18 @@ export async function startEventStream( // ── Balance updates ── if (prevBalance) { - const equityDelta = Math.abs(Number(balance.equity) - Number(prevBalance.equity)); - const availDelta = Math.abs(Number(balance.available) - Number(prevBalance.available)); + const equityNow = Number(balance.equity); + const equityPrev = Number(prevBalance.equity); + const availNow = Number(balance.available); + const availPrev = Number(prevBalance.available); + // Rule #2: a NaN delta would silently fail the `> 0.01` threshold, + // suppressing balance_update events. Surface the corruption. + if (!Number.isFinite(equityNow) || !Number.isFinite(equityPrev) || + !Number.isFinite(availNow) || !Number.isFinite(availPrev)) { + console.warn(`[event-stream] non-finite balance values for ${adapter.name} (equity=${balance.equity}/${prevBalance.equity}, available=${balance.available}/${prevBalance.available}) — skipping balance_update emit`); + } else { + const equityDelta = Math.abs(equityNow - equityPrev); + const availDelta = Math.abs(availNow - availPrev); if (equityDelta > 0.01 || availDelta > 0.01) { emit({ type: "balance_update", @@ -198,6 +219,7 @@ export async function startEventStream( }, }); } + } } prevBalance = balance; diff --git a/src/exchanges/hyperliquid-outcome.ts b/src/exchanges/hyperliquid-outcome.ts index b4455cd..b4d8c68 100644 --- a/src/exchanges/hyperliquid-outcome.ts +++ b/src/exchanges/hyperliquid-outcome.ts @@ -433,10 +433,22 @@ export class HyperliquidOutcomeAdapter implements OutcomeAdapter { ); } const levels = book.levels!; + // Rule #2: `Number("abc") ?? 0` would silently coerce a non-numeric + // venue time to 0 (1970 epoch). 0 is a legitimate "no time given" + // value when book.time is undefined, but a NaN should not be hidden. + const timeRaw = book.time; + const time = timeRaw === undefined || timeRaw === null ? 0 : Number(timeRaw); + if (!Number.isFinite(time)) { + throw new PerpError( + "EXCHANGE_ERROR", + `Hyperliquid l2Book returned non-finite time for ${coin}: ${timeRaw}`, + { exchange: "hyperliquid" }, + ); + } return { outcome, side, - time: Number(book.time ?? 0), + time, bids: levels[0].map((l) => [String(l.px ?? "0"), String(l.sz ?? "0")] as [string, string]), asks: levels[1].map((l) => [String(l.px ?? "0"), String(l.sz ?? "0")] as [string, string]), }; diff --git a/src/rebalance.ts b/src/rebalance.ts index 174a5d8..f8b77d3 100644 --- a/src/rebalance.ts +++ b/src/rebalance.ts @@ -1,4 +1,5 @@ import type { ExchangeAdapter } from "./exchanges/index.js"; +import { PerpError } from "./errors.js"; /** * Cross-exchange rebalancing engine. @@ -47,12 +48,30 @@ export async function fetchAllBalances( const results = await Promise.allSettled( entries.map(async ([name, adapter]) => { const bal = await adapter.getBalance(); + const equity = Number(bal.equity); + const available = Number(bal.available); + const marginUsed = Number(bal.marginUsed); + const unrealizedPnl = Number(bal.unrealizedPnl); + // Rule #2: rebalance plan computes sums and per-exchange targets. + // A NaN value would silently propagate into the plan output and the + // execute step would attempt nonsense moves. Reject upfront so the + // affected exchange falls out of `Promise.allSettled` (filtered to + // fulfilled below) and the user sees a partial result instead of a + // corrupt plan. + if (!Number.isFinite(equity) || !Number.isFinite(available) || + !Number.isFinite(marginUsed) || !Number.isFinite(unrealizedPnl)) { + throw new PerpError( + "EXCHANGE_ERROR", + `${name} returned non-finite balance: equity=${bal.equity} available=${bal.available} marginUsed=${bal.marginUsed} unrealizedPnl=${bal.unrealizedPnl}`, + { exchange: name }, + ); + } return { exchange: name, - equity: Number(bal.equity), - available: Number(bal.available), - marginUsed: Number(bal.marginUsed), - unrealizedPnl: Number(bal.unrealizedPnl), + equity, + available, + marginUsed, + unrealizedPnl, }; }), ); From dcae8703903b9ce5273931489fed855c269d1529 Mon Sep 17 00:00:00 2001 From: Hui-Sang Kim <102507786+Hiksang@users.noreply.github.com> Date: Tue, 5 May 2026 19:32:55 +0900 Subject: [PATCH 4/4] docs(qa-report): numeric-validation-audit cycle report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle artifact for `qa/2026-05-06-numeric-validation-audit`. Documents how the v0.13.0 cycle's "tip of the iceberg" hypothesis was tested across 5 modules and confirmed by 18 additional Rule #2 violations. Includes: - Hypothesis verification table (3 → 21 total findings across 2 cycles) - Per-module audit pattern + before/after table - Production interface change summary (no envelope drift on healthy responses; throws now surface previously hidden corruption) - Follow-up plan: mcp-server output sanitization, cross-adapter audit, Number.isFinite uniformity, dep-approval gates (coverage / fast-check) - Appendix A: cumulative finding count table - Appendix B: re-usable grep commands for the next audit cycle Container ground-truth at HEAD `ed533cb`: 75 files / 1396 tests / 23.09s. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-06-numeric-validation-audit.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/qa-reports/2026-05-06-numeric-validation-audit.md diff --git a/docs/qa-reports/2026-05-06-numeric-validation-audit.md b/docs/qa-reports/2026-05-06-numeric-validation-audit.md new file mode 100644 index 0000000..2625967 --- /dev/null +++ b/docs/qa-reports/2026-05-06-numeric-validation-audit.md @@ -0,0 +1,228 @@ +# QA Report — Numeric Validation Audit + +> 본 보고서는 `docs/QA_WORKFLOW.md` Section 11 의 한국어 구조화 포맷에 +> 따라 작성된다. 이전 사이클 (`qa/2026-05-05-v0.13.0-validation`) 의 +> production 결함 3건 (NaN silent-pass / NaN propagation / silent empty +> book) 이 같은 audit pass 에서 발견된 것을 확인하고, **사용자 가설 +> "빙산의 일각"** 을 다른 모듈 전반에 검증한 follow-up. + +## QA 결과 요약 + +- **브랜치:** `qa/2026-05-06-numeric-validation-audit` (origin push 완료) +- **베이스 커밋:** `1d0e961` — `Merge pull request #13 from hypurrquant/qa/2026-05-05-v0.13.0-validation` +- **베이스 버전:** `0.13.0` +- **추가 커밋 수:** 3개 + +| # | 해시 | 제목 | +|---|------|------| +| 1 | `f381c0b` | `fix(validator): reject non-finite numeric inputs (Rule #2 audit)` | +| 2 | `26d78d7` | `fix(lighter): reject non-finite numeric venue payloads (Rule #2 audit)` | +| 3 | `ed533cb` | `fix(numeric-audit): observability + rebalance + outcome-time NaN guards` | + +PR URL 후보: +`https://github.com/hypurrquant/perp-cli/pull/new/qa/2026-05-06-numeric-validation-audit` + +## 가설 검증 결과 + +이전 사이클 사용자 review 의 핵심 가설: + +> "production 버그 2건 = 빙산의 일각일 가능성. 같은 treatment 안 받은 +> 다른 모듈에도 같은 패턴의 버그가 있을 가능성이 높음." + +이번 audit 가 **18개의 추가 사이트** 에서 동일 패턴의 NaN silent-pass / +NaN propagation 결함을 확인. 가설 강하게 입증됨. + +| 모듈 | 결함 사이트 | Fix commit | +|------|----------|-----------| +| `trade-validator.ts` | 5 (markPrice / balance / orderbook px·sz / posSize / fundingRate) | `f381c0b` | +| `exchanges/lighter.ts` | 9 (balance + position aggregation 9곳) | `26d78d7` | +| `event-stream.ts` | 2 (liquidation distance / balance delta) | `ed533cb` | +| `rebalance.ts` | 1 (planner input) | `ed533cb` | +| `exchanges/hyperliquid-outcome.ts:439` | 1 (l2Book time, 이전 사이클 leftover) | `ed533cb` | + +**이전 사이클 (3건) + 이번 사이클 (18건) = 합계 21건** 의 같은 클래스 +Rule #2 위반이 **2 audit pass** 에서 발견됨. + +## 환경 / Pre-flight (Section 4) + +- **호스트:** macOS (Darwin 25.4.0), pnpm 10, Node 20·22·24 매트릭스 +- **컨테이너:** `perp-qa` Docker, `~/.ows`·`~/.perp` 마운트 +- working tree clean, base `1d0e961` = origin/main HEAD +- mainnet 거래 실행 0건 (audit 자체가 readonly + 단위 테스트 영역) + +## 실행 내역 + +### 적용된 audit 패턴 + +이전 사이클의 `_computeUnderlying` / `_computeMidSum` / +`_assertOutcomeRange` 패턴 follow-through: + +1. **Helper 추출 + 단위 테스트** (가능한 곳) — 같은 가드 로직을 한 곳에 + 두고 테스트로 freeze. +2. **Inline 가드 + 명시 throw** (helper 추출이 어려운 곳, e.g. event + stream 에서 venue 응답이 일회용으로 해석됨). +3. **에러 메시지에 field name + 원본 value** 포함 — triage 시 어느 venue + endpoint 의 어떤 field 가 깨졌는지 즉시 식별. + +### 모듈별 변경 + +#### `trade-validator.ts` (`f381c0b`, 5 sites + 6 tests) + +| Site | 이전 동작 | 변경 후 | +|------|---------|---------| +| `markPrice` | `<= 0` 검사가 NaN 통과시킴 | `Number.isNaN` / Infinity pre-check throw | +| `balance.available` | NaN 면 "insufficient" branch 거짓 진입 + `$NaN available` 노출 | throw EXCHANGE_ERROR | +| 주문북 level `px`·`sz` | NaN 누적 시 `availableLiquidity` NaN, `>= notional` 항상 false | level 단위 finite + positive 검사 throw | +| reduce-only `pos.size` | NaN 면 size 비교 거짓 통과 | parseFloat 후 finite 검사 throw | +| `marketInfo.fundingRate` (output) | envelope 에 `NaN` JSON 노출 | 0 substitute + warning push (output sanitization) | + +#### `exchanges/lighter.ts` (`26d78d7`, helper + 9 sites + 9 tests) + +`LighterAdapter._toFiniteNumber(value, fieldName, defaultValue=0)` 추출: +- undefined / null → defaultValue (venue 가 빈 계정 / position 에서 omit 가능) +- finite number / parseable string → 그대로 +- NaN / ±Infinity / 비숫자 string → throw EXCHANGE_ERROR (field name 포함) + +Sites: +- `total_asset_value`, `available_balance`, `collateral`, `position.unrealized_pnl` (balance) +- `position`, `posSize`, `position_value` (positions, markPrice/leverage 계산) + +#### `event-stream.ts` (`ed533cb`, 2 sites) + +- liquidation distance (`mark`/`liq` NaN → critical alert silent skip) +- balance delta (NaN → balance_update emit 누락) + +둘 다 `console.warn` + 분기 가드로 변경. observability 영역이라 +throw 가 stream 흐름 깨므로 silent → noisy 로 격상 (warn 로그가 사용자 +디버깅 단서). + +#### `rebalance.ts` (`ed533cb`, 1 site) + +`Number(bal.equity)` 등 NaN propagation → `Promise.allSettled` 의 reject +경로 유도 (어댑터 단위로 plan 에서 제외, 다른 어댑터 영향 없음). + +#### `exchanges/hyperliquid-outcome.ts:439` (`ed533cb`, 1 site) + +이전 사이클 v0.13.0 의 `getOrderbook` 가드 강화에서 leftover. 이제 +`Number(book.time ?? 0)` 도 nullish 와 NaN 분리: +- nullish → 0 (legit "time 정보 없음") +- non-finite → throw (corrupt) + +## 테스트 결과 + +- **passed: 1396 / failed: 0 / added: 15** (host + container cross-validate) +- **이전 (베이스 = main HEAD `1d0e961`):** 1381 / 74 files +- **이후 (QA 브랜치 HEAD = `ed533cb`):** 1396 / 75 files +- **신규 test files (1):** `exchanges/lighter-toFinite.test.ts` +- **확장된 test files (1):** `trade-validator.test.ts` (38 → 44) +- **새 helper 단위 테스트:** 9 case (`_toFiniteNumber`) +- **NaN edge case 테스트 추가:** trade-validator 의 5 production fix 마다 1 case + +## 변경된 공개 인터페이스 + +- **CLI / JSON envelope: 변경 없음.** 정상 동작 시 출력 동일. +- **에러 행동 변경:** NaN/Infinity venue 응답 시 이제 명시 EXCHANGE_ERROR. + - 이전: silent 0 substitution → "$0 balance" / "Insufficient liquidity: $NaN" / "no alert" 같은 false-positive 분기 + - 이후: throw with field name + value (envelope `error` 필드로 surface) +- **Internal 신규 helpers (모두 underscore prefix, 기존 컨벤션):** + - `LighterAdapter._toFiniteNumber` (public static, 단위 테스트 가능) + +## 테스트 작성 중 발견된 production 결함 + +이전 사이클은 helper-extract pattern 으로 3건 발견. 이번 audit pass 는 +같은 pattern 으로 **18건의 추가 결함**. 모두 NaN propagation +(silent-pass, false-positive 분기, envelope NaN 노출) 의 동일 클래스. + +| 분류 | 건수 | 영향 | +|------|------|------| +| 자금 계산 (balance / position / margin) | 13 | "$0" 으로 corrupt 가림, false 충분 잔고 판정, plan 입력 NaN | +| 시세 / 주문북 (markPrice / level px·sz / time) | 4 | NaN 비교 false → silent skip | +| Output sanitization (envelope output) | 1 | agent JSON 파싱 깨짐 | + +이전 사이클의 가설 — "같은 treatment 안 받은 모듈에 같은 패턴 더 있음" +— 이 audit 가 18건으로 **6배 강화**된 형태로 검증. + +## 사람 검토 필요 항목 + +1. **`Number.isNaN` vs `!Number.isFinite` 사용** — `markPrice` 가드는 + `Number.isNaN(x) || x === Infinity || x === -Infinity` 명시. 다른 + 사이트는 `!Number.isFinite(x)`. 동등하지만 가독성 차이. 통일 + 권장 (`!Number.isFinite` 가 더 짧음). +2. **mcp-server.ts 의 advisor numeric output sanitization** — 이번 + 사이클 범위에서 분리. portfolio aggregation 의 `Number(snap.balance.equity)` 등 + 가드 부재 영역 follow-up. envelope 영향 작지만 lighter.ts cascade + 덕에 부분 보호. 별도 micro-PR 가치. +3. **다른 어댑터 (`pacifica.ts` / `hyperliquid.ts` / `aster.ts`) 의 + 동일 audit** — `cross-adapter-matrix` 사이클 (P1) 의 일부로 + 처리 권장. lighter.ts 가 가장 많은 silent fallback 을 가졌지만 + 다른 어댑터도 부분 패턴 가능. +4. **event-stream / rebalance 의 단위 테스트 부재** — stream/aggregation + shape 라 단위 테스트 비용 큼. integration test 인프라 (mock signer + matrix 사이클) 와 함께 생기면 추가 가치. + +## 다음 권장 액션 + +### 즉시 (이번 PR) + +- [ ] **PR 생성** — `qa/2026-05-06-numeric-validation-audit` → `main`. + 3 commits, +18 production fixes, +15 tests. + +### Follow-up micro-PRs + +- [ ] **mcp-server.ts numeric output sanitization** — portfolio aggregation + `Number(...)` 사이트들 (`extractNumber || ""` 패턴 + balance + reduce). envelope NaN 노출 차단. +- [ ] **`pacifica.ts` / `hyperliquid.ts` / `aster.ts` 동일 audit** — + 이번 사이클의 cross-adapter follow-up. cross-adapter-matrix 와 + 병합 가능. +- [ ] **`Number.isFinite` 통일** — 사람 검토 #1 의 가독성 통일. +- [ ] **`@vitest/coverage-v8` dep 추가 (사용자 승인 필요)** — 정량 + coverage. 이번 audit 가 18건 추가했는데 0% → 미커버 모듈 식별. +- [ ] **`fast-check` property test (사용자 승인 필요)** — `--json` numeric + 필드 finite 강제. 1000+ 자동 case 로 audit 패턴 영구화. + +### 다른 사이클로 이미 분리된 항목 (이전 보고서 plan 그대로) + +- `qa/2026-05-XX-aster-signer-regression` (P0 격상) +- `commander program-builder factory` (P1) +- `qa/2026-05-XX-cross-adapter-matrix` (P1, mcp-server / 다른 어댑터 audit 묶기 가능) +- `qa/2026-05-XX-failure-modes` (P2) + +## Section 3 / Section 13 — 절대 금지 항목 준수 + +| 항목 | 수행 여부 | +|------|----------| +| `main` 머지 / push | ✗ | +| `npm publish` | ✗ | +| `git tag` | ✗ | +| GitHub Release 생성 | ✗ | +| mainnet 실거래 | ✗ | +| 의존성 메이저 업데이트 | ✗ | +| 새 npm 패키지 추가 | ✗ | + +## 부록 A — 가설 강화 데이터 + +| 사이클 | audit 통과 모듈 | 발견 production 결함 | +|--------|--------------|------------------| +| v0.13.0 (`qa/2026-05-05`) | `hyperliquid-outcome.ts` (helper 5개) | 3 | +| Numeric audit (`qa/2026-05-06`, 본 보고서) | `trade-validator` / `lighter` / `event-stream` / `rebalance` / `outcome-time` | 18 | +| **합계** | 5 영역 | **21** | + +이 시점에서 **다음 사이클이 같은 패턴으로 더 발견할 결함의 lower bound** +는 0 이 아님. 추가 audit 사이클의 가성비는 여전히 높음 — 본 보고서의 +follow-up plan 이 그 우선순위 정렬. + +## 부록 B — audit 검색 명령 + +다음 사이클이 동일 패턴 적용 시 사용: + +```bash +# 1. Number()/parseFloat()/parseInt() 후 isFinite 가드 부재 사이트 +grep -rnE '(Number\(|parseFloat\(|parseInt\()' src --include='*.ts' \ + --exclude-dir=__tests__ --exclude-dir=dist \ + | grep -vE '(Number\.is(Finite|Integer)|\.\s*toString)' + +# 2. `|| 0` 또는 `?? 0` 같은 numeric default fallback +grep -rnE '\?\? \d|\|\| \d' src/exchanges src/strategies src/arb \ + --include='*.ts' +```