src/ws/subscriptionKey.test.ts
v0.3.0 · 11.5 KB
import { describe, expect, it } from 'vitest';
import {
canonicalizeSubscription,
channelsOf,
frameMatchesSubscription,
isValidAddress,
looseAckKey,
matchEcho,
normalizeAddress,
subscriptionKey,
trackedUserOf,
WsSubscriptionError,
} from './subscriptionKey.js';
import type { WsSubscription } from './types.js';
const A = '0x' + 'a'.repeat(40);
const A_MIXED = '0x' + 'aA'.repeat(20);
const B = '0x' + 'b'.repeat(40);
describe('normalizeAddress', () => {
it('trims and lowercases', () => {
expect(normalizeAddress(` ${A_MIXED} `)).toBe(A);
expect(normalizeAddress('0x' + 'A'.repeat(40))).toBe(A);
});
it('rejects placeholders and malformed addresses (a placeholder caused an endless reconnect loop)', () => {
for (const bad of ['0x...', '0xYOUR_ADDRESS', '', '0x' + 'a'.repeat(39), '0x' + 'g'.repeat(40), 'a'.repeat(42)]) {
expect(() => normalizeAddress(bad)).toThrow(WsSubscriptionError);
expect(isValidAddress(bad)).toBe(false);
}
expect(isValidAddress(A_MIXED)).toBe(true);
expect(isValidAddress(42)).toBe(false);
});
});
describe('canonicalizeSubscription / subscriptionKey', () => {
it('sorts keys, drops null/undefined and lowercases user', () => {
const c = canonicalizeSubscription({ user: A_MIXED, type: 'userFills', aggregateByTime: undefined });
expect(JSON.stringify(c)).toBe(`{"type":"userFills","user":"${A}"}`);
});
it('same logical subscription -> same key', () => {
expect(subscriptionKey({ type: 'l2Book', coin: 'BTC', nSigFigs: null })).toBe(subscriptionKey({ coin: 'BTC', type: 'l2Book' }));
expect(subscriptionKey({ type: 'userFills', user: A_MIXED })).toBe(subscriptionKey({ type: 'userFills', user: A }));
});
it('different params -> different keys (no accidental dedupe)', () => {
expect(subscriptionKey({ type: 'candle', coin: 'BTC', interval: '1m' })).not.toBe(
subscriptionKey({ type: 'candle', coin: 'BTC', interval: '5m' }),
);
expect(subscriptionKey({ type: 'l2Book', coin: 'BTC', nSigFigs: 5 })).not.toBe(subscriptionKey({ type: 'l2Book', coin: 'BTC' }));
});
it('does not lowercase coin names', () => {
expect(canonicalizeSubscription({ type: 'trades', coin: 'xyz:TSLA' })).toEqual({ type: 'trades', coin: 'xyz:TSLA' });
});
it('validates type and user', () => {
expect(() => canonicalizeSubscription({ type: '' } as WsSubscription)).toThrow(WsSubscriptionError);
expect(() => canonicalizeSubscription(null as unknown as WsSubscription)).toThrow(WsSubscriptionError);
expect(() => canonicalizeSubscription({ type: 'orderUpdates', user: '0x...' })).toThrow(WsSubscriptionError);
expect(() => canonicalizeSubscription({ type: 'custom', user: 5 })).toThrow(WsSubscriptionError);
});
});
describe('trackedUserOf / channelsOf', () => {
it('market subscriptions do not track users', () => {
expect(trackedUserOf({ type: 'l2Book', coin: 'BTC' })).toBeUndefined();
expect(trackedUserOf({ type: 'webData3', user: A_MIXED })).toBe(A);
});
it('maps userEvents to channel "user" and activeAssetCtx to perp and spot channels', () => {
expect(channelsOf({ type: 'userEvents', user: A })).toEqual(['user']);
expect(channelsOf({ type: 'activeAssetCtx', coin: '@1' })).toEqual(['activeAssetCtx', 'activeSpotAssetCtx']);
expect(channelsOf({ type: 'bbo', coin: 'BTC' })).toEqual(['bbo']);
});
});
describe('frameMatchesSubscription', () => {
it('routes by coin', () => {
const sub = { type: 'l2Book', coin: 'BTC' } as const;
expect(frameMatchesSubscription(sub, 'l2Book', { coin: 'BTC', levels: [[], []], time: 1 })).toBe(true);
expect(frameMatchesSubscription(sub, 'l2Book', { coin: 'ETH' })).toBe(false);
expect(frameMatchesSubscription(sub, 'bbo', { coin: 'BTC' })).toBe(false);
});
it('routes trades by the first trade coin; empty arrays match nothing', () => {
const sub = { type: 'trades', coin: 'ETH' } as const;
expect(frameMatchesSubscription(sub, 'trades', [{ coin: 'ETH' }])).toBe(true);
expect(frameMatchesSubscription(sub, 'trades', [{ coin: 'BTC' }])).toBe(false);
expect(frameMatchesSubscription(sub, 'trades', [])).toBe(false);
expect(frameMatchesSubscription(sub, 'trades', { coin: 'ETH' })).toBe(false);
});
it('routes candles by coin and interval', () => {
const sub = { type: 'candle', coin: 'BTC', interval: '1m' } as const;
expect(frameMatchesSubscription(sub, 'candle', { s: 'BTC', i: '1m' })).toBe(true);
expect(frameMatchesSubscription(sub, 'candle', { s: 'BTC', i: '5m' })).toBe(false);
});
it('routes allMids by dex (missing = main dex)', () => {
expect(frameMatchesSubscription({ type: 'allMids' }, 'allMids', { mids: {} })).toBe(true);
expect(frameMatchesSubscription({ type: 'allMids' }, 'allMids', { mids: {}, dex: 'xyz' })).toBe(false);
expect(frameMatchesSubscription({ type: 'allMids', dex: 'xyz' }, 'allMids', { mids: {}, dex: 'xyz' })).toBe(true);
});
it('routes user channels by user, case-insensitively', () => {
const sub = { type: 'userFills', user: A } as const;
expect(frameMatchesSubscription(sub, 'userFills', { user: A_MIXED, fills: [] })).toBe(true);
expect(frameMatchesSubscription(sub, 'userFills', { user: B, fills: [] })).toBe(false);
expect(frameMatchesSubscription({ type: 'allDexsClearinghouseState', user: A }, 'allDexsClearinghouseState', { user: B })).toBe(false);
expect(frameMatchesSubscription({ type: 'webData3', user: A }, 'webData3', { userState: { user: A_MIXED } })).toBe(true);
expect(frameMatchesSubscription({ type: 'webData3', user: A }, 'webData3', { userState: { user: B } })).toBe(false);
});
it('frames without a user field go to every subscription of the channel', () => {
expect(frameMatchesSubscription({ type: 'orderUpdates', user: A }, 'orderUpdates', [{ order: {} }])).toBe(true);
expect(frameMatchesSubscription({ type: 'userEvents', user: A }, 'user', { fills: [] })).toBe(true);
expect(frameMatchesSubscription({ type: 'allDexsAssetCtxs' }, 'allDexsAssetCtxs', { ctxs: [] })).toBe(true);
expect(frameMatchesSubscription({ type: 'activeAssetCtx', coin: '@1' }, 'activeSpotAssetCtx', { coin: '@1' })).toBe(true);
});
it('generic subscriptions compare user/coin when both sides have them', () => {
const sub = { type: 'userFundings', user: A };
expect(frameMatchesSubscription(sub, 'userFundings', { user: B })).toBe(false);
expect(frameMatchesSubscription(sub, 'userFundings', { user: A })).toBe(true);
expect(frameMatchesSubscription({ type: 'x', coin: 'BTC' }, 'x', { coin: 'ETH' })).toBe(false);
expect(frameMatchesSubscription({ type: 'x' }, 'x', null)).toBe(true);
});
});
describe('matchEcho (ack matching)', () => {
const pending = (subs: WsSubscription[]) =>
subs.map((s) => ({ key: subscriptionKey(s), subscription: canonicalizeSubscription(s) }));
it('matches an echo with a differently cased user', () => {
const p = pending([{ type: 'userFills', user: A }, { type: 'userFills', user: B }]);
expect(matchEcho(p, { type: 'userFills', user: A_MIXED })).toBe(p[0]?.key);
});
it('an echo for one user never confirms another user', () => {
const p = pending([{ type: 'orderUpdates', user: A }]);
expect(matchEcho(p, { type: 'orderUpdates', user: B })).toBeUndefined();
});
it('accepts server-added fields (subset) and prefers the most specific payload', () => {
const p = pending([{ type: 'l2Book', coin: 'BTC' }, { type: 'l2Book', coin: 'BTC', nSigFigs: 5 }]);
expect(matchEcho(p, { type: 'l2Book', coin: 'BTC', nSigFigs: 5, mantissa: null })).toBe(p[1]?.key);
expect(matchEcho(p, { type: 'l2Book', coin: 'BTC', nSigFigs: null, mantissa: null })).toBe(p[0]?.key);
});
it('falls back to the loose {type, coin, user, interval, dex} identity when fields were dropped', () => {
const p = pending([{ type: 'userFills', user: A, aggregateByTime: true }]);
expect(matchEcho(p, { type: 'userFills', user: A_MIXED })).toBe(p[0]?.key);
expect(looseAckKey({ type: 'userFills', user: A_MIXED, aggregateByTime: true })).toBe(`{"type":"userFills","user":"${A}"}`);
});
it('ignores non-objects', () => {
expect(matchEcho(pending([{ type: 'bbo', coin: 'BTC' }]), undefined)).toBeUndefined();
expect(matchEcho(pending([{ type: 'bbo', coin: 'BTC' }]), 'bbo')).toBeUndefined();
});
});
describe('live frame and echo shapes (mainnet, 2026-09-15)', () => {
it('dex "" is the main dex: same canonical form and key as no dex', () => {
expect(canonicalizeSubscription({ type: 'allMids', dex: '' })).toEqual({ type: 'allMids' });
expect(subscriptionKey({ type: 'allMids', dex: '' })).toBe(subscriptionKey({ type: 'allMids' }));
expect(subscriptionKey({ type: 'allMids', dex: 'xyz' })).not.toBe(subscriptionKey({ type: 'allMids' }));
});
it('matches the l2Book echo with server-added nulls and fast:false to the plain subscription', () => {
const plain = { type: 'l2Book', coin: 'BTC' } as const;
const agg = { type: 'l2Book', coin: 'BTC', nSigFigs: 5, mantissa: 2 } as const;
const pending = [plain, agg].map((s) => ({ key: subscriptionKey(s), subscription: canonicalizeSubscription(s) }));
expect(matchEcho(pending, { type: 'l2Book', coin: 'BTC', nSigFigs: null, mantissa: null, fast: false })).toBe(subscriptionKey(plain));
expect(matchEcho(pending, { type: 'l2Book', coin: 'BTC', nSigFigs: 5, mantissa: 2, fast: false })).toBe(subscriptionKey(agg));
});
it('matches the allMids main-dex echo to a dex "" subscription', () => {
const sub = { type: 'allMids', dex: '' } as const;
const pending = [{ key: subscriptionKey(sub), subscription: canonicalizeSubscription(sub) }];
expect(matchEcho(pending, { type: 'allMids' })).toBe(subscriptionKey(sub));
});
it('matches the candle echo whose key order differs from the request', () => {
const sub = { type: 'candle', coin: 'xyz:TSLA', interval: '1m' } as const;
const pending = [{ key: subscriptionKey(sub), subscription: canonicalizeSubscription(sub) }];
expect(matchEcho(pending, { type: 'candle', interval: '1m', coin: 'xyz:TSLA' })).toBe(subscriptionKey(sub));
});
it('routes HIP-3 and spot market frames by their exact coin', () => {
const trade = { coin: 'xyz:XYZ100', side: 'B', px: '25000.0', sz: '0.01', time: 1, hash: '0x00', tid: 123456789012345, users: ['0x0', '0x0'] };
expect(frameMatchesSubscription({ type: 'trades', coin: 'xyz:XYZ100' }, 'trades', [trade])).toBe(true);
expect(frameMatchesSubscription({ type: 'trades', coin: 'XYZ100' }, 'trades', [trade])).toBe(false);
const candle = { t: 1, T: 2, s: '@107', i: '1m', o: '82.1', c: '82.1', h: '82.1', l: '82.1', v: '53.35', n: 11 };
expect(frameMatchesSubscription({ type: 'candle', coin: '@107', interval: '1m' }, 'candle', candle)).toBe(true);
expect(frameMatchesSubscription({ type: 'candle', coin: '@107', interval: '5m' }, 'candle', candle)).toBe(false);
const bbo = { coin: '@107', time: 1, bbo: [{ px: '82.103', sz: '90.2', n: 4 }, null] };
expect(frameMatchesSubscription({ type: 'bbo', coin: '@107' }, 'bbo', bbo)).toBe(true);
expect(frameMatchesSubscription({ type: 'allMids', dex: '' }, 'allMids', { mids: {} })).toBe(true);
expect(frameMatchesSubscription({ type: 'allMids', dex: 'xyz' }, 'allMids', { mids: {} })).toBe(false);
expect(frameMatchesSubscription({ type: 'allMids', dex: 'xyz' }, 'allMids', { dex: 'xyz', mids: {} })).toBe(true);
});
it('does not treat a zero-hash or hex-looking value as a user', () => {
expect(trackedUserOf({ type: 'trades', coin: 'BTC' })).toBeUndefined();
expect(canonicalizeSubscription({ type: 'l2Book', coin: 'kPEPE' })).toEqual({ type: 'l2Book', coin: 'kPEPE' });
});
});