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