Skip to content
markpaper

src/account/reliability.ts

v0.3.0 · 8.3 KB

Download file
// Read-reliability helpers: a single equity reading is not a fact. Lagging or broken HL replicas return
// well-formed answers with equity ~9x lower or ~48x higher than reality, in bursts shorter than a minute,
// while exposure stays unchanged. These pure helpers implement the defences from the knowledge base.

export interface EquityReading {
  /** `marginSummary.accountValue` of one dex. */
  accountValue: number;
  /** `marginSummary.totalNtlPos` of the same dex. */
  totalNtlPos: number;
}

export interface ImplausibilityOptions {
  /** Max relative exposure change still considered "exposure held". Default 0.10. */
  exposureTolerance?: number;
  /** Equity below `prev × lowerRatio` is suspicious. Default 0.75. */
  lowerRatio?: number;
  /** Equity above `prev × upperRatio` is suspicious. Default 1.33. */
  upperRatio?: number;
}

/**
 * Per-dex detector: equity jumped while exposure stood still.
 *
 * `exposureHeld = |ntl − prevNtl| <= 0.10 × prevNtl`; implausible when exposure held and
 * `av < 0.75 × prevAv` or `av > 1.33 × prevAv`. No previous reading, `prevAv <= 0` or `prevNtl <= 0`
 * → not implausible (first tick after restart). Price moves change equity and notional together and
 * honest entries/exits change notional, so only a jump at constant exposure is flagged.
 *
 * The detector must stay silent while exposure changes, and a phantom can slip through then — combine it
 * with a median window ({@link createEquitySmoother}). Non-finite inputs are reported as implausible.
 *
 * Boundaries are evaluated with a tiny relative tolerance so binary float artifacts do not flip the
 * verdict at exactly the stated limits (`0.33 − 0.3` is `0.030000000000000027` in floats, which would
 * otherwise make an exact +10% exposure change read as "exposure moved").
 */
export function isEquityImplausible(
  current: EquityReading,
  previous: EquityReading | null | undefined,
  opts: ImplausibilityOptions = {},
): boolean {
  if (!Number.isFinite(current.accountValue) || !Number.isFinite(current.totalNtlPos)) return true;
  if (!previous || !(previous.accountValue > 0) || !(previous.totalNtlPos > 0)) return false;
  const tol = opts.exposureTolerance ?? 0.1;
  const lower = opts.lowerRatio ?? 0.75;
  const upper = opts.upperRatio ?? 1.33;
  const exposureHeld = Math.abs(current.totalNtlPos - previous.totalNtlPos) <= tol * previous.totalNtlPos * (1 + REL_EPS);
  return (
    exposureHeld &&
    (current.accountValue < lower * previous.accountValue * (1 - REL_EPS) ||
      current.accountValue > upper * previous.accountValue * (1 + REL_EPS))
  );
}

/** Relative tolerance for boundary comparisons on floats. */
const REL_EPS = 1e-9;

/**
 * Median of finite numbers; the upper median for an even count. Throws on an empty list and on non-finite
 * values (a NaN breaks the sort comparator and would return an arbitrary element).
 */
export function median(values: readonly number[]): number {
  if (values.length === 0) throw new RangeError('median of an empty list');
  if (!values.every((v) => Number.isFinite(v))) throw new RangeError('median of non-finite values');
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.floor(sorted.length / 2)] as number;
}

export interface EquitySmoother {
  /**
   * Feeds one reading and returns the smoothed value. Untrusted readings do not enter the window and
   * return the previous median; with an empty window the result is 0 (fail-closed).
   */
  push(value: number, trusted: boolean): number;
  /** Current median, 0 when the window is empty. */
  value(): number;
  /** Number of trusted readings in the window. */
  size(): number;
  reset(): void;
}

/**
 * Median-of-window smoother ("truth repeats, a phantom blinks"). Default window 7: single and double
 * phantoms never reach the median, e.g. `[100, 101, 4050, 100, 4850, 99, 100] → 100`.
 *
 * Smooth the SUM read at one moment (perp of all dexes + free spot), not the components: component
 * medians can mix two HL conventions and produce a phantom of their own. Cost: real changes arrive with a
 * lag of about half a window. Without a median a phantom can become the baseline after a restart, and
 * healthy readings are then rejected until it ages out.
 */
