MediumBuilding a multi-protocol arbitrage bot | by 7suyash7 | Medium
Sitemap

Building a multi-protocol arbitrage bot

7 min readOct 28, 2025

--

Introduction

I have always wanted to build an arbitrage bot ever since I got into crypto. The original goal of this project wasn’t to build a standalone bot, but to create “ArbRs”; a low-level, high-performance library that abstracts all the complex EVM Math around various DEXes, allowing anyone to implement and test their own arbitrage strategies quickly.

However, safety and liability concerns quickly caught up to me as I kept writing more and more code. The idea of a public library handling real funds, especially in the context of an arbitrage bot, something which people think just prints money, is kinda immense. I paused the public library vision, but the technical mission still remained. I’m still thinking of making a library, but without an execution engine, so people can’t really send any transactions through it, which would still allow them to test strategies. If there’s enough interest around this idea, I will do it!

The current library is built to withstand the full complexity of the modern EVM market:

Scan and Synchronise: Monitor and update thousands of pools concurrently, from a specific block up to the latest one.

Multi-Protocol Math: Find profitable cycles across Uniswap v2, v3, Curve and Balancer simultaneously.

Adaptive Discovery: Automatically integrate and save new pools to the DB as they’re deployed.

I’ll be adding Uniswap v4 next followed by Aerodrome and Camelot, along with a few other DEXes. Also, integrating any v2 or v3 fork is extremely easy thanks to the LiquidityPool trait that is the core abstraction layer built to model every DEX.

Modelling the Multi-Protocol Chaos

Since I wanted to cover multiple different protocols, the core challenge was the variety of liquidity pool mechanics. To analyse the market concurrently, the engine requires a precise, unified mathematical framework.

This Unified Abstraction lives inside the LiquidityPool trait. It’s the contract that standardises the external API calls as well as handles the internal invariant math across different protocols. This structure ensures that the central arbitration logic remains simple and reusable regardless of the underlying DEX.

The Curve Implementation: The most complex task until now has been implementing the Curve StableSwap formulas in pure Rust (src/curve/math.rs). The main challenge here was reliably replicating the subtle engineering decisions made in the Curve Vyper contracts to handle various pool types:

Strategy Dispatch & Hydration: Before any math runs, the system dynamically determines the pool’s specific Swap Strategy (Tricrypto, Lending, Metapool, etc.) by fetching the initial parameters. This ensures we apply the correct math to the specific pool type, for instance, only querying external price oracles for designated pools.

Variant Solver: The core of Curve math relies on Newton’s method for solving the invariant D. However, Curve uses different calculation variants (Legacy, Group 0–4) for derivatives depending on the pool’s age and structure (see src/curve/pool_overrides.rs). My implementation ensures that the solver logic dynamically selects the exact corresponding derivative function (calc_dp_alpha, calc_dp_beta, etc.)

Data Integrity & Concurrency: This is achieved by isolating CPU and I/O bound tasks, maintaining state consistency and implementing tiered caching.

Concurrency Separation: Using Tokio’s primitives to structure the data access. So, I/O bound tasks (like fetching snapshots) run concurrently, while the CPU-intensive Curve calculations run separately inside a spawn_blocking task, preventing network latency from stalling our solvers.

State Consistency: The ArbitrageEngine explicitly checks that all snapshots used for a given calculation were derived from the same Block Number. This looks like a small check, but it guarantees atomicity and ensures any profitable result is based on a deterministic, unified view of the market.

Tiered Caching: Critical Token data and other things required for off-chain calculations are persisted to a local SQLite database, minimising expensive RPC calls and cutting down initialisation time while increasing calculation speed.

Using Graph Theory to find profitable arbitrages

Finding a 2-pool cycle is trivial and already hogged by multiple existing arbitrage bots. To try and find profitable arbs, we need to think of the market as a complex, tangled graph made up of thousands of pools. The core engineering problem here is building a system that can map out this entire market efficiently to find all unique, profitable up to N-hops without a combinatorial explosion. The solution is a multi-stage graph traversal and optimisation pipeline.

  1. The Data Structure: An in-memory market graph

The first step, handled in src/arbitrage/finder.rs, is to map the entire DEX market (the pools we have scanned and saved in our DB). This is modelled as an Adjacency List for maximum lookup speed:

type AdjacencyList<P> = HashMap<Arc<Token<P>>, Vec<PoolNeighbor<P>>>;

The Key is a Token — Arc<Token<P>> (e.g., WETH).

The Value is a Vector of PoolNeighbor structs, where each neighbour contains the Arc<LiquidityPool<P>> (the “edge”) and the Arc<Token<P>> it connects to (the “destination node”).

This structure allows us to instantly get all possible next hops from any token in O(1) time.

2. The Algorithm: Leveraging BFS to detect cycles.

Now that we have a graph built that represents all of the pools and paths between each of these pools, we can use a generalised BFS to discover all cycles, as it explores all path lengths simultaneously.

The BFS is driven by a queue (VecDequeue) that holds a custom PathInSearch struct.

