Skip to content
markpaper

src/account/roles.test.ts

v0.3.0 · 6.9 KB

Download file
import { describe, expect, it } from 'vitest';
import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import { fetchSubAccounts, fetchUserAbstraction, fetchUserRole, parseUserRole, resolveTradingAccount } from './roles.js';

const addr = (n: number) => `0x${n.toString(16).padStart(40, '0')}`;
const ACCOUNT = addr(0x5);
const MASTER = addr(0x1);
const OTHER = addr(0x2);

function makeInfo(answer: unknown | ((body: InfoRequest) => unknown)) {
  const calls: InfoRequest[] = [];
  const info = (async (body: InfoRequest, _opts?: InfoCallOptions) => {
    calls.push(body);
    return typeof answer === 'function' ? (answer as (b: InfoRequest) => unknown)(body) : answer;
  }) as InfoRequester;
  return { info, calls };
}

describe('parseUserRole', () => {
  it('agent → account in data.user; subAccount/vault → data.master; user/missing → null', () => {
    expect(parseUserRole({ role: 'agent', data: { user: MASTER.toUpperCase().replace('0X', '0x') } })).toEqual({
      role: 'agent',
      master: MASTER,
    });
    expect(parseUserRole({ role: 'subAccount', data: { master: MASTER } })).toEqual({ role: 'subAccount', master: MASTER });
    expect(parseUserRole({ role: 'vault', data: { master: MASTER } })).toEqual({ role: 'vault', master: MASTER });
    expect(parseUserRole({ role: 'vault' })).toEqual({ role: 'vault', master: null });
    expect(parseUserRole({ role: 'user' })).toEqual({ role: 'user', master: null });
    expect(parseUserRole({ role: 'missing' })).toEqual({ role: 'missing', master: null });
  });

  it('unknown roles are invalid responses', () => {
    expect(() => parseUserRole({ role: 'admin' })).toThrow(expect.objectContaining({ code: 'INVALID_RESPONSE' }));
    expect(() => parseUserRole(null)).toThrow();
  });
});

describe('fetchUserRole', () => {
  it('sends userRole for the lowercased address', async () => {
    const { info, calls } = makeInfo({ role: 'user' });
    await fetchUserRole(info, ACCOUNT);
    expect(calls).toEqual([{ type: 'userRole', user: ACCOUNT }]);
  });
});

