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|V4BlockEnginedesign from Plans 079β082. It predates thebot_core/solversrestructure ofdegenbot-bot(theoptimizers/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 |
|
Unified V2+V3+V4 engine: state, solving, result storage |
V2BlockEngine |
Rust |
|
V2 pool state, Sync decoding, constant-product solving |
V3BlockEngine |
Rust |
|
V3 pool state, Swap/Mint/Burn decoding, tick-range construction, piecewise solving |
V4BlockEngine |
Rust |
|
V4 pool state, Swap/ModifyLiquidity decoding, same CL math as V3 |
ArbitrageEnginePump |
Rust |
|
Unified async pump: dual WS subscription (newHeads + logs), backfill on timeout/empty block, routes to sub-engines |
Bot |
Rust |
|
Single owner of pool/token state (future all-state owner, currently V2+V3 partial) |
ReorgJournal |
Rust |
|
Bounded deque of per-block deltas for rollback (V2: 2 reserves; V3: scalars + tick priors) |
Tick bitmap walk & tick mutation |
Rust |
|
|
Event decoders |
Rust |
|
Decode Sync, Swap, Mint/Burn, ModifyLiquidity from Alloy logs |
MΓΆbius solvers |
Rust |
|
Integer-exact arbitrage solvers (V2-V2, mixed V2-V3, V3-V3) |
V2 swap encoding |
Rust |
|
Pre-encoded |
PyArbitrageEngine |
Rust/PyO3 |
|
PyO3 wrapper exposing engine to Python |
Executor contract |
Vyper |
|
Generic payload queue with V2/V3/V4 callbacks |
Settlement-arbitrage bot |
Python |
|
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? |
|---|---|---|---|
|
V2 |
contract address β forward pool_id; reverse = forward + 1 |
Yes |
|
V3 |
contract address β pool_key |
No (zfo in path ref) |
|
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:
Categorise logs by topic0:
V2_SYNC_TOPICβ V2,V3_SWAP/MINT/BURN_TOPICβ V3,V4_SWAP/MODIFY_LIQUIDITY_TOPICβ V4Route each log batch to the appropriate sub-engineβs
process_blockCollect affected pool keys from each sub-engine (dual-orientation for V2/V4)
Rebuild and re-solve only paths that reference affected pools (via
pool_to_pathsreverse index)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 |
|
Closed-form β(KΒ·M) via U512 isqrt; Β±2 neighborhood search |
V3-V3, V4-V4 |
|
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 |
|
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 |
Mint |
Delegates to |
Burn |
Same as Mint with negated |
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:
Calls
compute_tick_ranges()β the Rust port ofgen_ticks()that walkstick_bitmap: HashMap<i16, U256>and interleaves boundary ticks with initialised ticksFor each range, constructs
IntV3TickRangeHopwith U256sqrt_price_*and u128liquidityComputes 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) != 0where0xCC = 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 == 0x100000indicates 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_coefficientsChecks
K > Mfor profitabilityComputes
x_opt = (isqrt(KΒ·M) - M) / Nusingisqrt_u512(Newtonβs method in U512, ~100ns)EVM-simulates at
x_optand Β±2 neighbors to handle floor-division roundingReturns 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:
For each (kβ, kβ) pair of ending tick ranges, computes a single (K, M, N) triple by composing the shifted MΓΆbius transforms
Calls
exact_mobius_solvefor closed-form optimal input β no golden-section searchValidates via
int_simulate_v3_v3_pathwith 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:
Enumerate V3/V4 ending ranges (like
int_solve_v3_v3)For each candidate ending range k, compute crossing data
Build mixed hop list with the ending rangeβs effective reserves
Solve via closed-form MΓΆbius
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:
newHeadsβ block boundary notifications (block number, timestamp, base fee, gas)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:
Address filter limits β some providers reject subscriptions with >1000 address constraints, or silently ignore them
Topic filter truncation β some providers handle 6-topic OR filters incorrectly
Provider-dependent filter semantics β different providers interpret
addressandtopicconstraints 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:
Logs a warning with the last-seen block
Calls
eth_getLogsfromlast_processed_block + 1to the latest block (determined viaeth_blockNumber)Processes any logs found, filling the gap
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ΒΆ
|
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 |
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+ 3logs)1 lock acquisition per block instead of 3
Single
eth_getLogsbackfill 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:
Bot.from_config_file()β load config, DB, connectionsbuild_paths_asyncβ discover V2/V3/V4 pools, build Python pool objects, register with engineengine.freeze()β lock registrationengine.initial_solve()β solve all paths from current statebackfill_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.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; seeEngineRegistry.start())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 |
|---|---|
|
Append same-block replacement, reject older blocks, evict beyond max_depth |
|
Remove deltas older than the target (no longer rollback-reachable) |
|
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 |
|
|
|
V3 Swap |
|
|
|
V3 Mint |
|
|
|
V3 Burn |
|
|
|
V4 Swap |
|
|
|
V4 ModifyLiquidity |
|
|
|
Key differences from Python:
~50ns decode time per event vs ~5-10Β΅s via Python
eth_abiZero-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:
Read
t_payloads[index]If
will_callback=True, register target int_allowed_callback_addressesraw_call(target, calldata)β_no return value checkAdvance queue index
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 |
|---|---|---|
|
Uniswap/SushiSwap V2 |
|
|
Aerodrome/Velodrome V2 |
Same |
|
PancakeSwap V2 |
Same |
|
Uniswap/SushiSwap V3 |
|
|
PancakeSwap V3 |
Same |
|
Uniswap V4 |
|
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 swapERC20.transfer(PoolManager, amount)β pay debt to PMPoolManager.sync(currency)β update PMβs internal balance trackingPoolManager.settle()β credit tokens to our deltaPoolManager.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 |
|
V3.swap (callback) |
Token transfers |
V3-V3 |
|
V3.swap (callback) |
Token transfers (nested callbacks) |
V2-V2 |
|
V2.swap (flash borrow) |
Token transfers |
V2-V3 |
|
V2.swap (flash borrow) |
Token transfers (nested callbacks) |
V4-V4 |
|
PM.unlock (callback) |
sync/settle/take |
V4-V3 |
|
PM.unlock (callback) |
sync/settle/take + token transfers |
V3-V4 |
|
V3.swap (callback) β PM.unlock (nested) |
sync/settle/take |
V4-V2 |
|
PM.unlock (callback) |
sync/settle/take + token transfers |
V2-V4 |
|
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 |
|
|
Exact OUTPUT |
|
|
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 |
|
1 |
Parallel simulation |
|
2 |
Staleness tracking |
|
3 |
Market-aware fees |
Target from profit ratio, age decay, feeHistory percentile bounds |
4 |
Best-path selection |
Sort by profit desc, mutual exclusivity via |
5 |
Gas from simulation |
|
6 |
WS reconnection |
Exponential backoff + pool state reconciliation (handled by Rust pumpβs Alloy provider) |
7 |
Subscription filtering |
Address filter on WS |
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 |
|
|
Uniswap V3 |
|
|
Sushiswap V2 |
|
|
Sushiswap V3 |
|
|
PancakeSwap V3 |
|
|
Uniswap V4 |
|
PoolManager |
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) |
|
Skip paths with known scam/tax tokens |
Whitelist |
|
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, zeropyo3imports.PyO3 wrapper β
PyBot(#[pyclass]) holdsArc<parking_lot::RwLock<Bot>>and is the sharing mechanism;PyLiquidityPool(carrying apool_idkey) andPyErc20Token(carrying anAddresskey) clone thatArcso N Python handles reference one Rust-ownedBot. Reads take a read guard; mutations take a write guard.Python session β
bot.py:Botconstructsself._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 ( |
Hold |
~20ns compute; GIL release/reacquire costs ~200ns |
Address utils ( |
Hold |
~50ns compute |
Provider I/O ( |
Release via |
I/O-bound; holding the GIL blocks all Python threads |
Pump main loop |
Never acquires |
Runs on Tokio worker threads; communicates via |
|
Attach in wrapper |
Wraps |
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 |
|---|---|---|
|
25+ |
isqrt correctness, exact vs f64, never-panics |
|
14+ |
Effective reserves, mixed sequence solver, crossing computation |
|
15+ |
Registration, dual-orientation, solve, dependency tracking |
|
10+ |
Registration, swap + mint/burn updates, tick-range construction |
|
10+ |
Registration, hook filtering, dynamic-fee exclusion, swap + modify_liquidity |
|
20+ |
Mixed path resolution, mixed V2-V3/V3-V2, V4 integration, freeze |
|
3+ |
Filter construction, shutdown flag |
|
15+ unit + property tests |
Push/discard/restore, proptest model equivalence |
|
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 enginetests/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:
Generate test vectors from the Python reference
Write Rust tests asserting identical outputs for identical inputs
Include edge cases: zero amounts, max uint256, fee boundaries, single-coin pools
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 |
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 |
Encoding uses amounts from the same block (before dispatch); long-term fix is Rust-owned encoding |
|
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 |
No three-hop paths |
Path discovery limited to |
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 |