DEX Aggregator
Building a DEX routing system with pool state, graph-based route discovery, split allocation, and atomic on-chain execution
- Role -
- Independent Project
- Date -
- Ongoing
- Stack -
- Rust, Solidity, Ethereum, DeFi, DEX, AMM, Routing, Alloy, Foundry, Tokio, Blockchain
I am building this project to understand the engineering behind a DEX aggregator rather than treating routing as a simple “find the pool with the best price” problem.
The system has two main parts. A Rust routing engine discovers candidate swap paths, synchronizes pool reserves from the blockchain, simulates competing allocations against mutable pool state, and turns the selected plan into ABI-encoded execution steps. A Solidity execution layer then validates the targets and executes the complete plan in one transaction.
The most interesting part of the project is the interaction between route selection and changing liquidity. Two apparently independent routes can share a downstream pool, so consuming liquidity on one route changes the price available to the other. The current implementation models that shared state explicitly and uses it during split allocation.
What I Wanted to Build
The basic problem is:
Given:
input token
output token
input amount
Find:
one or more useful swap routes
an allocation across those routes
an execution order
Such that:
simulated output is high
shared pool state is respected
the resulting plan can be executed atomically on-chain
A single-path router can quote a route against the current reserves and stop there. A split router has a harder problem because each executed swap changes the reserves that later swaps depend on.
This project is structured around that problem.
Architecture
The current architecture is split between an off-chain Rust engine and a Solidity execution layer.
Blockchain / RPC
|
read pool state
|
v
+---------------------+
| Rust Routing Engine |
+---------------------+
|
+--------------+--------------+
| |
v v
Token / pool graph Pool synchronization
| |
+--------------+--------------+
v
Candidate route discovery
|
v
Route dependency graph
|
v
State-aware split allocation
+ simulation
|
v
Ordered execution plan
|
v
ABI calldata builder
|
v
+-------------------------+
| Solidity QuoteAggregator|
+-------------------------+
|
approved pools
|
v
Atomic multi-hop swaps
|
v
Final output
The Rust side is responsible for routing intelligence. The Solidity side is deliberately generic: it receives encoded execution steps and performs them against approved pool contracts.
Rust Module Structure
aggregator/src/
├── cli.rs
├── constants.rs
├── contracts.rs
├── execute.rs
├── graph.rs
├── lib.rs
├── main.rs
├── quote.rs
├── scoring.rs
├── simulation.rs
├── state.rs
└── types.rs
The main responsibilities are:
| Module | Responsibility |
|---|---|
cli.rs |
CLI arguments, RPC configuration and environment address helpers |
constants.rs |
Router gas constants |
contracts.rs |
Alloy-generated Solidity bindings |
types.rs |
Pools, graph edges, routes and execution-plan types |
state.rs |
Read and synchronize pool reserves from the chain |
graph.rs |
Token graph construction, bounded route discovery and route dependencies |
quote.rs |
AMM quote calculations |
scoring.rs |
Route score calculation using output value and gas cost |
simulation.rs |
Mutable pool-state simulation and split allocation |
execute.rs |
Execution-step creation, ABI encoding and transaction submission |
main.rs |
End-to-end orchestration |
1. Load Pool State From the Chain
The aggregator starts from a set of configured AMM pool addresses.
For each pool, the Rust service reads:
pool address
|
+--> tokenA()
+--> tokenB()
+--> getReserves()
The resulting data is represented locally as:
pub struct Pool {
pub address: Address,
pub token_a: Address,
pub token_b: Address,
pub reserve_a: U256,
pub reserve_b: U256,
}
Route edges then store reserves from the perspective of the direction in which the swap is being considered.
For example, a pool containing A/B becomes two graph edges:
A -> B
B -> A
The same physical pool therefore supports both swap directions while retaining the correct input-side and output-side reserves.
2. Build a Token Graph
Once the pool data is available, the router constructs a token graph where tokens are nodes and pools are directed swap edges.
For example:
AMM1
A ---------> B
| |
AMM3| |AMM4
| |
v v
C <-----------
The current local topology contains:
AMM1: A / B
AMM2: A / B
AMM3: A / C
AMM4: B / C
This intentionally creates multiple ways to reach the same destination.
3. Discover Candidate Routes With Bounded DFS
Candidate paths are discovered using depth-first search with a visited-token set and a hop limit.
The current router allows up to three hops.
For the test topology, that produces routes such as:
Route 0:
A -> B -> C
AMM1 AMM4
Route 1:
A -> B -> C
AMM2 AMM4
Route 2:
A -> C
AMM3
The important distinction is that Route 0 and Route 1 have the same token path but use different liquidity for the first hop.
They are therefore different routes from the allocator’s perspective.
The route search also carries the current amount through the path, so later hops can be quoted using the output of the previous hop.
4. Quote Each Route Using the AMM Invariant
The current test AMM uses a constant-product style formula with a 0.3% swap fee.
The quote calculation is:
amountInWithFee = amountIn × 997
amountOut =
(amountInWithFee × reserveOut)
/
(reserveIn × 1000 + amountInWithFee)
A multi-hop quote is evaluated one hop at a time:
A -> B
|
| output becomes next input
v
B -> C
|
v
final C output
The important part is that the output of one hop is not just a display value. It becomes the exact simulated input for the next hop.
5. Synchronize Route Reserves With the Current Chain State
Routes are first discovered from the configured pool topology, but the aggregator does not rely on the reserves that were present when the graph was created.
Before allocation, it refreshes the relevant pool state from the blockchain:
candidate routes
|
v
read getReserves()
|
v
rebuild directional edge reserves
|
v
state synchronized with chain
This gives the simulator a concrete starting state for the current transaction attempt.
6. Model Shared Liquidity Explicitly
This is the core part of the project.
Consider:
Route 0: A -> B via AMM1 -> B -> C via AMM4
Route 1: A -> B via AMM2 -> B -> C via AMM4
Both routes use AMM4 for the final hop.
A naive split allocator might quote Route 0 and Route 1 independently against the same original B/C reserves:
original AMM4 state
|
+--> quote Route 0
|
+--> quote Route 1
That is not what happens during execution. Once Route 0 consumes liquidity from AMM4, the reserves seen by Route 1 have changed.
This project therefore keeps a mutable simulated state and updates every route’s view of a shared pool after each simulated swap.
7. Simulate Candidate Allocations Against Mutable State
The allocator processes the input amount in fixed-size chunks.
For every chunk, it evaluates each candidate route from the same current state.
Conceptually:
Current state S0
|
+---- candidate Route 0 ----> S0'
|
+---- candidate Route 1 ----> S0''
|
+---- candidate Route 2 ----> S0'''
|
v
choose best candidate
|
v
commit winning state S1
Each candidate starts from a clone of the current state.
That matters because a candidate route represents a hypothetical choice. Testing Route 0 must not permanently modify the state before Route 1 is evaluated.
Only the winning candidate state is committed for the next chunk.
For a simulated swap:
reserveIn += amountIn
reserveOut -= amountOut
The reverse directional edge for the same physical pool is updated as well.
So if one route changes a pool from:
A -> B
the corresponding:
B -> A
view sees the same underlying reserve change.
8. Greedy Split Allocation
The current allocator is intentionally simple and explicit rather than pretending to be a globally optimal optimizer.
The input is divided into chunks:
amountIn
|
+--> chunk 1
+--> chunk 2
+--> chunk 3
+--> ...
For each chunk, every route is simulated and the route producing the best incremental output is selected.
The result contains both total allocation and execution order.
For example:
Allocations
-----------
Route 0: 30
Route 1: 50
Route 2: 20
Execution plan
--------------
Route 1 / 10
Route 0 / 10
Route 1 / 10
Route 2 / 10
...
The order is important because the first swap changes the state used to evaluate later swaps.
The current implementation therefore treats allocation and execution ordering as related problems rather than producing a split and then arbitrarily executing it.
The tradeoff is that the current chunk-based greedy algorithm does not guarantee a globally optimal allocation across all routes.
9. Route Scoring and Gas Model
The router also calculates a simple route score based on expected output value minus an estimated gas cost.
Conceptually:
output value in USD
-
gas estimate × gas price × native-token price
=
route score
The current implementation uses coarse constants for router and hop gas costs and fixed example prices in the local test setup.
The score is useful as part of the routing model, but the current split allocator selects candidates by simulated incremental output rather than using the score as its complete optimization objective.
This is one of the areas intended for further development.
10. Turn the Plan Into ABI-Encoded Execution Steps
After allocation, the Rust engine replays the chosen execution order against the simulated state and constructs Solidity execution objects.
The execution type is:
struct Execution {
address target;
address tokenIn;
uint256 amountIn;
bytes data;
}
For each hop, Rust constructs the AMM swap call and ABI-encodes it with Alloy:
let call = AMMPool::swapCall {
tokenIn: edge.token_in,
amountIn: hop_amount_in,
minAmountOut: min_amount_out,
};
let data = call.abi_encode();
The execution builder uses the same simulated state transitions as the allocator while generating the final call sequence.
That means the calldata is created for the exact order that the simulator selected.
11. Generic Solidity Execution Layer
The Solidity contract is intentionally separated from the routing algorithm.
QuoteAggregator accepts a sequence of generic execution steps rather than requiring the Rust router to know about a specific DEX implementation.
The flow is:
Trader
|
| approve input token
v
QuoteAggregator
|
| transfer input tokens
v
Execution 1
|
v
Execution 2
|
v
Execution N
|
v
final output token
|
v
Trader
For every step, the contract:
- checks that the target pool is approved by the registry
- checks basic execution arguments
- approves the target to spend the input token
- performs the encoded low-level call
- clears the approval
- emits an execution event
The executor measures the final token balance change and then checks it against the transaction-level minimum output.
12. Pool Registry as an Execution Trust Boundary
The project keeps pool authorization separate from swap execution.
PoolRegistry maintains a mapping of approved pool addresses:
PoolRegistry
|
+--> AMM1 approved
+--> AMM2 approved
+--> AMM3 approved
+--> AMM4 approved
The execution contract refuses to call a target that is not approved by the registry.
This creates a small explicit trust boundary between:
routing decision
|
v
encoded execution target
|
v
registry validation
|
v
actual external call
The current project uses a manually configured registry. A more complete system could evolve this toward a richer adapter or factory/discovery model.
13. On-Chain Quote Support
The Solidity execution contract also contains quote functions that evaluate a supplied path against the current pool reserves.
The quote API accepts:
input token
output token
input amount
paths
pools
and can return the best final output across the supplied candidate paths.
A route quote also returns the intermediate amounts for every hop:
amountIn
|
v
hop 1 output
|
v
hop 2 output
|
v
final amountOut
The main routing pipeline currently performs the more sophisticated route discovery and split simulation off-chain in Rust.
14. Transaction-Level Slippage
The current prototype applies a 1% minimum-output tolerance at the final transaction level.
The simulated final output is converted into:
amountOutMin = simulatedOutput × 99 / 100
For the first correctness-focused execution path, individual AMM calls use the simulated hop output as their minAmountOut.
That is intentionally strict and useful for testing simulation correctness, but it is not yet a complete production slippage policy.
A production router would need a more deliberate treatment of per-hop tolerances, transaction-level protection, stale state and execution-time price movement.
15. Atomic Multi-Hop Execution
The complete plan is submitted as one call to QuoteAggregator.execute.
Conceptually:
execution plan
|
v
one Solidity call
|
+--> swap 1
+--> swap 2
+--> swap 3
+--> ...
|
v
final output check
Because the steps are performed inside the same EVM transaction, a failing step reverts the transaction rather than leaving a partially executed route.
The Rust side then checks the trader’s token-out balance before and after the transaction to calculate the actual received amount.
Correctness Validation
One of the main goals of the project is to make the local simulation agree with real on-chain reserve transitions.
The current end-to-end validation produced:
Simulated output: 552782643753440975662
Actual output: 552782643753440975662
Perfect match: simulation == on-chain execution.
This is an important milestone because the routing engine is not useful if its local reserve model diverges from the execution semantics of the contracts.
For the tested pool topology and execution plan, the simulation reproduced the same final output as the actual transaction.
Local Test Topology
The current local setup uses three ERC-20 test tokens and four AMM pools:
Token A <----> Token B
| |
| |
+---- AMM3 +---- AMM4
| |
v v
Token C <---------+
More precisely:
AMM1: Token A / Token B
AMM2: Token A / Token B
AMM3: Token A / Token C
AMM4: Token B / Token C
This topology is intentionally useful for testing both:
parallel liquidity
A -> B via AMM1
A -> B via AMM2
and:
shared downstream liquidity
AMM1 -> AMM4
AMM2 -> AMM4
The setup script deploys the test tokens, AMMs, registry and aggregator, configures the approved pools, seeds liquidity and funds a local trader account.
Solidity Components
The on-chain side currently contains:
src/
├── aggregator/
│ └── QuoteAggregator.sol
├── amm/
│ └── AMMPool.sol
├── interfaces/
│ └── IAMMPool.sol
├── pool_registry/
│ └── PoolRegistry.sol
└── tokens/
├── TokenA.sol
├── TokenB.sol
└── TokenC.sol
AMMPool
The test AMM implements:
- constant-product style quoting
- 0.3% swap fee
- two-token liquidity pools
- reserve synchronization
- liquidity add/remove operations
- swaps in either token direction
The swap implementation measures the actual input token amount received by the pool before calculating output, which keeps the reserve update tied to what the pool actually received.
QuoteAggregator
The aggregator contract provides:
- candidate route quote evaluation
- route quote calculation
- generic execution steps
- approved-target validation
- atomic execution
- final output/slippage verification
- execution events
PoolRegistry
A simple owner-controlled allowlist of approved execution pools.
Test Tokens
Three local ERC-20 tokens provide a deterministic environment for developing and validating the router.
Development Workflow
The repository is designed around a local Foundry/Anvil environment and a Rust CLI.
Build the Solidity project with:
forge build
Run Solidity formatting and tests with:
forge fmt --check
forge test -vvv
Check the Rust project with:
cd aggregator
cargo check
Run Rust tests with:
cargo test
Start a local Anvil node:
anvil
Then deploy the local environment:
./script/setup_local.sh
The setup script deploys the contracts through Setup.s.sol and writes the local addresses and trader key to .env.local.
The router itself can then be invoked with:
cd aggregator
cargo run -- \
--in-token <INPUT_TOKEN_ADDRESS> \
--out-token <OUTPUT_TOKEN_ADDRESS> \
--amount-in <AMOUNT_IN_BASE_UNITS>
The RPC URL can be supplied through the CLI or RPC_URL environment variable.
Engineering Decisions
Keep routing off-chain
The expensive search, quoting, cloning and split simulation happen in Rust rather than inside the EVM.
That makes it practical to explore more candidate paths without paying on-chain computation costs for every hypothetical route.
Keep execution generic
The Solidity executor does not contain the routing algorithm.
Rust decides:
which route
which pool
which amount
which order
which calldata
Solidity is responsible for:
authorization
execution
atomicity
minimum-output enforcement
This separation allows routing logic to evolve without redesigning the complete execution layer.
Model shared pools by physical identity
The simulator identifies shared liquidity through the pool address rather than treating every directional graph edge as a different pool.
That is important because:
A -> B via AMM1
B -> A via AMM1
are two graph views of one liquidity source.
Replay the chosen order when building calldata
The allocator’s result is an ordered plan, not just a set of percentages.
The execution builder replays that exact order while updating the same simulated pool state, which keeps the generated hop inputs consistent with the state assumed by the allocator.
Current Limitations
This is an engineering prototype and several components are intentionally simplified.
Allocation
The current split algorithm is greedy and chunk-based.
It does not guarantee the globally optimal split across all available routes.
Gas model
Gas estimates are coarse constants rather than measured execution costs for the complete generated plan.
Route search
Candidate routes are discovered with bounded DFS. A production-scale router would need stronger pruning, caching, topology management and more scalable candidate generation.
DEX coverage
The current contracts model a single test AMM interface. The project does not yet integrate multiple production DEX protocols with different pool and swap interfaces.
Pool discovery
The current setup uses configured pool addresses. There is no production indexing/discovery system that continuously discovers new pools and updates the routing graph.
Slippage
Slippage handling is intentionally simple and aimed at correctness testing rather than production execution policy.
Execution optimization
The current execution representation can be improved to reduce unnecessary calldata and execution overhead, especially when the allocator produces many small chunks.
Reliability under rapidly changing chain state
The current simulation starts from synchronized reserves but does not yet provide a sophisticated mechanism for protecting against all forms of state change between synchronization and transaction mining.
Future Work
The project is still ongoing. The areas I want to develop next include:
- stronger global split optimization
- gas-aware allocation and execution ordering
- more efficient shared-pool state indexing
- route pruning and caching
- execution-plan compression
- realistic gas estimation
- generic adapters for multiple DEX designs
- broader pool discovery and indexing
- stronger target and calldata validation
- better slippage modeling
- fuzzing and invariant testing across routing and execution
- larger topology and performance benchmarks
Project Goal
The goal is to build a routing engine that demonstrates the systems problem behind DEX aggregation:
pool state
|
v
route discovery
|
v
AMM quoting
|
v
shared-liquidity simulation
|
v
split allocation
|
v
ordered execution plan
|
v
ABI encoding
|
v
atomic on-chain execution
|
v
simulation vs actual result
The current implementation is deliberately small enough to reason about end to end, while leaving the main optimization and productionization problems open for further work.