Skip to content

Commit 2d4f3cd

Browse files
committed
feat: skip create in SyncWithdrawal when the redeem order already exists
1 parent 6998ffd commit 2d4f3cd

2 files changed

Lines changed: 120 additions & 6 deletions

File tree

‎src/request/scripts/SyncWithdrawal.sol‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,30 @@ import {IFacilityFunds} from "../../interfaces/facility/base/IFacilityFunds.sol"
55
import {IFacilityIntents} from "../../interfaces/facility/base/IFacilityIntents.sol";
66
import {IFacilityPositionManager} from "../../interfaces/facility/base/IFacilityPositionManager.sol";
77
import {IFund} from "../../interfaces/funds/IFund.sol";
8-
import {Mode} from "../../libs/funds/Order.sol";
8+
import {Order, Mode} from "../../libs/funds/Order.sol";
99
import {IERC20} from "../../interfaces/integrations/IERC20.sol";
1010
import {WithdrawalStrategy} from "../../interfaces/manager/base/IPositionManagerAdmin.sol";
1111

1212
/// @title SyncWithdrawal
1313
/// @notice Stateless script designed to be delegatecalled from MorphoFlashLoanRequest.
1414
/// Executes a synchronous withdrawal flow: withdrawManager → create → commit → unlock.
15+
/// If the intent already has an order (e.g. Midas, created ahead of time), create is skipped.
1516
/// @dev Must NOT have any storage variables. The fund must be set on the intent before execute().
1617
/// The share token is queried on-chain from the intent's fund to measure actual collateral received.
1718
/// @author 3F Protocol
1819
contract SyncWithdrawal {
20+
/// @notice Thrown when the intent's existing order is not a redeem.
21+
/// @param intentId The intent ID.
22+
error NotRedeemOrder(uint256 intentId);
23+
1924
/// @notice Executes the synchronous withdrawal flow for a facility intent.
2025
/// @param facility The facility contract address.
2126
/// @param intentId The intent ID on the facility.
2227
/// @param withdrawAmount The amount of collateral to withdraw from the position manager.
2328
/// @param repayAmount The amount of debt to repay to the position manager.
2429
/// @param useTarget Whether to use the target asset for the position manager withdrawal.
2530
/// @param strategy The withdrawal strategy (SEQUENTIAL or PROPORTIONAL).
26-
/// @param minAssetsOut The minimum amount of assets expected from the redemption.
31+
/// @param minAssetsOut The minimum amount of assets expected from the redemption (ignored if the order exists).
2732
function run(
2833
address facility,
2934
uint256 intentId,
@@ -46,8 +51,13 @@ contract SyncWithdrawal {
4651
// 3. Measure actual collateral received
4752
uint256 collateralReceived = IERC20(shareToken).balanceOf(facility) - collateralBefore;
4853

49-
// 4. Create redeem order using actual received collateral
50-
IFacilityFunds(facility).create(intentId, collateralReceived, minAssetsOut, Mode.REDEEM);
54+
// 4. Create redeem order using actual received collateral, unless one already exists (e.g. Midas)
55+
(Order memory order,) = IFacilityFunds(facility).getOrder(intentId);
56+
if (order.owner == address(0)) {
57+
IFacilityFunds(facility).create(intentId, collateralReceived, minAssetsOut, Mode.REDEEM);
58+
} else if (order.mode != Mode.REDEEM) {
59+
revert NotRedeemOrder(intentId);
60+
}
5161

5262
// 5. Commit order (facility sends shares/collateral to fund)
5363
IFacilityFunds(facility).commit(intentId);

‎test/request/MorphoFlashLoanRequest.t.sol‎

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {MockMorphoAllocator} from "test/mock/request/MockMorphoAllocator.sol";
1212
import {Offer} from "src/interfaces/request/IOfferReceiver.sol";
1313
import {LibCommonErrors} from "src/libs/common/LibCommonErrors.sol";
1414
import {MockERC20} from "test/mock/MockERC20.sol";
15-
import {Mode, State} from "src/libs/funds/Order.sol";
15+
import {Order, Mode, State} from "src/libs/funds/Order.sol";
1616
import {IntentProperties, Asset} from "src/libs/facility/LibIntent.sol";
1717
import {WithdrawalStrategy} from "src/interfaces/manager/base/IPositionManagerAdmin.sol";
1818
import {MarketParams} from "lib/morpho-blue/src/interfaces/IMorpho.sol";
@@ -937,7 +937,111 @@ contract MorphoFlashLoanRequestTest is FacilityBaseTest {
937937
assertEq(collateralToken.balanceOf(address(facility)), 0);
938938
}
939939

940-
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
940+
/// @dev Midas flow: the redeem order is created ahead of time, so SyncWithdrawal must skip create().
941+
function testFuzz_execute_syncWithdrawalSkipsCreateWhenOrderExists(bool proportional) public {
942+
uint256 morphoBalBefore = debtToken.balanceOf(address(morpho));
943+
vm.prank(facilitator);
944+
facility.depositManager(intentId, 0, 5_000e18, false);
945+
946+
uint256 withdrawAmount = 1_000e18;
947+
uint256 repayAmount = 500e18;
948+
WithdrawalStrategy strategy = proportional ? WithdrawalStrategy.PROPORTIONAL : WithdrawalStrategy.SEQUENTIAL;
949+
vm.prank(facilitator);
950+
_setFund(intentId, address(mockFund));
951+
mockFund.setNextCommitState(State.UNLOCKING);
952+
mockFund.setUnlockAmount(withdrawAmount);
953+
debtToken.setBalance(address(mockFund), withdrawAmount);
954+
vm.prank(facilitator);
955+
facility.create(intentId, withdrawAmount, withdrawAmount, Mode.REDEEM);
956+
957+
vm.prank(executor);
958+
flashLoanRequest.execute(
959+
repayAmount,
960+
_buildSetRequestParams(intentId, block.timestamp + 1 hours),
961+
address(syncWithdrawal),
962+
abi.encodeCall(SyncWithdrawal.run, (address(facility), intentId, withdrawAmount, repayAmount, false, strategy, 0))
963+
);
964+
965+
assertEq(positionManager.collateralAmount(), 9_000e18);
966+
assertEq(positionManager.debtAmount(), 4_500e18);
967+
assertEq(collateralToken.balanceOf(address(mockFund)), withdrawAmount);
968+
assertEq(collateralToken.balanceOf(address(facility)), 0);
969+
assertEq(debtToken.balanceOf(address(facility)), 5_500e18);
970+
assertEq(debtToken.balanceOf(address(morpho)), morphoBalBefore - 4_500e18);
971+
assertEq(debtToken.balanceOf(address(flashLoanRequest)), 0);
972+
(, address fund, address request,) = facility.getIntent(intentId);
973+
assertEq(fund, address(0));
974+
assertEq(request, address(0));
975+
}
976+
977+
/// @dev A canceled order is deleted from the intent, so SyncWithdrawal creates a fresh one.
978+
function test_execute_syncWithdrawalCreatesAfterCanceledOrder() public {
979+
vm.prank(facilitator);
980+
facility.depositManager(intentId, 0, 5_000e18, false);
981+
982+
uint256 withdrawAmount = 1_000e18;
983+
uint256 repayAmount = 500e18;
984+
vm.prank(facilitator);
985+
_setFund(intentId, address(mockFund));
986+
vm.prank(facilitator);
987+
facility.create(intentId, 1, 1, Mode.REDEEM);
988+
vm.prank(facilitator);
989+
facility.cancel(intentId);
990+
(Order memory canceled,) = facility.getOrder(intentId);
991+
assertEq(canceled.owner, address(0));
992+
993+
// Cancel also cleared the fund: re-set it (fresh deadline, the first signature digest is used)
994+
address[] memory fundSigners = new address[](1);
995+
bytes[] memory fundSigs = new bytes[](1);
996+
uint256 fundDeadline = block.timestamp + 3 hours;
997+
fundSigners[0] = guardian;
998+
fundSigs[0] = _signSetFund(intentId, address(mockFund), fundDeadline, GUARDIAN_PK);
999+
vm.prank(facilitator);
1000+
_setFund(intentId, address(mockFund), fundDeadline, fundSigners, fundSigs);
1001+
mockFund.setNextCommitState(State.UNLOCKING);
1002+
mockFund.setUnlockAmount(withdrawAmount);
1003+
debtToken.setBalance(address(mockFund), withdrawAmount);
1004+
1005+
vm.prank(executor);
1006+
flashLoanRequest.execute(
1007+
repayAmount,
1008+
_buildSetRequestParams(intentId, block.timestamp + 1 hours),
1009+
address(syncWithdrawal),
1010+
abi.encodeCall(
1011+
SyncWithdrawal.run,
1012+
(address(facility), intentId, withdrawAmount, repayAmount, false, WithdrawalStrategy.SEQUENTIAL, 0)
1013+
)
1014+
);
1015+
1016+
// The fresh order consumed the full withdrawal (the canceled order's input was 1)
1017+
assertEq(collateralToken.balanceOf(address(mockFund)), withdrawAmount);
1018+
assertEq(collateralToken.balanceOf(address(facility)), 0);
1019+
assertEq(debtToken.balanceOf(address(facility)), 5_500e18);
1020+
}
1021+
1022+
/// @dev An existing DEPOSIT order must not be committed as part of a withdrawal.
1023+
function test_execute_syncWithdrawalRevertsWhenExistingOrderIsDeposit() public {
1024+
vm.prank(facilitator);
1025+
facility.depositManager(intentId, 0, 5_000e18, false);
1026+
1027+
vm.prank(facilitator);
1028+
_setFund(intentId, address(mockFund));
1029+
vm.prank(facilitator);
1030+
facility.create(intentId, 1_000e18, 1_000e18, Mode.DEPOSIT);
1031+
1032+
vm.prank(executor);
1033+
vm.expectRevert(abi.encodeWithSelector(SyncWithdrawal.NotRedeemOrder.selector, intentId));
1034+
flashLoanRequest.execute(
1035+
500e18,
1036+
_buildSetRequestParams(intentId, block.timestamp + 1 hours),
1037+
address(syncWithdrawal),
1038+
abi.encodeCall(
1039+
SyncWithdrawal.run, (address(facility), intentId, 1_000e18, 500e18, false, WithdrawalStrategy.SEQUENTIAL, 0)
1040+
)
1041+
);
1042+
}
1043+
1044+
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
9411045
/* I. pullFunds() TESTS */
9421046
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
9431047

0 commit comments

Comments
 (0)