Rust-Owned Settlement-Arbitrage Bot ArchitectureΒΆ

Covers the Rust extension, unified engine, pump, executor, and Python orchestration layer for Uniswap V2/V3/V4 same-block arbitrage on Ethereum mainnet.

Developed under Plans 079–082 (all complete).

STATUS: HISTORICAL. This captures the original ArbitrageEngine/V2|V3|V4BlockEngine design from Plans 079–082. It predates the bot_core/solvers restructure of degenbot-bot (the optimizers/ paths below no longer exist) and the ports of simulation (degenbot-simulation) and submission (degenbot-submission) to Rust. Do not use it as the component map β€” read the crate sources and ADR-003/ADR-005 instead. Kept as a design-history reference.

1. Guiding PrincipleΒΆ

Rust is the engine, Python is the cockpit.

Every hot-path operation β€” event decoding, pool state mutation, tick-range construction, solver dispatch, result storage β€” lives in Rust. Python participates in construction (pool discovery, engine registration) and orchestration; simulation (degenbot-simulation) and transaction submission (degenbot-submission) are now Rust-owned. During the per-block loop, Python reads results from Rust and encodes swap payloads using Python pool objects; it does not receive events, update pool state, or push data to the engine.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          Startup (Python)                           β”‚
β”‚  Bot.from_config_file β†’ build_paths_async β†’ register_pool/path     β”‚
β”‚  β†’ backfill_snapshots β†’ engine.freeze β†’ engine.initial_solve       β”‚
β”‚  β†’ engine.subscribe(rpc_url) β†’ backfill_from_snapshot              β”‚
β”‚  β†’ engine.resume_from_subscribe()                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                                β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      Hot Loop (Rust Pump)                           β”‚
β”‚  WS newHeads+logs β†’ process_block β†’ solve β†’ store results              β”‚
β”‚  ↳ send BlockNotification via watch channel                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                                β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Dispatch (Python)                                 β”‚
β”‚  wait_for_block β†’ latest_results β†’ encode_payloads                 β”‚
β”‚  β†’ eth_simulateV1 β†’ sign β†’ send_raw_transaction                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. Component MapΒΆ

Component

Language

File(s)

Role

ArbitrageEngine

Rust

rust/crates/engine/degenbot-bot/src/optimizers/arb_engine.rs

Unified V2+V3+V4 engine: state, solving, result storage

V2BlockEngine

Rust

rust/crates/engine/degenbot-bot/src/optimizers/v2_block_engine.rs

V2 pool state, Sync decoding, constant-product solving

V3BlockEngine

Rust

rust/crates/engine/degenbot-bot/src/optimizers/v3_block_engine.rs

V3 pool state, Swap/Mint/Burn decoding, tick-range construction, piecewise solving

V4BlockEngine

Rust

rust/crates/engine/degenbot-bot/src/optimizers/v4_block_engine.rs

V4 pool state, Swap/ModifyLiquidity decoding, same CL math as V3

ArbitrageEnginePump

Rust

rust/crates/engine/degenbot-bot/src/optimizers/arb_engine_pump.rs

Unified async pump: dual WS subscription (newHeads + logs), backfill on timeout/empty block, routes to sub-engines

Bot

Rust

rust/crates/engine/degenbot-bot/src/bot_core/mod.rs

Single owner of pool/token state (future all-state owner, currently V2+V3 partial)

ReorgJournal

Rust

rust/crates/engine/degenbot-bot/src/bot_core/state_history.rs

Bounded deque of per-block deltas for rollback (V2: 2 reserves; V3: scalars + tick priors)

Tick bitmap walk & tick mutation

Rust

rust/crates/engine/degenbot-bot/src/bot_core/tick_bitmap.rs

gen_ticks() port + shared update_tick_liquidity / apply_liquidity_to_tick_range helpers used by both V3 and V4 engines

Event decoders

Rust

rust/crates/engine/degenbot-bot/src/bot_core/v*_decoder.rs

Decode Sync, Swap, Mint/Burn, ModifyLiquidity from Alloy logs

MΓΆbius solvers

Rust

rust/crates/engine/degenbot-bot/src/optimizers/mobius_*.rs

Integer-exact arbitrage solvers (V2-V2, mixed V2-V3, V3-V3)

V2 swap encoding

Rust

rust/crates/engine/degenbot-bot/src/bot_core/v2_encoding.rs

Pre-encoded swap() calldata production

PyArbitrageEngine

Rust/PyO3

arb_engine.rs (bottom)

PyO3 wrapper exposing engine to Python

Executor contract

Vyper

contracts/tstore_executor.vy

Generic payload queue with V2/V3/V4 callbacks

Settlement-arbitrage bot

Python

examples/eth_settlement_arbitrage_v2_v3_v4_rust.py

Pool discovery, encoding, simulation, submission


3. The ArbitrageEngineΒΆ

3.1 CompositionΒΆ

ArbitrageEngine composes three sub-engines behind a single API:

