SPB Git

Repository Public

exec.ts 109.2 KB 1,245 lines typescript

1// sp-execution-engine: controlled LIVE trading on a venue account (Hyperliquid perpetuals). Ships disabled: a venue account is2// READ_ONLY until the owner approves a venue authorization with his passkey, which only the isolated signer judges.3//4// Order: validate → live ticker/book + the account's real state from the venue → risk engine → proposal persisted5// (`proposed` → `risk_checked`) → WebAuthn options whose challenge is the order hash → on approval `authorized` → signer6// (`signed`) → venue (`submitted`) → reconciliation against the venue's own order status and fills →7// `open | partially_filled | filled | cancelled | rejected`. A timeout or an unreadable answer is `unknown`: it is resolved only8// by asking the venue for the client order id, never by assumption.9//10// What this file can NOT do: it holds no key. It cannot make the signer sign anything but an order, a cancel or a leverage11// change that the signer has read itself, inside the limits of the authorization the owner approved.12import { randomUUID } from 'node:crypto';13import { generateAuthenticationOptions } from '@simplewebauthn/server';14import { z } from 'zod';15import { toB64u } from '@spw/core';16import {17  checkOrder, D, hlFormatPrice, initialMargin, OrderRequestSchema, RiskPolicySchema, VenueError,18  type AccountState, type Book, type DecStr, type Fill, type Instrument, type Order, type OrderRequest, type Position, type RiskPolicy, type RiskResult, type RiskUsage, type Ticker,19} from '@spw/derivatives';20import {21  hex, hlCloid, hlPinnedAsset, HL_PINNED_AT, HL_PINNED_ASSETS, jurisdictionAck, validateVenueAuthorization, venueAgentWireName, venueAuthorizationHash, venueLeverageHash, venueOrderHash, venueOrderNotional,22  VenuePolicySchema, VENUE_AUTH_MAX_DAYS, VENUE_POLICY_BOUNDS,23  validateVenueMandate, VENUE_MANDATE_BOUNDS, VENUE_MANDATE_MAX_DAYS, VENUE_MANDATE_WIND_DOWN_S, venueMandateHash, venueMandatePhase, VenueMandateSchema,24  type HlNetwork, type JurisdictionAck, type SignRequest, type VenueAuthorization, type VenueLeverage, type VenueMandate, type VenueMandatePhase, type VenueOrder, type VenuePolicy,25} from '@spw/derivatives/live';26import { UserError } from '../config.ts';27import { audit, sql } from '../db.ts';28import { LIVE_REASONS, liveReason, liveTexts, revokeWarning, type LiveLocale } from './live-texts.ts';29import { LIVE_ACTION_TTL_MS, type LiveVenue, type LiveVenues } from './live-venue.ts';30import { CloseBodySchema, DEFAULT_SLIPPAGE_BPS, OrderBodySchema, worstPrice, type OrderBody } from './paper.ts';3132export const LIVE_STEP_MS = 10_000;33/** funding payments are read this often (they happen once an hour) */34const FUNDING_EVERY_MS = 300_000;35/** after the venue expiry of a signed action, how long before "the venue has no such order" is taken as final */36export const NEVER_PLACED_GRACE_MS = 5_000;37/** an order left `authorized` this long means the process died while asking the signer: nothing was sent */38const AUTHORIZED_STUCK_MS = 60_000;39const TAKER_FEE_RATE = '0.00045';40const PRE_SUBMIT = ['proposed', 'risk_checked'], IN_FLIGHT = ['authorized', 'signed'], AT_VENUE = ['submitted', 'unknown', 'open', 'partially_filled', 'filled'];41export type LiveState = 'proposed' | 'risk_checked' | 'authorized' | 'signed' | 'submitted' | 'unknown' | 'open' | 'partially_filled' | 'filled' | 'cancelled' | 'rejected';4243export interface LiveSignerPort {44  status(fresh?: boolean): Promise<{ keyId: string; halted: boolean; addresses: Record<string, string> }>;45  venueKey(venue: string, network: string): Promise<{ venue: string; network: string; address: string; path: string; account: string }>;46  venueAuthorize(authorization: unknown, assertion: unknown): Promise<{ authorizationId: string; hash: string; agentAddress: string; approve: { action: unknown; signature: { r: string; s: string; v: number }; nonce: number } }>;47  venueSign(p: { authorizationId: string; sign: unknown; context?: unknown; assertion?: unknown }): Promise<{ signature: { r: string; s: string; v: number }; signer: string; kind: string; hash: string }>;48  venueRevoke(authorizationId: string): Promise<{ revoked: true; revokedAt: string }>;49}50/** the signer's derivative-mandate endpoints (services/isolated-signer/src/venue-mandate.ts) */51export interface MandateSignerPort {52  venueMandateApprove(mandate: unknown, assertion: unknown): Promise<{ mandateId: string; hash: string; authorizationId: string }>;53  venueMandateSign(p: { mandateId: string; sign: unknown; context?: unknown }): Promise<{ signature: { r: string; s: string; v: number }; signer: string; kind: string; hash: string; mandateId: string; phase: string }>;54  venueMandateRevoke(mandateId: string): Promise<{ revoked: true; revokedAt: string; windDownUntil: number }>;55}56export interface LiveDeps {57  venues: LiveVenues; signer: LiveSignerPort; now: () => number; rpId: string;58  /** MANDATE_LIVE: absent = mandates are unavailable (every mandate route answers 503 mandate_unavailable) */59  mandateSigner?: MandateSignerPort;60  /** server setting SPW_DERIV_MANDATE_MAINNET=on (default off): autonomous trading on mainnet needs it AND mainnetEnabled */61  mandateMainnetEnabled?: () => boolean;62  /** server setting SPW_DERIV_LIVE_MAINNET=on (default off) */63  mainnetEnabled: () => boolean;64  /** enrolled passkeys, for the WebAuthn request options */65  credentials: () => Promise<{ id: string; transports: unknown }[]>;66  /** pause between two reads of the venue when waiting for it to show an approval (tests: 0) */67  confirmDelayMs?: number;68}6970interface Meta { baseline: Record<string, DecStr>; baselineTs: number; fillsCursor: number; fundingCursor: number; fundingReadAt: number; lastEquityMinute: number; pendingDivergence: string | null }71interface Pending { authorization: VenueAuthorization; ack: Pick<JurisdictionAck, 'locale' | 'sha256' | 'version'>; preparedAt: number }72interface AccountRow {73  id: string; venue: 'hyperliquid'; network: HlNetwork; account: string; agent_address: string; agent_name: string; label: string; mode: 'READ_ONLY' | 'MANUAL_LIVE';74  policy: RiskPolicy; pending: Pending | null; authorization_id: string | null; authorization_hash: string | null; venue_authorization: VenueAuthorization | null;75  halt: string | null; divergence: string | null; meta: Partial<Meta>; last_nonce: string; authorized_at: Date | null; revoked_at: Date | null; created_at: Date; updated_at: Date;76}77type Row = Record<string, any>;7879export const AuthorizationBodySchema = z.strictObject({80  policy: VenuePolicySchema, days: z.number().int().min(1).max(VENUE_AUTH_MAX_DAYS), locale: z.enum(['fr', 'en']),81  agentName: z.string().regex(/^[A-Za-z0-9_-]{1,16}$/).default('spw-live'),82});83export const ApproveAuthorizationSchema = z.strictObject({ assertion: z.record(z.string(), z.unknown()), acknowledged: z.literal(true) });84export const AssertionBodySchema = z.strictObject({ assertion: z.record(z.string(), z.unknown()) });85export const CreateLiveAccountSchema = z.strictObject({ venue: z.literal('hyperliquid'), network: z.enum(['mainnet', 'testnet']), label: z.string().trim().min(1).max(60).default('Hyperliquid') });86export const LeverageBodySchema = z.strictObject({ instrument: z.string().min(3).max(120), leverage: z.number().int().min(1).max(VENUE_POLICY_BOUNDS.maxLeverage), marginMode: z.enum(['cross', 'isolated']).default('cross') });87export const HaltBodySchema = z.strictObject({ reason: z.string().trim().max(200).default('') });88export const RevokeBodySchema = z.strictObject({ cancelOpenOrders: z.boolean().default(false), locale: z.enum(['fr', 'en']).default('en') });89/** the limits of a derivative mandate, as the owner chooses them (everything else of the canonical mandate comes from the account and its authorization) */90export const MandateBodySchema = z.strictObject({91  ...VenueMandateSchema.pick({ instruments: true, maxOrderNotional: true, maxGrossExposure: true, maxOrdersPerDay: true, maxOrdersPerHour: true, maxDailyTurnover: true, floorEquity: true, strategySha256: true, agentId: true }).shape,92  /** default: the band of the authorization */93  maxPriceDeviationBps: VenueMandateSchema.shape.maxPriceDeviationBps.optional(), days: z.number().int().min(1).max(VENUE_MANDATE_MAX_DAYS), locale: z.enum(['fr', 'en']).default('en'),94});95export const ApproveMandateSchema = z.strictObject({ assertion: z.record(z.string(), z.unknown()), acknowledged: z.literal(true) });96/** who places a mandate order: the strategy version and the agent the mandate was approved for, and the order's idempotency key */97export const MandateOrderCtxSchema = z.strictObject({ strategySha256: z.string().regex(/^[0-9a-f]{64}$/), agentId: z.string().regex(/^[A-Za-z0-9._:-]{1,64}$/), clientId: z.string().regex(/^[A-Za-z0-9._:-]{8,100}$/) });98export type MandateOrderCtx = z.infer<typeof MandateOrderCtxSchema>;99/** venue expiry of an order signed under a mandate: short, because the signer counts a signed order as exposure until it has expired */100export const MANDATE_ACTION_TTL_MS = 30_000;101interface MandateRow { id: string; account_id: string; authorization_id: string; state: 'prepared' | 'consumed' | 'failed' | 'active' | 'revoked'; mandate: VenueMandate; hash: string;102  ack: Pick<JurisdictionAck, 'locale' | 'sha256' | 'version'>; prepared_at: Date; approved_at: Date | null; revoked_at: Date | null }103104const canon = (x: string): DecStr => D.str(D.from(x));105const canonN = (x: string | null): DecStr | null => (x === null ? null : canon(x));106const dayStart = (now: number): number => Math.floor(now / 86_400_000) * 86_400_000;107const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e)).slice(0, 300);108const MAINNET_OFF = LIVE_REASONS.mainnet_disabled.en;109110/** API-side risk policy derived from the authorization the owner approved; every field can be tightened later, none widened past it */111export function liveRiskPolicy(vp: VenuePolicy, network: HlNetwork): RiskPolicy {112  const gross = D.from(vp.maxGrossExposure), times = (k: string): DecStr => D.str(D.mul(gross, D.from(k)));113  return {114    mode: 'MANUAL_LIVE', allowedInstruments: [...vp.allowedInstruments], halt: null, maxDataAgeMs: 5_000,115    maxDeviationBps: String(Math.min(vp.maxPriceDeviationBps, 300)),116    // testnet books are thin and its mark wanders far from the oracle (2 % seen on 2026-11-11): a mainnet bound would refuse everything there117    maxMarkIndexBps: network === 'testnet' ? '1000' : '100',118    maxLeverage: String(vp.maxLeverage), maxOrderNotional: canon(vp.maxOrderNotional), maxGrossExposure: canon(vp.maxGrossExposure), maxPositionNotional: canon(vp.maxPositionNotional),119    minFreeCollateral: '0', minLiquidationDistanceBps: '1000', maxDailyLoss: times('0.05'), maxTotalLoss: times('0.2'), maxOrdersPerMinute: 6, maxOrdersPerDay: vp.maxOrdersPerDay,120  };121}122const READ_ONLY_POLICY: RiskPolicy = { mode: 'READ_ONLY', allowedInstruments: [], halt: null, maxDataAgeMs: 5_000, maxDeviationBps: '0', maxMarkIndexBps: '0', maxLeverage: '1', maxOrderNotional: '0',123  maxGrossExposure: '0', maxPositionNotional: '0', minFreeCollateral: '0', minLiquidationDistanceBps: '0', maxDailyLoss: '0', maxTotalLoss: '0', maxOrdersPerMinute: 0, maxOrdersPerDay: 0 };124125export type LiveEvent = { event: 'account' | 'order' | 'action' | 'risk' | 'status'; data: unknown };126export interface StepSummary { accounts: number; reconciled: number; fills: number; funding: number; divergences: number; errors: string[] }127128export class LiveService {129  readonly deps: LiveDeps;130  readonly now: () => number;131  private readonly listeners = new Map<string, Set<(ev: LiveEvent) => void>>();132  /** proposals being built right now, by account and idempotency key: a concurrent duplicate waits for the first instead of answering half-built */133  private readonly building = new Map<string, Promise<unknown>>();134  constructor(deps: LiveDeps) { this.deps = deps; this.now = deps.now; }135136  // ------------------------------------------------------------------ stream (same shape as the market hub: event + data)137138  subscribe(id: string, fn: (ev: LiveEvent) => void): () => void {139    let set = this.listeners.get(id);140    if (!set) { set = new Set(); this.listeners.set(id, set); }141    set.add(fn);142    return () => { set!.delete(fn); if (set!.size === 0) this.listeners.delete(id); };143  }144  private emit(id: string, ev: LiveEvent): void { for (const l of [...(this.listeners.get(id) ?? [])]) { try { l(ev); } catch { /* a listener must not break the others */ } } }145146  // ------------------------------------------------------------------ storage helpers147148  private async account(id: string): Promise<AccountRow> {149    const [row] = await sql<AccountRow[]>`select * from deriv_accounts where id = ${id}`;150    if (!row) throw new UserError('unknown_account', 'unknown venue account', 404);151    return row;152  }153  private meta(a: AccountRow): Meta {154    return { baseline: { ...(a.meta.baseline ?? {}) }, baselineTs: a.meta.baselineTs ?? 0, fillsCursor: a.meta.fillsCursor ?? 0, fundingCursor: a.meta.fundingCursor ?? 0, fundingReadAt: a.meta.fundingReadAt ?? 0,155      lastEquityMinute: a.meta.lastEquityMinute ?? 0, pendingDivergence: a.meta.pendingDivergence ?? null };156  }157  private async riskEvent(id: string, kind: string, code: string | null, message: string, data: Record<string, unknown> = {}, dedupe: string | null = null): Promise<void> {158    const ts = this.now();159    const ins = await sql`insert into deriv_risk_events (account_id, ts, kind, code, message, data, dedupe) values (${id}, ${new Date(ts)}, ${kind}, ${code}, ${message.slice(0, 500)}, ${sql.json(data as never)}, ${dedupe})160      on conflict (account_id, dedupe) do nothing returning id`;161    if (ins.length) this.emit(id, { event: 'risk', data: { ts, kind, code, message, data } });162  }163  private audit(type: string, data: Record<string, unknown>, actor = 'owner'): Promise<void> {164    return audit(type, actor, data).catch((e: Error) => console.error(`[derivatives] audit ${type} failed: ${e.message}`));165  }166  private async nextNonce(id: string): Promise<number> {167    const [r] = await sql<{ last_nonce: string }[]>`update deriv_accounts set last_nonce = greatest(last_nonce + 1, ${this.now()}::bigint) where id = ${id} returning last_nonce`;168    return Number(r!.last_nonce);169  }170  private async options(hash: Uint8Array) {171    const creds = await this.deps.credentials();172    const options = await generateAuthenticationOptions({ rpID: this.deps.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: 120_000,173      allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });174    if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');175    return options;176  }177178  /** why this account cannot send anything to the venue right now; null = it can */179  private blocked(a: AccountRow, what: 'trade' | 'stop'): UserError | null {180    if (a.network === 'mainnet' && !this.deps.mainnetEnabled()) return new UserError('mainnet_disabled', MAINNET_OFF, 403);181    if (!a.authorization_id || !a.venue_authorization) return new UserError('mode_read_only', 'this venue account is read-only: live trading has not been authorized with the owner\'s passkey', 422);182    if (a.mode !== 'MANUAL_LIVE') return new UserError('mode_read_only', 'the authorization of this venue account was revoked: it is read-only', 422);183    if (what === 'trade' && this.now() >= a.venue_authorization.expiresAt * 1000) return new UserError('authorization_expired', 'the venue authorization has expired: approve a new one', 422);184    return null;185  }186  private effectiveHalt(a: AccountRow): string | null {187    return a.halt ?? (a.divergence ? `the venue's state and the wallet's records differ (${a.divergence}): new orders are stopped until it clears` : null);188  }189190  // ------------------------------------------------------------------ views191192  private orderView(r: Row) {193    return {194      clientId: r.client_id as string, cloid: r.cloid as string | null, instrument: r.instrument as string, side: r.side as 'buy' | 'sell', type: r.type as string, size: canon(r.size), price: canonN(r.price),195      triggerPrice: canonN(r.trigger_price), reduceOnly: r.reduce_only as boolean, tif: r.tif as string, state: r.state as LiveState, final: r.final as boolean, venueOrderId: r.venue_order_id as string | null,196      filled: canon(r.filled), avgFillPrice: canonN(r.avg_fill_price), reason: r.reject_reason as string | null, origin: r.origin as string, hash: r.order_hash as string | null,197      nonce: r.nonce === null ? null : Number(r.nonce), expiresAt: r.expires_at ? (r.expires_at as Date).getTime() : null, canonical: r.request as VenueOrder | null,198      createdAt: (r.created_at as Date).getTime(), updatedAt: (r.updated_at as Date).getTime(), submittedAt: r.submitted_at ? (r.submitted_at as Date).getTime() : null,199      /** where the state comes from: the venue's own records, or the wallet alone (nothing reached the venue, or not yet confirmed) */200      source: (['open', 'partially_filled', 'filled'].includes(r.state) || (r.state === 'cancelled' && r.venue_order_id) ? 'venue' : 'local') as 'venue' | 'local',201    };202  }203  private actionView(r: Row) {204    return { id: r.id as string, kind: r.kind as string, state: r.state as string, request: r.request as Record<string, unknown>, hash: r.action_hash as string | null, nonce: Number(r.nonce),205      expiresAt: (r.expires_at as Date).getTime(), error: r.error as string | null, createdAt: (r.created_at as Date).getTime(), updatedAt: (r.updated_at as Date).getTime() };206  }207  private summary(a: AccountRow) {208    const auth = a.venue_authorization, now = this.now();209    return {210      id: a.id, venue: a.venue, network: a.network, kind: 'venue' as const, mode: a.mode, label: a.label, account: a.account, agentAddress: a.agent_address,211      policy: a.policy, halt: a.halt, divergence: a.divergence, halted: this.effectiveHalt(a),212      mainnetEnabled: this.deps.mainnetEnabled(), tradable: this.blocked(a, 'trade') === null,213      authorization: auth && a.authorization_id ? { id: a.authorization_id, hash: a.authorization_hash, mode: auth.mode, agentName: auth.agentName, policy: auth.policy, createdAt: auth.createdAt * 1000, expiresAt: auth.expiresAt * 1000,214        ackSha256: auth.ackSha256, active: a.mode === 'MANUAL_LIVE' && now < auth.expiresAt * 1000, expired: now >= auth.expiresAt * 1000, revokedAt: a.revoked_at?.getTime() ?? null, authorizedAt: a.authorized_at?.getTime() ?? null } : null,215      pendingAuthorization: a.pending ? { authorizationId: a.pending.authorization.authorizationId, preparedAt: a.pending.preparedAt } : null,216      createdAt: a.created_at.getTime(), updatedAt: a.updated_at.getTime(),217    };218  }219  /** the account as the venue reports it right now; an unreadable venue is said, never papered over */220  private async venueState(a: AccountRow): Promise<{ state: AccountState | null; positions: Position[]; openOrders: Order[]; error: string | null; ts: number; source: 'venue' }> {221    const v = this.deps.venues.get(a.network).adapter;222    try {223      const [state, positions, openOrders] = await Promise.all([v.account(a.account), v.positions(a.account), v.openOrders(a.account)]);224      return { state, positions, openOrders, error: null, ts: this.now(), source: 'venue' };225    } catch (e) { return { state: null, positions: [], openOrders: [], error: msg(e), ts: this.now(), source: 'venue' }; }226  }227228  config(locale: LiveLocale = 'en') {229    const mainnet = this.deps.mainnetEnabled(), mandates = this.deps.mandateSigner !== undefined, mandateMainnet = mainnet && (this.deps.mandateMainnetEnabled?.() ?? false);230    const mandateCode = !mandates ? 'mandate_unavailable' as const : null;231    return {232      mainnetEnabled: mainnet, locale,233      /** why mainnet is closed, as a code and as a sentence in the requested locale; null when it is open */234      mainnet: { enabled: mainnet, code: mainnet ? null : 'mainnet_disabled' as const, reason: mainnet ? null : liveReason('mainnet_disabled', locale) },235      venues: [{ venue: 'hyperliquid' as const, networks: ['testnet', 'mainnet'] as const }], modes: (mandates ? ['READ_ONLY', 'MANUAL_LIVE', 'MANDATE_LIVE'] : ['READ_ONLY', 'MANUAL_LIVE']) as string[],236      mandateLive: { available: mandates, code: mandateCode, reason: mandateCode ? liveReason(mandateCode, locale) : null,237        mainnet: { enabled: mandateMainnet, code: mandateMainnet ? null : 'mandate_mainnet_disabled' as const, reason: mandateMainnet ? null : liveReason('mandate_mainnet_disabled', locale) },238        bounds: { ...VENUE_MANDATE_BOUNDS, windDownSeconds: VENUE_MANDATE_WIND_DOWN_S, orderTtlMs: MANDATE_ACTION_TTL_MS } },239      /** every reason code above, in both languages */240      reasons: LIVE_REASONS,241      pinnedAssets: { pinnedAt: HL_PINNED_AT, mainnet: HL_PINNED_ASSETS.mainnet.map((x) => ({ instrument: `hyperliquid:${x.symbol}`, maxLeverage: x.maxLeverage })), testnet: HL_PINNED_ASSETS.testnet.map((x) => ({ instrument: `hyperliquid:${x.symbol}`, maxLeverage: x.maxLeverage })) },242      bounds: { ...VENUE_POLICY_BOUNDS, maxAuthorizationDays: VENUE_AUTH_MAX_DAYS, orderApprovalTtlMs: LIVE_ACTION_TTL_MS }, ts: this.now(),243    };244  }245246  async list() {247    const rows = await sql<AccountRow[]>`select * from deriv_accounts order by created_at`, out = [];248    for (const a of rows) out.push({ ...this.summary(a), ...(await this.mandateInfo(a)) });249    return out;250  }251252  async get(id: string) {253    const a = await this.account(id);254    const [venue, orders, actions, fills, funding, events, equity] = await Promise.all([255      this.venueState(a),256      sql`select * from deriv_orders where account_id = ${id} order by created_at desc limit 100`,257      sql`select * from deriv_actions where account_id = ${id} order by created_at desc limit 50`,258      sql`select * from deriv_fills where account_id = ${id} order by ts desc, fill_id desc limit 100`,259      sql`select * from deriv_funding where account_id = ${id} order by ts desc limit 100`,260      sql`select * from deriv_risk_events where account_id = ${id} order by ts desc, id desc limit 100`,261      sql`select * from (select * from deriv_equity where account_id = ${id} order by ts desc limit 1500) e order by ts`,262    ]);263    return {264      ...this.summary(a), ...(await this.mandateInfo(a)), venueState: venue,265      orders: orders.map((r) => this.orderView(r)), actions: actions.map((r) => this.actionView(r)),266      fills: fills.map((f): Fill & { source: 'venue' } => ({ instrument: f.instrument, orderId: f.order_id, clientId: f.client_id, side: f.side, price: canon(f.price), size: canon(f.size), fee: canon(f.fee), feeAsset: f.fee_asset,267        closedPnl: canonN(f.closed_pnl), ts: f.ts.getTime(), id: f.fill_id, liquidation: f.liquidation, source: 'venue' })),268      funding: funding.map((f) => ({ instrument: f.instrument as string, amount: canon(f.amount), rate: canon(f.rate), size: canon(f.size), ts: f.ts.getTime() as number, source: 'venue' as const })),269      riskEvents: events.map((e) => ({ id: Number(e.id), ts: e.ts.getTime() as number, kind: e.kind as string, code: e.code as string | null, message: e.message as string, data: e.data as Record<string, unknown> })),270      equity: equity.map((e) => ({ ts: e.ts.getTime() as number, equity: canon(e.equity), collateral: canon(e.collateral), marginUsed: canon(e.margin_used), unrealizedPnl: canon(e.unrealized_pnl), source: 'venue' as const })),271      ts: this.now(),272    };273  }274275  /** read-only: what can be withdrawn / what backs the positions, and where the funding instructions are. Nothing is moved from here. */276  async collateral(id: string, locale: LiveLocale = 'en') {277    const a = await this.account(id), v = await this.venueState(a), t = liveTexts(locale);278    return {279      account: a.account, network: a.network, equity: v.state?.equity ?? null, collateral: v.state?.collateral ?? null, freeCollateral: v.state?.freeCollateral ?? null, marginUsed: v.state?.marginUsed ?? null,280      withdrawable: v.state?.withdrawable ?? null, asset: 'USDC', error: v.error, ts: v.ts, source: 'venue' as const,281      locale, deposits: { supported: a.network === 'mainnet', how: t.depositsHow }, withdrawals: { supported: true, how: t.withdrawalsHow },282    };283  }284285  // ------------------------------------------------------------------ venue key, account, authorization286287  async venueKey(venue: string, network: string) {288    if (venue !== 'hyperliquid') throw new UserError('unknown_venue', 'live trading exists on Hyperliquid only', 404);289    this.deps.venues.get(network);290    return this.deps.signer.venueKey(venue, network);291  }292293  /** a venue account is the owner's own address on the venue; it starts READ_ONLY and trades nothing */294  async createAccount(body: z.infer<typeof CreateLiveAccountSchema>) {295    // a mainnet venue account is not even created while the server keeps mainnet closed (an account that already exists stays, read-only)296    if (body.network === 'mainnet' && !this.deps.mainnetEnabled()) throw new UserError('mainnet_disabled', MAINNET_OFF, 403);297    const key = await this.venueKey(body.venue, body.network), id = randomUUID(), now = new Date(this.now());298    const ins = await sql`insert into deriv_accounts (id, venue, network, account, agent_address, label, mode, policy, created_at, updated_at)299      values (${id}, ${body.venue}, ${body.network}, ${key.account.toLowerCase()}, ${key.address.toLowerCase()}, ${body.label}, 'READ_ONLY', ${sql.json(READ_ONLY_POLICY as never)}, ${now}, ${now})300      on conflict (venue, network, account) do nothing returning id`;301    const [row] = await sql<AccountRow[]>`select * from deriv_accounts where venue = ${body.venue} and network = ${body.network} and account = ${key.account.toLowerCase()}`;302    if (ins.length) await this.audit('deriv.live.account_created', { account: row!.id, venue: body.venue, network: body.network, address: row!.account, agentAddress: row!.agent_address });303    return this.get(row!.id);304  }305306  /** step 1 of enabling live trading: the canonical authorization, the acknowledgement text to DISPLAY, and the WebAuthn request */307  async prepareAuthorization(id: string, body: z.infer<typeof AuthorizationBodySchema>) {308    const a = await this.account(id);309    if (a.network === 'mainnet' && !this.deps.mainnetEnabled()) throw new UserError('mainnet_disabled', MAINNET_OFF, 403);310    if (a.mode !== 'READ_ONLY') throw new UserError('bad_state', 'an authorization is already active on this venue account: revoke it to change its limits', 409);311    const st = await this.deps.signer.status(true);312    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);313    const key = await this.deps.signer.venueKey(a.venue, a.network);314    if (key.address.toLowerCase() !== a.agent_address || key.account.toLowerCase() !== a.account) throw new UserError('wrong_key', 'this venue account belongs to another signer key', 409);315    const venue = this.deps.venues.get(a.network);316    await venue.assertPinned();317    for (const inst of body.policy.allowedInstruments) if (!hlPinnedAsset(a.network, inst)) throw new UserError('bad_policy', `${inst} is not in the pinned ${a.network} asset table: it cannot be allowed`, 400);318    const nowMs = this.now(), nowS = Math.floor(nowMs / 1000), ack = jurisdictionAck('hyperliquid', body.locale);319    const authorization: VenueAuthorization = { v: 1, authorizationId: randomUUID(), keyId: st.keyId, venue: 'hyperliquid', network: a.network, account: a.account, agentAddress: a.agent_address, agentName: body.agentName,320      mode: 'MANUAL_LIVE', policy: body.policy, createdAt: nowS, expiresAt: nowS + body.days * 86400, approveNonce: nowMs, ackSha256: ack.sha256 };321    try { validateVenueAuthorization(authorization, nowS); } catch (e) { throw new UserError('bad_policy', msg(e), 400); }322    const hash = venueAuthorizationHash(authorization);323    const pending: Pending = { authorization, ack: { locale: ack.locale, sha256: ack.sha256, version: ack.version }, preparedAt: nowMs };324    await sql`update deriv_accounts set pending = ${sql.json(pending as never)}, updated_at = ${new Date(nowMs)} where id = ${id}`;325    const v = await this.venueState(a), t = liveTexts(body.locale);326    return {327      authorization, hash: hex(hash), acknowledgement: ack, locale: body.locale,328      agent: { address: a.agent_address, name: body.agentName, wireName: venueAgentWireName(authorization), validUntil: authorization.expiresAt * 1000, can: t.can, cannot: t.cannot, note: t.agentNote },329      whatIsSigned: { byMainKey: t.byMainKey, byPasskey: t.byPasskey },330      venueState: v, options: await this.options(hash), ts: nowMs,331    };332  }333334  /** step 2: the signer judges the passkey, stores the limits and signs the venue approval; the venue must then show the API wallet */335  async approveAuthorization(id: string, body: z.infer<typeof ApproveAuthorizationSchema>) {336    const a = await this.account(id);337    if (a.network === 'mainnet' && !this.deps.mainnetEnabled()) throw new UserError('mainnet_disabled', MAINNET_OFF, 403);338    if (a.mode !== 'READ_ONLY') throw new UserError('bad_state', 'an authorization is already active on this venue account', 409);339    const p = a.pending;340    if (!p) throw new UserError('bad_state', 'prepare the authorization first', 409);341    const auth = p.authorization, venue = this.deps.venues.get(a.network);342    await sql`update deriv_accounts set pending = null where id = ${id}`;                      // an authorization is approved once343    const r = await this.deps.signer.venueAuthorize(auth, body.assertion);                    // refusal → UserError signer_*, nothing changed344    // The signer verified the owner's passkey over an authorization that contains the hash of the text that was displayed: that345    // is the owner's confirmation. It is recorded now, whatever the venue answers next.346    await this.audit('deriv.live.acknowledged', { account: id, venue: a.venue, network: a.network, authorizationId: auth.authorizationId, authorizationHash: r.hash, ackSha256: p.ack.sha256, locale: p.ack.locale, version: p.ack.version,347      statement: 'the owner confirmed with his passkey having read the jurisdiction summary; enabling live derivative trading on this venue is his own decision' });348    const undo = async (why: string): Promise<never> => {349      await this.deps.signer.venueRevoke(auth.authorizationId).catch(() => {});350      await this.audit('deriv.live.authorization_failed', { account: id, authorizationId: auth.authorizationId, reason: why.slice(0, 300) });351      throw new UserError('venue_rejected', `the venue did not register the trading key: ${why.slice(0, 240)} — the authorization was revoked on the signer, nothing can trade. Fund the venue account first if the venue asks for a deposit, then prepare a new authorization.`, 422);352    };353    let answer: string | null = null;354    try { await venue.trading().submit(r.approve.action, r.approve.signature, r.approve.nonce); }355    catch (e) {356      if (!(e instanceof VenueError) || e.code === 'rejected' || e.code === 'unsupported') await undo(msg(e));357      answer = msg(e);                                                                          // unknown outcome: only the venue's own state decides358    }359    let confirmed = false;360    for (let k = 0; k < 5 && !confirmed; k++) {361      const role = await venue.userRole(a.agent_address).catch(() => null);362      confirmed = role !== null && role.role === 'agent' && role.user === a.account;363      if (!confirmed && k < 4) await new Promise((res) => setTimeout(res, this.deps.confirmDelayMs ?? 500));364    }365    if (!confirmed) await undo(answer ?? 'the venue accepted the request but does not list the trading key as an API wallet of the account');366    const positions = await venue.adapter.positions(a.account).catch(() => [] as Position[]);367    const now = this.now(), meta: Meta = { ...this.meta(a), baseline: Object.fromEntries(positions.map((x) => [x.instrument, x.side === 'long' ? x.size : `-${x.size}`])), baselineTs: now, fillsCursor: now, fundingCursor: now, pendingDivergence: null };368    await sql`update deriv_accounts set mode = 'MANUAL_LIVE', authorization_id = ${auth.authorizationId}, authorization_hash = ${r.hash}, venue_authorization = ${sql.json(auth as never)}, agent_name = ${auth.agentName},369      policy = ${sql.json(liveRiskPolicy(auth.policy, a.network) as never)}, halt = null, divergence = null, meta = ${sql.json(meta as never)}, authorized_at = ${new Date(now)}, revoked_at = null, updated_at = ${new Date(now)} where id = ${id}`;370    await this.audit('deriv.live.authorized', { account: id, venue: a.venue, network: a.network, authorizationId: auth.authorizationId, hash: r.hash, agentAddress: a.agent_address, mode: auth.mode, policy: auth.policy, expiresAt: auth.expiresAt, ackSha256: auth.ackSha256 });371    this.emit(id, { event: 'status', data: { mode: 'MANUAL_LIVE', ts: now } });372    return this.get(id);373  }374375  /** stopping never needs a passkey. The signer forgets nothing: a revoked authorization signs nothing, not even a cancel. */376  async revoke(id: string, body: { cancelOpenOrders: boolean; locale?: LiveLocale }) {377    const a = await this.account(id);378    let cancelled: Awaited<ReturnType<LiveService['cancelAll']>> | null = null, cancelError: { code: string; message: string } | null = null;379    if (body.cancelOpenOrders && a.mode === 'MANUAL_LIVE') {380      try { cancelled = await this.cancelAll(id); }381      catch (e) { cancelError = { code: e instanceof UserError ? e.code : 'internal', message: msg(e) }; }          // said to the owner; the revocation goes on382    }383    if (a.authorization_id) await this.deps.signer.venueRevoke(a.authorization_id);384    const now = new Date(this.now());385    // a mandate dies with its authorization: no wind-down (the signer signs nothing for a revoked authorization)386    const ended = await sql<{ id: string }[]>`update deriv_mandates set state = 'revoked', revoked_at = ${now}, updated_at = ${now} where account_id = ${id} and state = 'active' returning id`;387    for (const m of ended) await this.deps.mandateSigner?.venueMandateRevoke(m.id).catch(() => {});388    await sql`delete from deriv_mandates where account_id = ${id} and state = 'prepared'`;389    await sql`update deriv_accounts set mode = 'READ_ONLY', policy = ${sql.json({ ...a.policy, mode: 'READ_ONLY' } as never)}, pending = null, revoked_at = ${a.authorization_id ? now : a.revoked_at}, updated_at = ${now} where id = ${id}`;390    await sql`update deriv_orders set state = 'cancelled', status = 'cancelled', final = true, reject_reason = 'withdrawn: the venue authorization was revoked before approval', updated_at = ${now}391      where account_id = ${id} and state = any(${PRE_SUBMIT})`;392    await this.audit('deriv.live.revoked', { account: id, authorizationId: a.authorization_id });393    const v = await this.venueState(a);394    this.emit(id, { event: 'status', data: { mode: 'READ_ONLY', ts: now.getTime() } });395    return { ...(await this.get(id)), cancelled,396      /** why cancel-all could not run (`cancelled` is then null); null when it ran or was not asked for */397      cancelError,398      warning: v.openOrders.length ? revokeWarning(v.openOrders.length, body.locale ?? 'en') : null,   // v8: in the body's `locale` (default en = the same sentence as before)399      venueAgent: liveTexts(body.locale ?? 'en').venueAgent, locale: body.locale ?? 'en' };400  }401402  /** the API-side risk policy can be tightened freely; it can never exceed what the passkey-approved authorization allows */403  async setPolicy(id: string, policy: RiskPolicy) {404    const a = await this.account(id), auth = a.venue_authorization;405    if (a.mode !== 'MANUAL_LIVE' || !auth) throw new UserError('mode_read_only', 'a read-only venue account has no trading policy: authorize it first', 422);406    const vp = auth.policy, bad = (why: string): never => { throw new UserError('bad_policy', `${why}: a wider limit needs a new authorization, approved with the passkey`, 400); };407    if (policy.mode !== 'MANUAL_LIVE') bad('the mode of a venue account is set by its authorization');408    if (policy.halt !== null) bad('a halt is set with the halt route');409    if (policy.allowedInstruments.some((i) => !vp.allowedInstruments.includes(i))) bad('an instrument outside the authorization');410    if (D.gt(D.from(policy.maxLeverage), D.from(BigInt(vp.maxLeverage)))) bad('leverage above the authorization');411    if (D.gt(D.from(policy.maxOrderNotional), D.from(vp.maxOrderNotional))) bad('order notional above the authorization');412    if (D.gt(D.from(policy.maxPositionNotional), D.from(vp.maxPositionNotional))) bad('position notional above the authorization');413    if (D.gt(D.from(policy.maxGrossExposure), D.from(vp.maxGrossExposure))) bad('gross exposure above the authorization');414    if (policy.maxOrdersPerDay > vp.maxOrdersPerDay) bad('orders per day above the authorization');415    await sql`update deriv_accounts set policy = ${sql.json(policy as never)}, updated_at = ${new Date(this.now())} where id = ${id}`;416    await this.audit('deriv.live.policy_replaced', { account: id, maxLeverage: policy.maxLeverage, maxOrderNotional: policy.maxOrderNotional, maxGrossExposure: policy.maxGrossExposure });417    return this.get(id);418  }419420  // ------------------------------------------------------------------ orders: preview, propose, approve, cancel421422  private async usage(id: string, now: number): Promise<RiskUsage> {423    const day = new Date(dayStart(now));424    const [f] = await sql<{ today: string; total: string }[]>`select coalesce(sum(coalesce(closed_pnl, 0) - fee) filter (where ts >= ${day}), 0)::text as today, coalesce(sum(coalesce(closed_pnl, 0) - fee), 0)::text as total from deriv_fills where account_id = ${id}`;425    const [g] = await sql<{ today: string; total: string }[]>`select coalesce(sum(amount) filter (where ts >= ${day}), 0)::text as today, coalesce(sum(amount), 0)::text as total from deriv_funding where account_id = ${id}`;426    // proposals the risk engine or the signer refused never reached the venue: they do not use up the order allowance427    const [c] = await sql<{ minute: number; today: number }[]>`select count(*) filter (where created_at > ${new Date(now - 60_000)})::int as minute, count(*) filter (where created_at >= ${day})::int as today428      from deriv_orders where account_id = ${id} and not (final and signature is null)`;429    return { realizedToday: D.str(D.add(D.from(f!.today), D.from(g!.today))), realizedTotal: D.str(D.add(D.from(f!.total), D.from(g!.total))), ordersLastMinute: c!.minute, ordersToday: c!.today, clientIds: [] };430  }431432  /** the order as the engines take it + the venue's own price rule (5 significant figures) */433  private request(body: OrderBody, inst: Instrument, book: Book, clientId: string): { req: OrderRequest; refusal: RiskResult | null } {434    if (body.leverage !== undefined) throw new UserError('bad_request', 'on a venue account the leverage of an instrument is a venue setting: change it with the leverage route (passkey)', 400);435    let price = body.price === null ? null : canon(body.price);436    if (price === null && body.type === 'market' && body.slippageBps !== undefined) price = worstPrice(inst, book, body.side, body.slippageBps);437    const req: OrderRequest = { instrument: inst.id, side: body.side, type: body.type, size: canon(body.size), price, triggerPrice: body.triggerPrice === null ? null : canon(body.triggerPrice),438      reduceOnly: body.reduceOnly, tif: body.type === 'market' ? 'ioc' : body.tif, clientId };439    const sz = inst.lotSize.includes('.') ? inst.lotSize.length - inst.lotSize.indexOf('.') - 1 : 0;440    for (const [name, p] of [['price', req.price], ['trigger price', req.triggerPrice]] as const) {441      const r = p === null ? null : hlFormatPrice(p, sz);442      if (r && !r.ok) return { req, refusal: { ok: false, code: 'price_not_on_tick', reason: `${name}: ${r.reason}` } };443    }444    return { req, refusal: null };445  }446447  /** live market + the account's REAL state from the venue, then the risk engine with the account's policy */448  private async check(a: AccountRow, venue: LiveVenue, body: OrderBody, clientId: string, over: RiskPolicy | null = null) {449    const inst = await venue.instrument(body.instrument);450    const read = async <T>(fn: () => Promise<T>): Promise<T> => { try { return await fn(); } catch (e) { if (e instanceof VenueError) throw new UserError('venue_unavailable', msg(e), 503); throw e; } };451    const [m, state, positions, openOrders, lev] = await Promise.all([venue.market(inst), read(() => venue.adapter.account(a.account)), read(() => venue.adapter.positions(a.account)),452      read(() => venue.adapter.openOrders(a.account)), venue.leverageOf(a.account, inst.symbol)]);453    const now = this.now(), { req, refusal } = this.request(body, inst, m.book, clientId);454    const policy: RiskPolicy = { ...RiskPolicySchema.parse(over ?? a.policy), halt: this.effectiveHalt(a) };455    let risk: RiskResult = refusal ?? checkOrder(policy, req, { instrument: inst, account: state, positions, openOrders, book: m.book, ticker: m.ticker, now, usage: await this.usage(a.id, now), leverage: lev.leverage });456    // the signer bounds EVERY order by its notional, reduce-only included (it cannot see positions): said here rather than at the passkey prompt457    const cap = a.venue_authorization?.policy.maxOrderNotional;458    if (risk.ok && cap && req.price !== null && D.gt(D.from(venueOrderNotional({ size: req.size, price: req.price, triggerPrice: req.triggerPrice })), D.from(cap))) {459      risk = { ok: false, code: 'order_notional_exceeded', reason: `order notional ${venueOrderNotional({ size: req.size, price: req.price, triggerPrice: req.triggerPrice })} exceeds the ${cap} the signer will sign per order (reduce-only orders included: close in smaller parts)` };460    }461    return { inst, m, state, positions, openOrders, lev, req, risk, now };462  }463464  private estimate(c: Awaited<ReturnType<LiveService['check']>>) {465    const { req, m, lev, state } = c, price = req.price === null ? D.from(m.ticker.mark) : D.from(req.price), notional = D.mul(D.from(req.size), price);466    const margin = req.reduceOnly ? D.zero : initialMargin(notional, D.from(lev.leverage));467    return { notional: D.str(notional), initialMargin: D.str(margin), fee: D.str(D.mul(notional, D.from(TAKER_FEE_RATE))), feeNote: 'upper bound at the base taker rate (0.045 %); the real fee comes from the venue\'s fills',468      freeCollateralAfter: D.str(D.sub(D.from(state.freeCollateral), margin)), leverage: lev.leverage, marginMode: lev.marginMode, price: req.price, mark: m.ticker.mark,469      liquidationPrice: null, liquidationNote: 'not estimated before the order: the venue reports the liquidation price of the position once it exists', source: 'estimate' as const };470  }471472  async preview(id: string, body: OrderBody) {473    const a = await this.account(id), stop = this.blocked(a, 'trade');474    if (stop) throw stop;475    const venue = this.deps.venues.get(a.network);476    await venue.assertPinned();477    const c = await this.check(a, venue, body, 'preview');478    return { risk: c.risk, estimate: this.estimate(c), request: c.req, venueState: { state: c.state, positions: c.positions, source: 'venue' as const }, ts: c.now };479  }480481  /**482   * Proposal. `key` is the Idempotency-Key = the order's client id; the venue's client order id (cloid) is derived from it, so a483   * retry — of this call, of the approval, or of the submission — can never place a second order.484   */485  async propose(id: string, body: OrderBody, key: string, origin: 'owner' | 'close' = 'owner') {486    // a duplicate that arrives while the first call is still building waits for it, then answers the built proposal487    const k = `${id}|${key}`, running = this.building.get(k);488    if (running) {489      await running.catch(() => {});490      // (when the first call failed before storing anything — a lock, an unreadable venue — this one simply runs)491      const [built] = await sql`select client_id from deriv_orders where account_id = ${id} and client_id = ${key}`;492      if (built) return this.proposalAnswer(await this.settledRow(id, key), true);493    }494    const p = this.proposeOnce(id, body, key, origin);495    this.building.set(k, p);496    try { return await p; } finally { if (this.building.get(k) === p) this.building.delete(k); }497  }498499  /** the stored order of an idempotency key, once it is no longer half-built (`proposed` is the state of a proposal being built — by this process or another) */500  private async settledRow(id: string, key: string): Promise<Row> {501    for (let k = 0; ; k++) {502      const [row] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`;503      if (!row) throw new UserError('unknown_order', 'unknown order', 404);504      if (row.state !== 'proposed' || k >= 40 || this.now() - (row.created_at as Date).getTime() > 30_000) return row;505      await new Promise((res) => setTimeout(res, 50));506    }507  }508509  private async proposeOnce(id: string, body: OrderBody, key: string, origin: 'owner' | 'close') {510    const a = await this.account(id);511    const [prior] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`;512    if (prior) return this.proposalAnswer(await this.settledRow(id, key), true);513    const stop = this.blocked(a, 'trade');514    if (stop) throw stop;515    const venue = this.deps.venues.get(a.network);516    await venue.assertPinned();517    const c = await this.check(a, venue, body, key), { req, inst, now } = c, at = new Date(now);518    const ins = await sql`insert into deriv_orders (account_id, client_id, cloid, instrument, side, type, size, price, trigger_price, reduce_only, tif, status, state, origin, created_at, updated_at)519      values (${id}, ${key}, ${hlCloid(key)}, ${req.instrument}, ${req.side}, ${req.type}, ${req.size}, ${req.price}, ${req.triggerPrice}, ${req.reduceOnly}, ${req.tif}, 'pending', 'proposed', ${origin}, ${at}, ${at})520      on conflict (account_id, client_id) do nothing returning client_id`;521    if (!ins.length) return this.proposalAnswer(await this.settledRow(id, key), true);522    if (!c.risk.ok) {523      await sql`update deriv_orders set state = 'rejected', status = 'rejected', final = true, reject_reason = ${`${c.risk.code}: ${c.risk.reason}`.slice(0, 500)}, updated_at = ${at} where account_id = ${id} and client_id = ${key}`;524      await this.riskEvent(id, 'order_refused', c.risk.code, c.risk.reason, { clientId: key, instrument: req.instrument, side: req.side, type: req.type, size: req.size, price: req.price, reduceOnly: req.reduceOnly, origin });525      throw new UserError(c.risk.code, c.risk.reason, 422);526    }527    const trading = venue.trading(), nonce = await this.nextNonce(id);528    let built: ReturnType<typeof trading.buildOrder>, canonical: VenueOrder;529    try {530      built = trading.buildOrder(req, { instrument: inst, nonce });531      const norm = trading.normalizeRequest(req, inst);532      canonical = { v: 1, authorizationId: a.authorization_id!, venue: 'hyperliquid', network: a.network, account: a.account, kind: 'order', instrument: norm.instrument, side: norm.side, type: norm.type as VenueOrder['type'],533        size: norm.size, price: norm.price!, triggerPrice: norm.triggerPrice, reduceOnly: norm.reduceOnly, tif: norm.tif, clientId: norm.clientId, nonce, expiresAt: nonce + LIVE_ACTION_TTL_MS };534    } catch (e) {535      await sql`update deriv_orders set state = 'rejected', status = 'rejected', final = true, reject_reason = ${msg(e)}, updated_at = ${at} where account_id = ${id} and client_id = ${key}`;536      throw new UserError('venue_rejected', msg(e), 422);537    }538    const hash = venueOrderHash(canonical), estimate = this.estimate(c);539    const [row] = await sql`update deriv_orders set state = 'risk_checked', request = ${sql.json(canonical as never)}, order_hash = ${hex(hash)}, nonce = ${nonce}, expires_at = ${new Date(canonical.expiresAt)},540      action = ${built.action as never}::json, sign_request = ${built.sign as never}::json, estimate = ${sql.json(estimate as never)}, updated_at = ${at} where account_id = ${id} and client_id = ${key} returning *`;541    await this.audit(origin === 'close' ? 'deriv.live.close_proposed' : 'deriv.live.order_proposed', { account: id, clientId: key, hash: hex(hash), instrument: canonical.instrument, side: canonical.side, type: canonical.type, size: canonical.size,542      price: canonical.price, triggerPrice: canonical.triggerPrice, reduceOnly: canonical.reduceOnly, tif: canonical.tif, nonce });543    this.emit(id, { event: 'order', data: this.orderView(row!) });544    return this.proposalAnswer(row!, false);545  }546547  /** `estimate` is the one computed when the proposal was made (stored with it), also on an idempotent replay; null for a proposal that was refused */548  private async proposalAnswer(r: Row, idempotent: boolean, estimate: ReturnType<LiveService['estimate']> | null = (r.estimate as ReturnType<LiveService['estimate']> | null) ?? null) {549    const order = this.orderView(r), approvable = r.state === 'risk_checked' && r.expires_at.getTime() > this.now();550    return { order, canonical: order.canonical, hash: order.hash, expiresAt: order.expiresAt, estimate, idempotent,551      /** the WebAuthn request whose challenge is the order hash; null when there is nothing (left) to approve */552      options: approvable ? await this.options(Buffer.from(r.order_hash as string, 'hex')) : null };553  }554555  private async setOrder(id: string, key: string, patch: { state: LiveState; final?: boolean; reason?: string | null; venueOrderId?: string | null; filled?: DecStr; avg?: DecStr | null; signature?: unknown; answer?: unknown; submitted?: boolean }): Promise<Row> {556    const status = ['open', 'partially_filled', 'filled', 'cancelled', 'rejected'].includes(patch.state) ? patch.state : 'pending', at = new Date(this.now());557    const [row] = await sql`update deriv_orders set state = ${patch.state}, status = ${status}, final = ${patch.final ?? false},558      reject_reason = ${patch.reason === undefined ? sql`reject_reason` : patch.reason}, venue_order_id = ${patch.venueOrderId === undefined ? sql`venue_order_id` : patch.venueOrderId},559      filled = ${patch.filled === undefined ? sql`filled` : patch.filled}, avg_fill_price = ${patch.avg === undefined ? sql`avg_fill_price` : patch.avg},560      signature = ${patch.signature === undefined ? sql`signature` : sql.json(patch.signature as never)}, venue_answer = ${patch.answer === undefined ? sql`venue_answer` : sql.json((patch.answer ?? null) as never)},561      submitted_at = ${patch.submitted ? at : sql`submitted_at`}, updated_at = ${at} where account_id = ${id} and client_id = ${key} returning *`;562    this.emit(id, { event: 'order', data: this.orderView(row!) });563    return row!;564  }565566  /** the owner approved the order with his passkey: signer → venue → reconciliation. Only ONE call ever takes the order past `risk_checked`. */567  async approveOrder(id: string, key: string, assertion: Record<string, unknown>) {568    const a = await this.account(id);569    const [row] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key} and state is not null`;570    if (!row) throw new UserError('unknown_order', 'unknown order', 404);571    if (row.state !== 'risk_checked') return { order: this.orderView(row), idempotent: true };      // already approved, sent, refused or withdrawn: nothing is sent again572    const stop = this.blocked(a, 'trade');573    if (stop) throw stop;574    const halt = this.effectiveHalt(a);575    if (halt && !row.reduce_only) {576      // the proposal ends here: it must not stay approvable until its window closes577      await this.setOrder(id, key, { state: 'cancelled', final: true, reason: `withdrawn: trading was halted before the approval (${halt}); nothing was signed`.slice(0, 500) });578      throw new UserError('halted', `trading is halted: ${halt}`, 423);579    }580    if (row.expires_at.getTime() <= this.now() + 2_000) {581      await this.setOrder(id, key, { state: 'cancelled', final: true, reason: 'the approval window expired before the passkey approval; nothing was signed' });582      throw new UserError('proposal_expired', 'this proposal has expired; propose the order again', 410);583    }584    const venue = this.deps.venues.get(a.network);585    try { await venue.assertPinned(); }586    catch (e) {587      if (e instanceof UserError && e.code === 'asset_table_mismatch') await this.setOrder(id, key, { state: 'cancelled', final: true, reason: `withdrawn: ${msg(e)}`.slice(0, 500) });588      throw e;589    }590    const taken = await sql`update deriv_orders set state = 'authorized', updated_at = ${new Date(this.now())} where account_id = ${id} and client_id = ${key} and state = 'risk_checked' returning client_id`;591    if (!taken.length) { const [cur] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`; return { order: this.orderView(cur!), idempotent: true }; }592593    let signed: Awaited<ReturnType<LiveSignerPort['venueSign']>>;594    try { signed = await this.deps.signer.venueSign({ authorizationId: a.authorization_id!, sign: row.sign_request, context: { order: row.request }, assertion }); }595    catch (e) {596      // refused, or unreachable: either way no signature came back, so nothing can have been sent597      await this.setOrder(id, key, { state: 'rejected', final: true, reason: `not signed: ${msg(e)}` });598      await this.riskEvent(id, 'order_refused', e instanceof UserError ? e.code : 'signer', `the signer did not sign the order: ${msg(e)}`, { clientId: key });599      throw e;600    }601    await this.setOrder(id, key, { state: 'signed', signature: signed.signature });602    await this.submit(a, venue, row, signed.signature);603    const final = await this.reconcileOrder(a, venue, key).catch(() => null);604    const [cur] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`;605    return { order: this.orderView(final ?? cur!), idempotent: false };606  }607608  /** send a signed order. A refusal is final; a timeout or an unreadable answer is `unknown` — never "probably fine", never "probably failed". */609  private async submit(a: AccountRow, venue: LiveVenue, row: Row, signature: unknown): Promise<void> {610    const key = row.client_id as string;611    try {612      // the venue expiry sent with the action must be the one that was signed (manual: 90 s, mandate: 30 s after the nonce)613      const r = await venue.trading((row.expires_at as Date).getTime() - Number(row.nonce)).submit(row.action, signature, Number(row.nonce));614      await this.setOrder(a.id, key, { state: 'submitted', venueOrderId: r.venueOrderIds[0] ?? null, answer: r.raw, submitted: true });615      await this.audit('deriv.live.order_submitted', { account: a.id, clientId: key, hash: row.order_hash, venueOrderId: r.venueOrderIds[0] ?? null });616    } catch (e) {617      const ve = e instanceof VenueError ? e : null, detail = (ve?.detail ?? {}) as { raw?: unknown; local?: boolean };618      // not sent at all: the adapter refused before any request (mainnet lock, or its local rate budget)619      const notSent = ve !== null && (ve.code === 'unsupported' || (ve.code === 'rate_limited' && detail.local === true));620      if (ve !== null && (ve.code === 'rejected' || notSent)) {621        await this.setOrder(a.id, key, { state: 'rejected', final: true, reason: `${notSent ? 'not sent' : 'refused by the venue'}: ${msg(e)}`, answer: detail.raw ?? null, submitted: !notSent });622        await this.audit('deriv.live.order_rejected', { account: a.id, clientId: key, hash: row.order_hash, reason: msg(e) });623      } else {624        await this.setOrder(a.id, key, { state: 'unknown', reason: `no usable answer from the venue (${msg(e)}); the order may or may not exist — it is being looked up by its client id`, submitted: true });625        await this.riskEvent(a.id, 'order_unknown', 'unknown', `order ${key}: the venue gave no usable answer; outcome unknown until reconciled`, { clientId: key, error: msg(e) });626      }627    }628  }629630  /**631   * One order against the venue's own records: its status by client order id, and the fills that carry that id. The only632   * place where an order becomes open / filled / cancelled, and the only way out of `unknown`.633   */634  private async reconcileOrder(a: AccountRow, venue: LiveVenue, key: string, fills?: Fill[]): Promise<Row | null> {635    const [row] = await sql`select * from deriv_orders where account_id = ${a.id} and client_id = ${key}`;636    if (!row || row.final || !row.state || PRE_SUBMIT.includes(row.state) || row.state === 'authorized') return row ?? null;637    const now = this.now(), cloid = row.cloid as string;638    const seen = await venue.orderStatus(a.account, cloid);639    if (!fills) { fills = await venue.adapter.fills(a.account, { start: (row.created_at as Date).getTime() - 60_000 }); await this.storeFills(a.id, fills); }640    const [stored] = await sql<{ size: string; cost: string }[]>`select coalesce(sum(size), 0)::text as size, coalesce(sum(size * price), 0)::text as cost from deriv_fills where account_id = ${a.id} and client_id = ${cloid}`;641    // two venue sources for the filled size: the order's own remaining size (origSz − sz) and the fills that carry its client id.642    // The fills may be published a moment after the status, so the larger of the two is the filled size and the fills give the price.643    const fromFills = D.from(stored!.size), size = D.from(canon(row.size));644    const avg = D.isPos(fromFills) ? D.str(D.div(D.from(stored!.cost), fromFills, 12, 'half_even')) : null;645646    if (seen === null) {647      if (row.state === 'signed' || row.state === 'unknown') {648        // the signature dies at its venue expiry: after that the venue can no longer accept it, so "no such order" is final649        if (now > (row.expires_at as Date).getTime() + NEVER_PLACED_GRACE_MS) {650          const done = await this.setOrder(a.id, key, { state: 'rejected', final: true, reason: 'never placed: the venue has no order with this client id and the signature has expired' });651          await this.audit('deriv.live.order_never_placed', { account: a.id, clientId: key, hash: row.order_hash }, 'reconciler');652          return done;653        }654        return row.state === 'signed' ? this.setOrder(a.id, key, { state: 'unknown', reason: 'interrupted between signing and sending; being looked up on the venue by its client id' }) : row;655      }656      // the venue acknowledged this order and now denies knowing it: that is a divergence, not a state657      await this.riskEvent(a.id, 'divergence', 'order_missing', `order ${key} was acknowledged by the venue but the venue no longer knows its client id`, { clientId: key, cloid }, `order_missing:${key}`);658      return this.setOrder(a.id, key, { state: 'unknown', reason: 'the venue no longer knows this order' });659    }660    const filled = D.max(fromFills, D.from(seen.filled));661    // a finished order stays under watch until its fills add up to the filled size (or a minute has passed): the average price needs them662    const settled = D.gte(fromFills, filled) || now - ((row.submitted_at as Date | null) ?? (row.created_at as Date)).getTime() > 60_000;663    let state: LiveState, final = false, reason: string | null = null;664    if (seen.status === 'open' || seen.status === 'partially_filled' || seen.status === 'triggered' || seen.status === 'pending') state = D.isPos(filled) ? 'partially_filled' : 'open';665    else if (seen.status === 'filled' || seen.status === 'cancelled') {666      const whole = D.gte(filled, size);667      state = whole ? 'filled' : D.isPos(filled) ? 'partially_filled' : 'cancelled';668      final = settled;669      reason = whole ? null : D.isPos(filled) ? `remainder not filled (venue: ${seen.rejectReason ?? (seen.status === 'cancelled' ? 'canceled' : 'filled')})` : seen.rejectReason;670    } else { state = 'rejected'; final = true; reason = `refused by the venue (${seen.rejectReason ?? 'rejected'})`; }671    const filledStr = D.str(filled);672    if (row.state === state && row.final === final && canon(row.filled) === filledStr && (row.venue_order_id ?? null) === seen.venueOrderId && canonN(row.avg_fill_price) === avg) return row;673    const done = await this.setOrder(a.id, key, { state, final, reason, venueOrderId: seen.venueOrderId, filled: filledStr, avg });674    if (final || row.state !== state) await this.audit(`deriv.live.order_${state}`, { account: a.id, clientId: key, hash: row.order_hash, venueOrderId: seen.venueOrderId, filled: filledStr, avgFillPrice: avg, final }, 'reconciler');675    return done;676  }677678  private async storeFills(id: string, fills: Fill[]): Promise<number> {679    let n = 0;680    for (const f of fills) {681      const ins = await sql`insert into deriv_fills (account_id, fill_id, instrument, order_id, client_id, side, price, size, fee, fee_asset, closed_pnl, liquidation, ts)682        values (${id}, ${f.id}, ${f.instrument}, ${f.orderId}, ${f.clientId}, ${f.side}, ${f.price}, ${f.size}, ${f.fee}, ${f.feeAsset}, ${f.closedPnl}, ${f.liquidation}, ${new Date(f.ts)})683        on conflict (account_id, fill_id) do nothing returning fill_id`;684      n += ins.length;685    }686    return n;687  }688689  /** one signed venue action that is not an order (cancel, leverage): recorded, signed, sent */690  private async act(a: AccountRow, venue: LiveVenue, kind: 'cancel' | 'leverage', request: Record<string, unknown>, built: { action: unknown; sign: SignRequest }, nonce: number, o: { id?: string; context?: unknown; assertion?: unknown; hash?: string; /** sign under this derivative mandate (no passkey) instead of the authorization */ mandateId?: string } = {}): Promise<{ row: Row; outcome: 'accepted' | 'refused' | 'unknown'; error: string | null }> {691    const id = o.id ?? randomUUID(), at = new Date(this.now());692    if (!o.id) await sql`insert into deriv_actions (id, account_id, kind, state, request, action_hash, nonce, expires_at, action, sign_request, created_at, updated_at)693      values (${id}, ${a.id}, ${kind}, 'authorized', ${sql.json(request as never)}, ${o.hash ?? null}, ${nonce}, ${new Date(nonce + LIVE_ACTION_TTL_MS)}, ${built.action as never}::json, ${built.sign as never}::json, ${at}, ${at})`;694    const set = async (state: string, error: string | null, extra: { signature?: unknown; answer?: unknown } = {}): Promise<Row> => {695      const [r] = await sql`update deriv_actions set state = ${state}, error = ${error}, signature = ${extra.signature === undefined ? sql`signature` : sql.json(extra.signature as never)},696        venue_answer = ${extra.answer === undefined ? sql`venue_answer` : sql.json((extra.answer ?? null) as never)}, updated_at = ${new Date(this.now())} where id = ${id} returning *`;697      this.emit(a.id, { event: 'action', data: this.actionView(r!) });698      return r!;699    };700    let signed: { signature: { r: string; s: string; v: number } };701    try {702      signed = o.mandateId ? await this.mandateSigner().venueMandateSign({ mandateId: o.mandateId, sign: built.sign })703        : await this.deps.signer.venueSign({ authorizationId: a.authorization_id!, sign: built.sign, ...(o.context ? { context: o.context } : {}), ...(o.assertion ? { assertion: o.assertion } : {}) });704    } catch (e) { await set('rejected', `not signed: ${msg(e)}`); throw e; }705    await set('signed', null, { signature: signed.signature });706    try {707      const r = await venue.trading().submit(built.action, signed.signature, nonce);708      return { row: await set('submitted', null, { answer: r.raw }), outcome: 'accepted', error: null };709    } catch (e) {710      const refused = e instanceof VenueError && (e.code === 'rejected' || e.code === 'unsupported');711      return { row: await set(refused ? 'rejected' : 'unknown', msg(e)), outcome: refused ? 'refused' : 'unknown', error: msg(e) };712    }713  }714715  /** withdraw an order. Before approval it is only a local row; after, the venue is asked (no passkey: stopping never needs one). */716  async cancelOrder(id: string, key: string) {717    const a = await this.account(id);718    const [row] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key} and state is not null`;719    if (!row) throw new UserError('unknown_order', 'unknown order', 404);720    if (row.final) return { order: this.orderView(row), sent: false };721    if (PRE_SUBMIT.includes(row.state)) {722      const taken = await sql`update deriv_orders set state = 'cancelled', status = 'cancelled', final = true, reject_reason = 'withdrawn before approval; nothing was signed', updated_at = ${new Date(this.now())}723        where account_id = ${id} and client_id = ${key} and state = any(${PRE_SUBMIT}) returning *`;724      if (taken.length) { await this.audit('deriv.live.order_withdrawn', { account: id, clientId: key }); this.emit(id, { event: 'order', data: this.orderView(taken[0]!) }); return { order: this.orderView(taken[0]!), sent: false }; }725    }726    if (IN_FLIGHT.includes(row.state)) throw new UserError('bad_state', 'the order is being signed or sent right now; ask again in a moment', 409);727    const stop = this.blocked(a, 'stop');728    if (stop) throw stop;729    const venue = this.deps.venues.get(a.network), inst = await venue.instrument(row.instrument), nonce = await this.nextNonce(id);730    const built = venue.trading().buildCancel({ instrument: inst, clientId: row.cloid, nonce });731    const r = await this.act(a, venue, 'cancel', { clientId: key, cloid: row.cloid, instrument: row.instrument }, built, nonce);732    await this.audit('deriv.live.cancel_sent', { account: id, clientId: key, outcome: r.outcome, error: r.error });733    const after = await this.reconcileOrder(a, venue, key).catch(() => null);734    const [cur] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`;735    return { order: this.orderView(after ?? cur!), sent: true, venue: { outcome: r.outcome, error: r.error } };736  }737738  // ------------------------------------------------------------------ leverage (passkey)739740  async proposeLeverage(id: string, body: z.infer<typeof LeverageBodySchema>) {741    const a = await this.account(id), stop = this.blocked(a, 'trade');742    if (stop) throw stop;743    const halt = this.effectiveHalt(a);744    if (halt) throw new UserError('halted', `trading is halted: ${halt}`, 423);745    const venue = this.deps.venues.get(a.network);746    await venue.assertPinned();747    const inst = await venue.instrument(body.instrument), vp = a.venue_authorization!.policy;748    if (!vp.allowedInstruments.includes(inst.id)) throw new UserError('instrument_not_allowed', `${inst.id} is not allowed by the authorization`, 422);749    if (body.leverage > vp.maxLeverage || D.gt(D.from(BigInt(body.leverage)), D.from(a.policy.maxLeverage))) throw new UserError('leverage_exceeded', `leverage ${body.leverage} is above the authorized maximum (${Math.min(vp.maxLeverage, Number(a.policy.maxLeverage))})`, 422);750    if (!inst.marginModes.includes(body.marginMode)) throw new UserError('venue_rejected', `${inst.id} does not offer ${body.marginMode} margin`, 422);751    const nonce = await this.nextNonce(id);752    let built: { action: unknown; sign: SignRequest };753    try { built = venue.trading().buildUpdateLeverage({ instrument: inst, leverage: body.leverage, marginMode: body.marginMode, nonce }); } catch (e) { throw new UserError('venue_rejected', msg(e), 422); }754    const canonical: VenueLeverage = { v: 1, authorizationId: a.authorization_id!, venue: 'hyperliquid', network: a.network, account: a.account, kind: 'leverage', instrument: inst.id, leverage: body.leverage, marginMode: body.marginMode,755      nonce, expiresAt: nonce + LIVE_ACTION_TTL_MS };756    const hash = venueLeverageHash(canonical), actionId = randomUUID(), at = new Date(this.now());757    const [row] = await sql`insert into deriv_actions (id, account_id, kind, state, request, action_hash, nonce, expires_at, action, sign_request, created_at, updated_at)758      values (${actionId}, ${id}, 'leverage', 'risk_checked', ${sql.json(canonical as never)}, ${hex(hash)}, ${nonce}, ${new Date(canonical.expiresAt)}, ${built.action as never}::json, ${built.sign as never}::json, ${at}, ${at}) returning *`;759    const current = await venue.leverageOf(a.account, inst.symbol).catch(() => null);760    await this.audit('deriv.live.leverage_proposed', { account: id, action: actionId, hash: hex(hash), instrument: inst.id, leverage: body.leverage, marginMode: body.marginMode });761    return { action: this.actionView(row!), canonical, hash: hex(hash), current, expiresAt: canonical.expiresAt, options: await this.options(hash) };762  }763764  async approveLeverage(id: string, actionId: string, assertion: Record<string, unknown>) {765    const a = await this.account(id);766    const [row] = await sql`select * from deriv_actions where id = ${actionId} and account_id = ${id} and kind = 'leverage'`;767    if (!row) throw new UserError('unknown_action', 'unknown leverage change', 404);768    if (row.state !== 'risk_checked') return { action: this.actionView(row), idempotent: true, venue: null };769    const stop = this.blocked(a, 'trade');770    if (stop) throw stop;771    if (this.effectiveHalt(a)) throw new UserError('halted', `trading is halted: ${this.effectiveHalt(a)}`, 423);772    if (row.expires_at.getTime() <= this.now() + 2_000) { await sql`update deriv_actions set state = 'cancelled', error = 'expired before approval', updated_at = ${new Date(this.now())} where id = ${actionId}`; throw new UserError('proposal_expired', 'this proposal has expired; propose it again', 410); }773    const taken = await sql`update deriv_actions set state = 'authorized', updated_at = ${new Date(this.now())} where id = ${actionId} and state = 'risk_checked' returning id`;774    if (!taken.length) { const [cur] = await sql`select * from deriv_actions where id = ${actionId}`; return { action: this.actionView(cur!), idempotent: true, venue: null }; }775    const venue = this.deps.venues.get(a.network), c = row.request as VenueLeverage;776    const r = await this.act(a, venue, 'leverage', c, { action: row.action, sign: row.sign_request }, Number(row.nonce), { id: actionId, context: { leverage: c }, assertion });777    // the venue's own setting decides, whatever the HTTP answer was778    const now = await venue.leverageOf(a.account, c.instrument.slice(c.instrument.indexOf(':') + 1)).catch(() => null);779    const confirmed = now !== null && now.leverage === String(c.leverage) && now.marginMode === c.marginMode;780    const state = confirmed ? 'confirmed' : r.outcome === 'refused' ? 'rejected' : 'unknown';781    const [cur] = await sql`update deriv_actions set state = ${state}, updated_at = ${new Date(this.now())} where id = ${actionId} returning *`;782    await this.audit('deriv.live.leverage', { account: id, action: actionId, instrument: c.instrument, leverage: c.leverage, marginMode: c.marginMode, state, error: r.error });783    this.emit(id, { event: 'action', data: this.actionView(cur!) });784    return { action: this.actionView(cur!), idempotent: false, venue: { leverage: now, confirmed, source: 'venue' as const } };785  }786787  // ------------------------------------------------------------------ kill switch: halt, cancel-all, close — three separate actions788789  /** stops NEW orders on this account (reduce-only orders that reduce still pass). No passkey. It cancels nothing and closes nothing. */790  async halt(id: string, body: z.infer<typeof HaltBodySchema>) {791    const a = await this.account(id), now = this.now();792    const text = `halted by the owner${body.reason ? `: ${body.reason}` : ''} (${new Date(now).toISOString()})`;793    await sql`update deriv_accounts set halt = ${text}, updated_at = ${new Date(now)} where id = ${id}`;794    const withdrawn = await sql`update deriv_orders set state = 'cancelled', status = 'cancelled', final = true, reject_reason = 'withdrawn: trading was halted before approval', updated_at = ${new Date(now)}795      where account_id = ${id} and state = any(${PRE_SUBMIT}) and not reduce_only returning client_id`;796    await this.riskEvent(id, 'halt', 'halted', text, { withdrawnProposals: withdrawn.length });797    await this.audit('deriv.live.halt', { account: id, reason: body.reason, withdrawnProposals: withdrawn.length });798    this.emit(id, { event: 'status', data: { halt: text, halted: text, ts: now } });799    void a;800    return { ...(await this.get(id)), note: 'New orders are stopped. Resting orders are still on the venue (cancel-all is a separate action) and positions are still open (close is a separate action).' };801  }802803  /** lifts an owner halt. A divergence is not lifted here: it clears when the venue and the records agree again, or when the owner re-bases them. */804  async resume(id: string) {805    const a = await this.account(id);806    await sql`update deriv_accounts set halt = null, updated_at = ${new Date(this.now())} where id = ${id}`;807    await this.riskEvent(id, 'resume', null, 'owner halt lifted');808    await this.audit('deriv.live.resume', { account: id });809    this.emit(id, { event: 'status', data: { halt: null, halted: this.effectiveHalt({ ...a, halt: null }), ts: this.now() } });810    return this.get(id);811  }812813  /** cancels every order resting on the venue account (the venue's own list, not the wallet's). No passkey. It closes no position. */814  async cancelAll(id: string) {815    const a = await this.account(id), stop = this.blocked(a, 'stop');816    if (stop) throw stop;817    const venue = this.deps.venues.get(a.network);818    const open = await venue.adapter.openOrders(a.account).catch((e: unknown) => { throw new UserError('venue_unavailable', msg(e), 503); });819    const failed: { venueOrderId: string | null; instrument: string; error: string }[] = [];820    let cancelled = 0;821    for (const o of open) {822      try {823        if (!o.venueOrderId) throw new Error('no venue order id');824        const inst = await venue.instrument(o.instrument), nonce = await this.nextNonce(id);825        const built = venue.trading().buildCancel({ instrument: inst, venueOrderId: o.venueOrderId, nonce });826        const r = await this.act(a, venue, 'cancel', { venueOrderId: o.venueOrderId, instrument: o.instrument, cancelAll: true }, built, nonce);827        if (r.outcome === 'accepted') cancelled += 1; else failed.push({ venueOrderId: o.venueOrderId, instrument: o.instrument, error: r.error ?? r.outcome });828      } catch (e) { failed.push({ venueOrderId: o.venueOrderId, instrument: o.instrument, error: msg(e) }); }829    }830    // the venue's list decides what is left831    const remaining = await venue.adapter.openOrders(a.account).catch(() => null);832    const live = await sql`select client_id from deriv_orders where account_id = ${id} and state = any(${AT_VENUE}) and not final`;833    for (const r of live) await this.reconcileOrder(a, venue, r.client_id).catch(() => null);834    await this.audit('deriv.live.cancel_all', { account: id, requested: open.length, accepted: cancelled, failed: failed.length, remaining: remaining?.length ?? null });835    return { requested: open.length, accepted: cancelled, failed, remaining, source: 'venue' as const, ts: this.now() };836  }837838  /** proposes a reduce-only market order on a fraction of the venue's position, bounded by a stated slippage. Needs the passkey like any order. */839  async close(id: string, body: z.infer<typeof CloseBodySchema>, key: string = `close-${randomUUID()}`) {840    const a = await this.account(id);841    const [prior] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`;842    if (prior) return { ...(await this.proposalAnswer(prior, true)), worstPrice: canonN(prior.price), slippageBoundBps: null };843    const stop = this.blocked(a, 'trade');844    if (stop) throw stop;845    const venue = this.deps.venues.get(a.network), inst = await venue.instrument(body.instrument);846    const positions = await venue.adapter.positions(a.account).catch((e: unknown) => { throw new UserError('venue_unavailable', msg(e), 503); });847    const pos = positions.find((p) => p.instrument === inst.id);848    if (!pos) throw new UserError('no_position', `the venue reports no open position on ${inst.id}`, 422);849    const whole = D.eq(D.from(body.fraction), D.one);850    const size = whole ? D.from(pos.size) : D.quantize(D.mul(D.from(pos.size), D.from(body.fraction)), D.from(inst.lotSize), 'down');851    if (!D.isPos(size)) throw new UserError('below_min_size', `${body.fraction} of the position is less than one lot (${inst.lotSize})`, 422);852    const side = pos.side === 'long' ? 'sell' as const : 'buy' as const, bound = body.slippageBps ?? DEFAULT_SLIPPAGE_BPS;853    const { book } = await venue.market(inst), price = worstPrice(inst, book, side, bound);854    const r = await this.propose(id, OrderBodySchema.parse({ instrument: inst.id, side, type: 'market', size: D.str(size), price, reduceOnly: true, tif: 'ioc' }), key, 'close');855    return { ...r, worstPrice: price, slippageBoundBps: bound, position: { ...pos, source: 'venue' as const } };856  }857858  /** the owner accepts the venue's present positions as the reference (after trading from another interface, a liquidation…) */859  async rebase(id: string) {860    const a = await this.account(id), venue = this.deps.venues.get(a.network);861    const positions = await venue.adapter.positions(a.account).catch((e: unknown) => { throw new UserError('venue_unavailable', msg(e), 503); });862    const now = this.now(), meta: Meta = { ...this.meta(a), baseline: Object.fromEntries(positions.map((x) => [x.instrument, x.side === 'long' ? x.size : `-${x.size}`])), baselineTs: now, pendingDivergence: null };863    await sql`update deriv_accounts set meta = ${sql.json(meta as never)}, divergence = null, updated_at = ${new Date(now)} where id = ${id}`;864    await this.riskEvent(id, 'divergence_rebased', null, 'the owner accepted the venue\'s present positions as the reference', { baseline: meta.baseline });865    await this.audit('deriv.live.rebased', { account: id, baseline: meta.baseline });866    return this.get(id);867  }868869  // ------------------------------------------------------------------ reconciliation worker870871  /** every venue account that was ever authorized or still has an order in flight */872  async active(): Promise<string[]> {873    const rows = await sql<{ id: string }[]>`select a.id from deriv_accounts a where a.authorization_id is not null and (a.mode <> 'READ_ONLY' or exists (select 1 from deriv_orders o where o.account_id = a.id and o.state is not null and not o.final)) order by a.created_at`;874    return rows.map((r) => r.id);875  }876877  /**878   * One reconciliation pass on one account, entirely from the database and the venue (nothing kept in memory between passes):879   * stale proposals, orders in flight, fills, funding, positions, divergence, equity.880   */881  async reconcile(id: string): Promise<{ reconciled: number; fills: number; funding: number; divergence: string | null }> {882    const a = await this.account(id), venue = this.deps.venues.get(a.network), now = this.now(), meta = this.meta(a);883    // 1 — local timeouts: nothing here touched the venue884    await sql`update deriv_orders set state = 'cancelled', status = 'cancelled', final = true, reject_reason = 'expired before approval; nothing was signed', updated_at = ${new Date(now)}885      where account_id = ${id} and state = any(${PRE_SUBMIT}) and (expires_at is null or expires_at < ${new Date(now)})`;886    await sql`update deriv_orders set state = 'rejected', status = 'rejected', final = true, reject_reason = 'interrupted while asking the signer; no signature was received, nothing was sent', updated_at = ${new Date(now)}887      where account_id = ${id} and state = 'authorized' and updated_at < ${new Date(now - AUTHORIZED_STUCK_MS)}`;888    await sql`update deriv_actions set state = 'cancelled', error = 'expired before approval', updated_at = ${new Date(now)} where account_id = ${id} and state = 'risk_checked' and expires_at < ${new Date(now)}`;889890    // 2 — the venue's own state891    const [state, positions, openOrders, fills] = await Promise.all([venue.adapter.account(a.account), venue.adapter.positions(a.account), venue.adapter.openOrders(a.account),892      venue.adapter.fills(a.account, { start: Math.max(0, meta.fillsCursor - 60_000) })]);893    const newFills = await this.storeFills(id, fills);894    if (fills.length) meta.fillsCursor = Math.max(meta.fillsCursor, ...fills.map((f) => f.ts));895    let newFunding = 0;896    if (now - meta.fundingReadAt >= FUNDING_EVERY_MS) {897      const pays = await venue.adapter.funding(a.account, { start: Math.max(0, meta.fundingCursor - 3_600_000) }).catch(() => null);898      if (pays) {899        for (const p of pays) {900          const ins = await sql`insert into deriv_funding (account_id, instrument, ts, amount, rate, size) values (${id}, ${p.instrument}, ${new Date(p.ts)}, ${p.amount}, ${p.rate}, ${p.size}) on conflict (account_id, instrument, ts) do nothing returning ts`;901          newFunding += ins.length;902        }903        if (pays.length) meta.fundingCursor = Math.max(meta.fundingCursor, ...pays.map((p) => p.ts));904        meta.fundingReadAt = now;905      }906    }907908    // 3 — every order that is somewhere between the signer and a final state909    const live = await sql`select client_id from deriv_orders where account_id = ${id} and state is not null and not final and state <> all(${[...PRE_SUBMIT, 'authorized']})`;910    for (const r of live) await this.reconcileOrder(a, venue, r.client_id, fills).catch((e: unknown) => console.error(`[derivatives] live reconcile ${id}/${r.client_id}: ${msg(e)}`));911912    // 4 — divergence between the venue and what the wallet's own orders explain913    const ours = new Set((await sql<{ cloid: string }[]>`select cloid from deriv_orders where account_id = ${id} and cloid is not null`).map((r) => r.cloid));914    const sums = await sql<{ instrument: string; net: string }[]>`select instrument, sum(case when side = 'buy' then size else -size end)::text as net from deriv_fills915      where account_id = ${id} and ts >= ${new Date(meta.baselineTs)} and client_id = any(${[...ours]}) group by instrument`;916    const expected = new Map<string, ReturnType<typeof D.from>>();917    for (const [k, v] of Object.entries(meta.baseline)) expected.set(k, D.from(v));918    for (const s of sums) expected.set(s.instrument, D.add(expected.get(s.instrument) ?? D.zero, D.from(s.net)));919    const actual = new Map(positions.map((p) => [p.instrument, D.from(p.side === 'long' ? p.size : `-${p.size}`)]));920    const problems: string[] = [];921    for (const k of new Set([...expected.keys(), ...actual.keys()])) {922      const e = expected.get(k) ?? D.zero, v = actual.get(k) ?? D.zero;923      if (!D.eq(e, v)) problems.push(`position ${k}: venue ${D.str(v)}, wallet records ${D.str(e)}`);924    }925    for (const o of openOrders) if (!ours.has(o.clientId)) problems.push(`order ${o.venueOrderId} on ${o.instrument} rests on the venue and was not placed by the wallet`);926    const found = problems.length ? problems.slice(0, 4).join('; ') : null;927    // a difference must be seen on two passes in a row: the venue's position can move a moment before its fills are published928    let divergence = a.divergence;929    if (a.mode === 'MANUAL_LIVE') {930      if (found && (meta.pendingDivergence === found || a.divergence !== null)) {931        if (a.divergence !== found) {932          divergence = found;933          await this.riskEvent(id, 'divergence', 'state_divergence', `the venue's state and the wallet's records differ: ${found}. New orders are stopped until it clears.`, { problems }, `divergence:${found}`);934          await this.audit('deriv.live.divergence', { account: id, problems }, 'reconciler');935        }936      } else if (!found && a.divergence !== null) {937        divergence = null;938        await this.riskEvent(id, 'divergence_cleared', null, 'the venue\'s state and the wallet\'s records agree again');939        await this.audit('deriv.live.divergence_cleared', { account: id }, 'reconciler');940      }941      meta.pendingDivergence = found;942    }943944    // 5 — equity, one row per minute, straight from the venue945    const minute = Math.floor(now / 60_000) * 60_000;946    if (minute !== meta.lastEquityMinute) {947      const unrealized = D.str(D.sub(D.from(state.equity), D.from(state.collateral)));948      await sql`insert into deriv_equity (account_id, ts, equity, collateral, margin_used, unrealized_pnl) values (${id}, ${new Date(minute)}, ${state.equity}, ${state.collateral}, ${state.marginUsed}, ${unrealized})949        on conflict (account_id, ts) do update set equity = excluded.equity, collateral = excluded.collateral, margin_used = excluded.margin_used, unrealized_pnl = excluded.unrealized_pnl`;950      meta.lastEquityMinute = minute;951    }952    await sql`update deriv_accounts set meta = ${sql.json(meta as never)}, divergence = ${divergence}, updated_at = ${new Date(now)} where id = ${id}`;953    this.emit(id, { event: 'account', data: { state, positions, openOrders, divergence, halt: a.halt, mode: a.mode, ts: now, source: 'venue' } });954    return { reconciled: live.length, fills: newFills, funding: newFunding, divergence };955  }956957  async step(): Promise<StepSummary> {958    const s: StepSummary = { accounts: 0, reconciled: 0, fills: 0, funding: 0, divergences: 0, errors: [] };959    for (const id of await this.active()) {960      s.accounts += 1;961      try { const r = await this.reconcile(id); s.reconciled += r.reconciled; s.fills += r.fills; s.funding += r.funding; if (r.divergence) s.divergences += 1; }962      catch (e) { s.errors.push(`account ${id}: ${msg(e)}`); this.emit(id, { event: 'status', data: { error: msg(e), ts: this.now() } }); }963    }964    return s;965  }966967  // ------------------------------------------------------------------ MANDATE_LIVE: a derivative mandate on top of the authorization968  //969  // The account keeps `mode: 'MANUAL_LIVE'` (manual orders still need the passkey, exactly as before); an active mandate adds970  // `MANDATE_LIVE` to `modes`. A mandate order goes through the same pipeline as a manual one — live data, the venue's real971  // state, the risk engine, a stored order, the signer, the venue, reconciliation by client order id — with one difference: the972  // signer is asked through /venue/mandate/sign, which needs no passkey and enforces the mandate's limits itself.973974  private mandateSigner(): MandateSignerPort {975    const s = this.deps.mandateSigner;976    if (!s) throw new UserError('mandate_unavailable', liveReason('mandate_unavailable'), 503);977    return s;978  }979  /** the two mainnet locks of autonomous trading; null = open */980  private mandateLock(a: AccountRow): UserError | null {981    if (a.network !== 'mainnet') return null;982    if (!this.deps.mainnetEnabled()) return new UserError('mainnet_disabled', MAINNET_OFF, 403);983    if (!(this.deps.mandateMainnetEnabled?.() ?? false)) return new UserError('mandate_mainnet_disabled', liveReason('mandate_mainnet_disabled'), 403);984    return null;985  }986  /** the mandate that governs the account now (the last one approved), with its phase as the signer will see it */987  private async currentMandate(a: AccountRow): Promise<{ row: MandateRow; phase: VenueMandatePhase; windDownUntil: number | null } | null> {988    const [row] = await sql<MandateRow[]>`select * from deriv_mandates where account_id = ${a.id} and state in ('active', 'revoked') order by approved_at desc limit 1`;989    if (!row) return null;990    const now = Math.floor(this.now() / 1000), ph = venueMandatePhase(row.mandate, row.revoked_at ? Math.floor(row.revoked_at.getTime() / 1000) : null, now);991    // the authorization is the root: when it is gone the mandate signs nothing, whatever its own dates say992    const rootAlive = a.mode === 'MANUAL_LIVE' && a.authorization_id === row.authorization_id && !!a.venue_authorization && now < a.venue_authorization.expiresAt;993    return { row, phase: rootAlive ? ph.phase : 'ended', windDownUntil: ph.windDownUntil === null ? null : ph.windDownUntil * 1000 };994  }995  private async mandateInfo(a: AccountRow) {996    const cur = await this.currentMandate(a), [prep] = await sql<MandateRow[]>`select * from deriv_mandates where account_id = ${a.id} and state = 'prepared' order by prepared_at desc limit 1`;997    const m = cur?.row.mandate;998    return {999      /** every mode the account is in: MANDATE_LIVE is added to MANUAL_LIVE while a mandate is active (manual orders still need the passkey) */1000      modes: (a.mode === 'MANUAL_LIVE' && cur?.phase === 'active' ? ['MANUAL_LIVE', 'MANDATE_LIVE'] : [a.mode]) as string[],1001      mandate: cur && m ? { id: m.mandateId, hash: cur.row.hash, authorizationId: m.authorizationId, agentId: m.agentId, strategySha256: m.strategySha256, limits: m,1002        phase: cur.phase, active: cur.phase === 'active', createdAt: m.createdAt * 1000, expiresAt: m.expiresAt * 1000, expired: this.now() >= m.expiresAt * 1000, approvedAt: cur.row.approved_at?.getTime() ?? null,1003        revokedAt: cur.row.revoked_at?.getTime() ?? null, windDownUntil: cur.windDownUntil, ackSha256: m.ackSha256 } : null,1004      pendingMandate: prep ? { mandateId: prep.mandate.mandateId, preparedAt: prep.prepared_at.getTime() } : null,1005    };1006  }10071008  /** step 1: the canonical mandate, the acknowledgement text to DISPLAY again, what the signer will enforce, and the WebAuthn request */1009  async prepareMandate(id: string, body: z.infer<typeof MandateBodySchema>) {1010    const a = await this.account(id);1011    this.mandateSigner();1012    const lock = this.mandateLock(a) ?? this.blocked(a, 'trade');1013    if (lock) throw lock;1014    const auth = a.venue_authorization!;1015    const st = await this.deps.signer.status(true);1016    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);1017    if ((await this.currentMandate(a))?.phase === 'active') throw new UserError('bad_state', 'a mandate is already active on this venue account: revoke it to change its limits', 409);1018    await this.deps.venues.get(a.network).assertPinned();1019    const nowMs = this.now(), nowS = Math.floor(nowMs / 1000), ack = jurisdictionAck('hyperliquid', body.locale);1020    const mandate: VenueMandate = { v: 1, mandateId: randomUUID(), authorizationId: a.authorization_id!, keyId: auth.keyId, venue: 'hyperliquid', network: a.network, account: a.account, agentAddress: a.agent_address,1021      strategySha256: body.strategySha256, agentId: body.agentId, instruments: body.instruments, maxOrderNotional: body.maxOrderNotional, maxGrossExposure: body.maxGrossExposure, maxOrdersPerDay: body.maxOrdersPerDay,1022      maxOrdersPerHour: body.maxOrdersPerHour, maxDailyTurnover: body.maxDailyTurnover, floorEquity: body.floorEquity, maxPriceDeviationBps: body.maxPriceDeviationBps ?? auth.policy.maxPriceDeviationBps,1023      createdAt: nowS, expiresAt: nowS + body.days * 86400, ackSha256: ack.sha256 };1024    try { validateVenueMandate(mandate, auth, nowS); } catch (e) { throw new UserError('bad_policy', msg(e), 400); }1025    for (const i of mandate.instruments) { const pin = hlPinnedAsset(a.network, i.instrument); if (!pin || i.maxLeverage > pin.maxLeverage) throw new UserError('bad_policy', `${i.instrument}: not in the pinned ${a.network} asset table, or leverage above the venue maximum`, 400); }1026    const hash = venueMandateHash(mandate), at = new Date(nowMs);1027    await sql`delete from deriv_mandates where account_id = ${id} and state = 'prepared'`;1028    await sql`insert into deriv_mandates (id, account_id, authorization_id, state, mandate, hash, ack, prepared_at, created_at, updated_at)1029      values (${mandate.mandateId}, ${id}, ${mandate.authorizationId}, 'prepared', ${sql.json(mandate as never)}, ${hex(hash)}, ${sql.json({ locale: ack.locale, sha256: ack.sha256, version: ack.version } as never)}, ${at}, ${at}, ${at})`;1030    const v = await this.venueState(a), t = liveTexts(body.locale);1031    return {1032      mandate, hash: hex(hash), acknowledgement: ack, locale: body.locale,1033      authorization: { id: a.authorization_id!, hash: a.authorization_hash, expiresAt: auth.expiresAt * 1000, policy: auth.policy },1034      warning: t.mandateWarning, enforcedBySigner: t.enforcedBySigner, trustedBySigner: t.trustedBySigner, notEnforced: t.notEnforced,1035      windDown: { seconds: VENUE_MANDATE_WIND_DOWN_S, text: t.windDown }, whatIsSigned: { byPasskey: t.mandateByPasskey },1036      venueState: v, options: await this.options(hash), ts: nowMs,1037    };1038  }10391040  /** step 2: the signer judges the passkey and stores the mandate. No key signs anything here, and nothing is sent to the venue. */1041  async approveMandate(id: string, body: z.infer<typeof ApproveMandateSchema>) {1042    const a = await this.account(id), ms = this.mandateSigner();1043    const lock = this.mandateLock(a) ?? this.blocked(a, 'trade');1044    if (lock) throw lock;1045    const [taken] = await sql<MandateRow[]>`update deriv_mandates set state = 'consumed', updated_at = ${new Date(this.now())} where account_id = ${id} and state = 'prepared' returning *`;   // a mandate is approved once1046    if (!taken) throw new UserError('bad_state', 'prepare the mandate first', 409);1047    const m = taken.mandate;1048    let r: Awaited<ReturnType<MandateSignerPort['venueMandateApprove']>>;1049    try { r = await ms.venueMandateApprove(m, body.assertion); }1050    catch (e) {1051      await sql`update deriv_mandates set state = 'failed', updated_at = ${new Date(this.now())} where id = ${m.mandateId}`;1052      await this.audit('deriv.live.mandate_refused', { account: id, mandateId: m.mandateId, reason: msg(e) });1053      throw e;1054    }1055    const now = new Date(this.now());1056    await sql`update deriv_mandates set state = 'active', approved_at = ${now}, updated_at = ${now} where id = ${m.mandateId}`;1057    await this.audit('deriv.live.mandate_acknowledged', { account: id, mandateId: m.mandateId, mandateHash: r.hash, ackSha256: taken.ack.sha256, locale: taken.ack.locale, version: taken.ack.version,1058      statement: 'the owner confirmed again with his passkey having read the jurisdiction summary; letting an automated strategy trade derivatives on this venue inside the mandate is his own decision' });1059    await this.audit('deriv.live.mandate_approved', { account: id, venue: a.venue, network: a.network, mandateId: m.mandateId, hash: r.hash, authorizationId: m.authorizationId, strategySha256: m.strategySha256, agentId: m.agentId,1060      instruments: m.instruments, maxOrderNotional: m.maxOrderNotional, maxGrossExposure: m.maxGrossExposure, maxOrdersPerDay: m.maxOrdersPerDay, maxOrdersPerHour: m.maxOrdersPerHour, maxDailyTurnover: m.maxDailyTurnover,1061      floorEquity: m.floorEquity, maxPriceDeviationBps: m.maxPriceDeviationBps, expiresAt: m.expiresAt });1062    this.emit(id, { event: 'status', data: { mode: a.mode, modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], mandate: m.mandateId, ts: now.getTime() } });1063    return this.get(id);1064  }10651066  /**1067   * Stops the mandate — no passkey. On the signer first (no new exposure from that instant), then, if asked, cancel-all. Positions1068   * are NOT closed: during the wind-down the engine may still close them with reduce-only orders, and the owner can close with his passkey.1069   */1070  async revokeMandate(id: string, body: { cancelOpenOrders: boolean; locale?: LiveLocale }) {1071    const a = await this.account(id), ms = this.mandateSigner(), cur = await this.currentMandate(a);1072    if (!cur) throw new UserError('bad_state', 'this venue account has no mandate', 409);1073    const now = new Date(this.now());1074    let windDownUntil = cur.windDownUntil;1075    if (cur.row.state === 'active') {1076      const r = await ms.venueMandateRevoke(cur.row.id);1077      windDownUntil = r.windDownUntil * 1000;1078      await sql`update deriv_mandates set state = 'revoked', revoked_at = ${now}, updated_at = ${now} where id = ${cur.row.id} and state = 'active'`;1079      await this.audit('deriv.live.mandate_revoked', { account: id, mandateId: cur.row.id, windDownUntil });1080      await this.riskEvent(id, 'mandate_revoked', null, 'the derivative mandate was revoked: no order that can add exposure is signed any more', { mandateId: cur.row.id, windDownUntil });1081    }1082    let cancelled: Awaited<ReturnType<LiveService['cancelAll']>> | null = null, cancelError: { code: string; message: string } | null = null;1083    if (body.cancelOpenOrders) {1084      try { cancelled = await this.cancelAll(id); }1085      catch (e) { cancelError = { code: e instanceof UserError ? e.code : 'internal', message: msg(e) }; }1086    }1087    const v = await this.venueState(a);1088    this.emit(id, { event: 'status', data: { mode: a.mode, modes: [a.mode], mandate: null, ts: now.getTime() } });1089    return { ...(await this.get(id)), cancelled, cancelError, locale: body.locale ?? 'en',1090      windDown: { until: windDownUntil, text: liveTexts(body.locale ?? 'en').windDown, openPositions: v.positions.length, openOrders: v.openOrders.length } };1091  }10921093  /** the mandate as the API holds it, and the signer's own record of it (counters of the day, phase) */1094  async mandateStatus(id: string) {1095    const a = await this.account(id), info = await this.mandateInfo(a);1096    const st = info.mandate ? await this.deps.signer.status(true).catch(() => null) as { venueMandates?: { mandate: { mandateId: string } }[] } | null : null;1097    return { ...info, signer: st?.venueMandates?.find((x) => x.mandate.mandateId === info.mandate!.id) ?? null, ts: this.now() };1098  }10991100  /** the API-side risk policy of a mandate order: the account's policy, tightened by the mandate — never wider than either */1101  private mandatePolicy(a: AccountRow, m: VenueMandate, instrument: string): RiskPolicy {1102    const p = RiskPolicySchema.parse(a.policy), min = (x: DecStr, y: DecStr): DecStr => D.str(D.min(D.from(x), D.from(y))), mi = m.instruments.find((i) => i.instrument === instrument);1103    return { ...p, allowedInstruments: p.allowedInstruments.filter((i) => m.instruments.some((x) => x.instrument === i)), maxOrderNotional: min(p.maxOrderNotional, m.maxOrderNotional),1104      maxPositionNotional: mi ? min(p.maxPositionNotional, mi.maxPositionNotional) : p.maxPositionNotional, maxGrossExposure: min(p.maxGrossExposure, m.maxGrossExposure),1105      maxDeviationBps: min(p.maxDeviationBps, String(m.maxPriceDeviationBps)), maxOrdersPerDay: Math.min(p.maxOrdersPerDay, m.maxOrdersPerDay) };1106  }11071108  /**1109   * ONE order placed by an approved automated strategy, without a passkey. Server-side only: there is no HTTP route to it.1110   * `ctx.clientId` is the idempotency key (the venue's client order id is derived from it): calling again with the same key1111   * never places a second order, it returns the first one as the venue reports it now. Returns the reconciled order.1112   * Refusals are UserErrors with a stable code: the risk engine's codes, `signer_<code>` for the signer's, and mandate_none,1113   * mandate_mismatch, mandate_wind_down, mandate_floor_equity, mandate_mainnet_disabled, mainnet_disabled, halted, …1114   */1115  async placeMandateOrder(id: string, req: OrderRequest, ctx: MandateOrderCtx) {1116    const c = MandateOrderCtxSchema.parse(ctx), k = `${id}|${c.clientId}`, running = this.building.get(k);1117    if (running) { await running.catch(() => {}); const again = await this.placeMandateOrderReplay(id, c.clientId); if (again) return again; }1118    const p = this.placeMandateOnce(id, req, c);1119    this.building.set(k, p);1120    try { return await p; } finally { if (this.building.get(k) === p) this.building.delete(k); }1121  }11221123  async placeMandateOrderReplay(id: string, key: string) {1124    const [row] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`;1125    if (!row) return null;1126    if (row.origin !== 'mandate') throw new UserError('idempotency', 'this client id belongs to an order that was not placed under the mandate', 409);1127    const a = await this.account(id);1128    const now = row.final ? row : (await this.reconcileOrder(a, this.deps.venues.get(a.network), key).catch(() => null)) ?? row;1129    return { order: this.orderView(now), idempotent: true, estimate: (row.estimate ?? null) as ReturnType<LiveService['estimate']> | null };1130  }11311132  private async placeMandateOnce(id: string, req: OrderRequest, c: MandateOrderCtx) {1133    const key = c.clientId, prior = await this.placeMandateOrderReplay(id, key);1134    if (prior) return prior;1135    const a = await this.account(id), ms = this.mandateSigner();1136    const lock = this.mandateLock(a) ?? this.blocked(a, 'trade');1137    if (lock) throw lock;1138    const cur = await this.currentMandate(a);1139    if (!cur || cur.phase === 'ended') throw new UserError('mandate_none', 'no derivative mandate is in force on this venue account: the owner approves one with his passkey', 422);1140    const m = cur.row.mandate;1141    if (c.strategySha256 !== m.strategySha256 || c.agentId !== m.agentId) throw new UserError('mandate_mismatch', 'the mandate was approved for another strategy version or another agent', 422);1142    const parsed = OrderRequestSchema.safeParse({ ...req, clientId: key });      // the idempotency key is ctx.clientId; req.clientId is ignored1143    if (!parsed.success || parsed.data.price === null) throw new UserError('bad_request', 'a mandate order is a complete order request and names its price (limit price, or worst acceptable price for a market order)', 400);1144    const r0 = parsed.data;1145    if (cur.phase === 'wind_down' && !r0.reduceOnly) throw new UserError('mandate_wind_down', `the mandate ${cur.row.state === 'revoked' ? 'was revoked' : 'has expired'}: only cancels and reduce-only orders are placed until the wind-down ends`, 422);1146    const venue = this.deps.venues.get(a.network);1147    await venue.assertPinned();1148    const body = OrderBodySchema.parse({ instrument: r0.instrument, side: r0.side, type: r0.type, size: r0.size, price: r0.price, triggerPrice: r0.triggerPrice, reduceOnly: r0.reduceOnly, tif: r0.tif });1149    const chk = await this.check(a, venue, body, key, this.mandatePolicy(a, m, r0.instrument)), { req: order, inst, now } = chk, at = new Date(now);1150    let refusal: { code: string; reason: string } | null = chk.risk.ok ? null : { code: chk.risk.code, reason: chk.risk.reason };1151    const notional = order.price === null ? null : venueOrderNotional({ size: order.size, price: order.price, triggerPrice: order.triggerPrice });1152    // what the signer will refuse is said here first, from the venue state the API just read1153    if (!refusal && !m.instruments.some((i) => i.instrument === order.instrument)) refusal = { code: 'instrument_not_allowed', reason: `${order.instrument} is not allowed by the mandate` };1154    if (!refusal && notional !== null && D.gt(D.from(notional), D.from(m.maxOrderNotional))) refusal = { code: 'order_notional_exceeded', reason: `order notional ${notional} exceeds the mandate's ${m.maxOrderNotional} per order (reduce-only orders included: close in smaller parts)` };1155    if (!refusal && !order.reduceOnly && D.lt(D.from(chk.state.equity), D.from(m.floorEquity))) refusal = { code: 'mandate_floor_equity', reason: `account value ${chk.state.equity} is below the mandate's floor ${m.floorEquity}: only cancels and reduce-only orders` };1156    const agent = { mandateId: m.mandateId, agentId: c.agentId, strategySha256: c.strategySha256 };1157    const ins = await sql`insert into deriv_orders (account_id, client_id, cloid, instrument, side, type, size, price, trigger_price, reduce_only, tif, status, state, origin, mandate_id, agent, created_at, updated_at)1158      values (${id}, ${key}, ${hlCloid(key)}, ${order.instrument}, ${order.side}, ${order.type}, ${order.size}, ${order.price}, ${order.triggerPrice}, ${order.reduceOnly}, ${order.tif}, 'pending', 'proposed', 'mandate', ${m.mandateId}, ${sql.json(agent as never)}, ${at}, ${at})1159      on conflict (account_id, client_id) do nothing returning client_id`;1160    if (!ins.length) { const again = await this.placeMandateOrderReplay(id, key); if (again) return again; throw new UserError('idempotency', 'this client id is in use', 409); }1161    const refuse = async (code: string, reason: string, status = 422): Promise<never> => {1162      await this.setOrder(id, key, { state: 'rejected', final: true, reason: `${code}: ${reason}`.slice(0, 500) });1163      await this.riskEvent(id, 'order_refused', code, reason, { clientId: key, instrument: order.instrument, side: order.side, type: order.type, size: order.size, price: order.price, reduceOnly: order.reduceOnly, origin: 'mandate', ...agent });1164      throw new UserError(code, reason, status);1165    };1166    if (refusal) await refuse(refusal.code, refusal.reason);1167    const trading = venue.trading(MANDATE_ACTION_TTL_MS), nonce = await this.nextNonce(id);1168    let built: ReturnType<typeof trading.buildOrder>, canonical: VenueOrder;1169    try {1170      built = trading.buildOrder(order, { instrument: inst, nonce });1171      const norm = trading.normalizeRequest(order, inst);1172      canonical = { v: 1, authorizationId: a.authorization_id!, venue: 'hyperliquid', network: a.network, account: a.account, kind: 'order', instrument: norm.instrument, side: norm.side, type: norm.type as VenueOrder['type'],1173        size: norm.size, price: norm.price!, triggerPrice: norm.triggerPrice, reduceOnly: norm.reduceOnly, tif: norm.tif, clientId: norm.clientId, nonce, expiresAt: nonce + MANDATE_ACTION_TTL_MS };1174    } catch (e) { return refuse('venue_rejected', msg(e)); }1175    const hash = venueOrderHash(canonical), estimate = this.estimate(chk);1176    // no approval step: the mandate is the authorization. `authorized` = handed to the signer, which decides.1177    const [row] = await sql`update deriv_orders set state = 'authorized', request = ${sql.json(canonical as never)}, order_hash = ${hex(hash)}, nonce = ${nonce}, expires_at = ${new Date(canonical.expiresAt)},1178      action = ${built.action as never}::json, sign_request = ${built.sign as never}::json, estimate = ${sql.json(estimate as never)}, updated_at = ${at} where account_id = ${id} and client_id = ${key} and state = 'proposed' returning *`;1179    // withdrawn meanwhile by a halt or a revocation (they end every proposal that is not yet with the signer): nothing is signed1180    if (!row) { const [cur] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`; return { order: this.orderView(cur!), idempotent: false, estimate: null }; }1181    await this.audit('deriv.live.mandate_order', { account: id, clientId: key, hash: hex(hash), instrument: canonical.instrument, side: canonical.side, type: canonical.type, size: canonical.size, price: canonical.price,1182      triggerPrice: canonical.triggerPrice, reduceOnly: canonical.reduceOnly, tif: canonical.tif, nonce, ...agent }, `agent:${c.agentId}`);1183    this.emit(id, { event: 'order', data: this.orderView(row!) });1184    let signed: Awaited<ReturnType<MandateSignerPort['venueMandateSign']>>;1185    try { signed = await ms.venueMandateSign({ mandateId: m.mandateId, sign: built.sign, context: { order: canonical } }); }1186    catch (e) {1187      // refused, or unreachable: either way no signature came back, so nothing can have been sent1188      await this.setOrder(id, key, { state: 'rejected', final: true, reason: `not signed: ${msg(e)}` });1189      await this.riskEvent(id, 'order_refused', e instanceof UserError ? e.code : 'signer', `the signer did not sign the mandate order: ${msg(e)}`, { clientId: key, origin: 'mandate', ...agent });1190      throw e;1191    }1192    await this.setOrder(id, key, { state: 'signed', signature: signed.signature });1193    await this.submit(a, venue, row!, signed.signature);1194    const final = await this.reconcileOrder(a, venue, key).catch(() => null);1195    const [now2] = await sql`select * from deriv_orders where account_id = ${id} and client_id = ${key}`;1196    return { order: this.orderView(final ?? now2!), idempotent: false, estimate: estimate as ReturnType<LiveService['estimate']> | null };1197  }11981199  /**1200   * The leverage setting of one instrument, changed under the mandate — no passkey. The signer signs it only at or below the1201   * mandate's leverage for that instrument, and never when it would change the margin mode of an open position. The venue's1202   * own setting decides the outcome (`venue.confirmed`).1203   */1204  async setMandateLeverage(id: string, body: z.infer<typeof LeverageBodySchema>, ctx: Pick<MandateOrderCtx, 'strategySha256' | 'agentId'>) {1205    const a = await this.account(id);1206    const lock = this.mandateLock(a) ?? this.blocked(a, 'trade');1207    if (lock) throw lock;1208    const cur = await this.currentMandate(a);1209    if (!cur || cur.phase !== 'active') throw new UserError(cur?.phase === 'wind_down' ? 'mandate_wind_down' : 'mandate_none', 'no derivative mandate is active on this venue account', 422);1210    const m = cur.row.mandate, mi = m.instruments.find((i) => i.instrument === body.instrument);1211    if (ctx.strategySha256 !== m.strategySha256 || ctx.agentId !== m.agentId) throw new UserError('mandate_mismatch', 'the mandate was approved for another strategy version or another agent', 422);1212    const halt = this.effectiveHalt(a);1213    if (halt) throw new UserError('halted', `trading is halted: ${halt}`, 423);1214    if (!mi) throw new UserError('instrument_not_allowed', `${body.instrument} is not allowed by the mandate`, 422);1215    if (body.leverage > mi.maxLeverage) throw new UserError('leverage_exceeded', `leverage ${body.leverage} is above the mandate's ${mi.maxLeverage} for ${body.instrument}`, 422);1216    const venue = this.deps.venues.get(a.network);1217    await venue.assertPinned();1218    const inst = await venue.instrument(body.instrument);1219    if (!inst.marginModes.includes(body.marginMode)) throw new UserError('venue_rejected', `${inst.id} does not offer ${body.marginMode} margin`, 422);1220    const nonce = await this.nextNonce(id);1221    let built: { action: unknown; sign: SignRequest };1222    try { built = venue.trading().buildUpdateLeverage({ instrument: inst, leverage: body.leverage, marginMode: body.marginMode, nonce }); } catch (e) { throw new UserError('venue_rejected', msg(e), 422); }1223    const request = { instrument: inst.id, leverage: body.leverage, marginMode: body.marginMode, mandateId: m.mandateId, agentId: ctx.agentId, strategySha256: ctx.strategySha256 };1224    const r = await this.act(a, venue, 'leverage', request, built, nonce, { mandateId: m.mandateId });1225    const now = await venue.leverageOf(a.account, inst.symbol).catch(() => null);1226    const confirmed = now !== null && now.leverage === String(body.leverage) && now.marginMode === body.marginMode;1227    const state = confirmed ? 'confirmed' : r.outcome === 'refused' ? 'rejected' : 'unknown';1228    const [row] = await sql`update deriv_actions set state = ${state}, updated_at = ${new Date(this.now())} where id = ${r.row.id} returning *`;1229    await this.audit('deriv.live.mandate_leverage', { account: id, action: r.row.id, ...request, state, error: r.error }, `agent:${ctx.agentId}`);1230    this.emit(id, { event: 'action', data: this.actionView(row!) });1231    return { action: this.actionView(row!), venue: { leverage: now, confirmed, source: 'venue' as const } };1232  }12331234  /** what an automated strategy needs to decide: the venue's own account state, positions and open orders, and whether it may trade */1235  async mandateAccountState(id: string) {1236    const a = await this.account(id), info = await this.mandateInfo(a), v = await this.venueState(a), halted = this.effectiveHalt(a), lock = this.mandateLock(a) ?? this.blocked(a, 'trade');1237    const phase = info.mandate?.phase ?? null;1238    return { accountId: a.id, venue: a.venue, network: a.network, account: a.account, mode: a.mode, modes: info.modes, mandate: info.mandate,1239      /** may an order that ADDS exposure be placed right now? (reduce-only orders also pass while halted and during the wind-down) */1240      canOpen: lock === null && phase === 'active' && halted === null && v.error === null, canReduce: lock === null && (phase === 'active' || phase === 'wind_down'),1241      blocked: lock ? { code: lock.code, message: lock.message } : null, halted, divergence: a.divergence,1242      state: v.state, positions: v.positions, openOrders: v.openOrders, error: v.error, ts: v.ts, source: 'venue' as const };1243  }1244}1245
Files main
Menu

Theme