Back to Blog
December 9, 2024

Branch-and-bound vs greedy: how IVZA finds optimal swap routes

Inside the solver that decides how your swap gets executed. Pool graphs, Dijkstra routing, constant-product math, and why we implemented two strategies instead of one.

The routing problem

When IVZA processes a swap intent -- "swap 500 USDC to SOL" -- it needs to decide how that swap gets executed. Which liquidity pool? If there are multiple pools for the same pair, which one gives the best output? Should the swap go through an intermediate token? Should it be split across multiple pools?

This is a constrained optimization problem. The solver's job is to find the execution path that maximizes output while respecting slippage limits, pool depth constraints, and compute budget.

Pool graph

The solver maintains a PoolGraph -- a weighted graph where nodes are token mints and edges are liquidity pools. Each edge carries the pool's address, reserves, fee rate, and pool type (constant-product AMM, concentrated liquidity, or orderbook).

pool.rs
pub struct PoolInfo {
    pub address: Pubkey,
    pub token_a: Pubkey,
    pub token_b: Pubkey,
    pub reserve_a: u64,
    pub reserve_b: u64,
    pub fee_bps: u16,
    pub pool_type: PoolType,
    pub active: bool,
}

pub enum PoolType {
    ConstantProduct,  // x * y = k
    ConcentratedLiquidity { tick_spacing: i32 },
    Orderbook,
}

The PoolRegistry is backed by DashMap for thread-safe concurrent access. Pools can be registered, updated, and deactivated at runtime without locking the entire registry.

Dijkstra routing

Finding the best route from token A to token B is a shortest-path problem on the pool graph. The RouteEngine uses Dijkstra's algorithm with a cost function that accounts for:

router.rs
fn score_route(&self, route: &Route, amount: u64) -> f64 {
    let output_ratio =
        route.estimated_output as f64 / amount as f64;

    let hop_penalty =
        0.98_f64.powi(route.hops.len() as i32 - 1);

    let impact_factor =
        1.0 - route.total_price_impact;

    let depth_factor = (route.min_depth as f64)
        .ln()
        .max(1.0)
        / 25.0;

    output_ratio * hop_penalty * impact_factor * depth_factor
}

The router supports up to 4 hops (A → B → C → D → E) and prunes routes that cycle through the same token. For each token pair, it returns the top-K routes sorted by score.

Constant-product math

For standard AMM pools, the output calculation follows the constant-product formula. Given reserves (x, y) and input amount dx (after fees):

amm_math.rs
pub fn calculate_output(
    reserve_in: u64,
    reserve_out: u64,
    amount_in: u64,
    fee_bps: u16,
) -> u64 {
    let fee_adjusted = amount_in as u128
        * (10_000 - fee_bps as u128)
        / 10_000;

    let numerator = fee_adjusted * reserve_out as u128;
    let denominator = reserve_in as u128 + fee_adjusted;

    (numerator / denominator) as u64
}

pub fn calculate_price_impact(
    reserve_in: u64,
    amount_in: u64,
) -> f64 {
    amount_in as f64 / (reserve_in as f64 + amount_in as f64)
}

For concentrated liquidity pools, the math is more involved. The output depends on which tick ranges the swap traverses, and the available liquidity at each tick. IVZA's CLMM implementation walks the tick array, computing partial outputs at each range until the input amount is fully consumed.

Greedy solver

The GreedySolver is the fast path. For each swap in the execution plan, it independently finds the best route and assigns it. No coordination between swaps. Simple and fast.

This works well when swaps are independent -- different token pairs, different pools. It breaks down when multiple swaps compete for the same liquidity. If two swaps both route through the same pool, the first one changes the reserves, making the second one's estimated output wrong.

solver.rs
impl Solver for GreedySolver {
    fn solve(
        &self,
        swaps: &[SwapRequest],
        router: &RouteEngine,
    ) -> Result<SolverResult> {
        let mut results = Vec::new();

        for swap in swaps {
            let route = router.find_best_route(
                &swap.input_mint,
                &swap.output_mint,
                swap.amount,
            )?;
            results.push(SolvedSwap {
                request: swap.clone(),
                route,
            });
        }

        Ok(SolverResult { swaps: results })
    }
}

Branch-and-bound solver

The BranchAndBoundSolver is the optimizer. It considers all swaps together, exploring different route combinations to find the globally optimal assignment.

The algorithm:

branch_and_bound.rs
fn search(
    &self,
    depth: usize,
    assigned: &mut Vec<SolvedSwap>,
    virtual_reserves: &mut HashMap<Pubkey, (u64, u64)>,
    best: &mut f64,
    best_solution: &mut Vec<SolvedSwap>,
    deadline: Instant,
) {
    if Instant::now() > deadline { return; }

    if depth == self.swaps.len() {
        let score = total_output(assigned);
        if score > *best {
            *best = score;
            *best_solution = assigned.clone();
        }
        return;
    }

    let swap = &self.swaps[depth];
    for route in &self.routes[depth] {
        // Check if route is still viable with
        // current virtual reserves
        let output = requote(&route, virtual_reserves);
        let bound = output + optimistic_remaining(
            depth + 1, &self.swaps, virtual_reserves
        );

        if bound <= *best { continue; } // prune

        // Branch: assign this route
        apply_reserves(virtual_reserves, &route);
        assigned.push(SolvedSwap { route, output });

        self.search(
            depth + 1, assigned,
            virtual_reserves, best,
            best_solution, deadline
        );

        // Backtrack
        assigned.pop();
        revert_reserves(virtual_reserves, &route);
    }
}

When to use which

The SolverEngine defaults to greedy and switches to branch-and-bound when the optimization opportunity is high:

In benchmarks, branch-and-bound finds routes with 2-8% better output than greedy for graphs with 5+ swaps sharing liquidity pools. For simple operations, the results are identical and greedy runs 10x faster.

Post-solve optimization

After the solver assigns routes, the ExecutionOptimizer applies a final pass:

The optimizer reports savings in basis points. For typical DeFi operations, the combined solver + optimizer pipeline produces 3-15 bps improvement over naive sequential execution.

Read the solver source code.