From 45c8020ab8008a0ca67f95fd4a8713a2ee639d3a Mon Sep 17 00:00:00 2001 From: skhomuti Date: Fri, 6 Oct 2023 16:22:28 +0500 Subject: [PATCH 1/8] feat: add setNodeOperatorName --- src/CommunityStakingModule.sol | 48 ++++++++++++--- test/CSMAddValidator.t.sol | 106 ++++++++++++++++++++++----------- 2 files changed, 110 insertions(+), 44 deletions(-) diff --git a/src/CommunityStakingModule.sol b/src/CommunityStakingModule.sol index ffcbbd43..d332d5b3 100644 --- a/src/CommunityStakingModule.sol +++ b/src/CommunityStakingModule.sol @@ -33,6 +33,7 @@ contract CommunityStakingModuleBase { string name, address rewardAddress ); + event NodeOperatorNameSet(uint256 indexed nodeOperatorId, string name); event VettedKeysCountChanged( uint256 indexed nodeOperatorId, @@ -61,13 +62,13 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { bytes32 public constant SIGNING_KEYS_POSITION = keccak256("lido.CommunityStakingModule.signingKeysPosition"); + uint256 public constant MAX_NODE_OPERATOR_NAME_LENGTH = 255; address public bondManagerAddress; address public lidoLocator; constructor(bytes32 _type, address _locator) { moduleType = _type; - nodeOperatorsCount = 0; require(_locator != address(0), "lido locator is zero address"); lidoLocator = _locator; @@ -222,7 +223,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external onlyActiveNodeOperator(_nodeOperatorId) { + ) external onlyExistedNodeOperator(_nodeOperatorId) { // TODO sanity checks // TODO store keys @@ -244,7 +245,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external onlyActiveNodeOperator(_nodeOperatorId) { + ) external onlyExistedNodeOperator(_nodeOperatorId) { // TODO sanity checks // TODO store keys @@ -267,7 +268,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external payable onlyActiveNodeOperator(_nodeOperatorId) { + ) external payable onlyExistedNodeOperator(_nodeOperatorId) { // TODO sanity checks // TODO store keys @@ -290,6 +291,25 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); } + function setNodeOperatorName( + uint256 _nodeOperatorId, + string memory _name + ) + external + onlyExistedNodeOperator(_nodeOperatorId) + onlyNodeOperatorManager(_nodeOperatorId) + { + _onlyValidNodeOperatorName(_name); + require( + keccak256(bytes(_name)) != + keccak256(bytes(nodeOperators[_nodeOperatorId].name)), + "SAME_NAME" + ); + + nodeOperators[_nodeOperatorId].name = _name; + emit NodeOperatorNameSet(_nodeOperatorId, _name); + } + function getNodeOperator( uint256 _nodeOperatorId, bool _fullInfo @@ -437,7 +457,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) internal onlyActiveNodeOperator(_nodeOperatorId) { + ) internal { // TODO: sanity checks uint256 _startIndex = nodeOperators[_nodeOperatorId].totalAddedKeys; @@ -505,14 +525,26 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { nonce++; } - modifier onlyActiveNodeOperator(uint256 _nodeOperatorId) { + function _onlyValidNodeOperatorName(string memory _name) internal pure { + require( + bytes(_name).length > 0 && + bytes(_name).length <= MAX_NODE_OPERATOR_NAME_LENGTH, + "WRONG_NAME_LENGTH" + ); + } + + modifier onlyExistedNodeOperator(uint256 _nodeOperatorId) { require( _nodeOperatorId < nodeOperatorsCount, "node operator does not exist" ); + _; + } + + modifier onlyNodeOperatorManager(uint256 _nodeOperatorId) { require( - nodeOperators[_nodeOperatorId].active, - "node operator is not active" + nodeOperators[_nodeOperatorId].rewardAddress == msg.sender, + "sender is not eligible to manage node operator" ); _; } diff --git a/test/CSMAddValidator.t.sol b/test/CSMAddValidator.t.sol index c7ea66f0..40b864ed 100644 --- a/test/CSMAddValidator.t.sol +++ b/test/CSMAddValidator.t.sol @@ -12,12 +12,7 @@ import "./helpers/mocks/LidoMock.sol"; import "./helpers/mocks/WstETHMock.sol"; import "./helpers/Utilities.sol"; -contract CSMAddNodeOperator is - Test, - Fixtures, - Utilities, - CommunityStakingModuleBase -{ +contract CSMCommon is Test, Fixtures, Utilities, CommunityStakingModuleBase { LidoLocatorMock public locator; WstETHMock public wstETH; LidoMock public stETH; @@ -61,6 +56,25 @@ contract CSMAddNodeOperator is csm.setBondManager(address(bondManager)); } + function createNodeOperator() internal returns (uint256) { + uint256 keysCount = 1; + (bytes memory keys, bytes memory signatures) = keysSignatures( + keysCount + ); + vm.deal(nodeOperator, 2 ether); + vm.prank(nodeOperator); + csm.addNodeOperatorETH{ value: 2 ether }( + "test", + nodeOperator, + keysCount, + keys, + signatures + ); + return csm.getNodeOperatorsCount() - 1; + } +} + +contract CSMAddNodeOperator is CSMCommon { function test_AddNodeOperatorWstETH() public { uint16 keysCount = 1; (bytes memory keys, bytes memory signatures) = keysSignatures( @@ -81,19 +95,12 @@ contract CSMAddNodeOperator is } function test_AddValidatorKeysWstETH() public { - uint16 keysCount = 1; - (bytes memory keys, bytes memory signatures) = keysSignatures( - keysCount - ); - vm.startPrank(nodeOperator); - wstETH.wrap(2 ether); - csm.addNodeOperatorWstETH("test", nodeOperator, 1, keys, signatures); - uint256 noId = csm.getNodeOperatorsCount() - 1; + uint256 noId = createNodeOperator(); vm.deal(nodeOperator, 2 ether); stETH.submit{ value: 2 ether }(address(0)); wstETH.wrap(2 ether); - (keys, signatures) = keysSignatures(keysCount, 1); + (bytes memory keys, bytes memory signatures) = keysSignatures(1, 1); { vm.expectEmit(true, true, false, true, address(csm)); emit TotalKeysCountChanged(0, 2); @@ -120,13 +127,8 @@ contract CSMAddNodeOperator is } function test_AddValidatorKeysStETH() public { - uint16 keysCount = 1; - (bytes memory keys, bytes memory signatures) = keysSignatures( - keysCount - ); - vm.prank(nodeOperator); - csm.addNodeOperatorStETH("test", nodeOperator, 1, keys, signatures); - uint256 noId = csm.getNodeOperatorsCount() - 1; + uint256 noId = createNodeOperator(); + (bytes memory keys, bytes memory signatures) = keysSignatures(1, 1); vm.deal(nodeOperator, 2 ether); vm.startPrank(nodeOperator); @@ -164,20 +166,8 @@ contract CSMAddNodeOperator is } function test_AddValidatorKeysETH() public { - uint16 keysCount = 1; - (bytes memory keys, bytes memory signatures) = keysSignatures( - keysCount - ); - vm.deal(nodeOperator, 2 ether); - vm.prank(nodeOperator); - csm.addNodeOperatorETH{ value: 2 ether }( - "test", - nodeOperator, - 1, - keys, - signatures - ); - uint256 noId = csm.getNodeOperatorsCount() - 1; + uint256 noId = createNodeOperator(); + (bytes memory keys, bytes memory signatures) = keysSignatures(1, 1); vm.deal(nodeOperator, 2 ether); vm.prank(nodeOperator); @@ -187,7 +177,9 @@ contract CSMAddNodeOperator is } csm.addValidatorKeysETH{ value: 2 ether }(noId, 1, keys, signatures); } +} +contract CSMObtainDepositData is CSMCommon { function test_obtainDepositData_RevertWhenNoMoreKeys() public { uint16 keysCount = 1; (bytes memory keys, bytes memory signatures) = keysSignatures( @@ -211,3 +203,45 @@ contract CSMAddNodeOperator is csm.obtainDepositData(1, ""); } } + +contract CSMEditNodeOperatorInfo is CSMCommon { + function test_setNodeOperatorName() public { + uint256 noId = createNodeOperator(); + vm.prank(nodeOperator); + vm.expectEmit(true, true, false, true, address(csm)); + emit NodeOperatorNameSet(noId, "newName"); + csm.setNodeOperatorName(noId, "newName"); + + string memory name; + (, name, , , , , , ) = csm.getNodeOperator(noId, true); + assertEq(name, "newName"); + } + + function test_setNodeOperatorName_revertIfNotManager() public { + uint256 noId = createNodeOperator(); + vm.prank(stranger); + vm.expectRevert("sender is not eligible to manage node operator"); + csm.setNodeOperatorName(noId, "newName"); + } + + function test_setNodeOperatorName_revertIfInvalidLength() public { + uint256 noId = createNodeOperator(); + vm.prank(nodeOperator); + vm.expectRevert("WRONG_NAME_LENGTH"); + csm.setNodeOperatorName(noId, ""); + + string memory tooLongName = new string( + csm.MAX_NODE_OPERATOR_NAME_LENGTH() + 1 + ); + vm.prank(nodeOperator); + vm.expectRevert("WRONG_NAME_LENGTH"); + csm.setNodeOperatorName(noId, tooLongName); + } + + function test_setNodeOperatorName_revertIfSameName() public { + uint256 noId = createNodeOperator(); + vm.prank(nodeOperator); + vm.expectRevert("SAME_NAME"); + csm.setNodeOperatorName(noId, "test"); + } +} From f81c29af04989b0868c37b51bc000ba8cdabd806 Mon Sep 17 00:00:00 2001 From: skhomuti Date: Fri, 6 Oct 2023 17:54:40 +0500 Subject: [PATCH 2/8] review fixes --- src/CommunityStakingModule.sol | 10 +++++----- test/CSMAddValidator.t.sol | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/CommunityStakingModule.sol b/src/CommunityStakingModule.sol index d332d5b3..91f704ed 100644 --- a/src/CommunityStakingModule.sol +++ b/src/CommunityStakingModule.sol @@ -223,7 +223,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external onlyExistedNodeOperator(_nodeOperatorId) { + ) external onlyExistingNodeOperator(_nodeOperatorId) { // TODO sanity checks // TODO store keys @@ -245,7 +245,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external onlyExistedNodeOperator(_nodeOperatorId) { + ) external onlyExistingNodeOperator(_nodeOperatorId) { // TODO sanity checks // TODO store keys @@ -268,7 +268,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external payable onlyExistedNodeOperator(_nodeOperatorId) { + ) external payable onlyExistingNodeOperator(_nodeOperatorId) { // TODO sanity checks // TODO store keys @@ -296,7 +296,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { string memory _name ) external - onlyExistedNodeOperator(_nodeOperatorId) + onlyExistingNodeOperator(_nodeOperatorId) onlyNodeOperatorManager(_nodeOperatorId) { _onlyValidNodeOperatorName(_name); @@ -533,7 +533,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { ); } - modifier onlyExistedNodeOperator(uint256 _nodeOperatorId) { + modifier onlyExistingNodeOperator(uint256 _nodeOperatorId) { require( _nodeOperatorId < nodeOperatorsCount, "node operator does not exist" diff --git a/test/CSMAddValidator.t.sol b/test/CSMAddValidator.t.sol index 40b864ed..acdda9f7 100644 --- a/test/CSMAddValidator.t.sol +++ b/test/CSMAddValidator.t.sol @@ -244,4 +244,10 @@ contract CSMEditNodeOperatorInfo is CSMCommon { vm.expectRevert("SAME_NAME"); csm.setNodeOperatorName(noId, "test"); } + + function test_setNodeOperatorName_revertIfNotExists() public { + vm.prank(nodeOperator); + vm.expectRevert("node operator does not exist"); + csm.setNodeOperatorName(0, "test"); + } } From d69f1c6abb9a68fc036fbc45aa5c74a7acf8b0a7 Mon Sep 17 00:00:00 2001 From: skhomuti Date: Mon, 9 Oct 2023 17:05:10 +0500 Subject: [PATCH 3/8] added missed name check calls reordered functions --- src/CommunityStakingModule.sol | 89 ++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/src/CommunityStakingModule.sol b/src/CommunityStakingModule.sol index 91f704ed..21859cc8 100644 --- a/src/CommunityStakingModule.sol +++ b/src/CommunityStakingModule.sol @@ -126,14 +126,24 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { ); } - function addNodeOperatorWstETH( + function addNodeOperatorETH( string calldata _name, address _rewardAddress, uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external { + ) external payable { // TODO sanity checks + _onlyValidNodeOperatorName(_name); + + require( + msg.value >= + _lido().getPooledEthByShares( + _bondManager().getRequiredBondSharesForKeys(_keysCount) + ), + "not enough eth to deposit" + ); + uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; no.name = _name; @@ -142,15 +152,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { nodeOperatorsCount++; activeNodeOperatorsCount++; - uint256 requiredEth = _lido().getPooledEthByShares( - _bondManager().getRequiredBondSharesForKeys(_keysCount) - ); - - _bondManager().depositWstETH( - msg.sender, - id, - _lido().getSharesByPooledEth(requiredEth) // to get wstETH amount - ); + _bondManager().depositETH{ value: msg.value }(msg.sender, id); _addSigningKeys(id, _keysCount, _publicKeys, _signatures); @@ -165,6 +167,8 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { bytes calldata _signatures ) external { // TODO sanity checks + _onlyValidNodeOperatorName(_name); + uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; no.name = _name; @@ -186,22 +190,15 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { emit NodeOperatorAdded(id, _name, _rewardAddress); } - function addNodeOperatorETH( + function addNodeOperatorWstETH( string calldata _name, address _rewardAddress, uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external payable { + ) external { // TODO sanity checks - - require( - msg.value >= - _lido().getPooledEthByShares( - _bondManager().getRequiredBondSharesForKeys(_keysCount) - ), - "not enough eth to deposit" - ); + _onlyValidNodeOperatorName(_name); uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; @@ -211,30 +208,44 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { nodeOperatorsCount++; activeNodeOperatorsCount++; - _bondManager().depositETH{ value: msg.value }(msg.sender, id); + uint256 requiredEth = _lido().getPooledEthByShares( + _bondManager().getRequiredBondSharesForKeys(_keysCount) + ); + + _bondManager().depositWstETH( + msg.sender, + id, + _lido().getSharesByPooledEth(requiredEth) // to get wstETH amount + ); _addSigningKeys(id, _keysCount, _publicKeys, _signatures); emit NodeOperatorAdded(id, _name, _rewardAddress); } - function addValidatorKeysWstETH( + function addValidatorKeysETH( uint256 _nodeOperatorId, uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external onlyExistingNodeOperator(_nodeOperatorId) { + ) external payable onlyExistingNodeOperator(_nodeOperatorId) { // TODO sanity checks // TODO store keys - uint256 requiredEth = _lido().getPooledEthByShares( - _bondManager().getRequiredBondShares(_nodeOperatorId, _keysCount) + require( + msg.value >= + _lido().getPooledEthByShares( + _bondManager().getRequiredBondShares( + _nodeOperatorId, + _keysCount + ) + ), + "not enough eth to deposit" ); - _bondManager().depositWstETH( + _bondManager().depositETH{ value: msg.value }( msg.sender, - _nodeOperatorId, - _lido().getSharesByPooledEth(requiredEth) // to get wstETH amount + _nodeOperatorId ); _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); @@ -263,29 +274,23 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); } - function addValidatorKeysETH( + function addValidatorKeysWstETH( uint256 _nodeOperatorId, uint256 _keysCount, bytes calldata _publicKeys, bytes calldata _signatures - ) external payable onlyExistingNodeOperator(_nodeOperatorId) { + ) external onlyExistingNodeOperator(_nodeOperatorId) { // TODO sanity checks // TODO store keys - require( - msg.value >= - _lido().getPooledEthByShares( - _bondManager().getRequiredBondShares( - _nodeOperatorId, - _keysCount - ) - ), - "not enough eth to deposit" + uint256 requiredEth = _lido().getPooledEthByShares( + _bondManager().getRequiredBondShares(_nodeOperatorId, _keysCount) ); - _bondManager().depositETH{ value: msg.value }( + _bondManager().depositWstETH( msg.sender, - _nodeOperatorId + _nodeOperatorId, + _lido().getSharesByPooledEth(requiredEth) // to get wstETH amount ); _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); From 504189f2762b0ddca3e85348958e8f1912936628 Mon Sep 17 00:00:00 2001 From: skhomuti Date: Tue, 10 Oct 2023 11:09:51 +0500 Subject: [PATCH 4/8] added name to id mapping now names must be unique --- src/CommunityStakingModule.sol | 17 +++++++++++++++-- test/CSMAddValidator.t.sol | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/CommunityStakingModule.sol b/src/CommunityStakingModule.sol index 21859cc8..bd97059f 100644 --- a/src/CommunityStakingModule.sol +++ b/src/CommunityStakingModule.sol @@ -59,6 +59,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { bytes32 private moduleType; uint256 private nonce; mapping(uint256 => NodeOperator) private nodeOperators; + mapping(string => uint256) private nodeOperatorIdsByName; bytes32 public constant SIGNING_KEYS_POSITION = keccak256("lido.CommunityStakingModule.signingKeysPosition"); @@ -146,6 +147,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; + nodeOperatorIdsByName[_name] = id; no.name = _name; no.rewardAddress = _rewardAddress; no.active = true; @@ -171,6 +173,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; + nodeOperatorIdsByName[_name] = id; no.name = _name; no.rewardAddress = _rewardAddress; no.active = true; @@ -202,6 +205,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; + nodeOperatorIdsByName[_name] = id; no.name = _name; no.rewardAddress = _rewardAddress; no.active = true; @@ -304,17 +308,25 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { onlyExistingNodeOperator(_nodeOperatorId) onlyNodeOperatorManager(_nodeOperatorId) { - _onlyValidNodeOperatorName(_name); require( keccak256(bytes(_name)) != keccak256(bytes(nodeOperators[_nodeOperatorId].name)), "SAME_NAME" ); + _onlyValidNodeOperatorName(_name); + nodeOperatorIdsByName[nodeOperators[_nodeOperatorId].name] = 0; nodeOperators[_nodeOperatorId].name = _name; + nodeOperatorIdsByName[_name] = _nodeOperatorId; emit NodeOperatorNameSet(_nodeOperatorId, _name); } + function getNodeOperatorIdByName( + string memory _name + ) external view returns (uint256) { + return nodeOperatorIdsByName[_name]; + } + function getNodeOperator( uint256 _nodeOperatorId, bool _fullInfo @@ -530,12 +542,13 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { nonce++; } - function _onlyValidNodeOperatorName(string memory _name) internal pure { + function _onlyValidNodeOperatorName(string memory _name) internal view { require( bytes(_name).length > 0 && bytes(_name).length <= MAX_NODE_OPERATOR_NAME_LENGTH, "WRONG_NAME_LENGTH" ); + require(nodeOperatorIdsByName[_name] == 0, "NAME_ALREADY_EXISTS"); } modifier onlyExistingNodeOperator(uint256 _nodeOperatorId) { diff --git a/test/CSMAddValidator.t.sol b/test/CSMAddValidator.t.sol index acdda9f7..ea9b84b1 100644 --- a/test/CSMAddValidator.t.sol +++ b/test/CSMAddValidator.t.sol @@ -57,6 +57,10 @@ contract CSMCommon is Test, Fixtures, Utilities, CommunityStakingModuleBase { } function createNodeOperator() internal returns (uint256) { + return createNodeOperator("test"); + } + + function createNodeOperator(string memory name) internal returns (uint256) { uint256 keysCount = 1; (bytes memory keys, bytes memory signatures) = keysSignatures( keysCount @@ -64,7 +68,7 @@ contract CSMCommon is Test, Fixtures, Utilities, CommunityStakingModuleBase { vm.deal(nodeOperator, 2 ether); vm.prank(nodeOperator); csm.addNodeOperatorETH{ value: 2 ether }( - "test", + name, nodeOperator, keysCount, keys, @@ -245,6 +249,15 @@ contract CSMEditNodeOperatorInfo is CSMCommon { csm.setNodeOperatorName(noId, "test"); } + function test_setNodeOperatorName_revertIfNonUniqueName() public { + uint256 noId = createNodeOperator("test"); + createNodeOperator("test2"); + + vm.prank(nodeOperator); + vm.expectRevert("NAME_ALREADY_EXISTS"); + csm.setNodeOperatorName(noId, "test2"); + } + function test_setNodeOperatorName_revertIfNotExists() public { vm.prank(nodeOperator); vm.expectRevert("node operator does not exist"); From 9ba52550fd7e27f2cb51ed3daf308f1157f91756 Mon Sep 17 00:00:00 2001 From: skhomuti Date: Tue, 10 Oct 2023 11:09:51 +0500 Subject: [PATCH 5/8] using a library to store a map with possible zero value --- src/CommunityStakingModule.sol | 16 +++++++++------- src/lib/StringToUint256WithZeroMap.sol | 21 +++++++++++++++++++++ test/lib/StringToUint256WithZeroMap.sol | 25 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 src/lib/StringToUint256WithZeroMap.sol create mode 100644 test/lib/StringToUint256WithZeroMap.sol diff --git a/src/CommunityStakingModule.sol b/src/CommunityStakingModule.sol index bd97059f..4c0d1101 100644 --- a/src/CommunityStakingModule.sol +++ b/src/CommunityStakingModule.sol @@ -9,6 +9,7 @@ import "./interfaces/ILidoLocator.sol"; import "./interfaces/ILido.sol"; import "./lib/SigningKeys.sol"; +import "./lib/StringToUint256WithZeroMap.sol"; struct NodeOperator { string name; @@ -54,6 +55,7 @@ contract CommunityStakingModuleBase { } contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { + using StringToUint256WithZeroMap for mapping(string => uint256); uint256 private nodeOperatorsCount; uint256 private activeNodeOperatorsCount; bytes32 private moduleType; @@ -147,7 +149,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; - nodeOperatorIdsByName[_name] = id; + nodeOperatorIdsByName.set(_name, id); no.name = _name; no.rewardAddress = _rewardAddress; no.active = true; @@ -173,7 +175,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; - nodeOperatorIdsByName[_name] = id; + nodeOperatorIdsByName.set(_name, id); no.name = _name; no.rewardAddress = _rewardAddress; no.active = true; @@ -205,7 +207,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { uint256 id = nodeOperatorsCount; NodeOperator storage no = nodeOperators[id]; - nodeOperatorIdsByName[_name] = id; + nodeOperatorIdsByName.set(_name, id); no.name = _name; no.rewardAddress = _rewardAddress; no.active = true; @@ -315,16 +317,16 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { ); _onlyValidNodeOperatorName(_name); - nodeOperatorIdsByName[nodeOperators[_nodeOperatorId].name] = 0; + nodeOperatorIdsByName.remove(nodeOperators[_nodeOperatorId].name); nodeOperators[_nodeOperatorId].name = _name; - nodeOperatorIdsByName[_name] = _nodeOperatorId; + nodeOperatorIdsByName.set(_name, _nodeOperatorId); emit NodeOperatorNameSet(_nodeOperatorId, _name); } function getNodeOperatorIdByName( string memory _name ) external view returns (uint256) { - return nodeOperatorIdsByName[_name]; + return nodeOperatorIdsByName.get(_name); } function getNodeOperator( @@ -548,7 +550,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { bytes(_name).length <= MAX_NODE_OPERATOR_NAME_LENGTH, "WRONG_NAME_LENGTH" ); - require(nodeOperatorIdsByName[_name] == 0, "NAME_ALREADY_EXISTS"); + require(!nodeOperatorIdsByName.exists(_name), "NAME_ALREADY_EXISTS"); } modifier onlyExistingNodeOperator(uint256 _nodeOperatorId) { diff --git a/src/lib/StringToUint256WithZeroMap.sol b/src/lib/StringToUint256WithZeroMap.sol new file mode 100644 index 00000000..8a823d0a --- /dev/null +++ b/src/lib/StringToUint256WithZeroMap.sol @@ -0,0 +1,21 @@ +pragma solidity 0.8.21; + +library StringToUint256WithZeroMap { + + function set(mapping(string => uint256) storage mapStorage, string memory key, uint256 value) internal { + mapStorage[key] = value + 1; + } + + function get(mapping(string => uint256) storage mapStorage, string memory key) internal view returns (uint256) { + require(exists(mapStorage, key), "StringToUint256WithZeroMap: key does not exist"); + return mapStorage[key] - 1; + } + + function exists(mapping(string => uint256) storage mapStorage, string memory key) internal view returns (bool) { + return mapStorage[key] != 0; + } + + function remove(mapping(string => uint256) storage mapStorage, string memory key) internal { + delete mapStorage[key]; + } +} diff --git a/test/lib/StringToUint256WithZeroMap.sol b/test/lib/StringToUint256WithZeroMap.sol new file mode 100644 index 00000000..ad75699c --- /dev/null +++ b/test/lib/StringToUint256WithZeroMap.sol @@ -0,0 +1,25 @@ +pragma solidity 0.8.21; + +import "forge-std/Test.sol"; +import "src/lib/StringToUint256WithZeroMap.sol"; + +contract TestStringToUint256WithZeroMap is Test { + + using StringToUint256WithZeroMap for mapping(string => uint256); + mapping(string => uint256) private map; + + function test_zeroValue() public { + uint256 value = 0; + map.set("key", value); + assertEq(map.get("key"), value); + assertTrue(map.exists("key")); + assertFalse(map.exists("unexpected")); + } + + function test_removeElement() public { + uint256 value = 1; + map.set("key", value); + map.remove("key"); + assertFalse(map.exists("key")); + } +} From fe5b0da20bb93ec11c4b7b469c5ea2f183c2176b Mon Sep 17 00:00:00 2001 From: skhomuti Date: Tue, 10 Oct 2023 18:54:14 +0500 Subject: [PATCH 6/8] mapStorage -> self --- src/lib/StringToUint256WithZeroMap.sol | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/StringToUint256WithZeroMap.sol b/src/lib/StringToUint256WithZeroMap.sol index 8a823d0a..61b47287 100644 --- a/src/lib/StringToUint256WithZeroMap.sol +++ b/src/lib/StringToUint256WithZeroMap.sol @@ -2,20 +2,20 @@ pragma solidity 0.8.21; library StringToUint256WithZeroMap { - function set(mapping(string => uint256) storage mapStorage, string memory key, uint256 value) internal { - mapStorage[key] = value + 1; + function set(mapping(string => uint256) storage self, string memory key, uint256 value) internal { + self[key] = value + 1; } - function get(mapping(string => uint256) storage mapStorage, string memory key) internal view returns (uint256) { - require(exists(mapStorage, key), "StringToUint256WithZeroMap: key does not exist"); - return mapStorage[key] - 1; + function get(mapping(string => uint256) storage self, string memory key) internal view returns (uint256) { + require(exists(self, key), "StringToUint256WithZeroMap: key does not exist"); + return self[key] - 1; } - function exists(mapping(string => uint256) storage mapStorage, string memory key) internal view returns (bool) { - return mapStorage[key] != 0; + function exists(mapping(string => uint256) storage self, string memory key) internal view returns (bool) { + return self[key] != 0; } - function remove(mapping(string => uint256) storage mapStorage, string memory key) internal { - delete mapStorage[key]; + function remove(mapping(string => uint256) storage self, string memory key) internal { + delete self[key]; } } From 6fe09e2dd3e97dde5c60aaa7d3b822a42bad6a30 Mon Sep 17 00:00:00 2001 From: Vladimir Gorkavenko <32727352+vgorkavenko@users.noreply.github.com> Date: Thu, 12 Oct 2023 18:31:01 +0400 Subject: [PATCH 7/8] feat: create NO and add validator with permit (#27) --- src/CommunityStakingModule.sol | 279 ++++++++++++++++++ .../ICommunityStakingBondManager.sol | 34 +++ test/CSMAddValidator.t.sol | 150 +++++++++- 3 files changed, 462 insertions(+), 1 deletion(-) diff --git a/src/CommunityStakingModule.sol b/src/CommunityStakingModule.sol index 4c0d1101..aff84055 100644 --- a/src/CommunityStakingModule.sol +++ b/src/CommunityStakingModule.sol @@ -195,6 +195,82 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { emit NodeOperatorAdded(id, _name, _rewardAddress); } + function addNodeOperatorStETHWithPermit( + string calldata _name, + address _rewardAddress, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) external { + return + _addNodeOperatorStETHWithPermit( + msg.sender, + _name, + _rewardAddress, + _keysCount, + _publicKeys, + _signatures, + _permit + ); + } + + function addNodeOperatorStETHWithPermit( + address _from, + string calldata _name, + address _rewardAddress, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) external { + return + _addNodeOperatorStETHWithPermit( + _from, + _name, + _rewardAddress, + _keysCount, + _publicKeys, + _signatures, + _permit + ); + } + + function _addNodeOperatorStETHWithPermit( + address _from, + string calldata _name, + address _rewardAddress, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) internal { + // TODO sanity checks + _onlyValidNodeOperatorName(_name); + + uint256 id = nodeOperatorsCount; + NodeOperator storage no = nodeOperators[id]; + nodeOperatorIdsByName.set(_name, id); + no.name = _name; + no.rewardAddress = _rewardAddress; + no.active = true; + nodeOperatorsCount++; + activeNodeOperatorsCount++; + + _bondManager().depositStETHWithPermit( + _from, + id, + _lido().getPooledEthByShares( + _bondManager().getRequiredBondSharesForKeys(_keysCount) + ), + _permit + ); + + _addSigningKeys(id, _keysCount, _publicKeys, _signatures); + + emit NodeOperatorAdded(id, _name, _rewardAddress); + } + function addNodeOperatorWstETH( string calldata _name, address _rewardAddress, @@ -229,6 +305,84 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { emit NodeOperatorAdded(id, _name, _rewardAddress); } + function addNodeOperatorWstETHWithPermit( + string calldata _name, + address _rewardAddress, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) external { + return + _addNodeOperatorWstETHWithPermit( + msg.sender, + _name, + _rewardAddress, + _keysCount, + _publicKeys, + _signatures, + _permit + ); + } + + function addNodeOperatorWstETHWithPermit( + address _from, + string calldata _name, + address _rewardAddress, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) external { + return + _addNodeOperatorWstETHWithPermit( + _from, + _name, + _rewardAddress, + _keysCount, + _publicKeys, + _signatures, + _permit + ); + } + + function _addNodeOperatorWstETHWithPermit( + address _from, + string calldata _name, + address _rewardAddress, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) internal { + // TODO sanity checks + _onlyValidNodeOperatorName(_name); + + uint256 id = nodeOperatorsCount; + NodeOperator storage no = nodeOperators[id]; + nodeOperatorIdsByName.set(_name, id); + no.name = _name; + no.rewardAddress = _rewardAddress; + no.active = true; + nodeOperatorsCount++; + activeNodeOperatorsCount++; + + uint256 requiredEth = _lido().getPooledEthByShares( + _bondManager().getRequiredBondSharesForKeys(_keysCount) + ); + + _bondManager().depositWstETHWithPermit( + _from, + id, + _lido().getSharesByPooledEth(requiredEth), // to get wstETH amount + _permit + ); + + _addSigningKeys(id, _keysCount, _publicKeys, _signatures); + + emit NodeOperatorAdded(id, _name, _rewardAddress); + } + function addValidatorKeysETH( uint256 _nodeOperatorId, uint256 _keysCount, @@ -280,6 +434,69 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); } + function addValidatorKeysStETHWithPermit( + uint256 _nodeOperatorId, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) external { + return + _addValidatorKeysStETHWithPermit( + msg.sender, + _nodeOperatorId, + _keysCount, + _publicKeys, + _signatures, + _permit + ); + } + + function addValidatorKeysStETHWithPermit( + address _from, + uint256 _nodeOperatorId, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) external { + return + _addValidatorKeysStETHWithPermit( + _from, + _nodeOperatorId, + _keysCount, + _publicKeys, + _signatures, + _permit + ); + } + + function _addValidatorKeysStETHWithPermit( + address _from, + uint256 _nodeOperatorId, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) internal onlyExistingNodeOperator(_nodeOperatorId) { + // TODO sanity checks + // TODO store keys + + _bondManager().depositStETHWithPermit( + _from, + _nodeOperatorId, + _lido().getPooledEthByShares( + _bondManager().getRequiredBondShares( + _nodeOperatorId, + _keysCount + ) + ), + _permit + ); + + _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); + } + function addValidatorKeysWstETH( uint256 _nodeOperatorId, uint256 _keysCount, @@ -302,6 +519,68 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); } + function addValidatorKeysWstETHWithPermit( + uint256 _nodeOperatorId, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) external { + return + _addValidatorKeysWstETHWithPermit( + msg.sender, + _nodeOperatorId, + _keysCount, + _publicKeys, + _signatures, + _permit + ); + } + + function addValidatorKeysWstETHWithPermit( + address _from, + uint256 _nodeOperatorId, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) external { + return + _addValidatorKeysWstETHWithPermit( + _from, + _nodeOperatorId, + _keysCount, + _publicKeys, + _signatures, + _permit + ); + } + + function _addValidatorKeysWstETHWithPermit( + address _from, + uint256 _nodeOperatorId, + uint256 _keysCount, + bytes calldata _publicKeys, + bytes calldata _signatures, + ICommunityStakingBondManager.PermitInput calldata _permit + ) internal onlyExistingNodeOperator(_nodeOperatorId) { + // TODO sanity checks + // TODO store keys + + uint256 requiredEth = _lido().getPooledEthByShares( + _bondManager().getRequiredBondShares(_nodeOperatorId, _keysCount) + ); + + _bondManager().depositWstETHWithPermit( + _from, + _nodeOperatorId, + _lido().getSharesByPooledEth(requiredEth), // to get wstETH amount + _permit + ); + + _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); + } + function setNodeOperatorName( uint256 _nodeOperatorId, string memory _name diff --git a/src/interfaces/ICommunityStakingBondManager.sol b/src/interfaces/ICommunityStakingBondManager.sol index 123a3ef9..475af9ec 100644 --- a/src/interfaces/ICommunityStakingBondManager.sol +++ b/src/interfaces/ICommunityStakingBondManager.sol @@ -4,17 +4,37 @@ pragma solidity 0.8.21; interface ICommunityStakingBondManager { + struct PermitInput { + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + } + function getBondShares( uint256 nodeOperatorId ) external view returns (uint256); function getBondEth(uint256 nodeOperatorId) external view returns (uint256); + function depositWstETHWithPermit( + uint256 nodeOperatorId, + uint256 wstETHAmount, + PermitInput calldata permit + ) external returns (uint256); + function depositWstETH( uint256 nodeOperatorId, uint256 wstETHAmount ) external returns (uint256); + function depositStETHWithPermit( + uint256 nodeOperatorId, + uint256 stETHAmount, + PermitInput calldata permit + ) external returns (uint256); + function depositStETH( uint256 nodeOperatorId, uint256 stETHAmount @@ -24,12 +44,26 @@ interface ICommunityStakingBondManager { uint256 nodeOperatorId ) external payable returns (uint256); + function depositWstETHWithPermit( + address from, + uint256 nodeOperatorId, + uint256 wstETHAmount, + PermitInput calldata permit + ) external returns (uint256); + function depositWstETH( address from, uint256 nodeOperatorId, uint256 wstETHAmount ) external returns (uint256); + function depositStETHWithPermit( + address from, + uint256 nodeOperatorId, + uint256 stETHAmount, + PermitInput calldata permit + ) external returns (uint256); + function depositStETH( address from, uint256 nodeOperatorId, diff --git a/test/CSMAddValidator.t.sol b/test/CSMAddValidator.t.sol index ea9b84b1..fbbab993 100644 --- a/test/CSMAddValidator.t.sol +++ b/test/CSMAddValidator.t.sol @@ -28,6 +28,7 @@ contract CSMCommon is Test, Fixtures, Utilities, CommunityStakingModuleBase { function setUp() public { alice = address(1); nodeOperator = address(2); + stranger = address(3); address[] memory penalizeRoleMembers = new address[](1); penalizeRoleMembers[0] = alice; @@ -78,7 +79,7 @@ contract CSMCommon is Test, Fixtures, Utilities, CommunityStakingModuleBase { } } -contract CSMAddNodeOperator is CSMCommon { +contract CSMAddNodeOperator is CSMCommon, PermitTokenBase { function test_AddNodeOperatorWstETH() public { uint16 keysCount = 1; (bytes memory keys, bytes memory signatures) = keysSignatures( @@ -98,6 +99,43 @@ contract CSMAddNodeOperator is CSMCommon { assertEq(csm.getNodeOperatorsCount(), 1); } + function test_AddNodeOperatorWstETHWithPermit() public { + uint16 keysCount = 1; + (bytes memory keys, bytes memory signatures) = keysSignatures( + keysCount + ); + vm.prank(nodeOperator); + uint256 wstETHAmount = wstETH.wrap(2 ether); + + { + vm.expectEmit(true, true, true, true, address(wstETH)); + emit Approval(nodeOperator, address(bondManager), wstETHAmount); + vm.expectEmit(true, true, false, true, address(csm)); + emit TotalKeysCountChanged(0, 1); + vm.expectEmit(true, true, false, true, address(csm)); + emit NodeOperatorAdded(0, "test", nodeOperator); + } + + vm.prank(stranger); + csm.addNodeOperatorWstETHWithPermit( + nodeOperator, + "test", + nodeOperator, + 1, + keys, + signatures, + ICommunityStakingBondManager.PermitInput({ + value: wstETHAmount, + deadline: type(uint256).max, + // mock permit signature + v: 0, + r: 0, + s: 0 + }) + ); + assertEq(csm.getNodeOperatorsCount(), 1); + } + function test_AddValidatorKeysWstETH() public { uint256 noId = createNodeOperator(); @@ -112,6 +150,45 @@ contract CSMAddNodeOperator is CSMCommon { csm.addValidatorKeysWstETH(noId, 1, keys, signatures); } + function test_AddValidatorKeysWstETHWithPermit() public { + uint16 keysCount = 1; + (bytes memory keys, bytes memory signatures) = keysSignatures( + keysCount + ); + vm.startPrank(nodeOperator); + wstETH.wrap(2 ether); + csm.addNodeOperatorWstETH("test", nodeOperator, 1, keys, signatures); + uint256 noId = csm.getNodeOperatorsCount() - 1; + + vm.deal(nodeOperator, 2 ether); + stETH.submit{ value: 2 ether }(address(0)); + uint256 wstETHAmount = wstETH.wrap(2 ether); + vm.stopPrank(); + (keys, signatures) = keysSignatures(keysCount, 1); + { + vm.expectEmit(true, true, true, true, address(wstETH)); + emit Approval(nodeOperator, address(bondManager), wstETHAmount); + vm.expectEmit(true, true, false, true, address(csm)); + emit TotalKeysCountChanged(0, 2); + } + vm.prank(stranger); + csm.addValidatorKeysWstETHWithPermit( + nodeOperator, + noId, + 1, + keys, + signatures, + ICommunityStakingBondManager.PermitInput({ + value: wstETHAmount, + deadline: type(uint256).max, + // mock permit signature + v: 0, + r: 0, + s: 0 + }) + ); + } + function test_AddNodeOperatorStETH() public { uint16 keysCount = 1; (bytes memory keys, bytes memory signatures) = keysSignatures( @@ -130,6 +207,41 @@ contract CSMAddNodeOperator is CSMCommon { assertEq(csm.getNodeOperatorsCount(), 1); } + function test_AddNodeOperatorStETHWithPermit() public { + uint16 keysCount = 1; + (bytes memory keys, bytes memory signatures) = keysSignatures( + keysCount + ); + + { + vm.expectEmit(true, true, true, true, address(stETH)); + emit Approval(nodeOperator, address(bondManager), 2 ether); + vm.expectEmit(true, true, false, true, address(csm)); + emit TotalKeysCountChanged(0, 1); + vm.expectEmit(true, true, false, true, address(csm)); + emit NodeOperatorAdded(0, "test", nodeOperator); + } + + vm.prank(stranger); + csm.addNodeOperatorStETHWithPermit( + nodeOperator, + "test", + nodeOperator, + 1, + keys, + signatures, + ICommunityStakingBondManager.PermitInput({ + value: 2 ether, + deadline: type(uint256).max, + // mock permit signature + v: 0, + r: 0, + s: 0 + }) + ); + assertEq(csm.getNodeOperatorsCount(), 1); + } + function test_AddValidatorKeysStETH() public { uint256 noId = createNodeOperator(); (bytes memory keys, bytes memory signatures) = keysSignatures(1, 1); @@ -144,6 +256,42 @@ contract CSMAddNodeOperator is CSMCommon { csm.addValidatorKeysStETH(noId, 1, keys, signatures); } + function test_AddValidatorKeysStETHWithPermit() public { + uint16 keysCount = 1; + (bytes memory keys, bytes memory signatures) = keysSignatures( + keysCount + ); + vm.prank(nodeOperator); + csm.addNodeOperatorStETH("test", nodeOperator, 1, keys, signatures); + uint256 noId = csm.getNodeOperatorsCount() - 1; + + vm.deal(nodeOperator, 2 ether); + vm.prank(nodeOperator); + stETH.submit{ value: 2 ether }(address(0)); + { + vm.expectEmit(true, true, true, true, address(stETH)); + emit Approval(nodeOperator, address(bondManager), 2 ether); + vm.expectEmit(true, true, false, true, address(csm)); + emit TotalKeysCountChanged(0, 2); + } + vm.prank(stranger); + csm.addValidatorKeysStETHWithPermit( + nodeOperator, + noId, + 1, + keys, + signatures, + ICommunityStakingBondManager.PermitInput({ + value: 2 ether, + deadline: type(uint256).max, + // mock permit signature + v: 0, + r: 0, + s: 0 + }) + ); + } + function test_AddNodeOperatorETH() public { uint16 keysCount = 1; (bytes memory keys, bytes memory signatures) = keysSignatures( From 162d36ed01529ef22169f62e9aa0c1e9e20b8da2 Mon Sep 17 00:00:00 2001 From: Vladimir Gorkavenko <32727352+vgorkavenko@users.noreply.github.com> Date: Tue, 24 Oct 2023 18:47:57 +0400 Subject: [PATCH 8/8] CS-78 feat: required bond methods (#28) * feat: required bond methods * feat: lcov * feat: refactor, tests * fix: review * fix: prettier * fix: review * fix: review --- .gitignore | 1 + Makefile | 3 + src/CommunityStakingBondManager.sol | 476 ++++++-- src/CommunityStakingModule.sol | 64 +- src/FeeDistributor.sol | 27 +- .../ICommunityStakingBondManager.sol | 22 +- .../ICommunityStakingFeeDistributor.sol | 6 + test/BondManager.sol | 478 -------- test/BondManager.t.sol | 1020 +++++++++++++++++ test/CSMAddValidator.t.sol | 37 +- .../CommunityStakingFeeDistributorMock.sol | 8 + .../mocks/CommunityStakingModuleMock.sol | 6 + 12 files changed, 1468 insertions(+), 680 deletions(-) delete mode 100644 test/BondManager.sol create mode 100644 test/BondManager.t.sol diff --git a/.gitignore b/.gitignore index 91120ba7..97ad7a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules/ broadcast/ cache/ out/ +lcov.info diff --git a/Makefile b/Makefile index 1fbe4c47..777823ec 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,8 @@ test-integration: forge test --match-path '*test/integration*' -vvv coverage: forge coverage +coverage-lcov: + forge coverage --report lcov anvil-fork: exec anvil -f ${RPC_URL} @@ -52,5 +54,6 @@ t: test tu: test-unit ti: test-integration c: coverage +cl: coverage-lcov af: anvil-fork ak: anvil-kill diff --git a/src/CommunityStakingBondManager.sol b/src/CommunityStakingBondManager.sol index 475de389..e7ac3cd0 100644 --- a/src/CommunityStakingBondManager.sol +++ b/src/CommunityStakingBondManager.sol @@ -12,25 +12,35 @@ import { IWstETH } from "./interfaces/IWstETH.sol"; import { ICommunityStakingFeeDistributor } from "./interfaces/ICommunityStakingFeeDistributor.sol"; contract CommunityStakingBondManagerBase { - event BondDeposited( - uint256 nodeOperatorId, - address indexed from, - uint256 shares + event ETHBondDeposited( + uint256 indexed nodeOperatorId, + address from, + uint256 amount + ); + event StETHBondDeposited( + uint256 indexed nodeOperatorId, + address from, + uint256 amount + ); + event WstETHBondDeposited( + uint256 indexed nodeOperatorId, + address from, + uint256 amount ); event BondPenalized( - uint256 nodeOperatorId, + uint256 indexed nodeOperatorId, uint256 penaltyShares, uint256 burnedShares ); event StETHRewardsClaimed( - uint256 nodeOperatorId, - address indexed to, - uint256 shares + uint256 indexed nodeOperatorId, + address to, + uint256 amount ); event WstETHRewardsClaimed( - uint256 nodeOperatorId, - address indexed to, - uint256 wstETHAmount + uint256 indexed nodeOperatorId, + address to, + uint256 amount ); } @@ -52,6 +62,7 @@ contract CommunityStakingBondManager is keccak256("PENALIZE_BOND_ROLE"); address public FEE_DISTRIBUTOR; + uint256 public totalBondShares; mapping(uint256 => uint256) private bondShares; ILidoLocator private immutable LIDO_LOCATOR; @@ -108,12 +119,6 @@ contract CommunityStakingBondManager is FEE_DISTRIBUTOR = _fdAddress; } - /// @notice Returns the total bond shares. - /// @return total bond shares. - function totalBondShares() public view returns (uint256) { - return _lido().sharesOf(address(this)); - } - /// @notice Returns the bond shares for the given node operator. /// @param nodeOperatorId id of the node operator to get bond for. /// @return bond shares. @@ -123,66 +128,233 @@ contract CommunityStakingBondManager is return bondShares[nodeOperatorId]; } - /// @notice Returns excess bond for the given node operator. - /// @param nodeOperatorId id of the node operator to get bond rewards for. - /// @return excess bond in shares. - function getExcessBondShares( - uint256 nodeOperatorId + /// @notice Returns total rewards (bond + fees) in ETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get rewards for. + /// @return total rewards in ETH + function getTotalRewardsETH( + bytes32[] memory rewardsProof, + uint256 nodeOperatorId, + uint256 cumulativeFeeShares ) public view returns (uint256) { - uint256 activeKeys = _getNodeOperatorActiveKeys(nodeOperatorId); - uint256 currentBondShares = getBondShares(nodeOperatorId); - uint256 requiredBondShares = _lido().getSharesByPooledEth( - activeKeys * COMMON_BOND_SIZE + (uint256 current, uint256 required) = _bondSharesSummary( + _getNodeOperatorActiveKeys(nodeOperatorId) ); + current += _feeDistributor().getFeesToDistribute( + rewardsProof, + nodeOperatorId, + cumulativeFeeShares + ); + uint256 excess = current > required ? current - required : 0; + return excess > 0 ? _ethByShares(excess) : 0; + } + + /// @notice Returns total rewards (bond + fees) in stETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get rewards for. + /// @return total rewards in stETH + function getTotalRewardsStETH( + bytes32[] memory rewardsProof, + uint256 nodeOperatorId, + uint256 cumulativeFeeShares + ) public view returns (uint256) { return - currentBondShares > requiredBondShares - ? currentBondShares - requiredBondShares - : 0; + getTotalRewardsETH( + rewardsProof, + nodeOperatorId, + cumulativeFeeShares + ); } - /// @notice Returns the required bond shares for the given node operator. - /// @param nodeOperatorId id of the node operator to get required bond for. - /// @return required bond shares. - function getRequiredBondShares( + /// @notice Returns total rewards (bond + fees) in wstETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get rewards for. + /// @return total rewards in wstETH + function getTotalRewardsWstETH( + bytes32[] memory rewardsProof, + uint256 nodeOperatorId, + uint256 cumulativeFeeShares + ) public view returns (uint256) { + return + WSTETH.getWstETHByStETH( + getTotalRewardsStETH( + rewardsProof, + nodeOperatorId, + cumulativeFeeShares + ) + ); + } + + /// @notice Returns excess bond ETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get excess bond for. + /// @return excess bond ETH. + function getExcessBondETH( + uint256 nodeOperatorId + ) public view returns (uint256) { + (uint256 current, uint256 required) = _bondETHSummary(nodeOperatorId); + return current > required ? current - required : 0; + } + + /// @notice Returns excess bond stETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get excess bond for. + /// @return excess bond stETH. + function getExcessBondStETH( + uint256 nodeOperatorId + ) public view returns (uint256) { + return getExcessBondETH(nodeOperatorId); + } + + /// @notice Returns excess bond wstETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get excess bond for. + /// @return excess bond wstETH. + function getExcessBondWstETH( + uint256 nodeOperatorId + ) public view returns (uint256) { + return WSTETH.getWstETHByStETH(getExcessBondStETH(nodeOperatorId)); + } + + /// @notice Returns the missing bond ETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get missing bond for. + /// @return missing bond ETH. + function getMissingBondETH( + uint256 nodeOperatorId + ) public view returns (uint256) { + (uint256 current, uint256 required) = _bondETHSummary(nodeOperatorId); + return required > current ? required - current : 0; + } + + /// @notice Returns the missing bond stETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get missing bond for. + /// @return missing bond stETH. + function getMissingBondStETH( uint256 nodeOperatorId ) public view returns (uint256) { - return _getRequiredBondShares(nodeOperatorId, 0); + return getMissingBondETH(nodeOperatorId); } - /// @notice Returns the required bond shares for the given node operator. + /// @notice Returns the missing bond wstETH for the given node operator. + /// @param nodeOperatorId id of the node operator to get missing bond for. + /// @return missing bond wstETH. + function getMissingBondWstETH( + uint256 nodeOperatorId + ) public view returns (uint256) { + return WSTETH.getWstETHByStETH(getMissingBondStETH(nodeOperatorId)); + } + + /// @notice Returns the required bond ETH (inc. missed and excess) for the given node operator to upload new keys. /// @param nodeOperatorId id of the node operator to get required bond for. - /// @param newKeysCount number of new keys to add. - /// @return required bond shares. - function getRequiredBondShares( + /// @return required bond ETH. + function getRequiredBondETH( uint256 nodeOperatorId, - uint256 newKeysCount + uint256 additionalKeysCount ) public view returns (uint256) { - return _getRequiredBondShares(nodeOperatorId, newKeysCount); + (uint256 current, uint256 required) = _bondETHSummary(nodeOperatorId); + uint256 requiredForKeys = getRequiredBondETHForKeys( + additionalKeysCount + ); + + uint256 missing = required > current ? required - current : 0; + if (missing > 0) { + return missing + requiredForKeys; + } + + uint256 excess = current - required; + if (excess >= requiredForKeys) { + return 0; + } + + return requiredForKeys - excess; } - function _getRequiredBondShares( + /// @notice Returns the required bond stETH (inc. missed and excess) for the given node operator to upload new keys. + /// @param nodeOperatorId id of the node operator to get required bond for. + /// @return required bond stETH. + function getRequiredBondStETH( uint256 nodeOperatorId, - uint256 newKeysCount - ) internal view returns (uint256) { - uint256 currentBondShares = getBondShares(nodeOperatorId); - uint256 requiredBondShares = _lido().getSharesByPooledEth( - (_getNodeOperatorActiveKeys(nodeOperatorId)) * COMMON_BOND_SIZE - ) + getRequiredBondSharesForKeys(newKeysCount); + uint256 additionalKeysCount + ) public view returns (uint256) { + return getRequiredBondETH(nodeOperatorId, additionalKeysCount); + } + + /// @notice Returns the required bond wstETH (inc. missed and excess) for the given node operator to upload new keys. + /// @param nodeOperatorId id of the node operator to get required bond for. + /// @param additionalKeysCount number of new keys to add. + /// @return required bond wstETH. + function getRequiredBondWstETH( + uint256 nodeOperatorId, + uint256 additionalKeysCount + ) public view returns (uint256) { return - requiredBondShares > currentBondShares - ? requiredBondShares - currentBondShares - : 0; + WSTETH.getWstETHByStETH( + getRequiredBondStETH(nodeOperatorId, additionalKeysCount) + ); + } + + /// @notice Returns the required bond ETH for the given number of keys. + /// @param keysCount number of keys to get required bond for. + /// @return required ETH. + function getRequiredBondETHForKeys( + uint256 keysCount + ) public view returns (uint256) { + return keysCount * COMMON_BOND_SIZE; } - /// @notice Returns the required bond shares for the given number of keys. + /// @notice Returns the required bond stETH for the given number of keys. /// @param keysCount number of keys to get required bond for. - /// @return required bond shares. - function getRequiredBondSharesForKeys( + /// @return required stETH. + function getRequiredBondStETHForKeys( uint256 keysCount ) public view returns (uint256) { - return _lido().getSharesByPooledEth(keysCount * COMMON_BOND_SIZE); + return getRequiredBondETHForKeys(keysCount); } + /// @notice Returns the required bond wstETH for the given number of keys. + /// @param keysCount number of keys to get required bond for. + /// @return required wstETH. + function getRequiredBondWstETHForKeys( + uint256 keysCount + ) public view returns (uint256) { + return _getRequiredBondSharesForKeys(keysCount); + } + + function _getRequiredBondSharesForKeys( + uint256 keysCount + ) internal view returns (uint256) { + return _sharesByEth(getRequiredBondETHForKeys(keysCount)); + } + + /// @dev unbonded meaning amount of keys with no bond at all + /// @notice Returns the number of unbonded keys + /// @param nodeOperatorId id of the node operator to get keys count for. + /// @return unbonded keys count. + function getUnbondedKeysCount( + uint256 nodeOperatorId + ) public view returns (uint256) { + return + getRequiredBondETH(nodeOperatorId, 0) / + getRequiredBondETHForKeys(1); + } + + /// @notice Returns the number of keys by the given bond ETH amount + function getKeysCountByBondETH( + uint256 ETHAmount + ) public view returns (uint256) { + return ETHAmount / getRequiredBondETHForKeys(1); + } + + /// @notice Returns the number of keys by the given bond stETH amount + function getKeysCountByBondStETH( + uint256 stETHAmount + ) public view returns (uint256) { + return stETHAmount / getRequiredBondStETHForKeys(1); + } + + /// @notice Returns the number of keys by the given bond wstETH amount + function getKeysCountByBondWstETH( + uint256 wstETHAmount + ) public view returns (uint256) { + return wstETHAmount / getRequiredBondWstETHForKeys(1); + } + + /// @notice Deposits ETH to the bond for the given node operator. + /// @param nodeOperatorId id of the node operator to deposit bond for. function depositETH( uint256 nodeOperatorId ) external payable returns (uint256) { @@ -209,10 +381,14 @@ contract CommunityStakingBondManager is ); uint256 shares = _lido().submit{ value: msg.value }(address(0)); bondShares[nodeOperatorId] += shares; - emit BondDeposited(nodeOperatorId, from, shares); + totalBondShares += shares; + emit ETHBondDeposited(nodeOperatorId, from, msg.value); return shares; } + /// @notice Deposits stETH to the bond for the given node operator. + /// @param nodeOperatorId id of the node operator to deposit bond for. + /// @param stETHAmount amount of stETH to deposit. function depositStETH( uint256 nodeOperatorId, uint256 stETHAmount @@ -240,10 +416,11 @@ contract CommunityStakingBondManager is nodeOperatorId < CSM.getNodeOperatorsCount(), "node operator does not exist" ); - uint256 shares = _lido().getSharesByPooledEth(stETHAmount); + uint256 shares = _sharesByEth(stETHAmount); _lido().transferSharesFrom(from, address(this), shares); bondShares[nodeOperatorId] += shares; - emit BondDeposited(nodeOperatorId, from, shares); + totalBondShares += shares; + emit StETHBondDeposited(nodeOperatorId, from, stETHAmount); return shares; } @@ -330,9 +507,10 @@ contract CommunityStakingBondManager is ); WSTETH.transferFrom(from, address(this), wstETHAmount); uint256 stETHAmount = WSTETH.unwrap(wstETHAmount); - uint256 shares = _lido().getSharesByPooledEth(stETHAmount); + uint256 shares = _sharesByEth(stETHAmount); bondShares[nodeOperatorId] += shares; - emit BondDeposited(nodeOperatorId, from, shares); + totalBondShares += shares; + emit WstETHBondDeposited(nodeOperatorId, from, wstETHAmount); return shares; } @@ -395,33 +573,41 @@ contract CommunityStakingBondManager is /// @notice Claims full reward (fee + bond) for the given node operator available for this moment /// @param rewardsProof merkle proof of the rewards. /// @param nodeOperatorId id of the node operator to claim rewards for. - /// @param cumulativeFeeShares cummulative fee shares for the node operator. + /// @param cumulativeFeeShares cumulative fee shares for the node operator. function claimRewardsStETH( bytes32[] memory rewardsProof, uint256 nodeOperatorId, uint256 cumulativeFeeShares ) external { - ( - address rewardAddress, - uint256 rewardShares - ) = _calculateFinalRewardShares( - rewardsProof, - nodeOperatorId, - cumulativeFeeShares - ); - _lido().transferSharesFrom(address(this), rewardAddress, rewardShares); - bondShares[nodeOperatorId] -= rewardShares; + address rewardAddress = _getNodeOperatorRewardAddress(nodeOperatorId); + _isSenderEligableToClaim(rewardAddress); + uint256 claimableShares = _pullFeeRewards( + rewardsProof, + nodeOperatorId, + cumulativeFeeShares + ); + if (claimableShares == 0) { + emit StETHRewardsClaimed(nodeOperatorId, rewardAddress, 0); + return; + } + _lido().transferSharesFrom( + address(this), + rewardAddress, + claimableShares + ); + bondShares[nodeOperatorId] -= claimableShares; + totalBondShares -= claimableShares; emit StETHRewardsClaimed( nodeOperatorId, rewardAddress, - _lido().getPooledEthByShares(rewardShares) + _ethByShares(claimableShares) ); } /// @notice Claims full reward (fee + bond) for the given node operator with desirable value /// @param rewardsProof merkle proof of the rewards. /// @param nodeOperatorId id of the node operator to claim rewards for. - /// @param cumulativeFeeShares cummulative fee shares for the node operator. + /// @param cumulativeFeeShares cumulative fee shares for the node operator. /// @param stETHAmount amount of stETH to claim. function claimRewardsStETH( bytes32[] memory rewardsProof, @@ -429,78 +615,90 @@ contract CommunityStakingBondManager is uint256 cumulativeFeeShares, uint256 stETHAmount ) external { - ( - address rewardAddress, - uint256 rewardShares - ) = _calculateFinalRewardShares( - rewardsProof, - nodeOperatorId, - cumulativeFeeShares - ); - uint256 shares = _lido().getSharesByPooledEth(stETHAmount); - rewardShares = shares < rewardShares ? shares : rewardShares; - _lido().transferSharesFrom(address(this), rewardAddress, rewardShares); - bondShares[nodeOperatorId] -= rewardShares; + address rewardAddress = _getNodeOperatorRewardAddress(nodeOperatorId); + _isSenderEligableToClaim(rewardAddress); + uint256 claimableShares = _pullFeeRewards( + rewardsProof, + nodeOperatorId, + cumulativeFeeShares + ); + uint256 shares = _sharesByEth(stETHAmount); + claimableShares = shares < claimableShares ? shares : claimableShares; + if (claimableShares == 0) { + emit StETHRewardsClaimed(nodeOperatorId, rewardAddress, 0); + return; + } + _lido().transferSharesFrom( + address(this), + rewardAddress, + claimableShares + ); + bondShares[nodeOperatorId] -= claimableShares; + totalBondShares -= claimableShares; emit StETHRewardsClaimed( nodeOperatorId, rewardAddress, - _lido().getPooledEthByShares(rewardShares) + _ethByShares(claimableShares) ); } /// @notice Claims full reward (fee + bond) for the given node operator available for this moment /// @param rewardsProof merkle proof of the rewards. /// @param nodeOperatorId id of the node operator to claim rewards for. - /// @param cumulativeFeeShares cummulative fee shares for the node operator. + /// @param cumulativeFeeShares cumulative fee shares for the node operator. function claimRewardsWstETH( bytes32[] memory rewardsProof, uint256 nodeOperatorId, uint256 cumulativeFeeShares - ) external returns (uint256) { - ( - address rewardAddress, - uint256 rewardShares - ) = _calculateFinalRewardShares( - rewardsProof, - nodeOperatorId, - cumulativeFeeShares - ); - uint256 wstETHAmount = WSTETH.wrap( - _lido().getPooledEthByShares(rewardShares) + ) external { + address rewardAddress = _getNodeOperatorRewardAddress(nodeOperatorId); + _isSenderEligableToClaim(rewardAddress); + uint256 claimableShares = _pullFeeRewards( + rewardsProof, + nodeOperatorId, + cumulativeFeeShares ); + if (claimableShares == 0) { + emit WstETHRewardsClaimed(nodeOperatorId, rewardAddress, 0); + return; + } + uint256 wstETHAmount = WSTETH.wrap(_ethByShares(claimableShares)); WSTETH.transferFrom(address(this), rewardAddress, wstETHAmount); - bondShares[nodeOperatorId] -= rewardShares; + bondShares[nodeOperatorId] -= wstETHAmount; + totalBondShares -= wstETHAmount; emit WstETHRewardsClaimed(nodeOperatorId, rewardAddress, wstETHAmount); - return wstETHAmount; } /// @notice Claims full reward (fee + bond) for the given node operator available for this moment /// @param rewardsProof merkle proof of the rewards. /// @param nodeOperatorId id of the node operator to claim rewards for. - /// @param cumulativeFeeShares cummulative fee shares for the node operator. + /// @param cumulativeFeeShares cumulative fee shares for the node operator. /// @param wstETHAmount amount of wstETH to claim. function claimRewardsWstETH( bytes32[] memory rewardsProof, uint256 nodeOperatorId, uint256 cumulativeFeeShares, uint256 wstETHAmount - ) external returns (uint256) { - ( - address rewardAddress, - uint256 rewardShares - ) = _calculateFinalRewardShares( - rewardsProof, - nodeOperatorId, - cumulativeFeeShares - ); - rewardShares = wstETHAmount < rewardShares + ) external { + address rewardAddress = _getNodeOperatorRewardAddress(nodeOperatorId); + _isSenderEligableToClaim(rewardAddress); + uint256 claimableShares = _pullFeeRewards( + rewardsProof, + nodeOperatorId, + cumulativeFeeShares + ); + claimableShares = wstETHAmount < claimableShares ? wstETHAmount - : rewardShares; - wstETHAmount = WSTETH.wrap(_lido().getPooledEthByShares(rewardShares)); + : claimableShares; + if (claimableShares == 0) { + emit WstETHRewardsClaimed(nodeOperatorId, rewardAddress, 0); + return; + } + wstETHAmount = WSTETH.wrap(_ethByShares(claimableShares)); WSTETH.transferFrom(address(this), rewardAddress, wstETHAmount); - bondShares[nodeOperatorId] -= rewardShares; + bondShares[nodeOperatorId] -= wstETHAmount; + totalBondShares -= wstETHAmount; emit WstETHRewardsClaimed(nodeOperatorId, rewardAddress, wstETHAmount); - return wstETHAmount; } /// @notice Penalize bond by burning shares @@ -518,6 +716,7 @@ contract CommunityStakingBondManager is coveringShares ); bondShares[nodeOperatorId] -= coveringShares; + totalBondShares -= coveringShares; emit BondPenalized(nodeOperatorId, shares, coveringShares); } @@ -562,20 +761,53 @@ contract CommunityStakingBondManager is return rewardAddress; } - function _calculateFinalRewardShares( - bytes32[] memory rewardsProof, - uint256 nodeOperatorId, - uint256 cumulativeFeeShares - ) internal returns (address rewardAddress, uint256 rewardShares) { - rewardAddress = _getNodeOperatorRewardAddress(nodeOperatorId); + function _isSenderEligableToClaim(address rewardAddress) internal view { if (msg.sender != rewardAddress) { revert NotOwnerToClaim(msg.sender, rewardAddress); } - bondShares[nodeOperatorId] += _feeDistributor().distributeFees( + } + + function _pullFeeRewards( + bytes32[] memory rewardsProof, + uint256 nodeOperatorId, + uint256 cumulativeFeeShares + ) internal returns (uint256 claimableShares) { + uint256 distributed = _feeDistributor().distributeFees( rewardsProof, nodeOperatorId, cumulativeFeeShares ); - rewardShares = getExcessBondShares(nodeOperatorId); + bondShares[nodeOperatorId] += distributed; + totalBondShares += distributed; + (uint256 current, uint256 required) = _bondSharesSummary( + nodeOperatorId + ); + claimableShares = current > required ? current - required : 0; + } + + function _bondETHSummary( + uint256 nodeOperatorId + ) internal view returns (uint256 current, uint256 required) { + current = _ethByShares(getBondShares(nodeOperatorId)); + required = getRequiredBondETHForKeys( + _getNodeOperatorActiveKeys(nodeOperatorId) + ); + } + + function _bondSharesSummary( + uint256 nodeOperatorId + ) internal view returns (uint256 current, uint256 required) { + current = getBondShares(nodeOperatorId); + required = _getRequiredBondSharesForKeys( + _getNodeOperatorActiveKeys(nodeOperatorId) + ); + } + + function _sharesByEth(uint256 ethAmount) internal view returns (uint256) { + return _lido().getSharesByPooledEth(ethAmount); + } + + function _ethByShares(uint256 shares) internal view returns (uint256) { + return _lido().getPooledEthByShares(shares); } } diff --git a/src/CommunityStakingModule.sol b/src/CommunityStakingModule.sol index aff84055..80fb8c6a 100644 --- a/src/CommunityStakingModule.sol +++ b/src/CommunityStakingModule.sol @@ -140,11 +140,8 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _onlyValidNodeOperatorName(_name); require( - msg.value >= - _lido().getPooledEthByShares( - _bondManager().getRequiredBondSharesForKeys(_keysCount) - ), - "not enough eth to deposit" + msg.value == _bondManager().getRequiredBondETHForKeys(_keysCount), + "eth value is not equal to required bond" ); uint256 id = nodeOperatorsCount; @@ -185,9 +182,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _bondManager().depositStETH( msg.sender, id, - _lido().getPooledEthByShares( - _bondManager().getRequiredBondSharesForKeys(_keysCount) - ) + _bondManager().getRequiredBondStETHForKeys(_keysCount) ); _addSigningKeys(id, _keysCount, _publicKeys, _signatures); @@ -260,9 +255,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _bondManager().depositStETHWithPermit( _from, id, - _lido().getPooledEthByShares( - _bondManager().getRequiredBondSharesForKeys(_keysCount) - ), + _bondManager().getRequiredBondStETHForKeys(_keysCount), _permit ); @@ -290,14 +283,10 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { nodeOperatorsCount++; activeNodeOperatorsCount++; - uint256 requiredEth = _lido().getPooledEthByShares( - _bondManager().getRequiredBondSharesForKeys(_keysCount) - ); - _bondManager().depositWstETH( msg.sender, id, - _lido().getSharesByPooledEth(requiredEth) // to get wstETH amount + _bondManager().getRequiredBondWstETHForKeys(_keysCount) ); _addSigningKeys(id, _keysCount, _publicKeys, _signatures); @@ -367,14 +356,10 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { nodeOperatorsCount++; activeNodeOperatorsCount++; - uint256 requiredEth = _lido().getPooledEthByShares( - _bondManager().getRequiredBondSharesForKeys(_keysCount) - ); - _bondManager().depositWstETHWithPermit( _from, id, - _lido().getSharesByPooledEth(requiredEth), // to get wstETH amount + _bondManager().getRequiredBondWstETHForKeys(_keysCount), _permit ); @@ -393,14 +378,9 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { // TODO store keys require( - msg.value >= - _lido().getPooledEthByShares( - _bondManager().getRequiredBondShares( - _nodeOperatorId, - _keysCount - ) - ), - "not enough eth to deposit" + msg.value == + _bondManager().getRequiredBondETH(_nodeOperatorId, _keysCount), + "eth value is not equal to required bond" ); _bondManager().depositETH{ value: msg.value }( @@ -423,12 +403,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _bondManager().depositStETH( msg.sender, _nodeOperatorId, - _lido().getPooledEthByShares( - _bondManager().getRequiredBondShares( - _nodeOperatorId, - _keysCount - ) - ) + _bondManager().getRequiredBondStETH(_nodeOperatorId, _keysCount) ); _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); @@ -485,12 +460,7 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { _bondManager().depositStETHWithPermit( _from, _nodeOperatorId, - _lido().getPooledEthByShares( - _bondManager().getRequiredBondShares( - _nodeOperatorId, - _keysCount - ) - ), + _bondManager().getRequiredBondStETH(_nodeOperatorId, _keysCount), _permit ); @@ -506,14 +476,10 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { // TODO sanity checks // TODO store keys - uint256 requiredEth = _lido().getPooledEthByShares( - _bondManager().getRequiredBondShares(_nodeOperatorId, _keysCount) - ); - _bondManager().depositWstETH( msg.sender, _nodeOperatorId, - _lido().getSharesByPooledEth(requiredEth) // to get wstETH amount + _bondManager().getRequiredBondWstETH(_nodeOperatorId, _keysCount) ); _addSigningKeys(_nodeOperatorId, _keysCount, _publicKeys, _signatures); @@ -567,14 +533,10 @@ contract CommunityStakingModule is IStakingModule, CommunityStakingModuleBase { // TODO sanity checks // TODO store keys - uint256 requiredEth = _lido().getPooledEthByShares( - _bondManager().getRequiredBondShares(_nodeOperatorId, _keysCount) - ); - _bondManager().depositWstETHWithPermit( _from, _nodeOperatorId, - _lido().getSharesByPooledEth(requiredEth), // to get wstETH amount + _bondManager().getRequiredBondWstETH(_nodeOperatorId, _keysCount), _permit ); diff --git a/src/FeeDistributor.sol b/src/FeeDistributor.sol index e80598ba..9e162ed8 100644 --- a/src/FeeDistributor.sol +++ b/src/FeeDistributor.sol @@ -36,17 +36,15 @@ contract FeeDistributor is FeeDistributorBase { CSM = _CSM; } - /// @notice Distribute fees to the BondManager + /// @notice Returns the amount of shares that can be distributed in favor of the NO /// @param proof Merkle proof of the leaf /// @param noIndex Index of the NO /// @param shares Total amount of shares earned as fees - function distributeFees( + function getFeesToDistribute( bytes32[] calldata proof, uint64 noIndex, uint64 shares - ) external returns (uint64) { - if (msg.sender != BOND_MANAGER) revert NotBondManager(); - + ) public view returns (uint64) { bool isValid = MerkleProof.verifyCalldata( proof, IFeeOracle(ORACLE).reportRoot(), @@ -58,12 +56,25 @@ contract FeeDistributor is FeeDistributorBase { revert InvalidShares(); } - if (distributedShares[noIndex] == shares) { + return shares - distributedShares[noIndex]; + } + + /// @notice Distribute fees to the BondManager in favor of the NO + /// @param proof Merkle proof of the leaf + /// @param noIndex Index of the NO + /// @param shares Total amount of shares earned as fees + function distributeFees( + bytes32[] calldata proof, + uint64 noIndex, + uint64 shares + ) external returns (uint64) { + if (msg.sender != BOND_MANAGER) revert NotBondManager(); + + uint64 sharesToDistribute = getFeesToDistribute(proof, noIndex, shares); + if (sharesToDistribute == 0) { // To avoid breaking claim rewards logic return 0; } - - uint64 sharesToDistribute = shares - distributedShares[noIndex]; distributedShares[noIndex] += sharesToDistribute; IStETH(STETH).transferShares(BOND_MANAGER, sharesToDistribute); emit FeeDistributed(noIndex, sharesToDistribute); diff --git a/src/interfaces/ICommunityStakingBondManager.sol b/src/interfaces/ICommunityStakingBondManager.sol index 475af9ec..1545488e 100644 --- a/src/interfaces/ICommunityStakingBondManager.sol +++ b/src/interfaces/ICommunityStakingBondManager.sol @@ -75,15 +75,29 @@ interface ICommunityStakingBondManager { uint256 nodeOperatorId ) external payable returns (uint256); - function getRequiredBondSharesForKeys( + function getRequiredBondETHForKeys( uint256 keysCount ) external view returns (uint256); - function getRequiredBondShares( - uint256 nodeOperatorId + function getRequiredBondStETHForKeys( + uint256 keysCount + ) external view returns (uint256); + + function getRequiredBondWstETHForKeys( + uint256 keysCount + ) external view returns (uint256); + + function getRequiredBondETH( + uint256 nodeOperatorId, + uint256 newKeysCount + ) external view returns (uint256); + + function getRequiredBondStETH( + uint256 nodeOperatorId, + uint256 newKeysCount ) external view returns (uint256); - function getRequiredBondShares( + function getRequiredBondWstETH( uint256 nodeOperatorId, uint256 newKeysCount ) external view returns (uint256); diff --git a/src/interfaces/ICommunityStakingFeeDistributor.sol b/src/interfaces/ICommunityStakingFeeDistributor.sol index c053fb9d..1d16046e 100644 --- a/src/interfaces/ICommunityStakingFeeDistributor.sol +++ b/src/interfaces/ICommunityStakingFeeDistributor.sol @@ -4,6 +4,12 @@ pragma solidity 0.8.21; interface ICommunityStakingFeeDistributor { + function getFeesToDistribute( + bytes32[] calldata rewardProof, + uint256 noIndex, + uint256 shares + ) external view returns (uint256); + function distributeFees( bytes32[] calldata rewardProof, uint256 noIndex, diff --git a/test/BondManager.sol b/test/BondManager.sol deleted file mode 100644 index 87ec98c3..00000000 --- a/test/BondManager.sol +++ /dev/null @@ -1,478 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Lido -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.21; - -import "forge-std/Test.sol"; - -import { CommunityStakingBondManagerBase, CommunityStakingBondManager } from "../src/CommunityStakingBondManager.sol"; -import { PermitTokenBase } from "./helpers/Permit.sol"; -import { Stub } from "./helpers/mocks/Stub.sol"; -import { LidoMock } from "./helpers/mocks/LidoMock.sol"; -import { WstETHMock } from "./helpers/mocks/WstETHMock.sol"; -import { LidoLocatorMock } from "./helpers/mocks/LidoLocatorMock.sol"; -import { CommunityStakingModuleMock } from "./helpers/mocks/CommunityStakingModuleMock.sol"; -import { CommunityStakingFeeDistributorMock } from "./helpers/mocks/CommunityStakingFeeDistributorMock.sol"; -import { Fixtures } from "./helpers/Fixtures.sol"; - -contract CommunityStakingBondManagerTest is - Test, - Fixtures, - PermitTokenBase, - CommunityStakingBondManagerBase -{ - LidoLocatorMock internal locator; - WstETHMock internal wstETH; - LidoMock internal stETH; - Stub internal burner; - - CommunityStakingBondManager public bondManager; - CommunityStakingModuleMock public communityStakingModule; - CommunityStakingFeeDistributorMock public communityStakingFeeDistributor; - - address internal admin; - address internal user; - address internal stranger; - - function setUp() public { - admin = address(1); - - user = address(2); - stranger = address(777); - - address[] memory penalizeRoleMembers = new address[](1); - penalizeRoleMembers[0] = admin; - - (locator, wstETH, stETH, burner) = initLido(); - - communityStakingModule = new CommunityStakingModuleMock(); - bondManager = new CommunityStakingBondManager( - 2 ether, - admin, - address(locator), - address(wstETH), - address(communityStakingModule), - penalizeRoleMembers - ); - communityStakingFeeDistributor = new CommunityStakingFeeDistributorMock( - address(locator), - address(bondManager) - ); - vm.prank(admin); - bondManager.setFeeDistributor(address(communityStakingFeeDistributor)); - } - - function test_totalBondShares() public { - stETH.mintShares(address(bondManager), 32 * 1e18); - assertEq(bondManager.totalBondShares(), 32 * 1e18); - } - - function test_depositStETH() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(user, 32 ether); - vm.startPrank(user); - uint256 shares = stETH.submit{ value: 32 ether }({ - _referal: address(0) - }); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit BondDeposited(0, user, shares); - - bondManager.depositStETH(0, 32 ether); - - assertEq(stETH.balanceOf(user), 0); - assertEq(bondManager.getBondShares(0), shares); - assertEq(stETH.sharesOf(address(bondManager)), shares); - } - - function test_depositETH() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(user, 32 ether); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit BondDeposited(0, user, stETH.getSharesByPooledEth(32 ether)); - - vm.prank(user); - uint256 shares = bondManager.depositETH{ value: 32 ether }(0); - - assertEq(address(user).balance, 0); - assertEq(bondManager.getBondShares(0), shares); - assertEq(stETH.sharesOf(address(bondManager)), shares); - } - - function test_depositWstETH() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - uint256 wstETHAmount = wstETH.wrap(32 ether); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit BondDeposited( - 0, - user, - stETH.getSharesByPooledEth(stETH.getPooledEthByShares(wstETHAmount)) - ); - - uint256 shares = bondManager.depositWstETH(0, wstETHAmount); - - assertEq(wstETH.balanceOf(user), 0); - assertEq(bondManager.getBondShares(0), shares); - assertEq(stETH.sharesOf(address(bondManager)), shares); - } - - function test_depositStETHWithPermit() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(user, 32 ether); - vm.prank(user); - uint256 shares = stETH.submit{ value: 32 ether }({ - _referal: address(0) - }); - - vm.expectEmit(true, true, true, true, address(stETH)); - emit Approval(user, address(bondManager), 32 ether); - vm.expectEmit(true, true, true, true, address(bondManager)); - emit BondDeposited(0, user, stETH.getSharesByPooledEth(32 ether)); - - vm.prank(stranger); - bondManager.depositStETHWithPermit( - user, - 0, - 32 ether, - CommunityStakingBondManager.PermitInput({ - value: 32 ether, - deadline: type(uint256).max, - // mock permit signature - v: 0, - r: 0, - s: 0 - }) - ); - - assertEq(stETH.balanceOf(user), 0); - assertEq(bondManager.getBondShares(0), shares); - assertEq(stETH.sharesOf(address(bondManager)), shares); - } - - function test_depositWstETHWithPermit() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - uint256 wstETHAmount = wstETH.wrap(32 ether); - vm.stopPrank(); - - vm.expectEmit(true, true, true, true, address(wstETH)); - emit Approval(user, address(bondManager), 32 ether); - vm.expectEmit(true, true, true, true, address(bondManager)); - emit BondDeposited( - 0, - user, - stETH.getSharesByPooledEth(stETH.getPooledEthByShares(wstETHAmount)) - ); - - vm.prank(stranger); - uint256 shares = bondManager.depositWstETHWithPermit( - user, - 0, - wstETHAmount, - CommunityStakingBondManager.PermitInput({ - value: 32 ether, - deadline: type(uint256).max, - // mock permit signature - v: 0, - r: 0, - s: 0 - }) - ); - - assertEq(wstETH.balanceOf(user), 0); - assertEq(bondManager.getBondShares(0), shares); - assertEq(bondManager.totalBondShares(), shares); - } - - function test_deposit_RevertIfNotExistedOperator() public { - vm.expectRevert("node operator does not exist"); - bondManager.depositStETH(0, 32 ether); - } - - function test_getRequiredBondShares_OneWithdrawnValidator() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 1 }); - assertEq( - bondManager.getRequiredBondShares(0), - stETH.getSharesByPooledEth(30 ether) - ); - } - - function test_getRequiredBondShares_NoWithdrawnValidators() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - assertEq( - bondManager.getRequiredBondShares(0), - stETH.getSharesByPooledEth(32 ether) - ); - } - - function test_getRequiredBondSharesForKeys() public { - assertEq( - bondManager.getRequiredBondSharesForKeys(1), - stETH.getSharesByPooledEth(2 ether) - ); - } - - function test_claimRewardsWstETH() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(address(communityStakingFeeDistributor), 0.1 ether); - vm.prank(address(communityStakingFeeDistributor)); - uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - bondManager.depositStETH(0, 32 ether); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit WstETHRewardsClaimed( - 0, - user, - wstETH.getWstETHByStETH(stETH.getPooledEthByShares(sharesAsFee)) - ); - - uint256 bondSharesBefore = bondManager.getBondShares(0); - bondManager.claimRewardsWstETH(new bytes32[](1), 0, sharesAsFee); - uint256 bondSharesAfter = bondManager.getBondShares(0); - - assertEq( - wstETH.balanceOf(address(user)), - wstETH.getWstETHByStETH(stETH.getPooledEthByShares(sharesAsFee)) - ); - assertEq(bondSharesAfter, bondSharesBefore); - } - - function test_claimRewardsStETH() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(address(communityStakingFeeDistributor), 0.1 ether); - vm.prank(address(communityStakingFeeDistributor)); - uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); - - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - bondManager.depositStETH(0, 32 ether); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit StETHRewardsClaimed( - 0, - user, - stETH.getPooledEthByShares(sharesAsFee) - ); - - uint256 bondSharesBefore = bondManager.getBondShares(0); - bondManager.claimRewardsStETH(new bytes32[](1), 0, sharesAsFee); - uint256 bondSharesAfter = bondManager.getBondShares(0); - - assertEq(stETH.sharesOf(address(user)), sharesAsFee); - assertEq(bondSharesAfter, bondSharesBefore); - } - - function test_claimRewardsStETH_WithDesirableValue() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(address(communityStakingFeeDistributor), 0.1 ether); - vm.prank(address(communityStakingFeeDistributor)); - uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); - - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - bondManager.depositStETH(0, 32 ether); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit StETHRewardsClaimed( - 0, - user, - stETH.getPooledEthByShares(stETH.getSharesByPooledEth(0.05 ether)) - ); - - uint256 bondSharesBefore = bondManager.getBondShares(0); - bondManager.claimRewardsStETH( - new bytes32[](1), - 0, - sharesAsFee, - 0.05 ether - ); - uint256 claimedShares = stETH.getSharesByPooledEth(0.05 ether); - - assertEq(stETH.sharesOf(address(user)), claimedShares); - assertEq( - bondManager.getBondShares(0), - (bondSharesBefore + sharesAsFee) - claimedShares - ); - } - - function test_claimRewardsStETH_WhenAmountToClaimIsHigherThanRewards() - public - { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(address(communityStakingFeeDistributor), 0.1 ether); - vm.prank(address(communityStakingFeeDistributor)); - uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); - - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - bondManager.depositStETH(0, 32 ether); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit StETHRewardsClaimed( - 0, - user, - stETH.getPooledEthByShares(sharesAsFee) - ); - - uint256 bondSharesBefore = bondManager.getBondShares(0); - bondManager.claimRewardsStETH( - new bytes32[](1), - 0, - sharesAsFee, - 100 * 1e18 - ); - uint256 bondSharesAfter = bondManager.getBondShares(0); - - assertEq(stETH.sharesOf(address(user)), sharesAsFee); - assertEq(bondSharesAfter, bondSharesBefore); - } - - function test_claimRewardsStETH_WhenRequiredBondIsEqualActual() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(address(communityStakingFeeDistributor), 1 ether); - vm.prank(address(communityStakingFeeDistributor)); - uint256 sharesAsFee = stETH.submit{ value: 1 ether }(address(0)); - - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 31 ether }({ _referal: address(0) }); - bondManager.depositStETH(0, 31 ether); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit StETHRewardsClaimed(0, user, 0); - - uint256 bondSharesBefore = bondManager.getBondShares(0); - bondManager.claimRewardsStETH(new bytes32[](1), 0, sharesAsFee); - uint256 bondSharesAfter = bondManager.getBondShares(0); - - assertEq(stETH.sharesOf(address(user)), 0); - assertEq(bondSharesAfter, bondSharesBefore + sharesAsFee); - } - - function test_claimRewardsWstETH_RevertWhenCallerIsNotRewardAddress() - public - { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - - vm.expectRevert( - abi.encodeWithSelector( - CommunityStakingBondManager.NotOwnerToClaim.selector, - stranger, - user - ) - ); - vm.prank(stranger); - bondManager.claimRewardsWstETH(new bytes32[](1), 0, 1, 1 ether); - } - - function test_claimRewardsStETH_RevertWhenCallerIsNotRewardAddress() - public - { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - - vm.expectRevert( - abi.encodeWithSelector( - CommunityStakingBondManager.NotOwnerToClaim.selector, - stranger, - user - ) - ); - vm.prank(stranger); - bondManager.claimRewardsStETH(new bytes32[](1), 0, 1, 1 ether); - } - - function test_penalize_LessThanDeposit() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - bondManager.depositStETH(0, 32 ether); - vm.stopPrank(); - - vm.expectEmit(true, true, true, true, address(bondManager)); - emit BondPenalized(0, 1e18, 1e18); - - uint256 bondSharesBefore = bondManager.getBondShares(0); - vm.prank(admin); - bondManager.penalize(0, 1e18); - - assertEq(bondManager.getBondShares(0), bondSharesBefore - 1e18); - assertEq(stETH.sharesOf(address(burner)), 1e18); - } - - function test_penalize_MoreThanDeposit() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - bondManager.depositStETH(0, 32 ether); - vm.stopPrank(); - - uint256 shares = stETH.getSharesByPooledEth(32 ether); - vm.expectEmit(true, true, true, true, address(bondManager)); - emit BondPenalized(0, 32 * 1e18, shares); - - vm.prank(admin); - bondManager.penalize(0, 32 * 1e18); - - assertEq(bondManager.getBondShares(0), 0); - assertEq(stETH.sharesOf(address(burner)), shares); - } - - function test_penalize_EqualToDeposit() public { - _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); - vm.deal(user, 32 ether); - vm.startPrank(user); - stETH.submit{ value: 32 ether }({ _referal: address(0) }); - bondManager.depositStETH(0, 32 ether); - vm.stopPrank(); - - uint256 shares = stETH.getSharesByPooledEth(32 ether); - vm.expectEmit(true, true, true, true, address(bondManager)); - emit BondPenalized(0, shares, shares); - - vm.prank(admin); - bondManager.penalize(0, shares); - - assertEq(bondManager.getBondShares(0), 0); - assertEq(stETH.sharesOf(address(burner)), shares); - } - - function test_penalize_RevertWhenCallerHasNoRole() public { - vm.expectRevert( - "AccessControl: account 0x0000000000000000000000000000000000000309 is missing role 0xf3c54f9b8dbd8c6d8596d09d52b61d4bdce01620000dd9d49c5017dca6e62158" - ); - vm.prank(stranger); - bondManager.penalize(0, 20); - } - - function _createNodeOperator( - uint64 ongoingVals, - uint64 withdrawnVals - ) internal { - communityStakingModule.setNodeOperator({ - _nodeOperatorId: 0, - _active: true, - _name: "User", - _rewardAddress: user, - _totalVettedValidators: ongoingVals, - _totalExitedValidators: 0, - _totalWithdrawnValidators: withdrawnVals, - _totalAddedValidators: ongoingVals, - _totalDepositedValidators: ongoingVals - }); - } -} diff --git a/test/BondManager.t.sol b/test/BondManager.t.sol new file mode 100644 index 00000000..0c95705f --- /dev/null +++ b/test/BondManager.t.sol @@ -0,0 +1,1020 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import "forge-std/Test.sol"; + +import { CommunityStakingBondManagerBase, CommunityStakingBondManager } from "../src/CommunityStakingBondManager.sol"; +import { PermitTokenBase } from "./helpers/Permit.sol"; +import { Stub } from "./helpers/mocks/Stub.sol"; +import { LidoMock } from "./helpers/mocks/LidoMock.sol"; +import { WstETHMock } from "./helpers/mocks/WstETHMock.sol"; +import { LidoLocatorMock } from "./helpers/mocks/LidoLocatorMock.sol"; +import { CommunityStakingModuleMock } from "./helpers/mocks/CommunityStakingModuleMock.sol"; +import { CommunityStakingFeeDistributorMock } from "./helpers/mocks/CommunityStakingFeeDistributorMock.sol"; +import { Fixtures } from "./helpers/Fixtures.sol"; + +contract CommunityStakingBondManagerTest is + Test, + Fixtures, + PermitTokenBase, + CommunityStakingBondManagerBase +{ + LidoLocatorMock internal locator; + WstETHMock internal wstETH; + LidoMock internal stETH; + Stub internal burner; + + CommunityStakingBondManager public bondManager; + CommunityStakingModuleMock public communityStakingModule; + CommunityStakingFeeDistributorMock public communityStakingFeeDistributor; + + address internal admin; + address internal user; + address internal stranger; + + function setUp() public { + admin = address(1); + + user = address(2); + stranger = address(777); + + address[] memory penalizeRoleMembers = new address[](1); + penalizeRoleMembers[0] = admin; + + (locator, wstETH, stETH, burner) = initLido(); + + communityStakingModule = new CommunityStakingModuleMock(); + bondManager = new CommunityStakingBondManager( + 2 ether, + admin, + address(locator), + address(wstETH), + address(communityStakingModule), + penalizeRoleMembers + ); + communityStakingFeeDistributor = new CommunityStakingFeeDistributorMock( + address(locator), + address(bondManager) + ); + vm.prank(admin); + bondManager.setFeeDistributor(address(communityStakingFeeDistributor)); + } + + function test_totalBondShares() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 32 ether }(0); + uint256 sharesToDeposit = stETH.getSharesByPooledEth(32 ether); + assertEq(bondManager.totalBondShares(), sharesToDeposit); + } + + function test_getRequiredBondETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + assertEq(bondManager.getRequiredBondETH(0, 0), 32 ether); + } + + function test_getRequiredBondStETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + assertEq(bondManager.getRequiredBondStETH(0, 0), 32 ether); + } + + function test_getRequiredBondWstETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + assertEq( + bondManager.getRequiredBondWstETH(0, 0), + stETH.getSharesByPooledEth(32 ether) + ); + } + + function test_getRequiredBondETH_OneWithdrawnValidator() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 1 }); + assertEq(bondManager.getRequiredBondETH(0, 0), 30 ether); + } + + function test_getRequiredBondStETH_OneWithdrawnValidator() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 1 }); + assertEq(bondManager.getRequiredBondStETH(0, 0), 30 ether); + } + + function test_getRequiredBondWstETH_OneWithdrawnValidator() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 1 }); + assertEq( + bondManager.getRequiredBondWstETH(0, 0), + stETH.getSharesByPooledEth(30 ether) + ); + } + + function test_getRequiredBondETH_OneWithdrawnOneAddedValidator() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 1 }); + assertEq(bondManager.getRequiredBondETH(0, 1), 32 ether); + } + + function test_getRequiredBondStETH_OneWithdrawnOneAddedValidator() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 1 }); + assertEq(bondManager.getRequiredBondStETH(0, 1), 32 ether); + } + + function test_getRequiredBondWstETH_OneWithdrawnOneAddedValidator() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 1 }); + assertEq( + bondManager.getRequiredBondWstETH(0, 1), + stETH.getSharesByPooledEth(32 ether) + ); + } + + function test_getRequiredBondETH_WithExcessBond() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 64 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 64 ether }(0); + assertApproxEqAbs( + bondManager.getRequiredBondETH(0, 16), + 0, + 1, // max accuracy error + "required ETH should be ~0 for the next 16 validators to deposit" + ); + } + + function test_getRequiredBondStETH_WithExcessBond() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 64 ether); + vm.startPrank(user); + stETH.submit{ value: 64 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 64 ether); + assertApproxEqAbs( + bondManager.getRequiredBondStETH(0, 16), + 0, + 1, // max accuracy error + "required stETH should be ~0 for the next 16 validators to deposit" + ); + } + + function test_getRequiredBondWstETH_WithExcessBond() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 64 ether); + vm.startPrank(user); + stETH.submit{ value: 64 ether }({ _referal: address(0) }); + uint256 amount = wstETH.wrap(64 ether); + bondManager.depositWstETH(0, amount); + assertApproxEqAbs( + bondManager.getRequiredBondWstETH(0, 16), + 0, + 1, // max accuracy error + "required wstETH should be ~0 for the next 16 validators to deposit" + ); + } + + function test_getRequiredBondETHForKeys() public { + assertEq(bondManager.getRequiredBondETHForKeys(1), 2 ether); + } + + function test_getRequiredBondStETHForKeys() public { + assertEq(bondManager.getRequiredBondStETHForKeys(1), 2 ether); + } + + function test_getRequiredBondWstETHForKeys() public { + assertEq( + bondManager.getRequiredBondWstETHForKeys(1), + stETH.getSharesByPooledEth(2 ether) + ); + } + + function test_depositETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + uint256 sharesToDeposit = stETH.getSharesByPooledEth(32 ether); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit ETHBondDeposited(0, user, 32 ether); + + vm.prank(user); + bondManager.depositETH{ value: 32 ether }(0); + + assertEq( + address(user).balance, + 0, + "user balance should be 0 after deposit" + ); + assertEq( + bondManager.getBondShares(0), + sharesToDeposit, + "bond shares should be equal to deposited shares" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + sharesToDeposit, + "bond manager shares should be equal to deposited shares" + ); + } + + function test_depositETH_CoverSeveralValidators() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + + uint256 required = bondManager.getRequiredBondETHForKeys(1); + vm.startPrank(user); + bondManager.depositETH{ value: required }(0); + + assertApproxEqAbs( + bondManager.getRequiredBondETH(0, 0), + 0, + 1, // max accuracy error + "required ETH should be ~0 for 1 deposited validator" + ); + + required = bondManager.getRequiredBondETH(0, 1); + bondManager.depositETH{ value: required }(0); + communityStakingModule.addValidator(0, 1); + + assertApproxEqAbs( + bondManager.getRequiredBondETH(0, 0), + 0, + 1, // max accuracy error + "required ETH should be ~0 for 2 deposited validators" + ); + } + + function test_depositStETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.startPrank(user); + uint256 sharesToDeposit = stETH.submit{ value: 32 ether }({ + _referal: address(0) + }); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit StETHBondDeposited(0, user, 32 ether); + + bondManager.depositStETH(0, 32 ether); + + assertEq( + stETH.balanceOf(user), + 0, + "user balance should be 0 after deposit" + ); + assertEq( + bondManager.getBondShares(0), + sharesToDeposit, + "bond shares should be equal to deposited shares" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + sharesToDeposit, + "bond manager shares should be equal to deposited shares" + ); + } + + function test_depositStETH_CoverSeveralValidators() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + + uint256 required = bondManager.getRequiredBondStETHForKeys(1); + bondManager.depositStETH(0, required); + + assertApproxEqAbs( + bondManager.getRequiredBondStETH(0, 0), + 0, + 1, // max accuracy error + "required stETH should be ~0 for 1 deposited validator" + ); + + required = bondManager.getRequiredBondStETH(0, 1); + bondManager.depositStETH(0, required); + communityStakingModule.addValidator(0, 1); + assertApproxEqAbs( + bondManager.getRequiredBondStETH(0, 0), + 0, + 1, // max accuracy error + "required stETH should be ~0 for 2 deposited validators" + ); + } + + function test_depositWstETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + uint256 wstETHAmount = wstETH.wrap(32 ether); + uint256 sharesToDeposit = stETH.getSharesByPooledEth( + wstETH.getStETHByWstETH(wstETHAmount) + ); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit WstETHBondDeposited(0, user, wstETHAmount); + + bondManager.depositWstETH(0, wstETHAmount); + + assertEq( + wstETH.balanceOf(user), + 0, + "user balance should be 0 after deposit" + ); + assertEq( + bondManager.getBondShares(0), + sharesToDeposit, + "bond shares should be equal to deposited shares" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + sharesToDeposit, + "bond manager shares should be equal to deposited shares" + ); + } + + function test_depositWstETH_CoverSeveralValidators() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + vm.startPrank(user); + vm.deal(user, 32 ether); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + wstETH.wrap(32 ether); + + uint256 required = bondManager.getRequiredBondWstETHForKeys(1); + bondManager.depositWstETH(0, required); + + assertApproxEqAbs( + bondManager.getRequiredBondWstETH(0, 0), + 0, + 1, // max accuracy error + "required wstETH should be ~0 for 1 deposited validator" + ); + + required = bondManager.getRequiredBondStETH(0, 1); + bondManager.depositWstETH(0, required); + communityStakingModule.addValidator(0, 1); + + assertApproxEqAbs( + bondManager.getRequiredBondWstETH(0, 0), + 0, + 1, // max accuracy error + "required wstETH should be ~0 for 2 deposited validators" + ); + } + + function test_depositStETHWithPermit() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.prank(user); + uint256 sharesToDeposit = stETH.submit{ value: 32 ether }({ + _referal: address(0) + }); + + vm.expectEmit(true, true, true, true, address(stETH)); + emit Approval(user, address(bondManager), 32 ether); + vm.expectEmit(true, true, true, true, address(bondManager)); + emit StETHBondDeposited(0, user, 32 ether); + + vm.prank(stranger); + bondManager.depositStETHWithPermit( + user, + 0, + 32 ether, + CommunityStakingBondManager.PermitInput({ + value: 32 ether, + deadline: type(uint256).max, + // mock permit signature + v: 0, + r: 0, + s: 0 + }) + ); + + assertEq( + stETH.balanceOf(user), + 0, + "user balance should be 0 after deposit" + ); + assertEq( + bondManager.getBondShares(0), + sharesToDeposit, + "bond shares should be equal to deposited shares" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + sharesToDeposit, + "bond manager shares should be equal to deposited shares" + ); + } + + function test_depositWstETHWithPermit() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + uint256 wstETHAmount = wstETH.wrap(32 ether); + uint256 sharesToDeposit = stETH.getSharesByPooledEth( + wstETH.getStETHByWstETH(wstETHAmount) + ); + vm.stopPrank(); + + vm.expectEmit(true, true, true, true, address(wstETH)); + emit Approval(user, address(bondManager), 32 ether); + vm.expectEmit(true, true, true, true, address(bondManager)); + emit WstETHBondDeposited(0, user, wstETHAmount); + + vm.prank(stranger); + bondManager.depositWstETHWithPermit( + user, + 0, + wstETHAmount, + CommunityStakingBondManager.PermitInput({ + value: 32 ether, + deadline: type(uint256).max, + // mock permit signature + v: 0, + r: 0, + s: 0 + }) + ); + + assertEq( + wstETH.balanceOf(user), + 0, + "user balance should be 0 after deposit" + ); + assertEq( + bondManager.getBondShares(0), + sharesToDeposit, + "bond shares should be equal to deposited shares" + ); + assertEq( + bondManager.totalBondShares(), + sharesToDeposit, + "bond manager shares should be equal to deposited shares" + ); + } + + function test_deposit_RevertIfNotExistedOperator() public { + vm.expectRevert("node operator does not exist"); + bondManager.depositStETH(0, 32 ether); + } + + function test_getTotalRewardsETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 0.1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + uint256 ETHAsFee = stETH.getPooledEthByShares(sharesAsFee); + vm.deal(user, 32 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 32 ether }(0); + + // todo: should we think about simulate rebase? + uint256 totalRewards = bondManager.getTotalRewardsETH( + new bytes32[](1), + 0, + sharesAsFee + ); + + assertEq(totalRewards, ETHAsFee); + } + + function test_getTotalRewardsStETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 0.1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + uint256 stETHAsFee = stETH.getPooledEthByShares(sharesAsFee); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + + // todo: should we think about simulate rebase? + uint256 totalRewards = bondManager.getTotalRewardsStETH( + new bytes32[](1), + 0, + sharesAsFee + ); + + assertEq(totalRewards, stETHAsFee); + } + + function test_getTotalRewardsWstETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 0.1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + uint256 wstETHAsFee = wstETH.getWstETHByStETH( + stETH.getPooledEthByShares(sharesAsFee) + ); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + + // todo: should we think about simulate rebase? + uint256 totalRewards = bondManager.getTotalRewardsWstETH( + new bytes32[](1), + 0, + sharesAsFee + ); + + assertEq(totalRewards, wstETHAsFee); + } + + function test_getExcessBondETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 64 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 64 ether }(0); + + assertApproxEqAbs(bondManager.getExcessBondETH(0), 32 ether, 1); + } + + function test_getExcessBondStETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 64 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 64 ether }(0); + + assertApproxEqAbs(bondManager.getExcessBondStETH(0), 32 ether, 1); + } + + function test_getExcessBondWstETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 64 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 64 ether }(0); + + assertApproxEqAbs( + bondManager.getExcessBondWstETH(0), + wstETH.getWstETHByStETH(32 ether), + 1 + ); + } + + function test_getMissingBondETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 16 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 16 ether }(0); + + assertApproxEqAbs(bondManager.getMissingBondETH(0), 16 ether, 1); + } + + function test_getMissingBondStETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 16 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 16 ether }(0); + + assertApproxEqAbs(bondManager.getMissingBondStETH(0), 16 ether, 1); + } + + function test_getMissingBondWstETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 16 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 16 ether }(0); + + assertApproxEqAbs( + bondManager.getMissingBondWstETH(0), + wstETH.getWstETHByStETH(16 ether), + 1 + ); + } + + function test_getUnbondedKeysCount() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 17.57 ether); + vm.startPrank(user); + bondManager.depositETH{ value: 17.57 ether }(0); + + assertEq(bondManager.getUnbondedKeysCount(0), 7); + } + + function test_claimRewardsStETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 0.1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + uint256 stETHAsFee = stETH.getPooledEthByShares(sharesAsFee); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit StETHRewardsClaimed( + 0, + user, + stETH.getPooledEthByShares(sharesAsFee) + ); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + bondManager.claimRewardsStETH(new bytes32[](1), 0, sharesAsFee); + uint256 bondSharesAfter = bondManager.getBondShares(0); + + assertEq( + stETH.balanceOf(address(user)), + stETHAsFee, + "user balance should be equal to fee reward" + ); + assertEq( + bondSharesAfter, + bondSharesBefore, + "bond shares after claim should be equal to before" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + bondSharesAfter, + "bond manager after claim should be equal to before" + ); + } + + function test_claimRewardsStETH_WithDesirableValue() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 0.1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + uint256 sharesToClaim = stETH.getSharesByPooledEth(0.05 ether); + uint256 stETHToClaim = stETH.getPooledEthByShares(sharesToClaim); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit StETHRewardsClaimed(0, user, stETHToClaim); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + + bondManager.claimRewardsStETH( + new bytes32[](1), + 0, + sharesAsFee, + 0.05 ether + ); + uint256 bondSharesAfter = bondManager.getBondShares(0); + + assertEq( + stETH.balanceOf(address(user)), + stETHToClaim, + "user balance should be equal to claimed" + ); + assertEq( + bondSharesAfter, + (bondSharesBefore + sharesAsFee) - sharesToClaim, + "bond shares after should be equal to before and fee minus claimed shares" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + bondSharesAfter, + "bond manager after should be equal to before and fee minus claimed shares" + ); + } + + function test_claimRewardsStETH_WhenAmountToClaimIsHigherThanRewards() + public + { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 0.1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + uint256 stETHAsFee = stETH.getPooledEthByShares(sharesAsFee); + + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit StETHRewardsClaimed(0, user, stETHAsFee); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + bondManager.claimRewardsStETH( + new bytes32[](1), + 0, + sharesAsFee, + 100 * 1e18 + ); + uint256 bondSharesAfter = bondManager.getBondShares(0); + + assertEq( + stETH.balanceOf(address(user)), + stETHAsFee, + "user balance should be equal to fee reward" + ); + assertEq( + bondSharesAfter, + bondSharesBefore, + "bond shares after should be equal to before" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + bondSharesAfter, + "bond manager after should be equal to before" + ); + } + + function test_claimRewardsStETH_WhenRequiredBondIsEqualActual() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 1 ether }(address(0)); + + vm.deal(user, 31 ether); + vm.startPrank(user); + stETH.submit{ value: 31 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 31 ether); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit StETHRewardsClaimed(0, user, 0); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + bondManager.claimRewardsStETH(new bytes32[](1), 0, sharesAsFee); + uint256 bondSharesAfter = bondManager.getBondShares(0); + + assertEq(stETH.balanceOf(address(user)), 0, "user balance should be 0"); + assertEq( + bondSharesAfter, + bondSharesBefore + sharesAsFee, + "bond shares should be increased by fee" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + bondSharesAfter, + "bond manager shares should be increased by fee" + ); + } + + function test_claimRewardsStETH_WhenRequiredBondIsHigherActual() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.5 ether }(address(0)); + + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 31 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 31 ether); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit StETHRewardsClaimed(0, user, 0); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + bondManager.claimRewardsStETH(new bytes32[](1), 0, sharesAsFee); + uint256 bondSharesAfter = bondManager.getBondShares(0); + + assertEq(stETH.balanceOf(address(user)), 0, "user balance should be 0"); + assertEq( + bondSharesAfter, + bondSharesBefore + sharesAsFee, + "bond shares should be increased by fee" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + bondSharesAfter, + "bond manager shares should be increased by fee" + ); + } + + function test_claimRewardsStETH_RevertWhenCallerIsNotRewardAddress() + public + { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + + vm.expectRevert( + abi.encodeWithSelector( + CommunityStakingBondManager.NotOwnerToClaim.selector, + stranger, + user + ) + ); + vm.prank(stranger); + bondManager.claimRewardsStETH(new bytes32[](1), 0, 1, 1 ether); + } + + function test_claimRewardsWstETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 0.1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + uint256 wstETHAsFee = wstETH.getWstETHByStETH( + stETH.getPooledEthByShares(sharesAsFee) + ); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit WstETHRewardsClaimed(0, user, wstETHAsFee); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + bondManager.claimRewardsWstETH(new bytes32[](1), 0, sharesAsFee); + uint256 bondSharesAfter = bondManager.getBondShares(0); + + assertEq( + wstETH.balanceOf(address(user)), + wstETHAsFee, + "user balance should be equal to fee reward" + ); + assertEq( + bondSharesAfter, + bondSharesBefore + 1 wei, + "bond shares after claim should contain wrapped fee accuracy error" + ); + assertEq( + wstETH.balanceOf(address(bondManager)), + 0, + "bond manager wstETH balance should be 0" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + bondSharesBefore + 1 wei, + "bond manager after claim should contain wrapped fee accuracy error" + ); + } + + function test_claimRewardsWstETH_WithDesirableValue() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(communityStakingFeeDistributor), 0.1 ether); + vm.prank(address(communityStakingFeeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + uint256 sharesToClaim = stETH.getSharesByPooledEth(0.05 ether); + uint256 wstETHToClaim = wstETH.getWstETHByStETH( + stETH.getPooledEthByShares(sharesToClaim) + ); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit WstETHRewardsClaimed(0, user, wstETHToClaim); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + bondManager.claimRewardsWstETH( + new bytes32[](1), + 0, + sharesAsFee, + stETH.getSharesByPooledEth(0.05 ether) + ); + uint256 bondSharesAfter = bondManager.getBondShares(0); + + assertEq( + wstETH.balanceOf(address(user)), + wstETHToClaim, + "user balance should be equal to fee reward" + ); + assertEq( + bondSharesAfter, + (bondSharesBefore + sharesAsFee) - wstETHToClaim, + "bond shares after should be equal to before and fee minus claimed shares" + ); + assertEq( + wstETH.balanceOf(address(bondManager)), + 0, + "bond manager wstETH balance should be 0" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + (bondSharesBefore + sharesAsFee) - wstETHToClaim, + "bond shares after should be equal to before and fee minus claimed shares" + ); + } + + function test_claimRewardsWstETH_RevertWhenCallerIsNotRewardAddress() + public + { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + + vm.expectRevert( + abi.encodeWithSelector( + CommunityStakingBondManager.NotOwnerToClaim.selector, + stranger, + user + ) + ); + vm.prank(stranger); + bondManager.claimRewardsWstETH(new bytes32[](1), 0, 1, 1 ether); + } + + function test_penalize_LessThanDeposit() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + vm.stopPrank(); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit BondPenalized(0, 1e18, 1e18); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + vm.prank(admin); + bondManager.penalize(0, 1e18); + + assertEq( + bondManager.getBondShares(0), + bondSharesBefore - 1e18, + "bond shares should be decreased by penalty" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + bondSharesBefore - 1e18, + "bond manager shares should be decreased by penalty" + ); + assertEq( + stETH.sharesOf(address(burner)), + 1e18, + "burner shares should be equal to penalty" + ); + } + + function test_penalize_MoreThanDeposit() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + vm.stopPrank(); + + uint256 bondSharesBefore = bondManager.getBondShares(0); + + vm.expectEmit(true, true, true, true, address(bondManager)); + emit BondPenalized(0, 32 * 1e18, bondSharesBefore); + + vm.prank(admin); + bondManager.penalize(0, 32 * 1e18); + + assertEq( + bondManager.getBondShares(0), + 0, + "bond shares should be 0 after penalty" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + 0, + "bond manager shares should be 0 after penalty" + ); + assertEq( + stETH.sharesOf(address(burner)), + bondSharesBefore, + "burner shares should be equal to bond shares" + ); + } + + function test_penalize_EqualToDeposit() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + bondManager.depositStETH(0, 32 ether); + vm.stopPrank(); + + uint256 shares = stETH.getSharesByPooledEth(32 ether); + vm.expectEmit(true, true, true, true, address(bondManager)); + emit BondPenalized(0, shares, shares); + + vm.prank(admin); + bondManager.penalize(0, shares); + + assertEq( + bondManager.getBondShares(0), + 0, + "bond shares should be 0 after penalty" + ); + assertEq( + stETH.sharesOf(address(bondManager)), + 0, + "bond manager shares should be 0 after penalty" + ); + assertEq( + stETH.sharesOf(address(burner)), + shares, + "burner shares should be equal to penalty" + ); + } + + function test_penalize_RevertWhenCallerHasNoRole() public { + vm.expectRevert( + "AccessControl: account 0x0000000000000000000000000000000000000309 is missing role 0xf3c54f9b8dbd8c6d8596d09d52b61d4bdce01620000dd9d49c5017dca6e62158" + ); + vm.prank(stranger); + bondManager.penalize(0, 20); + } + + function _createNodeOperator( + uint64 ongoingVals, + uint64 withdrawnVals + ) internal { + communityStakingModule.setNodeOperator({ + _nodeOperatorId: 0, + _active: true, + _name: "User", + _rewardAddress: user, + _totalVettedValidators: ongoingVals, + _totalExitedValidators: 0, + _totalWithdrawnValidators: withdrawnVals, + _totalAddedValidators: ongoingVals, + _totalDepositedValidators: ongoingVals + }); + } +} diff --git a/test/CSMAddValidator.t.sol b/test/CSMAddValidator.t.sol index fbbab993..f4c0b2b7 100644 --- a/test/CSMAddValidator.t.sol +++ b/test/CSMAddValidator.t.sol @@ -34,9 +34,9 @@ contract CSMCommon is Test, Fixtures, Utilities, CommunityStakingModuleBase { (locator, wstETH, stETH, burner) = initLido(); - vm.deal(nodeOperator, 2 ether); + vm.deal(nodeOperator, 2 ether + 1 wei); vm.prank(nodeOperator); - stETH.submit{ value: 2 ether }(address(0)); + stETH.submit{ value: 2 ether + 1 wei }(address(0)); communityStakingFeeDistributor = new CommunityStakingFeeDistributorMock( address(locator), @@ -86,7 +86,7 @@ contract CSMAddNodeOperator is CSMCommon, PermitTokenBase { keysCount ); vm.startPrank(nodeOperator); - wstETH.wrap(2 ether); + wstETH.wrap(2 ether + 1 wei); { vm.expectEmit(true, true, false, true, address(csm)); @@ -138,10 +138,10 @@ contract CSMAddNodeOperator is CSMCommon, PermitTokenBase { function test_AddValidatorKeysWstETH() public { uint256 noId = createNodeOperator(); - - vm.deal(nodeOperator, 2 ether); - stETH.submit{ value: 2 ether }(address(0)); - wstETH.wrap(2 ether); + uint256 toWrap = 2 ether + 1 wei; + vm.deal(nodeOperator, toWrap); + stETH.submit{ value: toWrap }(address(0)); + wstETH.wrap(toWrap); (bytes memory keys, bytes memory signatures) = keysSignatures(1, 1); { vm.expectEmit(true, true, false, true, address(csm)); @@ -156,13 +156,14 @@ contract CSMAddNodeOperator is CSMCommon, PermitTokenBase { keysCount ); vm.startPrank(nodeOperator); - wstETH.wrap(2 ether); + uint256 toWrap = 2 ether + 1 wei; + wstETH.wrap(toWrap); csm.addNodeOperatorWstETH("test", nodeOperator, 1, keys, signatures); uint256 noId = csm.getNodeOperatorsCount() - 1; - vm.deal(nodeOperator, 2 ether); - stETH.submit{ value: 2 ether }(address(0)); - uint256 wstETHAmount = wstETH.wrap(2 ether); + vm.deal(nodeOperator, toWrap); + stETH.submit{ value: toWrap }(address(0)); + uint256 wstETHAmount = wstETH.wrap(toWrap); vm.stopPrank(); (keys, signatures) = keysSignatures(keysCount, 1); { @@ -265,12 +266,13 @@ contract CSMAddNodeOperator is CSMCommon, PermitTokenBase { csm.addNodeOperatorStETH("test", nodeOperator, 1, keys, signatures); uint256 noId = csm.getNodeOperatorsCount() - 1; - vm.deal(nodeOperator, 2 ether); + uint256 required = bondManager.getRequiredBondStETH(0, 1); + vm.deal(nodeOperator, required); vm.prank(nodeOperator); - stETH.submit{ value: 2 ether }(address(0)); + stETH.submit{ value: required }(address(0)); { vm.expectEmit(true, true, true, true, address(stETH)); - emit Approval(nodeOperator, address(bondManager), 2 ether); + emit Approval(nodeOperator, address(bondManager), required); vm.expectEmit(true, true, false, true, address(csm)); emit TotalKeysCountChanged(0, 2); } @@ -282,7 +284,7 @@ contract CSMAddNodeOperator is CSMCommon, PermitTokenBase { keys, signatures, ICommunityStakingBondManager.PermitInput({ - value: 2 ether, + value: required, deadline: type(uint256).max, // mock permit signature v: 0, @@ -321,13 +323,14 @@ contract CSMAddNodeOperator is CSMCommon, PermitTokenBase { uint256 noId = createNodeOperator(); (bytes memory keys, bytes memory signatures) = keysSignatures(1, 1); - vm.deal(nodeOperator, 2 ether); + uint256 required = bondManager.getRequiredBondETH(0, 1); + vm.deal(nodeOperator, required); vm.prank(nodeOperator); { vm.expectEmit(true, true, false, true, address(csm)); emit TotalKeysCountChanged(0, 2); } - csm.addValidatorKeysETH{ value: 2 ether }(noId, 1, keys, signatures); + csm.addValidatorKeysETH{ value: required }(noId, 1, keys, signatures); } } diff --git a/test/helpers/mocks/CommunityStakingFeeDistributorMock.sol b/test/helpers/mocks/CommunityStakingFeeDistributorMock.sol index 7b7c6c4a..1a322635 100644 --- a/test/helpers/mocks/CommunityStakingFeeDistributorMock.sol +++ b/test/helpers/mocks/CommunityStakingFeeDistributorMock.sol @@ -17,6 +17,14 @@ contract CommunityStakingFeeDistributorMock { BOND_MANAGER_ADDRESS = _bondManager; } + function getFeesToDistribute( + bytes32[] calldata /*rewardProof*/, + uint256 /*noIndex*/, + uint256 shares + ) external returns (uint256) { + return shares; + } + function distributeFees( bytes32[] calldata /*rewardProof*/, uint256 noIndex, diff --git a/test/helpers/mocks/CommunityStakingModuleMock.sol b/test/helpers/mocks/CommunityStakingModuleMock.sol index 199d7a00..57cf0d10 100644 --- a/test/helpers/mocks/CommunityStakingModuleMock.sol +++ b/test/helpers/mocks/CommunityStakingModuleMock.sol @@ -77,6 +77,12 @@ contract CommunityStakingModuleMock { } } + function addValidator(uint256 _nodeOperatorId, uint256 _valsToAdd) public { + nodeOperators[_nodeOperatorId].totalAddedValidators += uint64( + _valsToAdd + ); + } + function getNodeOperatorsCount() external view returns (uint256) { return totalNodeOperatorsCount; }