diff --git a/src/core/modes/router/index.test.ts b/src/core/modes/router/index.test.ts index 549c52af..839bb5c7 100644 --- a/src/core/modes/router/index.test.ts +++ b/src/core/modes/router/index.test.ts @@ -8,10 +8,15 @@ import { SimulationResult, TradeType } from "../../types"; import { describe, it, expect, vi, beforeEach, Mock, assert } from "vitest"; // Mocks -vi.mock("../../../common", async (importOriginal) => ({ - ...(await importOriginal()), - extendObjectWithHeader: vi.fn(), -})); +// extendObjectWithHeader is wrapped with its real implementation so call +// assertions work while span attributes still get merged for assertions +vi.mock("../../../common", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + extendObjectWithHeader: vi.fn(original.extendObjectWithHeader), + }; +}); vi.mock("sushi/currency", async (importOriginal) => { return { @@ -99,7 +104,7 @@ describe("Test findBestRouterTrade", () => { ); assert(result.isOk()); - expect(result.value.spanAttributes.foundOpp).toBe(true); + expect(result.value.spanAttributes).toEqual({ foundOpp: true }); expect(result.value.estimatedProfit).toBe(100n); expect(result.value.oppBlockNumber).toBe(123); expect(result.value.type).toBe("balancer"); @@ -139,6 +144,10 @@ describe("Test findBestRouterTrade", () => { assert(result.isErr()); expect(result.error.noneNodeError).toBe("no route available"); expect(result.error.type).toBe("router"); + expect(result.error.spanAttributes).toEqual({ + "full.route": "no-way", + "partial.error": "no viable partial trade size found", + }); expect(extendObjectWithHeader).toHaveBeenCalledWith( expect.any(Object), { route: "no-way" }, @@ -175,7 +184,7 @@ describe("Test findBestRouterTrade", () => { ); assert(result.isOk()); - expect(result.value.spanAttributes.foundOpp).toBe(true); + expect(result.value.spanAttributes).toEqual({ foundOpp: true }); expect(result.value.estimatedProfit).toBe(50n); expect(result.value.type).toBe("routeProcessor"); expect(mockRainSolver.state.router.findLargestTradeSize).toHaveBeenCalledWith( @@ -185,6 +194,8 @@ describe("Test findBestRouterTrade", () => { 1000n, 100n, undefined, + false, + undefined, ); expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith({ @@ -230,7 +241,7 @@ describe("Test findBestRouterTrade", () => { ); assert(result.isOk()); - expect(result.value.spanAttributes.foundOpp).toBe(true); + expect(result.value.spanAttributes).toEqual({ foundOpp: true }); expect(result.value.estimatedProfit).toBe(50n); expect(result.value.type).toBe("routeProcessor"); expect(mockRainSolver.state.router.findLargestTradeSize).toHaveBeenCalledWith( @@ -240,6 +251,8 @@ describe("Test findBestRouterTrade", () => { 1000n, 100n, undefined, + false, + undefined, ); expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith({ @@ -280,9 +293,10 @@ describe("Test findBestRouterTrade", () => { assert(result.isErr()); expect(result.error.noneNodeError).toBe("order ratio issue"); expect(result.error.type).toBe("router"); - expect(result.error.spanAttributes["partial.error"]).toBe( - "no viable partial trade size found", - ); + expect(result.error.spanAttributes).toEqual({ + "full.error": "ratio too high", + "partial.error": "no viable partial trade size found", + }); expect(extendObjectWithHeader).toHaveBeenCalledWith( expect.any(Object), { error: "ratio too high" }, @@ -322,6 +336,10 @@ describe("Test findBestRouterTrade", () => { assert(result.isErr()); expect(result.error.noneNodeError).toBe("order ratio issue"); // from full trade error expect(result.error.type).toBe("balancer"); + expect(result.error.spanAttributes).toEqual({ + "full.error": "ratio too high", + "partial.error": "no opportunity", + }); expect(extendObjectWithHeader).toHaveBeenCalledWith( expect.any(Object), { error: "ratio too high" }, @@ -363,7 +381,7 @@ describe("Test findBestRouterTrade", () => { ); assert(result.isOk()); - expect(result.value.spanAttributes.foundOpp).toBe(true); + expect(result.value.spanAttributes).toEqual({ foundOpp: true }); expect(result.value.estimatedProfit).toBe(75n); expect(result.value.oppBlockNumber).toBe(123); expect(result.value.type).toBe("routeProcessor"); @@ -374,6 +392,8 @@ describe("Test findBestRouterTrade", () => { 1000n, 100n, undefined, + false, + undefined, ); expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith({ @@ -395,6 +415,129 @@ describe("Test findBestRouterTrade", () => { ); }); + it("should retry with the failing route dexes excluded when full trade dryrun fails", async () => { + const sushiQuote = { + route: { + pcMap: new Map([["pool1", { liquidityProvider: "Hydrex" }]]), + route: { legs: [{ uniqueId: "pool1" }] }, + }, + } as any; + const mockFullTradeError = Result.err({ + type: TradeType.RouteProcessor, + reason: SimulationHaltReason.NoOpportunity, + spanAttributes: { error: "dryrun failed" }, + }); + const mockRetrySuccess = Result.ok({ + type: TradeType.RouteProcessor, + spanAttributes: { foundOpp: true }, + estimatedProfit: 50n, + oppBlockNumber: 123, + }); + (simulatorWithArgsSpy as Mock) + .mockReturnValueOnce({ + quote: sushiQuote, + trySimulateTrade: vi.fn().mockResolvedValue(mockFullTradeError), + }) + .mockReturnValueOnce({ + quote: sushiQuote, + trySimulateTrade: vi.fn().mockResolvedValue(mockRetrySuccess), + }); + + const result: SimulationResult = await findBestRouterTrade.call( + mockRainSolver, + orderDetails, + signer, + ethPrice, + toToken, + fromToken, + blockNumber, + ); + + assert(result.isOk()); + expect(result.value.spanAttributes).toEqual({ foundOpp: true }); + expect(result.value.estimatedProfit).toBe(50n); + expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(2); + expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith({ + type: TradeType.Router, + solver: mockRainSolver, + orderDetails, + fromToken, + toToken, + signer, + maximumInputFixed: 1000n, + ethPrice, + isPartial: false, + blockNumber: 123n, + excludeDexes: new Set(["Hydrex"]), + }); + expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); + }); + + it("should return error when retry attempt also fails", async () => { + const sushiQuote = { + route: { + pcMap: new Map([["pool1", { liquidityProvider: "Hydrex" }]]), + route: { legs: [{ uniqueId: "pool1" }] }, + }, + } as any; + const mockFullTradeError = Result.err({ + type: TradeType.RouteProcessor, + reason: SimulationHaltReason.NoOpportunity, + spanAttributes: { error: "dryrun failed" }, + noneNodeError: "full failed", + }); + const mockRetryError = Result.err({ + type: TradeType.RouteProcessor, + reason: SimulationHaltReason.NoOpportunity, + spanAttributes: { error: "retry dryrun failed" }, + noneNodeError: "retry failed", + }); + (simulatorWithArgsSpy as Mock) + .mockReturnValueOnce({ + quote: sushiQuote, + trySimulateTrade: vi.fn().mockResolvedValue(mockFullTradeError), + }) + .mockReturnValueOnce({ + quote: undefined, + trySimulateTrade: vi.fn().mockResolvedValue(mockRetryError), + }); + + const result: SimulationResult = await findBestRouterTrade.call( + mockRainSolver, + orderDetails, + signer, + ethPrice, + toToken, + fromToken, + blockNumber, + ); + + assert(result.isErr()); + expect(result.error.noneNodeError).toBe("full failed"); + expect(result.error.type).toBe(TradeType.RouteProcessor); + expect(result.error.spanAttributes).toEqual({ + "full.error": "dryrun failed", + "secondary.full.error": "retry dryrun failed", + }); + expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(2); + expect(extendObjectWithHeader).toHaveBeenCalledWith( + expect.any(Object), + { error: "dryrun failed" }, + "full", + ); + expect(extendObjectWithHeader).toHaveBeenCalledWith( + expect.any(Object), + { error: "retry dryrun failed" }, + "full", + ); + expect(extendObjectWithHeader).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + "secondary", + ); + expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); + }); + it("should return early if ethPrice is unknown", async () => { const result: SimulationResult = await findBestRouterTrade.call( mockRainSolver, diff --git a/src/core/modes/router/index.ts b/src/core/modes/router/index.ts index 4e381bcd..8c6382f9 100644 --- a/src/core/modes/router/index.ts +++ b/src/core/modes/router/index.ts @@ -1,17 +1,31 @@ import { RainSolver } from "../.."; import { Pair } from "../../../order"; import { Token } from "sushi/currency"; +import { LiquidityProviders } from "sushi"; import { Attributes } from "@opentelemetry/api"; import { RainSolverSigner } from "../../../signer"; import { RouterTradeSimulator } from "./simulate"; import { SimulationHaltReason } from "../simulator"; +import { SushiRouterQuote } from "../../../router"; import { SimulationResult, TradeType } from "../../types"; import { Result, extendObjectWithHeader } from "../../../common"; +/** Represents the result of a router trade attempt paired with its full trade size quote */ +export type RouterTradeAttempt = { + /** The simulation result of the attempt */ + result: SimulationResult; + /** The quote of the attempt's full trade size simulation */ + quote?: RouterTradeSimulator["quote"]; +}; + /** - * Tries to find the best trade against rain router (balancer and sushi) for the given order, - * it will try to simulate a trade for full trade size (order's max output) - * and if it was not successful it will try again with partial trade size + * Tries to find the best trade against rain router (balancer and sushi) for the + * given order, it will first try normally with all enabled dexes, and if the best + * route got rejected onchain during dryrun, it will try once more with the failing + * route's dexes excluded, so the next best route is tried, this is because the + * sushi router lib pool models can be inaccurate for some dexes leading to false + * positive quotes that dont hold up onchain and also shadow other good routes as + * long as they wrongly quote the best amount out * @param this - RainSolver instance * @param orderDetails - The details of the order to be processed * @param signer - The signer to be used for the trade @@ -29,32 +43,105 @@ export async function findBestRouterTrade( fromToken: Token, blockNumber: bigint, ): Promise { + // primary attempt normally with all enabled dexes + const primary = await tryFindBestRouterTrade.call( + this, + orderDetails, + signer, + ethPrice, + toToken, + fromToken, + blockNumber, + ); + if (primary.result.isOk()) { + return primary.result; + } + + // retry once more with the primary attempt's failing route dexes + // excluded if it was rejected onchain during dryrun + const excludeDexes = SushiRouterQuote.is(primary.quote) + ? SushiRouterQuote.getRouteDexes(primary.quote) + : new Set(); + if ( + primary.result.error.reason === SimulationHaltReason.NoOpportunity && + excludeDexes.size == 1 + ) { + const secondary = await tryFindBestRouterTrade.call( + this, + orderDetails, + signer, + ethPrice, + toToken, + fromToken, + blockNumber, + excludeDexes, + ); + if (secondary.result.isOk()) { + return secondary.result; + } + extendObjectWithHeader( + primary.result.error.spanAttributes, + secondary.result.error.spanAttributes, + "secondary", + ); + primary.result.error.noneNodeError ??= secondary.result.error.noneNodeError; + } + return primary.result; +} + +/** + * Tries to find a trade against rain router (balancer and sushi) for the given order, + * it will try to simulate a trade for full trade size (order's max output) + * and if it was not successful it will try again with partial trade size + * @param this - RainSolver instance + * @param orderDetails - The details of the order to be processed + * @param signer - The signer to be used for the trade + * @param ethPrice - The current ETH price + * @param toToken - The token to trade to + * @param fromToken - The token to trade from + * @param blockNumber - The current block number + * @param excludeDexes - (optional) Liquidity providers (dexes) to exclude from route finding + */ +export async function tryFindBestRouterTrade( + this: RainSolver, + orderDetails: Pair, + signer: RainSolverSigner, + ethPrice: string, + toToken: Token, + fromToken: Token, + blockNumber: bigint, + excludeDexes?: Set, +): Promise { const spanAttributes: Attributes = {}; // exit early if required trade addresses are not configured if (!this.state.contracts.getAddressesForTrade(orderDetails, TradeType.Router)) { spanAttributes["error"] = `Cannot trade as sushi route processor and balancer arb addresses are not configured for order ${orderDetails.takeOrder.struct.order.type} trade`; - return Result.err({ - type: TradeType.Router, - spanAttributes, - reason: SimulationHaltReason.UndefinedTradeDestinationAddress, - }); + return { + result: Result.err({ + type: TradeType.Router, + spanAttributes, + reason: SimulationHaltReason.UndefinedTradeDestinationAddress, + }), + }; } // exit early if eth price is unknown if (!ethPrice) { spanAttributes["error"] = "no route to get price of input token to eth"; - return Result.err({ - type: TradeType.Router, - spanAttributes, - }); + return { + result: Result.err({ + type: TradeType.Router, + spanAttributes, + }), + }; } const maximumInput = orderDetails.takeOrder.quote!.maxOutput; // try simulation for full trade size and return if succeeds - const fullTradeSizeSimResult = await RouterTradeSimulator.withArgs({ + const fullTradeSimulator = RouterTradeSimulator.withArgs({ type: TradeType.Router, solver: this, orderDetails, @@ -65,9 +152,12 @@ export async function findBestRouterTrade( ethPrice, isPartial: false, blockNumber, - }).trySimulateTrade(); + excludeDexes, + }); + const fullTradeSizeSimResult = await fullTradeSimulator.trySimulateTrade(); + const quote = fullTradeSimulator.quote; if (fullTradeSizeSimResult.isOk()) { - return fullTradeSizeSimResult; + return { result: fullTradeSizeSimResult, quote }; } extendObjectWithHeader(spanAttributes, fullTradeSizeSimResult.error.spanAttributes, "full"); @@ -82,11 +172,15 @@ export async function findBestRouterTrade( fullTradeSizeSimResult.error.reason !== SimulationHaltReason.OrderRatioGreaterThanMarketPrice ) { - return Result.err({ - type: fullTradeSizeSimResult.error.type, - spanAttributes, - noneNodeError: fullTradeSizeSimResult.error.noneNodeError, - }); + return { + result: Result.err({ + type: fullTradeSizeSimResult.error.type, + spanAttributes, + noneNodeError: fullTradeSizeSimResult.error.noneNodeError, + reason: fullTradeSizeSimResult.error.reason, + }), + quote, + }; } // try simulation for partial trade size @@ -97,16 +191,21 @@ export async function findBestRouterTrade( maximumInput, this.state.gasPrice, this.appOptions.route, + false, + excludeDexes, ); if (!partialTradeSize) { spanAttributes["partial.error"] = "no viable partial trade size found"; - return Result.err({ - type: fullTradeSizeSimResult.error.type, - spanAttributes, - noneNodeError: fullTradeSizeSimResult.error.noneNodeError, - }); + return { + result: Result.err({ + type: fullTradeSizeSimResult.error.type, + spanAttributes, + noneNodeError: fullTradeSizeSimResult.error.noneNodeError, + }), + quote, + }; } - const partialTradeSizeSimResult = await RouterTradeSimulator.withArgs({ + const partialTradeSimulator = RouterTradeSimulator.withArgs({ type: TradeType.Router, solver: this, orderDetails, @@ -117,20 +216,25 @@ export async function findBestRouterTrade( ethPrice, isPartial: true, blockNumber, - }).trySimulateTrade(); + excludeDexes, + }); + const partialTradeSizeSimResult = await partialTradeSimulator.trySimulateTrade(); if (partialTradeSizeSimResult.isOk()) { - return partialTradeSizeSimResult; + return { result: partialTradeSizeSimResult, quote }; } extendObjectWithHeader( spanAttributes, partialTradeSizeSimResult.error.spanAttributes, "partial", ); - return Result.err({ - type: fullTradeSizeSimResult.error.type, - spanAttributes, - noneNodeError: - fullTradeSizeSimResult.error.noneNodeError ?? - partialTradeSizeSimResult.error.noneNodeError, - }); + return { + result: Result.err({ + type: fullTradeSizeSimResult.error.type, + spanAttributes, + noneNodeError: + fullTradeSizeSimResult.error.noneNodeError ?? + partialTradeSizeSimResult.error.noneNodeError, + }), + quote, + }; } diff --git a/src/core/modes/router/simulate.ts b/src/core/modes/router/simulate.ts index 2460ad13..fcb42db4 100644 --- a/src/core/modes/router/simulate.ts +++ b/src/core/modes/router/simulate.ts @@ -8,7 +8,8 @@ import { Result, ABI, RawTransaction } from "../../../common"; import { encodeFunctionData, formatUnits, parseUnits, zeroAddress } from "viem"; import { TradeType, FailedSimulation, TaskType } from "../../types"; import { SimulationHaltReason, TradeSimulatorBase } from "../simulator"; -import { RainSolverRouterErrorType, RouterType } from "../../../router"; +import { LiquidityProviders } from "sushi"; +import { RainSolverRouterErrorType, RouterType, RainSolverRouterQuote } from "../../../router"; import { EnsureBountyTaskType, EnsureBountyTaskErrorType, @@ -37,6 +38,8 @@ export type SimulateRouterTradeArgs = { blockNumber: bigint; /** Whether should set partial max input for take order */ isPartial: boolean; + /** Liquidity providers (dexes) to exclude from route finding */ + excludeDexes?: Set; }; /** Arguments for preparing router trade type parameters required for simulation and building tx object */ @@ -67,6 +70,8 @@ export type RouterTradePreparedParams = { */ export class RouterTradeSimulator extends TradeSimulatorBase { declare tradeArgs: SimulateRouterTradeArgs; + /** The quote of the route that this simulation was tried with, set during prepareTradeParams */ + quote?: RainSolverRouterQuote; static withArgs(tradeArgs: SimulateRouterTradeArgs): RouterTradeSimulator { return new RouterTradeSimulator(tradeArgs); @@ -87,6 +92,9 @@ export class RouterTradeSimulator extends TradeSimulatorBase { const maximumInput = scaleFrom18(maximumInputFixed, orderDetails.sellTokenDecimals); this.spanAttributes["amountIn"] = formatUnits(maximumInputFixed, 18); this.spanAttributes["oppBlockNumber"] = Number(blockNumber); + if (this.tradeArgs.excludeDexes?.size) { + this.spanAttributes["excludedDexes"] = Array.from(this.tradeArgs.excludeDexes); + } const tradeParamsResult = await this.tradeArgs.solver.state.router.getTradeParams({ state: this.tradeArgs.solver.state, @@ -97,6 +105,7 @@ export class RouterTradeSimulator extends TradeSimulatorBase { signer, blockNumber, isPartial, + excludeDexes: this.tradeArgs.excludeDexes, }); if (tradeParamsResult.isErr()) { const result = { @@ -120,6 +129,10 @@ export class RouterTradeSimulator extends TradeSimulatorBase { takeOrdersConfigStruct, } = tradeParamsResult.value; + // keep the quote to make the route that this simulation was + // tried with identifiable by the caller in case it fails + this.quote = quote; + // determine trade type based on route type let type = TradeType.Router; switch (routeType) { diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 429e5362..21373bcf 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -930,6 +930,7 @@ describe("RainSolverRouter", () => { mockGasPrice, "single", false, + undefined, ); sushiSpy.mockRestore(); diff --git a/src/router/router.ts b/src/router/router.ts index b20f722f..bf110139 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -292,6 +292,7 @@ export class RainSolverRouter extends RainSolverRouterBase { gasPriceBI: bigint, routeType: "single" | "multi" = "single", absolute = false, + excludeDexes?: Set, ): bigint | undefined { return this.sushi?.findLargestTradeSize( orderDetails, @@ -301,6 +302,7 @@ export class RainSolverRouter extends RainSolverRouterBase { gasPriceBI, routeType, absolute, + excludeDexes, ); } diff --git a/src/router/sushi/index.test.ts b/src/router/sushi/index.test.ts index f35ca91b..f51de12c 100644 --- a/src/router/sushi/index.test.ts +++ b/src/router/sushi/index.test.ts @@ -364,6 +364,77 @@ describe("test SushiRouter methods", () => { ); }); + it("should exclude the given dexes from route finding", async () => { + const mockRoute = { + status: "Success", + amountOutBI: 2000000000n, + }; + const mockPcMap = new Map(); + + (mockDataFetcher.getCurrentPoolCodeMap as Mock).mockReturnValue(mockPcMap); + (Router.findBestRoute as Mock).mockReturnValue(mockRoute); + + const routerWithLps = new SushiRouter( + chainId, + mockClient, + mockDataFetcher, + routerAddress, + [LiquidityProviders.UniswapV2, LiquidityProviders.UniswapV3], + ); + const params: SushiQuoteParams = { + fromToken: mockTokenIn, + toToken: mockTokenOut, + amountIn: mockSwapAmount, + skipFetch: true, + gasPrice, + excludeDexes: new Set([LiquidityProviders.UniswapV3]), + }; + + const result = await routerWithLps.findBestRoute(params); + assert(result.isOk()); + + // the liquidity providers passed to Router.findBestRoute should + // exclude the given dexes and keep others + const passedLps = (Router.findBestRoute as Mock).mock.calls[0][6]; + expect(passedLps).toEqual([LiquidityProviders.UniswapV2]); + }); + + it("should fall back to dataFetcher providers for exclusion when no lps configured", async () => { + const mockRoute = { + status: "Success", + amountOutBI: 2000000000n, + }; + const mockPcMap = new Map(); + + (mockDataFetcher.getCurrentPoolCodeMap as Mock).mockReturnValue(mockPcMap); + (Router.findBestRoute as Mock).mockReturnValue(mockRoute); + (mockDataFetcher as any).providers = [ + { getType: () => LiquidityProviders.UniswapV2 }, + { getType: () => LiquidityProviders.UniswapV3 }, + ]; + + const routerWithoutLps = new SushiRouter( + chainId, + mockClient, + mockDataFetcher, + routerAddress, + ); + const params: SushiQuoteParams = { + fromToken: mockTokenIn, + toToken: mockTokenOut, + amountIn: mockSwapAmount, + skipFetch: true, + gasPrice, + excludeDexes: new Set([LiquidityProviders.UniswapV2]), + }; + + const result = await routerWithoutLps.findBestRoute(params); + assert(result.isOk()); + + const passedLps = (Router.findBestRoute as Mock).mock.calls[0][6]; + expect(passedLps).toEqual([LiquidityProviders.UniswapV3]); + }); + it("should return NoRouteFound error when router finds no way", async () => { const mockRoute = { status: "NoWay", @@ -979,7 +1050,7 @@ describe("test SushiRouter methods", () => { ); expect(typeof result).toBe("bigint"); - expect(result).toBe(3999999761581420898n); + expect(result).toBe(3959999978542327883n); }); it("should return undefined if all OK routes have price < ratio", () => { diff --git a/src/router/sushi/index.ts b/src/router/sushi/index.ts index 71aa5638..0465e085 100644 --- a/src/router/sushi/index.ts +++ b/src/router/sushi/index.ts @@ -66,6 +66,25 @@ export type SushiRouterQuote = { /** The amount out for the given amount in of the route */ amountOut: bigint; }; +export namespace SushiRouterQuote { + export function is(value: any): value is SushiRouterQuote { + return !!value?.route?.pcMap; + } + + /** + * Gets the list of dexes (liquidity providers) that make + * up the given quote's route legs + * @param quote - The quote to get the route dexes for + */ + export function getRouteDexes(quote: SushiRouterQuote): Set { + const dexes = new Set(); + quote.route.route.legs?.forEach((leg) => { + const dex = quote.route.pcMap.get(leg.uniqueId)?.liquidityProvider; + if (dex) dexes.add(dex); + }); + return dexes; + } +} /** Represents the trade params for a Sushi route */ export type SushiTradeParams = { @@ -199,6 +218,7 @@ export class SushiRouter extends RainSolverRouterBase { ignoreCache = undefined, skipFetch = false, sushiRouteType, + excludeDexes, } = params; try { if (!skipFetch) { @@ -215,7 +235,7 @@ export class SushiRouter extends RainSolverRouterBase { amountIn, toToken, Number(gasPrice), - undefined, + this.getFilteredLiquidityProviders(excludeDexes), poolFilter, undefined, sushiRouteType, @@ -422,6 +442,7 @@ export class SushiRouter extends RainSolverRouterBase { blockNumber, skipFetch: true, sushiRouteType: state.appOptions.route, + excludeDexes: args.excludeDexes, }); // exit early if no route found @@ -498,10 +519,12 @@ export class SushiRouter extends RainSolverRouterBase { gasPriceBI: bigint, routeType: "single" | "multi" = "single", absolute = false, + excludeDexes?: Set, ): bigint | undefined { const result: bigint[] = []; const gasPrice = Number(gasPriceBI); const ratio = orderDetails.takeOrder.quote!.ratio; + const liquidityProviders = this.getFilteredLiquidityProviders(excludeDexes); const pcMap = this.dataFetcher.getCurrentPoolCodeMap(fromToken, toToken); const initAmount = scaleFrom18(maximumInputFixed, fromToken.decimals) / 2n; let maximumInput = initAmount; @@ -514,7 +537,7 @@ export class SushiRouter extends RainSolverRouterBase { maximumInput, toToken, gasPrice, - undefined, + liquidityProviders, poolFilter, undefined, routeType, @@ -535,12 +558,15 @@ export class SushiRouter extends RainSolverRouterBase { } else { // realized average execution price of the simulated swap, this already // includes the route's price impact, same as the trade simulation gate - const effectivePrice = calculatePrice18( - maximumInput, - route.amountOutBI, - fromToken.decimals, - toToken.decimals, - ); + const effectivePrice = + (calculatePrice18( + maximumInput, + route.amountOutBI, + fromToken.decimals, + toToken.decimals, + ) * + 99n) / + 100n; if (effectivePrice < ratio) { maximumInput = maximumInput - initAmount / 2n ** i; } else { @@ -556,4 +582,16 @@ export class SushiRouter extends RainSolverRouterBase { return undefined; } } + + /** + * Returns the list of enabled liquidity providers (dexes) with the given + * ones excluded, or undefined (meaning all) if there is nothing to exclude + * @param excludeDexes - The liquidity providers (dexes) to exclude + */ + getFilteredLiquidityProviders(excludeDexes?: Set): LiquidityProviders[] | undefined { + if (!excludeDexes?.size) return undefined; + const enabledLps = + this.liquidityProviders ?? this.dataFetcher.providers.map((p) => p.getType()); + return enabledLps.filter((lp) => !excludeDexes.has(lp)); + } } diff --git a/src/router/types.ts b/src/router/types.ts index 1100dad0..7bd98374 100644 --- a/src/router/types.ts +++ b/src/router/types.ts @@ -17,6 +17,7 @@ import { TakeOrdersConfigTypeV4, TakeOrdersConfigTypeV5, } from "../order"; +import { LiquidityProviders } from "sushi"; /* * Default price imapct tolerance used for getting unit market price. @@ -53,6 +54,8 @@ export type RainSolverRouterQuoteParams = { senderAddress?: `0x${string}`; sushiRouteType?: "single" | "multi"; sushiRouter?: SushiRouter; + /** Liquidity providers (dexes) to exclude from route finding */ + excludeDexes?: Set; }; /** Arguments for simulating a trade against routers */ @@ -73,6 +76,8 @@ export type GetTradeParamsArgs = { blockNumber: bigint; /** Whether should set partial max input for take order */ isPartial: boolean; + /** Liquidity providers (dexes) to exclude from route finding */ + excludeDexes?: Set; }; /** Represents the trade params for a RainSolverRouter route */