diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index c80349a22..38d2ef0cf 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -192,7 +192,31 @@ export async function getQuoteForExactInput( } : params.outputToken, }); - outputAmount = unsponsoredOutputAmount; + + // For swap pairs, simulate the HyperLiquid market order to get actual output with swap impact + if (isSwapPair) { + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDC", + decimals: SPOT_TOKEN_DECIMALS, + }, + tokenOut: { + symbol: getNormalizedSpotTokenSymbol(outputToken.symbol), + decimals: SPOT_TOKEN_DECIMALS, + }, + amount: unsponsoredOutputAmount, + amountType: "input", + }); + + outputAmount = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.outputAmount); + } else { + outputAmount = unsponsoredOutputAmount; + } + provider = "cctp"; fees = unsponsoredFees; } @@ -222,6 +246,7 @@ export async function getQuoteForOutput( assertSupportedRoute({ inputToken, outputToken }); let inputAmount: BigNumber; + let outputAmount: BigNumber = minOutputAmount; let provider: "sponsored-cctp" | "cctp" = "sponsored-cctp"; let fees: { amount: BigNumber; @@ -247,6 +272,37 @@ export async function getQuoteForOutput( } else { const isSwapPair = inputToken.symbol !== getNormalizedSpotTokenSymbol(outputToken.symbol); + + let bridgeOutputRequired = minOutputAmount; + if (isSwapPair) { + // For swap pairs, simulate to determine how much bridge output we need + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDC", + decimals: SPOT_TOKEN_DECIMALS, + }, + tokenOut: { + symbol: getNormalizedSpotTokenSymbol(outputToken.symbol), + decimals: SPOT_TOKEN_DECIMALS, + }, + amount: ConvertDecimals( + outputToken.decimals, + SPOT_TOKEN_DECIMALS + )(minOutputAmount), + amountType: "output", + }); + + outputAmount = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.outputAmount); + bridgeOutputRequired = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.inputAmount); + } + const { bridgeQuote: { inputAmount: unsponsoredInputAmount, @@ -258,6 +314,7 @@ export async function getQuoteForOutput( useForwardFee: false, }).getQuoteForOutput({ ...params, + minOutputAmount: isSwapPair ? bridgeOutputRequired : minOutputAmount, outputToken: isSwapPair ? { ...TOKEN_SYMBOLS_MAP["USDC-SPOT"], @@ -279,8 +336,8 @@ export async function getQuoteForOutput( inputToken, outputToken, inputAmount, - outputAmount: minOutputAmount, - minOutputAmount, + outputAmount, + minOutputAmount: outputAmount, estimatedFillTimeSec: getEstimatedFillTime( inputToken.chainId, CCTP_TRANSFER_MODE @@ -594,7 +651,8 @@ export async function calculateMaxBpsToSponsor(params: { symbol: outputToken.symbol, decimals: outputToken.decimals, }, - inputAmount: bridgeOutputAmountOutputTokenDecimals, + amount: bridgeOutputAmountOutputTokenDecimals, + amountType: "input", }); swapSlippageBps = BigNumber.from( Math.ceil(simResult.slippagePercent * 100) diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index 369b053b8..37811e50a 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -35,18 +35,6 @@ export const bridgeStrategies: BridgeStrategiesConfig = { [TOKEN_SYMBOLS_MAP.USDH.symbol]: getUsdhIntentsBridgeStrategy(), }, }, - // NOTE: Disable subset of HyperCore destination routes via mint/burn routes until we - // fully support them. We force return the Across bridge strategy here to avoid - // routing to via our algorithm. TODO until we can enable these routes: - // - https://linear.app/uma/issue/ACX-4895/api-return-swap-fees-for-unsponsored-flows - [CHAIN_IDs.HYPERCORE]: { - [TOKEN_SYMBOLS_MAP.USDC.symbol]: { - [TOKEN_SYMBOLS_MAP["USDT-SPOT"].symbol]: getAcrossBridgeStrategy(), - }, - [TOKEN_SYMBOLS_MAP.USDT.symbol]: { - [TOKEN_SYMBOLS_MAP["USDC-SPOT"].symbol]: getAcrossBridgeStrategy(), - }, - }, }, fromToChains: { [CHAIN_IDs.HYPEREVM]: { diff --git a/api/_bridges/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts index 80336d8d5..09e11a057 100644 --- a/api/_bridges/oft-sponsored/strategy.ts +++ b/api/_bridges/oft-sponsored/strategy.ts @@ -27,6 +27,7 @@ import { simulateMarketOrder, SPOT_TOKEN_DECIMALS, isToHyperCore, + getNormalizedSpotTokenSymbol, } from "../../_hypercore"; import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id"; import { @@ -188,11 +189,41 @@ export async function getSponsoredOftQuoteForExactInput( const nativeToken = getNativeTokenInfo(inputToken.chainId); - // Convert output amount from intermediary token decimals to final output token decimals - const finalOutputAmount = ConvertDecimals( - intermediaryToken.decimals, - outputToken.decimals - )(outputAmount); + const isSwapPair = + inputToken.symbol !== getNormalizedSpotTokenSymbol(outputToken.symbol); + + let finalOutputAmount: BigNumber; + if (isSwapPair) { + // For swap pairs, simulate the HyperLiquid market order to get actual output with swap impact + const bridgeOutputInSpotDecimals = ConvertDecimals( + TOKEN_SYMBOLS_MAP.USDT.decimals, + SPOT_TOKEN_DECIMALS + )(outputAmount); + + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDT", + decimals: SPOT_TOKEN_DECIMALS, + }, + tokenOut: { + symbol: getNormalizedSpotTokenSymbol(outputToken.symbol), + decimals: SPOT_TOKEN_DECIMALS, + }, + amount: bridgeOutputInSpotDecimals, + amountType: "input", + }); + + finalOutputAmount = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.outputAmount); + } else { + finalOutputAmount = ConvertDecimals( + intermediaryToken.decimals, + outputToken.decimals + )(outputAmount); + } return { bridgeQuote: { @@ -238,13 +269,53 @@ export async function getSponsoredOftQuoteForOutput( // All sponsored OFT transfers route through HyperEVM USDT before reaching final destination const intermediaryToken = await getIntermediaryToken(); - // Convert minOutputAmount to input token decimals - const minOutputInInputDecimals = ConvertDecimals( - outputToken.decimals, + const isSwapPair = + inputToken.symbol !== getNormalizedSpotTokenSymbol(outputToken.symbol); + + let bridgeOutputRequired: BigNumber; + let finalOutputAmount: BigNumber; + + if (isSwapPair) { + // For swap pairs, simulate the HyperLiquid market order to get actual input needed with swap impact + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDT", + decimals: SPOT_TOKEN_DECIMALS, + }, + tokenOut: { + symbol: getNormalizedSpotTokenSymbol(outputToken.symbol), + decimals: SPOT_TOKEN_DECIMALS, + }, + amount: ConvertDecimals( + outputToken.decimals, + SPOT_TOKEN_DECIMALS + )(minOutputAmount), + amountType: "output", + }); + + bridgeOutputRequired = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + TOKEN_SYMBOLS_MAP.USDT.decimals + )(simResult.inputAmount); + finalOutputAmount = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.outputAmount); + } else { + bridgeOutputRequired = ConvertDecimals( + outputToken.decimals, + intermediaryToken.decimals + )(minOutputAmount); + finalOutputAmount = minOutputAmount; + } + + // Convert bridge output required to input token decimals for OFT quote + const bridgeOutputInInputDecimals = ConvertDecimals( + intermediaryToken.decimals, inputToken.decimals - )(minOutputAmount); + )(bridgeOutputRequired); - // Get OFT quote to intermediary token and estimated fill time const [ { inputAmount, outputAmount: intermediaryOutputAmount, nativeFee }, estimatedFillTimeSec, @@ -252,7 +323,7 @@ export async function getSponsoredOftQuoteForOutput( getQuote({ inputToken, outputToken: intermediaryToken, - inputAmount: minOutputInInputDecimals, + inputAmount: bridgeOutputInInputDecimals, recipient: recipient!, }), getEstimatedFillTime( @@ -262,11 +333,13 @@ export async function getSponsoredOftQuoteForOutput( ), ]); - // Convert output amount from intermediary token decimals to output token decimals - const finalOutputAmount = ConvertDecimals( - intermediaryToken.decimals, - outputToken.decimals - )(intermediaryOutputAmount); + // For non-swap case, update finalOutputAmount based on actual bridge output + if (!isSwapPair) { + finalOutputAmount = ConvertDecimals( + intermediaryToken.decimals, + outputToken.decimals + )(intermediaryOutputAmount); + } // OFT precision limitations may prevent delivering the exact minimum amount // We validate against the rounded amount (maximum possible given shared decimals) @@ -334,7 +407,8 @@ export async function calculateMaxBpsToSponsor(params: { symbol: "USDC", decimals: SPOT_TOKEN_DECIMALS, // Spot token decimals always 8 }, - inputAmount: ConvertDecimals( + amountType: "input", + amount: ConvertDecimals( TOKEN_SYMBOLS_MAP.USDT.decimals, SPOT_TOKEN_DECIMALS )(bridgeOutputAmount), // Convert USDT to USDT-SPOT, as `bridgeOutputAmount` is in USDT decimals diff --git a/api/_hypercore.ts b/api/_hypercore.ts index 156fca332..abc6b49ec 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -235,25 +235,31 @@ export type MarketOrderSimulationResult = { /** * Simulates a market order by walking through the order book levels. - * Calculates execution price, slippage, and output amounts. + * Calculates execution price, slippage, and input/output amounts. * * @param tokenIn - Token being sold * @param tokenOut - Token being bought - * @param inputAmount - Amount of input token to sell (as BigNumber) + * @param amount - Amount to simulate (interpretation depends on amountType) + * @param amountType - "input" to specify input amount (calculate output), + * "output" to specify desired output amount (calculate required input) * @returns Simulation result with execution details and slippage * * @example - * // Simulate selling 1000 USDC for USDH + * // Simulate selling 1000 USDC for USDH (input amount type) * const result = await simulateMarketOrder({ - * tokenIn: { - * symbol: "USDC", - * decimals: 8, - * }, - * tokenOut: { - * symbol: "USDH", - * decimals: 8, - * }, - * inputAmount: ethers.utils.parseUnits("1000", 8), + * tokenIn: { symbol: "USDC", decimals: 8 }, + * tokenOut: { symbol: "USDH", decimals: 8 }, + * amount: ethers.utils.parseUnits("1000", 8), + * amountType: "input", + * }); + * + * @example + * // Simulate how much USDC is needed to receive 500 USDH (output amount type) + * const result = await simulateMarketOrder({ + * tokenIn: { symbol: "USDC", decimals: 8 }, + * tokenOut: { symbol: "USDH", decimals: 8 }, + * amount: ethers.utils.parseUnits("500", 8), + * amountType: "output", * }); */ export async function simulateMarketOrder(params: { @@ -266,13 +272,15 @@ export async function simulateMarketOrder(params: { symbol: string; decimals: number; }; - inputAmount: BigNumber; + amount: BigNumber; + amountType: "input" | "output"; }): Promise { const { chainId = CHAIN_IDs.HYPERCORE, tokenIn, tokenOut, - inputAmount, + amount, + amountType, } = params; const orderBook = await getL2OrderBookForPair({ @@ -319,12 +327,13 @@ export async function simulateMarketOrder(params: { const bestPrice = levels[0].px; // Walk through order book levels - let remainingInput = inputAmount; + let totalInput = BigNumber.from(0); let totalOutput = BigNumber.from(0); + let remaining = amount; let levelsConsumed = 0; for (const level of levels) { - if (remainingInput.lte(0)) break; + if (remaining.lte(0)) break; levelsConsumed++; @@ -333,76 +342,70 @@ export async function simulateMarketOrder(params: { Number(level.px).toFixed(tokenOut.decimals), tokenOut.decimals ); - // Level size is returned by the API in a parsed format, e.g. 1000 USDC - const levelSize = ethers.utils.parseUnits( + // Level size (base amount) is returned by the API in a parsed format + const baseAvailable = ethers.utils.parseUnits( Number(level.sz).toFixed(tokenIn.decimals), tokenIn.decimals ); + // Calculate quote equivalent for this level + const quoteAvailable = isBuyingBase + ? baseAvailable + .mul(price) + .div(ethers.utils.parseUnits("1", tokenOut.decimals)) + : baseAvailable + .mul(price) + .div(ethers.utils.parseUnits("1", 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); + // Determine available and consumed amounts based on direction and amount type + // isBuyingBase: input=quote, output=base + // !isBuyingBase (selling base): input=base, output=quote + const inputAvailable = isBuyingBase ? quoteAvailable : baseAvailable; + const outputAvailable = isBuyingBase ? baseAvailable : quoteAvailable; + + let inputConsumed: BigNumber; + let outputConsumed: BigNumber; + + if (amountType === "input") { + // Constrained by input - calculate how much output we get + if (remaining.gte(inputAvailable)) { + inputConsumed = inputAvailable; + outputConsumed = outputAvailable; } 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); + inputConsumed = remaining; + // Partial fill: scale output proportionally + outputConsumed = outputAvailable.mul(remaining).div(inputAvailable); } } 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); + // Constrained by output - calculate how much input we need + if (remaining.gte(outputAvailable)) { + inputConsumed = inputAvailable; + outputConsumed = outputAvailable; } else { - // Partial fill - const quoteAmount = remainingInput - .mul(price) - .div(ethers.utils.parseUnits("1", tokenIn.decimals)); - totalOutput = totalOutput.add(quoteAmount); - remainingInput = BigNumber.from(0); + outputConsumed = remaining; + // Partial fill: scale input proportionally + inputConsumed = inputAvailable.mul(remaining).div(outputAvailable); } } + + totalInput = totalInput.add(inputConsumed); + totalOutput = totalOutput.add(outputConsumed); + remaining = remaining.sub( + amountType === "input" ? inputConsumed : outputConsumed + ); } - const fullyFilled = remainingInput.eq(0); - const filledInputAmount = inputAmount.sub(remainingInput); + const fullyFilled = remaining.eq(0); // 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)) { + if (totalInput.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) + ethers.utils.formatUnits(totalInput, tokenIn.decimals) ); // When buying base (input=quote, output=base): price = input/output (quote per base) @@ -432,7 +435,7 @@ export async function simulateMarketOrder(params: { return { averageExecutionPrice, - inputAmount: filledInputAmount, + inputAmount: totalInput, outputAmount: totalOutput, slippagePercent, bestPrice, diff --git a/api/swap/_swap-fees.ts b/api/swap/_swap-fees.ts index 995a5f9c2..eb083df29 100644 --- a/api/swap/_swap-fees.ts +++ b/api/swap/_swap-fees.ts @@ -548,15 +548,7 @@ function getTotalFeeUsd(params: { return 0; } - if ( - [ - "oft", - "cctp", - // NOTE: "sponsored-oft" is a special case because the bridge fees are paid in - // native tokens, so we need to return it. - "sponsored-oft", - ].includes(bridgeProvider) - ) { + if (["sponsored-oft"].includes(bridgeProvider)) { return bridgeFeesUsd; } diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/isTokenUnreachable.ts b/src/views/SwapAndBridge/components/ChainTokenSelector/isTokenUnreachable.ts index c2aa8c0df..22d1f7d4d 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/isTokenUnreachable.ts +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/isTokenUnreachable.ts @@ -26,19 +26,6 @@ const RESTRICTED_ROUTES: RestrictedRoute[] = [ toChainId: [CHAIN_IDs.HYPERCORE], toSymbol: ["USDH-SPOT"], }, - { - fromChainId: "*", - fromSymbol: ["USDC*"], - toChainId: [CHAIN_IDs.HYPERCORE], - toSymbol: ["USDT-SPOT"], - }, - { - fromChainId: "*", - fromSymbol: ["USDT*"], - toChainId: [CHAIN_IDs.HYPERCORE], - toSymbol: ["USDC-SPOT"], - }, - // only allow bridegable output to SOlana { fromChainId: "*", diff --git a/src/views/SwapAndBridge/utils/fees.ts b/src/views/SwapAndBridge/utils/fees.ts index e44114580..b6884f7ef 100644 --- a/src/views/SwapAndBridge/utils/fees.ts +++ b/src/views/SwapAndBridge/utils/fees.ts @@ -25,8 +25,22 @@ export function getSwapQuoteFees(swapQuote?: SwapApprovalQuote) { ); // show swap impact only if swaps involved + const inputSymbol = swapQuote?.inputToken?.symbol; + const outputSymbol = swapQuote?.outputToken?.symbol; + const bridgeProvider = swapQuote?.steps?.bridge?.provider; + + const isHyperCoreSwap = + (inputSymbol === "USDC" && + outputSymbol === "USDT-SPOT" && + bridgeProvider === "cctp") || + (inputSymbol === "USDT" && + outputSymbol === "USDC-SPOT" && + bridgeProvider === "oft"); + const showSwapImpact = - swapQuote?.steps?.originSwap || swapQuote?.steps?.destinationSwap; + swapQuote?.steps?.originSwap || + swapQuote?.steps?.destinationSwap || + isHyperCoreSwap; const rawValues = { totalFeeUsd: showZeroFee ? "0" : swapQuote?.fees?.total.amountUsd || "0", diff --git a/test/api/_hypercore.test.ts b/test/api/_hypercore.test.ts index f1f1186a2..a71f2e242 100644 --- a/test/api/_hypercore.test.ts +++ b/test/api/_hypercore.test.ts @@ -110,278 +110,442 @@ describe("api/_hypercore.ts", () => { ], }; - test("should simulate buying USDH with USDC (small order)", async () => { - mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + describe("amountType: input", () => { + 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), + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + amount: ethers.utils.parseUnits("1000", usdc.decimals), + amountType: "input", + }); + + // 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); }); - // 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 }); - 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, + amount: ethers.utils.parseUnits("100000", usdc.decimals), + amountType: "input", + }); + + 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); + }); - const result = await simulateMarketOrder({ - tokenIn: usdc, - tokenOut: usdh, - inputAmount: ethers.utils.parseUnits("100000", usdc.decimals), + test("should simulate selling USDH for USDC", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdh, + tokenOut: usdc, + amount: ethers.utils.parseUnits("1000", usdh.decimals), + amountType: "input", + }); + + 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); }); - expect(result.fullyFilled).toBe(true); - expect(result.levelsConsumed).toBeGreaterThan(1); - expect(result.bestPrice).toBe("0.99983"); + 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, + }, + ], + ], + }; - // Should have consumed multiple levels - const outputAmount = parseFloat( - ethers.utils.formatUnits(result.outputAmount, usdh.decimals) - ); - expect(outputAmount).toBeGreaterThan(99000); - expect(outputAmount).toBeLessThan(101000); + mockedAxios.post.mockResolvedValue({ data: limitedLiquidityOrderBook }); - // Slippage should be higher for larger order - expect(result.slippagePercent).toBeGreaterThan(0); - }); + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + amount: ethers.utils.parseUnits("2000", usdc.decimals), + amountType: "input", + }); + + // 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 simulate selling USDH for USDC", async () => { - mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + test("should calculate average execution price correctly", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); - const result = await simulateMarketOrder({ - tokenIn: usdh, - tokenOut: usdc, - inputAmount: ethers.utils.parseUnits("1000", usdh.decimals), + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + amount: ethers.utils.parseUnits("50000", usdc.decimals), + amountType: "input", + }); + + 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); }); - expect(result.fullyFilled).toBe(true); - expect(result.levelsConsumed).toBe(1); - expect(result.bestPrice).toBe("0.99979"); + 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, + }, + ], + ], + }; - // 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); + mockedAxios.post.mockResolvedValue({ data: twoLevelOrderBook }); - // Slippage should be minimal for small order - expect(result.slippagePercent).toBeGreaterThanOrEqual(0); - expect(result.slippagePercent).toBeLessThan(0.01); - }); + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + amount: ethers.utils.parseUnits("1500", usdc.decimals), + amountType: "input", + }); - 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, - }, - ], - ], - }; + // Should consume both levels + expect(result.levelsConsumed).toBe(2); - mockedAxios.post.mockResolvedValue({ data: limitedLiquidityOrderBook }); + // 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); - const result = await simulateMarketOrder({ - tokenIn: usdc, - tokenOut: usdh, - inputAmount: ethers.utils.parseUnits("2000", usdc.decimals), + // Slippage should be positive (worse than best price) + expect(result.slippagePercent).toBeGreaterThan(0); + expect(result.slippagePercent).toBeLessThan(1); }); - // 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 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, + }, + ], + ], + }; - test("should calculate average execution price correctly", async () => { - mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + mockedAxios.post.mockResolvedValue({ data: twoLevelOrderBook }); - const result = await simulateMarketOrder({ - tokenIn: usdc, - tokenOut: usdh, - inputAmount: ethers.utils.parseUnits("50000", usdc.decimals), - }); + const result = await simulateMarketOrder({ + tokenIn: usdh, + tokenOut: usdc, + amount: ethers.utils.parseUnits("1500", usdh.decimals), + amountType: "input", + }); - const avgPrice = parseFloat(result.averageExecutionPrice); - const bestPrice = parseFloat(result.bestPrice); + // Should consume both levels + expect(result.levelsConsumed).toBe(2); - // Average price should be worse (higher) than best price when buying - expect(avgPrice).toBeGreaterThan(bestPrice); + // 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); - // 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); + // Slippage should be positive (worse than best price) + expect(result.slippagePercent).toBeGreaterThan(0); + expect(result.slippagePercent).toBeLessThan(1); + }); }); - 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"); - }); + describe("amountType: output", () => { + test("should calculate required USDC input to receive desired USDH output", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); - test("should throw error when order book has no liquidity", async () => { - const emptyOrderBook: MockOrderBookData = { - coin: "@230", - time: 1760575824177, - levels: [[], []], - }; + const desiredOutput = ethers.utils.parseUnits("1000", usdh.decimals); + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + amount: desiredOutput, + amountType: "output", + }); + + // At best price of 0.99983, to get 1000 USDH we need approximately 999.83 USDC + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBe(1); + expect(result.bestPrice).toBe("0.99983"); + + // Output should match requested amount + expect(result.outputAmount.toString()).toBe(desiredOutput.toString()); + + // Input should be close to 1000 * 0.99983 ≈ 999.83 + const inputAmount = parseFloat( + ethers.utils.formatUnits(result.inputAmount, usdc.decimals) + ); + expect(inputAmount).toBeGreaterThan(999); + expect(inputAmount).toBeLessThan(1001); + }); - mockedAxios.post.mockResolvedValue({ data: emptyOrderBook }); + test("should calculate required USDH input to receive desired USDC output", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const desiredOutput = ethers.utils.parseUnits("1000", usdc.decimals); + const result = await simulateMarketOrder({ + tokenIn: usdh, + tokenOut: usdc, + amount: desiredOutput, + amountType: "output", + }); + + // To get 1000 USDC by selling USDH at price 0.99979, need ~1000.21 USDH + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBe(1); + expect(result.bestPrice).toBe("0.99979"); + + // Output should match requested amount + expect(result.outputAmount.toString()).toBe(desiredOutput.toString()); + + // Input should be slightly more than 1000 + const inputAmount = parseFloat( + ethers.utils.formatUnits(result.inputAmount, usdh.decimals) + ); + expect(inputAmount).toBeGreaterThan(1000); + expect(inputAmount).toBeLessThan(1001); + }); - await expect( - simulateMarketOrder({ + test("should handle large output orders consuming multiple levels", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ tokenIn: usdc, tokenOut: usdh, - inputAmount: ethers.utils.parseUnits("1000", usdc.decimals), - }) - ).rejects.toThrow("No liquidity available for USDC/USDH"); - }); + amount: ethers.utils.parseUnits("50000", usdh.decimals), + amountType: "output", + }); - 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, - }, + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBeGreaterThan(1); + + // Output should match requested amount + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdh.decimals) + ); + expect(outputAmount).toBeCloseTo(50000, 0); + + // Slippage should be higher for larger orders + expect(result.slippagePercent).toBeGreaterThan(0); + }); + + test("should handle partial fills when desired output 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: twoLevelOrderBook }); + mockedAxios.post.mockResolvedValue({ data: limitedLiquidityOrderBook }); - const result = await simulateMarketOrder({ - tokenIn: usdc, - tokenOut: usdh, - inputAmount: ethers.utils.parseUnits("1500", usdc.decimals), + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + amount: ethers.utils.parseUnits("2000", usdh.decimals), + amountType: "output", + }); + + // Should only partially fill since only 1000 USDH available + expect(result.fullyFilled).toBe(false); + + // Should have received only the available liquidity + const outputReceived = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdh.decimals) + ); + expect(outputReceived).toBeLessThan(2000); + expect(outputReceived).toBeCloseTo(1000, 0); }); - // Should consume both levels - expect(result.levelsConsumed).toBe(2); + test("input and output amount types should be consistent for same trade", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); - // 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); + // First, simulate with input amount type + const inputResult = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + amount: ethers.utils.parseUnits("1000", usdc.decimals), + amountType: "input", + }); - // Slippage should be positive (worse than best price) - expect(result.slippagePercent).toBeGreaterThan(0); - expect(result.slippagePercent).toBeLessThan(1); + // Then, simulate with output amount type using the output from first simulation + const outputResult = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + amount: inputResult.outputAmount, + amountType: "output", + }); + + // The required input should match the original input (within rounding) + const inputFromInputType = parseFloat( + ethers.utils.formatUnits(inputResult.inputAmount, usdc.decimals) + ); + const inputFromOutputType = parseFloat( + ethers.utils.formatUnits(outputResult.inputAmount, usdc.decimals) + ); + + expect(inputFromOutputType).toBeCloseTo(inputFromInputType, 2); + }); }); - 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, + describe("error cases", () => { + 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, }, - { - px: "0.99", - sz: "1000.0", - n: 1, + tokenOut: { + symbol: "ETH", + decimals: 18, }, - ], - [ - { - 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), + amount: ethers.utils.parseUnits("1", 8), + amountType: "input", + }) + ).rejects.toThrow("No L2 order book coin found for pair BTC/ETH"); }); - // 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); + 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, + amount: ethers.utils.parseUnits("1000", usdc.decimals), + amountType: "input", + }) + ).rejects.toThrow("No liquidity available for USDC/USDH"); + }); }); });