Skip to content

Commit 9d4da76

Browse files
authored
Merge pull request #48 from lidofinance/feature/review
Review
2 parents 1863aef + 9562929 commit 9d4da76

36 files changed

+2488
-264
lines changed

script/HarnessCore.s.sol

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,14 +168,11 @@ contract HarnessCore is Script {
168168
return (true, abi.decode(ret, (address)));
169169
}
170170

171-
function _arr6(
172-
string memory a,
173-
string memory b,
174-
string memory c,
175-
string memory d,
176-
string memory e,
177-
string memory f
178-
) private pure returns (string[] memory r) {
171+
function _arr6(string memory a, string memory b, string memory c, string memory d, string memory e, string memory f)
172+
private
173+
pure
174+
returns (string[] memory r)
175+
{
179176
r = new string[](6);
180177
r[0] = a;
181178
r[1] = b;

src/Factory.sol

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ contract Factory {
247247
timelock,
248248
abi.encodeCall(
249249
WithdrawalQueue.initialize,
250-
(vaultConfig.nodeOperatorManager, vaultConfig.nodeOperator) // (admin, finalizerRoleHolder)
250+
(timelock, vaultConfig.nodeOperator) // (admin, finalizerRoleHolder)
251251
)
252252
)
253253
);
@@ -318,10 +318,10 @@ contract Factory {
318318
}
319319

320320
pool.grantRole(DEFAULT_ADMIN_ROLE, timelock);
321-
pool.renounceRole(DEFAULT_ADMIN_ROLE, tempAdmin);
321+
pool.revokeRole(DEFAULT_ADMIN_ROLE, tempAdmin);
322322

323323
dashboard.grantRole(DEFAULT_ADMIN_ROLE, timelock);
324-
dashboard.renounceRole(DEFAULT_ADMIN_ROLE, tempAdmin);
324+
dashboard.revokeRole(DEFAULT_ADMIN_ROLE, tempAdmin);
325325

326326
deployment = StvPoolDeployment({
327327
poolType: poolType,

src/StvPool.sol

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {IDashboard} from "./interfaces/IDashboard.sol";
88
import {IStETH} from "./interfaces/IStETH.sol";
99
import {IStakingVault} from "./interfaces/IStakingVault.sol";
1010
import {IVaultHub} from "./interfaces/IVaultHub.sol";
11+
import {FeaturePausable} from "./utils/FeaturePausable.sol";
1112
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
1213
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
1314
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
@@ -17,23 +18,23 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
1718
* @notice ERC20 staking vault token pool that accepts ETH deposits and manages withdrawals through a queue
1819
* @dev Implements a tokenized staking pool where users deposit ETH and receive STV tokens representing their share
1920
*/
20-
contract StvPool is Initializable, ERC20Upgradeable, AllowList {
21+
contract StvPool is Initializable, ERC20Upgradeable, AllowList, FeaturePausable {
2122
// Custom errors
2223
error ZeroDeposit();
23-
error InvalidReceiver();
24+
error InvalidRecipient();
2425
error NotWithdrawalQueue();
2526
error NotEnoughToRebalance();
2627
error UnassignedLiabilityOnVault();
2728
error VaultInBadDebt();
2829

29-
bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("REQUEST_VALIDATOR_EXIT_ROLE");
30-
bytes32 public constant TRIGGER_VALIDATOR_WITHDRAWAL_ROLE = keccak256("TRIGGER_VALIDATOR_WITHDRAWAL_ROLE");
30+
bytes32 public constant DEPOSITS_FEATURE = keccak256("DEPOSITS_FEATURE");
31+
bytes32 public constant DEPOSITS_PAUSE_ROLE = keccak256("DEPOSITS_PAUSE_ROLE");
32+
bytes32 public constant DEPOSITS_RESUME_ROLE = keccak256("DEPOSITS_RESUME_ROLE");
3133

3234
uint256 public constant TOTAL_BASIS_POINTS = 100_00;
3335

3436
uint256 private constant DECIMALS = 27;
3537
uint256 private constant ASSET_DECIMALS = 18;
36-
uint256 private constant EXTRA_DECIMALS_BASE = 10 ** (DECIMALS - ASSET_DECIMALS);
3738

3839
IStETH public immutable STETH;
3940
IDashboard public immutable DASHBOARD;
@@ -44,7 +45,7 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList {
4445
Distributor public immutable DISTRIBUTOR;
4546

4647
event Deposit(
47-
address indexed sender, address indexed receiver, address indexed referral, uint256 assets, uint256 stv
48+
address indexed sender, address indexed recipient, address indexed referral, uint256 assets, uint256 stv
4849
);
4950

5051
event UnassignedLiabilityRebalanced(uint256 stethShares, uint256 ethFunded);
@@ -61,6 +62,9 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList {
6162

6263
// Disable initializers since we only support proxy deployment
6364
_disableInitializers();
65+
66+
// Pause features in implementation
67+
_pauseFeature(DEPOSITS_FEATURE);
6468
}
6569

6670
function poolType() external view virtual returns (bytes32) {
@@ -83,8 +87,10 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList {
8387
uint256 initialVaultBalance = address(STAKING_VAULT).balance;
8488
uint256 connectDeposit = VAULT_HUB.CONNECT_DEPOSIT();
8589
assert(initialVaultBalance >= connectDeposit);
90+
assert(totalSupply() == 0);
8691

87-
_mint(address(this), _convertToStv(initialVaultBalance, Math.Rounding.Floor));
92+
uint256 stvToMint = initialVaultBalance * 10 ** (DECIMALS - ASSET_DECIMALS);
93+
_mint(address(this), stvToMint);
8894
}
8995

9096
// =================================================================================
@@ -132,27 +138,22 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList {
132138
// CONVERSION
133139
// =================================================================================
134140

135-
function _convertToStv(uint256 _assetsE18, Math.Rounding _rounding) internal view returns (uint256 stv) {
136-
uint256 totalAssetsE18 = totalAssets();
137-
uint256 totalSupplyE27 = totalSupply();
138-
139-
if (totalSupplyE27 == 0) return _assetsE18 * EXTRA_DECIMALS_BASE; // 1:1 for the first deposit
140-
if (totalAssetsE18 == 0) return 0;
141+
function _convertToStv(uint256 _assets, Math.Rounding _rounding) internal view returns (uint256 stv) {
142+
uint256 totalAssets_ = totalAssets();
143+
if (totalAssets_ == 0) return 0;
141144

142-
stv = Math.mulDiv(_assetsE18, totalSupplyE27, totalAssetsE18, _rounding);
145+
stv = Math.mulDiv(_assets, totalSupply(), totalAssets_, _rounding);
143146
}
144147

145148
function _convertToAssets(uint256 _stv) internal view returns (uint256 assets) {
146149
assets = _getAssetsShare(_stv, totalAssets());
147150
}
148151

149-
function _getAssetsShare(uint256 _stv, uint256 _assetsE18) internal view returns (uint256 assets) {
150-
uint256 supplyE27 = totalSupply();
151-
if (supplyE27 == 0) return 0;
152+
function _getAssetsShare(uint256 _stv, uint256 _assets) internal view returns (uint256 assets) {
153+
uint256 totalSupply_ = totalSupply();
154+
if (totalSupply_ == 0) return 0;
152155

153-
// TODO: review this Math.Rounding.Ceil
154-
uint256 assetsShare = Math.mulDiv(_stv * EXTRA_DECIMALS_BASE, _assetsE18, supplyE27, Math.Rounding.Ceil);
155-
assets = assetsShare / EXTRA_DECIMALS_BASE;
156+
assets = Math.mulDiv(_stv, _assets, totalSupply_, Math.Rounding.Floor);
156157
}
157158

158159
// =================================================================================
@@ -207,7 +208,8 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList {
207208

208209
function _deposit(address _recipient, address _referral) internal returns (uint256 stv) {
209210
if (msg.value == 0) revert ZeroDeposit();
210-
if (_recipient == address(0)) revert InvalidReceiver();
211+
if (_recipient == address(0)) revert InvalidRecipient();
212+
_checkFeatureNotPaused(DEPOSITS_FEATURE);
211213
_checkAllowList();
212214

213215
stv = previewDeposit(msg.value);
@@ -375,4 +377,26 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList {
375377
function _checkOnlyWithdrawalQueue() internal view {
376378
if (address(WITHDRAWAL_QUEUE) != msg.sender) revert NotWithdrawalQueue();
377379
}
380+
381+
// =================================================================================
382+
// PAUSE / RESUME DEPOSITS
383+
// =================================================================================
384+
385+
/**
386+
* @notice Pause deposits
387+
* @dev Can only be called by accounts with the DEPOSITS_PAUSE_ROLE
388+
*/
389+
function pauseDeposits() external {
390+
_checkRole(DEPOSITS_PAUSE_ROLE, msg.sender);
391+
_pauseFeature(DEPOSITS_FEATURE);
392+
}
393+
394+
/**
395+
* @notice Resume deposits
396+
* @dev Can only be called by accounts with the DEPOSITS_RESUME_ROLE
397+
*/
398+
function resumeDeposits() external {
399+
_checkRole(DEPOSITS_RESUME_ROLE, msg.sender);
400+
_resumeFeature(DEPOSITS_FEATURE);
401+
}
378402
}

src/StvStETHPool.sol

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ contract StvStETHPool is StvPool {
1717
event StethSharesMinted(address indexed account, uint256 stethShares);
1818
event StethSharesBurned(address indexed account, uint256 stethShares);
1919
event StethSharesRebalanced(address indexed account, uint256 stethShares, uint256 stvBurned);
20-
event SocializedLoss(uint256 stv, uint256 assets);
20+
event SocializedLoss(uint256 stv, uint256 assets, uint256 maxLossSocializationBP);
2121
event VaultParametersUpdated(uint256 newReserveRatioBP, uint256 newForcedRebalanceThresholdBP);
22+
event MaxLossSocializationUpdated(uint256 newMaxLossSocializationBP);
2223

2324
error InsufficientMintingCapacity();
2425
error InsufficientStethShares();
@@ -31,6 +32,13 @@ contract StvStETHPool is StvPool {
3132
error VaultReportStale();
3233
error UndercollateralizedAccount();
3334
error CollateralizedAccount();
35+
error ExcessiveLossSocialization();
36+
error SameValue();
37+
error InvalidValue();
38+
39+
bytes32 public constant MINTING_FEATURE = keccak256("MINTING_FEATURE");
40+
bytes32 public constant MINTING_PAUSE_ROLE = keccak256("MINTING_PAUSE_ROLE");
41+
bytes32 public constant MINTING_RESUME_ROLE = keccak256("MINTING_RESUME_ROLE");
3442

3543
bytes32 public constant LOSS_SOCIALIZER_ROLE = keccak256("LOSS_SOCIALIZER_ROLE");
3644

@@ -47,6 +55,7 @@ contract StvStETHPool is StvPool {
4755
uint256 totalMintedStethShares;
4856
uint16 reserveRatioBP;
4957
uint16 forcedRebalanceThresholdBP;
58+
uint16 maxLossSocializationBP;
5059
}
5160

5261
// keccak256(abi.encode(uint256(keccak256("pool.storage.StvStETHPool")) - 1)) & ~bytes32(uint256(0xff))
@@ -67,10 +76,14 @@ contract StvStETHPool is StvPool {
6776
address _distributor,
6877
bytes32 _poolType
6978
) StvPool(_dashboard, _allowListEnabled, _withdrawalQueue, _distributor) {
79+
if (_reserveRatioGapBP >= TOTAL_BASIS_POINTS) revert InvalidValue();
80+
7081
RESERVE_RATIO_GAP_BP = _reserveRatioGapBP;
82+
POOL_TYPE = _poolType;
7183
WSTETH = IWstETH(DASHBOARD.WSTETH());
7284

73-
POOL_TYPE = _poolType;
85+
// Pause features in implementation
86+
_pauseFeature(MINTING_FEATURE);
7487
}
7588

7689
function poolType() external view override returns (bytes32) {
@@ -301,7 +314,9 @@ contract StvStETHPool is StvPool {
301314
* on WSTETH contract during unwrapping. The dust from rounding accumulates on the WSTETH contract during unwrapping
302315
*/
303316
function mintWsteth(uint256 _wsteth) public {
317+
_checkFeatureNotPaused(MINTING_FEATURE);
304318
_checkRemainingMintingCapacityOf(msg.sender, _wsteth);
319+
305320
_increaseMintedStethShares(msg.sender, _wsteth);
306321
DASHBOARD.mintWstETH(msg.sender, _wsteth);
307322
}
@@ -311,7 +326,9 @@ contract StvStETHPool is StvPool {
311326
* @param _stethShares The amount of stETH shares to mint
312327
*/
313328
function mintStethShares(uint256 _stethShares) public {
329+
_checkFeatureNotPaused(MINTING_FEATURE);
314330
_checkRemainingMintingCapacityOf(msg.sender, _stethShares);
331+
315332
_increaseMintedStethShares(msg.sender, _stethShares);
316333
DASHBOARD.mintShares(msg.sender, _stethShares);
317334
}
@@ -453,7 +470,6 @@ contract StvStETHPool is StvPool {
453470
* @notice Sync reserve ratio and forced rebalance threshold from VaultHub
454471
* @dev Permissionless method to keep reserve ratio and forced rebalance threshold in sync with VaultHub
455472
* @dev Adds a gap defined by RESERVE_RATIO_GAP_BP to VaultHub's values
456-
* @dev Reverts if the new reserve ratio or forced rebalance threshold is invalid (>= TOTAL_BASIS_POINTS)
457473
*/
458474
function syncVaultParameters() public {
459475
IVaultHub.VaultConnection memory connection = DASHBOARD.vaultConnection();
@@ -656,9 +672,14 @@ contract StvStETHPool is StvPool {
656672

657673
if (remainingStethShares > 0) DASHBOARD.rebalanceVaultWithShares(remainingStethShares);
658674

659-
// TODO: Add sanity check for loss socialization
660675
if (stvToBurn > _maxStvToBurn) {
661-
emit SocializedLoss(stvToBurn - _maxStvToBurn, ethToRebalance - _convertToAssets(_maxStvToBurn));
676+
_checkAllowedLossSocializationPortion(stvToBurn, _maxStvToBurn);
677+
678+
emit SocializedLoss(
679+
stvToBurn - _maxStvToBurn,
680+
ethToRebalance - _convertToAssets(_maxStvToBurn),
681+
_getStvStETHPoolStorage().maxLossSocializationBP
682+
);
662683
stvToBurn = _maxStvToBurn;
663684
}
664685

@@ -681,10 +702,60 @@ contract StvStETHPool is StvPool {
681702
isBreached = _assets < assetsThreshold;
682703
}
683704

705+
function _checkAllowedLossSocializationPortion(uint256 stvRequired, uint256 stvAvailable) internal view {
706+
// It's guaranteed that stvRequired > stvAvailable here
707+
uint256 portionToSocializeBP =
708+
Math.mulDiv(stvRequired - stvAvailable, TOTAL_BASIS_POINTS, stvRequired, Math.Rounding.Ceil);
709+
710+
if (portionToSocializeBP > _getStvStETHPoolStorage().maxLossSocializationBP) {
711+
revert ExcessiveLossSocialization();
712+
}
713+
}
714+
684715
function _checkFreshReport() internal view {
685716
if (!VAULT_HUB.isReportFresh(address(STAKING_VAULT))) revert VaultReportStale();
686717
}
687718

719+
// =================================================================================
720+
// LOSS SOCIALIZATION LIMITER
721+
// =================================================================================
722+
723+
// During rebalancing, it's possible that the stv available for burning is not sufficient to cover the entire liability.
724+
// This may be due to a sharp drop in the stv price, which has resulted in an individual account or a request in Withdrawal Queue
725+
// no longer being collateralized (assets < liability).
726+
//
727+
// The limiter on loss socialization is introduced to prevent excessive losses from being socialized to all pool participants.
728+
// The limiter is defined as a maximum portion of the loss that can be socialized, expressed in basis points (BP).
729+
//
730+
// The default value is set to 0 BP, meaning that no loss socialization is allowed without explicit permission.
731+
732+
/**
733+
* @notice Maximum allowed loss socialization in basis points
734+
* @return maxSocializablePortionBP The maximum allowed portion of loss to be socialized in basis points
735+
* @dev Used to limit the portion of loss that can be socialized to all pool participants during rebalance
736+
*/
737+
function maxLossSocializationBP() external view returns (uint256 maxSocializablePortionBP) {
738+
maxSocializablePortionBP = uint256(_getStvStETHPoolStorage().maxLossSocializationBP);
739+
}
740+
741+
/**
742+
* @notice Set the maximum allowed loss socialization in basis points
743+
* @param _maxSocializablePortionBP The new maximum allowed loss socialization in basis points
744+
* @dev Sets the maximum portion of loss that can be socialized to all pool participants during rebalance
745+
* @dev Can only be called by accounts with the DEFAULT_ADMIN_ROLE
746+
*/
747+
function setMaxLossSocializationBP(uint16 _maxSocializablePortionBP) external {
748+
_checkRole(DEFAULT_ADMIN_ROLE, msg.sender);
749+
750+
if (_maxSocializablePortionBP > TOTAL_BASIS_POINTS) revert InvalidValue();
751+
752+
StvStETHPoolStorage storage $ = _getStvStETHPoolStorage();
753+
if (_maxSocializablePortionBP == $.maxLossSocializationBP) revert SameValue();
754+
$.maxLossSocializationBP = _maxSocializablePortionBP;
755+
756+
emit MaxLossSocializationUpdated(_maxSocializablePortionBP);
757+
}
758+
688759
// =================================================================================
689760
// TRANSFER WITH LIABILITY
690761
// =================================================================================
@@ -727,4 +798,26 @@ contract StvStETHPool is StvPool {
727798

728799
if (balanceOf(_from) < stvToLock) revert InsufficientReservedBalance();
729800
}
801+
802+
// =================================================================================
803+
// PAUSE / RESUME MINTING
804+
// =================================================================================
805+
806+
/**
807+
* @notice Pause (w)stETH minting
808+
* @dev Can only be called by accounts with the MINTING_PAUSE_ROLE
809+
*/
810+
function pauseMinting() external {
811+
_checkRole(MINTING_PAUSE_ROLE, msg.sender);
812+
_pauseFeature(MINTING_FEATURE);
813+
}
814+
815+
/**
816+
* @notice Resume (w)stETH minting
817+
* @dev Can only be called by accounts with the MINTING_RESUME_ROLE
818+
*/
819+
function resumeMinting() external {
820+
_checkRole(MINTING_RESUME_ROLE, msg.sender);
821+
_resumeFeature(MINTING_FEATURE);
822+
}
730823
}

0 commit comments

Comments
 (0)