pub struct ArbitrageEngine {
    v2_engine: V2BlockEngine,
    v3_engine: V3BlockEngine,
    v4_engine: V4BlockEngine,
    paths: HashMap<u64, (MixedPath, ResolvedMixedPath)>,
    pool_to_paths: HashMap<(HopType, u64), HashSet<u64>>,  // reverse index
    results: Vec<(u64, U256, U256)>,                        // (path_id, opt_input, profit)
    results_block: u64,
    running: bool,
}

HopType (V2 | V3 | V4) tags each pool reference in a path. V3 and V4 both produce IntV3TickRangeSequence β€” the solver cannot distinguish them and should not need to.

3.2 RegistrationΒΆ

Pools are registered individually by type, then paths combine them:

Method

Sub-engine

Key

Dual orientation?

register_v2_pool

V2

contract address β†’ forward pool_id; reverse = forward + 1

Yes

register_v3_pool

V3

contract address β†’ pool_key

No (zfo in path ref)

register_v4_pool

V4

(pool_manager, pool_id) β†’ forward pool_key; reverse = forward + 1

Yes

register_path takes a Vec<MixedPoolRef> (hop_type, pool_key, zero_for_one) and returns a path_id. After freeze(), no more registration is allowed.

3.3 Block ProcessingΒΆ

process_block(&[Log], block_number) is the single entry point called by the pump each block:

  1. Categorise logs by topic0: V2_SYNC_TOPIC β†’ V2, V3_SWAP/MINT/BURN_TOPIC β†’ V3, V4_SWAP/MODIFY_LIQUIDITY_TOPIC β†’ V4

  2. Route each log batch to the appropriate sub-engine’s process_block

  3. Collect affected pool keys from each sub-engine (dual-orientation for V2/V4)

  4. Rebuild and re-solve only paths that reference affected pools (via pool_to_paths reverse index)

  5. Merge new results with unchanged path results

3.4 Solver DispatchΒΆ

The engine matches on path composition to select the solver:

Path type

Solver

Method

V2-V2

exact_mobius_solve

Closed-form √(K·M) via U512 isqrt; ±2 neighborhood search

V3-V3, V4-V4

int_solve_v3_v3

Piecewise integer-MΓΆbius per (k₁,kβ‚‚) tick-range pair; closed-form per segment

V2-V3, V3-V2, V2-V4, V4-V2, V3-V4, V4-V3

exact_solve_mixed_v2_v3_sequence

V3/V4 effective reserves + piecewise tick-range enumeration; closed-form per range

All paths use integer-exact arithmetic. Zero f64 on any solve path. The former golden-section search and f64 MΓΆbius solver are deleted.


4. Sub-EnginesΒΆ

4.1 V2BlockEngineΒΆ

Owns V2 pool state as IntHopState pairs (forward + reverse). Each pool registration creates two entries with pool_id and pool_id + 1. Sync events update both entries. The constant-product calc uses U512 arithmetic matching EVM semantics:

amount_out = (gamma * reserve_out * amount_in) / (fee_denom * reserve_in + gamma * amount_in)

Dual-orientation: apply_sync_updates returns both forward and reverse pool keys so the unified engine can track V2 dependencies for zfo=False paths.

4.2 V3BlockEngineΒΆ

Owns V3 pool state including tick_data: HashMap<i32, TickInfo> where each TickInfo holds liquidity_gross (u128) and liquidity_net (I256). Process block handles three event types:

Event

Effect on pool state

Swap

Updates sqrt_price_x96, liquidity, tick (scalar fields)

Mint

Delegates to apply_liquidity_to_tick_range (lower: net += Ξ”, upper: net -= Ξ”, both: gross += Ξ”)

Burn

Same as Mint with negated Ξ”; ticks with zero liquidity_gross are removed (de-initialised)

Tick update logic (update_tick_liquidity and apply_liquidity_to_tick_range) lives in tick_bitmap.rs β€” shared with V4BlockEngine. Tick-range sequences are built via build_int_v3_sequence() which:

  1. Calls compute_tick_ranges() β€” the Rust port of gen_ticks() that walks tick_bitmap: HashMap<i16, U256> and interleaves boundary ticks with initialised ticks

  2. For each range, constructs IntV3TickRangeHop with U256 sqrt_price_* and u128 liquidity

  3. Computes integer effective reserves: Rβ‚€ = L Β· 2⁹⁢ / √P, R₁ = L Β· √P / 2⁹⁢ (U512 intermediates)

4.3 V4BlockEngineΒΆ

Mirrors V3BlockEngine exactly but identifies pools by (pool_manager: Address, pool_id: [u8; 32]) instead of contract address. Two registration-time filters reject unusable pools:

  • Hook filtering: (hook_flags & AMOUNT_MODIFYING_HOOK_MASK) != 0 where 0xCC = BEFORE_SWAP | AFTER_SWAP | BEFORE_SWAP_RETURNS_DELTA | AFTER_SWAP_RETURNS_DELTA. Hooked pools can modify swap amounts, violating the solver’s V3-math assumption.

  • Dynamic fee exclusion: fee == 0x100000 indicates swap-dependent fees that the fixed-fee solver cannot handle.