export function createEquitySmoother(opts: { window?: number } = {}): EquitySmoother {
  const size = opts.window ?? 7;
  if (!Number.isInteger(size) || size < 1) throw new RangeError('window must be a positive integer');
  let buf: number[] = [];
  const current = (): number => (buf.length ? median(buf) : 0);
  return {
    push(value, trusted) {
      if (trusted && Number.isFinite(value)) {
        buf.push(value);
        if (buf.length > size) buf.shift();
      }
      return current();
    },
    value: current,
    size: () => buf.length,
    reset() {
      buf = [];
    },
  };
}

export interface LedgerEntry {
  time: number;
  delta?: { usdc?: string | number; amount?: string | number; [key: string]: unknown } | null;
}

/**
 * Whether `userNonFundingLedgerUpdates` explains an equity shortfall: Σ |usdc ?? amount| of entries with
 * `time >= sinceMs` covers at least `minCoverage` (default 50%) of the shortfall. Direction is ignored on
 * purpose (send/transfer encode it differently); the threshold keeps someone else's small transfer from
 * "explaining" a large drop. A real withdrawal always leaves a ledger record; a phantom does not.
 */
export function ledgerExplainsDrop(
  ledger: readonly LedgerEntry[] | null | undefined,
  sinceMs: number,
  shortfall: number,
  opts: { minCoverage?: number } = {},
): boolean {
  if (!ledger || ledger.length === 0 || !(shortfall > 0)) return false;
  let moved = 0;
  for (const e of ledger) {
    if (!(e.time >= sinceMs)) continue;
    const raw: unknown = e.delta?.usdc ?? e.delta?.amount;
    const v = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
    if (Number.isFinite(v)) moved += Math.abs(v);
  }
  return moved >= (opts.minCoverage ?? 0.5) * shortfall;
}

export interface LastGoodInput {
  /** The read was complete and validated. */
  trusted: boolean;
  /** The value read, `null` when the read produced none. */
  value: number | null;
}

export interface LastGoodResult {
  /** A usable value is returned (fresh or cached within TTL). */
  ok: boolean;
  value: number;
  /** The value comes from this read. Cached values must not authorise growing exposure. */
  fresh: boolean;
  /** Age of the returned value in ms (0 for fresh, `null` when not ok). */
  ageMs: number | null;
}

export interface LastGoodCache {
  update(input: LastGoodInput): LastGoodResult;
  /** Drops the cached value (for example when the account/subject changes). */
  clear(): void;
  peek(): { value: number; at: number } | null;
}

/**
 * Last-known-good cache for equity or free stables (knowledge base, pitfalls §2.3):
 * 1. updated only by a trusted positive reading;
 * 2. a degraded reading returns the cache within TTL (default 10 min) with `fresh: false`;
 * 3. no cache → `ok: false` (skip the tick);
 * 4. a trusted honest 0 deletes the cache — otherwise `100 → 0 → error` would return 100 with `ok: true`;
 * 5. a hard-invalid value (negative or non-finite) is never replaced by the cache: `ok: false, value: 0`.
 *
 * Clear it when the subject changes: otherwise a watchdog compares a new account's capital with the
 * previous account's.
 */
export function createLastGoodCache(opts: { ttlMs?: number; now?: () => number } = {}): LastGoodCache {
  const ttl = opts.ttlMs ?? 10 * 60_000;
  const now = opts.now ?? Date.now;
  let entry: { value: number; at: number } | null = null;
  const fail: LastGoodResult = { ok: false, value: 0, fresh: false, ageMs: null };
  return {
    update({ trusted, value }) {
      const t = now();
      if (value !== null && (!Number.isFinite(value) || value < 0)) return { ...fail };
      if (trusted) {
        if (value === null) return { ...fail };
        if (value === 0) {
          entry = null;
          return { ok: true, value: 0, fresh: true, ageMs: 0 };
        }
        entry = { value, at: t };
        return { ok: true, value, fresh: true, ageMs: 0 };
      }
      if (entry && t - entry.at <= ttl) return { ok: true, value: entry.value, fresh: false, ageMs: t - entry.at };
      return { ...fail };
    },
    clear() {
      entry = null;
    },
    peek: () => (entry ? { ...entry } : null),
  };
}
All files