Repository Public
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