Skip to content

Commit defb4e6

Browse files
committed
Add synchronous Midas withdrawal settlement script
1 parent 6998ffd commit defb4e6

2 files changed

Lines changed: 156 additions & 2 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity ^0.8.24;
3+
4+
import {IFacilityFunds} from "../../interfaces/facility/base/IFacilityFunds.sol";
5+
import {IFacilityIntents} from "../../interfaces/facility/base/IFacilityIntents.sol";
6+
import {IFacilityPositionManager} from "../../interfaces/facility/base/IFacilityPositionManager.sol";
7+
import {IPositionManager} from "../../interfaces/manager/IPositionManager.sol";
8+
import {WithdrawalStrategy} from "../../interfaces/manager/base/IPositionManagerAdmin.sol";
9+
import {Asset, IntentProperties} from "../../libs/facility/LibIntent.sol";
10+
import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol";
11+
12+
/// @title SyncMidasWithdrawal
13+
/// @notice Stateless script designed to be delegatecalled from MorphoFlashLoanRequest after a
14+
/// bonded Midas redeem has been unlocked for instant settlement.
15+
/// @dev Must NOT have any storage variables. The redeem order must already exist and its first
16+
/// commit must already have paid the bond. Repays enough live debt to restore the target LTV,
17+
/// withdraws the remaining redemption collateral, then executes second commit -> unlock;
18+
/// MorphoFlashLoanRequest supplies and repays the debt asset around this call.
19+
/// @author 3F Protocol
20+
contract SyncMidasWithdrawal {
21+
using FixedPointMathLib for uint256;
22+
23+
uint256 private constant _WAD = 1e18;
24+
25+
error InvalidPositionManager();
26+
error InvalidTargetLtv();
27+
error InvalidWithdrawAmount();
28+
error TargetLtvExceeded();
29+
30+
/// @notice Restores the target LTV, settles the remaining Midas redeem, and unlocks its proceeds.
31+
/// @param facility The facility contract address.
32+
/// @param intentId The intent ID on the facility.
33+
/// @param withdrawAmount The remaining collateral required by the Midas redemption.
34+
/// @param targetLtv The maximum LTV after the withdrawal, in WAD precision.
35+
/// @param useTarget Whether the intent's target asset is the position manager.
36+
function run(address facility, uint256 intentId, uint256 withdrawAmount, uint256 targetLtv, bool useTarget) external {
37+
if (targetLtv >= _WAD) revert InvalidTargetLtv();
38+
39+
(IntentProperties memory properties,,,) = IFacilityIntents(facility).getIntent(intentId);
40+
Asset memory managerAsset = useTarget ? properties.targetAsset : properties.depositAsset;
41+
if (!managerAsset.isPositionManager) revert InvalidPositionManager();
42+
43+
IPositionManager manager = IPositionManager(managerAsset.asset);
44+
uint256 collateral = manager.collateralAmount();
45+
if (withdrawAmount == 0 || withdrawAmount > collateral) revert InvalidWithdrawAmount();
46+
47+
uint256 quotedCollateral = manager.collateralAmountQuoted();
48+
uint256 quotedWithdrawal = quotedCollateral.mulDivUp(withdrawAmount, collateral);
49+
uint256 targetDebt = (quotedCollateral - quotedWithdrawal).mulDiv(targetLtv, _WAD);
50+
uint256 liveDebt = manager.debtAmount();
51+
uint256 repayAmount = liveDebt > targetDebt ? liveDebt - targetDebt : 0;
52+
53+
IFacilityPositionManager(facility)
54+
.withdrawManager(intentId, withdrawAmount, repayAmount, useTarget, WithdrawalStrategy.PROPORTIONAL);
55+
// A partial withdrawal queue can remove more quoted value than the aggregate estimate.
56+
if (manager.debtAmount() > manager.collateralAmountQuoted().mulDiv(targetLtv, _WAD)) revert TargetLtvExceeded();
57+
58+
IFacilityFunds(facility).commit(intentId);
59+
IFacilityFunds(facility).unlock(intentId);
60+
}
61+
}

