Skip to content

Commit 0bdca83

Browse files
committed
refactor emergency recovery
1 parent 5366765 commit 0bdca83

18 files changed

Lines changed: 597 additions & 274 deletions

solidity/script/DeployVault.s.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {IFCMVault} from "../src/interfaces/IFCMVault.sol";
1111
import {ConfiguredScript} from "./ConfiguredScript.s.sol";
1212
import {IOracle} from "@morpho-blue/interfaces/IOracle.sol";
1313
import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
14+
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
1415

1516
/// @title DeployVault
1617
/// @notice Deploys the FCMVault (and, if the config has no `yieldOracle`
@@ -78,7 +79,7 @@ contract DeployVault is ConfiguredScript {
7879
yieldFactorMax: c.yieldFactorMax,
7980
yieldOracle: IOracle(yieldOracle),
8081
admin: deployer,
81-
recoveryDelay: c.recoveryDelay,
82+
emergencyRecoveryDelay: SafeCast.toUint32(c.recoveryDelay),
8283
name: name,
8384
symbol: symbol
8485
})

solidity/script/LiveCheck.s.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ contract LiveCheck is Script {
107107
/// read is fresh while the pre-read may lag by unaccrued interest
108108
/// (immaterial for this report).
109109
function _debt(FCMVault vault) internal view returns (uint256) {
110-
MarketParams memory market = vault.getMarket();
110+
MarketParams memory market = vault.market();
111111
Position memory pos = MarketLib.MORPHO.position(market.id(), address(vault));
112112
if (pos.borrowShares == 0) return 0;
113113
Market memory mkt = MarketLib.MORPHO.market(market.id());

solidity/script/Rebalance.s.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ contract Rebalance is Script {
6969
/// health factor the same way the contract does (WAD-scaled). Returns
7070
/// `type(uint256).max` when there is no debt.
7171
function _healthFactor(FCMVault vault) internal view returns (uint256) {
72-
MarketParams memory mp = vault.getMarket();
72+
MarketParams memory mp = vault.market();
7373
Position memory pos = MarketLib.MORPHO.position(mp.id(), address(vault));
7474
if (pos.borrowShares == 0) return type(uint256).max;
7575
uint256 debt = _debtFromPosition(mp, pos);
@@ -79,7 +79,7 @@ contract Rebalance is Script {
7979

8080
/// @dev The vault's outstanding debt in loan-token units.
8181
function _debt(FCMVault vault) internal view returns (uint256) {
82-
MarketParams memory mp = vault.getMarket();
82+
MarketParams memory mp = vault.market();
8383
Position memory pos = MarketLib.MORPHO.position(mp.id(), address(vault));
8484
if (pos.borrowShares == 0) return 0;
8585
return _debtFromPosition(mp, pos);

solidity/src/FCMVault.sol

Lines changed: 91 additions & 93 deletions
Large diffs are not rendered by default.

solidity/src/interfaces/IFCMVault.sol

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ interface IFCMVault is IERC4626 {
2727
uint256 yieldFactorMax;
2828
IOracle yieldOracle;
2929
address admin;
30-
uint256 recoveryDelay;
30+
uint32 emergencyRecoveryDelay;
3131
string name;
3232
string symbol;
3333
}
@@ -38,11 +38,10 @@ interface IFCMVault is IERC4626 {
3838
/// @notice Emitted when a pending emergency recovery is cancelled before execution.
3939
event EmergencyRecoveryCancelled();
4040
/// @notice Emitted when an emergency recovery executes and the position is swept to the owner.
41-
/// @param loanRepaid The amount of loan tokens repaid to clear the debt.
4241
/// @param collateralOut The amount of collateral tokens swept to the owner.
4342
/// @param yieldOut The amount of yield tokens swept to the owner.
4443
/// @param loanOut The amount of unaccounted loan tokens swept to the owner.
45-
event EmergencyRecoveryExecuted(uint256 loanRepaid, uint256 collateralOut, uint256 yieldOut, uint256 loanOut);
44+
event EmergencyRecoveryExecuted(uint256 collateralOut, uint256 yieldOut, uint256 loanOut);
4645

4746
/// @notice Emitted when the admin updates the fee recipient (old + new).
4847
/// @param oldRecipient Previous fee recipient.
@@ -165,15 +164,18 @@ interface IFCMVault is IERC4626 {
165164
/// yield sold per call.
166165
/// @param maximumYield Maximum yield tokens to sell in this harvest.
167166
function harvest(uint256 maximumYield) external;
167+
168+
/// @notice Schedule a timelocked emergency recovery. Executable after `recoveryDelay`; the owner may cancel in the
169+
/// meantime.
170+
function scheduleEmergencyRecovery() external;
168171
/// @notice Cancel a pending recovery during its timelock window.
172+
/// @dev Its not possible to cancel after the emergency recovery has been executed.
169173
function cancelEmergencyRecovery() external;
170-
171-
/// @notice Execute a scheduled recovery once its timelock elapses. The owner funds the full debt in `loanToken`;
172-
/// the position is fully unwound (no swap, no oracle read) and all assets are swept to the owner. Burns no shares
173-
/// and permanently blocks deposits. `redeem` stays callable throughout the window so holders may exit first.
174-
/// @dev Oracle-independent by construction: fees are never accrued here (no NAV mark), and `repayAll` zeroes the
175-
/// debt before `withdrawCollateral`, so Morpho's health check short-circuits on `borrowShares == 0` without reading
176-
/// its oracle. Recovery stays executable when oracles are bricked.
174+
/// @notice Execute a scheduled recovery once its timelock elapses.
175+
/// @dev WARNING: Must have repaid the full debt externally, must be done atomically to prevent frontrunning!
176+
/// @dev The position is fully unwound and all assets are swept to the owner. Oracle-independent by construction:
177+
/// fees are never accrued here (no NAV mark), and `withdrawCollateral` after the debt is cleared short-circuits
178+
/// Morpho's health check.
177179
function executeEmergencyRecovery() external;
178180

179181
/// @notice Escape hatch - swap-free, in-kind redemption: the caller repays `owner`'s pro-rata debt slice in
@@ -194,9 +196,7 @@ interface IFCMVault is IERC4626 {
194196
function redeemInKind(uint256 shares, address receiver, address owner)
195197
external
196198
returns (uint256 collateralOut, uint256 yieldOut);
197-
/// @notice Schedule a timelocked emergency recovery. Executable after `recoveryDelay`; the owner may cancel in the
198-
/// meantime.
199-
function scheduleEmergencyRecovery() external;
199+
200200
/// @notice Set the fee recipient. Accrues to the old recipient first.
201201
/// @dev The recipient must hold `EARLY_ACCESS_ROLE` to receive minted fee shares; if it doesn't, accrual silently
202202
/// skips (see `_accrueFees`).
@@ -236,6 +236,10 @@ interface IFCMVault is IERC4626 {
236236
/// @return yieldTokenAddress The yield token address.
237237
function YIELD_TOKEN() external view returns (IERC20 yieldTokenAddress);
238238

239+
/// @notice Health factor
240+
/// @dev WAD-scaled.
241+
/// @return healthFactor The health factor of the vault.
242+
function healthFactor() external view returns (uint256 healthFactor);
239243
/// @notice Health factor below which `rebalance` will delever (sell yield to repay debt). The position is
240244
/// over-levered below this bound.
241245
/// @dev WAD-scaled.
@@ -301,11 +305,13 @@ interface IFCMVault is IERC4626 {
301305

302306
// - Timelocked emergency recovery (custodial, in-kind) -----
303307
/// @notice Delay (in seconds) between scheduling and executing a recovery.
304-
function RECOVERY_DELAY() external view returns (uint256);
308+
function EMERGENCY_RECOVERY_DELAY() external view returns (uint32);
305309
/// @notice Timestamp a scheduled recovery becomes executable; 0 = none pending.
306-
function recoveryValidAt() external view returns (uint256);
307-
/// @notice Set once a recovery executes; permanently blocks new deposits.
308-
function recovered() external view returns (bool);
310+
function emergencyRecoveryValidAt() external view returns (uint64);
311+
/// @notice Set when a recovery is scheduled. Can be unset before the recovery is executed.
312+
function emergencyRecoveryActive() external view returns (bool);
313+
/// @notice Set once the recovery executes. Will never be unset after being set.
314+
function emergencyRecovered() external view returns (bool);
309315

310316
// - Admin-controlled parameters & fees ---------
311317
/// @notice TVL limit, denominated in the vault's Asset token. Enforced by `super.deposit`, which reverts with

solidity/test/EmergencyRecovery.t.sol

Whitespace-only changes.

solidity/test/FCMDeposit.t.sol

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.25;
3+
4+
import {FCMVault} from "../src/FCMVault.sol";
5+
import {IFCMVault} from "../src/interfaces/IFCMVault.sol";
6+
import {MarketLib} from "../src/libraries/MarketLib.sol";
7+
import {Deployers} from "./utils/Deployers.sol";
8+
import {VaultHelpers} from "./utils/FCMVaultHelpers.sol";
9+
import {Test} from "forge-std/Test.sol";
10+
11+
contract FCMDepositTest is Test, Deployers {
12+
using VaultHelpers for FCMVault;
13+
bytes errorActive = abi.encodeWithSelector(IFCMVault.EmergencyRecoveryActive.selector);
14+
15+
function setUp() public {
16+
deployVault();
17+
}
18+
19+
function test_DepositBlockedDuringEmergencyRecovery() public {
20+
vm.startPrank(owner);
21+
vault.grantEarlyAccess(alice);
22+
vault.setMaxTvl(1 ether);
23+
vault.scheduleEmergencyRecovery();
24+
vm.stopPrank();
25+
26+
vm.startPrank(alice);
27+
COLLATERAL_TOKEN.mint(alice, 10 ether);
28+
COLLATERAL_TOKEN.approve(address(vault), 10 ether);
29+
vm.expectRevert(errorActive);
30+
vault.deposit(1 ether, alice);
31+
vm.stopPrank();
32+
33+
vm.startPrank(owner);
34+
vault.cancelEmergencyRecovery();
35+
vm.stopPrank();
36+
37+
vm.prank(alice);
38+
vault.deposit(1 ether, alice);
39+
}
40+
41+
function test_DepositBlockedAfterEmergencyRecovery() public {
42+
vm.startPrank(owner);
43+
vault.scheduleEmergencyRecovery();
44+
vm.warp(vault.emergencyRecoveryValidAt());
45+
vault.executeEmergencyRecovery();
46+
vm.stopPrank();
47+
48+
vm.startPrank(alice);
49+
vm.expectRevert(errorActive);
50+
vault.deposit(1 ether, alice);
51+
vm.stopPrank();
52+
}
53+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.25;
3+
4+
import {FCMVault} from "../src/FCMVault.sol";
5+
import {IFCMVault} from "../src/interfaces/IFCMVault.sol";
6+
import {MarketLib} from "../src/libraries/MarketLib.sol";
7+
import {Deployers} from "./utils/Deployers.sol";
8+
import {VaultHelpers} from "./utils/FCMVaultHelpers.sol";
9+
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
10+
import {Test} from "forge-std/Test.sol";
11+
import {Vm} from "forge-std/Vm.sol";
12+
13+
contract FCMEmergencyRecoveryTest is Test, Deployers {
14+
using VaultHelpers for FCMVault;
15+
bytes errorBobUnauthorized = abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(bob));
16+
bytes errorNotReady = abi.encodeWithSelector(IFCMVault.EmergencyRecoveryNotReady.selector);
17+
bytes errorActive = abi.encodeWithSelector(IFCMVault.EmergencyRecoveryActive.selector);
18+
19+
function setUp() public {
20+
deployVault();
21+
}
22+
23+
function test_OnlyOwner() public {
24+
vm.prank(bob);
25+
vm.expectRevert(errorBobUnauthorized);
26+
vault.scheduleEmergencyRecovery();
27+
vm.prank(owner);
28+
vault.scheduleEmergencyRecovery();
29+
30+
vm.prank(bob);
31+
vm.expectRevert(errorBobUnauthorized);
32+
vault.cancelEmergencyRecovery();
33+
vm.prank(owner);
34+
vault.cancelEmergencyRecovery();
35+
36+
vm.prank(owner);
37+
vault.scheduleEmergencyRecovery();
38+
vm.warp(block.timestamp + vault.EMERGENCY_RECOVERY_DELAY());
39+
vm.prank(bob);
40+
vm.expectRevert(errorBobUnauthorized);
41+
vault.executeEmergencyRecovery();
42+
vm.prank(owner);
43+
vault.executeEmergencyRecovery();
44+
}
45+
46+
function test_RecoveryDelay() public {
47+
vm.startPrank(owner);
48+
vault.scheduleEmergencyRecovery();
49+
uint256 snapshot = vm.snapshot();
50+
51+
assertEq(vault.emergencyRecoveryValidAt(), block.timestamp + vault.EMERGENCY_RECOVERY_DELAY());
52+
assertEq(vault.emergencyRecoveryActive(), true);
53+
vm.warp(vault.emergencyRecoveryValidAt() - 1);
54+
55+
vm.expectRevert(errorNotReady);
56+
vault.executeEmergencyRecovery();
57+
58+
vm.warp(vault.emergencyRecoveryValidAt());
59+
vault.executeEmergencyRecovery();
60+
61+
vm.revertTo(snapshot);
62+
vm.warp(vault.emergencyRecoveryValidAt() * 2);
63+
vault.executeEmergencyRecovery();
64+
}
65+
66+
function test_doubleSchedule() public {
67+
vm.startPrank(owner);
68+
vault.scheduleEmergencyRecovery();
69+
vault.scheduleEmergencyRecovery();
70+
assertEq(vault.emergencyRecoveryValidAt(), block.timestamp + vault.EMERGENCY_RECOVERY_DELAY());
71+
assertEq(vault.emergencyRecoveryActive(), true);
72+
}
73+
74+
function test_cancelNotScheduled() public {
75+
vm.startPrank(owner);
76+
vault.cancelEmergencyRecovery();
77+
assertEq(vault.emergencyRecoveryValidAt(), 0, "recovery not scheduled");
78+
assertEq(vault.emergencyRecoveryActive(), false);
79+
}
80+
81+
function test_doubleCancel() public {
82+
vm.startPrank(owner);
83+
vault.scheduleEmergencyRecovery();
84+
vault.cancelEmergencyRecovery();
85+
vault.cancelEmergencyRecovery();
86+
assertEq(vault.emergencyRecoveryValidAt(), 0, "recovery not scheduled");
87+
assertEq(vault.emergencyRecoveryActive(), false);
88+
}
89+
90+
function test_noCancelAfterExecute() public {
91+
vm.startPrank(owner);
92+
vault.scheduleEmergencyRecovery();
93+
uint64 emergencyRecoveryValidAt = vault.emergencyRecoveryValidAt();
94+
vm.warp(emergencyRecoveryValidAt);
95+
vault.executeEmergencyRecovery();
96+
assertEq(emergencyRecoveryValidAt, vault.emergencyRecoveryValidAt());
97+
vm.expectRevert(errorActive);
98+
vault.cancelEmergencyRecovery();
99+
}
100+
101+
function test_cancelBeforeExecute() public {
102+
vm.startPrank(owner);
103+
vault.scheduleEmergencyRecovery();
104+
vm.warp(vault.emergencyRecoveryValidAt());
105+
vault.cancelEmergencyRecovery();
106+
assertEq(vault.emergencyRecoveryValidAt(), 0, "recovery not scheduled");
107+
assertEq(vault.emergencyRecoveryActive(), false);
108+
}
109+
110+
function test_events() public {
111+
uint256 expectedValidAt = block.timestamp + vault.EMERGENCY_RECOVERY_DELAY();
112+
vm.expectEmit(true, false, false, true);
113+
emit IFCMVault.EmergencyRecoveryScheduled(expectedValidAt);
114+
vm.prank(owner);
115+
vault.scheduleEmergencyRecovery();
116+
vm.stopPrank();
117+
118+
vm.expectEmit(true, false, false, true);
119+
emit IFCMVault.EmergencyRecoveryCancelled();
120+
vm.prank(owner);
121+
vault.cancelEmergencyRecovery();
122+
vm.stopPrank();
123+
}
124+
125+
function test_happyPath() public {
126+
setCollateralPrice(10 ether);
127+
setYieldPrice(0.1 ether);
128+
uint256 originalCollateral = 1 ether;
129+
vault.depositFor(alice, originalCollateral);
130+
uint256 originalYield = YIELD_TOKEN.balanceOf(address(vault));
131+
assertNotEq(originalYield, 0, "originalYield should not be 0");
132+
133+
assertEq(COLLATERAL_TOKEN.balanceOf(address(owner)), 0);
134+
assertEq(YIELD_TOKEN.balanceOf(address(owner)), 0);
135+
assertEq(LOAN_TOKEN.balanceOf(address(owner)), 0);
136+
137+
vm.startPrank(owner);
138+
vault.scheduleEmergencyRecovery();
139+
140+
vm.warp(vault.emergencyRecoveryValidAt());
141+
LOAN_TOKEN.mint(owner, 1e10 ether);
142+
LOAN_TOKEN.approve(address(vault), type(uint256).max);
143+
MarketLib.repayAll(vault.market());
144+
145+
vm.recordLogs();
146+
vault.executeEmergencyRecovery();
147+
Vm.Log[] memory entries = vm.getRecordedLogs();
148+
149+
assertEq(COLLATERAL_TOKEN.balanceOf(address(vault)), 0);
150+
assertEq(YIELD_TOKEN.balanceOf(address(vault)), 0);
151+
assertEq(MarketLib.collateral(vault.market()), 0);
152+
assertEq(MarketLib.debt(vault.market()), 0);
153+
154+
uint256 collateralOut = COLLATERAL_TOKEN.balanceOf(owner);
155+
uint256 yieldOut = YIELD_TOKEN.balanceOf(owner);
156+
157+
assertEq(collateralOut, originalCollateral, "collateralOut should be originalCollateral");
158+
assertEq(yieldOut, originalYield, "yieldOut should be originalYield");
159+
160+
for (uint256 i = 0; i < entries.length; i++) {
161+
if (entries[i].topics[0] == IFCMVault.EmergencyRecoveryExecuted.selector) {
162+
(uint256 collateralOutRecorded, uint256 yieldOutRecorded, uint256 loanOutRecorded) =
163+
abi.decode(entries[i].data, (uint256, uint256, uint256));
164+
165+
assertEq(collateralOut, collateralOutRecorded);
166+
assertEq(yieldOut, yieldOutRecorded);
167+
assertEq(0, loanOutRecorded, "loanOutRecorded should be 0");
168+
return;
169+
}
170+
}
171+
revert("EmergencyRecoveryExecuted event not found");
172+
}
173+
174+
function test_unaccountedTokens() public {
175+
LOAN_TOKEN.mint(address(vault), 1 ether);
176+
COLLATERAL_TOKEN.mint(address(vault), 1 ether);
177+
YIELD_TOKEN.mint(address(vault), 1 ether);
178+
179+
vm.startPrank(owner);
180+
vault.scheduleEmergencyRecovery();
181+
vm.warp(vault.emergencyRecoveryValidAt());
182+
vault.executeEmergencyRecovery();
183+
184+
assertEq(LOAN_TOKEN.balanceOf(address(owner)), 1 ether, "loan not swept");
185+
assertEq(COLLATERAL_TOKEN.balanceOf(address(owner)), 1 ether, "collateral not swept");
186+
assertEq(YIELD_TOKEN.balanceOf(address(owner)), 1 ether, "yield not swept");
187+
}
188+
}

0 commit comments

Comments
 (0)