Repository Public
1// The signer's own READ of a venue account, used only by derivative mandates (venue-mandate.ts): positions, account value,2// resting orders and the leverage setting of an instrument, from the venue's PUBLIC info endpoint. No key is needed to read,3// nothing is ever sent to the venue's trading endpoint from here, and the hosts are pinned below (never taken from a request).4//5// Why the signer reads the venue itself: a mandate signs orders without a passkey, so its position and exposure limits must6// not depend on what the API says the positions are. What this trusts: the venue's info endpoint, for the SIZE of the7// positions, the resting orders and the account value. A venue that lies about them can already do anything with the funds8// it holds; it is the same party that executes the orders. What it does not trust the venue for: prices (prices.ts).9// Any doubt — unreachable, HTTP error, an answer that does not have exactly the expected shape — is an error: no signature.10export type VenueNetwork = 'mainnet' | 'testnet';11export interface VenuePositionView { coin: string; /** signed size */ szi: string; /** the venue's own valuation of the position, at its mark */ positionValue: string; leverage: { type: 'cross' | 'isolated'; value: number } }12export interface VenueOpenOrderView { coin: string; side: 'buy' | 'sell'; /** remaining size */ size: string; /** the higher of the limit and the trigger price */ price: string; reduceOnly: boolean; cloid: string | null; /** when the venue took the order (unix ms) */ timestamp: number }13export interface VenueAccountView { /** the venue's own timestamp of the account state (unix ms) */ time: number; accountValue: string; totalNtlPos: string; positions: VenuePositionView[]; openOrders: VenueOpenOrderView[] }14export interface VenueReader {15 /** positions, account value and resting orders of an account; throws when they cannot be established */16 account(network: VenueNetwork, account: string): Promise<VenueAccountView>;17 /** the leverage setting of one instrument on the account */18 leverage(network: VenueNetwork, account: string, coin: string): Promise<{ type: 'cross' | 'isolated'; value: number }>;19}20export type JsonPost = (url: string, body: unknown) => Promise<unknown>;2122/** the only venue hosts the signer ever contacts, and only their /info path */23export const VENUE_INFO_URL: Record<VenueNetwork, string> = { mainnet: 'https://api.hyperliquid.xyz/info', testnet: 'https://api.hyperliquid-testnet.xyz/info' };24const TIMEOUT_MS = 6000, MAX_BYTES = 400_000, MAX_ROWS = 2000;25const post: JsonPost = async (url, body) => {26 const r = await fetch(url, { method: 'POST', signal: AbortSignal.timeout(TIMEOUT_MS), redirect: 'error', headers: { accept: 'application/json', 'content-type': 'application/json', 'user-agent': 'account-check' }, body: JSON.stringify(body) });27 if (!r.ok) throw new Error(`HTTP ${r.status}`);28 const t = await r.text();29 if (t.length > MAX_BYTES) throw new Error('response too large');30 return JSON.parse(t);31};3233const DEC = /^-?(0|[1-9]\d{0,24})(\.\d{1,24})?$/, COIN = /^[A-Za-z0-9:@/._-]{1,40}$/, ADDRESS = /^0x[0-9a-f]{40}$/;34const bad = (what: string): never => { throw new Error(`unexpected venue answer (${what})`); };35const obj = (v: unknown, what: string): Record<string, unknown> => (v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : bad(what));36const dec = (v: unknown, what: string): string => (typeof v === 'string' && DEC.test(v) ? v : bad(what));37const nonNeg = (v: unknown, what: string): string => { const d = dec(v, what); return d.startsWith('-') ? bad(what) : d; };38const coin = (v: unknown, what: string): string => (typeof v === 'string' && COIN.test(v) ? v : bad(what));39const lev = (v: unknown, what: string): { type: 'cross' | 'isolated'; value: number } => {40 const l = obj(v, what);41 if ((l.type !== 'cross' && l.type !== 'isolated') || !Number.isInteger(l.value) || (l.value as number) < 1 || (l.value as number) > 1000) bad(what);42 return { type: l.type as 'cross' | 'isolated', value: l.value as number };43};44/** numeric comparison of two non-negative decimal strings, without floats */45const higher = (a: string, b: string): string => {46 const [ai, af = ''] = a.split('.'), [bi, bf = ''] = b.split('.'), n = Math.max(af.length, bf.length);47 return BigInt(ai + af.padEnd(n, '0')) >= BigInt(bi + bf.padEnd(n, '0')) ? a : b;48};4950export function hyperliquidReader(send: JsonPost = post): VenueReader {51 const user = (account: string): string => (ADDRESS.test(account) ? account : bad('account address'));52 return {53 async account(network, account) {54 const url = VENUE_INFO_URL[network] ?? bad('network'), u = user(account);55 // both must answer; the account state is read last so that its timestamp is not older than the order list56 const ordersRaw = await send(url, { type: 'frontendOpenOrders', user: u });57 const s = obj(await send(url, { type: 'clearinghouseState', user: u }), 'clearinghouseState');58 const m = obj(s.marginSummary, 'marginSummary');59 if (!Number.isSafeInteger(s.time) || (s.time as number) <= 0) bad('time');60 if (!Array.isArray(s.assetPositions) || s.assetPositions.length > MAX_ROWS) bad('assetPositions');61 const positions = (s.assetPositions as unknown[]).map((row): VenuePositionView => {62 const p = obj(obj(row, 'assetPosition').position, 'position');63 return { coin: coin(p.coin, 'position coin'), szi: dec(p.szi, 'szi'), positionValue: nonNeg(p.positionValue, 'positionValue'), leverage: lev(p.leverage, 'position leverage') };64 });65 if (!Array.isArray(ordersRaw) || ordersRaw.length > MAX_ROWS) bad('frontendOpenOrders');66 const openOrders = (ordersRaw as unknown[]).map((row): VenueOpenOrderView => {67 const o = obj(row, 'open order');68 if ((o.side !== 'B' && o.side !== 'A') || typeof o.reduceOnly !== 'boolean' || typeof o.isTrigger !== 'boolean') bad('open order');69 const limit = nonNeg(o.limitPx, 'limitPx'), trigger = o.isTrigger ? nonNeg(o.triggerPx, 'triggerPx') : '0';70 const cloid = o.cloid === null || o.cloid === undefined ? null : typeof o.cloid === 'string' && /^0x[0-9a-f]{32}$/.test(o.cloid) ? o.cloid : bad('cloid');71 if (!Number.isSafeInteger(o.timestamp) || (o.timestamp as number) < 0) bad('order timestamp');72 return { coin: coin(o.coin, 'order coin'), side: o.side === 'B' ? 'buy' : 'sell', size: nonNeg(o.sz, 'sz'), price: higher(limit, trigger), reduceOnly: o.reduceOnly as boolean, cloid, timestamp: o.timestamp as number };73 });74 return { time: s.time as number, accountValue: dec(m.accountValue, 'accountValue'), totalNtlPos: nonNeg(m.totalNtlPos, 'totalNtlPos'), positions, openOrders };75 },76 async leverage(network, account, c) {77 const url = VENUE_INFO_URL[network] ?? bad('network');78 const a = obj(await send(url, { type: 'activeAssetData', user: user(account), coin: coin(c, 'coin') }), 'activeAssetData');79 if (a.coin !== undefined && a.coin !== c) bad('activeAssetData is for another coin');80 return lev(a.leverage, 'leverage');81 },82 };83}84/** default of the Signer class (tests, CLI paths): no venue view, so nothing that needs one is ever signed */85export const noVenueReader: VenueReader = { account: async () => { throw new Error('no venue reader configured'); }, leverage: async () => { throw new Error('no venue reader configured'); } };86