OpenPond
1Branch0Tags
GL
glucryptoMerge commit 'refs/tmp/prod-master'
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; } } }