typescript
import { z } from "zod";
import { wallet } from "opentool/wallet";
import {
DEFAULT_HYPERLIQUID_MARKET_SLIPPAGE_BPS,
buildHyperliquidMarketIdentity,
extractHyperliquidOrderIds,
fetchHyperliquidAllMids,
fetchHyperliquidSizeDecimals,
HyperliquidApiError,
isHyperliquidSpotSymbol,
normalizeHyperliquidBaseSymbol,
normalizeSpotTokenName,
parseSpotPairSymbol,
resolveHyperliquidChainConfig,
resolveHyperliquidOrderRef,
resolveHyperliquidOrderSymbol,
resolveHyperliquidPair,
resolveSpotMidCandidates,
fetchHyperliquidSpotMetaAndAssetCtxs,
placeHyperliquidOrder,
updateHyperliquidLeverage,
} from "opentool/adapters/hyperliquid";
import { store } from "opentool/store";
import type { WalletFullContext } from "opentool/wallet";
import type {
HyperliquidTriggerOptions,
} from "opentool/adapters/hyperliquid";
const toNumber = (value: string): number | null => {
if (!value || !value.trim()) return null;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
};
type SpotUniverseItem = {
tokens?: number[];
index?: number;
};
type SpotToken = {
name?: string;
index?: number;
szDecimals?: number;
};
type SpotAssetContext = {
markPx?: string | number;
midPx?: string | number;
oraclePx?: string | number;
};
type SpotMarketInfo = {
pairSymbol: string;
baseSymbol: string;
quoteSymbol: string;
szDecimals: number;
markPrice: number;
};
const normalizeOrderSize = (
rawSize: string,
sizeDecimals: number | null
): string | null => {
const numeric = toNumber(rawSize);
if (numeric == null || numeric <= 0) return null;
if (sizeDecimals == null) return rawSize;
const precision = Math.max(0, Math.min(8, sizeDecimals));
const factor = 10 ** precision;
const floored = Math.floor(numeric * factor) / factor;
if (!Number.isFinite(floored) || floored <= 0) return null;
const fixed = floored.toFixed(precision);
return fixed.replace(/\.?0+$/, "");
};
const DEFAULT_HYPERLIQUID_PRICE_SIGFIGS = 5;
const ORDERBOOK_TICK_SAMPLE_LIMIT = 60;
const MAX_HYPERLIQUID_PRICE_DECIMALS = 8;
const toFiniteNumber = (value: unknown): number | null => {
if (typeof value !== "number") return null;
return Number.isFinite(value) ? value : null;
};
const readNumber = (value: unknown): number | null => {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const resolveSpotMarketInfo = (
meta: unknown,
assetCtxs: unknown,
symbol: string
): SpotMarketInfo | null => {
const payload = meta as { universe?: SpotUniverseItem[]; tokens?: SpotToken[] } | null;
const universe = Array.isArray(payload?.universe) ? payload?.universe : [];
const tokens = Array.isArray(payload?.tokens) ? payload?.tokens : [];
const contexts = Array.isArray(assetCtxs)
? (assetCtxs as SpotAssetContext[])
: [];
if (!universe.length || !tokens.length) return null;
const tokenMap = new Map<number, { name: string; szDecimals: number }>();
for (const token of tokens) {
const index = token?.index;
const szDecimals = readNumber(token?.szDecimals);
if (typeof index !== "number" || szDecimals == null) continue;
tokenMap.set(index, {
name: normalizeSpotTokenName(token?.name),
szDecimals,
});
}
const resolveMarket = (idx: number, market: SpotUniverseItem): SpotMarketInfo | null => {
const [baseIndex, quoteIndex] = Array.isArray(market?.tokens)
? market.tokens
: [];
const baseToken = tokenMap.get(baseIndex ?? -1);
const quoteToken = tokenMap.get(quoteIndex ?? -1);
if (!baseToken || !quoteToken) return null;
const ctx = contexts[idx] ?? null;
const price = readNumber(ctx?.markPx ?? ctx?.midPx ?? ctx?.oraclePx);
if (!price || price <= 0) return null;
return {
pairSymbol: `${baseToken.name}/${quoteToken.name}`,
baseSymbol: baseToken.name,
quoteSymbol: quoteToken.name,
szDecimals: baseToken.szDecimals,
markPrice: price,
};
};
if (symbol.startsWith("@")) {
const targetIndex = Number.parseInt(symbol.slice(1), 10);
if (!Number.isFinite(targetIndex)) return null;
for (let idx = 0; idx < universe.length; idx += 1) {
const market = universe[idx];
const marketIndex = typeof market?.index === "number" ? market.index : idx;
if (marketIndex !== targetIndex) continue;
return resolveMarket(idx, market);
}
return null;
}
const pair = parseSpotPairSymbol(symbol);
if (!pair) return null;
const normalizedBase = normalizeSpotTokenName(pair.base).toUpperCase();
const normalizedQuote = normalizeSpotTokenName(pair.quote).toUpperCase();
for (let idx = 0; idx < universe.length; idx += 1) {
const market = universe[idx];
const [baseIndex, quoteIndex] = Array.isArray(market?.tokens)
? market.tokens
: [];
const baseToken = tokenMap.get(baseIndex ?? -1);
const quoteToken = tokenMap.get(quoteIndex ?? -1);
if (!baseToken || !quoteToken) continue;
if (
baseToken.name.toUpperCase() === normalizedBase &&
quoteToken.name.toUpperCase() === normalizedQuote
) {
return resolveMarket(idx, market);
}
}
return null;
};
const resolveSpotMarketMidPrice = async (
environment: "mainnet" | "testnet",
spotInfo: SpotMarketInfo | null
): Promise<number | null> => {
if (!spotInfo?.baseSymbol) return spotInfo?.markPrice ?? null;
try {
const mids = await fetchHyperliquidAllMids(environment);
const candidates = resolveSpotMidCandidates(spotInfo.baseSymbol);
for (const symbol of candidates) {
const value = readNumber(mids[symbol]);
if (value != null && value > 0) {
return value;
}
}
} catch {
// Fallback to spot mark price if mid lookup fails.
}
return spotInfo.markPrice ?? null;
};
const extractTickSizeFromOrderbook = (orderbook: {
bids?: Array<{ price?: number }>;
asks?: Array<{ price?: number }>;
}): number | null => {
const prices: number[] = [];
const bids = orderbook.bids ?? [];
const asks = orderbook.asks ?? [];
for (const level of bids.slice(0, ORDERBOOK_TICK_SAMPLE_LIMIT)) {
const price = toFiniteNumber(level?.price);
if (price && price > 0) prices.push(price);
}
for (const level of asks.slice(0, ORDERBOOK_TICK_SAMPLE_LIMIT)) {
const price = toFiniteNumber(level?.price);
if (price && price > 0) prices.push(price);
}
const unique = Array.from(new Set(prices)).sort((a, b) => a - b);
if (unique.length < 2) return null;
let minDiff = Number.POSITIVE_INFINITY;
for (let i = 1; i < unique.length; i += 1) {
const diff = unique[i] - unique[i - 1];
if (diff > 0 && diff < minDiff) minDiff = diff;
}
return Number.isFinite(minDiff) ? minDiff : null;
};
const decimalsFromTick = (tick: number): number => {
const raw = tick.toString();
if (raw.includes("e-")) {
const exp = Number(raw.split("e-")[1]);
return Number.isFinite(exp) ? Math.max(0, exp) : 0;
}
const dot = raw.indexOf(".");
return dot === -1 ? 0 : raw.length - dot - 1;
};
const roundHyperliquidPriceToSigFigs = (
price: number,
side: "buy" | "sell",
sigFigs: number = DEFAULT_HYPERLIQUID_PRICE_SIGFIGS
): string => {
if (!Number.isFinite(price) || price <= 0) {
throw new Error("Market price must be positive.");
}
const effectiveSigFigs = Math.max(1, Math.floor(sigFigs));
const exponent = Math.floor(Math.log10(Math.abs(price)));
const tickExp = exponent - (effectiveSigFigs - 1);
const tick = 10 ** tickExp;
const steps = price / tick;
const roundedSteps = side === "buy" ? Math.ceil(steps) : Math.floor(steps);
const rounded = roundedSteps * tick;
if (!Number.isFinite(rounded) || rounded <= 0) {
throw new Error("Market price rounding failed.");
}
const decimals = Math.max(0, -tickExp);
const fixed = rounded.toFixed(decimals);
return fixed.replace(/\.?0+$/, "");
};
const resolveSigFigTick = (price: number, sigFigs: number): number => {
if (!Number.isFinite(price) || price <= 0) {
throw new Error("Market price must be positive.");
}
const effectiveSigFigs = Math.max(1, Math.floor(sigFigs));
const exponent = Math.floor(Math.log10(Math.abs(price)));
const tickExp = exponent - (effectiveSigFigs - 1);
return 10 ** tickExp;
};
const isTickMultiple = (tick: number, baseTick: number): boolean => {
if (!Number.isFinite(tick) || !Number.isFinite(baseTick) || baseTick <= 0) {
return false;
}
const ratio = tick / baseTick;
if (!Number.isFinite(ratio)) return false;
const rounded = Math.round(ratio);
return Math.abs(ratio - rounded) < 1e-9;
};
const roundHyperliquidPriceToTick = (
price: number,
side: "buy" | "sell",
tick: number
): string => {
if (!Number.isFinite(price) || price <= 0) {
throw new Error("Market price must be positive.");
}
if (!Number.isFinite(tick) || tick <= 0) {
throw new Error("Tick size must be positive.");
}
const steps = price / tick;
const roundedSteps = side === "buy" ? Math.ceil(steps) : Math.floor(steps);
const rounded = roundedSteps * tick;
if (!Number.isFinite(rounded) || rounded <= 0) {
throw new Error("Market price rounding failed.");
}
const decimals = decimalsFromTick(tick);
const fixed = rounded.toFixed(decimals);
return fixed.replace(/\.?0+$/, "");
};
const clampPriceDecimals = (value: string): string => {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error("Price must be positive.");
}
const fixed = parsed.toFixed(MAX_HYPERLIQUID_PRICE_DECIMALS);
return fixed.replace(/\.?0+$/, "");
};
const resolvePriceTick = (
price: number,
orderbookTick: number | null
): number => {
const sigFigTick = resolveSigFigTick(
price,
DEFAULT_HYPERLIQUID_PRICE_SIGFIGS
);
if (orderbookTick && orderbookTick > 0 && isTickMultiple(orderbookTick, sigFigTick)) {
return orderbookTick;
}
return sigFigTick;
};
export const schema = z.object({
symbol: z.string().min(1),
side: z.enum(["buy", "sell"]),
type: z.enum(["market", "limit"]).default("market"),
price: z
.union([z.string(), z.number()])
.optional()
.transform((v) => (v === undefined ? undefined : v.toString())),
size: z.union([z.string(), z.number()]).transform((v) => v.toString()),
cloid: z
.string()
.regex(/^0x[a-fA-F0-9]{32}$/, "cloid must be a 0x-prefixed 16-byte hex")
.optional(),
tif: z.enum(["FrontendMarket", "Ioc", "Gtc", "Alo"]).optional(),
slippageBps: z.number().int().min(0).max(5000).optional(),
leverage: z.number().positive().max(100).optional(),
leverageMode: z.enum(["cross", "isolated"]).default("cross"),
takeProfitPx: z.union([z.string(), z.number()]).optional(),
stopLossPx: z.union([z.string(), z.number()]).optional(),
reduceOnly: z.boolean().default(false),
environment: z.enum(["mainnet", "testnet"]).default("testnet"),
});
export const profile = {
description:
"Place a Hyperliquid entry (market or limit) with optional leverage, TP, SL, and reduce-only flag. TP/SL placed as separate reduce-only trigger orders.",
};
export async function POST(req: Request): Promise<Response> {
const originalFetch = globalThis.fetch;
let exchangeRequest: Record<string, unknown> | null = null;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
try {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : "";
if (url.includes("/exchange") && init?.body && typeof init.body === "string") {
const parsed = JSON.parse(init.body) as Record<string, unknown>;
if (parsed && typeof parsed === "object") {
if ("signature" in parsed) {
parsed.signature = "[redacted]";
}
exchangeRequest = parsed;
}
}
} catch {
// Ignore capture failures.
}
return originalFetch(input, init);
}) as typeof fetch;
try {
const body = await req.json().catch(() => ({}));
const parsed = schema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify({ ok: false, error: parsed.error.flatten() }),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
const { symbol, side, type, price, size, cloid, tif: userTif, leverage, leverageMode, takeProfitPx, stopLossPx, reduceOnly, environment } =
parsed.data;
const rawSymbol = symbol.trim();
const orderSymbol = resolveHyperliquidOrderSymbol(rawSymbol);
if (!orderSymbol) {
return new Response(
JSON.stringify({
ok: false,
error: "symbol must be a valid Hyperliquid market",
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
const isSpot = isHyperliquidSpotSymbol(orderSymbol);
const pair = resolveHyperliquidPair(rawSymbol);
let spotInfo: SpotMarketInfo | null = null;
if (isSpot) {
const spotPayload = await fetchHyperliquidSpotMetaAndAssetCtxs(environment).catch(
() => null
);
if (spotPayload) {
const [spotMeta, spotCtxs] = spotPayload as [unknown, unknown];
spotInfo = resolveSpotMarketInfo(spotMeta, spotCtxs, orderSymbol);
}
if (!spotInfo) {
return new Response(
JSON.stringify({
ok: false,
error: `Unknown Hyperliquid spot symbol: ${orderSymbol}`,
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
}
const normalizedSymbol = normalizeHyperliquidBaseSymbol(rawSymbol);
if (!normalizedSymbol && !isSpot) {
return new Response(
JSON.stringify({
ok: false,
error: "symbol must be a valid Hyperliquid market",
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
const chainConfig = resolveHyperliquidChainConfig(environment);
const ctx = await wallet({
chain: chainConfig.chain,
});
if (leverage !== undefined) {
if (isSpot) {
return new Response(
JSON.stringify({
ok: false,
error: "leverage is not supported for spot markets",
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
await updateHyperliquidLeverage({
wallet: ctx as WalletFullContext,
environment,
input: {
symbol: normalizedSymbol as string,
leverageMode,
leverage,
},
});
}
const tif = type === "market" ? "Ioc" : userTif ?? "Ioc";
const gatewayBase = process.env.OPENPOND_GATEWAY_URL?.replace(/\/$/, "");
const coin = normalizedSymbol ?? "";
let orderbookTick: number | null = null;
if (gatewayBase && !isSpot) {
try {
const bookRes = await fetch(
`${gatewayBase}/v1/hyperliquid/orderbook?symbol=${encodeURIComponent(coin)}&environment=${encodeURIComponent(environment)}`
);
if (bookRes.ok) {
const book = (await bookRes.json().catch(() => null)) as {
bids?: Array<{ price?: number }>;
asks?: Array<{ price?: number }>;
} | null;
if (book) {
orderbookTick = extractTickSizeFromOrderbook(book);
}
}
} catch {
orderbookTick = null;
}
}
// Resolve price for market orders using mid price.
let entryPrice = price;
let entryTick: number | null = null;
let marketMarkPrice: number | null = null;
let marketSlippageBps: number | null = null;
if (type === "market") {
if (isSpot) {
const spotMid = await resolveSpotMarketMidPrice(environment, spotInfo);
if (!spotMid || spotMid <= 0) {
throw new Error("Spot market did not return a valid mid price.");
}
marketMarkPrice = spotMid;
} else {
if (!gatewayBase) {
throw new Error(
"OPENPOND_GATEWAY_URL is not configured for price lookup."
);
}
const url = `${gatewayBase}/v1/hyperliquid/market-stats?symbol=${encodeURIComponent(
coin
)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`Failed to fetch market price (${res.status}) from gateway`
);
}
const stats = (await res.json().catch(() => null)) as {
markPrice?: number | null;
} | null;
const mark =
typeof stats?.markPrice === "number" && Number.isFinite(stats.markPrice)
? stats.markPrice
: null;
if (mark == null || mark <= 0) {
throw new Error("Gateway did not return a valid mark price.");
}
marketMarkPrice = mark;
}
marketSlippageBps =
parsed.data.slippageBps ?? DEFAULT_HYPERLIQUID_MARKET_SLIPPAGE_BPS;
const slippage = marketSlippageBps / 10_000;
if (marketMarkPrice == null) {
throw new Error("Market price was not resolved.");
}
const rawPrice =
marketMarkPrice * (side === "buy" ? 1 + slippage : 1 - slippage);
entryTick = resolvePriceTick(rawPrice, orderbookTick);
entryPrice = clampPriceDecimals(
roundHyperliquidPriceToTick(rawPrice, side, entryTick)
);
}
if (!entryPrice && type === "limit") {
return new Response(
JSON.stringify({
ok: false,
error: "price is required for limit orders",
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
if (type === "limit" && entryPrice) {
const parsedPrice = toNumber(entryPrice);
if (parsedPrice != null && parsedPrice > 0) {
entryTick = resolvePriceTick(parsedPrice, orderbookTick);
entryPrice = clampPriceDecimals(
roundHyperliquidPriceToTick(parsedPrice, side, entryTick)
);
}
}
const sizeDecimals = isSpot
? spotInfo?.szDecimals ?? null
: await fetchHyperliquidSizeDecimals({
environment,
symbol: normalizedSymbol as string,
}).catch(() => null);
const normalizedSize = normalizeOrderSize(size, sizeDecimals);
if (!normalizedSize) {
return new Response(
JSON.stringify({
ok: false,
error: "Order size is too small.",
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
const parsedSize = toNumber(normalizedSize);
if (parsedSize == null || parsedSize <= 0) {
return new Response(
JSON.stringify({
ok: false,
error: "Order size must be a positive number.",
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
const parsedEntryPrice = entryPrice ? toNumber(entryPrice) : null;
if (parsedEntryPrice == null || parsedEntryPrice <= 0) {
return new Response(
JSON.stringify({
ok: false,
error: "Order price must be a positive number.",
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
entryPrice = clampPriceDecimals(parsedEntryPrice.toString());
const orderPrice = entryPrice.toString();
const orderSize = normalizedSize.toString();
const entry = await placeHyperliquidOrder({
wallet: ctx as WalletFullContext,
environment,
orders: [
{
symbol: orderSymbol,
side,
price: orderPrice,
size: orderSize,
tif,
reduceOnly,
...(cloid ? { clientId: cloid as `0x${string}` } : {}),
},
],
});
// Optional TP/SL as separate trigger reduce-only orders.
const triggers: Array<
Parameters<typeof placeHyperliquidOrder>[0]["orders"][number]
> = [];
const triggerSide = side === "buy" ? "sell" : "buy";
const resolveTriggerPx = (raw: string | number): string | number => {
const parsedPx = toNumber(raw.toString());
if (parsedPx == null || parsedPx <= 0) return raw;
const tick = entryTick ?? resolvePriceTick(parsedPx, orderbookTick);
return clampPriceDecimals(
roundHyperliquidPriceToTick(parsedPx, triggerSide, tick)
);
};
if (takeProfitPx !== undefined) {
const roundedTp = resolveTriggerPx(takeProfitPx);
const trigger: HyperliquidTriggerOptions = {
triggerPx: roundedTp,
isMarket: true,
tpsl: "tp",
};
triggers.push({
symbol: orderSymbol,
side: triggerSide,
price: roundedTp.toString(),
size: orderSize,
tif: "Ioc",
reduceOnly: true,
trigger,
});
}
if (stopLossPx !== undefined) {
const roundedSl = resolveTriggerPx(stopLossPx);
const trigger: HyperliquidTriggerOptions = {
triggerPx: roundedSl,
isMarket: true,
tpsl: "sl",
};
triggers.push({
symbol: orderSymbol,
side: triggerSide,
price: roundedSl.toString(),
size: orderSize,
tif: "Ioc",
reduceOnly: true,
trigger,
});
}
let tpSlResult: unknown = null;
if (triggers.length) {
tpSlResult = await placeHyperliquidOrder({
wallet: ctx as WalletFullContext,
environment,
orders: triggers,
});
}
const orderIds = extractHyperliquidOrderIds(
[entry] as unknown as Array<{
response?: { data?: { statuses?: Array<Record<string, unknown>> } };
}>
);
if (cloid && !orderIds.cloids.includes(cloid)) {
orderIds.cloids.unshift(cloid);
}
const orderRef =
orderIds.cloids[0] ??
orderIds.oids[0] ??
resolveHyperliquidOrderRef({
response: entry as unknown as {
response?: { data?: { statuses?: Array<Record<string, unknown>> } };
},
prefix: orderSymbol,
});
const assetSymbol = isSpot
? spotInfo?.baseSymbol ?? normalizedSymbol ?? orderSymbol
: (normalizedSymbol as string);
const spotPair = spotInfo?.pairSymbol ?? pair ?? null;
const marketIdentity = buildHyperliquidMarketIdentity({
environment,
symbol: spotPair ?? orderSymbol,
rawSymbol,
isSpot,
base: spotInfo?.baseSymbol ?? null,
quote: spotInfo?.quoteSymbol ?? null,
});
if (!marketIdentity) {
throw new Error("Unable to resolve market identity for order.");
}
// Persist entry + optional TP/SL setup
await store({
source: "hyperliquid",
ref: orderRef,
status: "submitted",
walletAddress: ctx.address,
action: "order",
notional: normalizedSize,
network: environment === "mainnet" ? "hyperliquid" : "hyperliquid-testnet",
market: marketIdentity,
metadata: {
symbol: orderSymbol,
assetSymbol,
pair: spotPair,
rawSymbol,
side,
type,
price: entryPrice ?? null,
marketMarkPrice,
marketSlippageBps,
size: normalizedSize,
leverage: leverage ?? null,
leverageMode,
cloid: cloid ?? orderIds.cloids[0] ?? null,
orderIds,
reduceOnly,
takeProfitPx: takeProfitPx ?? null,
stopLossPx: stopLossPx ?? null,
environment,
entryResponse: entry,
tpSlResponse: tpSlResult,
},
});
return Response.json({
ok: true,
environment,
entry,
tpSl: tpSlResult,
});
} catch (error) {
if (originalFetch) {
globalThis.fetch = originalFetch;
}
if (error instanceof HyperliquidApiError) {
return Response.json(
{
ok: false,
error: error.message,
exchangeResponse: error.response,
exchangeRequest,
},
{ status: 500 }
);
}
return Response.json(
{
ok: false,
error: error instanceof Error ? error.message : "Unknown error",
exchangeRequest,
},
{ status: 500 }
);
} finally {
if (globalThis.fetch !== originalFetch) {
globalThis.fetch = originalFetch;
}
}
}