degenbot.arbitrage.engine_registry¶
The canonical bot-startup orchestrator (Plan 102, slice 3).
EngineRegistry is the one correct way to start a
ArbitrageEngine operator: it runs the
pre-pump startup ritual (subscribe → stream snapshots → backfill →
verify config) and stops before resume(), so the caller can attach its
result consumer before any batches flow. It also registers pools and paths.
It is a thin adapter: every decision it used to own now belongs to the
Rust core. Pool identity is derived, not mirrored — a pool’s engine
pool_id comes from the shared BotState through
ArbitrageEngine.pool_id_for_pool / pool_id_for_v4_pool (or straight
off the pool’s own core handle), so this module holds no address → id map
that could disagree with the state owner. The at-most-once verify claim is
likewise core-owned: run_v*_registration_lifecycle enters the session’s
VerifyClaims table inside the driver (ADR-022), so concurrent
registration workers share one lifecycle run without a Python claim table.
The name stays EngineRegistry because it remains the public registration
interface for operators; the registration state it used to own is gone.
Lifted from examples/eth_backrun_v2_v3_v4_rust.py — engine-operation
machinery only. Deployment policy (the main loop, dispatcher, simulation
overrides) stays example-side (B-mid scope).
Module Contents¶
- class degenbot.arbitrage.engine_registry.EngineRegistry(bot: degenbot.Bot | None = None, *, engine: degenbot.arbitrage.ArbitrageEngine | None = None, path_predicate: degenbot.arbitrage.policy.PathCompositionPredicate | None = None)¶
Thin adapter over the Rust engine: the public registration interface.
Registration is three questions, all answered by the core:
which id does this pool have? — the shared
BotState, either off the pool’s own core handle or, for a caller holding only an identity, viaArbitrageEngine.pool_id_for_pool()/ArbitrageEngine.pool_id_for_v4_pool().has this pool been verified? — the core’s own registration verify lifecycle, run at most once per live claim window and skipped entirely once it has completed (ADR-022 D1). The durable fact lives on the shared driver, never here.
what is this path’s id? — the engine’s signature dedup, via
register_and_solve_path().
The first pool in each path provides the flash borrow: V2 via uniswapV2Call, V3 via swapCallback, V4 via unlockCallback. V4 pools are keyed by the
(PoolManager, pool_id)pair — the engine receives the pool_manager address at registration time.- path_predicate: degenbot.arbitrage.policy.PathCompositionPredicate¶
- start(node_http: str, node_ws: str, *, v3_snapshot: degenbot.uniswap.v3_snapshot.UniswapV3LiquiditySnapshot | None = None, v4_snapshot: degenbot.uniswap.v4_snapshot.UniswapV4LiquiditySnapshot | None = None, verify_state_view: str | None = None) int¶
Run the pre-pump startup ritual and stop BEFORE resume().
Delegates the core ordering to the engine’s one-call startup ritual (the Rust
EngineDriver::startmirror —subscribe(ws)then verify-config); the Python side keeps only the snapshot seedSresolution and the non-DBset_snapshot_seed_blockcall. Stops at the snapshot-loaded phase so the caller can attach its result consumer before batches begin to flow (resume() is the single gate after which the pump emits one ResultBatch per block into the fire-and-forget channel — attaching the consumer after resume risks unbounded backlog and stale-batch dispatch; resume() also runs the auto-backfill that closes the snapshot→WS gap).DB snapshot (Shape 2): the V3+V4 DB snapshot is eagerly loaded into the core
BotStateatBot.__init__time viaBot.load_snapshot_from_db— so the DB path needs NO snapshot kwargs here. The snapshot seed blockSstays on the sharedBotState; the per-pool two-step verify (step-1) reads the stashed_verify_snapshot_block(set below from the same source), and the snapshot→WS backfill runs automatically insideresume()(BlockPump::resume_from_subscribe) using the pump’s own HTTP provider.Non-DB snapshots (file/memory): pass
v3_snapshot/v4_snapshotkwargs; each is converted to a single Python dict and handed to the engine viaload_v3_snapshot_from_py/load_v4_snapshot_from_py(ONE PyO3 crossing per family — the per-poolinsert_*_pool_snapshotcrossings), thensnapshot_block = min(s.newest_block)stashes the per-pool step-1 verify seed. These two kwargs are non-DB-only — the DB path constructs the Bot withconfig.database.pathand passes no snapshots here.- Returns:
The first observed WS block from
subscribe(the resume live-loop anchor).
- static register_v2_pool(pool: degenbot.UniswapV2Pool) int¶
Return the shared-core
pool_idof a V2 pool.ADR-006 slice 9: with the engine sharing the bot’s BotState, the V2 pool is ALREADY registered there by bot.build_pool (the V2 builder calls py_bot.register_v2_pool + hands back the Pool handle), and re-registering via engine.register_v2_pool would panic on the duplicate address. The id is read off the pool’s own core handle — the state owner’s answer, not a Python cache — and orientation is decided at register_path time (no fwd_key + 1 shim).
- Returns:
The pool’s engine
pool_id.
- static register_aerodrome_pool(pool: degenbot.aerodrome.pools.AerodromeV2Pool) int¶
Return the shared-core
pool_idof an Aerodrome V2 pool.The V2 twin of
register_v2_pool()— the pool is already registered in the sharedBotStateby the delegatedbuild_aerodrome_v2path’s call topy_bot.register_aerodrome_pool, and the engine derives the Solidly hop family from thatBotStateidentity atregister_pathtime, so no engine-side pre-registration carries a family tag.- Returns:
The registered pool’s engine
pool_id.
- async register_v3_pool(pool: degenbot.uniswap.v3_liquidity_pool.UniswapV3Pool) int¶
Run a V3 pool’s core-owned verify lifecycle and return its
pool_id.Tick data is resolved by the Rust engine from the loaded snapshot (fed via load_v3_snapshot_from_py or the DB path’s load_snapshot_from_db). The engine applies buffered events on top of stale snapshot data.
ADR-006 slice 9 / D1: the engine shares the Bot’s BotState, so the V3 pool is ALREADY registered there by bot.build_pool (the V3 builder calls py_bot.register_v3_pool + hands back the Pool handle). Re-registering via engine.register_v3_pool would PANIC the Rust core on the duplicate address — taking the process down — so this reads the shared-core pool_id off the handle and runs the lifecycle.
The at-most-once policy and the durable verify-once fact both live in the CORE: the lifecycle call enters the driver’s session claim table (ADR-022 D1), so N concurrent workers registering one pool run the choreography once and each receive its outcome, and a completed lifecycle is recorded on the driver so a later registration of the same identity is a no-op. There is deliberately no Python claim table or verified-pool set here — a caller that raced on its own map would have re-run the verify.
- Returns:
The registered pool’s engine
pool_id.
- async register_v4_pool(pool: degenbot.uniswap.v4_liquidity_pool.UniswapV4Pool) int¶
Run a V4 pool’s core-owned verify lifecycle and return its
pool_id.Tick data is resolved by the Rust engine from the loaded snapshot (fed via load_v4_snapshot_from_py or the DB path’s load_snapshot_from_db). The engine applies buffered events on top of stale snapshot data.
Pool admission (amount-modifying hooks / dynamic fees) is enforced by the Rust core as a correctness floor — the solver’s V3-CL math assumes no hook intervention + a fixed fee. A rejection surfaces as a typed
HookedPoolRejectedError/DynamicFeePoolRejectedError(both subclassValueError);build_pathsclassifies by type.ADR-006 slice 9 / D1: the pool is ALREADY registered in the shared
BotStateby bot.build_managed_pool; re-registering would raiseValueError("V4 pool already registered")for every V4 hop in every discovered path. V4 hook/dynamic-fee admission is enforced at bot.build_managed_pool time — BEFORE this method is ever called — so it surfaces from the builder, not here. As with V3, the at-most-once verify claim is the driver’s (ADR-022 D1), keyed by the(PoolManager, pool_id)pair.- Returns:
The registered pool’s engine
pool_id.
- pool_id(pool: degenbot.UniswapV2Pool | degenbot.aerodrome.pools.AerodromeV2Pool | degenbot.uniswap.v3_liquidity_pool.UniswapV3Pool | degenbot.uniswap.v4_liquidity_pool.UniswapV4Pool) int¶
Resolve the engine
pool_idof a pool from its canonical identity.The read the retired per-family key maps used to serve, now asked of the shared
BotState: a family tag plus the pool’s own address for the address-keyed families, and the(PoolManager, pool_id)pair for V4. Identity is the key, so a pool object and the core can never disagree about which id a hop means.- Returns:
The pool’s engine
pool_id.- Raises:
ValueError – If no pool with that identity is registered in the shared
BotState.
- knows_pool(address: str) bool¶
Return whether a V2 or V3 pool with address is registered.
The core-derived question (“does the shared
BotStatehold a pool with this address in this family?”) — no Python map answers it.- Returns:
True if registered.
- run_v3_verify_lifecycle_sync_with_retry(address: str, policy: degenbot.arbitrage.RetryPolicy) None¶
V3 seat-thread verify under the core-owned bounded retry dance.
The retry classification (transient RPC/provider vs fatal mismatch) and the backoff dance are the core’s; this adapter injects the driver’s resolved policy and passes the stashed snapshot seed block. A transient failure releases the lifecycle claim, so a retry re-runs the whole choreography.
- run_v4_verify_lifecycle_sync_with_retry(pool_manager: str, pool_id_hex: str, policy: degenbot.arbitrage.RetryPolicy) None¶
V4 twin of
run_v3_verify_lifecycle_sync_with_retry().
- register_crawl_path(engine_hops: collections.abc.Sequence[tuple[int, bool]]) tuple[int, bool]¶
Register a resolved hop list straight into the engine (PRG-5).
The seat-thread pathRegistration used by the crawl units: unlike
register_path()it takes ALREADY-RESOLVED(pool_id, zero_for_one)hops (the unit gets the ids off the build handles). The path-composition predicate is evaluated by the caller over the concrete pools BEFORE hop building. Returns(path_id, created)with the same semantics asregister_path()(dedup by construction in the engine, PRG-4; the cap refusal surfaces as typedPathRegistryFullError).- Returns:
(path_id, created)— created is False when the engine’s signature dedup answered (PRG-4).
- register_path(pools_and_zfos: collections.abc.Sequence[tuple[degenbot.UniswapV2Pool | degenbot.uniswap.v3_liquidity_pool.UniswapV3Pool | degenbot.uniswap.v4_liquidity_pool.UniswapV4Pool, bool]]) tuple[int, bool]¶
Register a path from concrete pool objects + per-hop directions.
Each pool’s engine key is DERIVED from the shared
BotStateby canonical identity (family + address, or the V4(PoolManager, pool_id)pair) and dispatched as a(key, zero_for_one)tuple to the engine’sregister_and_solve_path(eager solve — the path is immediately included in the next result batch). The PythonPathInforelay is retired —DispatchCandidateresolves the encoder’scomposers::PathInfofrom the returnedpath_idviaPyArbitrageEngine.path_info_for_core(no Python hop build, no stored copy).A hop whose pool is not registered in the shared
BotStateis refused bypool_id()with aValueErrorbefore the engine is reached.- Returns:
(path_id, created)— created is False when the engine’s own signature dedup answered with an existing path_id (PRG-4: dedup is by construction core-side; the Python dedup set retired).
A path-composition policy rejection (when a predicate is injected) surfaces as a typed
PathRejectedErrorsubtype (e.g.TokenDenylistedError,HopCountExceededError,DuplicatePoolError) BEFORE hop building + engine dispatch — distinct from the Rust core’s pool-admission floor (HookedPoolRejectedError/DynamicFeePoolRejectedError).