Repository Public
1/**2 * Controlled LIVE trading — typed client of `/api/v1/derivatives/live` and the pure helpers of the live screens.3 * Binding reference: docs/v4/API-LIVE-NOTES.md (routes, objects, error codes); truth for shapes: services/api/src/derivatives/4 * {live-routes,exec}.ts. Nothing here signs anything: a passkey assertion is produced by the browser and passed on untouched.5 *6 * Rules this file keeps for the screens:7 * - sizes and prices leave as the decimal strings the form produced (never through a JS number);8 * - ONE Idempotency-Key per review (`newKey`), reused on a retry of the same review, never reused for another order;9 * - what the passkey is asked to sign must be the order that was asked for (`canonicalProblem`), and must not be about to10 * expire (`remainingMs`).11 */12import { ApiError, get, post } from '../api';13import { D, dec, onTick, parseInput, worstPrice } from './dec';14import type { AccountState, DecStr, Fill, Instrument, Order, OrderBody, OrderRequest, Position, RiskPolicy, RiskResult } from './types';1516export type LiveNetwork = 'testnet' | 'mainnet';17// >>> v7 perp live: MANDATE_LIVE objects (docs/v4/API-LIVE-NOTES.md, section V6)18export type LiveLocale = 'fr' | 'en';19export interface MandateInstrument { instrument: string; maxLeverage: number; maxPositionNotional: DecStr }20export interface VenueMandate { v: 1; mandateId: string; authorizationId: string; keyId: string; venue: 'hyperliquid'; network: LiveNetwork; account: string; agentAddress: string; strategySha256: string; agentId: string;21 instruments: MandateInstrument[]; maxOrderNotional: DecStr; maxGrossExposure: DecStr; maxOrdersPerDay: number; maxOrdersPerHour: number; maxDailyTurnover: DecStr; floorEquity: DecStr; maxPriceDeviationBps: number;22 createdAt: number; /** unix SECONDS */ expiresAt: number; ackSha256: string }23export type MandatePhase = 'active' | 'wind_down' | 'ended';24export interface MandateSummary { id: string; hash: string; authorizationId: string; agentId: string; strategySha256: string; limits: VenueMandate; phase: MandatePhase; active: boolean; createdAt: number; expiresAt: number; expired: boolean;25 approvedAt: number | null; revokedAt: number | null; /** ms */ windDownUntil: number | null; ackSha256: string }26export interface SignerMandate { mandate: VenueMandate; hash: string; approvedAt: number | string | null; revoked: boolean; revokedAt: number | string | null; expired: boolean; phase: MandatePhase; /** unix SECONDS */ windDownUntil: number | null;27 today: { orders: number; reduceOnly: number; cancels: number; leverage: number; turnover: DecStr }; ordersLastHour: number; pendingOrders: number }28export interface MandateBody { instruments: MandateInstrument[]; maxOrderNotional: DecStr; maxGrossExposure: DecStr; maxDailyTurnover: DecStr; floorEquity: DecStr; maxOrdersPerDay: number; maxOrdersPerHour: number; maxPriceDeviationBps?: number;29 days: number; locale?: LiveLocale; strategySha256: string; agentId: string }30// <<< v7 perp live31export interface VenuePolicy { allowedInstruments: string[]; maxLeverage: number; maxOrderNotional: DecStr; maxPositionNotional: DecStr; maxGrossExposure: DecStr; maxOrdersPerDay: number; maxPriceDeviationBps: number }32export interface VenueAuthorization { v: 1; authorizationId: string; keyId: string; venue: 'hyperliquid'; network: LiveNetwork; account: string; agentAddress: string; agentName: string; mode: 'MANUAL_LIVE'; policy: VenuePolicy;33 createdAt: number; /** unix SECONDS */ expiresAt: number; approveNonce: number; ackSha256: string }34export interface VenueOrder { v: 1; authorizationId: string; venue: 'hyperliquid'; network: LiveNetwork; account: string; kind: 'order'; instrument: string; side: 'buy' | 'sell';35 type: 'limit' | 'stop_market' | 'stop_limit' | 'take_profit_market' | 'take_profit_limit'; size: DecStr; price: DecStr; triggerPrice: DecStr | null; reduceOnly: boolean; tif: 'gtc' | 'ioc' | 'alo'; clientId: string; nonce: number; expiresAt: number }36export interface VenueLeverage { v: 1; authorizationId: string; venue: 'hyperliquid'; network: LiveNetwork; account: string; kind: 'leverage'; instrument: string; leverage: number; marginMode: 'cross' | 'isolated'; nonce: number; expiresAt: number }37export interface Ack { venue: string; locale: 'fr' | 'en'; text: string; sha256: string; version: string; source: string }38export type LiveState = 'proposed' | 'risk_checked' | 'authorized' | 'signed' | 'submitted' | 'unknown' | 'open' | 'partially_filled' | 'filled' | 'cancelled' | 'rejected';39export interface LiveOrder { clientId: string; cloid: string | null; instrument: string; side: 'buy' | 'sell'; type: string; size: DecStr; price: DecStr | null; triggerPrice: DecStr | null; reduceOnly: boolean; tif: string;40 state: LiveState; final: boolean; venueOrderId: string | null; filled: DecStr; avgFillPrice: DecStr | null; reason: string | null; origin: 'owner' | 'close' | 'mandate'; hash: string | null; nonce: number | null; expiresAt: number | null;41 canonical: VenueOrder | null; createdAt: number; updatedAt: number; submittedAt: number | null; source: 'venue' | 'local' }42export interface LiveAction { id: string; kind: 'leverage' | 'cancel'; state: string; request: Record<string, unknown>; hash: string | null; nonce: number; expiresAt: number; error: string | null; createdAt: number; updatedAt: number }43export interface LiveAuthorization { id: string; hash: string | null; mode: string; agentName: string; policy: VenuePolicy; createdAt: number; expiresAt: number; ackSha256: string; active: boolean; expired: boolean; revokedAt: number | null; authorizedAt: number | null }44export interface LiveSummary { id: string; venue: 'hyperliquid'; network: LiveNetwork; kind: 'venue'; mode: 'READ_ONLY' | 'MANUAL_LIVE'; label: string; account: string; agentAddress: string; policy: RiskPolicy;45 halt: string | null; divergence: string | null; /** effective reason new orders are refused */ halted: string | null; mainnetEnabled: boolean; tradable: boolean;46 authorization: LiveAuthorization | null; pendingAuthorization: { authorizationId: string; preparedAt: number } | null; createdAt: number; updatedAt: number;47 /** v7 perp live (V6 server): every mode the account is in, and its derivative mandate */ modes?: string[]; mandate?: MandateSummary | null; pendingMandate?: { mandateId: string; preparedAt: number } | null }48export interface VenueState { state: AccountState | null; positions: Position[]; openOrders: Order[]; /** non-null = the venue could not be read: never show the account as empty */ error: string | null; ts: number; source: 'venue' }49export interface LiveRiskEvent { id: string; ts: number; kind: string; code: string | null; message: string; data: Record<string, unknown> }50export interface LiveFunding { instrument: string; amount: DecStr; rate: DecStr; size: DecStr; ts: number; source: 'venue' }51export interface LiveDetail extends LiveSummary { venueState: VenueState; orders: LiveOrder[]; actions: LiveAction[]; fills: (Fill & { source: 'venue' })[]; funding: LiveFunding[]; riskEvents: LiveRiskEvent[];52 equity: { ts: number; equity: DecStr; collateral: DecStr; marginUsed: DecStr; unrealizedPnl: DecStr; source: 'venue' }[]; ts: number }53export interface LiveEstimate { notional: DecStr; initialMargin: DecStr; fee: DecStr; feeNote: string; freeCollateralAfter: DecStr; leverage: DecStr; marginMode: 'cross' | 'isolated'; price: DecStr | null; mark: DecStr; liquidationPrice: null; liquidationNote: string; source: 'estimate' }54/** WebAuthn request as the server made it (`PublicKeyCredentialRequestOptionsJSON`): passed to the browser untouched */55export type PasskeyOptions = Parameters<typeof import('@simplewebauthn/browser').startAuthentication>[0]['optionsJSON'];56export interface Proposal { order: LiveOrder; canonical: VenueOrder | null; hash: string | null; expiresAt: number | null; estimate: LiveEstimate | null; idempotent: boolean; /** null = nothing (left) to approve */ options: PasskeyOptions | null;57 worstPrice?: DecStr | null; slippageBoundBps?: number | null; position?: Position }58export interface LivePreview { risk: RiskResult; estimate: LiveEstimate; request: OrderRequest; venueState: { state: AccountState; positions: Position[]; source: 'venue' }; ts: number }59export interface LiveConfig { mainnetEnabled: boolean; venues: { venue: string; networks: LiveNetwork[] }[]; modes: string[];60 // >>> v7 perp live: the V6 shape of GET /config (locale, reasons in both languages, the two mainnet locks)61 locale?: LiveLocale; mainnet?: { enabled: boolean; code: string | null; reason: string | null }; reasons?: Record<string, { fr: string; en: string }>;62 mandateLive: { available: boolean; code?: string | null; reason: string | null; mainnet?: { enabled: boolean; code: string | null; reason: string | null };63 bounds?: { maxDays: number; maxOrdersPerHour: number; maxLeverageChangesPerDay: number; maxInstruments: number; windDownSeconds: number; orderTtlMs: number } };64 // <<< v7 perp live65 pinnedAssets: { pinnedAt: string; mainnet: { instrument: string; maxLeverage: number }[]; testnet: { instrument: string; maxLeverage: number }[] };66 bounds: { maxLeverage: number; maxOrdersPerDay: number; minPriceBandBps: number; maxPriceBandBps: number; maxInstruments: number; maxAuthorizationDays: number; orderApprovalTtlMs: number }; ts: number }67export interface Collateral { account: string; network: LiveNetwork; equity: DecStr | null; collateral: DecStr | null; freeCollateral: DecStr | null; marginUsed: DecStr | null; withdrawable: DecStr | null; asset: string; error: string | null; ts: number; source: 'venue';68 deposits: { supported: boolean; how: string }; withdrawals: { supported: boolean; how: string }; locale?: LiveLocale }69export interface Prepared { authorization: VenueAuthorization; hash: string; acknowledgement: Ack; agent: { address: string; name: string; wireName: string; validUntil: number; can: string[]; cannot: string[]; note: string };70 whatIsSigned: { byMainKey: string; byPasskey: string }; venueState: VenueState; options: PasskeyOptions; ts: number }71// >>> v7 perp live72export interface MandatePrepared { mandate: VenueMandate; hash: string; acknowledgement: Ack; locale: LiveLocale; authorization: { id: string; hash: string | null; expiresAt: number; policy: VenuePolicy }; warning: string;73 enforcedBySigner: string[]; trustedBySigner: string[]; notEnforced: string[]; windDown: { seconds: number; text: string }; whatIsSigned: { byPasskey: string }; venueState: VenueState; options: PasskeyOptions; ts: number }74// <<< v7 perp live75export interface CancelAll { requested: number; accepted: number; failed: { venueOrderId: string | null; instrument: string; error: string }[]; remaining: Order[] | null; source: 'venue'; ts: number }76export interface Revoked extends LiveDetail { cancelled: CancelAll | null; warning: string | null; venueAgent: string; cancelError?: { code: string; message: string } | null; locale?: LiveLocale }77export interface LeverageProposal { action: LiveAction; canonical: VenueLeverage; hash: string; current: { leverage: DecStr; marginMode: 'cross' | 'isolated' } | null; expiresAt: number; options: PasskeyOptions }78export interface LeverageResult { action: LiveAction; idempotent: boolean; venue: { leverage: { leverage: DecStr; marginMode: string } | null; confirmed: boolean; source: 'venue' } | null }79/** the order body of a venue account: no `leverage` (a venue setting, changed with its own passkey-approved route), no `clientId` */80export type LiveOrderBody = Omit<OrderBody, 'leverage'>;8182const L = '/derivatives/live';83const A = (id: string) => `${L}/accounts/${encodeURIComponent(id)}`;84const arr = <T>(x: unknown): T[] => (Array.isArray(x) ? (x as T[]) : []);85const normDetail = <T extends LiveDetail>(j: any): T => ({ ...j, venueState: { ...(j.venueState ?? {}), state: j.venueState?.state ?? null, positions: arr(j.venueState?.positions), openOrders: arr(j.venueState?.openOrders), error: j.venueState ? j.venueState.error ?? null : 'no venue state in the answer' },86 orders: arr(j.orders), actions: arr(j.actions), fills: arr(j.fills), funding: arr(j.funding), equity: arr(j.equity),87 riskEvents: arr<any>(j.riskEvents).map((e) => ({ id: String(e.id ?? `${e.ts}${e.kind}`), ts: Number(e.ts) || 0, kind: String(e.kind ?? 'event'), code: typeof e.code === 'string' ? e.code : null, message: String(e.message ?? ''), data: e.data ?? {} })) });8889export const lapi = {90 /** `locale` selects the language of the server-authored sentences (codes never change) */91 config: (locale?: LiveLocale) => get<LiveConfig>(`${L}/config${locale ? `?locale=${locale}` : ''}`),92 accounts: async (): Promise<LiveSummary[]> => arr((await get(`${L}/accounts`)).accounts),93 /** one account per venue and network: a second call answers the same account */94 create: async (network: LiveNetwork, label: string): Promise<LiveDetail> => normDetail(await post(`${L}/accounts`, { venue: 'hyperliquid', network, label })),95 account: async (id: string): Promise<LiveDetail> => normDetail(await get(A(id))),96 collateral: (id: string, locale?: LiveLocale) => get<Collateral>(`${A(id)}/collateral${locale ? `?locale=${locale}` : ''}`),97 prepare: (id: string, body: { policy: VenuePolicy; days: number; locale: 'fr' | 'en' }) => post<Prepared>(`${A(id)}/authorization/prepare`, body),98 approveAuthorization: async (id: string, assertion: unknown): Promise<LiveDetail> => normDetail(await post(`${A(id)}/authorization/approve`, { assertion, acknowledged: true })),99 revoke: async (id: string, cancelOpenOrders: boolean, locale?: LiveLocale): Promise<Revoked> => normDetail<Revoked>(await post(`${A(id)}/authorization/revoke`, { cancelOpenOrders, ...(locale ? { locale } : {}) })),100 preview: (id: string, body: LiveOrderBody) => post<LivePreview>(`${A(id)}/orders/preview`, body),101 propose: (id: string, body: LiveOrderBody, key: string) => post<Proposal>(`${A(id)}/orders`, body, { 'idempotency-key': key }),102 approveOrder: (id: string, clientId: string, assertion: unknown) => post<{ order: LiveOrder; idempotent: boolean }>(`${A(id)}/orders/${encodeURIComponent(clientId)}/approve`, { assertion }),103 /** no passkey. Before approval it only withdraws the proposal (`sent: false`); after, the venue is asked */104 cancel: (id: string, clientId: string) => post<{ order: LiveOrder; sent: boolean; venue?: { outcome: 'accepted' | 'refused' | 'unknown'; error: string | null } }>(`${A(id)}/orders/${encodeURIComponent(clientId)}/cancel`),105 leverage: (id: string, body: { instrument: string; leverage: number; marginMode: 'cross' | 'isolated' }) => post<LeverageProposal>(`${A(id)}/leverage`, body),106 approveLeverage: (id: string, actionId: string, assertion: unknown) => post<LeverageResult>(`${A(id)}/leverage/${encodeURIComponent(actionId)}/approve`, { assertion }),107 halt: async (id: string, reason = ''): Promise<LiveDetail & { note: string }> => normDetail(await post(`${A(id)}/halt`, { reason })),108 resume: async (id: string): Promise<LiveDetail> => normDetail(await post(`${A(id)}/resume`)),109 cancelAll: (id: string) => post<CancelAll>(`${A(id)}/cancel-all`),110 /** a reduce-only market order on a fraction of the VENUE's position: a proposal, approved like any order */111 close: (id: string, instrument: string, fraction: DecStr, key: string) => post<Proposal>(`${A(id)}/close`, { instrument, fraction }, { 'idempotency-key': key }),112 rebase: async (id: string): Promise<LiveDetail> => normDetail(await post(`${A(id)}/rebase`)),113 reconcile: async (id: string): Promise<{ reconciled: number; fills: number; funding: number; divergence: string | null; account: LiveDetail }> => { const j = await post(`${A(id)}/reconcile`); return { ...j, account: normDetail(j.account) }; },114 streamUrl: (id: string): string => `/api/v1${A(id)}/stream`,115};116117// ------------------------------------------------------------------------------------------------ pure helpers (unit-tested)118119/** which money an account moves: decides the band, the labels and every confirmation */120export type Tone = 'paper' | 'testnet' | 'mainnet';121/** anything that is not known to be the testnet is treated as real money */122export const toneOf = (a: { kind: 'paper' | 'venue'; network: string }): Tone => (a.kind === 'paper' ? 'paper' : a.network === 'testnet' ? 'testnet' : 'mainnet');123124/** one Idempotency-Key per review: 8–100 characters of [A-Za-z0-9._:-] */125export const newKey = (prefix = 'ui'): string => `${prefix}-${crypto.randomUUID()}`;126127/** time left before the server refuses the approval; the server stops 2 s before `expiresAt`, the screen stops 3 s before */128export const APPROVAL_MARGIN_MS = 3000;129export function remainingMs(expiresAt: number | null, now: number, skewMs = 0): number {130 return expiresAt === null ? 0 : Math.max(0, expiresAt - (now - skewMs) - APPROVAL_MARGIN_MS);131}132/** « 1:27 », « 0:05 » */133export function clock(ms: number): string { const s = Math.ceil(Math.max(0, ms) / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }134135const same = (a: DecStr | null | undefined, b: DecStr | null | undefined): boolean => { const x = dec(a), y = dec(b); return x && y ? D.eq(x, y) : (a ?? null) === (b ?? null); };136/**137 * Is the canonical order (what the passkey signs) the order that was asked for? Returns the first field that differs, or null.138 * A market order comes back as a limit IOC at its worst price: that is expected. The server answers a re-used Idempotency-Key139 * with the STORED order "whatever the body says" — this check is what keeps a stale answer from being approved by mistake.140 */141export function canonicalProblem(body: LiveOrderBody, c: VenueOrder | null): string | null {142 if (!c) return 'canonical';143 if (c.instrument !== body.instrument) return 'instrument';144 if (c.side !== body.side) return 'side';145 if (!same(c.size, body.size)) return 'size';146 if (c.reduceOnly !== (body.reduceOnly ?? false)) return 'reduceOnly';147 const market = body.type === 'market';148 if (c.type !== (market ? 'limit' : body.type)) return 'type';149 if (c.tif !== (market ? 'ioc' : body.tif ?? 'gtc')) return 'tif';150 if (!same(c.triggerPrice, body.triggerPrice ?? null)) return 'triggerPrice';151 if (body.price !== null && body.price !== undefined && !same(c.price, body.price)) return 'price';152 if (!dec(c.price) || !D.isPos(D.from(c.price))) return 'price';153 return null;154}155/** the same for a close: reduce-only, IOC, the side that reduces the position, at most the position's size */156export function closeProblem(pos: { instrument: string; side: 'long' | 'short'; size: DecStr }, c: VenueOrder | null): string | null {157 if (!c) return 'canonical';158 if (c.instrument !== pos.instrument) return 'instrument';159 if (c.side !== (pos.side === 'long' ? 'sell' : 'buy')) return 'side';160 if (!c.reduceOnly) return 'reduceOnly';161 if (c.type !== 'limit' || c.tif !== 'ioc' || c.triggerPrice !== null) return 'type';162 const s = dec(c.size), p = dec(pos.size);163 if (!s || !p || !D.isPos(s) || D.gt(s, p)) return 'size';164 return null;165}166167export interface PolicyForm { instruments: string[]; maxLeverage: string; maxOrderNotional: string; maxPositionNotional: string; maxGrossExposure: string; maxOrdersPerDay: string; bandBps: string; days: string }168/**169 * Conservative starting values, all editable: one market, leverage 2, small notionals, few orders, one week. The price band is170 * the one field that differs by network: the signer compares every order price with its own USD spot price, and the testnet171 * mark wanders more than 1 % away from it (docs/v4/venues/hyperliquid.md, section 13).172 */173export function defaultPolicyForm(network: LiveNetwork, pinned: { instrument: string }[]): PolicyForm {174 const btc = pinned.find((p) => p.instrument === 'hyperliquid:BTC') ?? pinned[0];175 return { instruments: btc ? [btc.instrument] : [], maxLeverage: '2', maxOrderNotional: '50', maxPositionNotional: '100', maxGrossExposure: '150', maxOrdersPerDay: '20', bandBps: network === 'testnet' ? '1000' : '200', days: '7' };176}177export type PolicyProblem = 'instruments' | 'tooManyInstruments' | 'leverage' | 'leverageVenue' | 'order' | 'position' | 'gross' | 'orderAbovePosition' | 'positionAboveGross' | 'ordersPerDay' | 'band' | 'days';178const int = (s: string): number | null => (/^\d{1,6}$/.test(s.trim()) ? Number(s.trim()) : null);179const pos = (s: string): DecStr | null => { const v = parseInput(s); return v && D.isPos(D.from(v)) ? v : null; };180/** the form as the `VenuePolicy` the server takes, or the first reason it cannot be one (the server checks again) */181export function policyFromForm(f: PolicyForm, bounds: LiveConfig['bounds'], pinned: { instrument: string; maxLeverage: number }[]): { ok: true; policy: VenuePolicy; days: number } | { ok: false; problem: PolicyProblem } {182 const no = (problem: PolicyProblem) => ({ ok: false as const, problem });183 const allowed = f.instruments.filter((i) => pinned.some((p) => p.instrument === i));184 if (!allowed.length) return no('instruments');185 if (allowed.length > bounds.maxInstruments) return no('tooManyInstruments');186 const lev = int(f.maxLeverage);187 if (lev === null || lev < 1 || lev > bounds.maxLeverage) return no('leverage');188 if (allowed.some((i) => lev > (pinned.find((p) => p.instrument === i)?.maxLeverage ?? 0))) return no('leverageVenue');189 const order = pos(f.maxOrderNotional), position = pos(f.maxPositionNotional), gross = pos(f.maxGrossExposure);190 if (!order) return no('order');191 if (!position) return no('position');192 if (!gross) return no('gross');193 if (D.gt(D.from(order), D.from(position))) return no('orderAbovePosition');194 if (D.gt(D.from(position), D.from(gross))) return no('positionAboveGross');195 const perDay = int(f.maxOrdersPerDay);196 if (perDay === null || perDay < 1 || perDay > bounds.maxOrdersPerDay) return no('ordersPerDay');197 const band = int(f.bandBps);198 if (band === null || band < bounds.minPriceBandBps || band > bounds.maxPriceBandBps) return no('band');199 const days = int(f.days);200 if (days === null || days < 1 || days > bounds.maxAuthorizationDays) return no('days');201 return { ok: true, policy: { allowedInstruments: allowed, maxLeverage: lev, maxOrderNotional: order, maxPositionNotional: position, maxGrossExposure: gross, maxOrdersPerDay: perDay, maxPriceDeviationBps: band }, days };202}203/** does the authorization the server prepared carry exactly the limits that were asked for? */204export function samePolicy(a: VenuePolicy, b: VenuePolicy): boolean {205 return [...a.allowedInstruments].sort().join() === [...b.allowedInstruments].sort().join() && a.maxLeverage === b.maxLeverage && same(a.maxOrderNotional, b.maxOrderNotional) && same(a.maxPositionNotional, b.maxPositionNotional)206 && same(a.maxGrossExposure, b.maxGrossExposure) && a.maxOrdersPerDay === b.maxOrdersPerDay && a.maxPriceDeviationBps === b.maxPriceDeviationBps;207}208/** SHA-256 of the acknowledgement text exactly as displayed, hex — compared with the hash the authorization commits to */209export async function sha256Hex(text: string): Promise<string> {210 const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));211 return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, '0')).join('');212}213214/**215 * The venue's own words inside the API's `venue_rejected` message216 * ("the venue did not register the trading key: <words> — the authorization was revoked on the signer…").217 */218export function venueWords(message: string): string {219 const m = /^the venue did not register the trading key:\s*([\s\S]*?)\s+—\s+the authorization was revoked/.exec(message);220 return (m ? m[1]! : message).trim();221}222/** an unfunded account: the venue's documented answer to any action of an address that never deposited */223export const isUnfunded = (message: string): boolean => /must deposit before performing actions/i.test(message);224225export interface LiveErr { /** i18n key suffix: `flE_<key>` */ key: string; code: string; status: number; /** the server's own sentence, shown under ours */ detail: string; /** the proposal is gone: prepare again */ consumed: boolean; /** the same request may be sent again as is */ retry: boolean }226const KNOWN = new Set(['mainnet_disabled', 'mode_read_only', 'authorization_expired', 'venue_rejected', 'no_position', 'below_min_size', 'instrument_not_allowed', 'leverage_exceeded', 'halted', 'proposal_expired', 'bad_state', 'wrong_key',227 'bad_request', 'idempotency', 'bad_policy', 'unknown_account', 'unknown_order', 'unknown_action', 'unknown_instrument', 'unknown_venue', 'too_many_streams', 'asset_table_mismatch', 'venue_unavailable', 'venue_rate_limited',228 'signer_unreachable', 'signer_price', 'signer_no_price', 'signer_bad_approval', 'signer_unknown_credential', 'signer_policy', 'signer_mismatch', 'signer_expired', 'signer_revoked', 'signer_stale_nonce', 'signer_replay',229 'signer_acknowledgement', 'signer_unsupported', 'signer_wrong_key', 'signer_wrong_agent', 'signer_conflict', 'signer_halted', 'offline', 'stale_data', 'no_liquidity', 'csrf', 'unauthenticated']);230/**231 * Every failure of a live route as: which sentence to show, the server's words, and what the screen may offer next.232 * `halted` is two different moments: 422 on a proposal (nothing was prepared), 423 on an approval (the proposal is void).233 * A signer refusal or an expired proposal consumes the proposal. Only a lost connection (status 0) or a read failure of the234 * venue/signer transport leaves the same request safe to send again.235 */236export function liveError(e: unknown, stage: 'propose' | 'approve' | 'other' = 'other'): LiveErr {237 const x = e as { status?: number; code?: string; message?: string; name?: string };238 if (x?.name === 'NotAllowedError' || x?.name === 'AbortError') return { key: 'passkey_cancelled', code: 'passkey_cancelled', status: 0, detail: '', consumed: stage === 'approve', retry: false };239 if (!(e instanceof ApiError)) return { key: 'passkey_failed', code: String(x?.name ?? 'error'), status: 0, detail: String(x?.message ?? ''), consumed: stage === 'approve', retry: false };240 const code = e.code, detail = e.message && e.message !== 'error' && e.message !== code ? e.message : '';241 const key = code === 'halted' && e.status === 423 ? 'halted_423' : KNOWN.has(code) ? code : code.startsWith('signer_') ? 'signer_other' : 'other';242 const transport = e.status === 0 || code === 'venue_unavailable' || code === 'venue_rate_limited' || code === 'stale_data';243 // At approval the screen treats EVERY answered failure as the end of that proposal (the server marks the order `rejected`244 // whenever no signature came back, and an expired one `cancelled`): the only thing sent again as is, is an approval whose245 // answer was lost on the way (no answer, or an unexplained 5xx) — the server sends an order once however many times it is246 // approved, and answers the second call with the order's present state.247 // At proposal a 422 is stored under the Idempotency-Key as a refused order: the next review needs a new key.248 const lost = e.status === 0 || (e.status >= 500 && !['signer_unreachable', 'asset_table_mismatch', 'venue_unavailable', 'venue_rate_limited'].includes(code));249 const consumed = stage === 'approve' ? !lost : stage === 'propose' ? e.status === 422 : false;250 return { key, code, status: e.status, detail, consumed, retry: stage === 'approve' ? lost : transport };251}252253/** `unknown`, `submitted`, `signed`, `authorized`: the venue has not confirmed anything yet — never shown as done, never re-sent */254export const inFlight = (s: LiveState): boolean => s === 'authorized' || s === 'signed' || s === 'submitted' || s === 'unknown';255export const stateTone = (o: Pick<LiveOrder, 'state' | 'final'>): 'ok' | 'warn' | 'danger' | 'info' =>256 o.state === 'filled' || o.state === 'open' ? 'ok' : o.state === 'rejected' ? 'danger' : o.state === 'partially_filled' || o.state === 'cancelled' || inFlight(o.state) ? 'warn' : 'info';257258// ------------------------------------------------------------------------------------------------ the order form → the body of the live routes259/** market-like orders: bounded by the server at this distance from the book; a triggered order carries a worst price this far from its trigger */260export const LIVE_SLIPPAGE_BPS = 50;261type Kind = 'market' | 'limit' | 'stop' | 'tp';262/** i18n keys of the form's own problems (shared with the paper panel) */263export type BuildProblem = 'fxErrInstrument' | 'fxErrPrice' | 'fxErrTrigger' | 'fxErrSize' | 'fxErrSizeSmall';264const TYPE: Record<Kind, LiveOrderBody['type']> = { market: 'market', limit: 'limit', stop: 'stop_market', tp: 'take_profit_market' };265export interface LiveForm { side: 'buy' | 'sell'; kind: Kind; size: string; price: string; trigger: string; reduceOnly: boolean; postOnly: boolean }266/** the form as the body of the live routes; every amount is a canonical decimal string put on the venue's grid */267export function buildLive(f: LiveForm, inst: Pick<Instrument, 'id' | 'venue' | 'tickSize' | 'lotSize'>): { body: LiveOrderBody | null; problem: BuildProblem | null } {268 const none = (problem: BuildProblem | null) => ({ body: null, problem });269 const tick = dec(inst.tickSize), lot = dec(inst.lotSize);270 if (!tick || !lot || !D.isPos(tick) || !D.isPos(lot)) return none('fxErrInstrument');271 let price: string | null = null, trigger: string | null = null;272 if (f.kind === 'limit') { const p = parseInput(f.price); if (!p) return none(f.price.trim() ? 'fxErrPrice' : null); const q = onTick(D.from(p), tick, inst.venue); if (!D.isPos(q)) return none('fxErrPrice'); price = D.str(q); }273 else if (f.kind !== 'market') { const p = parseInput(f.trigger); if (!p) return none(f.trigger.trim() ? 'fxErrTrigger' : null); const q = onTick(D.from(p), tick, inst.venue); if (!D.isPos(q)) return none('fxErrTrigger');274 trigger = D.str(q); price = D.str(worstPrice(q, f.side, LIVE_SLIPPAGE_BPS, tick, inst.venue)); }275 const raw = parseInput(f.size);276 if (!raw) return none(f.size.trim() ? 'fxErrSize' : null);277 const size = D.quantize(D.from(raw), lot, 'down');278 if (!D.isPos(size)) return none('fxErrSizeSmall');279 return { body: { instrument: inst.id, side: f.side, type: TYPE[f.kind], size: D.str(size), price, triggerPrice: trigger, reduceOnly: f.reduceOnly, tif: f.kind === 'market' ? 'ioc' : f.kind === 'limit' && f.postOnly ? 'alo' : 'gtc',280 ...(f.kind === 'market' ? { slippageBps: LIVE_SLIPPAGE_BPS } : {}) }, problem: null };281}282