From ba608e4666556c1801affd3c6d89526d1511aa96 Mon Sep 17 00:00:00 2001 From: Schlagonia Date: Fri, 22 May 2026 21:08:02 -0600 Subject: [PATCH] feat: add strategy pause control --- src/TokenizedStrategy.sol | 78 +++++- src/interfaces/IEvents.sol | 5 + src/interfaces/ITokenizedStrategy.sol | 6 + src/libraries/TokenizedStrategyLib.sol | 7 +- src/test/Pause.t.sol | 336 +++++++++++++++++++++++ src/test/Shutdown.t.sol | 2 +- src/test/TokenizedStrategyLibViews.t.sol | 5 + src/test/mocks/MockStorage.sol | 3 +- src/test/mocks/MockStrategy.sol | 4 + 9 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 src/test/Pause.t.sol diff --git a/src/TokenizedStrategy.sol b/src/TokenizedStrategy.sol index 5fa39c0..a9ce9a0 100644 --- a/src/TokenizedStrategy.sol +++ b/src/TokenizedStrategy.sol @@ -83,6 +83,11 @@ contract TokenizedStrategy { */ event StrategyShutdown(); + /** + * @notice Emitted when a strategies paused status is updated. + */ + event UpdatePaused(bool paused); + /** * @notice Emitted on the initialization of any new `strategy` that uses `asset` * with this specific `apiVersion`. @@ -253,8 +258,9 @@ contract TokenizedStrategy { // Strategy Status uint8 entered; // To prevent reentrancy. Use uint8 for gas savings. bool shutdown; // Bool that can be used to stop deposits into the strategy. + bool paused; // Bool that can be used to stop user facing 4626 functions. - uint80 lastAccrual; // The last time accounting synced. + uint72 lastAccrual; // The last time accounting synced. } /*////////////////////////////////////////////////////////////// @@ -305,6 +311,14 @@ contract TokenizedStrategy { S.entered = NOT_ENTERED; } + /** + * @dev Require that the strategy is not paused. + */ + modifier whenNotPaused() { + require(!_strategyStorage().paused, "paused"); + _; + } + /** * @notice Require a caller is `management`. * @dev Is left public so that it can be used by the Strategy. @@ -474,7 +488,7 @@ contract TokenizedStrategy { // Initialize both timestamps to the deployment block. S.lastReport = uint96(block.timestamp); // -1 to not allow first deposit inflation - S.lastAccrual = uint80(block.timestamp - 1); + S.lastAccrual = uint72(block.timestamp - 1); // Set the default management address. Can't be 0. require(_management != address(0), "ZERO ADDRESS"); @@ -500,7 +514,7 @@ contract TokenizedStrategy { function deposit( uint256 assets, address receiver - ) external nonReentrant returns (uint256 shares) { + ) external whenNotPaused nonReentrant returns (uint256 shares) { // Get the storage slot for all following calls. StrategyData storage S = _strategyStorage(); _accrue(S); @@ -534,7 +548,7 @@ contract TokenizedStrategy { function mint( uint256 shares, address receiver - ) external nonReentrant returns (uint256 assets) { + ) external whenNotPaused nonReentrant returns (uint256 assets) { // Get the storage slot for all following calls. StrategyData storage S = _strategyStorage(); _accrue(S); @@ -582,7 +596,7 @@ contract TokenizedStrategy { address receiver, address owner, uint256 maxLoss - ) public nonReentrant returns (uint256 shares) { + ) public whenNotPaused nonReentrant returns (uint256 shares) { // Get the storage slot for all following calls. StrategyData storage S = _strategyStorage(); _accrue(S); @@ -634,7 +648,7 @@ contract TokenizedStrategy { address receiver, address owner, uint256 maxLoss - ) public nonReentrant returns (uint256) { + ) public whenNotPaused nonReentrant returns (uint256) { // Get the storage slot for all following calls. StrategyData storage S = _strategyStorage(); _accrue(S); @@ -961,8 +975,8 @@ contract TokenizedStrategy { StrategyData storage S, address receiver ) internal view returns (uint256) { - // Cannot deposit when shutdown or to the strategy. - if (S.shutdown || receiver == address(this)) return 0; + // Cannot deposit when shutdown, paused or to the strategy. + if (S.shutdown || S.paused || receiver == address(this)) return 0; return IBaseStrategy(address(this)).availableDepositLimit(receiver); } @@ -972,8 +986,8 @@ contract TokenizedStrategy { StrategyData storage S, address receiver ) internal view returns (uint256 maxMint_) { - // Cannot mint when shutdown or to the strategy. - if (S.shutdown || receiver == address(this)) return 0; + // Cannot mint when shutdown, paused or to the strategy. + if (S.shutdown || S.paused || receiver == address(this)) return 0; maxMint_ = IBaseStrategy(address(this)).availableDepositLimit(receiver); if (maxMint_ != type(uint256).max) { @@ -986,6 +1000,9 @@ contract TokenizedStrategy { StrategyData storage S, address owner ) internal view returns (uint256 maxWithdraw_) { + // Cannot withdraw when paused. + if (S.paused) return 0; + // Get the max the owner could withdraw currently. maxWithdraw_ = IBaseStrategy(address(this)).availableWithdrawLimit( owner @@ -1012,6 +1029,9 @@ contract TokenizedStrategy { StrategyData storage S, address owner ) internal view returns (uint256 maxRedeem_) { + // Cannot redeem when paused. + if (S.paused) return 0; + // Get the max the owner could withdraw currently. maxRedeem_ = IBaseStrategy(address(this)).availableWithdrawLimit(owner); @@ -1181,7 +1201,7 @@ contract TokenizedStrategy { } S.lastTotalAssets = newTotalAssets; - S.lastAccrual = uint80(block.timestamp); + S.lastAccrual = uint72(block.timestamp); emit Accrued(profit, loss, protocolFees, totalFees - protocolFees); } @@ -1535,10 +1555,30 @@ contract TokenizedStrategy { emit StrategyShutdown(); } + /** + * @notice Used to set the pause status for user facing 4626 functions. + * @dev Pausing can be called by the current `management` or `emergencyAdmin`. + * Unpausing can only be called by the current `management`. + * + * This will stop {deposit}, {mint}, {withdraw} and {redeem}, but will + * leave management functions live so the strategy can still be tended, + * reported, configured, shutdown or manually withdrawn in an emergency. + */ + function setPaused(bool paused) external { + if (paused) { + requireEmergencyAuthorized(msg.sender); + } else { + requireManagement(msg.sender); + } + _strategyStorage().paused = paused; + + emit UpdatePaused(paused); + } + /** * @notice To manually withdraw funds from the yield source after a * strategy has been shutdown. - * @dev This can only be called post {shutdownStrategy}. + * @dev This can only be called when the strategy is paused or shutdown. * * This will never cause a change in PPS. Total assets will * be the same before and after. @@ -1551,8 +1591,10 @@ contract TokenizedStrategy { function emergencyWithdraw( uint256 amount ) external nonReentrant onlyEmergencyAuthorized { - // Make sure the strategy has been shutdown. - require(_strategyStorage().shutdown, "not shutdown"); + StrategyData storage S = _strategyStorage(); + + // Make sure the strategy has been paused or shutdown. + require(S.paused || S.shutdown, "not paused or shutdown"); // Withdraw from the yield source. IBaseStrategy(address(this)).shutdownWithdraw(amount); @@ -1701,6 +1743,14 @@ contract TokenizedStrategy { return _strategyStorage().shutdown; } + /** + * @notice To check if the strategy has been paused. + * @return . Whether or not the strategy is paused. + */ + function isPaused() external view returns (bool) { + return _strategyStorage().paused; + } + /*////////////////////////////////////////////////////////////// SETTER FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/src/interfaces/IEvents.sol b/src/interfaces/IEvents.sol index bc6d1fb..9aac8f5 100644 --- a/src/interfaces/IEvents.sol +++ b/src/interfaces/IEvents.sol @@ -11,6 +11,11 @@ interface IEvents { */ event StrategyShutdown(); + /** + * @notice Emitted when a strategies paused status is updated. + */ + event UpdatePaused(bool paused); + /** * @notice Emitted on the initialization of any new `strategy` that uses `asset` * with this specific `apiVersion`. diff --git a/src/interfaces/ITokenizedStrategy.sol b/src/interfaces/ITokenizedStrategy.sol index ea5f9db..ca709d3 100644 --- a/src/interfaces/ITokenizedStrategy.sol +++ b/src/interfaces/ITokenizedStrategy.sol @@ -13,6 +13,8 @@ interface ITokenizedStrategy is IERC4626, IERC20Permit { event StrategyShutdown(); + event UpdatePaused(bool paused); + event NewTokenizedStrategy( address indexed strategy, address indexed asset, @@ -149,6 +151,8 @@ interface ITokenizedStrategy is IERC4626, IERC20Permit { function isShutdown() external view returns (bool); + function isPaused() external view returns (bool); + function unlockedShares() external view returns (uint256); /*////////////////////////////////////////////////////////////// @@ -175,5 +179,7 @@ interface ITokenizedStrategy is IERC4626, IERC20Permit { function shutdownStrategy() external; + function setPaused(bool paused) external; + function emergencyWithdraw(uint256 _amount) external; } diff --git a/src/libraries/TokenizedStrategyLib.sol b/src/libraries/TokenizedStrategyLib.sol index fba706b..ef0d2a6 100644 --- a/src/libraries/TokenizedStrategyLib.sol +++ b/src/libraries/TokenizedStrategyLib.sol @@ -31,7 +31,8 @@ library TokenizedStrategyLib { address emergencyAdmin; uint8 entered; bool shutdown; - uint80 lastAccrual; + bool paused; + uint72 lastAccrual; } function strategyStorage() internal pure returns (StrategyData storage S) { @@ -126,6 +127,10 @@ library TokenizedStrategyLib { return strategyStorage().shutdown; } + function isPaused() internal view returns (bool) { + return strategyStorage().paused; + } + function totalAssets() internal view returns (uint256) { return ITokenizedStrategy(address(this)).totalAssets(); } diff --git a/src/test/Pause.t.sol b/src/test/Pause.t.sol new file mode 100644 index 0000000..f124ddb --- /dev/null +++ b/src/test/Pause.t.sol @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.18; + +import {Setup} from "./utils/Setup.sol"; + +contract PauseTest is Setup { + bytes32 internal constant PERMIT_TYPEHASH = + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + + function setUp() public override { + super.setUp(); + } + + function test_pauseAccessControl(address _address) public { + vm.assume(_address != management && _address != emergencyAdmin); + + assertTrue(!strategy.isPaused()); + + vm.prank(_address); + vm.expectRevert("!emergency authorized"); + strategy.setPaused(true); + + vm.expectEmit(true, true, true, true, address(strategy)); + emit UpdatePaused(true); + + vm.prank(management); + strategy.setPaused(true); + + assertTrue(strategy.isPaused()); + + vm.prank(_address); + vm.expectRevert("!management"); + strategy.setPaused(false); + + vm.prank(emergencyAdmin); + vm.expectRevert("!management"); + strategy.setPaused(false); + + vm.expectEmit(true, true, true, true, address(strategy)); + emit UpdatePaused(false); + + vm.prank(management); + strategy.setPaused(false); + + assertTrue(!strategy.isPaused()); + + vm.expectEmit(true, true, true, true, address(strategy)); + emit UpdatePaused(true); + + vm.prank(emergencyAdmin); + strategy.setPaused(true); + + assertTrue(strategy.isPaused()); + } + + function test_pauseBlocks4626UserFlows( + address _address, + uint256 _amount + ) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != address(yieldSource) + ); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + + vm.prank(emergencyAdmin); + strategy.setPaused(true); + + assertTrue(strategy.isPaused()); + assertEq(strategy.maxDeposit(_address), 0); + assertEq(strategy.maxMint(_address), 0); + assertEq(strategy.maxWithdraw(_address), 0); + assertEq(strategy.maxRedeem(_address), 0); + assertEq(strategy.maxWithdraw(_address, 0), 0); + assertEq(strategy.maxRedeem(_address, MAX_BPS), 0); + + vm.prank(_address); + vm.expectRevert("paused"); + strategy.deposit(1, _address); + + vm.prank(_address); + vm.expectRevert("paused"); + strategy.mint(1, _address); + + vm.prank(_address); + vm.expectRevert("paused"); + strategy.withdraw(1, _address, _address); + + vm.prank(_address); + vm.expectRevert("paused"); + strategy.withdraw(1, _address, _address, 0); + + vm.prank(_address); + vm.expectRevert("paused"); + strategy.redeem(1, _address, _address); + + vm.prank(_address); + vm.expectRevert("paused"); + strategy.redeem(1, _address, _address, MAX_BPS); + } + + function test_unpauseRestores4626Flows( + address _address, + uint256 _amount + ) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != address(yieldSource) + ); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + + vm.prank(management); + strategy.setPaused(true); + + vm.prank(management); + strategy.setPaused(false); + + assertTrue(!strategy.isPaused()); + assertEq(strategy.maxRedeem(_address), _amount); + + uint256 before = asset.balanceOf(_address); + + vm.prank(_address); + strategy.redeem(_amount, _address, _address); + + assertEq(asset.balanceOf(_address), before + _amount); + checkStrategyTotals(strategy, 0, 0, 0, 0); + } + + function test_unpauseDoesNotUndoShutdown( + address _address, + uint256 _amount + ) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != address(yieldSource) + ); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + + vm.prank(management); + strategy.setPaused(true); + + vm.prank(emergencyAdmin); + strategy.shutdownStrategy(); + + vm.prank(management); + strategy.setPaused(false); + + assertTrue(!strategy.isPaused()); + assertTrue(strategy.isShutdown()); + assertEq(strategy.maxDeposit(_address), 0); + assertEq(strategy.maxMint(_address), 0); + + asset.mint(_address, _amount); + vm.prank(_address); + asset.approve(address(strategy), _amount); + + vm.prank(_address); + vm.expectRevert("ERC4626: deposit more than max"); + strategy.deposit(_amount, _address); + } + + function test_emergencyWithdrawWhenPaused( + address _address, + uint256 _amount + ) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != address(yieldSource) + ); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + + vm.prank(emergencyAdmin); + strategy.setPaused(true); + + uint256 toWithdraw = _amount / 2; + + vm.prank(emergencyAdmin); + strategy.emergencyWithdraw(toWithdraw); + + checkStrategyTotals( + strategy, + _amount, + _amount - toWithdraw, + toWithdraw, + _amount + ); + assertEq(asset.balanceOf(address(strategy)), toWithdraw); + assertTrue(!strategy.isShutdown()); + } + + function test_pauseLeavesERC20ShareFunctionsLive() public { + uint256 ownerSk = 0xA11CE; + address owner = vm.addr(ownerSk); + address spender = address(0xBEEF); + address recipient = address(0xCAFE); + uint256 amount = 100 * wad; + uint256 slice = amount / 4; + + mintAndDepositIntoStrategy(strategy, owner, amount); + + vm.prank(management); + strategy.setPaused(true); + + vm.prank(owner); + assertTrue(strategy.transfer(recipient, slice)); + + vm.prank(owner); + assertTrue(strategy.approve(spender, slice)); + assertEq(strategy.allowance(owner, spender), slice); + + vm.prank(spender); + assertTrue(strategy.transferFrom(owner, recipient, slice)); + assertEq(strategy.allowance(owner, spender), 0); + + _permit(owner, spender, slice, block.timestamp + 1 days, ownerSk); + + assertEq(strategy.allowance(owner, spender), slice); + assertEq(strategy.balanceOf(recipient), slice * 2); + assertEq(strategy.balanceOf(owner), amount - slice * 2); + } + + function test_pauseLeavesManagementFunctionsLive() public { + uint256 amount = 100 * wad; + uint256 profit = 10 * wad; + uint256 idle = 5 * wad; + address pendingManagement = address(0xB0B); + address newKeeper = address(0xCA11); + address newEmergencyAdmin = address(0xEAA); + address newPerformanceFeeRecipient = address(0xFEE); + string memory newName = "Paused Strategy"; + + mintAndDepositIntoStrategy(strategy, user, amount); + + vm.prank(emergencyAdmin); + strategy.setPaused(true); + + vm.prank(management); + strategy.setPendingManagement(pendingManagement); + assertEq(strategy.pendingManagement(), pendingManagement); + + vm.prank(management); + strategy.setName(newName); + assertEq(strategy.name(), newName); + + vm.prank(management); + strategy.setPerformanceFee(1_234); + assertEq(strategy.performanceFee(), 1_234); + + vm.prank(management); + strategy.setPerformanceFeeRecipient(newPerformanceFeeRecipient); + assertEq( + strategy.performanceFeeRecipient(), + newPerformanceFeeRecipient + ); + + vm.prank(management); + strategy.setProfitMaxUnlockTime(7 days); + assertEq(strategy.profitMaxUnlockTime(), 7 days); + + vm.prank(management); + strategy.setKeeper(newKeeper); + assertEq(strategy.keeper(), newKeeper); + + vm.prank(management); + strategy.setEmergencyAdmin(newEmergencyAdmin); + assertEq(strategy.emergencyAdmin(), newEmergencyAdmin); + + queueHarvestProfit(strategy, profit); + + vm.prank(newKeeper); + (uint256 reportedProfit, uint256 reportedLoss) = strategy.report(); + assertEq(reportedProfit, profit); + assertEq(reportedLoss, 0); + + asset.mint(address(strategy), idle); + + vm.prank(newKeeper); + strategy.tend(); + assertEq(asset.balanceOf(address(strategy)), 0); + + vm.prank(newEmergencyAdmin); + strategy.shutdownStrategy(); + assertTrue(strategy.isShutdown()); + assertTrue(strategy.isPaused()); + } + + function test_emergencyWithdrawNotPausedOrShutdownReverts() public { + vm.prank(management); + vm.expectRevert("not paused or shutdown"); + strategy.emergencyWithdraw(0); + } + + function _permit( + address owner, + address spender, + uint256 amount, + uint256 deadline, + uint256 ownerSk + ) internal { + bytes32 structHash = keccak256( + abi.encode( + PERMIT_TYPEHASH, + owner, + spender, + amount, + strategy.nonces(owner), + deadline + ) + ); + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + strategy.DOMAIN_SEPARATOR(), + structHash + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerSk, digest); + + strategy.permit(owner, spender, amount, deadline, v, r, s); + } +} diff --git a/src/test/Shutdown.t.sol b/src/test/Shutdown.t.sol index 78580cc..5ff7a63 100644 --- a/src/test/Shutdown.t.sol +++ b/src/test/Shutdown.t.sol @@ -347,7 +347,7 @@ contract ShutdownTest is Setup { uint256 toWithdraw = _amount / 2; - vm.expectRevert("not shutdown"); + vm.expectRevert("not paused or shutdown"); vm.prank(management); strategy.emergencyWithdraw(toWithdraw); } diff --git a/src/test/TokenizedStrategyLibViews.t.sol b/src/test/TokenizedStrategyLibViews.t.sol index c4e0965..f90cee0 100644 --- a/src/test/TokenizedStrategyLibViews.t.sol +++ b/src/test/TokenizedStrategyLibViews.t.sol @@ -166,6 +166,11 @@ contract TokenizedStrategyLibViewsTest is Setup { strategy.isShutdown(), "isShutdown" ); + assertEq( + libraryStrategy.libraryIsPaused(), + strategy.isPaused(), + "isPaused" + ); assertEq( libraryStrategy.libraryTotalAssets(), strategy.totalAssets(), diff --git a/src/test/mocks/MockStorage.sol b/src/test/mocks/MockStorage.sol index ab4351a..a1e3c46 100644 --- a/src/test/mocks/MockStorage.sol +++ b/src/test/mocks/MockStorage.sol @@ -45,5 +45,6 @@ contract MockStorage { // Strategy status checks. uint8 entered; // To prevent reentrancy. Use uint8 for gas savings. bool shutdown; // Bool that can be used to stop deposits into the strategy. - uint80 lastAccrual; // The last time accounting synced. + bool paused; // Bool that can be used to stop user facing 4626 functions. + uint72 lastAccrual; // The last time accounting synced. } diff --git a/src/test/mocks/MockStrategy.sol b/src/test/mocks/MockStrategy.sol index 807cf1a..8b6b763 100644 --- a/src/test/mocks/MockStrategy.sol +++ b/src/test/mocks/MockStrategy.sol @@ -158,6 +158,10 @@ contract MockStrategy is BaseStrategy { return TokenizedStrategy.isShutdown(); } + function libraryIsPaused() external view returns (bool) { + return TokenizedStrategy.isPaused(); + } + function libraryTotalAssets() external view returns (uint256) { return TokenizedStrategy.totalAssets(); }