From a7f1c723a2550cb038ef74f6d692554c979aa946 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 26 Jun 2026 09:36:27 -0700 Subject: [PATCH 01/15] SwapLib: add swapExactInPartial best-effort swap helper 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 --- solidity/src/libraries/SwapLib.sol | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/solidity/src/libraries/SwapLib.sol b/solidity/src/libraries/SwapLib.sol index 4c61f7b..1285783 100644 --- a/solidity/src/libraries/SwapLib.sol +++ b/solidity/src/libraries/SwapLib.sol @@ -73,4 +73,71 @@ library SwapLib { }) ); } + + /// @notice Best-effort partial swap: try to swap `amountIn` honoring an + /// `amountOutMinimum` floor; if the swap would breach the floor, + /// halve both the input and the floor and retry, up to `maxHalvings` + /// times. Returns the input actually swapped and the output received, + /// or `(0, 0)` if no size down to `amountIn >> maxHalvings` cleared + /// the floor. + /// @dev Used by vault-initiated rebalances so a swap too large to clear + /// the slippage floor still makes partial progress instead of + /// reverting the whole rebalance. + /// + /// The floor is halved alongside the input because it derives from + /// an oracle-expected output that is linear in the input, whereas + /// realized AMM price impact grows with size. A smaller swap + /// therefore faces a proportionally-smaller floor *and* less price + /// impact, so it can clear a bound the full size could not. When the + /// shortfall is a uniform per-unit cost (a flat fee or an + /// oracle/pool spot divergence) rather than price impact, no size + /// clears the floor and the call returns `(0, 0)` — the caller + /// no-ops rather than trading at a bad price. + /// + /// The retry wraps the router call in try/catch, so *any* revert + /// (slippage or otherwise) triggers a smaller retry; a pool that + /// fails at every attempted size yields `(0, 0)`. Caller MUST have + /// approved `SWAP_ROUTER` for `tokenIn`. + /// @param tokenIn Token being sold. + /// @param tokenOut Token being bought. + /// @param fee Pool fee tier. + /// @param amountIn Desired (full) amount of `tokenIn` to sell. + /// @param amountOutMinimum Slippage floor for the full `amountIn`; halved + /// in lockstep with the input on each retry. + /// @param maxHalvings Maximum number of halvings to attempt after the + /// full-size attempt (smallest size is + /// `amountIn >> maxHalvings`). + /// @return amountInUsed Input actually swapped (0 if none cleared). + /// @return amountOut Realized output received (0 if none cleared). + function swapExactInPartial( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint256 amountOutMinimum, + uint256 maxHalvings + ) internal returns (uint256 amountInUsed, uint256 amountOut) { + for (uint256 i = 0; i <= maxHalvings; i++) { + if (amountIn == 0) break; + try SWAP_ROUTER.exactInputSingle( + ISwapRouter.ExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + fee: fee, + recipient: address(this), + amountIn: amountIn, + amountOutMinimum: amountOutMinimum, + sqrtPriceLimitX96: 0 + }) + ) returns ( + uint256 out + ) { + return (amountIn, out); + } catch { + amountIn /= 2; + amountOutMinimum /= 2; + } + } + return (0, 0); + } } From b7d6788832e969870a44fa7c1a20b63c687566a2 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 26 Jun 2026 09:38:34 -0700 Subject: [PATCH 02/15] FCMVault: partial rebalancing when a full rebalance breaches slippage 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 --- solidity/src/FCMVault.sol | 77 +++++++++++++++++++++++++++++------- solidity/test/FCMVault.t.sol | 48 +++++++++++++++------- 2 files changed, 97 insertions(+), 28 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index 5bba26b..b49deaf 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -46,6 +46,18 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// @dev Basis-points denominator for `maxSlippageBps`. uint256 internal constant BPS = 10_000; + /// @dev Maximum number of halvings a rebalance swap may attempt after the + /// full-size attempt before giving up (see `SwapLib.swapExactInPartial`). + /// The smallest size tried is `target >> MAX_PARTIAL_HALVINGS` + /// (~1/1024 of the target). Beyond that, the residual swap is treated + /// as infeasible — the slippage is a uniform per-unit cost no smaller + /// trade can escape — and the rebalance no-ops. The price-impact case + /// typically clears in the first one or two attempts; the full count is + /// only reached near the no-feasible-size boundary, so the worst-case + /// gas (this many reverting swaps) is rare. The off-chain rebalancer's + /// EVM gas limit must accommodate it. + uint256 internal constant MAX_PARTIAL_HALVINGS = 10; + // @dev Address of the loan token (inner vault asset) IERC20 public immutable loanToken; // @dev Address of the yield token (inner vault share) @@ -566,6 +578,18 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// So, the smallest swap that restores health within the band incurs /// the lowest average-case cost. By convention, there is a small /// buffer between the band's bound and the target. + /// + /// Partial rebalancing: if the swap that would land the position at + /// the re-entry target is too large to clear the `maxSlippageBps` + /// floor, the rebalance does not revert — it swaps the largest + /// feasible fraction (see `SwapLib.swapExactInPartial`) and lands + /// partway to the target. Because price impact grows with size, + /// successive rebalances each clear a larger fraction, so the + /// position converges to the band over several calls rather than in + /// one. When the slippage is a uniform per-unit cost no trade size + /// can escape, the rebalance makes no progress (a swap-free no-op); + /// the off-chain rebalancer surfaces the unchanged health factor via + /// the emitted `Rebalanced`/`VaultState` events. function rebalance() external logsVaultState { // After a recovery the position is terminal; revert with an explicit // error so the off-chain rebalancer surfaces it and stops, rather than @@ -599,33 +623,52 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// exactly `healthFactorMaxTarget` (just below the upper bound). /// Since `hf > max >= maxTarget`, `currentDebt < targetDebt`. The /// borrow leg adds `targetDebt - currentDebt`. + /// + /// Partial: the full `additionalDebt` is borrowed up front, then the + /// loan->yield swap runs best-effort under the slippage floor. If the + /// floor caps the swap below the full size, only the swapped portion + /// stays levered; the unswapped loan token is immediately repaid, so + /// the position lands partway to `healthFactorMaxTarget` with no idle + /// loan token left behind. Borrowing first and repaying the remainder + /// (rather than sizing the borrow to the swap) avoids needing the swap + /// output before the tokens to swap exist. /// @param maxBorrow Current maximum-borrowable amount at LLTV (independent of current debt) /// @param currentDebt Current outstanding debt (caller passes the same /// value used to compute `hfBefore` to avoid a /// second `MORPHO.position` SLOAD). - /// @return additionalDebt Amount of loan token borrowed in this call. + /// @return additionalDebt Net new debt taken on in this call (the loan token + /// actually swapped into yield; 0 if no size cleared). function _rebalanceLever(uint256 maxBorrow, uint256 currentDebt) internal returns (uint256 additionalDebt) { uint256 targetDebt = maxBorrow.mulDiv(MarketLib.WAD, healthFactorMaxTarget); if (targetDebt <= currentDebt) return 0; - additionalDebt = targetDebt - currentDebt; + uint256 borrowAmount = targetDebt - currentDebt; - market.borrow(additionalDebt); + market.borrow(borrowAmount); // Floor the loan->yield swap at the oracle-expected yield out, less // maxSlippageBps. Deposit's identical leg is intentionally unfloored // (user-facing slippage is the router's job); this leg is // vault-initiated, so the floor is the price-impact / sandwich guard. + // Best-effort: swaps the largest feasible fraction rather than reverting. uint256 expectedYield = - additionalDebt.mulDiv(MarketLib.ORACLE_PRICE_SCALE, IOracle(yieldOracle).price()); - SwapLib.swapExactInMin( + borrowAmount.mulDiv(MarketLib.ORACLE_PRICE_SCALE, IOracle(yieldOracle).price()); + (uint256 loanSwapped,) = SwapLib.swapExactInPartial( address(loanToken), address(yieldToken), feeYieldDebt, - additionalDebt, - _slippageFloor(expectedYield) + borrowAmount, + _slippageFloor(expectedYield), + MAX_PARTIAL_HALVINGS ); + + // Repay any loan token the swap could not place within the floor, so + // no idle loan token lingers and the position only levers by the amount + // actually converted to yield. + uint256 leftover = borrowAmount - loanSwapped; + if (leftover > 0) market.repay(leftover); + additionalDebt = loanSwapped; } /// @dev Delever branch of `rebalance`: position is over-levered @@ -642,6 +685,11 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// under-shoot (post-rebalance HF is slightly below /// `healthFactorMinTarget` if the swap realized less than oracle). /// + /// Partial: the yield->loan swap runs best-effort under the slippage + /// floor. If the floor caps the swap below `yieldToSell`, only the + /// realized loan token is repaid and the position lands partway to + /// `healthFactorMinTarget` rather than reverting. + /// /// @param maxBorrow Current maximum-borrowable amount at LLTV (may be 0 /// after a liquidation that wiped collateral). /// @param currentDebt Current outstanding debt. @@ -664,24 +712,25 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { if (yieldToSell > yieldBalance) yieldToSell = yieldBalance; if (yieldToSell == 0) return 0; - uint256 loanBefore = loanToken.balanceOf(address(this)); // Floor the yield->loan swap at the oracle-expected loan out for the // (possibly-capped) yieldToSell, less maxSlippageBps. Redeem's identical // leg is intentionally unfloored (router's job); this vault-initiated - // leg gets the price-impact / sandwich guard. + // leg gets the price-impact / sandwich guard. Best-effort: swaps the + // largest feasible fraction rather than reverting, so a too-large + // delever still repays as much as the floor allows. uint256 expectedLoan = yieldToSell.mulDiv(yieldPrice, MarketLib.ORACLE_PRICE_SCALE); - SwapLib.swapExactInMin( + (, uint256 loanGot) = SwapLib.swapExactInPartial( address(yieldToken), address(loanToken), feeYieldDebt, yieldToSell, - _slippageFloor(expectedLoan) + _slippageFloor(expectedLoan), + MAX_PARTIAL_HALVINGS ); - uint256 loanGot = loanToken.balanceOf(address(this)) - loanBefore; // Cap repayment at outstanding debt - repayAmount = loanGot > currentDebt ? currentDebt : loanGot; - if (repayAmount > 0) market.repay(repayAmount); + repaid = loanGot > currentDebt ? currentDebt : loanGot; + if (repaid > 0) market.repay(repaid); } /// @notice Not implemented. Use `deposit` instead. diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 582abaf..9ac38c9 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -785,32 +785,52 @@ contract FCMVaultTest is Test { // ---- rebalance slippage floor ---------------------------------------- - /// @notice The delever swap reverts when realized slippage exceeds - /// `maxSlippageBps`: a 3% AMM haircut trips the default 1% floor, - /// so the rebalance reverts rather than executing at a bad price - /// (the price-impact / sandwich guard). Deposit/redeem have no such - /// floor — that slippage is the caller's via the router. - function test_Rebalance_DeleverRevertsWhenSlippageExceedsFloor() public { + /// @notice A uniform AMM haircut that exceeds `maxSlippageBps` makes the + /// delever a swap-free no-op rather than a revert. The 3% haircut is + /// per-unit (constant-rate mock), so no smaller trade can clear the + /// 1% floor — partial rebalancing finds no feasible size and the + /// position is left untouched, surfaced via the emitted events. + /// (Price-impact slippage, where a smaller trade *does* clear the + /// floor, is exercised in the partial-progress tests below.) + function test_Rebalance_DeleverNoopWhenUniformSlippageExceedsFloor() public { _depositFor(user, 1 ether); marketOracle.setPrice(1700e36); // push HF below min -> delever path - assertLt(_healthFactor(), HEALTH_FACTOR_MIN, "below min"); + uint256 hfBefore = _healthFactor(); + assertLt(hfBefore, HEALTH_FACTOR_MIN, "below min"); MockSwapRouter(address(SwapLib.SWAP_ROUTER)).setFeeBps(300); // 3% > 1% floor - vm.expectRevert("Too little received"); - vault.rebalance(); + uint256 debtBefore = _debt(); + uint256 fusBefore = FUSDEV.balanceOf(address(vault)); + + vault.rebalance(); // no revert; no feasible size, so no progress + + assertEq(_healthFactor(), hfBefore, "hf unchanged"); + assertEq(_debt(), debtBefore, "debt unchanged"); + assertEq(FUSDEV.balanceOf(address(vault)), fusBefore, "yield unchanged"); + assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle"); } - /// @notice Same guard on the lever leg. - function test_Rebalance_LeverRevertsWhenSlippageExceedsFloor() public { + /// @notice Same uniform-slippage no-op on the lever leg: the position is + /// left untouched and no idle loan token lingers (the unswappable + /// borrow is repaid in full). + function test_Rebalance_LeverNoopWhenUniformSlippageExceedsFloor() public { _depositFor(user, 1 ether); marketOracle.setPrice(2300e36); // push HF above max -> lever path - assertGt(_healthFactor(), HEALTH_FACTOR_MAX, "above max"); + uint256 hfBefore = _healthFactor(); + assertGt(hfBefore, HEALTH_FACTOR_MAX, "above max"); MockSwapRouter(address(SwapLib.SWAP_ROUTER)).setFeeBps(300); // 3% > 1% floor - vm.expectRevert("Too little received"); - vault.rebalance(); + uint256 debtBefore = _debt(); + uint256 fusBefore = FUSDEV.balanceOf(address(vault)); + + vault.rebalance(); // no revert; no feasible size, so no progress + + assertEq(_healthFactor(), hfBefore, "hf unchanged"); + assertEq(_debt(), debtBefore, "debt unchanged"); + assertEq(FUSDEV.balanceOf(address(vault)), fusBefore, "yield unchanged"); + assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle"); } /// @notice Loosening `maxSlippageBps` lets a previously-reverting rebalance From a633a44fc3506796bc9d57b9971f388f8beda1ef Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 26 Jun 2026 09:40:31 -0700 Subject: [PATCH 03/15] test: cover partial rebalancing with a price-impact pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solidity/test/FCMVault.t.sol | 102 +++++++++++++++++++++ solidity/test/mocks/MockCpmmSwapRouter.sol | 52 +++++++++++ 2 files changed, 154 insertions(+) create mode 100644 solidity/test/mocks/MockCpmmSwapRouter.sol diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 9ac38c9..3b86405 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -16,6 +16,7 @@ import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {MockMorpho} from "./mocks/MockMorpho.sol"; import {MockSwapRouter} from "./mocks/MockSwapRouter.sol"; +import {MockCpmmSwapRouter} from "./mocks/MockCpmmSwapRouter.sol"; import {MockOracle} from "./mocks/MockOracle.sol"; import {MockIrm} from "./mocks/MockIrm.sol"; @@ -833,6 +834,107 @@ contract FCMVaultTest is Test { assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle"); } + // ---- partial rebalancing (price-impact pool) ------------------------- + + /// @dev Etch a constant-product swap router over the swap-router address and + /// seed both sides of the yield/debt pair with `reserve`, giving a 1:1 + /// spot rate (matching the 1e36 yield oracle) plus size-dependent price + /// impact. Call AFTER any flat-rate deposit so deposit's unfloored swap + /// runs at the clean 1:1 rate; the CPMM then governs the rebalance. + function _installCpmmRouter(uint256 reserve) internal { + vm.etch(address(SwapLib.SWAP_ROUTER), address(new MockCpmmSwapRouter()).code); + MockCpmmSwapRouter r = MockCpmmSwapRouter(address(SwapLib.SWAP_ROUTER)); + r.setReserves(address(PYUSD0), reserve); + r.setReserves(address(FUSDEV), reserve); + } + + /// @notice Partial delever: when the full delever swap would breach the + /// slippage floor on a shallow price-impact pool, `rebalance` sells + /// the largest feasible fraction instead of reverting. HF rises + /// toward — but falls short of — the lower re-entry target, debt and + /// yield both shrink, and no idle loan token is left behind. + function test_Rebalance_PartialDeleverMakesProgress() public { + _depositFor(user, 1 ether); + + marketOracle.setPrice(1700e36); // push HF below min -> delever path + uint256 hfBefore = _healthFactor(); + assertLt(hfBefore, HEALTH_FACTOR_MIN, "below min"); + + // Shallow pool: the full delever (~61e18 yield) far exceeds the ~1% + // depth (~reserve/99 ≈ 10e18), so only a reduced fraction clears. + _installCpmmRouter(1000e18); + + uint256 debtBefore = _debt(); + uint256 fusBefore = FUSDEV.balanceOf(address(vault)); + + vault.rebalance(); + + uint256 hfAfter = _healthFactor(); + assertGt(hfAfter, hfBefore, "delever raised HF"); + assertLt(hfAfter, HEALTH_FACTOR_MIN_TARGET, "fell short of target (partial)"); + assertLt(_debt(), debtBefore, "debt partially repaid"); + assertLt(FUSDEV.balanceOf(address(vault)), fusBefore, "yield partially sold"); + assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle"); + } + + /// @notice Partial lever: when the full lever swap would breach the floor, + /// `rebalance` swaps the largest feasible fraction and repays the + /// remainder of the borrow, so HF falls toward — but not all the way + /// to — the upper re-entry target, with no idle loan token left. + function test_Rebalance_PartialLeverMakesProgress() public { + _depositFor(user, 1 ether); + + marketOracle.setPrice(2300e36); // push HF above max -> lever path + uint256 hfBefore = _healthFactor(); + assertGt(hfBefore, HEALTH_FACTOR_MAX, "above max"); + + _installCpmmRouter(1000e18); + + uint256 debtBefore = _debt(); + uint256 fusBefore = FUSDEV.balanceOf(address(vault)); + + vault.rebalance(); + + uint256 hfAfter = _healthFactor(); + assertLt(hfAfter, hfBefore, "lever lowered HF"); + assertGt(hfAfter, HEALTH_FACTOR_MAX_TARGET, "fell short of target (partial)"); + assertGt(_debt(), debtBefore, "debt partially increased"); + assertGt(FUSDEV.balanceOf(address(vault)), fusBefore, "yield partially bought"); + assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle (remainder repaid)"); + } + + /// @notice Convergence: repeated partial delevers drive the position back + /// inside the band. As each step repays debt, the remaining gap + /// shrinks until a full-size swap finally clears the floor, landing + /// the position at (just below) the lower re-entry target. A single + /// rebalance is not enough; several together are. + function test_Rebalance_PartialDeleverConvergesOverTicks() public { + _depositFor(user, 1 ether); + + marketOracle.setPrice(1700e36); + assertLt(_healthFactor(), HEALTH_FACTOR_MIN, "below min"); + + _installCpmmRouter(1000e18); + + // One rebalance alone does not restore the band. + vault.rebalance(); + assertLt(_healthFactor(), HEALTH_FACTOR_MIN, "single partial still below min"); + + // Successive rebalances converge: each clears a larger fraction as the + // gap shrinks. Bounded loop; convergence is reached well within it. + for (uint256 i = 0; i < 20; i++) { + vault.rebalance(); + if (_healthFactor() >= HEALTH_FACTOR_MIN) break; + } + + uint256 hf = _healthFactor(); + assertGe(hf, HEALTH_FACTOR_MIN, "converged back into band"); + // Landed at the lower re-entry target (slightly under, from CPMM + // undershoot vs the oracle-implied size), not overshooting it. + assertLe(hf, HEALTH_FACTOR_MIN_TARGET, "did not overshoot target"); + assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle after convergence"); + } + /// @notice Loosening `maxSlippageBps` lets a previously-reverting rebalance /// through: with the floor raised to 5%, a 3% haircut is tolerated. function test_Rebalance_RespectsLoosenedSlippage() public { diff --git a/solidity/test/mocks/MockCpmmSwapRouter.sol b/solidity/test/mocks/MockCpmmSwapRouter.sol new file mode 100644 index 0000000..300602f --- /dev/null +++ b/solidity/test/mocks/MockCpmmSwapRouter.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import {ISwapRouter} from "../../src/interfaces/ISwapRouter.sol"; +import {MockERC20} from "./MockERC20.sol"; + +/// @dev Constant-product (x*y=k) swap mock used to exercise price-impact- +/// dependent slippage. Unlike `MockSwapRouter` — a flat per-unit rate no +/// trade size can escape — here a smaller trade gets a better average +/// price, so a swap that breaches a slippage floor at full size can clear +/// it at a reduced size. That is exactly the property partial rebalancing +/// relies on, so this mock is what the partial-progress tests etch over +/// the swap router. +/// +/// Mechanics: `out = reserveOut * amountIn / (reserveIn + amountIn)`, the +/// standard constant-product fill. The marginal (spot) rate is +/// `reserveOut / reserveIn`; the realized average rate +/// `reserveOut / (reserveIn + amountIn)` degrades as `amountIn` grows, so +/// the slippage versus spot is `amountIn / (reserveIn + amountIn)`. With +/// equal reserves the spot rate is 1:1, matching a 1e36 yield oracle. +/// +/// Reserves are virtual: like the flat mock it mints/burns tokens and does +/// NOT update reserves after a swap, so it models a fixed-depth curve +/// (price impact within a single swap, no inventory accounting). The +/// partial-rebalance tests only need one committed swap per rebalance, so +/// that is sufficient and keeps the expected output exactly computable. +contract MockCpmmSwapRouter { + /// @dev Virtual reserve per token, keyed by token address. + mapping(address => uint256) public reserveOf; + + /// @notice Set the virtual reserve for a token. Set both sides of a pair to + /// the same value for a 1:1 spot rate. + function setReserves(address token, uint256 reserve) external { + reserveOf[token] = reserve; + } + + function exactInputSingle(ISwapRouter.ExactInputSingleParams calldata p) + external + payable + returns (uint256 amountOut) + { + uint256 reserveIn = reserveOf[p.tokenIn]; + uint256 reserveOut = reserveOf[p.tokenOut]; + require(reserveIn > 0 && reserveOut > 0, "reserves unset"); + + amountOut = reserveOut * p.amountIn / (reserveIn + p.amountIn); + require(amountOut >= p.amountOutMinimum, "Too little received"); + + MockERC20(p.tokenIn).burn(msg.sender, p.amountIn); + MockERC20(p.tokenOut).mint(p.recipient, amountOut); + } +} From ae1d01696c74014a3b7ffece8b6c17df264cc5d5 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 26 Jun 2026 09:41:11 -0700 Subject: [PATCH 04/15] docs: describe partial rebalancing under the slippage floor 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 --- docs/architecture.md | 1 + docs/vault-rebalancer.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index eec61c4..2c8d9c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -267,6 +267,7 @@ For each external function, how does it protect against re-entrancy? ### Sandwich Attack An attacker manipulates AMM prices before and after our swap to capture part of the value of our swap. - The primary mitigation is a slippage limit, which limits how much slippage we will accept on each trade. This doesn't prevent the attack, but does limit how much value can be extracted per trade. +- `rebalance` enforces this limit per-trade but does **not** revert the whole rebalance when the full target swap would breach it. Instead it swaps the largest feasible fraction under the floor (halving the candidate size until it clears) and lands partway to the re-entry target; successive rebalances converge as price impact shrinks. The per-trade slippage bound — and therefore the per-trade extraction cap — is unchanged; partial filling only changes whether a too-large swap reverts or makes bounded progress. When the slippage is a uniform per-unit cost no smaller trade can escape, the rebalance is a swap-free no-op. - Flow as the underlying platform provides some protection. There is no system akin to [MEV-Boost](https://github.com/flashbots/mev-boost), which systematizes MEV extraction. No individual node in Flow can deterministically dictate transaction ordering. Attackers need to send many transactions, hope some are placed in the desired order, and be able to revert operations on those that are not in the desired order. Still possible, but more complex and expensive. If an attacker is able to invoke a function which performs a swap (that isn't swapping their funds), then the sandwich attack becomes much more dangerous (eg. a permissionless `rebalance` function). diff --git a/docs/vault-rebalancer.md b/docs/vault-rebalancer.md index dcaab1d..1616ff5 100644 --- a/docs/vault-rebalancer.md +++ b/docs/vault-rebalancer.md @@ -35,7 +35,8 @@ One Cadence resource per EVM target, owned by an admin account. Stored at a dete | Failure | Observable | Recovery | | :--- | :--- | :--- | -| EVM call fails, transient (slippage, momentary state, out-of-gas) | Tick event with EVM error code | Next tick retries automatically; persistent OOG requires admin to raise the EVM gas limit | +| EVM call fails, transient (momentary state, out-of-gas) | Tick event with EVM error code | Next tick retries automatically; persistent OOG requires admin to raise the EVM gas limit | +| Slippage: full target swap would breach the floor | Tick succeeds; `Rebalanced`/`VaultState` show a smaller-than-target move (or no move) | None needed — `rebalance` partial-fills the largest feasible fraction and converges over subsequent ticks; a no-move tick means a uniform price gap no trade size escapes, which clears when the gap does | | EVM call fails, sustained (role revoked EVM-side, persistent Solidity-side condition) | Tick event repeats with non-zero error code N ticks in a row | Admin addresses EVM-side condition (restore role, resolve Solidity state) | | Fee vault depletion / storage-min breach (Cadence scheduling fees) | No event; absence of `Ticked` triggers the liveness alert | Admin tops up; signs tx to re-invoke self-reschedule | | COA FLOW depletion (EVM-side gas) | Tick events repeat with non-zero EVM error code; off-chain balance script catches drift earlier | Anyone can send FLOW to the COA (permissionless, from either Cadence or EVM) | From 246895bb7672ba21bfa92fa80e3549f9678083ca Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 26 Jun 2026 16:32:44 -0700 Subject: [PATCH 05/15] Partial rebalancing via native sqrtPriceLimitX96 (replaces halving) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solidity/script/DeployVault.s.sol | 1 + solidity/src/FCMVault.sol | 237 ++++++++++++++------- solidity/src/libraries/SwapLib.sol | 118 +++------- solidity/test/FCMVault.t.sol | 181 ++++++++++++---- solidity/test/TvlLimit.t.sol | 2 + solidity/test/mocks/MockCpmmSwapRouter.sol | 70 +++--- solidity/test/mocks/MockUniswapV3Pool.sol | 21 ++ 7 files changed, 402 insertions(+), 228 deletions(-) create mode 100644 solidity/test/mocks/MockUniswapV3Pool.sol diff --git a/solidity/script/DeployVault.s.sol b/solidity/script/DeployVault.s.sol index 5b49217..4906baf 100644 --- a/solidity/script/DeployVault.s.sol +++ b/solidity/script/DeployVault.s.sol @@ -65,6 +65,7 @@ contract DeployVault is ConfiguredScript { marketLltv: c.marketLltv, feeYieldDebt: c.feeYieldDebt, feeAssetDebt: c.feeAssetDebt, + yieldDebtPool: c.yieldDebtPool, healthFactorMin: c.healthFactorMin, healthFactorMax: c.healthFactorMax, healthFactorMinTarget: c.healthFactorMinTarget, diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index b49deaf..7905c94 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -16,6 +16,7 @@ import {IOracle} from "@morpho-blue/interfaces/IOracle.sol"; import {MarketLib} from "./libraries/MarketLib.sol"; import {SwapLib} from "./libraries/SwapLib.sol"; +import {IUniswapV3Pool} from "./interfaces/IUniswapV3Pool.sol"; // Morpho Blue singleton address for Flow EVM IMorpho constant MORPHO = IMorpho(0x9a094eA4AbE343D908E1bDE9fC478D71b41D665f); @@ -46,17 +47,16 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// @dev Basis-points denominator for `maxSlippageBps`. uint256 internal constant BPS = 10_000; - /// @dev Maximum number of halvings a rebalance swap may attempt after the - /// full-size attempt before giving up (see `SwapLib.swapExactInPartial`). - /// The smallest size tried is `target >> MAX_PARTIAL_HALVINGS` - /// (~1/1024 of the target). Beyond that, the residual swap is treated - /// as infeasible — the slippage is a uniform per-unit cost no smaller - /// trade can escape — and the rebalance no-ops. The price-impact case - /// typically clears in the first one or two attempts; the full count is - /// only reached near the no-feasible-size boundary, so the worst-case - /// gas (this many reverting swaps) is rare. The off-chain rebalancer's - /// EVM gas limit must accommodate it. - uint256 internal constant MAX_PARTIAL_HALVINGS = 10; + /// @dev Q64.96 fixed-point one (`2**96`) and its square (`2**192`), used to + /// build the `sqrtPriceX96` price limit for rebalance swaps. + uint256 internal constant ONE_X96 = 1 << 96; + uint256 internal constant ONE_X192 = 1 << 192; + + /// @dev Uniswap V3 tick-math bounds on a valid `sqrtPriceLimitX96`. A limit + /// outside `(MIN_SQRT_RATIO, MAX_SQRT_RATIO)` is rejected by the pool; + /// the vault treats such a limit as "no feasible swap" and skips. + uint160 internal constant MIN_SQRT_RATIO = 4295128739; + uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; // @dev Address of the loan token (inner vault asset) IERC20 public immutable loanToken; @@ -64,6 +64,11 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { IERC20 public immutable yieldToken; // @dev Pool fee for swapping yield<->debt uint24 public immutable feeYieldDebt; + /// @notice The FlowSwap V3 yield/debt pool the rebalance swaps route + /// through. Read for its live `slot0` marginal price so a rebalance + /// can derive a `sqrtPriceLimitX96` from the oracle and skip when the + /// pool is already priced past the slippage bound. + address public immutable yieldDebtPool; /// @notice Pool fee tier for the asset/debt pool, used to reconcile /// redeem surplus from loan token back to the underlying asset. uint24 public immutable feeAssetDebt; @@ -112,12 +117,15 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { event MaxTvlSet(uint256 previousMaxTvl, uint256 newMaxTvl); - /// @notice Max slippage (basis points) tolerated on the rebalance swaps - /// (lever and delever). The swap's `amountOutMinimum` is the - /// oracle-expected output discounted by this; a worse fill reverts - /// the rebalance. Applies only to vault-initiated rebalances — - /// deposit/redeem slippage is the caller's responsibility, set via - /// the ERC4626 router. Defaults to 1%, admin-adjustable. + /// @notice Max price impact (basis points) tolerated on the rebalance swaps + /// (lever and delever). It sets each swap's `sqrtPriceLimitX96` to + /// the oracle price discounted by this amount, so the pool fills + /// only while its marginal price stays within tolerance and + /// partial-fills (or skips) past it — rather than reverting. Bounds + /// price impact, not the pool's fixed LP fee. Applies only to + /// vault-initiated rebalances — deposit/redeem slippage is the + /// caller's responsibility, set via the ERC4626 router. Defaults to + /// 1%, admin-adjustable. uint256 public maxSlippageBps; /// @notice Emitted when the admin updates `maxSlippageBps`. @@ -163,6 +171,7 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { uint256 marketLltv; uint24 feeYieldDebt; uint24 feeAssetDebt; + address yieldDebtPool; uint256 healthFactorMin; uint256 healthFactorMax; uint256 healthFactorMinTarget; @@ -240,10 +249,13 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { require(p.healthFactorMinTarget <= p.healthFactorMaxTarget, "HF minTarget > maxTarget"); require(p.healthFactorMaxTarget <= p.healthFactorMax, "HF maxTarget > max"); + require(p.yieldDebtPool != address(0), "yieldDebtPool zero"); + loanToken = p.loanToken; yieldToken = p.yieldToken; feeYieldDebt = p.feeYieldDebt; feeAssetDebt = p.feeAssetDebt; + yieldDebtPool = p.yieldDebtPool; healthFactorMin = p.healthFactorMin; healthFactorMax = p.healthFactorMax; healthFactorMinTarget = p.healthFactorMinTarget; @@ -279,10 +291,77 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { maxSlippageBps = newBps; } - /// @dev Discount an oracle-expected swap output by `maxSlippageBps` to get - /// the `amountOutMinimum` floor for a rebalance swap. - function _slippageFloor(uint256 expectedOut) internal view returns (uint256) { - return expectedOut.mulDiv(BPS - maxSlippageBps, BPS); + /// @dev The `sqrtPriceX96` marginal-price limit for a rebalance swap on the + /// yield/debt pool: the oracle price discounted by `maxSlippageBps`, + /// expressed in the pool's `sqrt(token1/token0) * 2**96` coordinate. + /// The pool fills a swap only while its marginal price is on the + /// good side of this limit, so the realized average price is bounded + /// by `maxSlippageBps` of *price impact* relative to the oracle. + /// + /// Token decimals are already baked into the yield oracle price (the + /// Morpho/IOracle convention: `yield * price / 1e36 = loan` in raw + /// units), so the raw `token1/token0` ratio is read straight off it + /// with no decimal adjustment here. The slippage discount is applied + /// in sqrt space: a price-decreasing (`zeroForOne`) swap multiplies the + /// oracle sqrt price by `sqrt(1 - slip)`, a price-increasing swap + /// divides by it. + /// @param zeroForOne True when selling token0 for token1 (price falls). + /// @return The Q64.96 limit, unclamped — callers range/side-check it. + function _oracleSqrtPriceLimitX96(bool zeroForOne) internal view returns (uint256) { + // P_oracle = token1 / token0 in raw units. yieldOracle.price() (`py`) + // is the raw loan-per-yield ratio scaled by 1e36. + uint256 py = IOracle(yieldOracle).price(); + uint256 num; + uint256 den; + if (address(yieldToken) < address(loanToken)) { + // token0 = yield, token1 = loan -> P = loan/yield = py / 1e36 + num = py; + den = MarketLib.ORACLE_PRICE_SCALE; + } else { + // token0 = loan, token1 = yield -> P = yield/loan = 1e36 / py + num = MarketLib.ORACLE_PRICE_SCALE; + den = py; + } + + // sqrt(P_oracle) * 2**96 = sqrt(P_oracle * 2**192). + uint256 sqrtOracleX96 = Math.sqrt(Math.mulDiv(num, ONE_X192, den)); + // sqrt(1 - slip) * 2**96, slip = maxSlippageBps / BPS. + uint256 sqrtSlipX96 = Math.sqrt(Math.mulDiv(BPS - maxSlippageBps, ONE_X192, BPS)); + + return zeroForOne + ? Math.mulDiv(sqrtOracleX96, sqrtSlipX96, ONE_X96) // * sqrt(1 - slip) + : Math.mulDiv(sqrtOracleX96, ONE_X96, sqrtSlipX96); // / sqrt(1 - slip) + } + + /// @dev Resolve the `sqrtPriceLimitX96` for a rebalance swap of `tokenIn` + /// (yield or loan) on the yield/debt pool, and decide whether the swap + /// is feasible at all. `ok` is false when the limit is out of tick-math + /// range, or when the pool's live marginal price is already on the bad + /// side of the limit (so any swap would either be a no-op or revert + /// `SPL`) — in that case the caller skips the swap and the rebalance + /// makes no progress this call. + /// @param tokenIn The token the swap sells. + /// @return limit The Q64.96 price limit to pass to the pool. + /// @return ok Whether a swap should be attempted. + function _yieldDebtSwapLimit(address tokenIn) internal view returns (uint160 limit, bool ok) { + // token0 is the lower-addressed token; selling it is `zeroForOne`. + address token0 = + address(yieldToken) < address(loanToken) ? address(yieldToken) : address(loanToken); + bool zeroForOne = (tokenIn == token0); + + uint256 raw = _oracleSqrtPriceLimitX96(zeroForOne); + if (raw <= MIN_SQRT_RATIO || raw >= MAX_SQRT_RATIO) return (0, false); + + (uint160 spot,,,,,,) = IUniswapV3Pool(yieldDebtPool).slot0(); + // The limit must sit on the side the price moves toward: below spot for + // a price-decreasing swap, above spot for a price-increasing one. If the + // pool is already past it, there is no room to trade within tolerance. + if (zeroForOne) { + if (raw >= spot) return (0, false); + } else { + if (raw <= spot) return (0, false); + } + return (uint160(raw), true); } // @dev Defines the decimal offset between vault assets and shares. Larger offsets make inflation attacks more expensive. @@ -579,17 +658,23 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// the lowest average-case cost. By convention, there is a small /// buffer between the band's bound and the target. /// - /// Partial rebalancing: if the swap that would land the position at - /// the re-entry target is too large to clear the `maxSlippageBps` - /// floor, the rebalance does not revert — it swaps the largest - /// feasible fraction (see `SwapLib.swapExactInPartial`) and lands - /// partway to the target. Because price impact grows with size, - /// successive rebalances each clear a larger fraction, so the - /// position converges to the band over several calls rather than in - /// one. When the slippage is a uniform per-unit cost no trade size - /// can escape, the rebalance makes no progress (a swap-free no-op); - /// the off-chain rebalancer surfaces the unchanged health factor via - /// the emitted `Rebalanced`/`VaultState` events. + /// Partial rebalancing: the rebalance swap carries a + /// `sqrtPriceLimitX96` derived from the oracle price and + /// `maxSlippageBps` (see `_yieldDebtSwapLimit`). If reaching the + /// re-entry target would push the pool past that price, the pool + /// fills only up to it — a partial fill — and the position lands + /// partway to the target instead of reverting. Because price impact + /// grows with size, successive rebalances each fill more as the gap + /// shrinks, so the position converges over several calls. When the + /// pool's marginal price is already past the bound (e.g. an adverse + /// oracle/pool divergence or a fresh sandwich), the swap is skipped + /// and the rebalance is a swap-free no-op; the off-chain rebalancer + /// surfaces the unchanged health factor via the emitted + /// `Rebalanced`/`VaultState` events. + /// + /// Note the bound is on the pool's *marginal price* relative to the + /// oracle, i.e. on price impact. The pool's fixed LP fee is a + /// separate, known cost and is not part of this bound. function rebalance() external logsVaultState { // After a recovery the position is terminal; revert with an explicit // error so the off-chain rebalancer surfaces it and stops, rather than @@ -624,20 +709,22 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// Since `hf > max >= maxTarget`, `currentDebt < targetDebt`. The /// borrow leg adds `targetDebt - currentDebt`. /// - /// Partial: the full `additionalDebt` is borrowed up front, then the - /// loan->yield swap runs best-effort under the slippage floor. If the - /// floor caps the swap below the full size, only the swapped portion - /// stays levered; the unswapped loan token is immediately repaid, so - /// the position lands partway to `healthFactorMaxTarget` with no idle - /// loan token left behind. Borrowing first and repaying the remainder - /// (rather than sizing the borrow to the swap) avoids needing the swap - /// output before the tokens to swap exist. + /// Partial: the full `borrowAmount` is borrowed up front, then the + /// loan->yield swap runs under a `sqrtPriceLimitX96` derived from the + /// oracle and `maxSlippageBps`. If the swap would push the pool past + /// that price, the pool fills only up to it (a partial fill) and the + /// unspent loan token is immediately repaid, so the position lands + /// partway to `healthFactorMaxTarget` with no idle loan token left + /// behind. Borrowing first and repaying the remainder (rather than + /// sizing the borrow to the fill) avoids needing the swap output before + /// the tokens to swap exist. When the pool is already priced past the + /// bound, the swap is skipped and the borrow is fully repaid (no-op). /// @param maxBorrow Current maximum-borrowable amount at LLTV (independent of current debt) /// @param currentDebt Current outstanding debt (caller passes the same /// value used to compute `hfBefore` to avoid a /// second `MORPHO.position` SLOAD). /// @return additionalDebt Net new debt taken on in this call (the loan token - /// actually swapped into yield; 0 if no size cleared). + /// actually swapped into yield; 0 if nothing filled). function _rebalanceLever(uint256 maxBorrow, uint256 currentDebt) internal returns (uint256 additionalDebt) @@ -646,29 +733,25 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { if (targetDebt <= currentDebt) return 0; uint256 borrowAmount = targetDebt - currentDebt; + (uint160 limit, bool ok) = _yieldDebtSwapLimit(address(loanToken)); + if (!ok) return 0; // pool already past the slippage bound — no-op. + + // Borrow first, then swap loan->yield bounded by the price limit. The + // pool partial-fills up to the limit; whatever loan it does not consume + // stays with the vault and is repaid below, so we only lever by the + // amount actually converted to yield. The price limit is the + // price-impact / sandwich guard; deposit's identical leg is unfloored + // (user-facing slippage is the router's job). + uint256 loanBefore = loanToken.balanceOf(address(this)); market.borrow(borrowAmount); - // Floor the loan->yield swap at the oracle-expected yield out, less - // maxSlippageBps. Deposit's identical leg is intentionally unfloored - // (user-facing slippage is the router's job); this leg is - // vault-initiated, so the floor is the price-impact / sandwich guard. - // Best-effort: swaps the largest feasible fraction rather than reverting. - uint256 expectedYield = - borrowAmount.mulDiv(MarketLib.ORACLE_PRICE_SCALE, IOracle(yieldOracle).price()); - (uint256 loanSwapped,) = SwapLib.swapExactInPartial( - address(loanToken), - address(yieldToken), - feeYieldDebt, - borrowAmount, - _slippageFloor(expectedYield), - MAX_PARTIAL_HALVINGS + SwapLib.swapExactInToLimit( + address(loanToken), address(yieldToken), feeYieldDebt, borrowAmount, limit ); - // Repay any loan token the swap could not place within the floor, so - // no idle loan token lingers and the position only levers by the amount - // actually converted to yield. - uint256 leftover = borrowAmount - loanSwapped; + // Repay the loan token the swap left behind, so no idle loan lingers. + uint256 leftover = loanToken.balanceOf(address(this)) - loanBefore; if (leftover > 0) market.repay(leftover); - additionalDebt = loanSwapped; + additionalDebt = borrowAmount - leftover; } /// @dev Delever branch of `rebalance`: position is over-levered @@ -685,10 +768,13 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// under-shoot (post-rebalance HF is slightly below /// `healthFactorMinTarget` if the swap realized less than oracle). /// - /// Partial: the yield->loan swap runs best-effort under the slippage - /// floor. If the floor caps the swap below `yieldToSell`, only the - /// realized loan token is repaid and the position lands partway to - /// `healthFactorMinTarget` rather than reverting. + /// Partial: the yield->loan swap runs under a `sqrtPriceLimitX96` + /// derived from the oracle and `maxSlippageBps`. If selling the full + /// `yieldToSell` would push the pool past that price, the pool fills + /// only up to it and the vault repays just the realized loan token, so + /// the position lands partway to `healthFactorMinTarget` rather than + /// reverting. When the pool is already priced past the bound the swap + /// is skipped entirely (no-op). /// /// @param maxBorrow Current maximum-borrowable amount at LLTV (may be 0 /// after a liquidation that wiped collateral). @@ -712,20 +798,15 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { if (yieldToSell > yieldBalance) yieldToSell = yieldBalance; if (yieldToSell == 0) return 0; - // Floor the yield->loan swap at the oracle-expected loan out for the - // (possibly-capped) yieldToSell, less maxSlippageBps. Redeem's identical - // leg is intentionally unfloored (router's job); this vault-initiated - // leg gets the price-impact / sandwich guard. Best-effort: swaps the - // largest feasible fraction rather than reverting, so a too-large - // delever still repays as much as the floor allows. - uint256 expectedLoan = yieldToSell.mulDiv(yieldPrice, MarketLib.ORACLE_PRICE_SCALE); - (, uint256 loanGot) = SwapLib.swapExactInPartial( - address(yieldToken), - address(loanToken), - feeYieldDebt, - yieldToSell, - _slippageFloor(expectedLoan), - MAX_PARTIAL_HALVINGS + (uint160 limit, bool ok) = _yieldDebtSwapLimit(address(yieldToken)); + if (!ok) return 0; // pool already past the slippage bound — no-op. + + // Sell yield->loan bounded by the price limit. The pool partial-fills + // up to it, so a too-large delever still repays as much as the bound + // allows. The price limit is the price-impact / sandwich guard; redeem's + // identical leg is unfloored (router's job). + uint256 loanGot = SwapLib.swapExactInToLimit( + address(yieldToken), address(loanToken), feeYieldDebt, yieldToSell, limit ); // Cap repayment at outstanding debt diff --git a/solidity/src/libraries/SwapLib.sol b/solidity/src/libraries/SwapLib.sol index 1285783..5c5cd75 100644 --- a/solidity/src/libraries/SwapLib.sol +++ b/solidity/src/libraries/SwapLib.sol @@ -18,8 +18,8 @@ library SwapLib { /// `fee` is the FlowSwap V3 fee tier (e.g. 100 / 500 / 3000). /// Setting `amountOutMinimum = 0` is intentional for legs whose /// downstream accounting already enforces fairness (e.g. redeem - /// scales by realized output); use `swapExactInMin` for legs - /// that need an explicit floor. + /// scales by realized output); use `swapExactInToLimit` for legs + /// that need an explicit price-impact bound. /// @param tokenIn Token being sold. /// @param tokenOut Token being bought. /// @param fee Pool fee tier. @@ -42,24 +42,39 @@ library SwapLib { ); } - /// @notice Same as `swapExactIn` but reverts in the router if the - /// realized output is below `amountOutMinimum`. - /// @dev Used by legs that need an explicit slippage cap derived from - /// an oracle price and a price-impact tolerance. - /// @param tokenIn Token being sold. - /// @param tokenOut Token being bought. - /// @param fee Pool fee tier. - /// @param amountIn Amount of `tokenIn` to sell. - /// @param amountOutMinimum Minimum acceptable `tokenOut` output; the - /// router reverts if the realized amount is - /// strictly less than this value. - /// @return amountOut Realized amount of `tokenOut` received. - function swapExactInMin( + /// @notice Swap up to `amountIn` of `tokenIn` for `tokenOut`, stopping early + /// if the pool's marginal price reaches `sqrtPriceLimitX96`. The + /// pool fills the swap natively up to that price bound and then + /// stops without reverting, so a swap too large to complete within + /// the bound is a *partial fill* rather than a revert. + /// @dev This is the canonical Uniswap V3 mechanism for a best-effort + /// swap under a price bound: the pool's swap loop runs while input + /// remains AND the marginal price has not reached the limit, so the + /// marginal (and therefore average) execution price never crosses + /// the limit. `amountOutMinimum` is left at 0 — protection comes + /// entirely from the price limit, and a non-zero minimum would + /// revert a legitimate partial fill. + /// + /// IMPORTANT: on a partial fill the router consumes LESS than + /// `amountIn` and leaves the unspent `tokenIn` with the caller — the + /// caller must account for the remainder (the vault repays it). + /// `sqrtPriceLimitX96` MUST be on the correct side of the current + /// pool price (below it for a 0->1 swap, above it for 1->0), + /// otherwise the pool reverts `SPL`; callers check the live price + /// first. Caller MUST have approved `SWAP_ROUTER` for `tokenIn`. + /// @param tokenIn Token being sold. + /// @param tokenOut Token being bought. + /// @param fee Pool fee tier. + /// @param amountIn Maximum amount of `tokenIn` to sell. + /// @param sqrtPriceLimitX96 Q64.96 marginal-price bound the swap will not + /// cross; the fill stops here if reached. + /// @return amountOut Realized amount of `tokenOut` received. + function swapExactInToLimit( address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, - uint256 amountOutMinimum + uint160 sqrtPriceLimitX96 ) internal returns (uint256 amountOut) { return SWAP_ROUTER.exactInputSingle( ISwapRouter.ExactInputSingleParams({ @@ -68,76 +83,9 @@ library SwapLib { fee: fee, recipient: address(this), amountIn: amountIn, - amountOutMinimum: amountOutMinimum, - sqrtPriceLimitX96: 0 + amountOutMinimum: 0, + sqrtPriceLimitX96: sqrtPriceLimitX96 }) ); } - - /// @notice Best-effort partial swap: try to swap `amountIn` honoring an - /// `amountOutMinimum` floor; if the swap would breach the floor, - /// halve both the input and the floor and retry, up to `maxHalvings` - /// times. Returns the input actually swapped and the output received, - /// or `(0, 0)` if no size down to `amountIn >> maxHalvings` cleared - /// the floor. - /// @dev Used by vault-initiated rebalances so a swap too large to clear - /// the slippage floor still makes partial progress instead of - /// reverting the whole rebalance. - /// - /// The floor is halved alongside the input because it derives from - /// an oracle-expected output that is linear in the input, whereas - /// realized AMM price impact grows with size. A smaller swap - /// therefore faces a proportionally-smaller floor *and* less price - /// impact, so it can clear a bound the full size could not. When the - /// shortfall is a uniform per-unit cost (a flat fee or an - /// oracle/pool spot divergence) rather than price impact, no size - /// clears the floor and the call returns `(0, 0)` — the caller - /// no-ops rather than trading at a bad price. - /// - /// The retry wraps the router call in try/catch, so *any* revert - /// (slippage or otherwise) triggers a smaller retry; a pool that - /// fails at every attempted size yields `(0, 0)`. Caller MUST have - /// approved `SWAP_ROUTER` for `tokenIn`. - /// @param tokenIn Token being sold. - /// @param tokenOut Token being bought. - /// @param fee Pool fee tier. - /// @param amountIn Desired (full) amount of `tokenIn` to sell. - /// @param amountOutMinimum Slippage floor for the full `amountIn`; halved - /// in lockstep with the input on each retry. - /// @param maxHalvings Maximum number of halvings to attempt after the - /// full-size attempt (smallest size is - /// `amountIn >> maxHalvings`). - /// @return amountInUsed Input actually swapped (0 if none cleared). - /// @return amountOut Realized output received (0 if none cleared). - function swapExactInPartial( - address tokenIn, - address tokenOut, - uint24 fee, - uint256 amountIn, - uint256 amountOutMinimum, - uint256 maxHalvings - ) internal returns (uint256 amountInUsed, uint256 amountOut) { - for (uint256 i = 0; i <= maxHalvings; i++) { - if (amountIn == 0) break; - try SWAP_ROUTER.exactInputSingle( - ISwapRouter.ExactInputSingleParams({ - tokenIn: tokenIn, - tokenOut: tokenOut, - fee: fee, - recipient: address(this), - amountIn: amountIn, - amountOutMinimum: amountOutMinimum, - sqrtPriceLimitX96: 0 - }) - ) returns ( - uint256 out - ) { - return (amountIn, out); - } catch { - amountIn /= 2; - amountOutMinimum /= 2; - } - } - return (0, 0); - } } diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 3b86405..8cc7046 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -17,6 +17,7 @@ import {MockERC20} from "./mocks/MockERC20.sol"; import {MockMorpho} from "./mocks/MockMorpho.sol"; import {MockSwapRouter} from "./mocks/MockSwapRouter.sol"; import {MockCpmmSwapRouter} from "./mocks/MockCpmmSwapRouter.sol"; +import {MockUniswapV3Pool} from "./mocks/MockUniswapV3Pool.sol"; import {MockOracle} from "./mocks/MockOracle.sol"; import {MockIrm} from "./mocks/MockIrm.sol"; @@ -47,6 +48,7 @@ contract FCMVaultTest is Test { FCMVault internal vault; MockOracle internal marketOracle; MockOracle internal yieldOracle; + MockUniswapV3Pool internal yieldPool; address internal admin = address(0x12345); address internal user = address(0xA11CE); @@ -65,6 +67,10 @@ contract FCMVaultTest is Test { marketOracle = new MockOracle(WETH_PRICE); yieldOracle = new MockOracle(YIELD_PRICE); + // Yield/debt pool spot defaults to 1:1 (Q96), matching the 1e36 yield + // oracle so a rebalance's oracle-derived price limit sits on the + // tradeable side of spot. + yieldPool = new MockUniswapV3Pool(); vault = new FCMVault( FCMVault.InitParams({ @@ -76,6 +82,7 @@ contract FCMVaultTest is Test { marketLltv: LLTV, feeYieldDebt: FEE, feeAssetDebt: FEE_ASSET_DEBT, + yieldDebtPool: address(yieldPool), healthFactorMin: HEALTH_FACTOR_MIN, healthFactorMax: HEALTH_FACTOR_MAX, healthFactorMinTarget: HEALTH_FACTOR_MIN_TARGET, @@ -784,27 +791,27 @@ contract FCMVaultTest is Test { assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle"); } - // ---- rebalance slippage floor ---------------------------------------- + // ---- rebalance price-limit guard ------------------------------------- - /// @notice A uniform AMM haircut that exceeds `maxSlippageBps` makes the - /// delever a swap-free no-op rather than a revert. The 3% haircut is - /// per-unit (constant-rate mock), so no smaller trade can clear the - /// 1% floor — partial rebalancing finds no feasible size and the - /// position is left untouched, surfaced via the emitted events. - /// (Price-impact slippage, where a smaller trade *does* clear the - /// floor, is exercised in the partial-progress tests below.) - function test_Rebalance_DeleverNoopWhenUniformSlippageExceedsFloor() public { + /// @notice When the pool's marginal price is already past the oracle-derived + /// slippage bound, the delever swap is skipped entirely (a swap-free + /// no-op) rather than trading at a bad price. Selling yield raises + /// the yield/loan price, so a pool spot already above the bound + /// leaves no room to sell within tolerance. + function test_Rebalance_DeleverSkipsWhenSpotPastBound() public { _depositFor(user, 1 ether); marketOracle.setPrice(1700e36); // push HF below min -> delever path uint256 hfBefore = _healthFactor(); assertLt(hfBefore, HEALTH_FACTOR_MIN, "below min"); - MockSwapRouter(address(SwapLib.SWAP_ROUTER)).setFeeBps(300); // 3% > 1% floor + // Pool yield/loan price 2% above oracle — past the 1% bound for selling + // yield (a price-raising swap), so the swap is skipped. + _setPoolPrice(102, 100); uint256 debtBefore = _debt(); uint256 fusBefore = FUSDEV.balanceOf(address(vault)); - vault.rebalance(); // no revert; no feasible size, so no progress + vault.rebalance(); // no revert; spot past bound, so no swap assertEq(_healthFactor(), hfBefore, "hf unchanged"); assertEq(_debt(), debtBefore, "debt unchanged"); @@ -812,21 +819,23 @@ contract FCMVaultTest is Test { assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle"); } - /// @notice Same uniform-slippage no-op on the lever leg: the position is - /// left untouched and no idle loan token lingers (the unswappable - /// borrow is repaid in full). - function test_Rebalance_LeverNoopWhenUniformSlippageExceedsFloor() public { + /// @notice Same price-limit skip on the lever leg: buying yield lowers the + /// yield/loan price, so a pool spot already below the bound leaves no + /// room to buy within tolerance. No swap runs, no idle loan lingers. + function test_Rebalance_LeverSkipsWhenSpotPastBound() public { _depositFor(user, 1 ether); marketOracle.setPrice(2300e36); // push HF above max -> lever path uint256 hfBefore = _healthFactor(); assertGt(hfBefore, HEALTH_FACTOR_MAX, "above max"); - MockSwapRouter(address(SwapLib.SWAP_ROUTER)).setFeeBps(300); // 3% > 1% floor + // Pool yield/loan price 2% below oracle — past the 1% bound for buying + // yield (a price-lowering swap), so the swap is skipped. + _setPoolPrice(98, 100); uint256 debtBefore = _debt(); uint256 fusBefore = FUSDEV.balanceOf(address(vault)); - vault.rebalance(); // no revert; no feasible size, so no progress + vault.rebalance(); // no revert; spot past bound, so no swap assertEq(_healthFactor(), hfBefore, "hf unchanged"); assertEq(_debt(), debtBefore, "debt unchanged"); @@ -836,11 +845,20 @@ contract FCMVaultTest is Test { // ---- partial rebalancing (price-impact pool) ------------------------- + /// @dev Set the yield/debt pool's spot to `num/den` (yield/loan price) in + /// `sqrtPriceX96` form, so tests can place spot on either side of the + /// oracle-derived bound. Default (1/1) is `Q96`. + function _setPoolPrice(uint256 num, uint256 den) internal { + yieldPool.setSqrtPriceX96(uint160(Math.sqrt(Math.mulDiv(num, 1 << 192, den)))); + } + /// @dev Etch a constant-product swap router over the swap-router address and /// seed both sides of the yield/debt pair with `reserve`, giving a 1:1 - /// spot rate (matching the 1e36 yield oracle) plus size-dependent price - /// impact. Call AFTER any flat-rate deposit so deposit's unfloored swap - /// runs at the clean 1:1 rate; the CPMM then governs the rebalance. + /// spot rate (matching the 1e36 yield oracle and the pool mock's + /// default Q96 spot) plus size-dependent price impact. Call AFTER any + /// flat-rate deposit so deposit's unfloored swap runs at the clean 1:1 + /// rate; the CPMM then governs the rebalance, honoring the + /// `sqrtPriceLimitX96` the vault derives from the oracle. function _installCpmmRouter(uint256 reserve) internal { vm.etch(address(SwapLib.SWAP_ROUTER), address(new MockCpmmSwapRouter()).code); MockCpmmSwapRouter r = MockCpmmSwapRouter(address(SwapLib.SWAP_ROUTER)); @@ -848,11 +866,11 @@ contract FCMVaultTest is Test { r.setReserves(address(FUSDEV), reserve); } - /// @notice Partial delever: when the full delever swap would breach the - /// slippage floor on a shallow price-impact pool, `rebalance` sells - /// the largest feasible fraction instead of reverting. HF rises - /// toward — but falls short of — the lower re-entry target, debt and - /// yield both shrink, and no idle loan token is left behind. + /// @notice Partial delever: when selling the full delever amount would push + /// a shallow pool past the price bound, the pool fills only up to the + /// bound (a partial fill) instead of reverting. HF rises toward — but + /// falls short of — the lower re-entry target, debt and yield both + /// shrink, and no idle loan token is left behind. function test_Rebalance_PartialDeleverMakesProgress() public { _depositFor(user, 1 ether); @@ -860,8 +878,8 @@ contract FCMVaultTest is Test { uint256 hfBefore = _healthFactor(); assertLt(hfBefore, HEALTH_FACTOR_MIN, "below min"); - // Shallow pool: the full delever (~61e18 yield) far exceeds the ~1% - // depth (~reserve/99 ≈ 10e18), so only a reduced fraction clears. + // Shallow pool: the full delever (~61e18 yield) moves the price well past + // the 1% bound, so the pool fills only the fraction within tolerance. _installCpmmRouter(1000e18); uint256 debtBefore = _debt(); @@ -877,10 +895,10 @@ contract FCMVaultTest is Test { assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle"); } - /// @notice Partial lever: when the full lever swap would breach the floor, - /// `rebalance` swaps the largest feasible fraction and repays the - /// remainder of the borrow, so HF falls toward — but not all the way - /// to — the upper re-entry target, with no idle loan token left. + /// @notice Partial lever: when the full lever swap would push the pool past + /// the price bound, the pool fills only up to it and the vault repays + /// the unspent borrow, so HF falls toward — but not all the way to — + /// the upper re-entry target, with no idle loan token left. function test_Rebalance_PartialLeverMakesProgress() public { _depositFor(user, 1 ether); @@ -905,9 +923,9 @@ contract FCMVaultTest is Test { /// @notice Convergence: repeated partial delevers drive the position back /// inside the band. As each step repays debt, the remaining gap - /// shrinks until a full-size swap finally clears the floor, landing - /// the position at (just below) the lower re-entry target. A single - /// rebalance is not enough; several together are. + /// shrinks until the full intended sell fits within the price bound, + /// landing the position at (just below) the lower re-entry target. A + /// single rebalance is not enough; several together are. function test_Rebalance_PartialDeleverConvergesOverTicks() public { _depositFor(user, 1 ether); @@ -935,21 +953,91 @@ contract FCMVaultTest is Test { assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle after convergence"); } - /// @notice Loosening `maxSlippageBps` lets a previously-reverting rebalance - /// through: with the floor raised to 5%, a 3% haircut is tolerated. + /// @notice Loosening `maxSlippageBps` widens the price bound, admitting a + /// pool spot that was previously past it. With the pool 2% above + /// oracle, a 1% bound skips the delever (no-op); raising the bound to + /// 5% moves the limit past spot and the delever proceeds. function test_Rebalance_RespectsLoosenedSlippage() public { _depositFor(user, 1 ether); marketOracle.setPrice(1700e36); uint256 hfBefore = _healthFactor(); assertLt(hfBefore, HEALTH_FACTOR_MIN, "below min"); - MockSwapRouter(address(SwapLib.SWAP_ROUTER)).setFeeBps(300); // 3% + // Pool 2% above oracle: past the default 1% bound for selling yield. + _setPoolPrice(102, 100); + + // Default 1% bound: spot is past it, so the delever is a no-op. + vault.rebalance(); + assertEq(_healthFactor(), hfBefore, "1% bound skips: HF unchanged"); + // Loosen to 5%: the bound now sits past spot, so the delever proceeds. vm.prank(admin); - vault.setMaxSlippageBps(500); // 5% > 3%, so the 3% haircut now passes + vault.setMaxSlippageBps(500); vault.rebalance(); - assertGt(_healthFactor(), hfBefore, "delever raised HF"); + assertGt(_healthFactor(), hfBefore, "5% bound admits the delever: HF raised"); + } + + // ---- price-limit math (security-critical conversion) ----------------- + + /// @notice The oracle -> `sqrtPriceLimitX96` conversion matches the + /// oracle price discounted by `maxSlippageBps`, in both swap + /// directions, and tracks the oracle's magnitude (decimals baked in). + function test_PriceLimit_OracleMathMatchesOracleAndSlippage() public { + FCMVaultHarness h = new FCMVaultHarness(_baseParams()); + uint256 q96 = 1 << 96; + + // Default oracle is 1:1 (P_oracle = 1, oracle sqrt price = Q96) and the + // default bound is 1%. A price-decreasing (zeroForOne) swap multiplies + // the oracle sqrt price by sqrt(1 - slip); a price-increasing one + // divides by it. + uint256 sqrtFloor = Math.sqrt(Math.mulDiv(10_000 - 100, 1 << 192, 10_000)); // Q96*sqrt(0.99) + assertEq( + h.exposed_oracleSqrtPriceLimitX96(true), sqrtFloor, "zeroForOne = Q96*sqrt(1-slip)" + ); + assertEq( + h.exposed_oracleSqrtPriceLimitX96(false), + Math.mulDiv(q96, q96, sqrtFloor), + "oneForZero = Q96/sqrt(1-slip)" + ); + + // The two directions differ by exactly the slippage factor 1/(1-slip). + assertApproxEqRel( + Math.mulDiv(h.exposed_oracleSqrtPriceLimitX96(false), 1e18, sqrtFloor), + uint256(1e18) * 10_000 / (10_000 - 100), + 1e12, + "directions differ by 1/(1-slip)" + ); + + // Magnitude tracks the oracle: with yield worth 4 loan, P_oracle = 1/4 + // (yield is token1 here), so limit(true)*limit(false) ~= P_oracle*2**192. + yieldOracle.setPrice(4e36); + uint256 lo = h.exposed_oracleSqrtPriceLimitX96(true); + uint256 hi = h.exposed_oracleSqrtPriceLimitX96(false); + assertApproxEqRel(lo * hi, (uint256(1) << 192) / 4, 1e12, "product ~= P_oracle*2**192"); + } + + /// @notice The swap-limit feasibility flag flips with the pool's live spot: + /// feasible when spot is on the tradeable side of the bound, skipped + /// once spot is already past it. + function test_PriceLimit_SkipFlagTracksSpot() public { + FCMVaultHarness h = new FCMVaultHarness(_baseParams()); + + // Default spot (Q96) is within bound for both legs. + (, bool okSellYield) = h.exposed_yieldDebtSwapLimit(address(FUSDEV)); + (, bool okBuyYield) = h.exposed_yieldDebtSwapLimit(address(PYUSD0)); + assertTrue(okSellYield, "delever feasible at 1:1 spot"); + assertTrue(okBuyYield, "lever feasible at 1:1 spot"); + + // Spot 2% above oracle: selling yield (price-raising) is past the bound. + yieldPool.setSqrtPriceX96(uint160(Math.sqrt(Math.mulDiv(102, 1 << 192, 100)))); + (, okSellYield) = h.exposed_yieldDebtSwapLimit(address(FUSDEV)); + assertFalse(okSellYield, "delever skipped when spot 2% high"); + + // Spot 2% below oracle: buying yield (price-lowering) is past the bound. + yieldPool.setSqrtPriceX96(uint160(Math.sqrt(Math.mulDiv(98, 1 << 192, 100)))); + (, okBuyYield) = h.exposed_yieldDebtSwapLimit(address(PYUSD0)); + assertFalse(okBuyYield, "lever skipped when spot 2% low"); } /// @notice `maxSlippageBps` defaults to 1%, is admin-only, and rejects @@ -1326,6 +1414,7 @@ contract FCMVaultTest is Test { marketLltv: LLTV, feeYieldDebt: FEE, feeAssetDebt: FEE_ASSET_DEBT, + yieldDebtPool: address(yieldPool), healthFactorMin: HEALTH_FACTOR_MIN, healthFactorMax: HEALTH_FACTOR_MAX, healthFactorMinTarget: HEALTH_FACTOR_MIN_TARGET, @@ -1486,3 +1575,17 @@ contract FCMVaultTest is Test { vault.cancelEmergencyRecovery(); } } + +/// @dev Exposes the vault's internal price-limit math so the security-critical +/// oracle -> `sqrtPriceLimitX96` conversion can be asserted directly. +contract FCMVaultHarness is FCMVault { + constructor(FCMVault.InitParams memory p) FCMVault(p) {} + + function exposed_oracleSqrtPriceLimitX96(bool zeroForOne) external view returns (uint256) { + return _oracleSqrtPriceLimitX96(zeroForOne); + } + + function exposed_yieldDebtSwapLimit(address tokenIn) external view returns (uint160, bool) { + return _yieldDebtSwapLimit(tokenIn); + } +} diff --git a/solidity/test/TvlLimit.t.sol b/solidity/test/TvlLimit.t.sol index 03b19b7..400f9af 100644 --- a/solidity/test/TvlLimit.t.sol +++ b/solidity/test/TvlLimit.t.sol @@ -12,6 +12,7 @@ import {SwapLib} from "../src/libraries/SwapLib.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {MockMorpho} from "./mocks/MockMorpho.sol"; import {MockSwapRouter} from "./mocks/MockSwapRouter.sol"; +import {MockUniswapV3Pool} from "./mocks/MockUniswapV3Pool.sol"; import {MockOracle} from "./mocks/MockOracle.sol"; import {MockIrm} from "./mocks/MockIrm.sol"; @@ -74,6 +75,7 @@ contract TvlLimitTest is Test { marketLltv: LLTV, feeYieldDebt: FEE, feeAssetDebt: FEE_ASSET_DEBT, + yieldDebtPool: address(new MockUniswapV3Pool()), healthFactorMin: HEALTH_FACTOR_MIN, healthFactorMax: HEALTH_FACTOR_MAX, healthFactorMinTarget: HEALTH_FACTOR_MIN_TARGET, diff --git a/solidity/test/mocks/MockCpmmSwapRouter.sol b/solidity/test/mocks/MockCpmmSwapRouter.sol index 300602f..cf19c56 100644 --- a/solidity/test/mocks/MockCpmmSwapRouter.sol +++ b/solidity/test/mocks/MockCpmmSwapRouter.sol @@ -1,35 +1,32 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.19; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + import {ISwapRouter} from "../../src/interfaces/ISwapRouter.sol"; import {MockERC20} from "./MockERC20.sol"; -/// @dev Constant-product (x*y=k) swap mock used to exercise price-impact- -/// dependent slippage. Unlike `MockSwapRouter` — a flat per-unit rate no -/// trade size can escape — here a smaller trade gets a better average -/// price, so a swap that breaches a slippage floor at full size can clear -/// it at a reduced size. That is exactly the property partial rebalancing -/// relies on, so this mock is what the partial-progress tests etch over -/// the swap router. +/// @dev Constant-product (x*y=k) swap mock that honors `sqrtPriceLimitX96` the +/// way a real Uniswap V3 pool does: it fills only up to the point where the +/// marginal price reaches the limit, then stops — a *partial fill* — and +/// consumes only the input it actually used, leaving the remainder with the +/// caller. This is what exercises the vault's price-limit-based partial +/// rebalancing; the flat `MockSwapRouter` cannot, since it has no price. /// -/// Mechanics: `out = reserveOut * amountIn / (reserveIn + amountIn)`, the -/// standard constant-product fill. The marginal (spot) rate is -/// `reserveOut / reserveIn`; the realized average rate -/// `reserveOut / (reserveIn + amountIn)` degrades as `amountIn` grows, so -/// the slippage versus spot is `amountIn / (reserveIn + amountIn)`. With -/// equal reserves the spot rate is 1:1, matching a 1e36 yield oracle. +/// Reserves are virtual and static (set via `setReserves`, not updated +/// after a swap): the mock models a fixed-depth curve, which is all the +/// tests need (one committed swap per rebalance) and keeps fills exactly +/// reproducible. Pair both sides to the same value for a 1:1 spot price, +/// matching a 1e36 yield oracle and a `MockUniswapV3Pool` left at `Q96`. /// -/// Reserves are virtual: like the flat mock it mints/burns tokens and does -/// NOT update reserves after a swap, so it models a fixed-depth curve -/// (price impact within a single swap, no inventory accounting). The -/// partial-rebalance tests only need one committed swap per rebalance, so -/// that is sufficient and keeps the expected output exactly computable. +/// Price convention matches the pool: `token0` is the lower-addressed +/// token, `P = reserve1 / reserve0`, `sqrtPriceX96 = sqrt(P) * 2**96`. contract MockCpmmSwapRouter { + uint256 internal constant Q96 = 1 << 96; + /// @dev Virtual reserve per token, keyed by token address. mapping(address => uint256) public reserveOf; - /// @notice Set the virtual reserve for a token. Set both sides of a pair to - /// the same value for a 1:1 spot rate. function setReserves(address token, uint256 reserve) external { reserveOf[token] = reserve; } @@ -39,14 +36,35 @@ contract MockCpmmSwapRouter { payable returns (uint256 amountOut) { - uint256 reserveIn = reserveOf[p.tokenIn]; - uint256 reserveOut = reserveOf[p.tokenOut]; - require(reserveIn > 0 && reserveOut > 0, "reserves unset"); + bool zeroForOne = p.tokenIn < p.tokenOut; + (address token0, address token1) = + zeroForOne ? (p.tokenIn, p.tokenOut) : (p.tokenOut, p.tokenIn); + uint256 r0 = reserveOf[token0]; + uint256 r1 = reserveOf[token1]; + require(r0 > 0 && r1 > 0, "reserves unset"); + uint256 k = r0 * r1; + uint256 rootK = Math.sqrt(k); + + // Cap the consumed input at the amount that moves the marginal price to + // sqrtPriceLimitX96 (0 = no limit). Selling token0 grows r0 and lowers + // the price; selling token1 grows r1 and raises it. + uint256 consumed = p.amountIn; + if (p.sqrtPriceLimitX96 != 0) { + uint256 reserveInLimit = zeroForOne + ? Math.mulDiv(rootK, Q96, p.sqrtPriceLimitX96) // r0 at the lower price bound + : Math.mulDiv(rootK, p.sqrtPriceLimitX96, Q96); // r1 at the upper price bound + uint256 reserveIn = zeroForOne ? r0 : r1; + uint256 maxConsumed = reserveInLimit > reserveIn ? reserveInLimit - reserveIn : 0; + if (consumed > maxConsumed) consumed = maxConsumed; + } + if (consumed == 0) return 0; - amountOut = reserveOut * p.amountIn / (reserveIn + p.amountIn); - require(amountOut >= p.amountOutMinimum, "Too little received"); + // out = reserveOut - k / (reserveIn + consumed) + amountOut = zeroForOne + ? r1 - Math.mulDiv(r0, r1, r0 + consumed) + : r0 - Math.mulDiv(r0, r1, r1 + consumed); - MockERC20(p.tokenIn).burn(msg.sender, p.amountIn); + MockERC20(p.tokenIn).burn(msg.sender, consumed); MockERC20(p.tokenOut).mint(p.recipient, amountOut); } } diff --git a/solidity/test/mocks/MockUniswapV3Pool.sol b/solidity/test/mocks/MockUniswapV3Pool.sol new file mode 100644 index 0000000..e50a474 --- /dev/null +++ b/solidity/test/mocks/MockUniswapV3Pool.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +/// @dev Minimal Uniswap-V3-style pool mock exposing only `slot0`, which the +/// vault reads for the pool's live marginal price (`sqrtPriceX96`) when +/// deriving a rebalance swap's `sqrtPriceLimitX96`. The price is settable +/// so tests can place the pool spot on either side of the oracle-derived +/// bound. `Q96` (2**96) is the 1:1 price for equal-decimal tokens. +contract MockUniswapV3Pool { + uint160 public constant Q96 = 79228162514264337593543950336; // 2**96 + + uint160 internal sqrtPriceX96 = Q96; // default 1:1 + + function setSqrtPriceX96(uint160 newSqrtPriceX96) external { + sqrtPriceX96 = newSqrtPriceX96; + } + + function slot0() external view returns (uint160, int24, uint16, uint16, uint16, uint8, bool) { + return (sqrtPriceX96, int24(0), uint16(0), uint16(0), uint16(0), uint8(0), true); + } +} From 46bf6cd2303f8c305a5a8268ed8efca1e05d683e Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 26 Jun 2026 16:32:53 -0700 Subject: [PATCH 06/15] docs: rebalance partial fills use native sqrtPriceLimitX96 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 --- docs/architecture.md | 3 ++- docs/vault-rebalancer.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2c8d9c5..e59cb6d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -267,7 +267,8 @@ For each external function, how does it protect against re-entrancy? ### Sandwich Attack An attacker manipulates AMM prices before and after our swap to capture part of the value of our swap. - The primary mitigation is a slippage limit, which limits how much slippage we will accept on each trade. This doesn't prevent the attack, but does limit how much value can be extracted per trade. -- `rebalance` enforces this limit per-trade but does **not** revert the whole rebalance when the full target swap would breach it. Instead it swaps the largest feasible fraction under the floor (halving the candidate size until it clears) and lands partway to the re-entry target; successive rebalances converge as price impact shrinks. The per-trade slippage bound — and therefore the per-trade extraction cap — is unchanged; partial filling only changes whether a too-large swap reverts or makes bounded progress. When the slippage is a uniform per-unit cost no smaller trade can escape, the rebalance is a swap-free no-op. +- `rebalance` enforces this limit using the pool's native `sqrtPriceLimitX96`: each rebalance swap carries a marginal-price bound derived from the yield oracle discounted by `maxSlippageBps`. The pool fills the swap only while its marginal price stays within the bound, then stops — so a swap too large to reach the re-entry target within tolerance is a **partial fill** rather than a revert, landing the position partway to target. Successive rebalances fill more as the gap (and price impact) shrinks, converging over several calls. If the pool's marginal price is already past the bound (an adverse oracle/pool divergence, or a fresh sandwich), the swap is skipped entirely — a swap-free no-op — so the vault never trades into a manipulated price. This is the canonical Uniswap V3 mechanism (the pool's swap loop runs `while amountRemaining != 0 && price != limit`); it caps per-trade price impact, and therefore per-trade extraction, exactly as the old revert-on-breach floor did. +- The bound is on the pool's **marginal price** relative to the oracle, i.e. on price impact. The pool's fixed LP fee is a separate, known cost taken on the input and is not part of this bound. - Flow as the underlying platform provides some protection. There is no system akin to [MEV-Boost](https://github.com/flashbots/mev-boost), which systematizes MEV extraction. No individual node in Flow can deterministically dictate transaction ordering. Attackers need to send many transactions, hope some are placed in the desired order, and be able to revert operations on those that are not in the desired order. Still possible, but more complex and expensive. If an attacker is able to invoke a function which performs a swap (that isn't swapping their funds), then the sandwich attack becomes much more dangerous (eg. a permissionless `rebalance` function). diff --git a/docs/vault-rebalancer.md b/docs/vault-rebalancer.md index 1616ff5..5708547 100644 --- a/docs/vault-rebalancer.md +++ b/docs/vault-rebalancer.md @@ -36,7 +36,7 @@ One Cadence resource per EVM target, owned by an admin account. Stored at a dete | Failure | Observable | Recovery | | :--- | :--- | :--- | | EVM call fails, transient (momentary state, out-of-gas) | Tick event with EVM error code | Next tick retries automatically; persistent OOG requires admin to raise the EVM gas limit | -| Slippage: full target swap would breach the floor | Tick succeeds; `Rebalanced`/`VaultState` show a smaller-than-target move (or no move) | None needed — `rebalance` partial-fills the largest feasible fraction and converges over subsequent ticks; a no-move tick means a uniform price gap no trade size escapes, which clears when the gap does | +| Slippage: full target swap would breach the price bound | Tick succeeds; `Rebalanced`/`VaultState` show a smaller-than-target move (or no move) | None needed — the swap carries a `sqrtPriceLimitX96` from the oracle, so the pool partial-fills up to the bound and converges over subsequent ticks; a no-move tick means the pool's marginal price is already past the bound (oracle/pool divergence), which clears when the divergence does | | EVM call fails, sustained (role revoked EVM-side, persistent Solidity-side condition) | Tick event repeats with non-zero error code N ticks in a row | Admin addresses EVM-side condition (restore role, resolve Solidity state) | | Fee vault depletion / storage-min breach (Cadence scheduling fees) | No event; absence of `Ticked` triggers the liveness alert | Admin tops up; signs tx to re-invoke self-reschedule | | COA FLOW depletion (EVM-side gas) | Tick events repeat with non-zero EVM error code; off-chain balance script catches drift earlier | Anyone can send FLOW to the COA (permissionless, from either Cadence or EVM) | From 7173d843f59c391b881ec4bc8626fa40a94ed1a8 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 11:24:00 -0700 Subject: [PATCH 07/15] docs --- docs/architecture.md | 2 +- docs/vault-rebalancer.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index e59cb6d..a46e3be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -267,7 +267,7 @@ For each external function, how does it protect against re-entrancy? ### Sandwich Attack An attacker manipulates AMM prices before and after our swap to capture part of the value of our swap. - The primary mitigation is a slippage limit, which limits how much slippage we will accept on each trade. This doesn't prevent the attack, but does limit how much value can be extracted per trade. -- `rebalance` enforces this limit using the pool's native `sqrtPriceLimitX96`: each rebalance swap carries a marginal-price bound derived from the yield oracle discounted by `maxSlippageBps`. The pool fills the swap only while its marginal price stays within the bound, then stops — so a swap too large to reach the re-entry target within tolerance is a **partial fill** rather than a revert, landing the position partway to target. Successive rebalances fill more as the gap (and price impact) shrinks, converging over several calls. If the pool's marginal price is already past the bound (an adverse oracle/pool divergence, or a fresh sandwich), the swap is skipped entirely — a swap-free no-op — so the vault never trades into a manipulated price. This is the canonical Uniswap V3 mechanism (the pool's swap loop runs `while amountRemaining != 0 && price != limit`); it caps per-trade price impact, and therefore per-trade extraction, exactly as the old revert-on-breach floor did. +- `rebalance` enforces this limit using the pool's native `sqrtPriceLimitX96`: each rebalance swap carries a marginal-price bound derived from the yield oracle discounted by `maxSlippageBps`. The pool fills the swap only while its marginal price stays within the bound, then stops. A swap too large to reach the re-entry target within tolerance is a **partial fill** rather than a revert. Successive rebalances are expected to fill more as the gap (and price impact) shrinks, converging over several calls. - The bound is on the pool's **marginal price** relative to the oracle, i.e. on price impact. The pool's fixed LP fee is a separate, known cost taken on the input and is not part of this bound. - Flow as the underlying platform provides some protection. There is no system akin to [MEV-Boost](https://github.com/flashbots/mev-boost), which systematizes MEV extraction. No individual node in Flow can deterministically dictate transaction ordering. Attackers need to send many transactions, hope some are placed in the desired order, and be able to revert operations on those that are not in the desired order. Still possible, but more complex and expensive. diff --git a/docs/vault-rebalancer.md b/docs/vault-rebalancer.md index 5708547..36f4b86 100644 --- a/docs/vault-rebalancer.md +++ b/docs/vault-rebalancer.md @@ -36,7 +36,7 @@ One Cadence resource per EVM target, owned by an admin account. Stored at a dete | Failure | Observable | Recovery | | :--- | :--- | :--- | | EVM call fails, transient (momentary state, out-of-gas) | Tick event with EVM error code | Next tick retries automatically; persistent OOG requires admin to raise the EVM gas limit | -| Slippage: full target swap would breach the price bound | Tick succeeds; `Rebalanced`/`VaultState` show a smaller-than-target move (or no move) | None needed — the swap carries a `sqrtPriceLimitX96` from the oracle, so the pool partial-fills up to the bound and converges over subsequent ticks; a no-move tick means the pool's marginal price is already past the bound (oracle/pool divergence), which clears when the divergence does | +| Slippage: full target swap would breach the price bound | Tick succeeds; `Rebalanced`/`VaultState` show a smaller-than-target move (or no move) | None needed, subsequent ticks will invoke rebalance again until the target swap occurs | | EVM call fails, sustained (role revoked EVM-side, persistent Solidity-side condition) | Tick event repeats with non-zero error code N ticks in a row | Admin addresses EVM-side condition (restore role, resolve Solidity state) | | Fee vault depletion / storage-min breach (Cadence scheduling fees) | No event; absence of `Ticked` triggers the liveness alert | Admin tops up; signs tx to re-invoke self-reschedule | | COA FLOW depletion (EVM-side gas) | Tick events repeat with non-zero EVM error code; off-chain balance script catches drift earlier | Anyone can send FLOW to the COA (permissionless, from either Cadence or EVM) | From 1feb6c768c08f12e5b0a40d5c8f9afa28a87ede7 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 11:44:20 -0700 Subject: [PATCH 08/15] refactor swap limit --- solidity/src/FCMVault.sol | 105 ++++++++++++++++------------------- solidity/test/FCMVault.t.sol | 36 ++++++------ 2 files changed, 64 insertions(+), 77 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index 7905c94..f195084 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -47,9 +47,8 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// @dev Basis-points denominator for `maxSlippageBps`. uint256 internal constant BPS = 10_000; - /// @dev Q64.96 fixed-point one (`2**96`) and its square (`2**192`), used to - /// build the `sqrtPriceX96` price limit for rebalance swaps. - uint256 internal constant ONE_X96 = 1 << 96; + /// @dev Q64.96 fixed-point one squared (`2**192`), used to build the + /// `sqrtPriceX96` price limit for rebalance swaps. uint256 internal constant ONE_X192 = 1 << 192; /// @dev Uniswap V3 tick-math bounds on a valid `sqrtPriceLimitX96`. A limit @@ -248,7 +247,6 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { require(p.healthFactorMin <= p.healthFactorMinTarget, "HF min > minTarget"); require(p.healthFactorMinTarget <= p.healthFactorMaxTarget, "HF minTarget > maxTarget"); require(p.healthFactorMaxTarget <= p.healthFactorMax, "HF maxTarget > max"); - require(p.yieldDebtPool != address(0), "yieldDebtPool zero"); loanToken = p.loanToken; @@ -291,76 +289,67 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { maxSlippageBps = newBps; } - /// @dev The `sqrtPriceX96` marginal-price limit for a rebalance swap on the - /// yield/debt pool: the oracle price discounted by `maxSlippageBps`, + /// @dev Resolve the `sqrtPriceLimitX96` for a rebalance swap selling + /// `tokenIn` on the yield/debt pool, and decide whether a swap is + /// feasible at all. + /// + /// The limit is the oracle price discounted by `maxSlippageBps`, /// expressed in the pool's `sqrt(token1/token0) * 2**96` coordinate. - /// The pool fills a swap only while its marginal price is on the - /// good side of this limit, so the realized average price is bounded - /// by `maxSlippageBps` of *price impact* relative to the oracle. + /// The pool fills a swap only while its marginal price is on the good + /// side of this limit, so the realized average price is bounded by + /// `maxSlippageBps` of *price impact* relative to the oracle. /// /// Token decimals are already baked into the yield oracle price (the /// Morpho/IOracle convention: `yield * price / 1e36 = loan` in raw /// units), so the raw `token1/token0` ratio is read straight off it - /// with no decimal adjustment here. The slippage discount is applied - /// in sqrt space: a price-decreasing (`zeroForOne`) swap multiplies the - /// oracle sqrt price by `sqrt(1 - slip)`, a price-increasing swap - /// divides by it. - /// @param zeroForOne True when selling token0 for token1 (price falls). - /// @return The Q64.96 limit, unclamped — callers range/side-check it. - function _oracleSqrtPriceLimitX96(bool zeroForOne) internal view returns (uint256) { - // P_oracle = token1 / token0 in raw units. yieldOracle.price() (`py`) - // is the raw loan-per-yield ratio scaled by 1e36. - uint256 py = IOracle(yieldOracle).price(); - uint256 num; - uint256 den; - if (address(yieldToken) < address(loanToken)) { - // token0 = yield, token1 = loan -> P = loan/yield = py / 1e36 - num = py; - den = MarketLib.ORACLE_PRICE_SCALE; - } else { - // token0 = loan, token1 = yield -> P = yield/loan = 1e36 / py - num = MarketLib.ORACLE_PRICE_SCALE; - den = py; - } - - // sqrt(P_oracle) * 2**96 = sqrt(P_oracle * 2**192). - uint256 sqrtOracleX96 = Math.sqrt(Math.mulDiv(num, ONE_X192, den)); - // sqrt(1 - slip) * 2**96, slip = maxSlippageBps / BPS. - uint256 sqrtSlipX96 = Math.sqrt(Math.mulDiv(BPS - maxSlippageBps, ONE_X192, BPS)); - - return zeroForOne - ? Math.mulDiv(sqrtOracleX96, sqrtSlipX96, ONE_X96) // * sqrt(1 - slip) - : Math.mulDiv(sqrtOracleX96, ONE_X96, sqrtSlipX96); // / sqrt(1 - slip) - } - - /// @dev Resolve the `sqrtPriceLimitX96` for a rebalance swap of `tokenIn` - /// (yield or loan) on the yield/debt pool, and decide whether the swap - /// is feasible at all. `ok` is false when the limit is out of tick-math - /// range, or when the pool's live marginal price is already on the bad - /// side of the limit (so any swap would either be a no-op or revert - /// `SPL`) — in that case the caller skips the swap and the rebalance - /// makes no progress this call. + /// with no decimal adjustment here. + /// + /// `ok` is false when the limit is out of tick-math range, or when the + /// pool's live marginal price is already on the bad side of the limit + /// (so any swap would either be a no-op or revert `SPL`) — in that case + /// the caller should skip the swap. /// @param tokenIn The token the swap sells. /// @return limit The Q64.96 price limit to pass to the pool. /// @return ok Whether a swap should be attempted. function _yieldDebtSwapLimit(address tokenIn) internal view returns (uint160 limit, bool ok) { - // token0 is the lower-addressed token; selling it is `zeroForOne`. - address token0 = - address(yieldToken) < address(loanToken) ? address(yieldToken) : address(loanToken); + // Uniswap orders the pair by address: token0 is the lower address and + // the pool price is token1/token0. Selling token0 (`zeroForOne`) pushes + // the price down; selling token1 pushes it up. + bool yieldIsToken0 = address(yieldToken) < address(loanToken); + address token0 = yieldIsToken0 ? address(yieldToken) : address(loanToken); bool zeroForOne = (tokenIn == token0); - uint256 raw = _oracleSqrtPriceLimitX96(zeroForOne); + // Oracle price as an exact token1/token0 fraction. yieldOracle.price() + // is loan-per-yield scaled by 1e36. yield=token0 -> P = loan/yield = + // price/1e36; loan=token0 -> P = yield/loan = 1e36/price. + uint256 yieldPrice = IOracle(yieldOracle).price(); + (uint256 numerator, uint256 denominator) = yieldIsToken0 + ? (yieldPrice, MarketLib.ORACLE_PRICE_SCALE) + : (MarketLib.ORACLE_PRICE_SCALE, yieldPrice); + + // Discount the price toward the side the swap moves it: a + // price-decreasing swap allows down to price*(1-slip); a price-increasing + // swap allows up to price/(1-slip). Scaling the fraction is exact and + // happens before the single sqrt below, so no separate sqrt(1-slip) + // term is needed (sqrt(P)*sqrt(1-slip) = sqrt(P*(1-slip))). + if (zeroForOne) { + numerator *= (BPS - maxSlippageBps); + denominator *= BPS; + } else { + numerator *= BPS; + denominator *= (BPS - maxSlippageBps); + } + + // sqrtPriceX96 = sqrt(P) * 2**96 = sqrt(P * 2**192). + uint256 raw = Math.sqrt(Math.mulDiv(numerator, ONE_X192, denominator)); if (raw <= MIN_SQRT_RATIO || raw >= MAX_SQRT_RATIO) return (0, false); - (uint160 spot,,,,,,) = IUniswapV3Pool(yieldDebtPool).slot0(); // The limit must sit on the side the price moves toward: below spot for // a price-decreasing swap, above spot for a price-increasing one. If the // pool is already past it, there is no room to trade within tolerance. - if (zeroForOne) { - if (raw >= spot) return (0, false); - } else { - if (raw <= spot) return (0, false); - } + (uint160 spot,,,,,,) = IUniswapV3Pool(yieldDebtPool).slot0(); + if (zeroForOne ? raw >= spot : raw <= spot) return (0, false); + return (uint160(raw), true); } diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 8cc7046..f01c6e8 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -988,33 +988,35 @@ contract FCMVaultTest is Test { uint256 q96 = 1 << 96; // Default oracle is 1:1 (P_oracle = 1, oracle sqrt price = Q96) and the - // default bound is 1%. A price-decreasing (zeroForOne) swap multiplies + // default bound is 1%. A price-decreasing (zeroForOne) swap discounts // the oracle sqrt price by sqrt(1 - slip); a price-increasing one - // divides by it. + // divides by it. Selling the loan token is zeroForOne; selling the + // yield token is oneForZero. At the default 1:1 spot both directions are + // feasible, so the returned limit is the raw oracle conversion. uint256 sqrtFloor = Math.sqrt(Math.mulDiv(10_000 - 100, 1 << 192, 10_000)); // Q96*sqrt(0.99) - assertEq( - h.exposed_oracleSqrtPriceLimitX96(true), sqrtFloor, "zeroForOne = Q96*sqrt(1-slip)" - ); - assertEq( - h.exposed_oracleSqrtPriceLimitX96(false), - Math.mulDiv(q96, q96, sqrtFloor), - "oneForZero = Q96/sqrt(1-slip)" - ); + (uint160 loStart,) = h.exposed_yieldDebtSwapLimit(address(PYUSD0)); // zeroForOne + (uint160 hiStart,) = h.exposed_yieldDebtSwapLimit(address(FUSDEV)); // oneForZero + assertEq(uint256(loStart), sqrtFloor, "zeroForOne = Q96*sqrt(1-slip)"); + assertEq(uint256(hiStart), Math.mulDiv(q96, q96, sqrtFloor), "oneForZero = Q96/sqrt(1-slip)"); // The two directions differ by exactly the slippage factor 1/(1-slip). assertApproxEqRel( - Math.mulDiv(h.exposed_oracleSqrtPriceLimitX96(false), 1e18, sqrtFloor), + Math.mulDiv(uint256(hiStart), 1e18, sqrtFloor), uint256(1e18) * 10_000 / (10_000 - 100), 1e12, "directions differ by 1/(1-slip)" ); // Magnitude tracks the oracle: with yield worth 4 loan, P_oracle = 1/4 - // (yield is token1 here), so limit(true)*limit(false) ~= P_oracle*2**192. + // (yield is token1 here). Move spot to match so both legs stay feasible, + // then limit(zeroForOne)*limit(oneForZero) ~= P_oracle*2**192. yieldOracle.setPrice(4e36); - uint256 lo = h.exposed_oracleSqrtPriceLimitX96(true); - uint256 hi = h.exposed_oracleSqrtPriceLimitX96(false); - assertApproxEqRel(lo * hi, (uint256(1) << 192) / 4, 1e12, "product ~= P_oracle*2**192"); + yieldPool.setSqrtPriceX96(uint160(Math.sqrt((uint256(1) << 192) / 4))); + (uint160 lo,) = h.exposed_yieldDebtSwapLimit(address(PYUSD0)); + (uint160 hi,) = h.exposed_yieldDebtSwapLimit(address(FUSDEV)); + assertApproxEqRel( + uint256(lo) * uint256(hi), (uint256(1) << 192) / 4, 1e12, "product ~= P_oracle*2**192" + ); } /// @notice The swap-limit feasibility flag flips with the pool's live spot: @@ -1581,10 +1583,6 @@ contract FCMVaultTest is Test { contract FCMVaultHarness is FCMVault { constructor(FCMVault.InitParams memory p) FCMVault(p) {} - function exposed_oracleSqrtPriceLimitX96(bool zeroForOne) external view returns (uint256) { - return _oracleSqrtPriceLimitX96(zeroForOne); - } - function exposed_yieldDebtSwapLimit(address tokenIn) external view returns (uint160, bool) { return _yieldDebtSwapLimit(tokenIn); } From 349e1c7fc41bb343733050538ca9905f934737a1 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 11:52:21 -0700 Subject: [PATCH 09/15] simplify ternary, docs --- solidity/src/FCMVault.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index f195084..ad18cb3 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -329,9 +329,7 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // Discount the price toward the side the swap moves it: a // price-decreasing swap allows down to price*(1-slip); a price-increasing - // swap allows up to price/(1-slip). Scaling the fraction is exact and - // happens before the single sqrt below, so no separate sqrt(1-slip) - // term is needed (sqrt(P)*sqrt(1-slip) = sqrt(P*(1-slip))). + // swap allows up to price/(1-slip). if (zeroForOne) { numerator *= (BPS - maxSlippageBps); denominator *= BPS; @@ -348,7 +346,8 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // a price-decreasing swap, above spot for a price-increasing one. If the // pool is already past it, there is no room to trade within tolerance. (uint160 spot,,,,,,) = IUniswapV3Pool(yieldDebtPool).slot0(); - if (zeroForOne ? raw >= spot : raw <= spot) return (0, false); + if (zeroForOne && raw >= spot) return (0, false) + if (!zeroForOne && raw <= spot) return (0, false) return (uint160(raw), true); } From ec5b9b023e86fb084d5b73e0d7935c7a8e295680 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 12:04:38 -0700 Subject: [PATCH 10/15] docs --- solidity/src/FCMVault.sol | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index ad18cb3..17e7d7b 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -650,15 +650,7 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { /// `sqrtPriceLimitX96` derived from the oracle price and /// `maxSlippageBps` (see `_yieldDebtSwapLimit`). If reaching the /// re-entry target would push the pool past that price, the pool - /// fills only up to it — a partial fill — and the position lands - /// partway to the target instead of reverting. Because price impact - /// grows with size, successive rebalances each fill more as the gap - /// shrinks, so the position converges over several calls. When the - /// pool's marginal price is already past the bound (e.g. an adverse - /// oracle/pool divergence or a fresh sandwich), the swap is skipped - /// and the rebalance is a swap-free no-op; the off-chain rebalancer - /// surfaces the unchanged health factor via the emitted - /// `Rebalanced`/`VaultState` events. + /// fills as much as possible without reverting. /// /// Note the bound is on the pool's *marginal price* relative to the /// oracle, i.e. on price impact. The pool's fixed LP fee is a @@ -727,9 +719,7 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // Borrow first, then swap loan->yield bounded by the price limit. The // pool partial-fills up to the limit; whatever loan it does not consume // stays with the vault and is repaid below, so we only lever by the - // amount actually converted to yield. The price limit is the - // price-impact / sandwich guard; deposit's identical leg is unfloored - // (user-facing slippage is the router's job). + // amount actually converted to yield. uint256 loanBefore = loanToken.balanceOf(address(this)); market.borrow(borrowAmount); SwapLib.swapExactInToLimit( From 2a7356bd4222e1787de0976235b41d1ec7dc0945 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 12:16:31 -0700 Subject: [PATCH 11/15] docs --- solidity/src/FCMVault.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index 17e7d7b..29f0201 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -781,8 +781,7 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // Sell yield->loan bounded by the price limit. The pool partial-fills // up to it, so a too-large delever still repays as much as the bound - // allows. The price limit is the price-impact / sandwich guard; redeem's - // identical leg is unfloored (router's job). + // allows. uint256 loanGot = SwapLib.swapExactInToLimit( address(yieldToken), address(loanToken), feeYieldDebt, yieldToSell, limit ); From eeec3402533c904048238d9d3cc27122e0dbeb04 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 12:17:40 -0700 Subject: [PATCH 12/15] note mock support limits --- solidity/test/mocks/MockCpmmSwapRouter.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/solidity/test/mocks/MockCpmmSwapRouter.sol b/solidity/test/mocks/MockCpmmSwapRouter.sol index cf19c56..4cb97f2 100644 --- a/solidity/test/mocks/MockCpmmSwapRouter.sol +++ b/solidity/test/mocks/MockCpmmSwapRouter.sol @@ -36,6 +36,8 @@ contract MockCpmmSwapRouter { payable returns (uint256 amountOut) { + require(p.amountOutMinimum == 0, "amountOutMinimum not implemented in this mock") + bool zeroForOne = p.tokenIn < p.tokenOut; (address token0, address token1) = zeroForOne ? (p.tokenIn, p.tokenOut) : (p.tokenOut, p.tokenIn); From 060aac89a64a7af2da06ed6025034fb0d565e093 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 12:28:03 -0700 Subject: [PATCH 13/15] Remove partial-delever convergence test; fix missing semicolons 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 --- solidity/src/FCMVault.sol | 4 +-- solidity/test/FCMVault.t.sol | 32 ---------------------- solidity/test/mocks/MockCpmmSwapRouter.sol | 2 +- 3 files changed, 3 insertions(+), 35 deletions(-) diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index 29f0201..a769b1e 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -346,8 +346,8 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // a price-decreasing swap, above spot for a price-increasing one. If the // pool is already past it, there is no room to trade within tolerance. (uint160 spot,,,,,,) = IUniswapV3Pool(yieldDebtPool).slot0(); - if (zeroForOne && raw >= spot) return (0, false) - if (!zeroForOne && raw <= spot) return (0, false) + if (zeroForOne && raw >= spot) return (0, false); + if (!zeroForOne && raw <= spot) return (0, false); return (uint160(raw), true); } diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index f01c6e8..8c185b9 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -921,38 +921,6 @@ contract FCMVaultTest is Test { assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle (remainder repaid)"); } - /// @notice Convergence: repeated partial delevers drive the position back - /// inside the band. As each step repays debt, the remaining gap - /// shrinks until the full intended sell fits within the price bound, - /// landing the position at (just below) the lower re-entry target. A - /// single rebalance is not enough; several together are. - function test_Rebalance_PartialDeleverConvergesOverTicks() public { - _depositFor(user, 1 ether); - - marketOracle.setPrice(1700e36); - assertLt(_healthFactor(), HEALTH_FACTOR_MIN, "below min"); - - _installCpmmRouter(1000e18); - - // One rebalance alone does not restore the band. - vault.rebalance(); - assertLt(_healthFactor(), HEALTH_FACTOR_MIN, "single partial still below min"); - - // Successive rebalances converge: each clears a larger fraction as the - // gap shrinks. Bounded loop; convergence is reached well within it. - for (uint256 i = 0; i < 20; i++) { - vault.rebalance(); - if (_healthFactor() >= HEALTH_FACTOR_MIN) break; - } - - uint256 hf = _healthFactor(); - assertGe(hf, HEALTH_FACTOR_MIN, "converged back into band"); - // Landed at the lower re-entry target (slightly under, from CPMM - // undershoot vs the oracle-implied size), not overshooting it. - assertLe(hf, HEALTH_FACTOR_MIN_TARGET, "did not overshoot target"); - assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle after convergence"); - } - /// @notice Loosening `maxSlippageBps` widens the price bound, admitting a /// pool spot that was previously past it. With the pool 2% above /// oracle, a 1% bound skips the delever (no-op); raising the bound to diff --git a/solidity/test/mocks/MockCpmmSwapRouter.sol b/solidity/test/mocks/MockCpmmSwapRouter.sol index 4cb97f2..e5acf86 100644 --- a/solidity/test/mocks/MockCpmmSwapRouter.sol +++ b/solidity/test/mocks/MockCpmmSwapRouter.sol @@ -36,7 +36,7 @@ contract MockCpmmSwapRouter { payable returns (uint256 amountOut) { - require(p.amountOutMinimum == 0, "amountOutMinimum not implemented in this mock") + require(p.amountOutMinimum == 0, "amountOutMinimum not implemented in this mock"); bool zeroForOne = p.tokenIn < p.tokenOut; (address token0, address token1) = From 10bac19ec2b99e067430360211f219112fa22688 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 12:31:58 -0700 Subject: [PATCH 14/15] test: extract sqrtPriceX96 ratio helpers for readability 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 --- solidity/test/FCMVault.t.sol | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 8c185b9..461e549 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -845,11 +845,24 @@ contract FCMVaultTest is Test { // ---- partial rebalancing (price-impact pool) ------------------------- + /// @dev The price ratio `num/den` in Q64.192 fixed point (`num/den * 2**192`), + /// i.e. the square of a `sqrtPriceX96`. `_priceX192(1, 4)` is a 1:4 price. + function _priceX192(uint256 num, uint256 den) internal pure returns (uint256) { + return Math.mulDiv(num, 1 << 192, den); + } + + /// @dev The `sqrtPriceX96` encoding of the price ratio `num/den` + /// (`sqrt(num/den) * 2**96`). `_sqrtPriceX96(99, 100)` is the sqrt price + /// at 99% of a 1:1 price; `_sqrtPriceX96(1, 1)` is `Q96`. + function _sqrtPriceX96(uint256 num, uint256 den) internal pure returns (uint256) { + return Math.sqrt(_priceX192(num, den)); + } + /// @dev Set the yield/debt pool's spot to `num/den` (yield/loan price) in /// `sqrtPriceX96` form, so tests can place spot on either side of the /// oracle-derived bound. Default (1/1) is `Q96`. function _setPoolPrice(uint256 num, uint256 den) internal { - yieldPool.setSqrtPriceX96(uint160(Math.sqrt(Math.mulDiv(num, 1 << 192, den)))); + yieldPool.setSqrtPriceX96(uint160(_sqrtPriceX96(num, den))); } /// @dev Etch a constant-product swap router over the swap-router address and @@ -961,7 +974,7 @@ contract FCMVaultTest is Test { // divides by it. Selling the loan token is zeroForOne; selling the // yield token is oneForZero. At the default 1:1 spot both directions are // feasible, so the returned limit is the raw oracle conversion. - uint256 sqrtFloor = Math.sqrt(Math.mulDiv(10_000 - 100, 1 << 192, 10_000)); // Q96*sqrt(0.99) + uint256 sqrtFloor = _sqrtPriceX96(99, 100); // Q96*sqrt(0.99), the 1% floor (uint160 loStart,) = h.exposed_yieldDebtSwapLimit(address(PYUSD0)); // zeroForOne (uint160 hiStart,) = h.exposed_yieldDebtSwapLimit(address(FUSDEV)); // oneForZero assertEq(uint256(loStart), sqrtFloor, "zeroForOne = Q96*sqrt(1-slip)"); @@ -979,11 +992,11 @@ contract FCMVaultTest is Test { // (yield is token1 here). Move spot to match so both legs stay feasible, // then limit(zeroForOne)*limit(oneForZero) ~= P_oracle*2**192. yieldOracle.setPrice(4e36); - yieldPool.setSqrtPriceX96(uint160(Math.sqrt((uint256(1) << 192) / 4))); + _setPoolPrice(1, 4); (uint160 lo,) = h.exposed_yieldDebtSwapLimit(address(PYUSD0)); (uint160 hi,) = h.exposed_yieldDebtSwapLimit(address(FUSDEV)); assertApproxEqRel( - uint256(lo) * uint256(hi), (uint256(1) << 192) / 4, 1e12, "product ~= P_oracle*2**192" + uint256(lo) * uint256(hi), _priceX192(1, 4), 1e12, "product ~= P_oracle*2**192" ); } @@ -1000,12 +1013,12 @@ contract FCMVaultTest is Test { assertTrue(okBuyYield, "lever feasible at 1:1 spot"); // Spot 2% above oracle: selling yield (price-raising) is past the bound. - yieldPool.setSqrtPriceX96(uint160(Math.sqrt(Math.mulDiv(102, 1 << 192, 100)))); + _setPoolPrice(102, 100); (, okSellYield) = h.exposed_yieldDebtSwapLimit(address(FUSDEV)); assertFalse(okSellYield, "delever skipped when spot 2% high"); // Spot 2% below oracle: buying yield (price-lowering) is past the bound. - yieldPool.setSqrtPriceX96(uint160(Math.sqrt(Math.mulDiv(98, 1 << 192, 100)))); + _setPoolPrice(98, 100); (, okBuyYield) = h.exposed_yieldDebtSwapLimit(address(PYUSD0)); assertFalse(okBuyYield, "lever skipped when spot 2% low"); } From c364a8b983add1a801c9869ba17c13999eee1bf3 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 1 Jul 2026 12:32:58 -0700 Subject: [PATCH 15/15] lint --- solidity/test/FCMVault.t.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 461e549..f5f4a07 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -978,7 +978,9 @@ contract FCMVaultTest is Test { (uint160 loStart,) = h.exposed_yieldDebtSwapLimit(address(PYUSD0)); // zeroForOne (uint160 hiStart,) = h.exposed_yieldDebtSwapLimit(address(FUSDEV)); // oneForZero assertEq(uint256(loStart), sqrtFloor, "zeroForOne = Q96*sqrt(1-slip)"); - assertEq(uint256(hiStart), Math.mulDiv(q96, q96, sqrtFloor), "oneForZero = Q96/sqrt(1-slip)"); + assertEq( + uint256(hiStart), Math.mulDiv(q96, q96, sqrtFloor), "oneForZero = Q96/sqrt(1-slip)" + ); // The two directions differ by exactly the slippage factor 1/(1-slip). assertApproxEqRel(