Skip to content
markpaper

src/account/parse.test.ts

v0.3.0 · 9.8 KB

Download file
import { describe, expect, it } from 'vitest';
import * as D from './decimal.js';
import { HlAccountError } from './errors.js';
import { parseDexState, parsePerpStates, parseSpotStables } from './parse.js';

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',
      maxLeverage: 50,
      ...extra,
    },
  };
}

function ch(av: string, used = '0.0', positions: unknown[] = [], extra: Record<string, unknown> = {}) {
  return {
    marginSummary: { accountValue: av, totalNtlPos: '0.0', totalRawUsd: '0.0', totalMarginUsed: used },
    crossMarginSummary: { accountValue: av, totalNtlPos: '0.0', totalRawUsd: '0.0', totalMarginUsed: used },
    withdrawable: '0.0',
    assetPositions: positions,
    time: 1,
    ...extra,
  };
}

function expectDegraded(fn: () => unknown, pattern?: RegExp) {
  try {
    fn();
  } catch (err) {
    expect(err).toBeInstanceOf(HlAccountError);
    expect((err as HlAccountError).code).toBe('DEGRADED_PERP');
    if (pattern) expect((err as Error).message).toMatch(pattern);
    return;
  }
  throw new Error('expected DEGRADED_PERP');
}

describe('parseDexState — fail-closed clearinghouseState validation', () => {
  it('accepts a healthy flat account', () => {
    const d = parseDexState(ch('1234.5'), '');
    expect(D.toDecString(d.accountValue)).toBe('1234.5');
    expect(d.positions).toEqual([]);
  });

  it('HTTP 200 without marginSummary is a degraded read, not $0', () => {
    const { marginSummary: _drop, ...rest } = ch('100');
    expectDegraded(() => parseDexState(rest, ''), /marginSummary/);
    expectDegraded(() => parseDexState({}, 'xyz'));
    expectDegraded(() => parseDexState(null, ''));
    expectDegraded(() => parseDexState('oops', ''));
  });

  it('rejects non-strict accountValue', () => {
    expectDegraded(() => parseDexState(ch(' 100'), ''));
    expectDegraded(() => parseDexState(ch('abc'), ''));
    expectDegraded(() => parseDexState({ ...ch('1'), marginSummary: { accountValue: 100 } }, ''));
  });

  it('rejects accountValue < totalMarginUsed but accepts equality (stable isolated-only account)', () => {
    expectDegraded(() => parseDexState(ch('50', '60', [pos('BTC', '1')]), ''), /liquidated/);
    expect(parseDexState(ch('60', '60', [pos('BTC', '1')]), '').positions).toHaveLength(1);
  });

  it('requires assetPositions to be an array', () => {
    const { assetPositions: _drop, ...rest } = ch('100');
    expectDegraded(() => parseDexState(rest, ''), /assetPositions/);
  });

  it('non-numeric szi is a broken read, never flat', () => {
    expectDegraded(() => parseDexState(ch('100', '20', [pos('ETH', 'NaN')]), ''), /szi/);
    expectDegraded(() => parseDexState(ch('100', '20', [pos('ETH', undefined as unknown as string)]), ''), /szi/);
  });

  it('skips szi = 0 and positionValue = 0 entries', () => {
    const d = parseDexState(ch('100', '20', [pos('A', '0'), pos('B', '1', { positionValue: '0.0' }), pos('C', '-2')]), '');
    expect(d.positions.map((p) => p.coin)).toEqual(['C']);
  });

  it('rejects duplicate coins inside a dex', () => {
    expectDegraded(() => parseDexState(ch('100', '40', [pos('BTC', '1'), pos('BTC', '2')]), ''), /duplicate/);
  });

  it('requires leverage.value > 0 on an open position', () => {
    expectDegraded(() => parseDexState(ch('100', '20', [pos('BTC', '1', { leverage: { type: 'cross', value: 0 } })]), ''));
    expectDegraded(() => parseDexState(ch('100', '20', [pos('BTC', '1', { leverage: undefined })]), ''));
    expectDegraded(() => parseDexState(ch('100', '20', [pos('BTC', '1', { leverage: { type: 'weird', value: 3 } })]), ''));
  });

  it('positions with zero total margin mean an incomplete payload', () => {
    expectDegraded(() => parseDexState(ch('100', '0.0', [pos('BTC', '1')]), ''), /incomplete/);
  });

  it('rejects non-finite positionValue / unrealizedPnl on an open position', () => {
    expectDegraded(() => parseDexState(ch('100', '20', [pos('BTC', '1', { positionValue: 'x' })]), ''));
    expectDegraded(() => parseDexState(ch('100', '20', [pos('BTC', '1', { unrealizedPnl: null })]), ''));
  });

  it('allows a null entryPx', () => {
    const d = parseDexState(ch('100', '20', [pos('BTC', '1', { entryPx: null })]), '');
    expect(d.positions[0]?.entryPx).toBeNull();
  });
});

describe('parsePerpStates — cross-dex checks', () => {
  it('rejects the same coin on two dexes', () => {
    expectDegraded(
      () =>
        parsePerpStates([
          { dex: '', state: ch('100', '20', [pos('xyz:NVDA', '1')]) },
          { dex: 'xyz', state: ch('100', '20', [pos('xyz:NVDA', '1')]) },
        ]),
      /also reported/,
    );
  });

  it('rejects a dex listed twice', () => {
    expect(() => parsePerpStates([{ dex: '', state: ch('1') }, { dex: '', state: ch('1') }])).toThrow(/twice/);
  });
});

