src/account/edge.test.ts
v0.3.0 · 10.4 KB
// Edge cases added in review: float artifacts, empty/zero answers, HIP-3 and spot corner cases.
import { describe, expect, it } from 'vitest';
import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import { DEFAULT_STABLES, computeEquity } from './equity.js';
import { parseUserFees } from './fees.js';
import { parseDexState, parseSpotStables } from './parse.js';
import { listZeroSizePositions, normalizePositions } from './positions.js';
import { createLastGoodCache, isEquityImplausible, ledgerExplainsDrop, median } from './reliability.js';
import { resolveTradingAccount } from './roles.js';
import { fetchAccountSnapshot, fetchPerpDexNames } from './snapshot.js';
import type { AccountSnapshot } from './types.js';
const addr = (n: number) => `0x${n.toString(16).padStart(40, '0')}`;
const USER = addr(1);
function pos(coin: string, szi: string, extra: Record<string, unknown> = {}) {
return {
type: 'oneWay',
position: {
coin,
szi,
leverage: { type: 'cross', value: 5 },
entryPx: '100.0',
positionValue: '100.0',
unrealizedPnl: '0.0',
returnOnEquity: '0.0',
liquidationPx: null,
marginUsed: '20.0',
...extra,
},
};
}
function ch(av: string, used = '0.0', positions: unknown[] = [], ntl = '0.0') {
return {
marginSummary: { accountValue: av, totalNtlPos: ntl, totalRawUsd: '0.0', totalMarginUsed: used },
crossMarginSummary: { accountValue: av, totalNtlPos: ntl, totalRawUsd: '0.0', totalMarginUsed: used },
crossMaintenanceMarginUsed: '0.0',
withdrawable: av,
assetPositions: positions,
time: 1,
};
}
/** Plain (non-async) requester stub: the handler decides whether to return a promise or throw. */
function makeInfo(handler: (body: InfoRequest, opts?: InfoCallOptions) => unknown): { info: InfoRequester; calls: InfoRequest[] } {
const calls: InfoRequest[] = [];
const info = ((body: InfoRequest, opts?: InfoCallOptions) => {
calls.push(body);
return handler(body, opts);
}) as InfoRequester;
return { info, calls };
}
const snap = (perp: Array<[string, unknown]>, spot: unknown): AccountSnapshot => ({
user: USER as `0x${string}`,
perp: perp.map(([dex, state]) => ({ dex, state })),
spot: { ok: true, state: spot },
fetchedAt: 0,
});
describe('zero-size position rows are "no data", not a close', () => {
it('parseDexState lists them in zeroSizeCoins', () => {
const d = parseDexState(ch('100', '20', [pos('A', '0.0'), pos('B', '1', { positionValue: '0.0' }), pos('C', '-2')]), '');
expect(d.positions.map((p) => p.coin)).toEqual(['C']);
expect(d.zeroSizeCoins).toEqual(['A', 'B']);
});
it('listZeroSizePositions keeps dex and prefixed HIP-3 coin; normalizePositions still excludes them', () => {
const s = { perp: [{ dex: '', state: ch('10') }, { dex: 'xyz', state: ch('10', '0.0', [pos('xyz:NVDA', '0.0')]) }] };
expect(listZeroSizePositions(s)).toEqual([{ dex: 'xyz', coin: 'xyz:NVDA' }]);
expect(normalizePositions(s)).toEqual([]);
});
it('computeEquity reports them per dex', () => {
const e = computeEquity(snap([['', ch('10', '0.0', [pos('ETH', '0')])]], { balances: [] }));
expect(e.perDex[0]?.zeroSizeCoins).toEqual(['ETH']);
expect(e.perDex[0]?.openPositions).toBe(0);
});
});
describe('perpDexs parsing is fail-closed', () => {
it('a live-shaped answer (null only at index 0) maps to names in order', async () => {
const { info } = makeInfo(async () => [null, { name: 'xyz', fullName: 'x' }, { name: 'flx', fullName: 'f' }]);
expect(await fetchPerpDexNames(info)).toEqual(['', 'xyz', 'flx']);
});
it('a malformed non-null entry throws instead of silently dropping a dex', async () => {
const { info } = makeInfo(async () => [null, { name: 'xyz' }, { fullName: 'no name' }]);
await expect(fetchPerpDexNames(info)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
const { info: info2 } = makeInfo(async () => [null, 'xyz']);
await expect(fetchPerpDexNames(info2)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
});
it('an empty array still yields the main dex', async () => {
const { info } = makeInfo(async () => []);
expect(await fetchPerpDexNames(info)).toEqual(['']);
});
});
describe('fetchAccountSnapshot with a plain-function requester', () => {
it('a synchronous throw of the spot call does not reject the snapshot', async () => {
const { info } = makeInfo((body) => {
if (body.type === 'spotClearinghouseState') throw new Error('sync boom');
return Promise.resolve(ch('1'));
});
const s = await fetchAccountSnapshot(info, USER, { dexes: [''] });
expect(s.spot).toEqual({ ok: false, error: expect.stringContaining('sync boom') });
});
it('a null spot answer is degraded, not zero', async () => {
const { info } = makeInfo((body) => Promise.resolve(body.type === 'spotClearinghouseState' ? null : ch('1')));
const s = await fetchAccountSnapshot(info, USER, { dexes: [''] });
expect(s.spot.ok).toBe(false);
const e = computeEquity(s);
expect(e.total).toBeNull();
expect(e.perpEquity).toBe(1);
});
it('a null perp answer rejects the snapshot as DEGRADED_PERP', async () => {
const { info } = makeInfo((body) => Promise.resolve(body.type === 'clearinghouseState' ? null : { balances: [] }));
await expect(fetchAccountSnapshot(info, USER, { dexes: [''] })).rejects.toMatchObject({ code: 'DEGRADED_PERP', dex: '' });
});
});
describe('exact money arithmetic on real-looking strings', () => {
it('free stables do not suffer float artifacts (0.3 - 0.1 = 0.2 exactly)', () => {
const e = computeEquity(snap([['', ch('0.1')], ['xyz', ch('0.2')]], { balances: [{ coin: 'USDC', total: '0.3', hold: '0.1' }] }));
expect(e.exact.spotFreeStables).toBe('0.2');
expect(e.exact.total).toBe('0.5');
expect(e.total).toBe(0.5);
});
it('reserve above total by 1e-8 is rejected on a regular account (exact compare, no epsilon)', () => {
const s = parseSpotStables({ balances: [{ coin: 'USDC', total: '1000.0', hold: '1000.00000001' }] }, DEFAULT_STABLES);
expect(s.ok).toBe(false);
const eq = parseSpotStables({ balances: [{ coin: 'USDC', total: '1000.00000000', hold: '1000.0' }] }, DEFAULT_STABLES);
expect(eq.ok && eq.free.units).toBe(0n);
});
it('a HIP-3 dex with accountValue 0 and no positions is a valid zero, not degraded', () => {
const e = computeEquity(snap([['', ch('500')], ['flx', ch('0.0')]], { balances: [] }));
expect(e.perpAccountValue).toEqual({ '': 500, flx: 0 });
expect(e.total).toBe(500);
});
it('non-strict numbers in perp answers are degraded (exponent, whitespace, plus sign)', () => {
for (const bad of ['1e3', ' 100', '+5', '1,000', '.5', '']) {
expect(() => parseDexState(ch(bad), '')).toThrow(expect.objectContaining({ code: 'DEGRADED_PERP' }));
}
});
it('negative zero strings are zero', () => {
const d = parseDexState(ch('-0.0', '0.0', [pos('A', '-0.0')]), '');
expect(d.positions).toEqual([]);
expect(d.zeroSizeCoins).toEqual(['A']);
});
it('userFees with discounted rates converts to bps exactly', () => {
const f = parseUserFees({ userAddRate: '0.000144', userCrossRate: '0.000432', activeReferralDiscount: '0.04' });
expect(f.exact.makerBps).toBe('1.44');
expect(f.exact.takerBps).toBe('4.32');
expect(f.takerBps).toBe(4.32);
});
});
describe('isEquityImplausible boundaries are not flipped by float artifacts', () => {
it('exactly +10% exposure still counts as "exposure held"', () => {
// 0.33 - 0.3 = 0.030000000000000027 > 0.1 * 0.3 in plain floats
expect(isEquityImplausible({ accountValue: 100, totalNtlPos: 0.33 }, { accountValue: 1000, totalNtlPos: 0.3 })).toBe(true);
});
it('exactly x0.75 equity is not flagged, just below is', () => {
expect(isEquityImplausible({ accountValue: 0.3, totalNtlPos: 1 }, { accountValue: 0.4, totalNtlPos: 1 })).toBe(false);
expect(isEquityImplausible({ accountValue: 0.29, totalNtlPos: 1 }, { accountValue: 0.4, totalNtlPos: 1 })).toBe(true);
});
it('exactly x1.33 equity is not flagged, just above is', () => {
expect(isEquityImplausible({ accountValue: 133, totalNtlPos: 1 }, { accountValue: 100, totalNtlPos: 1 })).toBe(false);
expect(isEquityImplausible({ accountValue: 133.01, totalNtlPos: 1 }, { accountValue: 100, totalNtlPos: 1 })).toBe(true);
});
it('non-positive previous exposure disables the detector (first tick semantics)', () => {
expect(isEquityImplausible({ accountValue: 1, totalNtlPos: -5 }, { accountValue: 100, totalNtlPos: -5 })).toBe(false);
});
});
describe('median and ledger edge cases', () => {
it('median rejects non-finite values instead of returning an arbitrary element', () => {
expect(() => median([1, Number.NaN, 3])).toThrow(RangeError);
expect(median([5])).toBe(5);
});
it('ledger amounts may arrive as numbers; blank strings are ignored', () => {
expect(ledgerExplainsDrop([{ time: 10, delta: { usdc: -60 } }], 0, 100)).toBe(true);
expect(ledgerExplainsDrop([{ time: 10, delta: { usdc: '' } }], 0, 100)).toBe(false);
});
it('exactly 50% coverage explains the drop; an entry at exactly sinceMs counts', () => {
expect(ledgerExplainsDrop([{ time: 5, delta: { amount: '50' } }], 5, 100)).toBe(true);
expect(ledgerExplainsDrop([{ time: 4, delta: { amount: '50' } }], 5, 100)).toBe(false);
});
});
describe('last-good cache TTL boundary', () => {
it('stale value is served at exactly TTL and dropped after', () => {
let t = 0;
const c = createLastGoodCache({ ttlMs: 1000, now: () => t });
c.update({ trusted: true, value: 100 });
t = 1000;
expect(c.update({ trusted: false, value: null })).toEqual({ ok: true, value: 100, fresh: false, ageMs: 1000 });
t = 1001;
expect(c.update({ trusted: false, value: null }).ok).toBe(false);
});
it('an untrusted positive value never writes the cache', () => {
const c = createLastGoodCache({ now: () => 0 });
expect(c.update({ trusted: false, value: 500 }).ok).toBe(false);
expect(c.peek()).toBeNull();
});
});
describe('resolveTradingAccount abort', () => {
it('a caller abort is rethrown even with a configured master', async () => {
const controller = new AbortController();
controller.abort();
const { info } = makeInfo(() => Promise.reject(new Error('aborted')));
await expect(resolveTradingAccount(info, addr(5), { configuredMaster: addr(1), signal: controller.signal })).rejects.toThrow(
'aborted',
);
});
});