Skip to content
markpaper

src/account/snapshot.test.ts

v0.3.0 · 6.9 KB

Download file
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',
    });
  });
});
All files