Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .gas-snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FCMForkGasSnapshotsTest:test_gasFork_deposit() (gas: 689836)
FCMForkGasSnapshotsTest:test_gasFork_harvest() (gas: 43657)
FCMForkGasSnapshotsTest:test_gasFork_rebalance() (gas: 70714)
FCMForkGasSnapshotsTest:test_gasFork_redeem() (gas: 3226952)
FCMGasSnapshotsTest:test_gas_deposit() (gas: 318793)
FCMGasSnapshotsTest:test_gas_harvest() (gas: 166749)
FCMGasSnapshotsTest:test_gas_rebalance() (gas: 117714)
FCMGasSnapshotsTest:test_gas_redeem() (gas: 209089)
2 changes: 1 addition & 1 deletion .github/workflows/solidity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
run: FOUNDRY_PROFILE=ci forge lint

- name: Run Forge gas snapshot test
run: FOUNDRY_PROFILE=ci forge snapshot --match-path test/gas/*.sol --check
run: FOUNDRY_PROFILE=ci forge snapshot --match-path "test/gas/*.sol" --check

- name: Run Forge build
run: FOUNDRY_PROFILE=ci forge build --sizes
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ solidity-fmt-fix:

.PHONY:
snapshot:
cd solidity && FOUNDRY_PROFILE=ci forge snapshot --match-path test/gas/*.sol
cd solidity && FOUNDRY_PROFILE=ci forge snapshot --match-path "test/gas/*.sol"

# ---------------------------------------------------------------------------
# Pyth oracle maintenance (Flow EVM mainnet — MANUAL ONLY)
Expand Down
12 changes: 8 additions & 4 deletions solidity/.gas-snapshot
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
FCMGasSnapshotsTest:test_gas_deposit() (gas: 318756)
FCMGasSnapshotsTest:test_gas_harvest() (gas: 166758)
FCMGasSnapshotsTest:test_gas_rebalance() (gas: 117675)
FCMGasSnapshotsTest:test_gas_redeem() (gas: 209098)
FCMForkGasSnapshotsTest:test_gasFork_deposit() (gas: 687468)
FCMForkGasSnapshotsTest:test_gasFork_harvest() (gas: 43299)
FCMForkGasSnapshotsTest:test_gasFork_rebalance() (gas: 70356)
FCMForkGasSnapshotsTest:test_gasFork_redeem() (gas: 3234170)
FCMGasSnapshotsTest:test_gas_deposit() (gas: 314667)
FCMGasSnapshotsTest:test_gas_harvest() (gas: 164223)
FCMGasSnapshotsTest:test_gas_rebalance() (gas: 115188)
FCMGasSnapshotsTest:test_gas_redeem() (gas: 206563)
2 changes: 1 addition & 1 deletion solidity/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ remappings = [
]
optimizer_runs = 200
fs_permissions = [{ access = "read", path = "./deployments"}]
no_match_path = "test/fork/*"
no_match_path = "{test/fork/*,*Fork*}"
no_match_coverage = "script|test"

[rpc_endpoints]
Expand Down
4 changes: 2 additions & 2 deletions solidity/script/LiveCheck.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol";
import {SharesMathLib} from "@morpho-blue/libraries/SharesMathLib.sol";

import {FCMVault} from "../src/FCMVault.sol";
import {VaultHelpers} from "../test/utils/FCMVaultHelpers.sol";
import {FCMHelpers} from "../src/libraries/FCMHelpers.sol";

/// @title LiveCheck
/// @notice End-to-end integration check against a LIVE FCMVault deployment:
Expand Down Expand Up @@ -40,7 +40,7 @@ import {VaultHelpers} from "../test/utils/FCMVaultHelpers.sol";
contract LiveCheck is Script {
using MarketParamsLib for MarketParams;
using SharesMathLib for uint256;
using VaultHelpers for FCMVault;
using FCMHelpers for FCMVault;

function run() public {
FCMVault vault = FCMVault(vm.envAddress("VAULT"));
Expand Down
10 changes: 4 additions & 6 deletions solidity/script/Rebalance.s.sol
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import {Script, console} from "forge-std/Script.sol";

import {FCMVault} from "../src/FCMVault.sol";
import {FCMHelpers} from "../src/libraries/FCMHelpers.sol";
import {IMorpho, Market, MarketParams, Position} from "@morpho-blue/interfaces/IMorpho.sol";
import {IOracle} from "@morpho-blue/interfaces/IOracle.sol";
import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol";
import {SharesMathLib} from "@morpho-blue/libraries/SharesMathLib.sol";

import {FCMVault} from "../src/FCMVault.sol";
import {VaultHelpers} from "../test/utils/FCMVaultHelpers.sol";
import {Script, console} from "forge-std/Script.sol";

/// @title Rebalance
/// @notice Drives a LIVE FCMVault's leveraged Morpho position back inside its
Expand All @@ -36,7 +34,7 @@ import {VaultHelpers} from "../test/utils/FCMVaultHelpers.sol";
contract Rebalance is Script {
using MarketParamsLib for MarketParams;
using SharesMathLib for uint256;
using VaultHelpers for FCMVault;
using FCMHelpers for FCMVault;

function run() public {
FCMVault vault = FCMVault(vm.envAddress("VAULT"));
Expand Down
8 changes: 2 additions & 6 deletions solidity/src/FCMVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pragma solidity ^0.8.24;
import {IFCMVault} from "./interfaces/IFCMVault.sol";
import {ISwapRouter02} from "./interfaces/external/ISwapRouter02.sol";
import {FeesLib} from "./libraries/FeesLib.sol";
import {MarketLib} from "./libraries/MarketLib.sol";
import {MorphoLib} from "./libraries/MorphoLib.sol";
import {SwapLib} from "./libraries/SwapLib.sol";
import {IMorpho, MarketParams} from "@morpho-blue/interfaces/IMorpho.sol";
Expand Down Expand Up @@ -37,7 +38,7 @@ contract FCMVault is IFCMVault, ERC20, Ownable2Step, ReentrancyGuard, IMorphoFla
using SafeERC20 for IERC20;
using Math for uint256;
using MorphoLib for IMorpho;
using MorphoLib for MarketParams;
using MarketLib for MarketParams;

/// @dev Defines the decimal offset between vault assets and shares. Larger offsets make inflation attacks more
/// expensive. See
Expand Down Expand Up @@ -490,11 +491,6 @@ contract FCMVault is IFCMVault, ERC20, Ownable2Step, ReentrancyGuard, IMorphoFla
return 0;
}

/// @inheritdoc IFCMVault
function healthFactor() public view returns (uint256) {
return MORPHO.healthFactor(_market());
}

/// @dev Leverage leg of `rebalance`, rebalancing only to the re-entry target just inside the nearest bound rather
/// than to a central target.
/// - If `hf in [HEALTH_FACTOR_MIN, HEALTH_FACTOR_MAX]`, the call is a no-op.
Expand Down
5 changes: 0 additions & 5 deletions solidity/src/interfaces/IFCMVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,6 @@ interface IFCMVault is IERC4626 {
/// @dev The yield token is the inner vault's share token and the yield leg of the position.
function YIELD_TOKEN() external view returns (IERC20);

/// @notice Current health factor of the vault's Morpho position (WAD-scaled).
/// @dev WAD-scaled. `WAD` (1e18) is the liquidation line; below `HEALTH_FACTOR_MIN` is over-levered, above
/// `HEALTH_FACTOR_MAX` is under-levered.
function healthFactor() external view returns (uint256);

/// @notice Minimum health factor below which `rebalance` delevers (sells yield to repay debt).
/// @dev WAD-scaled. Positions below this threshold are over-levered.
function HEALTH_FACTOR_MIN() external view returns (uint256);
Expand Down
56 changes: 56 additions & 0 deletions solidity/src/libraries/FCMHelpers.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import {IFCMVault} from "../interfaces/IFCMVault.sol";
import {MorphoLib} from "./MorphoLib.sol";
import {IMorpho, MarketParams, Position} from "@morpho-blue/interfaces/IMorpho.sol";
import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol";

/// @title FCMHelpers
/// @author Flow Foundation
/// @notice Read-only views over an `IFCMVault`'s Morpho position, safe to call from a context that is not the vault
/// itself (e.g. a test contract). Each reader delegates to the `address`-overload of its `MorphoLib` counterpart,
/// passing `address(vault)` so the position read is the vault's, not the caller's (`address(this)`).
library FCMHelpers {
using MarketParamsLib for MarketParams;
using MorphoLib for IMorpho;

function market(IFCMVault vault) internal view returns (MarketParams memory _market) {
_market.loanToken = address(vault.LOAN_TOKEN());
_market.collateralToken = address(vault.COLLATERAL_TOKEN());
_market.oracle = address(vault.MARKET_ORACLE());
_market.irm = address(vault.MARKET_IRM());
_market.lltv = vault.MARKET_LLTV();
return _market;
}

/// @dev The vault's outstanding debt in its Morpho market, in raw loan-token units. Delegates to
/// `MorphoLib.debt(morpho, market, address(vault))`.
function debt(IFCMVault vault) internal view returns (uint256) {
return vault.MORPHO().debt(market(vault), address(vault));
}

/// @dev The vault's collateral supplied to its Morpho market, in raw collateral-token units. Delegates to
/// `MorphoLib.collateral(morpho, market, address(vault))`.
function collateral(IFCMVault vault) internal view returns (uint256) {
return vault.MORPHO().collateral(market(vault), address(vault));
}

function yield(IFCMVault vault) internal view returns (uint256) {
return vault.YIELD_TOKEN().balanceOf(address(vault));
}

/// @dev The vault's Morpho position (collateral + borrow shares).
function position(IFCMVault vault) internal view returns (Position memory) {
return vault.MORPHO().position(market(vault).id(), address(vault));
}

/// @notice Current health factor of the vault's Morpho position (WAD-scaled).
/// @dev WAD-scaled. `WAD` (1e18) is the liquidation line; below `HEALTH_FACTOR_MIN` is over-levered, above
/// `HEALTH_FACTOR_MAX` is under-levered. Delegates to `MorphoLib.healthFactor(morpho, market, address(vault))`,
/// which reads both the vault's collateral and its debt (not the caller's), so it is safe to call from a test.
/// @param vault The vault whose health factor is being read.
function healthFactor(IFCMVault vault) internal view returns (uint256) {
return vault.MORPHO().healthFactor(market(vault), address(vault));
}
}
61 changes: 61 additions & 0 deletions solidity/src/libraries/MarketLib.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import {MarketParams} from "@morpho-blue/interfaces/IMorpho.sol";
import {IOracle} from "@morpho-blue/interfaces/IOracle.sol";
import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";

/// @title MarketLib
/// @author Flow Foundation
/// @notice Price and amount-conversion helpers around a Morpho Blue market's oracle. All prices follow Morpho's
/// `IOracle` convention (1e36-scaled collateral -> debt), and conversions do not apply LLTV unless stated.
library MarketLib {
using Math for uint256;
using MarketParamsLib for MarketParams;

uint256 internal constant ORACLE_PRICE_SCALE = 1e36;
uint256 internal constant WAD = 1e18;
uint256 internal constant VIRTUAL_SHARES = 1e6;
uint256 internal constant VIRTUAL_ASSETS = 1;

/// @notice Returns the price of 1 unit of collateral token quoted in loan token, scaled by 1e36.
/// @dev The returned price has `36 + loanDecimals - collateralDecimals` decimals of precision, so that
/// `collateralAmount * price / 1e36` yields the collateral's value in raw loan-token units regardless of the two
/// tokens' decimal configurations.
/// Example (WETH collateral / USDC loan, 1 WETH = 2500 USDC):
/// price = 2500 * 10^(36 + 6 - 18) = 2.5e27
/// 1 WETH (1e18) collateral -> (1e18 * 2.5e27) / 1e36 = 2.5e9 = 2500 USDC
/// @param market Morpho market parameters identifying the position.
function oraclePrice(MarketParams memory market) internal view returns (uint256) {
return IOracle(market.oracle).price();
}

/// @notice Converts a collateral amount to its value in loan-token units at the current oracle price.
/// @dev Does not apply LLTV; this is a raw value conversion. Use `maxBorrowFor` for the LLTV-discounted borrowable
/// amount.
/// @param market Morpho market parameters identifying the position.
/// @param collateralAmount Amount of collateral to convert, in token units.
function collateralToDebt(MarketParams memory market, uint256 collateralAmount) internal view returns (uint256) {
if (collateralAmount == 0) return 0;
return collateralAmount.mulDiv(oraclePrice(market), ORACLE_PRICE_SCALE);
}

/// @notice Converts a loan-token amount to its equivalent collateral-token amount at the current oracle price.
/// @dev Inverse of `collateralToDebt`. Does not apply LLTV.
/// @param market Morpho market parameters identifying the position.
/// @param debtAmount Amount of loan token to convert, in token units.
function debtToCollateral(MarketParams memory market, uint256 debtAmount) internal view returns (uint256) {
if (debtAmount == 0) return 0;
return debtAmount.mulDiv(ORACLE_PRICE_SCALE, oraclePrice(market));
}

/// @notice Returns the maximum loan-token amount borrowable against `collateralAmount` at the market's LLTV.
/// @dev Equal to `collateralToDebt(collateralAmount) * lltv / WAD`. A position at exactly this debt level has a
/// health factor of WAD (the liquidation threshold).
/// @param market Morpho market parameters identifying the position.
/// @param collateralAmount Amount of collateral to borrow against, in token units.
function maxBorrowFor(MarketParams memory market, uint256 collateralAmount) internal view returns (uint256) {
return collateralToDebt(market, collateralAmount).mulDiv(market.lltv, WAD);
}
}
60 changes: 54 additions & 6 deletions solidity/src/libraries/MorphoLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,22 @@ library MorphoLib {
}

/// @notice Returns this contract's collateral balance in the given market, in raw collateral-token units.
/// @dev Convenience wrapper over `collateral(morpho, market, address(this))`.
/// @param morpho The Morpho Blue singleton.
/// @param market Morpho market parameters identifying the position.
function collateral(IMorpho morpho, MarketParams memory market) internal view returns (uint256) {
return uint256(morpho.position(market.id(), address(this)).collateral);
return collateral(morpho, market, address(this));
}

/// @notice Returns `user`'s collateral balance in the given market, in raw collateral-token units.
/// @dev Overload that reads an arbitrary account's position instead of `address(this)`'s, so it is safe to call
/// from a context (e.g. a test or periphery) that is not the position owner.
/// CAUTION: Call `accrueInterest(market)` first if an up-to-the-block value is required.
/// @param morpho The Morpho Blue singleton.
/// @param market Morpho market parameters identifying the position.
/// @param user The account whose collateral balance is being read.
function collateral(IMorpho morpho, MarketParams memory market, address user) internal view returns (uint256) {
return uint256(morpho.position(market.id(), user).collateral);
}

/// @notice Returns this contract's current debt in the given Morpho market, denominated in raw loan-token units.
Expand All @@ -117,7 +129,19 @@ library MorphoLib {
/// @param morpho The Morpho Blue singleton.
/// @param market Morpho market parameters identifying the position.
function debt(IMorpho morpho, MarketParams memory market) internal view returns (uint256) {
Position memory pos = morpho.position(market.id(), address(this));
return debt(morpho, market, address(this));
}

/// @notice Returns `user`'s current debt in the given Morpho market, denominated in raw loan-token units.
/// @dev Overload of `debt(IMorpho,MarketParams)` that reads an arbitrary account's borrow position instead of
/// `address(this)`'s, so it is safe to call from a context that is not the position owner. Conversion math and
/// virtual shares/assets handling are identical to `debt(IMorpho,MarketParams)`.
/// CAUTION: Call `accrueInterest(market)` first if an up-to-the-block value is required.
/// @param morpho The Morpho Blue singleton.
/// @param market Morpho market parameters identifying the position.
/// @param user The account whose debt is being read.
function debt(IMorpho morpho, MarketParams memory market, address user) internal view returns (uint256) {
Position memory pos = morpho.position(market.id(), user);
if (pos.borrowShares == 0) return 0;
Market memory mkt = morpho.market(market.id());
return uint256(pos.borrowShares)
Expand Down Expand Up @@ -169,11 +193,21 @@ library MorphoLib {
}

/// @notice Returns the maximum loan-token amount borrowable against this contract's current collateral balance.
/// @dev Convenience wrapper over `maxBorrowFor(collateral(market))`.
/// @dev Convenience wrapper over `maxBorrow(morpho, market, address(this))`.
/// @param morpho The Morpho Blue singleton.
/// @param market Morpho market parameters identifying the position.
function maxBorrow(IMorpho morpho, MarketParams memory market) internal view returns (uint256) {
return maxBorrowFor(market, collateral(morpho, market));
return maxBorrow(morpho, market, address(this));
}

/// @notice Returns the maximum loan-token amount borrowable against `user`'s current collateral balance.
/// @dev Convenience wrapper over `maxBorrowFor(collateral(market, user))`. Reads `user`'s position instead of
/// `address(this)`'s, so it is safe to call from a context that is not the position owner.
/// @param morpho The Morpho Blue singleton.
/// @param market Morpho market parameters identifying the position.
/// @param user The account whose collateral is being borrowed against.
function maxBorrow(IMorpho morpho, MarketParams memory market, address user) internal view returns (uint256) {
return maxBorrowFor(market, collateral(morpho, market, user));
}

/// @notice Returns this contract's health factor in the given market, scaled by WAD (1e18).
Expand All @@ -186,9 +220,23 @@ library MorphoLib {
/// @param morpho The Morpho Blue singleton.
/// @param market Morpho market parameters identifying the position.
function healthFactor(IMorpho morpho, MarketParams memory market) internal view returns (uint256) {
uint256 debtAmount = debt(morpho, market);
return healthFactor(morpho, market, address(this));
}

/// @notice Returns `user`'s health factor in the given market, scaled by WAD (1e18).
/// @dev Same definition as `healthFactor(IMorpho,MarketParams)` but computed against `user`'s collateral and debt
/// (both read via the `address` overloads), so it is safe to call from a context that is not the position owner.
/// The collateral and debt MUST refer to the same account: mixing `user`'s debt with `address(this)`'s collateral
/// would understate the health factor whenever the caller holds no collateral itself.
/// Returns `type(uint256).max` when `user` has no debt, since an unborrowed position cannot be liquidated.
/// CAUTION: Call `accrueInterest(market)` first if an up-to-the-block value is required.
/// @param morpho The Morpho Blue singleton.
/// @param market Morpho market parameters identifying the position.
/// @param user The account whose health factor is being read.
function healthFactor(IMorpho morpho, MarketParams memory market, address user) internal view returns (uint256) {
uint256 debtAmount = debt(morpho, market, user);
if (debtAmount == 0) return type(uint256).max;
return maxBorrow(morpho, market).mulDiv(WAD, debtAmount);
return maxBorrow(morpho, market, user).mulDiv(WAD, debtAmount);
}

/// @notice Returns the additional loan-token amount this contract can borrow to reach `targetHealthFactor`.
Expand Down
4 changes: 2 additions & 2 deletions solidity/test/FCMConstructor.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ pragma solidity ^0.8.24;

import {FCMVault} from "../src/FCMVault.sol";
import {IFCMVault} from "../src/interfaces/IFCMVault.sol";
import {FCMHelpers} from "../src/libraries/FCMHelpers.sol";
import {MorphoLib} from "../src/libraries/MorphoLib.sol";
import {Deployers} from "./utils/Deployers.sol";
import {Errors} from "./utils/Errors.sol";
import {VaultHelpers} from "./utils/FCMVaultHelpers.sol";
import {Test} from "forge-std/Test.sol";
import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol";

contract FCMConstructorTest is Test, Deployers {
using VaultHelpers for FCMVault;
using FCMHelpers for FCMVault;
using Math for uint256;

function setUp() public {
Expand Down
Loading