V4 ModifyLiquidity events replace V3’s separate Mint/Burn with a signed liquidity_delta (I256). Both V3 and V4 engines delegate tick mutations to the shared update_tick_liquidity (single tick) and apply_liquidity_to_tick_range (range with zero-gross cleanup) helpers in tick_bitmap.rs.


5. The MΓΆbius SolversΒΆ

5.1 Mathematical BasisΒΆ

Every constant-product swap y = Ξ³Β·sΒ·x / (r + Ξ³Β·x) is a MΓΆbius transformation fixing the origin. An n-hop path composes into:

l(x) = KΒ·x / (M + NΒ·x)

The optimal input is the closed-form x_opt = (√(K·M) - M) / N, solvable via integer square root on U512.

5.2 Integer-Exact Solver (mobius_int_exact.rs)ΒΆ

  • Computes K, M, N as U512 integers via compute_int_mobius_coefficients

  • Checks K > M for profitability

  • Computes x_opt = (isqrt(KΒ·M) - M) / N using isqrt_u512 (Newton’s method in U512, ~100ns)

  • EVM-simulates at x_opt and Β±2 neighbors to handle floor-division rounding

  • Returns best result with used_closed_form: bool

Performance: 131ns for a 2-hop V2 path (17Γ— faster than the iterative int_mobius_solve_with_refinement).

5.3 V3/V4 Integer Solver (mobius_v3_int.rs)ΒΆ

Concentrated-liquidity hops produce IntV3TickRangeSequence β€” a vector of IntV3TickRangeHop structs, each carrying integer sqrt-price bounds and liquidity. The solver:

  1. For each (k₁, kβ‚‚) pair of ending tick ranges, computes a single (K, M, N) triple by composing the shifted MΓΆbius transforms

  2. Calls exact_mobius_solve for closed-form optimal input β€” no golden-section search

  3. Validates via int_simulate_v3_v3_path with full piecewise simulation

Crossing constants (additive amounts from tick-range boundaries) fold into the MΓΆbius coefficient recurrence, so each (k₁, kβ‚‚) segment is still a single closed-form solve.

5.4 Mixed V2-V3/V4 Solver (exact_solve_mixed_v2_v3_sequence)ΒΆ

Combines V3/V4 effective reserves with V2 integer reserves:

  1. Enumerate V3/V4 ending ranges (like int_solve_v3_v3)

  2. For each candidate ending range k, compute crossing data

  3. Build mixed hop list with the ending range’s effective reserves

  4. Solve via closed-form MΓΆbius

  5. Validate with crossing-aware simulation

Replaced the single-range exact_solve_mixed_v2_v3 which produced false positives when swaps exceeded the current range capacity.


6. The Pump: Rust-Owned State PipelineΒΆ

6.1 Architecture: Dual-Subscription with Backfill Safety NetΒΆ

The ArbitrageEnginePump maintains two concurrent WS subscriptions against the same provider:

  1. newHeads β€” block boundary notifications (block number, timestamp, base fee, gas)

  2. logs β€” all log events, unfiltered (topic + address filtering happens in Rust)

The pump assumes the WS/IPC connection is live and delivering events correctly. Logs arrive in real-time and are buffered per block. When a newHeads event arrives, the buffered logs for the just-completed block are processed atomically.

AlloyProvider (WS/IPC)
    β”‚
    β”œβ”€ subscribe("newHeads") ──────────────────────┐
    β”‚                                               β”‚
    β”œβ”€ subscribe("logs") ──┐                       β”‚
    β”‚   (no filter)         β”‚                       β”‚
    β”‚                       β–Ό                       β–Ό
    β”‚               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚               β”‚  Buffer logs by block_number        β”‚
    β”‚               β”‚  (log.block_number from WS context  β”‚
    β”‚               β”‚   or matched against pending block)  β”‚
    β”‚               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    β”‚                          β”‚
    └─ on newHeads(block N):  β”‚
         β”‚                    β”‚
         β”œβ”€ Take all buffered logs for block N-1
         β”‚   (logs that arrived since the previous newHeads)
         β”‚
         β”œβ”€ Filter logs in Rust:
         β”‚   topic0 ∈ {V2_SYNC, V3_SWAP/MINT/BURN, V4_SWAP/MODIFY_LIQ}
         β”‚   address ∈ {registered V2+V3 pools, V4 PoolManagers}
         β”‚
         β”œβ”€ engine.lock().process_block(filtered_logs, block_number)
         β”‚   β†’ route to V2/V3/V4 sub-engines
         β”‚   β†’ rebuild affected sequences
         β”‚   β†’ solve affected paths
         β”‚   β†’ store results
         β”‚
         └─ block_tx.send(BlockNotification) β€” Python reads this

6.2 Why No Filter on the Logs SubscriptionΒΆ

The logs subscription carries no address or topic filter. All filtering happens in Rust after receipt. This avoids three classes of provider-specific failure:

  1. Address filter limits β€” some providers reject subscriptions with >1000 address constraints, or silently ignore them

  2. Topic filter truncation β€” some providers handle 6-topic OR filters incorrectly

  3. Provider-dependent filter semantics β€” different providers interpret address and topic constraints differently (AND vs OR, ordering requirements)

