SPB Git

Repository Public

venue.ts 18.1 KB 219 lines typescript

1// Live derivatives on a venue (Hyperliquid): the signer side. Docs: docs/v4/DERIVATIVES-ARCHITECTURE.md ("Signing"), docs/v4/venues/hyperliquid.md.2//3// Two keys, two very different powers:4//   - the MAIN wallet key signs, in THIS file, exactly one kind of venue message: Hyperliquid's `approveAgent`, naming the signer's own trading5//     key, once per authorization, and only after the owner's passkey approved the canonical authorization (venueAuthorize);6//   - the TRADING key (API wallet, path m/44'/60'/9000'/0/<i>) signs orders, cancels and leverage changes, only under a stored,7//     unrevoked, unexpired authorization, and only after this file has read the payload ITSELF (`describe`, pinned asset table).8// Nothing here can sign a transfer, a withdrawal, a sub-account or vault action, or an agent approval outside venueAuthorize.9// (v6: the main key's second and last venue message, `withdraw3` to the wallet's own address, is in venue-collateral.ts.)10import type { AuthenticationResponseJSON } from '@simplewebauthn/server';11import { sign } from 'viem/accounts';12import {13  createHlTrading, D, HL_KIND_L1, hex, hlPinnedAsset, hlPinnedAssets, jurisdictionAckOf, signatureFromHex, typedDataDigest, validateVenueAuthorization, venueAgentWireName,14  venueAuthorizationHash, VenueAuthorizationSchema, VENUE_PENDING_GRACE_MS, venueLeverageHash, VenueLeverageSchema, venueOrderHash, venueOrderNotional, VenueOrderSchema, venueWindowError, withinBand,15  type HlDescription, type HlL1SignPayload, type HlSignature, type HlUserSignPayload, type SignRequest, type VenueAuthorization,16} from '@spw/derivatives/live';17import type { Keys } from './keys.ts';18import type { PriceView } from './prices.ts';19import type { Store, StoredVenueAuthorization } from './store.ts';20import type { VenueReader } from './venue-read.ts';2122/** what venue.ts needs from the Signer: its keys, its store, its clock (unix seconds), its own prices and its passkey verifier */23export interface VenueCtx {24  keys: Keys; store: Store; now: () => number; prices: PriceView;25  /** the signer's own read of a venue account — used only by derivative mandates (venue-mandate.ts) */26  venueInfo: VenueReader;27  verify(assertion: AuthenticationResponseJSON, challenge: Uint8Array): Promise<unknown>;28  fail(code: string, msg: string): Error;29}30export interface VenueAuthorizeResult { authorizationId: string; hash: string; agentAddress: string; approve: StoredVenueAuthorization['approve'] }31export interface VenueSignRequest { authorizationId: string; sign: SignRequest; context?: { order?: unknown; leverage?: unknown }; assertion?: AuthenticationResponseJSON }32export interface VenueSignResult { signature: HlSignature; signer: string; kind: 'order' | 'cancel' | 'leverage'; hash: string }3334/** nonces remembered per trading key, as the venue does */35export const VENUE_NONCE_SET = 100;36const day = (nowSec: number) => new Date(nowSec * 1000).toISOString().slice(0, 10);37const hexKey = (priv: Uint8Array) => `0x${Buffer.from(priv).toString('hex')}` as `0x${string}`;38const canon = (x: string) => D.str(D.from(x));3940export function venueKeyAddress(ctx: VenueCtx, venue: unknown, network: unknown): { venue: 'hyperliquid'; network: 'mainnet' | 'testnet'; address: string; path: string; account: string } {41  if (venue !== 'hyperliquid' || (network !== 'mainnet' && network !== 'testnet')) throw ctx.fail('bad_request', 'unknown venue or network');42  const k = ctx.keys.venueKey(venue, network);43  return { venue, network, address: k.address, path: k.path, account: ctx.keys.addresses.evm.toLowerCase() };44}4546export function venueStatus(ctx: VenueCtx) {47  const now = ctx.now(), today = day(now);48  return Object.values(ctx.store.state.venues ?? {}).map((r) => ({49    authorization: r.authorization, hash: r.hash, approvedAt: r.approvedAt, revoked: r.revoked, revokedAt: r.revokedAt ?? null, expired: now >= r.authorization.expiresAt,50    lastNonce: r.nonces[r.nonces.length - 1] ?? 0, today: r.usage[today] ?? { orders: 0, cancels: 0, leverage: 0 },51  }));52}5354/**55 * The owner enables live trading on one venue account: passkey assertion over the canonical authorization. On success the56 * MAIN key signs the venue's approveAgent action for the signer's own trading key — the only venue message it signs here.57 */58export async function venueAuthorize(ctx: VenueCtx, raw: unknown, assertion: AuthenticationResponseJSON | undefined): Promise<VenueAuthorizeResult> {59  const st = ctx.store, now = ctx.now();60  const parsed = VenueAuthorizationSchema.safeParse(raw);61  if (!parsed.success) throw ctx.fail('bad_request', 'malformed venue authorization');62  const a: VenueAuthorization = parsed.data, hash = venueAuthorizationHash(a), id = a.authorizationId;63  const deny = (code: string, msg: string): never => { st.log('venue.authorize.denied', { authorizationId: id, venue: a.venue, network: a.network, code, msg }); st.save(); throw ctx.fail(code, msg); };64  if (st.state.halted) deny('policy', 'emergency halt is active');65  if (a.keyId !== ctx.keys.keyId) deny('wrong_key', 'authorization targets another key');66  try { validateVenueAuthorization(a, now); } catch (e) { deny('unsupported', (e as Error).message); }67  if (a.account !== ctx.keys.addresses.evm.toLowerCase()) deny('wrong_source', 'the venue account is not this wallet\'s own address');68  const agent = ctx.keys.venueKey(a.venue, a.network);69  if (a.agentAddress !== agent.address) deny('wrong_agent', 'the trading address does not match this signer\'s derivation: only the signer\'s own trading key can be approved');70  if (st.state.venues?.[id]) deny('replay', 'authorization already recorded');71  for (const r of Object.values(st.state.venues ?? {})) {72    if (r.authorization.venue === a.venue && r.authorization.network === a.network && !r.revoked && now < r.authorization.expiresAt) deny('conflict', 'another authorization is active for this venue account: revoke it first');73  }74  for (const inst of a.policy.allowedInstruments) {75    const pinned = hlPinnedAsset(a.network, inst);76    if (!pinned) deny('unsupported', `${inst} is not in the signer's pinned ${a.network} asset table`);77    if (a.policy.maxLeverage > pinned!.maxLeverage) deny('unsupported', `leverage ${a.policy.maxLeverage} is above the venue maximum of ${inst} (${pinned!.maxLeverage})`);78  }79  if (!jurisdictionAckOf(a.venue, a.ackSha256)) deny('acknowledgement', 'the acknowledgement hash is not the hash of the jurisdiction summary this signer knows');8081  // built here from the authorization alone; nothing from the API is signed82  const trading = createHlTrading({ network: a.network, assets: hlPinnedAssets(a.network) });83  let built: ReturnType<typeof trading.buildApproveAgent>;84  try { built = trading.buildApproveAgent({ agentAddress: a.agentAddress, agentName: venueAgentWireName(a), nonce: a.approveNonce }); }85  catch (e) { return deny('unsupported', (e as Error).message); }86  const d = trading.describe(built.sign);87  if (d.kind !== 'approve_agent' || d.detail.agentAddress !== agent.address || d.detail.validUntil !== a.expiresAt * 1000) deny('internal', 'approval action does not read back as expected');8889  if (!assertion) deny('bad_approval', 'the owner\'s passkey approval is required');90  try { await ctx.verify(assertion!, hash); } catch (e) { deny((e as { code?: string }).code ?? 'bad_approval', (e as Error).message); }9192  const signature = signatureFromHex(await sign({ hash: typedDataDigest((built.sign.payload as HlUserSignPayload).typedData), privateKey: hexKey(ctx.keys.evmPriv), to: 'hex' }));93  const rec: StoredVenueAuthorization = { authorization: a, hash: hex(hash), approvedAt: new Date(now * 1000).toISOString(), revoked: false,94    approve: { action: built.action, signature, nonce: a.approveNonce }, nonces: [], clientIds: {}, usage: {} };95  (st.state.venues ??= {})[id] = rec;96  st.log('venue.authorized', { authorizationId: id, hash: rec.hash, venue: a.venue, network: a.network, account: a.account, agentAddress: a.agentAddress, agentName: a.agentName, mode: a.mode,97    policy: a.policy, expiresAt: a.expiresAt, ackSha256: a.ackSha256 });98  st.save(); // durable before the signature leaves99  return { authorizationId: id, hash: rec.hash, agentAddress: agent.address, approve: rec.approve };100}101102/** Stopping never needs a passkey. A revoked authorization signs nothing, ever again (the record is kept, so a restart changes nothing). */103export function venueRevoke(ctx: VenueCtx, authorizationId: string): { revoked: true; revokedAt: string } {104  const r = ctx.store.state.venues?.[authorizationId];105  if (!r) throw ctx.fail('not_found', 'unknown venue authorization');106  if (!r.revoked) {107    r.revoked = true; r.revokedAt = new Date(ctx.now() * 1000).toISOString();108    ctx.store.log('venue.revoked', { authorizationId, venue: r.authorization.venue, network: r.authorization.network });109    ctx.store.save();110  }111  return { revoked: true, revokedAt: r.revokedAt! };112}113114/**115 * Sign ONE venue action with the trading key. The payload is read here, with the pinned asset table; what the API says it is116 * does not matter. MANUAL_LIVE: an order or a leverage change needs a passkey assertion over the hash of the canonical object117 * in `context`, which must equal, field by field, what the payload really does. A cancel needs no passkey.118 */119export async function venueSign(ctx: VenueCtx, req: VenueSignRequest): Promise<VenueSignResult> {120  const st = ctx.store, now = ctx.now(), nowMs = now * 1000;121  const id = String(req?.authorizationId ?? '');122  const rec = st.state.venues?.[id];123  if (!rec) throw ctx.fail('not_found', 'unknown venue authorization');124  const a = rec.authorization;125  const deny = (code: string, msg: string): never => { st.log('venue.sign.denied', { authorizationId: id, code, msg: msg.slice(0, 300) }); st.save(); throw ctx.fail(code, msg); };126  if (rec.revoked) deny('revoked', 'authorization revoked');127  if (now >= a.expiresAt) deny('expired', 'authorization expired');128  if (a.mode !== 'MANUAL_LIVE') deny('unsupported', 'only MANUAL_LIVE authorizations can sign');129  const s = req.sign;130  if (!s || typeof s !== 'object') deny('bad_request', 'missing sign request');131  if (s.venue !== a.venue || s.network !== a.network) deny('wrong_venue', 'the sign request is for another venue or network than the authorization');132133  // 1. what does the payload do? (independent reading, pinned table)134  const trading = createHlTrading({ network: a.network, assets: hlPinnedAssets(a.network) });135  let d: HlDescription;136  try { d = trading.describe(s); } catch (e) { return deny('rejected', `payload refused: ${(e as Error).message}`); }137  if (d.kind === 'approve_agent') deny('unsupported', 'an agent approval can only be signed through /venue/authorize, by the main key, with the owner\'s passkey');138  if (d.kind === 'other' || s.kind !== HL_KIND_L1) deny('unsupported', `this payload is not an order, a cancel or a leverage change (${String(d.detail.type ?? d.detail.primaryType ?? s.kind).slice(0, 60)}): the trading key signs nothing else`);139  const p = d.detail as { nonce: number; vaultAddress: string | null; expiresAfter: number | null; connectionId: string };140  if (p.vaultAddress !== null) deny('unsupported', 'sub-accounts and vaults are not traded');141  if (p.expiresAfter === null) deny('policy', 'the action carries no venue expiry: a signature that never expires is not released');142  const win = venueWindowError({ nonce: p.nonce, expiresAt: p.expiresAfter! }, nowMs);143  if (win) deny(/expired/.test(win) ? 'expired' : /stale/.test(win) ? 'stale_nonce' : 'policy', win);144  // the venue's own rule, applied here first: "the 100 highest nonces are stored per address. Every new transaction must have145  // nonce larger than the smallest nonce in this set and also never have been used before" (nonces are per signer key)146  if (rec.nonces.includes(p.nonce)) deny('stale_nonce', 'nonce already used with this trading key');147  if (rec.nonces.length >= VENUE_NONCE_SET && p.nonce <= rec.nonces[0]!) deny('stale_nonce', 'nonce is below the lowest of the last nonces signed with this trading key');148  const allowed = (inst: string) => { if (!a.policy.allowedInstruments.includes(inst)) deny('policy', `${inst} is not allowed by the authorization`); };149  const today = day(now), usage = rec.usage[today] ?? { orders: 0, cancels: 0, leverage: 0 };150  let hash: string, summary: Record<string, unknown>;151152  if (d.kind === 'cancel') {153    // no passkey, no halt check: withdrawing an order is always possible while the authorization lives154    const cancels = d.detail.cancels as { instrument: string; venueOrderId?: string; clientId?: string }[];155    for (const c of cancels) allowed(c.instrument);156    usage.cancels += cancels.length; hash = p.connectionId.slice(2);157    summary = { cancels: cancels.length, instruments: [...new Set(cancels.map((c) => c.instrument))] };158  } else if (d.kind === 'order') {159    if (st.state.halted) deny('policy', 'emergency halt is active');160    if (!d.orders || d.orders.length !== 1) deny('unsupported', 'exactly one order per signature');161    const o = d.orders![0]!;162    const cp = VenueOrderSchema.safeParse(req.context?.order);163    if (!cp.success) deny('bad_request', 'malformed order context');164    const c = cp.data!;165    const same: [string, boolean][] = [166      ['authorization', c.authorizationId === id && c.venue === a.venue && c.network === a.network && c.account === a.account],167      ['nonce', c.nonce === p.nonce], ['expiry', c.expiresAt === p.expiresAfter],168      ['instrument', c.instrument === o.instrument], ['side', c.side === o.side], ['type', c.type === o.type], ['size', canon(c.size) === o.size], ['price', o.price !== null && canon(c.price) === o.price],169      ['trigger price', (c.triggerPrice === null ? null : canon(c.triggerPrice)) === o.triggerPrice], ['reduce-only', c.reduceOnly === o.reduceOnly], ['time in force', c.tif === o.tif], ['client id', c.clientId === o.clientId],170    ];171    const diff = same.find(([, ok]) => !ok);172    if (diff) deny('mismatch', `the order shown for approval and the payload to sign differ (${diff[0]})`);173    allowed(c.instrument);174    const notional = venueOrderNotional(c);175    // the signer has no view of positions: reduce-only orders are bounded like the others176    if (D.gt(D.from(notional), D.from(a.policy.maxOrderNotional))) deny('policy', `order notional ${notional} is above the authorized ${a.policy.maxOrderNotional}`);177    if (usage.orders >= a.policy.maxOrdersPerDay) deny('policy', 'daily order count reached');178    if (rec.clientIds[c.clientId] !== undefined) deny('replay', 'an order with this client id was already signed');179    // the signer's own price: a compromised API cannot get a far-off-market order signed, even with a passkey approval of it180    const symbol = c.instrument.slice(c.instrument.indexOf(':') + 1);181    let mid: string;182    try { const px = (await ctx.prices.get([symbol]))[symbol]; if (!(typeof px === 'number' && px > 0 && Number.isFinite(px))) throw new Error('no price'); mid = canon(px.toFixed(8)); }183    catch (e) { return deny('no_price', `the signer could not establish its own market price for ${symbol} (${(e as Error).message.slice(0, 80)}); order refused`); }184    for (const [what, px] of [['price', c.price], ['trigger price', c.triggerPrice]] as const) {185      if (px !== null && !withinBand(px, mid, a.policy.maxPriceDeviationBps)) deny('price', `${what} ${canon(px)} is more than ${a.policy.maxPriceDeviationBps} bps away from the signer's own ${symbol} price ${mid}`);186    }187    if (!req.assertion) deny('bad_approval', 'MANUAL_LIVE: every order needs the owner\'s passkey approval');188    const oh = venueOrderHash(c);189    try { await ctx.verify(req.assertion!, oh); } catch (e) { deny((e as { code?: string }).code ?? 'bad_approval', (e as Error).message); }190    usage.orders += 1; rec.clientIds[c.clientId] = Math.floor(now / 86400); hash = hex(oh);191    // exposure ledger read by a derivative mandate on the same account (venue-mandate.ts): bookkeeping only, it decides nothing here192    if (!c.reduceOnly) (rec.pending ??= []).push({ clientId: c.clientId, instrument: c.instrument, side: c.side, size: canon(c.size), price: D.str(c.triggerPrice === null ? D.from(c.price) : D.max(D.from(c.price), D.from(c.triggerPrice))), until: c.expiresAt + VENUE_PENDING_GRACE_MS, signedAt: nowMs });193    summary = { instrument: c.instrument, side: c.side, type: c.type, size: canon(c.size), price: canon(c.price), triggerPrice: c.triggerPrice, reduceOnly: c.reduceOnly, tif: c.tif, clientId: c.clientId, notional, mid };194  } else {195    if (st.state.halted) deny('policy', 'emergency halt is active');196    const lp = VenueLeverageSchema.safeParse(req.context?.leverage);197    if (!lp.success) deny('bad_request', 'malformed leverage context');198    const l = lp.data!, x = d.detail as { instrument: string; leverage: string; marginMode: string };199    if (l.authorizationId !== id || l.venue !== a.venue || l.network !== a.network || l.account !== a.account || l.nonce !== p.nonce || l.expiresAt !== p.expiresAfter200      || l.instrument !== x.instrument || String(l.leverage) !== x.leverage || l.marginMode !== x.marginMode) deny('mismatch', 'the leverage change shown for approval and the payload to sign differ');201    allowed(l.instrument);202    if (l.leverage > a.policy.maxLeverage) deny('policy', `leverage ${l.leverage} is above the authorized ${a.policy.maxLeverage}`);203    if (!req.assertion) deny('bad_approval', 'MANUAL_LIVE: a leverage change needs the owner\'s passkey approval');204    const lh = venueLeverageHash(l);205    try { await ctx.verify(req.assertion!, lh); } catch (e) { deny((e as { code?: string }).code ?? 'bad_approval', (e as Error).message); }206    usage.leverage += 1; hash = hex(lh);207    summary = { instrument: l.instrument, leverage: l.leverage, marginMode: l.marginMode };208  }209210  // counters, replay marker and nonce are durable BEFORE the signature leaves211  rec.nonces = [...rec.nonces, p.nonce].sort((x, y) => x - y).slice(-VENUE_NONCE_SET); rec.usage[today] = usage;212  st.prune(now);213  const agent = ctx.keys.venueKey(a.venue, a.network);214  const signature = signatureFromHex(await sign({ hash: typedDataDigest((s.payload as HlL1SignPayload).typedData), privateKey: hexKey(agent.priv), to: 'hex' }));215  st.log('venue.sign.ok', { authorizationId: id, kind: d.kind, nonce: p.nonce, expiresAfter: p.expiresAfter, connectionId: p.connectionId, hash, ...summary });216  st.save();217  return { signature, signer: agent.address, kind: d.kind as VenueSignResult['kind'], hash };218}219
Files main
Menu

Theme