diff --git a/package.json b/package.json index 3107e7af2..87cf88be4 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "typecheck": "tsc --noEmit", "prepare": "husky", "abis:extract": "hardhat abis:extract", - "verify:deployed": "hardhat verify:deployed" + "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\"" }, "lint-staged": { "./**/*.ts": [ diff --git a/test/0.4.24/contracts/SecondOpinionOracle__Mock.sol b/test/0.4.24/contracts/SecondOpinionOracle__Mock.sol new file mode 100644 index 000000000..6b9504d38 --- /dev/null +++ b/test/0.4.24/contracts/SecondOpinionOracle__Mock.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.9; + +contract SecondOpinionOracle__Mock { + bool private success; + uint256 private clBalanceGwei; + uint256 private withdrawalVaultBalanceWei; + uint256 private totalDepositedValidators; + uint256 private totalExitedValidators; + + function getReport(uint256 refSlot) 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.4.24/contracts/StakingRouter__MockForLidoAccounting.sol b/test/0.4.24/contracts/StakingRouter__MockForLidoAccounting.sol index 8cfcd10dc..fc1890f8b 100644 --- a/test/0.4.24/contracts/StakingRouter__MockForLidoAccounting.sol +++ b/test/0.4.24/contracts/StakingRouter__MockForLidoAccounting.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: UNLICENSED // for testing purposes only - -pragma solidity 0.8.9; +pragma solidity 0.4.24; contract StakingRouter__MockForLidoAccounting { event Mock__MintedRewardsReported(); @@ -30,14 +29,14 @@ contract StakingRouter__MockForLidoAccounting { precisionPoints = precisionPoint__mocked; } - function reportRewardsMinted(uint256[] calldata _stakingModuleIds, uint256[] calldata _totalShares) external { + function reportRewardsMinted(uint256[], uint256[]) external { emit Mock__MintedRewardsReported(); } function mock__getStakingRewardsDistribution( - address[] calldata _recipients, - uint256[] calldata _stakingModuleIds, - uint96[] calldata _stakingModuleFees, + address[] _recipients, + uint256[] _stakingModuleIds, + uint96[] _stakingModuleFees, uint96 _totalFee, uint256 _precisionPoints ) external { diff --git a/test/0.4.24/contracts/StakingRouter__MockForLidoAccountingFuzzing.sol b/test/0.4.24/contracts/StakingRouter__MockForLidoAccountingFuzzing.sol new file mode 100644 index 000000000..6708c5371 --- /dev/null +++ b/test/0.4.24/contracts/StakingRouter__MockForLidoAccountingFuzzing.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.9; + +import {StakingRouter} from "contracts/0.8.9/StakingRouter.sol"; + +contract StakingRouter__MockForLidoAccountingFuzzing { + event Mock__MintedRewardsReported(); + event Mock__MintedTotalShares(uint256 indexed _totalShares); + + address[] private recipients__mocked; + uint256[] private stakingModuleIds__mocked; + uint96[] private stakingModuleFees__mocked; + uint96 private totalFee__mocked; + uint256 private precisionPoint__mocked; + + function getStakingRewardsDistribution() + public + view + returns ( + address[] memory recipients, + uint256[] memory stakingModuleIds, + uint96[] memory stakingModuleFees, + uint96 totalFee, + uint256 precisionPoints + ) + { + recipients = recipients__mocked; + stakingModuleIds = stakingModuleIds__mocked; + stakingModuleFees = stakingModuleFees__mocked; + totalFee = totalFee__mocked; + precisionPoints = precisionPoint__mocked; + } + + function reportRewardsMinted(uint256[] calldata _stakingModuleIds, 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, + uint256[] calldata _stakingModuleIds, + uint96[] calldata _stakingModuleFees, + uint96 _totalFee, + uint256 _precisionPoints + ) external { + recipients__mocked = _recipients; + stakingModuleIds__mocked = _stakingModuleIds; + stakingModuleFees__mocked = _stakingModuleFees; + totalFee__mocked = _totalFee; + precisionPoint__mocked = _precisionPoints; + } + + function getStakingModuleIds() public view returns (uint256[] memory) { + return stakingModuleIds__mocked; + } + + function getRecipients() public view returns (address[] memory) { + return recipients__mocked; + } + + function getStakingModule(uint256 _stakingModuleId) public view returns (StakingRouter.StakingModule memory) { + if (_stakingModuleId >= 4) { + revert("Staking module does not exist"); + } + + if (_stakingModuleId == 1) { + return + StakingRouter.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 + }); + } + + if (_stakingModuleId == 2) { + return + StakingRouter.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 + }); + } + + if (_stakingModuleId == 3) { + return + StakingRouter.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 + }); + } + } +} diff --git a/test/0.8.25/Accounting.t.sol b/test/0.8.25/Accounting.t.sol new file mode 100644 index 000000000..e0f7e9fda --- /dev/null +++ b/test/0.8.25/Accounting.t.sol @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only +pragma solidity ^0.8.0; + +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"; + +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 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; + } + + IAccounting private accounting; + ILido private lido; + ISecondOpinionOracleMock private secondOpinionOracle; + IStakingRouter public stakingRouter; + + Ghost public ghost; + LidoTransfer[] public ghost_lidoTransfers; + + 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); + } + + function cutGwei(uint256 value) public 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 + ); + + fuzz._preClValidators = bound(fuzz._preClValidators, 250_000, 100_000_000_000); + fuzz._preClBalanceWei = cutGwei(fuzz._preClValidators * stableBalanceWei); + + ghost.clValidators = int256(fuzz._preClValidators); + + fuzz._clValidators = bound( + fuzz._clValidators, + fuzz._preClValidators, + fuzz._preClValidators + limitList.appearedValidatorsPerDayLimit + ); + + uint256 minBalancePerValidatorWei = fuzz._clValidators * (stableBalanceWei - maxLossPerOperatorWei); + uint256 maxBalancePerValidatorWei = fuzz._clValidators * (stableBalanceWei + maxYieldPerOperatorWei); + fuzz._clBalanceWei = bound(fuzz._clBalanceWei, minBalancePerValidatorWei, maxBalancePerValidatorWei); + + // depositedValidators is always greater or equal to beaconValidators + // Todo: Upper extremum ? + uint256 depositedValidators = bound( + fuzz._preClValidators, + fuzz._clValidators + 1, + fuzz._clValidators + limitList.appearedValidatorsPerDayLimit + ); + 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), + vaultValues: new uint256[](0), + netCashFlows: new int256[](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; + } +} + +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})); + } + + /** + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 128 + * forge-config: default.invariant.depth = 128 + * forge-config: default.invariant.fail-on-revert = true + */ + function invariant_clValidatorNotDecreased() public view { + ILido lido = ILido(lidoLocator.lido()); + (uint256 depositedValidators, uint256 clValidators, uint256 clBalance) = lido.getBeaconStat(); + + // Should not be able to decrease validator number + assertGe(clValidators, uint256(accountingHandler.getGhost().clValidators)); + assertEq(depositedValidators, uint256(accountingHandler.getGhost().depositedValidators)); + } + + /** + * 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 = 128 + * forge-config: default.invariant.depth = 128 + * forge-config: default.invariant.fail-on-revert = true + */ + function invariant_NonNegativeRebase() public view { + ILido lido = ILido(lidoLocator.lido()); + + if (accountingHandler.getGhost().unifiedClBalanceWei > accountingHandler.getGhost().principalClBalanceWei) { + if (accountingHandler.getGhost().sharesMintAsFees < 0) { + revert("sharesMintAsFees < 0"); + } + + if (accountingHandler.getGhost().transferShares < 0) { + revert("transferShares < 0"); + } + + int256 treasuryFeesETH = int256( + lido.getPooledEthByShares(uint256(accountingHandler.getGhost().sharesMintAsFees)) + ); + int256 reportRewardsMintedETH = int256( + lido.getPooledEthByShares(uint256(accountingHandler.getGhost().transferShares)) + ); + int256 totalFees = int256(treasuryFeesETH + reportRewardsMintedETH); + int256 totalRewards = accountingHandler.getGhost().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", accountingHandler.getGhost().totalRewardsWei / 1 ether); + } + } + + /** + * 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 = 128 + * forge-config: default.invariant.depth = 128 + * 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" + ); + } + } + + /** + * 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 = 128 + * forge-config: default.invariant.depth = 128 + * forge-config: default.invariant.fail-on-revert = true + */ + function invariant_vaultsDonAffectSharesRate() public view { + ILido lido = ILido(lidoLocator.lido()); + + uint256 totalShares = lido.getTotalShares(); + uint256 totalEth = lido.getBufferedEther(); + uint256 totalShareRate = totalEth / totalShares; + + console2.log("totalShares", totalShares); + console2.log("totalEth", totalEth); + console2.log("totalShareRate", totalShareRate); + + (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); + + uint256 internalEther = totalEth + clBalance + transientEther; + console2.log("internalEther", internalEther); + uint256 internalShares = totalShares - lido.getExternalShares(); + console2.log("internalShares", internalShares); + console2.log("getExternalShares", lido.getExternalShares()); + + uint256 internalShareRate = internalEther / internalShares; + + console2.log("internalShareRate", internalShareRate); + + assertEq(totalShareRate, internalShareRate); + } +} 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 000000000..2c32f11d7 --- /dev/null +++ b/test/0.8.25/Protocol__Deployment.t.sol @@ -0,0 +1,301 @@ +// 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 "../0.4.24/contracts/StakingRouter__MockForLidoAccountingFuzzing.sol"; +import "../0.4.24/contracts/SecondOpinionOracle__Mock.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {LimitsList} from "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol"; + +interface IAccounting { + function initialize(address _admin) external; +} + +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; +} + +interface IKernel { + function acl() external view returns (IACL); + + function newAppInstance( + bytes32 _appId, + address _appBase, + bytes calldata _initializePayload, + bool _setDefault + ) external; +} + +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); +} + +interface IDaoFactory { + function newDAO(address _root) external returns (IKernel); +} + +struct LidoLocatorConfig { + address accountingOracle; + address depositSecurityModule; + address elRewardsVault; + address legacyOracle; + address lido; + address oracleReportSanityChecker; + address postTokenRebaseReceiver; + address burner; + address stakingRouter; + address treasury; + address validatorsExitBusOracle; + address withdrawalQueue; + address withdrawalVault; + address oracleDaemonConfig; + address accounting; + address wstETH; +} + +contract BaseProtocolTest is Test { + ILido public lidoContract; + ILidoLocator public lidoLocator; + IACL public acl; + SecondOpinionOracle__Mock public secondOpinionOracleMock; + IKernel private dao; + + address private rootAccount; + address private userAccount; + + address public kernelBase; + address public aclBase; + address public evmScriptRegistryFactory; + address public daoFactoryAdr; + + uint256 public genesisTimestamp = 1_695_902_400; + address private depositContract = address(0x4242424242424242424242424242424242424242); + address public lidoTreasury = makeAddr("dummy-lido:treasury"); + + 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 + }); + + function setUpProtocol(uint256 _startBalance, address _rootAccount, address _userAccount) public { + rootAccount = _rootAccount; + userAccount = _userAccount; + + address impl = deployCode("Lido.sol:Lido"); + + vm.startPrank(rootAccount); + (dao, acl) = createAragonDao(); + address lidoProxyAddress = addAragonApp(dao, impl); + + lidoContract = ILido(lidoProxyAddress); + + /// @dev deal lido contract with start balance + vm.deal(lidoProxyAddress, _startBalance); + + 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); + + StakingRouter__MockForLidoAccountingFuzzing stakingRouter = new StakingRouter__MockForLidoAccountingFuzzing(); + + uint256[] memory stakingModuleIds = new uint256[](3); + stakingModuleIds[0] = 1; + stakingModuleIds[1] = 2; + stakingModuleIds[2] = 3; + + 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, + stakingModuleIds, + stakingModuleFees, + 9999999999999999996, + 100000000000000000000 + ); + + /// @dev deploy lido locator with dummy default values + lidoLocator = _deployLidoLocator(lidoProxyAddress, address(stakingRouter)); + + // Add accounting contract with handler to the protocol + address accountingImpl = deployCode( + "Accounting.sol:Accounting", + abi.encode([address(lidoLocator), lidoProxyAddress]) + ); + + deployCodeTo( + "OssifiableProxy.sol:OssifiableProxy", + abi.encode(accountingImpl, rootAccount, new bytes(0)), + lidoLocator.accounting() + ); + + deployCodeTo( + "AccountingOracle.sol:AccountingOracle", + abi.encode( + address(lidoLocator), + lidoLocator.legacyOracle(), + 12, // secondsPerSlot + genesisTimestamp + ), + lidoLocator.accountingOracle() + ); + + // Add burner contract to the protocol + deployCodeTo( + "Burner.sol:Burner", + abi.encode(rootAccount, address(lidoLocator), lidoProxyAddress, 0, 0), + lidoLocator.burner() + ); + + // Add burner contract to the protocol + deployCodeTo( + "LidoExecutionLayerRewardsVault.sol:LidoExecutionLayerRewardsVault", + abi.encode(lidoProxyAddress, lidoTreasury), + lidoLocator.elRewardsVault() + ); + + // Add oracle report sanity checker contract to the protocol + deployCodeTo( + "OracleReportSanityChecker.sol:OracleReportSanityChecker", + abi.encode( + address(lidoLocator), + 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() + ); + + secondOpinionOracleMock = new SecondOpinionOracle__Mock(); + vm.store( + lidoLocator.oracleReportSanityChecker(), + bytes32(uint256(2)), + bytes32(uint256(uint160(address(secondOpinionOracleMock)))) + ); + + IAccounting(lidoLocator.accounting()).initialize(rootAccount); + + /// @dev deploy eip712steth + address eip712steth = deployCode("EIP712StETH.sol:EIP712StETH", abi.encode(lidoProxyAddress)); + + lidoContract.initialize(address(lidoLocator), address(eip712steth)); + + vm.stopPrank(); + } + + /// @dev create aragon dao and return kernel and acl + function createAragonDao() private returns (IKernel, IACL) { + 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); + + 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())); + + _acl.createPermission(rootAccount, daoAddress, keccak256("APP_MANAGER_ROLE"), rootAccount); + + return (_dao, _acl); + } + + /// @dev add aragon app to dao and return proxy address + function addAragonApp(IKernel _dao, address _impl) private 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; + } + + /// @dev deploy lido locator with dummy default values + function _deployLidoLocator(address lido, address stakingRouterAddress) internal returns (ILidoLocator) { + LidoLocatorConfig memory config = LidoLocatorConfig({ + accountingOracle: makeAddr("dummy-locator:accountingOracle"), + depositSecurityModule: makeAddr("dummy-locator:depositSecurityModule"), + elRewardsVault: makeAddr("dummy-locator:elRewardsVault"), + legacyOracle: makeAddr("dummy-locator:legacyOracle"), + 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: makeAddr("dummy-locator:withdrawalQueue"), + withdrawalVault: makeAddr("dummy-locator:withdrawalVault"), + oracleDaemonConfig: makeAddr("dummy-locator:oracleDaemonConfig"), + accounting: makeAddr("dummy-locator:accounting"), + wstETH: makeAddr("dummy-locator:wstETH") + }); + + return ILidoLocator(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 000000000..45af94b67 --- /dev/null +++ b/test/0.8.25/ShareRate.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only +pragma solidity ^0.8.0; + +import "contracts/0.8.9/EIP712StETH.sol"; + +import {CommonBase} from "forge-std/Base.sol"; +import {LidoLocator} from "contracts/0.8.9/LidoLocator.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"; + +import {BaseProtocolTest, ILido} from "./Protocol__Deployment.t.sol"; + +contract ShareRateHandler is CommonBase, StdCheats, StdUtils { + ILido public lidoContract; + address public accounting; + address public userAccount; + + uint256 public maxAmountOfShares; + + constructor(ILido _lido, address _accounting, address _userAccount, uint256 _maxAmountOfShares) { + lidoContract = _lido; + accounting = _accounting; + userAccount = _userAccount; + maxAmountOfShares = _maxAmountOfShares; + } + + function mintExternalShares(address _recipient, uint256 _amountOfShares) external { + // we don't want to test the zero address case, as it would revert + vm.assume(_recipient != address(0)); + + _amountOfShares = bound(_amountOfShares, 1, maxAmountOfShares); + // TODO: We need to make this condition work + // _amountOfShares = bound(_amountOfShares, 1, _amountOfShares); + + vm.prank(userAccount); + lidoContract.resumeStaking(); + + vm.prank(accounting); + lidoContract.mintExternalShares(_recipient, _amountOfShares); + } + + function burnExternalShares(uint256 _amountOfShares) external { + uint256 totalShares = lidoContract.getExternalShares(); + if (totalShares != 0) { + _amountOfShares = bound(_amountOfShares, 2, maxAmountOfShares); + } else { + _amountOfShares = 1; + } + + vm.prank(userAccount); + lidoContract.resumeStaking(); + + vm.prank(accounting); + lidoContract.burnExternalShares(_amountOfShares); + } + + function getTotalShares() external view returns (uint256) { + return lidoContract.getTotalShares(); + } +} + +contract ShareRateTest is BaseProtocolTest { + ShareRateHandler public shareRateHandler; + + uint256 private _maxExternalRatioBP = 10_000; + uint256 private _maxStakeLimit = 15_000 ether; + uint256 private _stakeLimitIncreasePerBlock = 20 ether; + uint256 private _maxAmountOfShares = 100; + + uint256 private protocolStartBalance = 15_000 ether; + uint256 private protocolStartExternalShares = 10_000; + + address private rootAccount = address(0x123); + address private userAccount = address(0x321); + + function setUp() public { + BaseProtocolTest.setUpProtocol(protocolStartBalance, rootAccount, userAccount); + + address accountingContract = lidoLocator.accounting(); + + vm.startPrank(userAccount); + lidoContract.setMaxExternalRatioBP(_maxExternalRatioBP); + lidoContract.setStakingLimit(_maxStakeLimit, _stakeLimitIncreasePerBlock); + lidoContract.resume(); + vm.stopPrank(); + + shareRateHandler = new ShareRateHandler(lidoContract, accountingContract, userAccount, _maxAmountOfShares); + targetContract(address(shareRateHandler)); + + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = shareRateHandler.mintExternalShares.selector; + selectors[1] = shareRateHandler.burnExternalShares.selector; + // TODO: transfers + // TODO: submit + // TODO: withdrawals request + // TODO: claim + + targetSelector(FuzzSelector({addr: address(shareRateHandler), selectors: selectors})); + + // @dev mint 10000 external shares to simulate some shares already minted, so + // burnExternalShares will be able to actually burn some shares + vm.prank(accountingContract); + lidoContract.mintExternalShares(accountingContract, protocolStartExternalShares); + } + + /** + * 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 + * + * TODO: Maybe add an invariant that lido.getExternalShares = startExternalBalance + mintedExternal - burnedExternal? + * So we'll know it something is odd inside a math for external shares? + */ + function invariant_totalShares() public view { + assertEq(lidoContract.getTotalShares(), shareRateHandler.getTotalShares()); + } +}