The traffic cost is bounded: Ethereum mainnet produces ~200–500 logs per block, of which typically 5–50 are relevant to our monitored pools. The Rust-side topic+address filter is O(1) per log (hash comparison + HashMap lookup).

6.3 Backfill TriggersΒΆ

The pump assumes the WS connection is live, but two conditions trigger a verification backfill via eth_getLogs:

Trigger 1: Timeout β€” 60s with nothing receivedΒΆ

If neither a newHeads nor a logs event arrives within 60 seconds, the connection is likely dead. The pump:

  1. Logs a warning with the last-seen block

  2. Calls eth_getLogs from last_processed_block + 1 to the latest block (determined via eth_blockNumber)

  3. Processes any logs found, filling the gap

  4. Resets the timeout watchdog

This catches: WS disconnects, provider restarts, network partitions, and local socket errors that don’t immediately surface as Err in the stream.

Trigger 2: Block with no received logsΒΆ

When a newHeads event arrives, the pump checks whether any logs were received since the previous newHeads. If zero logs arrived for the just-completed block, this could mean:

  • The block genuinely has no relevant events (common β€” many blocks have zero pool events)

  • The WS dropped some or all log events for this block

The pump cannot distinguish these cases from the subscription alone. It calls eth_getLogs(from=block, to=block) to verify:

  • Empty result β†’ block truly had no events. No harm done β€” one extra RPC call, no state change.

  • Non-empty result β†’ logs were missed. Process them. The engine now has correct state.

This provides protection against false positives: if the WS silently dropped events, the backfill catches them before the engine solves on stale state. If the block was truly empty, the backfill is a no-op with a small RPC cost.

Why this is better than eth_getLogs every blockΒΆ

eth_getLogs every block

Push + backfill on suspicion

Latency

Block header + ~50–100ms RPC

Block header only (logs already buffered)

RPC calls per block

Always 1

0 for blocks with events, 1 for empty blocks

Data freshness

Stale by one RPC round-trip

Real-time (logs arrive as they’re mined)

Safety

Guaranteed complete (by construction)

Guaranteed complete (backfill covers gaps)

Empty-block cost

Same as any block

One eth_getLogs call (cheap β€” empty response)

The key advantage: events are processed as they arrive, not after a round-trip delay. For same-block reactivity (where another searcher might see the same event and act first), this latency reduction is material.

6.4 Why One Pump, Not ThreeΒΆ

  • 2 WS subscriptions instead of 6 (3 newHeads + 3 logs)

  • 1 lock acquisition per block instead of 3

  • Single eth_getLogs backfill call covers all protocols

6.5 BlockNotificationΒΆ

pub struct BlockNotification {
    pub block_number: u64,
    pub timestamp: u64,
    pub base_fee_per_gas: Option<u64>,
    pub gas_used: u64,
    pub gas_limit: u64,
}

Published via tokio::sync::watch channel. Python reads it via engine.wait_for_block() (blocking call, releases GIL). This replaces Python’s WS newHeads subscription for fee/nonce computation β€” the pump is the sole source of block data.

6.6 Startup SequenceΒΆ

Python’s main() follows this exact order:

  1. Bot.from_config_file() β€” load config, DB, connections

  2. build_paths_async β€” discover V2/V3/V4 pools, build Python pool objects, register with engine

  3. engine.freeze() β€” lock registration

  4. engine.initial_solve() β€” solve all paths from current state

  5. backfill_snapshots() β€” V3: fetch Mint/Burn events from snapshot block to current; V4: fetch ModifyLiquidity events. Push updated tick_data to Rust engine one final time.

  6. engine.subscribe(node_ws) → backfill_from_snapshot() → resume_from_subscribe() — open WS, close the snapshot→WS gap, then begin normal pump processing (the canonical two-phase flow; see EngineRegistry.start())

  7. Main loop: wait_for_block β†’ latest_results β†’ dispatch_profitable_results

The backfill closes the gap between the DB snapshot and the first pump block. After backfill, Rust owns all state updates β€” Python never pushes pool state again.

6.7 Consumer Subscription ModelΒΆ

The pump’s watch channel provides a continuous stream of BlockNotifications to Python. Because backfill covers gaps, consumers see an unbroken, block-ordered sequence of notifications β€” even if the WS connection dropped and recovered during a short window. The engine’s latest_results() always reflects the most recent processed state, regardless of whether it came from the WS subscription or a backfill call.

For future consumers that want per-event granularity (not just per-block), the pump can expose a second channel carrying DecodedEvent objects β€” block-ordered, deduplicated (backfill results merged with WS-received events), filtered to relevant pools only.


7. The Reorg JournalΒΆ

7.1 DesignΒΆ

Each pool in Bot carries a ReorgJournal<D: BlockDelta> β€” a bounded VecDeque of per-block deltas storing prior values of modified state fields.

Forward progress: stash β€œbefore” values β†’ update current state.

