diff --git a/config.env.yaml b/config.env.yaml index ca8cf707..a73d1bfb 100644 --- a/config.env.yaml +++ b/config.env.yaml @@ -48,6 +48,8 @@ dryrunGasCache: $DRYRUN_GAS_CACHE dryrunGasCacheResetTime: $DRYRUN_GAS_CACHE_RESET_TIME dustGasCostMultiplier: $DUST_GAS_COST_MULTIPLIER dustUsdThreshold: $DUST_USD_THRESHOLD +snapTx: $SNAP_TX +snapTxThresholdUsd: $SNAP_TX_THRESHOLD_USD dustOrderCheck: $DUST_ORDER_CHECK strictMaxOwnerProfileCheck: $STRICT_MAX_OWNER_PROFILE_CHECK strictMaxOwnerProfilePartialTradeSizeCheck: $STRICT_MAX_OWNER_PROFILE_PARTIAL_TRADE_SIZE_CHECK diff --git a/config.example.yaml b/config.example.yaml index ed80b722..e5dc0b3b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -164,6 +164,16 @@ dustGasCostMultiplier: 1 # both to count as dust, 0 disables the usd dust check, default is 0 dustUsdThreshold: 0 +# Submits a router mode trade at the found trade size right away with the cached dryrun gas limit and no dryrun +# at all, so no backoff sizes get simulated either, only when the order pair already has a dryrun gas cache, +# the estimated profit covers the min expected bounty of the cached gas cost and it exceeds snapTxThresholdUsd, +# the tx carries no ensure bounty task, the same as the normal submitted tx, requires dryrunGasCache, +# default is false +snapTx: false + +# USD value the estimated profit of a trade must exceed for it to be submitted as a snap tx, default is 0 +snapTxThresholdUsd: 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 diff --git a/src/cli/commands/sweep.ts b/src/cli/commands/sweep.ts index 72d46b87..3a5138ba 100644 --- a/src/cli/commands/sweep.ts +++ b/src/cli/commands/sweep.ts @@ -116,6 +116,8 @@ export async function sweepFunds(opts: SweepOptions) { dryrunGasCacheResetTime: 60, dustGasCostMultiplier: 1, dustUsdThreshold: 0, + snapTx: false, + snapTxThresholdUsd: 0n, dustOrderCheck: true, strictMaxOwnerProfileCheck: false, strictMaxOwnerProfilePartialTradeSizeCheck: false, diff --git a/src/config/yaml.test.ts b/src/config/yaml.test.ts index 25568747..b40efc6a 100644 --- a/src/config/yaml.test.ts +++ b/src/config/yaml.test.ts @@ -49,6 +49,8 @@ dryrunGasCache: true dryrunGasCacheResetTime: 30 dustGasCostMultiplier: 1.5 dustUsdThreshold: 0.25 +snapTx: true +snapTxThresholdUsd: 1.5 dustOrderCheck: false strictMaxOwnerProfileCheck: true strictMaxOwnerProfilePartialTradeSizeCheck: true @@ -190,6 +192,8 @@ orderbookTradeTypes: dryrunGasCacheResetTime: 30, dustGasCostMultiplier: 1.5, dustUsdThreshold: 0.25, + snapTx: true, + snapTxThresholdUsd: 1_500000000000000000n, dustOrderCheck: false, strictMaxOwnerProfileCheck: true, strictMaxOwnerProfilePartialTradeSizeCheck: true, @@ -415,6 +419,8 @@ orderbookTradeTypes: 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.snapTx, false); // should be default false + assert.equal(result.snapTxThresholdUsd, 0n); // 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 diff --git a/src/config/yaml.ts b/src/config/yaml.ts index 8f65f402..6d268a02 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -145,6 +145,10 @@ export type AppOptions = { 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; + /** Submits a router mode trade at the found trade size right away with the cached dryrun gas and no dryrun at all (no backoff sizes either), when the order pair has a dryrun gas cache and the estimated profit covers the min expected bounty and exceeds snapTxThresholdUsd, requires dryrunGasCache, default is false */ + snapTx: boolean; + /** USD value the estimated profit of a trade must exceed for it to be submitted as a snap tx, kept as 18 point decimals, default is 0 */ + snapTxThresholdUsd: bigint; /** 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 */ @@ -492,6 +496,26 @@ export namespace AppOptions { "invalid dustUsdThreshold value, must be a number greater than or equal to 0", ), ), + snapTx: Validator.resolveBool( + input.snapTx, + "expected a boolean value for snapTx", + false, + ), + snapTxThresholdUsd: parseUnits( + Validator.resolveNumericValue( + input.snapTxThresholdUsd, + FLOAT_PATTERN, + "invalid snapTxThresholdUsd value, must be a number greater than or equal to 0", + "0", + true, + (value) => + assert( + Number(value) >= 0, + "invalid snapTxThresholdUsd value, must be a number greater than or equal to 0", + ), + ), + 18, + ), dustOrderCheck: Validator.resolveBool( input.dustOrderCheck, "expected a boolean value for dustOrderCheck", diff --git a/src/core/modes/router/index.test.ts b/src/core/modes/router/index.test.ts index 86d82f55..16dc9a47 100644 --- a/src/core/modes/router/index.test.ts +++ b/src/core/modes/router/index.test.ts @@ -712,6 +712,133 @@ describe("Test findBestRouterTrade", () => { }); }); + describe("snap tx", () => { + let trySnapTradeSpy: any; + const mockSnapSuccess = Result.ok({ + type: TradeType.RouteProcessor, + spanAttributes: { foundOpp: true, snapTx: true }, + estimatedProfit: 75n, + oppBlockNumber: 123, + }); + const mockSnapNotEligible = Result.err({ + type: TradeType.RouteProcessor, + reason: SimulationHaltReason.SnapTxNotEligible, + spanAttributes: { snapTxSkipped: "no cached dryrun gas for the order pair" }, + }); + + beforeEach(() => { + mockRainSolver.appOptions.snapTx = true; + setFoundSize(500n); + trySnapTradeSpy = vi.spyOn(RouterTradeSimulator.prototype, "trySnapTrade"); + }); + + it("should submit the found size as a snap tx and skip the batch when it qualifies", async () => { + (trySnapTradeSpy as Mock).mockResolvedValue(mockSnapSuccess); + const result = await run(); + + assert(result.isOk()); + expect(result.value.estimatedProfit).toBe(75n); + expect(result.value.spanAttributes).toEqual({ foundOpp: true, snapTx: true }); + expect(trySnapTradeSpy).toHaveBeenCalledTimes(1); + expect(trySimulateTradeSpy).not.toHaveBeenCalled(); + // the snap sim is locked to the found route at the found size + expect(simulatorWithArgsSpy).toHaveBeenCalledTimes(1); + expect(simulatorWithArgsSpy).toHaveBeenCalledWith({ + type: TradeType.Router, + solver: mockRainSolver, + orderDetails, + fromToken, + toToken, + signer, + maximumInputFixed: 500n, + ethPrice, + isPartial: true, + blockNumber: 123n, + excludeDexes: undefined, + ...lockedArgs, + }); + }); + + it("should not flag the full size as partial", async () => { + setFoundSize(1000n); + (trySnapTradeSpy as Mock).mockResolvedValue(mockSnapSuccess); + const result = await run(); + + assert(result.isOk()); + expect((simulatorWithArgsSpy as Mock).mock.calls[0][0]).toEqual( + expect.objectContaining({ maximumInputFixed: 1000n, isPartial: false }), + ); + }); + + it("should run the batch with the snap attributes when the found size does not qualify", async () => { + (trySnapTradeSpy as Mock).mockResolvedValue(mockSnapNotEligible); + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockSuccess) // 500n + .mockResolvedValue(mockViolationError); // halved sizes + const result = await run(); + + assert(result.isOk()); + expect(result.value.estimatedProfit).toBe(25n); + expect(trySnapTradeSpy).toHaveBeenCalledTimes(1); + expect(simulatedSizes()).toEqual([500n, 500n, 375n, 250n, 125n, 62n, 31n]); + expect(extendObjectWithHeader).toHaveBeenCalledWith( + expect.any(Object), + { snapTxSkipped: "no cached dryrun gas for the order pair" }, + "snap", + ); + }); + + it("should carry the snap attributes into the batch failure", async () => { + (trySnapTradeSpy as Mock).mockResolvedValue(mockSnapNotEligible); + (trySimulateTradeSpy as Mock).mockResolvedValue(mockViolationError); + const result = await run(); + + assert(result.isErr()); + expect(result.error.spanAttributes["snap.snapTxSkipped"]).toBe( + "no cached dryrun gas for the order pair", + ); + expect(result.error.spanAttributes["step1.error"]).toContain( + "MinimalOutputBalanceViolation", + ); + }); + + it("should not try a snap tx when disabled", async () => { + mockRainSolver.appOptions.snapTx = false; + (trySimulateTradeSpy as Mock) + .mockResolvedValueOnce(mockSuccess) // 500n + .mockResolvedValue(mockViolationError); // halved sizes + const result = await run(); + + assert(result.isOk()); + expect(trySnapTradeSpy).not.toHaveBeenCalled(); + expect(simulatedSizes()).toEqual([500n, 375n, 250n, 125n, 62n, 31n]); + }); + + it("should try a snap tx ahead of the dust check and still bail out on a dust found size", async () => { + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(true); + (trySnapTradeSpy as Mock).mockResolvedValue(mockSnapNotEligible); + const result = await run(); + + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.DustTradeSize); + expect(trySnapTradeSpy).toHaveBeenCalledTimes(1); + expect(trySimulateTradeSpy).not.toHaveBeenCalled(); + expect(result.error.spanAttributes["snap.snapTxSkipped"]).toBe( + "no cached dryrun gas for the order pair", + ); + }); + + it("should submit a dust found size as a snap tx when it qualifies", async () => { + (mockRainSolver.state.isDustTrade as Mock).mockReturnValue(true); + (trySnapTradeSpy as Mock).mockResolvedValue(mockSnapSuccess); + const result = await run(); + + assert(result.isOk()); + expect(result.value.estimatedProfit).toBe(75n); + expect(mockRainSolver.state.isDustTrade).not.toHaveBeenCalled(); + }); + }); + it("should return early if ethPrice is unknown", async () => { const result: SimulationResult = await findBestRouterTrade.call( mockRainSolver, diff --git a/src/core/modes/router/index.ts b/src/core/modes/router/index.ts index 2ddefc76..d18f7896 100644 --- a/src/core/modes/router/index.ts +++ b/src/core/modes/router/index.ts @@ -202,6 +202,28 @@ export async function tryFindBestRouterTrade( }; } const { size: tradeSize, quote } = tradeSizeResult; + // snap tx, the found size gets submitted right away with the cached dryrun + // gas and no dryrun when it qualifies (see snapTx config), so the batch of + // sizes is skipped altogether, a not qualifying size runs the batch as usual + if (this.appOptions.snapTx) { + const snapResult = await snapTradeSize.call( + this, + orderDetails, + signer, + ethPrice, + toToken, + fromToken, + blockNumber, + tradeSize, + quote, + excludeDexes, + ); + if (snapResult.isOk()) { + return { result: snapResult, quote }; + } + extendObjectWithHeader(spanAttributes, snapResult.error.spanAttributes, "snap"); + } + const isFullSize = tradeSize >= maximumInput; const shouldStrictSimulate = this.appOptions.strictMaxOwnerProfilePartialTradeSizeCheck && @@ -289,6 +311,52 @@ export function getHalvedTradeSizes( return sizes; } +/** + * Tries to build the trade tx of the given trade size as a snap tx, that is with the + * cached dryrun gas and no dryrun, locked to the given quote's route, see + * TradeSimulatorBase.trySnapTrade for the checks a snap tx must pass + * @param this - RainSolver instance + * @param orderDetails - The details of the order to be processed + * @param signer - The signer to be used for the trade + * @param ethPrice - The current ETH price + * @param toToken - The token to trade to + * @param fromToken - The token to trade from + * @param blockNumber - The current block number + * @param tradeSize - The trade size to snap + * @param quote - The sushi quote to lock the route of + * @param excludeDexes - (optional) Liquidity providers (dexes) to exclude from route finding + */ +export async function snapTradeSize( + this: RainSolver, + orderDetails: Pair, + signer: RainSolverSigner, + ethPrice: string, + toToken: Token, + fromToken: Token, + blockNumber: bigint, + tradeSize: bigint, + quote: SushiRouterQuote, + excludeDexes?: Set, +): Promise { + const maximumInput = orderDetails.takeOrder.quote!.maxOutput; + return RouterTradeSimulator.withArgs({ + type: TradeType.Router, + solver: this, + orderDetails, + fromToken, + toToken, + signer, + maximumInputFixed: tradeSize, + ethPrice, + isPartial: tradeSize < maximumInput, + blockNumber, + excludeDexes, + sushiQuote: quote, + lockRoute: true, + skipPriceMatchCheck: true, + }).trySnapTrade(); +} + /** * Simulates the given trade sizes (in descending order) validated against onchain * dryrun and returns the biggest size that passes, the sims all launch concurrently diff --git a/src/core/modes/simulator.test.ts b/src/core/modes/simulator.test.ts index 0b80d557..dc925880 100644 --- a/src/core/modes/simulator.test.ts +++ b/src/core/modes/simulator.test.ts @@ -962,7 +962,8 @@ describe("Test TradeSimulatorBase", () => { l1Cost: 15000n * 50000000000n, totalGasCost: 21000n * 1000000000000000000n, }, - estimatedGasCost: 21000n * 1000000000000000000n + 15000n * 50000000000n, + // a gas cost of 0.01 gas token, the 0.1 profit affords the full 2 multiplier + estimatedGasCost: ONE18 / 100n, spanAttributes: {}, }; (dryrun as Mock) @@ -1046,6 +1047,276 @@ describe("Test TradeSimulatorBase", () => { expect(result.value.spanAttributes["gasPriceBoosted"]).toBeUndefined(); }); }); + + describe("Test trySnapTrade method", () => { + const key = "0xob-0xid-0xs-0xb"; + let gasCache: any; + // the expected values of the cached gas of 20000 with the 120 gas limit multiplier + const gasLimit = (20000n * 120n) / 100n; + const gasCost = () => gasLimit * mockSolver.state.gasPrice + 500n; + const minExpected = () => { + const headroom = BigInt( + ( + Number(mockSolver.appOptions.gasCoveragePercentage) * + mockSolver.appOptions.headroom + ).toFixed(), + ); + return (gasCost() * headroom) / 10000n; + }; + + beforeEach(() => { + gasCache = { get: vi.fn().mockReturnValue({ gas: 20000n, l1Cost: 500n }) }; + (mockSolver.state as any).dryrunGasCache = gasCache; + (mockSolver.state as any).gasTokenUsdPrice = "2000"; + (mockSolver.appOptions as any).dryrunGasCache = true; + (mockSolver.appOptions as any).gasLimitMultiplier = 120; + (mockSolver.appOptions as any).snapTxThresholdUsd = 0n; + tradeArgs.orderDetails = { + orderbook: "0xOB", + takeOrder: { id: "0xID" }, + sellToken: "0xS", + buyToken: "0xB", + } as any; + mockSimulator = new MockTradeSimulator(tradeArgs); + (mockSimulator.prepareTradeParams as Mock).mockResolvedValue(Result.ok(preparedParams)); + (mockSimulator.setTransactionData as Mock).mockResolvedValue(Result.ok(void 0)); + // a profit well above the min expected bounty + (mockSimulator.estimateProfit as Mock).mockReturnValue(gasCost() * 10n); + }); + + it("should return error if prepareTradeParams fails", async () => { + const error = { + type: TradeType.RouteProcessor, + spanAttributes: { error: "prepare failed" }, + reason: SimulationHaltReason.NoRoute, + }; + (mockSimulator.prepareTradeParams as Mock).mockResolvedValue(Result.err(error)); + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error).toEqual(error); + expect(gasCache.get).toHaveBeenCalledWith(key); + expect(mockSimulator.setTransactionData).not.toHaveBeenCalled(); + }); + + it("should build the tx with the cached gas and the min expected bounty without any dryrun", async () => { + const result = await mockSimulator.trySnapTrade(); + assert(result.isOk()); + expect(gasCache.get).toHaveBeenCalledWith(key); + expect(dryrun).not.toHaveBeenCalled(); + expect(mockSimulator.estimateProfit).toHaveBeenCalledWith(preparedParams.price); + // the tx carries no ensure bounty task, the same as the normal submitted tx + expect(mockSimulator.setTransactionData).toHaveBeenCalledTimes(1); + expect(mockSimulator.setTransactionData).toHaveBeenCalledWith({ + ...preparedParams, + minimumExpected: minExpected(), + noTask: true, + }); + expect(result.value.rawtx.gas).toBe(gasLimit); + expect(result.value.estimatedGasCost).toBe(gasCost()); + expect(result.value.estimatedProfit).toBe(gasCost() * 10n); + expect(result.value.type).toBe(TradeType.RouteProcessor); + expect(result.value.oppBlockNumber).toBe(123); + expect(result.value.spanAttributes["snapTx"]).toBe(true); + expect(result.value.spanAttributes["foundOpp"]).toBe(true); + expect(result.value.spanAttributes["snapTxSkipped"]).toBeUndefined(); + expect(result.value.spanAttributes["snapEstimatedProfitUsd"]).toBe( + formatUnits(gasCost() * 10n * 2000n, 18), + ); + expect(extendObjectWithHeader).toHaveBeenCalledWith( + mockSimulator.spanAttributes, + { + cached: true, + gasLimit: gasLimit.toString(), + totalCost: gasCost().toString(), + gasPrice: mockSolver.state.gasPrice.toString(), + totalCostUsd: formatUnits(gasCost() * 2000n, 18), + minBountyExpected: minExpected().toString(), + minBountyExpectedUsd: formatUnits(minExpected() * 2000n, 18), + l1Cost: "500", + }, + "gasEst.snap", + ); + }); + + it("should not be eligible when the dryrun gas cache is disabled", async () => { + (mockSolver.appOptions as any).dryrunGasCache = false; + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.SnapTxNotEligible); + expect(result.error.type).toBe(TradeType.Router); + expect(result.error.spanAttributes["snapTxSkipped"]).toBe( + "dryrun gas cache is disabled or gas coverage is 0", + ); + expect(mockSimulator.prepareTradeParams).not.toHaveBeenCalled(); + expect(gasCache.get).not.toHaveBeenCalled(); + expect(mockSimulator.setTransactionData).not.toHaveBeenCalled(); + }); + + it("should not be eligible when gas coverage is 0", async () => { + (mockSolver.appOptions as any).gasCoveragePercentage = "0"; + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.SnapTxNotEligible); + expect(gasCache.get).not.toHaveBeenCalled(); + expect(mockSimulator.prepareTradeParams).not.toHaveBeenCalled(); + }); + + it("should not be eligible for a non sushi route processor trade", async () => { + (mockSimulator.prepareTradeParams as Mock).mockResolvedValue( + Result.ok({ ...preparedParams, type: TradeType.Balancer }), + ); + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.SnapTxNotEligible); + expect(result.error.type).toBe(TradeType.Balancer); + expect(result.error.spanAttributes["snapTxSkipped"]).toBe( + "not a sushi route processor trade", + ); + expect(gasCache.get).toHaveBeenCalledWith(key); + expect(mockSimulator.setTransactionData).not.toHaveBeenCalled(); + }); + + it("should not be eligible when the order pair has no cached gas", async () => { + gasCache.get.mockReturnValue(undefined); + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.SnapTxNotEligible); + expect(result.error.spanAttributes["snapTxSkipped"]).toBe( + "no cached dryrun gas for the order pair", + ); + expect(mockSimulator.setTransactionData).not.toHaveBeenCalled(); + }); + + it("should not be eligible when the gas token usd price is unknown", async () => { + (mockSolver.state as any).gasTokenUsdPrice = undefined; + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.SnapTxNotEligible); + expect(result.error.spanAttributes["snapTxSkipped"]).toBe( + "unknown gas token usd price", + ); + expect(mockSimulator.setTransactionData).not.toHaveBeenCalled(); + }); + + it("should not be eligible when the estimated profit is below the min expected bounty", async () => { + (mockSimulator.estimateProfit as Mock).mockReturnValue(minExpected() - 1n); + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.SnapTxNotEligible); + expect(result.error.spanAttributes["snapTxSkipped"]).toBe( + "estimated profit below the min expected bounty", + ); + expect(result.error.spanAttributes["snapEstimatedProfit"]).toBe( + formatUnits(minExpected() - 1n, 18), + ); + expect(mockSimulator.setTransactionData).not.toHaveBeenCalled(); + }); + + it("should be eligible when the estimated profit equals the min expected bounty", async () => { + (mockSimulator.estimateProfit as Mock).mockReturnValue(minExpected()); + + const result = await mockSimulator.trySnapTrade(); + assert(result.isOk()); + expect(result.value.estimatedProfit).toBe(minExpected()); + }); + + it("should not be eligible when the estimated profit usd is at or below the threshold", async () => { + // a tiny gas price keeps the min expected bounty far below the profit + (mockSolver.state as any).gasPrice = 1n; + // the profit is 10 gas token, so 20000 usd at the 2000 usd price + (mockSimulator.estimateProfit as Mock).mockReturnValue(10n * ONE18); + (mockSolver.appOptions as any).snapTxThresholdUsd = 20000n * ONE18; + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error.reason).toBe(SimulationHaltReason.SnapTxNotEligible); + expect(result.error.spanAttributes["snapTxSkipped"]).toBe( + "estimated profit usd below snapTxThresholdUsd", + ); + expect(mockSimulator.setTransactionData).not.toHaveBeenCalled(); + }); + + it("should be eligible when the estimated profit usd exceeds the threshold", async () => { + (mockSolver.state as any).gasPrice = 1n; + (mockSimulator.estimateProfit as Mock).mockReturnValue(10n * ONE18); + (mockSolver.appOptions as any).snapTxThresholdUsd = 20000n * ONE18 - 1n; + + const result = await mockSimulator.trySnapTrade(); + assert(result.isOk()); + expect(result.value.rawtx.gas).toBe(gasLimit); + }); + + it("should return error if setTransactionData fails", async () => { + const error = { + type: TradeType.RouteProcessor, + spanAttributes: { error: "set tx data failed" }, + reason: SimulationHaltReason.FailedToGetTaskBytecode, + }; + (mockSimulator.setTransactionData as Mock).mockResolvedValue(Result.err(error)); + + const result = await mockSimulator.trySnapTrade(); + assert(result.isErr()); + expect(result.error).toEqual(error); + }); + + it("should boost the tx gas price when the estimated profit usd exceeds the gas boost threshold", async () => { + (mockSolver.appOptions as any).gasBoostMultiplier = 2.5; + (mockSolver.appOptions as any).gasBoostUsdThreshold = 1n; + preparedParams.rawtx.gasPrice = 1000n; + + // the profit of 10 times the gas cost affords the full multiplier + const result = await mockSimulator.trySnapTrade(); + assert(result.isOk()); + expect(result.value.rawtx.gasPrice).toBe(2500n); + expect(result.value.spanAttributes["gasPriceBoosted"]).toBe(true); + expect(result.value.spanAttributes["gasBoostMultiplierApplied"]).toBe(2.5); + }); + + it("should settle on the last boost step whose gas cost fits in the estimated profit", async () => { + (mockSolver.appOptions as any).gasBoostMultiplier = 3; + (mockSolver.appOptions as any).gasBoostUsdThreshold = 1n; + preparedParams.rawtx.gasPrice = 1000n; + // the steps are 1.5, 2, 2.5 and 3, a profit of twice the gas cost + // affords the 2 step, the 2.5 step surpasses it + (mockSimulator.estimateProfit as Mock).mockReturnValue(gasCost() * 2n); + + const result = await mockSimulator.trySnapTrade(); + assert(result.isOk()); + expect(result.value.rawtx.gasPrice).toBe(2000n); + expect(result.value.spanAttributes["gasPriceBoosted"]).toBe(true); + expect(result.value.spanAttributes["gasBoostMultiplierApplied"]).toBe(2); + }); + + it("should not boost the tx gas price when even the first boost step surpasses the estimated profit", async () => { + (mockSolver.appOptions as any).gasBoostMultiplier = 2; + (mockSolver.appOptions as any).gasBoostUsdThreshold = 1n; + preparedParams.rawtx.gasPrice = 1000n; + // the first step is 1.25, a profit of 1.1 times the gas cost cannot afford it + (mockSimulator.estimateProfit as Mock).mockReturnValue((gasCost() * 110n) / 100n); + + const result = await mockSimulator.trySnapTrade(); + assert(result.isOk()); + expect(result.value.rawtx.gasPrice).toBe(1000n); + expect(result.value.spanAttributes["gasPriceBoosted"]).toBeUndefined(); + expect(result.value.spanAttributes["gasBoostMultiplierApplied"]).toBeUndefined(); + }); + + it("should not boost the tx gas price when the gas boost config fields are unset", async () => { + preparedParams.rawtx.gasPrice = 1000n; + + const result = await mockSimulator.trySnapTrade(); + assert(result.isOk()); + expect(result.value.rawtx.gasPrice).toBe(1000n); + expect(result.value.spanAttributes["gasPriceBoosted"]).toBeUndefined(); + }); + }); }); describe("Test SimulationHaltReason namespace", () => { diff --git a/src/core/modes/simulator.ts b/src/core/modes/simulator.ts index f7f5153e..278cb413 100644 --- a/src/core/modes/simulator.ts +++ b/src/core/modes/simulator.ts @@ -1,6 +1,7 @@ import { dryrun } from "./dryrun"; -import { formatUnits } from "viem"; import { toUsdValue } from "../../math"; +import { formatUnits } from "viem"; +import { RawTransaction } from "../../common"; import { Attributes } from "@opentelemetry/api"; import { EstimateGasCostResult } from "../../signer"; import { DryrunGasCache } from "../../state/dryrunGasCache"; @@ -20,6 +21,9 @@ import { SimulateRaindexRouterTradeArgs, } from "./raindex/simulation"; +/** The number of steps the gas price boost climbs from no boost to the configured multiplier */ +export const GAS_BOOST_STEPS = 4n; + /** Specifies the reason that simulation failed */ export enum SimulationHaltReason { NoOpportunity, @@ -29,6 +33,8 @@ export enum SimulationHaltReason { UndefinedTradeDestinationAddress, MinimalOutputBalanceViolation, DustTradeSize, + /** The trade does not qualify for a snap tx, the caller falls back to the normal simulation */ + SnapTxNotEligible, } export namespace SimulationHaltReason { /** @@ -329,29 +335,8 @@ export abstract class TradeSimulatorBase { ); } - // boost the tx gas price if the trade is highly profitable, that is when the - // estimated profit USD value exceeds the configured USD threshold, this - // increases the chance of the tx to land onchain faster as the trade can - // afford it, this has no effect if the config fields are not set or the - // gas token USD price is unknown const estimatedProfit = this.estimateProfit(prepareParamsResult.value.price)!; - const { gasBoostMultiplier, gasBoostUsdThreshold } = this.tradeArgs.solver.appOptions; - const exceedsUsdThreshold = - gasBoostUsdThreshold !== undefined && - !!gasTokenUsdPrice && - toUsdValue(estimatedProfit, gasTokenUsdPrice) > gasBoostUsdThreshold; - if ( - gasBoostMultiplier !== undefined && - typeof prepareParamsResult.value.rawtx.gasPrice === "bigint" && - exceedsUsdThreshold - ) { - // scale the multiplier by 100 to apply it with 2 decimal points precision - prepareParamsResult.value.rawtx.gasPrice = - (prepareParamsResult.value.rawtx.gasPrice * - BigInt(Math.round(gasBoostMultiplier * 100))) / - 100n; - this.spanAttributes["gasPriceBoosted"] = true; - } + this.maybeBoostGasPrice(prepareParamsResult.value.rawtx, estimatedProfit, estimatedGasCost); this.spanAttributes["foundOpp"] = true; this.spanAttributes["duration"] = performance.now() - this.startTime; @@ -364,4 +349,170 @@ export abstract class TradeSimulatorBase { estimatedProfit, }); } + + /** + * Tries to build the trade tx for submission without any dryrun, a snap tx, the + * gas limit comes from the dryrun gas cache of the order pair instead, so the tx + * is ready right after the trade params are prepared, the trade must be a sushi + * route processor trade (the only ones with a gas cache) with a cached gas, the + * estimated profit must cover the min expected bounty of the cached gas cost with + * headroom, and its usd value must exceed the snapTxThresholdUsd config, the tx + * carries no ensure bounty task, the same as the final tx of the dryrun path, as + * the min expected bounty check above stands in for the dryrun, fails with the + * SnapTxNotEligible reason when any of the checks fails, so the caller can fall + * back to the normal simulation + */ + async trySnapTrade(): Promise { + const { solver } = this.tradeArgs; + const { gasTokenUsdPrice, gasPrice } = solver.state; + let type: TradeType = this.tradeArgs.type; + const notEligible = (reason: string): SimulationResult => { + this.spanAttributes["snapTxSkipped"] = reason; + this.spanAttributes["duration"] = performance.now() - this.startTime; + return Result.err({ + type, + spanAttributes: this.spanAttributes, + reason: SimulationHaltReason.SnapTxNotEligible, + }); + }; + + // the cheap checks come before preparing the trade params, so a not + // eligible trade costs no route work, the gas cache only exists for + // sushi route processor trades with gas coverage + if (!solver.appOptions.dryrunGasCache || solver.appOptions.gasCoveragePercentage === "0") { + return notEligible("dryrun gas cache is disabled or gas coverage is 0"); + } + const cachedGas = solver.state.dryrunGasCache.get( + DryrunGasCache.key(this.tradeArgs.orderDetails), + ); + if (!cachedGas) { + return notEligible("no cached dryrun gas for the order pair"); + } + if (!gasTokenUsdPrice) { + return notEligible("unknown gas token usd price"); + } + + const prepareParamsResult = await this.prepareTradeParams(); + if (prepareParamsResult.isErr()) { + return Result.err(prepareParamsResult.error); + } + type = prepareParamsResult.value.type; + if (type !== TradeType.RouteProcessor) { + return notEligible("not a sushi route processor trade"); + } + + // the gas cost from the cached gas, the same way the cached init dryrun stage does + const gasLimit = (cachedGas.gas * BigInt(solver.appOptions.gasLimitMultiplier)) / 100n; + const estimatedGasCost = gasLimit * gasPrice + cachedGas.l1Cost; + const headroom = BigInt( + ( + Number(solver.appOptions.gasCoveragePercentage) * solver.appOptions.headroom + ).toFixed(), + ); + const minimumExpected = (estimatedGasCost * headroom) / 10000n; + const estimatedProfit = this.estimateProfit(prepareParamsResult.value.price)!; + const estimatedProfitUsd = toUsdValue(estimatedProfit, gasTokenUsdPrice); + extendObjectWithHeader( + this.spanAttributes, + { + cached: true, + gasLimit: gasLimit.toString(), + totalCost: estimatedGasCost.toString(), + gasPrice: gasPrice.toString(), + totalCostUsd: formatUnits(toUsdValue(estimatedGasCost, gasTokenUsdPrice), 18), + minBountyExpected: minimumExpected.toString(), + minBountyExpectedUsd: formatUnits( + toUsdValue(minimumExpected, gasTokenUsdPrice), + 18, + ), + ...(solver.state.chainConfig.isSpecialL2 + ? { l1Cost: cachedGas.l1Cost.toString() } + : {}), + }, + "gasEst.snap", + ); + this.spanAttributes["snapEstimatedProfit"] = formatUnits(estimatedProfit, 18); + this.spanAttributes["snapEstimatedProfitUsd"] = formatUnits(estimatedProfitUsd, 18); + + // the two checks, the bounty must cover the min expected (gas cost with + // coverage and headroom) and its usd value must exceed the configured threshold + if (estimatedProfit < minimumExpected) { + return notEligible("estimated profit below the min expected bounty"); + } + if (estimatedProfitUsd <= solver.appOptions.snapTxThresholdUsd) { + return notEligible("estimated profit usd below snapTxThresholdUsd"); + } + + // build the tx with the ensure bounty task and the cached gas limit + // build the tx with the cached gas limit and no ensure bounty task, the min + // expected bounty check above already validated the profitability offchain + const setTransactionDataResult = await this.setTransactionData({ + ...prepareParamsResult.value, + minimumExpected, + noTask: true, + }); + if (setTransactionDataResult.isErr()) { + return Result.err(setTransactionDataResult.error); + } + prepareParamsResult.value.rawtx.gas = gasLimit; + this.maybeBoostGasPrice(prepareParamsResult.value.rawtx, estimatedProfit, estimatedGasCost); + + this.spanAttributes["snapTx"] = true; + this.spanAttributes["foundOpp"] = true; + this.spanAttributes["duration"] = performance.now() - this.startTime; + return Result.ok({ + estimatedGasCost, + type, + spanAttributes: this.spanAttributes, + rawtx: prepareParamsResult.value.rawtx, + oppBlockNumber: Number(this.tradeArgs.blockNumber), + estimatedProfit, + }); + } + + /** + * Boosts the tx gas price if the trade is highly profitable, that is when the + * estimated profit USD value exceeds the configured USD threshold, this increases + * the chance of the tx to land onchain faster as the trade can afford it, the + * boost steps up from no boost to the configured multiplier in a few steps and + * settles on the last step whose boosted gas cost still fits in the estimated + * profit, so the boost never makes the trade unprofitable, this has no effect if + * the config fields are not set or the gas token USD price is unknown + * @param rawtx - The trade tx to boost the gas price of + * @param estimatedProfit - The estimated profit of the trade + * @param estimatedGasCost - The estimated gas cost of the trade at the current gas price + */ + maybeBoostGasPrice(rawtx: RawTransaction, estimatedProfit: bigint, estimatedGasCost: bigint) { + const { gasBoostMultiplier, gasBoostUsdThreshold } = this.tradeArgs.solver.appOptions; + const gasTokenUsdPrice = this.tradeArgs.solver.state.gasTokenUsdPrice; + const exceedsUsdThreshold = + gasBoostUsdThreshold !== undefined && + !!gasTokenUsdPrice && + toUsdValue(estimatedProfit, gasTokenUsdPrice) > gasBoostUsdThreshold; + if ( + gasBoostMultiplier === undefined || + typeof rawtx.gasPrice !== "bigint" || + !exceedsUsdThreshold + ) { + return; + } + + // the multipliers are scaled by 100 to apply them with 2 decimal points precision, + // step from no boost (100) up to the configured multiplier and stop at the first + // step whose boosted gas cost surpasses the estimated profit, the last step before + // it is the one applied, no step fitting means no boost at all + const base = 100n; + const target = BigInt(Math.round(gasBoostMultiplier * 100)); + let applied = base; + for (let i = 1n; i <= GAS_BOOST_STEPS; i++) { + const multiplier = base + ((target - base) * i) / GAS_BOOST_STEPS; + if ((estimatedGasCost * multiplier) / 100n > estimatedProfit) break; + applied = multiplier; + } + if (applied > base) { + rawtx.gasPrice = (rawtx.gasPrice * applied) / 100n; + this.spanAttributes["gasPriceBoosted"] = true; + this.spanAttributes["gasBoostMultiplierApplied"] = Number(applied) / 100; + } + } } diff --git a/src/gas/index.test.ts b/src/gas/index.test.ts index 3cb32708..6b9b99c7 100644 --- a/src/gas/index.test.ts +++ b/src/gas/index.test.ts @@ -140,6 +140,64 @@ describe("Test GasManager", () => { gasManager.unwatchGasPrice(); }); + it("should step the multiplier down once the deadline passes even without a tx mine", async () => { + (getGasPrice as any).mockResolvedValue({ + gasPrice: Result.ok(5555n), + l1GasPrice: Result.ok(8888n), + }); + gasManager.gasPriceMultiplier = 120; + gasManager.deadline = Date.now() - 1000; // the increase has run its step time + gasManager.watchGasPrice(10); + await sleep(50); + + // the tick steps the multiplier down on its own, no mine event needed, + // sets the deadline of the next step, and the gas price gets fetched + // with the stepped down multiplier from then on + expect(gasManager.gasPriceMultiplier).toBe(117); + expect(gasManager.deadline).toBeGreaterThan( + Date.now() + config.gasIncreaseStepTime - 1000, + ); + expect(getGasPrice).toHaveBeenLastCalledWith(config.client, config.chainConfig, 117); + + gasManager.unwatchGasPrice(); + }); + + it("should keep stepping down on ticks as each deadline passes until back at base", async () => { + (getGasPrice as any).mockResolvedValue({ + gasPrice: Result.ok(5555n), + l1GasPrice: Result.ok(8888n), + }); + // a step time shorter than the tick, so every tick finds the deadline passed + gasManager = new GasManager({ ...config, gasIncreaseStepTime: 1 }); + gasManager.gasPriceMultiplier = 113; + gasManager.deadline = Date.now() - 1000; + gasManager.watchGasPrice(10); + await sleep(100); + + expect(gasManager.gasPriceMultiplier).toBe(107); + expect(gasManager.deadline).toBeUndefined(); + + gasManager.unwatchGasPrice(); + }); + + it("should keep the increased multiplier on tick while the deadline is in the future", async () => { + (getGasPrice as any).mockResolvedValue({ + gasPrice: Result.ok(5555n), + l1GasPrice: Result.ok(8888n), + }); + gasManager.gasPriceMultiplier = 120; + gasManager.deadline = Date.now() + 100_000; + const deadline = gasManager.deadline; + gasManager.watchGasPrice(10); + await sleep(50); + + expect(gasManager.gasPriceMultiplier).toBe(120); + expect(gasManager.deadline).toBe(deadline); + expect(getGasPrice).toHaveBeenLastCalledWith(config.client, config.chainConfig, 120); + + gasManager.unwatchGasPrice(); + }); + it("should not start a second watcher when already watching", () => { // the active interval handle while watching gasManager.watchGasPrice(); @@ -166,7 +224,7 @@ describe("Test GasManager", () => { // class field defaults expect(manager.gasIncreasePointsPerStep).toBe(10); - expect(manager.gasIncreaseStepTime).toBe(15 * 60 * 1000); // 900_000 ms + expect(manager.gasIncreaseStepTime).toBe(6 * 60 * 1000); // 900_000 ms // maxGasPriceMultiplier defaults to base + 1000 when not provided expect(manager.maxGasPriceMultiplier).toBe(1100); @@ -195,6 +253,41 @@ describe("Test GasManager", () => { }); }); + describe("Test stepDownGasPriceMultiplierIfDue method", () => { + it("should do nothing without a deadline", () => { + gasManager.gasPriceMultiplier = 120; + gasManager.stepDownGasPriceMultiplierIfDue(); + expect(gasManager.gasPriceMultiplier).toBe(120); + expect(gasManager.deadline).toBeUndefined(); + }); + + it("should do nothing while the deadline is in the future", () => { + gasManager.gasPriceMultiplier = 120; + gasManager.deadline = Date.now() + 100_000; + const deadline = gasManager.deadline; + gasManager.stepDownGasPriceMultiplierIfDue(); + expect(gasManager.gasPriceMultiplier).toBe(120); + expect(gasManager.deadline).toBe(deadline); + }); + + it("should step down once and set the next deadline once the deadline has passed", () => { + gasManager.gasPriceMultiplier = 120; + gasManager.deadline = Date.now() - 1; + const before = Date.now(); + gasManager.stepDownGasPriceMultiplierIfDue(); + expect(gasManager.gasPriceMultiplier).toBe(117); + expect(gasManager.deadline).toBeGreaterThanOrEqual(before + config.gasIncreaseStepTime); + }); + + it("should land on base and clear the deadline on the last step", () => { + gasManager.gasPriceMultiplier = 108; // less than a step above base + gasManager.deadline = Date.now() - 1; + gasManager.stepDownGasPriceMultiplierIfDue(); + expect(gasManager.gasPriceMultiplier).toBe(107); + expect(gasManager.deadline).toBeUndefined(); + }); + }); + describe("Test onTransactionMine boundary and arithmetic", () => { it("should increase the multiplier when mine time equals the threshold exactly", () => { // a mine time equal to txTimeThreshold takes the increase branch diff --git a/src/gas/index.ts b/src/gas/index.ts index 8bf3b8d1..7d46a739 100644 --- a/src/gas/index.ts +++ b/src/gas/index.ts @@ -35,7 +35,7 @@ export type TxMineRecord = { * Features: * - Tracks and updates the current gas price and L1 gas price (for L2 chains). * - Dynamically increases the gas price multiplier if transactions take longer than a threshold to mine for certain period. - * - Resets the gas price multiplier to its base value after a configurable period. + * - Steps the gas price multiplier down towards its base value, one step per configurable period, whether a transaction mines or not. * - Periodically fetches and updates gas prices from the blockchain. * - Allows functionalities for starting and stopping gas price watcher. * @@ -65,7 +65,7 @@ export class GasManager { /** The points to increase the gas price multiplier at each step */ readonly gasIncreasePointsPerStep: number = 10; // default increase by 10 points /** The time to stay in increased the gas price multiplier before reseting to base */ - readonly gasIncreaseStepTime: number = 15 * 60 * 1000; // default 15 minutes in milliseconds + readonly gasIncreaseStepTime: number = 6 * 60 * 1000; // default 6 minutes in milliseconds /** The threshold for transaction time before considering it as a trigger for gas price multiplierincrease */ readonly txTimeThreshold: number; // default 15 seconds threshold @@ -161,12 +161,36 @@ export class GasManager { } /** - * Watches gas price during runtime by reading it periodically + * Steps the gas price multiplier down by one step once the deadline of the + * current step has passed, and sets the deadline of the next step, so the + * multiplier decays one step per step time until it is back at base, where + * the deadline gets cleared, the gas price watcher calls this on every tick, + * so the decay goes on without mined transactions, the mine events step the + * multiplier down on their own after the deadline, without a next deadline + */ + stepDownGasPriceMultiplierIfDue() { + const now = Date.now(); + if (this.deadline === undefined || now < this.deadline) return; + this.gasPriceMultiplier = Math.max( + this.baseGasPriceMultiplier, + this.gasPriceMultiplier - this.gasIncreasePointsPerStep, + ); + if (this.gasPriceMultiplier <= this.baseGasPriceMultiplier) { + this.deadline = undefined; + } else { + this.deadline = now + this.gasIncreaseStepTime; + } + } + + /** + * Watches gas price during runtime by reading it periodically, each tick also + * steps an increased gas price multiplier down once its deadline has passed * @param interval - Interval to update gas price in milliseconds, default is 20 seconds */ watchGasPrice(interval = 20_000) { if (this.isWatchingGasPrice) return; this.gasPriceWatcher = setInterval(async () => { + this.stepDownGasPriceMultiplierIfDue(); const { gasPrice, l1GasPrice } = await getGasPrice( this.client, this.chainConfig,