diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index 471024d4e5..f132b4bd07 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -37,4 +37,4 @@ jobs: run: forge --version - name: Run fuzzing and invariant tests - run: forge test -vvv + run: forge test diff --git a/foundry/lib/forge-std b/foundry/lib/forge-std index 8f24d6b04c..ffa2ee0d92 160000 --- a/foundry/lib/forge-std +++ b/foundry/lib/forge-std @@ -1 +1 @@ -Subproject commit 8f24d6b04c92975e0795b5868aa0d783251cdeaa +Subproject commit ffa2ee0d921b4163b7abd0f1122df93ead205805 diff --git a/package.json b/package.json index eb193407db..e5375c5814 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "typecheck": "tsc --noEmit", "abis:extract": "hardhat abis:extract", "verify:deployed": "hardhat verify:deployed", + "test:fuzzShateRate": "forge test --match-path \"test/0.8.25/ShareRate.t.sol\"", + "test:fuzzOracleReport": "forge test --match-path \"test/0.8.25/Accounting.t.sol\"", "postinstall": "husky" }, "lint-staged": { diff --git a/test/0.8.25/Accounting.t.sol b/test/0.8.25/Accounting.t.sol new file mode 100644 index 0000000000..7ceec98c90 --- /dev/null +++ b/test/0.8.25/Accounting.t.sol @@ -0,0 +1,502 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {Vm} from "forge-std/Vm.sol"; +import {CommonBase} from "forge-std/Base.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {console2} from "forge-std/console2.sol"; + +import {BaseProtocolTest} from "./Protocol__Deployment.t.sol"; +import {LimitsList} from "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol"; +import {ReportValues} from "contracts/common/interfaces/ReportValues.sol"; + +interface IStakingRouter { + function getRecipients() external view returns (address[] memory); +} + +interface IAccounting { + function handleOracleReport(ReportValues memory _report) external; + + function simulateOracleReport(ReportValues memory _report, uint256 _withdrawalShareRate) external; +} + +interface ILido { + function getTotalShares() external view returns (uint256); + + function getTotalPooledEther() external view returns (uint256); + + function getBufferedEther() external view returns (uint256); + + function getExternalShares() external view returns (uint256); + + function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256); + + function resume() external; + + function getBeaconStat() + external + view + returns (uint256 depositedValidators, uint256 beaconValidators, uint256 beaconBalance); +} + +interface ISecondOpinionOracleMock { + function mock__setReportValues( + bool _success, + uint256 _clBalanceGwei, + uint256 _withdrawalVaultBalanceWei, + uint256 _totalDepositedValidators, + uint256 _totalExitedValidators + ) external; +} + +// 0.002792 * 10^18 +// 0.0073 * 10^18 +uint256 constant maxYieldPerOperatorWei = 2_792_000_000_000_000; // which % of slashing could be? +uint256 constant maxLossPerOperatorWei = 7_300_000_000_000_000; +uint256 constant stableBalanceWei = 32 * 1 ether; + +struct FuzzValues { + uint256 _preClValidators; + uint256 _preClBalanceWei; + uint256 _clValidators; + uint256 _clBalanceWei; + uint256 _withdrawalVaultBalance; + uint256 _elRewardsVaultBalanceWei; + uint256 _sharesRequestedToBurn; + uint256 _lidoExecutionLayerRewardVaultWei; +} + +struct LidoTransfer { + address from; + address to; +} + +contract AccountingHandler is CommonBase, StdCheats, StdUtils { + struct Ghost { + int256 clValidators; + int256 depositedValidators; + int256 sharesMintAsFees; + int256 transferShares; + int256 totalRewardsWei; + int256 principalClBalanceWei; + int256 unifiedClBalanceWei; + } + + struct BoundaryValues { + uint256 minPreClValidators; + uint256 maxPreClValidators; + uint256 minClValidators; + uint256 maxClValidators; + uint256 minClBalanceWei; + uint256 maxClBalanceWei; + uint256 minDepositedValidators; + uint256 maxDepositedValidators; + uint256 minElRewardsVaultBalanceWei; + uint256 maxElRewardsVaultBalanceWei; + } + + IAccounting private accounting; + ILido private lido; + ISecondOpinionOracleMock private secondOpinionOracle; + IStakingRouter public stakingRouter; + + Ghost public ghost; + LidoTransfer[] public ghost_lidoTransfers; + BoundaryValues public boundaryValues; + + address private accountingOracle; + address private lidoExecutionLayerRewardVault; + address private burner; + LimitsList public limitList; + + constructor( + address _accounting, + address _lido, + address _accountingOracle, + LimitsList memory _limitList, + address _lidoExecutionLayerRewardVault, + address _secondOpinionOracle, + address _burnerAddress, + address _stakingRouter + ) { + accounting = IAccounting(_accounting); + lido = ILido(_lido); + accountingOracle = _accountingOracle; + limitList = _limitList; + lidoExecutionLayerRewardVault = _lidoExecutionLayerRewardVault; + + ghost = Ghost(0, 0, 0, 0, 0, 0, 0); + secondOpinionOracle = ISecondOpinionOracleMock(_secondOpinionOracle); + burner = _burnerAddress; + stakingRouter = IStakingRouter(_stakingRouter); + + // Initialize boundary values with extreme values + boundaryValues = BoundaryValues({ + minPreClValidators: type(uint256).max, + maxPreClValidators: 0, + minClValidators: type(uint256).max, + maxClValidators: 0, + minClBalanceWei: type(uint256).max, + maxClBalanceWei: 0, + minDepositedValidators: type(uint256).max, + maxDepositedValidators: 0, + minElRewardsVaultBalanceWei: type(uint256).max, + maxElRewardsVaultBalanceWei: 0 + }); + } + + function cutGwei(uint256 value) public pure returns (uint256) { + return (value / 1 gwei) * 1 gwei; + } + + function handleOracleReport(FuzzValues memory fuzz) external { + uint256 _timeElapsed = 86_400; + uint256 _timestamp = block.timestamp + _timeElapsed; + + // cheatCode for + // if (_report.timestamp >= block.timestamp) revert IncorrectReportTimestamp(_report.timestamp, block.timestamp); + vm.warp(_timestamp + 1); + + fuzz._lidoExecutionLayerRewardVaultWei = bound(fuzz._lidoExecutionLayerRewardVaultWei, 0, 1_000) * 1 ether; + fuzz._elRewardsVaultBalanceWei = bound( + fuzz._elRewardsVaultBalanceWei, + 0, + fuzz._lidoExecutionLayerRewardVaultWei + ); + + // Update boundary values for elRewardsVaultBalanceWei + if (fuzz._elRewardsVaultBalanceWei < boundaryValues.minElRewardsVaultBalanceWei) { + boundaryValues.minElRewardsVaultBalanceWei = fuzz._elRewardsVaultBalanceWei; + } + if (fuzz._elRewardsVaultBalanceWei > boundaryValues.maxElRewardsVaultBalanceWei) { + boundaryValues.maxElRewardsVaultBalanceWei = fuzz._elRewardsVaultBalanceWei; + } + + fuzz._preClValidators = bound(fuzz._preClValidators, 250_000, 100_000_000_000); + fuzz._preClBalanceWei = cutGwei(fuzz._preClValidators * stableBalanceWei); + + // Update boundary values for preClValidators + if (fuzz._preClValidators < boundaryValues.minPreClValidators) { + boundaryValues.minPreClValidators = fuzz._preClValidators; + } + if (fuzz._preClValidators > boundaryValues.maxPreClValidators) { + boundaryValues.maxPreClValidators = fuzz._preClValidators; + } + + ghost.clValidators = int256(fuzz._preClValidators); + + fuzz._clValidators = bound( + fuzz._clValidators, + fuzz._preClValidators, + fuzz._preClValidators + limitList.appearedValidatorsPerDayLimit + ); + + // Update boundary values for clValidators + if (fuzz._clValidators < boundaryValues.minClValidators) { + boundaryValues.minClValidators = fuzz._clValidators; + } + if (fuzz._clValidators > boundaryValues.maxClValidators) { + boundaryValues.maxClValidators = fuzz._clValidators; + } + + uint256 minBalancePerValidatorWei = fuzz._clValidators * (stableBalanceWei - maxLossPerOperatorWei); + uint256 maxBalancePerValidatorWei = fuzz._clValidators * (stableBalanceWei + maxYieldPerOperatorWei); + fuzz._clBalanceWei = bound(fuzz._clBalanceWei, minBalancePerValidatorWei, maxBalancePerValidatorWei); + + // Update boundary values for clBalanceWei + if (fuzz._clBalanceWei < boundaryValues.minClBalanceWei) { + boundaryValues.minClBalanceWei = fuzz._clBalanceWei; + } + if (fuzz._clBalanceWei > boundaryValues.maxClBalanceWei) { + boundaryValues.maxClBalanceWei = fuzz._clBalanceWei; + } + + // depositedValidators is always greater or equal to beaconValidators + // Todo: Upper extremum ? + uint256 depositedValidators = bound( + fuzz._preClValidators, + fuzz._clValidators + 1, + fuzz._clValidators + limitList.appearedValidatorsPerDayLimit + ); + + // Update boundary values for depositedValidators + if (depositedValidators < boundaryValues.minDepositedValidators) { + boundaryValues.minDepositedValidators = depositedValidators; + } + if (depositedValidators > boundaryValues.maxDepositedValidators) { + boundaryValues.maxDepositedValidators = depositedValidators; + } + + ghost.depositedValidators = int256(depositedValidators); + + vm.store(address(lido), keccak256("lido.Lido.depositedValidators"), bytes32(depositedValidators)); + vm.store(address(lido), keccak256("lido.Lido.beaconValidators"), bytes32(fuzz._preClValidators)); + vm.store(address(lido), keccak256("lido.Lido.beaconBalance"), bytes32(fuzz._preClBalanceWei)); + + vm.deal(lidoExecutionLayerRewardVault, fuzz._lidoExecutionLayerRewardVaultWei); + + ReportValues memory currentReport = ReportValues({ + timestamp: _timestamp, + timeElapsed: _timeElapsed, + clValidators: fuzz._clValidators, + clBalance: (fuzz._clBalanceWei / 1e9) * 1e9, + elRewardsVaultBalance: fuzz._elRewardsVaultBalanceWei, + withdrawalVaultBalance: 0, + sharesRequestedToBurn: 0, + withdrawalFinalizationBatches: new uint256[](0), + vaultsTotalDeficit: 0 + }); + + ghost.unifiedClBalanceWei = int256(fuzz._clBalanceWei + currentReport.withdrawalVaultBalance); // ? + ghost.principalClBalanceWei = int256( + fuzz._preClBalanceWei + (currentReport.clValidators - fuzz._preClValidators) * stableBalanceWei + ); + + ghost.totalRewardsWei = + ghost.unifiedClBalanceWei - + ghost.principalClBalanceWei + + int256(fuzz._elRewardsVaultBalanceWei); + + secondOpinionOracle.mock__setReportValues( + true, + fuzz._clBalanceWei / 1e9, + currentReport.withdrawalVaultBalance, + uint256(ghost.depositedValidators), + 0 + ); + + vm.prank(accountingOracle); + + delete ghost_lidoTransfers; + vm.recordLogs(); + accounting.handleOracleReport(currentReport); + Vm.Log[] memory entries = vm.getRecordedLogs(); + + bytes32 totalSharesSignature = keccak256("Mock__MintedTotalShares(uint256)"); + bytes32 transferSharesSignature = keccak256("TransferShares(address,address,uint256)"); + bytes32 lidoTransferSignature = keccak256("Transfer(address,address,uint256)"); + + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].topics[0] == totalSharesSignature) { + ghost.sharesMintAsFees = int256(abi.decode(abi.encodePacked(entries[i].topics[1]), (uint256))); + } + + if (entries[i].topics[0] == transferSharesSignature) { + ghost.transferShares = int256(abi.decode(entries[i].data, (uint256))); + } + + if (entries[i].topics[0] == lidoTransferSignature) { + if (entries[i].emitter == address(lido)) { + address from = abi.decode(abi.encodePacked(entries[i].topics[1]), (address)); + address to = abi.decode(abi.encodePacked(entries[i].topics[2]), (address)); + + ghost_lidoTransfers.push(LidoTransfer({from: from, to: to})); + } + } + } + } + + function getGhost() public view returns (Ghost memory) { + return ghost; + } + + function getLidoTransfers() public view returns (LidoTransfer[] memory) { + return ghost_lidoTransfers; + } + + function getBoundaryValues() public view returns (BoundaryValues memory) { + return boundaryValues; + } +} + +contract AccountingTest is BaseProtocolTest { + AccountingHandler private accountingHandler; + + uint256 private protocolStartBalance = 1 ether; + + address private rootAccount = address(0x123); + address private userAccount = address(0x321); + + mapping(address => bool) public possibleLidoRecipients; + + function setUp() public { + BaseProtocolTest.setUpProtocol(protocolStartBalance, rootAccount, userAccount); + + accountingHandler = new AccountingHandler( + lidoLocator.accounting(), + lidoLocator.lido(), + lidoLocator.accountingOracle(), + limitList, + lidoLocator.elRewardsVault(), + address(secondOpinionOracleMock), + lidoLocator.burner(), + lidoLocator.stakingRouter() + ); + + // Set target contract to the accounting handler + targetContract(address(accountingHandler)); + + vm.prank(userAccount); + lidoContract.resume(); + + possibleLidoRecipients[lidoLocator.burner()] = true; + possibleLidoRecipients[lidoLocator.treasury()] = true; + + for (uint256 i = 0; i < accountingHandler.stakingRouter().getRecipients().length; i++) { + possibleLidoRecipients[accountingHandler.stakingRouter().getRecipients()[i]] = true; + } + + // Set target selectors to the accounting handler + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = accountingHandler.handleOracleReport.selector; + + targetSelector(FuzzSelector({addr: address(accountingHandler), selectors: selectors})); + } + + function logBoundaryValues() internal view { + AccountingHandler.BoundaryValues memory bounds = accountingHandler.getBoundaryValues(); + console2.log("Boundary Values:"); + console2.log("PreClValidators min:", bounds.minPreClValidators); + console2.log("PreClValidators max:", bounds.maxPreClValidators); + console2.log("ClValidators min:", bounds.minClValidators); + console2.log("ClValidators max:", bounds.maxClValidators); + console2.log("ClBalanceWei min:", bounds.minClBalanceWei); + console2.log("ClBalanceWei max:", bounds.maxClBalanceWei); + console2.log("DepositedValidators min:", bounds.minDepositedValidators); + console2.log("DepositedValidators max:", bounds.maxDepositedValidators); + console2.log("ElRewardsVaultBalanceWei min:", bounds.minElRewardsVaultBalanceWei); + console2.log("ElRewardsVaultBalanceWei max:", bounds.maxElRewardsVaultBalanceWei); + } + + /** + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true + */ + function invariant_clValidatorNotDecreased() public view { + ILido lido = ILido(lidoLocator.lido()); + + (uint256 depositedValidators, uint256 clValidators, ) = lido.getBeaconStat(); + + // Should not be able to decrease validator number + assertGe(clValidators, uint256(accountingHandler.getGhost().clValidators)); + assertEq(depositedValidators, uint256(accountingHandler.getGhost().depositedValidators)); + + logBoundaryValues(); + } + + /** + * 0 OR 10% OF PROTOCOL FEES SHOULD BE REPORTED (Collect total fees from reports in handler) + * CLb + ELr <= 10% + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true + */ + function invariant_NonNegativeRebase() public view { + ILido lido = ILido(lidoLocator.lido()); + + AccountingHandler.Ghost memory ghost = accountingHandler.getGhost(); + + bool isRebasePositive = ghost.unifiedClBalanceWei > ghost.principalClBalanceWei; + if (isRebasePositive) { + if (ghost.sharesMintAsFees < 0) { + revert("sharesMintAsFees < 0"); + } + + if (ghost.transferShares < 0) { + revert("transferShares < 0"); + } + + int256 treasuryFeesETH = int256(lido.getPooledEthByShares(uint256(ghost.sharesMintAsFees))); + int256 reportRewardsMintedETH = int256(lido.getPooledEthByShares(uint256(ghost.transferShares))); + int256 totalFees = int256(treasuryFeesETH + reportRewardsMintedETH); + int256 totalRewards = ghost.totalRewardsWei; + + if (totalRewards != 0) { + int256 percents = (totalFees * 100) / totalRewards; + + assertTrue(percents <= 10, "all distributed rewards > 10%"); + assertTrue(percents >= 0, "all distributed rewards < 0%"); + } + } else { + console2.log("Negative rebase. Skipping report", ghost.totalRewardsWei / 1 ether); + } + + logBoundaryValues(); + } + + /** + * Lido.Transfer from (0x00, to treasure or burner. Other -> collect and check what is it) + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true + */ + function invariant_LidoTransfers() public view { + LidoTransfer[] memory lidoTransfers = accountingHandler.getLidoTransfers(); + + for (uint256 i = 0; i < lidoTransfers.length; i++) { + assertEq(lidoTransfers[i].from, address(0), "Lido.Transfer sender is not zero"); + assertTrue( + possibleLidoRecipients[lidoTransfers[i].to], + "Lido.Transfer recipient is not possibleLidoRecipients" + ); + } + + logBoundaryValues(); + } + + /** + * solvency - stETH <> ETH = 1:1 - internal and total share rates are equal + * vault params do not affect protocol share rate + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true + */ + function invariant_vaultsDonAffectSharesRate() public view { + ILido lido = ILido(lidoLocator.lido()); + + uint256 totalPooledEther = lido.getTotalPooledEther(); + uint256 bufferedEther = lido.getBufferedEther(); + uint256 totalShares = lido.getTotalShares(); + uint256 externalShares = lido.getExternalShares(); + + uint256 totalShareRate = totalPooledEther / totalShares; + + console2.log("bufferedEther", bufferedEther); + console2.log("totalPooledEther", totalPooledEther); + console2.log("totalShares", totalShares); + console2.log("totalShareRate", totalShareRate); + + // Get transient ether + (uint256 depositedValidators, uint256 clValidators, uint256 clBalance) = lido.getBeaconStat(); + // clValidators can never be less than deposited ones. + uint256 transientEther = (depositedValidators - clValidators) * 32 ether; + console2.log("transientEther", transientEther); + + // Calculate internal ether + uint256 internalEther = bufferedEther + clBalance + transientEther; + console2.log("internalEther", internalEther); + + // Calculate internal shares + uint256 internalShares = totalShares - externalShares; + console2.log("internalShares", internalShares); + console2.log("getExternalShares", externalShares); + + uint256 internalShareRate = internalEther / internalShares; + + console2.log("internalShareRate", internalShareRate); + + assertEq(totalShareRate, internalShareRate); + + logBoundaryValues(); + } +} diff --git a/test/0.8.25/Protocol__Deployment.t.sol b/test/0.8.25/Protocol__Deployment.t.sol new file mode 100644 index 0000000000..3bf2f79ec8 --- /dev/null +++ b/test/0.8.25/Protocol__Deployment.t.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import {CommonBase} from "forge-std/Base.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {console2} from "forge-std/console2.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; + +import {LidoLocator} from "contracts/0.8.9/LidoLocator.sol"; +import {LimitsList} from "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol"; + +import {StakingRouter__MockForLidoAccountingFuzzing} from "./contracts/StakingRouter__MockForLidoAccountingFuzzing.sol"; +import { + SecondOpinionOracle__MockForAccountingFuzzing +} from "./contracts/SecondOpinionOracle__MockForAccountingFuzzing.sol"; +import {WithdrawalQueue, IWstETH} from "../../contracts/0.8.9/WithdrawalQueue.sol"; +import {WithdrawalQueueERC721} from "../../contracts/0.8.9/WithdrawalQueueERC721.sol"; + +/** + * @title Interface for VaultHub + */ +interface IVaultHub { + function initialize(address _admin) external; +} + +/** + * @title Stake Limit State Data Structure + */ +struct StakeLimitStateData { + uint32 prevStakeBlockNumber; // block number of the previous stake submit + uint96 prevStakeLimit; // limit value (<= `maxStakeLimit`) obtained on the previous stake submit + uint32 maxStakeLimitGrowthBlocks; // limit regeneration speed expressed in blocks + uint96 maxStakeLimit; // maximum limit value +} + +/** + * @title Interface for Lido contract + */ +interface ILido { + function getTotalShares() external view returns (uint256); + function getExternalShares() external view returns (uint256); + function mintExternalShares(address _recipient, uint256 _amountOfShares) external; + function burnExternalShares(uint256 _amountOfShares) external; + function setMaxExternalRatioBP(uint256 _maxExternalRatioBP) external; + function initialize(address _lidoLocator, address _eip712StETH) external payable; + function resumeStaking() external; + function resume() external; + function setStakingLimit(uint256 _maxStakeLimit, uint256 _stakeLimitIncreasePerBlock) external; + function transfer(address _recipient, uint256 _amount) external returns (bool); + function submit(address _referral) external payable returns (uint256); + function getStakeLimitFullInfo() + external + view + returns ( + bool isStakingPaused_, + bool isStakingLimitSet, + uint256 currentStakeLimit, + uint256 maxStakeLimit, + uint256 maxStakeLimitGrowthBlocks, + uint256 prevStakeLimit, + uint256 prevStakeBlockNumber + ); + function approve(address _spender, uint256 _amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +/** + * @title Interface for Aragon Kernel + */ +interface IKernel { + function acl() external view returns (IACL); + function newAppInstance( + bytes32 _appId, + address _appBase, + bytes calldata _initializePayload, + bool _setDefault + ) external; +} + +/** + * @title Interface for Aragon ACL + */ +interface IACL { + function initialize(address _permissionsCreator) external; + function createPermission(address _entity, address _app, bytes32 _role, address _manager) external; + function hasPermission(address _who, address _where, bytes32 _what) external view returns (bool); +} + +/** + * @title Interface for Aragon DAO Factory + */ +interface IDaoFactory { + function newDAO(address _root) external returns (IKernel); +} + +/** + * @title Base Protocol Test Contract + * @notice Sets up the Lido protocol for testing + */ +contract BaseProtocolTest is Test { + // Main protocol contracts + ILido public lidoContract; + LidoLocator public lidoLocator; + WithdrawalQueueERC721 public wq; + IACL public acl; + SecondOpinionOracle__MockForAccountingFuzzing public secondOpinionOracleMock; + IKernel private dao; + + // Account addresses + address private rootAccount; + address private userAccount; + + // Aragon DAO components + address public kernelBase; + address public aclBase; + address public evmScriptRegistryFactory; + address public daoFactoryAdr; + + // Protocol configuration + address public depositContractAdr = address(0x4242424242424242424242424242424242424242); + address public withdrawalQueueAdr = makeAddr("dummy-locator:withdrawalQueue"); + uint256 public genesisTimestamp = 1_695_902_400; + address public lidoTreasuryAdr = makeAddr("dummy-lido:treasury"); + address public wstETHAdr = makeAddr("dummy-locator:wstETH"); + + // Constants + uint256 public constant VAULTS_LIMIT = 500; + uint256 public constant VAULTS_RELATIVE_SHARE_LIMIT = 10_00; + + LimitsList public limitList = + LimitsList({ + exitedValidatorsPerDayLimit: 9000, + appearedValidatorsPerDayLimit: 43200, + annualBalanceIncreaseBPLimit: 10_00, + maxValidatorExitRequestsPerReport: 600, + maxItemsPerExtraDataTransaction: 8, + maxNodeOperatorsPerExtraDataItem: 24, + requestTimestampMargin: 7680, + maxPositiveTokenRebase: 750000, + initialSlashingAmountPWei: 1000, + inactivityPenaltiesAmountPWei: 101, + clBalanceOraclesErrorUpperBPLimit: 50 + }); + + /** + * @notice Sets up the protocol with initial configuration + * @param _startBalance Initial balance for the Lido contract + * @param _rootAccount Admin account address + * @param _userAccount User account address + */ + function setUpProtocol(uint256 _startBalance, address _rootAccount, address _userAccount) public { + rootAccount = _rootAccount; + userAccount = _userAccount; + + // Deploy Lido implementation + address impl = deployCode("Lido.sol:Lido"); + + vm.startPrank(rootAccount); + + // Create Aragon DAO + (dao, acl) = _createAragonDao(); + + // Add Lido as an Aragon app + address lidoProxyAddress = _addAragonApp(dao, impl); + lidoContract = ILido(lidoProxyAddress); + + // Fund Lido contract + vm.deal(lidoProxyAddress, _startBalance); + + // Set up permissions + _setupLidoPermissions(lidoProxyAddress); + + // Set up staking router mock + StakingRouter__MockForLidoAccountingFuzzing stakingRouter = _setupStakingRouterMock(); + + // Deploy Lido locator + lidoLocator = _deployLidoLocator(lidoProxyAddress, address(stakingRouter)); + + // Deploy and set up protocol components + _deployProtocolComponents(lidoProxyAddress); + + // Initialize VaultHub + IVaultHub(lidoLocator.vaultHub()).initialize(rootAccount); + + // Deploy and initialize EIP712StETH + address eip712steth = deployCode("EIP712StETH.sol:EIP712StETH", abi.encode(lidoProxyAddress)); + lidoContract.initialize(address(lidoLocator), address(eip712steth)); + + // Deploy WstETH + deployCodeTo("WstETH.sol:WstETH", abi.encode(lidoProxyAddress), wstETHAdr); + + // Set up withdrawal queue + _setupWithdrawalQueue(); + + vm.stopPrank(); + } + + /** + * @notice Sets up permissions for the Lido contract + * @param lidoProxyAddress Address of the Lido proxy + */ + function _setupLidoPermissions(address lidoProxyAddress) internal { + acl.createPermission(userAccount, lidoProxyAddress, keccak256("STAKING_CONTROL_ROLE"), rootAccount); + acl.createPermission(userAccount, lidoProxyAddress, keccak256("STAKING_PAUSE_ROLE"), rootAccount); + acl.createPermission(userAccount, lidoProxyAddress, keccak256("RESUME_ROLE"), rootAccount); + acl.createPermission(userAccount, lidoProxyAddress, keccak256("PAUSE_ROLE"), rootAccount); + } + + /** + * @notice Sets up the staking router mock + * @return StakingRouter__MockForLidoAccountingFuzzing The configured staking router mock + */ + function _setupStakingRouterMock() internal returns (StakingRouter__MockForLidoAccountingFuzzing) { + StakingRouter__MockForLidoAccountingFuzzing stakingRouter = new StakingRouter__MockForLidoAccountingFuzzing(); + + uint96[] memory stakingModuleFees = new uint96[](3); + stakingModuleFees[0] = 4876942047684326532; + stakingModuleFees[1] = 145875332634464962; + stakingModuleFees[2] = 38263043302959438; + + address[] memory recipients = new address[](3); + recipients[0] = 0x55032650b14df07b85bF18A3a3eC8E0Af2e028d5; + recipients[1] = 0xaE7B191A31f627b4eB1d4DaC64eaB9976995b433; + recipients[2] = 0xdA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F; + + stakingRouter.mock__getStakingRewardsDistribution( + recipients, + stakingModuleFees, + 9999999999999999996, + 100000000000000000000 + ); + + return stakingRouter; + } + + /** + * @notice Deploys all protocol components + * @param lidoProxyAddress Address of the Lido proxy + */ + function _deployProtocolComponents(address lidoProxyAddress) internal { + // Deploy Accounting + address accountingImpl = deployCode( + "Accounting.sol:Accounting", + abi.encode(address(lidoLocator), lidoProxyAddress) + ); + deployCodeTo( + "OssifiableProxy.sol:OssifiableProxy", + abi.encode(accountingImpl, rootAccount, new bytes(0)), + lidoLocator.accounting() + ); + + // Deploy VaultHub + address vaultHubImpl = deployCode( + "VaultHub.sol:VaultHub", + abi.encode(address(lidoLocator), lidoProxyAddress, VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT) + ); + deployCodeTo( + "OssifiableProxy.sol:OssifiableProxy", + abi.encode(vaultHubImpl, rootAccount, new bytes(0)), + lidoLocator.vaultHub() + ); + + // Deploy AccountingOracle + deployCodeTo( + "AccountingOracle.sol:AccountingOracle", + abi.encode(address(lidoLocator), 12, genesisTimestamp), + lidoLocator.accountingOracle() + ); + + // Deploy Burner + deployCodeTo( + "Burner.sol:Burner", + abi.encode(rootAccount, address(lidoLocator), lidoProxyAddress, 0, 0), + lidoLocator.burner() + ); + + // Deploy EL Rewards Vault + deployCodeTo( + "LidoExecutionLayerRewardsVault.sol:LidoExecutionLayerRewardsVault", + abi.encode(lidoProxyAddress, lidoTreasuryAdr), + lidoLocator.elRewardsVault() + ); + + // Deploy Oracle Report Sanity Checker + _deployOracleReportSanityChecker(); + } + + /** + * @notice Deploys the Oracle Report Sanity Checker + */ + function _deployOracleReportSanityChecker() internal { + // Deploy the sanity checker + deployCodeTo( + "OracleReportSanityChecker.sol:OracleReportSanityChecker", + abi.encode( + address(lidoLocator), + lidoLocator.accountingOracle(), + lidoLocator.accounting(), + rootAccount, + [ + limitList.exitedValidatorsPerDayLimit, + limitList.appearedValidatorsPerDayLimit, + limitList.annualBalanceIncreaseBPLimit, + limitList.maxValidatorExitRequestsPerReport, + limitList.maxItemsPerExtraDataTransaction, + limitList.maxNodeOperatorsPerExtraDataItem, + limitList.requestTimestampMargin, + limitList.maxPositiveTokenRebase, + limitList.initialSlashingAmountPWei, + limitList.inactivityPenaltiesAmountPWei, + limitList.clBalanceOraclesErrorUpperBPLimit + ] + ), + lidoLocator.oracleReportSanityChecker() + ); + + // Set up second opinion oracle mock + secondOpinionOracleMock = new SecondOpinionOracle__MockForAccountingFuzzing(); + vm.store( + lidoLocator.oracleReportSanityChecker(), + bytes32(uint256(2)), + bytes32(uint256(uint160(address(secondOpinionOracleMock)))) + ); + } + + /** + * @notice Sets up the withdrawal queue + */ + function _setupWithdrawalQueue() internal { + wq = new WithdrawalQueueERC721(wstETHAdr, "withdrawalQueueERC721", "wstETH"); + vm.store(address(wq), keccak256("lido.Versioned.contractVersion"), bytes32(0)); + wq.initialize(rootAccount); + wq.grantRole(keccak256("RESUME_ROLE"), rootAccount); + wq.grantRole(keccak256("FINALIZE_ROLE"), rootAccount); + wq.resume(); + } + + /** + * @notice Creates an Aragon DAO and returns the kernel and ACL + * @return IKernel The DAO kernel + * @return IACL The DAO ACL + */ + function _createAragonDao() internal returns (IKernel, IACL) { + // Deploy Aragon components + kernelBase = deployCode("Kernel.sol:Kernel", abi.encode(true)); + aclBase = deployCode("ACL.sol:ACL"); + evmScriptRegistryFactory = deployCode("EVMScriptRegistryFactory.sol:EVMScriptRegistryFactory"); + daoFactoryAdr = deployCode( + "DAOFactory.sol:DAOFactory", + abi.encode(kernelBase, aclBase, evmScriptRegistryFactory) + ); + + IDaoFactory daoFactory = IDaoFactory(daoFactoryAdr); + + // Create new DAO + vm.recordLogs(); + daoFactory.newDAO(rootAccount); + Vm.Log[] memory logs = vm.getRecordedLogs(); + address daoAddress = abi.decode(logs[logs.length - 1].data, (address)); + + IKernel _dao = IKernel(address(daoAddress)); + IACL _acl = IACL(address(_dao.acl())); + + // Set up permissions + _acl.createPermission(rootAccount, daoAddress, keccak256("APP_MANAGER_ROLE"), rootAccount); + + return (_dao, _acl); + } + + /** + * @notice Adds an Aragon app to the DAO and returns the proxy address + * @param _dao The DAO kernel + * @param _impl The implementation address + * @return address The proxy address + */ + function _addAragonApp(IKernel _dao, address _impl) internal returns (address) { + vm.recordLogs(); + _dao.newAppInstance(keccak256(bytes("lido.aragonpm.test")), _impl, "", false); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + address proxyAddress = abi.decode(logs[logs.length - 1].data, (address)); + + return proxyAddress; + } + + /** + * @notice Deploys Lido locator with default values + * @param lido The Lido contract address + * @param stakingRouterAddress The staking router address + * @return LidoLocator The deployed Lido locator + */ + function _deployLidoLocator(address lido, address stakingRouterAddress) internal returns (LidoLocator) { + LidoLocator.Config memory config = LidoLocator.Config({ + accountingOracle: makeAddr("dummy-locator:accountingOracle"), + depositSecurityModule: makeAddr("dummy-locator:depositSecurityModule"), + elRewardsVault: makeAddr("dummy-locator:elRewardsVault"), + lido: lido, + oracleReportSanityChecker: makeAddr("dummy-locator:oracleReportSanityChecker"), + postTokenRebaseReceiver: address(0), + burner: makeAddr("dummy-locator:burner"), + stakingRouter: stakingRouterAddress, + treasury: makeAddr("dummy-locator:treasury"), + validatorsExitBusOracle: makeAddr("dummy-locator:validatorsExitBusOracle"), + withdrawalQueue: withdrawalQueueAdr, + withdrawalVault: makeAddr("dummy-locator:withdrawalVault"), + oracleDaemonConfig: makeAddr("dummy-locator:oracleDaemonConfig"), + accounting: makeAddr("dummy-locator:accounting"), + predepositGuarantee: makeAddr("dummy-locator:predeposit_guarantee"), + wstETH: wstETHAdr, + vaultHub: makeAddr("dummy-locator:vaultHub"), + lazyOracle: makeAddr("dummy-locator:lazyOracle"), + operatorGrid: makeAddr("dummy-locator:operatorGrid"), + validatorExitDelayVerifier: makeAddr("dummy-locator:validatorExitDelayVerifier"), + triggerableWithdrawalsGateway: makeAddr("dummy-locator:triggerableWithdrawalsGateway"), + vaultFactory: makeAddr("dummy-locator:vaultFactory") + }); + + return LidoLocator(deployCode("LidoLocator.sol:LidoLocator", abi.encode(config))); + } +} diff --git a/test/0.8.25/ShareRate.t.sol b/test/0.8.25/ShareRate.t.sol new file mode 100644 index 0000000000..0e71702521 --- /dev/null +++ b/test/0.8.25/ShareRate.t.sol @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only +pragma solidity ^0.8.0; + +import {WithdrawalQueueBase} from "contracts/0.8.9/WithdrawalQueueBase.sol"; +import {WithdrawalQueueERC721} from "contracts/0.8.9/WithdrawalQueueERC721.sol"; +import {EIP712StETH} from "contracts/0.8.9/EIP712StETH.sol"; +import {LidoLocator} from "contracts/0.8.9/LidoLocator.sol"; +import {BaseProtocolTest, ILido} from "./Protocol__Deployment.t.sol"; + +import {CommonBase} from "forge-std/Base.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {console2} from "forge-std/console2.sol"; + +// Number of blocks in one day (assuming 12 second block time) +uint256 constant ONE_DAY_IN_BLOCKS = 7_200; + +// Protocol configuration constants +uint256 constant MAX_EXTERNAL_RATIO_BP = 10_000; // 100% +uint256 constant MAX_STAKE_LIMIT = 15_000_000 ether; +uint256 constant STAKE_LIMIT_INCREASE_PER_BLOCK = 20 ether; +uint256 constant MAX_AMOUNT_OF_SHARES = 100; +uint256 constant PROTOCOL_START_BALANCE = 15_000 ether; +uint256 constant PROTOCOL_START_EXTERNAL_SHARES = 10_000; + +// Test account addresses +address constant ROOT_ACCOUNT = address(0x123); +address constant USER_ACCOUNT = address(0x321); + +// Withdrawal queue configuration +uint256 constant MIN_WITHDRAWAL_AMOUNT = 0.0005 ether; +uint256 constant MAX_WITHDRAWAL_AMOUNT = 1000 ether; +uint256 constant FINALIZATION_DELAY_BLOCKS = 1_500; +uint256 constant FINALIZATION_DELAY_DAYS = 1; +uint256 constant FINALIZATION_ETH_BUFFER = 10_000 ether; +uint256 constant MAX_BATCHES_SIZE = 36; +uint256 constant BATCH_CALCULATION_TIMEOUT = 1_000; +uint256 constant BATCH_CALCULATION_MAX_ITERATIONS = 3; + +// Share rate bounds for finalization +uint256 constant MIN_SHARE_RATE = 0.0001 * 10 ** 27; +uint256 constant MAX_SHARE_RATE = 100 * 10 ** 27; + +// Test user configuration +uint256 constant USER_COUNT = 1_000; + +contract ShareRateHandler is CommonBase, StdCheats, StdUtils { + // Protocol contracts + ILido public lidoContract; + WithdrawalQueueERC721 public wqContract; + address public vaultHub; + + // Account addresses + address public userAccount; + address public rootAccount; + + // Test state tracking + uint256 public maxAmountOfShares; + uint256[] public amountsQW; + address[] public users; + uint256 public constant userCount = 1_000; + + constructor( + ILido _lido, + WithdrawalQueueERC721 _wqContract, + address _vaultHub, + address _userAccount, + address _rootAccount, + uint256 _maxAmountOfShares + ) { + lidoContract = _lido; + wqContract = _wqContract; + vaultHub = _vaultHub; + userAccount = _userAccount; + rootAccount = _rootAccount; + maxAmountOfShares = _maxAmountOfShares; + + _initializeUsers(); + } + + /// Actions for fuzzing + + function mintExternalShares(address _recipient, uint256 _amountOfShares) external returns (bool) { + vm.assume(_recipient != address(0)); + + _amountOfShares = bound(_amountOfShares, 1, maxAmountOfShares); + + vm.prank(userAccount); + lidoContract.resumeStaking(); + + vm.prank(vaultHub); + lidoContract.mintExternalShares(_recipient, _amountOfShares); + + return true; + } + + function burnExternalShares(uint256 _amountOfShares) external returns (bool) { + uint256 totalShares = lidoContract.getExternalShares(); + if (totalShares != 0) { + _amountOfShares = bound(_amountOfShares, 2, maxAmountOfShares); + } else { + _amountOfShares = 1; + } + + vm.prank(userAccount); + lidoContract.resumeStaking(); + + vm.prank(vaultHub); + lidoContract.burnExternalShares(_amountOfShares); + + return true; + } + + function submit(uint256 _senderId, uint256 _amountETH) external payable returns (bool) { + _senderId = _boundSenderId(_senderId); + address sender = users[_senderId]; + + _amountETH = bound(_amountETH, MIN_WITHDRAWAL_AMOUNT, MAX_WITHDRAWAL_AMOUNT); + vm.deal(sender, _amountETH); + + vm.prank(sender); + lidoContract.submit{value: _amountETH}(address(0)); + vm.roll(block.number + ONE_DAY_IN_BLOCKS); + + return true; + } + + function transfer(uint256 _senderId, uint256 _recipientId, uint256 _amountTokens) external payable returns (bool) { + _senderId = _boundSenderId(_senderId); + _recipientId = _boundSenderId(_recipientId); + + if (_recipientId == _senderId) { + return false; + } + + address _sender = users[_senderId]; + address _recipient = users[_recipientId]; + + if (_getLidoUserBalance(_sender) == 0) { + vm.prank(_sender); + this.submit(_senderId, _amountTokens); + } + + _amountTokens = bound(_amountTokens, 1, _getLidoUserBalance(_sender)); + + vm.prank(_sender); + lidoContract.transfer(_recipient, _amountTokens); + vm.roll(block.number + ONE_DAY_IN_BLOCKS); + + return true; + } + + function withdrawStEth( + uint256 _ownerId, + uint256 _amountTokens, + uint256 _maxShareRate + ) external payable returns (bool) { + _ownerId = _boundSenderId(_ownerId); + address _owner = users[_ownerId]; + + _ensureUserHasTokens(_owner, _ownerId, _amountTokens); + + uint256 userBalance = _getLidoUserBalance(_owner); + + vm.prank(_owner); + lidoContract.approve(address(wqContract), userBalance); + vm.roll(block.number + 1); + + _amountTokens = _prepareWithdrawalAmounts(_amountTokens, userBalance); + + vm.prank(_owner); + uint256[] memory requestIds = wqContract.requestWithdrawals(amountsQW, _owner); + delete amountsQW; + + vm.roll(block.number + FINALIZATION_DELAY_BLOCKS); + vm.warp(block.timestamp + FINALIZATION_DELAY_DAYS); + + _finalize(_maxShareRate, _amountTokens + FINALIZATION_ETH_BUFFER); + + _claimWithdrawals(_owner, requestIds); + + return true; + } + + // Getters + + function getTotalShares() external view returns (uint256) { + return lidoContract.getTotalShares(); + } + + /// Helpers + + function _getLidoUserBalance(address _owner) public view returns (uint256) { + return lidoContract.balanceOf(_owner); + } + + function _initializeUsers() private { + for (uint256 i = 0; i <= USER_COUNT; i++) { + uint256 privateKey = uint256(keccak256(abi.encodePacked(i))); + address randomAddr = vm.addr(privateKey); + users.push(randomAddr); + } + } + + function _boundSenderId(uint256 _senderId) private view returns (uint256) { + if (_senderId > this.userCount()) { + return bound(_senderId, 0, this.userCount()); + } + return _senderId; + } + + function _ensureUserHasTokens(address _owner, uint256 _ownerId, uint256 _amountTokens) private { + if (_getLidoUserBalance(_owner) == 0) { + vm.prank(_owner); + this.submit(_ownerId, _amountTokens); + } + + if (_getLidoUserBalance(_owner) < wqContract.MIN_STETH_WITHDRAWAL_AMOUNT()) { + vm.prank(_owner); + this.submit(_ownerId, _amountTokens); + } + } + + function _prepareWithdrawalAmounts(uint256 _amountTokens, uint256 _userBalance) private returns (uint256) { + _amountTokens = bound(_amountTokens, wqContract.MIN_STETH_WITHDRAWAL_AMOUNT(), _userBalance); + + if (_amountTokens >= wqContract.MAX_STETH_WITHDRAWAL_AMOUNT()) { + while (_amountTokens >= wqContract.MAX_STETH_WITHDRAWAL_AMOUNT()) { + amountsQW.push(wqContract.MAX_STETH_WITHDRAWAL_AMOUNT()); + _amountTokens -= wqContract.MAX_STETH_WITHDRAWAL_AMOUNT(); + } + + if (_amountTokens > 0 && _amountTokens >= wqContract.MIN_STETH_WITHDRAWAL_AMOUNT()) { + amountsQW.push(_amountTokens); + } + } else { + amountsQW.push(_amountTokens); + } + + return _amountTokens; + } + + function _calculateBatches( + uint256 _ethBudget, + uint256 _maxShareRate + ) public view returns (uint256[] memory batches) { + uint256[MAX_BATCHES_SIZE] memory emptyBatches; + WithdrawalQueueBase.BatchesCalculationState memory state = WithdrawalQueueBase.BatchesCalculationState( + _ethBudget * 1 ether, + false, + emptyBatches, + 0 + ); + + while (!state.finished) { + state = wqContract.calculateFinalizationBatches( + _maxShareRate, + block.timestamp + BATCH_CALCULATION_TIMEOUT, + BATCH_CALCULATION_MAX_ITERATIONS, + state + ); + } + + batches = new uint256[](state.batchesLength); + for (uint256 i; i < state.batchesLength; ++i) { + batches[i] = state.batches[i]; + } + } + + function _finalize(uint256 _maxShareRate, uint256 _ethBudget) public payable { + _maxShareRate = bound(_maxShareRate, MIN_SHARE_RATE, MAX_SHARE_RATE); + + uint256[] memory batches = _calculateBatches(_ethBudget, _maxShareRate); + + if (batches.length > 0) { + (uint256 eth, ) = wqContract.prefinalize(batches, _maxShareRate); + + vm.deal(address(rootAccount), eth); + vm.prank(rootAccount); + wqContract.finalize{value: eth}(batches[batches.length - 1], _maxShareRate); + } + } + + function _claimWithdrawals(address _owner, uint256[] memory _requestIds) private { + if (wqContract.getLastFinalizedRequestId() > 0) { + WithdrawalQueueBase.WithdrawalRequestStatus[] memory requestStatues = wqContract.getWithdrawalStatus( + _requestIds + ); + + for (uint256 i = 0; i < _requestIds.length; i++) { + if (!requestStatues[i].isClaimed) { + vm.deal(_owner, 1 ether); + vm.prank(_owner); + wqContract.claimWithdrawal(_requestIds[i]); + } + } + } + } +} + +contract ShareRateTest is BaseProtocolTest { + // Contract under test + ShareRateHandler public shareRateHandler; + + function setUp() public { + // Initialize protocol with starting balance and accounts + BaseProtocolTest.setUpProtocol(PROTOCOL_START_BALANCE, ROOT_ACCOUNT, USER_ACCOUNT); + address vaultHubAddress = lidoLocator.vaultHub(); + + // Configure protocol parameters + _configureProtocolSettings(); + + // Initialize the handler + shareRateHandler = new ShareRateHandler( + lidoContract, + wq, + vaultHubAddress, + USER_ACCOUNT, + ROOT_ACCOUNT, + MAX_AMOUNT_OF_SHARES + ); + + // Configure fuzzing targets + bytes4[] memory externalSharesSelectors = new bytes4[](5); + externalSharesSelectors[0] = shareRateHandler.mintExternalShares.selector; + externalSharesSelectors[1] = shareRateHandler.burnExternalShares.selector; + externalSharesSelectors[2] = shareRateHandler.submit.selector; + externalSharesSelectors[3] = shareRateHandler.transfer.selector; + externalSharesSelectors[4] = shareRateHandler.withdrawStEth.selector; + + targetContract(address(shareRateHandler)); + targetSelector(FuzzSelector({addr: address(shareRateHandler), selectors: externalSharesSelectors})); + + // Initialize with starting shares + _provisionProtocol(vaultHubAddress); + + // Advance blockchain state + vm.roll(block.number + ONE_DAY_IN_BLOCKS); + } + + function _configureProtocolSettings() private { + vm.startPrank(USER_ACCOUNT); + lidoContract.setMaxExternalRatioBP(MAX_EXTERNAL_RATIO_BP); + lidoContract.setStakingLimit(MAX_STAKE_LIMIT, STAKE_LIMIT_INCREASE_PER_BLOCK); + lidoContract.resume(); + vm.stopPrank(); + } + + function _provisionProtocol(address vaultHubAddress) private { + // Mint external shares to simulate existing shares for burn operations + vm.prank(vaultHubAddress); + lidoContract.mintExternalShares(vaultHubAddress, PROTOCOL_START_EXTERNAL_SHARES); + shareRateHandler.submit(0, 10 ether); + } + + /** + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true + */ + function invariant_totalShares() public view { + assertEq(lidoContract.getTotalShares(), shareRateHandler.getTotalShares()); + } +} diff --git a/test/0.8.25/contracts/SecondOpinionOracle__MockForAccountingFuzzing.sol b/test/0.8.25/contracts/SecondOpinionOracle__MockForAccountingFuzzing.sol new file mode 100644 index 0000000000..519d67e190 --- /dev/null +++ b/test/0.8.25/contracts/SecondOpinionOracle__MockForAccountingFuzzing.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.9; + +contract SecondOpinionOracle__MockForAccountingFuzzing { + bool private success; + uint256 private clBalanceGwei; + uint256 private withdrawalVaultBalanceWei; + uint256 private totalDepositedValidators; + uint256 private totalExitedValidators; + + function getReport(uint256) external view returns (bool, uint256, uint256, uint256, uint256) { + return (success, clBalanceGwei, withdrawalVaultBalanceWei, totalDepositedValidators, totalExitedValidators); + } + + function mock__setReportValues( + bool _success, + uint256 _clBalanceGwei, + uint256 _withdrawalVaultBalanceWei, + uint256 _totalDepositedValidators, + uint256 _totalExitedValidators + ) external { + success = _success; + clBalanceGwei = _clBalanceGwei; + withdrawalVaultBalanceWei = _withdrawalVaultBalanceWei; + totalDepositedValidators = _totalDepositedValidators; + totalExitedValidators = _totalExitedValidators; + } +} diff --git a/test/0.8.25/contracts/StakingRouter__MockForLidoAccountingFuzzing.sol b/test/0.8.25/contracts/StakingRouter__MockForLidoAccountingFuzzing.sol new file mode 100644 index 0000000000..41db0a96e0 --- /dev/null +++ b/test/0.8.25/contracts/StakingRouter__MockForLidoAccountingFuzzing.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.9; + +interface IStakingRouter { + struct StakingModule { + uint256 id; + address stakingModuleAddress; + uint96 stakingModuleFee; + uint96 treasuryFee; + uint256 stakeShareLimit; + uint256 status; + string name; + uint256 lastDepositAt; + uint256 lastDepositBlock; + uint256 exitedValidatorsCount; + uint256 priorityExitShareThreshold; + uint256 maxDepositsPerBlock; + uint256 minDepositBlockDistance; + } +} + +contract StakingRouter__MockForLidoAccountingFuzzing { + event Mock__MintedRewardsReported(); + event Mock__MintedTotalShares(uint256 indexed _totalShares); + + address[] private recipients__mocked; + uint96[] private stakingModuleFees__mocked; + uint96 private totalFee__mocked; + uint256 private precisionPoint__mocked; + + mapping(uint256 => IStakingRouter.StakingModule) private stakingModules; + uint256[] private stakingModulesIds; + + constructor() { + stakingModules[1] = IStakingRouter.StakingModule({ + id: 1, + stakingModuleAddress: 0x55032650b14df07b85bF18A3a3eC8E0Af2e028d5, + stakingModuleFee: 500, + treasuryFee: 500, + stakeShareLimit: 10000, + status: 0, + name: "curated-onchain-v1", + lastDepositAt: 1732694279, + lastDepositBlock: 21277744, + exitedValidatorsCount: 88207, + priorityExitShareThreshold: 10000, + maxDepositsPerBlock: 150, + minDepositBlockDistance: 25 + }); + + stakingModulesIds.push(1); + + stakingModules[2] = IStakingRouter.StakingModule({ + id: 2, + stakingModuleAddress: 0xaE7B191A31f627b4eB1d4DaC64eaB9976995b433, + stakingModuleFee: 800, + treasuryFee: 200, + stakeShareLimit: 400, + status: 0, + name: "SimpleDVT", + lastDepositAt: 1735217831, + lastDepositBlock: 21486781, + exitedValidatorsCount: 5, + priorityExitShareThreshold: 444, + maxDepositsPerBlock: 150, + minDepositBlockDistance: 25 + }); + stakingModulesIds.push(2); + + stakingModules[3] = IStakingRouter.StakingModule({ + id: 3, + stakingModuleAddress: 0xdA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F, + stakingModuleFee: 600, + treasuryFee: 400, + stakeShareLimit: 100, + status: 0, + name: "Community Staking", + lastDepositAt: 1735217387, + lastDepositBlock: 21486745, + exitedValidatorsCount: 104, + priorityExitShareThreshold: 125, + maxDepositsPerBlock: 30, + minDepositBlockDistance: 25 + }); + + stakingModulesIds.push(3); + } + + function getStakingRewardsDistribution() + public + view + returns ( + address[] memory recipients, + uint256[] memory stakingModuleIds, + uint96[] memory stakingModuleFees, + uint96 totalFee, + uint256 precisionPoints + ) + { + recipients = recipients__mocked; + stakingModuleFees = stakingModuleFees__mocked; + stakingModuleIds = stakingModulesIds; + totalFee = totalFee__mocked; + precisionPoints = precisionPoint__mocked; + } + + function reportRewardsMinted(uint256[] calldata, uint256[] calldata _totalShares) external { + emit Mock__MintedRewardsReported(); + + uint256 totalShares = 0; + for (uint256 i = 0; i < _totalShares.length; i++) { + totalShares += _totalShares[i]; + } + + emit Mock__MintedTotalShares(totalShares); + } + + function mock__getStakingRewardsDistribution( + address[] calldata _recipients, + uint96[] calldata _stakingModuleFees, + uint96 _totalFee, + uint256 _precisionPoints + ) external { + recipients__mocked = _recipients; + stakingModuleFees__mocked = _stakingModuleFees; + totalFee__mocked = _totalFee; + precisionPoint__mocked = _precisionPoints; + } + + function getStakingModuleIds() public view returns (uint256[] memory) { + return stakingModulesIds; + } + + function getRecipients() public view returns (address[] memory) { + return recipients__mocked; + } + + function getStakingModule( + uint256 _stakingModuleId + ) public view returns (IStakingRouter.StakingModule memory stakingModule) { + if (_stakingModuleId >= 4) { + revert("Staking module does not exist"); + } + + return stakingModules[_stakingModuleId]; + } +} diff --git a/test/0.8.25/vaults/staking-vault/RandomLib.sol b/test/0.8.25/vaults/staking-vault/RandomLib.sol new file mode 100644 index 0000000000..471cc08442 --- /dev/null +++ b/test/0.8.25/vaults/staking-vault/RandomLib.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts-v5.2/utils/math/Math.sol"; + +library RandomLib { + using Math for uint256; + + uint256 private constant Q96 = 2 ** 96; + uint256 private constant D18 = 1e18; + + struct Storage { + uint256 seed; + } + + function rand(Storage storage s) internal returns (uint256) { + s.seed = uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, s.seed))); + return s.seed; + } + + function randInt(Storage storage s, uint256 maxValue) internal returns (uint256) { + return rand(s) % (maxValue + 1); + } + + function randInt(Storage storage s, uint256 minValue, uint256 maxValue) internal returns (uint256) { + if (maxValue < minValue) { + revert("RandomLib: maxValue < minValue"); + } + return (rand(s) % (maxValue - minValue + 1)) + minValue; + } + + function randFloatX96(Storage storage s, uint256 minValue, uint256 maxValue) internal returns (uint256) { + return randInt(s, minValue * Q96, maxValue * Q96); + } + + function randBool(Storage storage s) internal returns (bool) { + return rand(s) & 1 == 1; + } + + function randAddress(Storage storage s) internal returns (address) { + return address(uint160(rand(s))); + } + + function randAmountD18(Storage storage s) internal returns (uint256 result) { + uint256 result_x96 = randFloatX96(s, D18, 10 * D18); + if (randBool(s)) { + uint256 b_x96 = randFloatX96(s, 1, 1e6); + result = result_x96.mulDiv(b_x96, Q96) / Q96; + } else { + uint256 b_x96 = randFloatX96(s, 1e1, 1e10); + result = result_x96.mulDiv(Q96, b_x96) / Q96; + } + } +}