Skip to content

Commit 4906f4f

Browse files
committed
add fcmhelpers library for vault-position reads
1 parent 2c34f43 commit 4906f4f

35 files changed

Lines changed: 449 additions & 237 deletions

.gas-snapshot

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FCMForkGasSnapshotsTest:test_gasFork_deposit() (gas: 689836)
2+
FCMForkGasSnapshotsTest:test_gasFork_harvest() (gas: 43657)
3+
FCMForkGasSnapshotsTest:test_gasFork_rebalance() (gas: 70714)
4+
FCMForkGasSnapshotsTest:test_gasFork_redeem() (gas: 3226952)
5+
FCMGasSnapshotsTest:test_gas_deposit() (gas: 318793)
6+
FCMGasSnapshotsTest:test_gas_harvest() (gas: 166749)
7+
FCMGasSnapshotsTest:test_gas_rebalance() (gas: 117714)
8+
FCMGasSnapshotsTest:test_gas_redeem() (gas: 209089)

solidity/.gas-snapshot

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,8 @@
1-
<<<<<<< HEAD
2-
FCMGasSnapshotsTest:test_gas_deposit() (gas: 318756)
3-
FCMGasSnapshotsTest:test_gas_harvest() (gas: 166758)
4-
FCMGasSnapshotsTest:test_gas_rebalance() (gas: 117675)
5-
FCMGasSnapshotsTest:test_gas_redeem() (gas: 209098)
6-
||||||| parent of 7e1f953 (add & clean up fork tests)
7-
FCMGasSnapshotsTest:test_gas_deposit() (gas: 314629)
8-
FCMGasSnapshotsTest:test_gas_harvest() (gas: 164232)
9-
FCMGasSnapshotsTest:test_gas_rebalance() (gas: 115148)
10-
FCMGasSnapshotsTest:test_gas_redeem() (gas: 206572)
11-
=======
12-
FCMForkGasSnapshotsTest:test_gasFork_deposit() (gas: 687421)
13-
FCMForkGasSnapshotsTest:test_gasFork_harvest() (gas: 43288)
14-
FCMForkGasSnapshotsTest:test_gasFork_rebalance() (gas: 70307)
15-
FCMForkGasSnapshotsTest:test_gasFork_redeem() (gas: 3234137)
16-
FCMGasSnapshotsTest:test_gas_deposit() (gas: 314629)
17-
FCMGasSnapshotsTest:test_gas_harvest() (gas: 164232)
18-
FCMGasSnapshotsTest:test_gas_rebalance() (gas: 115148)
19-
FCMGasSnapshotsTest:test_gas_redeem() (gas: 206572)
20-
>>>>>>> 7e1f953 (add & clean up fork tests)
1+
FCMForkGasSnapshotsTest:test_gasFork_deposit() (gas: 687468)
2+
FCMForkGasSnapshotsTest:test_gasFork_harvest() (gas: 43299)
3+
FCMForkGasSnapshotsTest:test_gasFork_rebalance() (gas: 70356)
4+
FCMForkGasSnapshotsTest:test_gasFork_redeem() (gas: 3234170)
5+
FCMGasSnapshotsTest:test_gas_deposit() (gas: 314667)
6+
FCMGasSnapshotsTest:test_gas_harvest() (gas: 164223)
7+
FCMGasSnapshotsTest:test_gas_rebalance() (gas: 115188)
8+
FCMGasSnapshotsTest:test_gas_redeem() (gas: 206563)

solidity/foundry.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ remappings = [
1111
]
1212
optimizer_runs = 200
1313
fs_permissions = [{ access = "read", path = "./deployments"}]
14-
no_match_path = "test/fork/*"
14+
no_match_path = "{test/fork/*,*Fork*}"
1515
no_match_coverage = "script|test"
1616

1717
[rpc_endpoints]

solidity/script/LiveCheck.s.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol";
99
import {SharesMathLib} from "@morpho-blue/libraries/SharesMathLib.sol";
1010

1111
import {FCMVault} from "../src/FCMVault.sol";
12-
import {VaultHelpers} from "../test/utils/FCMVaultHelpers.sol";
12+
import {FCMHelpers} from "../src/libraries/FCMHelpers.sol";
1313

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

