src/account/reliability.ts
v0.3.0 · 8.3 KB
// 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),
};
}