src/account/snapshot.test.ts
v0.3.0 · 6.9 KB
import { describe, expect, it } from 'vitest';
import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import { HlAccountError } from './errors.js';
import { fetchAccountSnapshot, fetchPerpDexNames } from './snapshot.js';
const addr = (n: number) => `0x${n.toString(16).padStart(40, '0')}`;
const USER = addr(1);
function ch(av: string, positions: unknown[] = [], used = '0.0') {
return {
marginSummary: { accountValue: av, totalNtlPos: '0.0', totalRawUsd: '0.0', totalMarginUsed: used },
crossMarginSummary: { accountValue: av, totalNtlPos: '0.0', totalRawUsd: '0.0', totalMarginUsed: used },
withdrawable: av,
assetPositions: positions,
time: 1,
};
}
function makeInfo(handler: (body: InfoRequest, opts?: InfoCallOptions) => unknown) {
const calls: Array<{ body: InfoRequest; opts?: InfoCallOptions }> = [];
const info = (async (body: InfoRequest, opts?: InfoCallOptions) => {
calls.push({ body, opts });
return handler(body, opts);
}) as InfoRequester;
return { info, calls };
}
const healthy = (body: InfoRequest): unknown => {
if (body.type === 'perpDexs') return [null, { name: 'xyz' }, null, { name: 'flx' }];
if (body.type === 'spotClearinghouseState') return { balances: [{ coin: 'USDC', total: '10', hold: '0' }] };
if (body.type === 'clearinghouseState') return ch(body.dex === undefined ? '100' : '5');
throw new Error(`unexpected ${body.type}`);
};
describe('fetchPerpDexNames', () => {
it('returns main dex first and skips null gaps', async () => {
const { info } = makeInfo(healthy);
expect(await fetchPerpDexNames(info)).toEqual(['', 'xyz', 'flx']);
});
it('non-array answer is an error, not "main dex only"', async () => {
const { info } = makeInfo(() => ({ error: 'x' }));
await expect(fetchPerpDexNames(info)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
});
});
describe('fetchAccountSnapshot', () => {
it('reads every dex from perpDexs by default; main dex without a dex param', async () => {
const { info, calls } = makeInfo(healthy);
const snap = await fetchAccountSnapshot(info, USER);
expect(snap.perp.map((p) => p.dex)).toEqual(['', 'xyz', 'flx']);
const chCalls = calls.filter((c) => c.body.type === 'clearinghouseState').map((c) => c.body);
expect(chCalls[0]).toEqual({ type: 'clearinghouseState', user: USER });
expect('dex' in (chCalls[0] as object)).toBe(false);
expect(chCalls.slice(1)).toEqual([
{ type: 'clearinghouseState', user: USER, dex: 'xyz' },
{ type: 'clearinghouseState', user: USER, dex: 'flx' },
]);
expect(snap.spot.ok).toBe(true);
});
it('issues perp and spot reads at the same moment (one Promise.all)', async () => {
const releases: Array<() => void> = [];
const { info, calls } = makeInfo(
(body) =>
new Promise((resolve) => {
releases.push(() => resolve(healthy(body)));
}),
);
const pending = fetchAccountSnapshot(info, USER, { dexes: ['', 'xyz'] });
await Promise.resolve();
expect(calls.map((c) => c.body.type).sort()).toEqual(['clearinghouseState', 'clearinghouseState', 'spotClearinghouseState']);
for (const r of releases) r();
await expect(pending).resolves.toMatchObject({ user: USER });
});
it('always includes the main dex first and dedupes an explicit list', async () => {
const { info, calls } = makeInfo(healthy);
const snap = await fetchAccountSnapshot(info, USER, { dexes: ['xyz', 'xyz'] });
expect(snap.perp.map((p) => p.dex)).toEqual(['', 'xyz']);
expect(calls.some((c) => c.body.type === 'perpDexs')).toBe(false);
});
it('lowercases the address and rejects malformed ones before any request', async () => {
const { info, calls } = makeInfo(healthy);
const mixed = `0x${'0'.repeat(38)}AB`;
const snap = await fetchAccountSnapshot(info, mixed, { dexes: [''] });
expect(snap.user).toBe(mixed.toLowerCase());
const bad = makeInfo(healthy);
await expect(fetchAccountSnapshot(bad.info, '0xYOUR_ADDRESS')).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
expect(bad.calls).toHaveLength(0);
expect(calls.every((c) => c.body.user === undefined || c.body.user === mixed.toLowerCase())).toBe(true);
});
it('HTTP 200 without marginSummary on any dex rejects the snapshot (degraded, not $0)', async () => {
const { info } = makeInfo((body) => (body.type === 'clearinghouseState' && body.dex === 'xyz' ? { assetPositions: [] } : healthy(body)));
const err = await fetchAccountSnapshot(info, USER, { dexes: ['', 'xyz'] }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HlAccountError);
expect(err).toMatchObject({ code: 'DEGRADED_PERP', dex: 'xyz' });
});
it('a perp transport error is rethrown', async () => {
const { info } = makeInfo((body) => {
if (body.type === 'clearinghouseState') throw new Error('429');
return healthy(body);
});
await expect(fetchAccountSnapshot(info, USER, { dexes: [''] })).rejects.toThrow('429');
});
it('a failed spot read does not reject the snapshot', async () => {
const { info } = makeInfo((body) => {
if (body.type === 'spotClearinghouseState') throw new Error('socket hang up');
return healthy(body);
});
const snap = await fetchAccountSnapshot(info, USER, { dexes: [''] });
expect(snap.spot).toEqual({ ok: false, error: expect.stringContaining('socket hang up') });
expect(snap.perp).toHaveLength(1);
});
it('a spot answer without balances is recorded as degraded', async () => {
const { info } = makeInfo((body) => (body.type === 'spotClearinghouseState' ? {} : healthy(body)));
const snap = await fetchAccountSnapshot(info, USER, { dexes: [''] });
expect(snap.spot.ok).toBe(false);
});
it('rejects the same coin reported by two dexes', async () => {
const position = {
position: {
coin: 'xyz:TSLA',
szi: '1',
leverage: { type: 'isolated', value: 3, rawUsd: '-10' },
entryPx: '10',
positionValue: '10',
unrealizedPnl: '0',
returnOnEquity: '0',
liquidationPx: null,
marginUsed: '3.3',
},
};
const { info } = makeInfo((body) => (body.type === 'clearinghouseState' ? ch('50', [position], '3.3') : healthy(body)));
await expect(fetchAccountSnapshot(info, USER, { dexes: ['', 'xyz'] })).rejects.toMatchObject({ code: 'DEGRADED_PERP' });
});
it('forwards signal and timeout to every request', async () => {
const { info, calls } = makeInfo(healthy);
const controller = new AbortController();
await fetchAccountSnapshot(info, USER, { dexes: ['xyz'], signal: controller.signal, timeoutMs: 1234 });
expect(calls).toHaveLength(3);
for (const c of calls) expect(c.opts).toEqual({ signal: controller.signal, timeoutMs: 1234 });
});
it('rejects non-string dex ids', async () => {
const { info } = makeInfo(healthy);
await expect(fetchAccountSnapshot(info, USER, { dexes: [1 as unknown as string] })).rejects.toMatchObject({
code: 'INVALID_ARGUMENT',
});
});
});