From dff11ae09017d17236c6abebae6641beff8ff46d Mon Sep 17 00:00:00 2001 From: ashwinrava <213675439+ashwinrava@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:47:00 -0500 Subject: [PATCH 1/7] Calculate fees for unsponsored oft/cctp swaps --- api/_bridges/cctp-sponsored/strategy.ts | 77 ++++++++++++++++++++++++- api/_bridges/oft-sponsored/strategy.ts | 76 ++++++++++++++++++++---- api/swap/_swap-fees.ts | 12 +--- 3 files changed, 142 insertions(+), 23 deletions(-) diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index c80349a22..713b83804 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -192,7 +192,39 @@ export async function getQuoteForExactInput( } : params.outputToken, }); - outputAmount = unsponsoredOutputAmount; + + // For USDC to USDT-SPOT unsponsored flows, simulate the HyperLiquid market order + // to get the actual output amount with swap impact. + const isUsdcToUsdtSwap = + isSwapPair && ["USDT", "USDT-SPOT"].includes(outputToken.symbol); + + if (isUsdcToUsdtSwap) { + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + inputToken.decimals, + SPOT_TOKEN_DECIMALS + )(unsponsoredOutputAmount); + + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDC", + decimals: SPOT_TOKEN_DECIMALS, + }, + tokenOut: { + symbol: "USDT", + decimals: SPOT_TOKEN_DECIMALS, + }, + inputAmount: bridgeOutputAmountOutputTokenDecimals, + }); + + outputAmount = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.outputAmount); + } else { + outputAmount = unsponsoredOutputAmount; + } + provider = "cctp"; fees = unsponsoredFees; } @@ -222,6 +254,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 +280,41 @@ export async function getQuoteForOutput( } else { const isSwapPair = inputToken.symbol !== getNormalizedSpotTokenSymbol(outputToken.symbol); + + const isUsdcToUsdtSwap = + isSwapPair && ["USDT", "USDT-SPOT"].includes(outputToken.symbol); + + let bridgeOutputRequired = minOutputAmount; + if (isUsdcToUsdtSwap) { + // For USDC to USDT-SPOT unsponsored flows, simulate the swap to determine + // how much USDC-SPOT we need to get the desired output + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDC", + decimals: SPOT_TOKEN_DECIMALS, + }, + tokenOut: { + symbol: "USDT", + decimals: SPOT_TOKEN_DECIMALS, + }, + inputAmount: ConvertDecimals( + outputToken.decimals, + SPOT_TOKEN_DECIMALS + )(minOutputAmount), + }); + + // Use the simulation result to estimate the actual output with swap impact + outputAmount = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.outputAmount); + bridgeOutputRequired = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.inputAmount); + } + const { bridgeQuote: { inputAmount: unsponsoredInputAmount, @@ -258,6 +326,9 @@ export async function getQuoteForOutput( useForwardFee: false, }).getQuoteForOutput({ ...params, + minOutputAmount: isUsdcToUsdtSwap + ? bridgeOutputRequired + : minOutputAmount, outputToken: isSwapPair ? { ...TOKEN_SYMBOLS_MAP["USDC-SPOT"], @@ -279,8 +350,8 @@ export async function getQuoteForOutput( inputToken, outputToken, inputAmount, - outputAmount: minOutputAmount, - minOutputAmount, + outputAmount, + minOutputAmount: outputAmount, estimatedFillTimeSec: getEstimatedFillTime( inputToken.chainId, CCTP_TRANSFER_MODE diff --git a/api/_bridges/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts index 80336d8d5..6d435201b 100644 --- a/api/_bridges/oft-sponsored/strategy.ts +++ b/api/_bridges/oft-sponsored/strategy.ts @@ -188,11 +188,39 @@ 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 isUsdtToUsdcSwap = outputToken.symbol === "USDC-SPOT"; + + let finalOutputAmount: BigNumber; + if (isUsdtToUsdcSwap) { + // For USDT to USDC-SPOT, simulate the HyperLiquid market order to get actual output with swap impact + const bridgeOutputInSpotDecimals = ConvertDecimals( + intermediaryToken.decimals, + SPOT_TOKEN_DECIMALS + )(outputAmount); + + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDT", + decimals: SPOT_TOKEN_DECIMALS, + }, + tokenOut: { + symbol: "USDC", + decimals: SPOT_TOKEN_DECIMALS, + }, + inputAmount: bridgeOutputInSpotDecimals, + }); + + finalOutputAmount = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.outputAmount); + } else { + finalOutputAmount = ConvertDecimals( + intermediaryToken.decimals, + outputToken.decimals + )(outputAmount); + } return { bridgeQuote: { @@ -262,11 +290,39 @@ export async function getSponsoredOftQuoteForOutput( ), ]); - // Convert output amount from intermediary token decimals to output token decimals - const finalOutputAmount = ConvertDecimals( - intermediaryToken.decimals, - outputToken.decimals - )(intermediaryOutputAmount); + const isUsdtToUsdcSwap = outputToken.symbol === "USDC-SPOT"; + + let finalOutputAmount: BigNumber; + if (isUsdtToUsdcSwap) { + // For USDT to USDC-SPOT, simulate the HyperLiquid market order to get actual output with swap impact + const bridgeOutputInSpotDecimals = ConvertDecimals( + intermediaryToken.decimals, + SPOT_TOKEN_DECIMALS + )(intermediaryOutputAmount); + + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDT", + decimals: SPOT_TOKEN_DECIMALS, + }, + tokenOut: { + symbol: "USDC", + decimals: SPOT_TOKEN_DECIMALS, + }, + inputAmount: bridgeOutputInSpotDecimals, + }); + + finalOutputAmount = ConvertDecimals( + SPOT_TOKEN_DECIMALS, + outputToken.decimals + )(simResult.outputAmount); + } else { + 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) diff --git a/api/swap/_swap-fees.ts b/api/swap/_swap-fees.ts index 995a5f9c2..5fdb11855 100644 --- a/api/swap/_swap-fees.ts +++ b/api/swap/_swap-fees.ts @@ -544,19 +544,11 @@ function getTotalFeeUsd(params: { outputAmountSansAppFeesUsd, } = params; - if (["sponsored-intent", "sponsored-cctp"].includes(bridgeProvider)) { + if (["sponsored-intent"].includes(bridgeProvider)) { 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 (["oft", "cctp"].includes(bridgeProvider)) { return bridgeFeesUsd; } From 828952569204fbfefa6e49ce32280bcf84a1941e Mon Sep 17 00:00:00 2001 From: ashwinrava <213675439+ashwinrava@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:05:37 -0500 Subject: [PATCH 2/7] Remove override --- api/_bridges/index.ts | 12 ------------ 1 file changed, 12 deletions(-) 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]: { From 6fc330f8dfe7a8e418364e9d45cbc629e9196e50 Mon Sep 17 00:00:00 2001 From: ashwinrava <213675439+ashwinrava@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:08:29 -0500 Subject: [PATCH 3/7] Remove FE restriction --- .../ChainTokenSelector/isTokenUnreachable.ts | 13 ------------- 1 file changed, 13 deletions(-) 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: "*", From 7a7944540ec23fc50f9a2da3e583493d3b49625e Mon Sep 17 00:00:00 2001 From: ashwinrava <213675439+ashwinrava@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:52:25 -0500 Subject: [PATCH 4/7] Fix decimal conversion --- api/_bridges/cctp-sponsored/strategy.ts | 7 +------ api/swap/_swap-fees.ts | 10 +++++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index 713b83804..39c541ca3 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -199,11 +199,6 @@ export async function getQuoteForExactInput( isSwapPair && ["USDT", "USDT-SPOT"].includes(outputToken.symbol); if (isUsdcToUsdtSwap) { - const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( - inputToken.decimals, - SPOT_TOKEN_DECIMALS - )(unsponsoredOutputAmount); - const simResult = await simulateMarketOrder({ chainId: outputToken.chainId, tokenIn: { @@ -214,7 +209,7 @@ export async function getQuoteForExactInput( symbol: "USDT", decimals: SPOT_TOKEN_DECIMALS, }, - inputAmount: bridgeOutputAmountOutputTokenDecimals, + inputAmount: unsponsoredOutputAmount, }); outputAmount = ConvertDecimals( diff --git a/api/swap/_swap-fees.ts b/api/swap/_swap-fees.ts index 5fdb11855..c5eec5235 100644 --- a/api/swap/_swap-fees.ts +++ b/api/swap/_swap-fees.ts @@ -544,13 +544,13 @@ function getTotalFeeUsd(params: { outputAmountSansAppFeesUsd, } = params; - if (["sponsored-intent"].includes(bridgeProvider)) { + if ( + ["sponsored-intent", "sponsored-oft", "sponsored-cctp"].includes( + bridgeProvider + ) + ) { return 0; } - if (["oft", "cctp"].includes(bridgeProvider)) { - return bridgeFeesUsd; - } - return parseFloat((inputAmountUsd - outputAmountSansAppFeesUsd).toFixed(4)); } From 0edbc795ac50eadabb02c882895d5ede739669f8 Mon Sep 17 00:00:00 2001 From: ashwinrava <213675439+ashwinrava@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:58:08 -0500 Subject: [PATCH 5/7] Fix OFT simulation and swap impact in FE --- api/_bridges/oft-sponsored/strategy.ts | 4 ++-- api/swap/_swap-fees.ts | 10 +++++----- src/views/SwapAndBridge/utils/fees.ts | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/api/_bridges/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts index 6d435201b..3b2f5aa24 100644 --- a/api/_bridges/oft-sponsored/strategy.ts +++ b/api/_bridges/oft-sponsored/strategy.ts @@ -194,7 +194,7 @@ export async function getSponsoredOftQuoteForExactInput( if (isUsdtToUsdcSwap) { // For USDT to USDC-SPOT, simulate the HyperLiquid market order to get actual output with swap impact const bridgeOutputInSpotDecimals = ConvertDecimals( - intermediaryToken.decimals, + TOKEN_SYMBOLS_MAP.USDT.decimals, SPOT_TOKEN_DECIMALS )(outputAmount); @@ -296,7 +296,7 @@ export async function getSponsoredOftQuoteForOutput( if (isUsdtToUsdcSwap) { // For USDT to USDC-SPOT, simulate the HyperLiquid market order to get actual output with swap impact const bridgeOutputInSpotDecimals = ConvertDecimals( - intermediaryToken.decimals, + TOKEN_SYMBOLS_MAP.USDT.decimals, SPOT_TOKEN_DECIMALS )(intermediaryOutputAmount); diff --git a/api/swap/_swap-fees.ts b/api/swap/_swap-fees.ts index c5eec5235..eb083df29 100644 --- a/api/swap/_swap-fees.ts +++ b/api/swap/_swap-fees.ts @@ -544,13 +544,13 @@ function getTotalFeeUsd(params: { outputAmountSansAppFeesUsd, } = params; - if ( - ["sponsored-intent", "sponsored-oft", "sponsored-cctp"].includes( - bridgeProvider - ) - ) { + if (["sponsored-intent", "sponsored-cctp"].includes(bridgeProvider)) { return 0; } + if (["sponsored-oft"].includes(bridgeProvider)) { + return bridgeFeesUsd; + } + return parseFloat((inputAmountUsd - outputAmountSansAppFeesUsd).toFixed(4)); } 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", From cd3aa40a38ea6800fbffca468a9a71787fea5529 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Tue, 16 Dec 2025 10:43:27 +0700 Subject: [PATCH 6/7] feat: output amount sim market order hypercore --- api/_bridges/cctp-sponsored/strategy.ts | 3 +- api/_bridges/oft-sponsored/strategy.ts | 3 +- api/_hypercore.ts | 135 +++--- test/api/_hypercore.test.ts | 616 +++++++++++++++--------- 4 files changed, 463 insertions(+), 294 deletions(-) diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index 39c541ca3..86ba0a56b 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -660,7 +660,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/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts index 3b2f5aa24..971c38529 100644 --- a/api/_bridges/oft-sponsored/strategy.ts +++ b/api/_bridges/oft-sponsored/strategy.ts @@ -390,7 +390,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/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"); + }); }); }); From 5ed9559c65848497417daef627de13ecc5154f88 Mon Sep 17 00:00:00 2001 From: ashwinrava <213675439+ashwinrava@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:19:34 -0500 Subject: [PATCH 7/7] Add logic for output based flows --- api/_bridges/cctp-sponsored/strategy.ts | 31 +++------ api/_bridges/oft-sponsored/strategy.ts | 93 +++++++++++++++---------- 2 files changed, 66 insertions(+), 58 deletions(-) diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index 86ba0a56b..38d2ef0cf 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -193,12 +193,8 @@ export async function getQuoteForExactInput( : params.outputToken, }); - // For USDC to USDT-SPOT unsponsored flows, simulate the HyperLiquid market order - // to get the actual output amount with swap impact. - const isUsdcToUsdtSwap = - isSwapPair && ["USDT", "USDT-SPOT"].includes(outputToken.symbol); - - if (isUsdcToUsdtSwap) { + // 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: { @@ -206,10 +202,11 @@ export async function getQuoteForExactInput( decimals: SPOT_TOKEN_DECIMALS, }, tokenOut: { - symbol: "USDT", + symbol: getNormalizedSpotTokenSymbol(outputToken.symbol), decimals: SPOT_TOKEN_DECIMALS, }, - inputAmount: unsponsoredOutputAmount, + amount: unsponsoredOutputAmount, + amountType: "input", }); outputAmount = ConvertDecimals( @@ -276,13 +273,9 @@ export async function getQuoteForOutput( const isSwapPair = inputToken.symbol !== getNormalizedSpotTokenSymbol(outputToken.symbol); - const isUsdcToUsdtSwap = - isSwapPair && ["USDT", "USDT-SPOT"].includes(outputToken.symbol); - let bridgeOutputRequired = minOutputAmount; - if (isUsdcToUsdtSwap) { - // For USDC to USDT-SPOT unsponsored flows, simulate the swap to determine - // how much USDC-SPOT we need to get the desired output + if (isSwapPair) { + // For swap pairs, simulate to determine how much bridge output we need const simResult = await simulateMarketOrder({ chainId: outputToken.chainId, tokenIn: { @@ -290,16 +283,16 @@ export async function getQuoteForOutput( decimals: SPOT_TOKEN_DECIMALS, }, tokenOut: { - symbol: "USDT", + symbol: getNormalizedSpotTokenSymbol(outputToken.symbol), decimals: SPOT_TOKEN_DECIMALS, }, - inputAmount: ConvertDecimals( + amount: ConvertDecimals( outputToken.decimals, SPOT_TOKEN_DECIMALS )(minOutputAmount), + amountType: "output", }); - // Use the simulation result to estimate the actual output with swap impact outputAmount = ConvertDecimals( SPOT_TOKEN_DECIMALS, outputToken.decimals @@ -321,9 +314,7 @@ export async function getQuoteForOutput( useForwardFee: false, }).getQuoteForOutput({ ...params, - minOutputAmount: isUsdcToUsdtSwap - ? bridgeOutputRequired - : minOutputAmount, + minOutputAmount: isSwapPair ? bridgeOutputRequired : minOutputAmount, outputToken: isSwapPair ? { ...TOKEN_SYMBOLS_MAP["USDC-SPOT"], diff --git a/api/_bridges/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts index 971c38529..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,12 @@ export async function getSponsoredOftQuoteForExactInput( const nativeToken = getNativeTokenInfo(inputToken.chainId); - const isUsdtToUsdcSwap = outputToken.symbol === "USDC-SPOT"; + const isSwapPair = + inputToken.symbol !== getNormalizedSpotTokenSymbol(outputToken.symbol); let finalOutputAmount: BigNumber; - if (isUsdtToUsdcSwap) { - // For USDT to USDC-SPOT, simulate the HyperLiquid market order to get actual output with swap impact + 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 @@ -205,10 +207,11 @@ export async function getSponsoredOftQuoteForExactInput( decimals: SPOT_TOKEN_DECIMALS, }, tokenOut: { - symbol: "USDC", + symbol: getNormalizedSpotTokenSymbol(outputToken.symbol), decimals: SPOT_TOKEN_DECIMALS, }, - inputAmount: bridgeOutputInSpotDecimals, + amount: bridgeOutputInSpotDecimals, + amountType: "input", }); finalOutputAmount = ConvertDecimals( @@ -266,40 +269,14 @@ 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, - inputToken.decimals - )(minOutputAmount); - - // Get OFT quote to intermediary token and estimated fill time - const [ - { inputAmount, outputAmount: intermediaryOutputAmount, nativeFee }, - estimatedFillTimeSec, - ] = await Promise.all([ - getQuote({ - inputToken, - outputToken: intermediaryToken, - inputAmount: minOutputInInputDecimals, - recipient: recipient!, - }), - getEstimatedFillTime( - inputToken.chainId, - intermediaryToken.chainId, - inputToken.symbol - ), - ]); - - const isUsdtToUsdcSwap = outputToken.symbol === "USDC-SPOT"; + const isSwapPair = + inputToken.symbol !== getNormalizedSpotTokenSymbol(outputToken.symbol); + let bridgeOutputRequired: BigNumber; let finalOutputAmount: BigNumber; - if (isUsdtToUsdcSwap) { - // For USDT to USDC-SPOT, simulate the HyperLiquid market order to get actual output with swap impact - const bridgeOutputInSpotDecimals = ConvertDecimals( - TOKEN_SYMBOLS_MAP.USDT.decimals, - SPOT_TOKEN_DECIMALS - )(intermediaryOutputAmount); + 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: { @@ -307,17 +284,57 @@ export async function getSponsoredOftQuoteForOutput( decimals: SPOT_TOKEN_DECIMALS, }, tokenOut: { - symbol: "USDC", + symbol: getNormalizedSpotTokenSymbol(outputToken.symbol), decimals: SPOT_TOKEN_DECIMALS, }, - inputAmount: bridgeOutputInSpotDecimals, + 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 + )(bridgeOutputRequired); + + const [ + { inputAmount, outputAmount: intermediaryOutputAmount, nativeFee }, + estimatedFillTimeSec, + ] = await Promise.all([ + getQuote({ + inputToken, + outputToken: intermediaryToken, + inputAmount: bridgeOutputInInputDecimals, + recipient: recipient!, + }), + getEstimatedFillTime( + inputToken.chainId, + intermediaryToken.chainId, + inputToken.symbol + ), + ]); + + // For non-swap case, update finalOutputAmount based on actual bridge output + if (!isSwapPair) { finalOutputAmount = ConvertDecimals( intermediaryToken.decimals, outputToken.decimals