From 418678c46d43a09f49a8c3f62999ab84af4289ac Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Mon, 14 Sep 2026 19:08:08 +0000 Subject: [PATCH 1/2] init --- src/core/modes/router/simulate.ts | 5 +- src/router/sushi/index.test.ts | 158 ++++++++++++++++++++++++++++++ src/router/sushi/index.ts | 85 ++++++++++++++-- src/router/types.ts | 6 +- 4 files changed, 243 insertions(+), 11 deletions(-) diff --git a/src/core/modes/router/simulate.ts b/src/core/modes/router/simulate.ts index e8dceea4..6672d360 100644 --- a/src/core/modes/router/simulate.ts +++ b/src/core/modes/router/simulate.ts @@ -167,8 +167,9 @@ export class RouterTradeSimulator extends TradeSimulatorBase { this.spanAttributes["marketPrice"] = formatUnits(quote.price, 18); this.spanAttributes["route"] = routeVisual; if (this.tradeArgs.lockRoute && this.tradeArgs.sushiQuote) { - // the locked route quote may belong to a different amount, so the - // amountOut and marketPrice attributes above may not be for this size + // the route of the given quote is reused, the quote itself may belong + // to a different amount, so the router requoted the route for this size + // and the amountOut and marketPrice attributes above are for this size this.spanAttributes["routeReused"] = true; } diff --git a/src/router/sushi/index.test.ts b/src/router/sushi/index.test.ts index a3d841b8..815c0bad 100644 --- a/src/router/sushi/index.test.ts +++ b/src/router/sushi/index.test.ts @@ -515,6 +515,92 @@ describe("test SushiRouter methods", () => { }); }); + describe("test requoteRoute method", () => { + const poolCode1 = { pool: { address: "0xpool1" }, liquidityProvider: "SushiSwapV2" }; + const poolCode2 = { pool: { address: "0xpool2" }, liquidityProvider: "UniswapV3" }; + const quote = { + type: RouterType.Sushi as const, + status: RouteStatus.Success, + price: 2000n * ONE18, // for 2 WETH in + route: { + route: { + status: "Success", + legs: [{ uniqueId: "pool1" }], + amountOutBI: 4000000000n, + }, + pcMap: new Map([ + ["pool1", poolCode1], + ["pool2", poolCode2], + ]), + } as any, + amountOut: 4000000000n, + }; + + it("should requote the same route pools only for the new amount", () => { + const newRoute = { + status: "Success", + legs: [{ uniqueId: "pool1" }], + amountOutBI: 2100000000n, + }; + (Router.findBestRoute as Mock).mockReturnValue(newRoute); + + const result = router.requoteRoute( + quote, + mockSwapAmount, // 1 WETH + mockTokenIn, + mockTokenOut, + gasPrice, + "single", + ); + + // only the route leg pool is offered to the route finder + expect(Router.findBestRoute).toHaveBeenCalledWith( + new Map([["pool1", poolCode1]]), + chainId, + mockTokenIn, + mockSwapAmount, + mockTokenOut, + 0, + undefined, + expect.any(Function), + undefined, + "single", + ); + // price and amount out belong to the new amount, the full pool map is kept + expect(result).toEqual({ + type: RouterType.Sushi, + status: RouteStatus.Success, + price: 2100n * ONE18, + route: { route: newRoute, pcMap: quote.route.pcMap }, + amountOut: 2100000000n, + }); + }); + + it("should return the given quote as is when the route cannot be quoted for the new amount", () => { + (Router.findBestRoute as Mock).mockReturnValueOnce({ status: "NoWay" }); + expect( + router.requoteRoute(quote, mockSwapAmount, mockTokenIn, mockTokenOut, gasPrice), + ).toBe(quote); + + (Router.findBestRoute as Mock).mockReturnValueOnce({ + status: "Success", + legs: [{ uniqueId: "pool1" }], + amountOutBI: -1n, + }); + expect( + router.requoteRoute(quote, mockSwapAmount, mockTokenIn, mockTokenOut, gasPrice), + ).toBe(quote); + }); + + it("should return the given quote as is when it has no route legs", () => { + const legless = { ...quote, route: { ...quote.route, route: { legs: [] } } } as any; + expect( + router.requoteRoute(legless, mockSwapAmount, mockTokenIn, mockTokenOut, gasPrice), + ).toBe(legless); + expect(Router.findBestRoute).not.toHaveBeenCalled(); + }); + }); + describe("test update method", () => { it("should update pools without block number", async () => { (mockDataFetcher.updatePools as Mock).mockResolvedValue(undefined); @@ -874,6 +960,78 @@ describe("test SushiRouter methods", () => { routeProcessor4ParamsSpy.mockRestore(); }); + it("should requote a locked route for the given maximum input", async () => { + const lockedQuote = { + type: RouterType.Sushi as const, + status: RouteStatus.Success, + price: 3000n * ONE18, // found for a bigger size + route: { + route: { status: "Success", legs: [{ uniqueId: "pool1" }] }, + pcMap: new Map([["pool1", { pool: { address: "0xpool1" } }]]), + } as any, + amountOut: 6000000000n, + }; + const requoted = { ...lockedQuote, price: 3100n * ONE18, amountOut: 3100000000n }; + const tryQuoteSpy = vi.spyOn(router, "tryQuote"); + const requoteSpy = vi.spyOn(router, "requoteRoute").mockReturnValue(requoted); + const visSpy = vi.spyOn(SushiRouter, "visualizeRoute").mockReturnValue(["route"]); + const routeProcessor4ParamsSpy = vi + .spyOn(Router, "routeProcessor4Params") + .mockReturnValue({ routeCode: "0xrouteCode" as `0x${string}` } as any); + + const result = await router.getTradeParams({ + ...mockGetTradeParamsArgs, + sushiQuote: lockedQuote, + lockRoute: true, + }); + + assert(result.isOk()); + expect(tryQuoteSpy).not.toHaveBeenCalled(); + expect(requoteSpy).toHaveBeenCalledWith( + lockedQuote, + mockGetTradeParamsArgs.maximumInput, + mockTokenIn, + mockTokenOut, + mockGetTradeParamsArgs.state.gasPrice, + mockGetTradeParamsArgs.state.appOptions.route, + ); + // the requoted price and amount out are the ones used + expect(result.value.quote).toEqual(requoted); + + visSpy.mockRestore(); + tryQuoteSpy.mockRestore(); + requoteSpy.mockRestore(); + routeProcessor4ParamsSpy.mockRestore(); + }); + + it("should not requote a given quote when the route is not locked", async () => { + const givenQuote = { + type: RouterType.Sushi as const, + status: RouteStatus.Success, + price: 3000n * ONE18, + route: { route: { status: "Success", legs: [] }, pcMap: new Map() } as any, + amountOut: 3000000000n, + }; + const requoteSpy = vi.spyOn(router, "requoteRoute"); + const visSpy = vi.spyOn(SushiRouter, "visualizeRoute").mockReturnValue(["route"]); + const routeProcessor4ParamsSpy = vi + .spyOn(Router, "routeProcessor4Params") + .mockReturnValue({ routeCode: "0xrouteCode" as `0x${string}` } as any); + + const result = await router.getTradeParams({ + ...mockGetTradeParamsArgs, + sushiQuote: givenQuote, + }); + + assert(result.isOk()); + expect(requoteSpy).not.toHaveBeenCalled(); + expect(result.value.quote).toEqual(givenQuote); + + visSpy.mockRestore(); + requoteSpy.mockRestore(); + routeProcessor4ParamsSpy.mockRestore(); + }); + it("should successfully return trade params for partial trade size and maxRatio false", async () => { const mockQuote = { type: RouterType.Sushi as const, diff --git a/src/router/sushi/index.ts b/src/router/sushi/index.ts index 7660e678..b3b83c87 100644 --- a/src/router/sushi/index.ts +++ b/src/router/sushi/index.ts @@ -215,7 +215,6 @@ export class SushiRouter extends RainSolverRouterBase { fromToken, toToken, amountIn, - gasPrice, blockNumber = undefined, ignoreCache = undefined, skipFetch = false, @@ -236,7 +235,7 @@ export class SushiRouter extends RainSolverRouterBase { fromToken, amountIn, toToken, - Number(gasPrice), + 0, this.getFilteredLiquidityProviders(excludeDexes), poolFilter, undefined, @@ -292,6 +291,66 @@ export class SushiRouter extends RainSolverRouterBase { return await this.dataFetcher.updatePools(blockNumber); } + /** + * Requotes the route of the given quote for another amount in, the route is + * kept as is (same legs and pools) and only its amounts get simulated again for + * the new amount, so a route found for one trade size can be reused for another + * size with a price and amount out that belong to that size, falls back to the + * given quote as is when the route cannot be simulated for the new amount + * @param quote - The quote whose route is reused + * @param amountIn - The new amount in to quote the route for + * @param fromToken - The token to sell + * @param toToken - The token to buy + * @param gasPrice - The current gas price + * @param sushiRouteType - The route type, single or multi + */ + requoteRoute( + quote: SushiRouterQuote, + amountIn: bigint, + fromToken: Token, + toToken: Token, + _gasPrice: bigint, + sushiRouteType?: "single" | "multi", + ): SushiRouterQuote { + // narrow the pool map down to the pools of the route legs, so the route + // finder has no other pools to route through than the locked route ones + const routePcMap = new Map(); + for (const leg of quote.route.route.legs ?? []) { + const poolCode = quote.route.pcMap.get(leg.uniqueId); + if (poolCode) routePcMap.set(leg.uniqueId, poolCode); + } + if (!routePcMap.size) { + return quote; + } + const route = Router.findBestRoute( + routePcMap, + this.chainId as ChainId, + fromToken, + amountIn, + toToken, + 0, + undefined, + poolFilter, + undefined, + sushiRouteType, + ); + if (route.status == "NoWay" || route.amountOutBI < 0n) { + return quote; + } + return { + type: RouterType.Sushi, + status: RouteStatus.Success, + price: calculatePrice18( + amountIn, + route.amountOutBI, + fromToken.decimals, + toToken.decimals, + ), + route: { route, pcMap: quote.route.pcMap }, + amountOut: route.amountOutBI, + }; + } + /** * Resets the data fetcher to a fresh instance and returns true if resets successfully or false otherwise */ @@ -435,11 +494,25 @@ export class SushiRouter extends RainSolverRouterBase { ); } - // use the precomputed quote when given, since recomputing the route - // for the same amount is deterministic and yields the same result, - // otherwise get route details from sushi dataFetcher + // use the precomputed quote when given, since recomputing the route for + // the same amount is deterministic and yields the same result, a locked + // route quote may belong to another amount though, so its route gets + // requoted for the given maximum input, keeping the route and getting + // the price and amount out of this size, otherwise get route details + // from sushi dataFetcher const quoteResult = args.sushiQuote - ? Result.ok(args.sushiQuote) + ? Result.ok( + args.lockRoute + ? this.requoteRoute( + args.sushiQuote, + maximumInput, + fromToken, + toToken, + gasPrice, + state.appOptions.route, + ) + : args.sushiQuote, + ) : await this.tryQuote({ fromToken, toToken, diff --git a/src/router/types.ts b/src/router/types.ts index c4ae6daa..5006586c 100644 --- a/src/router/types.ts +++ b/src/router/types.ts @@ -107,9 +107,9 @@ export type GetTradeParamsArgs = { /** * Locks the trade to the route of the given `sushiQuote`, when set only the * sushi router builds the trade params from it and no other router gets quoted, - * the quote may have been found for a different amount, so its price and amount - * out do not correspond to the given maximum input and only the route itself - * is meant to be used, has no effect without `sushiQuote` + * the quote may have been found for a different amount, so only its route is + * reused and the sushi router requotes that route for the given maximum input + * to get the price and amount out of this size, has no effect without `sushiQuote` */ lockRoute?: boolean; }; From 2eaafd443b56965ba2125371a97537446d4f4b9f Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Mon, 14 Sep 2026 19:39:20 +0000 Subject: [PATCH 2/2] update --- src/state/chain.test.ts | 92 +++++++++++++++++++++++++++++++---------- src/state/chain.ts | 30 ++++++++------ 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/src/state/chain.test.ts b/src/state/chain.test.ts index eaf7fcdc..22bced7d 100644 --- a/src/state/chain.test.ts +++ b/src/state/chain.test.ts @@ -11,28 +11,52 @@ import { ROUTE_PROCESSOR_3_2_ADDRESS, } from "sushi/config"; -vi.mock("sushi/config", async (importOriginal) => ({ - ...(await importOriginal()), - ROUTE_PROCESSOR_3_ADDRESS: { - [ChainId.ETHEREUM]: `0xrp3`, - [ChainId.FLARE]: `0xrp3`, - [ChainId.POLYGON]: `0xrp3`, - }, - ROUTE_PROCESSOR_4_ADDRESS: { - [ChainId.ETHEREUM]: `0xrp4`, - [ChainId.FLARE]: `0xrp4`, - }, - ROUTE_PROCESSOR_3_1_ADDRESS: { - [ChainId.ETHEREUM]: `0xrp3.1`, - [ChainId.POLYGON]: `0xrp3.1`, - }, - ROUTE_PROCESSOR_3_2_ADDRESS: { - [ChainId.ETHEREUM]: `0xrp3.2`, - [ChainId.FLARE]: `0xrp3.2`, - [ChainId.POLYGON]: `0xrp3.2`, - }, +// a usd base token for a chain with no stables entry, stands in for USDG on +// Robinhood, hoisted as the mock factory below runs before this file's top level +const usdgBaseToken = vi.hoisted(() => ({ + chainId: 14, // flare + address: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", + decimals: 6, + symbol: "USDG", + name: "Global Dollar", })); +vi.mock("sushi/config", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + // flare stands in for a chain with no stables entry whose + // dollar token is a base token, like USDG on robinhood + STABLES: { ...original.STABLES, [ChainId.FLARE]: undefined }, + BASES_TO_CHECK_TRADES_AGAINST: { + ...original.BASES_TO_CHECK_TRADES_AGAINST, + [ChainId.FLARE]: [ + { symbol: "WFLR" }, + { symbol: "cUSDX" }, // not a usd token, no USDC or USDT in its symbol + usdgBaseToken, + ], + }, + ROUTE_PROCESSOR_3_ADDRESS: { + [ChainId.ETHEREUM]: `0xrp3`, + [ChainId.FLARE]: `0xrp3`, + [ChainId.POLYGON]: `0xrp3`, + }, + ROUTE_PROCESSOR_4_ADDRESS: { + [ChainId.ETHEREUM]: `0xrp4`, + [ChainId.FLARE]: `0xrp4`, + }, + ROUTE_PROCESSOR_3_1_ADDRESS: { + [ChainId.ETHEREUM]: `0xrp3.1`, + [ChainId.POLYGON]: `0xrp3.1`, + }, + ROUTE_PROCESSOR_3_2_ADDRESS: { + [ChainId.ETHEREUM]: `0xrp3.2`, + [ChainId.FLARE]: `0xrp3.2`, + [ChainId.POLYGON]: `0xrp3.2`, + }, + }; +}); + describe("Test getChainConfig", () => { it("should return correct config for a supported chain", () => { const chainId = ChainId.ETHEREUM; @@ -57,6 +81,25 @@ describe("Test getChainConfig", () => { } }); + it("should fall back to a usd base token for a chain with no stables entry", () => { + const chainId = ChainId.FLARE; + const configResult = getChainConfig(chainId); + assert(configResult.isOk()); + const config = configResult.value; + + expect(config.stableTokens).toBeUndefined(); + expect(config.usdToken).toBe(usdgBaseToken); + }); + + it("should keep the stables usd token when both a stable and a base usd token exist", () => { + // ethereum has USDC in its stables, any base usd token must not override it + const configResult = getChainConfig(ChainId.ETHEREUM); + assert(configResult.isOk()); + expect(configResult.value.usdToken).toBe( + STABLES[ChainId.ETHEREUM].find((t) => t.symbol === "USDC"), + ); + }); + it("should throw if chain is not supported", () => { const invalidChainId = 999999 as ChainId; const configResult = getChainConfig(invalidChainId); @@ -106,13 +149,18 @@ describe("Test findUsdToken", () => { const usdcVariant = { symbol: "USDC.e" } as any; const usdtVariant = { symbol: "USDT0" } as any; const dai = { symbol: "DAI" } as any; + const usdg = { symbol: "USDG" } as any; it("should prefer exact USDC over all others", () => { - expect(findUsdToken([dai, usdtVariant, usdt, usdcVariant, usdc])).toBe(usdc); + expect(findUsdToken([dai, usdtVariant, usdg, usdt, usdcVariant, usdc])).toBe(usdc); }); it("should pick exact USDT when there is no exact USDC", () => { - expect(findUsdToken([dai, usdcVariant, usdt])).toBe(usdt); + expect(findUsdToken([dai, usdcVariant, usdg, usdt])).toBe(usdt); + }); + + it("should pick exact USDG when there is no exact USDC or USDT", () => { + expect(findUsdToken([dai, usdcVariant, usdtVariant, usdg])).toBe(usdg); }); it("should fall back to a USDC variant when there is no exact match", () => { diff --git a/src/state/chain.ts b/src/state/chain.ts index c9ec808c..0d6ad9d9 100644 --- a/src/state/chain.ts +++ b/src/state/chain.ts @@ -6,6 +6,7 @@ import { RainSolverBaseError } from "../error/types"; import { STABLES, publicClientConfig, + BASES_TO_CHECK_TRADES_AGAINST, ROUTE_PROCESSOR_3_ADDRESS, ROUTE_PROCESSOR_4_ADDRESS, ROUTE_PROCESSOR_3_1_ADDRESS, @@ -44,21 +45,23 @@ export type ChainConfig = Chain & { routeProcessors: { [key: string]: `0x${string}` }; stableTokens?: Token[]; isSpecialL2: boolean; - /** The dollar denominated token (USDC or USDT) of the chain, used for reporting dollar values */ + /** The dollar denominated token (USDC, USDT or USDG) of the chain, used for reporting dollar values */ usdToken?: Token; }; /** - * Finds the USD denominated token (USDC or USDT or their variants) from the - * given list of stable tokens, prioritizing exact symbol matches over variants - * @param stableTokens - The list of stable tokens to search in + * Finds the USD denominated token (USDC, USDT, USDG or the USDC/USDT variants) + * from the given list of candidate tokens, prioritizing exact symbol matches in + * that order over variants + * @param candidates - The list of tokens to search in */ -export function findUsdToken(stableTokens?: Token[]): Token | undefined { +export function findUsdToken(candidates?: Token[]): Token | undefined { return ( - stableTokens?.find((token: Token) => token.symbol === "USDC") ?? - stableTokens?.find((token: Token) => token.symbol === "USDT") ?? - stableTokens?.find((token: Token) => token.symbol?.includes("USDC")) ?? - stableTokens?.find((token: Token) => token.symbol?.includes("USDT")) + candidates?.find((token: Token) => token.symbol === "USDC") ?? + candidates?.find((token: Token) => token.symbol === "USDT") ?? + candidates?.find((token: Token) => token.symbol === "USDG") ?? + candidates?.find((token: Token) => token.symbol?.includes("USDC")) ?? + candidates?.find((token: Token) => token.symbol?.includes("USDT")) ); } @@ -111,9 +114,12 @@ export function getChainConfig(chainId: ChainId): Result