# ADR-012: Spec-Bound Pool Admission Contract **Status: accepted.** Implementation shipped in epic `WOYYS2` (tasks `ZOICEZ`, `MSTAT2`, `24KNGF`, `K3IICB`, `F2EVV6`). The final-audit task `RNZQUO` confirms the contract is enforced for V2, V3, and V4. ## Context Pool state in degenbot flows from two sources: on-chain events (V2 `Sync(uint112,uint112)`, V3/V4 `Swap`/`Initialize`) and Rust-side pool construction. Before `WOYYS2`, registration (`register_v2_pool` / `register_v3_pool` / `register_v4_pool`) **did not enforce** the on-chain storage widths: it stored whatever `U256` it was handed. Out-of-spec state (`reserve > uint112::MAX`, `sqrtPriceX96` outside the TickMath bounds, `fee > type(u24).max`, `tick < MIN_TICK`, etc.) could therefore reach downstream swap math (`MobiusSolver`, `u512_to_u256_internal`, `IntHopState::swap`). The downstream narrowing helpers responded to that hazard with **silent saturation** to `U256::MAX`, then later with an `assert!` panic. Both were unsatisfying: - **Saturation** propagated garbage through Möbius / CL computations and hid the corruption at the source. - **`assert!`** was a correct invariant statement but a poor *contract*: the failure message did not say *where* the upstream enforcement was assumed to live. The deeper problem was architectural — the spec widths belong to the **admission** boundary (registration), not to every downstream consumer that happens to narrow a wider integer. The narrowing helpers were carrying an obligation they had no authority over. ## Decision ### Registration is the single spec-verification seam `register_v2_pool`, `register_v3_pool`, and `register_v4_pool` accept *spec-bound* state and reject *out-of-spec* state with a typed error before storing it. The validation helpers live in `rust/crates/engine/degenbot-bot/src/bot_core/spec_bounds.rs`: `validate_v2_reserve`, `validate_sqrt_price`, `validate_tick`, `validate_v3_fee`, `validate_tick_spacing`. Each returns a `SpecViolation` carrying the offending field name so callers (and the PyO3 layer) can surface it. The register functions return a typed `RegisterV×PoolError` enum whose two meaningful admission variants are `AlreadyRegistered` and `SpecViolation`: | Family | Error type | Variants | |--------|------------|----------| | V2 | `RegisterV2PoolError` | `AlreadyRegistered`, `SpecViolation` | | V3 | `RegisterV3PoolError` | `AlreadyRegistered`, `SpecViolation` | | V4 | `RegisterV4PoolError` | `AlreadyRegistered`, `SpecViolation`, `HookedPoolRejected`, `DynamicFeePoolRejected` | V4 carries the two pre-existing admission categories (`HookedPoolRejected`, `DynamicFeePoolRejected` — see ADR Program Plan-102) alongside the new `SpecViolation` and the upgraded `AlreadyRegistered`. ### Narrowing helpers narrow; they do not re-verify With spec-bound admission, downstream narrowing (`u512_to_u256_internal` in `mobius_int_exact.rs`, the in-body narrowing in `IntHopState::swap`) stops being an invariant *guard* and reverts to being a plain `assert!` that documents the upstream contract. The narrowing's `# Panics` notes cite **registration-time enforcement** as the upstream guarantee, and the assertion message names the enforcement site (`spec-bound pool state is unreachable — enforced at register_*_pool`). The narrowing helpers do **not** grow richer rejection (`Option`/`Result`); they remain panicking. They are reachable only by synthetic / corrupt construction that bypasses the seam — i.e. a Rust call-site that hand-builds an `IntHopState` with out-of-spec reserves. That is a programming error, not a runtime failure mode of bot state. ### PyO3 layer: one typed exception hierarchy per admission family The PyO3 wrappers in `degenbot-python/src/bot/engine/register.rs` translate each `RegisterV×PoolError` to a typed Python exception via the `map_register_v{2,3,4}_err` mappers. The Python exception hierarchy is: ``` ValueError └── PoolRegistrationError (base — added by F2EVV6) ├── HookedPoolRejectedError (V4 — amount-modifying hook) ├── DynamicFeePoolRejectedError (V4 — dynamic-fee flag) ├── PoolAlreadyRegisteredError (V2/V3/V4 — duplicate address) └── SpecViolationError (V2/V3/V4 — bad field) ``` All pyclasses live in `degenbot-python`; the bot core exposes only the `RegisterV×PoolError` enums. The mappers are pure translation tables, no business logic. A standalone Rust consumer (`cargo add degenbot`) sees the typed enums and no Python at all — the standalone-Rust-core constraint (ADR-005) is preserved. `just check-no-pyo3-in-cores` stays green. The hierarchy subclasses `ValueError` so the broad `except ValueError:` net in `build_paths` (Python) keeps working; callers that want to scope just admission refusals use `except PoolRegistrationError:`. ## Consequences - **Caller contract.** A `register_v×_pool` call that succeeds is a *guarantee*: all stored pool state is spec-bound. Downstream math can rely on the narrowing helper's assertion confidently without carrying an independent validator. - **Migration path.** Pools registered before this contract shipped are unaffected — the contract is enforced on *registration*, not on read. Stale pre-WOYYS2 state on disk is treated as trusted at load; new registrations and updates go through the seam. (If a future migration wants to retro-verify stored state, the `spec_bounds.rs` helpers are the right tool — no new validator code is needed.) - **The narrowing `assert!`s stay.** They are not dead defensive code: they are a runtime-emergent invariant statement for the path that bypasses registration (synthetic / programmatic construction of pool state objects in tests or benchmarks). Their existence does not contradict the seam contract — it makes the contract visible at the point where a violation would first corrupt a computation. - **No backwards-compatibility shim.** The previous silently-saturating narrowing is **deleted**, not retained behind a feature flag. Callers that fed synthetic garbage and relied on the sat-cap are expected to fix their construction, not their stepping code. This is consistent with the project guideline against backwards-compat layers for retired implementations. ## Addendum: type-level enforcement (epic `ZPHT6X`) The original decision enforced V2-family spec widths at *registration* but stored the reserves as `U256`, leaving the narrowing helper as the runtime guard. Epic `ZPHT6X` "Typed V2 reserve width (`U112`) + drop swap-primitive `U512` dodge" deepens the contract: the spec width is now enforced at the **type level**, superseding the runtime check. This addendum realizes the "deeper fix" forward-referenced in commit `19218a2c` ("refactor(rust): panic on U512→U256 overflow in mobius solvers"), which converted the silently-saturating narrowing to an explicit pre-check + panic but deferred the type-narrowing to a separate multi-crate refactor. The changes: - **(a) V2 reserve storage is now `U112`.** `V2PoolState`'s `reserve0` / `reserve1` fields are `alloy::primitives::aliases::U112`, the on-chain `uint112` storage type. `register_v2_pool` accepts `U112` reserves (the `RegisterV2PoolParams` fields are `U112`), so the runtime `validate_v2_reserve` check (`value > UINT112_MAX`) is a **tautology** for type-correct callers — a `U112` cannot exceed `uint112::MAX`. The runtime check **remains** as a diagnostic-message uniformity aid (it returns the `SpecViolation` with the offending field name, which the bare type would not), and as a guard against any future code path that widens-then-narrows around the typed seam. This is the registration-time enforcement cited by the Möbius narrowing helper's `# Panics` docstring. - **(b) `IntHopState::swap` does native `U256` arithmetic.** The V2 swap primitive (`degenbot_v2_math::hop_state::IntHopState::swap`) previously widend to `U512` for the multiply-then-narrow dodge. It now runs `checked_mul` / `checked_add` in `U256`, returning `HopSwapError::Overflow` on overflow — mirroring on-chain `getAmountOut`'s `SafeMath` revert semantics exactly (any `uint256`-overflowing intermediate reverts on-chain, so the primitive surfaces it rather than returning a phantom result the chain never produces). The `U512` intermediate and the `u512_to_u256_internal` narrowing assertion that lived on the swap-primitive path are **deleted** — replaced by on-chain-faithful multiply-and-panic. - **(c) The Möbius-solver `U512` is retained as legitimate extended precision.** The `U512` in `degenbot-solvers` (`mobius_int.rs`, `mobius_int_exact.rs`, `mobius_v3_int.rs`) is **not** a swap-primitive upcast-and-narrow dodge — it is legitimate extended precision for two distinct computations a single `U256` cannot hold: 1. **Multi-hop Möbius matrix recurrence** (`mobius_int.rs`). An *n*-hop constant-product path composes the per-hop Möbius transform `γ·s·x / (r + γ·x)` into `l(x) = K·x / (M + N·x)`. The coefficients `K`, `M`, `N` are the product of *n* `(s, r, γ, fee)` terms each; for a realistic arb path they exceed 256 bits. The recurrence (a 2×2 matrix product) is carried in `U512` so the composition does not overflow; the final `x* = (√(K·M) − M) / N` is narrowed back to `U256` via `u512_to_u256_internal`. This is the canonical "matrix recurrence" shape — extended precision for a *composition*, not a single swap. 2. **Integer square root** (`isqrt_u512` in `mobius_int_exact.rs`). The closed-form optimum `x* = (√(K·M) − M) / N` needs an integer square root. `U256` exposes no `isqrt`; `U512` does, and the `K·M` product is already `U512`-wide (see #1). `isqrt_u512` is the exact floor `isqrt` (Newton iteration, `EVM`-parity floor). This is the canonical "isqrt" shape — `U512` is needed because the sqrt input is the `U512` matrix coefficient, not because a single swap overflowed. 3. **V3/V4 concentrated-liquidity swap math** (`mobius_v3_int.rs`). The CL swap step multiplies `uint128` liquidity by `uint160` `sqrtPriceX96` by `uint256` `Q96` — a `385`-bit numerator and a `416`-bit denominator (the bit-width analysis is documented inline at each site). Both exceed 256 bits *independently*; the division is exact only in `U512`. The per-step quotients narrow back to `U256` via `u512_to_u256`. This is the canonical "CL-math intermediate" shape — extended precision for a *multi-field product*, not a single swap. A **"swap primitive"** (the rejected shape) is one that widens a single V2 `getAmountOut` multiply to `U512` purely to dodge the `uint256` overflow the chain itself would revert on, then narrows back — manufacturing a phantom result. The ZPHT6X audit confirms **zero** `U512` sites in `degenbot-solvers` are this shape: every `U512` either (i) composes a multi-hop recurrence, (ii) takes an `isqrt` whose input is the `U512` matrix coefficient, or (iii) multiplies CL fields whose product independently exceeds 256 bits. The `u512_to_u256_internal` narrowing is retained precisely because these are legitimate extended-precision computations whose *final* output fits in `U256` but whose *intermediate* does not. - **Forward-reference cleanup.** The `# Panics` docstring on `u512_to_u256_internal` (and the inline comment at the assertion site) now cites **registration-time enforcement** (`register_v2_pool` / `register_v3_pool` / `register_v4_pool` + `spec_bounds.rs` + this ADR) as the upstream guarantee — it does **not** cross-reference `IntHopState::swap` (the swap primitive no longer carries a narrowing assert; its docstring stands alone). The audit confirmed no stale cross-reference remained to rephrase. ## References - ADR-005 — Polars-inspired three-layer FFI (the standalone-Rust-core constraint the typed mappers preserve). - ADR-003 — `Bot` as the single Rust state owner (registration is a `Bot` method, so the seam is co-located with state ownership). - the five per-task result attachments for this ADR (zoicez-spec-bound-helpers.md, mstat2-register-v2-result.md, 24kngf-register-v3-result.md, k3iicb-register-v4-result.md, f2evv6-typed-mappers-result.md under `docs/ergo-results/`) — removed in the stale-docs cleanup `71ec78b2`; recoverable from git history at any parent of that commit.