From 20de07f8476f08ad3a285ecb3d7bb8102b1c146a Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Wed, 9 Sep 2026 01:44:04 +0000 Subject: [PATCH] init --- config.env.yaml | 3 + config.example.yaml | 21 +- src/cli/commands/sweep.ts | 3 + src/config/yaml.test.ts | 9 + src/config/yaml.ts | 41 +- src/core/modes/index.test.ts | 4 + src/core/modes/index.ts | 1 + src/core/modes/router/index.test.ts | 1817 ++++++++------------------- src/core/modes/router/index.ts | 341 +++-- src/core/modes/router/simulate.ts | 4 +- src/core/modes/simulator.test.ts | 16 + src/core/modes/simulator.ts | 4 + src/core/process/order.test.ts | 149 ++- src/core/process/order.ts | 54 +- src/core/process/round.ts | 4 + src/core/types.ts | 4 + src/math/index.test.ts | 12 + src/math/index.ts | 10 + src/router/sushi/index.test.ts | 35 +- src/router/sushi/index.ts | 121 +- src/state/index.test.ts | 166 +++ src/state/index.ts | 85 ++ 22 files changed, 1323 insertions(+), 1581 deletions(-) diff --git a/config.env.yaml b/config.env.yaml index 2c505a9b..fa4241bf 100644 --- a/config.env.yaml +++ b/config.env.yaml @@ -45,6 +45,9 @@ routerPartialFallbackSteps: $ROUTER_PARTIAL_FALLBACK_STEPS routerSecondaryRouteTry: $ROUTER_SECONDARY_ROUTE_TRY dryrunGasCache: $DRYRUN_GAS_CACHE dryrunGasCacheResetTime: $DRYRUN_GAS_CACHE_RESET_TIME +dustGasCostMultiplier: $DUST_GAS_COST_MULTIPLIER +dustUsdThreshold: $DUST_USD_THRESHOLD +dustOrderCheck: $DUST_ORDER_CHECK strictMaxOwnerProfileCheck: $STRICT_MAX_OWNER_PROFILE_CHECK strictMaxOwnerProfilePartialTradeSizeCheck: $STRICT_MAX_OWNER_PROFILE_PARTIAL_TRADE_SIZE_CHECK gasBoostMultiplier: $GAS_BOOST_MULTIPLIER diff --git a/config.example.yaml b/config.example.yaml index cf27f086..4a48e13b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -134,10 +134,11 @@ flashblocks: false # at the same time, taking the first accepted one, default is false multiBroadcast: false -# Enables the halving backoff retries for router mode partial trades that get rejected onchain, default is true +# Adds the backoff sizes of the found trade size, three quarters of it and its halved sizes, to the router mode +# trade size batch that gets validated against the onchain dryrun concurrently, for every order, default is true routerPartialFallback: true -# The number of halving backoff steps to run concurrently for router mode partial trades that get rejected onchain, default is 4 +# The number of halved sizes in a router mode trade size batch, the three quarters size comes on top, default is 4 routerPartialFallbackSteps: 4 # Sets which orders get a secondary router mode try with the failing route dexes excluded after an onchain rejection, @@ -150,10 +151,24 @@ dryrunGasCache: false # Time (in minutes) between dryrun gas cache resets, default is 60 dryrunGasCacheResetTime: 60 +# Multiplier of the trade tx gas cost below which a trade size counts as dust and gets skipped, since a trade +# worth less than its gas can never be profitable, 0 disables dust detection, default is 1 +dustGasCostMultiplier: 1 + +# USD value below which a trade size counts as dust and gets skipped, when both dust checks are set a trade must fail +# both to count as dust, 0 disables the usd dust check, default is 0 +dustUsdThreshold: 0 + +# Runs the dust check on each order's whole max output before any simulation and skips the dust ones for the round, +# the router mode partial trade size dust check is not affected, default is true +dustOrderCheck: true + # When true, zero output balance pairs of max profile owners go to round processing, when false, all zero output balance pairs are skipped, default is false strictMaxOwnerProfileCheck: false -# When true, the router mode fallback partial trade backoff runs on any partial sim failure for orders of max profile owners, default is false +# When true, orders of max profile owners get the backoff sizes in their router mode trade size batch even with +# routerPartialFallback disabled, and a dust found trade size backs off from the full size for them instead of +# bailing out, default is false strictMaxOwnerProfilePartialTradeSizeCheck: false # Time (in minutes) to to check the operating wallet balances, 0 means dont ever check wallet balance, default is 15 mins diff --git a/src/cli/commands/sweep.ts b/src/cli/commands/sweep.ts index c6c76b24..72d46b87 100644 --- a/src/cli/commands/sweep.ts +++ b/src/cli/commands/sweep.ts @@ -114,6 +114,9 @@ export async function sweepFunds(opts: SweepOptions) { routerSecondaryRouteTry: "max", dryrunGasCache: false, dryrunGasCacheResetTime: 60, + dustGasCostMultiplier: 1, + dustUsdThreshold: 0, + dustOrderCheck: true, strictMaxOwnerProfileCheck: false, strictMaxOwnerProfilePartialTradeSizeCheck: false, timeout: 15_000, diff --git a/src/config/yaml.test.ts b/src/config/yaml.test.ts index 9e081512..2579ecd7 100644 --- a/src/config/yaml.test.ts +++ b/src/config/yaml.test.ts @@ -46,6 +46,9 @@ routerPartialFallbackSteps: 6 routerSecondaryRouteTry: all dryrunGasCache: true dryrunGasCacheResetTime: 30 +dustGasCostMultiplier: 1.5 +dustUsdThreshold: 0.25 +dustOrderCheck: false strictMaxOwnerProfileCheck: true strictMaxOwnerProfilePartialTradeSizeCheck: true checkWalletBalanceTime: 30 @@ -183,6 +186,9 @@ orderbookTradeTypes: routerSecondaryRouteTry: "all", dryrunGasCache: true, dryrunGasCacheResetTime: 30, + dustGasCostMultiplier: 1.5, + dustUsdThreshold: 0.25, + dustOrderCheck: false, strictMaxOwnerProfileCheck: true, strictMaxOwnerProfilePartialTradeSizeCheck: true, checkWalletBalanceTime: 30, @@ -405,6 +411,9 @@ orderbookTradeTypes: assert.equal(result.routerSecondaryRouteTry, "max"); // should be default max assert.equal(result.dryrunGasCache, false); // should be default false assert.equal(result.dryrunGasCacheResetTime, 60); // should be default 60 + assert.equal(result.dustGasCostMultiplier, 1); // should be default 1 + assert.equal(result.dustUsdThreshold, 0); // should be default 0 + assert.equal(result.dustOrderCheck, true); // should be default true assert.equal(result.strictMaxOwnerProfileCheck, false); // should be default false assert.equal(result.strictMaxOwnerProfilePartialTradeSizeCheck, false); // should be default false assert.equal(result.checkWalletBalanceTime, 15); // should be default 15 diff --git a/src/config/yaml.ts b/src/config/yaml.ts index d135bc17..28ae2b88 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -129,9 +129,9 @@ export type AppOptions = { flashblocks: boolean; /** Broadcasts each signed transaction through all the configured write rpcs (or all rpcs when no write rpc is set) at the same time, taking the first accepted one, default is false */ multiBroadcast: boolean; - /** Enables the halving backoff retries for router mode partial trades that get rejected onchain, default is true */ + /** Adds the backoff sizes of the found trade size, three quarters of it and its halved sizes, to the router mode trade size batch that gets validated against the onchain dryrun concurrently, for every order, default is true */ routerPartialFallback: boolean; - /** The number of halving backoff steps to run concurrently for router mode partial trades that get rejected onchain, default is 4 */ + /** The number of halved sizes in a router mode trade size batch, the three quarters size comes on top, default is 4 */ routerPartialFallbackSteps: number; /** Sets which orders get a secondary router mode try with the failing route dexes excluded after an onchain rejection, "all" for every order, "max" for orders of max profile owners only, "off" for none, default is "max" */ routerSecondaryRouteTry: "all" | "max" | "off"; @@ -139,9 +139,15 @@ export type AppOptions = { dryrunGasCache: boolean; /** Time (in minutes) between dryrun gas cache resets, default is 60 */ dryrunGasCacheResetTime: number; + /** Multiplier of the trade tx gas cost below which a trade size counts as dust and gets skipped, 0 disables the gas cost dust check, default is 1 */ + dustGasCostMultiplier: number; + /** USD value below which a trade size counts as dust and gets skipped, when both dust checks are set a trade must fail both to count as dust, 0 disables the usd dust check, default is 0 */ + dustUsdThreshold: number; + /** Runs the dust check on each order's whole max output before any simulation and skips the dust ones for the round, the router mode partial trade size dust check is not affected, default is true */ + dustOrderCheck: boolean; /** When true, zero output balance pairs of max profile owners go to round processing, when false, all zero output balance pairs are skipped, default is false */ strictMaxOwnerProfileCheck: boolean; - /** When true, the router mode fallback partial trade backoff runs on any partial sim failure for orders of max profile owners, default is false */ + /** When true, orders of max profile owners get the backoff sizes in their router mode trade size batch even with routerPartialFallback disabled, and a dust found trade size backs off from the full size for them instead of bailing out, default is false */ strictMaxOwnerProfilePartialTradeSizeCheck: boolean; /** Time (in minutes) to to check the operating wallet balances, 0 means dont ever check wallet balance, default is 15 mins */ checkWalletBalanceTime: number; @@ -459,6 +465,35 @@ export namespace AppOptions { "invalid dryrunGasCacheResetTime value, must be an integer greater than 0", ), ), + dustGasCostMultiplier: Validator.resolveNumericValue( + input.dustGasCostMultiplier, + FLOAT_PATTERN, + "invalid dustGasCostMultiplier value, must be a number greater than or equal to 0", + "1", + undefined, + (dustGasCostMultiplier) => + assert( + dustGasCostMultiplier >= 0, + "invalid dustGasCostMultiplier value, must be a number greater than or equal to 0", + ), + ), + dustUsdThreshold: Validator.resolveNumericValue( + input.dustUsdThreshold, + FLOAT_PATTERN, + "invalid dustUsdThreshold value, must be a number greater than or equal to 0", + "0", + undefined, + (dustUsdThreshold) => + assert( + dustUsdThreshold >= 0, + "invalid dustUsdThreshold value, must be a number greater than or equal to 0", + ), + ), + dustOrderCheck: Validator.resolveBool( + input.dustOrderCheck, + "expected a boolean value for dustOrderCheck", + true, + ), strictMaxOwnerProfileCheck: Validator.resolveBool( input.strictMaxOwnerProfileCheck, "expected a boolean value for strictMaxOwnerProfileCheck", diff --git a/src/core/modes/index.test.ts b/src/core/modes/index.test.ts index eecd2e31..f4782e73 100644 --- a/src/core/modes/index.test.ts +++ b/src/core/modes/index.test.ts @@ -207,6 +207,7 @@ describe("Test findBestTrade", () => { args.toToken, args.fromToken, args.blockNumber, + args.outputToEthPrice, ); expect(findBestIntraOrderbookTrade).not.toHaveBeenCalled(); expect(findBestInterOrderbookTrade).not.toHaveBeenCalled(); @@ -275,6 +276,7 @@ describe("Test findBestTrade", () => { args.toToken, args.fromToken, args.blockNumber, + args.outputToEthPrice, ); expect(findBestIntraOrderbookTrade).toHaveBeenCalledWith( args.orderDetails, @@ -344,6 +346,7 @@ describe("Test findBestTrade", () => { args.toToken, args.fromToken, args.blockNumber, + args.outputToEthPrice, ); expect(findBestIntraOrderbookTrade).toHaveBeenCalledWith( args.orderDetails, @@ -526,6 +529,7 @@ describe("Test findBestTrade", () => { args.toToken, args.fromToken, args.blockNumber, + args.outputToEthPrice, ); expect(findBestIntraOrderbookTrade).toHaveBeenCalledWith( args.orderDetails, diff --git a/src/core/modes/index.ts b/src/core/modes/index.ts index e2b783bb..f78e387f 100644 --- a/src/core/modes/index.ts +++ b/src/core/modes/index.ts @@ -74,6 +74,7 @@ export async function findBestTrade( toToken, fromToken, blockNumber, + outputToEthPrice, ), findBestIntraOrderbookTradeFn?.call( this, diff --git a/src/core/modes/router/index.test.ts b/src/core/modes/router/index.test.ts index aaaa4e9a..86d82f55 100644 --- a/src/core/modes/router/index.test.ts +++ b/src/core/modes/router/index.test.ts @@ -1,5 +1,5 @@ import { Order } from "../../../order"; -import { findBestRouterTrade } from "./index"; +import { findBestRouterTrade, getHalvedTradeSizes } from "./index"; import { TradeSizeStatus } from "../../../router"; import { Dispair, Result } from "../../../common"; import { RouterTradeSimulator } from "./simulate"; @@ -43,6 +43,49 @@ describe("Test findBestRouterTrade", () => { let dispair: Dispair; let destination: `0x${string}`; + // the sushi quote the size finder settles on, with no route legs so + // the dex exclusion retry stays out unless a test asks for it + const sizerQuote = { + route: { route: { legs: [] }, pcMap: new Map() }, + tag: "sizer", + } as any; + const outputToEthPrice = "3"; + const mockViolationError = Result.err({ + type: TradeType.RouteProcessor, + reason: SimulationHaltReason.NoOpportunity, + spanAttributes: { + error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", + }, + noneNodeError: "violation", + }); + const mockSuccess = Result.ok({ + type: TradeType.RouteProcessor, + spanAttributes: { foundOpp: true }, + estimatedProfit: 25n, + oppBlockNumber: 123, + }); + const lockedArgs = { lockRoute: true, skipPriceMatchCheck: true, sushiQuote: sizerQuote }; + const simulatedSizes = (): bigint[] => + (simulatorWithArgsSpy as Mock).mock.calls.map((call: any[]) => call[0].maximumInputFixed); + const setFoundSize = (size: bigint, quote: any = sizerQuote) => { + (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ + status: TradeSizeStatus.Found, + size, + quote, + }); + }; + const run = (): Promise => + findBestRouterTrade.call( + mockRainSolver, + orderDetails, + signer, + ethPrice, + toToken, + fromToken, + blockNumber, + outputToEthPrice, + ); + beforeEach(() => { vi.clearAllMocks(); @@ -57,9 +100,14 @@ describe("Test findBestRouterTrade", () => { routerPartialFallback: true, routerPartialFallbackSteps: 4, routerSecondaryRouteTry: "all", + dustGasCostMultiplier: 1, + dustUsdThreshold: 0, }, state: { gasPrice: 100n, + gasTokenUsdPrice: "2000", + isDustCheckEnabled: true, + isDustTrade: vi.fn().mockReturnValue(undefined), client: { getBlockNumber: vi.fn().mockResolvedValue(123n), }, @@ -94,160 +142,17 @@ describe("Test findBestRouterTrade", () => { trySimulateTradeSpy = vi.spyOn(RouterTradeSimulator.prototype, "trySimulateTrade"); }); - it("should return success result if full trade size simulation succeeds", async () => { - const mockSuccessResult = Result.ok({ - type: "balancer", - spanAttributes: { foundOpp: true }, - estimatedProfit: 100n, - oppBlockNumber: 123, - }); - (trySimulateTradeSpy as Mock).mockResolvedValue(mockSuccessResult); - - 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(100n); - expect(result.value.oppBlockNumber).toBe(123); - expect(result.value.type).toBe("balancer"); - expect(simulatorWithArgsSpy).toHaveBeenCalledWith({ - type: TradeType.Router, - solver: mockRainSolver, - orderDetails, - fromToken, - toToken, - signer, - maximumInputFixed: 1000n, - ethPrice, - isPartial: false, - blockNumber: 123n, - }); - }); - - it("should return error if no route found", async () => { - const mockErrorResult = Result.err({ - type: TradeType.Router, - reason: SimulationHaltReason.NoRoute, - spanAttributes: { route: "no-way" }, - noneNodeError: "no route available", - }); - (trySimulateTradeSpy as Mock).mockResolvedValue(mockErrorResult); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + it("should bail out without any sim when no route is found at any size", async () => { + const result = await run(); assert(result.isErr()); - expect(result.error.noneNodeError).toBe("no route available"); - expect(result.error.type).toBe("router"); + expect(result.error.type).toBe(TradeType.Router); + expect(result.error.reason).toBe(SimulationHaltReason.NoRoute); expect(result.error.spanAttributes).toEqual({ - "full.route": "no-way", - "partial.error": "found no route for any trade size", - }); - expect(extendObjectWithHeader).toHaveBeenCalledWith( - expect.any(Object), - { route: "no-way" }, - "full", - ); - }); - - it("should try partial trade with the biggest routed size on PriceMismatch result", async () => { - const mockFullTradeError = Result.err({ - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockPartialTradeSuccess = Result.ok({ - type: "routeProcessor", - spanAttributes: { foundOpp: true }, - estimatedProfit: 25n, - oppBlockNumber: 123, - }); - - const mockSizeQuote = { route: { pcMap: new Map() }, price: 1n, amountOut: 1n }; - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) - .mockResolvedValueOnce(mockPartialTradeSuccess); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.PriceMismatch, - size: 400n, - quote: mockSizeQuote, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - // the price mismatch size is still simulated as partial trade size, - // with the size search winning probe quote plugged in, while the - // full size sim runs without a precomputed quote - assert(result.isOk()); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); - expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - maximumInputFixed: 400n, - isPartial: true, - sushiQuote: mockSizeQuote, - }), - ); - expect((simulatorWithArgsSpy as Mock).mock.calls[0][0].sushiQuote).toBeUndefined(); - }); - - it("should try partial trade if full trade fails with non-NoRoute reason", async () => { - const mockFullTradeError = Result.err({ - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockPartialTradeSuccess = Result.ok({ - type: "routeProcessor", - spanAttributes: { foundOpp: true }, - estimatedProfit: 50n, - oppBlockNumber: 123, - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) - .mockResolvedValueOnce(mockPartialTradeSuccess); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 500n, + error: "found no route for any trade size", }); - - 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(result.value.type).toBe("routeProcessor"); + expect(trySimulateTradeSpy).not.toHaveBeenCalled(); + expect(mockRainSolver.state.isDustTrade).not.toHaveBeenCalled(); expect(mockRainSolver.state.router.findLargestTradeSize).toHaveBeenCalledWith( orderDetails, toToken, @@ -258,1118 +163,391 @@ describe("Test findBestRouterTrade", () => { false, undefined, ); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); - expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith({ - type: TradeType.Router, - solver: mockRainSolver, - orderDetails, - fromToken, - toToken, - signer, - maximumInputFixed: 500n, - ethPrice, - isPartial: true, - blockNumber: 123n, - }); }); - it("should try partial trade if full trade fails with NoRoute reason", async () => { - const mockFullTradeError = Result.err({ - reason: SimulationHaltReason.NoRoute, - spanAttributes: { error: "no route" }, - noneNodeError: "no route for pair trade size", - }); - const mockPartialTradeSuccess = Result.ok({ - type: "routeProcessor", - spanAttributes: { foundOpp: true }, - estimatedProfit: 50n, - oppBlockNumber: 123, + it("should bail out without any sim when no size clears the order ratio", async () => { + (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ + status: TradeSizeStatus.PriceMismatch, + size: 400n, + quote: sizerQuote, }); + const result = await run(); - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) - .mockResolvedValueOnce(mockPartialTradeSuccess); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 500n, + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.OrderRatioGreaterThanMarketPrice); + expect(result.error.spanAttributes).toEqual({ + error: "found no trade size that clears the order ratio", }); + expect(trySimulateTradeSpy).not.toHaveBeenCalled(); + expect(mockRainSolver.state.isDustTrade).not.toHaveBeenCalled(); + }); - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + it("should run the full size and its halved sizes locked to the found route when the full size clears the ratio", async () => { + setFoundSize(1000n); + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockSuccess) // 1000n + .mockResolvedValue(mockViolationError); // halved sizes + const result = await run(); assert(result.isOk()); 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( - orderDetails, - toToken, - fromToken, - 1000n, - 100n, - undefined, - false, - undefined, - ); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); - expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith({ + expect(result.value.estimatedProfit).toBe(25n); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(6); + expect(simulatedSizes()).toEqual([1000n, 750n, 500n, 250n, 125n, 62n]); + // the full size is not a partial trade, every other size is + expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith(1, { type: TradeType.Router, solver: mockRainSolver, orderDetails, fromToken, toToken, signer, - maximumInputFixed: 500n, + maximumInputFixed: 1000n, ethPrice, - isPartial: true, + isPartial: false, blockNumber: 123n, + excludeDexes: undefined, + ...lockedArgs, }); + for (let i = 1; i < 6; i++) { + expect((simulatorWithArgsSpy as Mock).mock.calls[i][0]).toEqual( + expect.objectContaining({ isPartial: true, ...lockedArgs }), + ); + } }); - it("should return error if partial trade size cannot be found", async () => { - const mockFullTradeError = Result.err({ - type: TradeType.Router, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - - (trySimulateTradeSpy as Mock).mockResolvedValue(mockFullTradeError); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.NoWay, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + it("should run the found size and its halved sizes when the found size is below the full size", async () => { + setFoundSize(500n); + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockViolationError) // 500n + .mockResolvedValueOnce(mockSuccess) // 375n + .mockResolvedValue(mockViolationError); // rest + const result = await run(); - assert(result.isErr()); - expect(result.error.noneNodeError).toBe("order ratio issue"); - expect(result.error.type).toBe("router"); - expect(result.error.spanAttributes).toEqual({ - "full.error": "ratio too high", - "partial.error": "found no route for any trade size", - }); - expect(extendObjectWithHeader).toHaveBeenCalledWith( - expect.any(Object), - { error: "ratio too high" }, - "full", - ); + assert(result.isOk()); + expect(result.value.estimatedProfit).toBe(25n); + expect(simulatedSizes()).toEqual([500n, 375n, 250n, 125n, 62n, 31n]); + for (let i = 0; i < 6; i++) { + expect((simulatorWithArgsSpy as Mock).mock.calls[i][0]).toEqual( + expect.objectContaining({ isPartial: true, ...lockedArgs }), + ); + } }); - it("should return error if partial trade simulation also fails", async () => { - const mockFullTradeError = Result.err({ - type: TradeType.Balancer, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", + it("should pick the biggest passing size, not the first one that resolves", async () => { + const mockBigSuccess = Result.ok({ + type: TradeType.RouteProcessor, + spanAttributes: { size: "big" }, + estimatedProfit: 50n, + oppBlockNumber: 123, }); - const mockPartialTradeError = Result.err({ + const mockSmallSuccess = Result.ok({ type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { error: "no opportunity" }, - noneNodeError: "partial failed", + spanAttributes: { size: "small" }, + estimatedProfit: 10n, + oppBlockNumber: 123, }); - + setFoundSize(1000n); + // the 750n size passes but resolves after the 500n one (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) - .mockResolvedValueOnce(mockPartialTradeError); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 1500n, - }); + .mockResolvedValueOnce(mockViolationError) // 1000n + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve(mockBigSuccess), 20)), + ) // 750n + .mockResolvedValueOnce(mockSmallSuccess) // 500n + .mockResolvedValue(mockViolationError); // rest + const result = await run(); - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + assert(result.isOk()); + expect(result.value.spanAttributes).toEqual({ size: "big" }); + expect(result.value.estimatedProfit).toBe(50n); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(6); + }); + it("should run as many halved sizes as configured by routerPartialFallbackSteps", async () => { + mockRainSolver.appOptions.routerPartialFallbackSteps = 2; + setFoundSize(1000n); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); + const result = await run(); + + // the three quarters size comes on top of the two halved sizes 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" }, - "full", + expect(simulatedSizes()).toEqual([1000n, 750n, 500n, 250n]); + expect(result.error.spanAttributes["step1.error"]).toContain( + "MinimalOutputBalanceViolation", ); - expect(extendObjectWithHeader).toHaveBeenCalledWith( - expect.any(Object), - { error: "no opportunity" }, - "partial", + expect(result.error.spanAttributes["step4.error"]).toContain( + "MinimalOutputBalanceViolation", ); + expect(result.error.spanAttributes["step5.error"]).toBeUndefined(); }); - it("should return success result if partial trade simulation succeeds", async () => { - const mockFullTradeError = Result.err({ - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockPartialTradeSuccess = Result.ok({ - type: "routeProcessor", - spanAttributes: { foundOpp: true }, - estimatedProfit: 75n, - oppBlockNumber: 123, - }); + it("should drop halved sizes that reach zero", async () => { + orderDetails.takeOrder.quote.maxOutput = 16n; + setFoundSize(8n); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); + const result = await run(); - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) - .mockResolvedValueOnce(mockPartialTradeSuccess); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 500n, - }); + // the halving hits zero after 1n + assert(result.isErr()); + expect(simulatedSizes()).toEqual([8n, 6n, 4n, 2n, 1n]); + expect(result.error.spanAttributes["step5.error"]).toContain( + "MinimalOutputBalanceViolation", + ); + expect(result.error.spanAttributes["step6.error"]).toBeUndefined(); + }); - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, + it("should drop the halved sizes from the first dust one on", async () => { + (mockRainSolver.state.isDustTrade as Mock).mockImplementation( + (_pair: any, _price: any, _usd: any, size: bigint) => size < 300n, ); + setFoundSize(1000n); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); + const result = await run(); - assert(result.isOk()); - 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"); - expect(mockRainSolver.state.router.findLargestTradeSize).toHaveBeenCalledWith( - orderDetails, - toToken, - fromToken, - 1000n, - 100n, - undefined, - false, - undefined, - ); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); - expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith({ - type: TradeType.Router, - solver: mockRainSolver, - orderDetails, - fromToken, - toToken, - signer, - maximumInputFixed: 500n, - ethPrice, - isPartial: true, - blockNumber: 123n, - }); - expect(extendObjectWithHeader).toHaveBeenCalledWith( - expect.any(Object), - { error: "ratio too high" }, - "full", - ); - }); - - it("should backoff with halved trade sizes when partial trade fails with MinimalOutputBalanceViolation", async () => { - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - }); - const mockFallbackSuccess = Result.ok({ - type: TradeType.RouteProcessor, - spanAttributes: { foundOpp: true }, - estimatedProfit: 25n, - oppBlockNumber: 123, - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) // full size - .mockResolvedValueOnce(mockViolationError) // partial size 1000n - .mockResolvedValueOnce(mockViolationError) // partialFallback1 500n - .mockResolvedValueOnce(mockFallbackSuccess) // partialFallback2 250n - .mockResolvedValue(mockViolationError); // remaining fallbacks - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 1000n, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - // all 4 fallback sims launch concurrently and the successful one wins - assert(result.isOk()); - expect(result.value.spanAttributes).toEqual({ foundOpp: true }); - expect(result.value.estimatedProfit).toBe(25n); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(6); - // fallback sims lock the route to the already found quote and - // skip the offchain price match check to go straight to dryrun - const fallbackArgs = { isPartial: true, lockRoute: true, skipPriceMatchCheck: true }; - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ maximumInputFixed: 500n, ...fallbackArgs }), - ); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ maximumInputFixed: 250n, ...fallbackArgs }), - ); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 5, - expect.objectContaining({ maximumInputFixed: 125n, ...fallbackArgs }), - ); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 6, - expect.objectContaining({ maximumInputFixed: 62n, ...fallbackArgs }), - ); - // the full and partial sims never lock the route or skip the check - expect((simulatorWithArgsSpy as Mock).mock.calls[0][0].lockRoute).toBeUndefined(); - expect((simulatorWithArgsSpy as Mock).mock.calls[1][0].lockRoute).toBeUndefined(); - expect((simulatorWithArgsSpy as Mock).mock.calls[0][0].skipPriceMatchCheck).toBeUndefined(); - expect((simulatorWithArgsSpy as Mock).mock.calls[1][0].skipPriceMatchCheck).toBeUndefined(); - }); - - it("should run as many backoff steps as configured by routerPartialFallbackSteps", async () => { - mockRainSolver.appOptions.routerPartialFallbackSteps = 2; - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - }); - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - }); - (trySimulateTradeSpy as Mock).mockResolvedValueOnce(mockFullTradeError); - (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 1000n, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - // full, partial and only 2 fallback sims + // 250n is dust and so is everything below it assert(result.isErr()); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(4); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ maximumInputFixed: 500n, isPartial: true }), - ); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ maximumInputFixed: 250n, isPartial: true }), - ); - expect(result.error.spanAttributes["partialFallback1.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - expect(result.error.spanAttributes["partialFallback2.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - expect(result.error.spanAttributes["partialFallback3.error"]).toBeUndefined(); - }); - - it("should pick the biggest passing fallback size, not the first one that resolves", async () => { - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - }); - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - }); - const mockBigSuccess = Result.ok({ - type: TradeType.RouteProcessor, - spanAttributes: { size: "big" }, - estimatedProfit: 50n, - oppBlockNumber: 123, - }); - const mockSmallSuccess = Result.ok({ - type: TradeType.RouteProcessor, - spanAttributes: { size: "small" }, - estimatedProfit: 10n, - oppBlockNumber: 123, - }); - - // the 250n fallback passes but resolves after the 125n one - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) // full size - .mockResolvedValueOnce(mockViolationError) // partial size 1000n - .mockResolvedValueOnce(mockViolationError) // partialFallback1 500n - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve(mockBigSuccess), 20)), - ) // partialFallback2 250n - .mockResolvedValueOnce(mockSmallSuccess) // partialFallback3 125n - .mockResolvedValue(mockViolationError); // partialFallback4 62n - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 1000n, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - assert(result.isOk()); - expect(result.value.spanAttributes).toEqual({ size: "big" }); - expect(result.value.estimatedProfit).toBe(50n); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(6); + expect(simulatedSizes()).toEqual([1000n, 750n, 500n]); + expect(result.error.spanAttributes["dustTradeSize"]).toBeUndefined(); }); - it("should reuse the partial sim sushi route for fallback sims, falling back to the full sim route", async () => { - const fullQuote = { route: { pcMap: new Map() }, tag: "full" } as any; - const partialQuote = { route: { pcMap: new Map() }, tag: "partial" } as any; - const balancerQuote = { price: 1n, tag: "balancer" } as any; - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - }); - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - }); - const mockFallbackSuccess = Result.ok({ - type: TradeType.Balancer, - spanAttributes: { foundOpp: true }, - estimatedProfit: 25n, - oppBlockNumber: 123, - }); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 1000n, - }); - - // partial sim has its own quote, so fallback sims reuse it - (simulatorWithArgsSpy as Mock) - .mockReturnValueOnce({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFullTradeError), - }) - .mockReturnValueOnce({ - quote: partialQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockViolationError), - }) - .mockReturnValue({ - quote: partialQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFallbackSuccess), - }); - let result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - assert(result.isOk()); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(6); - for (let i = 2; i < 6; i++) { - const args = (simulatorWithArgsSpy as Mock).mock.calls[i][0]; - expect(args.sushiQuote).toBe(partialQuote); - expect(args.lockRoute).toBe(true); - expect(args.skipPriceMatchCheck).toBe(true); - } - - // partial sim has no quote, so fallback sims reuse the full sim one - (simulatorWithArgsSpy as Mock).mockReset(); - (simulatorWithArgsSpy as Mock) - .mockReturnValueOnce({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFullTradeError), - }) - .mockReturnValueOnce({ - quote: undefined, - trySimulateTrade: vi.fn().mockResolvedValue(mockViolationError), - }) - .mockReturnValue({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFallbackSuccess), - }); - result = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - assert(result.isOk()); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(6); - for (let i = 2; i < 6; i++) { - const args = (simulatorWithArgsSpy as Mock).mock.calls[i][0]; - expect(args.sushiQuote).toBe(fullQuote); - expect(args.lockRoute).toBe(true); - } - - // partial sim quote is not a sushi one, so fallback sims skip it - // and reuse the full sim sushi quote instead - (simulatorWithArgsSpy as Mock).mockReset(); - (simulatorWithArgsSpy as Mock) - .mockReturnValueOnce({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFullTradeError), - }) - .mockReturnValueOnce({ - quote: balancerQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockViolationError), - }) - .mockReturnValue({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFallbackSuccess), - }); - result = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - assert(result.isOk()); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(6); - for (let i = 2; i < 6; i++) { - const args = (simulatorWithArgsSpy as Mock).mock.calls[i][0]; - expect(args.sushiQuote).toBe(fullQuote); - expect(args.lockRoute).toBe(true); - } - - // neither sim has a sushi quote, so fallback sims carry no quote to - // lock to and get quoted normally - (simulatorWithArgsSpy as Mock).mockReset(); - (simulatorWithArgsSpy as Mock) - .mockReturnValueOnce({ - quote: balancerQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFullTradeError), - }) - .mockReturnValueOnce({ - quote: balancerQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockViolationError), - }) - .mockReturnValue({ - quote: balancerQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFallbackSuccess), - }); - result = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - assert(result.isOk()); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(6); - for (let i = 2; i < 6; i++) { - expect((simulatorWithArgsSpy as Mock).mock.calls[i][0].sushiQuote).toBeUndefined(); - } - - // drop the persistent mock impl so it doesnt leak into other tests - (simulatorWithArgsSpy as Mock).mockRestore(); - }); - - it("should return error with MinimalOutputBalanceViolation reason when all backoff steps fail", async () => { - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) // full size - .mockResolvedValue(mockViolationError); // partial + all fallbacks - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 1024000n, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - assert(result.isErr()); - expect(result.error.noneNodeError).toBe("order ratio issue"); - // 1 full + 1 partial + 4 fallbacks - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(6); - expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ maximumInputFixed: 64000n, isPartial: true }), - ); - expect(result.error.spanAttributes["full.error"]).toBe("ratio too high"); - expect(result.error.spanAttributes["partial.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - expect(result.error.spanAttributes["partialFallback1.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - expect(result.error.spanAttributes["partialFallback4.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - expect(result.error.spanAttributes["partialFallback5.error"]).toBeUndefined(); - }); - - it("should stop backoff when a step fails with an error other than MinimalOutputBalanceViolation", async () => { - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - }); - const mockOtherError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { error: "some other revert" }, - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) // full size - .mockResolvedValueOnce(mockViolationError) // partial size - .mockResolvedValueOnce(mockOtherError) // partialFallback1 - .mockResolvedValue(mockViolationError); // remaining fallbacks - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 1000n, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - assert(result.isErr()); - // all fallback steps run concurrently, a non retry error on one - // step does not stop the others anymore - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(6); - expect(result.error.spanAttributes["partialFallback1.error"]).toBe("some other revert"); - expect(result.error.spanAttributes["partialFallback2.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - expect(result.error.spanAttributes["partialFallback4.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - }); - - it("should drop halved sizes that reach zero", async () => { - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) // full size - .mockResolvedValue(mockViolationError); // partial + all fallbacks - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 8n, // tiny size, halves to 4, 2, 1 and then to zero - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - assert(result.isErr()); - // 1 full + 1 partial + only 3 fallbacks since the 4th halving hits zero - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(5); - expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ maximumInputFixed: 1n, isPartial: true }), - ); - expect(result.error.spanAttributes["partialFallback3.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - expect(result.error.spanAttributes["partialFallback4.error"]).toBeUndefined(); - }); - - it("should not run backoff when no trade size was found even for strict checked max owner", async () => { - mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = true; - mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; - const mockFullTradeError = Result.err({ - type: TradeType.Router, - reason: SimulationHaltReason.NoRoute, - spanAttributes: { error: "no route" }, - noneNodeError: "no route available", - }); - - (trySimulateTradeSpy as Mock).mockResolvedValue(mockFullTradeError); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.NoWay, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - // no way means no way, only the full size sim runs and the - // error returns directly without any partial or fallback sims - assert(result.isErr()); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(1); - expect(result.error.spanAttributes["partial.error"]).toBe( - "found no route for any trade size", - ); - expect(result.error.spanAttributes["partialFallback1.error"]).toBeUndefined(); - }); - - it("should skip backoff when routerPartialFallback is disabled in config", async () => { + it("should run the found size only when routerPartialFallback is disabled", async () => { mockRainSolver.appOptions.routerPartialFallback = false; - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) // full size - .mockResolvedValue(mockViolationError); // partial size - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.Found, - size: 1000n, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); - - assert(result.isErr()); - // only 1 full + 1 partial, no fallback attempts despite the violation error - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); - expect(result.error.reason).toBe(SimulationHaltReason.NoOpportunity); - expect(result.error.spanAttributes["partial.error"]).toContain( - "MinimalOutputBalanceViolation", - ); - expect(result.error.spanAttributes["partialFallback1.error"]).toBeUndefined(); - }); - - it("should run backoff on any partial failure for max owner with strict check enabled", async () => { - mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = true; - mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockPartialError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, // NOT a needsRetry error - }); - const mockFallbackSuccess = Result.ok({ - type: TradeType.RouteProcessor, - spanAttributes: { foundOpp: true }, - estimatedProfit: 25n, - oppBlockNumber: 123, - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) // full size - .mockResolvedValueOnce(mockPartialError) // partial size - .mockResolvedValueOnce(mockFallbackSuccess) // partialFallback1 - .mockResolvedValue(mockPartialError); // remaining fallbacks - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.PriceMismatch, - size: 1000n, - quote: { route: { pcMap: new Map() } }, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + setFoundSize(500n); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); + const result = await run(); - // the non retry partial failure still triggers the backoff - // since the owner has a max profile and strict check is on - assert(result.isOk()); - expect(result.value.estimatedProfit).toBe(25n); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(6); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ maximumInputFixed: 500n, isPartial: true }), + assert(result.isErr()); + expect(simulatedSizes()).toEqual([500n]); + expect(result.error.reason).toBe(SimulationHaltReason.NoOpportunity); + expect(result.error.spanAttributes["step1.error"]).toContain( + "MinimalOutputBalanceViolation", ); + expect(result.error.spanAttributes["step2.error"]).toBeUndefined(); }); - it("should not run backoff on non retry failure for max owner when strict check is disabled", async () => { - mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = false; + it("should run the halved sizes for a strict checked max owner even when routerPartialFallback is disabled", async () => { + mockRainSolver.appOptions.routerPartialFallback = false; + mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = true; mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockPartialError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, // NOT a needsRetry error - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) - .mockResolvedValue(mockPartialError); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.PriceMismatch, - size: 1000n, - quote: { route: { pcMap: new Map() } }, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + setFoundSize(500n); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); + const result = await run(); assert(result.isErr()); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); // no fallback attempts - expect(result.error.spanAttributes["partialFallback1.error"]).toBeUndefined(); + expect(simulatedSizes()).toEqual([500n, 375n, 250n, 125n, 62n, 31n]); }); - it("should not run backoff on non retry failure for non max owner with strict check enabled", async () => { + it("should run the found size only for a non max owner with strict check enabled when routerPartialFallback is disabled", async () => { + mockRainSolver.appOptions.routerPartialFallback = false; mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = true; mockRainSolver.appOptions.ownerProfile = { "0xowner": 100 }; // not max profile - const mockFullTradeError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, - noneNodeError: "order ratio issue", - }); - const mockPartialError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, - spanAttributes: { error: "ratio too high" }, // NOT a needsRetry error - }); - - (trySimulateTradeSpy as Mock) - .mockResolvedValueOnce(mockFullTradeError) - .mockResolvedValue(mockPartialError); - (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue({ - status: TradeSizeStatus.PriceMismatch, - size: 1000n, - quote: { route: { pcMap: new Map() } }, - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + setFoundSize(500n); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); + const result = await run(); assert(result.isErr()); - expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); // no fallback attempts - expect(result.error.spanAttributes["partialFallback1.error"]).toBeUndefined(); + expect(simulatedSizes()).toEqual([500n]); }); - describe("full trade size backoff", () => { - // a sushi quote with no route legs, so the dex exclusion retry stays out - const fullQuote = { route: { route: { legs: [] }, pcMap: new Map() }, tag: "full" } as any; - const mockViolationError = Result.err({ - type: TradeType.RouteProcessor, + it("should return the batch failure with the step attributes when every size fails", async () => { + const mockOtherError = Result.err({ + type: TradeType.Balancer, reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { - error: "execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)", - }, - noneNodeError: "full violation", + spanAttributes: { error: "some other revert" }, + noneNodeError: "other", }); - const mockFallbackSuccess = Result.ok({ - type: TradeType.RouteProcessor, - spanAttributes: { foundOpp: true }, - estimatedProfit: 25n, - oppBlockNumber: 123, + setFoundSize(1000n); + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockOtherError) // 1000n + .mockResolvedValue(mockViolationError); // halved sizes + const result = await run(); + + assert(result.isErr()); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(6); + // the biggest size failure represents the batch + expect(result.error.type).toBe(TradeType.Balancer); + expect(result.error.reason).toBe(SimulationHaltReason.NoOpportunity); + expect(result.error.noneNodeError).toBe("other"); + expect(result.error.spanAttributes["step1.error"]).toBe("some other revert"); + expect(result.error.spanAttributes["step2.error"]).toContain( + "MinimalOutputBalanceViolation", + ); + expect(result.error.spanAttributes["step6.error"]).toContain( + "MinimalOutputBalanceViolation", + ); + expect(result.error.spanAttributes["step7.error"]).toBeUndefined(); + expect(extendObjectWithHeader).toHaveBeenCalledWith( + expect.any(Object), + { error: "some other revert" }, + "step1", + ); + }); + + describe("dust found size", () => { + beforeEach(() => { + setFoundSize(5n); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockSuccess); }); - it("should backoff from full size with the full route locked when full dryrun fails with MinimalOutputBalanceViolation", async () => { - (simulatorWithArgsSpy as Mock) - .mockReturnValueOnce({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockViolationError), - }) - .mockReturnValueOnce({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockViolationError), - }) - .mockReturnValue({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFallbackSuccess), - }); + it("should ask the state dust check with the found size before any sim", async () => { + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(false); + const result = await run(); - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, + assert(result.isOk()); + expect(mockRainSolver.state.isDustTrade).toHaveBeenCalledWith( orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, + outputToEthPrice, + "2000", + 5n, ); + // not dust, so the found size gets simulated with its backoff sizes + expect(simulatedSizes()).toEqual([5n, 3n, 2n, 1n]); + }); + + it("should bail out without any sim when the found size is dust", async () => { + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(true); + const result = await run(); - // the size finder is skipped and the 4 halved sizes of the - // full size run straight away with the full route locked in + assert(result.isErr()); + expect(trySimulateTradeSpy).not.toHaveBeenCalled(); + expect(result.error.reason).toBe(SimulationHaltReason.DustTradeSize); + expect(result.error.spanAttributes).toEqual({ + dustTradeSize: true, + error: "dust trade size", + }); + }); + + it("should not count a found size as dust when the state cannot decide", async () => { + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(undefined); + const result = await run(); + + // undecided is not dust, however small the size, so the found + // size gets simulated with its backoff sizes assert(result.isOk()); - expect(result.value.spanAttributes).toEqual({ foundOpp: true }); - expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(5); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ maximumInputFixed: 1000n, isPartial: false }), - ); - const fallbackArgs = { - isPartial: true, - sushiQuote: fullQuote, - lockRoute: true, - skipPriceMatchCheck: true, - }; - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ maximumInputFixed: 500n, ...fallbackArgs }), - ); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ maximumInputFixed: 250n, ...fallbackArgs }), - ); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ maximumInputFixed: 125n, ...fallbackArgs }), - ); - expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( - 5, - expect.objectContaining({ maximumInputFixed: 62n, ...fallbackArgs }), - ); - expect((simulatorWithArgsSpy as Mock).mock.calls[0][0].lockRoute).toBeUndefined(); + expect(simulatedSizes()).toEqual([5n, 3n, 2n, 1n]); + }); - (simulatorWithArgsSpy as Mock).mockRestore(); + it("should never count the full size as dust", async () => { + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(true); + setFoundSize(1000n); + const result = await run(); + + // the full size runs, its halved sizes are all dust though + assert(result.isOk()); + expect(simulatedSizes()).toEqual([1000n]); }); - it("should return the full error with fallback attributes when all full size backoff steps fail", async () => { - (simulatorWithArgsSpy as Mock).mockReturnValue({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockViolationError), - }); + it("should have no dust logic at all when no check is enabled", async () => { + // the state is the gate, with no check enabled it never decides + mockRainSolver.state.isDustCheckEnabled = false; + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(undefined); + const result = await run(); - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, + // the found size gets simulated with its backoff sizes + assert(result.isOk()); + expect(simulatedSizes()).toEqual([5n, 3n, 2n, 1n]); + expect(mockRainSolver.state.isDustTrade).toHaveBeenCalledWith( orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, + outputToEthPrice, + "2000", + 5n, ); + }); - assert(result.isErr()); - expect(result.error.reason).toBe(SimulationHaltReason.NoOpportunity); - expect(result.error.noneNodeError).toBe("full violation"); - expect(result.error.spanAttributes["full.error"]).toContain( - "MinimalOutputBalanceViolation", + it("should halve from the full size for a strict checked max owner", async () => { + mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = true; + mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; + (mockRainSolver.state.isDustTrade as Mock).mockImplementation( + (_pair: any, _price: any, _usd: any, size: bigint) => size === 5n, ); - for (let i = 1; i <= 4; i++) { - expect(result.error.spanAttributes[`partialFallback${i}.error`]).toContain( - "MinimalOutputBalanceViolation", + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockViolationError) // 750n + .mockResolvedValueOnce(mockSuccess) // 500n + .mockResolvedValue(mockViolationError); // rest + const result = await run(); + + // the found size never gets simulated, the backoff sizes of the + // full size do, locked to the found route as well + assert(result.isOk()); + expect(result.value.estimatedProfit).toBe(25n); + expect(simulatedSizes()).toEqual([750n, 500n, 250n, 125n, 62n]); + for (let i = 0; i < 5; i++) { + expect((simulatorWithArgsSpy as Mock).mock.calls[i][0]).toEqual( + expect.objectContaining({ isPartial: true, ...lockedArgs }), ); } - expect(result.error.spanAttributes["partial.error"]).toBeUndefined(); - expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(5); - - (simulatorWithArgsSpy as Mock).mockRestore(); }); - it("should not backoff from full size when routerPartialFallback is disabled", async () => { - mockRainSolver.appOptions.routerPartialFallback = false; - (simulatorWithArgsSpy as Mock).mockReturnValue({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockViolationError), - }); - - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, + it("should flag the dust found size when the halved full sizes fail", async () => { + mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = true; + mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; + (mockRainSolver.state.isDustTrade as Mock).mockImplementation( + (_pair: any, _price: any, _usd: any, size: bigint) => size <= 125n, ); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); + const result = await run(); assert(result.isErr()); - expect(result.error.reason).toBe(SimulationHaltReason.NoOpportunity); - expect(result.error.spanAttributes["partialFallback1.error"]).toBeUndefined(); - expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(1); - - (simulatorWithArgsSpy as Mock).mockRestore(); + expect(simulatedSizes()).toEqual([750n, 500n, 250n]); + expect(result.error.spanAttributes["dustTradeSize"]).toBe(true); + expect(result.error.spanAttributes["step3.error"]).toContain( + "MinimalOutputBalanceViolation", + ); + expect(result.error.spanAttributes["step4.error"]).toBeUndefined(); }); - it("should not backoff from full size when full dryrun fails with a non retry error", async () => { - const mockOtherError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { error: "execution reverted: SomeOtherError()" }, - }); - (simulatorWithArgsSpy as Mock).mockReturnValue({ - quote: fullQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockOtherError), + it("should bail out for a strict checked max owner when every halved full size is dust too", async () => { + mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = true; + mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(true); + const result = await run(); + + assert(result.isErr()); + expect(trySimulateTradeSpy).not.toHaveBeenCalled(); + expect(result.error.reason).toBe(SimulationHaltReason.DustTradeSize); + expect(result.error.spanAttributes).toEqual({ + dustTradeSize: true, + error: "dust trade size", }); + }); - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + it("should bail out for a max owner when strict check is disabled", async () => { + mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = false; + mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(true); + const result = await run(); assert(result.isErr()); - expect(result.error.reason).toBe(SimulationHaltReason.NoOpportunity); - expect(result.error.spanAttributes["partialFallback1.error"]).toBeUndefined(); - expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(1); + expect(trySimulateTradeSpy).not.toHaveBeenCalled(); + expect(result.error.reason).toBe(SimulationHaltReason.DustTradeSize); + }); - (simulatorWithArgsSpy as Mock).mockRestore(); + it("should bail out for a non max owner when strict check is enabled", async () => { + mockRainSolver.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck = true; + mockRainSolver.appOptions.ownerProfile = { "0xowner": 100 }; // not max profile + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(true); + const result = await run(); + + assert(result.isErr()); + expect(trySimulateTradeSpy).not.toHaveBeenCalled(); + expect(result.error.reason).toBe(SimulationHaltReason.DustTradeSize); }); }); - it("should retry with the failing route dexes excluded when full trade dryrun fails", async () => { - const sushiQuote = { + describe("secondary route try", () => { + // a found route with a single dex, so the retry excludes exactly it + const hydrexQuote = { route: { pcMap: new Map([["pool1", { liquidityProvider: "Hydrex" }]]), route: { legs: [{ uniqueId: "pool1" }] }, }, } as any; - const mockFullTradeError = Result.err({ + const mockDryrunError = Result.err({ type: TradeType.RouteProcessor, reason: SimulationHaltReason.NoOpportunity, spanAttributes: { error: "dryrun failed" }, + noneNodeError: "full failed", }); const mockRetrySuccess = Result.ok({ type: TradeType.RouteProcessor, @@ -1377,184 +555,161 @@ describe("Test findBestRouterTrade", () => { 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"]), + beforeEach(() => { + // a single size per attempt keeps the sim sequence simple + mockRainSolver.appOptions.routerPartialFallbackSteps = 0; + setFoundSize(1000n, hydrexQuote); + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockDryrunError) // primary attempt + .mockResolvedValueOnce(mockRetrySuccess); // secondary attempt }); - expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); - }); - describe("routerSecondaryRouteTry config", () => { - 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, - }); - const runWithMode = async (mode: string, ownerProfile?: Record) => { - mockRainSolver.appOptions.routerSecondaryRouteTry = mode; - mockRainSolver.appOptions.ownerProfile = ownerProfile; - (simulatorWithArgsSpy as Mock) - .mockReturnValueOnce({ - quote: sushiQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockFullTradeError), - }) - .mockReturnValueOnce({ - quote: sushiQuote, - trySimulateTrade: vi.fn().mockResolvedValue(mockRetrySuccess), - }); - return findBestRouterTrade.call( - mockRainSolver, + it("should retry with the found route dexes excluded when the batch fails onchain", async () => { + const result = await run(); + + assert(result.isOk()); + expect(result.value.spanAttributes).toEqual({ foundOpp: true }); + expect(result.value.estimatedProfit).toBe(50n); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); + // the size finder runs again with the dexes excluded + expect(mockRainSolver.state.router.findLargestTradeSize).toHaveBeenCalledTimes(2); + expect(mockRainSolver.state.router.findLargestTradeSize).toHaveBeenLastCalledWith( orderDetails, - signer, - ethPrice, toToken, fromToken, - blockNumber, + 1000n, + 100n, + undefined, + false, + new Set(["Hydrex"]), ); - }; + expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + maximumInputFixed: 1000n, + excludeDexes: new Set(["Hydrex"]), + sushiQuote: hydrexQuote, + }), + ); + }); + + it("should not retry when the batch fails for a reason other than an onchain rejection", async () => { + (trySimulateTradeSpy as Mock).mockReset(); + (trySimulateTradeSpy as Mock).mockResolvedValue( + Result.err({ + type: TradeType.RouteProcessor, + reason: SimulationHaltReason.UndefinedTradeDestinationAddress, + spanAttributes: { error: "no address" }, + }), + ); + const result = await run(); + + assert(result.isErr()); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(1); + expect(result.error.spanAttributes["secondary.step1.error"]).toBeUndefined(); + }); it("should retry for every order when set to all", async () => { - const result = await runWithMode("all"); + mockRainSolver.appOptions.routerSecondaryRouteTry = "all"; + mockRainSolver.appOptions.ownerProfile = { "0xother": Number.MAX_SAFE_INTEGER }; + const result = await run(); + assert(result.isOk()); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(2); - expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ excludeDexes: new Set(["Hydrex"]) }), - ); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); }); it("should retry only for max profile owners when set to max", async () => { + mockRainSolver.appOptions.routerSecondaryRouteTry = "max"; // non max owner, no retry - let result = await runWithMode("max", { "0xother": Number.MAX_SAFE_INTEGER }); + mockRainSolver.appOptions.ownerProfile = { "0xother": Number.MAX_SAFE_INTEGER }; + let result = await run(); assert(result.isErr()); - expect(result.error.spanAttributes["secondary.full.error"]).toBeUndefined(); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(1); + expect(result.error.spanAttributes["secondary.step1.error"]).toBeUndefined(); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(1); // max owner, retry - (simulatorWithArgsSpy as Mock).mockReset(); - result = await runWithMode("max", { "0xowner": Number.MAX_SAFE_INTEGER }); + (trySimulateTradeSpy as Mock).mockReset(); + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockDryrunError) + .mockResolvedValueOnce(mockRetrySuccess); + mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; + result = await run(); assert(result.isOk()); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(2); - expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ excludeDexes: new Set(["Hydrex"]) }), - ); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); }); it("should never retry when set to off", async () => { - const result = await runWithMode("off", { "0xowner": Number.MAX_SAFE_INTEGER }); + mockRainSolver.appOptions.routerSecondaryRouteTry = "off"; + mockRainSolver.appOptions.ownerProfile = { "0xowner": Number.MAX_SAFE_INTEGER }; + const result = await run(); + assert(result.isErr()); - expect(result.error.spanAttributes["secondary.full.error"]).toBeUndefined(); - expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(1); + expect(result.error.spanAttributes["secondary.step1.error"]).toBeUndefined(); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(1); }); - }); - 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", + it("should return the primary error with the secondary attributes when the retry also fails", async () => { + const mockRetryError = Result.err({ + type: TradeType.RouteProcessor, + reason: SimulationHaltReason.NoOpportunity, + spanAttributes: { error: "retry dryrun failed" }, + noneNodeError: "retry failed", + }); + (trySimulateTradeSpy as Mock).mockReset(); + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockDryrunError) + .mockResolvedValueOnce(mockRetryError); + const result = await run(); + + assert(result.isErr()); + expect(result.error.noneNodeError).toBe("full failed"); + expect(result.error.type).toBe(TradeType.RouteProcessor); + expect(result.error.spanAttributes).toEqual({ + "step1.error": "dryrun failed", + "secondary.step1.error": "retry dryrun failed", + }); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(2); + expect(extendObjectWithHeader).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + "secondary", + ); }); - const mockRetryError = Result.err({ - type: TradeType.RouteProcessor, - reason: SimulationHaltReason.NoOpportunity, - spanAttributes: { error: "retry dryrun failed" }, - noneNodeError: "retry failed", + + it("should not retry when no route was found with the dexes excluded", async () => { + (mockRainSolver.state.router.findLargestTradeSize as Mock) + .mockReturnValueOnce({ + status: TradeSizeStatus.Found, + size: 1000n, + quote: hydrexQuote, + }) + .mockReturnValueOnce({ status: TradeSizeStatus.NoWay }); + const result = await run(); + + assert(result.isErr()); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(1); + expect(result.error.spanAttributes["secondary.error"]).toBe( + "found no route for any trade size", + ); }); - (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, - ); + it("should not retry when the found route spans more than one dex", async () => { + setFoundSize(1000n, { + route: { + pcMap: new Map([ + ["pool1", { liquidityProvider: "Hydrex" }], + ["pool2", { liquidityProvider: "UniswapV3" }], + ]), + route: { legs: [{ uniqueId: "pool1" }, { uniqueId: "pool2" }] }, + }, + }); + const result = await run(); - 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", + assert(result.isErr()); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(1); + expect(mockRainSolver.state.router.findLargestTradeSize).toHaveBeenCalledTimes(1); + expect(result.error.spanAttributes["secondary.step1.error"]).toBeUndefined(); }); - 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 () => { @@ -1573,19 +728,12 @@ describe("Test findBestRouterTrade", () => { expect(result.error.spanAttributes.error).toBe( "no route to get price of input token to eth", ); + expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); }); it("should return error when trade addresses are not configured", async () => { (mockRainSolver.state.contracts.getAddressesForTrade as Mock).mockReturnValue(undefined); - const result: SimulationResult = await findBestRouterTrade.call( - mockRainSolver, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - ); + const result = await run(); assert(result.isErr()); expect(result.error.type).toBe(TradeType.Router); @@ -1594,5 +742,30 @@ describe("Test findBestRouterTrade", () => { orderDetails, TradeType.Router, ); + expect(mockRainSolver.state.router.findLargestTradeSize).not.toHaveBeenCalled(); + }); +}); + +describe("Test getHalvedTradeSizes", () => { + it("should start at three quarters and halve the size as many times as the steps", () => { + expect(getHalvedTradeSizes(1000n, 4)).toEqual([750n, 500n, 250n, 125n, 62n]); + expect(getHalvedTradeSizes(1000n, 1)).toEqual([750n, 500n]); + // no steps means no sizes at all, not even the three quarters one + expect(getHalvedTradeSizes(1000n, 0)).toEqual([]); + }); + + it("should stop at the first size that reaches zero", () => { + expect(getHalvedTradeSizes(8n, 10)).toEqual([6n, 4n, 2n, 1n]); + expect(getHalvedTradeSizes(1n, 10)).toEqual([]); + expect(getHalvedTradeSizes(0n, 10)).toEqual([]); + }); + + it("should stop at the first dust size", () => { + const isDust = (size: bigint) => size < 200n; + expect(getHalvedTradeSizes(1000n, 4, isDust)).toEqual([750n, 500n, 250n]); + // 225n clears, its half 150n is dust + expect(getHalvedTradeSizes(300n, 4, isDust)).toEqual([225n]); + // a dust three quarters size means no sizes at all + expect(getHalvedTradeSizes(200n, 4, isDust)).toEqual([]); }); }); diff --git a/src/core/modes/router/index.ts b/src/core/modes/router/index.ts index 97850d8d..2ddefc76 100644 --- a/src/core/modes/router/index.ts +++ b/src/core/modes/router/index.ts @@ -11,11 +11,11 @@ import { SushiRouterQuote, TradeSizeStatus } 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 */ +/** Represents the result of a router trade attempt paired with the quote it was judged on */ export type RouterTradeAttempt = { /** The simulation result of the attempt */ result: SimulationResult; - /** The quote of the attempt's full trade size simulation */ + /** The quote every sim of the attempt was locked to, ie the one a secondary route try excludes the dexes of */ quote?: RouterTradeSimulator["quote"]; }; @@ -35,6 +35,7 @@ export type RouterTradeAttempt = { * @param toToken - The token to trade to * @param fromToken - The token to trade from * @param blockNumber - The current block number + * @param outputToEthPrice - (optional) The output token to eth price, used for the dust checks */ export async function findBestRouterTrade( this: RainSolver, @@ -44,6 +45,7 @@ export async function findBestRouterTrade( toToken: Token, fromToken: Token, blockNumber: bigint, + outputToEthPrice?: string, ): Promise { // primary attempt normally with all enabled dexes const primary = await tryFindBestRouterTrade.call( @@ -54,6 +56,8 @@ export async function findBestRouterTrade( toToken, fromToken, blockNumber, + undefined, + outputToEthPrice, ); if (primary.result.isOk()) { return primary.result; @@ -86,6 +90,7 @@ export async function findBestRouterTrade( fromToken, blockNumber, excludeDexes, + outputToEthPrice, ); if (secondary.result.isOk()) { return secondary.result; @@ -101,9 +106,18 @@ export async function findBestRouterTrade( } /** - * 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 + * Tries to find a trade against rain router for the given order, the size finder + * settles on the biggest trade size that routes and clears the order ratio offchain, + * probing the full size (order's max output) first, the route it settles on is then + * locked for every sim of the attempt, which all run concurrently as one batch of + * trade sizes validated against the onchain dryrun, the found size and its halved + * sizes (three quarters of it and its halved sizes), the biggest size that passes + * wins, the backoff sizes run when enabled by + * routerPartialFallback config, or for orders of max profile owners when enabled by + * strictMaxOwnerProfilePartialTradeSizeCheck config, a found size that counts as + * dust is never simulated, orders of max profile owners with the strict check + * enabled then back off from the full size instead, as the pool model most likely + * underestimated what the route can take, while other orders bail 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 @@ -112,6 +126,7 @@ export async function findBestRouterTrade( * @param fromToken - The token to trade from * @param blockNumber - The current block number * @param excludeDexes - (optional) Liquidity providers (dexes) to exclude from route finding + * @param outputToEthPrice - (optional) The output token to eth price, used for the dust checks */ export async function tryFindBestRouterTrade( this: RainSolver, @@ -122,6 +137,7 @@ export async function tryFindBestRouterTrade( fromToken: Token, blockNumber: bigint, excludeDexes?: Set, + outputToEthPrice?: string, ): Promise { const spanAttributes: Attributes = {}; @@ -151,78 +167,10 @@ export async function tryFindBestRouterTrade( const maximumInput = orderDetails.takeOrder.quote!.maxOutput; - // try simulation for full trade size and return if succeeds - const fullTradeSimulator = RouterTradeSimulator.withArgs({ - type: TradeType.Router, - solver: this, - orderDetails, - fromToken, - toToken, - signer, - maximumInputFixed: maximumInput, - ethPrice, - isPartial: false, - blockNumber, - excludeDexes, - }); - const fullTradeSizeSimResult = await fullTradeSimulator.trySimulateTrade(); - let quote = fullTradeSimulator.quote; - if (fullTradeSizeSimResult.isOk()) { - return { result: fullTradeSizeSimResult, quote }; - } - extendObjectWithHeader(spanAttributes, fullTradeSizeSimResult.error.spanAttributes, "full"); - - // only run the partial trade size finder if the full trade size failed due - // to order ratio being greater than market price or there was no route for full - // trade size, that's because if for example for a pair there is only 1 pool and that - // pool has certain amount of reserves that cant cover the full trade size but can - // cover partial, we still need to try it - if ( - fullTradeSizeSimResult.error.reason !== SimulationHaltReason.NoRoute && - fullTradeSizeSimResult.error.reason !== - SimulationHaltReason.OrderRatioGreaterThanMarketPrice - ) { - // if the full trade size got rejected onchain with MinimalOutputBalanceViolation, - // the offchain pool data overestimated the output for the full size, the found - // route already clears the order ratio offchain, so the size finder adds nothing - // and the halved sizes are dryrun straight away with that route locked in - if ( - this.appOptions.routerPartialFallback && - fullTradeSizeSimResult.error.reason === SimulationHaltReason.NoOpportunity && - SimulationHaltReason.needsRetry(fullTradeSizeSimResult.error.spanAttributes["error"]) - ) { - const fallbackPick = await simulateFallbackTradeSizes.call( - this, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - maximumInput, - spanAttributes, - [quote], - excludeDexes, - ); - if (fallbackPick) { - return { result: fallbackPick, quote }; - } - } - return { - result: Result.err({ - type: fullTradeSizeSimResult.error.type, - spanAttributes, - noneNodeError: fullTradeSizeSimResult.error.noneNodeError, - reason: fullTradeSizeSimResult.error.reason, - }), - quote, - }; - } - - // try simulation for partial trade size, a price mismatch result still - // carries the biggest routed size which is simulated as partial trade - // size and can then feed the fallback backoff on failure - const partialTradeSizeResult = this.state.router.findLargestTradeSize( + // find the biggest trade size that routes and clears the order ratio offchain, + // the full size is probed first, so a full size that clears it costs no search, + // no route at any size or no size that clears the ratio means no trade + const tradeSizeResult = this.state.router.findLargestTradeSize( orderDetails, toToken, fromToken, @@ -232,103 +180,124 @@ export async function tryFindBestRouterTrade( false, excludeDexes, ); - if (partialTradeSizeResult.status === TradeSizeStatus.NoWay) { - spanAttributes["partial.error"] = "found no route for any trade size"; + if (tradeSizeResult.status === TradeSizeStatus.NoWay) { + spanAttributes["error"] = "found no route for any trade size"; return { result: Result.err({ - type: fullTradeSizeSimResult.error.type, + type: TradeType.Router, spanAttributes, - noneNodeError: fullTradeSizeSimResult.error.noneNodeError, + reason: SimulationHaltReason.NoRoute, }), - quote, }; } - const partialTradeSize = partialTradeSizeResult.size; - const partialTradeSimulator = RouterTradeSimulator.withArgs({ - type: TradeType.Router, - solver: this, + if (tradeSizeResult.status === TradeSizeStatus.PriceMismatch) { + spanAttributes["error"] = "found no trade size that clears the order ratio"; + return { + result: Result.err({ + type: TradeType.Router, + spanAttributes, + reason: SimulationHaltReason.OrderRatioGreaterThanMarketPrice, + }), + quote: tradeSizeResult.quote, + }; + } + const { size: tradeSize, quote } = tradeSizeResult; + const isFullSize = tradeSize >= maximumInput; + const shouldStrictSimulate = + this.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck && + AppOptions.isMaxOwnerProfile( + orderDetails.takeOrder.struct.order.owner, + this.appOptions.ownerProfile, + ); + const steps = this.appOptions.routerPartialFallbackSteps; + + // a trade size is dust by the dust checks enabled in the app options, the state + // decides with its best known gas cost estimate for the pair, an undecided check + // (for lack of its inputs) does not count as dust, with no dust check enabled there + // is no dust logic at all, a dust size never gets simulated as it cannot pay the gas + const isDustSize = (size: bigint): boolean => + !!this.state.isDustTrade(orderDetails, outputToEthPrice, this.state.gasTokenUsdPrice, size); + + // build the batch of trade sizes, the found size and its backoff sizes, a dust + // found size is not worth a sim, the pool model most likely underestimated what + // the route can take in that case, so orders of max profile owners with the + // strict check enabled back off from the full size instead, other orders bail out + let tradeSizes: bigint[]; + if (!isFullSize && isDustSize(tradeSize)) { + spanAttributes["dustTradeSize"] = true; + tradeSizes = shouldStrictSimulate + ? getHalvedTradeSizes(maximumInput, steps, isDustSize) + : []; + if (!tradeSizes.length) { + spanAttributes["error"] = "dust trade size"; + return { + result: Result.err({ + type: TradeType.Router, + spanAttributes, + reason: SimulationHaltReason.DustTradeSize, + }), + quote, + }; + } + } else { + tradeSizes = [tradeSize]; + if (this.appOptions.routerPartialFallback || shouldStrictSimulate) { + tradeSizes.push(...getHalvedTradeSizes(tradeSize, steps, isDustSize)); + } + } + + const result = await simulateTradeSizes.call( + this, orderDetails, - fromToken, - toToken, signer, - maximumInputFixed: partialTradeSize, ethPrice, - isPartial: true, + toToken, + fromToken, blockNumber, - excludeDexes, - // plug in the winning probe quote of the size search, so the sushi - // router doesnt recompute the same route for the same size again - sushiQuote: partialTradeSizeResult.quote, - }); - const partialTradeSizeSimResult = await partialTradeSimulator.trySimulateTrade(); - quote = partialTradeSimulator.quote ?? quote; - if (partialTradeSizeSimResult.isOk()) { - return { result: partialTradeSizeSimResult, quote }; - } - extendObjectWithHeader( + tradeSizes, spanAttributes, - partialTradeSizeSimResult.error.spanAttributes, - "partial", + quote, + excludeDexes, ); + return { result, quote }; +} - // if the partial trade size sim got rejected onchain with MinimalOutputBalanceViolation, - // it means the offchain pool data overestimated the output for the found partial trade - // size, so backoff with halved trade sizes validated against onchain dryrun and accept - // the first size that passes, the backoff only runs when enabled by config, and for - // orders of max profile owners with strictMaxOwnerProfilePartialTradeSizeCheck config - // enabled, it runs on ANY partial sim failure, so smaller sizes get probed against - // the real chain even when the offchain quotes show no price match - const reason = partialTradeSizeSimResult.error.reason; - if ( - this.appOptions.routerPartialFallback && - (SimulationHaltReason.needsRetry(partialTradeSizeSimResult.error.spanAttributes["error"]) || - (this.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck && - AppOptions.isMaxOwnerProfile( - orderDetails.takeOrder.struct.order.owner, - this.appOptions.ownerProfile, - ))) - ) { - // the fallback sims reuse the sushi route already found by the partial - // sim, or by the full sim when the partial had none - const fallbackPick = await simulateFallbackTradeSizes.call( - this, - orderDetails, - signer, - ethPrice, - toToken, - fromToken, - blockNumber, - partialTradeSize, - spanAttributes, - [partialTradeSimulator.quote, fullTradeSimulator.quote], - excludeDexes, - ); - if (fallbackPick) { - return { result: fallbackPick, quote }; - } +/** + * Builds the backoff trade sizes of the given size, three quarters of it first, + * followed by its halved sizes, as many as the given steps, the first one being half + * of the given size, the sizes stop at the first one that reaches zero or counts as + * dust, since the smaller sizes are then dust as well, no steps means no sizes at all + * @param size - The trade size to back off from + * @param steps - The max number of halved sizes + * @param isDust - (optional) Tells if a trade size is dust, no size is dust by default + */ +export function getHalvedTradeSizes( + size: bigint, + steps: number, + isDust: (size: bigint) => boolean = () => false, +): bigint[] { + if (steps <= 0) return []; + const sizes: bigint[] = []; + const threeQuarters = (size * 3n) / 4n; + if (threeQuarters <= 0n || isDust(threeQuarters)) return sizes; + sizes.push(threeQuarters); + for (let i = 0; i < steps; i++) { + size /= 2n; + if (size <= 0n || isDust(size)) break; + sizes.push(size); } - return { - result: Result.err({ - type: fullTradeSizeSimResult.error.type, - spanAttributes, - noneNodeError: - fullTradeSizeSimResult.error.noneNodeError ?? - partialTradeSizeSimResult.error.noneNodeError, - reason, - }), - quote, - }; + return sizes; } /** - * Backs off from the given trade size with halved sizes (as many as the configured - * routerPartialFallbackSteps) validated against onchain dryrun and returns the biggest - * size that passes, the halved sims all launch concurrently - * and are all awaited, the sims reuse the first sushi route among the given quotes (in order of - * priority) instead of quoting again and skip the offchain price match check to go straight - * to dryrun, since the route already cleared the order ratio offchain for a bigger size, - * when none of the halved sizes pass, their span attributes get merged into the given - * attributes indexed by size order and undefined is returned + * Simulates the given trade sizes (in descending order) validated against onchain + * dryrun and returns the biggest size that passes, the sims all launch concurrently + * and are all awaited, the sims are all locked to the route of the given quote instead + * of quoting again and skip the offchain price match check to go straight to dryrun, + * since the onchain dryrun is the judge of the sizes, a size below the order's max + * output counts as a partial trade, when none of the sizes pass, their span attributes + * get merged into the given attributes indexed by size order and the biggest size + * failure represents the batch in the returned error, with the given attributes as its own * @param this - RainSolver instance * @param orderDetails - The details of the order to be processed * @param signer - The signer to be used for the trade @@ -336,12 +305,12 @@ export async function tryFindBestRouterTrade( * @param toToken - The token to trade to * @param fromToken - The token to trade from * @param blockNumber - The current block number - * @param startSize - The trade size to back off from, the halved sizes start at half of it - * @param spanAttributes - The attributes to merge the failed fallback sims attributes into - * @param quotes - The candidate quotes to reuse the route of, in order of priority + * @param tradeSizes - The trade sizes to simulate, in descending order + * @param spanAttributes - The attributes to merge the failed sims attributes into + * @param quote - The sushi quote to lock the route of * @param excludeDexes - (optional) Liquidity providers (dexes) to exclude from route finding */ -export async function simulateFallbackTradeSizes( +export async function simulateTradeSizes( this: RainSolver, orderDetails: Pair, signer: RainSolverSigner, @@ -349,22 +318,13 @@ export async function simulateFallbackTradeSizes( toToken: Token, fromToken: Token, blockNumber: bigint, - startSize: bigint, + tradeSizes: bigint[], spanAttributes: Attributes, - quotes: (RouterTradeSimulator["quote"] | undefined)[], + quote: SushiRouterQuote, excludeDexes?: Set, -): Promise { - // build the halved trade sizes, dropping zero or negative entries - const fallbackTradeSizes: bigint[] = []; - let fallbackTradeSize = startSize; - for (let i = 1; i <= this.appOptions.routerPartialFallbackSteps; i++) { - fallbackTradeSize /= 2n; - if (fallbackTradeSize <= 0n) break; - fallbackTradeSizes.push(fallbackTradeSize); - } - - const sushiQuote = quotes.find(SushiRouterQuote.is); - const fallbackSims = fallbackTradeSizes.map((size) => +): Promise { + const maximumInput = orderDetails.takeOrder.quote!.maxOutput; + const sims = tradeSizes.map((size) => RouterTradeSimulator.withArgs({ type: TradeType.Router, solver: this, @@ -374,10 +334,10 @@ export async function simulateFallbackTradeSizes( signer, maximumInputFixed: size, ethPrice, - isPartial: true, + isPartial: size < maximumInput, blockNumber, excludeDexes, - sushiQuote, + sushiQuote: quote, lockRoute: true, skipPriceMatchCheck: true, }).trySimulateTrade(), @@ -385,21 +345,22 @@ export async function simulateFallbackTradeSizes( // wait for all sims and take the biggest size that passed, not the first // one that resolved, the sims run concurrently so this only costs the // slowest sim's latency, which is paid anyway when all of them fail - const fallbackResults = await Promise.all(fallbackSims); - const fallbackPick = fallbackResults.find((fallbackResult) => fallbackResult.isOk()); - if (fallbackPick) { - return fallbackPick; + const results = await Promise.all(sims); + const pick = results.find((result) => result.isOk()); + if (pick) { + return pick; } // merge the failed sims attributes indexed by size order - fallbackResults.forEach((fallbackResult, i) => { - if (fallbackResult.isErr()) { - extendObjectWithHeader( - spanAttributes, - fallbackResult.error.spanAttributes, - `partialFallback${i + 1}`, - ); - } + const failures = results.flatMap((result) => (result.isErr() ? [result.error] : [])); + failures.forEach((failure, i) => { + extendObjectWithHeader(spanAttributes, failure.spanAttributes, `step${i + 1}`); + }); + // the biggest size failure represents the batch + return Result.err({ + type: failures[0]?.type ?? TradeType.Router, + spanAttributes, + reason: failures[0]?.reason, + noneNodeError: failures[0]?.noneNodeError, }); - return undefined; } diff --git a/src/core/modes/router/simulate.ts b/src/core/modes/router/simulate.ts index 774a58c9..e8dceea4 100644 --- a/src/core/modes/router/simulate.ts +++ b/src/core/modes/router/simulate.ts @@ -167,8 +167,8 @@ 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 belongs to a different amount, so the - // amountOut and marketPrice attributes above are not for this size + // the locked route quote may belong to a different amount, so the + // amountOut and marketPrice attributes above may not be for this size this.spanAttributes["routeReused"] = true; } diff --git a/src/core/modes/simulator.test.ts b/src/core/modes/simulator.test.ts index b746fb13..0b80d557 100644 --- a/src/core/modes/simulator.test.ts +++ b/src/core/modes/simulator.test.ts @@ -507,6 +507,22 @@ describe("Test TradeSimulatorBase", () => { expect(gasCache.recordFinal).not.toHaveBeenCalled(); }); + it("should carry the estimated gas cost on a final dryrun failure", async () => { + gasCache.get.mockReturnValue({ gas: 20000n, l1Cost: 500n }); + (dryrun as Mock).mockResolvedValueOnce( + Result.err({ spanAttributes: { error: "minimum sender output" } }), + ); + + const result = await mockSimulator.trySimulateTrade(); + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.NoOpportunity); + expect(result.error.spanAttributes["stage"]).toBe(2); + const gasLimit = (20000n * 120n) / 100n; + expect(result.error.estimatedGasCost).toBe( + gasLimit * mockSolver.state.gasPrice + 500n, + ); + }); + it("should not use the cache when gasCoveragePercentage is 0", async () => { (mockSolver.appOptions as any).gasCoveragePercentage = "0"; gasCache.get.mockReturnValue({ gas: 20000n, l1Cost: 500n }); diff --git a/src/core/modes/simulator.ts b/src/core/modes/simulator.ts index fa3a98c2..f7f5153e 100644 --- a/src/core/modes/simulator.ts +++ b/src/core/modes/simulator.ts @@ -28,6 +28,7 @@ export enum SimulationHaltReason { FailedToGetTaskBytecode, UndefinedTradeDestinationAddress, MinimalOutputBalanceViolation, + DustTradeSize, } export namespace SimulationHaltReason { /** @@ -270,6 +271,9 @@ export abstract class TradeSimulatorBase { Object.assign(finalDryrunResult.error.spanAttributes, this.spanAttributes); finalDryrunResult.error.reason = SimulationHaltReason.NoOpportunity; (finalDryrunResult.error as FailedSimulation).type = prepareParamsResult.value.type; + // carry the gas cost the trade was checked against, so the caller + // can tell a trade that is too small to ever pay the gas + (finalDryrunResult.error as FailedSimulation).estimatedGasCost = estimatedGasCost; return Result.err(finalDryrunResult.error as FailedSimulation); } diff --git a/src/core/process/order.test.ts b/src/core/process/order.test.ts index 86b41772..c04dba27 100644 --- a/src/core/process/order.test.ts +++ b/src/core/process/order.test.ts @@ -61,6 +61,8 @@ describe("Test processOrder", () => { .fn() .mockResolvedValue(Result.ok({ price: "100", amountOut: "100" })), gasPrice: 100n, + isDustTrade: vi.fn().mockReturnValue(undefined), + getGasCostEstimate: vi.fn().mockReturnValue(undefined), } as any; mockArgs = { orderDetails: { @@ -83,7 +85,7 @@ describe("Test processOrder", () => { mockRainSolver = { state: mockState, orderManager: mockOrderManager, - appOptions: {}, + appOptions: { dustOrderCheck: true }, findBestTrade, } as any; }); @@ -434,6 +436,106 @@ describe("Test processOrder", () => { }); }); + it("should skip the order as dust when the state dust check says so", async () => { + (mockState.isDustTrade as Mock).mockReturnValue(true); + (mockState.getGasCostEstimate as Mock).mockReturnValue(10n ** 40n); + (mockState as any).gasTokenUsdPrice = "2"; + + const fn: Awaited> = await processOrder.call( + mockRainSolver, + mockArgs, + ); + const result = await fn(); + + assert(result.isOk()); + expect(result.value.status).toBe(ProcessOrderStatus.DustOutput); + // 1 max output priced at the output token eth price of 100, 2 usd per eth + expect(result.value.spanAttributes["details.maxOutputValue"]).toBe("100"); + expect(result.value.spanAttributes["details.maxOutputValueUsd"]).toBe("200"); + // 1e40 wei is 1e22 eth + expect(result.value.spanAttributes["details.gasCostEstimate"]).toBe( + "10000000000000000000000", + ); + expect(mockState.isDustTrade).toHaveBeenCalledWith(mockArgs.orderDetails, "100", "2"); + expect(findBestTrade).not.toHaveBeenCalled(); + }); + + it("should not skip the order when the state dust check says not dust or cannot decide", async () => { + (findBestTrade as Mock).mockResolvedValue( + Result.ok({ + rawtx: { to: "0xRAW" }, + oppBlockNumber: 100, + estimatedProfit: 123n, + spanAttributes: {}, + }), + ); + (processTransaction as Mock).mockReturnValue(async () => + Result.ok({ status: ProcessOrderStatus.FoundOpportunity, endTime: 123 }), + ); + + (mockState.isDustTrade as Mock).mockReturnValue(false); + let fn: Awaited> = await processOrder.call( + mockRainSolver, + mockArgs, + ); + await fn(); + expect(findBestTrade).toHaveBeenCalledTimes(1); + + (mockState.isDustTrade as Mock).mockReturnValue(undefined); + fn = await processOrder.call(mockRainSolver, mockArgs); + await fn(); + expect(findBestTrade).toHaveBeenCalledTimes(2); + expect(mockState.isDustTrade).toHaveBeenCalledTimes(2); + }); + + it("should not run the dust check when disabled by config", async () => { + (mockRainSolver.appOptions as any).dustOrderCheck = false; + (mockState.isDustTrade as Mock).mockReturnValue(true); + (findBestTrade as Mock).mockResolvedValue( + Result.ok({ + rawtx: { to: "0xRAW" }, + oppBlockNumber: 100, + estimatedProfit: 123n, + spanAttributes: {}, + }), + ); + (processTransaction as Mock).mockReturnValue(async () => + Result.ok({ status: ProcessOrderStatus.FoundOpportunity, endTime: 123 }), + ); + + const fn: Awaited> = await processOrder.call( + mockRainSolver, + mockArgs, + ); + await fn(); + expect(mockState.isDustTrade).not.toHaveBeenCalled(); + expect(findBestTrade).toHaveBeenCalledTimes(1); + }); + + it("should not run the dust check when gas coverage is 0", async () => { + mockRainSolver.appOptions.gasCoveragePercentage = "0"; + (mockState.isDustTrade as Mock).mockReturnValue(true); + (findBestTrade as Mock).mockResolvedValue( + Result.ok({ + rawtx: { to: "0xRAW" }, + oppBlockNumber: 100, + estimatedProfit: 123n, + spanAttributes: {}, + }), + ); + (processTransaction as Mock).mockReturnValue(async () => + Result.ok({ status: ProcessOrderStatus.FoundOpportunity, endTime: 123 }), + ); + + const fn: Awaited> = await processOrder.call( + mockRainSolver, + mockArgs, + ); + await fn(); + expect(mockState.isDustTrade).not.toHaveBeenCalled(); + expect(findBestTrade).toHaveBeenCalledTimes(1); + }); + it("should record estimated profit usd when gas token usd price is set", async () => { (mockState as any).gasTokenUsdPrice = "2"; (findBestTrade as Mock).mockResolvedValue( @@ -462,6 +564,51 @@ describe("Test processOrder", () => { expect(callArgs.baseResult.spanAttributes["details.estimatedProfitUsd"]).toBe( "0.000000000000000246", ); + // in/out tokens are 100 eth each, so 200 dollars each + expect(callArgs.baseResult.spanAttributes["details.inputToEthPrice"]).toBe("100"); + expect(callArgs.baseResult.spanAttributes["details.inputToUsdPrice"]).toBe("200"); + expect(callArgs.baseResult.spanAttributes["details.outputToEthPrice"]).toBe("100"); + expect(callArgs.baseResult.spanAttributes["details.outputToUsdPrice"]).toBe("200"); + }); + + it("should not record in/out tokens usd price when unknown", async () => { + (findBestTrade as Mock).mockResolvedValue( + Result.ok({ + rawtx: { to: "0xRAW" }, + oppBlockNumber: 100, + estimatedProfit: 123n, + spanAttributes: {}, + }), + ); + (processTransaction as Mock).mockReturnValue(async () => + Result.ok({ status: ProcessOrderStatus.FoundOpportunity, endTime: 123 }), + ); + + // no gas token usd price, no usd prices at all + let fn: Awaited> = await processOrder.call( + mockRainSolver, + mockArgs, + ); + await fn(); + let callArgs = (processTransaction as Mock).mock.calls[0][0]; + expect(callArgs.baseResult.spanAttributes["details.inputToEthPrice"]).toBe("100"); + expect(callArgs.baseResult.spanAttributes["details.inputToUsdPrice"]).toBeUndefined(); + expect(callArgs.baseResult.spanAttributes["details.outputToUsdPrice"]).toBeUndefined(); + + // gas token usd price set but no route for the output token, so + // only the input token gets a usd price, the market price calls + // go pair quote, input to eth, output to eth in that order + (mockState as any).gasTokenUsdPrice = "2"; + (mockState.getMarketPrice as Mock) + .mockResolvedValueOnce(Result.ok({ price: "100" })) + .mockResolvedValueOnce(Result.ok({ price: "100" })) + .mockResolvedValueOnce(Result.err(new Error("no way"))); + fn = await processOrder.call(mockRainSolver, mockArgs); + await fn(); + callArgs = (processTransaction as Mock).mock.calls[1][0]; + expect(callArgs.baseResult.spanAttributes["details.inputToUsdPrice"]).toBe("200"); + expect(callArgs.baseResult.spanAttributes["details.outputToEthPrice"]).toBe("no-way"); + expect(callArgs.baseResult.spanAttributes["details.outputToUsdPrice"]).toBeUndefined(); }); it("should proceed to processTransaction if all steps succeed (happy path)", async () => { diff --git a/src/core/process/order.ts b/src/core/process/order.ts index fea485d1..aec022ef 100644 --- a/src/core/process/order.ts +++ b/src/core/process/order.ts @@ -1,7 +1,7 @@ import { RainSolver } from ".."; import { Pair } from "../../order"; import { Result } from "../../common"; -import { toUsdValue, toNumber } from "../../math"; +import { toNumber, toUsdValue, toEthValue } from "../../math"; import { Token } from "sushi/currency"; import { SpanWithContext } from "../../logger"; import { formatUnits, parseUnits } from "viem"; @@ -176,9 +176,24 @@ export async function processOrder( outputToEthPrice = "0"; } - // record in/out tokens to eth price andgas price for otel + // record in/out tokens to eth price andgas price for otel, along the in/out + // tokens usd price derived from their eth price and the gas token usd price spanAttributes["details.inputToEthPrice"] = inputToEthPrice || "no-way"; spanAttributes["details.outputToEthPrice"] = outputToEthPrice || "no-way"; + if (this.state.gasTokenUsdPrice) { + if (inputToEthPrice) { + spanAttributes["details.inputToUsdPrice"] = formatUnits( + toUsdValue(parseUnits(inputToEthPrice, 18), this.state.gasTokenUsdPrice), + 18, + ); + } + if (outputToEthPrice) { + spanAttributes["details.outputToUsdPrice"] = formatUnits( + toUsdValue(parseUnits(outputToEthPrice, 18), this.state.gasTokenUsdPrice), + 18, + ); + } + } spanAttributes["details.gasPrice"] = this.state.gasPrice.toString(); if (this.state.l1GasPrice) { spanAttributes["details.gasPriceL1"] = this.state.l1GasPrice.toString(); @@ -191,6 +206,41 @@ export async function processOrder( duration: getEthMarketPriceDuration, }; + // skip the order when its whole max output counts as dust by the dust checks + // enabled in the app options, no partial size can be profitable then, since + // the bounty of a trade is always a share of the market value of the output + // tokens it takes, the check is switched by config and dust has no meaning + // when gas coverage is 0 + if ( + this.appOptions.dustOrderCheck && + this.appOptions.gasCoveragePercentage !== "0" && + this.state.isDustTrade(orderDetails, outputToEthPrice, this.state.gasTokenUsdPrice) + ) { + const maxOutputValue = toEthValue( + orderDetails.takeOrder.quote!.maxOutput, + outputToEthPrice, + ); + spanAttributes["details.maxOutputValue"] = formatUnits(maxOutputValue, 18); + if (this.state.gasTokenUsdPrice) { + spanAttributes["details.maxOutputValueUsd"] = formatUnits( + toUsdValue(maxOutputValue, this.state.gasTokenUsdPrice), + 18, + ); + } + const gasCost = this.state.getGasCostEstimate(orderDetails); + if (gasCost !== undefined) { + spanAttributes["details.gasCostEstimate"] = formatUnits(gasCost, 18); + } + const endTime = performance.now(); + return async () => { + return Result.ok({ + ...baseResult, + endTime, + status: ProcessOrderStatus.DustOutput, + }); + }; + } + const findBestTradeTime = performance.now(); const trade = await this.findBestTrade({ orderDetails, diff --git a/src/core/process/round.ts b/src/core/process/round.ts index 2f294720..4174a489 100644 --- a/src/core/process/round.ts +++ b/src/core/process/round.ts @@ -287,6 +287,10 @@ export async function finalizeRound( report.setStatus({ code: SpanStatusCode.OK, message: "zero max output" }); break; } + case ProcessOrderStatus.DustOutput: { + report.setStatus({ code: SpanStatusCode.OK, message: "dust max output" }); + break; + } case ProcessOrderStatus.NoOpportunity: { if (value.message) { report.setStatus({ code: SpanStatusCode.ERROR, message: value.message }); diff --git a/src/core/types.ts b/src/core/types.ts index f6e17cc9..16a6b84d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -21,6 +21,8 @@ export enum ProcessOrderStatus { NoOpportunity, FoundOpportunity, UndefinedTradeAddresses, + /** The order's whole max output is worth less than the gas of a trade tx, so it cannot fill profitably */ + DustOutput, } /** Specifies types of trades */ @@ -90,6 +92,8 @@ export type DryrunSuccess = DryrunResultBase & { export type DryrunFailure = DryrunResultBase & { reason?: number; noneNodeError?: string; + /** The estimated gas cost the trade was checked against, set when the final dryrun failed */ + estimatedGasCost?: bigint; }; export type DryrunResult = Result; diff --git a/src/math/index.test.ts b/src/math/index.test.ts index ddbb4d99..fbf7e03e 100644 --- a/src/math/index.test.ts +++ b/src/math/index.test.ts @@ -6,6 +6,7 @@ import { toNumber, isBigNumberish, toUsdValue, + toEthValue, ONE18, } from "."; import { maxUint256 } from "viem"; @@ -151,4 +152,15 @@ describe("Test math functions", () => { assert.deepEqual(toUsdValue(0n, "3000.5"), 0n); }); }); + + describe("toEthValue", () => { + it("should convert the value with the given eth price", () => { + // 2 tokens at price of 0.5 eth each equals 1 eth + assert.deepEqual(toEthValue(2n * ONE18, "0.5"), ONE18); + assert.deepEqual(toEthValue(ONE18 / 2n, "3000.5"), (150025n * ONE18) / 100n); + // zero value or zero price equals zero + assert.deepEqual(toEthValue(0n, "3000.5"), 0n); + assert.deepEqual(toEthValue(2n * ONE18, "0"), 0n); + }); + }); }); diff --git a/src/math/index.ts b/src/math/index.ts index 516dbe79..2d0199c2 100644 --- a/src/math/index.ts +++ b/src/math/index.ts @@ -15,6 +15,16 @@ export function toUsdValue(value: bigint, usdPrice: string): bigint { return (value * parseUnits(usdPrice, 18)) / ONE18; } +/** + * Calculates the gas token (eth) denominated value of the given value with the given eth price + * @param value - The value (in 18 decimals fixed point) + * @param ethPrice - The eth price of the value's token (18 decimals fixed point number as decimal string) + * @returns The eth value in 18 decimals fixed point (ie wei) + */ +export function toEthValue(value: bigint, ethPrice: string): bigint { + return (value * parseUnits(ethPrice, 18)) / ONE18; +} + /** * Scales a given value and its decimals to 18 fixed point decimals * @param value - The value to scale to 18 diff --git a/src/router/sushi/index.test.ts b/src/router/sushi/index.test.ts index 8504c44d..a3d841b8 100644 --- a/src/router/sushi/index.test.ts +++ b/src/router/sushi/index.test.ts @@ -1121,12 +1121,11 @@ describe("test SushiRouter methods", () => { gasPrice, ); - // every probe has a route but fails the price match, so the search - // shrinks from the first probe, which is the biggest routed size - // at half of the maximum input + // every probe has a route but fails the price match, so the biggest + // routed size is the full size probed first expect(result).toMatchObject({ status: TradeSizeStatus.PriceMismatch, - size: 5n * ONE18, + size: 10n * ONE18, }); // carries the biggest routed probe quote assert(result.status === TradeSizeStatus.PriceMismatch); @@ -1215,10 +1214,32 @@ describe("test SushiRouter methods", () => { true, // absolute mode ); - // always below tolerance, so the search grows towards the maximum input + // the full size probed first is below tolerance, so it is returned right away assert(result.status === TradeSizeStatus.Found); - expect(result.size).toBeGreaterThan(9n * ONE18); - expect(result.size).toBeLessThan(10n * ONE18); + expect(result.size).toBe(10n * ONE18); + expect(Router.findBestRoute).toHaveBeenCalledTimes(1); + }); + + it("should return the full size right away when it clears the ratio", () => { + (Router.findBestRoute as Mock).mockReturnValue({ + status: "OK", + amountOutBI: 20n * ONE18, // price = 2 + }); + + const result = router.findLargestTradeSize( + makeOrderDetails(1n * ONE18), + toToken, + fromToken, + maximumInputFixed, + gasPrice, + ); + + // no bisection at all, the full size probe is the only one + assert(result.status === TradeSizeStatus.Found); + expect(result.size).toBe(10n * ONE18); + expect(result.quote.amountOut).toBe(20n * ONE18); + expect(Router.findBestRoute).toHaveBeenCalledTimes(1); + expect((Router.findBestRoute as Mock).mock.calls[0][3]).toBe(10n * ONE18); }); it("should return NoWay and never PriceMismatch in absolute mode", () => { diff --git a/src/router/sushi/index.ts b/src/router/sushi/index.ts index 94a2227f..7660e678 100644 --- a/src/router/sushi/index.ts +++ b/src/router/sushi/index.ts @@ -507,13 +507,15 @@ export class SushiRouter extends RainSolverRouterBase { } /** - * Searches for the largest possible partial trade size for rp clear, the result - * status determines the outcome: Found means the returned size clears the order - * ratio at quoted prices, PriceMismatch means routes exist but no size clears the - * order ratio and the returned size is the biggest size that had a route, NoWay - * means no route exists at any size, in absolute mode the search instead looks - * for the largest size below the price impact tolerance and the result is either - * Found or NoWay + * Searches for the largest possible trade size for rp clear, the full size is + * probed first and returned right away when it clears the gate, otherwise the + * sizes below it get bisected, the result status determines the outcome: Found + * means the returned size clears the order ratio at quoted prices, PriceMismatch + * means routes exist but no size clears the order ratio and the returned size is + * the biggest size that had a route, NoWay means no route exists at any size, in + * absolute mode the search instead looks for the largest size below the price + * impact tolerance and the result is either Found or NoWay, the result carries the + * quote of the returned size so downstream consumers dont recompute the same route * @param orderDetails - The order details * @param toToken - The token to trade to * @param fromToken - The token to trade from @@ -539,15 +541,19 @@ export class SushiRouter extends RainSolverRouterBase { 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; - for (let i = 1n; i < 26n; i++) { - const maxInput18 = scaleTo18(maximumInput, fromToken.decimals); + + // probes the route for the given amount, undefined when it has no route, + // negative output routes count as unroutable, this mirrors the NegativeOutput + // guard of findBestRoute, which the probe quotes that are plugged in + // downstream would otherwise bypass, the probe quote details are carried on + // the result for the winning size so downstream consumers dont recompute + // the same route again + const probe = (amount: bigint): SushiRouterQuote | undefined => { const route = Router.findBestRoute( pcMap, this.chainId as ChainId, fromToken, - maximumInput, + amount, toToken, gasPrice, liquidityProviders, @@ -555,21 +561,12 @@ export class SushiRouter extends RainSolverRouterBase { undefined, routeType, ); - - // negative output routes count as unroutable, this mirrors the - // NegativeOutput guard of findBestRoute, which the probe quotes - // that are plugged in downstream would otherwise bypass - if (route.status == "NoWay" || route.amountOutBI < 0n) { - maximumInput = maximumInput - initAmount / 2n ** i; - continue; - } - // probe quote details, carried on the result for the winning size - // so downstream consumers dont recompute the same route again - const probeQuote: SushiRouterQuote = { + if (route.status == "NoWay" || route.amountOutBI < 0n) return undefined; + return { type: RouterType.Sushi, status: RouteStatus.Success, price: calculatePrice18( - maximumInput, + amount, route.amountOutBI, fromToken.decimals, toToken.decimals, @@ -577,35 +574,57 @@ export class SushiRouter extends RainSolverRouterBase { route: { route, pcMap }, amountOut: route.amountOutBI, }; - + }; + // tells if the probe quote clears the gate, the price impact tolerance in + // absolute mode, otherwise the order ratio against the realized average + // execution price of the simulated swap, which already includes the route's + // price impact, same as the trade simulation gate + const clearsGate = (probeQuote: SushiRouterQuote): boolean => { if (absolute) { - if ( - typeof route.priceImpact === "undefined" || - route.priceImpact < DEFAULT_PRICE_IMPACT_TOLERANCE - ) { - result.unshift(maxInput18); - foundQuote = probeQuote; - maximumInput = maximumInput + initAmount / 2n ** i; - } else { - maximumInput = maximumInput - initAmount / 2n ** i; - } + const priceImpact = probeQuote.route.route.priceImpact; + return ( + typeof priceImpact === "undefined" || + priceImpact < DEFAULT_PRICE_IMPACT_TOLERANCE + ); + } + return probeQuote.price >= ratio; + }; + + // probe the full size first, a full size that clears the gate needs no search + const fullAmount = scaleFrom18(maximumInputFixed, fromToken.decimals); + const fullQuote = probe(fullAmount); + if (fullQuote) { + if (clearsGate(fullQuote)) { + return { status: TradeSizeStatus.Found, size: maximumInputFixed, quote: fullQuote }; + } + if (!absolute) { + biggestRoutedSize = maximumInputFixed; + biggestRoutedQuote = fullQuote; + } + } + + // bisect the sizes below the full size + const initAmount = fullAmount / 2n; + let maximumInput = initAmount; + for (let i = 1n; i < 26n; i++) { + const maxInput18 = scaleTo18(maximumInput, fromToken.decimals); + const probeQuote = probe(maximumInput); + if (!probeQuote) { + maximumInput = maximumInput - initAmount / 2n ** i; + continue; + } + // keep track of the biggest size that had a route regardless + // of whether its price clears the order ratio or not + if (!absolute && (biggestRoutedSize === undefined || maxInput18 > biggestRoutedSize)) { + biggestRoutedSize = maxInput18; + biggestRoutedQuote = probeQuote; + } + if (clearsGate(probeQuote)) { + result.unshift(maxInput18); + foundQuote = probeQuote; + maximumInput = maximumInput + initAmount / 2n ** i; } else { - // keep track of the biggest size that had a route regardless - // of whether its price clears the order ratio or not - if (biggestRoutedSize === undefined || maxInput18 > biggestRoutedSize) { - biggestRoutedSize = maxInput18; - biggestRoutedQuote = probeQuote; - } - // realized average execution price of the simulated swap, this already - // includes the route's price impact, same as the trade simulation gate, - // the probe quote price is exactly that effective price - if (probeQuote.price < ratio) { - maximumInput = maximumInput - initAmount / 2n ** i; - } else { - result.unshift(maxInput18); - foundQuote = probeQuote; - maximumInput = maximumInput + initAmount / 2n ** i; - } + maximumInput = maximumInput - initAmount / 2n ** i; } } diff --git a/src/state/index.test.ts b/src/state/index.test.ts index c26e73ec..e1fb4027 100644 --- a/src/state/index.test.ts +++ b/src/state/index.test.ts @@ -900,6 +900,172 @@ describe("Test SharedState", () => { }); }); + describe("Test getGasCostEstimate", () => { + const pair = { + orderbook: "0xob", + takeOrder: { id: "0xid" }, + sellToken: "0xs", + buyToken: "0xb", + } as any; + + it("should return undefined without any known gas cost", () => { + const state = new SharedState(config); + expect(state.getGasCostEstimate(pair)).toBeUndefined(); + }); + + it("should fall back to the avg gas cost of successful txs without a cache entry", () => { + const state = new SharedState(config); + state.gasCosts = [100n, 300n]; + expect(state.getGasCostEstimate(pair)).toBe(200n); + }); + + it("should use the pair dryrun gas cache priced at the current gas price first", () => { + config.appOptions.gasLimitMultiplier = 120; + const state = new SharedState(config); + state.gasPrice = 10n; + const key = "0xob-0xid-0xs-0xb"; + for (let i = 0; i < 5; i++) state.dryrunGasCache.recordInit(key, 1000n, 50n); + + // 1000 gas * 120% * 10 gas price + 50 l1 cost + expect(state.getGasCostEstimate(pair)).toBe(12050n); + + // the cache takes priority over the successful txs avg + state.gasCosts = [7n]; + expect(state.getGasCostEstimate(pair)).toBe(12050n); + + // other pairs have no cache entry, so they get the avg + expect(state.getGasCostEstimate({ ...pair, takeOrder: { id: "0xother" } })).toBe(7n); + + // the cache follows the current gas price + state.gasPrice = 20n; + expect(state.getGasCostEstimate(pair)).toBe(24050n); + }); + }); + + describe("Test isDustTrade", () => { + // 1 output token at 0.001 eth, so the max output is worth 1e15 wei + const pair = { + orderbook: "0xob", + takeOrder: { id: "0xid", quote: { maxOutput: 1000000000000000000n } }, + sellToken: "0xs", + buyToken: "0xb", + } as any; + const outputToEthPrice = "0.001"; + + it("should return undefined when no dust check is enabled", () => { + config.appOptions.dustGasCostMultiplier = 0; + config.appOptions.dustUsdThreshold = 0; + const state = new SharedState(config); + state.gasCosts = [10n ** 18n]; + expect(state.isDustCheckEnabled).toBe(false); + expect(state.isDustTrade(pair, outputToEthPrice, "2000")).toBeUndefined(); + }); + + it("should return undefined without the output eth price or a quoted size", () => { + config.appOptions.dustGasCostMultiplier = 1; + config.appOptions.dustUsdThreshold = 0; + const state = new SharedState(config); + state.gasCosts = [10n ** 18n]; + expect(state.isDustCheckEnabled).toBe(true); + expect(state.isDustTrade(pair, "", "2000")).toBeUndefined(); + expect(state.isDustTrade(pair, undefined, "2000")).toBeUndefined(); + expect( + state.isDustTrade({ ...pair, takeOrder: { id: "0xid" } }, outputToEthPrice, "2000"), + ).toBeUndefined(); + }); + + it("should check the max output value against the pair gas cost estimate", () => { + config.appOptions.dustGasCostMultiplier = 1; + config.appOptions.dustUsdThreshold = 0; + const state = new SharedState(config); + + // no gas cost known, cannot decide + expect(state.isDustTrade(pair, outputToEthPrice, undefined)).toBeUndefined(); + + // avg gas cost of 1 eth is above the 1e15 wei value, dust + state.gasCosts = [10n ** 18n]; + expect(state.isDustTrade(pair, outputToEthPrice, undefined)).toBe(true); + + // avg gas cost of 1e14 wei is below the value, not dust + state.gasCosts = [10n ** 14n]; + expect(state.isDustTrade(pair, outputToEthPrice, undefined)).toBe(false); + + // the multiplier scales the gas cost, 1e14 * 20 = 2e15 above the value + config.appOptions.dustGasCostMultiplier = 20; + expect(state.isDustTrade(pair, outputToEthPrice, undefined)).toBe(true); + }); + + it("should use the given size and gas cost over the defaults", () => { + config.appOptions.dustGasCostMultiplier = 1; + config.appOptions.dustUsdThreshold = 0; + const state = new SharedState(config); + state.gasCosts = [10n ** 18n]; + + // half the max output is worth 5e14 wei, the given gas cost of 1e14 is below it + expect( + state.isDustTrade(pair, outputToEthPrice, undefined, 5n * 10n ** 17n, 10n ** 14n), + ).toBe(false); + // and above it with a given gas cost of 1e15 + expect( + state.isDustTrade(pair, outputToEthPrice, undefined, 5n * 10n ** 17n, 10n ** 15n), + ).toBe(true); + }); + + it("should scale the gas cost by the multiplier with 4 decimal points precision", () => { + config.appOptions.dustUsdThreshold = 0; + const state = new SharedState(config); + // the max output is worth 1e15 wei, with a 1.2345 multiplier a gas cost + // of 8e14 scales to 9.876e14, below the value, and 8.2e14 scales to + // 1.01229e15, above it + config.appOptions.dustGasCostMultiplier = 1.2345; + expect( + state.isDustTrade(pair, outputToEthPrice, undefined, undefined, 8n * 10n ** 14n), + ).toBe(false); + expect( + state.isDustTrade(pair, outputToEthPrice, undefined, undefined, 82n * 10n ** 13n), + ).toBe(true); + // a gas cost of 0 cannot be evaluated + expect(state.isDustTrade(pair, outputToEthPrice, undefined, undefined, 0n)).toBe( + undefined, + ); + }); + + it("should rule out dust by one check even when the other cannot be evaluated", () => { + config.appOptions.dustGasCostMultiplier = 1; + config.appOptions.dustUsdThreshold = 2.5; + const state = new SharedState(config); + + // usd says not dust at 3000 usd per eth (3 usd), gas cost unknown, not dust + expect(state.isDustTrade(pair, outputToEthPrice, "3000")).toBe(false); + // gas says not dust with the 1e14 avg cost, usd price unknown, not dust + state.gasCosts = [10n ** 14n]; + expect(state.isDustTrade(pair, outputToEthPrice, undefined)).toBe(false); + // gas says dust with the 1 eth avg cost, usd price unknown, undecided + state.gasCosts = [10n ** 18n]; + expect(state.isDustTrade(pair, outputToEthPrice, undefined)).toBeUndefined(); + }); + + it("should check the usd value alone or together with the gas cost", () => { + config.appOptions.dustGasCostMultiplier = 0; + config.appOptions.dustUsdThreshold = 2.5; + const state = new SharedState(config); + state.gasCosts = [10n ** 18n]; + + // 1e15 wei at 2000 usd per eth is 2 usd, below 2.5, dust + expect(state.isDustTrade(pair, outputToEthPrice, "2000")).toBe(true); + // at 3000 usd per eth it is 3 usd, not dust + expect(state.isDustTrade(pair, outputToEthPrice, "3000")).toBe(false); + // unknown usd price, cannot decide + expect(state.isDustTrade(pair, outputToEthPrice, undefined)).toBeUndefined(); + + // both checks, gas says dust at 1 eth avg cost, usd says not at 3000, not dust + config.appOptions.dustGasCostMultiplier = 1; + expect(state.isDustTrade(pair, outputToEthPrice, "3000")).toBe(false); + // both agree at 2000 + expect(state.isDustTrade(pair, outputToEthPrice, "2000")).toBe(true); + }); + }); + describe("Test avgGasCost", () => { it("should return 0 when gasCosts array is empty", () => { const state = new SharedState(config); diff --git a/src/state/index.ts b/src/state/index.ts index 4406c72d..d98be41d 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -15,6 +15,8 @@ import { RainSolverRouter } from "../router/router"; import { SubgraphConfig } from "../subgraph/config"; import { RainSolverBaseError } from "../error/types"; import { OrderManagerConfig } from "../order/config"; +import type { Pair } from "../order/types"; +import { toEthValue, toUsdValue } from "../math"; import { RainSolverRouterError } from "../router/error"; import { ChainConfig, ChainConfigError, getChainConfig } from "./chain"; import { RpcState, rainSolverTransport, RainSolverTransportConfig } from "../rpc"; @@ -382,6 +384,89 @@ export class SharedState { return this.gasCosts.reduce((a, b) => a + b, 0n) / BigInt(this.gasCosts.length || 1); } + /** + * Returns the best known estimate of the gas cost (in wei) of a trade tx for the + * given order pair, the pair's dryrun gas cache priced at the current gas price + * when it holds enough samples, as it tracks the pair's own route at the gas + * price of the moment, otherwise the average of the recent successful txs when + * there is any, undefined when neither is available + * @param pair - The order pair + */ + getGasCostEstimate(pair: Pair): bigint | undefined { + const cached = this.dryrunGasCache.get(DryrunGasCache.key(pair)); + if (cached) { + const gasLimit = (cached.gas * BigInt(this.appOptions.gasLimitMultiplier)) / 100n; + return gasLimit * this.gasPrice + cached.l1Cost; + } + if (this.gasCosts.length) return this.avgGasCost; + return undefined; + } + + /** Whether any of the dust checks is enabled by the app options */ + get isDustCheckEnabled(): boolean { + return this.appOptions.dustGasCostMultiplier > 0 || this.appOptions.dustUsdThreshold > 0; + } + + /** + * Determines if a trade of the given order pair is dust by the dust checks enabled + * in the app options, the gas cost check, where the trade value cannot cover the gas + * cost of the trade tx scaled by the multiplier, and the usd check, where the trade + * value is below the usd threshold, when both are enabled a trade is dust only if + * both say so, when only one is enabled that one decides on its own, a dust trade can + * never be profitable since the bounty of a trade is always a share of the market + * value of the output tokens it takes, so the trade value is the trade size of the + * pair's output token priced by the output token eth price, the gas cost comes from + * the given one when known, otherwise from the best known estimate for the pair + * @param pair - The order pair + * @param outputToEthPrice - The output token eth price as decimal string, empty or undefined when unknown + * @param gasTokenUsdPrice - The gas token usd price as decimal string, undefined when unknown + * @param size - The trade size (18 fixed point decimals), defaults to the pair's quoted max output + * @param gasCost - The known gas cost of the trade tx (wei), defaults to the pair's gas cost estimate + * @returns True when the trade is dust, false when it is not, undefined when no dust check is + * enabled or an enabled check cannot be evaluated for lack of its inputs while no other + * enabled check has ruled the trade out as not dust + */ + isDustTrade( + pair: Pair, + outputToEthPrice: string | undefined, + gasTokenUsdPrice: string | undefined, + size = pair.takeOrder.quote?.maxOutput, + gasCost?: bigint, + ): boolean | undefined { + if (!this.isDustCheckEnabled || !outputToEthPrice || size === undefined) { + return undefined; + } + const tradeValue = toEthValue(size, outputToEthPrice); + const verdicts: (boolean | undefined)[] = []; + + // gas cost check, undecided without a known gas cost + const { dustGasCostMultiplier, dustUsdThreshold } = this.appOptions; + if (dustGasCostMultiplier > 0) { + const cost = gasCost ?? this.getGasCostEstimate(pair); + // keep 4 decimal points of the multiplier precision + const multiplier = BigInt(Math.round(dustGasCostMultiplier * 10_000)); + verdicts.push( + cost === undefined || cost <= 0n + ? undefined + : tradeValue < (cost * multiplier) / 10_000n, + ); + } + + // usd value check, undecided without a known gas token usd price + if (dustUsdThreshold > 0) { + verdicts.push( + gasTokenUsdPrice + ? toUsdValue(tradeValue, gasTokenUsdPrice) < + parseUnits(dustUsdThreshold.toFixed(18), 18) + : undefined, + ); + } + + if (verdicts.includes(false)) return false; + if (verdicts.includes(undefined)) return undefined; + return true; + } + /** * Watches gas price during runtime by reading it periodically * @param interval - Interval to update gas price in milliseconds, default is 20 seconds