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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config.env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/config/yaml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -190,6 +192,8 @@ orderbookTradeTypes:
dryrunGasCacheResetTime: 30,
dustGasCostMultiplier: 1.5,
dustUsdThreshold: 0.25,
snapTx: true,
snapTxThresholdUsd: 1_500000000000000000n,
dustOrderCheck: false,
strictMaxOwnerProfileCheck: true,
strictMaxOwnerProfilePartialTradeSizeCheck: true,
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/config/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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",
Expand Down
127 changes: 127 additions & 0 deletions src/core/modes/router/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions src/core/modes/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -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<LiquidityProviders>,
): Promise<SimulationResult> {
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
Expand Down
Loading
Loading