Skip to content

Partial Rebalancing#72

Merged
jribbink merged 15 commits into
mainfrom
jord/partial-rebalance
Jul 7, 2026
Merged

Partial Rebalancing#72
jribbink merged 15 commits into
mainfrom
jord/partial-rebalance

Conversation

@jordanschalm

@jordanschalm jordanschalm commented Jul 1, 2026

Copy link
Copy Markdown
Member

This PR modifies rebalancing to support performing as much of a rebalancing swap as is possible without breaching price impact limits,

jordanschalm and others added 15 commits June 26, 2026 09:36
Adds a swap helper that honors a slippage floor but, instead of reverting
when the full size would breach it, halves the input (and the floor, which
scales linearly with it) and retries until a size clears or the input is
exhausted. Returns the input swapped and output received, or (0,0) if no
size cleared.

This is the primitive partial rebalancing builds on: a smaller swap faces
both a proportionally-smaller oracle-derived floor and less price impact,
so it can clear a bound the full size could not. Uniform per-unit costs
(flat fee / spot divergence) still yield (0,0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both rebalance legs now swap best-effort under the slippage floor instead
of reverting when the swap that would reach the re-entry target is too
large to clear it. The lever leg borrows the full target slice, swaps the
largest feasible fraction, and repays the unswapped remainder so no idle
loan token lingers; the delever leg sells the largest feasible fraction of
yield and repays the realized loan token. The position lands partway to the
target and converges over successive rebalances as price impact shrinks.

When the slippage is a uniform per-unit cost no smaller trade can escape
(a flat fee or oracle/pool spot divergence), the rebalance is now a
swap-free no-op rather than a revert; the unchanged health factor is
surfaced via the emitted Rebalanced/VaultState events.

Updates the two slippage tests that asserted the old revert behavior to
assert the new uniform-slippage no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds MockCpmmSwapRouter, a constant-product swap mock whose average price
degrades with trade size, so a swap that breaches the slippage floor at
full size clears it at a reduced size — the property partial rebalancing
needs and that the flat-rate MockSwapRouter cannot express.

New tests assert the partial path on a shallow pool: a partial delever and
a partial lever each make progress toward (but fall short of) the re-entry
target with no idle loan token, and successive delevers converge the
position back into the band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Architecture: note that rebalance partial-fills the largest feasible
fraction under the per-trade slippage floor instead of reverting, leaving
the per-trade extraction cap unchanged. Rebalancer: replace the slippage
transient-failure row with a partial-fill/converge row, since a
slippage-bounded rebalance no longer fails the EVM call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the try/catch halving search with the canonical Uniswap V3
mechanism: each rebalance swap carries a sqrtPriceLimitX96 derived from the
yield oracle discounted by maxSlippageBps, so the pool natively fills only
up to that marginal-price bound and partial-fills (instead of reverting)
when the swap to the re-entry target is too large. A single bounded swap
replaces up to ~10 reverting probes — gas-cheaper, precise, and the
documented-standard approach.

Mechanics:
- SwapLib.swapExactInToLimit: amountOutMinimum=0 + a price limit; the pool
  stops at the bound and leaves unspent input with the caller.
- FCMVault._oracleSqrtPriceLimitX96 converts the oracle price (decimals
  already baked into the 1e36-scaled yield oracle) into the pool's
  sqrt(token1/token0)*2**96 space and applies the slippage factor in sqrt
  space; _yieldDebtSwapLimit reads the pool's live slot0 and skips the swap
  (no-op) when spot is already past the bound or the limit is out of
  TickMath range, avoiding an SPL revert.
- Lever borrows the full slice, swaps bounded by the limit, and repays the
  unspent loan; delever sells bounded by the limit and repays the realized
  loan. New yieldDebtPool immutable (plumbed via InitParams + DeployVault
  from the existing config) supplies slot0.

Semantics change: the bound is now on marginal price (price impact)
relative to the oracle; the pool's fixed LP fee is a separate known cost
and is not part of the bound (matching Uniswap's own model).

Tests: MockUniswapV3Pool (settable slot0) + MockCpmmSwapRouter reworked to
honor sqrtPriceLimitX96 (partial fill, consumes only used input). Fee-based
slippage tests replaced with price-based skip/loosen tests; partial-progress
and convergence tests retained; a harness test asserts the oracle->
sqrtPriceLimit conversion directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update the sandwich-attack and rebalancer-failure sections to describe the
marginal-price bound: the pool partial-fills up to a sqrtPriceLimitX96
derived from the oracle, skips when spot is already past the bound, and the
LP fee is a separate cost not included in the bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop test_Rebalance_PartialDeleverConvergesOverTicks and repair three
missing-semicolon syntax errors (FCMVault swap-limit side checks,
MockCpmmSwapRouter require) that were blocking the build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add _priceX192(num, den) and _sqrtPriceX96(num, den) so price expressions
read as plain fractions (e.g. _sqrtPriceX96(99, 100) for the 1% floor)
instead of inline Math.sqrt(Math.mulDiv(...)) with bit-shift constants.
Route _setPoolPrice and all raw sites through them; values unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jordanschalm jordanschalm changed the title Jord/partial rebalance Partial Rebalancing Jul 2, 2026
@jordanschalm jordanschalm requested review from holyfuchs and tim-barry and removed request for holyfuchs July 2, 2026 15:20
@jordanschalm jordanschalm marked this pull request as ready for review July 2, 2026 15:20
@jordanschalm jordanschalm requested a review from jribbink July 3, 2026 15:48
@jribbink jribbink merged commit 784083a into main Jul 7, 2026
4 checks passed
@jribbink jribbink deleted the jord/partial-rebalance branch July 7, 2026 01:17
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.

2 participants