From 1425063c405319be1251e9c431572bb32a4e0cc4 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 24 Aug 2026 08:15:49 -0700 Subject: [PATCH 1/7] fix(api): widen schemas to current API surface (#99-#103) - order: raise priority grouping cap to 1e8 (#99) - widen OrderProcessingStatus, twapHistory status, FrontendOpenOrder.orderType; type TwapState.trigger (#100) - borrowLendUserState: widen health states and healthFactor (#101) - userFees stakingLink discriminated union; UserFill feeTrialEscrow, optional liquidatedUser (#102) - spotClearinghouseState/outcomeMeta/settledOutcome/subAccounts2/legalCheck response drift (#103) --- src/api/exchange/_methods/order.ts | 9 +-- src/api/info/_methods/_base/_schemas.ts | 41 +++++++++++-- src/api/info/_methods/borrowLendUserState.ts | 17 +++--- src/api/info/_methods/historicalOrders.ts | 3 + src/api/info/_methods/legalCheck.ts | 10 ++-- src/api/info/_methods/orderStatus.ts | 3 + src/api/info/_methods/outcomeMeta.ts | 5 ++ src/api/info/_methods/settledOutcome.ts | 22 +++++++ .../info/_methods/spotClearinghouseState.ts | 18 +++++- src/api/info/_methods/subAccounts2.ts | 3 + src/api/info/_methods/twapHistory.ts | 4 +- src/api/info/_methods/userFees.ts | 42 +++++++------ src/api/info/_methods/userFills.ts | 2 +- src/api/subscription/_methods/orderUpdates.ts | 3 + tests/api/exchange/order.test.ts | 24 +++++++- tests/api/info/borrowLendUserState.test.ts | 30 +++++++++- tests/api/info/frontendOpenOrders.test.ts | 3 + tests/api/info/historicalOrders.test.ts | 6 ++ tests/api/info/legalCheck.test.ts | 21 ++++++- tests/api/info/orderStatus.test.ts | 6 ++ tests/api/info/outcomeMeta.test.ts | 2 + tests/api/info/settledOutcome.test.ts | 44 ++++++++++++++ tests/api/info/spotClearinghouseState.test.ts | 1 + tests/api/info/subAccounts.test.ts | 1 + tests/api/info/subAccounts2.test.ts | 3 + tests/api/info/twapHistory.test.ts | 50 ++++++++++++++-- tests/api/info/userFees.test.ts | 51 ++++++++++++++++ tests/api/info/userFills.test.ts | 60 ++++++++++++++++++- tests/api/info/userFillsByTime.test.ts | 6 +- tests/api/info/userTwapSliceFills.test.ts | 1 + .../api/info/userTwapSliceFillsByTime.test.ts | 1 + tests/api/info/webData2.test.ts | 9 ++- tests/api/subscription/openOrders.test.ts | 3 + tests/api/subscription/orderUpdates.test.ts | 3 + tests/api/subscription/spotState.test.ts | 1 + tests/api/subscription/twapStates.test.ts | 5 +- tests/api/subscription/userEvents.test.ts | 4 ++ tests/api/subscription/userFills.test.ts | 2 + .../subscription/userHistoricalOrders.test.ts | 6 ++ .../subscription/userTwapSliceFills.test.ts | 1 + 40 files changed, 470 insertions(+), 56 deletions(-) diff --git a/src/api/exchange/_methods/order.ts b/src/api/exchange/_methods/order.ts index 11ce641f..8f0ef9c1 100644 --- a/src/api/exchange/_methods/order.ts +++ b/src/api/exchange/_methods/order.ts @@ -75,15 +75,16 @@ export const OrderRequest = /* @__PURE__ */ (() => { * - `"na"`: Standard order without grouping. * - `"normalTpsl"`: TP/SL order with fixed size that doesn't adjust with position changes. * - `"positionTpsl"`: TP/SL order that adjusts proportionally with the position size. - * - `{ p: number }`: Order priority rate as a fraction `p / 1e8` (max `p = 80000`, i.e. 8 bps). - * Only valid when every order is IOC on a perp asset. + * - `{ p: number }`: Order priority rate as a fraction `p / 1e8` (max `p = 1e8`, i.e. 100%). + * Only valid when every order is on a non-outcome asset and either every order is IOC + * or every order is a non-reduce-only ALO. */ grouping: v.optional( v.union([ v.picklist(["na", "normalTpsl", "positionTpsl"]), v.object({ - /** Priority rate as a fraction `p / 1e8` (max `80000`, i.e. 8 bps). */ - p: v.pipe(UnsignedInteger, v.maxValue(80000)), + /** Priority rate as a fraction `p / 1e8` (max `100_000_000`, i.e. 100%). */ + p: v.pipe(UnsignedInteger, v.maxValue(100_000_000)), }), ]), "na", diff --git a/src/api/info/_methods/_base/_schemas.ts b/src/api/info/_methods/_base/_schemas.ts index 53c48dcc..f502ccf5 100644 --- a/src/api/info/_methods/_base/_schemas.ts +++ b/src/api/info/_methods/_base/_schemas.ts @@ -156,9 +156,21 @@ export type FrontendOpenOrder = { * - `"Stop Limit"`: Activates as a limit order when a stop price is reached. * - `"Take Profit Market"`: Executes as a market order when a take profit price is reached. * - `"Take Profit Limit"`: Executes as a limit order when a take profit price is reached. + * - `"Twap Slice"`: Executes a single slice of a TWAP order. + * - `"Vault Close"`: Closes a vault position. + * - `"Spot Dust Conversion"`: Converts residual spot balances. * @see https://hyperliquid.gitbook.io/hyperliquid-docs/trading/order-types */ - orderType: "Market" | "Limit" | "Stop Market" | "Stop Limit" | "Take Profit Market" | "Take Profit Limit"; + orderType: + | "Market" + | "Limit" + | "Stop Market" + | "Stop Limit" + | "Take Profit Market" + | "Take Profit Limit" + | "Twap Slice" + | "Vault Close" + | "Spot Dust Conversion"; /** * Time-in-force: * - `"Gtc"`: Remains active until filled or canceled. @@ -239,10 +251,18 @@ export type TwapState = { /** Start time of the TWAP order (in ms since epoch). */ timestamp: number; /** - * Trigger configuration, present on the wire (observed as `null`; not settable via the current - * TWAP order action, so the non-null shape is not yet established). + * Trigger configuration that activates the order; `null` when unset. Settable via the + * `details` parameter of the TWAP order action. */ - trigger?: unknown; + trigger?: { + /** + * Trigger price. + * @pattern ^[0-9]+(\.[0-9]+)?$ + */ + px: string; + /** Activates when the mark price is above (`true`) or below (`false`) the trigger price. */ + above: boolean; + } | null; /** * Stop price, present on the wire (observed as `null`; not settable via the current TWAP order * action). @@ -328,6 +348,11 @@ export type UserFill = { tid: number; /** Token in which the fee is denominated (e.g., USDC). */ feeToken: string; + /** + * Fee trial escrow amount. + * @pattern ^[0-9]+(\.[0-9]+)?$ + */ + feeTrialEscrow?: string; /** ID of the TWAP. */ twapId: number | null; }; @@ -347,7 +372,9 @@ export type UserFill = { * - `"siblingFilledCanceled"`: Canceled due to sibling ordering being filled. * - `"delistedCanceled"`: Canceled due to asset delisting. * - `"liquidatedCanceled"`: Canceled due to liquidation. + * - `"outcomeSettledCanceled"`: Canceled due to outcome market settlement. * - `"scheduledCancel"`: Canceled due to exceeding scheduled cancel deadline (dead man's switch). + * - `"internalCancel"`: Canceled due to an internal error. * - `"tickRejected"`: Rejected due to invalid tick price. * - `"minTradeNtlRejected"`: Rejected due to order notional below minimum. * - `"perpMarginRejected"`: Rejected due to insufficient margin. @@ -363,6 +390,7 @@ export type UserFill = { * - `"insufficientSpotBalanceRejected"`: Rejected due to insufficient spot balance. * - `"oracleRejected"`: Rejected due to price too far from oracle. * - `"perpMaxPositionRejected"`: Rejected due to exceeding margin tier limit at current leverage. + * - `"tooManyOpenOrdersRejected"`: Rejected due to exceeding the open order limit. */ export type OrderProcessingStatus = | "open" @@ -378,7 +406,9 @@ export type OrderProcessingStatus = | "siblingFilledCanceled" | "delistedCanceled" | "liquidatedCanceled" + | "outcomeSettledCanceled" | "scheduledCancel" + | "internalCancel" | "tickRejected" | "minTradeNtlRejected" | "perpMarginRejected" @@ -393,4 +423,5 @@ export type OrderProcessingStatus = | "openInterestIncreaseRejected" | "insufficientSpotBalanceRejected" | "oracleRejected" - | "perpMaxPositionRejected"; + | "perpMaxPositionRejected" + | "tooManyOpenOrdersRejected"; diff --git a/src/api/info/_methods/borrowLendUserState.ts b/src/api/info/_methods/borrowLendUserState.ts index fd3de8df..21a0d1e1 100644 --- a/src/api/info/_methods/borrowLendUserState.ts +++ b/src/api/info/_methods/borrowLendUserState.ts @@ -60,17 +60,18 @@ export type BorrowLendUserStateResponse = { }, ][]; /** - * Account health status. - * - * FIXME: other literals may exist (unconfirmed). + * Account health status: + * - `"healthy"`: Collateral covers the debt. + * - `"atRisk"`: Health factor approached the liquidation threshold. + * - `"marketLiquidatable"`: Debt is being liquidated through the order book. + * - `"backstopLiquidatable"`: Debt is taken over by the liquidator vault. */ - health: "healthy"; + health: "healthy" | "atRisk" | "marketLiquidatable" | "backstopLiquidatable"; /** - * Health factor. - * - * FIXME: non-null value not found (unconfirmed). + * Health factor; `null` when the account has no borrow position. + * @pattern ^[0-9]+(\.[0-9]+)?$ */ - healthFactor: null; + healthFactor: string | null; }; // ============================================================ diff --git a/src/api/info/_methods/historicalOrders.ts b/src/api/info/_methods/historicalOrders.ts index 57ce1331..f437b187 100644 --- a/src/api/info/_methods/historicalOrders.ts +++ b/src/api/info/_methods/historicalOrders.ts @@ -43,7 +43,9 @@ export type HistoricalOrdersResponse = { * - `"siblingFilledCanceled"`: Canceled due to sibling ordering being filled. * - `"delistedCanceled"`: Canceled due to asset delisting. * - `"liquidatedCanceled"`: Canceled due to liquidation. + * - `"outcomeSettledCanceled"`: Canceled due to outcome market settlement. * - `"scheduledCancel"`: Canceled due to exceeding scheduled cancel deadline (dead man's switch). + * - `"internalCancel"`: Canceled due to an internal error. * - `"tickRejected"`: Rejected due to invalid tick price. * - `"minTradeNtlRejected"`: Rejected due to order notional below minimum. * - `"perpMarginRejected"`: Rejected due to insufficient margin. @@ -59,6 +61,7 @@ export type HistoricalOrdersResponse = { * - `"insufficientSpotBalanceRejected"`: Rejected due to insufficient spot balance. * - `"oracleRejected"`: Rejected due to price too far from oracle. * - `"perpMaxPositionRejected"`: Rejected due to exceeding margin tier limit at current leverage. + * - `"tooManyOpenOrdersRejected"`: Rejected due to exceeding the open order limit. */ status: OrderProcessingStatus; /** Timestamp when the status was last updated (in ms since epoch). */ diff --git a/src/api/info/_methods/legalCheck.ts b/src/api/info/_methods/legalCheck.ts index ac91a269..2144d7c8 100644 --- a/src/api/info/_methods/legalCheck.ts +++ b/src/api/info/_methods/legalCheck.ts @@ -30,11 +30,13 @@ export type LegalCheckResponse = { /** Whether the user is allowed to use the platform. */ userAllowed: boolean; /** - * Restriction code. - * - * FIXME: meaning of `"n"` / `"a"` unconfirmed. + * Restriction code: + * - `"n"`: No restrictions. + * - `"a"`: Platform actions are blocked. + * - `"o"`: Outcome markets are hidden. + * - `"u"`: Restricted as a UK user. */ - restrictions?: "n" | "a"; + restrictions: "n" | "a" | "o" | "u"; }; // ============================================================ diff --git a/src/api/info/_methods/orderStatus.ts b/src/api/info/_methods/orderStatus.ts index 7c070f4b..7e817887 100644 --- a/src/api/info/_methods/orderStatus.ts +++ b/src/api/info/_methods/orderStatus.ts @@ -52,7 +52,9 @@ export type OrderStatusResponse = * - `"siblingFilledCanceled"`: Canceled due to sibling ordering being filled. * - `"delistedCanceled"`: Canceled due to asset delisting. * - `"liquidatedCanceled"`: Canceled due to liquidation. + * - `"outcomeSettledCanceled"`: Canceled due to outcome market settlement. * - `"scheduledCancel"`: Canceled due to exceeding scheduled cancel deadline (dead man's switch). + * - `"internalCancel"`: Canceled due to an internal error. * - `"tickRejected"`: Rejected due to invalid tick price. * - `"minTradeNtlRejected"`: Rejected due to order notional below minimum. * - `"perpMarginRejected"`: Rejected due to insufficient margin. @@ -68,6 +70,7 @@ export type OrderStatusResponse = * - `"insufficientSpotBalanceRejected"`: Rejected due to insufficient spot balance. * - `"oracleRejected"`: Rejected due to price too far from oracle. * - `"perpMaxPositionRejected"`: Rejected due to exceeding margin tier limit at current leverage. + * - `"tooManyOpenOrdersRejected"`: Rejected due to exceeding the open order limit. */ status: OrderProcessingStatus; /** Timestamp when the status was last updated (in ms since epoch). */ diff --git a/src/api/info/_methods/outcomeMeta.ts b/src/api/info/_methods/outcomeMeta.ts index 5096a747..fe847ecb 100644 --- a/src/api/info/_methods/outcomeMeta.ts +++ b/src/api/info/_methods/outcomeMeta.ts @@ -38,6 +38,11 @@ export type OutcomeMetaResponse = { }[]; /** Quote token for this outcome. */ quoteToken: string; + /** + * Address of the deployer; absent for outcomes not deployed from a template. + * @pattern ^0x[a-fA-F0-9]{40}$ + */ + deployer?: `0x${string}`; }[]; /** Array of prediction market questions. */ questions: { diff --git a/src/api/info/_methods/settledOutcome.ts b/src/api/info/_methods/settledOutcome.ts index cbc29334..b0e6c2f5 100644 --- a/src/api/info/_methods/settledOutcome.ts +++ b/src/api/info/_methods/settledOutcome.ts @@ -42,6 +42,11 @@ export type SettledOutcomeResponse = { }[]; /** Quote token for this outcome. */ quoteToken: string; + /** + * Address of the deployer; absent for outcomes not deployed from a template. + * @pattern ^0x[a-fA-F0-9]{40}$ + */ + deployer?: `0x${string}`; }; /** * Settlement fraction. @@ -50,6 +55,23 @@ export type SettledOutcomeResponse = { settleFraction: string; /** Settlement details. */ details: string; + /** Question that the outcome is a named outcome of. */ + question?: { + /** Question identifier, keyed by whether the question is still active or already settled. */ + question: + | { + /** Identifier of an active question. */ + active: number; + } + | { + /** Identifier of a settled question. */ + settled: number; + }; + /** Name of the question. */ + name: string; + /** Description of the question. */ + description: string; + }; } | null; // ============================================================ diff --git a/src/api/info/_methods/spotClearinghouseState.ts b/src/api/info/_methods/spotClearinghouseState.ts index ce32eb8f..3dbf97b4 100644 --- a/src/api/info/_methods/spotClearinghouseState.ts +++ b/src/api/info/_methods/spotClearinghouseState.ts @@ -71,8 +71,12 @@ export type SpotClearinghouseStateResponse = { supplied?: string; } | { - /** Outcome market identifier ("+" followed by `assetId - 100000000`). */ - coin: `+${number}`; + /** + * Outcome market identifier: + * - `+N`: `N` is `assetId - 100000000` of an active outcome market. + * - `oN`: `N` is the identifier of a settled outcome. + */ + coin: `+${number}` | `o${number}`; /** * Total balance. * @pattern ^[0-9]+(\.[0-9]+)?$ @@ -117,6 +121,16 @@ export type SpotClearinghouseStateResponse = { */ ratio: string, ][]; + /** Portfolio supply ratio per token. */ + tokenToPortfolioSupplyRatio?: [ + /** Token identifier. */ + token: number, + /** + * Supply ratio. + * @pattern ^[0-9]+(\.[0-9]+)?$ + */ + ratio: string, + ][]; /** Amount available after maintenance per token. */ tokenToAvailableAfterMaintenance?: [ /** Token identifier. */ diff --git a/src/api/info/_methods/subAccounts2.ts b/src/api/info/_methods/subAccounts2.ts index 2a695840..878f0504 100644 --- a/src/api/info/_methods/subAccounts2.ts +++ b/src/api/info/_methods/subAccounts2.ts @@ -7,6 +7,7 @@ import * as v from "valibot"; import { Address } from "../../_schemas.ts"; import type { ClearinghouseStateResponse } from "./clearinghouseState.ts"; import type { SpotClearinghouseStateResponse } from "./spotClearinghouseState.ts"; +import type { UserAbstractionResponse } from "./userAbstraction.ts"; /** * Request user sub-accounts (V2). @@ -49,6 +50,8 @@ export type SubAccounts2Response = ][]; /** Spot tokens clearinghouse state. */ spotState: SpotClearinghouseStateResponse; + /** Abstraction state of the sub-account; absent when the sub-account uses the default state. */ + abstraction?: Exclude; }[] | null; diff --git a/src/api/info/_methods/twapHistory.ts b/src/api/info/_methods/twapHistory.ts index 08536eb9..30df64bf 100644 --- a/src/api/info/_methods/twapHistory.ts +++ b/src/api/info/_methods/twapHistory.ts @@ -35,12 +35,14 @@ export type TwapHistoryResponse = { * - `"finished"`: Fully executed. * - `"activated"`: Active and executing. * - `"terminated"`: Terminated. + * - `"waitingForTrigger"`: Awaiting the trigger price. + * - `"stopped"`: Terminated by the stop price. * - `"error"`: An error occurred. */ status: | { /** Status of the TWAP order. */ - status: "finished" | "activated" | "terminated"; + status: "finished" | "activated" | "terminated" | "waitingForTrigger" | "stopped"; } | { /** Status of the TWAP order. */ diff --git a/src/api/info/_methods/userFees.ts b/src/api/info/_methods/userFees.ts index 8cccbc56..a6b0d963 100644 --- a/src/api/info/_methods/userFees.ts +++ b/src/api/info/_methods/userFees.ts @@ -166,28 +166,36 @@ export type UserFeesResponse = { */ feeTrialEscrow: string; /** Timestamp when next trial becomes available. */ - nextTrialAvailableTimestamp: unknown | null; + nextTrialAvailableTimestamp: number | null; /** * Permanent link between staking and trading accounts. * Staking user gains full control of trading account funds. * Staking user forfeits own fee discounts. */ - stakingLink: { - /** - * Linked account address: - * - When queried by staking account: contains trading account address. - * - When queried by trading account: contains staking account address. - * @pattern ^0x[a-fA-F0-9]{40}$ - */ - stakingUser: `0x${string}`; - /** - * Link status: - * - `requested` = link initiated by trading user, awaiting staking user confirmation. - * - `stakingUser` = response queried by staking account. - * - `tradingUser` = response queried by trading account. - */ - type: "requested" | "stakingUser" | "tradingUser"; - } | null; + stakingLink: + | { + /** + * Link status: + * - `"requested"`: Link initiated by trading user, awaiting staking user confirmation. + * - `"tradingUser"`: Response queried by trading account. + */ + type: "requested" | "tradingUser"; + /** + * Staking account address. + * @pattern ^0x[a-fA-F0-9]{40}$ + */ + stakingUser: `0x${string}`; + } + | { + /** Link status: response queried by staking account. */ + type: "stakingUser"; + /** + * Trading account address. + * @pattern ^0x[a-fA-F0-9]{40}$ + */ + tradingUser: `0x${string}`; + } + | null; /** Active staking discount details. */ activeStakingDiscount: { /** diff --git a/src/api/info/_methods/userFills.ts b/src/api/info/_methods/userFills.ts index 0f749ab2..cb825ca3 100644 --- a/src/api/info/_methods/userFills.ts +++ b/src/api/info/_methods/userFills.ts @@ -39,7 +39,7 @@ export type UserFillsResponse = (UserFill & { * Address of the liquidated user. * @pattern ^0x[a-fA-F0-9]{40}$ */ - liquidatedUser: `0x${string}`; + liquidatedUser?: `0x${string}`; /** * Mark price at the time of liquidation. * @pattern ^[0-9]+(\.[0-9]+)?$ diff --git a/src/api/subscription/_methods/orderUpdates.ts b/src/api/subscription/_methods/orderUpdates.ts index 6a9f82fd..5e547ef7 100644 --- a/src/api/subscription/_methods/orderUpdates.ts +++ b/src/api/subscription/_methods/orderUpdates.ts @@ -43,7 +43,9 @@ export type OrderUpdatesEvent = { * - `"siblingFilledCanceled"`: Canceled due to sibling ordering being filled. * - `"delistedCanceled"`: Canceled due to asset delisting. * - `"liquidatedCanceled"`: Canceled due to liquidation. + * - `"outcomeSettledCanceled"`: Canceled due to outcome market settlement. * - `"scheduledCancel"`: Canceled due to exceeding scheduled cancel deadline (dead man's switch). + * - `"internalCancel"`: Canceled due to an internal error. * - `"tickRejected"`: Rejected due to invalid tick price. * - `"minTradeNtlRejected"`: Rejected due to order notional below minimum. * - `"perpMarginRejected"`: Rejected due to insufficient margin. @@ -59,6 +61,7 @@ export type OrderUpdatesEvent = { * - `"insufficientSpotBalanceRejected"`: Rejected due to insufficient spot balance. * - `"oracleRejected"`: Rejected due to price too far from oracle. * - `"perpMaxPositionRejected"`: Rejected due to exceeding margin tier limit at current leverage. + * - `"tooManyOpenOrdersRejected"`: Rejected due to exceeding the open order limit. */ status: OrderProcessingStatus; /** Timestamp when the status was last updated (in ms since epoch). */ diff --git a/tests/api/exchange/order.test.ts b/tests/api/exchange/order.test.ts index 1dbf6439..90de37ab 100644 --- a/tests/api/exchange/order.test.ts +++ b/tests/api/exchange/order.test.ts @@ -134,8 +134,10 @@ runTest({ // ============================================================ describe("order (offline)", () => { + const wallet = privateKeyToAccount("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + const baseOrders = [{ a: 0, b: true, p: "1", s: "1", r: false, t: { limit: { tif: "Ioc" as const } } }]; + test("empty orders array fails validation before sending", () => { - const wallet = privateKeyToAccount("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); const transport: IRequestTransport = { isTestnet: true, request: () => Promise.reject(new Error("must not be sent")), @@ -143,4 +145,24 @@ describe("order (offline)", () => { assertThrows(() => order({ transport, wallet }, { orders: [] }), ValidationError, "Invalid length"); }); + + test("priority grouping cap is 1e8 (issue #99)", async () => { + const transport: IRequestTransport = { + isTestnet: true, + request: () => Promise.reject(new Error("validation passed")), + }; + + // Boundary: p = 1e8 (100%) passes client-side validation and reaches the transport. + await assertRejects( + () => order({ transport, wallet }, { orders: baseOrders, grouping: { p: 100_000_000 } }), + Error, + "validation passed", + ); + // Above the cap: rejected by client-side validation before sending. + assertThrows( + () => order({ transport, wallet }, { orders: baseOrders, grouping: { p: 100_000_001 } }), + ValidationError, + "Invalid value", + ); + }); }); diff --git a/tests/api/info/borrowLendUserState.test.ts b/tests/api/info/borrowLendUserState.test.ts index c619cdeb..ed86f18e 100644 --- a/tests/api/info/borrowLendUserState.test.ts +++ b/tests/api/info/borrowLendUserState.test.ts @@ -5,6 +5,7 @@ import { } from "@bloxwap/hyperliquid/api/info"; import { runOfflineMethodTests } from "./_offlineMethodTests.ts"; import * as v from "valibot"; +import { describe, test } from "bun:test"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; @@ -22,10 +23,37 @@ runTest({ const data = await Promise.all(params.map((p) => client.borrowLendUserState(p))); schemaCoverage(paramsSchema, params); - schemaCoverage(responseSchema, data); + // The live account is healthy with no observed non-null healthFactor; the offline block + // below covers the other health states and a numeric healthFactor (issue #101). + schemaCoverage(responseSchema, data, [ + "#/properties/health/enum/1", + "#/properties/health/enum/2", + "#/properties/health/enum/3", + "#/properties/healthFactor/defined", + ]); }, }); +// ============================================================ +// Offline: response schema — health states and healthFactor (issue #101) +// ============================================================ + +describe("borrowLendUserState (offline)", () => { + test("all health states satisfy the response schema", () => { + const state = { + borrow: { basis: "0.0", value: "0.0" }, + supply: { basis: "100.0", value: "100.0" }, + }; + const samples = [ + { tokenToState: [[0, state]], health: "healthy", healthFactor: null }, + { tokenToState: [[0, state]], health: "atRisk", healthFactor: "1.05" }, + { tokenToState: [[1, state]], health: "marketLiquidatable", healthFactor: "0.98" }, + { tokenToState: [], health: "backstopLiquidatable", healthFactor: "0.5" }, + ]; + schemaCoverage(responseSchema, samples); + }); +}); + // ============================================================ // Offline: request construction, passthrough, and InfoClient wrapper // ============================================================ diff --git a/tests/api/info/frontendOpenOrders.test.ts b/tests/api/info/frontendOpenOrders.test.ts index f1d40c69..23ebc74b 100644 --- a/tests/api/info/frontendOpenOrders.test.ts +++ b/tests/api/info/frontendOpenOrders.test.ts @@ -29,6 +29,9 @@ runTest({ "#/items/properties/orderType/enum/0", "#/items/properties/orderType/enum/4", "#/items/properties/orderType/enum/5", + "#/items/properties/orderType/enum/6", + "#/items/properties/orderType/enum/7", + "#/items/properties/orderType/enum/8", "#/items/properties/tif/enum/1", "#/items/properties/tif/enum/3", "#/items/properties/tif/enum/4", diff --git a/tests/api/info/historicalOrders.test.ts b/tests/api/info/historicalOrders.test.ts index db187971..c5608b38 100644 --- a/tests/api/info/historicalOrders.test.ts +++ b/tests/api/info/historicalOrders.test.ts @@ -23,6 +23,9 @@ runTest({ schemaCoverage(paramsSchema, params); schemaCoverage(responseSchema, data, [ + "#/items/properties/order/properties/orderType/enum/6", + "#/items/properties/order/properties/orderType/enum/7", + "#/items/properties/order/properties/orderType/enum/8", "#/items/properties/order/properties/tif/enum/5", "#/items/properties/status/enum/5", "#/items/properties/status/enum/6", @@ -47,6 +50,9 @@ runTest({ "#/items/properties/status/enum/26", "#/items/properties/status/enum/27", "#/items/properties/status/enum/28", + "#/items/properties/status/enum/29", + "#/items/properties/status/enum/30", + "#/items/properties/status/enum/31", "#/items/properties/order/properties/children/*", ]); }, diff --git a/tests/api/info/legalCheck.test.ts b/tests/api/info/legalCheck.test.ts index 41e37d5d..6908e9ed 100644 --- a/tests/api/info/legalCheck.test.ts +++ b/tests/api/info/legalCheck.test.ts @@ -1,6 +1,7 @@ import { legalCheck, type LegalCheckParameters, LegalCheckRequest } from "@bloxwap/hyperliquid/api/info"; import { runOfflineMethodTests } from "./_offlineMethodTests.ts"; import * as v from "valibot"; +import { describe, test } from "bun:test"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; @@ -18,14 +19,32 @@ runTest({ const data = await Promise.all(params.map((p) => client.legalCheck(p))); schemaCoverage(paramsSchema, params); + // The live account only ever returns one restriction code; the offline block below + // covers all four. schemaCoverage(responseSchema, data, [ - "#/properties/restrictions/missing", "#/properties/restrictions/enum/0", "#/properties/restrictions/enum/1", + "#/properties/restrictions/enum/2", + "#/properties/restrictions/enum/3", ]); }, }); +// ============================================================ +// Offline: response schema — all restriction codes (issue #103) +// ============================================================ + +describe("legalCheck (offline)", () => { + test("all restriction codes satisfy the response schema", () => { + const samples = (["n", "a", "o", "u"] as const).map((restrictions) => ({ + acceptedTerms: true, + userAllowed: restrictions === "n", + restrictions, + })); + schemaCoverage(responseSchema, samples); + }); +}); + // ============================================================ // Offline: request construction, passthrough, and InfoClient wrapper // ============================================================ diff --git a/tests/api/info/orderStatus.test.ts b/tests/api/info/orderStatus.test.ts index fd6cab28..6d4339cf 100644 --- a/tests/api/info/orderStatus.test.ts +++ b/tests/api/info/orderStatus.test.ts @@ -50,6 +50,9 @@ runTest({ schemaCoverage(paramsSchema, params); schemaCoverage(responseSchema, data, [ + "#/anyOf/0/properties/order/properties/order/properties/orderType/enum/6", + "#/anyOf/0/properties/order/properties/order/properties/orderType/enum/7", + "#/anyOf/0/properties/order/properties/order/properties/orderType/enum/8", "#/anyOf/0/properties/order/properties/order/properties/children/*", "#/anyOf/0/properties/order/properties/order/properties/tif/enum/5", "#/anyOf/0/properties/order/properties/status/enum/3", @@ -76,6 +79,9 @@ runTest({ "#/anyOf/0/properties/order/properties/status/enum/26", "#/anyOf/0/properties/order/properties/status/enum/27", "#/anyOf/0/properties/order/properties/status/enum/28", + "#/anyOf/0/properties/order/properties/status/enum/29", + "#/anyOf/0/properties/order/properties/status/enum/30", + "#/anyOf/0/properties/order/properties/status/enum/31", ]); }, }); diff --git a/tests/api/info/outcomeMeta.test.ts b/tests/api/info/outcomeMeta.test.ts index b68564ba..d01f2549 100644 --- a/tests/api/info/outcomeMeta.test.ts +++ b/tests/api/info/outcomeMeta.test.ts @@ -14,6 +14,8 @@ runTest({ schemaCoverage(responseSchema, data, [ "#/properties/outcomes/items/properties/sideSpecs/items/properties/token/present", + // deployer is only present for outcomes deployed from a template (unobserved live). + "#/properties/outcomes/items/properties/deployer/present", "#/properties/questions/items/properties/settledNamedOutcomes/array", ]); }, diff --git a/tests/api/info/settledOutcome.test.ts b/tests/api/info/settledOutcome.test.ts index 33eef66c..db1e64ce 100644 --- a/tests/api/info/settledOutcome.test.ts +++ b/tests/api/info/settledOutcome.test.ts @@ -1,6 +1,7 @@ import { settledOutcome, type SettledOutcomeParameters, SettledOutcomeRequest } from "@bloxwap/hyperliquid/api/info"; import { runOfflineMethodTests } from "./_offlineMethodTests.ts"; import * as v from "valibot"; +import { describe, test } from "bun:test"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; @@ -20,10 +21,53 @@ runTest({ schemaCoverage(paramsSchema, params); schemaCoverage(responseSchema, data, [ "#/anyOf/0/properties/spec/properties/sideSpecs/items/properties/token/present", + // deployer/question are only present for template-deployed named outcomes (unobserved + // live); the offline block below covers them. + "#/anyOf/0/properties/spec/properties/deployer/present", + "#/anyOf/0/properties/question/missing", + "#/anyOf/0/properties/question/present", + "#/anyOf/0/properties/question/properties/question/anyOf/0", + "#/anyOf/0/properties/question/properties/question/anyOf/1", ]); }, }); +// ============================================================ +// Offline: response schema — deployer/question branches (issue #103) +// ============================================================ + +describe("settledOutcome (offline)", () => { + test("spec.deployer and question variants satisfy the response schema", () => { + // Covers: spec.deployer present/absent, question absent, question keyed by active and + // by settled, sideSpecs token present/absent, and the null (not settled) branch. + const base = { + spec: { + outcome: 1, + name: "Yes", + description: "Resolves yes", + sideSpecs: [{ name: "Yes" }, { name: "No", token: 123 }], + quoteToken: "USDC", + }, + settleFraction: "1.0", + details: "Settled in favor of Yes", + }; + const samples = [ + base, + { + ...base, + spec: { ...base.spec, deployer: "0x0000000000000000000000000000000000000001" }, + question: { question: { active: 5 }, name: "Will it rain?", description: "Rain market" }, + }, + { + ...base, + question: { question: { settled: 5 }, name: "Will it rain?", description: "Rain market" }, + }, + null, + ]; + schemaCoverage(responseSchema, samples); + }); +}); + // ============================================================ // Offline: request construction, passthrough, and InfoClient wrapper // ============================================================ diff --git a/tests/api/info/spotClearinghouseState.test.ts b/tests/api/info/spotClearinghouseState.test.ts index 5ef11964..094fa1a2 100644 --- a/tests/api/info/spotClearinghouseState.test.ts +++ b/tests/api/info/spotClearinghouseState.test.ts @@ -34,6 +34,7 @@ runTest({ "#/properties/balances/items/anyOf/1", "#/properties/portfolioMarginRatio/present", "#/properties/tokenToPortfolioBorrowRatio/present", + "#/properties/tokenToPortfolioSupplyRatio/present", "#/properties/tokenToAvailableAfterMaintenance/present", ]); }, diff --git a/tests/api/info/subAccounts.test.ts b/tests/api/info/subAccounts.test.ts index d4a00fd3..4a5036a7 100644 --- a/tests/api/info/subAccounts.test.ts +++ b/tests/api/info/subAccounts.test.ts @@ -29,6 +29,7 @@ runTest({ "#/anyOf/0/items/properties/spotState/properties/portfolioMarginEnabled/present", "#/anyOf/0/items/properties/spotState/properties/portfolioMarginRatio/present", "#/anyOf/0/items/properties/spotState/properties/tokenToPortfolioBorrowRatio/present", + "#/anyOf/0/items/properties/spotState/properties/tokenToPortfolioSupplyRatio/present", "#/anyOf/0/items/properties/spotState/properties/tokenToAvailableAfterMaintenance/present", "#/anyOf/0/items/properties/spotState/properties/balances/items/anyOf/0/properties/spotHold/present", "#/anyOf/0/items/properties/spotState/properties/balances/items/anyOf/0/properties/ltv/present", diff --git a/tests/api/info/subAccounts2.test.ts b/tests/api/info/subAccounts2.test.ts index 6d021656..376f0d55 100644 --- a/tests/api/info/subAccounts2.test.ts +++ b/tests/api/info/subAccounts2.test.ts @@ -29,11 +29,14 @@ runTest({ "#/anyOf/0/items/properties/spotState/properties/portfolioMarginEnabled/present", "#/anyOf/0/items/properties/spotState/properties/portfolioMarginRatio/present", "#/anyOf/0/items/properties/spotState/properties/tokenToPortfolioBorrowRatio/present", + "#/anyOf/0/items/properties/spotState/properties/tokenToPortfolioSupplyRatio/present", "#/anyOf/0/items/properties/spotState/properties/tokenToAvailableAfterMaintenance/present", "#/anyOf/0/items/properties/spotState/properties/balances/items/anyOf/0/properties/spotHold/present", "#/anyOf/0/items/properties/spotState/properties/balances/items/anyOf/0/properties/ltv/present", "#/anyOf/0/items/properties/spotState/properties/balances/items/anyOf/0/properties/borrowed/present", "#/anyOf/0/items/properties/spotState/properties/balances/items/anyOf/0/properties/supplied/present", + // abstraction is absent while the sub-account uses the default state (unobserved live). + "#/anyOf/0/items/properties/abstraction/present", ]); }, }); diff --git a/tests/api/info/twapHistory.test.ts b/tests/api/info/twapHistory.test.ts index 10a8f3e1..1c8082d3 100644 --- a/tests/api/info/twapHistory.test.ts +++ b/tests/api/info/twapHistory.test.ts @@ -19,11 +19,12 @@ runTest({ const data = await Promise.all(params.map((p) => client.twapHistory(p))); schemaCoverage(paramsSchema, params); - // Live wire always carries trigger/stopPx as null (not settable via the current TWAP order - // action), so the missing/non-null branches are uncoverable live — the offline block below + // Live wire always carries trigger/stopPx as null for this account (no trigger/stop set), + // so the missing/non-null branches are uncoverable live — the offline block below // covers them. schemaCoverage(responseSchema, data, [ "#/items/properties/state/properties/trigger/missing", + "#/items/properties/state/properties/trigger/defined", "#/items/properties/state/properties/stopPx/missing", "#/items/properties/state/properties/stopPx/defined", ]); @@ -59,7 +60,8 @@ describe("twapHistory (offline)", () => { test("live-shaped states with trigger/stopPx satisfy the response schema", () => { // Covers every schema branch: side B/A, trigger present-null/present-non-null/absent, - // stopPx present-null/present-non-null/absent, all statuses, twapId present/absent. + // stopPx present-null/present-non-null/absent, all statuses (including waitingForTrigger + // and stopped from #100), twapId present/absent. const samples = [ liveSample, { @@ -75,7 +77,7 @@ describe("twapHistory (offline)", () => { reduceOnly: true, randomize: false, timestamp: 1784814835868, - // trigger and stopPx absent (not settable via the current TWAP order action) + // trigger and stopPx absent (not set on pre-field-availability responses) }, status: { status: "activated" }, // twapId absent on pre-id-availability responses @@ -93,7 +95,7 @@ describe("twapHistory (offline)", () => { reduceOnly: false, randomize: true, timestamp: 1732937510435, - trigger: { isMarket: true, triggerPx: "25.5", tpsl: "sl" }, // shape unestablished; unknown accepts any + trigger: { px: "25.5", above: false }, // settable via twapOrder `details` (#100) stopPx: "25.5", }, status: { status: "terminated" }, @@ -116,6 +118,44 @@ describe("twapHistory (offline)", () => { status: { status: "error", description: "Twap fill failure: insufficient balance" }, twapId: 1873181, }, + { + time: 1784814900, + state: { + coin: "HYPE", + user: "0xecb63caa47c7c4e77f60f1ce858cf28dc2b82b00", + side: "B", + sz: "144.36", + executedSz: "0.0", + executedNtl: "0.0", + minutes: 5, + reduceOnly: false, + randomize: false, + timestamp: 1732937510435, + trigger: { px: "30.0", above: true }, + stopPx: null, + }, + status: { status: "waitingForTrigger" }, + twapId: 1873182, + }, + { + time: 1784815000, + state: { + coin: "HYPE", + user: "0xecb63caa47c7c4e77f60f1ce858cf28dc2b82b00", + side: "B", + sz: "144.36", + executedSz: "50.13", + executedNtl: "348.294323", + minutes: 5, + reduceOnly: false, + randomize: false, + timestamp: 1732937510435, + trigger: null, + stopPx: "20.0", + }, + status: { status: "stopped" }, + twapId: 1873183, + }, ]; schemaCoverage(responseSchema, [samples]); }); diff --git a/tests/api/info/userFees.test.ts b/tests/api/info/userFees.test.ts index b99e26e9..c0f147ef 100644 --- a/tests/api/info/userFees.test.ts +++ b/tests/api/info/userFees.test.ts @@ -1,6 +1,7 @@ import { userFees, type UserFeesParameters, UserFeesRequest } from "@bloxwap/hyperliquid/api/info"; import { runOfflineMethodTests } from "./_offlineMethodTests.ts"; import * as v from "valibot"; +import { describe, test } from "bun:test"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; @@ -29,6 +30,56 @@ runTest({ }, }); +// ============================================================ +// Offline: response schema — stakingLink variants (issue #102) +// ============================================================ + +describe("userFees (offline)", () => { + test("all stakingLink variants satisfy the response schema", () => { + const base = { + dailyUserVlm: [{ date: "2026-01-01", userCross: "0.0", userAdd: "0.0", exchange: "0.0" }], + feeSchedule: { + cross: "0.00045", + add: "0.00015", + spotCross: "0.0007", + spotAdd: "0.0004", + tiers: { + vip: [{ ntlCutoff: "5000000.0", cross: "0.0004", add: "0.00012", spotCross: "0.0006", spotAdd: "0.0003" }], + mm: [{ makerFractionCutoff: "0.005", add: "0.00001" }], + }, + referralDiscount: "0.04", + stakingDiscountTiers: [{ bpsOfMaxSupply: "0.0", discount: "0.0" }], + }, + userCrossRate: "0.00045", + userAddRate: "0.00015", + userSpotCrossRate: "0.0007", + userSpotAddRate: "0.0004", + activeReferralDiscount: "0.04", + trial: null, + feeTrialEscrow: "0.0", + nextTrialAvailableTimestamp: null, + activeStakingDiscount: { bpsOfMaxSupply: "0.0", discount: "0.0" }, + }; + const samples = [ + { ...base, stakingLink: null }, + { ...base, trial: {}, nextTrialAvailableTimestamp: 1780000000000, stakingLink: null }, + { + ...base, + stakingLink: { type: "requested", stakingUser: "0x0000000000000000000000000000000000000001" }, + }, + { + ...base, + stakingLink: { type: "tradingUser", stakingUser: "0x0000000000000000000000000000000000000001" }, + }, + { + ...base, + stakingLink: { type: "stakingUser", tradingUser: "0x0000000000000000000000000000000000000002" }, + }, + ]; + schemaCoverage(responseSchema, samples); + }); +}); + // ============================================================ // Offline: request construction, passthrough, and InfoClient wrapper // ============================================================ diff --git a/tests/api/info/userFills.test.ts b/tests/api/info/userFills.test.ts index 83668fe2..8335cf0c 100644 --- a/tests/api/info/userFills.test.ts +++ b/tests/api/info/userFills.test.ts @@ -1,6 +1,7 @@ import { userFills, type UserFillsParameters, UserFillsRequest } from "@bloxwap/hyperliquid/api/info"; import { runOfflineMethodTests } from "./_offlineMethodTests.ts"; import * as v from "valibot"; +import { describe, test } from "bun:test"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; @@ -22,10 +23,67 @@ runTest({ const data = await Promise.all(params.map((p) => client.userFills(p))); schemaCoverage(paramsSchema, params); - schemaCoverage(responseSchema, data, ["#/items/properties/twapId/defined"]); + schemaCoverage(responseSchema, data, [ + "#/items/properties/twapId/defined", + // feeTrialEscrow is absent from these accounts' fills; liquidatedUser is only absent + // when the liquidation has no liquidated user (e.g. backstop), unobserved live. + "#/items/properties/feeTrialEscrow/present", + "#/items/properties/liquidation/properties/liquidatedUser/missing", + ]); }, }); +// ============================================================ +// Offline: response schema — feeTrialEscrow and liquidatedUser branches (issue #102) +// ============================================================ + +describe("userFills (offline)", () => { + test("feeTrialEscrow and optional liquidatedUser satisfy the response schema", () => { + const baseFill = { + coin: "BTC", + px: "100000.0", + sz: "0.1", + side: "B", + time: 1780000000000, + startPosition: "0.0", + dir: "Open Long", + closedPnl: "0.0", + hash: "0x0000000000000000000000000000000000000000000000000000000000000001", + oid: 1, + crossed: true, + fee: "1.0", + tid: 1, + feeToken: "USDC", + twapId: null, + }; + const samples = [ + baseFill, + { + ...baseFill, + side: "A", + builderFee: "0.1", + feeTrialEscrow: "0.5", + cloid: "0x00000000000000000000000000000001", + twapId: 7, + }, + { + ...baseFill, + liquidation: { + liquidatedUser: "0x0000000000000000000000000000000000000002", + markPx: "99000.0", + method: "market", + }, + }, + { + ...baseFill, + // Backstop liquidation: no liquidated user on the wire (#102) + liquidation: { markPx: "99000.0", method: "backstop" }, + }, + ]; + schemaCoverage(responseSchema, [samples]); + }); +}); + // ============================================================ // Offline: request construction, passthrough, and InfoClient wrapper // ============================================================ diff --git a/tests/api/info/userFillsByTime.test.ts b/tests/api/info/userFillsByTime.test.ts index a5c725d9..6d0a4441 100644 --- a/tests/api/info/userFillsByTime.test.ts +++ b/tests/api/info/userFillsByTime.test.ts @@ -29,7 +29,11 @@ runTest({ const data = await Promise.all(params.map((p) => client.userFillsByTime(p))); schemaCoverage(paramsSchema, params); - schemaCoverage(responseSchema, data, ["#/items/properties/twapId/defined"]); + schemaCoverage(responseSchema, data, [ + "#/items/properties/twapId/defined", + "#/items/properties/feeTrialEscrow/present", + "#/items/properties/liquidation/properties/liquidatedUser/missing", + ]); }, }); diff --git a/tests/api/info/userTwapSliceFills.test.ts b/tests/api/info/userTwapSliceFills.test.ts index 2d2cf798..22236e9f 100644 --- a/tests/api/info/userTwapSliceFills.test.ts +++ b/tests/api/info/userTwapSliceFills.test.ts @@ -24,6 +24,7 @@ runTest({ schemaCoverage(paramsSchema, params); schemaCoverage(responseSchema, data, [ "#/items/properties/fill/properties/builderFee/present", + "#/items/properties/fill/properties/feeTrialEscrow/present", "#/items/properties/fill/properties/twapId/defined", ]); }, diff --git a/tests/api/info/userTwapSliceFillsByTime.test.ts b/tests/api/info/userTwapSliceFillsByTime.test.ts index 73d0d9d3..9f1e379c 100644 --- a/tests/api/info/userTwapSliceFillsByTime.test.ts +++ b/tests/api/info/userTwapSliceFillsByTime.test.ts @@ -30,6 +30,7 @@ runTest({ schemaCoverage(paramsSchema, params); schemaCoverage(responseSchema, data, [ "#/items/properties/fill/properties/builderFee/present", + "#/items/properties/fill/properties/feeTrialEscrow/present", "#/items/properties/fill/properties/twapId/defined", ]); }, diff --git a/tests/api/info/webData2.test.ts b/tests/api/info/webData2.test.ts index b08bb6f0..d941a571 100644 --- a/tests/api/info/webData2.test.ts +++ b/tests/api/info/webData2.test.ts @@ -27,6 +27,9 @@ runTest({ "#/properties/openOrders/items/properties/orderType/enum/0", "#/properties/openOrders/items/properties/orderType/enum/4", "#/properties/openOrders/items/properties/orderType/enum/5", + "#/properties/openOrders/items/properties/orderType/enum/6", + "#/properties/openOrders/items/properties/orderType/enum/7", + "#/properties/openOrders/items/properties/orderType/enum/8", "#/properties/openOrders/items/properties/tif/enum/1", "#/properties/openOrders/items/properties/tif/enum/3", "#/properties/openOrders/items/properties/tif/enum/4", @@ -36,9 +39,10 @@ runTest({ "#/properties/meta/properties/universe/items/properties/growthMode/present", "#/properties/meta/properties/universe/items/properties/lastGrowthModeChangeTime/present", "#/properties/twapStates/array", - // trigger/stopPx always arrive as null on the wire (not settable via the current TWAP order - // action), so their missing/non-null branches are uncoverable live. + // trigger/stopPx always arrive as null for these accounts (no trigger/stop set), so + // their missing/non-null branches are uncoverable live. "#/properties/twapStates/items/items/1/properties/trigger/missing", + "#/properties/twapStates/items/items/1/properties/trigger/defined", "#/properties/twapStates/items/items/1/properties/stopPx/missing", "#/properties/twapStates/items/items/1/properties/stopPx/defined", "#/properties/perpsAtOpenInterestCap/present", @@ -48,6 +52,7 @@ runTest({ "#/properties/spotState/properties/portfolioMarginEnabled/present", "#/properties/spotState/properties/portfolioMarginRatio/present", "#/properties/spotState/properties/tokenToPortfolioBorrowRatio/present", + "#/properties/spotState/properties/tokenToPortfolioSupplyRatio/present", "#/properties/spotState/properties/tokenToAvailableAfterMaintenance/present", "#/properties/spotState/properties/balances/items/anyOf/0/properties/spotHold/present", "#/properties/spotState/properties/balances/items/anyOf/0/properties/ltv/present", diff --git a/tests/api/subscription/openOrders.test.ts b/tests/api/subscription/openOrders.test.ts index c8eb1fa5..93b2adc0 100644 --- a/tests/api/subscription/openOrders.test.ts +++ b/tests/api/subscription/openOrders.test.ts @@ -31,6 +31,9 @@ runTest({ "#/properties/orders/items/properties/orderType/enum/0", "#/properties/orders/items/properties/orderType/enum/4", "#/properties/orders/items/properties/orderType/enum/5", + "#/properties/orders/items/properties/orderType/enum/6", + "#/properties/orders/items/properties/orderType/enum/7", + "#/properties/orders/items/properties/orderType/enum/8", "#/properties/orders/items/properties/tif/enum/1", "#/properties/orders/items/properties/tif/enum/3", "#/properties/orders/items/properties/tif/enum/4", diff --git a/tests/api/subscription/orderUpdates.test.ts b/tests/api/subscription/orderUpdates.test.ts index 61b8864a..9e788b7c 100644 --- a/tests/api/subscription/orderUpdates.test.ts +++ b/tests/api/subscription/orderUpdates.test.ts @@ -60,6 +60,9 @@ runTestWithExchange({ "#/items/properties/status/enum/26", "#/items/properties/status/enum/27", "#/items/properties/status/enum/28", + "#/items/properties/status/enum/29", + "#/items/properties/status/enum/30", + "#/items/properties/status/enum/31", ]); }, }); diff --git a/tests/api/subscription/spotState.test.ts b/tests/api/subscription/spotState.test.ts index 50d8eed7..1cf38b22 100644 --- a/tests/api/subscription/spotState.test.ts +++ b/tests/api/subscription/spotState.test.ts @@ -29,6 +29,7 @@ runTest({ "#/properties/spotState/properties/portfolioMarginEnabled/present", "#/properties/spotState/properties/portfolioMarginRatio/present", "#/properties/spotState/properties/tokenToPortfolioBorrowRatio/present", + "#/properties/spotState/properties/tokenToPortfolioSupplyRatio/present", "#/properties/spotState/properties/tokenToAvailableAfterMaintenance/present", "#/properties/spotState/properties/balances/items/anyOf/0/properties/spotHold/present", "#/properties/spotState/properties/balances/items/anyOf/0/properties/ltv/present", diff --git a/tests/api/subscription/twapStates.test.ts b/tests/api/subscription/twapStates.test.ts index 5aac57cd..87c1c82b 100644 --- a/tests/api/subscription/twapStates.test.ts +++ b/tests/api/subscription/twapStates.test.ts @@ -28,11 +28,12 @@ runTestWithExchange({ }, 10_000); schemaCoverage(paramsSchema, params); - // trigger/stopPx always arrive as null on the wire (not settable via the current TWAP order - // action), so their missing/non-null branches are uncoverable live. + // trigger/stopPx always arrive as null for the test account (no trigger/stop set), so + // their missing/non-null branches are uncoverable live. schemaCoverage(responseSchema, data, [ "#/properties/states/items/items/1/properties/side/enum/1", "#/properties/states/items/items/1/properties/trigger/missing", + "#/properties/states/items/items/1/properties/trigger/defined", "#/properties/states/items/items/1/properties/stopPx/missing", "#/properties/states/items/items/1/properties/stopPx/defined", ]); diff --git a/tests/api/subscription/userEvents.test.ts b/tests/api/subscription/userEvents.test.ts index c71553a7..3f1f8b84 100644 --- a/tests/api/subscription/userEvents.test.ts +++ b/tests/api/subscription/userEvents.test.ts @@ -34,12 +34,16 @@ runTestWithExchange({ "#/anyOf/2", "#/anyOf/3", "#/anyOf/4/properties/twapHistory/items/properties/state/properties/side/enum/1", + "#/anyOf/4/properties/twapHistory/items/properties/state/properties/trigger/defined", "#/anyOf/4/properties/twapHistory/items/properties/status/anyOf/0/properties/status/enum/0", "#/anyOf/4/properties/twapHistory/items/properties/status/anyOf/0/properties/status/enum/2", + "#/anyOf/4/properties/twapHistory/items/properties/status/anyOf/0/properties/status/enum/3", + "#/anyOf/4/properties/twapHistory/items/properties/status/anyOf/0/properties/status/enum/4", "#/anyOf/4/properties/twapHistory/items/properties/status/anyOf/1", "#/anyOf/4/properties/twapHistory/items/properties/twapId/missing", "#/anyOf/5/properties/twapSliceFills/items/properties/fill/properties/side/enum/1", "#/anyOf/5/properties/twapSliceFills/items/properties/fill/properties/builderFee/present", + "#/anyOf/5/properties/twapSliceFills/items/properties/fill/properties/feeTrialEscrow/present", "#/anyOf/5/properties/twapSliceFills/items/properties/fill/properties/twapId/defined", ]); }, diff --git a/tests/api/subscription/userFills.test.ts b/tests/api/subscription/userFills.test.ts index 39339b39..d7098ae9 100644 --- a/tests/api/subscription/userFills.test.ts +++ b/tests/api/subscription/userFills.test.ts @@ -25,6 +25,8 @@ runTest({ schemaCoverage(paramsSchema, params); schemaCoverage(responseSchema, data, [ "#/properties/fills/items/properties/builderFee/present", + "#/properties/fills/items/properties/feeTrialEscrow/present", + "#/properties/fills/items/properties/liquidation/properties/liquidatedUser/missing", "#/properties/fills/items/properties/twapId/defined", "#/properties/isSnapshot/missing", ]); diff --git a/tests/api/subscription/userHistoricalOrders.test.ts b/tests/api/subscription/userHistoricalOrders.test.ts index 91573386..0c7b6a46 100644 --- a/tests/api/subscription/userHistoricalOrders.test.ts +++ b/tests/api/subscription/userHistoricalOrders.test.ts @@ -29,6 +29,9 @@ runTest({ schemaCoverage(paramsSchema, params); schemaCoverage(responseSchema, data, [ "#/properties/orderHistory/items/properties/order/properties/orderType/enum/3", + "#/properties/orderHistory/items/properties/order/properties/orderType/enum/6", + "#/properties/orderHistory/items/properties/order/properties/orderType/enum/7", + "#/properties/orderHistory/items/properties/order/properties/orderType/enum/8", "#/properties/orderHistory/items/properties/order/properties/tif/enum/1", "#/properties/orderHistory/items/properties/order/properties/tif/enum/5", "#/properties/orderHistory/items/properties/order/properties/children/*", @@ -56,6 +59,9 @@ runTest({ "#/properties/orderHistory/items/properties/status/enum/26", "#/properties/orderHistory/items/properties/status/enum/27", "#/properties/orderHistory/items/properties/status/enum/28", + "#/properties/orderHistory/items/properties/status/enum/29", + "#/properties/orderHistory/items/properties/status/enum/30", + "#/properties/orderHistory/items/properties/status/enum/31", "#/properties/isSnapshot/missing", ]); }, diff --git a/tests/api/subscription/userTwapSliceFills.test.ts b/tests/api/subscription/userTwapSliceFills.test.ts index 99278fb2..e6db09a0 100644 --- a/tests/api/subscription/userTwapSliceFills.test.ts +++ b/tests/api/subscription/userTwapSliceFills.test.ts @@ -29,6 +29,7 @@ runTest({ schemaCoverage(paramsSchema, params); schemaCoverage(responseSchema, data, [ "#/properties/twapSliceFills/items/properties/fill/properties/builderFee/present", + "#/properties/twapSliceFills/items/properties/fill/properties/feeTrialEscrow/present", "#/properties/twapSliceFills/items/properties/fill/properties/twapId/defined", "#/properties/isSnapshot/missing", ]); From 8a694d8b9249695333058c857862c1b6cd8cf0d6 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 24 Aug 2026 08:16:00 -0700 Subject: [PATCH 2/7] feat(api): HIP-4 outcome templates, usdcRouting, new optional params (#104-#106) - info.outcomeTemplates (role union + keyword formats verified against live testnet) - info.usdcRouting (#106) - exchange.activateOutcomeDeployer + spotDeploy outcome sub-actions; validatorL1Votes variants (#104) - twapOrder.details (trigger/stop), reserveRequestWeight.destination, marginTable.dex (#105) --- .../_methods/activateOutcomeDeployer.ts | 123 ++++++++++++ .../exchange/_methods/reserveRequestWeight.ts | 4 +- src/api/exchange/_methods/spotDeploy.ts | 79 ++++++++ src/api/exchange/_methods/twapOrder.ts | 16 ++ src/api/exchange/client.ts | 45 +++++ src/api/exchange/mod.ts | 1 + src/api/info/_methods/marginTable.ts | 2 + src/api/info/_methods/outcomeTemplates.ts | 97 ++++++++++ src/api/info/_methods/usdcRouting.ts | 72 +++++++ src/api/info/_methods/validatorL1Votes.ts | 50 +++++ src/api/info/client.ts | 54 ++++++ src/api/info/mod.ts | 2 + tests/api/exchange/_client.test.ts | 5 + .../exchange/activateOutcomeDeployer.test.ts | 83 ++++++++ .../api/exchange/reserveRequestWeight.test.ts | 56 +++++- tests/api/exchange/spotDeploy.test.ts | 183 ++++++++++++++++++ tests/api/exchange/twapOrder.test.ts | 68 ++++++- tests/api/info/marginTable.test.ts | 10 +- tests/api/info/outcomeTemplates.test.ts | 27 +++ tests/api/info/usdcRouting.test.ts | 27 +++ tests/api/info/validatorL1Votes.test.ts | 2 + 21 files changed, 999 insertions(+), 7 deletions(-) create mode 100644 src/api/exchange/_methods/activateOutcomeDeployer.ts create mode 100644 src/api/info/_methods/outcomeTemplates.ts create mode 100644 src/api/info/_methods/usdcRouting.ts create mode 100644 tests/api/exchange/activateOutcomeDeployer.test.ts create mode 100644 tests/api/info/outcomeTemplates.test.ts create mode 100644 tests/api/info/usdcRouting.test.ts diff --git a/src/api/exchange/_methods/activateOutcomeDeployer.ts b/src/api/exchange/_methods/activateOutcomeDeployer.ts new file mode 100644 index 00000000..64c41e69 --- /dev/null +++ b/src/api/exchange/_methods/activateOutcomeDeployer.ts @@ -0,0 +1,123 @@ +import * as v from "valibot"; + +// ============================================================ +// API Schemas +// ============================================================ + +import { Hex, UnsignedInteger } from "../../_schemas.ts"; + +/** + * Activate or deactivate the signer as an outcome deployer. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#activation + */ +export const ActivateOutcomeDeployerRequest = /* @__PURE__ */ (() => { + return v.object({ + /** Action to perform. */ + action: v.object({ + /** Type of action. */ + type: v.literal("activateOutcomeDeployer"), + /** Deactivate instead of activate. */ + isDeactivate: v.boolean(), + }), + /** Nonce (timestamp in ms) used to prevent replay attacks. */ + nonce: UnsignedInteger, + /** ECDSA signature components. */ + signature: v.object({ + /** First 32-byte component. */ + r: v.pipe(Hex, v.length(66)), + /** Second 32-byte component. */ + s: v.pipe(Hex, v.length(66)), + /** Recovery identifier. */ + v: v.picklist([27, 28]), + }), + /** Expiration time of the action. */ + expiresAfter: v.optional(UnsignedInteger), + }); +})(); +export type ActivateOutcomeDeployerRequest = v.InferOutput; + +/** + * Successful response without specific data or error response. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#activation + */ +export type ActivateOutcomeDeployerResponse = + | { + /** Successful status. */ + status: "ok"; + /** Response details. */ + response: { + /** Type of response. */ + type: "default"; + }; + } + | { + /** Error status. */ + status: "err"; + /** Error message. */ + response: string; + }; + +// ============================================================ +// Execution Logic +// ============================================================ + +import { + type ExchangeConfig, + type ExcludeErrorResponse, + buildAction, + executeL1Action, + type ExtractRequestOptions, +} from "./_base/mod.ts"; + +/** Schema for action fields (excludes request-level system fields). */ +const ActivateOutcomeDeployerActionSchema = /* @__PURE__ */ (() => { + return v.object(ActivateOutcomeDeployerRequest.entries.action.entries); +})(); + +/** Action parameters for the {@linkcode activateOutcomeDeployer} function. */ +export type ActivateOutcomeDeployerParameters = Omit, "type">; + +/** Request options for the {@linkcode activateOutcomeDeployer} function. */ +export type ActivateOutcomeDeployerOptions = ExtractRequestOptions>; + +/** Successful variant of {@linkcode ActivateOutcomeDeployerResponse} without errors. */ +export type ActivateOutcomeDeployerSuccessResponse = ExcludeErrorResponse; + +/** + * Activate or deactivate the signer as an outcome deployer. + * + * Signing: L1 Action. + * + * @param config General configuration for Exchange API requests. + * @param params Parameters specific to the API request. + * @param opts Request execution options. + * @return Successful response without specific data. + * + * @throws {ValidationError} When the request parameters fail validation (before sending). + * @throws {TransportError} When the transport layer throws an error. + * @throws {ApiRequestError} When the API returns an unsuccessful response. + * + * @example + * ```ts + * import { HttpTransport } from "@bloxwap/hyperliquid"; + * import { activateOutcomeDeployer } from "@bloxwap/hyperliquid/api/exchange"; + * import { privateKeyToAccount } from "viem/accounts"; + * + * const wallet = privateKeyToAccount("0x..."); + * const transport = new HttpTransport(); // or `WebSocketTransport` + * + * await activateOutcomeDeployer({ transport, wallet }, { + * isDeactivate: false, + * }); + * ``` + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#activation + */ +export function activateOutcomeDeployer( + config: ExchangeConfig, + params: ActivateOutcomeDeployerParameters, + opts?: ActivateOutcomeDeployerOptions, +): Promise { + const action = buildAction(ActivateOutcomeDeployerActionSchema, { type: "activateOutcomeDeployer", ...params }, opts); + return executeL1Action(config, action, opts); +} diff --git a/src/api/exchange/_methods/reserveRequestWeight.ts b/src/api/exchange/_methods/reserveRequestWeight.ts index f3689119..adc2d571 100644 --- a/src/api/exchange/_methods/reserveRequestWeight.ts +++ b/src/api/exchange/_methods/reserveRequestWeight.ts @@ -4,7 +4,7 @@ import * as v from "valibot"; // API Schemas // ============================================================ -import { Hex, UnsignedInteger } from "../../_schemas.ts"; +import { Address, Hex, UnsignedInteger } from "../../_schemas.ts"; /** * Reserve additional rate-limited actions for a fee. @@ -18,6 +18,8 @@ export const ReserveRequestWeightRequest = /* @__PURE__ */ (() => { type: v.literal("reserveRequestWeight"), /** Amount of request weight to reserve. */ weight: v.pipe(UnsignedInteger, v.maxValue(1844674407370955)), // Truncated max uint64 / 10000 + /** Address of an existing user to reserve the weight for. */ + destination: v.optional(Address), }), /** Nonce (timestamp in ms) used to prevent replay attacks. */ nonce: UnsignedInteger, diff --git a/src/api/exchange/_methods/spotDeploy.ts b/src/api/exchange/_methods/spotDeploy.ts index 0df2b9c5..40abd8e4 100644 --- a/src/api/exchange/_methods/spotDeploy.ts +++ b/src/api/exchange/_methods/spotDeploy.ts @@ -159,6 +159,85 @@ export const SpotDeployRequest = /* @__PURE__ */ (() => { evmExtraWeiDecimals: v.pipe(Integer, v.minValue(-2), v.maxValue(18)), }), }), + v.object({ + /** Type of action. */ + type: v.literal("spotDeploy"), + /** + * Outcome deployer parameters. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions + */ + outcome: v.union([ + v.object({ + /** Deploy a standalone Yes/No market from a standalone outcome template. */ + registerStandaloneOutcomeFromTemplate: v.object({ + /** Template identifier. */ + id: v.string(), + /** A list (sorted by key) of template keyword and value. */ + keywordToValue: v.array(v.tuple([v.string(), v.string()])), + }), + }), + v.object({ + /** Deploy a question and its named outcomes. */ + registerQuestionFromTemplate: v.object({ + /** Instantiation of the question template. */ + questionTemplateInstance: v.object({ + /** Template identifier. */ + id: v.string(), + /** A list (sorted by key) of template keyword and value. */ + keywordToValue: v.array(v.tuple([v.string(), v.string()])), + }), + /** Instantiations of the named outcome templates (at most 100). */ + namedOutcomeTemplateInstances: v.array( + v.object({ + /** Template identifier. */ + id: v.string(), + /** A list (sorted by key) of template keyword and value. */ + keywordToValue: v.array(v.tuple([v.string(), v.string()])), + }), + ), + }), + }), + v.object({ + /** Settle one outcome of the deployer. */ + settleOutcome: v.object({ + /** Outcome identifier. */ + outcome: UnsignedInteger, + /** Payout fraction of the Yes side (between 0 and 1). */ + settleFraction: UnsignedDecimal, + /** Settlement details. Must be empty. */ + details: v.string(), + /** Name and description of the outcome being settled. */ + nameAndDescription: v.tuple([v.string(), v.string()]), + /** Names of the Yes and No sides of the outcome being settled. */ + sideNames: v.tuple([v.string(), v.string()]), + }), + }), + v.object({ + /** Settle all remaining named outcomes of a question. */ + settleQuestion2: v.object({ + /** Question identifier. */ + question: UnsignedInteger, + /** Settlement of each remaining active named outcome. */ + outcomeSettlements: v.array( + v.object({ + /** Outcome identifier. */ + outcome: UnsignedInteger, + /** Payout fraction of the Yes side (between 0 and 1). */ + settleFraction: UnsignedDecimal, + /** Settlement details. Must be empty. */ + details: v.string(), + /** Name and description of the outcome being settled. */ + nameAndDescription: v.tuple([v.string(), v.string()]), + /** Names of the Yes and No sides of the outcome being settled. */ + sideNames: v.tuple([v.string(), v.string()]), + }), + ), + /** Name and description of the question being settled. */ + nameAndDescription: v.tuple([v.string(), v.string()]), + }), + }), + ]), + }), ]), /** Nonce (timestamp in ms) used to prevent replay attacks. */ nonce: UnsignedInteger, diff --git a/src/api/exchange/_methods/twapOrder.ts b/src/api/exchange/_methods/twapOrder.ts index 3f06741c..b8535d7f 100644 --- a/src/api/exchange/_methods/twapOrder.ts +++ b/src/api/exchange/_methods/twapOrder.ts @@ -31,6 +31,22 @@ export const TwapOrderRequest = /* @__PURE__ */ (() => { /** Enable random order timing. */ t: v.boolean(), }), + /** Trigger and stop prices. */ + details: v.optional( + v.object({ + /** Condition that activates the order. */ + t: v.nullable( + v.object({ + /** Trigger price. */ + p: UnsignedDecimal, + /** Activate when the mark price is above (`true`) or below (`false`) the trigger price. */ + a: v.boolean(), + }), + ), + /** Price at which the order is terminated. */ + s: v.nullable(UnsignedDecimal), + }), + ), }), /** Nonce (timestamp in ms) used to prevent replay attacks. */ nonce: UnsignedInteger, diff --git a/src/api/exchange/client.ts b/src/api/exchange/client.ts index 1b857065..1567ee47 100644 --- a/src/api/exchange/client.ts +++ b/src/api/exchange/client.ts @@ -9,6 +9,12 @@ import type { ExchangeConfig, ExchangeSingleWalletConfig } from "./_methods/_bas // Methods Imports // ============================================================ +import { + activateOutcomeDeployer, + type ActivateOutcomeDeployerOptions, + type ActivateOutcomeDeployerParameters, + type ActivateOutcomeDeployerSuccessResponse, +} from "./_methods/activateOutcomeDeployer.ts"; import { agentEnableDexAbstraction, type AgentEnableDexAbstractionOptions, @@ -405,6 +411,40 @@ export class ExchangeClient { + return activateOutcomeDeployer(this.config_, params, opts); + } + /** * Enable HIP-3 DEX abstraction. * @@ -2632,6 +2672,11 @@ export { type ExchangeSingleWalletConfig, } from "./_methods/_base/mod.ts"; +export type { + ActivateOutcomeDeployerOptions, + ActivateOutcomeDeployerParameters, + ActivateOutcomeDeployerSuccessResponse, +} from "./_methods/activateOutcomeDeployer.ts"; export type { AgentEnableDexAbstractionOptions, AgentEnableDexAbstractionSuccessResponse, diff --git a/src/api/exchange/mod.ts b/src/api/exchange/mod.ts index 5c0cbc4b..0368897f 100644 --- a/src/api/exchange/mod.ts +++ b/src/api/exchange/mod.ts @@ -50,6 +50,7 @@ export { export { cloidFromInt } from "../_schemas.ts"; +export * from "./_methods/activateOutcomeDeployer.ts"; export * from "./_methods/agentEnableDexAbstraction.ts"; export * from "./_methods/agentSendAsset.ts"; export * from "./_methods/agentSetAbstraction.ts"; diff --git a/src/api/info/_methods/marginTable.ts b/src/api/info/_methods/marginTable.ts index 98ceddc6..83d6e277 100644 --- a/src/api/info/_methods/marginTable.ts +++ b/src/api/info/_methods/marginTable.ts @@ -16,6 +16,8 @@ export const MarginTableRequest = /* @__PURE__ */ (() => { type: v.literal("marginTable"), /** Margin requirements table. */ id: UnsignedInteger, + /** DEX name (empty string for main dex). */ + dex: v.optional(v.string()), }); })(); diff --git a/src/api/info/_methods/outcomeTemplates.ts b/src/api/info/_methods/outcomeTemplates.ts new file mode 100644 index 00000000..110a0fad --- /dev/null +++ b/src/api/info/_methods/outcomeTemplates.ts @@ -0,0 +1,97 @@ +import * as v from "valibot"; + +// ============================================================ +// API Schemas +// ============================================================ + +/** + * Request outcome templates. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#read-api + */ +export const OutcomeTemplatesRequest = /* @__PURE__ */ (() => { + return v.object({ + /** Type of request. */ + type: v.literal("outcomeTemplates"), + }); +})(); +export type OutcomeTemplatesRequest = v.InferOutput; + +/** + * Array of templates that outcome deployers instantiate. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#read-api + */ +export type OutcomeTemplatesResponse = { + /** Template identifier. */ + id: string; + /** Role of the template. */ + role: + | { + /** Deploys a single outcome. */ + standaloneOutcome: { + /** Names of the Yes and No sides. */ + sideNames: [string, string]; + }; + } + | { + /** Deploys an outcome under an existing question template. */ + questionOutcome: { + /** Identifier of the parent question template. */ + parent: string; + }; + } + /** Deploys a question. */ + | "question"; + /** Display name containing `{keyword}` placeholders. */ + name: string; + /** Description containing `{keyword}` placeholders. */ + description: string; + /** + * Keywords of the template and the value format of each: + * - `dateTime` = `%Y%m%d-%H%M`, within the next year; e.g. `20260712-1830`. + * - `date` = `YYYYMMDD` (end of day), within the next year; e.g. `20260712`. + * - `string` = free text. + * - `hlPerp` = coin name of an existing perp; e.g. `ABC` or `test:ABC`. + * - `uDecimal` = unsigned decimal. + * - `uInt` = unsigned integer. + * - `shortString` = short free text. + * + * Note: `uDecimal`, `uInt`, and `shortString` are served by testnet but absent from the upstream reference schema. + */ + keywords: [string, "dateTime" | "date" | "string" | "hlPerp" | "uDecimal" | "uInt" | "shortString"][]; +}[]; + +// ============================================================ +// Execution Logic +// ============================================================ + +import { parse } from "../../../_base.ts"; +import type { InfoConfig } from "./_base/mod.ts"; + +/** + * Request outcome templates. + * + * @param config General configuration for Info API requests. + * @param signal {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal} to cancel the request. + * @return Array of templates that outcome deployers instantiate. + * + * @throws {ValidationError} When the request parameters fail validation (before sending). + * @throws {TransportError} When the transport layer throws an error. + * + * @example + * ```ts + * import { HttpTransport } from "@bloxwap/hyperliquid"; + * import { outcomeTemplates } from "@bloxwap/hyperliquid/api/info"; + * + * const transport = new HttpTransport(); // or `WebSocketTransport` + * + * const data = await outcomeTemplates({ transport }); + * ``` + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#read-api + */ +export function outcomeTemplates(config: InfoConfig, signal?: AbortSignal): Promise { + const request = parse(OutcomeTemplatesRequest, { + type: "outcomeTemplates", + }); + return config.transport.request("info", request, signal); +} diff --git a/src/api/info/_methods/usdcRouting.ts b/src/api/info/_methods/usdcRouting.ts new file mode 100644 index 00000000..0e4d05f5 --- /dev/null +++ b/src/api/info/_methods/usdcRouting.ts @@ -0,0 +1,72 @@ +import * as v from "valibot"; + +// ============================================================ +// API Schemas +// ============================================================ + +/** + * Request USDC transfer routing. + * @see null + */ +export const UsdcRoutingRequest = /* @__PURE__ */ (() => { + return v.object({ + /** Type of request. */ + type: v.literal("usdcRouting"), + }); +})(); +export type UsdcRoutingRequest = v.InferOutput; + +/** + * Routes currently used to move USDC in and out of the platform. + * @see null + */ +export type UsdcRoutingResponse = { + /** + * Route used for deposits: + * - `"bridge"`: Hyperliquid USDC bridge. + * - `"cctp"`: Circle Cross-Chain Transfer Protocol. + */ + depositRoute: "bridge" | "cctp"; + /** + * Route used for withdrawals: + * - `"bridge"`: Hyperliquid USDC bridge. + * - `"cctp"`: Circle Cross-Chain Transfer Protocol. + */ + withdrawalRoute: "bridge" | "cctp"; +}; + +// ============================================================ +// Execution Logic +// ============================================================ + +import { parse } from "../../../_base.ts"; +import type { InfoConfig } from "./_base/mod.ts"; + +/** + * Request USDC transfer routing. + * + * @param config General configuration for Info API requests. + * @param signal {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal} to cancel the request. + * @return Routes currently used to move USDC in and out of the platform. + * + * @throws {ValidationError} When the request parameters fail validation (before sending). + * @throws {TransportError} When the transport layer throws an error. + * + * @example + * ```ts + * import { HttpTransport } from "@bloxwap/hyperliquid"; + * import { usdcRouting } from "@bloxwap/hyperliquid/api/info"; + * + * const transport = new HttpTransport(); // or `WebSocketTransport` + * + * const data = await usdcRouting({ transport }); + * ``` + * + * @see null + */ +export function usdcRouting(config: InfoConfig, signal?: AbortSignal): Promise { + const request = parse(UsdcRoutingRequest, { + type: "usdcRouting", + }); + return config.transport.request("info", request, signal); +} diff --git a/src/api/info/_methods/validatorL1Votes.ts b/src/api/info/_methods/validatorL1Votes.ts index c96926be..c4d3113c 100644 --- a/src/api/info/_methods/validatorL1Votes.ts +++ b/src/api/info/_methods/validatorL1Votes.ts @@ -66,6 +66,31 @@ export type ValidatorL1VotesResponse = { namedOutcomes: [string, string][]; }; } + | { + /** Register an outcome template. */ + registerTemplate: { + /** Template identifier. */ + id: string; + /** Role of the template. */ + role: { + /** Deploys a single outcome. */ + standaloneOutcome: { + /** Names of the Yes and No sides. */ + sideNames: [string, string]; + }; + }; + /** Name and description containing `{keyword}` placeholders. */ + nameAndDescription: [string, string]; + /** + * Keywords of the template and the value format of each: + * - `dateTime` = `%Y%m%d-%H%M`, within the next year; e.g. `20260712-1830`. + * - `date` = `YYYYMMDD` (end of day), within the next year; e.g. `20260712`. + * - `string` = free text. + * - `hlPerp` = coin name of an existing perp; e.g. `ABC` or `test:ABC`. + */ + keywordToHint: [string, "dateTime" | "date" | "string" | "hlPerp"][]; + }; + } | { /** Settle an outcome. */ settleOutcome: { @@ -101,6 +126,31 @@ export type ValidatorL1VotesResponse = { ], ][]; }; + } + | { + /** Settle all remaining named outcomes of a question. */ + settleQuestion2: { + /** Question identifier. */ + question: number; + /** Settlement of each remaining active named outcome. */ + outcomeSettlements: { + /** Outcome identifier. */ + outcome: number; + /** + * Payout fraction of the Yes side (between 0 and 1). + * @pattern ^[0-9]+(\.[0-9]+)?$ + */ + settleFraction: string; + /** Settlement details. */ + details: string; + /** Name and description of the outcome being settled. */ + nameAndDescription: [string, string]; + /** Names of the Yes and No sides of the outcome being settled. */ + sideNames: [string, string]; + }[]; + /** Name and description of the question being settled. */ + nameAndDescription: [string, string]; + }; }; } | { diff --git a/src/api/info/client.ts b/src/api/info/client.ts index 19108723..a5ca8920 100644 --- a/src/api/info/client.ts +++ b/src/api/info/client.ts @@ -102,6 +102,7 @@ import { import { openOrders, type OpenOrdersParameters, type OpenOrdersResponse } from "./_methods/openOrders.ts"; import { orderStatus, type OrderStatusParameters, type OrderStatusResponse } from "./_methods/orderStatus.ts"; import { outcomeMeta, type OutcomeMetaResponse } from "./_methods/outcomeMeta.ts"; +import { outcomeTemplates, type OutcomeTemplatesResponse } from "./_methods/outcomeTemplates.ts"; import { perpAnnotation, type PerpAnnotationParameters, @@ -152,6 +153,7 @@ import { subAccounts, type SubAccountsParameters, type SubAccountsResponse } fro import { subAccounts2, type SubAccounts2Parameters, type SubAccounts2Response } from "./_methods/subAccounts2.ts"; import { tokenDetails, type TokenDetailsParameters, type TokenDetailsResponse } from "./_methods/tokenDetails.ts"; import { twapHistory, type TwapHistoryParameters, type TwapHistoryResponse } from "./_methods/twapHistory.ts"; +import { usdcRouting, type UsdcRoutingResponse } from "./_methods/usdcRouting.ts"; import { userAbstraction, type UserAbstractionParameters, @@ -1222,6 +1224,31 @@ export class InfoClient { return outcomeMeta(this.config_, signal); } + /** + * Request outcome templates. + * + * @param signal {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal} to cancel the request. + * @return Array of templates that outcome deployers instantiate. + * + * @throws {ValidationError} When the request parameters fail validation (before sending). + * @throws {TransportError} When the transport layer throws an error. + * + * @example + * ```ts + * import * as hl from "@bloxwap/hyperliquid"; + * + * const transport = new hl.HttpTransport(); // or `WebSocketTransport` + * const client = new hl.InfoClient({ transport }); + * + * const data = await client.outcomeTemplates(); + * ``` + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#read-api + */ + outcomeTemplates(signal?: AbortSignal): Promise { + return outcomeTemplates(this.config_, signal); + } + /** * Request perp annotation. * @@ -1825,6 +1852,31 @@ export class InfoClient { return twapHistory(this.config_, params, signal); } + /** + * Request USDC transfer routing. + * + * @param signal {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal} to cancel the request. + * @return Routes currently used to move USDC in and out of the platform. + * + * @throws {ValidationError} When the request parameters fail validation (before sending). + * @throws {TransportError} When the transport layer throws an error. + * + * @example + * ```ts + * import * as hl from "@bloxwap/hyperliquid"; + * + * const transport = new hl.HttpTransport(); // or `WebSocketTransport` + * const client = new hl.InfoClient({ transport }); + * + * const data = await client.usdcRouting(); + * ``` + * + * @see null + */ + usdcRouting(signal?: AbortSignal): Promise { + return usdcRouting(this.config_, signal); + } + /** * Request user abstraction state. * @@ -2518,6 +2570,7 @@ export type { MetaAndAssetCtxsParameters, MetaAndAssetCtxsResponse } from "./_me export type { OpenOrdersParameters, OpenOrdersResponse } from "./_methods/openOrders.ts"; export type { OrderStatusParameters, OrderStatusResponse } from "./_methods/orderStatus.ts"; export type { OutcomeMetaResponse } from "./_methods/outcomeMeta.ts"; +export type { OutcomeTemplatesResponse } from "./_methods/outcomeTemplates.ts"; export type { PerpAnnotationParameters, PerpAnnotationResponse } from "./_methods/perpAnnotation.ts"; export type { PerpCategoriesResponse } from "./_methods/perpCategories.ts"; export type { PerpConciseAnnotationsResponse } from "./_methods/perpConciseAnnotations.ts"; @@ -2547,6 +2600,7 @@ export type { SubAccountsParameters, SubAccountsResponse } from "./_methods/subA export type { SubAccounts2Parameters, SubAccounts2Response } from "./_methods/subAccounts2.ts"; export type { TokenDetailsParameters, TokenDetailsResponse } from "./_methods/tokenDetails.ts"; export type { TwapHistoryParameters, TwapHistoryResponse } from "./_methods/twapHistory.ts"; +export type { UsdcRoutingResponse } from "./_methods/usdcRouting.ts"; export type { UserAbstractionParameters, UserAbstractionResponse } from "./_methods/userAbstraction.ts"; export type { UserBorrowLendInterestParameters, diff --git a/src/api/info/mod.ts b/src/api/info/mod.ts index 53af5e86..868ffe97 100644 --- a/src/api/info/mod.ts +++ b/src/api/info/mod.ts @@ -60,6 +60,7 @@ export * from "./_methods/metaAndAssetCtxs.ts"; export * from "./_methods/openOrders.ts"; export * from "./_methods/orderStatus.ts"; export * from "./_methods/outcomeMeta.ts"; +export * from "./_methods/outcomeTemplates.ts"; export * from "./_methods/perpAnnotation.ts"; export * from "./_methods/perpCategories.ts"; export * from "./_methods/perpConciseAnnotations.ts"; @@ -83,6 +84,7 @@ export * from "./_methods/subAccounts.ts"; export * from "./_methods/subAccounts2.ts"; export * from "./_methods/tokenDetails.ts"; export * from "./_methods/twapHistory.ts"; +export * from "./_methods/usdcRouting.ts"; export * from "./_methods/userAbstraction.ts"; export * from "./_methods/userBorrowLendInterest.ts"; export * from "./_methods/userDexAbstraction.ts"; diff --git a/tests/api/exchange/_client.test.ts b/tests/api/exchange/_client.test.ts index 10dfcc68..de6df410 100644 --- a/tests/api/exchange/_client.test.ts +++ b/tests/api/exchange/_client.test.ts @@ -68,6 +68,11 @@ interface MethodCase { // ============================================================ const METHOD_CASES: Record = { + activateOutcomeDeployer: { + run: (c) => c.activateOutcomeDeployer({ isDeactivate: false }), + action: { type: "activateOutcomeDeployer", isDeactivate: false }, + invalid: (c) => c.activateOutcomeDeployer({} as never), + }, agentEnableDexAbstraction: { run: (c) => c.agentEnableDexAbstraction(), action: { type: "agentEnableDexAbstraction" }, diff --git a/tests/api/exchange/activateOutcomeDeployer.test.ts b/tests/api/exchange/activateOutcomeDeployer.test.ts new file mode 100644 index 00000000..77d819de --- /dev/null +++ b/tests/api/exchange/activateOutcomeDeployer.test.ts @@ -0,0 +1,83 @@ +import { ApiRequestError } from "@bloxwap/hyperliquid"; +import { + type ActivateOutcomeDeployerParameters, + ActivateOutcomeDeployerRequest, + activateOutcomeDeployer, +} from "@bloxwap/hyperliquid/api/exchange"; +import * as v from "valibot"; +import { describe, test } from "bun:test"; +import { assertEquals, assertRejects } from "@jsr/std__assert"; +import { schemaCoverage } from "../_utils/schemaCoverage.ts"; +import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; +import { FIXED_NONCE, recordingTransport, singleWalletConfig } from "./_mockTransport.ts"; +import { runTest } from "./_t.ts"; + +const paramsSchema = valibotToJsonSchema( + v.omit(v.object(ActivateOutcomeDeployerRequest.entries.action.entries), ["type"]), +); + +runTest({ + name: "activateOutcomeDeployer", + codeTestFn: async (_t, exchClient) => { + const params: ActivateOutcomeDeployerParameters[] = [ + // activate + { isDeactivate: false }, + // deactivate + { isDeactivate: true }, + ]; + + await assertRejects( + async () => { + await exchClient.activateOutcomeDeployer(params[0]); + }, + ApiRequestError, + "Insufficient stake", + ); + await assertRejects( + async () => { + await exchClient.activateOutcomeDeployer(params[1]); + }, + ApiRequestError, + "Error deploying outcome: not an outcome deployer", + ); + + schemaCoverage(paramsSchema, params); + }, +}); + +// ============================================================ +// Offline: wire payload and signature for both activation directions +// ============================================================ + +describe("activateOutcomeDeployer (offline)", () => { + test("posts the exact action with a deterministic signature (fixed nonce and wallet)", async () => { + for (const [params, expectedSignature] of [ + [ + { isDeactivate: false }, + { + r: "0x301138913ffc553e9c0aed05982601cfaca2c0ea1bdf253695cb9dbfb0797a81", + s: "0x102f87b005f398872b39adb47bd9688768784896d2e2f8fdb7e68f85373870c8", + v: 28, + }, + ], + [ + { isDeactivate: true }, + { + r: "0x14f0821a33e2cfbbada1a27891eb956ced25bee71ff7f004438fec57c1491e66", + s: "0x55826b27bb1fb76223b21e3908ad207f03a713d3dcd2705761fe1ed79232c96b", + v: 28, + }, + ], + ] as const) { + const { calls, transport } = recordingTransport(); + + await activateOutcomeDeployer(singleWalletConfig(transport), params); + + assertEquals(calls.length, 1); + assertEquals(calls[0].endpoint, "exchange"); + assertEquals(calls[0].payload.action, { type: "activateOutcomeDeployer", ...params }); + assertEquals(calls[0].payload.nonce, FIXED_NONCE); + assertEquals(calls[0].payload.signature, expectedSignature); + } + }); +}); diff --git a/tests/api/exchange/reserveRequestWeight.test.ts b/tests/api/exchange/reserveRequestWeight.test.ts index e18a8080..fbe9c7e1 100644 --- a/tests/api/exchange/reserveRequestWeight.test.ts +++ b/tests/api/exchange/reserveRequestWeight.test.ts @@ -1,8 +1,12 @@ import { type ReserveRequestWeightParameters, ReserveRequestWeightRequest } from "@bloxwap/hyperliquid/api/exchange"; +import { reserveRequestWeight } from "@bloxwap/hyperliquid/api/exchange"; import * as v from "valibot"; +import { describe, test } from "bun:test"; +import { assertEquals } from "@jsr/std__assert"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; +import { FIXED_NONCE, recordingTransport, singleWalletConfig } from "./_mockTransport.ts"; import { runTest } from "./_t.ts"; const sourceFile = new URL("../../../src/api/exchange/_methods/reserveRequestWeight.ts", import.meta.url).pathname; @@ -14,7 +18,12 @@ const paramsSchema = valibotToJsonSchema( runTest({ name: "reserveRequestWeight", codeTestFn: async (_t, exchClient) => { - const params: ReserveRequestWeightParameters[] = [{ weight: 1 }]; + const params: ReserveRequestWeightParameters[] = [ + // no destination + { weight: 1 }, + // destination + { weight: 1, destination: "0xe019d6167e7e324aed003d94098496b6d986ab05" }, + ]; const data = await Promise.all(params.map((p) => exchClient.reserveRequestWeight(p))); @@ -22,3 +31,48 @@ runTest({ schemaCoverage(responseSchema, data); }, }); + +// ============================================================ +// Offline: `destination` flows into the L1 action hash when set and is skipped when UNSET +// ============================================================ + +describe("reserveRequestWeight (offline)", () => { + test("omits destination from the action and hash when unset", async () => { + const { calls, transport } = recordingTransport(); + + await reserveRequestWeight(singleWalletConfig(transport), { weight: 1 }); + + assertEquals(calls.length, 1); + assertEquals(calls[0].payload.action, { type: "reserveRequestWeight", weight: 1 }); + assertEquals("destination" in calls[0].payload.action, false); + assertEquals(calls[0].payload.nonce, FIXED_NONCE); + // Pinned signature over { type, weight } — the hash preimage carries no destination entry. + assertEquals(calls[0].payload.signature, { + r: "0xa945457c63f9bb786559d7408de7c0c0eca6c6cd59eae60bab1a07152041bc77", + s: "0x156325bb4d59b6083d1abc6b50024ddba3f5eab93f9da323225f75df9e941917", + v: 28, + }); + }); + + test("includes destination in the action and hash when set", async () => { + const { calls, transport } = recordingTransport(); + + await reserveRequestWeight(singleWalletConfig(transport), { + weight: 1, + destination: "0xe019d6167e7e324aed003d94098496b6d986ab05", + }); + + assertEquals(calls.length, 1); + assertEquals(calls[0].payload.action, { + type: "reserveRequestWeight", + weight: 1, + destination: "0xe019d6167e7e324aed003d94098496b6d986ab05", + }); + // Different signature from the unset case: destination changed the L1 action hash. + assertEquals(calls[0].payload.signature, { + r: "0xba58246509cee6d97b00063e1f13def62f8fb3ea496ca32bc9259f2517783045", + s: "0x519e413183bb2adaf70e150803e32f9093f461a08645f8a7af6e39ad960a604d", + v: 28, + }); + }); +}); diff --git a/tests/api/exchange/spotDeploy.test.ts b/tests/api/exchange/spotDeploy.test.ts index a0a80aa9..f668470b 100644 --- a/tests/api/exchange/spotDeploy.test.ts +++ b/tests/api/exchange/spotDeploy.test.ts @@ -121,6 +121,70 @@ runTest({ evmExtraWeiDecimals: 0, }, }, + { + outcome: { + registerStandaloneOutcomeFromTemplate: { + id: "binaryPrice", + keywordToValue: [ + ["perp", "BTC"], + ["threshold", "1000000"], + ["time", "20260901-0600"], + ], + }, + }, + }, + { + outcome: { + registerQuestionFromTemplate: { + questionTemplateInstance: { + id: "binaryPrice", + keywordToValue: [ + ["perp", "BTC"], + ["threshold", "1000000"], + ["time", "20260901-0600"], + ], + }, + namedOutcomeTemplateInstances: [ + { + id: "binaryPrice", + keywordToValue: [ + ["perp", "BTC"], + ["threshold", "1000000"], + ["time", "20260901-0600"], + ], + }, + ], + }, + }, + }, + { + outcome: { + settleOutcome: { + outcome: 0, + settleFraction: "1", + details: "", + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + sideNames: ["Yes", "No"], + }, + }, + }, + { + outcome: { + settleQuestion2: { + question: 0, + outcomeSettlements: [ + { + outcome: 0, + settleFraction: "1", + details: "", + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + sideNames: ["Yes", "No"], + }, + ], + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + }, + }, + }, ]; await Promise.all( @@ -162,4 +226,123 @@ describe("spotDeploy (offline)", () => { ], ); }); + + test("outcome sub-actions are accepted and posted", async () => { + const wallet = privateKeyToAccount("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + const payloads: { action: Record }[] = []; + const transport: IRequestTransport = { + isTestnet: true, + request(_endpoint: "info" | "exchange", payload: unknown): Promise { + payloads.push(payload as { action: Record }); + return Promise.resolve({ status: "ok", response: { type: "default" } } as T); + }, + }; + + const templateInstance: { id: string; keywordToValue: [string, string][] } = { + id: "binaryPrice", + keywordToValue: [ + ["perp", "BTC"], + ["threshold", "1000000"], + ["time", "20260901-0600"], + ], + }; + + await spotDeploy( + { transport, wallet }, + { + outcome: { registerStandaloneOutcomeFromTemplate: templateInstance }, + }, + ); + await spotDeploy( + { transport, wallet }, + { + outcome: { + registerQuestionFromTemplate: { + questionTemplateInstance: templateInstance, + namedOutcomeTemplateInstances: [templateInstance], + }, + }, + }, + ); + await spotDeploy( + { transport, wallet }, + { + outcome: { + settleOutcome: { + outcome: 0, + settleFraction: "1", + details: "", + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + sideNames: ["Yes", "No"], + }, + }, + }, + ); + await spotDeploy( + { transport, wallet }, + { + outcome: { + settleQuestion2: { + question: 0, + outcomeSettlements: [ + { + outcome: 0, + settleFraction: "1", + details: "", + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + sideNames: ["Yes", "No"], + }, + ], + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + }, + }, + }, + ); + + assertEquals( + payloads.map((p) => p.action), + [ + { type: "spotDeploy", outcome: { registerStandaloneOutcomeFromTemplate: templateInstance } }, + { + type: "spotDeploy", + outcome: { + registerQuestionFromTemplate: { + questionTemplateInstance: templateInstance, + namedOutcomeTemplateInstances: [templateInstance], + }, + }, + }, + { + type: "spotDeploy", + outcome: { + settleOutcome: { + outcome: 0, + settleFraction: "1", + details: "", + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + sideNames: ["Yes", "No"], + }, + }, + }, + { + type: "spotDeploy", + outcome: { + settleQuestion2: { + question: 0, + outcomeSettlements: [ + { + outcome: 0, + settleFraction: "1", + details: "", + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + sideNames: ["Yes", "No"], + }, + ], + nameAndDescription: ["template:binaryPrice", "perp:BTC"], + }, + }, + }, + ], + ); + }); }); diff --git a/tests/api/exchange/twapOrder.test.ts b/tests/api/exchange/twapOrder.test.ts index 43be11c6..ae3af060 100644 --- a/tests/api/exchange/twapOrder.test.ts +++ b/tests/api/exchange/twapOrder.test.ts @@ -1,9 +1,12 @@ -import { type TwapOrderParameters, TwapOrderRequest } from "@bloxwap/hyperliquid/api/exchange"; -import { formatSize } from "@bloxwap/hyperliquid/utils"; +import { type TwapOrderParameters, TwapOrderRequest, twapOrder } from "@bloxwap/hyperliquid/api/exchange"; +import { formatPrice, formatSize } from "@bloxwap/hyperliquid/utils"; import * as v from "valibot"; +import { describe, test } from "bun:test"; +import { assertEquals } from "@jsr/std__assert"; import { schemaCoverage } from "../_utils/schemaCoverage.ts"; import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; +import { FIXED_NONCE, recordingTransport, singleWalletConfig } from "./_mockTransport.ts"; import { allMids, runTest, symbolConverter, topUpPerp } from "./_t.ts"; const sourceFile = new URL("../../../src/api/exchange/_methods/twapOrder.ts", import.meta.url).pathname; @@ -20,12 +23,20 @@ runTest({ const midPx = allMids["SOL"]; const sz = formatSize(60 / parseFloat(midPx), szDecimals); + const pxUp = formatPrice(parseFloat(midPx) * 1.5, szDecimals); + const pxDown = formatPrice(parseFloat(midPx) * 0.5, szDecimals); const params: TwapOrderParameters[] = [ // b=true | r=false | t=false { twap: { a: id, b: true, s: sz, r: false, m: 5, t: false } }, // b=false | t=true { twap: { a: id, b: false, s: sz, r: false, m: 5, t: true } }, + // details.t | a=true + { twap: { a: id, b: true, s: sz, r: false, m: 5, t: false }, details: { t: { p: pxUp, a: true }, s: null } }, + // details.t | a=false + { twap: { a: id, b: false, s: sz, r: false, m: 5, t: false }, details: { t: { p: pxDown, a: false }, s: null } }, + // details.s + { twap: { a: id, b: true, s: sz, r: false, m: 5, t: false }, details: { t: null, s: pxUp } }, ]; const data = await Promise.all(params.map((p) => exchClient.twapOrder(p))); @@ -36,3 +47,56 @@ runTest({ schemaCoverage(responseSchema, data); }, }); + +// ============================================================ +// Offline: `details` flows into the L1 action hash when set and is skipped when UNSET +// ============================================================ + +describe("twapOrder (offline)", () => { + const twap = { a: 0, b: true, s: "0.1", r: false, m: 5, t: false } as const; + + test("omits details from the action and hash when unset", async () => { + const { calls, transport } = recordingTransport(); + + await twapOrder(singleWalletConfig(transport), { twap }); + + assertEquals(calls.length, 1); + assertEquals(calls[0].payload.action, { type: "twapOrder", twap }); + assertEquals("details" in calls[0].payload.action, false); + assertEquals(calls[0].payload.nonce, FIXED_NONCE); + // Pinned signature over { type, twap } — the hash preimage carries no details entry. + assertEquals(calls[0].payload.signature, { + r: "0x5d55de850a87b5e9d2888b127f7c7affb93741a29f9fd1ccaa16cd43584acace", + s: "0x4c06e405d2f84173703a418a2a783139e063df11230c09ceae8a15e4edc43080", + v: 28, + }); + }); + + test("includes trigger details in the action and hash when set", async () => { + const { calls, transport } = recordingTransport(); + const details = { t: { p: "100", a: true }, s: null } as const; + + await twapOrder(singleWalletConfig(transport), { twap, details }); + + assertEquals(calls[0].payload.action, { type: "twapOrder", twap, details }); + assertEquals(calls[0].payload.signature, { + r: "0xb6cd37d718485c73996638967dd7350d449ec75046c0155265fb815ca8d40929", + s: "0x268a890c0ada877f47cfe0317013bb78fd1a65713b1b6e814eaf0724ead38f9b", + v: 27, + }); + }); + + test("includes stop details in the action and hash when set", async () => { + const { calls, transport } = recordingTransport(); + const details = { t: null, s: "100" } as const; + + await twapOrder(singleWalletConfig(transport), { twap, details }); + + assertEquals(calls[0].payload.action, { type: "twapOrder", twap, details }); + assertEquals(calls[0].payload.signature, { + r: "0x9277f680d164203adb56f3644c1d7e997f24b4174b7b9c3d3eccfdc60e7c5d90", + s: "0x21f66746dc7f89f81bcf2720d38338e0d35a02eec6156bb19c9673150d5bcd88", + v: 28, + }); + }); +}); diff --git a/tests/api/info/marginTable.test.ts b/tests/api/info/marginTable.test.ts index 14501df3..f58dabd2 100644 --- a/tests/api/info/marginTable.test.ts +++ b/tests/api/info/marginTable.test.ts @@ -13,7 +13,11 @@ const paramsSchema = valibotToJsonSchema(v.omit(MarginTableRequest, ["type"])); runTest({ name: "marginTable", codeTestFn: async (_t, client) => { - const params: MarginTableParameters[] = [{ id: 1 }]; + const params: MarginTableParameters[] = [ + { id: 1 }, + { id: 51, dex: "" }, // main dex + { id: 51, dex: "flx" }, // other dex + ]; const data = await Promise.all(params.map((p) => client.marginTable(p))); @@ -30,6 +34,6 @@ runOfflineMethodTests({ name: "marginTable", method: marginTable, signature: "params", - cases: [{ params: { id: 1 } }], - invalidParams: [{ id: -1 }, { id: "abc" }, {}], + cases: [{ params: { id: 1 } }, { params: { id: 51, dex: "" } }, { params: { id: 51, dex: "flx" } }], + invalidParams: [{ id: -1 }, { id: "abc" }, {}, { id: 1, dex: 5 }], }); diff --git a/tests/api/info/outcomeTemplates.test.ts b/tests/api/info/outcomeTemplates.test.ts new file mode 100644 index 00000000..3b1c0e11 --- /dev/null +++ b/tests/api/info/outcomeTemplates.test.ts @@ -0,0 +1,27 @@ +import { outcomeTemplates } from "@bloxwap/hyperliquid/api/info"; +import { runOfflineMethodTests } from "./_offlineMethodTests.ts"; +import { schemaCoverage } from "../_utils/schemaCoverage.ts"; +import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; +import { runTest } from "./_t.ts"; + +const sourceFile = new URL("../../../src/api/info/_methods/outcomeTemplates.ts", import.meta.url).pathname; +const responseSchema = typeToJsonSchema(sourceFile, "OutcomeTemplatesResponse"); + +runTest({ + name: "outcomeTemplates", + codeTestFn: async (_t, client) => { + const data = await Promise.all([client.outcomeTemplates()]); + + schemaCoverage(responseSchema, data, ["#/items/properties/keywords/items/items/1/enum/1"]); + }, +}); + +// ============================================================ +// Offline: request construction, passthrough, and InfoClient wrapper +// ============================================================ + +runOfflineMethodTests({ + name: "outcomeTemplates", + method: outcomeTemplates, + signature: "none", +}); diff --git a/tests/api/info/usdcRouting.test.ts b/tests/api/info/usdcRouting.test.ts new file mode 100644 index 00000000..1dccf1b7 --- /dev/null +++ b/tests/api/info/usdcRouting.test.ts @@ -0,0 +1,27 @@ +import { usdcRouting } from "@bloxwap/hyperliquid/api/info"; +import { runOfflineMethodTests } from "./_offlineMethodTests.ts"; +import { schemaCoverage } from "../_utils/schemaCoverage.ts"; +import { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; +import { runTest } from "./_t.ts"; + +const sourceFile = new URL("../../../src/api/info/_methods/usdcRouting.ts", import.meta.url).pathname; +const responseSchema = typeToJsonSchema(sourceFile, "UsdcRoutingResponse"); + +runTest({ + name: "usdcRouting", + codeTestFn: async (_t, client) => { + const data = await Promise.all([client.usdcRouting()]); + + schemaCoverage(responseSchema, data, ["#/properties/depositRoute/enum/0", "#/properties/withdrawalRoute/enum/0"]); + }, +}); + +// ============================================================ +// Offline: request construction, passthrough, and InfoClient wrapper +// ============================================================ + +runOfflineMethodTests({ + name: "usdcRouting", + method: usdcRouting, + signature: "none", +}); diff --git a/tests/api/info/validatorL1Votes.test.ts b/tests/api/info/validatorL1Votes.test.ts index 63c9647e..fc73427a 100644 --- a/tests/api/info/validatorL1Votes.test.ts +++ b/tests/api/info/validatorL1Votes.test.ts @@ -20,6 +20,8 @@ runTest({ "#/items/properties/action/anyOf/2/properties/O/anyOf/1", "#/items/properties/action/anyOf/2/properties/O/anyOf/2", "#/items/properties/action/anyOf/2/properties/O/anyOf/3", + "#/items/properties/action/anyOf/2/properties/O/anyOf/4", + "#/items/properties/action/anyOf/2/properties/O/anyOf/5", "#/items/properties/action/anyOf/3", ]); }, From 50d1e7586e1869e93bcfb11fc906b83fd743cf76 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 24 Aug 2026 08:16:13 -0700 Subject: [PATCH 3/7] feat(transport): opt-in 429 retry and full exchange weight billing (#107, #108) - retryOnRateLimit: honor Retry-After with jitter, exponential fallback, bounded attempts (#107) - exchangeWeight bills the longest array at any depth, multi-sig unwrapped; settles #49 semantics (#108) --- src/transport/http/mod.ts | 285 ++++++++++++++++++++------- tests/transport/http/mod.test.ts | 323 +++++++++++++++++++++++++++++++ 2 files changed, 536 insertions(+), 72 deletions(-) diff --git a/src/transport/http/mod.ts b/src/transport/http/mod.ts index 67a43061..934f9979 100644 --- a/src/transport/http/mod.ts +++ b/src/transport/http/mod.ts @@ -10,6 +10,7 @@ * rateLimit? ◄─ token bucket wait for the request's weight (opt-in; abort-aware; disabled by default) * controller ◄─ timeout / user signal / fetchOptions.signal (none allocated when all are absent) * └─► fetch ┬─► non-OK or non-JSON body ─► HttpRequestError; 429 ─► HttpRateLimitError + * │ retryOnRateLimit? ◄─ wait Retry-After (+jitter) / jittered backoff, re-fetch * └─► parse JSON ┬─► 200-OK `{ type: "error" }` envelope ─► HttpRequestError * └─► T * catch: classify by reference ─► finally: cancel timer, detach @@ -62,9 +63,11 @@ export interface HttpTransportOptions { * * When set, every request acquires its weight from a token bucket before sending and WAITS * (async) while the bucket is empty, instead of failing with HTTP 429 after the fact. Weights - * follow the server rules: `1 + floor(batchLength / 40)` for exchange batches — the batch - * length read from the action's `orders`/`cancels`/`modifies` array, unwrapping multi-sig - * actions — the documented per-`type` weight for info requests (2/20/60), and 40 for explorer + * follow the server rules: `1 + floor(batchLength / 40)` for exchange requests — the + * batch length being the longest array in the action (`orders`/`cancels`/`modifies` for the + * documented batch actions; deployer arrays like `spotDeploy`/`perpDeploy` setter lists count + * too, so the client never under-bills), unwrapping multi-sig actions — the documented + * per-`type` weight for info requests (2/20/60), and 40 for explorer * requests. Response-size surcharges (per 20/60 returned items, per block on `blockList`) are * debited after the response arrives, so later requests wait off the real cost. * @@ -81,6 +84,31 @@ export interface HttpTransportOptions { * ``` */ rateLimit?: HttpRateLimitOptions; + /** + * Opt-in automatic retry of requests the server answers with `429 Too Many Requests`. + * + * When set, a rate-limited attempt waits and retries instead of throwing + * {@linkcode HttpRateLimitError} right away: the wait honors the server's `Retry-After` (plus up + * to 1 s of jitter), falling back to full-jitter exponential backoff when the header is absent. + * Retries are bounded ({@linkcode HttpRateLimitRetryOptions.maxRetries}, default 3) and every + * single wait is capped ({@linkcode HttpRateLimitRetryOptions.maxDelayMs}, default 30 s) — a + * `Retry-After` beyond the cap surfaces the 429 rather than retrying sooner than the server + * allowed. The overall request timeout still spans every attempt and wait, and when + * {@linkcode HttpTransportOptions.rateLimit} is also enabled each retry debits the bucket again: + * the server bills attempts, not logical requests. + * + * `true` enables the defaults; an object overrides them. + * + * Default: `false` (a 429 throws {@linkcode HttpRateLimitError} on the first attempt) + * + * @example + * ```ts + * import { HttpTransport } from "@bloxwap/hyperliquid"; + * + * const transport = new HttpTransport({ retryOnRateLimit: { maxRetries: 5 } }); + * ``` + */ + retryOnRateLimit?: boolean | HttpRateLimitRetryOptions; /** * Custom API URL for `info` and `exchange` requests. * @@ -113,6 +141,25 @@ export interface HttpRateLimitOptions { refillPerMinute?: number; } +/** Configuration for the HTTP transport's opt-in 429 retry ({@linkcode HttpTransportOptions.retryOnRateLimit}). */ +export interface HttpRateLimitRetryOptions { + /** + * Maximum number of retries after the initial attempt, so at most `maxRetries + 1` requests go + * out for one call. + * + * Default: `3` + */ + maxRetries?: number; + /** + * Longest single retry wait in ms. A `Retry-After` asking for more than this surfaces the 429 + * instead of retrying sooner than the server allowed; the headerless exponential backoff is + * capped at this value too. + * + * Default: `30_000` + */ + maxDelayMs?: number; +} + /** Mainnet API URL. */ export const MAINNET_API_URL = "https://api.hyperliquid.xyz"; /** Testnet API URL. */ @@ -201,7 +248,8 @@ export class HttpRequestError extends TransportError { * Extends {@linkcode HttpRequestError}, so existing `instanceof HttpRequestError` checks keep * matching; catch this subclass specifically to back off instead of surfacing a failure. Hyperliquid * answers rate-limit violations with 429 and ultimately bans repeat offenders' IPs, so backing off - * on this error matters — or enable the transport's {@linkcode HttpTransportOptions.rateLimit} to + * on this error matters — enable {@linkcode HttpTransportOptions.retryOnRateLimit} to have the + * transport back off and retry automatically, and/or {@linkcode HttpTransportOptions.rateLimit} to * pace requests before they ever reach the limit. * * @example @@ -276,6 +324,8 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e fetchOptions: Omit; /** Opt-in token-bucket rate limiter; `null` keeps requests unthrottled (the default). */ private readonly _rateLimit: TokenBucketRateLimiter | null; + /** Opt-in 429 retry policy; `null` throws {@linkcode HttpRateLimitError} on the first 429 (the default). */ + private readonly _retryOnRateLimit: { maxRetries: number; maxDelayMs: number } | null; /** Shared request-timeout scheduler: at most one armed native timer, however many requests are in flight. */ private readonly _timeouts: abort.TimeoutWheel; /** Memoized endpoint URLs, keyed by base and endpoint; mutating `apiUrl`/`rpcUrl` simply misses the cache. */ @@ -292,6 +342,7 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e options?.rateLimit === undefined ? null : new TokenBucketRateLimiter(options.rateLimit.capacity ?? 1200, options.rateLimit.refillPerMinute ?? 1200); + this._retryOnRateLimit = normalizeRetryOnRateLimit(options?.retryOnRateLimit); this._timeouts = new abort.TimeoutWheel(); this._urlCache = new Map(); } @@ -355,11 +406,13 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e // bills attempts, and its own accounting of failures is undocumented — keeping the // debit is the conservative reading. const rateLimit = this._rateLimit; + // Hoisted for the retry loop below: a retried attempt debits the bucket again. + let weight = 0; if (rateLimit !== null) { // The parsed wire form — never the live payload — is the billing source, so // getters/proxies/toJSON cannot move the weight off what was actually sent. Explorer // requests are a flat 40 whatever the payload, so they skip the parse entirely. - const weight = endpoint === "explorer" ? 40 : requestWeight(endpoint, (snapshot = JSON.parse(body))); + weight = endpoint === "explorer" ? 40 : requestWeight(endpoint, (snapshot = JSON.parse(body))); await rateLimit.acquire(weight, controller?.signal); } @@ -388,59 +441,78 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e { signal: controller?.signal }, ); - // --- Send and validate ------------------------------------------------- - const response = await fetch(url, init); - if (!response.ok || !response.headers.get("Content-Type")?.includes("application/json")) { - const clone = response.clone(); - const text = await response.text().catch(() => undefined); // releases connection, clone stays readable - // 429 gets its own subclass so callers can back off programmatically. - const ErrorClass = clone.status === 429 ? HttpRateLimitError : HttpRequestError; - throw new ErrorClass({ - response: clone, - detail: text ? truncate(text) : undefined, - ...errorRequest(body, snapshot), - }); - } + // --- Send and validate, retrying 429s when opted in ---------------------- + // The timeout armed above spans the WHOLE attempt loop, retry waits included, so an + // opted-in retry can never outlive the caller's timeout; the wait itself races the + // request's signal, so caller aborts interrupt it too. + const retry = this._retryOnRateLimit; + for (let attempt = 0; ; attempt++) { + try { + const response = await fetch(url, init); + if (!response.ok || !response.headers.get("Content-Type")?.includes("application/json")) { + const clone = response.clone(); + const text = await response.text().catch(() => undefined); // releases connection, clone stays readable + // 429 gets its own subclass so callers can back off programmatically. + const ErrorClass = clone.status === 429 ? HttpRateLimitError : HttpRequestError; + throw new ErrorClass({ + response: clone, + detail: text ? truncate(text) : undefined, + ...errorRequest(body, snapshot), + }); + } - // --- Parse ------------------------------------------------------------- - // The try covers ONLY the parse itself: the envelope check below throws HttpRequestError, - // which this catch would otherwise rewrap as an "Invalid JSON response body". - const text = await response.text(); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (error) { - throw new HttpRequestError({ - response: recreateResponse(response, text), - detail: "Invalid JSON response body", - cause: error, - ...errorRequest(body, snapshot), - }); - } + // --- Parse --------------------------------------------------------- + // The try covers ONLY the parse itself: the envelope check below throws HttpRequestError, + // which this catch would otherwise rewrap as an "Invalid JSON response body". + const text = await response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new HttpRequestError({ + response: recreateResponse(response, text), + detail: "Invalid JSON response body", + cause: error, + ...errorRequest(body, snapshot), + }); + } - // Hyperliquid reports some failures inside a 200 OK: a top-level `{ type: "error", message }` - // envelope (the explorer/rpc failure shape). Surface it here with the server's own message, - // instead of handing the envelope to callers as data — where schema-validated methods would - // fail with a confusing ValidationError and unvalidated ones would return it as a "result". - // Exchange-level failures use a different envelope — `{ status: "err", response }`, handled - // at the API layer — so they still resolve here, as do array bodies and objects that merely - // nest a `type: "error"` somewhere below the top level. - if (isErrorEnvelope(parsed)) { - throw new HttpRequestError({ - response: recreateResponse(response, text), // the body stream is already consumed - detail: typeof parsed.message === "string" ? parsed.message : truncate(text), - ...errorRequest(body, snapshot), - }); - } + // Hyperliquid reports some failures inside a 200 OK: a top-level `{ type: "error", message }` + // envelope (the explorer/rpc failure shape). Surface it here with the server's own message, + // instead of handing the envelope to callers as data — where schema-validated methods would + // fail with a confusing ValidationError and unvalidated ones would return it as a "result". + // Exchange-level failures use a different envelope — `{ status: "err", response }`, handled + // at the API layer — so they still resolve here, as do array bodies and objects that merely + // nest a `type: "error"` somewhere below the top level. + if (isErrorEnvelope(parsed)) { + throw new HttpRequestError({ + response: recreateResponse(response, text), // the body stream is already consumed + detail: typeof parsed.message === "string" ? parsed.message : truncate(text), + ...errorRequest(body, snapshot), + }); + } - // Response-size surcharges can only be billed after the fact: debit the bucket so - // later requests wait off the real cost instead of the pre-request estimate. - if (rateLimit !== null && Array.isArray(parsed) && parsed.length > 0) { - snapshot ??= JSON.parse(body); // explorer skipped the pre-send parse (flat weight 40) - const surcharge = responseSurcharge(endpoint, snapshot, parsed); - if (surcharge > 0) rateLimit.charge(surcharge); + // Response-size surcharges can only be billed after the fact: debit the bucket so + // later requests wait off the real cost instead of the pre-request estimate. + if (rateLimit !== null && Array.isArray(parsed) && parsed.length > 0) { + snapshot ??= JSON.parse(body); // explorer skipped the pre-send parse (flat weight 40) + const surcharge = responseSurcharge(endpoint, snapshot, parsed); + if (surcharge > 0) rateLimit.charge(surcharge); + } + return parsed as T; + } catch (error) { + // Only a 429 is retried, and only while attempts remain; every other failure — + // a non-429 HttpRequestError included — propagates to the outer classifier as-is. + if (!(error instanceof HttpRateLimitError) || retry === null || attempt >= retry.maxRetries) throw error; + const delayMs = retryDelayMs(error.retryAfter, attempt, retry.maxDelayMs); + if (delayMs === undefined) throw error; // the asked wait exceeds the configured bound + // A retry is another billed attempt (the server bills attempts, not logical + // requests): debit the bucket WITHOUT waiting, so later requests pace off the real + // cost while this retry waits out the server's own delay rather than the refill. + if (rateLimit !== null) rateLimit.charge(weight); + await abort.race(sleep(delayMs), controller?.signal); + } } - return parsed as T; } catch (error) { if (error instanceof TransportError) throw error; if (timeout !== undefined && error === timeout.reason) { @@ -556,7 +628,8 @@ const INFO_SURCHARGE_PER_60_ITEMS: ReadonlySet = new Set(["candleSnapsho /** * Weight of a request under Hyperliquid's REST rate limits, billed before sending: the * documented per-`type` weight for info requests (2/20/60), 40 for explorer requests, and - * `1 + floor(batchLength / 40)` for exchange requests. Weights that depend on the response + * `1 + floor(batchLength / 40)` for exchange requests (see {@linkcode exchangeWeight} for what + * counts as the batch length). Weights that depend on the response * size cannot be known here — see {@linkcode responseSurcharge}. * * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits @@ -573,26 +646,45 @@ function requestWeight(endpoint: "info" | "exchange" | "explorer", payload: unkn } /** - * Exchange weight: `1 + floor(batchLength / 40)`, the batch length read from the action's - * `orders`/`cancels`/`modifies` array. Actions without such a batch cost the documented - * minimum of 1. + * Exchange weight: `1 + floor(batchLength / 40)`, where the docs define `batchLength` as "the + * length of the array in the action" (a batched order request is their example). The confirmed + * batch arrays are `orders` (order), `cancels` (cancel / cancelByCloid) and `modifies` + * (batchModify), but the docs never close the set, so the batch length here is the LONGEST array + * found anywhere in the action at any depth — which also bills the deployer arrays (`spotDeploy` + * genesis tuples, `perpDeploy` setter lists) and any future batch action. Over-billing only + * throttles the client harder while under-billing risks the server limit (429s, ultimately an IP + * ban), so the max is the conservative reading. Actions without arrays — or with only short ones + * (`< 40` entries: fixed tuples, a single `twap` object carries no array at all, + * `convertToMultiSigUser`'s wire-stringified `signers`) — cost the documented minimum of 1. * - * The three keys are the DOCUMENTED batch subset: other actions carry arrays that are not - * batch-billed (`spotDeploy`/`perpDeploy` payloads, the multi-sig `signatures` array), so a - * generic "first array" rule would mis-bill them. Whether the protocol's `batch_length` covers - * anything beyond these three is tracked in https://github.com/bloxwap/hyperliquid/issues/49. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits */ function exchangeWeight(payload: unknown): number { const action = exchangeAction(payload); if (action === undefined) return 1; - for (const key of ["orders", "cancels", "modifies"] as const) { - const batch = action[key]; - if (Array.isArray(batch)) return 1 + Math.floor(batch.length / 40); + return 1 + Math.floor(maxArrayLength(action) / 40); +} + +/** The length of the longest array found in `value` at any depth (0 when there is none). */ +function maxArrayLength(value: unknown): number { + if (Array.isArray(value)) { + let max = value.length; + for (const item of value) max = Math.max(max, maxArrayLength(item)); + return max; + } + if (isRecord(value)) { + let max = 0; + for (const key in value) max = Math.max(max, maxArrayLength(value[key])); + return max; } - return 1; + return 0; } -/** The action carrying the batch: the payload's `action`, unwrapped one level for multi-sig. */ +/** + * The action carrying the batch: the payload's `action`, unwrapped one level for multi-sig. + * Unwrapping also keeps the wrapper's `signatures` array — authentication material, not batch + * items — out of the billing walk. + */ function exchangeAction(payload: unknown): Record | undefined { if (!isRecord(payload) || !isRecord(payload.action)) return undefined; const action = payload.action; @@ -610,11 +702,12 @@ function exchangeAction(payload: unknown): Record | undefined { * `blockList`. Post-hoc accounting keeps the limiter honest on average: the response array is * the item count the server bills by. * - * Two readings are interpretations the docs do not pin down (tracked in - * https://github.com/bloxwap/hyperliquid/issues/49): the item count is taken as exactly the - * top-level response array length, and partial chunks round UP (`ceil`, the conservative - * choice — the docs say neither `ceil` nor `floor`). `blockList` is exact only for recent - * blocks: the docs warn older blocks "may be weighted more heavily" without giving a formula. + * Two readings are deliberate over-estimates the docs do not pin down (the residual open + * questions of https://github.com/bloxwap/hyperliquid/issues/49, settled client-side by + * "never under-bill"): the item count is exactly the top-level response array length, and + * partial chunks round UP (`ceil`) — the docs say neither `ceil` nor `floor`, so a partial + * chunk is billed as a full one. `blockList` is exact only for recent blocks: the docs warn + * older blocks "may be weighted more heavily" without giving a formula. */ function responseSurcharge(endpoint: "info" | "exchange" | "explorer", payload: unknown, response: unknown): number { if (!Array.isArray(response) || response.length === 0) return 0; @@ -778,6 +871,54 @@ function parseRetryAfter(value: string | null): number | undefined { return Math.max(0, (date - Date.now()) / 1000); } +/** + * Normalizes {@linkcode HttpTransportOptions.retryOnRateLimit}: `true` maps to the defaults, + * `false`/`undefined` to `null` (a 429 throws on the first attempt). Both knobs are validated up + * front, the way {@linkcode TokenBucketRateLimiter} validates its own: a negative or non-integer + * `maxRetries` and a non-positive or non-finite `maxDelayMs` would otherwise fail silently — + * never retrying, or waiting an unbounded time. + */ +function normalizeRetryOnRateLimit( + option: boolean | HttpRateLimitRetryOptions | undefined, +): { maxRetries: number; maxDelayMs: number } | null { + if (option === undefined || option === false) return null; + const { maxRetries = 3, maxDelayMs = 30_000 } = option === true ? {} : option; + if (!Number.isSafeInteger(maxRetries) || maxRetries < 0 || !Number.isFinite(maxDelayMs) || maxDelayMs <= 0) { + throw new RangeError( + `HttpTransport: retryOnRateLimit.maxRetries must be a non-negative integer and maxDelayMs a positive finite number (got maxRetries=${maxRetries}, maxDelayMs=${maxDelayMs})`, + ); + } + return { maxRetries, maxDelayMs }; +} + +/** + * The wait before a retry: with a server-asked `retryAfter` (seconds), exactly that long plus up + * to 1 s of jitter — honoring the header while spreading a herd of simultaneous retries; without + * one, full-jitter exponential backoff: a random wait within `[0, 2^attempt seconds)`, capped at + * `maxDelayMs` (`attempt` is 0-based, so the waits bound at 1 s, 2 s, 4 s, …). + * + * Returns `undefined` when the server asked for more than `maxDelayMs`: retrying sooner than the + * server allowed would just burn attempts on another 429, so the error surfaces instead. A + * near-`MAX_SAFE_INTEGER` header lands here too, its millisecond conversion dwarfing any bound. + */ +function retryDelayMs(retryAfter: number | undefined, attempt: number, maxDelayMs: number): number | undefined { + if (retryAfter !== undefined) { + const askedMs = retryAfter * 1000; + if (askedMs > maxDelayMs) return undefined; + return askedMs + Math.random() * 1000; + } + return Math.random() * Math.min(1000 * 2 ** attempt, maxDelayMs); +} + +/** Resolves after `ms`; the timer is `unref`'d where supported, so an abandoned wait never holds the process open. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + // Same guarded call as the TimeoutWheel: browser and fake timers return a plain number. + (timer as unknown as { unref?: () => void }).unref?.(); + }); +} + /** Resolves an endpoint against a base URL without dropping the base path or query. */ function buildEndpointUrl(base: string | URL, endpoint: string): URL { const baseUrl = new URL(base); diff --git a/tests/transport/http/mod.test.ts b/tests/transport/http/mod.test.ts index 8af1e224..20f976e3 100644 --- a/tests/transport/http/mod.test.ts +++ b/tests/transport/http/mod.test.ts @@ -782,6 +782,234 @@ describe("HttpTransport", () => { }); }); + describe("retryOnRateLimit", () => { + // Same FakeTime hook pattern as the rateLimit block: retry waits are real timers, + // ticked deterministically here. + let time: FakeTime; + + beforeEach(() => { + time = new FakeTime(); + }); + + afterEach(() => { + time.restore(); + }); + + /** A 429 response carrying the given Retry-After value (or none). */ + const rateLimited = (retryAfter?: string): Response => + new Response("Too Many Requests", { + status: 429, + headers: retryAfter === undefined ? {} : { "Retry-After": retryAfter }, + }); + + test("disabled by default: a 429 throws on the first attempt", async () => { + const stub = stubFetch(() => rateLimited("0")); + try { + const transport = new HttpTransport(); + await assertRejects(() => transport.request("info", {}), HttpRateLimitError); + assertEquals(stub.calls, 1); + } finally { + stub.restore(); + } + }); + + test("retries a 429 and resolves once the server recovers", async () => { + const stub = stubFetch(() => (stub.calls === 1 ? rateLimited("0") : jsonResponse({ ok: true }))); + try { + const transport = new HttpTransport({ retryOnRateLimit: true }); + const pending = transport.request("info", {}); + await flush(); + assertEquals(stub.calls, 1); + + time.tick(1_000); // Retry-After 0 plus up to 1 s of jitter + assertEquals(await pending, { ok: true }); + assertEquals(stub.calls, 2); + } finally { + stub.restore(); + } + }); + + test("honors Retry-After: no retry before the asked delay (the jitter only ever adds wait)", async () => { + const stub = stubFetch(() => (stub.calls === 1 ? rateLimited("2") : jsonResponse())); + try { + const transport = new HttpTransport({ retryOnRateLimit: true }); + const pending = transport.request("info", {}); + await flush(); + assertEquals(stub.calls, 1); + + time.tick(1_999); // 1 ms short of the asked 2 s + await flush(); + assertEquals(stub.calls, 1); + + time.tick(1_001); // past 2 s plus the worst-case 1 s of jitter + await pending; + assertEquals(stub.calls, 2); + } finally { + stub.restore(); + } + }); + + test("without Retry-After falls back to bounded exponential backoff", async () => { + const stub = stubFetch(() => (stub.calls < 3 ? rateLimited() : jsonResponse())); + try { + const transport = new HttpTransport({ retryOnRateLimit: true }); + const pending = transport.request("info", {}); + await flush(); + assertEquals(stub.calls, 1); + + time.tick(1_000); // first backoff: a random wait within [0, 1 s) + await flush(); + assertEquals(stub.calls, 2); + + time.tick(2_000); // second backoff: within [0, 2 s) + await pending; + assertEquals(stub.calls, 3); + } finally { + stub.restore(); + } + }); + + test("gives up after maxRetries and throws the last 429", async () => { + const stub = stubFetch(() => rateLimited()); + try { + const transport = new HttpTransport({ retryOnRateLimit: { maxRetries: 2 } }); + const pending = transport.request("info", {}); + const rejection = assertRejects(() => pending, HttpRateLimitError); + await flush(); + assertEquals(stub.calls, 1); + + time.tick(1_000); // retry 1 + await flush(); + assertEquals(stub.calls, 2); + + time.tick(2_000); // retry 2 — the next 429 is final + const error = await rejection; + assertEquals(error.status, 429); + assertEquals(stub.calls, 3); // maxRetries + 1 attempts in total + } finally { + stub.restore(); + } + }); + + test("a Retry-After beyond maxDelayMs surfaces the 429 instead of waiting", async () => { + const stub = stubFetch(() => rateLimited("60")); + try { + const transport = new HttpTransport({ retryOnRateLimit: true }); // default maxDelayMs: 30 s + await assertRejects(() => transport.request("info", {}), HttpRateLimitError); + assertEquals(stub.calls, 1); + } finally { + stub.restore(); + } + }); + + test("maxDelayMs is configurable", async () => { + const stub = stubFetch(() => (stub.calls === 1 ? rateLimited("60") : jsonResponse())); + try { + // timeout: null — otherwise the default 10 s timeout (which spans retry waits) fires first. + const transport = new HttpTransport({ timeout: null, retryOnRateLimit: { maxDelayMs: 120_000 } }); + const pending = transport.request("info", {}); + await flush(); + assertEquals(stub.calls, 1); + + time.tick(61_000); // the asked 60 s plus up to 1 s of jitter + await pending; + assertEquals(stub.calls, 2); + } finally { + stub.restore(); + } + }); + + test("the overall request timeout spans every retry wait", async () => { + const stub = stubFetch(() => (stub.calls === 1 ? rateLimited("5") : jsonResponse())); + try { + const transport = new HttpTransport({ timeout: 100, retryOnRateLimit: true }); + const pending = transport.request("info", {}); + const rejection = assertRejects(() => pending, HttpRequestError, "Request timed out after 100 ms"); + await flush(); + assertEquals(stub.calls, 1); // first attempt 429'd; the ~5 s retry wait is pending + + time.tick(100); // the timeout fires mid-wait — the retry never goes out + await rejection; + assertEquals(stub.calls, 1); + } finally { + stub.restore(); + } + }); + + test("a caller abort during the retry wait cancels the request", async () => { + const stub = stubFetch(() => (stub.calls === 1 ? rateLimited("5") : jsonResponse())); + try { + const controller = new AbortController(); + const reason = new DOMException("user cancel", "AbortError"); + const transport = new HttpTransport({ retryOnRateLimit: true }); + const pending = transport.request("info", {}, controller.signal); + const rejection = assertRejects(() => pending, HttpRequestError, "Request aborted"); + await flush(); + assertEquals(stub.calls, 1); + + controller.abort(reason); + const error = await rejection; + assertEquals(error.cause, reason); + assertEquals(stub.calls, 1); // the retry never went out + } finally { + stub.restore(); + } + }); + + test("only a 429 is retried: other failures surface on the first attempt", async () => { + const stub = stubFetch(() => new Response("nope", { status: 500 })); + try { + const transport = new HttpTransport({ retryOnRateLimit: true }); + const error = await assertRejects(() => transport.request("info", {}), HttpRequestError); + assert(!(error instanceof HttpRateLimitError)); + assertEquals(stub.calls, 1); + } finally { + stub.restore(); + } + }); + + test("a 200-OK error envelope is not retried either", async () => { + const stub = stubFetch(() => jsonResponse({ type: "error", message: "server-side failure" })); + try { + const transport = new HttpTransport({ retryOnRateLimit: true }); + await assertRejects(() => transport.request("info", {}), HttpRequestError, "server-side failure"); + assertEquals(stub.calls, 1); + } finally { + stub.restore(); + } + }); + + test("composed with rateLimit, each retry debits the bucket again", async () => { + const stub = stubFetch(() => (stub.calls === 1 ? rateLimited("0") : jsonResponse())); + try { + // 1 weight per second: the first attempt acquires 1 (bucket 0), the retry charges 1 + // more (bucket -1), and the ~1 s retry wait refills exactly that token (bucket 0). + const transport = new HttpTransport({ + rateLimit: { capacity: 1, refillPerMinute: 60 }, + retryOnRateLimit: true, + }); + const pending = transport.request("exchange", { action: { type: "noop" } }); + await flush(); + assertEquals(stub.calls, 1); + + time.tick(1_000); // Retry-After 0 plus jitter — the retry goes out and succeeds + await pending; + assertEquals(stub.calls, 2); + + // The retry's debit left the bucket empty: the next request waits one refill second. + // (Had the retry not been charged, the refilled token would send it immediately.) + const followUp = transport.request("exchange", { action: { type: "noop" } }); + await flush(); + assertEquals(stub.calls, 2); + time.tick(1_000); + await followUp; + assertEquals(stub.calls, 3); + } finally { + stub.restore(); + } + }); + }); + describe("request payload redaction", () => { const signedPayload = { action: { type: "order", orders: [{ a: 0, b: true }] }, @@ -1151,6 +1379,101 @@ describe("HttpTransport", () => { ); }); + test("exchange batch: cancel and cancelByCloid share the cancels key", async () => { + await assertWeight("exchange", { action: { type: "cancel", cancels: Array.from({ length: 41 }) } }, 2); + await assertWeight("exchange", { action: { type: "cancelByCloid", cancels: Array.from({ length: 41 }) } }, 2); + }); + + test("exchange batch: batchModify (modifies)", async () => { + await assertWeight("exchange", { action: { type: "batchModify", modifies: Array.from({ length: 80 }) } }, 3); + }); + + // #108: the docs define batch_length as "the length of the array in the action" without + // closing the set, so every array bills — by the longest one found, never under-billing. + test("perpDeploy setter arrays bill as a batch (setOracle)", async () => { + await assertWeight( + "exchange", + { + action: { + type: "perpDeploy", + setOracle: { + dex: "TEST", + oraclePxs: Array.from({ length: 41 }), + markPxs: [], + externalPerpPxs: [], + }, + }, + }, + 2, + ); + }); + + test("spotDeploy nested genesis arrays bill as a batch (userGenesis)", async () => { + await assertWeight( + "exchange", + { + action: { + type: "spotDeploy", + userGenesis: { token: 1, userAndWei: Array.from({ length: 41 }), existingTokenAndWei: [] }, + }, + }, + 2, + ); + }); + + test("arrays of arrays bill by the longest inner array (perpDeploy markPxs)", async () => { + await assertWeight( + "exchange", + { + action: { + type: "perpDeploy", + setOracle: { dex: "TEST", oraclePxs: [], markPxs: [Array.from({ length: 41 })], externalPerpPxs: [] }, + }, + }, + 2, + ); + }); + + test("arrays shorter than 40 keep the minimum weight of 1", async () => { + await assertWeight( + "exchange", + { action: { type: "perpDeploy", setFundingMultipliers: Array.from({ length: 39 }) } }, + 1, + ); + await assertWeight("exchange", { action: { type: "spotDeploy", registerSpot: { tokens: [1, 2] } } }, 1); + }); + + test("twapOrder: a single twap object is not a batch", async () => { + await assertWeight("exchange", { action: { type: "twapOrder", twap: { a: 1 } } }, 1); + }); + + test("convertToMultiSigUser: signers is a string on the wire, not an array", async () => { + await assertWeight( + "exchange", + { + action: { + type: "convertToMultiSigUser", + signers: JSON.stringify({ authorizedUsers: Array.from({ length: 100 }), threshold: 2 }), + }, + }, + 1, + ); + }); + + test("multi-sig: the wrapper's signatures array is never billed, only the inner action", async () => { + await assertWeight( + "exchange", + { + action: { + type: "multiSig", + signatures: Array.from({ length: 100 }), + payload: { action: { type: "noop" } }, + }, + }, + 1, + ); + }); + test("billing derives from the serialized form, never from live getters or toJSON", async () => { const stub = stubFetch(() => jsonResponse()); try { From 3c85ff334f7eca60132f7c73e7438ed9d5008033 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 24 Aug 2026 08:16:13 -0700 Subject: [PATCH 4/7] feat(transport): opt-in InfoCacheTransport TTL cache for slow-changing info endpoints (#109) --- docs/clients.md | 38 ++++ src/transport/_infoCache.ts | 267 +++++++++++++++++++++++++++++ src/transport/mod.ts | 1 + tests/transport/_infoCache.test.ts | 248 +++++++++++++++++++++++++++ 4 files changed, 554 insertions(+) create mode 100644 src/transport/_infoCache.ts create mode 100644 tests/transport/_infoCache.test.ts diff --git a/docs/clients.md b/docs/clients.md index 93dd5034..022543eb 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -49,6 +49,44 @@ and cannot reach older history. `historicalOrders` (at most 2000 most recent ord paginated. `userFillsByTimeAll` rejects `reversed: true`: the walk moves forward from `startTime` and needs ascending pages. +### Caching slow-changing metadata + +Info responses are never cached by default. If your app polls metadata endpoints (`meta`, `spotMeta`, …) in a loop, +wrap the transport in `InfoCacheTransport` — an opt-in TTL cache that works over both HTTP and WebSocket transports and +leaves every non-allowlisted request untouched: + +```ts +import { HttpTransport, InfoCacheTransport, InfoClient } from "@bloxwap/hyperliquid"; + +const transport = new InfoCacheTransport(new HttpTransport(), { + ttl: 60_000, // default TTL for every cached endpoint (1 minute) + ttlByType: { marginTable: 600_000 }, // per-endpoint overrides +}); +const client = new InfoClient({ transport }); + +await client.meta(); // hits the network +await client.meta(); // served from cache until the TTL expires +``` + +Only a conservative allowlist of listing- or deployment-driven endpoints is cached — `meta`, `spotMeta`, +`allPerpMetas`, `perpDexs`, `marginTable`, `tokenDetails`, `outcomeMeta`, `outcomeTemplates`. Responses with live +market data (`metaAndAssetCtxs`, `spotMetaAndAssetCtxs`), exchange status, user state, and order books always pass +through uncached. Cache keys incorporate the request params, so e.g. `marginTable` with different `id`/`dex` values +never collide, and concurrent identical calls share one in-flight request. + +Metadata changes are rare but unannounced (new listings, new DEXs), so the useful TTL band is seconds to minutes: +30 s – 5 min for the `meta` family and `outcomeMeta`; 5 – 10 min or more for the near-static `marginTable`, +`perpDexs`, `tokenDetails`, and `outcomeTemplates`. The default is 60 s. Call `transport.clear()` to force a refetch +of everything. + +Two interactions to be aware of: + +- `SymbolConverter` fetches `meta`/`spotMeta`/`perpDexs`/`outcomeMeta` through whatever transport it is given. With a + caching transport, its `reload()` serves cached data within the TTL — give the converter its own unwrapped transport, + or call `clear()` first, when a reload must see fresh listings. +- `InfoCacheTransport` implements only the request interface. When wrapping a `WebSocketTransport`, pass the raw + WebSocket transport to `SubscriptionClient` and the wrapped one to `InfoClient`. + ## Exchange endpoint `ExchangeClient` requires a wallet for [signing](signing.md#wallet-compatibility) and works with any transport. See all diff --git a/src/transport/_infoCache.ts b/src/transport/_infoCache.ts new file mode 100644 index 00000000..53e73dfb --- /dev/null +++ b/src/transport/_infoCache.ts @@ -0,0 +1,267 @@ +/** + * Opt-in TTL cache for slow-changing Info API endpoints. + * + * {@link InfoCacheTransport} wraps any {@link IRequestTransport} — HTTP or WebSocket — and answers + * repeated requests to a conservative allowlist of slow-changing info endpoints (`meta`, `spotMeta`, + * `allPerpMetas`, `perpDexs`, `marginTable`, `tokenDetails`, `outcomeMeta`, `outcomeTemplates`) + * from an in-memory cache instead of the network. Everything else — user state, order books, + * `exchange` and `explorer` requests — passes straight through. The wrapper is strictly opt-in: + * without it, behavior is byte-for-byte the default. + * + * ```text + * InfoCacheTransport.request(): + * endpoint !== "info" or type not allowlisted ─► inner transport (no cache, no overhead) + * allowlisted ─► key = type + sorted params + * ├─ fresh entry ─► cached promise + * └─ miss/expired ─► inner transport ─► cache promise (TTL from dispatch) + * ├─ resolves ─► served until expiry + * └─ rejects ─► evicted (next call retries) + * ``` + * + * @module + */ + +import type { IRequestTransport } from "./_base.ts"; + +/** + * Info request types whose responses {@linkcode InfoCacheTransport} may cache. + * + * The allowlist is deliberately conservative — every type on it returns deployment- or + * listing-driven data that changes rarely and never within seconds: + * + * - `meta` — perp universe and margin tables (per DEX; the `dex` param is part of the cache key). + * - `spotMeta` — spot token/pair universe. + * - `allPerpMetas` — perp metas across all DEXs. + * - `perpDexs` — builder DEX registry. + * - `marginTable` — margin tiers per table `id` (and `dex`); both params are part of the key. + * - `tokenDetails` — genesis/deployer metadata per `tokenId`; static after deploy. + * - `outcomeMeta` — prediction-market outcome/question metadata. + * - `outcomeTemplates` — outcome deployer templates. + * + * Deliberately NOT cached: `metaAndAssetCtxs`/`spotMetaAndAssetCtxs` (asset contexts carry live + * prices and funding), `exchangeStatus`, `validatorSummaries`, `perpDeployAuctionStatus`, and every + * user-state or order-book endpoint — staleness there is either wrong data or a trading hazard. + */ +export type InfoCacheableRequestType = + | "meta" + | "spotMeta" + | "allPerpMetas" + | "perpDexs" + | "marginTable" + | "tokenDetails" + | "outcomeMeta" + | "outcomeTemplates"; + +/** Configuration options for {@linkcode InfoCacheTransport}. */ +export interface InfoCacheOptions { + /** + * Time-to-live in ms applied to every allowlisted endpoint unless overridden in + * {@linkcode InfoCacheOptions.ttlByType}. Must be a non-negative number; `Infinity` caches + * forever (until {@linkcode InfoCacheTransport.clear}); `0` effectively disables caching. + * + * Metadata changes are listing- or deployment-driven — rare, but unannounced — so the useful + * band is seconds to minutes, not hours. As a guide: `meta`/`spotMeta`/`allPerpMetas`/ + * `outcomeMeta` sit well at 30 s – 5 min; `marginTable`, `perpDexs`, `tokenDetails` and + * `outcomeTemplates` are near-static and tolerate 5 – 10 min or more. + * + * Default: `60_000` (1 minute) + */ + ttl?: number; + /** + * Per-endpoint TTL overrides in ms, keyed by info request type; falls back to + * {@linkcode InfoCacheOptions.ttl} for types not listed. + * + * Default: `{}` (every allowlisted endpoint uses `ttl`) + * + * @example + * ```ts + * const transport = new InfoCacheTransport(new HttpTransport(), { + * ttl: 30_000, // meta family refreshes often + * ttlByType: { marginTable: 600_000, tokenDetails: 600_000 }, // near-static tables + * }); + * ``` + */ + ttlByType?: Partial>; + /** + * Maximum number of cached entries. Entries are keyed by request type AND params, so + * param-distinct calls (`tokenDetails` over many token IDs, `marginTable` over many tables) + * accumulate; beyond the limit the wrapper first drops expired entries, then the oldest ones. + * Must be a positive integer. + * + * Default: `1000` + */ + maxSize?: number; +} + +/** One cached response: the in-flight or settled promise plus its expiry. */ +interface CacheEntry { + promise: Promise; + expiresAt: number; +} + +/** + * Opt-in caching wrapper around any {@linkcode IRequestTransport}, TTL-caching the slow-changing + * info endpoints listed in {@linkcode InfoCacheableRequestType}. + * + * Cache keys incorporate the full request payload (params sorted), so e.g. `marginTable` with + * different `id`/`dex` values never collide. Concurrent identical calls share one in-flight + * request; note that the first caller's abort signal drives that shared request, so aborting it + * rejects every waiter and evicts the entry (the next call simply refetches). A rejected request + * is never served from cache. Cached responses ignore later callers' signals — a hit needs no + * network at all. + * + * The wrapper implements only {@linkcode IRequestTransport}: wrapping a `WebSocketTransport` + * hides its subscription interface, so pass the raw WebSocket transport to `SubscriptionClient` + * and the wrapped one to `InfoClient`. + * + * Note on `SymbolConverter`: it fetches `meta`/`spotMeta`/`perpDexs`/`outcomeMeta` through + * whatever transport it is given. If that transport is an `InfoCacheTransport`, `reload()` + * serves cached data within the TTL — give the converter its own unwrapped transport, or call + * {@linkcode clear} first, when a reload must see fresh listings. + * + * @example + * ```ts + * import { HttpTransport, InfoCacheTransport, InfoClient } from "@bloxwap/hyperliquid"; + * + * const transport = new InfoCacheTransport(new HttpTransport(), { + * ttl: 60_000, // default; override per endpoint via ttlByType + * }); + * const client = new InfoClient({ transport }); + * + * await client.meta(); // hits the network + * await client.meta(); // served from cache + * ``` + */ +export class InfoCacheTransport + implements IRequestTransport +{ + /** The wrapped transport every uncached request is delegated to. */ + readonly inner: IRequestTransport; + /** Default TTL in ms. */ + private readonly _ttl: number; + /** Per-type TTL overrides in ms. */ + private readonly _ttlByType: Partial>; + /** Maximum number of cached entries before eviction. */ + private readonly _maxSize: number; + /** Cached responses keyed by type + sorted params, in insertion order (oldest first). */ + private readonly _entries = new Map(); + + /** + * Creates a caching wrapper around `inner`. + * + * @param inner The transport to delegate uncached requests and cache misses to. + * @param options Cache configuration. See {@link InfoCacheOptions}. + */ + constructor(inner: IRequestTransport, options?: InfoCacheOptions) { + const { ttl = 60_000, ttlByType = {}, maxSize = 1000 } = options ?? {}; + if ( + typeof ttl !== "number" || + Number.isNaN(ttl) || + ttl < 0 || + Object.values(ttlByType).some((t) => typeof t !== "number" || Number.isNaN(t) || t < 0) + ) { + throw new RangeError("InfoCacheTransport: ttl values must be non-negative numbers"); + } + if (!Number.isSafeInteger(maxSize) || maxSize < 1) { + throw new RangeError(`InfoCacheTransport: maxSize must be a positive integer (got ${maxSize})`); + } + this.inner = inner; + this._ttl = ttl; + this._ttlByType = ttlByType; + this._maxSize = maxSize; + } + + /** Indicates this transport uses testnet endpoint(s) — mirrors the wrapped transport. */ + get isTestnet(): boolean { + return this.inner.isTestnet; + } + + /** + * Sends a request, answering allowlisted info requests from the cache while fresh. + * + * @param endpoint The API endpoint to send the request to. + * @param payload The payload to send with the request. + * @param signal {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal} to cancel the request. + * @return A promise that resolves with the parsed response payload. + */ + request(endpoint: E, payload: unknown, signal?: AbortSignal): Promise { + const type = endpoint === "info" ? cacheableType(payload) : undefined; + if (type === undefined) return this.inner.request(endpoint, payload, signal); + + const key = stableKey(payload as Record); + const now = Date.now(); + const hit = this._entries.get(key); + if (hit !== undefined && hit.expiresAt > now) return hit.promise as Promise; + + // Cache the in-flight promise itself, so concurrent identical calls share one request. The + // TTL runs from dispatch: a response is served until `ttl` after its request started, and a + // rejection evicts the entry (guarded against evicting a newer entry for the same key). + const promise = this.inner.request(endpoint, payload, signal); + const entry: CacheEntry = { promise, expiresAt: now + (this._ttlByType[type] ?? this._ttl) }; + this._entries.delete(key); // re-insert so eviction order tracks recency + this._evict(now); + this._entries.set(key, entry); + promise.catch(() => { + if (this._entries.get(key) === entry) this._entries.delete(key); + }); + return promise; + } + + /** Drops every cached entry; the next call to any allowlisted endpoint refetches. */ + clear(): void { + this._entries.clear(); + } + + /** Makes room for one more entry: expired entries first, then the oldest. */ + private _evict(now: number): void { + if (this._entries.size < this._maxSize) return; + for (const [key, entry] of this._entries) { + if (this._entries.size < this._maxSize) return; + if (entry.expiresAt <= now) this._entries.delete(key); + } + while (this._entries.size >= this._maxSize) { + const oldest = this._entries.keys().next(); + if (oldest.done === true) return; + this._entries.delete(oldest.value); + } + } +} + +/** Info request types eligible for caching, as a runtime set. */ +const CACHEABLE_TYPES: ReadonlySet = new Set([ + "meta", + "spotMeta", + "allPerpMetas", + "perpDexs", + "marginTable", + "tokenDetails", + "outcomeMeta", + "outcomeTemplates", +]); + +/** The payload's `type` when it names an allowlisted info request, otherwise `undefined`. */ +function cacheableType(payload: unknown): InfoCacheableRequestType | undefined { + if (typeof payload !== "object" || payload === null) return undefined; + const type = (payload as Record).type; + return typeof type === "string" && CACHEABLE_TYPES.has(type) ? (type as InfoCacheableRequestType) : undefined; +} + +/** + * Cache key for a payload: a JSON form with object keys sorted at every depth, so two payloads + * that differ only in key insertion order (`{ id, dex }` vs `{ dex, id }`) share one entry. + * Payloads reaching this wrapper are small plain-data records validated by the request schemas. + */ +function stableKey(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableKey).join(",")}]`; + if (typeof value === "object" && value !== null) { + const record = value as Record; + const parts: string[] = []; + for (const key of Object.keys(record).sort()) { + const field = record[key]; + if (field === undefined) continue; // mirrors JSON.stringify dropping undefined object values + parts.push(`${JSON.stringify(key)}:${stableKey(field)}`); + } + return `{${parts.join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} diff --git a/src/transport/mod.ts b/src/transport/mod.ts index fe48365b..5f1b10f8 100644 --- a/src/transport/mod.ts +++ b/src/transport/mod.ts @@ -15,5 +15,6 @@ */ export * from "./_base.ts"; +export * from "./_infoCache.ts"; export * from "./http/mod.ts"; export * from "./websocket/mod.ts"; diff --git a/tests/transport/_infoCache.test.ts b/tests/transport/_infoCache.test.ts new file mode 100644 index 00000000..d42a5564 --- /dev/null +++ b/tests/transport/_infoCache.test.ts @@ -0,0 +1,248 @@ +/** + * Tests for the opt-in info-endpoint TTL cache: cache hits, TTL expiry, param-distinct + * keys, in-flight dedup, failure eviction, bounded size, and disabled passthrough. + * Entirely offline — the wrapped transport is a scripted mock, never the network. + * @module + */ + +import { afterEach, beforeEach, describe, test } from "bun:test"; +import { assertEquals, assertRejects, assertThrows } from "@jsr/std__assert"; +import { FakeTime } from "@jsr/std__testing/time"; +import { InfoCacheTransport } from "../../src/transport/_infoCache.ts"; +import type { IRequestTransport } from "../../src/transport/_base.ts"; + +type Endpoint = "info" | "exchange" | "explorer"; + +/** An {@linkcode IRequestTransport} that records calls and answers from a scripted handler. */ +class MockTransport implements IRequestTransport { + readonly isTestnet = true; + readonly calls: { endpoint: Endpoint; payload: unknown; signal?: AbortSignal }[] = []; + + constructor(readonly handler: (endpoint: Endpoint, payload: Record) => unknown) {} + + async request(endpoint: Endpoint, payload: unknown, signal?: AbortSignal): Promise { + this.calls.push({ endpoint, payload, signal }); + // `async` so a throwing handler rejects the returned promise, like a real transport's failures. + return this.handler(endpoint, payload as Record) as T; + } +} + +/** A handler that answers every payload with a distinct echo object. */ +function echoHandler(_endpoint: Endpoint, payload: Record): unknown { + return { echo: payload }; +} + +describe("InfoCacheTransport", () => { + let time: FakeTime; + + beforeEach(() => { + time = new FakeTime(); + }); + + afterEach(() => { + time.restore(); + }); + + test("serves a repeated allowlisted request from cache without a second network call", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 60_000 }); + + const first = await transport.request("info", { type: "meta" }); + const second = await transport.request("info", { type: "meta" }); + + assertEquals(mock.calls.length, 1); + assertEquals(second, first); + }); + + test("refetches once the TTL expires", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 1_000 }); + + await transport.request("info", { type: "spotMeta" }); + time.tick(999); + await transport.request("info", { type: "spotMeta" }); + assertEquals(mock.calls.length, 1); // one ms short of expiry: still cached + + time.tick(1); + await transport.request("info", { type: "spotMeta" }); + assertEquals(mock.calls.length, 2); // exactly at expiry: stale, refetched + }); + + test("applies the default TTL unless a per-type override exists", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { + ttl: 60_000, + ttlByType: { marginTable: 500 }, + }); + + await transport.request("info", { type: "marginTable", id: 1 }); + await transport.request("info", { type: "meta" }); + time.tick(600); + + await transport.request("info", { type: "marginTable", id: 1 }); // override expired: refetch + await transport.request("info", { type: "meta" }); // default TTL still fresh: cache hit + assertEquals(mock.calls.length, 3); + }); + + test("keys entries by request params, so distinct params never collide", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 60_000 }); + + await transport.request("info", { type: "marginTable", id: 1 }); + await transport.request("info", { type: "marginTable", id: 2 }); + await transport.request("info", { type: "marginTable", id: 1, dex: "test" }); + await transport.request("info", { type: "meta", dex: "test" }); + await transport.request("info", { type: "meta" }); + assertEquals(mock.calls.length, 5); + + // Repeats hit the cache — including with a different param insertion order. + const reordered = await transport.request("info", { dex: "test", id: 1, type: "marginTable" }); + await transport.request("info", { type: "marginTable", id: 1 }); + await transport.request("info", { type: "meta", dex: "test" }); + assertEquals(mock.calls.length, 5); + assertEquals(reordered, { echo: { type: "marginTable", id: 1, dex: "test" } }); + }); + + test("passes non-allowlisted info requests straight through", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 60_000 }); + + await transport.request("info", { type: "allMids" }); + await transport.request("info", { type: "allMids" }); + await transport.request("info", { type: "l2Book", coin: "ETH" }); + await transport.request("info", { type: "l2Book", coin: "ETH" }); + await transport.request("info", { type: "metaAndAssetCtxs" }); + await transport.request("info", { type: "metaAndAssetCtxs" }); + await transport.request("info", { type: "clearinghouseState", user: "0xabc" }); + await transport.request("info", { type: "clearinghouseState", user: "0xabc" }); + + assertEquals(mock.calls.length, 8); // nothing cached + }); + + test("passes exchange and explorer requests straight through", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 60_000 }); + + await transport.request("exchange", { action: { type: "order" } }); + await transport.request("exchange", { action: { type: "order" } }); + await transport.request("explorer", { type: "blockList" }); + await transport.request("explorer", { type: "blockList" }); + + assertEquals(mock.calls.length, 4); + // Nothing exchange/explorer-shaped may leak into the info cache either: + // an allowlisted info type shares no key with them. + assertEquals( + mock.calls.every((call) => call.endpoint !== "info"), + true, + ); + }); + + test("ttl: 0 disables caching", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 0 }); + + await transport.request("info", { type: "meta" }); + await transport.request("info", { type: "meta" }); + assertEquals(mock.calls.length, 2); + }); + + test("shares one in-flight request between concurrent identical calls", async () => { + let release: (value: unknown) => void; + const gate = new Promise((resolve) => (release = resolve)); + const mock = new MockTransport(() => gate); + const transport = new InfoCacheTransport(mock, { ttl: 60_000 }); + + const first = transport.request("info", { type: "perpDexs" }); + const second = transport.request("info", { type: "perpDexs" }); + assertEquals(mock.calls.length, 1); // the second call joined the in-flight first + + release!(["dex-a"]); + assertEquals(await first, ["dex-a"]); + assertEquals(await second, ["dex-a"]); + }); + + test("evicts a rejected request so the next call retries instead of serving the failure", async () => { + let failures = 1; + const mock = new MockTransport((_endpoint, payload) => { + if (failures > 0) { + failures--; + throw new Error("network down"); + } + return { echo: payload }; + }); + const transport = new InfoCacheTransport(mock, { ttl: 60_000 }); + + await assertRejects(() => transport.request("info", { type: "outcomeMeta" })); + const retried = await transport.request("info", { type: "outcomeMeta" }); + + assertEquals(mock.calls.length, 2); + assertEquals(retried, { echo: { type: "outcomeMeta" } }); + await transport.request("info", { type: "outcomeMeta" }); // success is cached now + assertEquals(mock.calls.length, 2); + }); + + test("clear() drops every cached entry", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 60_000 }); + + await transport.request("info", { type: "meta" }); + await transport.request("info", { type: "tokenDetails", tokenId: "0x1" }); + assertEquals(mock.calls.length, 2); + + transport.clear(); + await transport.request("info", { type: "meta" }); + await transport.request("info", { type: "tokenDetails", tokenId: "0x1" }); + assertEquals(mock.calls.length, 4); + }); + + test("evicts the oldest entries beyond maxSize", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 60_000, maxSize: 2 }); + + await transport.request("info", { type: "tokenDetails", tokenId: "0x1" }); + time.tick(1); // distinct insertion order for oldest-first eviction + await transport.request("info", { type: "tokenDetails", tokenId: "0x2" }); + time.tick(1); + await transport.request("info", { type: "tokenDetails", tokenId: "0x3" }); // evicts 0x1 + + await transport.request("info", { type: "tokenDetails", tokenId: "0x3" }); // cached + await transport.request("info", { type: "tokenDetails", tokenId: "0x2" }); // cached + assertEquals(mock.calls.length, 3); + + await transport.request("info", { type: "tokenDetails", tokenId: "0x1" }); // evicted: refetch + assertEquals(mock.calls.length, 4); + }); + + test("caches every allowlisted type", async () => { + const mock = new MockTransport(echoHandler); + const transport = new InfoCacheTransport(mock, { ttl: 60_000 }); + const types = [ + "meta", + "spotMeta", + "allPerpMetas", + "perpDexs", + "marginTable", + "tokenDetails", + "outcomeMeta", + "outcomeTemplates", + ]; + + for (const type of types) await transport.request("info", { type }); + for (const type of types) await transport.request("info", { type }); + assertEquals(mock.calls.length, types.length); // one network call per type, not two + }); + + test("rejects invalid options", () => { + const mock = new MockTransport(echoHandler); + assertThrows(() => new InfoCacheTransport(mock, { ttl: -1 }), RangeError); + assertThrows(() => new InfoCacheTransport(mock, { ttl: Number.NaN }), RangeError); + assertThrows(() => new InfoCacheTransport(mock, { ttlByType: { meta: -5 } }), RangeError); + assertThrows(() => new InfoCacheTransport(mock, { maxSize: 0 }), RangeError); + assertThrows(() => new InfoCacheTransport(mock, { maxSize: 1.5 }), RangeError); + }); + + test("mirrors the wrapped transport's isTestnet flag", () => { + const mock = new MockTransport(echoHandler); + assertEquals(new InfoCacheTransport(mock).isTestnet, mock.isTestnet); + }); +}); From 341cf5c39fb6563fc2496928b60977b53f2b00db Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 24 Aug 2026 08:16:13 -0700 Subject: [PATCH 5/7] test: pin WS disconnect/subscription invariants and SymbolConverter behaviors (#98, #110) Audit found all four upstream WS fixes and all three SymbolConverter ports already present in the fork; these regression tests lock them in. --- tests/transport/websocket/_dispatcher.test.ts | 34 ++++++++ .../websocket/_subscriptionManager.test.ts | 34 ++++++++ tests/utils/symbolConverter.test.ts | 67 ++++++++++++++- tests/utils/symbolConverterReload.test.ts | 85 ++++++++++++++++++- 4 files changed, 218 insertions(+), 2 deletions(-) diff --git a/tests/transport/websocket/_dispatcher.test.ts b/tests/transport/websocket/_dispatcher.test.ts index 1550aaa9..f41b534a 100644 --- a/tests/transport/websocket/_dispatcher.test.ts +++ b/tests/transport/websocket/_dispatcher.test.ts @@ -586,6 +586,40 @@ describe("WebSocketDispatcher", () => { assertEquals(socket.sentMessages.length, 0); }); + test("a sent request rejected on disconnect is never re-sent after reconnect", async () => { + const { socket, requester } = createRequester(); + + // The frame already reached the server, but the connection drops before the + // response: the caller believes the request failed. Replaying the frame after + // the reconnect would execute it a second time — the double-execution hazard + // of an order the client recorded as failed (upstream nktkas#137). + const promise = requester.request("post", { foo: "bar" }); + assertEquals(socket.sentMessages.length, 1); + + socket.disconnect(); + await assertRejects(() => promise, WebSocketRequestError, "WebSocket connection closed"); + + socket.open(); + assertEquals(socket.sentMessages.length, 1); // no replay of the rejected frame + }); + + test("a synchronous re-open inside a close listener does not replay a rejected frame", async () => { + const { socket, requester } = createRequester(); + + const promise = requester.request("post", { foo: "bar" }); + assertEquals(socket.sentMessages.length, 1); + + // This listener is registered after the dispatcher's own close handler, so it + // runs after the rejections but before their `finally` dequeues (microtasks). + // A synchronous re-open in that window flushes whatever the queue still holds — + // only clearing the queue before rejecting keeps the rejected frame unsent. + socket.addEventListener("close", () => socket.open()); + socket.disconnect(); + + await assertRejects(() => promise, WebSocketRequestError, "WebSocket connection closed"); + assertEquals(socket.sentMessages.length, 1); + }); + test("rejects if permanently closed", async () => { const { socket, requester } = createRequester(); diff --git a/tests/transport/websocket/_subscriptionManager.test.ts b/tests/transport/websocket/_subscriptionManager.test.ts index 887913f6..b0461712 100644 --- a/tests/transport/websocket/_subscriptionManager.test.ts +++ b/tests/transport/websocket/_subscriptionManager.test.ts @@ -273,6 +273,40 @@ describe("WebSocketSubscriptionManager", () => { await retry; }); + test("concurrent subscriptions differing only in an optional field do not match each other's echo", async () => { + const { socket, manager } = createManager(); + + // The looser pending is a subset of the stricter one, so a rewritten echo of the + // strict confirmation also subset-matches the loose one — the echo must go to the + // most specific pending, not to the first subset match (upstream nktkas#137). + const loose = { type: "l2Book", coin: "BTC" }; + const strict = { type: "l2Book", coin: "BTC", nSigFigs: 5 }; + const loosePromise = manager.subscribe("l2Book", loose, () => {}); + const strictPromise = manager.subscribe("l2Book", strict, () => {}); + assertEquals(socket.sentMessages.length, 2); // distinct payloads: two wire subscriptions + + let looseSettled = false; + void loosePromise.then( + () => (looseSettled = true), + () => (looseSettled = true), + ); + + // The server rewrites the strict confirmation with an added field, so only the + // subset scan can match it — and the looser pending must not swallow it. + socket.mockMessage( + RESPONSES.subscriptionResponse("subscribe", { type: "l2Book", coin: "BTC", nSigFigs: 5, mantissa: null }), + ); + await strictPromise; + await drain(); + assertFalse(looseSettled); // still waiting for its own echo + + socket.mockMessage(RESPONSES.subscriptionResponse("subscribe", loose)); + await loosePromise; + assertEquals(manager._subscriptions.size, 2); + + socket.terminate(); + }); + test("limit errors carry the request payload", async () => { const { socket, manager } = createManager(); diff --git a/tests/utils/symbolConverter.test.ts b/tests/utils/symbolConverter.test.ts index 1052af4b..70fc869c 100644 --- a/tests/utils/symbolConverter.test.ts +++ b/tests/utils/symbolConverter.test.ts @@ -8,7 +8,7 @@ import { beforeAll, describe, test } from "bun:test"; import { assertEquals } from "@jsr/std__assert"; import { HttpTransport, type IRequestTransport } from "@bloxwap/hyperliquid"; import { SymbolConverter } from "@bloxwap/hyperliquid/utils"; -import type { OutcomeMetaResponse } from "@bloxwap/hyperliquid/api/info"; +import type { OutcomeMetaResponse, SpotMetaResponse } from "@bloxwap/hyperliquid/api/info"; import { OFFLINE } from "../_offline.ts"; // ============================================================ @@ -31,6 +31,22 @@ function createOutcomeTransport(outcomeMeta: OutcomeMetaResponse): IRequestTrans }; } +/** Builds a request transport that serves a fixed `spotMeta` and empty perpetual/outcome metadata. */ +function createSpotTransport(spotMeta: SpotMetaResponse): IRequestTransport { + const responses: Record = { + meta: { universe: [] }, + spotMeta, + outcomeMeta: { outcomes: [], questions: [] }, + }; + return { + isTestnet: false, + request(_endpoint: "info" | "exchange" | "explorer", payload: unknown): Promise { + const { type } = payload as { type: string }; + return Promise.resolve(responses[type] as T); + }, + }; +} + // ============================================================ // Test Data // ============================================================ @@ -50,6 +66,35 @@ const DEX_EXPECTATIONS = { "unit:ES": { assetId: 120000 }, } as const; +/** A trimmed `spotMeta` response with one canonical pair, modeled on real mainnet data. */ +const SPOT_META: SpotMetaResponse = { + tokens: [ + { + name: "USDC", + szDecimals: 8, + weiDecimals: 8, + index: 0, + tokenId: "0x00000000000000000000000000000000", + isCanonical: true, + evmContract: null, + fullName: null, + deployerTradingFeeShare: "0", + }, + { + name: "PURR", + szDecimals: 0, + weiDecimals: 0, + index: 1, + tokenId: "0x00000000000000000000000000000001", + isCanonical: true, + evmContract: null, + fullName: null, + deployerTradingFeeShare: "0", + }, + ], + universe: [{ tokens: [1, 0], name: "@1", index: 0, isCanonical: true }], +}; + /** A trimmed `outcomeMeta` response covering every supported market type, modeled on real mainnet data. */ const OUTCOME_META: OutcomeMetaResponse = { outcomes: [ @@ -314,3 +359,23 @@ describe("SymbolConverter outcome markets", () => { assertEquals(converter.getSzDecimals("2026-world-cup-champion-argentina-yes"), 0); }); }); + +describe("SymbolConverter spot pair lookups", () => { + // Fed by a stub transport, so this group stays runnable offline. + let converter: SymbolConverter; + + beforeAll(async () => { + converter = await SymbolConverter.create({ transport: createSpotTransport(SPOT_META) }); + }); + + test("getSpotPairId()", () => { + assertEquals(converter.getSpotPairId("PURR/USDC"), "@1"); + assertEquals(converter.getSpotPairId("NONE/EXISTENT"), undefined); + }); + + test("getSymbolBySpotPairId()", () => { + assertEquals(converter.getSymbolBySpotPairId("@1"), "PURR/USDC"); + assertEquals(converter.getSymbolBySpotPairId("@999999"), undefined); + assertEquals(converter.getSymbolBySpotPairId("PURR/USDC"), undefined); + }); +}); diff --git a/tests/utils/symbolConverterReload.test.ts b/tests/utils/symbolConverterReload.test.ts index 38e018f6..cc9730b9 100644 --- a/tests/utils/symbolConverterReload.test.ts +++ b/tests/utils/symbolConverterReload.test.ts @@ -9,7 +9,7 @@ */ import { describe, test } from "bun:test"; -import { assertEquals } from "@jsr/std__assert"; +import { assertEquals, assertRejects, assertStrictEquals } from "@jsr/std__assert"; import type { IRequestTransport } from "@bloxwap/hyperliquid"; import type { MetaResponse, @@ -140,6 +140,38 @@ async function waitForDexRequest(pendingDexRequests: Deferred[], c assertEquals(pendingDexRequests.length, count); } +/** + * Builds a transport stub that counts requests per info `type` and can fail the next `meta` + * request on demand, so a test can observe how many fetch rounds each reload actually ran. + * Builder-dex support stays disabled, so a round is exactly one `meta`/`spotMeta`/`outcomeMeta`. + */ +function createCountingTransport(): { + transport: IRequestTransport; + requestCounts: Map; + failMetaRequest: { current: boolean }; +} { + const requestCounts = new Map(); + const failMetaRequest = { current: false }; + const responses: Record = { + meta: PERP_META, + spotMeta: SPOT_META, + outcomeMeta: EMPTY_OUTCOME_META, + }; + const transport: IRequestTransport = { + isTestnet: false, + request(_endpoint: "info" | "exchange" | "explorer", payload: unknown): Promise { + const { type } = payload as { type: string }; + requestCounts.set(type, (requestCounts.get(type) ?? 0) + 1); + if (type === "meta" && failMetaRequest.current) { + failMetaRequest.current = false; + return Promise.reject(new Error("simulated transport failure")); + } + return Promise.resolve(responses[type] as T); + }, + }; + return { transport, requestCounts, failMetaRequest }; +} + // ============================================================ // Tests // ============================================================ @@ -189,3 +221,54 @@ describe("SymbolConverter reload() consistency", () => { assertEquals(converter.getSymbolBySpotPairId("@1"), before.spotSymbol); }); }); + +describe("SymbolConverter reload() dedup", () => { + test("concurrent reload() calls share one in-flight reload", async () => { + const { transport, requestCounts } = createCountingTransport(); + // One fetch round for creation: meta/spotMeta/outcomeMeta are each requested once. + const converter = await SymbolConverter.create({ transport }); + + const first = converter.reload(); + const second = converter.reload(); + const third = converter.reload(); + + // Concurrent callers receive the same in-flight promise... + assertStrictEquals(second, first); + assertStrictEquals(third, first); + await Promise.all([first, second, third]); + + // ...so only one additional fetch round ran: each info request was made exactly twice in total. + assertEquals(requestCounts.get("meta"), 2); + assertEquals(requestCounts.get("spotMeta"), 2); + assertEquals(requestCounts.get("outcomeMeta"), 2); + }); + + test("a reload started after the previous one settles issues a fresh fetch round", async () => { + const { transport, requestCounts } = createCountingTransport(); + const converter = await SymbolConverter.create({ transport }); + + await converter.reload(); + await converter.reload(); + + // The dedup window closes when the in-flight reload settles: create + two sequential reloads. + assertEquals(requestCounts.get("meta"), 3); + assertEquals(requestCounts.get("spotMeta"), 3); + assertEquals(requestCounts.get("outcomeMeta"), 3); + }); + + test("a rejected reload clears the in-flight slot so the next reload retries", async () => { + const { transport, requestCounts, failMetaRequest } = createCountingTransport(); + const converter = await SymbolConverter.create({ transport }); + + failMetaRequest.current = true; + await assertRejects(() => converter.reload(), Error, "simulated transport failure"); + + // The rejection must not poison the converter: a subsequent reload issues a fresh fetch round, + // and the previously published snapshot survives the failed attempt. + assertEquals(converter.getAssetId("BTC"), 0); + await converter.reload(); + + assertEquals(converter.getAssetId("BTC"), 0); + assertEquals(requestCounts.get("meta"), 3); // create + failed attempt + successful retry + }); +}); From e5299d97443e256c1b9019786c9e638b2b2ac67d Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 24 Aug 2026 08:16:13 -0700 Subject: [PATCH 6/7] docs: rate-limit details, retry/cache docs, known-drift updates (#111) --- docs/reference/known-drift.md | 29 +++++++++++++----- docs/transports.md | 57 +++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/docs/reference/known-drift.md b/docs/reference/known-drift.md index 0415d664..507e993f 100644 --- a/docs/reference/known-drift.md +++ b/docs/reference/known-drift.md @@ -100,10 +100,9 @@ When an entry is resolved upstream (docs fixed, or server aligned with docs), mo - **Docs claim:** each entry of `outcomes` carries no `deployer`. - **Server reality:** every outcome in the response now includes `deployer`; the schema-coverage check reports `additionalProperty: "deployer"` across the whole `outcomes` array (observed at indices 0 through 157+). -- **SDK behavior:** runtime unaffected — Info responses are delivered to callers as received, so the field is present - on the objects you get. The `OutcomeMetaResponse` **type** in `src/api/info/_methods/outcomeMeta.ts` does not - declare it yet, so it is invisible to TypeScript and `tests/api/info/outcomeMeta.test.ts` fails online until the - type is widened. Per [Versioning](../README.md#versioning) that type change ships in a patch release. +- **SDK behavior:** fixed — `OutcomeMetaResponse` in `src/api/info/_methods/outcomeMeta.ts` declares + `deployer` as an optional field, so it is typed and the schema-coverage test accepts it. The docs still don't + mention the field, so this entry stays open until they do. ### 10. `validatorL1Votes` actions gained `registerTemplate` @@ -113,9 +112,25 @@ When an entry is resolved upstream (docs fixed, or server aligned with docs), mo `registerTokensAndStandaloneOutcome`, so it matches no variant of the documented union — the check reports both `missingProperty: "registerTokensAndStandaloneOutcome"` and `additionalProperty: "registerTemplate"` for the same sample. -- **SDK behavior:** runtime unaffected for the same reason as #9; the union in - `src/api/info/_methods/validatorL1Votes.ts` needs a `registerTemplate` variant, and - `tests/api/info/validatorL1Votes.test.ts` fails online until it has one. +- **SDK behavior:** fixed — the union in `src/api/info/_methods/validatorL1Votes.ts` includes the + `registerTemplate` variant (and the `settleQuestion2` variant added alongside it), so live votes validate. The + docs still don't show the variant, so this entry stays open until they do. + +### 11. Aug-2026 outcome-template surface is undocumented + +- **Observed:** 2026-08-23. +- **Docs claim:** the info-endpoint and exchange-endpoint pages have no entries for `outcomeTemplates`, + `usdcRouting`, `activateOutcomeDeployer`, the `spotDeploy` outcome sub-actions + (`registerStandaloneOutcomeFromTemplate`, `registerQuestionFromTemplate`, `settleOutcome`, `settleQuestion2`), + `twapOrder`'s `details` (trigger/stop), `reserveRequestWeight`'s `destination`, or `marginTable`'s `dex` + parameter. +- **Server reality:** all of the above are live — they shipped in the Aug-2026 "HIP-4 outcome templates" API drop. +- **SDK behavior:** supported — schemas were implemented against the reference TypeScript SDK + ([nktkas/hyperliquid](https://github.com/nktkas/hyperliquid) v0.33.3), which tracks the deployed API, then + widened where live testnet responses went further: `outcomeTemplates` serves keyword formats `uDecimal`, `uInt`, + and `shortString` and a `role` union of `standaloneOutcome` / `questionOutcome` / `"question"` that the upstream + schema doesn't cover (observed 2026-08-24). If the official docs publish different shapes when they catch up, + reconcile the schemas then. ## Resolved diff --git a/docs/transports.md b/docs/transports.md index 57ab679c..009a4e5d 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -122,11 +122,13 @@ The limiter bills the documented weights: | `explorer` | 40 | | `exchange` | `1 + floor(batchLength / 40)` | -The exchange batch length is read from the action's `orders`/`cancels`/`modifies` array, unwrapping multi-sig -actions (the batch lives inside `action.payload.action`). Those three keys are the documented batch subset — other -actions carry arrays that are not batch-billed (`spotDeploy`/`perpDeploy` payloads, the multi-sig `signatures` -array), so the limiter deliberately does not bill by a generic "first array" rule; whether the protocol's -`batch_length` covers anything more is tracked in [issue #49](https://github.com/bloxwap/hyperliquid/issues/49). +The exchange batch length is the longest array found anywhere in the action at any depth, unwrapping multi-sig +actions (the batch lives inside `action.payload.action`; the wrapper's `signatures` array is auth material, never +billed). That covers the documented batch keys (`orders`/`cancels`/`modifies`) and also bills deployer arrays +(`spotDeploy` genesis tuples, `perpDeploy` setter lists) and any future batch action — the docs define +`batch_length` as "the length of the array in the action" without closing the set, and over-billing is the safe +side. Arrays shorter than 40 entries (fixed tuples), `twapOrder`'s single `twap` object, and +`convertToMultiSigUser`'s wire-stringified `signers` keep the minimum weight of 1. Response-size surcharges can only be known once the response arrives, so they are debited from the bucket **after** the response: 1 extra weight per 20 returned items on the documented list endpoints (`recentTrades`, `userFills`, @@ -135,7 +137,8 @@ then wait off the real cost rather than the estimate the request was sent with. that older `blockList` blocks "may be weighted more heavily" server-side, so the +1-per-block debit is exact only for recent blocks; and the item-count rule is an interpretation — the docs do not say whether the count is exactly the top-level response array length (what the limiter bills) nor whether partial chunks round up or down (the -limiter rounds up, the conservative choice). Both are tracked in [issue #49](https://github.com/bloxwap/hyperliquid/issues/49). +limiter rounds up). Both are settled client-side as deliberate conservative over-estimates; only the server-side +truth remains outstanding ([issue #49](https://github.com/bloxwap/hyperliquid/issues/49)). - The wait happens before the request timeout is armed, so throttling never trips `timeout` / `exchangeTimeout`; aborting the request's signal cancels the wait instead — an aborted request never reaches the wire. @@ -151,8 +154,44 @@ exceed what one instance can see still hit the server limit, and there is no end state. Handle [`HttpRateLimitError`](error-handling.md#httpratelimiterror) (which carries `status` and, when the server sends a `Retry-After` header, a `retryAfter` hint in seconds) as the backstop. +### Automatic retry on 429 + +For hands-off 429 handling, opt into `retryOnRateLimit`: + +```ts +const transport = new HttpTransport({ + retryOnRateLimit: true, // or { maxRetries: 3, maxDelayMs: 30_000 } — the defaults +}); +``` + +When the server answers 429, the transport waits and retries instead of throwing: if the response carried a +`Retry-After` header it waits exactly that long (plus up to 1 s of jitter to spread herds); otherwise it falls back +to full-jitter exponential backoff. A `Retry-After` longer than `maxDelayMs` surfaces the `HttpRateLimitError` +rather than retrying sooner than the server allowed, and after `maxRetries` attempts the error propagates. The +overall `timeout` / `exchangeTimeout` spans every attempt and wait, caller aborts interrupt the wait, and when +`rateLimit` is also enabled each retry re-debits the bucket (the server bills attempts, not logical requests). The +two options complement each other: the limiter prevents 429s, the retry absorbs the rest. Off by default. + The separate **address-based** limits (requests allowed per user, growing with cumulative trading volume) are what the [`userRateLimit`](clients.md) info method reports — it has no view of the shared per-IP weight budget either. +Per the official docs, an address gets 1 request per 1 USDC traded cumulatively since inception, on top of an +initial buffer of 10,000 requests; once limited, it is allowed one request every 10 seconds. Sub-accounts count as +separate users, and the limit applies to actions only, not info requests. Cancels get their own cumulative limit of +`min(limit + 100000, limit * 2)`, so hitting the address-based limit still leaves room to cancel open orders. +Batching interacts differently with the two budgets: a batch of `n` orders (or cancels) counts as one request +against the per-IP weight budget but as `n` requests against the address-based one. + +Three adjacent rules from the exchange-endpoint docs matter to anyone pacing orders: + +- **Open-order limit** — 1000 open orders per user plus one more per 5M USDC of trading volume, capped at 5000 + total. An order placed while the user already has at least 1000 open orders is rejected if it is reduce-only or a + trigger order. +- **High-congestion throttling** — during high congestion an address is limited to 2x its previous-day maker-share + percentage of the block space; the maker share is scaled by the asset's fee-tier volume contribution (HIP-3 assets + under growth mode count less) and computed once per UTC day. During high traffic it therefore helps not to resend + cancels whose results the API already returned. +- **Stale `expiresAfter`** — an action canceled because its `expiresAfter` timestamp went stale consumes 5x the + usual address-based rate limit. ## WebSocket @@ -270,6 +309,12 @@ in their own text ("across all websocket connections"): | Messages sent to Hyperliquid | 2000/minute | per IP, across all connections | | Simultaneous inflight post requests | 100 | per IP, across all connections | +The unique-user value needs a caveat: the official docs still say **10**, while the server's own refusal message +says 15 (`Cannot track more than 15 total users.`) — and neither number is what the server enforces. A live mainnet +probe found the 15th distinct user refused, so the SDK guards at **14**, one below the message's claim +(`src/transport/websocket/_quota.ts`). Erring low is the safe direction: refusing one subscription the server might +have taken costs a slot, while admitting one it refuses costs a 10 s request timeout. + Because that scope is the IP and not the socket, every `WebSocketTransport` on a network **shares one budget** by default, and the subscription and unique-user guards count what the server counts. Two transports no longer admit 2000 subscriptions against a limit of 1000 — the excess is refused locally with a clear From 4c4785a82b15a882657a540d7d84e67559afa055 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 24 Aug 2026 08:39:40 -0700 Subject: [PATCH 7/7] fix(perf): unbreak reconnect_resubscribe_burst against paced sends and keep-alive Red on main since 40c8bef: the mock never answered keep-alive pings, so the watchdog force-reconnected mid-burst and rejected in-flight subscribes; and paced sends (30 ms/msg) spread the 500-frame burst past the 10 s request timeout. The mock now pongs pings, the scenario disables the timeout it is not measuring, and echoes wait for the full burst to reach the wire. --- tests/perf/_helpers.ts | 6 ++++++ tests/perf/scenarios/subscription.ts | 22 +++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/perf/_helpers.ts b/tests/perf/_helpers.ts index 8a619511..a6426d7f 100644 --- a/tests/perf/_helpers.ts +++ b/tests/perf/_helpers.ts @@ -142,6 +142,12 @@ export class MockWebSocket extends EventTarget { } catch { return; // not JSON: nothing to auto-answer } + if (frame?.method === "ping") { + // Keep-alive watchdog: the real server always pongs, and without this a long-paced + // scenario looks silent and gets force-reconnected mid-measurement. + queueMicrotask(() => this.serverSend({ channel: "pong" })); + return; + } if (frame?.method === "subscribe" || frame?.method === "unsubscribe") { const confirmation = { channel: "subscriptionResponse", diff --git a/tests/perf/scenarios/subscription.ts b/tests/perf/scenarios/subscription.ts index 6c369c0b..caa52a77 100644 --- a/tests/perf/scenarios/subscription.ts +++ b/tests/perf/scenarios/subscription.ts @@ -165,8 +165,10 @@ scenario({ }, run: async () => { // A fresh transport per sample: the burst's cost is a function of how many requests are in - // flight, and a reused transport would only accumulate settled subscriptions. - const transport = new WebSocketTransport({ url: "wss://perf.local/ws" }); + // flight, and a reused transport would only accumulate settled subscriptions. The default + // 10 s request timeout is disabled: outbound pacing spreads the burst over ~15 s, and this + // scenario measures echo dispatch, not timeout behavior. + const transport = new WebSocketTransport({ url: "wss://perf.local/ws", timeout: null }); await transport.ready(); const socket = lastMockWebSocket(); @@ -174,12 +176,26 @@ scenario({ // arrive, which is exactly the reconnect shape this scenario measures. const frames: string[] = []; socket.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => { - frames.push(String(data)); + const text = String(data); + // Pings are keep-alive traffic, not burst frames: answer them like the real server and + // keep them out of the echo list (an unanswered ping force-reconnects mid-scenario). + if (text === '{"method":"ping"}') { + queueMicrotask(() => socket.serverSend({ channel: "pong" })); + return; + } + frames.push(text); }; const client = new SubscriptionClient({ transport }); const subs = Array.from({ length: BURST_SUBSCRIPTIONS }, (_, i) => client.l2Book({ coin: `BURST${i}` }, () => {})); + // Outbound pacing trickles the burst out over real time, so `frames` is still filling when + // Array.from returns; wait for the last subscribe to hit the wire before echoing, or the + // tail of the burst would never be answered. + while (frames.length < BURST_SUBSCRIPTIONS) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + // The server echoes each subscription verbatim, one frame per task; the microtask flushes // let each confirmed request dequeue before the next echo, as on a real socket. const echoes = frames.map((frame) => {