struct PathInSearch<P> {
pub pools: Vec<Arc<LiquidityPool<P>>>,
pub tokens: Vec<Arc<Token<P>>>,
pub current_token: Arc<Token<P>>,
}

This struct is the state machine for the search. The algorithm’s core loop executes three critical checks:

a. Depth Check (Pruning): if current_hop >= max_hops { continue; } This immediately culls any path that is too long, preventing the search from exploring 6-hop or 7-hop paths and wasting precious CPU time.

Get 7suyash7’s stories in your inbox

Join Medium for free to get updates from this writer.

b. Cycle Closure (Profit): if neighbor.token == start.token This is the money shot. This means that the algorithm has found a path that successfully navigates back to the origin token (e.g., WETH). This path is now a candidate for optimisation.

c. Path Pruning (Efficiency): if next_token.address() == previous_token.address() { continue; } . This check is vital. It prevents the search from exploring simple, useless reversals like A -> B -> A within a longer path, which could otherwise pollute the queue.

3. The GOTCHA!: Canonical Deduplication

A BFS will find the same cycle multiple times from different starting points (e.g., A -> B -> C -> A and B -> C -> A -> B). This is useless and computationally wasteful.

To solve this, we use a canonical deduplication strategy. When a cycle is found, we pass its list of pool addresses to get_canonical_cycle_path . This helper function normalises the path by sorting it and finding the “lowest” representation, regardless of direction and giving it an ID.

This canonical ID is then stored in a HashSet. If the ID already exists, we discard the path. This ensures that every unique cycle is processed exactly once, cutting down the optimisation workload by orders of magnitude.

This BFS -> Pruning -> Deduplication pipeline is what allows us to efficiently sift through millions of combinations and output thousands of unique, viable arbitrage paths every block.

The path from Gross to Net Profit

Finding a path is useless if the calculation is wrong. Trying to chase ‘phantom profits’ that disappear after transaction costs is a no-go. The engine is designed to solve this by modelling Net Profit, not Gross Profit.

  1. Flashloan Cost Modelling: The Gas & Fee Pipeline.

The engine’s final filter is a comprehensive cost model that subtracts all execution fees from the gross profit before optimisation. This ensures we only spend CPU time on paths that are actually profitable.

Flashloan Fee: We calculate the standard 0.09% fee (FLASHLOAN_FEE_BPS) required to borrow.

Gas Cost: The engine does the following:

a. Fetches the live gas_price from the provider.

b. Calculates the total ETH cost using a worst-case as fallback (~700K gas).

c. Fetches the live (WETH / profit_token) price from the most liquid AMM pool it knows.

d. Performs a final, precise U256 calculation to convert the ETH gas cost into the profit token’s denomination.

The gas_cost_in_profit_token is a fixed cost subtracted from all profit calculations for that path, ensuring our profit target is always net-positive.

2. Liquidity Depth-Aware Scoring (Max Capacity)

The peak profit is often a tiny, useless trade. The real goal is to find the largest possible trade that remains profitable.

The engine does this with a Liquidity Depth-Aware Scoring model:

  1. First, we find the theoretical peak profit (using Golden-Section Search).
  2. Then, we run a specialised Binary Search that finds the largest possible input amount that still yields a Net Profit greater than our minimum profit threshold (0.005 WETH).
  3. This Max Capacity Input (not the peak profit input) becomes our new optimal_input for the path.

The engine then ranks all opportunities by the Net Profit generated at this Max Capacity, ensuring the bot always prioritises the most robust, high-volume trades over small, high-slippage ones.

3. The Final Payload: The SwapAction Vector.

The final output is a complete, actionable payload (well, not really, since I have not added any execution capabilities to the engine), but it is ready for an execution contract!

The ArbitrageSolution struct contains a Vector Vec<SwapAction> , which is the canonical, ordered sequence of steps for the trade. Each SwapAction defines the exact parameters for that hop:

pub struct SwapAction<P> {
pub pool_address: Address,
pub token_in: Arc<Token<P>>,
pub token_out: Arc<Token<P>>,
pub amount_in: U256,
pub min_amount_out: U256,
}

This vector is the final, ABI-ready payload needed by a flashloan router to atomically execute the entire multi-hop arbitrage.

So, that brings us back to the start. The original goal was a high-performance library, but I paused the “public” part. The current version is a cool proof-of-concept. It successfully abstracts all the complex math, from Curve’s iterative variants to Balancer’s weighted pools, into a single, clean, LiquidityPool trait. It has a BFS graph solver that efficiently finds complex N-hop paths, a cost model that calculates real-world Net Profits, and a payload generator. With all the insane DEX math, etc. abstracted, it is effectively a complete, headless solver.

I’m still thinking about releasing it as a library, but without an execution engine, so people can’t send transactions through it. That would still allow developers to test strategies without the financial risk. If you’re interested in that, let me know. I’m also planning to continue adding more DEXes and also more interesting arbitrage strategies, mainly just for fun and practice.

Overall, this was a massive deep dive into Rust, concurrency, and EVM math. Check out the repo to see how it all fits together!

--

--