@@ -49,7 +49,10 @@ abstract contract VaultHub is PausableUntilWithRoles {
4949 uint16 treasuryFeeBP;
5050 /// @notice if true, vault is disconnected and fee is not accrued
5151 bool isDisconnected;
52- // ### we have 104 bits left in this slot
52+ /// @notice timestamp when the vault can force withdraw in case it is unbalanced
53+ /// @dev 0 if the vault is currently balanced
54+ uint40 forceWithdrawalUnlockTime;
55+ // ### we have 64 bits left in this slot
5356 }
5457
5558 // keccak256(abi.encode(uint256(keccak256("VaultHub")) - 1)) & ~bytes32(uint256(0xff))
@@ -69,6 +72,9 @@ abstract contract VaultHub is PausableUntilWithRoles {
6972 /// @notice amount of ETH that is locked on the vault on connect and can be withdrawn on disconnect only
7073 uint256 internal constant CONNECT_DEPOSIT = 1 ether ;
7174
75+ /// @notice Time-lock for force validator withdrawal
76+ uint256 public constant FORCE_WITHDRAWAL_TIMELOCK = 3 days ;
77+
7278 /// @notice Lido stETH contract
7379 IStETH public immutable STETH;
7480
@@ -83,7 +89,7 @@ abstract contract VaultHub is PausableUntilWithRoles {
8389 function __VaultHub_init (address _admin ) internal onlyInitializing {
8490 __AccessControlEnumerable_init ();
8591 // the stone in the elevator
86- _getVaultHubStorage ().sockets.push (VaultSocket (address (0 ), 0 , 0 , 0 , 0 , 0 , false ));
92+ _getVaultHubStorage ().sockets.push (VaultSocket (address (0 ), 0 , 0 , 0 , 0 , 0 , false , 0 ));
8793
8894 _grantRole (DEFAULT_ADMIN_ROLE, _admin);
8995 }
@@ -159,7 +165,8 @@ abstract contract VaultHub is PausableUntilWithRoles {
159165 uint16 (_reserveRatioBP),
160166 uint16 (_reserveRatioThresholdBP),
161167 uint16 (_treasuryFeeBP),
162- false // isDisconnected
168+ false , // isDisconnected
169+ 0 // forceWithdrawalUnlockTime
163170 );
164171 $.vaultIndex[_vault] = $.sockets.length ;
165172 $.sockets.push (vr);
@@ -265,6 +272,8 @@ abstract contract VaultHub is PausableUntilWithRoles {
265272
266273 STETH.burnExternalShares (_amountOfShares);
267274
275+ _updateUnbalancedState (_vault, socket);
276+
268277 emit BurnedSharesOnVault (_vault, _amountOfShares);
269278 }
270279
@@ -312,6 +321,8 @@ abstract contract VaultHub is PausableUntilWithRoles {
312321
313322 // TODO: add some gas compensation here
314323 IStakingVault (_vault).rebalance (amountToRebalance);
324+
325+ // NB: check _updateUnbalancedState is calculated in rebalance() triggered from the `StakingVault`.
315326 }
316327
317328 /// @notice rebalances the vault by writing off the amount of ether equal
@@ -330,10 +341,24 @@ abstract contract VaultHub is PausableUntilWithRoles {
330341
331342 STETH.rebalanceExternalEtherToInternal {value: msg .value }();
332343
344+ // Check if vault is still unbalanced after rebalance
345+ _updateUnbalancedState (msg .sender , socket);
346+
333347 emit VaultRebalanced (msg .sender , sharesToBurn);
334348 }
335349
336- /// @notice force validator withdrawal from the beacon chain in case the vault is unbalanced
350+ /// @notice checks if the vault can force withdraw
351+ /// @param _vault vault address
352+ /// @return bool whether the vault can force withdraw
353+ function canForceValidatorWithdrawal (address _vault ) public view returns (bool ) {
354+ uint40 forceWithdrawalUnlockTime = _connectedSocket (_vault).forceWithdrawalUnlockTime;
355+
356+ if (forceWithdrawalUnlockTime == 0 ) return false ;
357+
358+ return block .timestamp >= forceWithdrawalUnlockTime;
359+ }
360+
361+ /// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced
337362 /// @param _vault vault address
338363 /// @param _pubkeys pubkeys of the validators to withdraw
339364 function forceValidatorWithdrawal (address _vault , bytes calldata _pubkeys ) external payable {
@@ -347,6 +372,10 @@ abstract contract VaultHub is PausableUntilWithRoles {
347372 revert AlreadyBalanced (_vault, socket.sharesMinted, threshold);
348373 }
349374
375+ if (! canForceValidatorWithdrawal (_vault)) {
376+ revert ForceWithdrawalTimelockActive (_vault, socket.forceWithdrawalUnlockTime);
377+ }
378+
350379 IStakingVault (_vault).forceValidatorWithdrawal {value: msg .value }(_pubkeys);
351380
352381 emit VaultForceWithdrawalInitiated (_vault, _pubkeys);
@@ -443,7 +472,7 @@ abstract contract VaultHub is PausableUntilWithRoles {
443472
444473 // TODO: optimize potential rewards calculation
445474 uint256 potentialRewards = ((chargeableValue * (_postTotalPooledEther * _preTotalShares)) /
446- (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue);
475+ (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue);
447476 uint256 treasuryFee = (potentialRewards * _socket.treasuryFeeBP) / TOTAL_BASIS_POINTS;
448477
449478 treasuryFeeShares = (treasuryFee * _preTotalShares) / _preTotalPooledEther;
@@ -466,6 +495,9 @@ abstract contract VaultHub is PausableUntilWithRoles {
466495 if (treasuryFeeShares > 0 ) {
467496 socket.sharesMinted += uint96 (treasuryFeeShares);
468497 }
498+
499+ _updateUnbalancedState (socket.vault, socket);
500+
469501 IStakingVault (socket.vault).report (_valuations[i], _inOutDeltas[i], _locked[i]);
470502 }
471503
@@ -485,6 +517,25 @@ abstract contract VaultHub is PausableUntilWithRoles {
485517 }
486518 }
487519
520+ function _updateUnbalancedState (address _vault , VaultSocket storage _socket ) internal {
521+ uint256 threshold = _maxMintableShares (_vault, _socket.reserveRatioThresholdBP, _socket.shareLimit);
522+ bool isUnbalanced = _socket.sharesMinted > threshold;
523+ uint40 currentUnlockTime = _socket.forceWithdrawalUnlockTime;
524+
525+ if (isUnbalanced) {
526+ if (currentUnlockTime == 0 ) {
527+ uint40 newUnlockTime = uint40 (block .timestamp + FORCE_WITHDRAWAL_TIMELOCK);
528+ _socket.forceWithdrawalUnlockTime = newUnlockTime;
529+ emit VaultBecameUnbalanced (_vault, newUnlockTime);
530+ }
531+ } else {
532+ if (currentUnlockTime != 0 ) {
533+ _socket.forceWithdrawalUnlockTime = 0 ;
534+ emit VaultBecameBalanced (_vault);
535+ }
536+ }
537+ }
538+
488539 function _vaultAuth (address _vault , string memory _operation ) internal view {
489540 if (msg .sender != OwnableUpgradeable (_vault).owner ()) revert NotAuthorized (_operation, msg .sender );
490541 }
@@ -500,7 +551,7 @@ abstract contract VaultHub is PausableUntilWithRoles {
500551 /// it does not count shares that is already minted, but does count shareLimit on the vault
501552 function _maxMintableShares (address _vault , uint256 _reserveRatio , uint256 _shareLimit ) internal view returns (uint256 ) {
502553 uint256 maxStETHMinted = (IStakingVault (_vault).valuation () * (TOTAL_BASIS_POINTS - _reserveRatio)) /
503- TOTAL_BASIS_POINTS;
554+ TOTAL_BASIS_POINTS;
504555
505556 return Math256.min (STETH.getSharesByPooledEth (maxStETHMinted), _shareLimit);
506557 }
@@ -528,6 +579,8 @@ abstract contract VaultHub is PausableUntilWithRoles {
528579 event VaultRebalanced (address indexed vault , uint256 sharesBurned );
529580 event VaultProxyCodehashAdded (bytes32 indexed codehash );
530581 event VaultForceWithdrawalInitiated (address indexed vault , bytes pubkeys );
582+ event VaultBecameUnbalanced (address indexed vault , uint40 unlockTime );
583+ event VaultBecameBalanced (address indexed vault );
531584
532585 error StETHMintFailed (address vault );
533586 error AlreadyBalanced (address vault , uint256 mintedShares , uint256 rebalancingThresholdInShares );
@@ -548,4 +601,5 @@ abstract contract VaultHub is PausableUntilWithRoles {
548601 error AlreadyExists (bytes32 codehash );
549602 error NoMintedSharesShouldBeLeft (address vault , uint256 sharesMinted );
550603 error VaultProxyNotAllowed (address beacon );
604+ error ForceWithdrawalTimelockActive (address vault , uint256 unlockTime );
551605}
0 commit comments