Repository Public
1// Derivative mandates (MANDATE_LIVE), signer side. Docs: docs/v6/DERIVATIVE-MANDATE.md.2//3// One passkey approval stores a mandate (packages/derivatives/src/live/mandate.ts) that EXTENDS a MANUAL_LIVE venue4// authorization. After that, POST /venue/mandate/sign signs orders, cancels and leverage changes with the trading key WITHOUT5// a passkey — and every limit is enforced here, from this signer's own stored copy of the mandate, its own reading of the6// payload (`describe`, pinned asset table), its own prices (prices.ts) and its own read of the venue account (venue-read.ts).7// Nothing the API says about the order, the positions or the prices is used for a decision.8//9// Three kinds of action, three levels of freedom:10// cancel always signed while the authorization lives and the mandate has not ENDED (also under the emergency halt)11// reduce-only order same, plus: allowed instrument, order notional, price band, client id never signed — no venue read,12// no counter: closing must stay possible when limits are hit, when halted, and during the wind-down13// order that can add exposure only while the mandate is ACTIVE and the signer is not halted, inside every counter, and only14// / leverage change after the signer has read the venue account itself (fail closed)15import { sign } from 'viem/accounts';16import {17 createHlTrading, D, HL_KIND_L1, hex, hlPinnedAsset, hlPinnedAssets, jurisdictionAckOf, signatureFromHex, typedDataDigest, validateVenueMandate, VENUE_MANDATE_BOUNDS, VENUE_PENDING_GRACE_MS,18 VENUE_READ_MAX_AGE_MS, venueMandateHash, venueMandatePhase, VenueMandateSchema, venueOrderHash, venueOrderNotional, VenueOrderSchema, venueWindowError, withinBand, worstCaseExposure,19 type ExposureOrder, type HlDescription, type HlL1SignPayload, type HlSignature, type SignRequest, type VenueAuthorization, type VenueMandate, type VenueMandatePhase, type VenueOrder,20} from '@spw/derivatives/live';21import type { AuthenticationResponseJSON } from '@simplewebauthn/server';22import type { StoredVenueAuthorization, StoredVenueMandate } from './store.ts';23import { VENUE_NONCE_SET, type VenueCtx } from './venue.ts';24import type { VenueAccountView } from './venue-read.ts';2526export interface VenueMandateSignRequest { mandateId: string; sign: SignRequest; /** what the API says the order is: optional, and refused when it differs from the payload */ context?: { order?: unknown } }27export interface VenueMandateSignResult { signature: HlSignature; signer: string; kind: 'order' | 'cancel' | 'leverage'; hash: string; mandateId: string; phase: VenueMandatePhase }28type Usage = StoredVenueMandate['usage'][string];2930const day = (nowSec: number) => new Date(nowSec * 1000).toISOString().slice(0, 10);31const hexKey = (priv: Uint8Array) => `0x${Buffer.from(priv).toString('hex')}` as `0x${string}`;32const canon = (x: string) => D.str(D.from(x));33const sec = (iso: string | undefined) => (iso ? Math.floor(Date.parse(iso) / 1000) : null);34/** the highest price an order names (limit / worst price, or its trigger price): what venueOrderNotional multiplies the size by */35const topPrice = (o: Pick<VenueOrder, 'price' | 'triggerPrice'>) => D.str(o.triggerPrice === null ? D.from(o.price) : D.max(D.from(o.price), D.from(o.triggerPrice)));36/** tolerated difference between the venue's clock and the signer's when matching a resting order to a signed one */37const LANDED_SKEW_MS = 60_000;38const emptyUsage = (): Usage => ({ orders: 0, reduceOnly: 0, cancels: 0, leverage: 0, turnover: '0' });39const phaseOf = (sm: StoredVenueMandate, now: number) => venueMandatePhase(sm.mandate, sm.revoked ? sec(sm.revokedAt) : null, now);4041export function venueMandateStatus(ctx: VenueCtx) {42 const now = ctx.now(), today = day(now);43 return Object.values(ctx.store.state.venueMandates ?? {}).map((sm) => {44 const ph = phaseOf(sm, now), rec = ctx.store.state.venues?.[sm.mandate.authorizationId];45 const dead = !rec || rec.revoked || now >= rec.authorization.expiresAt;46 return { mandate: sm.mandate, hash: sm.hash, approvedAt: sm.approvedAt, revoked: sm.revoked, revokedAt: sm.revokedAt ?? null, expired: now >= sm.mandate.expiresAt,47 /** what it signs right now: `ended` also when its authorization is revoked or expired */48 phase: (dead ? 'ended' : ph.phase) as VenueMandatePhase, windDownUntil: ph.windDownUntil,49 today: sm.usage[today] ?? emptyUsage(), ordersLastHour: sm.hour.filter((t) => t > now - 3600).length,50 pendingOrders: (rec?.pending ?? []).filter((p) => p.until > now * 1000).length };51 });52}5354/** The owner approves a mandate: passkey assertion over its canonical hash. Limits are set here and nowhere else. */55export async function venueMandateApprove(ctx: VenueCtx, raw: unknown, assertion: AuthenticationResponseJSON | undefined): Promise<{ mandateId: string; hash: string; authorizationId: string }> {56 const st = ctx.store, now = ctx.now();57 const parsed = VenueMandateSchema.safeParse(raw);58 if (!parsed.success) throw ctx.fail('bad_request', 'malformed venue mandate');59 const m: VenueMandate = parsed.data, hash = venueMandateHash(m), id = m.mandateId;60 const deny = (code: string, msg: string): never => { st.log('venue.mandate.denied', { mandateId: id, authorizationId: m.authorizationId, code, msg: msg.slice(0, 300) }); st.save(); throw ctx.fail(code, msg); };61 if (st.state.halted) deny('halted', 'emergency halt is active');62 if (m.keyId !== ctx.keys.keyId) deny('wrong_key', 'mandate targets another key');63 const rec = st.state.venues?.[m.authorizationId];64 if (!rec) deny('no_authorization', 'a mandate extends a venue authorization this signer has stored: none with this id');65 const a = rec!.authorization;66 if (rec!.revoked) deny('revoked', 'the venue authorization was revoked');67 if (now >= a.expiresAt) deny('expired', 'the venue authorization has expired');68 try { validateVenueMandate(m, a, now); } catch (e) { deny('unsupported', (e as Error).message); }69 for (const i of m.instruments) {70 const pinned = hlPinnedAsset(a.network, i.instrument);71 if (!pinned) deny('unsupported', `${i.instrument} is not in the signer's pinned ${a.network} asset table`);72 if (i.maxLeverage > pinned!.maxLeverage) deny('unsupported', `leverage ${i.maxLeverage} is above the venue maximum of ${i.instrument} (${pinned!.maxLeverage})`);73 }74 if (!jurisdictionAckOf(m.venue, m.ackSha256)) deny('acknowledgement', 'the acknowledgement hash is not the hash of the jurisdiction summary this signer knows');75 if (st.state.venueMandates?.[id]) deny('replay', 'mandate already recorded');76 for (const other of Object.values(st.state.venueMandates ?? {})) {77 if (other.mandate.authorizationId === m.authorizationId && phaseOf(other, now).phase === 'active') deny('conflict', 'another mandate is active on this venue authorization: revoke it first');78 }79 if (!assertion) deny('bad_approval', 'the owner\'s passkey approval is required');80 try { await ctx.verify(assertion!, hash); } catch (e) { deny((e as { code?: string }).code ?? 'bad_approval', (e as Error).message); }81 (st.state.venueMandates ??= {})[id] = { mandate: m, hash: hex(hash), approvedAt: new Date(now * 1000).toISOString(), revoked: false, usage: {}, hour: [] };82 st.log('venue.mandate.approved', { mandateId: id, hash: hex(hash), authorizationId: m.authorizationId, network: m.network, account: m.account, agentAddress: m.agentAddress, strategySha256: m.strategySha256, agentId: m.agentId,83 instruments: m.instruments, maxOrderNotional: m.maxOrderNotional, maxGrossExposure: m.maxGrossExposure, maxOrdersPerDay: m.maxOrdersPerDay, maxOrdersPerHour: m.maxOrdersPerHour, maxDailyTurnover: m.maxDailyTurnover,84 floorEquity: m.floorEquity, maxPriceDeviationBps: m.maxPriceDeviationBps, expiresAt: m.expiresAt, ackSha256: m.ackSha256 });85 st.save();86 return { mandateId: id, hash: hex(hash), authorizationId: m.authorizationId };87}8889/** Stopping never needs a passkey. A revoked mandate adds no exposure ever again; it still signs cancels and reduce-only orders during the wind-down. */90export function venueMandateRevoke(ctx: VenueCtx, mandateId: string): { revoked: true; revokedAt: string; windDownUntil: number } {91 const sm = ctx.store.state.venueMandates?.[mandateId];92 if (!sm) throw ctx.fail('not_found', 'unknown venue mandate');93 if (!sm.revoked) {94 sm.revoked = true; sm.revokedAt = new Date(ctx.now() * 1000).toISOString();95 ctx.store.log('venue.mandate.revoked', { mandateId, authorizationId: sm.mandate.authorizationId });96 ctx.store.save();97 }98 return { revoked: true, revokedAt: sm.revokedAt!, windDownUntil: venueMandatePhase(sm.mandate, sec(sm.revokedAt), sec(sm.revokedAt)!).windDownUntil! };99}100101/**102 * The signer's own view of the account, and the worst-case exposure if `candidate` and everything else that can still add103 * exposure were to fill. Any doubt is a refusal (`deny` throws).104 */105async function ownExposure(ctx: VenueCtx, deny: (code: string, msg: string) => never, a: VenueAuthorization, m: VenueMandate, rec: StoredVenueAuthorization, nowMs: number, candidate: VenueOrder | null) {106 let view: VenueAccountView;107 try { view = await ctx.venueInfo.account(a.network, a.account); }108 catch (e) { return deny('venue_unreachable', `the signer could not read the venue account itself (${(e as Error).message.slice(0, 100)}): nothing that can add exposure is signed`); }109 if (Math.abs(nowMs - view.time) > VENUE_READ_MAX_AGE_MS) deny('venue_stale', `the venue's account state is dated ${Math.round((nowMs - view.time) / 1000)} s from the signer's clock`);110 const coins = new Set<string>();111 for (const p of view.positions) { if (coins.has(p.coin)) deny('venue_inconsistent', `the venue lists two positions on ${p.coin}`); coins.add(p.coin); }112 const open = view.positions.filter((p) => !D.isZero(D.from(p.szi)));113 const sum = open.reduce((n, p) => D.add(n, D.from(p.positionValue)), D.zero), total = D.from(view.totalNtlPos);114 // |Σ position values − the venue's own total| ≤ 0.1 % + 0.01115 if (D.gt(D.mul(D.abs(D.sub(sum, total)), D.from('1000')), D.add(D.max(sum, total), D.from('10')))) deny('venue_inconsistent', `the venue's positions (${D.str(sum)}) do not add up to its own total (${D.str(total)})`);116 const inst = (coin: string) => `hyperliquid:${coin}`;117 for (const p of open) if (!hlPinnedAsset(a.network, inst(p.coin))) deny('unpriced_position', `the account holds a position on ${p.coin}, which this signer cannot price itself: nothing that can add exposure is signed`);118 // A signed order that the venue now lists as resting is counted through the venue's list (its remaining size; what filled is in119 // the position) and no longer through the ledger. "The same order" = the same client order id AND taken by the venue after120 // this signer signed it: a client id is signed once (replay rule), so an older order that carries the same id is not it.121 const landed = (p: { clientId: string; signedAt?: number }) => p.signedAt !== undefined && view.openOrders.some((o) => o.cloid === p.clientId && o.timestamp >= p.signedAt! - LANDED_SKEW_MS);122 const orders: ExposureOrder[] = [123 ...view.openOrders.filter((o) => !o.reduceOnly && !D.isZero(D.from(o.size))).map((o) => ({ instrument: inst(o.coin), side: o.side, size: o.size, price: o.price, reduceOnly: false })),124 // every order this signer signed for the account that can still reach the venue — under this authorization or one revoked a moment ago125 ...Object.values(ctx.store.state.venues ?? {}).filter((r) => r === rec || (r.authorization.network === a.network && r.authorization.account === a.account)).flatMap((r) => r.pending ?? []).filter((p) => p.until > nowMs && !landed(p)).map((p) => ({ instrument: p.instrument, side: p.side, size: p.size, price: p.price, reduceOnly: false })),126 ...(candidate ? [{ instrument: candidate.instrument, side: candidate.side, size: candidate.size, price: topPrice(candidate), reduceOnly: false }] : []),127 ];128 const symbols = [...new Set([...open.map((p) => inst(p.coin)), ...orders.map((o) => o.instrument)])].filter((i) => hlPinnedAsset(a.network, i)).map((i) => i.slice(i.indexOf(':') + 1));129 const prices: Record<string, string> = {};130 try {131 const got = symbols.length ? await ctx.prices.get(symbols) : {};132 for (const s of symbols) { const px = got[s]; if (!(typeof px === 'number' && px > 0 && Number.isFinite(px))) throw new Error(`no price for ${s}`); prices[inst(s)] = canon(px.toFixed(8)); }133 } catch (e) { return deny('no_price', `the signer could not establish its own market prices (${(e as Error).message.slice(0, 80)})`); }134 // the venue's own valuation of each position must agree with the signer's price: a venue (or a read) that is far off is not believed135 for (const p of open) {136 const mine = D.mul(D.abs(D.from(p.szi)), D.from(prices[inst(p.coin)]!));137 if (D.gt(D.mul(D.abs(D.sub(D.from(p.positionValue), mine)), D.from(10000n)), D.mul(D.from(BigInt(m.maxPriceDeviationBps)), mine)))138 deny('venue_inconsistent', `the venue values the ${p.coin} position at ${p.positionValue}; at the signer's own price it is ${D.str(mine)} (more than ${m.maxPriceDeviationBps} bps apart)`);139 }140 let exposure: ReturnType<typeof worstCaseExposure>;141 try { exposure = worstCaseExposure(open.map((p) => ({ instrument: inst(p.coin), size: p.szi })), orders, prices); }142 catch (e) { return deny('venue_inconsistent', (e as Error).message); }143 return { view, open, exposure, prices };144}145146/**147 * Sign ONE venue action under a mandate, without a passkey. Every check uses the signer's stored mandate and authorization;148 * counters, the client id, the nonce and the exposure ledger are durable before the signature is returned.149 */150export async function venueMandateSign(ctx: VenueCtx, req: VenueMandateSignRequest): Promise<VenueMandateSignResult> {151 const st = ctx.store, now = ctx.now(), nowMs = now * 1000;152 const id = String(req?.mandateId ?? '');153 const sm = st.state.venueMandates?.[id];154 if (!sm) throw ctx.fail('not_found', 'unknown venue mandate');155 const m = sm.mandate;156 const deny = (code: string, msg: string): never => { st.log('venue.mandate.sign.denied', { mandateId: id, code, msg: msg.slice(0, 300) }); st.save(); throw ctx.fail(code, msg); };157 const rec = st.state.venues?.[m.authorizationId];158 if (!rec) return deny('no_authorization', 'the venue authorization of this mandate is unknown');159 const a = rec.authorization;160 // the authorization is the root: revoked or expired, nothing at all is signed (no wind-down)161 if (rec.revoked) deny('revoked', 'the venue authorization of this mandate was revoked: nothing is signed');162 if (now >= a.expiresAt) deny('expired', 'the venue authorization of this mandate has expired');163 const ph = phaseOf(sm, now);164 if (ph.phase === 'ended') deny(sm.revoked && sec(sm.revokedAt)! <= m.expiresAt ? 'revoked' : 'expired', `the mandate ${sm.revoked ? 'was revoked' : 'expired'} and its wind-down period is over`);165 const s = req.sign;166 if (!s || typeof s !== 'object') deny('bad_request', 'missing sign request');167 if (s.venue !== a.venue || s.network !== a.network) deny('wrong_venue', 'the sign request is for another venue or network than the mandate');168169 // 1. what does the payload do? (the signer's own reading, pinned table) — same rules as /venue/sign170 const trading = createHlTrading({ network: a.network, assets: hlPinnedAssets(a.network) });171 let d: HlDescription;172 try { d = trading.describe(s); } catch (e) { return deny('rejected', `payload refused: ${(e as Error).message}`); }173 if (d.kind === 'approve_agent' || d.kind === 'other' || s.kind !== HL_KIND_L1) deny('unsupported', `this payload is not an order, a cancel or a leverage change (${String(d.detail.type ?? d.detail.primaryType ?? s.kind).slice(0, 60)}): a mandate signs nothing else`);174 const p = d.detail as { nonce: number; vaultAddress: string | null; expiresAfter: number | null; connectionId: string };175 if (p.vaultAddress !== null) deny('unsupported', 'sub-accounts and vaults are not traded');176 if (p.expiresAfter === null) deny('policy', 'the action carries no venue expiry: a signature that never expires is not released');177 const win = venueWindowError({ nonce: p.nonce, expiresAt: p.expiresAfter! }, nowMs);178 if (win) deny(/expired/.test(win) ? 'expired_action' : /stale/.test(win) ? 'stale_nonce' : 'policy', win);179 if (rec.nonces.includes(p.nonce)) deny('stale_nonce', 'nonce already used with this trading key');180 if (rec.nonces.length >= VENUE_NONCE_SET && p.nonce <= rec.nonces[0]!) deny('stale_nonce', 'nonce is below the lowest of the last nonces signed with this trading key');181182 const today = day(now), usage = { ...(sm.usage[today] ?? emptyUsage()) }, authUsage = { ...(rec.usage[today] ?? { orders: 0, cancels: 0, leverage: 0 }) };183 let hash: string, summary: Record<string, unknown>, pending: NonNullable<StoredVenueAuthorization['pending']>[number] | null = null, hourMark = false, clientId: string | null = null;184185 if (d.kind === 'cancel') {186 const cancels = d.detail.cancels as { instrument: string }[];187 for (const c of cancels) if (!a.policy.allowedInstruments.includes(c.instrument)) deny('instrument', `${c.instrument} is not allowed by the authorization`);188 usage.cancels += cancels.length; authUsage.cancels += cancels.length; hash = p.connectionId.slice(2);189 summary = { cancels: cancels.length, instruments: [...new Set(cancels.map((c) => c.instrument))] };190 } else if (d.kind === 'order') {191 if (!d.orders || d.orders.length !== 1) deny('unsupported', 'exactly one order per signature');192 const o = d.orders![0]!;193 // the order is taken from the PAYLOAD; what the API says about it only has to agree194 const cp = VenueOrderSchema.safeParse({ v: 1, authorizationId: a.authorizationId, venue: a.venue, network: a.network, account: a.account, kind: 'order', instrument: o.instrument, side: o.side, type: o.type, size: o.size, price: o.price,195 triggerPrice: o.triggerPrice, reduceOnly: o.reduceOnly, tif: o.tif, clientId: o.clientId, nonce: p.nonce, expiresAt: p.expiresAfter });196 if (!cp.success) deny('rejected', `the payload is not an order this signer signs (${cp.error.issues[0]?.path.join('.') ?? 'shape'})`);197 const c = cp.data!;198 if (req.context?.order !== undefined) {199 const said = VenueOrderSchema.safeParse(req.context.order);200 if (!said.success) deny('bad_request', 'malformed order description');201 const x = said.data!, same: [string, boolean][] = [202 ['authorization', x.authorizationId === c.authorizationId && x.venue === c.venue && x.network === c.network && x.account === c.account], ['nonce', x.nonce === c.nonce], ['expiry', x.expiresAt === c.expiresAt],203 ['instrument', x.instrument === c.instrument], ['side', x.side === c.side], ['type', x.type === c.type], ['size', canon(x.size) === canon(c.size)], ['price', canon(x.price) === canon(c.price)],204 ['trigger price', (x.triggerPrice === null ? null : canon(x.triggerPrice)) === (c.triggerPrice === null ? null : canon(c.triggerPrice))], ['reduce-only', x.reduceOnly === c.reduceOnly], ['time in force', x.tif === c.tif], ['client id', x.clientId === c.clientId],205 ];206 const diff = same.find(([, ok]) => !ok);207 if (diff) deny('mismatch', `the order described by the API and the payload to sign differ (${diff[0]})`);208 }209 const mi = m.instruments.find((i) => i.instrument === c.instrument);210 if (!mi) deny('instrument', `${c.instrument} is not allowed by the mandate`);211 const notional = venueOrderNotional(c);212 if (D.gt(D.from(notional), D.from(m.maxOrderNotional))) deny('order_notional', `order notional ${notional} is above the mandate's ${canon(m.maxOrderNotional)} per order`);213 if (rec.clientIds[c.clientId] !== undefined) deny('replay', 'an order with this client id was already signed');214 const symbol = c.instrument.slice(c.instrument.indexOf(':') + 1);215 let mid: string;216 try { const px = (await ctx.prices.get([symbol]))[symbol]; if (!(typeof px === 'number' && px > 0 && Number.isFinite(px))) throw new Error('no price'); mid = canon(px.toFixed(8)); }217 catch (e) { return deny('no_price', `the signer could not establish its own market price for ${symbol} (${(e as Error).message.slice(0, 80)}); order refused`); }218 for (const [what, px] of [['price', c.price], ['trigger price', c.triggerPrice]] as const) {219 if (px !== null && !withinBand(px, mid, m.maxPriceDeviationBps)) deny('price', `${what} ${canon(px)} is more than ${m.maxPriceDeviationBps} bps away from the signer's own ${symbol} price ${mid}`);220 }221 let exposure: Record<string, unknown> = {};222 if (c.reduceOnly) usage.reduceOnly += 1; // the venue never lets a reduce-only order add to a position or open the opposite one223 else {224 if (ph.phase !== 'active') deny('wind_down', `the mandate ${sm.revoked ? 'was revoked' : 'has expired'}: only cancels and reduce-only orders are signed until the wind-down ends`);225 if (st.state.halted) deny('halted', 'emergency halt is active: only cancels and reduce-only orders are signed');226 if (usage.orders >= m.maxOrdersPerDay) deny('orders_day', `the mandate's ${m.maxOrdersPerDay} orders per day are used`);227 if (authUsage.orders >= a.policy.maxOrdersPerDay) deny('orders_day', `the authorization's ${a.policy.maxOrdersPerDay} orders per day are used`);228 if (sm.hour.filter((t) => t > now - 3600).length >= m.maxOrdersPerHour) deny('orders_hour', `the mandate's ${m.maxOrdersPerHour} orders per hour are used`);229 const turnover = D.add(D.from(usage.turnover), D.from(notional));230 if (D.gt(turnover, D.from(m.maxDailyTurnover))) deny('turnover', `daily turnover would reach ${D.str(turnover)}, above the mandate's ${canon(m.maxDailyTurnover)}`);231 const own = await ownExposure(ctx, deny, a, m, rec, nowMs, c);232 if (D.lt(D.from(own.view.accountValue), D.from(m.floorEquity))) deny('floor_equity', `the venue reports an account value of ${own.view.accountValue}, below the mandate's floor of ${canon(m.floorEquity)}: only cancels and reduce-only orders are signed`);233 const held = own.open.find((x) => x.coin === symbol);234 let setting: { type: string; value: number };235 try { setting = held ? held.leverage : await ctx.venueInfo.leverage(a.network, a.account, symbol); }236 catch (e) { return deny('venue_unreachable', `the signer could not read the leverage setting of ${symbol} itself (${(e as Error).message.slice(0, 100)})`); }237 if (setting.value > mi!.maxLeverage) deny('leverage_setting', `${symbol} is set to ${setting.value}× on the venue, above the mandate's ${mi!.maxLeverage}×: lower it first`);238 const worst = own.exposure.perInstrument[c.instrument] ?? '0';239 if (D.gt(D.from(worst), D.from(mi!.maxPositionNotional))) deny('position_exceeded', `worst-case ${symbol} position ${worst} (venue position + resting and recently signed orders + this order, at the signer's price) is above the mandate's ${canon(mi!.maxPositionNotional)}`);240 if (D.gt(D.from(own.exposure.gross), D.from(m.maxGrossExposure))) deny('gross_exceeded', `worst-case gross exposure ${own.exposure.gross} is above the mandate's ${canon(m.maxGrossExposure)}`);241 usage.orders += 1; authUsage.orders += 1; usage.turnover = D.str(turnover); hourMark = true;242 pending = { clientId: c.clientId, instrument: c.instrument, side: c.side, size: canon(c.size), price: topPrice(c), until: c.expiresAt + VENUE_PENDING_GRACE_MS, signedAt: nowMs };243 exposure = { accountValue: own.view.accountValue, worstPosition: worst, worstGross: own.exposure.gross, leverageSetting: setting.value };244 }245 clientId = c.clientId; hash = hex(venueOrderHash(c));246 summary = { instrument: c.instrument, side: c.side, type: c.type, size: canon(c.size), price: canon(c.price), triggerPrice: c.triggerPrice, reduceOnly: c.reduceOnly, tif: c.tif, clientId: c.clientId, notional, mid, ...exposure };247 } else {248 if (ph.phase !== 'active') deny('wind_down', 'the mandate has ended: only cancels and reduce-only orders are signed until the wind-down ends');249 if (st.state.halted) deny('halted', 'emergency halt is active: only cancels and reduce-only orders are signed');250 const x = d.detail as { instrument: string; leverage: string; marginMode: string };251 const mi = m.instruments.find((i) => i.instrument === x.instrument);252 if (!mi) deny('instrument', `${x.instrument} is not allowed by the mandate`);253 const value = Number(x.leverage);254 if (!Number.isInteger(value) || value < 1 || value > mi!.maxLeverage) deny('leverage_exceeded', `leverage ${x.leverage} is above the mandate's ${mi!.maxLeverage}× for ${x.instrument}`);255 if (usage.leverage >= VENUE_MANDATE_BOUNDS.maxLeverageChangesPerDay) deny('leverage_rate', `at most ${VENUE_MANDATE_BOUNDS.maxLeverageChangesPerDay} leverage changes per day under a mandate`);256 // a margin-mode change moves the margin of an open position: never without the owner257 const own = await ownExposure(ctx, deny, a, m, rec, nowMs, null), symbol = x.instrument.slice(x.instrument.indexOf(':') + 1), held = own.open.find((q) => q.coin === symbol);258 if (held && held.leverage.type !== x.marginMode) deny('margin_mode', `${symbol} has an open position in ${held.leverage.type} margin: a mandate does not change the margin mode of an open position`);259 usage.leverage += 1; authUsage.leverage += 1; hash = p.connectionId.slice(2);260 summary = { instrument: x.instrument, leverage: value, marginMode: x.marginMode };261 }262263 // counters, replay marker, exposure ledger and nonce are durable BEFORE the signature leaves264 if (clientId !== null) rec.clientIds[clientId] = Math.floor(now / 86400);265 if (pending) (rec.pending ??= []).push(pending);266 if (hourMark) sm.hour = [...sm.hour.filter((t) => t > now - 3600), now];267 sm.usage[today] = usage; rec.usage[today] = authUsage;268 rec.nonces = [...rec.nonces, p.nonce].sort((x, y) => x - y).slice(-VENUE_NONCE_SET);269 st.prune(now);270 const agent = ctx.keys.venueKey(a.venue, a.network);271 const signature = signatureFromHex(await sign({ hash: typedDataDigest((s.payload as HlL1SignPayload).typedData), privateKey: hexKey(agent.priv), to: 'hex' }));272 st.log('venue.mandate.sign.ok', { mandateId: id, authorizationId: a.authorizationId, phase: ph.phase, kind: d.kind, nonce: p.nonce, expiresAfter: p.expiresAfter, connectionId: p.connectionId, hash, ...summary });273 st.save();274 return { signature, signer: agent.address, kind: d.kind as VenueMandateSignResult['kind'], hash, mandateId: id, phase: ph.phase };275}276