Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/core/modes/router/simulate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rouzwelt marked this conversation as resolved.
this.spanAttributes["routeReused"] = true;
}

Expand Down
158 changes: 158 additions & 0 deletions src/router/sushi/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
85 changes: 79 additions & 6 deletions src/router/sushi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,6 @@ export class SushiRouter extends RainSolverRouterBase {
fromToken,
toToken,
amountIn,
gasPrice,
blockNumber = undefined,
ignoreCache = undefined,
skipFetch = false,
Expand All @@ -236,7 +235,7 @@ export class SushiRouter extends RainSolverRouterBase {
fromToken,
amountIn,
toToken,
Number(gasPrice),
0,
this.getFilteredLiquidityProviders(excludeDexes),
poolFilter,
undefined,
Expand Down Expand Up @@ -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<string, PoolCode>();
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,
};
}
Comment thread
rouzwelt marked this conversation as resolved.

/**
* Resets the data fetcher to a fresh instance and returns true if resets successfully or false otherwise
*/
Expand Down Expand Up @@ -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<SushiRouterQuote, SushiRouterError>(args.sushiQuote)
? Result.ok<SushiRouterQuote, SushiRouterError>(
args.lockRoute
? this.requoteRoute(
args.sushiQuote,
maximumInput,
fromToken,
toToken,
gasPrice,
state.appOptions.route,
)
: args.sushiQuote,
)
: await this.tryQuote({
fromToken,
toToken,
Expand Down
6 changes: 3 additions & 3 deletions src/router/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Loading
Loading