Reorg rollback: pop deltas at/after the target block β†’ restore β€œbefore” values into current state.

Hot path: swap calculations and the engine never touch the journal. They always read current mutable fields. Zero penalty.

7.2 V2 Delta (Degenerate Case)ΒΆ

struct V2BlockDelta {
    block: u64,
    reserve0_before: U256,
    reserve1_before: U256,
}

V2 β€œdelta = full state” β€” two reserves. Memory equivalent to full-state cloning, but restores in O(1) instead of O(n) for V3.

7.3 V3 Delta (Efficient Case)ΒΆ

struct V3BlockDelta {
    block: u64,
    sqrt_price_x96_before: U256,
    liquidity_before: U128,
    tick_before: i32,
    tick_priors: Vec<(i32, TickBefore)>,  // typically 0–4 entries
}

Only modified tick priors are stored. A typical V3 swap modifies 0–4 ticks (at the crossing boundary). Memory: 2000 entries + 8Γ—4 β‰ˆ 2032 vs full-tick-map cloning at 8 Γ— 2000 + scalars β‰ˆ 16000.

7.4 OperationsΒΆ

Method

Effect

push_delta

Append same-block replacement, reject older blocks, evict beyond max_depth

discard_before_block

Remove deltas older than the target (no longer rollback-reachable)

restore_before_block

Pop deltas at/after target, restore scalar + tick priors, remove de-initialised ticks

Property-based tests (via proptest) verify the journal matches a faithful model after arbitrary sequences of push/discard/restore operations.


8. Event DecodersΒΆ

All decoders live in rust/crates/engine/degenbot-bot/src/bot_core/ and decode from Alloy Log objects:

Decoder

File

Event signature

Topic constant

V2 Sync

v2_sync_decoder.rs (in optimizers/)

Sync(uint112,uint112)

0x1c411e9a...

V3 Swap

v3_swap_decoder.rs

Swap(address,address,int256,int256,uint160,uint128,int24)

0xc42079f9...

V3 Mint

v3_mint_burn_decoder.rs

Mint(address,address,int24,int24,uint128,uint256,uint256)

0x7a53080b...

V3 Burn

v3_mint_burn_decoder.rs

Burn(address,int24,int24,uint128,uint256,uint256)

0x0c396cd9...

V4 Swap

v4_swap_decoder.rs

Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)

0x40e9cecb...

V4 ModifyLiquidity

v4_modify_liquidity_decoder.rs

ModifyLiquidity(bytes32,address,int24,int24,int256,bytes32)

0xf208f491...

Key differences from Python:

  • ~50ns decode time per event vs ~5-10Β΅s via Python eth_abi

  • Zero-allocation for common paths; returns Option<Event> (None for wrong topic/malformed data)

  • V4 pools identified by (PoolManager address, pool_id) not contract address


9. The Executor ContractΒΆ

9.1 OverviewΒΆ

A single Vyper contract (contracts/tstore_executor.vy) handles all V2+V3+V4 arbitrage paths. It uses a generic payload queue stored in transient storage (EIP-1153, cleared every transaction):

struct Payload:
    target: address
    calldata: Bytes[MAX_PAYLOAD_BYTES]
    will_callback: bool

9.2 Payload DeliveryΒΆ

execute_payloads(payloads, bribe_bips) stores the queue in transient storage, then iterates:

  1. Read t_payloads[index]

  2. If will_callback=True, register target in t_allowed_callback_addresses

  3. raw_call(target, calldata) β€”_no return value check

  4. Advance queue index

  5. Repeat until t_all_payloads_delivered

Callbacks (uniswapV2Call, uniswapV3SwapCallback, unlockCallback) assert msg.sender is registered, then resume queue delivery from where the payload left off.

9.3 Callback TypesΒΆ

Callback

Protocol

Registration trigger

uniswapV2Call

Uniswap/SushiSwap V2

will_callback=True on V2 swap payload

hook

Aerodrome/Velodrome V2

Same

pancakeCall

PancakeSwap V2

Same

uniswapV3SwapCallback

Uniswap/SushiSwap V3

will_callback=True on V3 swap payload

pancakeV3SwapCallback

PancakeSwap V3

Same

unlockCallback

Uniswap V4

will_callback=True on PoolManager.unlock payload

9.4 V3 Auto-PayΒΆ

After delivering all queued payloads inside a V3 callback, the contract checks whether the calling pool is owed WETH and auto-transfers it:

if amount1_delta > 0:
    if token1() == WETH_ADDR:
        WETH.transfer(msg.sender, amount1_delta)
elif amount0_delta > 0:
    if token0() == WETH_ADDR:
        WETH.transfer(msg.sender, amount0_delta)

The Python encoder must not include explicit WETH transfer payloads for pools where auto-pay fires (would cause double-payment and revert).

9.5 V4 SettlementΒΆ

