Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions solidity/src/FCMVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions solidity/test/FCMVault.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down