From d72e91c8631b42e0f2323c137e5240858e9debc9 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 16 Oct 2025 09:20:37 +0200 Subject: [PATCH 1/2] feat: util for simulating market order on HL --- api/_hypercore.ts | 245 +++++++++++++++++++++++ test/api/_hypercore.test.ts | 375 ++++++++++++++++++++++++++++++++++++ 2 files changed, 620 insertions(+) create mode 100644 test/api/_hypercore.test.ts diff --git a/api/_hypercore.ts b/api/_hypercore.ts index c68b40156..ef5f1f415 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -1,8 +1,18 @@ import { BigNumber, ethers } from "ethers"; +import axios from "axios"; import { getProvider } from "./_providers"; import { CHAIN_IDs } from "./_constants"; +const HYPERLIQUID_API_BASE_URL = "https://api.hyperliquid.xyz"; + +// Maps / to the coin identifier to be used to +// retrieve the L2 order book for a given pair via the Hyperliquid API. +// See: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#perpetuals-vs-spot +const L2_ORDER_BOOK_COIN_MAP: Record = { + "USDH/USDC": "@230", +}; + // Contract used to query Hypercore balances from EVM export const CORE_BALANCE_SYSTEM_PRECOMPILE = "0x0000000000000000000000000000000000000801"; @@ -117,3 +127,238 @@ export async function accountExistsOnHyperCore(params: { ); return Boolean(decodedQueryResult[0]); } + +// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#l2-book-snapshot +export async function getL2OrderBookForPair(params: { + tokenInSymbol: string; + tokenOutSymbol: string; +}) { + const { tokenInSymbol, tokenOutSymbol } = params; + + // Try both directions since the pair might be stored either way + let coin = + L2_ORDER_BOOK_COIN_MAP[`${tokenInSymbol}/${tokenOutSymbol}`] || + L2_ORDER_BOOK_COIN_MAP[`${tokenOutSymbol}/${tokenInSymbol}`]; + + if (!coin) { + throw new Error( + `No L2 order book coin found for pair ${tokenInSymbol}/${tokenOutSymbol}` + ); + } + + const response = await axios.post<{ + coin: string; + time: number; + levels: [ + { px: string; sz: string; n: number }[], // bids sorted by price descending + { px: string; sz: string; n: number }[], // asks sorted by price ascending + ]; + }>(`${HYPERLIQUID_API_BASE_URL}/info`, { + type: "l2Book", + coin, + }); + + return response.data; +} + +export type MarketOrderSimulationResult = { + averageExecutionPrice: string; // Human-readable price + inputAmount: BigNumber; + outputAmount: BigNumber; + slippagePercent: number; + bestPrice: string; // Best available price (first level) + levelsConsumed: number; + fullyFilled: boolean; +}; + +/** + * Simulates a market order by walking through the order book levels. + * Calculates execution price, slippage, and output amounts. + * + * @param tokenIn - Token being sold + * @param tokenOut - Token being bought + * @param inputAmount - Amount of input token to sell (as BigNumber) + * @returns Simulation result with execution details and slippage + * + * @example + * // Simulate selling 1000 USDC for USDH + * const result = await simulateMarketOrder({ + * tokenIn: { + * symbol: "USDC", + * decimals: 8, + * }, + * tokenOut: { + * symbol: "USDH", + * decimals: 8, + * }, + * inputAmount: ethers.utils.parseUnits("1000", 8), + * }); + */ +export async function simulateMarketOrder(params: { + tokenIn: { + symbol: string; + decimals: number; + }; + tokenOut: { + symbol: string; + decimals: number; + }; + inputAmount: BigNumber; +}): Promise { + const { tokenIn, tokenOut, inputAmount } = params; + + const orderBook = await getL2OrderBookForPair({ + tokenInSymbol: tokenIn.symbol, + tokenOutSymbol: tokenOut.symbol, + }); + + // Determine which side of the order book to use + // We need to figure out the pair direction from L2_ORDER_BOOK_COIN_MAP + const pairKey = `${tokenIn.symbol}/${tokenOut.symbol}`; + const reversePairKey = `${tokenOut.symbol}/${tokenIn.symbol}`; + + let baseCurrency = ""; + + if (L2_ORDER_BOOK_COIN_MAP[pairKey]) { + // Normal direction: tokenIn/tokenOut exists in map + baseCurrency = tokenIn.symbol; + } else if (L2_ORDER_BOOK_COIN_MAP[reversePairKey]) { + // Reverse direction: tokenOut/tokenIn exists in map + baseCurrency = tokenOut.symbol; + } else { + throw new Error( + `No L2 order book key configured for pair ${tokenIn.symbol}/${tokenOut.symbol}` + ); + } + + // Determine which side to use: + // - If buying base (quote → base): use asks + // - If selling base (base → quote): use bids + const isBuyingBase = tokenOut.symbol === baseCurrency; + const levels = isBuyingBase ? orderBook.levels[1] : orderBook.levels[0]; // asks : bids + + if (levels.length === 0) { + throw new Error( + `No liquidity available for ${tokenIn.symbol}/${tokenOut.symbol}` + ); + } + + // Get best price for slippage calculation + const bestPrice = levels[0].px; + + // Walk through order book levels + let remainingInput = inputAmount; + let totalOutput = BigNumber.from(0); + let levelsConsumed = 0; + + for (const level of levels) { + if (remainingInput.lte(0)) break; + + levelsConsumed++; + + // Prices are returned by the API in a parsed format, e.g. 0.987 USDC + const price = ethers.utils.parseUnits(level.px, tokenOut.decimals); + // Level size is returned by the API in a parsed format, e.g. 1000 USDC + const levelSize = ethers.utils.parseUnits(level.sz, tokenIn.decimals); + + if (isBuyingBase) { + // Buying base with quote + // We have quote currency (input) and want base currency (output) + // price = quote per base, so base amount = quote amount / price + + // Calculate how much base currency is available at this level + const baseAvailable = levelSize; + + // Calculate how much quote we need to buy this base + const quoteNeeded = baseAvailable + .mul(price) + .div(ethers.utils.parseUnits("1", tokenOut.decimals)); + + if (remainingInput.gte(quoteNeeded)) { + // We can consume this entire level + totalOutput = totalOutput.add(baseAvailable); + remainingInput = remainingInput.sub(quoteNeeded); + } else { + // Partial fill - only consume part of this level + const baseAmount = remainingInput + .mul(ethers.utils.parseUnits("1", tokenOut.decimals)) + .div(price); + totalOutput = totalOutput.add(baseAmount); + remainingInput = BigNumber.from(0); + } + } else { + // Selling base for quote + // We have base currency (input) and want quote currency (output) + // price = quote per base, so quote amount = base amount * price + + // Level size represents how much base can be sold at this price + const baseAvailable = levelSize; + + if (remainingInput.gte(baseAvailable)) { + // We can consume this entire level + const quoteAmount = baseAvailable + .mul(price) + .div(ethers.utils.parseUnits("1", tokenIn.decimals)); + totalOutput = totalOutput.add(quoteAmount); + remainingInput = remainingInput.sub(baseAvailable); + } else { + // Partial fill + const quoteAmount = remainingInput + .mul(price) + .div(ethers.utils.parseUnits("1", tokenIn.decimals)); + totalOutput = totalOutput.add(quoteAmount); + remainingInput = BigNumber.from(0); + } + } + } + + const fullyFilled = remainingInput.eq(0); + const filledInputAmount = inputAmount.sub(remainingInput); + + // Calculate average execution price + // Price should be in same format as order book: quote per base + let averageExecutionPrice = "0"; + if (filledInputAmount.gt(0) && totalOutput.gt(0)) { + // Calculate with proper decimal handling + const outputFormatted = parseFloat( + ethers.utils.formatUnits(totalOutput, tokenOut.decimals) + ); + const inputFormatted = parseFloat( + ethers.utils.formatUnits(filledInputAmount, tokenIn.decimals) + ); + + // When buying base (input=quote, output=base): price = input/output (quote per base) + // When selling base (input=base, output=quote): price = output/input (quote per base) + if (isBuyingBase) { + averageExecutionPrice = (inputFormatted / outputFormatted).toString(); + } else { + averageExecutionPrice = (outputFormatted / inputFormatted).toString(); + } + } + + // Calculate slippage percentage + // slippage = ((avgPrice - bestPrice) / bestPrice) * 100 + let slippagePercent = 0; + if (parseFloat(averageExecutionPrice) > 0 && parseFloat(bestPrice) > 0) { + const avgPriceNum = parseFloat(averageExecutionPrice); + const bestPriceNum = parseFloat(bestPrice); + + if (isBuyingBase) { + // When buying, higher price is worse + slippagePercent = ((avgPriceNum - bestPriceNum) / bestPriceNum) * 100; + } else { + // When selling, lower price is worse + slippagePercent = ((bestPriceNum - avgPriceNum) / bestPriceNum) * 100; + } + } + + return { + averageExecutionPrice, + inputAmount: filledInputAmount, + outputAmount: totalOutput, + slippagePercent, + bestPrice, + levelsConsumed, + fullyFilled, + }; +} diff --git a/test/api/_hypercore.test.ts b/test/api/_hypercore.test.ts new file mode 100644 index 000000000..6d1edd954 --- /dev/null +++ b/test/api/_hypercore.test.ts @@ -0,0 +1,375 @@ +import { ethers } from "ethers"; +import axios from "axios"; + +import { + getL2OrderBookForPair, + simulateMarketOrder, +} from "../../api/_hypercore"; + +jest.mock("axios"); + +const mockedAxios = axios as jest.Mocked; + +type MockOrderBookData = Awaited>; + +describe("api/_hypercore.ts", () => { + const usdc = { + symbol: "USDC", + decimals: 8, + }; + const usdh = { + symbol: "USDH", + decimals: 8, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("#simulateMarketOrder()", () => { + const mockOrderBookData: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [ + [ + { + px: "0.99979", + sz: "29350.7", + n: 2, + }, + { + px: "0.99978", + sz: "101825.22", + n: 5, + }, + { + px: "0.99977", + sz: "32000.0", + n: 1, + }, + { + px: "0.99976", + sz: "32000.0", + n: 1, + }, + { + px: "0.99974", + sz: "500.0", + n: 1, + }, + ], + [ + { + px: "0.99983", + sz: "2104.57", + n: 5, + }, + { + px: "0.99987", + sz: "32000.0", + n: 1, + }, + { + px: "0.99988", + sz: "32000.0", + n: 1, + }, + { + px: "0.99989", + sz: "32000.0", + n: 1, + }, + { + px: "0.9999", + sz: "32000.0", + n: 1, + }, + { + px: "1.0", + sz: "679285.61", + n: 4, + }, + { + px: "1.0001", + sz: "365649.27", + n: 55, + }, + ], + ], + }; + + test("should simulate buying USDH with USDC (small order)", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("1000", usdc.decimals), + }); + + // At best price of 0.99983, 1000 USDC should buy approximately 1000.17 USDH + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBe(1); + expect(result.bestPrice).toBe("0.99983"); + + // Output should be close to 1000 / 0.99983 ≈ 1000.17 + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdh.decimals) + ); + expect(outputAmount).toBeGreaterThan(1000); + expect(outputAmount).toBeLessThan(1001); + + // Slippage should be minimal for small order + expect(result.slippagePercent).toBeGreaterThanOrEqual(0); + expect(result.slippagePercent).toBeLessThan(0.05); + }); + + test("should simulate buying USDH with USDC (large order consuming multiple levels)", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("100000", usdc.decimals), + }); + + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBeGreaterThan(1); + expect(result.bestPrice).toBe("0.99983"); + + // Should have consumed multiple levels + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdh.decimals) + ); + expect(outputAmount).toBeGreaterThan(99000); + expect(outputAmount).toBeLessThan(101000); + + // Slippage should be higher for larger order + expect(result.slippagePercent).toBeGreaterThan(0); + }); + + test("should simulate selling USDH for USDC", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdh, + tokenOut: usdc, + inputAmount: ethers.utils.parseUnits("1000", usdh.decimals), + }); + + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBe(1); + expect(result.bestPrice).toBe("0.99979"); + + // At best price of 0.99979, 1000 USDH should sell for approximately 999.79 USDC + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdc.decimals) + ); + expect(outputAmount).toBeGreaterThan(999); + expect(outputAmount).toBeLessThan(1000); + + // Slippage should be minimal for small order + expect(result.slippagePercent).toBeGreaterThanOrEqual(0); + expect(result.slippagePercent).toBeLessThan(0.01); + }); + + test("should handle partial fills when order size exceeds available liquidity", async () => { + const limitedLiquidityOrderBook: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [ + [ + { + px: "0.99979", + sz: "1000.0", + n: 1, + }, + ], + [ + { + px: "0.99983", + sz: "1000.0", + n: 1, + }, + ], + ], + }; + + mockedAxios.post.mockResolvedValue({ data: limitedLiquidityOrderBook }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("2000", usdc.decimals), + }); + + // Should only partially fill + expect(result.fullyFilled).toBe(false); + + // Should have consumed only the available liquidity + const inputUsed = parseFloat( + ethers.utils.formatUnits(result.inputAmount, usdc.decimals) + ); + expect(inputUsed).toBeLessThan(2000); + expect(inputUsed).toBeGreaterThan(999); + }); + + test("should calculate average execution price correctly", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("50000", usdc.decimals), + }); + + const avgPrice = parseFloat(result.averageExecutionPrice); + const bestPrice = parseFloat(result.bestPrice); + + // Average price should be worse (higher) than best price when buying + expect(avgPrice).toBeGreaterThan(bestPrice); + + // Verify calculation: when buying base, price = inputAmount / outputAmount (quote per base) + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdh.decimals) + ); + const inputAmount = parseFloat( + ethers.utils.formatUnits(result.inputAmount, usdc.decimals) + ); + const calculatedPrice = inputAmount / outputAmount; + + expect(Math.abs(calculatedPrice - avgPrice)).toBeLessThan(0.00001); + }); + + test("should throw error for unsupported token pair", async () => { + // Don't mock axios - let it fail naturally when the pair isn't found + await expect( + simulateMarketOrder({ + tokenIn: { + symbol: "BTC", + decimals: 8, + }, + tokenOut: { + symbol: "ETH", + decimals: 18, + }, + inputAmount: ethers.utils.parseUnits("1", 8), + }) + ).rejects.toThrow("No L2 order book coin found for pair BTC/ETH"); + }); + + test("should throw error when order book has no liquidity", async () => { + const emptyOrderBook: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [[], []], + }; + + mockedAxios.post.mockResolvedValue({ data: emptyOrderBook }); + + await expect( + simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("1000", usdc.decimals), + }) + ).rejects.toThrow("No liquidity available for USDC/USDH"); + }); + + test("should calculate slippage correctly for buying (higher price = worse)", async () => { + const twoLevelOrderBook: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [ + [ + { + px: "1.0", + sz: "1000.0", + n: 1, + }, + ], + [ + { + px: "1.0", + sz: "1000.0", + n: 1, + }, + { + px: "1.01", + sz: "1000.0", + n: 1, + }, + ], + ], + }; + + mockedAxios.post.mockResolvedValue({ data: twoLevelOrderBook }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("1500", usdc.decimals), + }); + + // Should consume both levels + expect(result.levelsConsumed).toBe(2); + + // Average price should be between 1.0 and 1.01 + const avgPrice = parseFloat(result.averageExecutionPrice); + expect(avgPrice).toBeGreaterThan(1.0); + expect(avgPrice).toBeLessThan(1.01); + + // Slippage should be positive (worse than best price) + expect(result.slippagePercent).toBeGreaterThan(0); + expect(result.slippagePercent).toBeLessThan(1); + }); + + test("should calculate slippage correctly for selling (lower price = worse)", async () => { + const twoLevelOrderBook: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [ + [ + { + px: "1.0", + sz: "1000.0", + n: 1, + }, + { + px: "0.99", + sz: "1000.0", + n: 1, + }, + ], + [ + { + px: "1.01", + sz: "1000.0", + n: 1, + }, + ], + ], + }; + + mockedAxios.post.mockResolvedValue({ data: twoLevelOrderBook }); + + const result = await simulateMarketOrder({ + tokenIn: usdh, + tokenOut: usdc, + inputAmount: ethers.utils.parseUnits("1500", usdh.decimals), + }); + + // Should consume both levels + expect(result.levelsConsumed).toBe(2); + + // Average price should be between 0.99 and 1.0 + const avgPrice = parseFloat(result.averageExecutionPrice); + expect(avgPrice).toBeGreaterThan(0.99); + expect(avgPrice).toBeLessThan(1.0); + + // Slippage should be positive (worse than best price) + expect(result.slippagePercent).toBeGreaterThan(0); + expect(result.slippagePercent).toBeLessThan(1); + }); + }); +}); From ef32696bb5446acf664e54483562ec3ad30a8a79 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 20 Oct 2025 11:06:00 +0200 Subject: [PATCH 2/2] validate key response data values --- api/_hypercore.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/api/_hypercore.ts b/api/_hypercore.ts index ef5f1f415..be940913d 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -158,6 +158,16 @@ export async function getL2OrderBookForPair(params: { coin, }); + if (!response.data) { + throw new Error( + `Hyperliquid API: Unexpected L2OrderBook value '${response.data}'` + ); + } + + if (response.data?.levels.length < 2) { + throw new Error("Hyperliquid API: Unexpected L2OrderBook 'levels' length"); + } + return response.data; }