Skip to content

fix(PositionPlanner): cap liquidity by per-tick headroom instead of a plan-wide budget#165

Open
EperezOk wants to merge 1 commit into
Uniswap:devfrom
EperezOk:fix/positionplanner-per-tick-liquidity-cap
Open

fix(PositionPlanner): cap liquidity by per-tick headroom instead of a plan-wide budget#165
EperezOk wants to merge 1 commit into
Uniswap:devfrom
EperezOk:fix/positionplanner-per-tick-liquidity-cap

Conversation

@EperezOk

@EperezOk EperezOk commented Jun 5, 2026

Copy link
Copy Markdown

Summary

  • PositionPlanner.resolve bounded Uniswap v4's per-tick maxLiquidityPerTick with a single plan-wide budget decremented after each position, conservatively treating the whole plan as if every position shared one tick boundary (which skips positions v4 would accept).
  • This change tracks liquidityGross per boundary tick and caps each position (and the implicit full-range fallback) to the headroom remaining at its own boundaries — matching v4's actual per-tick rule.

Background

In v4, maxLiquidityPerTick is enforced per tick against that tick's liquidityGross, and liquidityGross only
accrues at a position's two boundary ticks (tickLower/tickUpper). Two positions therefore compete for the cap only when they share an exact boundary; positions on distinct ticks are independent and may each hold up to
maxLiquidityPerTick.

The previous guard collapsed this into one plan-wide budget (Σ(all plan liquidity) ≤ maxLiquidityPerTick). It guaranteed no mint could overflow a tick on a fresh pool, but at the cost of dropping valid positions.

Change

_headroom(...) sums the liquidityGross already contributed by created positions at each of a candidate's two
boundaries and returns maxLiquidityPerTick − max(grossLower, grossUpper). Each candidate (and the fallback) is capped to that value before its liquidity is quoted.

Properties preserved:

  • No overflow / no revert on a fresh pool. Capping each position to its boundary headroom keeps every boundary's gross ≤ maxLiquidityPerTick (by induction, so maxLiquidityPerTick − maxGross can't underflow). Enforcing the per-tick gross bound is also what keeps the active-liquidity uint128 accumulator from overflowing.
  • Graceful degradation unchanged. Insufficient headroom shrinks or skips a position (rolling its allocation into the fallback); it never reverts.
  • Budget invariant unchanged. A position's quoted amounts never exceed its budget, so the checked subtraction in resolve still can't underflow.

Behavior change

Positions on independent boundaries now each receive their own per-tick headroom instead of competing for one budget, so plans that previously had positions silently dropped will now mint them. The existing
test_resolve_clampsPositionsAboveMaxLiquidityPerTick characterization test was updated to reflect this (a clamped explicit position now coexists with the full-range fallback rather than starving it).

Gas

Measured A/B of resolve (old vs new) on identical inputs where neither version clamps, isolating the per-tick scan cost:

Positions old new overhead
1 23,989 25,010 +1,021
2 29,452 31,601 +2,149
5 59,574 67,699 +8,125
10 (max) 110,064 136,797 +26,733

End-to-end, the standard 2-position migration snapshot moves 803,742 → 806,053 (+2,311 gas, < 0.3%); the worst case (10 positions) adds ~+26.7k to an ~830k-gas migration (~3%). The LBPStrategy_E2E_Test.json gas snapshot is updated accordingly.

…wide budget

resolve() bounded V4's per-tick maxLiquidityPerTick with a single plan-wide budget
decremented after each position, conservatively assuming all positions share one
tick boundary. That skipped positions V4 would accept, squeezing out one-sided and
laddered layouts.

liquidityGross only accrues at a position's two boundary ticks, so positions
compete only when they share an exact boundary. Track gross per boundary and cap
each candidate (and the full-range fallback) to maxLiquidityPerTick minus the gross
already at its tighter boundary. Positions sharing no boundary now each get full
per-tick headroom. The guarantee that no mint exceeds the cap on a fresh pool is
preserved (proof in _headroom NatSpec: maxGross <= cap by induction, so no
underflow), and degradation stays graceful (shrink/skip, never revert).

Adds a RED-GREEN test for the independent-boundary case and updates the
clamp characterization test to the new (intended) behavior.

Worst-case planning overhead at 10 positions ~ +26.7k gas; standard 2-position
plan ~ +2.3k gas (< 0.3% of an ~830k-gas migration). Measured A/B vs the prior
implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zhongeric zhongeric marked this pull request as ready for review June 8, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant