degenbot.arbitrage.engine_registry ================================== .. py:module:: degenbot.arbitrage.engine_registry .. autoapi-nested-parse:: The canonical bot-startup orchestrator (Plan 102, slice 3). :class:`EngineRegistry` is the **one correct way to start** a :class:`~degenbot._ffi.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 --------------- .. py:class:: 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, via :meth:`ArbitrageEngine.pool_id_for_pool` / :meth:`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 :meth:`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. .. py:attribute:: path_predicate :type: degenbot.arbitrage.policy.PathCompositionPredicate .. py:method:: 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::start`` mirror — ``subscribe(ws)`` then verify-config); the Python side keeps only the snapshot seed ``S`` resolution and the non-DB ``set_snapshot_seed_block`` call. 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 ``BotState`` at ``Bot.__init__`` time via ``Bot.load_snapshot_from_db`` — so the DB path needs NO snapshot kwargs here. The snapshot seed block ``S`` stays on the shared ``BotState``; 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 inside ``resume()`` (``BlockPump::resume_from_subscribe``) using the pump's own HTTP provider. Non-DB snapshots (file/memory): pass ``v3_snapshot``/``v4_snapshot`` kwargs; each is converted to a single Python dict and handed to the engine via ``load_v3_snapshot_from_py`` / ``load_v4_snapshot_from_py`` (ONE PyO3 crossing per family — the per-pool ``insert_*_pool_snapshot`` crossings), then ``snapshot_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 with ``config.database.path`` and passes no snapshots here. :returns: The first observed WS block from ``subscribe`` (the resume live-loop anchor). .. py:method:: register_v2_pool(pool: degenbot.UniswapV2Pool) -> int :staticmethod: Return the shared-core ``pool_id`` of 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``. .. py:method:: register_aerodrome_pool(pool: degenbot.aerodrome.pools.AerodromeV2Pool) -> int :staticmethod: Return the shared-core ``pool_id`` of an Aerodrome V2 pool. The V2 twin of :meth:`register_v2_pool` — the pool is already registered in the shared ``BotState`` by the delegated ``build_aerodrome_v2`` path's call to ``py_bot.register_aerodrome_pool``, and the engine derives the Solidly hop family from that ``BotState`` identity at ``register_path`` time, so no engine-side pre-registration carries a family tag. :returns: The registered pool's engine ``pool_id``. .. py:method:: register_v3_pool(pool: degenbot.uniswap.v3_liquidity_pool.UniswapV3Pool) -> int :async: 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``. .. py:method:: register_v4_pool(pool: degenbot.uniswap.v4_liquidity_pool.UniswapV4Pool) -> int :async: 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 subclass ``ValueError``); ``build_paths`` classifies by type. ADR-006 slice 9 / D1: the pool is ALREADY registered in the shared ``BotState`` by `bot.build_managed_pool`; re-registering would raise ``ValueError("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``. .. py:method:: 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_id`` of 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``. .. py:method:: knows_pool(address: str) -> bool Return whether a V2 or V3 pool with `address` is registered. The core-derived question ("does the shared ``BotState`` hold a pool with this address in this family?") — no Python map answers it. :returns: True if registered. .. py:method:: 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. .. py:method:: run_v4_verify_lifecycle_sync_with_retry(pool_manager: str, pool_id_hex: str, policy: degenbot.arbitrage.RetryPolicy) -> None V4 twin of :meth:`run_v3_verify_lifecycle_sync_with_retry`. .. py:method:: 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 :meth:`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 as :meth:`register_path` (dedup by construction in the engine, PRG-4; the cap refusal surfaces as typed :class:`PathRegistryFullError`). :returns: ``(path_id, created)`` — `created` is False when the engine's signature dedup answered (PRG-4). .. py:method:: 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 ``BotState`` by canonical identity (family + address, or the V4 ``(PoolManager, pool_id)`` pair) and dispatched as a ``(key, zero_for_one)`` tuple to the engine's ``register_and_solve_path`` (eager solve — the path is immediately included in the next result batch). The Python ``PathInfo`` relay is retired — ``DispatchCandidate`` resolves the encoder's ``composers::PathInfo`` from the returned ``path_id`` via ``PyArbitrageEngine.path_info_for_core`` (no Python hop build, no stored copy). A hop whose pool is not registered in the shared ``BotState`` is refused by :meth:`pool_id` with a ``ValueError`` before 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 ``PathRejectedError`` subtype (e.g. ``TokenDenylistedError``, ``HopCountExceededError``, ``DuplicatePoolError``) BEFORE hop building + engine dispatch — distinct from the Rust core's pool-admission floor (``HookedPoolRejectedError`` / ``DynamicFeePoolRejectedError``).