V4 swaps happen inside unlockCallback. The Python encoder pre-computes all amounts and encodes settlement operations as raw calldata payloads:

  • PoolManager.swap(PoolKey, SwapParams, hookData) β€” the V4 swap

  • ERC20.transfer(PoolManager, amount) β€” pay debt to PM

  • PoolManager.sync(currency) β€” update PM’s internal balance tracking

  • PoolManager.settle() β€” credit tokens to our delta

  • PoolManager.take(currency, to, amount) β€” receive tokens from PM

9.6 Profit MeasurementΒΆ

execute_payloads asserts WETH.balanceOf(self) does not decrease over the transaction. No prefunding required β€” V3’s callback is the flash borrow mechanism.


10. Swap EncodingΒΆ

10.1 Path Types and Their Payload SequencesΒΆ

The bot supports all 9 two-hop path combinations across V2/V3/V4. Each has a dedicated encoder function:

Path type

Encoder

Entry point

Settlement

V3-V2

encode_v3v2_payloads

V3.swap (callback)

Token transfers

V3-V3

encode_v3v3_payloads

V3.swap (callback)

Token transfers (nested callbacks)

V2-V2

encode_v2v2_payloads

V2.swap (flash borrow)

Token transfers

V2-V3

encode_v2v3_payloads

V2.swap (flash borrow)

Token transfers (nested callbacks)

V4-V4

encode_v4v4_payloads

PM.unlock (callback)

sync/settle/take

V4-V3

encode_v4v3_payloads

PM.unlock (callback)

sync/settle/take + token transfers

V3-V4

encode_v3v4_payloads

V3.swap (callback) β†’ PM.unlock (nested)

sync/settle/take

V4-V2

encode_v4v2_payloads

PM.unlock (callback)

sync/settle/take + token transfers

V2-V4

encode_v2v4_payloads

V2.swap (callback) β†’ PM.unlock (nested)

sync/settle/take

10.2 V3 vs V4 Sign ConventionΒΆ

V3 and V4 use opposite sign conventions for amountSpecified:

Mode

V3

V4

Exact INPUT

amountSpecified > 0

amountSpecified < 0

Exact OUTPUT

amountSpecified < 0

amountSpecified > 0

For arbitrage (always exact-input): V3 encoding uses positive values, V4 encoding uses negative values. Getting this wrong produces V3 β€œIIA” (Insufficient Input Amount) reverts.

10.3 NATIVE_ADDRESS HandlingΒΆ

V4 PoolKey uses currency0 = address(0) for ETH pools. The executor wraps received ETH to WETH via IWETH.deposit() during settlement. For direction resolution, the bot treats address(0) as equivalent to WETH.


11. Dispatch PipelineΒΆ

11.1 FlowΒΆ

latest_results() β†’ sort by profit desc β†’ parallel simulation
    β†’ staleness check β†’ encode β†’ simulate (3-call pattern)
    β†’ market-aware fee β†’ mutual exclusivity β†’ submit

11.2 Slices (from Plan 080)ΒΆ

#

Feature

Detail

0.5

Dispatch serialisation

asyncio.Lock prevents concurrent dispatches

1

Parallel simulation

asyncio.gather with MAX_SIMULATE_CONCURRENT=50

2

Staleness tracking

solve_block from Rust engine (not Python update_block); STALENESS_TOLERANCE=5 blocks

3

Market-aware fees

Target from profit ratio, age decay, feeHistory percentile bounds

4

Best-path selection

Sort by profit desc, mutual exclusivity via committed_pools

5

Gas from simulation

gasUsed * 1.1 instead of 1.5Γ— heuristic

6

WS reconnection

Exponential backoff + pool state reconciliation (handled by Rust pump’s Alloy provider)

7

Subscription filtering

Address filter on WS LogsSubscription when ≀1000 pools

11.3 Simulation: 3-Call PatternΒΆ

eth_simulateV1({
    "blockStateCalls": [{
        "calls": [
            WETH.balanceOf(executor),           # [0] Before
            execute_payloads(payloads, 0),        # [1] Arbitrage
            WETH.balanceOf(executor),           # [2] After
        ],
        "stateOverrides": {
            executor_owner: {balance: 100 ETH},   # Gas funding
            injected_address: {code: runtime},     # Code injection (if enabled)
        }
    }]
})

Gross profit = WETH balance after βˆ’ WETH balance before.

11.4 Code InjectionΒΆ

When INJECT_EXECUTOR_CODE=True, the bot loads the executor’s runtime bytecode from contracts/cmd_executor_runtime_bytecode.txt and injects it at a fresh address via stateOverrides.code. This enables simulation of undeployed contracts without mainnet deployment. The Vyper immutables (5 slots: OWNER, WETH and PM addresses + 2 precomputed V4 CurrencyDelta slots for the injected executor address) are appended after the CBOR metadata in the deployed bytecode layout [code][CBOR][immutables]. The CBOR must remain intact β€” it contains the function dispatch jump table and JUMPDEST targets used by the code section.


12. Path DiscoveryΒΆ

12.1 Token Pair ModelΒΆ

All paths are two-hop WETH-centred cycles: WETH ↔ intermediate ↔ WETH. A path is profitable when pool A sells WETH for the intermediate at a better rate than pool B buys WETH with the intermediate (or vice versa).

