diff --git a/contracts/contracts/yield/YieldManager.sol b/contracts/contracts/yield/YieldManager.sol index 6198cf6563..b3b72ae99f 100644 --- a/contracts/contracts/yield/YieldManager.sol +++ b/contracts/contracts/yield/YieldManager.sol @@ -619,6 +619,47 @@ contract YieldManager is whenTypeAndGeneralNotPaused(PauseType.NATIVE_YIELD_UNSTAKING) onlyKnownYieldProvider(_yieldProvider) onlyRole(YIELD_PROVIDER_UNSTAKER_ROLE) + { + _withdrawFromYieldProvider(_yieldProvider, _amount); + } + + /** + * @notice Safely withdraws ETH from a YieldProvider, capped by the available withdrawable amount. + * @dev YIELD_PROVIDER_UNSTAKER_ROLE is required to execute. + * @dev This function behaves like {withdrawFromYieldProvider}, but ensures the requested `_amount` + * does not exceed the provider’s currently withdrawable value, preventing reverts due to + * over-withdrawal. + * @param _yieldProvider The yield provider address. + * @param _amount The desired amount to withdraw (subject to capping by the withdrawable value). + */ + function safeWithdrawFromYieldProvider( + address _yieldProvider, + uint256 _amount + ) + external + whenTypeAndGeneralNotPaused(PauseType.NATIVE_YIELD_UNSTAKING) + onlyKnownYieldProvider(_yieldProvider) + onlyRole(YIELD_PROVIDER_UNSTAKER_ROLE) + { + _withdrawFromYieldProvider(_yieldProvider, Math256.min(withdrawableValue(_yieldProvider), _amount)); + } + + /** + * @notice Withdraw ETH from a YieldProvider. + * @dev YIELD_PROVIDER_UNSTAKER_ROLE is required to execute. + * @dev This function proactively allocates withdrawn funds in the following priority: + * 1. If the withdrawal reserve is below the target threshold, ETH is routed to the reserve + * to restore the deficit. + * 2. If there is an outstanding LST liability, it will be paid. + * 3. YieldManager will keep the remainder. + * @param _yieldProvider The yield provider address. + * @param _amount Amount to withdraw. + */ + function _withdrawFromYieldProvider( + address _yieldProvider, + uint256 _amount + ) + internal { uint256 targetDeficit = getTargetReserveDeficit(); // Withdraw from Vault -> YieldManager diff --git a/contracts/test/yield/unit/YieldManager.funds.ts b/contracts/test/yield/unit/YieldManager.funds.ts index d666adeb81..b31eab452e 100644 --- a/contracts/test/yield/unit/YieldManager.funds.ts +++ b/contracts/test/yield/unit/YieldManager.funds.ts @@ -1002,6 +1002,95 @@ describe("YieldManager contract - ETH transfer operations", () => { }); }); + describe("safe withdraw from yield provider", () => { + it("Should revert when the GENERAL pause type is activated", async () => { + const { mockYieldProviderAddress } = await addMockYieldProvider(yieldManager); + + await yieldManager.connect(securityCouncil).pauseByType(GENERAL_PAUSE_TYPE); + + await expectRevertWithCustomError( + yieldManager, + yieldManager.connect(nativeYieldOperator).safeWithdrawFromYieldProvider(mockYieldProviderAddress, 1n), + "IsPaused", + [GENERAL_PAUSE_TYPE], + ); + }); + + it("Should revert when the NATIVE_YIELD_UNSTAKING pause type is activated", async () => { + const { mockYieldProviderAddress } = await addMockYieldProvider(yieldManager); + + await yieldManager.connect(securityCouncil).pauseByType(NATIVE_YIELD_UNSTAKING_PAUSE_TYPE); + + await expectRevertWithCustomError( + yieldManager, + yieldManager.connect(nativeYieldOperator).safeWithdrawFromYieldProvider(mockYieldProviderAddress, 1n), + "IsPaused", + [NATIVE_YIELD_UNSTAKING_PAUSE_TYPE], + ); + }); + + it("Should revert when unstaking from an unknown YieldProvider", async () => { + const unknownYieldProvider = ethers.Wallet.createRandom().address; + + await expectRevertWithCustomError( + yieldManager, + yieldManager.connect(nativeYieldOperator).safeWithdrawFromYieldProvider(unknownYieldProvider, 1n), + "UnknownYieldProvider", + ); + }); + + it("Should revert when the caller does not have YIELD_PROVIDER_UNSTAKER_ROLE role", async () => { + const { mockYieldProviderAddress } = await addMockYieldProvider(yieldManager); + const unstakerRole = await yieldManager.YIELD_PROVIDER_UNSTAKER_ROLE(); + + await expect( + yieldManager.connect(nonAuthorizedAccount).safeWithdrawFromYieldProvider(mockYieldProviderAddress, 1n), + ).to.be.revertedWith(buildAccessErrorMessage(nonAuthorizedAccount, unstakerRole)); + }); + + it("If _amount < withdrawableValue, should withdraw _amount", async () => { + const { mockYieldProviderAddress, mockYieldProvider } = await addMockYieldProvider(yieldManager); + const withdrawableAmount = ONE_ETHER * 2n; + const requestedAmount = ONE_ETHER; + + await fundYieldProviderForWithdrawal(yieldManager, mockYieldProvider, nativeYieldOperator, withdrawableAmount); + await setWithdrawalReserveToTarget(yieldManager); + expect(await yieldManager.getTargetReserveDeficit()).to.equal(0n); + + await expect( + yieldManager + .connect(nativeYieldOperator) + .safeWithdrawFromYieldProvider(mockYieldProviderAddress, requestedAmount), + ) + .to.emit(yieldManager, "YieldProviderWithdrawal") + .withArgs(mockYieldProviderAddress, requestedAmount, requestedAmount, 0n, 0n); + + expect(await yieldManager.userFunds(mockYieldProviderAddress)).to.equal(withdrawableAmount - requestedAmount); + expect(await yieldManager.userFundsInYieldProvidersTotal()).to.equal(withdrawableAmount - requestedAmount); + }); + + it("If withdrawableValue < _amount , should withdraw withdrawableValue", async () => { + const { mockYieldProviderAddress, mockYieldProvider } = await addMockYieldProvider(yieldManager); + const withdrawableAmount = ONE_ETHER; + const requestedAmount = withdrawableAmount * 2n; + + await fundYieldProviderForWithdrawal(yieldManager, mockYieldProvider, nativeYieldOperator, withdrawableAmount); + await setWithdrawalReserveToTarget(yieldManager); + expect(await yieldManager.getTargetReserveDeficit()).to.equal(0n); + + await expect( + yieldManager + .connect(nativeYieldOperator) + .safeWithdrawFromYieldProvider(mockYieldProviderAddress, requestedAmount), + ) + .to.emit(yieldManager, "YieldProviderWithdrawal") + .withArgs(mockYieldProviderAddress, withdrawableAmount, withdrawableAmount, 0n, 0n); + + expect(await yieldManager.userFunds(mockYieldProviderAddress)).to.equal(0n); + expect(await yieldManager.userFundsInYieldProvidersTotal()).to.equal(0n); + }); + }); + describe("adding to withdrawal reserve", () => { it("Should revert when the GENERAL pause type is activated", async () => { const { mockYieldProviderAddress } = await addMockYieldProvider(yieldManager);