ADR-007: Pool unregister seam (the symmetric half of register; B2 construction shape settled)¶
Status: accepted. Recorded during the IESD3L session, June 2026. Resolves the “what does
Bot.build_pool return” question carried in the IESD3L task body as a B1/B2/B3 open decision,
records why duplicate-registration stays refusal-on-panic, and adds the missing
unregister_pool seam so the Python PoolRegistry and the Rust PyBot stay symmetric about
removal (they are already symmetric about construction). Implementation is tracked as IESD3L
children G2TGP6 (Rust), PDRB42 (Python wire), 3K4ZQF (docs).
Context¶
ADR-003 settled that Bot (formerly BotCore) is the single owner of pool/token state; thin
PyO3 handles (PyLiquidityPool, PyErc20Token) read it. ADR-005 then settled the wrapper
topology — and its Slice 4 implemented what the IESD3L task body called “Option B2”: every
Bot.build_pool path registers the pool in PyBot first, then wraps the returned
PyLiquidityPool handle in a Python companion class:
V2:
src/degenbot/builders/v2_pool_builder.py:146callsself._py_bot.register_v2_pool(...), thenself._py_bot.get_pool(pool_id), then constructs the PythonUniswapV2Pool(py_pool, ...).V3 and V4 builders do the same for
UniswapV3Pool/UniswapV4Pool, mirroringErc20Token-wraps-PyToken(the token-side split ADR-003 recorded).EngineRegistry.register_v2_pool(src/degenbot/arbitrage/engine_registry.py:147-151) is explicit that its register call would now panic on the duplicate address because the pool is already registered in the sharedBotState— it caches the sharedpool_idinstead.
So the construction shape — the thing IESD3L was framed as needing to decide — is already the
B2 pattern the task body recommended. B1 (return the raw PyPool handle) would break every
library caller of AbstractLiquidityPool (uniswap/trackers.py, curve/trackers.py,
examples/eth_backrun_*.py); B3 (delete the PoolRegistry, make BotState the registry) is a
larger-blast-radius cleanup that is not needed for correctness. The only actually-open gap is the
missing unregister half of the seam, which surfaced concretely as a failing test:
tests/uniswap/test_uniswap_trackers.py::test_pool_remove_and_recreate does:
uniswap_v2_pool_tracker.remove(pool_address=new_v2_weth_wbtc_lp.address)
bot.pools.remove(pool_address=new_v2_weth_wbtc_lp.address, chain_id=1) # Python-only removal
super_new_v2_weth_wbtc_lp = uniswap_v2_pool_tracker.get_pool_from_tokens(...) # re-build
PoolRegistry.remove (src/degenbot/registry/pool.py:156) deletes only the Python wrapper.
BotState retains the entry in pool_addresses: HashMap<Address, u64> and pools: HashMap<u64, PoolEntry> (rust/crates/engine/degenbot-bot/src/bot_core/mod.rs:225-227), because BotState has
register_v2/v3/v4_pool but no unregister. Re-build_pool calls register_v2_pool again
and panics: pyo3_runtime.PanicException: pool already registered (the assert! at
bot_core/mod.rs:285 and :348). Confirmed pre-existing at clean HEAD; the two halves of the
seam were never built together.
Decision¶
Three sub-decisions.
U1 — B2 is settled; the construction shape is not revisited¶
Bot.build_pool / build_erc20token / build_managed_pool return the Python companion
wrapping a PyLiquidityPool / PyToken handle (Option B2 in the IESD3L option matrix). This is
what ADR-005 Slice 4 already implemented; this ADR records it as the resolved decision so the
B1/B2/B3 option matrix in the IESD3L task body is closed, not re-litigated. The matrix is retained
only as historical context.
The Erc20Token-wraps-PyToken pattern is the reference: Rust owns the state it computes on
(reserves, ticks, sqrt_price, token metadata); the Python companion owns I/O orchestration
(subscriptions, the price oracle, display). The pool-side follows the same split.
U2 — Duplicate-registration stays refusal-on-panic (not idempotent)¶
BotState::register_v2/v3/v4_pool keep their panic!/Err on a duplicate address
(mod.rs:285, :348, V4 returns Err). They do not become “return the existing pool_id
if already present.” Rationale:
The panic is load-bearing as an invariant.
EngineRegistry(arbitrage/engine_registry.py:144, 179, 220) cachesaddress → pool_idin_v2_keys/_v3_keys/_v4_keysprecisely because the engine sharesBotStatewith the bot and cannot re-register. ADR-006 D3 specified the engine never constructs pools; the cache-and-skip is the consequence. Making register idempotent would let those caches go stale silently — the divergent state the test is probing for would propagate instead of fail fast.Removal is the explicit, paired operation:
PoolRegistry.addis preceded by aPyBotregister in the builders;PoolRegistry.remove/resetmust be followed by aPyBotunregister. The seam is symmetric — symmetric seams are cheap to reason about; “register is idempotent but remove is explicit” is not.
ADR-006 D1 records the panic behavior as load-bearing today (”Bot::register_v2_pool/
register_v3_pool panic on duplicate address … so the two registries must be separate or
the double-registration flow panics”). ADR-006 then unified the registries; the panic stayed as
the double-registration detector and EngineRegistry stopped double-registering. U2 retains the
panic for that detector role under the unified registry.
U3 — unregister_pool seam: address-keyed for V2/V3, (pool_manager, pool_id)-keyed for V4¶
Add BotState::unregister_pool and a PyBot::unregister_pool PyO3 method:
// BotState — V2/V3 path (PyBot-exposed) + V4 path (engine-exposed, deferred here)
pub fn unregister_pool(&mut self, address: Address, pool_id: Option<PoolId>) -> bool
// PyBot — V2/V3 only (matches what PyBot exposes today: register_v2/v3_pool, no V4)
#[pyo3(signature = (address, pool_id=None))]
pub fn unregister_pool(&self, address: &str, pool_id: Option<Vec<u8>>) -> PyResult<bool>
(The V4 pool_id: Some arm is in BotState for when the engine-side unregister lands;
PyBot itself only takes the V2/V3 path — see above — and returns PyResult<bool>
matching PoolRegistry.remove’s silent-on-miss contract. V4PoolId is
degenbot_decoders::v4_swap_decoder::V4PoolId — the V4 [u8; 32] pool-id type.)
Disposal rules:
V2/V3 path (
pool_idisNone): resolveaddress→u64viapool_addresses, drop thePoolEntryfrompoolsand the entry frompool_addresses. For V3, also drainv3_bufferfor that address (else a re-register after a remove would replay stale buffered Mint/Burn events onto the fresh pool). The reorg journal lives on thePoolEntryand is dropped with it — restore for a removed pool is a no-op target (the journal no longer exists).V4 path (
pool_idisSome): theaddressarg is the PoolManager contract address (one PoolManager hosts many pool ids — address alone is ambiguous, hence the V4 tuple key). Resolve(address, pool_id)→u64viav4_pool_ids, drop thePoolEntryfrompools, the tuple fromv4_pool_ids, and drainv4_bufferfor the same key (symmetric reason — stale bufferedModifyLiquiditymust not replay onto a re-created pool).V4 on
PyBot: deferred to the engine-side seam.PyBotdoes not expose a V4register_v4_pooltoday — V4 registration lives onArbitrageEngine(rust/src/py_binding.rs:1332, invoked viaEngineRegistry.register_v4_poolwherepool.addressis the PoolManager), not onPyBot. SoPyBot::unregister_poolhandles only the V2/V3 path; the V4(address=pool_manager, pool_id)path lands onArbitrageEnginealongside the engine-side unregister that “Consequences” already defers — the V4 removal is the matching half of the V4 registration that lives on the engine.Return contract:
trueif an entry was found and removed;falseif the address/tuple was never registered (silent no-op, returningfalse). Mirrors the PythonPoolRegistry.removesilent-on-miss behavior; theboolis for testability and a future engine-key cleanup.next_pool_idis not reused. Removedpool_ids are retired: a subsequent re-register allocates a freshnext_pool_id. This prevents a stalePyLiquidityPoolhandle (retained by a Python caller that missed theremovesignal) from aliasing onto a different pool that happens to be assigned the recycled id. Retiring ids has no allocation cost (u64is wide and retired ids are never scanned).
The v3_buffer / v4_buffer are LiquidityEventBuffer<K, U>
(rust/crates/engine/degenbot-bot/src/optimizers/liquidity_event_buffer.rs). It exposes
buffer_backfill/buffer_pump/drain_backfill/drain_pump/event_count/flush/expire but
no per-key discard. U3 adds one: discard_for(&mut self, key: &K) — drops all buffered
events for a single key (used by unregister). It is the symmetric inverse of buffer_pump(key, …)
buffer_backfill(key, …); the existingflushis the global variant.
Considered options¶
U2-alt — idempotent
register_*returning the existingpool_id. Rejected for the reasons in U2: it would mask the divergent state the failing test exists to probe, and would leaveEngineRegistry’s address-keyed caches without their double-registration detector. A failure mode that the panic surfaces immediately becomes silent drift between PythonPoolRegistryand RustBotState.U3-alt — make unregister a
panic!on miss (matching register’s panic-on-dup). Rejected. Register panics because a duplicate is a correctness event (the caller is about to create two sources of truth for one address). Unregister on a miss is benign — the post-condition (“the address is no longer registered”) already holds; panicking would force Python callers to guard everyremovewith apools.get(...)probe. The asymmetry (register panics, unregister is lenient) reflects the asymmetry in the operations’ invariants, not an inconsistency.U3-alt — key V4 by
pool_idalone (no PoolManager). Rejected: V4’s PoolManager hosts an unbounded number of pools on one contract; a barepool_idcollides across managers. The existingv4_pool_ids: HashMap<(Address, PoolId), u64>already encodes this; U3 mirrors it.U3-alt — reuse
next_pool_idafter unregister. Rejected (see U3): the recycling savings are nil and the aliasing risk is real — aPyLiquidityPoolhandle a Python caller retained through an unregister would resolve to a different pool after a recycle and silently corrupt reads.
Consequences¶
The Python
PoolRegistrygains an optionalpy_bot: PyBot | Nonereference (constructor arg, defaultNoneso tests that constructPoolRegistry()standalone still work).Bot.__init__passespy_bot=self._py_bot.PoolRegistry.remove/reset+ManagedPoolRegistry.removecallpy_bot.unregister_pool(...)before/after mutating the Python store (the Rust call is skipped whenpy_bot is None, preserving current behavior for non-bot registries).test_pool_remove_and_recreatepasses. This is the acceptance test for U3.EngineRegistry._v2_keys/_v3_keys/_v4_keyscache a staleaddress → pool_idafter a remove that goes throughbot.pools.remove. This is a known gap, explicitly out of scope here: no test or production path exercises remove/recreate through the engine today (EngineRegistryhas nounregister_pooland no caller). Recorded for a future cleanup; the_v*_keysdicts would need their ownunregister_poolthat pops the address and, if the engine holds per-path solver state referencing thatpool_id, clears or invalidates those paths. ADR-006 D3 already says “the engine never constructs pools” — the symmetric engine-side unregister is the matching half of that decision, separate from U3’s Python↔RustBotseam.Reorg correctness for removed pools:
restore_before_blockon a removedpool_idis a no-op (the entry is gone). A re-created pool with a freshpool_idhas only its genesis journal delta; it cannot be restored to pre-removal state. This is correct — a removed pool was removed; there is no pre-removal state to restore to. The journal depth and pump reorg detection (ADR-003 Option α) are unaffected; they key offpool_address/pool_idthat no longer resolve for the removed pool and naturally skip it.
Deferred¶
Engine-side unregister (
EngineRegistry.unregister_pool+ path invalidation when a pool referenced by a registered path is removed). Out of scope: no caller exercises it.ManagedPoolRegistry._resetparity withPoolRegistry._reset. The V4 registry rarely holds entries that get reset in bulk; wire it only if a test or caller needs it. Python slice (PDRB42) implements only whattest_pool_remove_and_recreateand symmetry require.