SPB Git

Repository Public

mandate-proof.test.ts 4.9 KB 62 lines typescript

1// Replay of what scripts/hl-mandate-testnet.mts recorded on the Hyperliquid TESTNET (unfunded run): actions signed by the2// trading key under a derivative MANDATE, without a passkey. For every request that was sent, the signature is re-verified here3// against OUR hash of the recorded payload, and the venue's answer must name the very address we recover.4import { readFileSync } from 'node:fs';5import { describe, expect, it } from 'vitest';6import { buildApproveAgent, l1SignPayload, recoverTypedDataSigner } from '../../src/venues/hyperliquid/signing.ts';78interface Exchange { step: string; request?: { action: any; nonce: number; signature: unknown; vaultAddress: string | null; expiresAfter: number | null }; status?: number; answer: any }9const proof = JSON.parse(readFileSync(new URL('../fixtures/hyperliquid/testnet-mandate-proof.json', import.meta.url), 'utf8')) as { network: string; account: string; agent: string; exchanges: Exchange[] };10const sent = proof.exchanges.filter((e) => e.request);1112describe('testnet proof of the mandate signing path (recorded)', () => {13  it('was recorded on testnet; one approval by the wallet key, then three actions by the trading key', () => {14    expect(proof.network).toBe('testnet');15    expect(proof.agent).not.toBe(proof.account);16    expect(sent.map((e) => e.request!.action.type)).toEqual(['approveAgent', 'updateLeverage', 'order', 'cancelByCloid']);17  });1819  it('the authorization the mandate extends: approveAgent by the WALLET key; the venue asked for a deposit', async () => {20    const e = sent[0]!, a = e.request!.action;21    const td = buildApproveAgent({ network: 'testnet', agentAddress: a.agentAddress, agentName: a.agentName, nonce: a.nonce, signatureChainId: a.signatureChainId }).typedData;22    expect(await recoverTypedDataSigner(td, e.request!.signature)).toBe(proof.account);23    expect(e.answer).toEqual({ status: 'err', response: `Must deposit before performing actions. User: ${proof.account}` });24  });2526  it('leverage change, reduce-only order and cancel signed under the mandate: the trading key, a 30 s venue expiry; the venue recovered the trading key', async () => {27    for (const e of sent.slice(1)) {28      const r = e.request!;29      expect(r.vaultAddress, e.step).toBeNull();30      expect(r.expiresAfter, e.step).toBe(r.nonce + 30_000);31      const td = l1SignPayload(r.action, { network: 'testnet', nonce: r.nonce, vaultAddress: null, expiresAfter: r.expiresAfter }).typedData;32      expect(await recoverTypedDataSigner(td, r.signature), e.step).toBe(proof.agent);33      expect(e.answer, e.step).toEqual({ status: 'err', response: `User or API Wallet ${proof.agent} does not exist.` });34      expect(JSON.stringify(e.answer)).not.toContain(proof.account);35    }36    expect(sent[1]!.request!.action).toMatchObject({ type: 'updateLeverage', asset: 3, isCross: true, leverage: 3 });37    expect(sent[2]!.request!.action.orders).toHaveLength(1);38    expect(sent[2]!.request!.action.orders[0]).toMatchObject({ a: 3, b: false, r: true, t: { limit: { tif: 'Ioc' } } });   // the only order sent is reduce-only39  });4041  it('the signer read the testnet account itself, and refused what it must refuse', () => {42    const read = proof.exchanges.find((e) => e.step.startsWith('signer read'))!.answer;43    expect(read).toMatchObject({ accountValue: '0.0', totalNtlPos: '0.0', positions: [], openOrders: [] });44    expect(read.time).toBeGreaterThan(1_790_000_000_000);45    const refusals = Object.fromEntries(proof.exchanges.filter((e) => e.step.startsWith('signer: ')).map((e) => [e.step.slice(8), String(e.answer)]));46    const find = (start: string) => Object.entries(refusals).find(([k]) => k.startsWith(start))![1];47    expect(find('order buy')).toMatch(/^signer_floor_equity: the venue reports an account value of 0\.0, below the mandate's floor of 5/);48    expect(find('leverage 4×')).toMatch(/^signer_leverage_exceeded/);49    expect(find('reduce-only order at half the market price')).toMatch(/^signer_price: price \d+ is more than 1000 bps away from the signer's own BTC price \d+/);50    expect(find('order above the mandate\'s notional')).toMatch(/^signer_order_notional/);51    expect(find('order on an instrument outside the mandate')).toMatch(/^signer_instrument: hyperliquid:ETH/);52    expect(find('the API describes a plain sell as reduce-only')).toMatch(/^signer_mismatch: .*reduce-only/);53    expect(find('the same client id again')).toMatch(/^signer_replay/);54    expect(find('an approveAgent for another address')).toMatch(/not an order, a cancel or a leverage change/);55    expect(find('a signature valid for more than 120 s')).toMatch(/^signer_policy/);56    expect(find('an order that can add exposure during the wind-down')).toMatch(/^signer_wind_down/);57    expect(find('a leverage change during the wind-down')).toMatch(/^signer_wind_down/);58    expect(find('a reduce-only order after the AUTHORIZATION was revoked')).toMatch(/^signer_revoked/);59    expect(find('a cancel after the authorization was revoked')).toMatch(/^signer_revoked/);60  });61});62
Files main
Menu

Theme