diff --git a/src/MorphoAllocator.sol b/src/MorphoAllocator.sol index f4f5518..8b2fd21 100644 --- a/src/MorphoAllocator.sol +++ b/src/MorphoAllocator.sol @@ -61,7 +61,8 @@ contract MorphoAllocator is OwnableRoles, Initializable { /// @notice Emitted when `run` (unlock + deallocate + allocate + depositManager) succeeds. /// @param intentId The intent ID. - /// @param unlocked The amount of collateral credited to the intent by `unlock`. + /// @param unlocked The amount of collateral credited to the intent by `unlock`, deposited in + /// full via `Facility.depositManager`. /// @param allocateAdapter The destination adapter as passed to `run`; address(0) when no allocation /// was requested. Allocation is also skipped when `gatheredTotal` is 0. /// @param gatheredTotal The total gathered across all deallocation sources. Allocated into @@ -215,23 +216,25 @@ contract MorphoAllocator is OwnableRoles, Initializable { /// market (validating post-withdrawal utilisation) → `MorphoVaultV2.allocate` of the gathered /// total (skipped when `allocateAdapter == address(0)`) → `Facility.depositManager`. Any revert /// (slippage, utilisation, unexpected state) reverts the whole call so it can be retried. - /// `depositAmount` is the caller's choice and need not equal the measured `unlocked` amount. + /// The measured `unlocked` amount is deposited in full, so the intent is left with no + /// collateral dust regardless of unlock slippage or fund price movement. /// @param intentId The intent ID. /// @param deallocations Sources to gather liquidity from (markets and/or idle); see {Deallocation}. /// @param allocateAdapter Destination Morpho V1 Market adapter, or address(0) to skip allocation. /// @param allocateMarket Destination market identifier (ignored when `allocateAdapter == address(0)`). - /// @param depositAmount Collateral amount to deposit via `Facility.depositManager`. Independent - /// of the unlocked amount, so the caller may deposit less than was unlocked. + /// @param minAmountReceived Minimum amount that must be unlocked. Occupies the slot that previously + /// fixed the deposit amount, keeping the signature unchanged for callers. /// @param borrowAmount Amount to borrow via `Facility.depositManager`. /// @param useTarget True to use the intent's target asset as the PositionManager, /// false to use the deposit asset. - /// @param minSharesUnlocked Minimum amount that must be unlocked (slippage guard on unlock). + /// @param minSharesUnlocked Minimum amount that must be unlocked (slippage guard on unlock); the + /// stricter of it and `minAmountReceived` applies. function run( uint256 intentId, Deallocation[] calldata deallocations, address allocateAdapter, MarketParams calldata allocateMarket, - uint256 depositAmount, + uint256 minAmountReceived, uint256 borrowAmount, bool useTarget, uint256 minSharesUnlocked @@ -257,11 +260,12 @@ contract MorphoAllocator is OwnableRoles, Initializable { revert UnlockBalanceDecreased(intentId, balanceBefore, balanceAfter); } uint256 unlocked = balanceAfter - balanceBefore; - if (unlocked < minSharesUnlocked) revert SlippageExceeded(minSharesUnlocked, unlocked); + uint256 minUnlocked = minAmountReceived > minSharesUnlocked ? minAmountReceived : minSharesUnlocked; + if (unlocked < minUnlocked) revert SlippageExceeded(minUnlocked, unlocked); uint256 gatheredTotal = _rebalance($.morphoVault, deallocations, allocateAdapter, allocateMarket); - _facility.depositManager(intentId, depositAmount, borrowAmount, useTarget); + _facility.depositManager(intentId, unlocked, borrowAmount, useTarget); emit Allocated(intentId, unlocked, allocateAdapter, gatheredTotal, borrowAmount); } diff --git a/test/MorphoAllocator.t.sol b/test/MorphoAllocator.t.sol index 23fc7f5..c4e42c1 100644 --- a/test/MorphoAllocator.t.sol +++ b/test/MorphoAllocator.t.sol @@ -448,11 +448,12 @@ contract MorphoAllocatorTest is Test { assertTrue(dUseTarget); } - function test_run_depositAmountIndependentOfUnlocked() public { + function test_run_depositsUnlockedAmount() public { _configureIntentWithTargetPm(); facility.setUnlockMint(collateralToken, 1_000e6); // full unlock = 1_000e6 - // Deposit only 600e6 of the 1_000e6 unlocked; the event still reports the full unlocked amount. + // minAmountReceived (600e6) is below the 1_000e6 unlocked; the full unlocked amount is + // deposited, leaving no collateral dust on the intent. vm.expectEmit(true, false, false, true, address(allocator)); emit Allocated(INTENT_ID, 1_000e6, allocAdapter, 500e6, 700e6); @@ -460,7 +461,19 @@ contract MorphoAllocatorTest is Test { allocator.run(INTENT_ID, _deals(500e6, WAD), allocAdapter, targetMarket, 600e6, 700e6, true, 0); (, uint256 dDeposit,,) = facility.lastDepositManager(); - assertEq(dDeposit, 600e6, "depositManager uses the provided amount, not unlocked"); + assertEq(dDeposit, 1_000e6, "depositManager uses the measured unlocked amount"); + } + + function test_run_revertsWhenUnlockedBelowMinAmountReceived() public { + _configureIntentWithTargetPm(); + facility.setUnlockMint(collateralToken, 1_000e6); + + // minAmountReceived (1_100e6) is stricter than minSharesUnlocked (900e6) and above the + // 1_000e6 actually unlocked, so the unlock slippage guard trips. + vm.expectRevert(abi.encodeWithSelector(MorphoAllocator.SlippageExceeded.selector, 1_100e6, 1_000e6)); + vm.prank(executor); + allocator.run(INTENT_ID, _deals(500e6, WAD), allocAdapter, targetMarket, 1_100e6, 700e6, true, 900e6); + assertEq(facility.depositManagerCount(), 0, "depositManager not called"); } function test_run_happyPath_useDeposit_noRebalance() public { @@ -582,7 +595,7 @@ contract MorphoAllocatorTest is Test { vm.expectRevert(abi.encodeWithSelector(MorphoAllocator.SlippageExceeded.selector, 900e6, 800e6)); vm.prank(executor); - allocator.run(INTENT_ID, _deals(500e6, WAD), allocAdapter, targetMarket, 950e6, 700e6, true, 900e6); + allocator.run(INTENT_ID, _deals(500e6, WAD), allocAdapter, targetMarket, 0, 700e6, true, 900e6); assertEq(vault.deallocateCount(), 0, "rebalance not reached"); assertEq(vault.allocateCount(), 0, "allocate not called"); @@ -680,6 +693,8 @@ contract MorphoAllocatorTest is Test { vm.prank(executor); allocator.run(INTENT_ID, _noDeals(), address(0), targetMarket, actual, 0, true, minOut); assertEq(facility.depositManagerCount(), 1, "deposit on success"); + (, uint256 dDeposit,,) = facility.lastDepositManager(); + assertEq(dDeposit, actual, "deposit equals the unlocked amount"); } } }