src/account/parse.test.ts
v0.3.0 · 9.8 KB
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');
});
});