Skip to content

Commit 96a18e3

Browse files
authored
feat: add SyncAllocatorDeposit script (#196)
Fork of SyncDeposit that runs create + commit, then delegates the unlock, Morpho Vault V2 rebalancing, and depositManager steps to the deployed MorphoAllocator via IMorphoAllocator.run. Enables routing 100%-sync deposits through the allocator when MorphoFlashLoanRequest uses a sync-deposit script. - Add local IMorphoAllocator interface (grunt does not depend on facilitators) - Add MockMorphoAllocator test double + SyncAllocatorDeposit integration tests
1 parent 89cbfa0 commit 96a18e3

4 files changed

Lines changed: 360 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity ^0.8.22;
3+
4+
import {MarketParams} from "lib/morpho-blue/src/interfaces/IMorpho.sol";
5+
6+
/// @title IMorphoAllocator
7+
/// @author 3F Protocol
8+
/// @notice Local minimal interface for the externally-deployed MorphoAllocator facilitator.
9+
/// @dev The canonical contract lives in the facilitators repo; grunt does not depend on it, so only the
10+
/// `run` entrypoint and its `Deallocation` struct are mirrored here. The encoding is ABI-compatible
11+
/// because grunt's `MarketParams` is structurally identical to the facilitators copy. Keep this
12+
/// signature in sync with the deployed MorphoAllocator.
13+
interface IMorphoAllocator {
14+
/// @notice A single source from which `run` gathers liquidity before allocating the total.
15+
/// @param adapter Morpho V1 Market adapter to deallocate from, or address(0) for idle liquidity.
16+
/// @param marketParams Source market identifier, ignored when adapter is address(0).
17+
/// @param amount Assets to source from this entry; contributes to the allocated total.
18+
/// @param maxUtilisation Post-deallocation utilisation cap in WAD, ignored when adapter is address(0).
19+
struct Deallocation {
20+
address adapter;
21+
MarketParams marketParams;
22+
uint256 amount;
23+
uint256 maxUtilisation;
24+
}
25+
26+
/// @notice Unlocks a matured fund DEPOSIT order, rebalances Morpho Vault V2 liquidity, then runs
27+
/// depositManager and borrows.
28+
/// @param intentId The intent ID.
29+
/// @param deallocations Sources to gather liquidity from (markets and/or idle); see {Deallocation}.
30+
/// @param allocateAdapter Destination Morpho V1 Market adapter, or address(0) to skip allocation.
31+
/// @param allocateMarket Destination market, ignored when allocateAdapter is address(0).
32+
/// @param depositAmount Collateral amount to deposit via depositManager (caller's fixed choice).
33+
/// @param borrowAmount The amount to borrow via depositManager.
34+
/// @param useTarget Whether to use the target asset for the position manager deposit.
35+
/// @param minSharesUnlocked The minimum amount that must be unlocked (slippage guard on unlock).
36+
function run(
37+
uint256 intentId,
38+
Deallocation[] calldata deallocations,
39+
address allocateAdapter,
40+
MarketParams calldata allocateMarket,
41+
uint256 depositAmount,
42+
uint256 borrowAmount,
43+
bool useTarget,
44+
uint256 minSharesUnlocked
45+
) external;
46+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 {IMorphoAllocator} from "../../interfaces/request/IMorphoAllocator.sol";
6+
import {Mode} from "../../libs/funds/Order.sol";
7+
import {MarketParams} from "lib/morpho-blue/src/interfaces/IMorpho.sol";
8+
9+
/// @title SyncAllocatorDeposit
10+
/// @notice Stateless script designed to be delegatecalled from MorphoFlashLoanRequest.
11+
/// Executes a synchronous deposit flow: create → commit → allocator.run, delegating the
12+
/// unlock, Morpho Vault V2 rebalancing, and depositManager steps to the MorphoAllocator.
13+
/// @dev Must NOT have any storage variables. The fund must be set on the intent before execute().
14+
/// Runs under delegatecall, so allocator.run executes with msg.sender == MorphoFlashLoanRequest,
15+
/// which must hold EXECUTOR_ROLE on the allocator; the allocator must hold FACILITATOR_ROLE on the
16+
/// facility and be a whitelisted allocator on the Vault V2.
17+
/// @author 3F Protocol
18+
contract SyncAllocatorDeposit {
19+
/// @notice Parameters forwarded to IMorphoAllocator.run, grouped into a struct to keep run() under
20+
/// the ABI decoder stack limit (the project compiles without via-ir).
21+
/// @param deallocations Vault V2 liquidity sources to gather before allocating; see IMorphoAllocator.
22+
/// @param allocateAdapter Destination Morpho V1 Market adapter, or address(0) to skip allocation.
23+
/// @param allocateMarket Destination market, ignored when allocateAdapter is address(0).
24+
/// @param depositAmount Collateral amount to deposit via depositManager (caller's fixed choice).
25+
/// @param borrowAmount The amount to borrow via the position manager.
26+
/// @param useTarget Whether to use the target asset for the position manager deposit.
27+
/// @param minSharesUnlocked The minimum amount that must be unlocked (allocator unlock slippage guard).
28+
struct AllocatorParams {
29+
IMorphoAllocator.Deallocation[] deallocations;
30+
address allocateAdapter;
31+
MarketParams allocateMarket;
32+
uint256 depositAmount;
33+
uint256 borrowAmount;
34+
bool useTarget;
35+
uint256 minSharesUnlocked;
36+
}
37+
38+
/// @notice Executes the synchronous allocator-backed deposit flow for a facility intent.
39+
/// @param facility The facility contract address.
40+
/// @param allocator The deployed MorphoAllocator to delegate unlock and depositManager to.
41+
/// @param intentId The intent ID on the facility.
42+
/// @param fundDepositAmount The amount to deposit into the fund order.
43+
/// @param minSharesOut The minimum shares expected from the deposit.
44+
/// @param params The parameters forwarded to IMorphoAllocator.run.
45+
function run(
46+
address facility,
47+
address allocator,
48+
uint256 intentId,
49+
uint256 fundDepositAmount,
50+
uint256 minSharesOut,
51+
AllocatorParams calldata params
52+
) external {
53+
// 1. Create deposit order on fund
54+
IFacilityFunds(facility).create(intentId, fundDepositAmount, minSharesOut, Mode.DEPOSIT);
55+
56+
// 2. Commit order (facility sends tokens to fund)
57+
IFacilityFunds(facility).commit(intentId);
58+
59+
// 3. Delegate unlock, vault rebalance, and depositManager to the allocator
60+
IMorphoAllocator(allocator)
61+
.run(
62+
intentId,
63+
params.deallocations,
64+
params.allocateAdapter,
65+
params.allocateMarket,
66+
params.depositAmount,
67+
params.borrowAmount,
68+
params.useTarget,
69+
params.minSharesUnlocked
70+
);
71+
}
72+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity ^0.8.24;
3+
4+
import {IMorphoAllocator} from "../../../src/interfaces/request/IMorphoAllocator.sol";
5+
import {IFacilityFunds} from "../../../src/interfaces/facility/base/IFacilityFunds.sol";
6+
import {IFacilityPositionManager} from "../../../src/interfaces/facility/base/IFacilityPositionManager.sol";
7+
import {MarketParams} from "lib/morpho-blue/src/interfaces/IMorpho.sol";
8+
import {OwnableRoles} from "lib/solady/src/auth/OwnableRoles.sol";
9+
10+
/// @notice Test double for the externally-deployed MorphoAllocator. Implements IMorphoAllocator,
11+
/// enforces EXECUTOR_ROLE, and mirrors the facility side of the real `run` (unlock +
12+
/// depositManager). Vault V2 rebalancing is not modeled (grunt has no Vault V2 mocks); the
13+
/// rebalance params are recorded instead so tests can assert the script routed them through.
14+
contract MockMorphoAllocator is IMorphoAllocator, OwnableRoles {
15+
uint256 internal constant EXECUTOR_ROLE = _ROLE_0;
16+
17+
address public immutable facility;
18+
19+
uint256 public lastDeallocationsLength;
20+
address public lastAllocateAdapter;
21+
uint256 public lastDepositAmount;
22+
uint256 public lastBorrowAmount;
23+
bool public lastUseTarget;
24+
uint256 public lastMinSharesUnlocked;
25+
26+
constructor(address owner_, address facility_) {
27+
_initializeOwner(owner_);
28+
facility = facility_;
29+
}
30+
31+
function run(
32+
uint256 intentId,
33+
Deallocation[] calldata deallocations,
34+
address allocateAdapter,
35+
MarketParams calldata, // allocateMarket — vault rebalancing not modeled in grunt
36+
uint256 depositAmount,
37+
uint256 borrowAmount,
38+
bool useTarget,
39+
uint256 minSharesUnlocked
40+
) external onlyRoles(EXECUTOR_ROLE) {
41+
lastDeallocationsLength = deallocations.length;
42+
lastAllocateAdapter = allocateAdapter;
43+
lastDepositAmount = depositAmount;
44+
lastBorrowAmount = borrowAmount;
45+
lastUseTarget = useTarget;
46+
lastMinSharesUnlocked = minSharesUnlocked;
47+
48+
// Mirror the real allocator: unlock the matured order, then deposit + borrow via the PM.
49+
IFacilityFunds(facility).unlock(intentId);
50+
IFacilityPositionManager(facility).depositManager(intentId, depositAmount, borrowAmount, useTarget);
51+
}
52+
}

‎test/request/MorphoFlashLoanRequest.t.sol‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@ import {MorphoFlashLoanRequest} from "src/request/MorphoFlashLoanRequest.sol";
66
import {MorphoFlashLoanRequestFactory} from "src/request/MorphoFlashLoanRequestFactory.sol";
77
import {SyncDeposit} from "src/request/scripts/SyncDeposit.sol";
88
import {SyncWithdrawal} from "src/request/scripts/SyncWithdrawal.sol";
9+
import {SyncAllocatorDeposit} from "src/request/scripts/SyncAllocatorDeposit.sol";
10+
import {IMorphoAllocator} from "src/interfaces/request/IMorphoAllocator.sol";
11+
import {MockMorphoAllocator} from "test/mock/request/MockMorphoAllocator.sol";
912
import {Offer} from "src/interfaces/request/IOfferReceiver.sol";
1013
import {LibCommonErrors} from "src/libs/common/LibCommonErrors.sol";
1114
import {MockERC20} from "test/mock/MockERC20.sol";
1215
import {Mode, State} from "src/libs/funds/Order.sol";
1316
import {IntentProperties, Asset} from "src/libs/facility/LibIntent.sol";
1417
import {WithdrawalStrategy} from "src/interfaces/manager/base/IPositionManagerAdmin.sol";
18+
import {MarketParams} from "lib/morpho-blue/src/interfaces/IMorpho.sol";
1519

1620
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
1721
/* HELPER CONTRACTS */
@@ -102,6 +106,8 @@ contract MorphoFlashLoanRequestTest is FacilityBaseTest {
102106
MockScript public mockScript;
103107
SyncDeposit public syncDeposit;
104108
SyncWithdrawal public syncWithdrawal;
109+
SyncAllocatorDeposit public syncAllocatorDeposit;
110+
MockMorphoAllocator public mockAllocator;
105111

106112
address public executor = makeAddr("executor");
107113

@@ -141,6 +147,22 @@ contract MorphoFlashLoanRequestTest is FacilityBaseTest {
141147
vm.prank(owner);
142148
flashLoanRequest.setScript(address(syncWithdrawal), true);
143149

150+
// Deploy and whitelist SyncAllocatorDeposit
151+
syncAllocatorDeposit = new SyncAllocatorDeposit();
152+
vm.label(address(syncAllocatorDeposit), "SyncAllocatorDeposit");
153+
vm.prank(owner);
154+
flashLoanRequest.setScript(address(syncAllocatorDeposit), true);
155+
156+
// Deploy a MorphoAllocator test double and wire its roles:
157+
// - it holds FACILITATOR_ROLE on the facility (to unlock + depositManager)
158+
// - the flash loan request proxy holds EXECUTOR_ROLE on it (msg.sender under delegatecall)
159+
mockAllocator = new MockMorphoAllocator(owner, address(facility));
160+
vm.label(address(mockAllocator), "MockMorphoAllocator");
161+
vm.prank(owner);
162+
facility.grantRoles(address(mockAllocator), FACILITATOR_ROLE);
163+
vm.prank(owner);
164+
mockAllocator.grantRoles(address(flashLoanRequest), 1); // EXECUTOR_ROLE == _ROLE_0
165+
144166
// Create an intent with deposits and move to resolving phase
145167
intentId = _createIntentWithDeposits(DEFAULT_AMOUNT);
146168
}
@@ -605,6 +627,174 @@ contract MorphoFlashLoanRequestTest is FacilityBaseTest {
605627
assertEq(collateralToken.balanceOf(address(facility)), 0);
606628
}
607629

630+
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
631+
/* H1b. SYNC ALLOCATOR DEPOSIT INTEGRATION TEST */
632+
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
633+
634+
/// @dev Sets up a fresh targetPM intent in the resolving phase with the mock fund configured for a
635+
/// synchronous deposit (create → ACCEPTED, commit → UNLOCKING, unlock → ENDED). Returns the
636+
/// intent ID and the amount used for the fund deposit / shares / borrow.
637+
function _setupSyncAllocatorIntent() internal returns (uint256 syncIntentId, uint256 amount) {
638+
vm.prank(owner);
639+
syncIntentId = facility.createIntent(_intentParamsWithTargetPM());
640+
641+
amount = 1_000e18;
642+
_mintDebt(user, amount);
643+
vm.prank(user);
644+
facility.deposit(syncIntentId, amount);
645+
646+
// Move to resolving phase
647+
vm.warp(block.timestamp + 1 days + 1);
648+
649+
// Set the mock fund on the intent (requires facilitator + guardian sig)
650+
vm.prank(facilitator);
651+
_setFund(syncIntentId, address(mockFund));
652+
653+
mockFund.setNextCreateState(State.ACCEPTED);
654+
mockFund.setNextCommitState(State.UNLOCKING);
655+
mockFund.setNextUnlockState(State.ENDED);
656+
mockFund.setCommitAmount(amount);
657+
658+
// sharesFromFund == amount so the PM deposit nets to zero shares on the facility.
659+
mockFund.setUnlockAmount(amount);
660+
collateralToken.setBalance(address(mockFund), amount);
661+
}
662+
663+
function _emptyMarket() internal pure returns (MarketParams memory) {
664+
return MarketParams(address(0), address(0), address(0), address(0), 0);
665+
}
666+
667+
/// @dev Default empty-rebalance SyncAllocatorDeposit payload (deposit == borrow == amount, targetPM).
668+
function _syncAllocatorDepositPayload(uint256 _intentId, uint256 amount) internal view returns (bytes memory) {
669+
SyncAllocatorDeposit.AllocatorParams memory allocatorParams = SyncAllocatorDeposit.AllocatorParams({
670+
deallocations: new IMorphoAllocator.Deallocation[](0),
671+
allocateAdapter: address(0),
672+
allocateMarket: _emptyMarket(),
673+
depositAmount: amount,
674+
borrowAmount: amount,
675+
useTarget: true,
676+
minSharesUnlocked: 0
677+
});
678+
return abi.encodeCall(
679+
SyncAllocatorDeposit.run, (address(facility), address(mockAllocator), _intentId, amount, amount, allocatorParams)
680+
);
681+
}
682+
683+
function test_execute_syncAllocatorDepositFullCycle() public {
684+
(uint256 syncIntentId, uint256 amount) = _setupSyncAllocatorIntent();
685+
686+
MorphoFlashLoanRequest.SetRequestParams memory params =
687+
_buildSetRequestParams(syncIntentId, block.timestamp + 1 hours);
688+
689+
// Empty rebalance: no deallocations, allocation skipped (allocateAdapter == address(0)).
690+
// pmDepositAmount = borrowAmount = amount.
691+
SyncAllocatorDeposit.AllocatorParams memory allocatorParams = SyncAllocatorDeposit.AllocatorParams({
692+
deallocations: new IMorphoAllocator.Deallocation[](0),
693+
allocateAdapter: address(0),
694+
allocateMarket: _emptyMarket(),
695+
depositAmount: amount,
696+
borrowAmount: amount,
697+
useTarget: true, // targetPM
698+
minSharesUnlocked: 0
699+
});
700+
701+
bytes memory scriptPayload = abi.encodeCall(
702+
SyncAllocatorDeposit.run,
703+
(address(facility), address(mockAllocator), syncIntentId, amount, amount, allocatorParams)
704+
);
705+
706+
uint256 morphoBalBefore = debtToken.balanceOf(address(morpho));
707+
708+
vm.prank(executor);
709+
flashLoanRequest.execute(amount, params, address(syncAllocatorDeposit), scriptPayload);
710+
711+
// Verify: request removed from intent
712+
(,, address request,) = facility.getIntent(syncIntentId);
713+
assertEq(request, address(0));
714+
715+
// Verify: flash loan fully repaid (Morpho balance only decreased by borrow amount, not flash loan)
716+
assertEq(debtToken.balanceOf(address(morpho)), morphoBalBefore - amount);
717+
718+
// Verify: flash loan request has no leftover balance
719+
assertEq(debtToken.balanceOf(address(flashLoanRequest)), 0);
720+
721+
// Verify: collateral was deposited into PM (facility no longer holds it)
722+
assertEq(collateralToken.balanceOf(address(facility)), 0);
723+
724+
// Verify: the allocator received the params routed by the script
725+
assertEq(mockAllocator.lastDepositAmount(), amount);
726+
assertEq(mockAllocator.lastBorrowAmount(), amount);
727+
assertEq(mockAllocator.lastDeallocationsLength(), 0);
728+
assertEq(mockAllocator.lastAllocateAdapter(), address(0));
729+
assertTrue(mockAllocator.lastUseTarget());
730+
}
731+
732+
function test_execute_syncAllocatorDeposit_forwardsDeallocations() public {
733+
(uint256 syncIntentId, uint256 amount) = _setupSyncAllocatorIntent();
734+
735+
MorphoFlashLoanRequest.SetRequestParams memory params =
736+
_buildSetRequestParams(syncIntentId, block.timestamp + 1 hours);
737+
738+
// Non-empty rebalance: proves the calldata array survives delegatecall + encode/decode round-trip.
739+
IMorphoAllocator.Deallocation[] memory deallocations = new IMorphoAllocator.Deallocation[](1);
740+
deallocations[0] = IMorphoAllocator.Deallocation({
741+
adapter: address(0xADA9), marketParams: _emptyMarket(), amount: 123, maxUtilisation: 1e18
742+
});
743+
744+
SyncAllocatorDeposit.AllocatorParams memory allocatorParams = SyncAllocatorDeposit.AllocatorParams({
745+
deallocations: deallocations,
746+
allocateAdapter: address(0xBEEF),
747+
allocateMarket: _emptyMarket(),
748+
depositAmount: amount,
749+
borrowAmount: amount,
750+
useTarget: true,
751+
minSharesUnlocked: 0
752+
});
753+
754+
bytes memory scriptPayload = abi.encodeCall(
755+
SyncAllocatorDeposit.run,
756+
(address(facility), address(mockAllocator), syncIntentId, amount, amount, allocatorParams)
757+
);
758+
759+
vm.prank(executor);
760+
flashLoanRequest.execute(amount, params, address(syncAllocatorDeposit), scriptPayload);
761+
762+
assertEq(mockAllocator.lastDeallocationsLength(), 1);
763+
assertEq(mockAllocator.lastAllocateAdapter(), address(0xBEEF));
764+
}
765+
766+
function test_execute_syncAllocatorDeposit_revertsWhenRequestLacksExecutorRole() public {
767+
// Remove EXECUTOR_ROLE from the request proxy on the allocator.
768+
vm.prank(owner);
769+
mockAllocator.revokeRoles(address(flashLoanRequest), 1);
770+
771+
(uint256 syncIntentId, uint256 amount) = _setupSyncAllocatorIntent();
772+
773+
MorphoFlashLoanRequest.SetRequestParams memory params =
774+
_buildSetRequestParams(syncIntentId, block.timestamp + 1 hours);
775+
bytes memory scriptPayload = _syncAllocatorDepositPayload(syncIntentId, amount);
776+
777+
vm.prank(executor);
778+
vm.expectRevert();
779+
flashLoanRequest.execute(amount, params, address(syncAllocatorDeposit), scriptPayload);
780+
}
781+
782+
function test_execute_syncAllocatorDeposit_revertsWhenAllocatorLacksFacilitatorRole() public {
783+
// Remove FACILITATOR_ROLE from the allocator on the facility (so unlock reverts).
784+
vm.prank(owner);
785+
facility.revokeRoles(address(mockAllocator), FACILITATOR_ROLE);
786+
787+
(uint256 syncIntentId, uint256 amount) = _setupSyncAllocatorIntent();
788+
789+
MorphoFlashLoanRequest.SetRequestParams memory params =
790+
_buildSetRequestParams(syncIntentId, block.timestamp + 1 hours);
791+
bytes memory scriptPayload = _syncAllocatorDepositPayload(syncIntentId, amount);
792+
793+
vm.prank(executor);
794+
vm.expectRevert();
795+
flashLoanRequest.execute(amount, params, address(syncAllocatorDeposit), scriptPayload);
796+
}
797+
608798
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
609799
/* H2. SYNC WITHDRAWAL INTEGRATION TEST */
610800
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

0 commit comments

Comments
 (0)