diff --git a/solidity/src/FCMVault.sol b/solidity/src/FCMVault.sol index f96e94c..7cdedd7 100644 --- a/solidity/src/FCMVault.sol +++ b/solidity/src/FCMVault.sol @@ -43,6 +43,9 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { // @dev See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC4626.sol#L32-L39 uint8 internal constant DECIMALS_OFFSET = 6; + /// @dev Basis-points denominator for `maxSlippageBps`. + uint256 internal constant BPS = 10_000; + // @dev Address of the loan token (inner vault asset) IERC20 public immutable loanToken; // @dev Address of the yield token (inner vault share) @@ -86,6 +89,20 @@ 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. + uint256 public maxSlippageBps; + + /// @notice Emitted when the admin updates `maxSlippageBps`. + event MaxSlippageBpsSet(uint256 oldBps, uint256 newBps); + + /// @dev Thrown when a slippage tolerance >= 100% (10_000 bps) is set. + error InvalidSlippage(); + struct InitParams { IERC20 collateral; IERC20 loanToken; @@ -155,9 +172,26 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { p.loanToken.forceApprove(address(SwapLib.SWAP_ROUTER), maxAllowance); p.yieldToken.forceApprove(address(SwapLib.SWAP_ROUTER), maxAllowance); + maxSlippageBps = 100; // 1% default; admin retunes per pool depth. + _grantRole(DEFAULT_ADMIN_ROLE, p.admin); } + /// @notice Set the max slippage tolerance applied to the rebalance swaps. + /// @param newBps Tolerance in basis points; must be < 100% (10_000) so the + /// floor can never be fully disabled. + function setMaxSlippageBps(uint256 newBps) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newBps >= BPS) revert InvalidSlippage(); + emit MaxSlippageBpsSet(maxSlippageBps, newBps); + 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 Defines the decimal offset between vault assets and shares. Larger offsets make inflation attacks more expensive. // @dev See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC4626.sol#L32-L39 function _decimalsOffset() internal pure override returns (uint8) { @@ -476,7 +510,19 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { additionalDebt = targetDebt - currentDebt; market.borrow(additionalDebt); - SwapLib.swapExactIn(address(loanToken), address(yieldToken), feeYieldDebt, additionalDebt); + // 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. + uint256 expectedYield = + additionalDebt.mulDiv(MarketLib.ORACLE_PRICE_SCALE, IOracle(yieldOracle).price()); + SwapLib.swapExactInMin( + address(loanToken), + address(yieldToken), + feeYieldDebt, + additionalDebt, + _slippageFloor(expectedYield) + ); } /// @dev Delever branch of `rebalance`: position is over-levered @@ -516,7 +562,18 @@ contract FCMVault is ERC4626, AccessControl, Ownable2Step { if (yieldToSell == 0) return 0; uint256 loanBefore = loanToken.balanceOf(address(this)); - SwapLib.swapExactIn(address(yieldToken), address(loanToken), feeYieldDebt, yieldToSell); + // 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. + uint256 expectedLoan = yieldToSell.mulDiv(yieldPrice, MarketLib.ORACLE_PRICE_SCALE); + SwapLib.swapExactInMin( + address(yieldToken), + address(loanToken), + feeYieldDebt, + yieldToSell, + _slippageFloor(expectedLoan) + ); uint256 loanGot = loanToken.balanceOf(address(this)) - loanBefore; // Cap repayment at outstanding debt diff --git a/solidity/test/FCMVault.t.sol b/solidity/test/FCMVault.t.sol index 1384b7d..dc7ab81 100644 --- a/solidity/test/FCMVault.t.sol +++ b/solidity/test/FCMVault.t.sol @@ -771,6 +771,77 @@ contract FCMVaultTest is Test { assertEq(PYUSD0.balanceOf(address(vault)), 0, "no loan idle"); } + // ---- 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 { + _depositFor(user, 1 ether); + marketOracle.setPrice(1700e36); // push HF below min -> delever path + assertLt(_healthFactor(), HEALTH_FACTOR_MIN, "below min"); + + MockSwapRouter(address(SwapLib.SWAP_ROUTER)).setFeeBps(300); // 3% > 1% floor + + vm.expectRevert("Too little received"); + vault.rebalance(false); + } + + /// @notice Same guard on the lever leg. + function test_Rebalance_LeverRevertsWhenSlippageExceedsFloor() public { + _depositFor(user, 1 ether); + marketOracle.setPrice(2300e36); // push HF above max -> lever path + assertGt(_healthFactor(), HEALTH_FACTOR_MAX, "above max"); + + MockSwapRouter(address(SwapLib.SWAP_ROUTER)).setFeeBps(300); // 3% > 1% floor + + vm.expectRevert("Too little received"); + vault.rebalance(false); + } + + /// @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 { + _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% + + vm.prank(admin); + vault.setMaxSlippageBps(500); // 5% > 3%, so the 3% haircut now passes + + vault.rebalance(false); + assertGt(_healthFactor(), hfBefore, "delever raised HF"); + } + + /// @notice `maxSlippageBps` defaults to 1%, is admin-only, and rejects + /// values >= 100%. + function test_SetMaxSlippageBps() public { + assertEq(vault.maxSlippageBps(), 100, "default 1%"); + + vm.prank(admin); + vault.setMaxSlippageBps(250); + assertEq(vault.maxSlippageBps(), 250, "admin updated"); + + // Non-admin cannot set (DEFAULT_ADMIN_ROLE == bytes32(0)). + vm.prank(stranger); + vm.expectRevert( + abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", stranger, bytes32(0) + ) + ); + vault.setMaxSlippageBps(300); + + // >= 100% rejected. + vm.prank(admin); + vm.expectRevert(FCMVault.InvalidSlippage.selector); + vault.setMaxSlippageBps(10_000); + } + /// @notice `force=true` rebalances when HF is inside the dead band but /// not exactly at target. Here we nudge HF slightly off-target /// (still inside [min, max]); a non-forced call is a no-op, a