diff --git a/src/CSAccounting.sol b/src/CSAccounting.sol index 34f174a2..025332d7 100644 --- a/src/CSAccounting.sol +++ b/src/CSAccounting.sol @@ -43,7 +43,7 @@ contract CSAccountingBase { address to, uint256 amount ); - event ELRewardsStealingReported( + event ELRewardsStealingPenaltyInitiated( uint256 indexed nodeOperatorId, uint256 proposedBlockNumber, uint256 stolenAmount @@ -57,6 +57,10 @@ contract CSAccountingBase { uint256 indexed nodeOperatorId, uint256 ETHAmount ); + event BlockedBondReleased( + uint256 indexed nodeOperatorId, + uint256 ETHAmount + ); event BondPenalized( uint256 indexed nodeOperatorId, uint256 penaltyETH, @@ -94,8 +98,8 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { address public FEE_DISTRIBUTOR; uint256 public totalBondShares; - mapping(uint256 => uint256) private _bondShares; - mapping(uint256 => BlockedBondEther) private _blockedBondEther; + mapping(uint256 => uint256) internal _bondShares; + mapping(uint256 => BlockedBondEther) internal _blockedBondEther; error NotOwnerToClaim(address msgSender, address owner); @@ -212,6 +216,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId ) public view returns (uint256) { (uint256 current, uint256 required) = _bondETHSummary(nodeOperatorId); + required += getBlockedBondETH(nodeOperatorId); return current > required ? current - required : 0; } @@ -266,7 +271,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId ) public view returns (uint256) { if ( - _blockedBondEther[nodeOperatorId].retentionUntil > block.timestamp + _blockedBondEther[nodeOperatorId].retentionUntil >= block.timestamp ) { return _blockedBondEther[nodeOperatorId].ETHAmount; } @@ -517,7 +522,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { nodeOperatorId, cumulativeFeeShares ); - if (claimableShares == 0 || getBlockedBondETH(nodeOperatorId) > 0) { + if (claimableShares == 0) { emit StETHRewardsClaimed(nodeOperatorId, rewardAddress, 0); return; } @@ -555,7 +560,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { nodeOperatorId, cumulativeFeeShares ); - if (claimableShares == 0 || getBlockedBondETH(nodeOperatorId) > 0) { + if (claimableShares == 0) { emit WstETHRewardsClaimed(nodeOperatorId, rewardAddress, 0); return; } @@ -595,7 +600,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { nodeOperatorId, cumulativeFeeShares ); - if (claimableShares == 0 || getBlockedBondETH(nodeOperatorId) > 0) { + if (claimableShares == 0) { emit ETHRewardsRequested(nodeOperatorId, rewardAddress, 0); return requestIds; } @@ -628,7 +633,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { onlyExistingNodeOperator(nodeOperatorId) { require(stolenAmount > 0, "stolen amount should be greater than zero"); - emit ELRewardsStealingReported( + emit ELRewardsStealingPenaltyInitiated( nodeOperatorId, proposedBlockNumber, stolenAmount @@ -652,7 +657,8 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { onlyRole(EL_REWARDS_STEALING_PENALTY_ROLE) onlyExistingNodeOperator(nodeOperatorId) { - _releaseBlockedBondETH(nodeOperatorId, amount); + emit BlockedBondReleased(nodeOperatorId, amount); + _reduceBlockedBondETH(nodeOperatorId, amount); } /// @notice Compensates blocked bond ETH for the given node operator. @@ -662,10 +668,11 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { ) external payable onlyExistingNodeOperator(nodeOperatorId) { require(msg.value > 0, "value should be greater than zero"); payable(LIDO_LOCATOR.elRewardsVault()).transfer(msg.value); - _releaseBlockedBondETH(nodeOperatorId, msg.value); + emit BlockedBondCompensated(nodeOperatorId, msg.value); + _reduceBlockedBondETH(nodeOperatorId, msg.value); } - function _releaseBlockedBondETH( + function _reduceBlockedBondETH( uint256 nodeOperatorId, uint256 amount ) internal { @@ -673,7 +680,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { require(blocked > 0, "no blocked bond to release"); require( _blockedBondEther[nodeOperatorId].ETHAmount >= amount, - "blocked bond is less than amount to compensate" + "blocked bond is less than amount to release" ); _changeBlockedBondState( nodeOperatorId, @@ -705,11 +712,13 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { continue; } uint256 blockedAmount = getBlockedBondETH(nodeOperatorId); + uint256 uncovered; + if (blockedAmount > 0) { + uncovered = penalize(nodeOperatorId, blockedAmount); + } _changeBlockedBondState({ nodeOperatorId: nodeOperatorId, - ETHAmount: blockedAmount > 0 - ? penalize(nodeOperatorId, blockedAmount) - : 0, + ETHAmount: uncovered, retentionUntil: data.retentionUntil }); } @@ -816,6 +825,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { (uint256 current, uint256 required) = _bondSharesSummary( nodeOperatorId ); + required += _sharesByEth(getBlockedBondETH(nodeOperatorId)); claimableShares = current > required ? current - required : 0; } diff --git a/test/CSAccounting.blockedBond.t.sol b/test/CSAccounting.blockedBond.t.sol new file mode 100644 index 00000000..8ed62536 --- /dev/null +++ b/test/CSAccounting.blockedBond.t.sol @@ -0,0 +1,729 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import "forge-std/Test.sol"; + +import { CSAccountingBase, CSAccounting } from "../src/CSAccounting.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 { WithdrawalQueueMockBase, WithdrawalQueueMock } from "./helpers/mocks/WithdrawalQueueMock.sol"; + +import { Fixtures } from "./helpers/Fixtures.sol"; + +contract CSAccounting_revealed is CSAccounting { + constructor( + uint256 commonBondSize, + address admin, + address lidoLocator, + address wstETH, + address communityStakingModule, + uint256 blockedBondRetentionPeriod + ) + CSAccounting( + commonBondSize, + admin, + lidoLocator, + wstETH, + communityStakingModule, + blockedBondRetentionPeriod + ) + {} + + function _bondShares_set_value( + uint256 nodeOperatorId, + uint256 value + ) public { + _bondShares[nodeOperatorId] = value; + } + + function _blockedBondEther_get_value( + uint256 nodeOperatorId + ) public view returns (BlockedBondEther memory) { + return _blockedBondEther[nodeOperatorId]; + } + + function _blockedBondEther_set_value( + uint256 nodeOperatorId, + BlockedBondEther memory value + ) public { + _blockedBondEther[nodeOperatorId] = value; + } + + function _changeBlockedBondState_revealed( + uint256 nodeOperatorId, + uint256 ETHAmount, + uint256 retentionUntil + ) public { + _changeBlockedBondState(nodeOperatorId, ETHAmount, retentionUntil); + } + + function _reduceBlockedBondETH_revealed( + uint256 nodeOperatorId, + uint256 ETHAmount + ) public { + _reduceBlockedBondETH(nodeOperatorId, ETHAmount); + } +} + +contract CSAccountingTest is Test, Fixtures, CSAccountingBase { + using stdStorage for StdStorage; + + LidoLocatorMock internal locator; + WstETHMock internal wstETH; + LidoMock internal stETH; + + Stub internal burner; + + CSAccounting_revealed public accounting; + CommunityStakingModuleMock public stakingModule; + CommunityStakingFeeDistributorMock public feeDistributor; + + address internal admin; + address internal user; + address internal stranger; + + function setUp() public { + admin = address(1); + + user = address(2); + stranger = address(777); + + (locator, wstETH, stETH, burner) = initLido(); + + stakingModule = new CommunityStakingModuleMock(); + accounting = new CSAccounting_revealed( + 2 ether, + admin, + address(locator), + address(wstETH), + address(stakingModule), + 8 weeks + ); + feeDistributor = new CommunityStakingFeeDistributorMock( + address(locator), + address(accounting) + ); + vm.startPrank(admin); + accounting.setFeeDistributor(address(feeDistributor)); + accounting.grantRole(accounting.PENALIZE_BOND_ROLE(), admin); + accounting.grantRole( + accounting.EL_REWARDS_STEALING_PENALTY_ROLE(), + admin + ); + accounting.grantRole(accounting.EASY_TRACK_MOTION_AGENT_ROLE(), admin); + vm.stopPrank(); + } + + function test_getBlockedBondETH() public { + uint256 noId = 0; + uint256 amount = 1 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + assertEq(accounting.getBlockedBondETH(noId), amount); + + // retentionUntil is not passed yet + vm.warp(retentionUntil); + assertEq(accounting.getBlockedBondETH(noId), amount); + + // the next block after retentionUntil + vm.warp(retentionUntil + 12); + assertEq(accounting.getBlockedBondETH(noId), 0); + } + + function test_getRequiredBondETH_withBlockedBond() public { + uint256 noId = 0; + uint256 amount = 100500 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + assertEq(accounting.getRequiredBondETH(noId, 0), amount); + + // the next block after retentionUntil + vm.warp(retentionUntil + 12); + assertEq(accounting.getRequiredBondETH(noId, 0), 0); + } + + function test_getExcessBondETH_withBlockedBond() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + uint256 noId = 0; + uint256 amount = 100500 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + vm.deal(user, 12 ether); + vm.startPrank(user); + stETH.submit{ value: 12 ether }({ _referal: address(0) }); + accounting.depositStETH(user, 0, 12 ether); + vm.stopPrank(); + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + assertEq(accounting.getExcessBondETH(noId), 0); + + // the next block after retentionUntil + vm.warp(retentionUntil + 12); + assertApproxEqAbs(accounting.getExcessBondETH(0), 10 ether, 1); + } + + function test_claimRewardStETH_withBlockedBond() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + uint256 noId = 0; + uint256 amount = 100500 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + vm.deal(user, 12 ether); + vm.startPrank(user); + stETH.submit{ value: 12 ether }({ _referal: address(0) }); + accounting.depositStETH(user, 0, 12 ether); + vm.stopPrank(); + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit StETHRewardsClaimed(0, user, 0); + + vm.prank(user); + accounting.claimRewardsStETH(new bytes32[](0), noId, 0, UINT256_MAX); + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: 1 ether, + retentionUntil: retentionUntil + }) + ); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit StETHRewardsClaimed(0, user, 9 ether + 1 wei); + + vm.prank(user); + accounting.claimRewardsStETH(new bytes32[](0), noId, 0, UINT256_MAX); + } + + function test_claimRewardsWstETH_withBlockedBond() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + uint256 noId = 0; + uint256 amount = 100500 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + vm.deal(user, 12 ether); + vm.startPrank(user); + stETH.submit{ value: 12 ether }({ _referal: address(0) }); + accounting.depositStETH(user, 0, 12 ether); + vm.stopPrank(); + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit WstETHRewardsClaimed(0, user, 0); + + vm.prank(user); + accounting.claimRewardsWstETH(new bytes32[](0), noId, 0, UINT256_MAX); + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: 1 ether, + retentionUntil: retentionUntil + }) + ); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit WstETHRewardsClaimed( + 0, + user, + stETH.getSharesByPooledEth(9 ether + 1 wei) + ); + + vm.prank(user); + accounting.claimRewardsWstETH(new bytes32[](0), noId, 0, UINT256_MAX); + } + + function test_requestRewardsETH_withBlockedBond() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + uint256 noId = 0; + uint256 amount = 100500 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + accounting._bondShares_set_value(0, 100 ether); + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit ETHRewardsRequested(0, user, 0); + + vm.prank(user); + accounting.requestRewardsETH(new bytes32[](0), noId, 0, UINT256_MAX); + } + + function test_private_changeBlockedBondState() public { + uint256 noId = 0; + uint256 amount = 1 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(noId, amount, retentionUntil); + accounting._changeBlockedBondState_revealed({ + nodeOperatorId: noId, + ETHAmount: amount, + retentionUntil: retentionUntil + }); + + CSAccounting.BlockedBondEther memory value = accounting + ._blockedBondEther_get_value(noId); + + assertEq(value.ETHAmount, amount); + assertEq(value.retentionUntil, retentionUntil); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(noId, 0, 0); + + accounting._changeBlockedBondState_revealed({ + nodeOperatorId: noId, + ETHAmount: 0, + retentionUntil: 0 + }); + + value = accounting._blockedBondEther_get_value(noId); + + assertEq(value.ETHAmount, 0); + assertEq(value.retentionUntil, 0); + } + + function test_initELRewardsStealingPenalty() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + uint256 noId = 0; + uint256 proposedBlockNumber = 100500; + uint256 firstStolenAmount = 1 ether; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit ELRewardsStealingPenaltyInitiated( + noId, + proposedBlockNumber, + firstStolenAmount + ); + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged( + noId, + firstStolenAmount, + block.timestamp + 8 weeks + ); + + vm.prank(admin); + accounting.initELRewardsStealingPenalty({ + nodeOperatorId: noId, + proposedBlockNumber: proposedBlockNumber, + stolenAmount: firstStolenAmount + }); + + assertEq( + accounting._blockedBondEther_get_value(noId).ETHAmount, + firstStolenAmount + ); + assertEq( + accounting._blockedBondEther_get_value(noId).retentionUntil, + block.timestamp + 8 weeks + ); + + // new block and new stealing + vm.warp(block.timestamp + 12 seconds); + + uint256 secondStolenAmount = 2 ether; + proposedBlockNumber = 100501; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit ELRewardsStealingPenaltyInitiated( + noId, + proposedBlockNumber, + secondStolenAmount + ); + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged( + noId, + firstStolenAmount + secondStolenAmount, + block.timestamp + 8 weeks + ); + + vm.prank(admin); + accounting.initELRewardsStealingPenalty({ + nodeOperatorId: noId, + proposedBlockNumber: proposedBlockNumber, + stolenAmount: secondStolenAmount + }); + + assertEq( + accounting._blockedBondEther_get_value(noId).ETHAmount, + firstStolenAmount + secondStolenAmount + ); + assertEq( + accounting._blockedBondEther_get_value(noId).retentionUntil, + block.timestamp + 8 weeks + ); + } + + function test_initELRewardsStealingPenalty_revertWhenNonExistingOperator() + public + { + vm.expectRevert("node operator does not exist"); + + vm.prank(admin); + accounting.initELRewardsStealingPenalty({ + nodeOperatorId: 0, + proposedBlockNumber: 100500, + stolenAmount: 100 ether + }); + } + + function test_initELRewardsStealingPenalty_revertWhenZero() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + vm.expectRevert("stolen amount should be greater than zero"); + + vm.prank(admin); + accounting.initELRewardsStealingPenalty({ + nodeOperatorId: 0, + proposedBlockNumber: 100500, + stolenAmount: 0 + }); + } + + function test_initELRewardsStealingPenalty_revertWhenNoRole() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + vm.expectRevert( + "AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0xd5726f791c124091830fa80135a85d3ea48ec1d26b2c06296f2175f326b23db3" + ); + + accounting.initELRewardsStealingPenalty({ + nodeOperatorId: 0, + proposedBlockNumber: 100500, + stolenAmount: 100 ether + }); + } + + function test_settleBlockedBondETH() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + vm.deal(user, 12 ether); + vm.startPrank(user); + stETH.submit{ value: 12 ether }({ _referal: address(0) }); + accounting.depositStETH(user, 0, 12 ether); + vm.stopPrank(); + + uint256[] memory nosToPenalize = new uint256[](2); + nosToPenalize[0] = 0; + // non-existing node operator should be skipped in the loop + nosToPenalize[1] = 100500; + + uint256 retentionUntil = block.timestamp + 8 weeks; + + accounting._blockedBondEther_set_value( + 0, + CSAccounting.BlockedBondEther({ + ETHAmount: 1 ether, + retentionUntil: retentionUntil + }) + ); + + // less than 1 day after penalty init + vm.warp(block.timestamp + 20 hours); + + vm.prank(admin); + accounting.settleBlockedBondETH(nosToPenalize); + + CSAccounting.BlockedBondEther memory value = accounting + ._blockedBondEther_get_value(0); + + assertEq(value.ETHAmount, 1 ether); + assertEq(value.retentionUntil, retentionUntil); + + // penalty amount is less than the bond + vm.warp(block.timestamp + 2 days); + + uint256 penalty = stETH.getPooledEthByShares( + stETH.getSharesByPooledEth(1 ether) + ); + uint256 covering = penalty; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BondPenalized(0, penalty, covering); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(0, 0, 0); + + vm.prank(admin); + accounting.settleBlockedBondETH(nosToPenalize); + + value = accounting._blockedBondEther_get_value(0); + assertEq(value.ETHAmount, 0); + assertEq(value.retentionUntil, 0); + + // penalty amount is greater than the bond + accounting._blockedBondEther_set_value( + 0, + CSAccounting.BlockedBondEther({ + ETHAmount: 100 ether, + retentionUntil: retentionUntil + }) + ); + + penalty = stETH.getPooledEthByShares( + stETH.getSharesByPooledEth(100 ether) + ); + covering = 11 ether; + uint256 uncovered = penalty - covering; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BondPenalized(0, penalty, covering); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(0, uncovered, retentionUntil); + + vm.prank(admin); + accounting.settleBlockedBondETH(nosToPenalize); + + value = accounting._blockedBondEther_get_value(0); + assertEq(value.ETHAmount, uncovered); + assertEq(value.retentionUntil, retentionUntil); + + // retention period expired + accounting._blockedBondEther_set_value( + 0, + CSAccounting.BlockedBondEther({ + ETHAmount: 100 ether, + retentionUntil: retentionUntil + }) + ); + vm.warp(retentionUntil + 12); + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(0, 0, 0); + + vm.prank(admin); + accounting.settleBlockedBondETH(nosToPenalize); + + value = accounting._blockedBondEther_get_value(0); + assertEq(value.ETHAmount, 0); + assertEq(value.retentionUntil, 0); + } + + function test_private_reduceBlockedBondETH() public { + uint256 noId = 0; + uint256 amount = 100 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + // part of blocked bond is released + uint256 toReduce = 10 ether; + uint256 rest = amount - toReduce; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(noId, rest, retentionUntil); + + accounting._reduceBlockedBondETH_revealed(noId, toReduce); + + CSAccounting.BlockedBondEther memory value = accounting + ._blockedBondEther_get_value(noId); + + assertEq(value.ETHAmount, rest); + assertEq(value.retentionUntil, retentionUntil); + + // all blocked bond is released + toReduce = rest; + rest = 0; + retentionUntil = 0; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(noId, rest, retentionUntil); + + accounting._reduceBlockedBondETH_revealed(noId, toReduce); + + value = accounting._blockedBondEther_get_value(noId); + + assertEq(value.ETHAmount, rest); + assertEq(value.retentionUntil, retentionUntil); + } + + function test_private_reduceBlockedBondETH_revertWhenNoBlocked() public { + vm.expectRevert("no blocked bond to release"); + accounting._reduceBlockedBondETH_revealed(0, 1 ether); + } + + function test_private_reduceBlockedBondETH_revertWhenAmountGreaterThanBlocked() + public + { + uint256 noId = 0; + uint256 amount = 100 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + vm.expectRevert("blocked bond is less than amount to release"); + accounting._reduceBlockedBondETH_revealed(0, 101 ether); + } + + function test_releaseBlockedBondETH() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + uint256 noId = 0; + uint256 amount = 100 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + uint256 toRelease = 10 ether; + uint256 rest = amount - toRelease; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondReleased(noId, toRelease); + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(noId, rest, retentionUntil); + + vm.prank(admin); + accounting.releaseBlockedBondETH(noId, toRelease); + } + + function test_releaseBlockedBondETH_revertWhenNonExistingOperator() public { + vm.expectRevert("node operator does not exist"); + + vm.prank(admin); + accounting.releaseBlockedBondETH(0, 1 ether); + } + + function test_releaseBlockedBondETH_revertWhenNoRole() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + vm.expectRevert( + "AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0xd5726f791c124091830fa80135a85d3ea48ec1d26b2c06296f2175f326b23db3" + ); + accounting.releaseBlockedBondETH(0, 1 ether); + } + + function test_compensateBlockedBondETH() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + uint256 noId = 0; + uint256 amount = 100 ether; + uint256 retentionUntil = block.timestamp + 1 weeks; + + accounting._blockedBondEther_set_value( + noId, + CSAccounting.BlockedBondEther({ + ETHAmount: amount, + retentionUntil: retentionUntil + }) + ); + + uint256 toCompensate = 10 ether; + uint256 rest = amount - toCompensate; + + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondCompensated(noId, toCompensate); + vm.expectEmit(true, true, true, true, address(accounting)); + emit BlockedBondChanged(noId, rest, retentionUntil); + + vm.deal(user, toCompensate); + vm.prank(user); + accounting.compensateBlockedBondETH{ value: toCompensate }(noId); + + assertEq(address(locator.elRewardsVault()).balance, toCompensate); + } + + function test_compensateBlockedBondETH_revertWhenZero() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + vm.expectRevert("value should be greater than zero"); + accounting.compensateBlockedBondETH{ value: 0 }(0); + } + + function test_compensateBlockedBondETH_revertWhenNonExistingOperator() + public + { + vm.expectRevert("node operator does not exist"); + accounting.compensateBlockedBondETH{ value: 1 ether }(0); + } + + function _createNodeOperator( + uint64 ongoingVals, + uint64 withdrawnVals + ) internal { + stakingModule.setNodeOperator({ + _nodeOperatorId: 0, + _active: true, + _rewardAddress: user, + _totalVettedValidators: ongoingVals, + _totalExitedValidators: 0, + _totalWithdrawnValidators: withdrawnVals, + _totalAddedValidators: ongoingVals, + _totalDepositedValidators: ongoingVals + }); + } +} diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index b9c83a03..18b0bd93 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -25,11 +25,13 @@ contract Fixtures is StdCheats { _sharesAmount: 7059313073779349112833523 }); burner = new Stub(); + Stub elVault = new Stub(); WithdrawalQueueMock wq = new WithdrawalQueueMock(address(stETH)); locator = new LidoLocatorMock( address(stETH), address(burner), - address(wq) + address(wq), + address(elVault) ); wstETH = new WstETHMock(address(stETH)); } diff --git a/test/helpers/mocks/LidoLocatorMock.sol b/test/helpers/mocks/LidoLocatorMock.sol index 66fe8afd..754871bb 100644 --- a/test/helpers/mocks/LidoLocatorMock.sol +++ b/test/helpers/mocks/LidoLocatorMock.sol @@ -7,11 +7,13 @@ contract LidoLocatorMock { address public l; address public b; address public wq; + address public el; - constructor(address _lido, address _burner, address _wq) { + constructor(address _lido, address _burner, address _wq, address _el) { l = _lido; b = _burner; wq = _wq; + el = _el; } function lido() external view returns (address) { @@ -25,4 +27,8 @@ contract LidoLocatorMock { function withdrawalQueue() external view returns (address) { return wq; } + + function elRewardsVault() external view returns (address) { + return el; + } } diff --git a/test/helpers/mocks/Stub.sol b/test/helpers/mocks/Stub.sol index 79898ed5..500018c5 100644 --- a/test/helpers/mocks/Stub.sol +++ b/test/helpers/mocks/Stub.sol @@ -2,4 +2,6 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.21; -contract Stub {} +contract Stub { + receive() external payable {} +}