SPB Git

Repository Public

perp-live-world.ts 6.9 KB 86 lines typescript

1// The world of a LIVE perp agent for tests and for scripts/sim-perp-agent-live.mts: the REAL live execution engine, the REAL2// isolated signer class with its REAL venue reader (in process, a public test mnemonic or a throwaway one), a software passkey3// producing real WebAuthn assertions, and the scripted fake of the Hyperliquid exchange + info endpoints (hl-fake.ts — the one4// derivatives-mandate.test.ts uses). No network: a request to any real host fails. Import AFTER the database environment is set5// (`useDatabase`), because it loads the API's source lazily.6import { createHash } from 'node:crypto';7import { mkdtempSync } from 'node:fs';8import { tmpdir } from 'node:os';9import { join } from 'node:path';10import { TestPasskey } from '../../../tests/helpers/passkey.ts';11import { deriveKeys } from '../../isolated-signer/src/keys.ts';12import { Signer, SignerError } from '../../isolated-signer/src/signer.ts';13import { Store } from '../../isolated-signer/src/store.ts';14import { hyperliquidReader } from '../../isolated-signer/src/venue-read.ts';15import { Clock } from './derivatives-helpers.ts';16import { FakeHl, routeFetch } from './hl-fake.ts';1718export const RP = 'wallet.test', ORIGIN = 'https://wallet.test', BTC = 'hyperliquid:BTC';19export const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';20/** the venue authorization of the tests: BTC, 3×, 1 000 per order and position, 1 500 gross */21export const AUTH_POLICY = { allowedInstruments: [BTC], maxLeverage: 3, maxOrderNotional: '1000', maxPositionNotional: '1000', maxGrossExposure: '1500', maxOrdersPerDay: 50, maxPriceDeviationBps: 300 };2223/**24 * `real`: instead of the fake, the venue is reached through this fetch (scripts/sim-perp-agent-live.mts --testnet passes one25 * that only lets the Hyperliquid TESTNET through) and the clock is the wall clock; the fakes then stay unused.26 */27export async function liveWorld(o: { mnemonic?: string; start?: number; real?: { fetch: typeof fetch } } = {}) {28  const lv = await import('../src/derivatives/live.ts'), ex = await import('../src/derivatives/exec.ts'), { UserError } = await import('../src/config.ts');29  const keys = deriveKeys(o.mnemonic ?? TEST_MNEMONIC), ACCOUNT = keys.addresses.evm.toLowerCase();30  const clock = new Clock(o.start ?? Date.parse('2026-11-11T12:00:00Z'));31  if (o.real) { clock.now = () => Date.now(); clock.advance = () => Date.now(); }32  const t = new FakeHl('testnet', clock.now), m = new FakeHl('mainnet', clock.now);33  const dir = mkdtempSync(join(tmpdir(), 'spw-perp-live-'));34  const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));35  store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');36  /** the signer's OWN prices (it never takes one from the API) */37  const px: { BTC: number; fail: string } = { BTC: 70_000, fail: '' };38  /** an info request type the venue fails on (502): for the API, or for the signer's own reader */39  const faults: { info: string | null; signerInfo: string | null } = { info: null, signerInfo: null };40  const base = o.real?.fetch ?? routeFetch([t, m]);41  const failing = (which: 'info' | 'signerInfo') => (async (url: string, init?: RequestInit) => {42    const type = JSON.parse(String(init?.body ?? '{}')).type;43    if (String(url).endsWith('/info') && faults[which] === type) return new Response('bad gateway', { status: 502 });44    return base(url as never, init);45  }) as unknown as typeof fetch;46  const reader = hyperliquidReader(async (url, body) => {47    const r = await failing('signerInfo')(url, { method: 'POST', body: JSON.stringify(body) });48    if (!r.ok) throw new Error(`HTTP ${r.status}`);49    return r.json();50  });51  const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => Math.floor(clock.now() / 1000),52    { get: async (s) => { if (px.fail) throw new Error(px.fail); return Object.fromEntries(s.map((k) => [k, (px as unknown as Record<string, number>)[k]!])); } }, reader);53  const pk = new TestPasskey(RP, ORIGIN);54  const call = async <T>(fn: () => T | Promise<T>): Promise<T> => { try { return await fn(); } catch (e) { if (e instanceof SignerError) throw new UserError(`signer_${e.code}`, e.message, 422); throw e; } };55  /** every request that reached the signer's mandate signing endpoint (orders carry `context.order`, leverage changes do not) */56  const mandateSigns: any[] = [], signs: unknown[] = [];57  const port: import('../src/derivatives/exec.ts').LiveSignerPort = {58    status: () => call(() => signer.status()), venueKey: (v, n) => call(() => signer.venueKey(v, n)),59    venueAuthorize: (a, s) => call(() => signer.venueAuthorize(a, s as never)), venueRevoke: (id) => call(() => signer.venueRevoke(id)),60    venueSign: (p) => { signs.push(p); return call(() => signer.venueSign(p as never)); },61  };62  const mandatePort: import('../src/derivatives/exec.ts').MandateSignerPort = {63    venueMandateApprove: (x, s) => call(() => signer.venueMandateApprove(x, s as never)), venueMandateRevoke: (id) => call(() => signer.venueMandateRevoke(id)),64    venueMandateSign: (p) => { mandateSigns.push(p); return call(() => signer.venueMandateSign(p as never)); },65  };66  const flags = { mainnet: false, mandateMainnet: false };67  const svc = lv.createLive({ fetch: failing('info'), signer: port, mandateSigner: mandatePort, now: clock.now, rpId: RP, mainnetEnabled: () => flags.mainnet, mandateMainnetEnabled: () => flags.mandateMainnet,68    credentials: async () => [{ id: pk.id, transports: ['internal'] }], confirmDelayMs: 0 });69  const b64 = (hexHash: string) => Buffer.from(hexHash, 'hex');70  /** the owner's part in Futures → Accounts: a venue account, funded on the fake venue, authorised with the passkey */71  const authorise = async (network: 'testnet' | 'mainnet' = 'testnet', fund: string | null = '1000', policy: Record<string, unknown> = AUTH_POLICY, days = 30): Promise<string> => {72    if (signer.status().credentials.length === 0) await signer.enroll({ credential: pk.credential(), setupToken: 'tok' });73    if (fund !== null) (network === 'testnet' ? t : m).fund(ACCOUNT, fund);74    const id = (await svc.createAccount({ venue: 'hyperliquid', network, label: network === 'testnet' ? 'Testnet' : 'Mainnet' })).id as string;75    const prep = await svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy, days, locale: 'fr' }));76    await svc.approveAuthorization(id, { assertion: pk.assert(b64(prep.hash)) as never, acknowledged: true });77    return id;78  };79  return { clock, t, m, signer, store, pk, px, flags, faults, signs, mandateSigns, svc, ACCOUNT, b64, authorise, port, mandatePort,80    /** requests to the signer's mandate endpoint for ORDERS (not leverage changes) */81    orderSigns: () => mandateSigns.filter((s) => s.context?.order).length,82    /** a minute later: past the signer's ledger of the last order, and a fresh minute of the adapter's local rate budget */83    next: (ms = 61_000) => clock.advance(ms) };84}85export type LiveWorld = Awaited<ReturnType<typeof liveWorld>>;86
Files main
Menu

Theme