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 |
|
|
V3 |
|
|
V4 |
|
|
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×_poolcall 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.rshelpers 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’sreserve0/reserve1fields arealloy::primitives::aliases::U112, the on-chainuint112storage type.register_v2_poolacceptsU112reserves (theRegisterV2PoolParamsfields areU112), so the runtimevalidate_v2_reservecheck (value > UINT112_MAX) is a tautology for type-correct callers — aU112cannot exceeduint112::MAX. The runtime check remains as a diagnostic-message uniformity aid (it returns theSpecViolationwith 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# Panicsdocstring.(b)
IntHopState::swapdoes nativeU256arithmetic. The V2 swap primitive (degenbot_v2_math::hop_state::IntHopState::swap) previously widend toU512for the multiply-then-narrow dodge. It now runschecked_mul/checked_addinU256, returningHopSwapError::Overflowon overflow — mirroring on-chaingetAmountOut’sSafeMathrevert semantics exactly (anyuint256-overflowing intermediate reverts on-chain, so the primitive surfaces it rather than returning a phantom result the chain never produces). TheU512intermediate and theu512_to_u256_internalnarrowing assertion that lived on the swap-primitive path are deleted — replaced by on-chain-faithful multiply-and-panic.(c) The Möbius-solver
U512is retained as legitimate extended precision. TheU512indegenbot-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 singleU256cannot hold: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)intol(x) = K·x / (M + N·x). The coefficientsK,M,Nare 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 inU512so the composition does not overflow; the finalx* = (√(K·M) − M) / Nis narrowed back toU256viau512_to_u256_internal. This is the canonical “matrix recurrence” shape — extended precision for a composition, not a single swap.Integer square root (
isqrt_u512inmobius_int_exact.rs). The closed-form optimumx* = (√(K·M) − M) / Nneeds an integer square root.U256exposes noisqrt;U512does, and theK·Mproduct is alreadyU512-wide (see #1).isqrt_u512is the exact floorisqrt(Newton iteration,EVM-parity floor). This is the canonical “isqrt” shape —U512is needed because the sqrt input is theU512matrix coefficient, not because a single swap overflowed.V3/V4 concentrated-liquidity swap math (
mobius_v3_int.rs). The CL swap step multipliesuint128liquidity byuint160sqrtPriceX96byuint256Q96— a385-bit numerator and a416-bit denominator (the bit-width analysis is documented inline at each site). Both exceed 256 bits independently; the division is exact only inU512. The per-step quotients narrow back toU256viau512_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
getAmountOutmultiply toU512purely to dodge theuint256overflow the chain itself would revert on, then narrows back — manufacturing a phantom result. The ZPHT6X audit confirms zeroU512sites indegenbot-solversare this shape: everyU512either (i) composes a multi-hop recurrence, (ii) takes anisqrtwhose input is theU512matrix coefficient, or (iii) multiplies CL fields whose product independently exceeds 256 bits. Theu512_to_u256_internalnarrowing is retained precisely because these are legitimate extended-precision computations whose final output fits inU256but whose intermediate does not.Forward-reference cleanup. The
# Panicsdocstring onu512_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-referenceIntHopState::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 —
Botas the single Rust state owner (registration is aBotmethod, 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 cleanup71ec78b2; recoverable from git history at any parent of that commit.