diff --git a/api/_dexes/cross-swap-service.ts b/api/_dexes/cross-swap-service.ts index 6c203c388..d3a050433 100644 --- a/api/_dexes/cross-swap-service.ts +++ b/api/_dexes/cross-swap-service.ts @@ -1,3 +1,4 @@ +import { BigNumber } from "ethers"; import { TradeType } from "@uniswap/sdk-core"; import { @@ -49,7 +50,7 @@ import { BridgeStrategy } from "../_bridges/types"; import { getSpokePoolPeripheryAddress } from "../_spoke-pool-periphery"; import { accountExistsOnHyperCore } from "../_hypercore"; import { CHAIN_IDs } from "../_constants"; -import { BigNumber } from "ethers"; +import { validateDestinationSwapSlippage } from "../_slippage"; const QUOTE_BUFFER = 0.005; // 0.5% @@ -708,6 +709,13 @@ function _prepCrossSwapQuotesRetrievalB2A( chainId: destinationSwapChainId, }; + validateDestinationSwapSlippage({ + tokenIn: bridgeableOutputToken, + tokenOut: crossSwap.outputToken, + slippageTolerance: crossSwap.slippageTolerance, + splitSlippage: false, + }); + const destinationSwap = { chainId: destinationSwapChainId, tokenIn: bridgeableOutputToken, @@ -1640,6 +1648,13 @@ function _prepCrossSwapQuotesRetrievalA2A(params: { chainId: bridgeRoute.toChain, }; + validateDestinationSwapSlippage({ + tokenIn: bridgeableOutputToken, + tokenOut: crossSwap.outputToken, + slippageTolerance: crossSwap.slippageTolerance, + splitSlippage: true, // A2A splits slippage between origin and destination swaps + }); + const originStrategies = getQuoteFetchStrategies( originSwapChainId, crossSwap.inputToken.symbol, diff --git a/api/_errors.ts b/api/_errors.ts index 79321ab47..5ac493488 100644 --- a/api/_errors.ts +++ b/api/_errors.ts @@ -45,6 +45,7 @@ export const AcrossErrorCode = { ROUTE_NOT_ENABLED: "ROUTE_NOT_ENABLED", SWAP_LIQUIDITY_INSUFFICIENT: "SWAP_LIQUIDITY_INSUFFICIENT", SWAP_QUOTE_UNAVAILABLE: "SWAP_QUOTE_UNAVAILABLE", + SWAP_SLIPPAGE_INSUFFICIENT: "SWAP_SLIPPAGE_INSUFFICIENT", SWAP_TYPE_NOT_GUARANTEED: "SWAP_TYPE_NOT_GUARANTEED", ABI_ENCODING_ERROR: "ABI_ENCODING_ERROR", SPONSORED_SWAP_SLIPPAGE_TOO_HIGH: "SPONSORED_SWAP_SLIPPAGE_TOO_HIGH", @@ -250,6 +251,16 @@ export class AmountTooHighError extends InputError { } } +export class SwapSlippageInsufficientError extends InputError { + constructor(args: { message: string }) { + super({ + message: args.message, + code: AcrossErrorCode.SWAP_SLIPPAGE_INSUFFICIENT, + param: "slippage", + }); + } +} + export class SwapQuoteUnavailableError extends AcrossApiError { constructor( args: { diff --git a/api/_slippage.ts b/api/_slippage.ts index 9f8ecbbdc..c56639c50 100644 --- a/api/_slippage.ts +++ b/api/_slippage.ts @@ -5,6 +5,7 @@ import { CHAIN_IDs, } from "./_constants"; import { OriginOrDestination, Token } from "./_dexes/types"; +import { SwapSlippageInsufficientError } from "./_errors"; export const STABLE_COIN_SWAP_SLIPPAGE = { origin: 0.25, // 0.25% @@ -147,3 +148,45 @@ function isMajorTokenSymbol(symbol: string, chainId: number) { majorTokenSymbol.toUpperCase() === symbol.toUpperCase() ); } + +/** + * Validates that user-provided slippage is sufficient for destination swaps. + * Throws an error if the user slippage is less than the auto (recommended) slippage. + * Only validates when slippage is explicitly set by the user. + * + * @param params.tokenIn - The input token for the destination swap + * @param params.tokenOut - The output token for the destination swap + * @param params.slippageTolerance - The user-provided slippage tolerance + * @param params.splitSlippage - Whether slippage is being split between origin and destination + * @throws {InputError} When user slippage is less than auto slippage + */ +export function validateDestinationSwapSlippage(params: { + tokenIn: Token; + tokenOut: Token; + slippageTolerance: number | "auto"; + splitSlippage?: boolean; +}) { + // Only validate if user explicitly provided a slippage value + if (params.slippageTolerance === "auto") { + return; + } + + const autoSlippage = resolveAutoSlippage({ + tokenIn: params.tokenIn, + tokenOut: params.tokenOut, + originOrDestination: "destination", + }); + + // Calculate the effective user slippage (accounting for split if applicable) + const userSlippage = params.splitSlippage + ? params.slippageTolerance / 2 + : params.slippageTolerance; + + if (userSlippage < autoSlippage) { + throw new SwapSlippageInsufficientError({ + message: `Insufficient slippage tolerance. Minimum recommended slippage is ${( + autoSlippage / 100 + ).toFixed(4)} for this token pair.`, + }); + } +} diff --git a/test/api/_slippage.test.ts b/test/api/_slippage.test.ts index 6c77689c9..272cff612 100644 --- a/test/api/_slippage.test.ts +++ b/test/api/_slippage.test.ts @@ -1,10 +1,12 @@ import { getSlippage, + validateDestinationSwapSlippage, STABLE_COIN_SWAP_SLIPPAGE, MAJOR_PAIR_SLIPPAGE, LONG_TAIL_SLIPPAGE, } from "../../api/_slippage"; import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../api/_constants"; +import { SwapSlippageInsufficientError } from "../../api/_errors"; describe("api/_slippage", () => { const wethMainnet = { @@ -166,4 +168,141 @@ describe("api/_slippage", () => { ).toThrow("Can't resolve auto slippage for tokens on different chains"); }); }); + + describe("#validateDestinationSwapSlippage()", () => { + test("should not throw when slippage is 'auto'", () => { + expect(() => + validateDestinationSwapSlippage({ + tokenIn: usdcMainnet, + tokenOut: usdtMainnet, + slippageTolerance: "auto", + splitSlippage: false, + }) + ).not.toThrow(); + }); + + test("should not throw when user slippage equals auto slippage", () => { + // Stable coin pair destination slippage = 0.5% + expect(() => + validateDestinationSwapSlippage({ + tokenIn: usdcMainnet, + tokenOut: usdtMainnet, + slippageTolerance: STABLE_COIN_SWAP_SLIPPAGE.destination, + splitSlippage: false, + }) + ).not.toThrow(); + }); + + test("should not throw when user slippage is greater than auto slippage", () => { + // Stable coin pair destination slippage = 0.5%, user provides 1.0% + expect(() => + validateDestinationSwapSlippage({ + tokenIn: usdcMainnet, + tokenOut: usdtMainnet, + slippageTolerance: 1.0, + splitSlippage: false, + }) + ).not.toThrow(); + }); + + test("should throw SwapSlippageInsufficientError when user slippage < auto for stable coins", () => { + // Stable coin pair destination slippage = 0.5%, user provides 0.3% + expect(() => + validateDestinationSwapSlippage({ + tokenIn: usdcMainnet, + tokenOut: usdtMainnet, + slippageTolerance: 0.3, + splitSlippage: false, + }) + ).toThrow(SwapSlippageInsufficientError); + + expect(() => + validateDestinationSwapSlippage({ + tokenIn: usdcMainnet, + tokenOut: usdtMainnet, + slippageTolerance: 0.3, + splitSlippage: false, + }) + ).toThrow(/Minimum recommended slippage is 0\.0050/); + }); + + test("should throw SwapSlippageInsufficientError when user slippage < auto for major pair", () => { + // Major pair (WETH/USDC) destination slippage = 1.5%, user provides 1.0% + expect(() => + validateDestinationSwapSlippage({ + tokenIn: wethMainnet, + tokenOut: usdcMainnet, + slippageTolerance: 1.0, + splitSlippage: false, + }) + ).toThrow(SwapSlippageInsufficientError); + + expect(() => + validateDestinationSwapSlippage({ + tokenIn: wethMainnet, + tokenOut: usdcMainnet, + slippageTolerance: 1.0, + splitSlippage: false, + }) + ).toThrow(/Minimum recommended slippage is 0\.0150/); + }); + + test("should throw SwapSlippageInsufficientError when user slippage < auto for long tail pair", () => { + // Long tail pair (PEPE/USDT) destination slippage = 5.0%, user provides 3.0% + expect(() => + validateDestinationSwapSlippage({ + tokenIn: usdtMainnet, + tokenOut: pepeMainnet, + slippageTolerance: 3.0, + splitSlippage: false, + }) + ).toThrow(SwapSlippageInsufficientError); + + expect(() => + validateDestinationSwapSlippage({ + tokenIn: usdtMainnet, + tokenOut: pepeMainnet, + slippageTolerance: 3.0, + splitSlippage: false, + }) + ).toThrow(/Minimum recommended slippage is 0\.0500/); + }); + + test("should account for split slippage in validation", () => { + // User provides 2.0% with splitSlippage: true + // Effective slippage = 1.0% + // Major pair auto = 1.5%, so should throw + expect(() => + validateDestinationSwapSlippage({ + tokenIn: wethMainnet, + tokenOut: usdcMainnet, + slippageTolerance: 2.0, + splitSlippage: true, + }) + ).toThrow(SwapSlippageInsufficientError); + + expect(() => + validateDestinationSwapSlippage({ + tokenIn: wethMainnet, + tokenOut: usdcMainnet, + slippageTolerance: 2.0, + splitSlippage: true, + }) + ).toThrow(/Minimum recommended slippage is 0\.0150/); + }); + + test("should pass validation with split slippage when sufficient", () => { + // User provides 4.0% with splitSlippage: true + // Effective slippage = 2.0% (4.0% / 2) + // Major pair auto = 1.5%, so should pass + expect(() => + validateDestinationSwapSlippage({ + tokenIn: wethMainnet, + tokenOut: usdcMainnet, + slippageTolerance: 4.0, + splitSlippage: true, + }) + ).not.toThrow(); + }); + }); });