A minimal, readable cyclic (triangular) arbitrageur on Uniswap V2, written in Rust. It is a reference searcher workload: the smallest thing that still does what a real searcher does — read pool state every block, search for the profit-maximising trade size across a set of cycles, and submit the winner.
It is deliberately not a production system. There is no proprietary strategy here, no private orderflow, no exotic optimiser — just the textbook algorithm, laid out to be read and reproduced. MIT-licensed.
This repo was written to give researchers a realistic, self-contained searcher binary to run and study — for example, work on TEE / TDX side-channel security, where you want a representative MEV workload running inside an enclave to see how much of its behaviour leaks to the host. The properties that make it useful for that:
- Compiled, single hot loop. The interesting compute is one bounded,
integer-only optimiser (
src/arb.rs) called once per cycle per block. Its shape is fixed; only the data changes. Real searchers look like this. - Deterministic and data-dependent. Same reserves in → same control flow and
same number of iterations out. The
optimizer_iterationsknob lets you widen or narrow the loop to taste. - No hidden dependencies. It talks plain JSON-RPC to any node and, by default, sends nothing. You can run it read-only against mainnet forever.
Each block the bot does, for every configured cycle:
- Read reserves.
getReserves()on each Uniswap V2 pair in the cycle, then orient them soreserve_in/reserve_outline up with the trade direction. - Search for the optimal input. Profit as a function of trade size,
f(x) = cycleOutput(x) − x, is concave for a chain of constant-product pools. A golden-section search over[1, reserve_in₀ / 2]converges to the maximum inoptimizer_iterationssteps. (src/arb.rs) - Price each candidate by walking the input through every hop with the
exact on-chain
getAmountOutformula (0.30% fee, integer floor division). (src/amm.rs) - Threshold. Keep the best cycle; if its profit clears
min_profit_wei, act on it. - Act. Dry-run (default): log it. Live: submit
executeArb(...)to the on-chainCyclicArbExecutor, which performs the whole cycle atomically and reverts unless the closing balance grew by at leastminProfit.
A production searcher swaps step 2 for something sharper (Brent, Nelder-Mead, or the closed form for two hops), watches the mempool instead of polling, prices many more venues, and submits via a builder/relay bundle rather than a public transaction. None of that changes the shape above.
Needs a recent stable Rust toolchain.
cargo build --release
cargo test # unit tests for the AMM math and the optimiserThe release profile keeps LTO off, uses a single codegen unit, and emits line tables — the binary stays easy to profile and disassemble.
cp config.example.toml config.toml
# edit rpc_url if you like; the default is a public mainnet endpoint
cargo run --release -- --config config.tomlYou'll see one scan per block: the reserves fetched, the best cycle, its optimal
size and profit, and — for anything above the threshold — a DRY-RUN: would submit arb line. --once scans a single block and exits. Set RUST_LOG=debug
for more detail.
The bundled cycle (WETH → USDC → DAI → WETH) rarely clears a real edge on mainnet — that's expected. To see the submit path fire, lower
min_profit_wei, or point the config at pools you control on a testnet.
For an end-to-end test on a testnet or your own node:
- Deploy the executor and note its address:
forge build forge create contracts/CyclicArbExecutor.sol:CyclicArbExecutor \ --rpc-url <rpc> --private-key <key> - Fund the executor with some of the start token (e.g. WETH).
- In
config.tomlsetprivate_key,executor, and repointrpc_url/chain_id/ the pool addresses at your environment. - Run with
--live:cargo run --release -- --config config.toml --live
The bot signs and sends executeArb straight to the configured RPC's mempool.
The transaction is atomic and self-checking: it reverts unless the cycle nets at
least min_profit_wei, so a stale or unprofitable opportunity costs only gas.
If --live is set but private_key/executor are missing, the bot logs a
warning and stays in dry-run.
src/
main.rs block loop: fetch reserves, optimise each cycle, log or submit
arb.rs golden-section search for the optimal trade size (the workload)
amm.rs Uniswap V2 getAmountOut / cycle pricing (the hot kernel)
config.rs TOML config + cycle validation
contracts/
CyclicArbExecutor.sol atomic multi-hop executor (reference, unaudited)
config.example.toml WETH -> USDC -> DAI -> WETH on mainnet
Educational reference code. Unaudited. Do not point the live path at real funds you are not prepared to lose. No warranty (MIT).