diff --git a/src/core/modes/router/index.test.ts b/src/core/modes/router/index.test.ts index 839bb5c7..5e1c236f 100644 --- a/src/core/modes/router/index.test.ts +++ b/src/core/modes/router/index.test.ts @@ -415,6 +415,153 @@ describe("Test findBestRouterTrade", () => { ); }); + 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 + (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue(1000n); + + 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(25n); + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(4); + expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ maximumInputFixed: 500n, isPartial: true }), + ); + expect(simulatorWithArgsSpy).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ maximumInputFixed: 250n, isPartial: true }), + ); + }); + + 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(1024000n); + + const result: SimulationResult = await findBestRouterTrade.call( + mockRainSolver, + orderDetails, + signer, + ethPrice, + toToken, + fromToken, + blockNumber, + ); + + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.MinimalOutputBalanceViolation); + expect(result.error.noneNodeError).toBe("order ratio issue"); + // 1 full + 1 partial + 5 fallbacks + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(7); + expect(simulatorWithArgsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ maximumInputFixed: 32000n, 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["partialFallback5.error"]).toContain( + "MinimalOutputBalanceViolation", + ); + expect(result.error.spanAttributes["partialFallback6.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 + (mockRainSolver.state.router.findLargestTradeSize as Mock).mockReturnValue(1000n); + + const result: SimulationResult = await findBestRouterTrade.call( + mockRainSolver, + orderDetails, + signer, + ethPrice, + toToken, + fromToken, + blockNumber, + ); + + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.MinimalOutputBalanceViolation); + // 1 full + 1 partial + 1 fallback, stopped early + expect(trySimulateTradeSpy).toHaveBeenCalledTimes(3); + expect(result.error.spanAttributes["partialFallback1.error"]).toBe("some other revert"); + expect(result.error.spanAttributes["partialFallback2.error"]).toBeUndefined(); + }); + it("should retry with the failing route dexes excluded when full trade dryrun fails", async () => { const sushiQuote = { route: { diff --git a/src/core/modes/router/index.ts b/src/core/modes/router/index.ts index 8c6382f9..116b51af 100644 --- a/src/core/modes/router/index.ts +++ b/src/core/modes/router/index.ts @@ -227,6 +227,54 @@ export async function tryFindBestRouterTrade( partialTradeSizeSimResult.error.spanAttributes, "partial", ); + + // 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 by halving the trade size at each step validated against onchain + // dryrun and accept the first size that passes, the backoff stops early if a step fails + // with any other error + let reason = partialTradeSizeSimResult.error.reason; + if ( + SimulationHaltReason.isMinimalOutputBalanceViolation( + partialTradeSizeSimResult.error.spanAttributes["error"], + ) + ) { + reason = SimulationHaltReason.MinimalOutputBalanceViolation; + let fallbackTradeSize = partialTradeSize; + for (let i = 1; i <= 5; i++) { + fallbackTradeSize /= 2n; + if (fallbackTradeSize <= 0n) break; + const partialFallbackSimulator = RouterTradeSimulator.withArgs({ + type: TradeType.Router, + solver: this, + orderDetails, + fromToken, + toToken, + signer, + maximumInputFixed: fallbackTradeSize, + ethPrice, + isPartial: true, + blockNumber, + excludeDexes, + }); + const partialFallbackSimResult = await partialFallbackSimulator.trySimulateTrade(); + if (partialFallbackSimResult.isOk()) { + return { result: partialFallbackSimResult, quote }; + } + extendObjectWithHeader( + spanAttributes, + partialFallbackSimResult.error.spanAttributes, + `partialFallback${i}`, + ); + if ( + !SimulationHaltReason.isMinimalOutputBalanceViolation( + partialFallbackSimResult.error.spanAttributes["error"], + ) + ) { + break; + } + } + } return { result: Result.err({ type: fullTradeSizeSimResult.error.type, @@ -234,6 +282,7 @@ export async function tryFindBestRouterTrade( noneNodeError: fullTradeSizeSimResult.error.noneNodeError ?? partialTradeSizeSimResult.error.noneNodeError, + reason, }), quote, }; diff --git a/src/core/modes/simulator.test.ts b/src/core/modes/simulator.test.ts index 99f0841d..fd0c3faa 100644 --- a/src/core/modes/simulator.test.ts +++ b/src/core/modes/simulator.test.ts @@ -620,3 +620,18 @@ describe("Test TradeSimulatorBase", () => { }); }); }); + +describe("Test SimulationHaltReason namespace", () => { + it("should detect MinimalOutputBalanceViolation in the given text", () => { + expect( + SimulationHaltReason.isMinimalOutputBalanceViolation( + 'execution reverted: MinimalOutputBalanceViolation(0xtoken, 123)"', + ), + ).toBe(true); + expect(SimulationHaltReason.isMinimalOutputBalanceViolation("some other error")).toBe( + false, + ); + expect(SimulationHaltReason.isMinimalOutputBalanceViolation(undefined)).toBe(false); + expect(SimulationHaltReason.isMinimalOutputBalanceViolation(123)).toBe(false); + }); +}); diff --git a/src/core/modes/simulator.ts b/src/core/modes/simulator.ts index e1e9fa80..e662ad7b 100644 --- a/src/core/modes/simulator.ts +++ b/src/core/modes/simulator.ts @@ -23,6 +23,17 @@ export enum SimulationHaltReason { OrderRatioGreaterThanMarketPrice, FailedToGetTaskBytecode, UndefinedTradeDestinationAddress, + MinimalOutputBalanceViolation, +} +export namespace SimulationHaltReason { + /** + * Returns true if the given input contains the sushi RouteProcessor + * contract MinimalOutputBalanceViolation error selector name + * @param text - The text to search in + */ + export function isMinimalOutputBalanceViolation(text: unknown): boolean { + return typeof text === "string" && text.includes("MinimalOutputBalanceViolation"); + } } export type SimulateTradeArgs =