Skip to content
markpaper

src/ws/subscriptionKey.test.ts

v0.3.0 · 11.5 KB

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