diff --git a/src/contracts/interfaces/IDurationVaultStrategy.sol b/src/contracts/interfaces/IDurationVaultStrategy.sol index af7debc073..fedc74abe0 100644 --- a/src/contracts/interfaces/IDurationVaultStrategy.sol +++ b/src/contracts/interfaces/IDurationVaultStrategy.sol @@ -39,6 +39,15 @@ interface IDurationVaultStrategyErrors { error OperatorIntegrationInvalid(); /// @dev Thrown when attempting to deposit into a vault whose underlying token is blacklisted. error UnderlyingTokenBlacklisted(); + + /// @dev Thrown when a deposit exceeds the configured `maxPerDeposit` limit. + error DepositExceedsMaxPerDeposit(); + + /// @dev Thrown when attempting to lock with an operator set that doesn't include this strategy. + error StrategyNotSupportedByOperatorSet(); + + /// @dev Thrown when attempting to allocate while a pending allocation modification already exists. + error PendingAllocation(); } /// @title Types for IDurationVaultStrategy @@ -58,6 +67,7 @@ interface IDurationVaultStrategyTypes { /// @notice Configuration parameters for initializing a duration vault. /// @param underlyingToken The ERC20 token that stakers deposit into this vault. /// @param vaultAdmin The address authorized to manage vault configuration. + /// @param arbitrator The address authorized to call `advanceToWithdrawals` for early maturity. /// @param duration The lock period in seconds after which the vault matures. /// @param maxPerDeposit Maximum amount of underlying tokens accepted per deposit. /// @param stakeCap Maximum total underlying tokens the vault will accept. @@ -118,6 +128,20 @@ interface IDurationVaultStrategyEvents { /// @notice Emitted when the vault metadata URI is updated. /// @param newMetadataURI The new metadata URI. event MetadataURIUpdated(string newMetadataURI); + + /// @notice Emitted when deallocation from the operator set is attempted. + /// @param success Whether the deallocation call succeeded. + event DeallocateAttempted(bool success); + + /// @notice Emitted when deregistration from the operator set is attempted. + /// @param success Whether the deregistration call succeeded. + event DeregisterAttempted(bool success); + + /// @notice Emitted when `maxPerDeposit` value is updated from `previousValue` to `newValue`. + event MaxPerDepositUpdated(uint256 previousValue, uint256 newValue); + + /// @notice Emitted when `maxTotalDeposits` value is updated from `previousValue` to `newValue`. + event MaxTotalDepositsUpdated(uint256 previousValue, uint256 newValue); } /// @title Interface for time-bound EigenLayer vault strategies. @@ -166,6 +190,27 @@ interface IDurationVaultStrategy is string calldata newMetadataURI ) external; + /// @notice Updates the delegation approver used for operator delegation approvals. + /// @param newDelegationApprover The new delegation approver (0x0 for open delegation). + /// @dev Only callable by the vault admin. + function updateDelegationApprover( + address newDelegationApprover + ) external; + + /// @notice Updates the operator metadata URI emitted by the DelegationManager. + /// @param newOperatorMetadataURI The new operator metadata URI. + /// @dev Only callable by the vault admin. + function updateOperatorMetadataURI( + string calldata newOperatorMetadataURI + ) external; + + /// @notice Sets the claimer for operator rewards accrued to the vault. + /// @param claimer The address authorized to claim rewards for the vault. + /// @dev Only callable by the vault admin. + function setRewardsClaimer( + address claimer + ) external; + /// @notice Updates the TVL limits for max deposit per transaction and total stake cap. /// @param newMaxPerDeposit New maximum deposit amount per transaction. /// @param newStakeCap New maximum total deposits allowed. diff --git a/src/contracts/strategies/DurationVaultStrategy.sol b/src/contracts/strategies/DurationVaultStrategy.sol index 0d052a271b..a3625dbc98 100644 --- a/src/contracts/strategies/DurationVaultStrategy.sol +++ b/src/contracts/strategies/DurationVaultStrategy.sol @@ -16,15 +16,6 @@ import "../libraries/OperatorSetLib.sol"; contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { using OperatorSetLib for OperatorSet; - /// @dev Thrown when attempting to allocate while a pending allocation modification already exists. - error PendingAllocation(); - - /// @notice Emitted when `maxPerDeposit` value is updated from `previousValue` to `newValue`. - event MaxPerDepositUpdated(uint256 previousValue, uint256 newValue); - - /// @notice Emitted when `maxTotalDeposits` value is updated from `previousValue` to `newValue`. - event MaxTotalDepositsUpdated(uint256 previousValue, uint256 newValue); - /// @notice Delegation manager reference used to register the vault as an operator. IDelegationManager public immutable override delegationManager; @@ -109,6 +100,17 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { function lock() external override onlyVaultAdmin { require(depositsOpen(), VaultAlreadyLocked()); + // Verify this strategy is supported by the operator set before allocating. + IStrategy[] memory strategies = allocationManager.getStrategiesInOperatorSet(_operatorSet); + bool supported = false; + for (uint256 i = 0; i < strategies.length; ++i) { + if (strategies[i] == IStrategy(address(this))) { + supported = true; + break; + } + } + require(supported, StrategyNotSupportedByOperatorSet()); + uint32 currentTimestamp = uint32(block.timestamp); lockedAt = currentTimestamp; unlockAt = currentTimestamp + duration; @@ -123,7 +125,7 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { /// @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 + _attemptOperatorCleanup(); return; } require(_state == VaultState.ALLOCATIONS, DurationNotElapsed()); @@ -132,15 +134,14 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { maturedAt = uint32(block.timestamp); emit VaultMatured(maturedAt); - _deallocateAll(); - _deregisterFromOperatorSet(); + _attemptOperatorCleanup(); } /// @notice Advances the vault to withdrawals early, after lock but before duration elapses. /// @dev Only callable by the configured arbitrator. function advanceToWithdrawals() external override onlyArbitrator { if (_state == VaultState.WITHDRAWALS) { - // already recorded; noop + _attemptOperatorCleanup(); return; } require(_state == VaultState.ALLOCATIONS, VaultNotLocked()); @@ -152,8 +153,7 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { emit VaultMatured(maturedAt); emit VaultAdvancedToWithdrawals(msg.sender, maturedAt); - _deallocateAll(); - _deregisterFromOperatorSet(); + _attemptOperatorCleanup(); } /// @notice Updates the metadata URI describing the vault. @@ -164,6 +164,27 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { emit MetadataURIUpdated(newMetadataURI); } + /// @notice Updates the delegation approver used for operator delegation approvals. + function updateDelegationApprover( + address newDelegationApprover + ) external override onlyVaultAdmin { + delegationManager.modifyOperatorDetails(address(this), newDelegationApprover); + } + + /// @notice Updates the operator metadata URI emitted by the DelegationManager. + function updateOperatorMetadataURI( + string calldata newOperatorMetadataURI + ) external override onlyVaultAdmin { + delegationManager.updateOperatorMetadataURI(address(this), newOperatorMetadataURI); + } + + /// @notice Sets the claimer for operator rewards accrued to the vault. + function setRewardsClaimer( + address claimer + ) external override onlyVaultAdmin { + rewardsCoordinator.setClaimerFor(address(this), claimer); + } + /// @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( @@ -236,7 +257,7 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { // Enforce per-deposit cap using the minted shares as proxy for underlying. uint256 amountUnderlying = sharesToUnderlyingView(shares); - require(amountUnderlying <= maxPerDeposit, MaxPerDepositExceedsMax()); + require(amountUnderlying <= maxPerDeposit, DepositExceedsMaxPerDeposit()); // Enforce total cap using operatorShares (active, non-queued shares). // At this point, operatorShares hasn't been updated yet, so we add the new shares. @@ -283,7 +304,7 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { /// @inheritdoc IDurationVaultStrategy function operatorSetRegistered() public view override returns (bool) { - return _state == VaultState.DEPOSITS || _state == VaultState.ALLOCATIONS; + return allocationManager.isMemberOfOperatorSet(address(this), _operatorSet); } /// @inheritdoc IDurationVaultStrategy @@ -346,9 +367,18 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { allocationManager.modifyAllocations(address(this), params); } - /// @notice Deallocates all magnitude from the configured operator set. + /// @notice Attempts to deallocate all magnitude from the configured operator set. /// @dev Best-effort: failures are ignored to avoid bricking `markMatured()`. - function _deallocateAll() internal { + function _deallocateAll() internal returns (bool) { + IAllocationManager.Allocation memory alloc = + allocationManager.getAllocation(address(this), _operatorSet, IStrategy(address(this))); + if (alloc.currentMagnitude == 0 && alloc.pendingDiff == 0) { + return true; + } + // If an allocation modification is pending, wait until it clears. + if (alloc.effectBlock != 0) { + return false; + } IAllocationManager.AllocateParams[] memory params = new IAllocationManager.AllocateParams[](1); params[0].operatorSet = _operatorSet; params[0].strategies = new IStrategy[](1); @@ -359,12 +389,15 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { // We use a low-level call instead of try/catch to avoid wallet gas-estimation pitfalls. (bool success,) = address(allocationManager) .call(abi.encodeWithSelector(IAllocationManagerActions.modifyAllocations.selector, address(this), params)); - success; // suppress unused variable warning + return success; } - /// @notice Deregisters the vault from its configured operator set. + /// @notice Attempts to deregister the vault from its configured operator set. /// @dev Best-effort: failures are ignored to avoid bricking `markMatured()`. - function _deregisterFromOperatorSet() internal { + function _deregisterFromOperatorSet() internal returns (bool) { + if (!allocationManager.isMemberOfOperatorSet(address(this), _operatorSet)) { + return true; + } IAllocationManager.DeregisterParams memory params; params.operator = address(this); params.avs = _operatorSet.avs; @@ -374,6 +407,15 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase { // We use a low-level call instead of try/catch to avoid wallet gas-estimation pitfalls. (bool success,) = address(allocationManager) .call(abi.encodeWithSelector(IAllocationManagerActions.deregisterFromOperatorSets.selector, params)); - success; // suppress unused variable warning + return success; + } + + /// @notice Best-effort cleanup after maturity, with retry tracking. + function _attemptOperatorCleanup() internal { + bool deallocateSuccess = _deallocateAll(); + emit DeallocateAttempted(deallocateSuccess); + + bool deregisterSuccess = _deregisterFromOperatorSet(); + emit DeregisterAttempted(deregisterSuccess); } } diff --git a/src/test/integration/tests/DurationVaultIntegration.t.sol b/src/test/integration/tests/DurationVaultIntegration.t.sol index e860005013..af093a7006 100644 --- a/src/test/integration/tests/DurationVaultIntegration.t.sol +++ b/src/test/integration/tests/DurationVaultIntegration.t.sol @@ -281,7 +281,7 @@ contract Integration_DurationVault is IntegrationCheckUtils { uint[] memory amounts = _singleAmountArray(60 ether); ctx.asset.transfer(address(staker), amounts[0]); _delegateToVault(staker, ctx.vault); - cheats.expectRevert(IStrategyErrors.MaxPerDepositExceedsMax.selector); + cheats.expectRevert(IDurationVaultStrategyErrors.DepositExceedsMaxPerDeposit.selector); staker.depositIntoEigenlayer(strategies, amounts); // Deposit within limits. diff --git a/src/test/mocks/AllocationManagerMock.sol b/src/test/mocks/AllocationManagerMock.sol index eedbc5993a..a2bf5a8e6b 100644 --- a/src/test/mocks/AllocationManagerMock.sol +++ b/src/test/mocks/AllocationManagerMock.sol @@ -30,6 +30,7 @@ contract AllocationManagerMock is Test { mapping(bytes32 operatorSetKey => mapping(address operator => bool)) internal _isOperatorSlashable; mapping(address operator => mapping(bytes32 operatorSetKey => mapping(IStrategy strategy => IAllocationManagerTypes.Allocation))) internal _allocations; + mapping(bytes32 operatorSetKey => mapping(address operator => bool)) internal _isMemberOfOperatorSet; struct RegisterCall { address operator; @@ -101,6 +102,10 @@ contract AllocationManagerMock is Test { return _isOperatorSet[operatorSet.key()]; } + function isMemberOfOperatorSet(address operator, OperatorSet memory operatorSet) external view returns (bool) { + return _isMemberOfOperatorSet[operatorSet.key()][operator]; + } + function setMaxMagnitudes(address operator, IStrategy[] calldata strategies, uint64[] calldata maxMagnitudes) external { for (uint i = 0; i < strategies.length; ++i) { setMaxMagnitude(operator, strategies[i], maxMagnitudes[i]); @@ -213,6 +218,7 @@ contract AllocationManagerMock is Test { delete _lastRegisterCall.operatorSetIds; for (uint i = 0; i < params.operatorSetIds.length; ++i) { _lastRegisterCall.operatorSetIds.push(params.operatorSetIds[i]); + _isMemberOfOperatorSet[OperatorSet({avs: params.avs, id: params.operatorSetIds[i]}).key()][operator] = true; } _lastRegisterCall.data = params.data; } @@ -260,6 +266,7 @@ contract AllocationManagerMock is Test { delete _lastDeregisterCall.operatorSetIds; for (uint i = 0; i < params.operatorSetIds.length; ++i) { _lastDeregisterCall.operatorSetIds.push(params.operatorSetIds[i]); + _isMemberOfOperatorSet[OperatorSet({avs: params.avs, id: params.operatorSetIds[i]}).key()][params.operator] = false; } } diff --git a/src/test/mocks/DelegationManagerMock.sol b/src/test/mocks/DelegationManagerMock.sol index 2bc1e5c5c4..49907fec10 100644 --- a/src/test/mocks/DelegationManagerMock.sol +++ b/src/test/mocks/DelegationManagerMock.sol @@ -27,6 +27,21 @@ contract DelegationManagerMock is Test { RegisterAsOperatorCall internal _lastRegisterAsOperatorCall; uint public registerAsOperatorCallCount; + struct ModifyOperatorDetailsCall { + address operator; + address newDelegationApprover; + } + + struct UpdateOperatorMetadataURICall { + address operator; + string metadataURI; + } + + ModifyOperatorDetailsCall internal _lastModifyOperatorDetailsCall; + UpdateOperatorMetadataURICall internal _lastUpdateOperatorMetadataURICall; + uint public modifyOperatorDetailsCallCount; + uint public updateOperatorMetadataURICallCount; + function getDelegatableShares(address staker) external view returns (IStrategy[] memory, uint[] memory) {} function setMinWithdrawalDelayBlocks(uint32 newMinWithdrawalDelayBlocks) external { @@ -96,10 +111,28 @@ contract DelegationManagerMock is Test { }); } + function modifyOperatorDetails(address operator, address newDelegationApprover) external { + modifyOperatorDetailsCallCount++; + _lastModifyOperatorDetailsCall = ModifyOperatorDetailsCall({operator: operator, newDelegationApprover: newDelegationApprover}); + } + + function updateOperatorMetadataURI(address operator, string calldata metadataURI) external { + updateOperatorMetadataURICallCount++; + _lastUpdateOperatorMetadataURICall = UpdateOperatorMetadataURICall({operator: operator, metadataURI: metadataURI}); + } + function lastRegisterAsOperatorCall() external view returns (RegisterAsOperatorCall memory) { return _lastRegisterAsOperatorCall; } + function lastModifyOperatorDetailsCall() external view returns (ModifyOperatorDetailsCall memory) { + return _lastModifyOperatorDetailsCall; + } + + function lastUpdateOperatorMetadataURICall() external view returns (UpdateOperatorMetadataURICall memory) { + return _lastUpdateOperatorMetadataURICall; + } + function undelegate(address staker) external returns (bytes32[] memory withdrawalRoot) { delegatedTo[staker] = address(0); return withdrawalRoot; diff --git a/src/test/mocks/RewardsCoordinatorMock.sol b/src/test/mocks/RewardsCoordinatorMock.sol index df115985ed..de62451b2c 100644 --- a/src/test/mocks/RewardsCoordinatorMock.sol +++ b/src/test/mocks/RewardsCoordinatorMock.sol @@ -22,10 +22,17 @@ contract RewardsCoordinatorMock is Test { uint16 split; } + struct SetClaimerForCall { + address earner; + address claimer; + } + SetOperatorAVSSplitCall internal _lastSetOperatorAVSSplitCall; SetOperatorSetSplitCall internal _lastSetOperatorSetSplitCall; + SetClaimerForCall internal _lastSetClaimerForCall; uint public setOperatorAVSSplitCallCount; uint public setOperatorSetSplitCallCount; + uint public setClaimerForCallCount; function setOperatorAVSSplit(address operator, address avs, uint16 split) external { setOperatorAVSSplitCallCount++; @@ -37,6 +44,11 @@ contract RewardsCoordinatorMock is Test { _lastSetOperatorSetSplitCall = SetOperatorSetSplitCall({operator: operator, operatorSet: operatorSet, split: split}); } + function setClaimerFor(address earner, address claimer) external { + setClaimerForCallCount++; + _lastSetClaimerForCall = SetClaimerForCall({earner: earner, claimer: claimer}); + } + function lastSetOperatorAVSSplitCall() external view returns (SetOperatorAVSSplitCall memory) { return _lastSetOperatorAVSSplitCall; } @@ -44,4 +56,8 @@ contract RewardsCoordinatorMock is Test { function lastSetOperatorSetSplitCall() external view returns (SetOperatorSetSplitCall memory) { return _lastSetOperatorSetSplitCall; } + + function lastSetClaimerForCall() external view returns (SetClaimerForCall memory) { + return _lastSetClaimerForCall; + } } diff --git a/src/test/unit/DurationVaultStrategyUnit.t.sol b/src/test/unit/DurationVaultStrategyUnit.t.sol index 1cbf2f8cc7..bcaf78bbf8 100644 --- a/src/test/unit/DurationVaultStrategyUnit.t.sol +++ b/src/test/unit/DurationVaultStrategyUnit.t.sol @@ -80,6 +80,11 @@ contract DurationVaultStrategyUnitTests is StrategyBaseUnitTests { // Set the strategy for inherited tests strategy = StrategyBase(address(durationVault)); + + // Configure the mock to return the vault as a supported strategy in the operator set + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = IStrategy(address(durationVault)); + allocationManagerMock.setStrategiesInOperatorSet(OperatorSet({avs: OPERATOR_SET_AVS, id: OPERATOR_SET_ID}), strategies); } // ===================== OPERATOR INTEGRATION TESTS ===================== @@ -168,13 +173,61 @@ contract DurationVaultStrategyUnitTests is StrategyBaseUnitTests { assertTrue(durationVault.withdrawalsOpen(), "withdrawals must open after maturity"); assertFalse(durationVault.allocationsActive(), "allocations should be inactive after maturity"); - assertFalse(durationVault.operatorSetRegistered(), "operator set should be unregistered after maturity"); + assertTrue(durationVault.operatorSetRegistered(), "operator set should remain registered on failure"); // Since the mock reverts before incrementing, only the initial lock allocation is recorded. assertEq(allocationManagerMock.modifyAllocationsCallCount(), 1, "unexpected modifyAllocations count"); assertEq(allocationManagerMock.deregisterFromOperatorSetsCallCount(), 0, "unexpected deregister count"); } + function testMarkMaturedCanRetryOperatorCleanup() public { + durationVault.lock(); + cheats.warp(block.timestamp + defaultDuration + 1); + + allocationManagerMock.setRevertModifyAllocations(true); + allocationManagerMock.setRevertDeregisterFromOperatorSets(true); + + durationVault.markMatured(); + assertTrue(durationVault.operatorSetRegistered(), "operator set should remain registered after failure"); + + allocationManagerMock.setRevertModifyAllocations(false); + allocationManagerMock.setRevertDeregisterFromOperatorSets(false); + + // markMatured is a permissionless retry path once in WITHDRAWALS. + durationVault.markMatured(); + + assertEq(allocationManagerMock.modifyAllocationsCallCount(), 2, "deallocation should be retried"); + assertEq(allocationManagerMock.deregisterFromOperatorSetsCallCount(), 1, "deregistration should be retried"); + assertFalse(durationVault.operatorSetRegistered(), "operator set should be deregistered after retry"); + } + + function testUpdateDelegationApprover() public { + address newApprover = address(0xDE1E6A7E); + durationVault.updateDelegationApprover(newApprover); + + DelegationManagerMock.ModifyOperatorDetailsCall memory callDetails = delegationManagerMock.lastModifyOperatorDetailsCall(); + assertEq(callDetails.operator, address(durationVault), "operator mismatch"); + assertEq(callDetails.newDelegationApprover, newApprover, "delegation approver mismatch"); + } + + function testUpdateOperatorMetadataURI() public { + string memory newURI = "ipfs://updated-operator-metadata"; + durationVault.updateOperatorMetadataURI(newURI); + + DelegationManagerMock.UpdateOperatorMetadataURICall memory callDetails = delegationManagerMock.lastUpdateOperatorMetadataURICall(); + assertEq(callDetails.operator, address(durationVault), "operator mismatch"); + assertEq(callDetails.metadataURI, newURI, "metadata URI mismatch"); + } + + function testSetRewardsClaimer() public { + address claimer = address(0xC1A1A3); + durationVault.setRewardsClaimer(claimer); + + RewardsCoordinatorMock.SetClaimerForCall memory callDetails = rewardsCoordinatorMock.lastSetClaimerForCall(); + assertEq(callDetails.earner, address(durationVault), "earner mismatch"); + assertEq(callDetails.claimer, claimer, "claimer mismatch"); + } + function testAdvanceToWithdrawals_onlyArbitrator_and_onlyBeforeUnlock() public { // Cannot advance before lock (even as arbitrator). cheats.expectRevert(IDurationVaultStrategyErrors.VaultNotLocked.selector); @@ -280,7 +333,7 @@ contract DurationVaultStrategyUnitTests is StrategyBaseUnitTests { cheats.startPrank(address(strategyManager)); uint shares = durationVault.deposit(underlyingToken, depositAmount); - cheats.expectRevert(IStrategyErrors.MaxPerDepositExceedsMax.selector); + cheats.expectRevert(IDurationVaultStrategyErrors.DepositExceedsMaxPerDeposit.selector); durationVault.beforeAddShares(staker, shares); cheats.stopPrank(); } diff --git a/src/test/unit/StrategyManagerDurationUnit.t.sol b/src/test/unit/StrategyManagerDurationUnit.t.sol index 80b119559b..b8d3690f72 100644 --- a/src/test/unit/StrategyManagerDurationUnit.t.sol +++ b/src/test/unit/StrategyManagerDurationUnit.t.sol @@ -94,6 +94,11 @@ contract StrategyManagerDurationUnitTests is EigenLayerUnitTestSetup, IStrategyM ) ); + // Configure the mock to return the vault as a supported strategy in the operator set + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = IStrategy(address(durationVault)); + allocationManagerMock.setStrategiesInOperatorSet(OperatorSet({avs: OPERATOR_SET_AVS, id: OPERATOR_SET_ID}), strategies); + IStrategy[] memory whitelist = new IStrategy[](1); whitelist[0] = IStrategy(address(durationVault));