Skip to content

Commit dd67b36

Browse files
committed
chore: add timelock for force withdrawals
1 parent 33f1d5c commit dd67b36

File tree

5 files changed

+197
-18
lines changed

5 files changed

+197
-18
lines changed

contracts/0.8.25/vaults/Dashboard.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ contract Dashboard is Permissions {
167167
return stakingVault().valuation();
168168
}
169169

170+
/**
171+
* @notice Returns the force withdrawal unlock time of the vault.
172+
* @return The force withdrawal unlock time as a uint40.
173+
*/
174+
function forceWithdrawalUnlockTime() external view returns (uint40) {
175+
return vaultSocket().forceWithdrawalUnlockTime;
176+
}
177+
170178
/**
171179
* @notice Returns the overall capacity of stETH shares that can be minted by the vault bound by valuation and vault share limit.
172180
* @return The maximum number of mintable stETH shares not counting already minted ones.

contracts/0.8.25/vaults/VaultHub.sol

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,11 @@ contract StETH__HarnessForVaultHub is StETH {
4545
function mintExternalShares(address _recipient, uint256 _sharesAmount) public {
4646
_mintShares(_recipient, _sharesAmount);
4747
}
48+
49+
function rebalanceExternalEtherToInternal() public payable {
50+
require(msg.value != 0, "ZERO_VALUE");
51+
52+
totalPooledEther += msg.value;
53+
externalBalance -= msg.value;
54+
}
4855
}

test/0.8.25/vaults/dashboard/dashboard.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ describe("Dashboard.sol", () => {
179179
reserveRatioThresholdBP: 800n,
180180
treasuryFeeBP: 500n,
181181
isDisconnected: false,
182+
forceWithdrawalUnlockTime: 0n,
182183
};
183184

184185
await hub.mock__setVaultSocket(vault, sockets);
@@ -189,6 +190,7 @@ describe("Dashboard.sol", () => {
189190
expect(await dashboard.reserveRatioBP()).to.equal(sockets.reserveRatioBP);
190191
expect(await dashboard.thresholdReserveRatioBP()).to.equal(sockets.reserveRatioThresholdBP);
191192
expect(await dashboard.treasuryFee()).to.equal(sockets.treasuryFeeBP);
193+
expect(await dashboard.forceWithdrawalUnlockTime()).to.equal(sockets.forceWithdrawalUnlockTime);
192194
});
193195
});
194196

@@ -215,7 +217,9 @@ describe("Dashboard.sol", () => {
215217
reserveRatioThresholdBP: 800n,
216218
treasuryFeeBP: 500n,
217219
isDisconnected: false,
220+
forceWithdrawalUnlockTime: 0n,
218221
};
222+
219223
await hub.mock__setVaultSocket(vault, sockets);
220224

221225
await dashboard.fund({ value: 1000n });
@@ -236,7 +240,9 @@ describe("Dashboard.sol", () => {
236240
reserveRatioThresholdBP: 800n,
237241
treasuryFeeBP: 500n,
238242
isDisconnected: false,
243+
forceWithdrawalUnlockTime: 0n,
239244
};
245+
240246
await hub.mock__setVaultSocket(vault, sockets);
241247

242248
await dashboard.fund({ value: 1000n });
@@ -255,7 +261,9 @@ describe("Dashboard.sol", () => {
255261
reserveRatioThresholdBP: 800n,
256262
treasuryFeeBP: 500n,
257263
isDisconnected: false,
264+
forceWithdrawalUnlockTime: 0n,
258265
};
266+
259267
await hub.mock__setVaultSocket(vault, sockets);
260268

261269
await dashboard.fund({ value: 1000n });
@@ -274,7 +282,9 @@ describe("Dashboard.sol", () => {
274282
reserveRatioThresholdBP: 0n,
275283
treasuryFeeBP: 500n,
276284
isDisconnected: false,
285+
forceWithdrawalUnlockTime: 0n,
277286
};
287+
278288
await hub.mock__setVaultSocket(vault, sockets);
279289
const funding = 1000n;
280290
await dashboard.fund({ value: funding });
@@ -301,7 +311,9 @@ describe("Dashboard.sol", () => {
301311
reserveRatioThresholdBP: 800n,
302312
treasuryFeeBP: 500n,
303313
isDisconnected: false,
314+
forceWithdrawalUnlockTime: 0n,
304315
};
316+
305317
await hub.mock__setVaultSocket(vault, sockets);
306318

307319
const funding = 1000n;
@@ -326,7 +338,9 @@ describe("Dashboard.sol", () => {
326338
reserveRatioThresholdBP: 800n,
327339
treasuryFeeBP: 500n,
328340
isDisconnected: false,
341+
forceWithdrawalUnlockTime: 0n,
329342
};
343+
330344
await hub.mock__setVaultSocket(vault, sockets);
331345
const funding = 1000n;
332346

@@ -348,7 +362,9 @@ describe("Dashboard.sol", () => {
348362
reserveRatioThresholdBP: 800n,
349363
treasuryFeeBP: 500n,
350364
isDisconnected: false,
365+
forceWithdrawalUnlockTime: 0n,
351366
};
367+
352368
await hub.mock__setVaultSocket(vault, sockets);
353369
const funding = 1000n;
354370
const preFundCanMint = await dashboard.projectedNewMintableShares(funding);
@@ -368,7 +384,9 @@ describe("Dashboard.sol", () => {
368384
reserveRatioThresholdBP: 800n,
369385
treasuryFeeBP: 500n,
370386
isDisconnected: false,
387+
forceWithdrawalUnlockTime: 0n,
371388
};
389+
372390
await hub.mock__setVaultSocket(vault, sockets);
373391
const funding = 2000n;
374392

@@ -391,6 +409,7 @@ describe("Dashboard.sol", () => {
391409
reserveRatioThresholdBP: 800n,
392410
treasuryFeeBP: 500n,
393411
isDisconnected: false,
412+
forceWithdrawalUnlockTime: 0n,
394413
};
395414

396415
await hub.mock__setVaultSocket(vault, sockets);

0 commit comments

Comments
 (0)