4545
function run() public {
4646
FCMVault vault = FCMVault(vm.envAddress("VAULT"));

solidity/script/Rebalance.s.sol

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
// SPDX-License-Identifier: UNLICENSED
22
pragma solidity ^0.8.24;
33

4-
import {Script, console} from "forge-std/Script.sol";
5-
4+
import {FCMVault} from "../src/FCMVault.sol";
5+
import {FCMHelpers} from "../src/libraries/FCMHelpers.sol";
66
import {IMorpho, Market, MarketParams, Position} from "@morpho-blue/interfaces/IMorpho.sol";
77
import {IOracle} from "@morpho-blue/interfaces/IOracle.sol";
88
import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol";
99
import {SharesMathLib} from "@morpho-blue/libraries/SharesMathLib.sol";
10-
11-
import {FCMVault} from "../src/FCMVault.sol";
12-
import {VaultHelpers} from "../test/utils/FCMVaultHelpers.sol";
10+
import {Script, console} from "forge-std/Script.sol";
1311

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

4139
function run() public {
4240
FCMVault vault = FCMVault(vm.envAddress("VAULT"));

solidity/src/FCMVault.sol

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pragma solidity ^0.8.24;
44
import {IFCMVault} from "./interfaces/IFCMVault.sol";
55
import {ISwapRouter02} from "./interfaces/external/ISwapRouter02.sol";
66
import {FeesLib} from "./libraries/FeesLib.sol";
7+
import {MarketLib} from "./libraries/MarketLib.sol";
78
import {MorphoLib} from "./libraries/MorphoLib.sol";
89
import {SwapLib} from "./libraries/SwapLib.sol";
910
import {IMorpho, MarketParams} from "@morpho-blue/interfaces/IMorpho.sol";
@@ -37,7 +38,7 @@ contract FCMVault is IFCMVault, ERC20, Ownable2Step, ReentrancyGuard, IMorphoFla
3738
using SafeERC20 for IERC20;
3839
using Math for uint256;
3940
using MorphoLib for IMorpho;
40-
using MorphoLib for MarketParams;
41+
using MarketLib for MarketParams;
4142

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

493-
/// @inheritdoc IFCMVault
494-
function healthFactor() public view returns (uint256) {
495-
return MORPHO.healthFactor(_market());
496-
}
497-
498494
/// @dev Leverage leg of `rebalance`, rebalancing only to the re-entry target just inside the nearest bound rather
499495
/// than to a central target.
500496
/// - If `hf in [HEALTH_FACTOR_MIN, HEALTH_FACTOR_MAX]`, the call is a no-op.

solidity/src/interfaces/IFCMVault.sol

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,6 @@ interface IFCMVault is IERC4626 {
240240
/// @dev The yield token is the inner vault's share token and the yield leg of the position.
241241
function YIELD_TOKEN() external view returns (IERC20);
242242

243-
/// @notice Current health factor of the vault's Morpho position (WAD-scaled).
244-
/// @dev WAD-scaled. `WAD` (1e18) is the liquidation line; below `HEALTH_FACTOR_MIN` is over-levered, above
245-
/// `HEALTH_FACTOR_MAX` is under-levered.
246-
function healthFactor() external view returns (uint256);
247-
248243
/// @notice Minimum health factor below which `rebalance` delevers (sells yield to repay debt).
249244
/// @dev WAD-scaled. Positions below this threshold are over-levered.
250245
function HEALTH_FACTOR_MIN() external view returns (uint256);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.24;
3+
4+
import {IFCMVault} from "../interfaces/IFCMVault.sol";
5+
import {MorphoLib} from "./MorphoLib.sol";
6+
import {IMorpho, MarketParams, Position} from "@morpho-blue/interfaces/IMorpho.sol";
7+
import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol";
8+
9+
/// @title FCMHelpers
10+
/// @author Flow Foundation
11+
/// @notice Read-only views over an `IFCMVault`'s Morpho position, safe to call from a context that is not the vault
12+
/// itself (e.g. a test contract). Each reader delegates to the `address`-overload of its `MorphoLib` counterpart,
13+
/// passing `address(vault)` so the position read is the vault's, not the caller's (`address(this)`).
14+
library FCMHelpers {
15+
using MarketParamsLib for MarketParams;
16+
using MorphoLib for IMorpho;
17+
18+
function market(IFCMVault vault) internal view returns (MarketParams memory _market) {
19+
_market.loanToken = address(vault.LOAN_TOKEN());
20+
_market.collateralToken = address(vault.COLLATERAL_TOKEN());
21+
_market.oracle = address(vault.MARKET_ORACLE());
22+
_market.irm = address(vault.MARKET_IRM());
23+
_market.lltv = vault.MARKET_LLTV();
24+
return _market;
25+
}
26+
27+
/// @dev The vault's outstanding debt in its Morpho market, in raw loan-token units. Delegates to
28+
/// `MorphoLib.debt(morpho, market, address(vault))`.
29+
function debt(IFCMVault vault) internal view returns (uint256) {
30+
return vault.MORPHO().debt(market(vault), address(vault));
31+
}
32+
33+
/// @dev The vault's collateral supplied to its Morpho market, in raw collateral-token units. Delegates to
34+
/// `MorphoLib.collateral(morpho, market, address(vault))`.
35+
function collateral(IFCMVault vault) internal view returns (uint256) {
36+
return vault.MORPHO().collateral(market(vault), address(vault));
37+
}
38+
39+
function yield(IFCMVault vault) internal view returns (uint256) {
40+
return vault.YIELD_TOKEN().balanceOf(address(vault));
41+
}
42+
43+
/// @dev The vault's Morpho position (collateral + borrow shares).
44+
function position(IFCMVault vault) internal view returns (Position memory) {
45+
return vault.MORPHO().position(market(vault).id(), address(vault));
46+
}
47+
48+
/// @notice Current health factor of the vault's Morpho position (WAD-scaled).
49+
/// @dev WAD-scaled. `WAD` (1e18) is the liquidation line; below `HEALTH_FACTOR_MIN` is over-levered, above
50+
/// `HEALTH_FACTOR_MAX` is under-levered. Delegates to `MorphoLib.healthFactor(morpho, market, address(vault))`,
51+
/// which reads both the vault's collateral and its debt (not the caller's), so it is safe to call from a test.
52+
/// @param vault The vault whose health factor is being read.
53+
function healthFactor(IFCMVault vault) internal view returns (uint256) {
54+
return vault.MORPHO().healthFactor(market(vault), address(vault));
55+
}
56+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.24;
3+
4+
import {MarketParams} from "@morpho-blue/interfaces/IMorpho.sol";
5+
import {IOracle} from "@morpho-blue/interfaces/IOracle.sol";
6+
import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol";
7+
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
8+
9+
/// @title MarketLib
10+
/// @author Flow Foundation
11+
/// @notice Price and amount-conversion helpers around a Morpho Blue market's oracle. All prices follow Morpho's
12+
/// `IOracle` convention (1e36-scaled collateral -> debt), and conversions do not apply LLTV unless stated.
13+
library MarketLib {
14+
using Math for uint256;
15+
using MarketParamsLib for MarketParams;
16+
17+
uint256 internal constant ORACLE_PRICE_SCALE = 1e36;
18+
uint256 internal constant WAD = 1e18;
19+
uint256 internal constant VIRTUAL_SHARES = 1e6;
20+
uint256 internal constant VIRTUAL_ASSETS = 1;
21+
22+
/// @notice Returns the price of 1 unit of collateral token quoted in loan token, scaled by 1e36.
23+
/// @dev The returned price has `36 + loanDecimals - collateralDecimals` decimals of precision, so that
24+
/// `collateralAmount * price / 1e36` yields the collateral's value in raw loan-token units regardless of the two
25+
/// tokens' decimal configurations.
26+
/// Example (WETH collateral / USDC loan, 1 WETH = 2500 USDC):
27+
/// price = 2500 * 10^(36 + 6 - 18) = 2.5e27
28+
/// 1 WETH (1e18) collateral -> (1e18 * 2.5e27) / 1e36 = 2.5e9 = 2500 USDC
29+
/// @param market Morpho market parameters identifying the position.
30+
function oraclePrice(MarketParams memory market) internal view returns (uint256) {
31+
return IOracle(market.oracle).price();
32+
}
33+
34+
/// @notice Converts a collateral amount to its value in loan-token units at the current oracle price.
35+
/// @dev Does not apply LLTV; this is a raw value conversion. Use `maxBorrowFor` for the LLTV-discounted borrowable
36+
/// amount.
37+
/// @param market Morpho market parameters identifying the position.
38+
/// @param collateralAmount Amount of collateral to convert, in token units.
39+
function collateralToDebt(MarketParams memory market, uint256 collateralAmount) internal view returns (uint256) {
40+
if (collateralAmount == 0) return 0;
41+
return collateralAmount.mulDiv(oraclePrice(market), ORACLE_PRICE_SCALE);
42+
}
43+
44+
/// @notice Converts a loan-token amount to its equivalent collateral-token amount at the current oracle price.
45+
/// @dev Inverse of `collateralToDebt`. Does not apply LLTV.
46+
/// @param market Morpho market parameters identifying the position.
47+
/// @param debtAmount Amount of loan token to convert, in token units.
48+
function debtToCollateral(MarketParams memory market, uint256 debtAmount) internal view returns (uint256) {
49+
if (debtAmount == 0) return 0;
50+
return debtAmount.mulDiv(ORACLE_PRICE_SCALE, oraclePrice(market));
51+
}
52+
53+
/// @notice Returns the maximum loan-token amount borrowable against `collateralAmount` at the market's LLTV.
54+
/// @dev Equal to `collateralToDebt(collateralAmount) * lltv / WAD`. A position at exactly this debt level has a
55+
/// health factor of WAD (the liquidation threshold).
56+
/// @param market Morpho market parameters identifying the position.
57+
/// @param collateralAmount Amount of collateral to borrow against, in token units.
58+
function maxBorrowFor(MarketParams memory market, uint256 collateralAmount) internal view returns (uint256) {
59+
return collateralToDebt(market, collateralAmount).mulDiv(market.lltv, WAD);
60+
}
61+
}

solidity/src/libraries/MorphoLib.sol

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,22 @@ library MorphoLib {
9696
}
9797

9898
/// @notice Returns this contract's collateral balance in the given market, in raw collateral-token units.
99+
/// @dev Convenience wrapper over `collateral(morpho, market, address(this))`.
99100
/// @param morpho The Morpho Blue singleton.
100101
/// @param market Morpho market parameters identifying the position.
101102
function collateral(IMorpho morpho, MarketParams memory market) internal view returns (uint256) {
102-
return uint256(morpho.position(market.id(), address(this)).collateral);
103+
return collateral(morpho, market, address(this));
104+
}
105+
106+
/// @notice Returns `user`'s collateral balance in the given market, in raw collateral-token units.
107+
/// @dev Overload that reads an arbitrary account's position instead of `address(this)`'s, so it is safe to call
108+
/// from a context (e.g. a test or periphery) that is not the position owner.
109+
/// CAUTION: Call `accrueInterest(market)` first if an up-to-the-block value is required.
110+
/// @param morpho The Morpho Blue singleton.
111+
/// @param market Morpho market parameters identifying the position.
112+
/// @param user The account whose collateral balance is being read.
113+
function collateral(IMorpho morpho, MarketParams memory market, address user) internal view returns (uint256) {
114+
return uint256(morpho.position(market.id(), user).collateral);
103115
}
104116

105117
/// @notice Returns this contract's current debt in the given Morpho market, denominated in raw loan-token units.
@@ -117,7 +129,19 @@ library MorphoLib {
117129
/// @param morpho The Morpho Blue singleton.
118130
/// @param market Morpho market parameters identifying the position.
119131
function debt(IMorpho morpho, MarketParams memory market) internal view returns (uint256) {
120-
Position memory pos = morpho.position(market.id(), address(this));
132+
return debt(morpho, market, address(this));
133+
}
134+
135+
/// @notice Returns `user`'s current debt in the given Morpho market, denominated in raw loan-token units.
136+
/// @dev Overload of `debt(IMorpho,MarketParams)` that reads an arbitrary account's borrow position instead of
137+
/// `address(this)`'s, so it is safe to call from a context that is not the position owner. Conversion math and
138+
/// virtual shares/assets handling are identical to `debt(IMorpho,MarketParams)`.
139+
/// CAUTION: Call `accrueInterest(market)` first if an up-to-the-block value is required.
140+
/// @param morpho The Morpho Blue singleton.
141+
/// @param market Morpho market parameters identifying the position.
142+
/// @param user The account whose debt is being read.
143+
function debt(IMorpho morpho, MarketParams memory market, address user) internal view returns (uint256) {
144+
Position memory pos = morpho.position(market.id(), user);
121145
if (pos.borrowShares == 0) return 0;
122146
Market memory mkt = morpho.market(market.id());
123147
return uint256(pos.borrowShares)
@@ -169,11 +193,21 @@ library MorphoLib {
169193
}
170194

171195
/// @notice Returns the maximum loan-token amount borrowable against this contract's current collateral balance.
172-
/// @dev Convenience wrapper over `maxBorrowFor(collateral(market))`.
196+
/// @dev Convenience wrapper over `maxBorrow(morpho, market, address(this))`.
173197
/// @param morpho The Morpho Blue singleton.
174198
/// @param market Morpho market parameters identifying the position.
175199
function maxBorrow(IMorpho morpho, MarketParams memory market) internal view returns (uint256) {
176-
return maxBorrowFor(market, collateral(morpho, market));
200+
return maxBorrow(morpho, market, address(this));
201+
}
202+
203+
/// @notice Returns the maximum loan-token amount borrowable against `user`'s current collateral balance.
204+
/// @dev Convenience wrapper over `maxBorrowFor(collateral(market, user))`. Reads `user`'s position instead of
205+
/// `address(this)`'s, so it is safe to call from a context that is not the position owner.
206+
/// @param morpho The Morpho Blue singleton.
207+
/// @param market Morpho market parameters identifying the position.
208+
/// @param user The account whose collateral is being borrowed against.
209+
function maxBorrow(IMorpho morpho, MarketParams memory market, address user) internal view returns (uint256) {
210+
return maxBorrowFor(market, collateral(morpho, market, user));
177211
}
178212

179213
/// @notice Returns this contract's health factor in the given market, scaled by WAD (1e18).
@@ -186,9 +220,23 @@ library MorphoLib {
186220
/// @param morpho The Morpho Blue singleton.
187221
/// @param market Morpho market parameters identifying the position.
188222
function healthFactor(IMorpho morpho, MarketParams memory market) internal view returns (uint256) {
189-
uint256 debtAmount = debt(morpho, market);
223+
return healthFactor(morpho, market, address(this));
224+
}
225+
226+
/// @notice Returns `user`'s health factor in the given market, scaled by WAD (1e18).
227+
/// @dev Same definition as `healthFactor(IMorpho,MarketParams)` but computed against `user`'s collateral and debt
228+
/// (both read via the `address` overloads), so it is safe to call from a context that is not the position owner.
229+
/// The collateral and debt MUST refer to the same account: mixing `user`'s debt with `address(this)`'s collateral
230+
/// would understate the health factor whenever the caller holds no collateral itself.
231+
/// Returns `type(uint256).max` when `user` has no debt, since an unborrowed position cannot be liquidated.
232+
/// CAUTION: Call `accrueInterest(market)` first if an up-to-the-block value is required.
233+
/// @param morpho The Morpho Blue singleton.
234+
/// @param market Morpho market parameters identifying the position.
235+
/// @param user The account whose health factor is being read.
236+
function healthFactor(IMorpho morpho, MarketParams memory market, address user) internal view returns (uint256) {
237+
uint256 debtAmount = debt(morpho, market, user);
190238
if (debtAmount == 0) return type(uint256).max;
191-
return maxBorrow(morpho, market).mulDiv(WAD, debtAmount);
239+
return maxBorrow(morpho, market, user).mulDiv(WAD, debtAmount);
192240
}
193241

194242
/// @notice Returns the additional loan-token amount this contract can borrow to reach `targetHealthFactor`.

0 commit comments

Comments
 (0)