@@ -49,7 +49,10 @@ abstract contract VaultHub is PausableUntilWithRoles {
49
49
uint16 treasuryFeeBP;
50
50
/// @notice if true, vault is disconnected and fee is not accrued
51
51
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
53
56
}
54
57
55
58
// keccak256(abi.encode(uint256(keccak256("VaultHub")) - 1)) & ~bytes32(uint256(0xff))
@@ -69,6 +72,9 @@ abstract contract VaultHub is PausableUntilWithRoles {
69
72
/// @notice amount of ETH that is locked on the vault on connect and can be withdrawn on disconnect only
70
73
uint256 internal constant CONNECT_DEPOSIT = 1 ether ;
71
74
75
+ /// @notice Time-lock for force validator withdrawal
76
+ uint256 public constant FORCE_WITHDRAWAL_TIMELOCK = 3 days ;
77
+
72
78
/// @notice Lido stETH contract
73
79
IStETH public immutable STETH;
74
80
@@ -83,7 +89,7 @@ abstract contract VaultHub is PausableUntilWithRoles {
83
89
function __VaultHub_init (address _admin ) internal onlyInitializing {
84
90
__AccessControlEnumerable_init ();
85
91
// 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 ));
87
93
88
94
_grantRole (DEFAULT_ADMIN_ROLE, _admin);
89
95
}
@@ -159,7 +165,8 @@ abstract contract VaultHub is PausableUntilWithRoles {
159
165
uint16 (_reserveRatioBP),
160
166
uint16 (_reserveRatioThresholdBP),
161
167
uint16 (_treasuryFeeBP),
162
- false // isDisconnected
168
+ false , // isDisconnected
169
+ 0 // forceWithdrawalUnlockTime
163
170
);
164
171
$.vaultIndex[_vault] = $.sockets.length ;
165
172
$.sockets.push (vr);
@@ -265,6 +272,8 @@ abstract contract VaultHub is PausableUntilWithRoles {
265
272
266
273
STETH.burnExternalShares (_amountOfShares);
267
274
275
+ _updateUnbalancedState (_vault, socket);
276
+
268
277
emit BurnedSharesOnVault (_vault, _amountOfShares);
269
278
}
270
279
@@ -312,6 +321,8 @@ abstract contract VaultHub is PausableUntilWithRoles {
312
321
313
322
// TODO: add some gas compensation here
314
323
IStakingVault (_vault).rebalance (amountToRebalance);
324
+
325
+ // NB: check _updateUnbalancedState is calculated in rebalance() triggered from the `StakingVault`.
315
326
}
316
327
317
328
/// @notice rebalances the vault by writing off the amount of ether equal
@@ -330,10 +341,24 @@ abstract contract VaultHub is PausableUntilWithRoles {
330
341
331
342
STETH.rebalanceExternalEtherToInternal {value: msg .value }();
332
343
344
+ // Check if vault is still unbalanced after rebalance
345
+ _updateUnbalancedState (msg .sender , socket);
346
+
333
347
emit VaultRebalanced (msg .sender , sharesToBurn);
334
348
}
335
349
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
337
362
/// @param _vault vault address
338
363
/// @param _pubkeys pubkeys of the validators to withdraw
339
364
function forceValidatorWithdrawal (address _vault , bytes calldata _pubkeys ) external payable {
@@ -347,6 +372,10 @@ abstract contract VaultHub is PausableUntilWithRoles {
347
372
revert AlreadyBalanced (_vault, socket.sharesMinted, threshold);
348
373
}
349
374
375
+ if (! canForceValidatorWithdrawal (_vault)) {
376
+ revert ForceWithdrawalTimelockActive (_vault, socket.forceWithdrawalUnlockTime);
377
+ }
378
+
350
379
IStakingVault (_vault).forceValidatorWithdrawal {value: msg .value }(_pubkeys);
351
380
352
381
emit VaultForceWithdrawalInitiated (_vault, _pubkeys);
@@ -443,7 +472,7 @@ abstract contract VaultHub is PausableUntilWithRoles {
443
472
444
473
// TODO: optimize potential rewards calculation
445
474
uint256 potentialRewards = ((chargeableValue * (_postTotalPooledEther * _preTotalShares)) /
446
- (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue);
475
+ (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue);
447
476
uint256 treasuryFee = (potentialRewards * _socket.treasuryFeeBP) / TOTAL_BASIS_POINTS;
448
477
449
478
treasuryFeeShares = (treasuryFee * _preTotalShares) / _preTotalPooledEther;
@@ -466,6 +495,9 @@ abstract contract VaultHub is PausableUntilWithRoles {
466
495
if (treasuryFeeShares > 0 ) {
467
496
socket.sharesMinted += uint96 (treasuryFeeShares);
468
497
}
498
+
499
+ _updateUnbalancedState (socket.vault, socket);
500
+
469
501
IStakingVault (socket.vault).report (_valuations[i], _inOutDeltas[i], _locked[i]);
470
502
}
471
503
@@ -485,6 +517,25 @@ abstract contract VaultHub is PausableUntilWithRoles {
485
517
}
486
518
}
487
519
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
+
488
539
function _vaultAuth (address _vault , string memory _operation ) internal view {
489
540
if (msg .sender != OwnableUpgradeable (_vault).owner ()) revert NotAuthorized (_operation, msg .sender );
490
541
}
@@ -500,7 +551,7 @@ abstract contract VaultHub is PausableUntilWithRoles {
500
551
/// it does not count shares that is already minted, but does count shareLimit on the vault
501
552
function _maxMintableShares (address _vault , uint256 _reserveRatio , uint256 _shareLimit ) internal view returns (uint256 ) {
502
553
uint256 maxStETHMinted = (IStakingVault (_vault).valuation () * (TOTAL_BASIS_POINTS - _reserveRatio)) /
503
- TOTAL_BASIS_POINTS;
554
+ TOTAL_BASIS_POINTS;
504
555
505
556
return Math256.min (STETH.getSharesByPooledEth (maxStETHMinted), _shareLimit);
506
557
}
@@ -528,6 +579,8 @@ abstract contract VaultHub is PausableUntilWithRoles {
528
579
event VaultRebalanced (address indexed vault , uint256 sharesBurned );
529
580
event VaultProxyCodehashAdded (bytes32 indexed codehash );
530
581
event VaultForceWithdrawalInitiated (address indexed vault , bytes pubkeys );
582
+ event VaultBecameUnbalanced (address indexed vault , uint40 unlockTime );
583
+ event VaultBecameBalanced (address indexed vault );
531
584
532
585
error StETHMintFailed (address vault );
533
586
error AlreadyBalanced (address vault , uint256 mintedShares , uint256 rebalancingThresholdInShares );
@@ -548,4 +601,5 @@ abstract contract VaultHub is PausableUntilWithRoles {
548
601
error AlreadyExists (bytes32 codehash );
549
602
error NoMintedSharesShouldBeLeft (address vault , uint256 sharesMinted );
550
603
error VaultProxyNotAllowed (address beacon );
604
+ error ForceWithdrawalTimelockActive (address vault , uint256 unlockTime );
551
605
}
0 commit comments