From 68c3ddd13fe91f07f11f038a59fd438712ff4f5e Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Sat, 10 Oct 2026 19:35:34 -0700 Subject: [PATCH] feat(sdk): support the trailingStop exchange action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyperliquid's dedicated trailing stop action places a native trailing stop with a fixed retracement distance — { pct } as a percent string or { px } as an absolute price — plus a nullable activationPx. Its success response carries data.oid directly instead of an order-style statuses array, so the error parser gains a fourth shape for data-level { error } responses, at runtime (isErrorResponse/getErrorMessage) and at the type level (ExcludeErrorResponse). Wire it up end to end: trailingStop() and buildTrailingStop() under api/exchange, an actions/trailingStop entry point, ExchangeClient.trailingStop with coin-symbol resolution (CoinTrailingStopParameters), package.json exports, offline + conformance tests, and the clients.md symbol-orders inventory and example. --- apps/docs/content/docs/clients.md | 12 +- packages/hyperliquid/package.json | 2 + packages/hyperliquid/src/actions/mod.ts | 1 + .../hyperliquid/src/actions/trailingStop.ts | 7 + packages/hyperliquid/src/api/_errors.ts | 10 ++ .../src/api/exchange/_methods/_base/errors.ts | 33 +++- .../src/api/exchange/_methods/_base/mod.ts | 1 + .../src/api/exchange/_methods/trailingStop.ts | 168 ++++++++++++++++++ .../hyperliquid/src/api/exchange/client.ts | 58 ++++++ packages/hyperliquid/src/api/exchange/mod.ts | 2 + .../tests/api/exchange/_client.test.ts | 21 +++ .../api/exchange/_coinResolution.test.ts | 21 +++ .../tests/api/exchange/_errors.test.ts | 31 +++- .../tests/api/exchange/trailingStop.test.ts | 140 +++++++++++++++ .../signing/canonicalize_conformance.test.ts | 13 ++ 15 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 packages/hyperliquid/src/actions/trailingStop.ts create mode 100644 packages/hyperliquid/src/api/exchange/_methods/trailingStop.ts create mode 100644 packages/hyperliquid/tests/api/exchange/trailingStop.test.ts diff --git a/apps/docs/content/docs/clients.md b/apps/docs/content/docs/clients.md index 9170db95..47993a15 100644 --- a/apps/docs/content/docs/clients.md +++ b/apps/docs/content/docs/clients.md @@ -341,12 +341,20 @@ await client.order({ }); await client.cancel({ cancels: [{ coin: "BTC", o: 12345 }] }); +await client.trailingStop({ + coin: "BTC", + isBuy: false, + sz: "0.1", + reduceOnly: true, + retracement: { pct: "1.5%" }, // or { px: "30000" } for an absolute retracement + activationPx: null, +}); await client.updateLeverage({ coin: "BTC", isCross: true, leverage: 5 }); ``` This works on `order`, `modify`, `batchModify`, `cancel`, `cancelByCloid`, `twapOrder`, `twapCancel`, -`updateLeverage`, `updateIsolatedMargin`, and `topUpIsolatedOnlyMargin`. Entries without a `coin` are dispatched -unchanged — no metadata is fetched unless a call actually uses a symbol. +`trailingStop`, `updateLeverage`, `updateIsolatedMargin`, and `topUpIsolatedOnlyMargin`. Entries without a `coin` +are dispatched unchanged — no metadata is fetched unless a call actually uses a symbol. The symbol metadata comes from the client's `symbolConverter` config. When omitted, the client lazily creates and loads a `SymbolConverter` over the same transport on the first symbol-based call; pass a preloaded instance to enable diff --git a/packages/hyperliquid/package.json b/packages/hyperliquid/package.json index f3e02025..2ba6e4ec 100644 --- a/packages/hyperliquid/package.json +++ b/packages/hyperliquid/package.json @@ -191,6 +191,7 @@ "./api/exchange/submitPrepared": "./src/api/exchange/_methods/submitPrepared.ts", "./api/exchange/tokenDelegate": "./src/api/exchange/_methods/tokenDelegate.ts", "./api/exchange/topUpIsolatedOnlyMargin": "./src/api/exchange/_methods/topUpIsolatedOnlyMargin.ts", + "./api/exchange/trailingStop": "./src/api/exchange/_methods/trailingStop.ts", "./api/exchange/twapCancel": "./src/api/exchange/_methods/twapCancel.ts", "./api/exchange/twapOrder": "./src/api/exchange/_methods/twapOrder.ts", "./api/exchange/updateIsolatedMargin": "./src/api/exchange/_methods/updateIsolatedMargin.ts", @@ -289,6 +290,7 @@ "./actions/subAccountTransfer": "./src/actions/subAccountTransfer.ts", "./actions/tokenDelegate": "./src/actions/tokenDelegate.ts", "./actions/topUpIsolatedOnlyMargin": "./src/actions/topUpIsolatedOnlyMargin.ts", + "./actions/trailingStop": "./src/actions/trailingStop.ts", "./actions/twapCancel": "./src/actions/twapCancel.ts", "./actions/twapOrder": "./src/actions/twapOrder.ts", "./actions/updateIsolatedMargin": "./src/actions/updateIsolatedMargin.ts", diff --git a/packages/hyperliquid/src/actions/mod.ts b/packages/hyperliquid/src/actions/mod.ts index 0b484380..10a718d7 100644 --- a/packages/hyperliquid/src/actions/mod.ts +++ b/packages/hyperliquid/src/actions/mod.ts @@ -45,6 +45,7 @@ export * from "./subAccountSpotTransfer.ts"; export * from "./subAccountTransfer.ts"; export * from "./tokenDelegate.ts"; export * from "./topUpIsolatedOnlyMargin.ts"; +export * from "./trailingStop.ts"; export * from "./twapCancel.ts"; export * from "./twapOrder.ts"; export * from "./updateIsolatedMargin.ts"; diff --git a/packages/hyperliquid/src/actions/trailingStop.ts b/packages/hyperliquid/src/actions/trailingStop.ts new file mode 100644 index 00000000..c3a606f0 --- /dev/null +++ b/packages/hyperliquid/src/actions/trailingStop.ts @@ -0,0 +1,7 @@ +/** Canonical trailingStop action builder. @module */ +export { buildTrailingStop } from "../api/exchange/_methods/trailingStop.ts"; +export type { + TrailingStopOptions, + TrailingStopSuccessResponse, + TrailingStopParameters, +} from "../api/exchange/_methods/trailingStop.ts"; diff --git a/packages/hyperliquid/src/api/_errors.ts b/packages/hyperliquid/src/api/_errors.ts index f15b6d35..af5b4c4b 100644 --- a/packages/hyperliquid/src/api/_errors.ts +++ b/packages/hyperliquid/src/api/_errors.ts @@ -43,6 +43,15 @@ export interface ApiSingleErrorResponse { }; } +/** Data-level error response where `response.data` itself carries the error (e.g. `trailingStop`). */ +export interface ApiDataErrorResponse { + /** Response details. */ + response: { + /** Specific data carrying the error message. */ + data: { error: string }; + }; +} + /** Explorer endpoint error envelope (`{ type: "error", message }`). */ export interface ApiExplorerErrorResponse { /** Envelope type. */ @@ -62,6 +71,7 @@ export type ApiErrorResponse = | ApiTopLevelErrorResponse | ApiBulkErrorResponse | ApiSingleErrorResponse + | ApiDataErrorResponse | ApiExplorerErrorResponse; // ============================================================ diff --git a/packages/hyperliquid/src/api/exchange/_methods/_base/errors.ts b/packages/hyperliquid/src/api/exchange/_methods/_base/errors.ts index 5debd21d..ccdcc698 100644 --- a/packages/hyperliquid/src/api/exchange/_methods/_base/errors.ts +++ b/packages/hyperliquid/src/api/exchange/_methods/_base/errors.ts @@ -5,6 +5,7 @@ import { type ApiBulkErrorResponse, + type ApiDataErrorResponse, ApiRequestError, type ApiSingleErrorResponse, type ApiTopLevelErrorResponse, @@ -13,6 +14,7 @@ import { export { ApiRequestError }; export type { ApiBulkErrorResponse, + ApiDataErrorResponse, ApiErrorResponse, ApiExplorerErrorResponse, ApiSingleErrorResponse, @@ -49,11 +51,18 @@ function isSingleError(r: unknown): r is ApiSingleErrorResponse { return hasErrorField(status); } -/** True if `r` matches any of the three Hyperliquid error response shapes. */ +/** True if `r` matches `{ response: { data: { error } } }` (e.g. `trailingStop`). */ +function isDataError(r: unknown): r is ApiDataErrorResponse { + if (typeof r !== "object" || r === null) return false; + const data = (r as { response?: { data?: unknown } }).response?.data; + return hasErrorField(data); +} + +/** True if `r` matches any of the four Hyperliquid error response shapes. */ export function isErrorResponse( r: unknown, -): r is ApiTopLevelErrorResponse | ApiBulkErrorResponse | ApiSingleErrorResponse { - return isTopLevelError(r) || isBulkError(r) || isSingleError(r); +): r is ApiTopLevelErrorResponse | ApiBulkErrorResponse | ApiSingleErrorResponse | ApiDataErrorResponse { + return isTopLevelError(r) || isBulkError(r) || isSingleError(r) || isDataError(r); } // ============================================================ @@ -73,6 +82,9 @@ function getErrorMessage(r: unknown): string | undefined { if (isSingleError(r)) { return r.response.data.status.error; } + if (isDataError(r)) { + return r.response.data.error; + } return undefined; } @@ -128,5 +140,16 @@ type ExcludeSingleError = T extends { response: { data: { status: infer S } } > : T; -/** Exclude all three Hyperliquid error response shapes from `T`. */ -export type ExcludeErrorResponse = ExcludeTopLevelError>>; +/** Filter out error variant from `response.data` itself (e.g. `trailingStop`). */ +type ExcludeDataError = T extends { response: { data: infer D } } + ? Exclude extends never + ? never + : Prettify< + Omit & { + response: Prettify & { data: Exclude }>; + } + > + : T; + +/** Exclude all four Hyperliquid error response shapes from `T`. */ +export type ExcludeErrorResponse = ExcludeTopLevelError>>>; diff --git a/packages/hyperliquid/src/api/exchange/_methods/_base/mod.ts b/packages/hyperliquid/src/api/exchange/_methods/_base/mod.ts index 09a896d2..cfb1fc37 100644 --- a/packages/hyperliquid/src/api/exchange/_methods/_base/mod.ts +++ b/packages/hyperliquid/src/api/exchange/_methods/_base/mod.ts @@ -12,6 +12,7 @@ export type { export { buildAction } from "./_action.ts"; export { type ApiBulkErrorResponse, + type ApiDataErrorResponse, type ApiErrorResponse, type ApiExplorerErrorResponse, ApiRequestError, diff --git a/packages/hyperliquid/src/api/exchange/_methods/trailingStop.ts b/packages/hyperliquid/src/api/exchange/_methods/trailingStop.ts new file mode 100644 index 00000000..e57205cf --- /dev/null +++ b/packages/hyperliquid/src/api/exchange/_methods/trailingStop.ts @@ -0,0 +1,168 @@ +import { canonicalAction, type CanonicalAction } from "../../../actions/_canonical.ts"; +import * as v from "valibot"; + +// ============================================================ +// API Schemas +// ============================================================ + +import { Address, Hex, Percent, UnsignedDecimal, UnsignedInteger } from "../../_schemas.ts"; + +/** + * Place a trailing stop order. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#place-a-trailing-stop-order + */ +export const TrailingStopRequest = /* @__PURE__ */ (() => { + return v.object({ + /** Action to perform. */ + action: v.object({ + /** Type of action. */ + type: v.literal("trailingStop"), + /** Asset ID. */ + asset: UnsignedInteger, + /** Position side (`true` for long, `false` for short). */ + isBuy: v.boolean(), + /** Size (in base currency units). */ + sz: UnsignedDecimal, + /** Whether the order is reduce-only. */ + reduceOnly: v.boolean(), + /** Retracement distance from the extreme price at which the stop triggers. */ + retracement: v.union([ + v.object({ + /** Retracement as a percentage of the price (e.g., "1.234%"). */ + pct: Percent, + }), + v.object({ + /** Retracement as an absolute price distance. */ + px: UnsignedDecimal, + }), + ]), + /** Price at which the trailing stop activates (`null` to activate immediately). */ + activationPx: v.nullable(UnsignedDecimal), + }), + /** 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]), + }), + /** Vault address (for vault trading). */ + vaultAddress: v.optional(Address), + /** Expiration time of the action. */ + expiresAfter: v.optional(UnsignedInteger), + }); +})(); +export type TrailingStopRequest = v.InferOutput; + +/** + * Response for placing a trailing stop order. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#place-a-trailing-stop-order + */ +export type TrailingStopResponse = { + /** Successful status. */ + status: "ok"; + /** Response details. */ + response: { + /** Type of response. */ + type: "trailingStop"; + /** Specific data. */ + data: + | { + /** Order ID of the placed trailing stop order. */ + oid: number; + } + | { + /** Error message. */ + error: 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 TrailingStopActionSchema = /* @__PURE__ */ (() => { + return v.object(TrailingStopRequest.entries.action.entries); +})(); + +/** Action parameters for the {@linkcode trailingStop} function. */ +export type TrailingStopParameters = Omit, "type">; + +/** Request options for the {@linkcode trailingStop} function. */ +export type TrailingStopOptions = ExtractRequestOptions>; + +/** Successful variant of {@linkcode TrailingStopResponse} without errors. */ +export type TrailingStopSuccessResponse = ExcludeErrorResponse; + +/** + * Place a trailing stop order. + * + * 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 variant of {@link TrailingStopResponse} without error status. + * + * @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 { trailingStop } from "@bloxwap/hyperliquid/api/exchange"; + * import { privateKeyToAccount } from "viem/accounts"; + * + * const wallet = privateKeyToAccount("0x..."); + * const transport = new HttpTransport(); // or `WebSocketTransport` + * + * const data = await trailingStop({ transport, wallet }, { + * asset: 0, + * isBuy: true, + * sz: "0.1", + * reduceOnly: false, + * retracement: { pct: "1.234%" }, + * activationPx: "30000", + * }); + * ``` + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#place-a-trailing-stop-order + */ +export function trailingStop( + config: ExchangeConfig, + params: TrailingStopParameters, + opts?: TrailingStopOptions, +): Promise { + const action = buildAction(TrailingStopActionSchema, { type: "trailingStop", ...params }, opts); + return executeL1Action(config, action, opts); +} + +/** + * Build a canonical {@linkcode trailingStop} action: validate, normalize, fill defaults, and copy and freeze + * the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed + * and submitted later, or reused while its fields stay valid. + * + * @param params Parameters specific to the API request. + * @return Immutable action typed with {@link TrailingStopSuccessResponse}. + * + * @throws {ValidationError} When the request parameters fail validation. + */ +export function buildTrailingStop(params: TrailingStopParameters): CanonicalAction { + const action = buildAction(TrailingStopActionSchema, { type: "trailingStop", ...params }); + return canonicalAction(action, { kind: "l1" }); +} diff --git a/packages/hyperliquid/src/api/exchange/client.ts b/packages/hyperliquid/src/api/exchange/client.ts index d1e89838..4f3c932d 100644 --- a/packages/hyperliquid/src/api/exchange/client.ts +++ b/packages/hyperliquid/src/api/exchange/client.ts @@ -278,6 +278,12 @@ import { type TopUpIsolatedOnlyMarginParameters, type TopUpIsolatedOnlyMarginSuccessResponse, } from "./_methods/topUpIsolatedOnlyMargin.ts"; +import { + trailingStop, + type TrailingStopOptions, + type TrailingStopParameters, + type TrailingStopSuccessResponse, +} from "./_methods/trailingStop.ts"; import { twapCancel, type TwapCancelOptions, @@ -463,6 +469,9 @@ export type CoinUpdateIsolatedMarginParameters = CoinResolvable; +/** {@linkcode TrailingStopParameters} with a `coin` symbol alternative to the raw asset ID `asset`. */ +export type CoinTrailingStopParameters = CoinResolvable; + // ============================================================ // Symbol Resolution // ============================================================ @@ -2488,6 +2497,49 @@ export class ExchangeClient { + if (params.coin === undefined) { + return trailingStop(this.config, params as TrailingStopParameters, opts); + } + return resolveCoinRefs(this.config, [params], "asset").then(([resolved]) => + trailingStop(this.config, resolved as TrailingStopParameters, opts), + ); + } + /** * Cancel a TWAP order. * @@ -3169,6 +3221,7 @@ export class ExchangeClient = { action: { type: "topUpIsolatedOnlyMargin", asset: 0, leverage: "0.5" }, invalid: (c) => c.topUpIsolatedOnlyMargin({} as never), }, + trailingStop: { + run: (c) => + c.trailingStop({ + asset: 0, + isBuy: true, + sz: "0.1", + reduceOnly: false, + retracement: { pct: "1.234%" }, + activationPx: "30000", + }), + action: { + type: "trailingStop", + asset: 0, + isBuy: true, + sz: "0.1", + reduceOnly: false, + retracement: { pct: "1.234%" }, + activationPx: "30000", + }, + invalid: (c) => c.trailingStop({} as never), + }, twapCancel: { run: (c) => c.twapCancel({ a: 0, t: 1 }), action: { type: "twapCancel", a: 0, t: 1 }, diff --git a/packages/hyperliquid/tests/api/exchange/_coinResolution.test.ts b/packages/hyperliquid/tests/api/exchange/_coinResolution.test.ts index a82a514b..a824392a 100644 --- a/packages/hyperliquid/tests/api/exchange/_coinResolution.test.ts +++ b/packages/hyperliquid/tests/api/exchange/_coinResolution.test.ts @@ -225,6 +225,27 @@ describe("ExchangeClient coin resolution (offline)", () => { run: (c) => c.topUpIsolatedOnlyMargin({ coin: "BTC", leverage: "0.5" }), action: { type: "topUpIsolatedOnlyMargin", asset: 0, leverage: "0.5" }, }, + { + name: "trailingStop", + run: (c) => + c.trailingStop({ + coin: "BTC", + isBuy: true, + sz: "0.1", + reduceOnly: false, + retracement: { pct: "1.234%" }, + activationPx: null, + }), + action: { + type: "trailingStop", + asset: 0, + isBuy: true, + sz: "0.1", + reduceOnly: false, + retracement: { pct: "1.234%" }, + activationPx: null, + }, + }, ]; for (const { name, run, action } of COIN_CASES) { diff --git a/packages/hyperliquid/tests/api/exchange/_errors.test.ts b/packages/hyperliquid/tests/api/exchange/_errors.test.ts index 1d1d205b..2060fa9d 100644 --- a/packages/hyperliquid/tests/api/exchange/_errors.test.ts +++ b/packages/hyperliquid/tests/api/exchange/_errors.test.ts @@ -8,7 +8,7 @@ import { describe, test } from "bun:test"; import { assertEquals, assertRejects } from "@jsr/std__assert"; import type { IRequestTransport } from "@bloxwap/hyperliquid"; -import { ApiRequestError, order } from "@bloxwap/hyperliquid/api/exchange"; +import { ApiRequestError, order, trailingStop } from "@bloxwap/hyperliquid/api/exchange"; import { privateKeyToAccount } from "viem/accounts"; const wallet = privateKeyToAccount("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); @@ -25,6 +25,15 @@ function transportWith(response: unknown): IRequestTransport { const LIMIT_ORDER = { a: 0, b: true, p: "30000", s: "0.1", r: false, t: { limit: { tif: "Gtc" } } } as const; +const TRAILING_STOP = { + asset: 0, + isBuy: true, + sz: "0.1", + reduceOnly: false, + retracement: { pct: "1.234%" }, + activationPx: "30000", +} as const; + describe("exchange error responses", () => { test("nested per-order error throws ApiRequestError", async () => { const response = { @@ -60,6 +69,26 @@ describe("exchange error responses", () => { assertEquals(await order({ transport: transportWith(response), wallet }, { orders: [LIMIT_ORDER] }), response); }); + test("data-level error (response.data.error) throws ApiRequestError with the server message", async () => { + const response = { status: "ok", response: { type: "trailingStop", data: { error: "Unknown asset" } } }; + + const error = await assertRejects( + () => trailingStop({ transport: transportWith(response), wallet }, TRAILING_STOP), + ApiRequestError, + "Unknown asset", + ); + assertEquals(error.response, response); + }); + + test("data-level success payload (response.data.oid) is returned as data", async () => { + const response: Awaited> = { + status: "ok", + response: { type: "trailingStop", data: { oid: 77738308 } }, + }; + + assertEquals(await trailingStop({ transport: transportWith(response), wallet }, TRAILING_STOP), response); + }); + test("the typed response narrows to the top-level error shape", async () => { const response = { status: "err", response: "Must deposit before performing actions." } as const; diff --git a/packages/hyperliquid/tests/api/exchange/trailingStop.test.ts b/packages/hyperliquid/tests/api/exchange/trailingStop.test.ts new file mode 100644 index 00000000..98794481 --- /dev/null +++ b/packages/hyperliquid/tests/api/exchange/trailingStop.test.ts @@ -0,0 +1,140 @@ +import { ApiRequestError, ValidationError } from "@bloxwap/hyperliquid"; +import { type TrailingStopParameters, TrailingStopRequest, trailingStop } from "@bloxwap/hyperliquid/api/exchange"; +import { formatPrice } from "@bloxwap/hyperliquid/utils"; +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 { typeToJsonSchema } from "../_utils/typeToJsonSchema.ts"; +import { valibotToJsonSchema } from "../_utils/valibotToJsonSchema.ts"; +import { FIXED_NONCE, recordingTransport, singleWalletConfig } from "./_mockTransport.ts"; +import { openOrder, runTest, symbolConverter, topUpPerp } from "./_t.ts"; + +const sourceFile = new URL("../../../src/api/exchange/_methods/trailingStop.ts", import.meta.url).pathname; +const responseSchema = typeToJsonSchema(sourceFile, "TrailingStopSuccessResponse"); +const paramsSchema = valibotToJsonSchema(v.omit(v.object(TrailingStopRequest.entries.action.entries), ["type"])); + +runTest({ + name: "trailingStop", + codeTestFn: async (_t, exchClient) => { + await topUpPerp(exchClient, "30"); + const opened = await openOrder(exchClient, "market", "SOL"); + + const szDecimals = symbolConverter.getSzDecimals("SOL")!; + const midPx = parseFloat(opened.midPx); + + const params: TrailingStopParameters[] = [ + // pct retracement | reduce-only sell trailing the opened long | activates immediately + { asset: opened.a, isBuy: false, sz: opened.s, reduceOnly: true, retracement: { pct: "5%" }, activationPx: null }, + // px retracement | buy side | deferred activation above the current price + { + asset: opened.a, + isBuy: true, + sz: "0.1", + reduceOnly: false, + retracement: { px: formatPrice(midPx * 0.05, szDecimals) }, + activationPx: formatPrice(midPx * 1.1, szDecimals), + }, + ]; + + const data = await Promise.all(params.map((p) => exchClient.trailingStop(p))); + + schemaCoverage(paramsSchema, params); + schemaCoverage(responseSchema, data); + }, +}); + +// ============================================================ +// Offline: retracement variants and activationPx flow into the L1 action +// ============================================================ + +const TRAILING_STOP = { + asset: 0, + isBuy: true, + sz: "0.1", + reduceOnly: false, + retracement: { pct: "1.234%" }, + activationPx: "30000", +} as const; + +describe("trailingStop (offline)", () => { + test("a pct retracement posts the exact wire action", async () => { + const { calls, transport } = recordingTransport(); + + await trailingStop(singleWalletConfig(transport), TRAILING_STOP); + + assertEquals(calls.length, 1); + assertEquals(calls[0].payload.action, { type: "trailingStop", ...TRAILING_STOP }); + assertEquals(calls[0].payload.nonce, FIXED_NONCE); + // Pinned signature over the full action — the hash preimage carries every field. + assertEquals(calls[0].payload.signature, { + r: "0x59eed0cdff8d6b5261e1f52feb37e4da7c3f256e74c152c108d5bae370e54679", + s: "0x20e2655aed29e363e0d7b8e43ea1d5e020377af5c1fd79bd23102b7329987ba7", + v: 27, + }); + }); + + test("a px retracement posts the exact wire action", async () => { + const { calls, transport } = recordingTransport(); + + await trailingStop(singleWalletConfig(transport), { ...TRAILING_STOP, retracement: { px: "100" } }); + + assertEquals(calls.length, 1); + assertEquals(calls[0].payload.action, { type: "trailingStop", ...TRAILING_STOP, retracement: { px: "100" } }); + }); + + test("a null activationPx is carried into the wire action", async () => { + const { calls, transport } = recordingTransport(); + + await trailingStop(singleWalletConfig(transport), { ...TRAILING_STOP, activationPx: null }); + + assertEquals(calls.length, 1); + assertEquals(calls[0].payload.action, { type: "trailingStop", ...TRAILING_STOP, activationPx: null }); + }); + + test("malformed inputs fail validation with zero requests sent", async () => { + const { calls, transport } = recordingTransport(); + const config = singleWalletConfig(transport); + + // Percent string without the `%` suffix. + await assertRejects( + async () => await trailingStop(config, { ...TRAILING_STOP, retracement: { pct: "1.234" } }), + ValidationError, + ); + // Retracement variant with neither `pct` nor `px`. + await assertRejects( + async () => await trailingStop(config, { ...TRAILING_STOP, retracement: {} as never }), + ValidationError, + ); + // Negative activation price. + await assertRejects( + async () => await trailingStop(config, { ...TRAILING_STOP, activationPx: "-30000" }), + ValidationError, + ); + assertEquals(calls.length, 0); + }); + + test("the success response exposes the order id at data.oid", async () => { + const response = { status: "ok", response: { type: "trailingStop", data: { oid: 77738308 } } } as const; + const { transport } = recordingTransport(response); + + const data = await trailingStop(singleWalletConfig(transport), TRAILING_STOP); + + assertEquals(data, response); + // Type-level: the success variant narrows `data` to `{ oid: number }`. + const oid: number = data.response.data.oid; + assertEquals(oid, 77738308); + }); + + test("a data-level error envelope rejects with ApiRequestError", async () => { + const response = { status: "ok", response: { type: "trailingStop", data: { error: "Unknown asset" } } } as const; + const { transport } = recordingTransport(response); + + const error = await assertRejects( + async () => await trailingStop(singleWalletConfig(transport), TRAILING_STOP), + ApiRequestError, + "Unknown asset", + ); + assertEquals(error.response, response); + }); +}); diff --git a/packages/hyperliquid/tests/signing/canonicalize_conformance.test.ts b/packages/hyperliquid/tests/signing/canonicalize_conformance.test.ts index c755d0cf..bbfff482 100644 --- a/packages/hyperliquid/tests/signing/canonicalize_conformance.test.ts +++ b/packages/hyperliquid/tests/signing/canonicalize_conformance.test.ts @@ -125,6 +125,19 @@ const REQUESTS: [name: string, schema: v.GenericSchema, input: unknown][] = [ exchange.TwapOrderRequest.entries.action as v.GenericSchema, { twap: { t: false, r: false, m: 30, s: "1", b: true, a: 0 }, type: "twapOrder" }, ], + [ + "trailingStop (union retracement, nullable activationPx)", + exchange.TrailingStopRequest.entries.action as v.GenericSchema, + { + activationPx: null, + retracement: { pct: "1.234%" }, + reduceOnly: false, + sz: "0.1", + isBuy: true, + asset: 0, + type: "trailingStop", + }, + ], [ "cSignerAction (variant whose options share the `type` literal)", exchange.CSignerActionRequest.entries.action as v.GenericSchema,