diff --git a/lib/sushiswap b/lib/sushiswap index fe4be0e3..fbbc4eed 160000 --- a/lib/sushiswap +++ b/lib/sushiswap @@ -1 +1 @@ -Subproject commit fe4be0e3d02e297e55671dbe545c464a5017a0ba +Subproject commit fbbc4eed607238378b94c0d14221ff7db2c2b2df diff --git a/src/router/router.ts b/src/router/router.ts index 0a0a6095..5374938f 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -2,9 +2,12 @@ import { Pair } from "../order"; import { SushiRouter } from "./sushi"; import { Token } from "sushi/currency"; import { Err, Result } from "../common"; +import { MultiRoute } from "sushi/tines"; +import { StabullRouter } from "./stabull"; import { LiquidityProviders } from "sushi"; import { BalancerRouter } from "./balancer"; import { Account, Chain, PublicClient, Transport, parseUnits } from "viem"; +import { StabullRouterError, StabullRouterErrorType } from "./stabull/error"; import { TradeParamsType, GetTradeParamsArgs, @@ -20,8 +23,6 @@ import { BalancerRouterErrorType, RainSolverRouterErrorType, } from "./error"; -import { StabullRouter } from "./stabull"; -import { StabullRouterError, StabullRouterErrorType } from "./stabull/error"; export type RainSolverRouterConfig = { /** The chain id of the operating chain */ @@ -139,7 +140,7 @@ export class RainSolverRouter extends RainSolverRouterBase { */ async getMarketPrice( params: RainSolverRouterQuoteParams, - ): Promise> { + ): Promise> { const key = `${params.fromToken.address.toLowerCase()}-${params.toToken.address.toLowerCase}`; let value = this.cache.get(key); if (typeof value === "number") { @@ -170,7 +171,7 @@ export class RainSolverRouter extends RainSolverRouterBase { if (results.every((res) => !res?.isOk())) { return Result.err(getError("Failed to get market price", results)); } - return results[0] as Result<{ price: string }, RainSolverRouterError>; + return results[0] as Result<{ price: string; route?: MultiRoute }, RainSolverRouterError>; } /** diff --git a/src/router/sushi/index.test.ts b/src/router/sushi/index.test.ts index 22cc8c42..76b6ce62 100644 --- a/src/router/sushi/index.test.ts +++ b/src/router/sushi/index.test.ts @@ -1,14 +1,15 @@ import { ONE18 } from "../../math"; -import { Order, OrderbookVersions, TakeOrdersConfigType } from "../../order"; -import { RouteLeg } from "sushi/tines"; import { Token } from "sushi/currency"; import { SharedState } from "../../state"; import { Dispair, Result } from "../../common"; -import { maxUint256, PublicClient } from "viem"; +import { calculateEffectivePrice } from "./index"; import { RouterType, RouteStatus } from "../types"; +import { RouteLeg, MultiRoute } from "sushi/tines"; +import { maxUint256, PublicClient, parseUnits } from "viem"; import { SushiRouterError, SushiRouterErrorType } from "./error"; import { LiquidityProviders, RainDataFetcher, Router } from "sushi"; import { describe, it, expect, vi, beforeEach, Mock, assert } from "vitest"; +import { Order, OrderbookVersions, TakeOrdersConfigType } from "../../order"; import { SushiRouter, SushiQuoteParams, ExcludedLiquidityProviders } from "."; // mock the sushi dependencies @@ -1021,3 +1022,146 @@ describe("test SushiRouter methods", () => { }); }); }); + +describe("calculateEffectivePrice", () => { + const mockFromToken = new Token({ + address: "0x1111111111111111111111111111111111111111", + decimals: 18, + symbol: "TOKEN1", + chainId: 1, + name: "Token 1", + }); + + const mockToToken = new Token({ + address: "0x2222222222222222222222222222222222222222", + decimals: 6, + symbol: "TOKEN2", + chainId: 1, + name: "Token 2", + }); + + it("should return the base price when priceImpact is undefined", () => { + const maximumInput = parseUnits("100", 18); + const route: MultiRoute = { + status: RouteStatus.Success, + amountOutBI: parseUnits("200", 6), + priceImpact: undefined, + } as any as MultiRoute; + + const result = calculateEffectivePrice(maximumInput, route, mockFromToken, mockToToken); + + // Expected price: (200 * 10^6 * 10^18) / (100 * 10^18) = 2 * 10^6 = 2000000 + // Scaled to 18 decimals: 2 * 10^18 + expect(result).toBe(parseUnits("2", 18)); + }); + + it("should apply price impact when priceImpact is defined", () => { + const maximumInput = parseUnits("100", 18); + const route: MultiRoute = { + status: RouteStatus.Success, + amountOutBI: parseUnits("200", 6), + priceImpact: 0.05, // 5% price impact + } as any as MultiRoute; + + const result = calculateEffectivePrice(maximumInput, route, mockFromToken, mockToToken); + + // Base price: 2 * 10^18 + // With 5% impact: 2 * 0.95 = 1.9 * 10^18 + expect(result).toBe(parseUnits("1.9", 18)); + }); + + it("should handle zero price impact", () => { + const maximumInput = parseUnits("100", 18); + const route: MultiRoute = { + status: RouteStatus.Success, + amountOutBI: parseUnits("150", 6), + priceImpact: 0, + } as any as MultiRoute; + + const result = calculateEffectivePrice(maximumInput, route, mockFromToken, mockToToken); + + // Price: 1.5 * 10^18, no impact + expect(result).toBe(parseUnits("1.5", 18)); + }); + + it("should handle small price impact correctly", () => { + const maximumInput = parseUnits("1000", 18); + const route: MultiRoute = { + status: RouteStatus.Success, + amountOutBI: parseUnits("1000", 6), + priceImpact: 0.001, // 0.1% price impact + } as any as MultiRoute; + + const result = calculateEffectivePrice(maximumInput, route, mockFromToken, mockToToken); + + // Base price: 1 * 10^18 + // With 0.1% impact: 1 * 0.999 = 0.999 * 10^18 + expect(result).toBe(parseUnits("0.999", 18)); + }); + + it("should handle very small amounts", () => { + const maximumInput = parseUnits("0.001", 18); // 1e-3 + const route: MultiRoute = { + status: RouteStatus.Success, + amountOutBI: parseUnits("0.002", 6), // 2e-3 + priceImpact: 0.1, // 10% price impact + } as any as MultiRoute; + + const result = calculateEffectivePrice(maximumInput, route, mockFromToken, mockToToken); + + // Base price: 2 * 10^18 + // With 10% impact: 2 * 0.9 = 1.8 * 10^18 + expect(result).toBe(parseUnits("1.8", 18)); + }); + + it("should handle different token decimals", () => { + const token8Decimals: Token = { + ...mockFromToken, + decimals: 8, + } as Token; + + const maximumInput = parseUnits("50", 8); + const route: MultiRoute = { + status: RouteStatus.Success, + amountOutBI: parseUnits("100", 6), + priceImpact: 0.02, // 2% price impact + } as any as MultiRoute; + + const result = calculateEffectivePrice(maximumInput, route, token8Decimals, mockToToken); + + // Base price: 2 * 10^18 + // With 2% impact: 2 * 0.98 = 1.96 * 10^18 + expect(result).toBe(parseUnits("1.96", 18)); + }); + + it("should handle high price impact", () => { + const maximumInput = parseUnits("1000", 18); + const route: MultiRoute = { + status: RouteStatus.Success, + amountOutBI: parseUnits("500", 6), + priceImpact: 0.5, // 50% price impact + } as any as MultiRoute; + + const result = calculateEffectivePrice(maximumInput, route, mockFromToken, mockToToken); + + // Base price: 0.5 * 10^18 + // With 50% impact: 0.5 * 0.5 = 0.25 * 10^18 + expect(result).toBe(parseUnits("0.25", 18)); + }); + + it("should handle very small price impact (scientific notation)", () => { + const maximumInput = parseUnits("10000", 18); + const route: MultiRoute = { + status: RouteStatus.Success, + amountOutBI: parseUnits("10000", 6), + priceImpact: 1e-20, // extremely small impact + } as any as MultiRoute; + + const result = calculateEffectivePrice(maximumInput, route, mockFromToken, mockToToken); + + // Base price: 1 * 10^18 + // With negligible impact, should be very close to base price + expect(result).toBeGreaterThan(parseUnits("0.999999999999999999", 18)); + expect(result).toBeLessThanOrEqual(parseUnits("1", 18)); + }); +}); diff --git a/src/router/sushi/index.ts b/src/router/sushi/index.ts index 52651e08..ec57611a 100644 --- a/src/router/sushi/index.ts +++ b/src/router/sushi/index.ts @@ -6,15 +6,24 @@ import { MultiRoute, RouteLeg } from "sushi/tines"; import { BlackListSet, poolFilter } from "./blacklist"; import { TakeOrdersConfigType } from "../../order/types"; import { SushiRouterError, SushiRouterErrorType } from "./error"; -import { calculatePrice18, scaleFrom18, scaleTo18 } from "../../math"; +import { calculatePrice18, ONE18, scaleFrom18, scaleTo18 } from "../../math"; import { ChainId, LiquidityProviders, PoolCode, RainDataFetcher, Router } from "sushi"; -import { Chain, Account, Transport, formatUnits, PublicClient, encodeAbiParameters } from "viem"; +import { + Chain, + Account, + Transport, + parseUnits, + formatUnits, + PublicClient, + encodeAbiParameters, +} from "viem"; import { RouterType, RouteStatus, GetTradeParamsArgs, RainSolverRouterBase, RainSolverRouterQuoteParams, + DEFAULT_PRICE_IMPACT_TOLERANCE, } from "../types"; import { ROUTE_PROCESSOR_3_ADDRESS, @@ -153,7 +162,7 @@ export class SushiRouter extends RainSolverRouterBase { */ async getMarketPrice( params: SushiQuoteParams, - ): Promise> { + ): Promise> { // return early if from and to tokens are the same if (params.fromToken.address.toLowerCase() === params.toToken.address.toLowerCase()) { return Result.ok({ price: "1" }); @@ -163,7 +172,10 @@ export class SushiRouter extends RainSolverRouterBase { if (quoteResult.isErr()) { return Result.err(quoteResult.error); } - return Result.ok({ price: formatUnits(quoteResult.value.price, 18) }); + return Result.ok({ + price: formatUnits(quoteResult.value.price, 18), + route: quoteResult.value.route.route, + }); } /** @@ -508,17 +520,23 @@ export class SushiRouter extends RainSolverRouterBase { if (route.status == "NoWay") { maximumInput = maximumInput - initAmount / 2n ** i; } else if (absolute) { - result.unshift(maxInput18); - maximumInput = maximumInput + initAmount / 2n ** i; + if ( + typeof route.priceImpact === "undefined" || + route.priceImpact < DEFAULT_PRICE_IMPACT_TOLERANCE + ) { + result.unshift(maxInput18); + maximumInput = maximumInput + initAmount / 2n ** i; + } else { + maximumInput = maximumInput - initAmount / 2n ** i; + } } else { - const price = calculatePrice18( + const effectivePrice = calculateEffectivePrice( maximumInput, - route.amountOutBI, - fromToken.decimals, - toToken.decimals, + route, + fromToken, + toToken, ); - - if (price < ratio) { + if (effectivePrice < ratio) { maximumInput = maximumInput - initAmount / 2n ** i; } else { result.unshift(maxInput18); @@ -534,3 +552,21 @@ export class SushiRouter extends RainSolverRouterBase { } } } + +export function calculateEffectivePrice( + maximumInput: bigint, + route: MultiRoute, + fromToken: Token, + toToken: Token, +): bigint { + const price = calculatePrice18( + maximumInput, + route.amountOutBI, + fromToken.decimals, + toToken.decimals, + ); + if (typeof route.priceImpact === "undefined") { + return price; + } + return (price * parseUnits((1 - route.priceImpact).toFixed(12), 18)) / ONE18; +} diff --git a/src/router/types.ts b/src/router/types.ts index ea39a1f2..1100dad0 100644 --- a/src/router/types.ts +++ b/src/router/types.ts @@ -18,6 +18,13 @@ import { TakeOrdersConfigTypeV5, } from "../order"; +/* + * Default price imapct tolerance used for getting unit market price. + * this is not used for actual trade size finding in simulations but + * only for getting market price for unit token. + */ +export const DEFAULT_PRICE_IMPACT_TOLERANCE = 2.5 as const; + /** Represents the different router types */ export enum RouterType { /** The Sushi router (RainDataFetcher) */ diff --git a/src/state/index.ts b/src/state/index.ts index 3b792553..683fb0f6 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -2,7 +2,7 @@ import { GasManager } from "../gas"; import { ChainId } from "sushi/chain"; import { AppOptions } from "../config"; import { Token } from "sushi/currency"; -import { BalancerRouter } from "../router"; +import { BalancerRouter, DEFAULT_PRICE_IMPACT_TOLERANCE } from "../router"; import { LiquidityProviders } from "sushi"; import { SolverContracts } from "./contracts"; import { SushiRouter } from "../router/sushi"; @@ -326,7 +326,12 @@ export class SharedState { sushiRouteType: this.appOptions.route, skipFetch: !!skipFetch, }); - if (result.isOk()) { + if ( + result.isOk() && + (!result.value.route || + typeof result.value.route.priceImpact === "undefined" || + result.value.route.priceImpact <= DEFAULT_PRICE_IMPACT_TOLERANCE) + ) { return result; } const partialAmountIn = this.router.findLargestTradeSize(