ADR-017: Forward-Apply Pool-State Traits (ADR-016 Forward-Apply Twin)¶
Status: accepted (decision). The adoption itself is a candidate slice
(tracked under ergo epic FORWARD_APPLY; see SEQUENCING). This ADR records
the architectural decision so future architecture reviews do not re-suggest
leaving the forward-apply field-writes as per-family duplicated methods on
BotState, and so the CL apply_swap/apply_liquidity_update twins resume
with this decision’s framing rather than re-deriving it.
This decision is the forward-apply mirror of ADR-016: where ADR-016
collapsed the restore (rollback) field-write into a ReorgPoolState trait,
this ADR collapses the forward-apply (event-replay) field-write into the
matching apply traits. It does not revisit ADR-016, ADR-014 D1, D2, D3, D5,
or D6 — those stand (D3’s “too short to justify a trait” finding is
superseded below where the reserve-pair family is concerned, for the reason
ADR-016 already overturned it on the restore twin).
Context¶
After ADR-016 landed ReorgPoolState on all seven pool state structs and
collapsed BotState’s per-family reorg dispatchers, the restore layer is
fully trait-covered. The forward-apply layer is not. The two halves of a
pool’s field-write lifecycle — apply an event forward, restore on reorg —
are now asymmetric: the restore half lives in the struct’s trait impl, the
forward-apply half lives in BotState (or as byte-identical inherent twins).
This is the exact residue ADR-016 cleaned up for restore, still sitting for
apply.
Three families, three shapes of the same gap:
Reserve-pair family (V2PoolState, AerodromeV2PoolState)¶
V2PoolState::apply_sync (rust/crates/foundation/degenbot-pools/src/v2_state.rs) and
AerodromeV2PoolState::apply_sync (aerodrome_v2_state.rs) are
byte-identical inherent methods — both push a V2BlockDelta (before
= pre-sync reserves, after = new reserves, at block_number), overwrite
reserve0/reserve1, advance update_block. Both already return ().
BotState still carries two dispatchers (apply_v2_sync_by_pool_id,
apply_aerodrome_sync_by_pool_id).
Balance-vector family (CurvePoolState, BalancerWeightedPoolState, BalancerStablePoolState)¶
There is no apply_balance_update inherent method on the structs at all.
The three byte-identical bodies live inline in BotState:
apply_curve_balance_update_by_pool_id, apply_balancer_weighted_*, and
apply_balancer_stable_* (each: assert arity, push BalancesBlockDelta,
overwrite balances, advance update_block). ADR-014 D1’s relocation of
apply_* onto the state structs never reached this family’s forward apply;
the same three structs the other agent just gave ReorgPoolState impls to
have their forward-apply field-write stranded in BotState.
Concentrated-liquidity family (V3PoolState, V4PoolState)¶
V3PoolState::apply_swap / V4PoolState::apply_swap are byte-identical
twins (V4’s doc-string still says “the CL mut trait (ADR-014 D2) will dedup
these twins”); same for V3PoolState::apply_liquidity_update /
V4PoolState::apply_liquidity_update. Both return (). The
ConcentratedLiquidityPoolMut trait (ADR-014 D2b) exists
(rust/crates/foundation/degenbot-pools/src/registry.rs) but currently carries only
replace_tick_data — the apply twins are not members and remain as
inherent methods.
Separately, BotState::merge_tick_word has a byte-identical V3/V4 twin arm
(insert ticks, insert word, invalidate cache, return true) — a missed
sibling of replace_tick_data on the same trait, simpler (no
tick_spacing lookup) and already returning bool uniformly.
And apply_backfill_buffer_v3 / apply_pump_buffer_v3 /
apply_backfill_buffer_v4 / apply_pump_buffer_v4 — four BotState drain
loops — re-inline the full journaled apply_liquidity_update body
(tick-prior capture → apply_liquidity_to_tick_range →
push_delta(V3BlockDelta{scalar_priors:None,..}) → advance update_block →
invalidate_tick_range_cache) instead of calling the existing inherent
method. The apply_*_liquidity_update_by_pool_id dispatchers do delegate;
the drains don’t. That is six total copies of the same tick-prior-and-delta
logic (2 inherent + 4 drain-inlined).
Decision¶
D1 — BalanceVectorPoolState trait on the three balance-vector structs.¶
pub trait BalanceVectorPoolState: ReorgPoolState {
/// Apply a balances-vector update (a Curve `Exchange` or a Balancer Vault
/// `PoolBalanceChanged` event), capturing the prior balances into the
/// reorg journal and overwriting the live `balances` + `update_block`.
fn apply_balance_update(&mut self, balances: Vec<U256>, block_number: u64);
}
Returns () — the Option<u64> is a BotState-level variant-dispatch
concern (wrong-family ⇒ None), not a trait concern; identical to how
ADR-016 D2 keeps restore_pool_before_block returning Option<Result<(),_>>
on BotState while the trait returns Result<(),_>. The arity assert!
moves into the impl body. The three impls are byte-identical modulo the
struct name — verifiable by diff, same as ADR-016 certified the three
restore impls.
BotState gains one apply_balance_update_by_pool_id dispatcher matching
Curve | BalancerWeighted | BalancerStable; the three per-family methods
delete.
D2 — Extend ConcentratedLiquidityPoolMut with the two apply twins + merge_tick_word.¶
pub trait ConcentratedLiquidityPoolMut: ConcentratedLiquidityPool {
fn replace_tick_data(/* existing */) -> bool; // existing
fn apply_swap(&mut self, sqrt_price_x96: U256, liquidity: u128, tick: i32,
block_number: u64, tick_priors: &[(i32, TickInfo)]);
fn apply_liquidity_update(&mut self, tick_lower: i32, tick_upper: i32,
liquidity_delta: i128, block_number: u64);
fn merge_tick_word(&mut self, fetched: TickWordFetcher::FetchedTickWord) -> bool;
}
All already return () / bool. The V3 and V4 impls of all three are
byte-identical identical to one another (no identity-agnostic massage needed
beyond what replace_tick_data already established: the trait takes no
tick_spacing, fee/tick_spacing live on the identity slice and are read
by the caller before dispatch — and notably none of these three methods needs
even that). ADR-014 D2’s two-adapter rule is preserved; V3 and V4 stay
distinct types, two adapters behind one interface.
D3 — ReservePairPoolState trait on V2 + Aerodrome.¶
pub trait ReservePairPoolState: ReorgPoolState {
/// Apply a `Sync(uint112, uint112)` event, capturing the prior reserves
/// into the reorg journal and overwriting `reserve0`/`reserve1`/`update_block`.
fn apply_sync(&mut self, reserve0: U112, reserve1: U112, block_number: u64);
}
Returns (). The two impls are byte-identical modulo the struct name.
BotState gains one apply_sync_by_pool_id dispatcher matching
V2 | AerodromeV2; the two per-family methods delete.
D4 — Reserve-pair “too short” (ADR-014 D3) is overturned, same as ADR-016 overturned it on restore.¶
ADR-014 D3 rejected a reserve-pair + balance-vector restore state-struct
trait as “too short to justify a trait + dyn.” ADR-016 overturned that for
the restore twin (the ()-return dissolves the no-op trap; the bodies are
the pool’s own field-mutation logic; the BotState dispatcher collapse is a
real gain). The same reasoning applies identically to the forward-apply
twin: V2PoolState::apply_sync and AerodromeV2PoolState::apply_sync are
byte-identical, already return (), and the BotState dispatcher collapse
(2→1) is the forward-apply mirror of ADR-016 D2. D3’s “too short” finding is
overturned for the reserve-pair forward apply for the same reason ADR-016
overturned it for the reserve-pair restore. (The balance-vector forward
apply was never rejected — D3’s discussion predates the relocation of the
apply bodies, so there was nothing to reject; D1 here is greenfield.)
D5 — CL buffer-drain methods delegate to the trait.¶
Once apply_liquidity_update is on ConcentratedLiquidityPoolMut (D2),
the four drain loops (apply_backfill_buffer_v3, apply_pump_buffer_v3,
apply_backfill_buffer_v4, apply_pump_buffer_v4) collapse to a
state.apply_liquidity_update(...) call in their loop body. The V4
I256→i128 narrowing stays at the drain site (ADR-014 D4). This deletes four
inlined copies of the tick-prior + delta body.
D6 — BotState apply dispatchers collapse.¶
BotState gets three trait-dispatching apply methods (one per structural
family) — apply_sync_by_pool_id (matching V2 | AerodromeV2), the existing
apply_balance_update_by_pool_id (matching
Curve | BalancerWeighted | BalancerStable, replacing the three per-family
methods), and the CL apply dispatch routes through
PoolEntry::as_cl_mut() (apply_*_v3/v4_swap_by_pool_id + the family
dispatchers apply_swap_by_pool_id / apply_liquidity_update_by_pool_id
already exist; they gain a one-line &dyn ConcentratedLiquidityPoolMut
delegation body in their V3/V4 branches instead of two separate family
methods).
D7 — PoolEntry CL projections.¶
Add PoolEntry::as_cl_mut(&mut self) -> Option<&mut dyn ConcentratedLiquidityPoolMut>
matching V3/V4 — the apply-twin of as_reorg_state_mut (ADR-016) and the
mut counterpart of the existing as_cl projection used by
get_v3_or_v4_pool. The balance-vector and reserve-pair apply dispatchers
dispatch directly on the variant match (the projected &dyn is formable but
not load-bearing, exactly as ADR-014 D2 accepted for the CL read trait);
the trait is the dedup site, the match is the call-site seam.
Consequences¶
bot_core/mod.rs’simpl BotStateshrinks further: three per-family balance-vector apply methods + two per-family reserve-pair apply methods + the V3/V4apply_*_swap_by_pool_id/apply_*_liquidity_update_by_pool_idfamily pair collapse to trait-dispatching methods; the four drain loops lose ~25 lines each of inlined body.The forward-apply + restore layers are now symmetric: each struct owns both halves of its field-write lifecycle behind a within-family trait.
BotStateholds the registry, the buffers, the cross-family dispatch; it no longer holds any pool field-write.ADR-003 (single state owner) preserved —
BotStatestill owns theHashMap<u64, PoolEntry>, the buffers, the address index; the trait impls operate on&mut selfover the struct’s own fields.ADR-014 D2’s latent value lands: future CL mutators join free, and the standalone consumer reaches a uniform
ConcentratedLiquidityPoolMutapply surface for both V3 and V4.The
ReorgPoolStatesupertrait bound onBalanceVectorPoolStateandReservePairPoolStateties the two halves together at the type level: a pool that owns its restore owns its apply, and vice versa.
Sequencing¶
Tracking under ergo epic FORWARD_APPLY (“ADR-017: Forward-apply pool-state
traits”):
merge_tick_word→ConcentratedLiquidityPoolMut— the simplest member (notick_spacinglookup, already returnsbool); goes first as a no-risk warm-up that proves the trait-extension seam.CL
apply_swap/apply_liquidity_update→ConcentratedLiquidityPoolMut(ADR-014 D2 open slice) — move the two byte-identical inherent twins onto the trait; V3 and V4 are two adapters behind one interface.CL buffer-drain delegation (D5) — collapses the four inlined drain bodies to
state.apply_liquidity_update(...); gates on (2).Balance-vector
BalanceVectorPoolStatetrait + dispatcher collapse (D1) — the headline: the three byte-identical bodies finally leaveBotState; the struct owns its forward-apply write. Independent of (2)/(3).Reserve-pair
ReservePairPoolStatetrait + dispatcher collapse (D3 + D4) — V2 + Aerodrome adopt the trait; gate cleared (DBISWPlanded: both structs carryreserve{0,1}: U112). Independent of (2)/(3)/(4).
(1)/(2)/(3) form a CL chain; (4) and (5) are independent and can proceed in parallel with the CL chain.
Why not the alternatives¶
Leave the forward-apply as inherent methods / inline bodies (status quo) — rejected. The restore twin just collapsed (ADR-016); leaving apply as the mirror-residue keeps the two halves asymmetric and leaves the highest-value dedup (the three balance-vector bodies, byte-identical, in
BotState) undone. The()-return is already in place on 5 of the 6 twins; the lemma bites cleanly.A single cross-family
trait ApplyPoolState— rejected. ADR-014’s rejection of cross-familyPoolFamilyRegstands and the()-return lemma does not rescue it: the three families differ in field shape (U112×2/Vec<U256>/ slot0+tick_data), delta shape (full-stateV2BlockDelta/ full-stateBalancesBlockDelta/ partial-priorV3BlockDelta), and apply algorithm (two reserves / N balances / scalar+tick-prior capture). The lemma dissolves the within-family no-op trap; it cannot paper over families whose field-writes have nothing in common. Each trait here is within-family, mirroring ADR-016’s within-familyReorgPoolStateboundary.Fold the apply into
ReorgPoolStateitself — rejected. Restore and apply are opposite directions of the same lifecycle but have genuinely different signatures (restore takes ablock: u64and returnsResult<(), JournalError>; apply takes family-specific event args and returns()). Merging them would re-introduce the cross-family signature-mismatch ADR-016 dissolved. Separate within-family traits, withReorgPoolStateas the shared supertrait bound, keeps each seam honest.Defer the forward-apply traits behind the
PyPool/proposed-pool-interface.mdredesign (the doc file itself was never committed — the track is named in the ADR-005/016 lineage only) — rejected. That track is a cross-familyStructure-enumerated handle redesign (aPoolwrapper overPoolEntry+ structural views); it is orthogonal to the within-family return-type lemma. The two tracks are independent and the forward-apply dedup should not be gated on the cross-family handle landing. ThePyBalanceVectorView/PyReservePairViewwrappers will consume the trait impls cleanly; landing the traits first is a strict prerequisite for the handle rewrite, not a competitor.