ADR-004: Typed TickMap boundary for CL verifier + liquidity-apply seam¶
Status: accepted. Follow-up to ADR-003 (which left the per-pool CL state struct flat).
Decided by survey (TODO-6177b602, Slice 0): a four-way classification of every Rust
read/write site of V3PoolState/V4PoolState slots found that the {Slot0 Head / Tick
Bookkeeping Map} deferral trigger condition was already met. Implementation lands via
TDD vertical slices; V3 first, mirror to V4.
Context¶
ADR-003 consolidated V2/V3/V4 state into BotCore as flat pub-field structs
(V3PoolState / V4PoolState). It named a conceptual two-part split within each CL
pool’s state — the slot0 head (sqrt_price_x96, tick, active liquidity — high
churn, every Swap) vs. the tick bookkeeping map (tick_data: HashMap<i32, TickInfo>
A clean-slate survey (rg over all of rust/src/, 148 V3 + 184 V4 field accesses → 22
function-level surfaces) found that trigger condition is met today:
classification |
count |
sites |
|---|---|---|
|
12 |
|
|
3 |
|
|
6 |
|
|
0 |
— |
Decisive facts¶
Zero
slot0-onlyconsumers exist. The slot0 head is never read in isolation; the closest isv3_simulate_swapreading slot0 + ticks in lockstep, which isboth. SplittingSlot0Headinto its own type would earn nothing — no caller wants only the head.Six
takes-whole-but-wants-onesites pay the seam today. They want only the tick map and recover the “don’t read slot0” rule from comments:verify_v3_pool/verify_v4_pool(liquidity_verifier.rs:187,:410) take&V3PoolState/&V4PoolStateand readpool.tick(a slot0 scalar) only to seed the ±2-word bitmap scan around the current tick during bitmap discovery — it is not verification content. The module doc carries the rule as prose: “Mutable scalar state like sqrtPriceX96, tick, and liquidity changes on every swap and is NOT verified here.”apply_v3_liquidity_update/apply_v4_liquidity_update(mod.rs:446,:1206) write onlytick_dataviaapply_liquidity_to_tick_range(the source comment literally says: “Mint/Burn mutate tick_data only, NOT the active liquidity scalar”) but the monolithicV3BlockDeltashape forces them to read slot0 priors (sqrt_price_x96,liquidity,tick) to journal them unchanged. The journal shape, not the apply logic, induces the whole-pool read.
A typed-boundary precedent already ships 200 lines above the offending sites.
verify_v3_liquidity_map/verify_v4_liquidity_map(liquidity_verifier.rs:103,:136) — the snapshot-block variants — already take&HashMap<i32, TickInfo>+Address+ block. They take the tick map as a typed arg and do not take&V3PoolStateat all. The candidate chosen here is not new architecture; it is completing a conversion that was started and paused.
Bonus structural findings (carry into the doc pass)¶
The survey surfaced three stale-documentation issues, fixed in the same Slice 1 context pass:
**S1 — ADR-003 and the
{BotCore}term both say: “The standaloneLiquidityMapgeneric was NOT extracted: the inline-PoolEntry+BotCore::apply_*pattern suited V2/V3/V4.” But these two terms persisted unchanged. Reality:BotCoreholdspools: HashMap<u64, PoolEntry>withPoolEntry::V3(V3PoolIdentity, V3PoolState)/V4(V4PoolIdentity, V4PoolState)(the ADR-005 identity/state split — see that ADR’s “Achieved invariant” section; under the split theTickMaptrait is impl’d on the(identity, state)tuple so it reads both immutableaddress/tick_spacingAND mutabletick/tick_data), and verification is the free functions inliquidity_verifier.rs.S2 —
{PyPoolCache}describes deleted code. ADR-003 “Legacy solver path retirement: delete, not migrate” explicitly deletedRustPoolCache/RustIntHopState/RustArbResultPyO3 classes. The actual memoization seam is the per-poolcached_tick_ranges: parking_lot::Mutex<TickRangeCache>field onV3PoolState/V4PoolState, consumed bybuild_int_v3_sequence/build_int_v4_sequenceand invalidated on everyapply_*.{PyPoolCache}must be rewritten to name what was removed (matching the {V2Snapshot} / {Dual-Orientation Registration} removed-term pattern);{IntHopState}must drop the “stored inPyPoolCache” framing.S3 — reasoned-analysis re-anchoring. The TODO-6177b602 framing’s reasoned-analysis argument was anchored to deleted
PyPoolCache. The real argument is below (see “Reasoned analysis — re-anchored to the live mechanism”).
Decision¶
Adopt candidate (β) — typed TickMap boundary. State structs stay flat; a new trait narrows the verifier + liquidity-apply views so the type — not a doc comment — carries the “verify/mutate the tick map, do not touch the slot0 scalars” rule.
pub trait TickMap {
fn address(&self) -> Address; // immutable — RPC target
fn tick_spacing(&self) -> i32; // immutable — bitmap word compression
fn active_tick(&self) -> i32; // slot0 scalar, read-only — ±2 word scan only
fn tick_data(&self) -> &HashMap<i32, TickInfo>;
}
impl TickMap for V3PoolState { /* blanket over existing fields */ }
impl TickMap for V4PoolState { /* blanket over existing fields */ }
verify_v3_pool(provider, pool: &impl TickMap, block_number)and the V4 mirror.apply_v3_liquidity_update/apply_v4_liquidity_updatetake a&mutview (aTickMapMuttrait exposingtick_data_mut); same shape, writeable variant.All 12
bothconsumers (v3_simulate_swap,v4_simulate_swap,build_int_v3_sequence,build_int_v4_sequence,apply_v3_swap,apply_v4_swap, restore,calculate_tokens_*, accessors, the path resolver) are untouched — they keep taking&V3PoolState/&V4PoolState, exactly as Python’s_v3_swapand the simulator kept the flat state.
Journal-shape alignment (the contained follow-on)¶
The trait conversion surfaces a latent V3BlockDelta wart: today every Mint/Burn/
ModifyLiquidity delta journals the slot0 priors unchanged because the delta shape
requires them. Under the TickMap trait the apply path cannot “accidentally” journal
slot0 priors it didn’t change (the trait exposes no slot0 setters). The clean fix is to
make the scalar priors Option<ScalarPriors> in V3BlockDelta (None for tick-only
events), which also shrinks tick-event journal storage (currently redundant). This
refactor is contained to state_history.rs + the four apply_* call sites that
build V3BlockDeltas. If an implementation review finds it spills beyond that, fall
back to candidate (γ) (see Alternatives) and revisit the trait conversion as a follow-up.
Consequences¶
The rule “verify/mutate the tick map, do not touch the slot0 scalars” is now carried by the type system on the six paying sites, not by a module doc comment. A future implementer who tries to read
sqrt_price_x96/liquidityfrom a&impl TickMapsimply lacks the method.V3BlockDeltabecomes two-concept-precise: scalar priors areOption-gated, tick priors stay asVec<(i32, TickBefore)>.restore_before_blockis unchanged in semantics (two distinct steps: scalars then tick priors) but the scalar step becomes a no-op for tick-only events (the prior wasNone).V4 parity is trivial and mandatory.
V3PoolStateandV4PoolStateboth implTickMapwith identical method bodies (modulo the immutable-source field — V4 readspool_key.tick_spacing). The four V3/V4 mirror sites (verify_v3/v4_pool,apply_v3/v4_liquidity_update) convert line-for-line. No asymmetry lands.The {Pool’s authority over its own math} principle (ADR-003) is preserved.
v3_simulate_swap/v4_simulate_swap/build_int_*_sequence/calculate_tokens_*keep reading the whole state by reference; the trait only narrows the verifier + apply views. The pool still owns its swap math; the engine still reads by reference.Optimizer-cache finding (resolves the Q6 deferral):
PyPoolCache/RustPoolCachedo not exist — deleted under ADR-003. The actual memoization seam — the per-poolcached_tick_ranges: parking_lot::Mutex<TickRangeCache>field — survives unchanged. It is not in scope for this ADR’s code change; it lives insideV3PoolState/V4PoolStateand reads both halves (tick_data+liquidity+tick), so it stays attached to the flat struct exactly as today.Reasoned-analysis re-anchoring (see below) replaces the deleted-
PyPoolCacheargument the survey found in the TODO-6177b602 framing.
Reasoned analysis — re-anchored to the live mechanism (no benchmarking)¶
Per the project’s ruling (Q5: reasoned analysis suffices, no criterion benches stand
up). The TODO-6177b602 framing’s argument was anchored to the deleted PyPoolCache
(“IntHopState / PyPoolCache pre-convert slot0 fields to U512 at construction, so the
inner solve loop does not dereference through the state struct”). That object is gone.
The real argument for “the typed boundary does not hurt the hot loop” is:
The trait conversion only touches verifier + liquidity-apply views. The lockstep hot-loop readers (
v3_simulate_swap/v4_simulate_swap,build_int_v3_sequence/build_int_v4_sequence, the path resolversolver_dispatch.rs:332-348) keep taking&V3PoolState/&V4PoolStatedirectly — the trait is not in their signature. There is no extra indirection on the hot path.The solve hot loop does not dereference
V3PoolStateat all.solver_dispatch.rscallspool_state.build_int_v3_sequence(zfo, 10)once persolve_dirty(per dirty-path burst, coalesced), producing anIntV3TickRangeSequence; the V3/V4 Mobius solver (mobius_v3_int.rs) then operates onIntV3TickRangeHopfields — never onV3PoolState/V4PoolState. The per-tick-crossing inner loop in the solver uses pre-built integer hop state, not state-struct dereferences.The only state-struct dereference in the simulation path is
v3_simulate_swap/v4_simulate_swapthemselves, called from the single-pool-query surface (calculate_tokens_out/calculate_tokens_in, the PyO3 single-pool-query path) — not the solver hot loop. These are unchanged by the trait conversion (they take&V3PoolState/&V4PoolStateflat, as today).The per-pool memoization seam (
cached_tick_ranges) keeps thebuild_int_*call O(1) on cache hits; invalidated on everyapply_*. The trait conversion does not touch it (it lives inside the state struct, attached to whichever code reads both halves).Python precedent. Python’s
_v3_swapalready runs the tick-crossing loop through a typed snapshot (LiquidityMapSnapshot) + separate scalar args in production, and the survey confirmed it ships. The typed-boundary shape has a working existence proof; the Rust conversion is more conservative than Python’s (Rust leaves the simulator signature flat and only narrows the verifier/apply views).
Benches only stand up if this reasoning is later shown wrong by a regression.
Alternatives¶
(α) Full split — V3PoolState/V4PoolState gain slot0: Slot0Head + ticks: TickBookkeepingMap sub-structs¶
Rejected by survey evidence. Every slot0-only consumer count is zero. Splitting
Slot0Head into its own type earns nothing — no caller reads the head without also
reading ticks. The full-split also forces the lockstep consumers (v3_simulate_swap,
v4_simulate_swap, build_int_*_sequence, calculate_tokens_*, the path resolver —
12 sites) to thread state.slot0.sqrt_price_x96 + state.ticks.tick_data instead of
flat field access, at HIGH migration cost, for zero semantic gain at those sites. The
six takes-whole-but-wants-one paying sites would still need a trait/view to take “only
the tick half” (the full split alone doesn’t give them a typed-narrow argument; they’d
take &state.ticks instead of &state, which is better than today but still couples the
verifier to the full sub-struct). The Slot0Head half of the split is dead weight with
no consumer.
(α-snapshot) LiquidityMapSnapshot-style frozen value threaded into v3_simulate_swap / v4_simulate_swap (Python’s _v3_swap shape)¶
Rejected by survey evidence. A snapshot is the wrong shape for the six paying sites: the verifier needs live references (it compares against on-chain RPC, not a frozen copy), and the apply path mutates the tick map. Snapshot-per-call only satisfies the three simulator-call sites (#2, #4, #20) that genuinely take both halves, where it trades one arg for three with no semantic gain. Rejected.
(γ) Hold — strengthen the {Slot0 Head} term to a firm ruling; no code change¶
Demoted to fallback. The survey found a paying consumer and a shipping precedent,
so holding is no longer a neutral default — it is a positive decision to leave six takes-whole-but-wants-one sites recovering the rule from comments, when a 1-trait fix exists with a shipping precedent 200 lines away. Hold is only justifiable if the V3BlockDelta Option<ScalarPriors> refactor (see “Journal-shape alignment”) is judged too risky for the per-pool seam before the migration; the argument against is that the refactor is independently desirable (shrinks Mint/Burn/ModifyLiquidity journals — currently redundant slot0 priors) and contained to state_history.rs + four call sites. Recorded as the fallback if a Slice-2 implementation review finds the journal refactor spills beyond that.
Python prior art (study only — not classified in the survey)¶
Inspected src/degenbot/uniswap/v3_liquidity_pool.py, concentrated/liquidity_map.py,
concentrated/state_manager.py, v3_pool_state.py. Findings:
Python did NOT solve the seam by splitting its state struct.
UniswapV3PoolState(built via__value__) carriesliquidity, sqrt_price_x96, tick, tick_bitmap, tick_data, blockas flat sibling fields — identical to Rust’s flat layout. This ADR mirrors that conservative choice on the Rust side (state structs stay flat).The tick half IS its own type in Python, via
LiquidityMapSnapshot(concentrated/liquidity_map.py, frozen/slots dataclass:tick_data, tick_bitmap, tick_spacing, sparse), duck-typed for V3 and V4 through the_HasTickData/_HasPoolLiquidityMapprotocols. The RustTickMaptrait is the direct Rust counterpart of Python’s_HasTickDataprotocol — same move, static-typed idiom.The simulation seam consumes the two halves separately. Python’s
_v3_swap(snapshot: LiquidityMapSnapshot, *, liquidity_start, sqrt_price_x96_start, tick_start, …)takes the tick half as a typed snapshot and the scalars as separate args. The Rust Rust code did not make this move on the simulator (candidate α- snapshot was rejected — see Alternatives); it makes the narrower move (candidate β) on the verifier/apply seam only.Mutation paths split at the method level via
dataclasses.replace:external_update()→ slot0 scalars;update_liquidity_map()/update_tick_data()→ tick map. The Rustapply_v3_swap(slot0) vs.apply_v3_liquidity_update(ticks) split is the direct mirror — and the latter is exactly the site theTickMaptrait narrows.Temporal navigation is orthogonal.
ConcentratedLiquidityStateManager[StateT: _StateLike]treats the state as opaque and only does deque/lock/discard/restore — mirroring Rust’sReorgJournal<V3BlockDelta>. Neither cares about the slot0/ticks split; the Rust journal stays onV3BlockDelta(with theOption<ScalarPriors>refinement above).