diff --git a/script/deploy/devnet/deploy_from_scratch.s.sol b/script/deploy/devnet/deploy_from_scratch.s.sol index 6c646de7e2..c34dd7845e 100644 --- a/script/deploy/devnet/deploy_from_scratch.s.sol +++ b/script/deploy/devnet/deploy_from_scratch.s.sol @@ -344,7 +344,8 @@ contract DeployFromScratch is Script, Test { StrategyFactory.initialize.selector, executorMultisig, 0, // initial paused status - IBeacon(strategyBeacon) + IBeacon(strategyBeacon), + IBeacon(address(0)) ) ); diff --git a/script/releases/TestUtils.sol b/script/releases/TestUtils.sol index b8335406e9..3d287f2879 100644 --- a/script/releases/TestUtils.sol +++ b/script/releases/TestUtils.sol @@ -967,7 +967,7 @@ library TestUtils { StrategyFactory strategyFactory ) internal { vm.expectRevert(errInit); - strategyFactory.initialize(address(0), 0, UpgradeableBeacon(address(0))); + strategyFactory.initialize(address(0), 0, UpgradeableBeacon(address(0)), UpgradeableBeacon(address(0))); } /// multichain/ diff --git a/script/utils/ExistingDeploymentParser.sol b/script/utils/ExistingDeploymentParser.sol index 8485bcedd0..6615f5eb24 100644 --- a/script/utils/ExistingDeploymentParser.sol +++ b/script/utils/ExistingDeploymentParser.sol @@ -16,6 +16,7 @@ import "../../src/contracts/permissions/PermissionController.sol"; import "../../src/contracts/strategies/StrategyFactory.sol"; import "../../src/contracts/strategies/StrategyBase.sol"; import "../../src/contracts/strategies/StrategyBaseTVLLimits.sol"; +import "../../src/contracts/strategies/DurationVaultStrategy.sol"; import "../../src/contracts/strategies/EigenStrategy.sol"; import "../../src/contracts/pods/EigenPod.sol"; @@ -103,6 +104,7 @@ contract ExistingDeploymentParser is Script, Logger { PauserRegistry public eigenLayerPauserReg; UpgradeableBeacon public eigenPodBeacon; UpgradeableBeacon public strategyBeacon; + UpgradeableBeacon public durationVaultBeacon; /// @dev AllocationManager IAllocationManager public allocationManager; @@ -138,6 +140,7 @@ contract ExistingDeploymentParser is Script, Logger { StrategyFactory public strategyFactory; StrategyFactory public strategyFactoryImplementation; StrategyBase public baseStrategyImplementation; + DurationVaultStrategy public durationVaultImplementation; StrategyBase public strategyFactoryBeaconImplementation; // Token diff --git a/src/contracts/interfaces/IDurationVaultStrategy.sol b/src/contracts/interfaces/IDurationVaultStrategy.sol new file mode 100644 index 0000000000..817d9dc25f --- /dev/null +++ b/src/contracts/interfaces/IDurationVaultStrategy.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "./IStrategy.sol"; +import "./IDelegationManager.sol"; +import "./IAllocationManager.sol"; +import "../libraries/OperatorSetLib.sol"; + +/// @title Interface for time-bound EigenLayer vault strategies. +/// @author Layr Labs, Inc. +/// @notice Terms of Service: https://docs.eigenlayer.xyz/overview/terms-of-service +interface IDurationVaultStrategy is IStrategy { + enum VaultState { + UNINITIALIZED, + DEPOSITS, + ALLOCATIONS, + WITHDRAWALS + } + + struct VaultConfig { + IERC20 underlyingToken; + address vaultAdmin; + uint32 duration; + uint256 maxPerDeposit; + uint256 stakeCap; + string metadataURI; + OperatorSet operatorSet; + bytes operatorSetRegistrationData; + address delegationApprover; + uint32 operatorAllocationDelay; + string operatorMetadataURI; + } + + /// @dev Thrown when attempting to use a zero-address vault admin. + error InvalidVaultAdmin(); + /// @dev Thrown when attempting to configure a zero duration. + error InvalidDuration(); + /// @dev Thrown when attempting to mutate configuration from a non-admin. + error OnlyVaultAdmin(); + /// @dev Thrown when attempting to lock an already locked vault. + error VaultAlreadyLocked(); + /// @dev Thrown when attempting to deposit after the vault has been locked. + error DepositsLocked(); + /// @dev Thrown when attempting to withdraw while funds remain locked. + error WithdrawalsLocked(); + /// @dev Thrown when attempting to mark the vault as matured before duration elapses. + error DurationNotElapsed(); + /// @dev Thrown when operator integration inputs are missing or invalid. + error OperatorIntegrationInvalid(); + + event VaultInitialized( + address indexed vaultAdmin, + IERC20 indexed underlyingToken, + uint32 duration, + uint256 maxPerDeposit, + uint256 stakeCap, + string metadataURI + ); + + event VaultLocked(uint32 lockedAt, uint32 unlockAt); + + event VaultMatured(uint32 maturedAt); + + event MetadataURIUpdated(string newMetadataURI); + + /// @notice Locks the vault, preventing further deposits / withdrawals until maturity. + function lock() external; + + /// @notice Marks the vault as matured once the configured duration has elapsed. + /// @dev After maturation, withdrawals are permitted while deposits remain disabled. + function markMatured() external; + + /// @notice Updates the vault metadata URI. + function updateMetadataURI( + string calldata newMetadataURI + ) external; + + /// @notice Updates the TVL limits for max deposit per transaction and total stake cap. + /// @dev Only callable by the vault admin while deposits are open (before lock). + function updateTVLLimits( + uint256 newMaxPerDeposit, + uint256 newStakeCap + ) external; + + function vaultAdmin() external view returns (address); + function duration() external view returns (uint32); + function lockedAt() external view returns (uint32); + function unlockTimestamp() external view returns (uint32); + function isLocked() external view returns (bool); + function isMatured() external view returns (bool); + function state() external view returns (VaultState); + function metadataURI() external view returns (string memory); + function stakeCap() external view returns (uint256); + function depositsOpen() external view returns (bool); + function withdrawalsOpen() external view returns (bool); + function delegationManager() external view returns (IDelegationManager); + function allocationManager() external view returns (IAllocationManager); + function operatorIntegrationConfigured() external view returns (bool); + function operatorSetRegistered() external view returns (bool); + function allocationsActive() external view returns (bool); + function operatorSetInfo() external view returns (address avs, uint32 operatorSetId); +} diff --git a/src/contracts/interfaces/IStrategyFactory.sol b/src/contracts/interfaces/IStrategyFactory.sol index 9054b6827a..04987e1166 100644 --- a/src/contracts/interfaces/IStrategyFactory.sol +++ b/src/contracts/interfaces/IStrategyFactory.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.27; import "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./IStrategy.sol"; +import "./IDurationVaultStrategy.sol"; +import "./ISemVerMixin.sol"; /// @title Interface for the `StrategyFactory` contract. /// @author Layr Labs, Inc. @@ -16,12 +18,17 @@ interface IStrategyFactory { error StrategyAlreadyExists(); /// @dev Thrown when attempting to blacklist a token that is already blacklisted error AlreadyBlacklisted(); + /// @dev Thrown when attempting to deploy a duration vault before its beacon has been configured. + error DurationVaultBeaconNotSet(); event TokenBlacklisted(IERC20 token); /// @notice Upgradeable beacon which new Strategies deployed by this contract point to function strategyBeacon() external view returns (IBeacon); + /// @notice Upgradeable beacon which duration vault strategies deployed by this contract point to + function durationVaultBeacon() external view returns (IBeacon); + /// @notice Mapping token => Strategy contract for the token /// The strategies in this mapping are deployed by the StrategyFactory. /// The factory can only deploy a single strategy per token address @@ -42,6 +49,17 @@ interface IStrategyFactory { IERC20 token ) external returns (IStrategy newStrategy); + /// @notice Deploys a new duration-bound vault strategy contract. + /// @dev Enforces the same blacklist semantics as vanilla strategies. + function deployDurationVaultStrategy( + IDurationVaultStrategy.VaultConfig calldata config + ) external returns (IDurationVaultStrategy newVault); + + /// @notice Returns all duration vaults that have ever been deployed for a given token. + function getDurationVaults( + IERC20 token + ) external view returns (IDurationVaultStrategy[] memory); + /// @notice Owner-only function to pass through a call to `StrategyManager.addStrategiesToDepositWhitelist` function whitelistStrategies( IStrategy[] calldata strategiesToWhitelist @@ -52,9 +70,30 @@ interface IStrategyFactory { IStrategy[] calldata strategiesToRemoveFromWhitelist ) external; + /// @notice Owner-only function to update the beacon used for deploying duration vault strategies. + function setDurationVaultBeacon( + IBeacon newDurationVaultBeacon + ) external; + /// @notice Emitted when the `strategyBeacon` is changed event StrategyBeaconModified(IBeacon previousBeacon, IBeacon newBeacon); + /// @notice Emitted when the `durationVaultBeacon` is changed + event DurationVaultBeaconModified(IBeacon previousBeacon, IBeacon newBeacon); + /// @notice Emitted whenever a slot is set in the `tokenStrategy` mapping event StrategySetForToken(IERC20 token, IStrategy strategy); + + /// @notice Emitted whenever a duration vault is deployed. The vault address uniquely identifies the deployment. + event DurationVaultDeployed( + IDurationVaultStrategy indexed vault, + IERC20 indexed underlyingToken, + address indexed vaultAdmin, + uint32 duration, + uint256 maxPerDeposit, + uint256 stakeCap, + string metadataURI, + address operatorSetAVS, + uint32 operatorSetId + ); } diff --git a/src/contracts/strategies/DurationVaultStrategy.sol b/src/contracts/strategies/DurationVaultStrategy.sol new file mode 100644 index 0000000000..bb6d3b805c --- /dev/null +++ b/src/contracts/strategies/DurationVaultStrategy.sol @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "./StrategyBaseTVLLimits.sol"; +import "./DurationVaultStrategyStorage.sol"; +import "../interfaces/IDurationVaultStrategy.sol"; +import "../interfaces/IDelegationManager.sol"; +import "../interfaces/IAllocationManager.sol"; +import "../libraries/OperatorSetLib.sol"; + +/// @title Duration-bound EigenLayer vault strategy with configurable deposit caps and windows. +/// @author Layr Labs, Inc. +/// @notice Terms of Service: https://docs.eigenlayer.xyz/overview/terms-of-service +contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBaseTVLLimits { + using OperatorSetLib for OperatorSet; + + /// @notice Delegation manager reference used to register the vault as an operator. + IDelegationManager public immutable override delegationManager; + + /// @notice Allocation manager reference used to register/allocate operator sets. + IAllocationManager public immutable override allocationManager; + + modifier onlyVaultAdmin() { + require(msg.sender == vaultAdmin, OnlyVaultAdmin()); + _; + } + + constructor( + IStrategyManager _strategyManager, + IPauserRegistry _pauserRegistry, + IDelegationManager _delegationManager, + IAllocationManager _allocationManager + ) StrategyBaseTVLLimits(_strategyManager, _pauserRegistry) { + require( + address(_delegationManager) != address(0) && address(_allocationManager) != address(0), + OperatorIntegrationInvalid() + ); + delegationManager = _delegationManager; + allocationManager = _allocationManager; + } + + /// @notice Initializes the vault configuration. + function initialize( + VaultConfig memory config + ) public initializer { + require(config.vaultAdmin != address(0), InvalidVaultAdmin()); + require(config.duration != 0 && config.duration <= MAX_DURATION, InvalidDuration()); + _setTVLLimits(config.maxPerDeposit, config.stakeCap); + _initializeStrategyBase(config.underlyingToken); + + vaultAdmin = config.vaultAdmin; + duration = config.duration; + metadataURI = config.metadataURI; + + _configureOperatorIntegration(config); + _state = VaultState.DEPOSITS; + + emit VaultInitialized( + vaultAdmin, + config.underlyingToken, + duration, + config.maxPerDeposit, + config.stakeCap, + metadataURI + ); + } + + /// @notice Locks the vault, preventing new deposits and withdrawals until maturity. + function lock() external override onlyVaultAdmin { + require(_state == VaultState.DEPOSITS, VaultAlreadyLocked()); + + uint32 currentTimestamp = uint32(block.timestamp); + lockedAt = currentTimestamp; + uint32 newUnlockAt = currentTimestamp + duration; + require(newUnlockAt >= currentTimestamp, InvalidDuration()); + unlockAt = newUnlockAt; + + _state = VaultState.ALLOCATIONS; + + emit VaultLocked(lockedAt, unlockAt); + + _allocateFullMagnitude(); + } + + /// @notice Marks the vault as matured once the configured duration elapses. Callable by anyone. + function markMatured() external override { + if (_state == VaultState.WITHDRAWALS) { + // already recorded; noop + return; + } + require(_state == VaultState.ALLOCATIONS, DurationNotElapsed()); + require(block.timestamp >= unlockAt, DurationNotElapsed()); + _state = VaultState.WITHDRAWALS; + maturedAt = uint32(block.timestamp); + emit VaultMatured(maturedAt); + + _deallocateAll(); + _deregisterFromOperatorSet(); + } + + /// @notice Updates the metadata URI describing the vault. + function updateMetadataURI( + string calldata newMetadataURI + ) external override onlyVaultAdmin { + metadataURI = newMetadataURI; + emit MetadataURIUpdated(newMetadataURI); + } + + /// @notice Updates the TVL limits for max deposit per transaction and total stake cap. + /// @dev Only callable by the vault admin while deposits are open (before lock). + function updateTVLLimits( + uint256 newMaxPerDeposit, + uint256 newStakeCap + ) external override onlyVaultAdmin { + require(depositsOpen(), DepositsLocked()); + _setTVLLimits(newMaxPerDeposit, newStakeCap); + } + + /// @inheritdoc IDurationVaultStrategy + function unlockTimestamp() public view override returns (uint32) { + return unlockAt; + } + + /// @inheritdoc IDurationVaultStrategy + function isLocked() public view override returns (bool) { + return _state != VaultState.DEPOSITS; + } + + /// @inheritdoc IDurationVaultStrategy + function isMatured() public view override returns (bool) { + return _state == VaultState.WITHDRAWALS; + } + + /// @inheritdoc IDurationVaultStrategy + function state() public view override returns (VaultState) { + return _state; + } + + /// @inheritdoc IDurationVaultStrategy + function stakeCap() external view override returns (uint256) { + return maxTotalDeposits; + } + + /// @inheritdoc IDurationVaultStrategy + function depositsOpen() public view override returns (bool) { + return _state == VaultState.DEPOSITS; + } + + /// @inheritdoc IDurationVaultStrategy + function withdrawalsOpen() public view override returns (bool) { + return _state != VaultState.ALLOCATIONS; + } + + /// @inheritdoc IDurationVaultStrategy + function operatorIntegrationConfigured() public pure override returns (bool) { + return true; + } + + /// @inheritdoc IDurationVaultStrategy + function operatorSetInfo() external view override returns (address avs, uint32 operatorSetId) { + return (_operatorSet.avs, _operatorSet.id); + } + + function operatorSetRegistered() public view override returns (bool) { + return _state == VaultState.DEPOSITS || _state == VaultState.ALLOCATIONS; + } + + function allocationsActive() public view override returns (bool) { + return _state == VaultState.ALLOCATIONS; + } + + function _beforeDeposit( + IERC20 token, + uint256 amount + ) internal virtual override { + require(depositsOpen(), DepositsLocked()); + super._beforeDeposit(token, amount); + } + + function _beforeWithdrawal( + address recipient, + IERC20 token, + uint256 amountShares + ) internal virtual override { + if (!withdrawalsOpen()) { + address redistributionRecipient = allocationManager.getRedistributionRecipient(_operatorSet); + bool isRedistribution = recipient == redistributionRecipient; + require(isRedistribution, WithdrawalsLocked()); + } + super._beforeWithdrawal(recipient, token, amountShares); + } + + function _configureOperatorIntegration( + VaultConfig memory config + ) internal { + require(config.operatorSet.avs != address(0) && config.operatorSet.id != 0, OperatorIntegrationInvalid()); + _operatorSet = config.operatorSet; + + delegationManager.registerAsOperator( + config.delegationApprover, config.operatorAllocationDelay, config.operatorMetadataURI + ); + + IAllocationManager.RegisterParams memory params; + params.avs = config.operatorSet.avs; + params.operatorSetIds = new uint32[](1); + params.operatorSetIds[0] = config.operatorSet.id; + params.data = config.operatorSetRegistrationData; + allocationManager.registerForOperatorSets(address(this), params); + } + + function _allocateFullMagnitude() internal { + IAllocationManager.AllocateParams[] memory params = new IAllocationManager.AllocateParams[](1); + params[0].operatorSet = _operatorSet; + params[0].strategies = new IStrategy[](1); + params[0].strategies[0] = IStrategy(address(this)); + params[0].newMagnitudes = new uint64[](1); + params[0].newMagnitudes[0] = FULL_ALLOCATION; + allocationManager.modifyAllocations(address(this), params); + } + + function _deallocateAll() internal { + IAllocationManager.AllocateParams[] memory params = new IAllocationManager.AllocateParams[](1); + params[0].operatorSet = _operatorSet; + params[0].strategies = new IStrategy[](1); + params[0].strategies[0] = IStrategy(address(this)); + params[0].newMagnitudes = new uint64[](1); + params[0].newMagnitudes[0] = 0; + allocationManager.modifyAllocations(address(this), params); + } + + function _deregisterFromOperatorSet() internal { + IAllocationManager.DeregisterParams memory params; + params.operator = address(this); + params.avs = _operatorSet.avs; + params.operatorSetIds = new uint32[](1); + params.operatorSetIds[0] = _operatorSet.id; + allocationManager.deregisterFromOperatorSets(params); + } +} diff --git a/src/contracts/strategies/DurationVaultStrategyStorage.sol b/src/contracts/strategies/DurationVaultStrategyStorage.sol new file mode 100644 index 0000000000..ae49419a05 --- /dev/null +++ b/src/contracts/strategies/DurationVaultStrategyStorage.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "../interfaces/IDurationVaultStrategy.sol"; +import "../libraries/OperatorSetLib.sol"; + +/// @title Storage layout for DurationVaultStrategy. +/// @author Layr Labs, Inc. +/// @notice Terms of Service: https://docs.eigenlayer.xyz/overview/terms-of-service +abstract contract DurationVaultStrategyStorage is IDurationVaultStrategy { + /// @notice Constant representing the full allocation magnitude (1 WAD) for allocation manager calls. + uint64 internal constant FULL_ALLOCATION = 1e18; + + /// @notice Maximum allowable duration (approximately 2 years). + uint32 internal constant MAX_DURATION = uint32(2 * 365 days); + + /// @notice Address empowered to configure and lock the vault. + address public vaultAdmin; + + /// @notice The enforced lock duration once `lock` is called. + uint32 public duration; + + /// @notice Timestamp when the vault was locked. Zero indicates the vault is not yet locked. + uint32 public lockedAt; + + /// @notice Timestamp when the vault unlocks (set at lock time). + uint32 public unlockAt; + + /// @notice Timestamp when the vault was marked as matured (purely informational). + uint32 public maturedAt; + + /// @notice Optional metadata URI describing the vault configuration. + string public metadataURI; + + /// @notice Stored operator set metadata for integration with the allocation manager. + OperatorSet internal _operatorSet; + + /// @notice Tracks the lifecycle of the vault (deposits -> allocations -> withdrawals). + VaultState internal _state; + + /// @dev This empty reserved space is put in place to allow future versions to add new + /// variables without shifting down storage in the inheritance chain. + uint256[43] private __gap; +} diff --git a/src/contracts/strategies/StrategyFactory.sol b/src/contracts/strategies/StrategyFactory.sol index 43f80ca237..8bae750e3a 100644 --- a/src/contracts/strategies/StrategyFactory.sol +++ b/src/contracts/strategies/StrategyFactory.sol @@ -5,6 +5,8 @@ import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; import "@openzeppelin-upgrades/contracts/access/OwnableUpgradeable.sol"; import "./StrategyFactoryStorage.sol"; import "./StrategyBase.sol"; +import "./DurationVaultStrategy.sol"; +import "../interfaces/IDurationVaultStrategy.sol"; import "../permissions/Pausable.sol"; /// @title Factory contract for deploying BeaconProxies of a Strategy contract implementation for arbitrary ERC20 tokens @@ -30,11 +32,15 @@ contract StrategyFactory is StrategyFactoryStorage, OwnableUpgradeable, Pausable function initialize( address _initialOwner, uint256 _initialPausedStatus, - IBeacon _strategyBeacon + IBeacon _strategyBeacon, + IBeacon _durationVaultBeacon ) public virtual initializer { _transferOwnership(_initialOwner); _setPausedStatus(_initialPausedStatus); _setStrategyBeacon(_strategyBeacon); + if (address(_durationVaultBeacon) != address(0)) { + _setDurationVaultBeacon(_durationVaultBeacon); + } } /// @notice Deploy a new StrategyBase contract for the ERC20 token, using a beacon proxy @@ -100,6 +106,31 @@ contract StrategyFactory is StrategyFactoryStorage, OwnableUpgradeable, Pausable strategyManager.addStrategiesToDepositWhitelist(strategiesToWhitelist); } + /// @notice Deploys a new duration vault strategy backed by the configured beacon. + function deployDurationVaultStrategy( + IDurationVaultStrategy.VaultConfig calldata config + ) external onlyWhenNotPaused(PAUSED_NEW_STRATEGIES) returns (IDurationVaultStrategy newVault) { + IERC20 underlyingToken = config.underlyingToken; + require(!isBlacklisted[underlyingToken], BlacklistedToken()); + require(address(durationVaultBeacon) != address(0), DurationVaultBeaconNotSet()); + + newVault = IDurationVaultStrategy( + address( + new BeaconProxy( + address(durationVaultBeacon), + abi.encodeWithSelector(DurationVaultStrategy.initialize.selector, config) + ) + ) + ); + + _registerDurationVault(underlyingToken, newVault); + IStrategy[] memory strategiesToWhitelist = new IStrategy[](1); + strategiesToWhitelist[0] = newVault; + strategyManager.addStrategiesToDepositWhitelist(strategiesToWhitelist); + + _emitDurationVaultDeployed(newVault, underlyingToken, config); + } + /// @notice Owner-only function to pass through a call to `StrategyManager.removeStrategiesFromDepositWhitelist` function removeStrategiesFromWhitelist( IStrategy[] calldata strategiesToRemoveFromWhitelist @@ -121,4 +152,50 @@ contract StrategyFactory is StrategyFactoryStorage, OwnableUpgradeable, Pausable emit StrategyBeaconModified(strategyBeacon, _strategyBeacon); strategyBeacon = _strategyBeacon; } + + /// @notice Owner-only function to update the duration vault beacon. + function setDurationVaultBeacon( + IBeacon newDurationVaultBeacon + ) external onlyOwner { + _setDurationVaultBeacon(newDurationVaultBeacon); + } + + /// @inheritdoc IStrategyFactory + function getDurationVaults( + IERC20 token + ) external view returns (IDurationVaultStrategy[] memory) { + return durationVaultsByToken[token]; + } + + function _setDurationVaultBeacon( + IBeacon newDurationVaultBeacon + ) internal { + emit DurationVaultBeaconModified(durationVaultBeacon, newDurationVaultBeacon); + durationVaultBeacon = newDurationVaultBeacon; + } + + function _registerDurationVault( + IERC20 token, + IDurationVaultStrategy vault + ) internal { + durationVaultsByToken[token].push(vault); + } + + function _emitDurationVaultDeployed( + IDurationVaultStrategy vault, + IERC20 underlyingToken, + IDurationVaultStrategy.VaultConfig calldata config + ) internal { + emit DurationVaultDeployed( + vault, + underlyingToken, + config.vaultAdmin, + config.duration, + config.maxPerDeposit, + config.stakeCap, + config.metadataURI, + config.operatorSet.avs, + config.operatorSet.id + ); + } } diff --git a/src/contracts/strategies/StrategyFactoryStorage.sol b/src/contracts/strategies/StrategyFactoryStorage.sol index 943b820e38..e580352529 100644 --- a/src/contracts/strategies/StrategyFactoryStorage.sol +++ b/src/contracts/strategies/StrategyFactoryStorage.sol @@ -7,7 +7,7 @@ import "../interfaces/IStrategyFactory.sol"; /// @author Layr Labs, Inc. /// @notice Terms of Service: https://docs.eigenlayer.xyz/overview/terms-of-service abstract contract StrategyFactoryStorage is IStrategyFactory { - /// @notice Upgradeable beacon which new Strategies deployed by this contract point to + /// @notice Upgradeable beacon used for baseline strategies deployed by this contract. IBeacon public strategyBeacon; /// @notice Mapping token => Strategy contract for the token @@ -22,8 +22,14 @@ abstract contract StrategyFactoryStorage is IStrategyFactory { /// @notice Mapping token => Whether or not a strategy can be deployed for the token mapping(IERC20 => bool) public isBlacklisted; + /// @notice Upgradeable beacon used for duration vault strategies deployed by this contract. + IBeacon public durationVaultBeacon; + + /// @notice Mapping token => all duration vault strategies deployed for the token. + mapping(IERC20 => IDurationVaultStrategy[]) internal durationVaultsByToken; + /// @dev This empty reserved space is put in place to allow future versions to add new /// variables without shifting down storage in the inheritance chain. /// See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps - uint256[48] private __gap; + uint256[46] private __gap; } diff --git a/src/test/integration/IntegrationDeployer.t.sol b/src/test/integration/IntegrationDeployer.t.sol index e50b6325df..343520b1a4 100644 --- a/src/test/integration/IntegrationDeployer.t.sol +++ b/src/test/integration/IntegrationDeployer.t.sol @@ -16,6 +16,7 @@ import "src/contracts/core/StrategyManager.sol"; import "src/contracts/strategies/StrategyFactory.sol"; import "src/contracts/strategies/StrategyBase.sol"; import "src/contracts/strategies/StrategyBaseTVLLimits.sol"; +import "src/contracts/strategies/DurationVaultStrategy.sol"; import "src/contracts/pods/EigenPodManager.sol"; import "src/contracts/pods/EigenPod.sol"; import "src/contracts/permissions/PauserRegistry.sol"; @@ -313,6 +314,7 @@ abstract contract IntegrationDeployer is ExistingDeploymentParser { } if (address(eigenPodBeacon) == address(0)) eigenPodBeacon = new UpgradeableBeacon(address(emptyContract)); if (address(strategyBeacon) == address(0)) strategyBeacon = new UpgradeableBeacon(address(emptyContract)); + if (address(durationVaultBeacon) == address(0)) durationVaultBeacon = new UpgradeableBeacon(address(emptyContract)); // multichain if (address(keyRegistrar) == address(0)) { keyRegistrar = KeyRegistrar(address(new TransparentUpgradeableProxy(address(emptyContract), address(eigenLayerProxyAdmin), ""))); @@ -370,6 +372,7 @@ abstract contract IntegrationDeployer is ExistingDeploymentParser { // Beacon implementations eigenPodImplementation = new EigenPod(DEPOSIT_CONTRACT, eigenPodManager); baseStrategyImplementation = new StrategyBase(strategyManager, eigenLayerPauserReg); + durationVaultImplementation = new DurationVaultStrategy(strategyManager, eigenLayerPauserReg, delegationManager, allocationManager); // Pre-longtail StrategyBaseTVLLimits implementation // TODO - need to update ExistingDeploymentParser @@ -422,6 +425,7 @@ abstract contract IntegrationDeployer is ExistingDeploymentParser { // StrategyBase Beacon strategyBeacon.upgradeTo(address(baseStrategyImplementation)); + durationVaultBeacon.upgradeTo(address(durationVaultImplementation)); // Upgrade All deployed strategy contracts to new base strategy for (uint i = 0; i < numStrategiesDeployed; i++) { @@ -455,7 +459,20 @@ abstract contract IntegrationDeployer is ExistingDeploymentParser { allocationManager.initialize({initialPausedStatus: 0}); - strategyFactory.initialize({_initialOwner: executorMultisig, _initialPausedStatus: 0, _strategyBeacon: strategyBeacon}); + strategyFactory.initialize({ + _initialOwner: executorMultisig, + _initialPausedStatus: 0, + _strategyBeacon: strategyBeacon, + _durationVaultBeacon: durationVaultBeacon + }); + + rewardsCoordinator.initialize({ + initialOwner: executorMultisig, + initialPausedStatus: 0, + _rewardsUpdater: executorMultisig, + _activationDelay: 0, + _defaultSplitBips: 5000 + }); } /// @dev Deploy a strategy and its underlying token, push to global lists of tokens/strategies, and whitelist diff --git a/src/test/integration/tests/DurationVaultIntegration.t.sol b/src/test/integration/tests/DurationVaultIntegration.t.sol new file mode 100644 index 0000000000..56a2a2d442 --- /dev/null +++ b/src/test/integration/tests/DurationVaultIntegration.t.sol @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +import "src/contracts/interfaces/IDurationVaultStrategy.sol"; +import "src/contracts/interfaces/IStrategy.sol"; +import "src/contracts/interfaces/IDelegationManager.sol"; +import "src/contracts/interfaces/IAllocationManager.sol"; +import "src/contracts/interfaces/IRewardsCoordinator.sol"; +import "src/contracts/strategies/StrategyBaseTVLLimits.sol"; + +import "src/test/integration/IntegrationChecks.t.sol"; +import "src/test/integration/users/User.t.sol"; +import "src/test/integration/users/AVS.t.sol"; + +contract Integration_DurationVault is IntegrationCheckUtils { + struct DurationVaultContext { + IDurationVaultStrategy vault; + ERC20PresetFixedSupply asset; + AVS avs; + OperatorSet operatorSet; + } + + uint32 internal constant DEFAULT_DURATION = 10 days; + uint internal constant VAULT_MAX_PER_DEPOSIT = 200 ether; + uint internal constant VAULT_STAKE_CAP = 1000 ether; + + function test_durationVaultLifecycle_flow_deposit_lock_mature() public { + DurationVaultContext memory ctx = _deployDurationVault(_randomInsuranceRecipient()); + User staker = new User("duration-staker"); + + uint depositAmount = 120 ether; + ctx.asset.transfer(address(staker), depositAmount); + + IStrategy[] memory strategies = _durationStrategyArray(ctx.vault); + uint[] memory tokenBalances = _singleAmountArray(depositAmount); + uint[] memory depositShares = _calculateExpectedShares(strategies, tokenBalances); + + staker.depositIntoEigenlayer(strategies, tokenBalances); + check_Deposit_State(staker, strategies, depositShares); + + _delegateToVault(staker, ctx.vault); + ctx.vault.lock(); + assertTrue(ctx.vault.allocationsActive(), "allocations should be active after lock"); + assertTrue(allocationManager.isOperatorSlashable(address(ctx.vault), ctx.operatorSet), "should be slashable"); + + // Cannot deposit once locked. + uint extraDeposit = 10 ether; + User lateStaker = new User("duration-late-staker"); + ctx.asset.transfer(address(lateStaker), extraDeposit); + uint[] memory lateTokenBalances = _singleAmountArray(extraDeposit); + cheats.expectRevert(IDurationVaultStrategy.DepositsLocked.selector); + lateStaker.depositIntoEigenlayer(strategies, lateTokenBalances); + + // Queue withdrawal prior to maturity. + uint[] memory withdrawableShares = _getStakerWithdrawableShares(staker, strategies); + Withdrawal[] memory withdrawals = staker.queueWithdrawals(strategies, withdrawableShares); + + // Mature the vault and allow withdrawals. + cheats.warp(block.timestamp + ctx.vault.duration() + 1); + ctx.vault.markMatured(); + assertTrue(ctx.vault.withdrawalsOpen(), "withdrawals must open after maturity"); + assertFalse(ctx.vault.allocationsActive(), "allocations disabled after maturity"); + + // Advance past deallocation delay to ensure slashability cleared. + cheats.roll(block.number + allocationManager.DEALLOCATION_DELAY() + 1); + assertFalse(allocationManager.isOperatorSlashable(address(ctx.vault), ctx.operatorSet), "should not be slashable"); + + _rollBlocksForCompleteWithdrawals(withdrawals); + IERC20[] memory tokens = staker.completeWithdrawalAsTokens(withdrawals[0]); + assertEq(address(tokens[0]), address(ctx.asset), "unexpected token returned"); + assertEq(ctx.asset.balanceOf(address(staker)), depositAmount, "staker should recover deposit"); + } + + function test_durationVault_operatorIntegrationAndMetadataUpdate() public { + DurationVaultContext memory ctx = _deployDurationVault(_randomInsuranceRecipient()); + + assertTrue(delegationManager.isOperator(address(ctx.vault)), "vault must self-register as operator"); + assertEq(delegationManager.delegatedTo(address(ctx.vault)), address(ctx.vault), "vault should self delegate"); + assertTrue(ctx.vault.operatorSetRegistered(), "operator set should be marked registered"); + + (address avsAddress, uint32 operatorSetId) = ctx.vault.operatorSetInfo(); + assertEq(avsAddress, ctx.operatorSet.avs, "avs mismatch"); + assertEq(operatorSetId, ctx.operatorSet.id, "operator set id mismatch"); + assertTrue(allocationManager.isOperatorSlashable(address(ctx.vault), ctx.operatorSet), "vault must be slashable"); + + string memory newURI = "ipfs://duration-vault/metadata"; + cheats.expectEmit(false, false, false, true, address(ctx.vault)); + emit IDurationVaultStrategy.MetadataURIUpdated(newURI); + ctx.vault.updateMetadataURI(newURI); + assertEq(ctx.vault.metadataURI(), newURI, "metadata not updated"); + } + + function test_durationVault_TVLLimits_enforced_and_frozen_after_lock() public { + DurationVaultContext memory ctx = _deployDurationVault(_randomInsuranceRecipient()); + User staker = new User("duration-tvl-staker"); + + (uint maxPerDepositBefore, uint maxStakeBefore) = StrategyBaseTVLLimits(address(ctx.vault)).getTVLLimits(); + assertEq(maxPerDepositBefore, VAULT_MAX_PER_DEPOSIT, "initial max per deposit mismatch"); + assertEq(maxStakeBefore, VAULT_STAKE_CAP, "initial stake cap mismatch"); + + uint newMaxPerDeposit = 50 ether; + uint newStakeCap = 100 ether; + ctx.vault.updateTVLLimits(newMaxPerDeposit, newStakeCap); + + IStrategy[] memory strategies = _durationStrategyArray(ctx.vault); + uint[] memory amounts = _singleAmountArray(60 ether); + ctx.asset.transfer(address(staker), amounts[0]); + cheats.expectRevert(IStrategyErrors.MaxPerDepositExceedsMax.selector); + staker.depositIntoEigenlayer(strategies, amounts); + + // Deposit within limits. + uint firstDeposit = 50 ether; + ctx.asset.transfer(address(staker), firstDeposit); + amounts[0] = firstDeposit; + staker.depositIntoEigenlayer(strategies, amounts); + + uint secondDeposit = 40 ether; + ctx.asset.transfer(address(staker), secondDeposit); + amounts[0] = secondDeposit; + staker.depositIntoEigenlayer(strategies, amounts); + + // Hitting total cap reverts. + uint thirdDeposit = 20 ether; + ctx.asset.transfer(address(staker), thirdDeposit); + amounts[0] = thirdDeposit; + cheats.expectRevert(IStrategyErrors.BalanceExceedsMaxTotalDeposits.selector); + staker.depositIntoEigenlayer(strategies, amounts); + + ctx.vault.lock(); + cheats.expectRevert(IDurationVaultStrategy.DepositsLocked.selector); + ctx.vault.updateTVLLimits(10 ether, 20 ether); + } + + function test_durationVault_rewards_claim_while_locked() public { + DurationVaultContext memory ctx = _deployDurationVault(_randomInsuranceRecipient()); + User staker = new User("duration-reward-staker"); + uint depositAmount = 80 ether; + ctx.asset.transfer(address(staker), depositAmount); + + IStrategy[] memory strategies = _durationStrategyArray(ctx.vault); + uint[] memory tokenBalances = _singleAmountArray(depositAmount); + staker.depositIntoEigenlayer(strategies, tokenBalances); + _delegateToVault(staker, ctx.vault); + ctx.vault.lock(); + + // Prepare reward token and fund RewardsCoordinator. + ERC20PresetFixedSupply rewardToken = new ERC20PresetFixedSupply("RewardToken", "RWRD", 1e24, address(this)); + uint rewardAmount = 25 ether; + rewardToken.transfer(address(rewardsCoordinator), rewardAmount); + + // Allow this test to submit roots. + cheats.startPrank(executorMultisig); + rewardsCoordinator.setRewardsUpdater(address(this)); + cheats.stopPrank(); + + // Build single-earner, single-token merkle claim. + (IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim, bytes32 rootHash) = + _buildSingleLeafClaim(address(staker), rewardToken, rewardAmount); + + rewardsCoordinator.submitRoot(rootHash, uint32(block.timestamp - 1)); + claim.rootIndex = uint32(rewardsCoordinator.getDistributionRootsLength() - 1); + + cheats.prank(address(staker)); + rewardsCoordinator.processClaim(claim, address(staker)); + assertEq(rewardToken.balanceOf(address(staker)), rewardAmount, "staker failed to claim rewards"); + } + + function test_durationVault_slashing_routes_to_insurance_and_blocks_after_maturity() public { + address insuranceRecipient = _randomInsuranceRecipient(); + DurationVaultContext memory ctx = _deployDurationVault(insuranceRecipient); + User staker = new User("duration-slash-staker"); + uint depositAmount = 200 ether; + ctx.asset.transfer(address(staker), depositAmount); + + IStrategy[] memory strategies = _durationStrategyArray(ctx.vault); + uint[] memory tokenBalances = _singleAmountArray(depositAmount); + staker.depositIntoEigenlayer(strategies, tokenBalances); + _delegateToVault(staker, ctx.vault); + ctx.vault.lock(); + + uint slashWad = 0.25e18; + IAllocationManager.SlashingParams memory slashParams; + slashParams.operator = address(ctx.vault); + slashParams.operatorSetId = ctx.operatorSet.id; + slashParams.strategies = strategies; + slashParams.wadsToSlash = _singleAmountArray(slashWad); + slashParams.description = "insurance event"; + + (uint slashId,) = ctx.avs.slashOperator(slashParams); + uint redistributed = + strategyManager.clearBurnOrRedistributableSharesByStrategy(ctx.operatorSet, slashId, IStrategy(address(ctx.vault))); + uint expectedRedistribution = (depositAmount * slashWad) / 1e18; + assertEq(redistributed, expectedRedistribution, "unexpected redistribution amount"); + assertEq(ctx.asset.balanceOf(insuranceRecipient), expectedRedistribution, "insurance recipient not paid"); + + // Mature the vault and advance beyond slashable window. + cheats.warp(block.timestamp + ctx.vault.duration() + 1); + ctx.vault.markMatured(); + cheats.roll(block.number + allocationManager.DEALLOCATION_DELAY() + 2); + + cheats.expectRevert(IAllocationManagerErrors.OperatorNotSlashable.selector); + ctx.avs.slashOperator(slashParams); + assertEq(ctx.asset.balanceOf(insuranceRecipient), expectedRedistribution, "post-maturity slash should not pay"); + } + + function test_durationVault_slashing_affectsQueuedWithdrawalsAndPaysInsurance() public { + address insuranceRecipient = _randomInsuranceRecipient(); + DurationVaultContext memory ctx = _deployDurationVault(insuranceRecipient); + User staker = new User("duration-slash-queued"); + uint depositAmount = 180 ether; + ctx.asset.transfer(address(staker), depositAmount); + + IStrategy[] memory strategies = _durationStrategyArray(ctx.vault); + uint[] memory tokenBalances = _singleAmountArray(depositAmount); + staker.depositIntoEigenlayer(strategies, tokenBalances); + _delegateToVault(staker, ctx.vault); + ctx.vault.lock(); + + uint[] memory withdrawableShares = _getStakerWithdrawableShares(staker, strategies); + Withdrawal[] memory withdrawals = staker.queueWithdrawals(strategies, withdrawableShares); + + uint slashWad = 0.3e18; + IAllocationManager.SlashingParams memory slashParams; + slashParams.operator = address(ctx.vault); + slashParams.operatorSetId = ctx.operatorSet.id; + slashParams.strategies = strategies; + slashParams.wadsToSlash = _singleAmountArray(slashWad); + slashParams.description = "queued withdrawal slash"; + + (uint slashId,) = ctx.avs.slashOperator(slashParams); + uint redistributed = + strategyManager.clearBurnOrRedistributableSharesByStrategy(ctx.operatorSet, slashId, IStrategy(address(ctx.vault))); + uint expectedRedistribution = (depositAmount * slashWad) / 1e18; + assertEq(redistributed, expectedRedistribution, "queued slash redistribution mismatch"); + assertEq(ctx.asset.balanceOf(insuranceRecipient), expectedRedistribution, "insurance recipient incorrect"); + + _rollBlocksForCompleteWithdrawals(withdrawals); + cheats.expectRevert(IDurationVaultStrategy.WithdrawalsLocked.selector); + staker.completeWithdrawalAsTokens(withdrawals[0]); + + cheats.warp(block.timestamp + ctx.vault.duration() + 1); + ctx.vault.markMatured(); + cheats.roll(block.number + allocationManager.DEALLOCATION_DELAY() + 2); + _rollBlocksForCompleteWithdrawals(withdrawals); + + IERC20[] memory tokens = staker.completeWithdrawalAsTokens(withdrawals[0]); + assertEq(address(tokens[0]), address(ctx.asset), "unexpected withdrawal token"); + assertEq( + ctx.asset.balanceOf(address(staker)), depositAmount - expectedRedistribution, "staker balance after queued slash incorrect" + ); + } + + function _deployDurationVault(address insuranceRecipient) internal returns (DurationVaultContext memory ctx) { + ERC20PresetFixedSupply asset = new ERC20PresetFixedSupply("Duration Asset", "DURA", 1e24, address(this)); + AVS avsInstance = new AVS("duration-avs"); + avsInstance.updateAVSMetadataURI("https://avs-metadata.local"); + + avsInstance.createOperatorSet(new IStrategy[](0)); + OperatorSet memory opSet = avsInstance.createRedistributingOperatorSet(new IStrategy[](0), insuranceRecipient); + + IDurationVaultStrategy.VaultConfig memory config; + config.underlyingToken = asset; + config.vaultAdmin = address(this); + config.duration = DEFAULT_DURATION; + config.maxPerDeposit = VAULT_MAX_PER_DEPOSIT; + config.stakeCap = VAULT_STAKE_CAP; + config.metadataURI = "ipfs://duration-vault"; + config.operatorSet = opSet; + config.operatorSetRegistrationData = ""; + config.delegationApprover = address(0); + config.operatorAllocationDelay = 0; + config.operatorMetadataURI = "ipfs://duration-vault/operator"; + + IDurationVaultStrategy vault = IDurationVaultStrategy(address(strategyFactory.deployDurationVaultStrategy(config))); + + IStrategy[] memory strategies = _durationStrategyArray(vault); + avsInstance.addStrategiesToOperatorSet(opSet.id, strategies); + + ctx = DurationVaultContext({vault: vault, asset: asset, avs: avsInstance, operatorSet: opSet}); + } + + function _durationStrategyArray(IDurationVaultStrategy vault) internal pure returns (IStrategy[] memory arr) { + arr = new IStrategy[](1); + arr[0] = IStrategy(address(vault)); + } + + function _singleAmountArray(uint amount) internal pure returns (uint[] memory arr) { + arr = new uint[](1); + arr[0] = amount; + } + + function _delegateToVault(User staker, IDurationVaultStrategy vault) internal { + IDelegationManager.SignatureWithExpiry memory emptySig; + cheats.startPrank(address(staker)); + delegationManager.delegateTo(address(vault), emptySig, bytes32(0)); + cheats.stopPrank(); + assertEq(delegationManager.delegatedTo(address(staker)), address(vault), "delegation failed"); + } + + function _randomInsuranceRecipient() internal view returns (address) { + return address(uint160(uint(keccak256(abi.encodePacked(block.timestamp, address(this)))))); + } + + function _buildSingleLeafClaim(address earner, IERC20 token, uint amount) + internal + pure + returns (IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim, bytes32 rootHash) + { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({token: token, cumulativeEarnings: amount}); + bytes32 tokenLeafHash = keccak256(abi.encodePacked(uint8(1), address(token), amount)); + IRewardsCoordinatorTypes.EarnerTreeMerkleLeaf memory earnerLeaf = + IRewardsCoordinatorTypes.EarnerTreeMerkleLeaf({earner: earner, earnerTokenRoot: tokenLeafHash}); + rootHash = keccak256(abi.encodePacked(uint8(0), earner, tokenLeafHash)); + + uint32[] memory tokenIndices = new uint32[](1); + tokenIndices[0] = 0; + bytes[] memory tokenProofs = new bytes[](1); + tokenProofs[0] = bytes(""); + + claim = IRewardsCoordinatorTypes.RewardsMerkleClaim({ + rootIndex: 0, + earnerIndex: 0, + earnerTreeProof: bytes(""), + earnerLeaf: earnerLeaf, + tokenIndices: tokenIndices, + tokenTreeProofs: tokenProofs, + tokenLeaves: tokenLeaves + }); + } +} + diff --git a/src/test/integration/users/AVS.t.sol b/src/test/integration/users/AVS.t.sol index 115bfb9a96..122bafb798 100644 --- a/src/test/integration/users/AVS.t.sol +++ b/src/test/integration/users/AVS.t.sol @@ -210,7 +210,7 @@ contract AVS is Logger, IAllocationManagerTypes, IAVSRegistrar { "slashOperator", string.concat( "{operator: ", - User(payable(params.operator)).NAME_COLORED(), + _formatActor(params.operator), ", operatorSetId: ", cheats.toString(params.operatorSetId), ", strategy: ", @@ -246,7 +246,7 @@ contract AVS is Logger, IAllocationManagerTypes, IAVSRegistrar { "slashOperator", string.concat( "{operator: ", - User(payable(params.operator)).NAME_COLORED(), + _formatActor(params.operator), ", operatorSetId: ", cheats.toString(params.operatorSetId), ", strategy: ", @@ -285,7 +285,7 @@ contract AVS is Logger, IAllocationManagerTypes, IAVSRegistrar { "slashOperator", string.concat( "{operator: ", - operator.NAME_COLORED(), + _formatActor(address(operator)), ", operatorSetId: ", cheats.toString(operatorSetId), ", strategy: ", @@ -412,4 +412,13 @@ contract AVS is Logger, IAllocationManagerTypes, IAVSRegistrar { function _tryPrankAppointee_AllocationManager(bytes4 selector) internal { return _tryPrankAppointee(address(allocationManager), selector); } + + function _formatActor(address actor) internal view returns (string memory) { + if (actor == address(0)) return "address(0)"; + try Logger(actor).NAME_COLORED() returns (string memory colored) { + return colored; + } catch { + return cheats.toString(actor); + } + } } diff --git a/src/test/mocks/AllocationManagerMock.sol b/src/test/mocks/AllocationManagerMock.sol index b0cfe66aa2..d34c077861 100644 --- a/src/test/mocks/AllocationManagerMock.sol +++ b/src/test/mocks/AllocationManagerMock.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.9; import "forge-std/Test.sol"; import "src/contracts/interfaces/IStrategy.sol"; +import "src/contracts/interfaces/IAllocationManager.sol"; import "src/contracts/libraries/Snapshots.sol"; import "src/contracts/libraries/OperatorSetLib.sol"; import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; @@ -28,6 +29,35 @@ contract AllocationManagerMock is Test { _minimumSlashableStake; mapping(bytes32 operatorSetKey => mapping(address operator => bool)) internal _isOperatorSlashable; + struct RegisterCall { + address operator; + address avs; + uint32[] operatorSetIds; + bytes data; + } + + struct AllocateCall { + address operator; + address avs; + uint32 operatorSetId; + IStrategy strategy; + uint64 magnitude; + } + + struct DeregisterCall { + address operator; + address avs; + uint32[] operatorSetIds; + } + + RegisterCall internal _lastRegisterCall; + AllocateCall internal _lastAllocateCall; + DeregisterCall internal _lastDeregisterCall; + + uint public registerForOperatorSetsCallCount; + uint public modifyAllocationsCallCount; + uint public deregisterFromOperatorSetsCallCount; + function getSlashCount(OperatorSet memory operatorSet) external view returns (uint) { return _getSlashCount[operatorSet.key()]; } @@ -162,4 +192,51 @@ contract AllocationManagerMock is Test { function setIsOperatorSlashable(address operator, OperatorSet memory operatorSet, bool isSlashable) external { _isOperatorSlashable[operatorSet.key()][operator] = isSlashable; } + + function registerForOperatorSets(address operator, IAllocationManager.RegisterParams calldata params) external { + registerForOperatorSetsCallCount++; + _lastRegisterCall.operator = operator; + _lastRegisterCall.avs = params.avs; + delete _lastRegisterCall.operatorSetIds; + for (uint i = 0; i < params.operatorSetIds.length; ++i) { + _lastRegisterCall.operatorSetIds.push(params.operatorSetIds[i]); + } + _lastRegisterCall.data = params.data; + } + + function lastRegisterForOperatorSetsCall() external view returns (RegisterCall memory) { + return _lastRegisterCall; + } + + function modifyAllocations(address operator, IAllocationManager.AllocateParams[] calldata params) external { + modifyAllocationsCallCount++; + delete _lastAllocateCall; + _lastAllocateCall.operator = operator; + + if (params.length > 0) { + IAllocationManager.AllocateParams calldata first = params[0]; + _lastAllocateCall.avs = first.operatorSet.avs; + _lastAllocateCall.operatorSetId = first.operatorSet.id; + if (first.strategies.length > 0) _lastAllocateCall.strategy = first.strategies[0]; + if (first.newMagnitudes.length > 0) _lastAllocateCall.magnitude = first.newMagnitudes[0]; + } + } + + function lastModifyAllocationsCall() external view returns (AllocateCall memory) { + return _lastAllocateCall; + } + + function deregisterFromOperatorSets(IAllocationManager.DeregisterParams calldata params) external { + deregisterFromOperatorSetsCallCount++; + _lastDeregisterCall.operator = params.operator; + _lastDeregisterCall.avs = params.avs; + delete _lastDeregisterCall.operatorSetIds; + for (uint i = 0; i < params.operatorSetIds.length; ++i) { + _lastDeregisterCall.operatorSetIds.push(params.operatorSetIds[i]); + } + } + + function lastDeregisterFromOperatorSetsCall() external view returns (DeregisterCall memory) { + return _lastDeregisterCall; + } } diff --git a/src/test/mocks/DelegationManagerMock.sol b/src/test/mocks/DelegationManagerMock.sol index 1c4ab66cac..2e6960d7e6 100644 --- a/src/test/mocks/DelegationManagerMock.sol +++ b/src/test/mocks/DelegationManagerMock.sol @@ -15,6 +15,16 @@ contract DelegationManagerMock is Test { mapping(address => address) public delegatedTo; mapping(address => mapping(IStrategy => uint)) public operatorShares; + struct RegisterAsOperatorCall { + address operator; + address delegationApprover; + uint32 allocationDelay; + string metadataURI; + } + + RegisterAsOperatorCall internal _lastRegisterAsOperatorCall; + uint public registerAsOperatorCallCount; + function getDelegatableShares(address staker) external view returns (IStrategy[] memory, uint[] memory) {} function setMinWithdrawalDelayBlocks(uint newMinWithdrawalDelayBlocks) external {} @@ -67,6 +77,21 @@ contract DelegationManagerMock is Test { delegatedTo[msg.sender] = operator; } + function registerAsOperator(address delegationApprover, uint32 allocationDelay, string calldata metadataURI) external { + registerAsOperatorCallCount++; + isOperator[msg.sender] = true; + _lastRegisterAsOperatorCall = RegisterAsOperatorCall({ + operator: msg.sender, + delegationApprover: delegationApprover, + allocationDelay: allocationDelay, + metadataURI: metadataURI + }); + } + + function lastRegisterAsOperatorCall() external view returns (RegisterAsOperatorCall memory) { + return _lastRegisterAsOperatorCall; + } + function undelegate(address staker) external returns (bytes32[] memory withdrawalRoot) { delegatedTo[staker] = address(0); return withdrawalRoot; diff --git a/src/test/unit/DurationVaultStrategyUnit.t.sol b/src/test/unit/DurationVaultStrategyUnit.t.sol new file mode 100644 index 0000000000..0cc0d0a26d --- /dev/null +++ b/src/test/unit/DurationVaultStrategyUnit.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "./StrategyBaseTVLLimitsUnit.sol"; +import "../../contracts/strategies/DurationVaultStrategy.sol"; +import "../../contracts/interfaces/IDurationVaultStrategy.sol"; +import "../../contracts/interfaces/IDelegationManager.sol"; +import "../../contracts/interfaces/IAllocationManager.sol"; +import "../../contracts/libraries/OperatorSetLib.sol"; +import "../mocks/DelegationManagerMock.sol"; +import "../mocks/AllocationManagerMock.sol"; + +contract DurationVaultStrategyUnitTests is StrategyBaseTVLLimitsUnitTests { + DurationVaultStrategy public durationVaultImplementation; + DurationVaultStrategy public durationVault; + DelegationManagerMock internal delegationManagerMock; + AllocationManagerMock internal allocationManagerMock; + + uint32 internal defaultDuration = uint32(30 days); + address internal constant OPERATOR_SET_AVS = address(0xA11CE); + uint32 internal constant OPERATOR_SET_ID = 42; + address internal constant DELEGATION_APPROVER = address(0xB0B); + uint32 internal constant OPERATOR_ALLOCATION_DELAY = 5; + string internal constant OPERATOR_METADATA_URI = "ipfs://operator-metadata"; + bytes internal constant REGISTRATION_DATA = hex"1234"; + uint64 internal constant FULL_ALLOCATION = 1e18; + + function setUp() public virtual override { + StrategyBaseUnitTests.setUp(); + + delegationManagerMock = new DelegationManagerMock(); + allocationManagerMock = new AllocationManagerMock(); + + durationVaultImplementation = new DurationVaultStrategy( + strategyManager, + pauserRegistry, + IDelegationManager(address(delegationManagerMock)), + IAllocationManager(address(allocationManagerMock)) + ); + + IDurationVaultStrategy.VaultConfig memory config = IDurationVaultStrategy.VaultConfig({ + underlyingToken: underlyingToken, + vaultAdmin: address(this), + duration: defaultDuration, + maxPerDeposit: maxPerDeposit, + stakeCap: maxTotalDeposits, + metadataURI: "ipfs://duration-vault", + operatorSet: OperatorSet({avs: OPERATOR_SET_AVS, id: OPERATOR_SET_ID}), + operatorSetRegistrationData: REGISTRATION_DATA, + delegationApprover: DELEGATION_APPROVER, + operatorAllocationDelay: OPERATOR_ALLOCATION_DELAY, + operatorMetadataURI: OPERATOR_METADATA_URI + }); + + durationVault = DurationVaultStrategy( + address( + new TransparentUpgradeableProxy( + address(durationVaultImplementation), + address(proxyAdmin), + abi.encodeWithSelector(DurationVaultStrategy.initialize.selector, config) + ) + ) + ); + + strategy = StrategyBase(address(durationVault)); + strategyWithTVLLimits = StrategyBaseTVLLimits(address(durationVault)); + } + + function testInitializeConfiguresOperatorIntegration() public { + DelegationManagerMock.RegisterAsOperatorCall memory delegationCall = delegationManagerMock.lastRegisterAsOperatorCall(); + assertEq(delegationCall.operator, address(durationVault), "delegation operator mismatch"); + assertEq(delegationCall.delegationApprover, DELEGATION_APPROVER, "delegation approver mismatch"); + assertEq(delegationCall.allocationDelay, OPERATOR_ALLOCATION_DELAY, "allocation delay mismatch"); + assertEq(delegationCall.metadataURI, OPERATOR_METADATA_URI, "metadata mismatch"); + + AllocationManagerMock.RegisterCall memory registerCall = allocationManagerMock.lastRegisterForOperatorSetsCall(); + assertEq(registerCall.operator, address(durationVault), "register operator mismatch"); + assertEq(registerCall.avs, OPERATOR_SET_AVS, "register AVS mismatch"); + assertEq(registerCall.operatorSetIds.length, 1, "unexpected operatorSetIds length"); + assertEq(registerCall.operatorSetIds[0], OPERATOR_SET_ID, "operatorSetId mismatch"); + assertEq(registerCall.data, REGISTRATION_DATA, "registration data mismatch"); + + (address avs, uint32 operatorSetId) = durationVault.operatorSetInfo(); + assertEq(avs, OPERATOR_SET_AVS, "stored AVS mismatch"); + assertEq(operatorSetId, OPERATOR_SET_ID, "stored operatorSetId mismatch"); + assertEq(address(durationVault.delegationManager()), address(delegationManagerMock), "delegation manager mismatch"); + assertEq(address(durationVault.allocationManager()), address(allocationManagerMock), "allocation manager mismatch"); + assertTrue(durationVault.operatorSetRegistered(), "operator set should be registered"); + } + + function testLockAllocatesFullMagnitude() public { + assertEq(allocationManagerMock.modifyAllocationsCallCount(), 0, "precondition failed"); + + durationVault.lock(); + + assertTrue(durationVault.allocationsActive(), "allocations should be active after lock"); + assertEq(allocationManagerMock.modifyAllocationsCallCount(), 1, "modifyAllocations not called"); + + AllocationManagerMock.AllocateCall memory allocateCall = allocationManagerMock.lastModifyAllocationsCall(); + assertEq(allocateCall.operator, address(durationVault), "allocate operator mismatch"); + assertEq(allocateCall.avs, OPERATOR_SET_AVS, "allocate AVS mismatch"); + assertEq(allocateCall.operatorSetId, OPERATOR_SET_ID, "allocate operatorSetId mismatch"); + assertEq(address(allocateCall.strategy), address(durationVault), "allocate strategy mismatch"); + assertEq(allocateCall.magnitude, FULL_ALLOCATION, "allocate magnitude mismatch"); + } + + function testMarkMaturedDeallocatesAndDeregisters() public { + durationVault.lock(); + cheats.warp(block.timestamp + defaultDuration + 1); + + durationVault.markMatured(); + + assertEq(allocationManagerMock.modifyAllocationsCallCount(), 2, "deallocation not invoked"); + AllocationManagerMock.AllocateCall memory allocateCall = allocationManagerMock.lastModifyAllocationsCall(); + assertEq(allocateCall.magnitude, 0, "expected zero magnitude"); + + AllocationManagerMock.DeregisterCall memory deregisterCall = allocationManagerMock.lastDeregisterFromOperatorSetsCall(); + assertEq(deregisterCall.operator, address(durationVault), "deregister operator mismatch"); + assertEq(deregisterCall.avs, OPERATOR_SET_AVS, "deregister AVS mismatch"); + assertEq(deregisterCall.operatorSetIds.length, 1, "unexpected deregister length"); + assertEq(deregisterCall.operatorSetIds[0], OPERATOR_SET_ID, "deregister operatorSetId mismatch"); + + assertFalse(durationVault.allocationsActive(), "allocations should be inactive"); + assertFalse(durationVault.operatorSetRegistered(), "operator set should be deregistered"); + } + + function testDepositsBlockedAfterLock() public { + durationVault.lock(); + + uint depositAmount = 1e18; + + underlyingToken.transfer(address(durationVault), depositAmount); + cheats.prank(address(strategyManager)); + cheats.expectRevert(IDurationVaultStrategy.DepositsLocked.selector); + durationVault.deposit(underlyingToken, depositAmount); + } + + function testWithdrawalsBlockedUntilMaturity() public { + // prepare deposit + uint depositAmount = 10 ether; + underlyingToken.transfer(address(durationVault), depositAmount); + cheats.prank(address(strategyManager)); + durationVault.deposit(underlyingToken, depositAmount); + + durationVault.lock(); + + assertTrue(durationVault.isLocked(), "vault should be locked"); + assertFalse(durationVault.withdrawalsOpen(), "withdrawals should be closed before maturity"); + + uint shares = durationVault.totalShares(); + + // Attempt withdrawal before maturity + cheats.startPrank(address(strategyManager)); + cheats.expectRevert(IDurationVaultStrategy.WithdrawalsLocked.selector); + durationVault.withdraw(address(this), underlyingToken, shares); + cheats.stopPrank(); + + cheats.warp(block.timestamp + defaultDuration + 1); + durationVault.markMatured(); + assertTrue(durationVault.withdrawalsOpen(), "withdrawals should open after maturity"); + + cheats.startPrank(address(strategyManager)); + durationVault.withdraw(address(this), underlyingToken, durationVault.totalShares()); + cheats.stopPrank(); + } +} diff --git a/src/test/unit/StrategyFactoryUnit.t.sol b/src/test/unit/StrategyFactoryUnit.t.sol index 873883ce0e..922afdee01 100644 --- a/src/test/unit/StrategyFactoryUnit.t.sol +++ b/src/test/unit/StrategyFactoryUnit.t.sol @@ -5,6 +5,11 @@ import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import "src/contracts/strategies/StrategyFactory.sol"; +import "src/contracts/strategies/DurationVaultStrategy.sol"; +import "../../contracts/interfaces/IDurationVaultStrategy.sol"; +import "../../contracts/interfaces/IDelegationManager.sol"; +import "../../contracts/interfaces/IAllocationManager.sol"; +import "../../contracts/libraries/OperatorSetLib.sol"; import "src/test/utils/EigenLayerUnitTestSetup.sol"; import "../../contracts/permissions/PauserRegistry.sol"; @@ -17,7 +22,9 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup { // Contract dependencies StrategyBase public strategyImplementation; + DurationVaultStrategy public durationVaultImplementation; UpgradeableBeacon public strategyBeacon; + UpgradeableBeacon public durationVaultBeacon; ERC20PresetFixedSupply public underlyingToken; uint initialSupply = 1e36; @@ -26,6 +33,12 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup { address notOwner = address(7_777_777); uint initialPausedStatus = 0; + address internal constant OPERATOR_SET_AVS = address(0xABCD); + uint32 internal constant OPERATOR_SET_ID = 9; + address internal constant DELEGATION_APPROVER = address(0x5151); + uint32 internal constant OPERATOR_ALLOCATION_DELAY = 4; + string internal constant OPERATOR_METADATA_URI = "ipfs://factory-duration-vault"; + bytes internal constant REGISTRATION_DATA = hex"F00D"; /// @notice Emitted when the `strategyBeacon` is changed event StrategyBeaconModified(IBeacon previousBeacon, IBeacon newBeacon); @@ -49,6 +62,15 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup { strategyBeacon = new UpgradeableBeacon(address(strategyImplementation)); strategyBeacon.transferOwnership(beaconProxyOwner); + durationVaultImplementation = new DurationVaultStrategy( + IStrategyManager(address(strategyManagerMock)), + pauserRegistry, + IDelegationManager(address(delegationManagerMock)), + IAllocationManager(address(allocationManagerMock)) + ); + durationVaultBeacon = new UpgradeableBeacon(address(durationVaultImplementation)); + durationVaultBeacon.transferOwnership(beaconProxyOwner); + strategyFactoryImplementation = new StrategyFactory(IStrategyManager(address(strategyManagerMock)), pauserRegistry); strategyFactory = StrategyFactory( @@ -56,7 +78,9 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup { new TransparentUpgradeableProxy( address(strategyFactoryImplementation), address(eigenLayerProxyAdmin), - abi.encodeWithSelector(StrategyFactory.initialize.selector, initialOwner, initialPausedStatus, strategyBeacon) + abi.encodeWithSelector( + StrategyFactory.initialize.selector, initialOwner, initialPausedStatus, strategyBeacon, durationVaultBeacon + ) ) ) ); @@ -93,7 +117,8 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup { strategyFactory.initialize({ _initialOwner: initialOwner, _initialPausedStatus: initialPausedStatus, - _strategyBeacon: strategyBeacon + _strategyBeacon: strategyBeacon, + _durationVaultBeacon: durationVaultBeacon }); } @@ -116,6 +141,80 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup { strategyFactory.deployNewStrategy(underlyingToken); } + function test_deployDurationVaultStrategy() public { + IDurationVaultStrategy.VaultConfig memory config = IDurationVaultStrategy.VaultConfig({ + underlyingToken: underlyingToken, + vaultAdmin: address(this), + duration: uint32(30 days), + maxPerDeposit: 10 ether, + stakeCap: 100 ether, + metadataURI: "ipfs://duration", + operatorSet: OperatorSet({avs: OPERATOR_SET_AVS, id: OPERATOR_SET_ID}), + operatorSetRegistrationData: REGISTRATION_DATA, + delegationApprover: DELEGATION_APPROVER, + operatorAllocationDelay: OPERATOR_ALLOCATION_DELAY, + operatorMetadataURI: OPERATOR_METADATA_URI + }); + + DurationVaultStrategy vault = DurationVaultStrategy(address(strategyFactory.deployDurationVaultStrategy(config))); + + IDurationVaultStrategy[] memory deployedVaults = strategyFactory.getDurationVaults(underlyingToken); + require(deployedVaults.length == 1, "vault not tracked"); + require(address(deployedVaults[0]) == address(vault), "vault mismatch"); + assertTrue(strategyManagerMock.strategyIsWhitelistedForDeposit(vault), "duration vault not whitelisted"); + } + + function test_deployDurationVaultStrategy_revertBeaconNotSet() public { + strategyFactory.setDurationVaultBeacon(IBeacon(address(0))); + + IDurationVaultStrategy.VaultConfig memory config = IDurationVaultStrategy.VaultConfig({ + underlyingToken: underlyingToken, + vaultAdmin: address(this), + duration: uint32(30 days), + maxPerDeposit: 10 ether, + stakeCap: 100 ether, + metadataURI: "ipfs://duration", + operatorSet: OperatorSet({avs: OPERATOR_SET_AVS, id: OPERATOR_SET_ID}), + operatorSetRegistrationData: REGISTRATION_DATA, + delegationApprover: DELEGATION_APPROVER, + operatorAllocationDelay: OPERATOR_ALLOCATION_DELAY, + operatorMetadataURI: OPERATOR_METADATA_URI + }); + + cheats.expectRevert(IStrategyFactory.DurationVaultBeaconNotSet.selector); + strategyFactory.deployDurationVaultStrategy(config); + } + + function test_deployDurationVaultStrategy_withExistingStrategy() public { + StrategyBase base = StrategyBase(address(strategyFactory.deployNewStrategy(underlyingToken))); + require(strategyFactory.deployedStrategies(underlyingToken) == base, "base strategy missing"); + + IDurationVaultStrategy.VaultConfig memory config = IDurationVaultStrategy.VaultConfig({ + underlyingToken: underlyingToken, + vaultAdmin: address(this), + duration: uint32(7 days), + maxPerDeposit: 5 ether, + stakeCap: 50 ether, + metadataURI: "ipfs://duration", + operatorSet: OperatorSet({avs: OPERATOR_SET_AVS, id: OPERATOR_SET_ID}), + operatorSetRegistrationData: REGISTRATION_DATA, + delegationApprover: DELEGATION_APPROVER, + operatorAllocationDelay: OPERATOR_ALLOCATION_DELAY, + operatorMetadataURI: OPERATOR_METADATA_URI + }); + + strategyFactory.deployDurationVaultStrategy(config); + + IDurationVaultStrategy[] memory deployedVaults = strategyFactory.getDurationVaults(underlyingToken); + require(deployedVaults.length == 1, "duration vault missing"); + assertTrue( + strategyManagerMock.strategyIsWhitelistedForDeposit(IDurationVaultStrategy(address(deployedVaults[0]))), "vault not whitelisted" + ); + + // Base mapping should remain untouched. + require(strategyFactory.deployedStrategies(underlyingToken) == base, "base strategy overwritten"); + } + function test_blacklistTokens(IERC20 token) public { IERC20[] memory tokens = new IERC20[](1); tokens[0] = token; diff --git a/src/test/unit/StrategyManagerDurationUnit.t.sol b/src/test/unit/StrategyManagerDurationUnit.t.sol new file mode 100644 index 0000000000..568327ff88 --- /dev/null +++ b/src/test/unit/StrategyManagerDurationUnit.t.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +import "src/contracts/core/StrategyManager.sol"; +import "src/contracts/strategies/DurationVaultStrategy.sol"; +import "src/contracts/interfaces/IDurationVaultStrategy.sol"; +import "src/contracts/interfaces/IStrategy.sol"; +import "src/contracts/interfaces/IDelegationManager.sol"; +import "src/contracts/interfaces/IAllocationManager.sol"; +import "src/contracts/libraries/OperatorSetLib.sol"; +import "src/test/utils/EigenLayerUnitTestSetup.sol"; + +contract StrategyManagerDurationUnitTests is EigenLayerUnitTestSetup, IStrategyManagerEvents { + StrategyManager public strategyManagerImplementation; + StrategyManager public strategyManager; + + DurationVaultStrategy public durationVaultImplementation; + IDurationVaultStrategy public durationVault; + + ERC20PresetFixedSupply public underlyingToken; + + address internal constant STAKER = address(0xBEEF); + uint internal constant INITIAL_SUPPLY = 1e36; + address internal constant OPERATOR_SET_AVS = address(0xF00D); + uint32 internal constant OPERATOR_SET_ID = 7; + address internal constant DELEGATION_APPROVER = address(0xCAFE); + uint32 internal constant OPERATOR_ALLOCATION_DELAY = 3; + string internal constant OPERATOR_METADATA_URI = "ipfs://strategy-manager-vault"; + bytes internal constant REGISTRATION_DATA = hex"DEADBEEF"; + + function setUp() public override { + EigenLayerUnitTestSetup.setUp(); + + strategyManagerImplementation = new StrategyManager( + IAllocationManager(address(allocationManagerMock)), IDelegationManager(address(delegationManagerMock)), pauserRegistry, "9.9.9" + ); + + strategyManager = StrategyManager( + address( + new TransparentUpgradeableProxy( + address(strategyManagerImplementation), + address(eigenLayerProxyAdmin), + abi.encodeWithSelector(StrategyManager.initialize.selector, address(this), address(this), 0) + ) + ) + ); + + underlyingToken = new ERC20PresetFixedSupply("Mock Token", "MOCK", INITIAL_SUPPLY, address(this)); + + durationVaultImplementation = new DurationVaultStrategy( + IStrategyManager(address(strategyManager)), + pauserRegistry, + IDelegationManager(address(delegationManagerMock)), + IAllocationManager(address(allocationManagerMock)) + ); + + IDurationVaultStrategy.VaultConfig memory cfg = IDurationVaultStrategy.VaultConfig({ + underlyingToken: IERC20(address(underlyingToken)), + vaultAdmin: address(this), + duration: uint32(30 days), + maxPerDeposit: 1_000_000 ether, + stakeCap: 10_000_000 ether, + metadataURI: "ipfs://duration-vault-test", + operatorSet: OperatorSet({avs: OPERATOR_SET_AVS, id: OPERATOR_SET_ID}), + operatorSetRegistrationData: REGISTRATION_DATA, + delegationApprover: DELEGATION_APPROVER, + operatorAllocationDelay: OPERATOR_ALLOCATION_DELAY, + operatorMetadataURI: OPERATOR_METADATA_URI + }); + + durationVault = IDurationVaultStrategy( + address( + new TransparentUpgradeableProxy( + address(durationVaultImplementation), + address(eigenLayerProxyAdmin), + abi.encodeWithSelector(DurationVaultStrategy.initialize.selector, cfg) + ) + ) + ); + + IStrategy[] memory whitelist = new IStrategy[](1); + whitelist[0] = IStrategy(address(durationVault)); + + cheats.prank(strategyManager.owner()); + strategyManager.addStrategiesToDepositWhitelist(whitelist); + } + + function testDepositIntoDurationVaultViaStrategyManager() public { + uint amount = 10 ether; + underlyingToken.transfer(STAKER, amount); + + cheats.startPrank(STAKER); + underlyingToken.approve(address(strategyManager), amount); + cheats.expectEmit(true, true, true, true, address(strategyManager)); + emit Deposit(STAKER, IStrategy(address(durationVault)), amount); + strategyManager.depositIntoStrategy(IStrategy(address(durationVault)), IERC20(address(underlyingToken)), amount); + cheats.stopPrank(); + + uint shares = strategyManager.stakerDepositShares(STAKER, IStrategy(address(durationVault))); + assertEq(shares, amount, "staker shares mismatch"); + } + + function testDepositRevertsAfterVaultLock() public { + durationVault.lock(); + + uint amount = 5 ether; + underlyingToken.transfer(STAKER, amount); + + cheats.startPrank(STAKER); + underlyingToken.approve(address(strategyManager), amount); + cheats.expectRevert(IDurationVaultStrategy.DepositsLocked.selector); + strategyManager.depositIntoStrategy(IStrategy(address(durationVault)), IERC20(address(underlyingToken)), amount); + cheats.stopPrank(); + } + + function _depositFor(address staker, uint amount) internal { + underlyingToken.transfer(staker, amount); + cheats.startPrank(staker); + underlyingToken.approve(address(strategyManager), amount); + strategyManager.depositIntoStrategy(IStrategy(address(durationVault)), IERC20(address(underlyingToken)), amount); + cheats.stopPrank(); + } + + function testWithdrawalsBlockedViaStrategyManagerBeforeMaturity() public { + uint amount = 8 ether; + _depositFor(STAKER, amount); + durationVault.lock(); + + uint shares = strategyManager.stakerDepositShares(STAKER, IStrategy(address(durationVault))); + + cheats.prank(address(delegationManagerMock)); + cheats.expectRevert(IDurationVaultStrategy.WithdrawalsLocked.selector); + strategyManager.withdrawSharesAsTokens(STAKER, IStrategy(address(durationVault)), IERC20(address(underlyingToken)), shares); + } + + function testWithdrawalsAllowedAfterMaturity() public { + uint amount = 6 ether; + _depositFor(STAKER, amount); + durationVault.lock(); + + cheats.warp(block.timestamp + durationVault.duration() + 1); + durationVault.markMatured(); + + uint shares = strategyManager.stakerDepositShares(STAKER, IStrategy(address(durationVault))); + + cheats.prank(address(delegationManagerMock)); + strategyManager.removeDepositShares(STAKER, IStrategy(address(durationVault)), shares); + + uint balanceBefore = underlyingToken.balanceOf(STAKER); + + cheats.prank(address(delegationManagerMock)); + strategyManager.withdrawSharesAsTokens(STAKER, IStrategy(address(durationVault)), IERC20(address(underlyingToken)), shares); + + assertEq(strategyManager.stakerDepositShares(STAKER, IStrategy(address(durationVault))), 0, "shares should be zero after removal"); + assertEq(underlyingToken.balanceOf(STAKER), balanceBefore + amount, "staker did not receive withdrawn tokens"); + } +}