describe('parseSpotStables — free stables', () => {
  const S = ['USDC', 'USDT', 'USDT0', 'USDH', 'USDE'];
  const free = (r: ReturnType<typeof parseSpotStables>) => {
    if (!r.ok) throw new Error(`spot rejected: ${r.reason}`);
    return D.toDecString(r.free);
  };

  it('free = total − hold on a regular account; total tracked separately', () => {
    const r = parseSpotStables({ balances: [{ coin: 'USDC', total: '1000.0', hold: '200.0', entryNtl: '0.0' }] }, S);
    expect(free(r)).toBe('800');
    expect(r.ok && D.toDecString(r.total)).toBe('1000');
  });

  it('ignores spot alts in any amount', () => {
    const r = parseSpotStables(
      { balances: [{ coin: 'HYPE', total: '5000000.0', hold: '0.0' }, { coin: 'USDC', total: '10', hold: '0' }] },
      S,
    );
    expect(free(r)).toBe('10');
  });

  it('portfolio margin phantom row: USDT0 total 0 / hold -999999.99 / spotHold 0 → 0, not $1M', () => {
    const r = parseSpotStables(
      {
        portfolioMarginEnabled: true,
        balances: [{ coin: 'USDT0', total: '0.0', hold: '-999999.99999999', spotHold: '0.0' }],
      },
      S,
    );
    expect(free(r)).toBe('0');
  });

  it('portfolio margin uses spotHold as the reserve (Earn balance is the only free part)', () => {
    const r = parseSpotStables(
      { portfolioMarginEnabled: true, balances: [{ coin: 'USDC', total: '1000.0', hold: '-50000.0', spotHold: '700.0' }] },
      S,
    );
    expect(free(r)).toBe('300');
    expect(r.ok && r.rows[0]?.reserveSource).toBe('spotHold');
  });

  it('portfolio margin: reserve above total clamps the row to 0 without rejecting spot', () => {
    const r = parseSpotStables(
      { portfolioMarginEnabled: true, balances: [{ coin: 'USDC', total: '100', hold: '-5', spotHold: '150' }] },
      S,
    );
    expect(free(r)).toBe('0');
  });

  it('negative hold is legal only on portfolio margin with spotHold', () => {
    expect(parseSpotStables({ balances: [{ coin: 'USDC', total: '10', hold: '-1' }] }, S).ok).toBe(false);
    expect(
      parseSpotStables({ portfolioMarginEnabled: true, balances: [{ coin: 'USDC', total: '10', hold: '-1' }] }, S).ok,
    ).toBe(false);
  });

  it('reserve above total is rejected on a non-portfolio-margin account', () => {
    expect(parseSpotStables({ balances: [{ coin: 'USDC', total: '10', hold: '11' }] }, S).ok).toBe(false);
  });

  it('negative total or negative spotHold is rejected', () => {
    expect(parseSpotStables({ balances: [{ coin: 'USDC', total: '-1', hold: '0' }] }, S).ok).toBe(false);
    expect(
      parseSpotStables({ portfolioMarginEnabled: true, balances: [{ coin: 'USDC', total: '1', hold: '-1', spotHold: '-1' }] }, S)
        .ok,
    ).toBe(false);
  });

  it('duplicate stable row rejects the spot leg', () => {
    const r = parseSpotStables({ balances: [{ coin: 'USDC', total: '1', hold: '0' }, { coin: 'USDC', total: '1', hold: '0' }] }, S);
    expect(r.ok).toBe(false);
  });

  it('missing balances / non-object is degraded; empty balances is a valid zero', () => {
    expect(parseSpotStables({}, S).ok).toBe(false);
    expect(parseSpotStables(null, S).ok).toBe(false);
    expect(free(parseSpotStables({ balances: [] }, S))).toBe('0');
  });

  it('strict number parsing', () => {
    expect(parseSpotStables({ balances: [{ coin: 'USDC', total: ' 100', hold: '0' }] }, S).ok).toBe(false);
    expect(parseSpotStables({ balances: [{ coin: 'USDC', total: '100', hold: 'abc' }] }, S).ok).toBe(false);
  });

  it('exact symbol match: no prefix matching, no case folding', () => {
    const r = parseSpotStables(
      {
        balances: [
          { coin: 'USDX', total: '0.0', hold: '-999999.99' },
          { coin: 'usdc', total: '500', hold: '0' },
          { coin: 'USDC', total: '1', hold: '0' },
        ],
      },
      S,
    );
    expect(free(r)).toBe('1');
  });

  it('empty spotHold string falls back to hold', () => {
    const r = parseSpotStables({ balances: [{ coin: 'USDC', total: '100', hold: '40', spotHold: '' }] }, S);
    expect(free(r)).toBe('60');
    expect(r.ok && r.rows[0]?.reserveSource).toBe('hold');
  });

  it('missing hold counts as 0 reserve', () => {
    expect(free(parseSpotStables({ balances: [{ coin: 'USDH', total: '5' }] }, S))).toBe('5');
  });

  it('sums several stables exactly and flags borrowing', () => {
    const r = parseSpotStables(
      {
        balances: [
          { coin: 'USDC', total: '0.1', hold: '0' },
          { coin: 'USDT', total: '0.2', hold: '0', borrowed: '3.0' },
          { coin: 'USDE', total: '1', hold: '1' },
        ],
      },
      S,
    );
    expect(free(r)).toBe('0.3');
    expect(r.ok && r.hasBorrowed).toBe(true);
  });

  it('honours a custom stable list', () => {
    const r = parseSpotStables({ balances: [{ coin: 'USDC', total: '1', hold: '0' }, { coin: 'USDT', total: '2', hold: '0' }] }, ['USDC']);
    expect(free(r)).toBe('1');
  });
});
All files