diff --git a/README.md b/README.md index e8f36a59..8fab390b 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ These functions verify balances and authorize the caller to retrieve their accum   +## :gear: _Feature activation_ + +[Feature activation](./specs/feature_activation.md) + ## :page_facing_up: _Whitepaper_ [Whitepaper](https://ssv.network/wp-content/uploads/2025/01/SSV2.0-Based-Applications-Protocol-1.pdf) diff --git a/doc/feature_activation.md b/doc/feature_activation.md new file mode 100644 index 00000000..ffcd0d01 --- /dev/null +++ b/doc/feature_activation.md @@ -0,0 +1,100 @@ +# Feature Flags in StrategyManager + +## Purpose + +Introduce a compact, on‑chain mechanism to enable or disable selected features of the `StrategyManager` contract without a full redeploy. By packing multiple boolean toggles into a single `uint32`, the design: + +- Minimizes storage footprint and gas cost. +- Provides an upgrade‑friendly switchboard for safety (e.g., emergency pause). +- Centralizes feature management under DAO control. + +## Technical Explanation + +- **Storage**: A `uint32 disabledFeatures` field in `ProtocolStorageLib.Data`. +- **Bitmask Layout**: + - Bit 0 (LSB) → Slashing Disabled (`SLASHING_DISABLED = 1 << 0`) + - Bit 1 → Withdrawals Disabled (`WITHDRAWALS_DISABLED = 1 << 1`) + - Further bits reserved for future toggles. + +- **Checks**: Two internal functions in `StrategyManager`: + ```solidity + function _checkSlashingAllowed() internal view { + if (ProtocolStorageLib.load().disabledFeatures & SLASHING_DISABLED != 0) + revert SlashingDisabled(); + } + + function _checkWithdrawalsAllowed() internal view { + if (ProtocolStorageLib.load().disabledFeatures & WITHDRAWALS_DISABLED != 0) + revert WithdrawalsDisabled(); + } + ``` + - Called at the entry points of: + - `slash(...)` + - `proposeWithdrawal(...)` + - `finalizeWithdrawal(...)` + - `proposeWithdrawalETH(...)` + - `finalizeWithdrawalETH(...)` + +## Authorized Accounts + +- Only the **DAO (owner)** can update the entire `disabledFeatures` bitmask via: + ```solidity + function updateDisabledFeatures(uint32 disabledFeatures) external onlyOwner; + ``` +- No per-feature granularity: toggles are applied in bulk. + +## Current Features That Can Be Enabled/Disabled + +| Bit Position | Feature | Constant | Description | +|:------------:|:-------------------|:----------------------|:------------------------------------------| +| 0 | Slashing | `SLASHING_DISABLED` | Stops all calls to `slash(...)` | +| 1 | Withdrawals | `WITHDRAWALS_DISABLED`| Stops all withdrawal proposals and finalizations | + +## Usage & Examples + +1. **Disable slashing only**: + ```js + // binary: 0b...01 → 1 + SSVBasedApps.updateDisabledFeatures(1); + // `slash(...)` now reverts with `SlashingDisabled()`. + ``` +2. **Re-enable slashing, disable withdrawals**: + ```js + // binary: 0b...10 → 2 + SSVBasedApps.updateDisabledFeatures(2); + // `slash(...)` resumes; `proposeWithdrawal(...)` and `finalizeWithdrawal(...)` revert. + ``` +3. **Disable both**: + ```js + SSVBasedApps.updateDisabledFeatures(3); + ``` +4. **Full example:** + ```js + // bit-definitions + const SLASHING_DISABLED = 1 << 0; // 0b0001 + const WITHDRAWALS_DISABLED = 1 << 1; // 0b0010 + + // Suppose you want to disable only withdrawals: + let flags = 0; + flags |= WITHDRAWALS_DISABLED; // flags === 0b0010 + + // Later you decide to also disable slashing: + flags |= SLASHING_DISABLED; // flags === 0b0011 + + // To re-enable withdrawals but keep slashing disabled: + flags &= ~WITHDRAWALS_DISABLED; // flags === 0b0001 + + // Finally, send the update on-chain: + await SSVBasedApps.updateDisabledFeatures(flags); + ``` + +## Future Extensions + +- Reserve bits 2–31 for other purposes: + - Feature disabling + - Emergency pause (global) + + +--- +*This document outlines the bitmask‑driven feature gating mechanism for `StrategyManager`. It ensures rapid reaction to on‑chain emergencies and fine‑grained control over critical operations.* + diff --git a/scripts/DeployProxy.s.sol b/scripts/DeployProxy.s.sol index ad0e6bf8..99384295 100644 --- a/scripts/DeployProxy.s.sol +++ b/scripts/DeployProxy.s.sol @@ -33,7 +33,8 @@ contract DeployProxy is Script { obligationTimelockPeriod: 14 days, obligationExpireTime: 3 days, tokenUpdateTimelockPeriod: 14 days, - maxFeeIncrement: 500 + maxFeeIncrement: 500, + disabledFeatures: 0 }); bytes memory initData = abi.encodeWithSelector( diff --git a/src/core/SSVBasedApps.sol b/src/core/SSVBasedApps.sol index 34b0dd35..f17cdcef 100644 --- a/src/core/SSVBasedApps.sol +++ b/src/core/SSVBasedApps.sol @@ -116,6 +116,7 @@ contract SSVBasedApps is sp.obligationExpireTime = config.obligationExpireTime; sp.tokenUpdateTimelockPeriod = config.tokenUpdateTimelockPeriod; sp.maxShares = config.maxShares; + sp.disabledFeatures = config.disabledFeatures; emit MaxFeeIncrementSet(sp.maxFeeIncrement); } @@ -342,6 +343,12 @@ contract SSVBasedApps is _delegateTo(SSVCoreModules.SSV_PROTOCOL_MANAGER); } + function updateDisabledFeatures( + uint32 disabledFeatures + ) external onlyOwner { + _delegateTo(SSVCoreModules.SSV_PROTOCOL_MANAGER); + } + // ***************************** // ** Section: External Views ** // ***************************** @@ -554,6 +561,10 @@ contract SSVBasedApps is return ProtocolStorageLib.load().obligationExpireTime; } + function disabledFeatures() external view returns (uint32) { + return ProtocolStorageLib.load().disabledFeatures; + } + function tokenUpdateTimelockPeriod() external view returns (uint32) { return ProtocolStorageLib.load().tokenUpdateTimelockPeriod; } diff --git a/src/core/interfaces/IProtocolManager.sol b/src/core/interfaces/IProtocolManager.sol index fae64d56..65801c25 100644 --- a/src/core/interfaces/IProtocolManager.sol +++ b/src/core/interfaces/IProtocolManager.sol @@ -11,6 +11,7 @@ interface IProtocolManager { event StrategyMaxSharesUpdated(uint256 maxShares); event WithdrawalExpireTimeUpdated(uint32 withdrawalExpireTime); event WithdrawalTimelockPeriodUpdated(uint32 withdrawalTimelockPeriod); + event DisabledFeaturesUpdated(uint32 disabledFeatures); function updateFeeExpireTime(uint32 value) external; function updateFeeTimelockPeriod(uint32 value) external; diff --git a/src/core/interfaces/IStrategyManager.sol b/src/core/interfaces/IStrategyManager.sol index dfe61993..f2506d2e 100644 --- a/src/core/interfaces/IStrategyManager.sol +++ b/src/core/interfaces/IStrategyManager.sol @@ -188,7 +188,9 @@ interface IStrategyManager { error ObligationAlreadySet(); error ObligationHasNotBeenCreated(); error RequestTimeExpired(); + error SlashingDisabled(); error TimelockNotElapsed(); error TokenNotSupportedByBApp(address token); error WithdrawTransferFailed(); + error WithdrawalsDisabled(); } diff --git a/src/core/libraries/ProtocolStorageLib.sol b/src/core/libraries/ProtocolStorageLib.sol index 3ef850d1..50136616 100644 --- a/src/core/libraries/ProtocolStorageLib.sol +++ b/src/core/libraries/ProtocolStorageLib.sol @@ -13,6 +13,11 @@ library ProtocolStorageLib { uint32 obligationExpireTime; uint32 tokenUpdateTimelockPeriod; uint32 maxFeeIncrement; + // each bit, starting from the LSB, represents a DISABLED feature + // bit 0 = slashingDisabled + // bit 1 = withdrawalsDisabled + // ... + uint32 disabledFeatures; uint256 maxShares; } diff --git a/src/core/modules/ProtocolManager.sol b/src/core/modules/ProtocolManager.sol index 3c93f001..cb006f62 100644 --- a/src/core/modules/ProtocolManager.sol +++ b/src/core/modules/ProtocolManager.sol @@ -5,6 +5,9 @@ import { IProtocolManager } from "@ssv/src/core/interfaces/IProtocolManager.sol" import { ProtocolStorageLib } from "@ssv/src/core/libraries/ProtocolStorageLib.sol"; contract ProtocolManager is IProtocolManager { + uint32 private constant SLASHING_DISABLED = 1 << 0; + uint32 private constant WITHDRAWALS_DISABLED = 1 << 1; + function updateFeeTimelockPeriod(uint32 feeTimelockPeriod) external { ProtocolStorageLib.load().feeTimelockPeriod = feeTimelockPeriod; emit FeeTimelockPeriodUpdated(feeTimelockPeriod); @@ -61,4 +64,9 @@ contract ProtocolManager is IProtocolManager { ProtocolStorageLib.load().maxFeeIncrement = maxFeeIncrement; emit StrategyMaxFeeIncrementUpdated(maxFeeIncrement); } + + function updateDisabledFeatures(uint32 disabledFeatures) external { + ProtocolStorageLib.load().disabledFeatures = disabledFeatures; + emit DisabledFeaturesUpdated(disabledFeatures); + } } diff --git a/src/core/modules/StrategyManager.sol b/src/core/modules/StrategyManager.sol index 1e0a01c6..8f4af829 100644 --- a/src/core/modules/StrategyManager.sol +++ b/src/core/modules/StrategyManager.sol @@ -18,6 +18,9 @@ import { IBasedApp } from "@ssv/src/middleware/interfaces/IBasedApp.sol"; contract StrategyManager is ReentrancyGuardTransient, IStrategyManager { using SafeERC20 for IERC20; + uint32 private constant SLASHING_DISABLED = 1 << 0; + uint32 private constant WITHDRAWALS_DISABLED = 1 << 1; + /// @notice Checks if the caller is the strategy owner /// @param strategyId The ID of the strategy /// @param s The CoreStorageLib data @@ -255,6 +258,8 @@ contract StrategyManager is ReentrancyGuardTransient, IStrategyManager { address token, uint256 amount ) external { + _checkWithdrawalsAllowed(); + if (token == ETH_ADDRESS) revert InvalidToken(); _proposeWithdrawal(strategyId, token, amount); } @@ -266,6 +271,8 @@ contract StrategyManager is ReentrancyGuardTransient, IStrategyManager { uint32 strategyId, IERC20 token ) external nonReentrant { + _checkWithdrawalsAllowed(); + uint256 amount = _finalizeWithdrawal(strategyId, address(token)); token.safeTransfer(msg.sender, amount); @@ -283,12 +290,15 @@ contract StrategyManager is ReentrancyGuardTransient, IStrategyManager { /// @param strategyId The ID of the strategy. /// @param amount The amount of ETH to withdraw. function proposeWithdrawalETH(uint32 strategyId, uint256 amount) external { + _checkWithdrawalsAllowed(); _proposeWithdrawal(strategyId, ETH_ADDRESS, amount); } /// @notice Finalize the ETH withdrawal after the timelock period has passed. /// @param strategyId The ID of the strategy. function finalizeWithdrawalETH(uint32 strategyId) external nonReentrant { + _checkWithdrawalsAllowed(); + uint256 amount = _finalizeWithdrawal(strategyId, ETH_ADDRESS); payable(msg.sender).transfer(amount); @@ -742,6 +752,8 @@ contract StrategyManager is ReentrancyGuardTransient, IStrategyManager { uint32 percentage, bytes calldata data ) external nonReentrant { + _checkSlashingAllowed(); + ValidationLib.validatePercentageAndNonZero(percentage); CoreStorageLib.Data storage s = CoreStorageLib.load(); @@ -888,4 +900,21 @@ contract StrategyManager is ReentrancyGuardTransient, IStrategyManager { s.slashingFund[msg.sender][token] -= amount; } + + function _checkSlashingAllowed() internal view { + if ( + ProtocolStorageLib.load().disabledFeatures & SLASHING_DISABLED != 0 + ) { + revert SlashingDisabled(); + } + } + + function _checkWithdrawalsAllowed() internal view { + if ( + ProtocolStorageLib.load().disabledFeatures & WITHDRAWALS_DISABLED != + 0 + ) { + revert WithdrawalsDisabled(); + } + } } diff --git a/src/middleware/README.md b/src/middleware/README.md index 66bbed2a..785a6526 100644 --- a/src/middleware/README.md +++ b/src/middleware/README.md @@ -1,4 +1,4 @@ -# :construction_worker: :closed_lock_with_key: __Middleware Contracts__ +# :construction_worker: :closed_lock_with_key: **Middleware Contracts** ## Modules & Examples for BApp Development @@ -78,11 +78,10 @@ Ideal for BApps that want to avoid excessive reward costs. ## :page_facing_up: To Have: Examples -* **BLS Strategy Example**: Demonstrates how to implement and use the BLS verification module securely. +- **BLS Strategy Example**: Demonstrates how to implement and use the BLS verification module securely. -* **ECDSA Strategy Example**: Showcases a lighter alternative using ECDSA verification. +- **ECDSA Strategy Example**: Showcases a lighter alternative using ECDSA verification. -* **Capped Strategy Example**: Implements a BApp that limits deposits to 100k SSV. - -* **Whitelist Manager Example**: Uses whitelist-based permissions instead of owner-based control. +- **Capped Strategy Example**: Implements a BApp that limits deposits to 100k SSV. +- **Whitelist Manager Example**: Uses whitelist-based permissions instead of owner-based control. diff --git a/test/SSVBasedApps.t.sol b/test/SSVBasedApps.t.sol index 083aec83..6033dfe0 100644 --- a/test/SSVBasedApps.t.sol +++ b/test/SSVBasedApps.t.sol @@ -164,7 +164,8 @@ contract SSVBasedAppsTest is Setup, Ownable2StepUpgradeable { obligationTimelockPeriod: 14 days, obligationExpireTime: 3 days, tokenUpdateTimelockPeriod: 14 days, - maxShares: 1e50 + maxShares: 1e50, + disabledFeatures: 0 }); vm.expectRevert( abi.encodeWithSelector( @@ -194,7 +195,8 @@ contract SSVBasedAppsTest is Setup, Ownable2StepUpgradeable { obligationTimelockPeriod: 14 days, obligationExpireTime: 3 days, tokenUpdateTimelockPeriod: 14 days, - maxFeeIncrement: 10_001 + maxFeeIncrement: 10_001, + disabledFeatures: 0 }); vm.expectRevert( abi.encodeWithSelector( diff --git a/test/helpers/Setup.t.sol b/test/helpers/Setup.t.sol index 83ee86aa..53eb9fcc 100644 --- a/test/helpers/Setup.t.sol +++ b/test/helpers/Setup.t.sol @@ -110,7 +110,8 @@ contract Setup is Test { obligationTimelockPeriod: 14 days, obligationExpireTime: 3 days, tokenUpdateTimelockPeriod: 14 days, - maxShares: 1e50 + maxShares: 1e50, + disabledFeatures: 0 }); bytes memory data = abi.encodeWithSelector( diff --git a/test/modules/ProtocolManager.t.sol b/test/modules/ProtocolManager.t.sol index 134d8758..c9457716 100644 --- a/test/modules/ProtocolManager.t.sol +++ b/test/modules/ProtocolManager.t.sol @@ -5,6 +5,11 @@ import { Ownable2StepUpgradeable } from "@openzeppelin/contracts-upgradeable/acc import { ETH_ADDRESS } from "@ssv/src/core/libraries/ValidationLib.sol"; import { Setup } from "@ssv/test/helpers/Setup.t.sol"; +import { IProtocolManager } from "@ssv/src/core/interfaces/IProtocolManager.sol"; +import { IBasedAppManager } from "@ssv/src/core/interfaces/IBasedAppManager.sol"; +import { IStrategyManager } from "@ssv/src/core/interfaces/IStrategyManager.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { SSVBasedApps } from "@ssv/src/core/SSVBasedApps.sol"; contract ProtocolManagerTest is Setup, Ownable2StepUpgradeable { function testUpdateFeeTimelockPeriod() public { @@ -213,4 +218,120 @@ contract ProtocolManagerTest is Setup, Ownable2StepUpgradeable { ); proxiedManager.updateMaxFeeIncrement(501); } + + /// @notice By default, no features should be disabled + function testDefaultDisabledFeaturesIsZero() public { + assertEq( + proxiedManager.disabledFeatures(), + 0, + "default disabledFeatures should be zero" + ); + } + + /// @notice The initializer should respect `config.disabledFeatures` + function testInitializeDisabledFeaturesFromConfig() public { + // Override config in Setup + config.disabledFeatures = 3; // slashing & withdrawals disabled + + // Re-deploy a fresh proxy with the modified config + bytes memory initData = abi.encodeWithSelector( + implementation.initialize.selector, + address(OWNER), + IBasedAppManager(basedAppsManagerMod), + IStrategyManager(strategyManagerMod), + IProtocolManager(protocolManagerMod), + config + ); + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + initData + ); + SSVBasedApps proxiedManager = SSVBasedApps(payable(address(proxy))); + + // It should read back exactly what we set + assertEq( + proxiedManager.disabledFeatures(), + 3, + "initializer did not set disabledFeatures from config" + ); + } + + /// @notice Only the owner can update the feature mask + function testUpdateFeatureDisabledFlagsAsOwner() public { + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(2); + assertEq( + proxiedManager.disabledFeatures(), + 2, + "owner update of disabledFeatures failed" + ); + } + + /// @notice Updating the flags should emit DisabledFeaturesUpdated + function testEmitDisabledFeaturesUpdatedEvent() public { + vm.prank(OWNER); + vm.expectEmit(true, false, false, true); + emit IProtocolManager.DisabledFeaturesUpdated(5); + proxiedManager.updateDisabledFeatures(5); + } + + function testRevertUpdateDisabledFeaturesWithNonOwner() public { + vm.prank(ATTACKER); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUnauthorizedAccount.selector, + address(ATTACKER) + ) + ); + proxiedManager.updateDisabledFeatures(1); + } + + function testSetIndividualDisabledFeatureBits() public { + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(1 << 0); + assertEq( + proxiedManager.disabledFeatures(), + 1, + "slashingDisabled bit not set correctly" + ); + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(1 << 1); + assertEq( + proxiedManager.disabledFeatures(), + 2, + "withdrawalsDisabled bit not set correctly" + ); + } + + function testClearFlags() public { + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(3); + assertEq(proxiedManager.disabledFeatures(), 3, "mask precondition"); + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(0); + assertEq(proxiedManager.disabledFeatures(), 0, "flags not cleared"); + } + + function testCombinedFlags() public { + uint32 mask = (1 << 0) | (1 << 2) | (1 << 4); + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(mask); + assertEq( + proxiedManager.disabledFeatures(), + mask, + "combined mask mismatch" + ); + } + + function testOtherParamsUnaffectedByFeatureMask() public { + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(type(uint32).max); + vm.prank(OWNER); + proxiedManager.updateFeeTimelockPeriod(2 days); + assertEq( + proxiedManager.feeTimelockPeriod(), + 2 days, + "feeTimelockPeriod should update despite flags" + ); + } } diff --git a/test/modules/StrategyManager.t.sol b/test/modules/StrategyManager.t.sol index 1e27c20e..49f2f668 100644 --- a/test/modules/StrategyManager.t.sol +++ b/test/modules/StrategyManager.t.sol @@ -2359,4 +2359,172 @@ contract StrategyManagerTest is UtilsTest, BasedAppsManagerTest { newWithdrawalAmount ); } + + function testSlashWhenEnabled() public { + uint32 pct = proxiedManager.maxPercentage(); + // register & opt‐in + testStrategyOptInToBApp(pct); + // deposit 1 token + vm.startPrank(USER1); + erc20mock.approve(address(proxiedManager), 1); + proxiedManager.depositERC20(STRATEGY1, erc20mock, 1); + vm.stopPrank(); + // slash from bApp1 + vm.prank(address(bApp1)); + proxiedManager.slash( + STRATEGY1, + address(bApp1), + address(erc20mock), + 10_000, + "" + ); + // after slash, strategy balance should be zero + uint256 bal = proxiedManager.strategyTotalBalance( + STRATEGY1, + address(erc20mock) + ); + assertEq(bal, 0, "Strategy balance should have decreased by 1"); + } + + function testSlashRevertsWhenDisabled() public { + // disable slashing + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(1 << 0); + // now any slash call must revert + vm.prank(address(bApp1)); + vm.expectRevert( + abi.encodeWithSelector(IStrategyManager.SlashingDisabled.selector) + ); + proxiedManager.slash( + STRATEGY1, + address(bApp1), + address(erc20mock), + 10_000, + "" + ); + } + + function testSlashSucceedsAfterReenable() public { + // re‐enable slashing + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(0); + // prepare valid slash (opt-in + deposit) + uint32 pct = proxiedManager.maxPercentage(); + testStrategyOptInToBApp(pct); + vm.startPrank(USER1); + erc20mock.approve(address(proxiedManager), 1); + proxiedManager.depositERC20(STRATEGY1, erc20mock, 1); + vm.stopPrank(); + // should no longer revert + vm.prank(address(bApp1)); + proxiedManager.slash( + STRATEGY1, + address(bApp1), + address(erc20mock), + 10_000, + "" + ); + // confirm balance dropped + uint256 bal = proxiedManager.strategyTotalBalance( + STRATEGY1, + address(erc20mock) + ); + assertEq(bal, 0, "Slash should succeed once re-enabled"); + } + + function testProposeAndFinalizeWithdrawalWhenEnabled() public { + // set up a deposit + testCreateStrategyAndSingleDeposit(100); + // propose withdrawal + vm.prank(USER1); + proxiedManager.proposeWithdrawal(STRATEGY1, address(erc20mock), 50); + // fast-forward timelock + vm.warp(block.timestamp + proxiedManager.withdrawalTimelockPeriod()); + // finalize and check balances + vm.prank(USER1); + proxiedManager.finalizeWithdrawal(STRATEGY1, erc20mock); + uint256 remaining = proxiedManager.strategyAccountShares( + STRATEGY1, + USER1, + address(erc20mock) + ); + assertEq( + remaining, + 50, + "User should have 50 tokens left in the strategy" + ); + } + + function testProposeWithdrawalRevertsWhenDisabled() public { + // disable withdrawals (bit 1) + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(1 << 1); + + // attempt to propose an ERC20 withdrawal + vm.prank(USER1); + vm.expectRevert( + abi.encodeWithSelector( + IStrategyManager.WithdrawalsDisabled.selector + ) + ); + proxiedManager.proposeWithdrawal(STRATEGY1, address(erc20mock), 1); + } + + function testFinalizeWithdrawalRevertsWhenDisabled() public { + // first get a pending withdrawal + testProposeWithdrawalFromStrategy(); + + // disable withdrawals + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(1 << 1); + + // now finalize should revert + vm.warp(block.timestamp + proxiedManager.withdrawalTimelockPeriod()); + vm.prank(USER1); + vm.expectRevert( + abi.encodeWithSelector( + IStrategyManager.WithdrawalsDisabled.selector + ) + ); + proxiedManager.finalizeWithdrawal(STRATEGY1, erc20mock); + } + + function testProposeWithdrawalETHRevertsWhenDisabled() public { + // deposit some ETH first + testCreateStrategyETHAndDepositETH(); + + // disable withdrawals + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(1 << 1); + + // attempt to propose an ETH withdrawal + vm.prank(USER1); + vm.expectRevert( + abi.encodeWithSelector( + IStrategyManager.WithdrawalsDisabled.selector + ) + ); + proxiedManager.proposeWithdrawalETH(STRATEGY1, 1 ether); + } + + function testFinalizeWithdrawalETHRevertsWhenDisabled() public { + // get a pending ETH withdrawal + testProposeWithdrawalETHFromStrategy(0.5 ether); + + // disable withdrawals + vm.prank(OWNER); + proxiedManager.updateDisabledFeatures(1 << 1); + + // warp past timelock + vm.warp(block.timestamp + proxiedManager.withdrawalTimelockPeriod()); + + // now finalize should revert + vm.prank(USER1); + vm.expectRevert( + abi.encodeWithSelector( + IStrategyManager.WithdrawalsDisabled.selector + ) + ); + proxiedManager.finalizeWithdrawalETH(STRATEGY1); + } }