src/assets/parse.test.ts
v0.3.0 · 10.6 KB
import { describe, expect, it } from 'vitest';
import {
checkAppendOnly,
checkRegistryCoverage,
crossCheckUniverses,
MetaValidationError,
parseMetaAndAssetCtxs,
parsePerpDexs,
parsePerpUniverse,
parseSpotMeta,
} from './parse.js';
const u = (name: string, extra: Record<string, unknown> = {}) => ({ name, szDecimals: 2, maxLeverage: 10, ...extra });
describe('parsePerpUniverse', () => {
it('uses the array position as the asset index', () => {
const res = parsePerpUniverse({ universe: [u('BTC', { szDecimals: 5, maxLeverage: 40 }), u('ETH')] }, '');
expect(res.map((a) => [a.coin, a.index, a.szDecimals, a.maxLeverage])).toEqual([
['BTC', 0, 5, 40],
['ETH', 1, 2, 10],
]);
});
it('normalizes HIP-3 names in both the old bare and the new prefixed format', () => {
const res = parsePerpUniverse({ universe: [u('XYZ100'), u('xyz:TSLA')] }, 'xyz');
expect(res.map((a) => a.coin)).toEqual(['xyz:XYZ100', 'xyz:TSLA']);
});
it('onlyIsolated: main requires === true, HIP-3 treats a missing field as isolated', () => {
const main = parsePerpUniverse({ universe: [u('A'), u('B', { onlyIsolated: true }), u('C', { onlyIsolated: false })] }, '');
expect(main.map((a) => a.onlyIsolated)).toEqual([false, true, false]);
const xyz = parsePerpUniverse({ universe: [u('A'), u('B', { onlyIsolated: true }), u('C', { onlyIsolated: false })] }, 'xyz');
expect(xyz.map((a) => a.onlyIsolated)).toEqual([true, true, false]);
});
it('marginMode can only make a market more restrictive', () => {
const res = parsePerpUniverse(
{ universe: [u('A', { marginMode: 'strictIsolated' }), u('B', { marginMode: 'noCross' }), u('C', { marginMode: 'other' })] },
'',
);
expect(res.map((a) => a.onlyIsolated)).toEqual([true, true, false]);
});
it('reads isDelisted', () => {
const res = parsePerpUniverse({ universe: [u('A', { isDelisted: true }), u('B')] }, '');
expect(res.map((a) => a.isDelisted)).toEqual([true, false]);
});
it('accepts numeric strings for szDecimals / maxLeverage', () => {
const [a] = parsePerpUniverse({ universe: [{ name: 'A', szDecimals: '3', maxLeverage: '20' }] }, '');
expect(a).toMatchObject({ szDecimals: 3, maxLeverage: 20 });
});
it('one bad element invalidates the whole universe (skipping would shift ids)', () => {
const bad = [
[u('A'), u('B', { szDecimals: 9 }), u('C')],
[u('A'), u('B', { szDecimals: 1.5 }), u('C')],
[u('A'), u('B', { szDecimals: '' }), u('C')],
[u('A'), u('B', { maxLeverage: 0 }), u('C')],
[u('A'), u('B', { maxLeverage: 1001 }), u('C')],
[u('A'), { name: 'B', szDecimals: 1 }, u('C')],
[u('A'), u('B', { onlyIsolated: 'yes' }), u('C')],
[u('A'), u('B', { isDelisted: 1 }), u('C')],
[u('A'), null, u('C')],
[u('A'), u(''), u('C')],
[u('A'), u(`B${String.fromCharCode(7)}`), u('C')],
[u('A'), u('A')],
];
for (const universe of bad) {
expect(() => parsePerpUniverse({ universe }, ''), JSON.stringify(universe)).toThrow(MetaValidationError);
}
});
it('rejects names of another dex and HIP-3 names in main meta', () => {
expect(() => parsePerpUniverse({ universe: [u('xyz:TSLA')] }, '')).toThrow(MetaValidationError);
expect(() => parsePerpUniverse({ universe: [u('flx:ABC')] }, 'xyz')).toThrow(MetaValidationError);
expect(() => parsePerpUniverse({ universe: [u('@1')] }, '')).toThrow(MetaValidationError);
});
it('treats a duplicate after normalization as an error (TSLA and xyz:TSLA)', () => {
expect(() => parsePerpUniverse({ universe: [u('TSLA'), u('xyz:TSLA')] }, 'xyz')).toThrow(/duplicate/);
});
it('rejects non-object responses and oversized universes', () => {
expect(() => parsePerpUniverse(null, '')).toThrow(MetaValidationError);
expect(() => parsePerpUniverse({ universe: {} }, '')).toThrow(MetaValidationError);
const huge = Array.from({ length: 10_001 }, (_, i) => u(`C${i}`));
expect(() => parsePerpUniverse({ universe: huge }, 'xyz')).toThrow(/exceeds/);
});
it('collects issues in the error', () => {
try {
parsePerpUniverse({ universe: [u('A', { szDecimals: -1 }), u('B', { maxLeverage: 'x' })] }, '');
expect.unreachable();
} catch (e) {
expect((e as MetaValidationError).issues).toHaveLength(2);
}
});
});
describe('parseMetaAndAssetCtxs / crossCheckUniverses', () => {
const meta = { universe: [u('A'), u('B')] };
it('parses the tuple and requires equal lengths', () => {
expect(parseMetaAndAssetCtxs([meta, [{}, {}]], '')).toHaveLength(2);
expect(() => parseMetaAndAssetCtxs([meta, [{}]], '')).toThrow(/assetCtxs length/);
expect(() => parseMetaAndAssetCtxs(meta, '')).toThrow(MetaValidationError);
expect(() => parseMetaAndAssetCtxs([meta, null], '')).toThrow(MetaValidationError);
});
it('detects a truncated or shifted universe', () => {
const a = parsePerpUniverse({ universe: [u('A'), u('B'), u('C')] }, '');
expect(crossCheckUniverses(a, a)).toEqual([]);
const truncated = parsePerpUniverse({ universe: [u('A'), u('C')] }, '');
expect(crossCheckUniverses(a, truncated).join()).toMatch(/length 3 != 2/);
const changed = parsePerpUniverse({ universe: [u('A'), u('B', { szDecimals: 3 }), u('C', { maxLeverage: 5 })] }, '');
expect(crossCheckUniverses(a, changed)).toHaveLength(2);
const iso = parsePerpUniverse({ universe: [u('A', { onlyIsolated: true }), u('B'), u('C')] }, '');
expect(crossCheckUniverses(a, iso)).toEqual(['[0] A onlyIsolated mismatch']);
});
});
describe('checkRegistryCoverage', () => {
it('requires inclusion, not equality (XBI listing: meta 116 vs registry 115)', () => {
const meta = Array.from({ length: 116 }, (_, i) => `xyz:A${i}`);
const registry = meta.slice(0, 115);
expect(checkRegistryCoverage(meta, registry)).toEqual({ missing: [], extra: ['xyz:A115'] });
});
it('reports registry assets missing from meta (truncation / delisting)', () => {
expect(checkRegistryCoverage(['xyz:A'], ['xyz:A', 'xyz:B'])).toEqual({ missing: ['xyz:B'], extra: [] });
});
});
describe('checkAppendOnly', () => {
const prev = [
{ coin: 'A', index: 0, szDecimals: 2 },
{ coin: 'B', index: 1, szDecimals: 3 },
];
it('accepts additions', () => {
expect(checkAppendOnly(prev, [...prev, { coin: 'C', index: 2, szDecimals: 1 }])).toEqual([]);
});
it('rejects disappearance, index change and szDecimals change', () => {
expect(checkAppendOnly(prev, [prev[0]!])).toEqual(['B disappeared']);
expect(checkAppendOnly(prev, [{ coin: 'B', index: 0, szDecimals: 3 }, { coin: 'A', index: 1, szDecimals: 2 }])).toHaveLength(2);
expect(checkAppendOnly(prev, [prev[0]!, { coin: 'B', index: 1, szDecimals: 4 }])).toEqual(['B szDecimals 3 -> 4']);
});
});
describe('parsePerpDexs', () => {
it('uses array positions as dex indices and keeps null gaps', () => {
const topo = parsePerpDexs([
null,
{ name: 'xyz', assetToStreamingOiCap: [['xyz:TSLA', '1000'], ['SP500', 5]] },
null,
{ name: 'flx', assetToStreamingOiCap: [] },
]);
expect(topo.byName.get('xyz')).toEqual({ name: 'xyz', index: 1, registry: ['xyz:TSLA', 'xyz:SP500'] });
expect(topo.byName.get('flx')?.index).toBe(3);
expect(topo.byIndex.get(3)?.name).toBe('flx');
expect(topo.duplicates.size).toBe(0);
});
it('skips malformed entries and registry rows without shifting indices', () => {
const topo = parsePerpDexs([null, { fullName: 'no name' }, 'junk', { name: 'a:b' }, { name: 'km', assetToStreamingOiCap: [null, [42], ['flx:X', '1'], ['km:OK', '1']] }]);
expect(topo.dexes.map((d) => [d.name, d.index])).toEqual([['km', 4]]);
expect(topo.byName.get('km')?.registry).toEqual(['km:OK']);
});
it('records duplicated names as unsafe', () => {
const topo = parsePerpDexs([null, { name: 'xyz' }, { name: 'xyz' }]);
expect(topo.duplicates.has('xyz')).toBe(true);
expect(topo.byName.get('xyz')?.index).toBe(1);
});
it('rejects structurally invalid responses', () => {
expect(() => parsePerpDexs({})).toThrow(MetaValidationError);
expect(() => parsePerpDexs([null])).toThrow(/at least 2/);
expect(() => parsePerpDexs([{ name: 'xyz' }, { name: 'flx' }])).toThrow(/element 0/);
});
});
describe('parseSpotMeta', () => {
const USDC = { name: 'USDC', index: 0, tokenId: '0x6d1e7cde53ba9467b783cb7c530ce054', szDecimals: 8, weiDecimals: 8, isCanonical: true };
const PURR = { name: 'PURR', index: 1, tokenId: '0x11111111111111111111111111111111', szDecimals: 0, weiDecimals: 5, isCanonical: true };
const HYPE = { name: 'HYPE', index: 150, tokenId: '0x22222222222222222222222222222222', szDecimals: 2, weiDecimals: 8, isCanonical: false };
it('parses pairs with base/quote tokens and wire ids', () => {
const res = parseSpotMeta({
tokens: [USDC, PURR, HYPE],
universe: [
{ tokens: [1, 0], name: 'PURR/USDC', index: 0, isCanonical: true },
{ tokens: [150, 0], name: '@107', index: 107, isCanonical: false },
],
});
expect(res.pairs.map((p) => [p.coin, p.index, p.base.name, p.quote.name])).toEqual([
['PURR/USDC', 0, 'PURR', 'USDC'],
['@107', 107, 'HYPE', 'USDC'],
]);
expect(res.tokens[0]?.wireId).toBe('USDC:0x6d1e7cde53ba9467b783cb7c530ce054');
expect(res.skipped).toEqual([]);
});
it('skips invalid elements individually (ids come from the explicit index field)', () => {
const res = parseSpotMeta({
tokens: [USDC, PURR, { ...HYPE, tokenId: 'bad' }],
universe: [
{ tokens: [1, 0], name: 'PURR/USDC', index: 0 },
{ tokens: [150, 0], name: '@107', index: 107 }, // unknown base token
{ tokens: [1, 0], name: '@5', index: 6 }, // @name does not match index
{ tokens: [1, 0], name: 'X', index: 7 }, // not a spot name
{ tokens: [1, 0], name: '@8', index: 95_000 }, // out of id range
],
});
expect(res.pairs.map((p) => p.coin)).toEqual(['PURR/USDC']);
expect(res.skipped).toEqual(['tokens[2]', 'universe[1]', 'universe[2]', 'universe[3]', 'universe[4]']);
});
it('rejects duplicate indices or names', () => {
expect(() => parseSpotMeta({ tokens: [USDC, USDC], universe: [] })).toThrow(/duplicate token index/);
expect(() =>
parseSpotMeta({ tokens: [USDC, PURR], universe: [{ tokens: [1, 0], name: '@3', index: 3 }, { tokens: [1, 0], name: '@3', index: 3 }] }),
).toThrow(/duplicate pair index/);
expect(() =>
parseSpotMeta({ tokens: [USDC, PURR], universe: [{ tokens: [1, 0], name: 'PURR/USDC', index: 0 }, { tokens: [1, 0], name: 'PURR/USDC', index: 1 }] }),
).toThrow(/duplicate pair name/);
expect(() => parseSpotMeta({ tokens: [] })).toThrow(MetaValidationError);
});
});