12.2 Pool SourcesΒΆ

DEX

Pool table

Factory

Uniswap V2

UniswapV2PoolTable

0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f

Uniswap V3

UniswapV3PoolTable

0x1F98431c8aD98523631AE4a59f267346ea31F984

Sushiswap V2

SushiswapV2PoolTable

0xC0AEe478e3658e2610c5F7A4A2E1777cE9e4f2Ac

Sushiswap V3

SushiswapV3PoolTable

0xbACEB8eC6b9355Dfc0269C18bac9d6E2Bdc29C4F

PancakeSwap V3

PancakeswapV3PoolTable

0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865

Uniswap V4

UniswapV4PoolTable

PoolManager 0x000000000004444c5dc75cB358380D2e3De08A90

V4 pools are identified by (PoolManager, pool_id) β€” discovered via find_paths_async with step.hash carrying the pool_id.

12.3 Token Quality FilteringΒΆ

Mode

Environment variable

Behaviour

Blacklist (default)

TOKEN_BLACKLIST_MODE=1

Skip paths with known scam/tax tokens

Whitelist

TOKEN_WHITELIST_MODE=1

Only allow paths with known-good intermediates (USDC, USDT, DAI, WBTC, etc.)

Eliminates ~95%+ of simulation failures from scam/tax/honeypot tokens.

12.4 Steering a Live BotΒΆ

The path set of a running bot is steerable without a restart: the operator add-path / on-demand-discovery surface (long-lived registration pipeline, the Unix-socket JSON-lines channel, and the degenbot path CLI client) is documented in operator-add-path-surface.md.


13. Bot: State Owner & FFI TopologyΒΆ

13.1 RoleΒΆ

Bot (in rust/crates/engine/degenbot-bot/src/bot_core/mod.rs) is the single Rust owner of all runtime pool/token state β€” the state layer ADR-003 makes a peer to ArbitrageEngine. Under Plan 100 it holds V2/V3/V4 PoolEntry state, the reorg journal, per-pool swap math (calculate_tokens_out/calculate_tokens_in via v3_simulate_swap/v4_simulate_swap), and V2 swap encoding. The block engines (V2BlockEngine/V3BlockEngine/V4BlockEngine) are dissolved β€” ArbitrageEngine holds core: Arc<Mutex<Bot>> and reads/writes all pool state through it. See ADR-003 for the state-ownership decision.

13.2 FFI Topology β€” Polars-Inspired Three-Layer ArchitectureΒΆ