‎test/request/MorphoFlashLoanRequest.t.sol‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pragma solidity ^0.8.24;
44
import {FacilityBaseTest} from "test/facility/FacilityBase.t.sol";
55
import {MorphoFlashLoanRequest} from "src/request/MorphoFlashLoanRequest.sol";
66
import {MorphoFlashLoanRequestFactory} from "src/request/MorphoFlashLoanRequestFactory.sol";
7+
import {SyncMidasWithdrawal} from "src/request/scripts/SyncMidasWithdrawal.sol";
78
import {SyncDeposit} from "src/request/scripts/SyncDeposit.sol";
89
import {SyncWithdrawal} from "src/request/scripts/SyncWithdrawal.sol";
910
import {SyncAllocatorDeposit} from "src/request/scripts/SyncAllocatorDeposit.sol";
@@ -12,6 +13,7 @@ import {MockMorphoAllocator} from "test/mock/request/MockMorphoAllocator.sol";
1213
import {Offer} from "src/interfaces/request/IOfferReceiver.sol";
1314
import {LibCommonErrors} from "src/libs/common/LibCommonErrors.sol";
1415
import {MockERC20} from "test/mock/MockERC20.sol";
16+
import {MockBorrowPosition} from "test/mock/borrow/MockBorrowPosition.sol";
1517
import {Mode, State} from "src/libs/funds/Order.sol";
1618
import {IntentProperties, Asset} from "src/libs/facility/LibIntent.sol";
1719
import {WithdrawalStrategy} from "src/interfaces/manager/base/IPositionManagerAdmin.sol";
@@ -105,6 +107,7 @@ contract MorphoFlashLoanRequestTest is FacilityBaseTest {
105107
MorphoFlashLoanRequest public flashLoanRequest;
106108
MockScript public mockScript;
107109
SyncDeposit public syncDeposit;
110+
SyncMidasWithdrawal public syncMidasWithdrawal;
108111
SyncWithdrawal public syncWithdrawal;
109112
SyncAllocatorDeposit public syncAllocatorDeposit;
110113
MockMorphoAllocator public mockAllocator;
@@ -141,6 +144,12 @@ contract MorphoFlashLoanRequestTest is FacilityBaseTest {
141144
vm.prank(owner);
142145
flashLoanRequest.setScript(address(syncDeposit), true);
143146

147+
// Deploy and whitelist SyncMidasWithdrawal
148+
syncMidasWithdrawal = new SyncMidasWithdrawal();
149+
vm.label(address(syncMidasWithdrawal), "SyncMidasWithdrawal");
150+
vm.prank(owner);
151+
flashLoanRequest.setScript(address(syncMidasWithdrawal), true);
152+
144153
// Deploy and whitelist SyncWithdrawal
145154
syncWithdrawal = new SyncWithdrawal();
146155
vm.label(address(syncWithdrawal), "SyncWithdrawal");
@@ -908,8 +917,8 @@ contract MorphoFlashLoanRequestTest is FacilityBaseTest {
908917
.execute(
909918
actualDebtBorrowed,
910919
MorphoFlashLoanRequest.SetRequestParams({
911-
intentId: syncIntentId, deadline: block.timestamp + 2 hours, signers: signers, signatures: sigs
912-
}),
920+
intentId: syncIntentId, deadline: block.timestamp + 2 hours, signers: signers, signatures: sigs
921+
}),
913922
address(syncWithdrawal),
914923
withdrawPayload
915924
);
@@ -937,6 +946,90 @@ contract MorphoFlashLoanRequestTest is FacilityBaseTest {
937946
assertEq(collateralToken.balanceOf(address(facility)), 0);
938947
}
939948

949+
function test_execute_syncMidasWithdrawalCommitUnlock() public {
950+
uint256 morphoBalBefore = debtToken.balanceOf(address(morpho));
951+
vm.prank(facilitator);
952+
facility.depositManager(intentId, 0, 5_000e18, false);
953+
954+
// A price drop makes the live LTV exceed the requested 50% target.
955+
oracle.setPrice(0.9e36);
956+
uint256 withdrawAmount = 1_000e18;
957+
uint256 repayAmount = 950e18; // 5,000 - (10,000 - 1,000) * 0.9 * 0.5
958+
vm.prank(facilitator);
959+
_setFund(intentId, address(mockFund));
960+
// MockFund represents the remaining redemption at the script's facility boundary.
961+
mockFund.setNextCommitState(State.UNLOCKING);
962+
mockFund.setUnlockAmount(withdrawAmount);
963+
debtToken.setBalance(address(mockFund), withdrawAmount);
964+
vm.prank(facilitator);
965+
facility.create(intentId, withdrawAmount, withdrawAmount, Mode.REDEEM);
966+
967+
vm.prank(executor);
968+
flashLoanRequest.execute(
969+
repayAmount,
970+
_buildSetRequestParams(intentId, block.timestamp + 1 hours),
971+
address(syncMidasWithdrawal),
972+
abi.encodeCall(SyncMidasWithdrawal.run, (address(facility), intentId, withdrawAmount, 0.5e18, false))
973+
);
974+
975+
assertEq(positionManager.collateralAmount(), 9_000e18);
976+
assertEq(positionManager.debtAmount(), 4_050e18);
977+
assertEq(positionManager.debtAmount() * 1e18 / positionManager.collateralAmountQuoted(), 0.5e18);
978+
assertEq(collateralToken.balanceOf(address(mockFund)), withdrawAmount);
979+
assertEq(collateralToken.balanceOf(address(facility)), 0);
980+
assertEq(debtToken.balanceOf(address(facility)), 5_050e18);
981+
assertEq(debtToken.balanceOf(address(morpho)), morphoBalBefore - 4_050e18);
982+
assertEq(debtToken.balanceOf(address(flashLoanRequest)), 0);
983+
(, address fund, address request,) = facility.getIntent(intentId);
984+
assertEq(fund, address(0));
985+
assertEq(request, address(0));
986+
}
987+
988+
function test_syncMidasWithdrawal_rejectsInvalidParameters() public {
989+
vm.expectRevert(SyncMidasWithdrawal.InvalidTargetLtv.selector);
990+
syncMidasWithdrawal.run(address(facility), intentId, 1, 1e18, false);
991+
vm.expectRevert(SyncMidasWithdrawal.InvalidPositionManager.selector);
992+
syncMidasWithdrawal.run(address(facility), intentId, 1, 0.5e18, true);
993+
vm.expectRevert(SyncMidasWithdrawal.InvalidWithdrawAmount.selector);
994+
syncMidasWithdrawal.run(address(facility), intentId, 0, 0.5e18, false);
995+
uint256 tooMuch = positionManager.collateralAmount() + 1;
996+
vm.expectRevert(SyncMidasWithdrawal.InvalidWithdrawAmount.selector);
997+
syncMidasWithdrawal.run(address(facility), intentId, tooMuch, 0.5e18, false);
998+
}
999+
1000+
function test_syncMidasWithdrawal_revertsWhenPartialQueueRemovesMoreQuotedCollateral() public {
1001+
vm.prank(facilitator);
1002+
facility.depositManager(intentId, 0, 4_500e18, false);
1003+
1004+
// This lower-priced module contributes to the aggregate quote but is excluded from withdrawals.
1005+
MockBorrowPosition excluded = new MockBorrowPosition(address(collateralToken), address(debtToken));
1006+
excluded.setTotalCollateral(10_000e18);
1007+
excluded.setTotalCollateralQuoted(5_000e18);
1008+
excluded.setSafeLtv(uint128(PM_LTV));
1009+
vm.mockCall(address(excluded), abi.encodeWithSignature("owner()"), abi.encode(address(positionManager)));
1010+
vm.prank(owner);
1011+
positionManager.addBorrowModule(address(excluded));
1012+
1013+
uint256 withdrawAmount = 1_000e18;
1014+
uint256 repayAmount = 225e18; // Aggregate quote estimates 750 removed; the queued position removes 1,000.
1015+
vm.prank(facilitator);
1016+
_setFund(intentId, address(mockFund));
1017+
mockFund.setNextCommitState(State.UNLOCKING);
1018+
mockFund.setUnlockAmount(withdrawAmount);
1019+
debtToken.setBalance(address(mockFund), withdrawAmount);
1020+
vm.prank(facilitator);
1021+
facility.create(intentId, withdrawAmount, withdrawAmount, Mode.REDEEM);
1022+
1023+
vm.prank(executor);
1024+
vm.expectRevert(SyncMidasWithdrawal.TargetLtvExceeded.selector);
1025+
flashLoanRequest.execute(
1026+
repayAmount,
1027+
_buildSetRequestParams(intentId, block.timestamp + 1 hours),
1028+
address(syncMidasWithdrawal),
1029+
abi.encodeCall(SyncMidasWithdrawal.run, (address(facility), intentId, withdrawAmount, 0.3e18, false))
1030+
);
1031+
}
1032+
9401033
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
9411034
/* I. pullFunds() TESTS */
9421035
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

0 commit comments

Comments
 (0)