describe('resolveTradingAccount', () => {
  it('main account: no vaultAddress, agent approved on the account itself', async () => {
    expect(await resolveTradingAccount(makeInfo({ role: 'user' }).info, ACCOUNT)).toEqual({
      account: ACCOUNT,
      role: 'user',
      owner: ACCOUNT,
      vaultAddress: null,
      roleSource: 'exchange',
    });
  });

  it('sub-account: trade with vaultAddress = sub, agent approved on the master', async () => {
    const r = await resolveTradingAccount(makeInfo({ role: 'subAccount', data: { master: MASTER } }).info, ACCOUNT, {
      configuredMaster: MASTER,
    });
    expect(r).toEqual({ account: ACCOUNT, role: 'subAccount', owner: MASTER, vaultAddress: ACCOUNT, roleSource: 'exchange' });
  });

  it('an API wallet address in the account field is rejected with a clear error', async () => {
    await expect(resolveTradingAccount(makeInfo({ role: 'agent', data: { user: MASTER } }).info, ACCOUNT)).rejects.toMatchObject({
      code: 'ADDRESS_IS_AGENT',
    });
  });

  it('unknown address (typo) is rejected', async () => {
    await expect(resolveTradingAccount(makeInfo({ role: 'missing' }).info, ACCOUNT)).rejects.toMatchObject({
      code: 'UNKNOWN_ADDRESS',
    });
  });

  it('configured master must match the exchange', async () => {
    await expect(
      resolveTradingAccount(makeInfo({ role: 'subAccount', data: { master: MASTER } }).info, ACCOUNT, { configuredMaster: OTHER }),
    ).rejects.toMatchObject({ code: 'MASTER_MISMATCH' });
  });

  it('a configured master for a plain user account is a mismatch (read B, execute on A)', async () => {
    await expect(resolveTradingAccount(makeInfo({ role: 'user' }).info, ACCOUNT, { configuredMaster: MASTER })).rejects.toMatchObject({
      code: 'MASTER_MISMATCH',
    });
  });

  it('master equal to the account is a config error', async () => {
    await expect(resolveTradingAccount(makeInfo({ role: 'user' }).info, ACCOUNT, { configuredMaster: ACCOUNT })).rejects.toMatchObject({
      code: 'MASTER_MISMATCH',
    });
  });

  it('sub-account without a master anywhere is rejected; config fills a missing master', async () => {
    await expect(resolveTradingAccount(makeInfo({ role: 'subAccount' }).info, ACCOUNT)).rejects.toMatchObject({
      code: 'MASTER_UNKNOWN',
    });
    const r = await resolveTradingAccount(makeInfo({ role: 'vault' }).info, ACCOUNT, { configuredMaster: MASTER });
    expect(r).toMatchObject({ owner: MASTER, vaultAddress: ACCOUNT, role: 'vault' });
  });

  it('network failure: trust a configured master (sub-account), otherwise rethrow', async () => {
    const failing = () =>
      makeInfo(() => {
        throw new Error('ETIMEDOUT');
      }).info;
    expect(await resolveTradingAccount(failing(), ACCOUNT, { configuredMaster: MASTER })).toEqual({
      account: ACCOUNT,
      role: null,
      owner: MASTER,
      vaultAddress: ACCOUNT,
      roleSource: 'config',
    });
    await expect(resolveTradingAccount(failing(), ACCOUNT)).rejects.toThrow('ETIMEDOUT');
  });

  it('a malformed userRole answer is not treated as a network failure', async () => {
    await expect(resolveTradingAccount(makeInfo({ nope: 1 }).info, ACCOUNT, { configuredMaster: MASTER })).rejects.toMatchObject({
      code: 'INVALID_RESPONSE',
    });
  });
});

describe('fetchSubAccounts', () => {
  it('null means no sub-accounts', async () => {
    const { info, calls } = makeInfo(null);
    expect(await fetchSubAccounts(info, MASTER)).toEqual([]);
    expect(calls).toEqual([{ type: 'subAccounts', user: MASTER }]);
  });

  it('maps name, address, master and keeps nested states raw', async () => {
    const nested = { marginSummary: { accountValue: '1' } };
    const { info } = makeInfo([
      { name: 'sub-1', subAccountUser: ACCOUNT.toUpperCase().replace('0X', '0x'), master: MASTER, clearinghouseState: nested, spotState: { balances: [] } },
      { name: 'bare', subAccountUser: OTHER },
    ]);
    expect(await fetchSubAccounts(info, MASTER)).toEqual([
      { name: 'sub-1', address: ACCOUNT, master: MASTER, clearinghouseState: nested, spotState: { balances: [] } },
      { name: 'bare', address: OTHER, master: MASTER, clearinghouseState: null, spotState: null },
    ]);
  });

  it('malformed answers throw', async () => {
    await expect(fetchSubAccounts(makeInfo({}).info, MASTER)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
    await expect(fetchSubAccounts(makeInfo([{ name: 'x' }]).info, MASTER)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
  });
});

describe('fetchUserAbstraction (experimental)', () => {
  it('returns the mode string', async () => {
    const { info, calls } = makeInfo('unifiedAccount');
    expect(await fetchUserAbstraction(info, ACCOUNT)).toBe('unifiedAccount');
    expect(calls).toEqual([{ type: 'userAbstraction', user: ACCOUNT }]);
  });

  it('non-string answers throw', async () => {
    await expect(fetchUserAbstraction(makeInfo(false).info, ACCOUNT)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
  });
});
All files