How Python reaches that Rust-owned state across the FFI is canonicalized in ADR-005: Polars-Inspired Three-Layer Architecture β€” the stateful specialization of the generic three-layer convention the former rust/AGENTS.md (dropped in affebc8de) carried. The realized topology:

  • Rust core β€” Bot, pure Rust, zero pyo3 imports.

  • PyO3 wrapper β€” PyBot (#[pyclass]) holds Arc<parking_lot::RwLock<Bot>> and is the sharing mechanism; PyLiquidityPool (carrying a pool_id key) and PyErc20Token (carrying an Address key) clone that Arc so N Python handles reference one Rust-owned Bot. Reads take a read guard; mutations take a write guard.

  • Python session β€” bot.py:Bot constructs self._py_bot = PyBot() in __init__ and delegates Rust-owned state through it.

class Bot:
    _py_bot: PyBot   # Arc<parking_lot::RwLock<Bot>>

    # pools/tokens: PyLiquidityPool/PyErc20Token handles sharing the same Arc
    # PyO3 read  β†’ Bot.pools[pool_id] under a read guard
    # PyO3 write β†’ mutation under a write guard

Grounded in Polars’ RwLock<DataFrame> + Arc-shared SharedStorage: many Python views, one Rust-owned buffer set. See ADR-005 for the rejected alternatives (engine-Mutex parity; Python-Bot-as-#[pyclass]; global-registry handles; Mutex-status-quo) and the deferred unification of ArbitrageEngine onto the shared Arc<RwLock<Bot>>.

13.3 Deferred ItemsΒΆ

The Plan 079 slices still genuinely deferred. The engines are dissolved β€” these are pool families whose math has not yet ported to Rust, not engines holding separate state:

Slice

Description

Status

5

Solidly-stable math in Rust

Deferred

9

Stableswap math in Rust

Deferred

10

Balancer math in Rust

Deferred

These families are still served by the pure-Python solver stack (ArbSolver/MobiusSolver/PiecewiseMobiusSolver) until their Rust-native equivalents land.


14. GIL DisciplineΒΆ

Function type

GIL policy

Rationale

Tick math (get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio)

Hold

~20ns compute; GIL release/reacquire costs ~200ns

Address utils (to_checksum_address)

Hold

~50ns compute

Provider I/O (raw_call, async operations)

Release via py.detach()

I/O-bound; holding the GIL blocks all Python threads

Pump main loop

Never acquires

Runs on Tokio worker threads; communicates via parking_lot::Mutex and watch channel

drain_buffer()

Attach in wrapper

Wraps drain_raw() which is pure Rust; Python::attach() only at the boundary

Every Python::attach() call site has a // SAFETY: comment documenting the no-circular-wait guarantee.


15. Relationship Between ComponentsΒΆ

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚    Python main()   β”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚     PyArbitrageEngine    β”‚
              β”‚  (PyO3 wrapper, GIL gate) β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚ Arc<Mutex<...>>
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚     ArbitrageEngine         β”‚
              β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β” β”‚
              β”‚  β”‚V2 Engβ”‚β”‚V3 Engβ”‚β”‚V4 Engβ”‚ β”‚
              β”‚  β””β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”˜ β”‚
              β”‚  paths  pool_to_paths     β”‚
              β”‚  results results_block    β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚ Arc<Mutex<...>>
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚   ArbitrageEnginePump       β”‚
              β”‚  (Tokio async task)       β”‚
              β”‚  WS newHeads + logs β†’     β”‚
              β”‚  process_block β†’ solve    β”‚
              β”‚  β†’ BlockNotification      β”‚
              β”‚  backfill on gap/timeout  β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚      Bot (partial)   β”‚
              β”‚  pools: HashMap<u64,     β”‚
              β”‚    PoolEntry>            β”‚
              β”‚  ReorgJournal per pool   β”‚
              β”‚  calculate_tokens_out/in β”‚
              β”‚  encode_swap (V2 only)  β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚   MΓΆbius Solvers        β”‚
              β”‚  exact_mobius_solve     β”‚ ← V2-V2, closed-form √(KΒ·M)
              β”‚  int_solve_v3_v3        β”‚ ← V3-V3/V4-V4, piecewise integer-MΓΆbius
              β”‚  exact_solve_mixed_     β”‚ ← V2-V3/V2-V4/V3-V4, sequence + crossing
              β”‚    v2_v3_sequence       β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  tstore_executor.vy      β”‚
              β”‚  Generic payload queue   β”‚
              β”‚  V2/V3/V4 callbacks      β”‚
              β”‚  V3 auto-pay WETH       β”‚
              β”‚  V4 unlockCallback      β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

16. Test CoverageΒΆ

16.1 Rust Tests (409 tests)ΒΆ

Module

Test count

Coverage

mobius_int_exact

25+

isqrt correctness, exact vs f64, never-panics

mobius_v3_int

14+

Effective reserves, mixed sequence solver, crossing computation

v2_block_engine

15+

Registration, dual-orientation, solve, dependency tracking

v3_block_engine

10+

Registration, swap + mint/burn updates, tick-range construction

v4_block_engine

10+

Registration, hook filtering, dynamic-fee exclusion, swap + modify_liquidity

arb_engine

20+

Mixed path resolution, mixed V2-V3/V3-V2, V4 integration, freeze

arb_engine_pump

3+

Filter construction, shutdown flag

state_history

15+ unit + property tests

Push/discard/restore, proptest model equivalence

tick_bitmap

10+

gen_ticks edge cases, boundary ticks, MIN_TICK/MAX_TICK

Decoders

7+ each

Valid/wrong-topic decode, field extraction

16.2 Python Tests (3223+ tests)ΒΆ

  • tests/arbitrage/test_optimizers/test_uniswap_arb_engine.py β€” 9 integration tests for mixed V2-V3 engine

  • tests/arbitrage/test_optimizers/test_engine_v3v3_vs_brent.py β€” 13 tests comparing integer-exact V3-V3 against Brent (float) and brute-force (integer gold standard)

  • Full library test suite (3000+ tests) covers pool construction, swap calculations, state management

16.3 Validation MethodΒΆ

Each math port follows the pattern:

  1. Generate test vectors from the Python reference

  2. Write Rust tests asserting identical outputs for identical inputs

  3. Include edge cases: zero amounts, max uint256, fee boundaries, single-coin pools

  4. Engine integration tests verify end-to-end: register β†’ update β†’ solve β†’ results match Python


17. Known LimitationsΒΆ

Limitation

Impact

Mitigation

V2 asymmetric fees: register_v2_pool only uses _fee_token0

Wrong fee for one direction if asymmetric

Runtime warning on detection; full fix requires Rust engine API change

Python pool objects used for encoding

Encoding calls calculate_tokens_out_from_tokens_in() on Python pools β€” may use stale state if Rust owns updates

Encoding uses amounts from the same block (before dispatch); long-term fix is Rust-owned encoding

Bot is partial

V3 calculation/encoding not in Bot; Curve/Balancer math not ported

Engines own their own state; Bot is a future consolidation point

V4 encoding not validated on anvil fork

ABI selectors and amountSpecified verified in unit tests, not against real node

Dry-run mode validates via eth_simulateV1 before live submission

No three-hop paths

Path discovery limited to max_depth=2

Would require solver extension (3-hop MΓΆbius composition) and more complex encoding

WS stability depends on Alloy

Rust pump uses Alloy’s WS provider for dual subscriptions

Timeout backfill (60s) covers dead connections; empty-block backfill covers silent event drops; Alloy auto-reconnects