# 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:146` calls `self._py_bot.register_v2_pool(...)`,
then `self._py_bot.get_pool(pool_id)`, then constructs the Python `UniswapV2Pool(py_pool, ...)`.
- V3 and V4 builders do the same for `UniswapV3Pool` / `UniswapV4Pool`, mirroring
`Erc20Token`-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 shared `BotState` — it caches the shared `pool_id` instead.
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:
```python
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
` and `pools: HashMap` (`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`) caches `address → pool_id` in `_v2_keys`/`_v3_keys`/`_v4_keys` precisely *because*
the engine shares `BotState` with 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.add` is preceded by a `PyBot`
register in the builders; `PoolRegistry.remove`/`reset` must be **followed by** a `PyBot`
unregister. 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:
```rust
// BotState — V2/V3 path (PyBot-exposed) + V4 path (engine-exposed, deferred here)
pub fn unregister_pool(&mut self, address: Address, pool_id: Option) -> 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>) -> PyResult
```
(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`
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_id` is `None`): resolve `address` → `u64` via `pool_addresses`, drop the
`PoolEntry` from `pools` and the entry from `pool_addresses`. For V3, also drain
`v3_buffer` for 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 the `PoolEntry` and is dropped
with it — restore for a removed pool is a no-op target (the journal no longer exists).
- **V4 path** (`pool_id` is `Some`): the `address` arg is the **PoolManager contract address**
(one PoolManager hosts many pool ids — address alone is ambiguous, hence the V4 tuple key).
Resolve `(address, pool_id)` → `u64` via `v4_pool_ids`, drop the `PoolEntry` from `pools`, the
tuple from `v4_pool_ids`, and drain `v4_buffer` for the same key (symmetric reason — stale
buffered `ModifyLiquidity` must not replay onto a re-created pool).
**V4 on `PyBot`: deferred to the engine-side seam.** `PyBot` does not expose a V4
`register_v4_pool` today — V4 registration lives on `ArbitrageEngine`
(`rust/src/py_binding.rs:1332`, invoked via `EngineRegistry.register_v4_pool` where
`pool.address` is the PoolManager), not on `PyBot`. So `PyBot::unregister_pool` handles only
the V2/V3 path; the V4 `(address=pool_manager, pool_id)` path lands on `ArbitrageEngine`
alongside 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**: `true` if an entry was found and removed; `false` if the address/tuple was
never registered (silent no-op, returning `false`). Mirrors the Python `PoolRegistry.remove`
silent-on-miss behavior; the `bool` is for testability and a future engine-key cleanup.
- **`next_pool_id` is not reused.** Removed `pool_id`s are retired: a subsequent re-register
allocates a fresh `next_pool_id`. This prevents a stale `PyLiquidityPool` handle (retained by a
Python caller that missed the `remove` signal) from aliasing onto a *different* pool that
happens to be assigned the recycled id. Retiring ids has no allocation cost (`u64` is wide and
retired ids are never scanned).
The `v3_buffer` / `v4_buffer` are `LiquidityEventBuffer`
(`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 existing `flush` is the global variant.
## Considered options
- **U2-alt — idempotent `register_*` returning the existing `pool_id`.** Rejected for the reasons
in U2: it would mask the divergent state the failing test exists to probe, and would leave
`EngineRegistry`'s address-keyed caches without their double-registration detector. A failure
mode that the panic surfaces immediately becomes silent drift between Python `PoolRegistry` and
Rust `BotState`.
- **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
every `remove` with a `pools.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_id` alone (no PoolManager).** Rejected: V4's PoolManager hosts an
unbounded number of pools on one contract; a bare `pool_id` collides across managers. The
existing `v4_pool_ids: HashMap<(Address, PoolId), u64>` already encodes this; U3 mirrors it.
- **U3-alt — reuse `next_pool_id` after unregister.** Rejected (see U3): the recycling savings are
nil and the aliasing risk is real — a `PyLiquidityPool` handle a Python caller retained through
an unregister would resolve to a *different* pool after a recycle and silently corrupt reads.
## Consequences
- The Python `PoolRegistry` gains an optional `py_bot: PyBot | None` reference (constructor arg,
default `None` so tests that construct `PoolRegistry()` standalone still work). `Bot.__init__`
passes `py_bot=self._py_bot`. `PoolRegistry.remove`/`reset` + `ManagedPoolRegistry.remove` call
`py_bot.unregister_pool(...)` before/after mutating the Python store (the Rust call is skipped
when `py_bot is None`, preserving current behavior for non-bot registries).
- `test_pool_remove_and_recreate` passes. This is the acceptance test for U3.
- `EngineRegistry._v2_keys`/`_v3_keys`/`_v4_keys` cache a stale `address → pool_id` after a
remove that goes through `bot.pools.remove`. **This is a known gap, explicitly out of scope
here**: no test or production path exercises remove/recreate through the engine today
(`EngineRegistry` has no `unregister_pool` and no caller). Recorded for a future cleanup; the
`_v*_keys` dicts would need their own `unregister_pool` that pops the address and, if the engine
holds per-path solver state referencing that `pool_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↔Rust `Bot` seam.
- Reorg correctness for removed pools: `restore_before_block` on a removed `pool_id` is a no-op
(the entry is gone). A re-created pool with a fresh `pool_id` has 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 off `pool_address`/`pool_id` that no
longer resolve for the removed pool and naturally skip it.
## Related
- **ADR-003** (BotCore as state layer) — `BotState` owns `pool_addresses`/`pools`/`v4_pool_ids`
that this ADR's `unregister_pool` mutates; the per-pool reorg journal this ADR drops on remove.
- **ADR-005** (Polars-inspired three-layer) — Slice 4 implemented B2 (the wrapper construction
shape); this ADR records B2 as settled and adds the missing removal half.
- **ADR-006** (Bot as per-chain orchestrator) — D3 ("the engine never constructs pools") is the
reason `EngineRegistry.register_v2_pool` caches instead of re-registering; U2's panic-on-dup
remains its detector. The engine-side unregister (out of scope here, see Consequences) is the
matching half of D3 for removal.
## 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._reset` parity with `PoolRegistry._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 what `test_pool_remove_and_recreate` and symmetry require.