From 6622db3e5c10785182cbff0964719b8e6c7948e2 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 31 Oct 2023 11:49:34 +0400 Subject: [PATCH] feat: add solhint to makefile feat: block funds by mev committee feat: tests feat: more tests fix: required fix: remarks Update blocked bond retention and management periods Check onRewardsMinted call from the staking router forge install: openzeppelin-contracts-v4.4 v4.4.1 build: import oracle contracts from lido-dao feat: CSFeeOracle based on HashConsensus refactor: deploy script docs: udate READMEs lint: add compiler-version solhint rule fix: some remarks refactor: integration tests addresses [CS-93] locking bond funds by mev committee (#35) * feat: add solhint to makefile * feat: block funds by mev committee * feat: tests * feat: more tests * fix: required * fix: remarks * Update blocked bond retention and management periods * fix: some remarks --- .env.sample | 5 - .github/workflows/test.yml | 2 - .gitmodules | 3 + .solhint.json | 1 + Makefile | 16 +- README.md | 10 +- lib/base-oracle/README.md | 3 + lib/base-oracle/lib/Math.sol | 31 + lib/base-oracle/lib/UnstructuredStorage.sol | 43 + lib/base-oracle/oracle/BaseOracle.sol | 412 +++++++ lib/base-oracle/oracle/HashConsensus.sol | 1096 +++++++++++++++++ lib/base-oracle/utils/PausableUntil.sol | 103 ++ lib/base-oracle/utils/Versioned.sol | 61 + .../utils/access/AccessControl.sol | 233 ++++ .../utils/access/AccessControlEnumerable.sol | 77 ++ lib/openzeppelin-contracts-v4.4 | 1 + lib/proxy/OssifiableProxy.sol | 94 ++ remappings.txt | 3 +- script/Deploy.s.sol | 72 -- script/DeployBase.s.sol | 153 +++ script/DeployGoerli.s.sol | 27 + script/DeployHolesky.s.sol | 27 + script/DeployMainnetish.s.sol | 27 + src/CSAccounting.sol | 338 ++++- src/CSFeeDistributor.sol | 58 +- src/CSFeeDistributorBase.sol | 17 - src/CSFeeOracle.sol | 354 ++---- src/CSFeeOracleBase.sol | 44 - src/CSModule.sol | 4 +- src/interfaces/ICSFeeDistributor.sol | 2 + src/interfaces/ICSFeeOracle.sol | 4 +- test/CSAccounting.blockedBond.t.sol | 735 +++++++++++ test/CSAccounting.t.sol | 103 +- test/CSFeeDistributor.t.sol | 43 +- test/CSFeeOracle.t.sol | 825 ++++--------- test/CSMAddValidator.t.sol | 3 +- test/CSMInit.t.sol | 3 +- test/helpers/Fixtures.sol | 27 +- test/helpers/MerkleTree.sol | 2 +- test/helpers/Utilities.sol | 6 + test/helpers/mocks/LidoLocatorMock.sol | 8 +- test/helpers/mocks/OracleMock.sol | 11 +- test/helpers/mocks/Stub.sol | 4 +- test/integration/DepositInTokens.t.sol | 42 +- test/integration/StakingRouter.t.sol | 69 +- 45 files changed, 4041 insertions(+), 1161 deletions(-) create mode 100644 lib/base-oracle/README.md create mode 100644 lib/base-oracle/lib/Math.sol create mode 100644 lib/base-oracle/lib/UnstructuredStorage.sol create mode 100644 lib/base-oracle/oracle/BaseOracle.sol create mode 100644 lib/base-oracle/oracle/HashConsensus.sol create mode 100644 lib/base-oracle/utils/PausableUntil.sol create mode 100644 lib/base-oracle/utils/Versioned.sol create mode 100644 lib/base-oracle/utils/access/AccessControl.sol create mode 100644 lib/base-oracle/utils/access/AccessControlEnumerable.sol create mode 160000 lib/openzeppelin-contracts-v4.4 create mode 100644 lib/proxy/OssifiableProxy.sol delete mode 100644 script/Deploy.s.sol create mode 100644 script/DeployBase.s.sol create mode 100644 script/DeployGoerli.s.sol create mode 100644 script/DeployHolesky.s.sol create mode 100644 script/DeployMainnetish.s.sol delete mode 100644 src/CSFeeDistributorBase.sol delete mode 100644 src/CSFeeOracleBase.sol create mode 100644 test/CSAccounting.blockedBond.t.sol diff --git a/.env.sample b/.env.sample index 24881d7f..fca62a26 100644 --- a/.env.sample +++ b/.env.sample @@ -1,9 +1,4 @@ RPC_URL= -LIDO_LOCATOR_ADDRESS= -WSTETH_ADDRESS= # For deployment DEPLOYER_PRIVATE_KEY= -INITIALIZATION_EPOCH= -## mainnet -CL_GENESIS_TIME=1606824023 KEEP_ANVIL_AFTER_LOCAL_DEPLOY=false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8b2fe44..22f3f45d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,5 +60,3 @@ jobs: forge test -vvv env: RPC_URL: ${{ secrets.RPC_URL }} - LIDO_LOCATOR_ADDRESS: "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" - WSTETH_ADDRESS: "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0" diff --git a/.gitmodules b/.gitmodules index 690924b6..144bd3be 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-v4.4"] + path = lib/openzeppelin-contracts-v4.4 + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/.solhint.json b/.solhint.json index 191e6290..6d95ed2c 100644 --- a/.solhint.json +++ b/.solhint.json @@ -2,6 +2,7 @@ "extends": "solhint:recommended", "plugins": ["lido-csm"], "rules": { + "compiler-version": ["error", "0.8.21"], "no-inline-assembly": "off", "no-unused-import": "error", "func-named-parameters": "error", diff --git a/Makefile b/Makefile index 777823ec..2700d9fb 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,21 @@ include .env -.PHONY: artifacts clean test deploy-prod deploy-local anvil-fork anvil-kill +.PHONY: build clean test deploy-prod deploy-local anvil-fork anvil-kill -DEPLOY_SCRIPT_PATH := script/Deploy.s.sol:Deploy +CHAIN ?= mainnet -artifacts: - forge compile --force +DEPLOY_SCRIPT_PATH_mainnet := script/DeployMainnetish.s.sol:DeployMainnetish +DEPLOY_SCRIPT_PATH_holesky := script/DeployHolesky.s.sol:DeployHolesky +DEPLOY_SCRIPT_PATH_goerli := script/DeployGoerli.s.sol:DeployGoerli +DEPLOY_SCRIPT_PATH := ${DEPLOY_SCRIPT_PATH_${CHAIN}} + +build: + forge build --force clean: forge clean rm -rf cache broadcast out +lint-solhint: + yarn lint:solhint lint-check: yarn lint:check lint-fix: @@ -48,6 +55,7 @@ endif # aliases a: artifacts +ls: lint-solhint lc: lint-check lf: lint-fix t: test diff --git a/README.md b/README.md index 7bf04414..602d92db 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,11 @@ forge install ``` - Config environment variables + ```bash -cp .env.example .env +cp .env.sample .env ``` + Fill vars in the `.env` file with your own values ### Features @@ -38,6 +40,12 @@ forge install rari-capital/solmate make deploy-local ``` +- Deploy to local fork of non-mainnet chain + +```bash +CHAIN=holesky make deploy-local +``` + ### Notes Whenever you install new libraries using Foundry, make sure to update your diff --git a/lib/base-oracle/README.md b/lib/base-oracle/README.md new file mode 100644 index 00000000..67f3ee87 --- /dev/null +++ b/lib/base-oracle/README.md @@ -0,0 +1,3 @@ +This is an extraction from [lidofinance/lido-dao@v2.0.0](https://github.com/lidofinance/lido-dao/releases/tag/v2.0.0) codebase. +Patched to use ^0.8.9 solidity compiler version. +@openzeppelin/contracts-v4.4 mapped to v4.4.1. diff --git a/lib/base-oracle/lib/Math.sol b/lib/base-oracle/lib/Math.sol new file mode 100644 index 00000000..de1278a5 --- /dev/null +++ b/lib/base-oracle/lib/Math.sol @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: MIT + +// See contracts/COMPILERS.md +pragma solidity ^0.8.9; + +library Math { + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } + + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + /// @notice Tests if x ∈ [a, b) (mod n) + /// + function pointInHalfOpenIntervalModN(uint256 x, uint256 a, uint256 b, uint256 n) + internal pure returns (bool) + { + return (x + n - a) % n < (b - a) % n; + } + + /// @notice Tests if x ∈ [a, b] (mod n) + /// + function pointInClosedIntervalModN(uint256 x, uint256 a, uint256 b, uint256 n) + internal pure returns (bool) + { + return (x + n - a) % n <= (b - a) % n; + } +} diff --git a/lib/base-oracle/lib/UnstructuredStorage.sol b/lib/base-oracle/lib/UnstructuredStorage.sol new file mode 100644 index 00000000..b865840f --- /dev/null +++ b/lib/base-oracle/lib/UnstructuredStorage.sol @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: MIT + */ + +pragma solidity ^0.8.9; + + +/** + * @notice Aragon Unstructured Storage library + */ +library UnstructuredStorage { + function getStorageBool(bytes32 position) internal view returns (bool data) { + assembly { data := sload(position) } + } + + function getStorageAddress(bytes32 position) internal view returns (address data) { + assembly { data := sload(position) } + } + + function getStorageBytes32(bytes32 position) internal view returns (bytes32 data) { + assembly { data := sload(position) } + } + + function getStorageUint256(bytes32 position) internal view returns (uint256 data) { + assembly { data := sload(position) } + } + + function setStorageBool(bytes32 position, bool data) internal { + assembly { sstore(position, data) } + } + + function setStorageAddress(bytes32 position, address data) internal { + assembly { sstore(position, data) } + } + + function setStorageBytes32(bytes32 position, bytes32 data) internal { + assembly { sstore(position, data) } + } + + function setStorageUint256(bytes32 position, uint256 data) internal { + assembly { sstore(position, data) } + } +} diff --git a/lib/base-oracle/oracle/BaseOracle.sol b/lib/base-oracle/oracle/BaseOracle.sol new file mode 100644 index 00000000..e59c682c --- /dev/null +++ b/lib/base-oracle/oracle/BaseOracle.sol @@ -0,0 +1,412 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.9; + +import { SafeCast } from "@openzeppelin/contracts-v4.4/utils/math/SafeCast.sol"; + +import { UnstructuredStorage } from "../lib/UnstructuredStorage.sol"; +import { Versioned } from "../utils/Versioned.sol"; +import { AccessControlEnumerable } from "../utils/access/AccessControlEnumerable.sol"; + +import { IReportAsyncProcessor } from "./HashConsensus.sol"; + + +interface IConsensusContract { + function getIsMember(address addr) external view returns (bool); + + function getCurrentFrame() external view returns ( + uint256 refSlot, + uint256 reportProcessingDeadlineSlot + ); + + function getChainConfig() external view returns ( + uint256 slotsPerEpoch, + uint256 secondsPerSlot, + uint256 genesisTime + ); + + function getFrameConfig() external view returns (uint256 initialEpoch, uint256 epochsPerFrame); + + function getInitialRefSlot() external view returns (uint256); +} + + +abstract contract BaseOracle is IReportAsyncProcessor, AccessControlEnumerable, Versioned { + using UnstructuredStorage for bytes32; + using SafeCast for uint256; + + error AddressCannotBeZero(); + error AddressCannotBeSame(); + error VersionCannotBeSame(); + error UnexpectedChainConfig(); + error SenderIsNotTheConsensusContract(); + error InitialRefSlotCannotBeLessThanProcessingOne(uint256 initialRefSlot, uint256 processingRefSlot); + error RefSlotMustBeGreaterThanProcessingOne(uint256 refSlot, uint256 processingRefSlot); + error RefSlotCannotDecrease(uint256 refSlot, uint256 prevRefSlot); + error NoConsensusReportToProcess(); + error ProcessingDeadlineMissed(uint256 deadline); + error RefSlotAlreadyProcessing(); + error UnexpectedRefSlot(uint256 consensusRefSlot, uint256 dataRefSlot); + error UnexpectedConsensusVersion(uint256 expectedVersion, uint256 receivedVersion); + error HashCannotBeZero(); + error UnexpectedDataHash(bytes32 consensusHash, bytes32 receivedHash); + error SecondsPerSlotCannotBeZero(); + + event ConsensusHashContractSet(address indexed addr, address indexed prevAddr); + event ConsensusVersionSet(uint256 indexed version, uint256 indexed prevVersion); + event ReportSubmitted(uint256 indexed refSlot, bytes32 hash, uint256 processingDeadlineTime); + event ReportDiscarded(uint256 indexed refSlot, bytes32 hash); + event ProcessingStarted(uint256 indexed refSlot, bytes32 hash); + event WarnProcessingMissed(uint256 indexed refSlot); + + struct ConsensusReport { + bytes32 hash; + uint64 refSlot; + uint64 processingDeadlineTime; + } + + /// @notice An ACL role granting the permission to set the consensus + /// contract address by calling setConsensusContract. + bytes32 public constant MANAGE_CONSENSUS_CONTRACT_ROLE = + keccak256("MANAGE_CONSENSUS_CONTRACT_ROLE"); + + /// @notice An ACL role granting the permission to set the consensus + /// version by calling setConsensusVersion. + bytes32 public constant MANAGE_CONSENSUS_VERSION_ROLE = + keccak256("MANAGE_CONSENSUS_VERSION_ROLE"); + + + /// @dev Storage slot: address consensusContract + bytes32 internal constant CONSENSUS_CONTRACT_POSITION = + keccak256("lido.BaseOracle.consensusContract"); + + /// @dev Storage slot: uint256 consensusVersion + bytes32 internal constant CONSENSUS_VERSION_POSITION = + keccak256("lido.BaseOracle.consensusVersion"); + + /// @dev Storage slot: uint256 lastProcessingRefSlot + bytes32 internal constant LAST_PROCESSING_REF_SLOT_POSITION = + keccak256("lido.BaseOracle.lastProcessingRefSlot"); + + /// @dev Storage slot: ConsensusReport consensusReport + bytes32 internal constant CONSENSUS_REPORT_POSITION = + keccak256("lido.BaseOracle.consensusReport"); + + + uint256 public immutable SECONDS_PER_SLOT; + uint256 public immutable GENESIS_TIME; + + /// + /// Initialization & admin functions + /// + + constructor(uint256 secondsPerSlot, uint256 genesisTime) { + if (secondsPerSlot == 0) revert SecondsPerSlotCannotBeZero(); + SECONDS_PER_SLOT = secondsPerSlot; + GENESIS_TIME = genesisTime; + } + + /// @notice Returns the address of the HashConsensus contract. + /// + function getConsensusContract() external view returns (address) { + return CONSENSUS_CONTRACT_POSITION.getStorageAddress(); + } + + /// @notice Sets the address of the HashConsensus contract. + /// + function setConsensusContract(address addr) external onlyRole(MANAGE_CONSENSUS_CONTRACT_ROLE) { + _setConsensusContract(addr, LAST_PROCESSING_REF_SLOT_POSITION.getStorageUint256()); + } + + /// @notice Returns the current consensus version expected by the oracle contract. + /// + /// Consensus version must change every time consensus rules change, meaning that + /// an oracle looking at the same reference slot would calculate a different hash. + /// + function getConsensusVersion() external view returns (uint256) { + return CONSENSUS_VERSION_POSITION.getStorageUint256(); + } + + /// @notice Sets the consensus version expected by the oracle contract. + /// + function setConsensusVersion(uint256 version) external onlyRole(MANAGE_CONSENSUS_VERSION_ROLE) { + _setConsensusVersion(version); + } + + /// + /// Data provider interface + /// + + /// @notice Returns the last consensus report hash and metadata. + /// + function getConsensusReport() external view returns ( + bytes32 hash, + uint256 refSlot, + uint256 processingDeadlineTime, + bool processingStarted + ) { + ConsensusReport memory report = _storageConsensusReport().value; + uint256 processingRefSlot = LAST_PROCESSING_REF_SLOT_POSITION.getStorageUint256(); + return ( + report.hash, + report.refSlot, + report.processingDeadlineTime, + report.hash != bytes32(0) && report.refSlot == processingRefSlot + ); + } + + /// + /// Consensus contract interface + /// + + /// @notice Called by HashConsensus contract to push a consensus report for processing. + /// + /// Note that submitting the report doesn't require the processor to start processing it right + /// away, this can happen later (see `getLastProcessingRefSlot`). Until processing is started, + /// HashConsensus is free to reach consensus on another report for the same reporting frame an + /// submit it using this same function, or to lose the consensus on the submitted report, + /// notifying the processor via `discardConsensusReport`. + /// + function submitConsensusReport(bytes32 reportHash, uint256 refSlot, uint256 deadline) external { + _checkSenderIsConsensusContract(); + + uint256 prevSubmittedRefSlot = _storageConsensusReport().value.refSlot; + if (refSlot < prevSubmittedRefSlot) { + revert RefSlotCannotDecrease(refSlot, prevSubmittedRefSlot); + } + + uint256 prevProcessingRefSlot = LAST_PROCESSING_REF_SLOT_POSITION.getStorageUint256(); + if (refSlot <= prevProcessingRefSlot) { + revert RefSlotMustBeGreaterThanProcessingOne(refSlot, prevProcessingRefSlot); + } + + if (_getTime() > deadline) { + revert ProcessingDeadlineMissed(deadline); + } + + if (refSlot != prevSubmittedRefSlot && prevProcessingRefSlot != prevSubmittedRefSlot) { + emit WarnProcessingMissed(prevSubmittedRefSlot); + } + + if (reportHash == bytes32(0)) { + revert HashCannotBeZero(); + } + + emit ReportSubmitted(refSlot, reportHash, deadline); + + ConsensusReport memory report = ConsensusReport({ + hash: reportHash, + refSlot: refSlot.toUint64(), + processingDeadlineTime: deadline.toUint64() + }); + + _storageConsensusReport().value = report; + _handleConsensusReport(report, prevSubmittedRefSlot, prevProcessingRefSlot); + } + + /// @notice Called by HashConsensus contract to notify that the report for the given ref. slot + /// is not a conensus report anymore and should be discarded. This can happen when a member + /// changes their report, is removed from the set, or when the quorum value gets increased. + /// + /// Only called when, for the given reference slot: + /// + /// 1. there previously was a consensus report; AND + /// 1. processing of the consensus report hasn't started yet; AND + /// 2. report processing deadline is not expired yet; AND + /// 3. there's no consensus report now (otherwise, `submitConsensusReport` is called instead). + /// + /// Can be called even when there's no submitted non-discarded consensus report for the current + /// reference slot, i.e. can be called multiple times in succession. + /// + function discardConsensusReport(uint256 refSlot) external { + _checkSenderIsConsensusContract(); + + ConsensusReport memory submittedReport = _storageConsensusReport().value; + if (refSlot < submittedReport.refSlot) { + revert RefSlotCannotDecrease(refSlot, submittedReport.refSlot); + } else if (refSlot > submittedReport.refSlot) { + return; + } + + uint256 lastProcessingRefSlot = LAST_PROCESSING_REF_SLOT_POSITION.getStorageUint256(); + if (refSlot <= lastProcessingRefSlot) { + revert RefSlotAlreadyProcessing(); + } + + _storageConsensusReport().value.hash = bytes32(0); + _handleConsensusReportDiscarded(submittedReport); + + emit ReportDiscarded(submittedReport.refSlot, submittedReport.hash); + } + + /// @notice Returns the last reference slot for which processing of the report was started. + /// + function getLastProcessingRefSlot() external view returns (uint256) { + return LAST_PROCESSING_REF_SLOT_POSITION.getStorageUint256(); + } + + /// + /// Descendant contract interface + /// + + /// @notice Initializes the contract storage. Must be called by a descendant + /// contract as part of its initialization. + /// + function _initialize( + address consensusContract, + uint256 consensusVersion, + uint256 lastProcessingRefSlot + ) internal virtual { + _initializeContractVersionTo(1); + _setConsensusContract(consensusContract, lastProcessingRefSlot); + _setConsensusVersion(consensusVersion); + LAST_PROCESSING_REF_SLOT_POSITION.setStorageUint256(lastProcessingRefSlot); + _storageConsensusReport().value.refSlot = lastProcessingRefSlot.toUint64(); + } + + /// @notice Returns whether the given address is a member of the oracle committee. + /// + function _isConsensusMember(address addr) internal view returns (bool) { + address consensus = CONSENSUS_CONTRACT_POSITION.getStorageAddress(); + return IConsensusContract(consensus).getIsMember(addr); + } + + /// @notice Called when the oracle gets a new consensus report from the HashConsensus contract. + /// + /// Keep in mind that, until you call `_startProcessing`, the oracle committee is free to + /// reach consensus on another report for the same reporting frame and re-submit it using + /// this function, or lose consensus on the report and ask to discard it by calling the + /// `_handleConsensusReportDiscarded` function. + /// + function _handleConsensusReport( + ConsensusReport memory report, + uint256 prevSubmittedRefSlot, + uint256 prevProcessingRefSlot + ) internal virtual; + + /// @notice Called when the HashConsensus contract loses consensus on a previously submitted + /// report that is not processing yet and asks to discard this report. Only called if there is + /// no new consensus report at the moment; otherwise, `_handleConsensusReport` is called instead. + /// + function _handleConsensusReportDiscarded(ConsensusReport memory report) internal virtual {} + + /// @notice May be called by a descendant contract to check if the received data matches + /// the currently submitted consensus report. Reverts otherwise. + /// + function _checkConsensusData(uint256 refSlot, uint256 consensusVersion, bytes32 hash) + internal view + { + ConsensusReport memory report = _storageConsensusReport().value; + if (refSlot != report.refSlot) { + revert UnexpectedRefSlot(report.refSlot, refSlot); + } + + uint256 expectedConsensusVersion = CONSENSUS_VERSION_POSITION.getStorageUint256(); + if (consensusVersion != expectedConsensusVersion) { + revert UnexpectedConsensusVersion(expectedConsensusVersion, consensusVersion); + } + + if (hash != report.hash) { + revert UnexpectedDataHash(report.hash, hash); + } + } + + /// @notice Called by a descendant contract to mark the current consensus report + /// as being processed. Returns the last ref. slot which processing was started + /// before the call. + /// + /// Before this function is called, the oracle committee is free to reach consensus + /// on another report for the same reporting frame. After this function is called, + /// the consensus report for the current frame is guaranteed to remain the same. + /// + function _startProcessing() internal returns (uint256) { + ConsensusReport memory report = _storageConsensusReport().value; + if (report.hash == bytes32(0)) { + revert NoConsensusReportToProcess(); + } + + _checkProcessingDeadline(report.processingDeadlineTime); + + uint256 prevProcessingRefSlot = LAST_PROCESSING_REF_SLOT_POSITION.getStorageUint256(); + if (prevProcessingRefSlot == report.refSlot) { + revert RefSlotAlreadyProcessing(); + } + + LAST_PROCESSING_REF_SLOT_POSITION.setStorageUint256(report.refSlot); + + emit ProcessingStarted(report.refSlot, report.hash); + return prevProcessingRefSlot; + } + + /// @notice Reverts if the processing deadline for the current consensus report is missed. + /// + function _checkProcessingDeadline() internal view { + _checkProcessingDeadline(_storageConsensusReport().value.processingDeadlineTime); + } + + function _checkProcessingDeadline(uint256 deadlineTime) internal view { + if (_getTime() > deadlineTime) revert ProcessingDeadlineMissed(deadlineTime); + } + + /// @notice Returns the reference slot for the current frame. + /// + function _getCurrentRefSlot() internal view returns (uint256) { + address consensusContract = CONSENSUS_CONTRACT_POSITION.getStorageAddress(); + (uint256 refSlot, ) = IConsensusContract(consensusContract).getCurrentFrame(); + return refSlot; + } + + /// + /// Implementation & helpers + /// + + function _setConsensusVersion(uint256 version) internal { + uint256 prevVersion = CONSENSUS_VERSION_POSITION.getStorageUint256(); + if (version == prevVersion) revert VersionCannotBeSame(); + CONSENSUS_VERSION_POSITION.setStorageUint256(version); + emit ConsensusVersionSet(version, prevVersion); + } + + function _setConsensusContract(address addr, uint256 lastProcessingRefSlot) internal { + if (addr == address(0)) revert AddressCannotBeZero(); + + address prevAddr = CONSENSUS_CONTRACT_POSITION.getStorageAddress(); + if (addr == prevAddr) revert AddressCannotBeSame(); + + (, uint256 secondsPerSlot, uint256 genesisTime) = IConsensusContract(addr).getChainConfig(); + if (secondsPerSlot != SECONDS_PER_SLOT || genesisTime != GENESIS_TIME) { + revert UnexpectedChainConfig(); + } + + uint256 initialRefSlot = IConsensusContract(addr).getInitialRefSlot(); + if (initialRefSlot < lastProcessingRefSlot) { + revert InitialRefSlotCannotBeLessThanProcessingOne(initialRefSlot, lastProcessingRefSlot); + } + + CONSENSUS_CONTRACT_POSITION.setStorageAddress(addr); + emit ConsensusHashContractSet(addr, prevAddr); + } + + function _checkSenderIsConsensusContract() internal view { + if (_msgSender() != CONSENSUS_CONTRACT_POSITION.getStorageAddress()) { + revert SenderIsNotTheConsensusContract(); + } + } + + function _getTime() internal virtual view returns (uint256) { + return block.timestamp; // solhint-disable-line not-rely-on-time + } + + /// + /// Storage helpers + /// + + struct StorageConsensusReport { + ConsensusReport value; + } + + function _storageConsensusReport() internal pure returns (StorageConsensusReport storage r) { + bytes32 position = CONSENSUS_REPORT_POSITION; + assembly { r.slot := position } + } +} diff --git a/lib/base-oracle/oracle/HashConsensus.sol b/lib/base-oracle/oracle/HashConsensus.sol new file mode 100644 index 00000000..48f11cc6 --- /dev/null +++ b/lib/base-oracle/oracle/HashConsensus.sol @@ -0,0 +1,1096 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.9; + +import { SafeCast } from "@openzeppelin/contracts-v4.4/utils/math/SafeCast.sol"; + +import { Math } from "../lib/Math.sol"; +import { AccessControlEnumerable } from "../utils/access/AccessControlEnumerable.sol"; + + +/// @notice A contract that gets consensus reports (i.e. hashes) pushed to and processes them +/// asynchronously. +/// +/// HashConsensus doesn't expect any specific behavior from a report processor, and guarantees +/// the following: +/// +/// 1. HashConsensus won't submit reports via `IReportAsyncProcessor.submitConsensusReport` or ask +/// to discard reports via `IReportAsyncProcessor.discardConsensusReport` for any slot up to (and +/// including) the slot returned from `IReportAsyncProcessor.getLastProcessingRefSlot`. +/// +/// 2. HashConsensus won't accept member reports (and thus won't include such reports in calculating +/// the consensus) that have `consensusVersion` argument of the `HashConsensus.submitReport` call +/// holding a diff. value than the one returned from `IReportAsyncProcessor.getConsensusVersion()` +/// at the moment of the `HashConsensus.submitReport` call. +/// +interface IReportAsyncProcessor { + /// @notice Submits a consensus report for processing. + /// + /// Note that submitting the report doesn't require the processor to start processing it right + /// away, this can happen later (see `getLastProcessingRefSlot`). Until processing is started, + /// HashConsensus is free to reach consensus on another report for the same reporting frame an + /// submit it using this same function, or to lose the consensus on the submitted report, + /// notifying the processor via `discardConsensusReport`. + /// + function submitConsensusReport(bytes32 report, uint256 refSlot, uint256 deadline) external; + + /// @notice Notifies that the report for the given ref. slot is not a conensus report anymore + /// and should be discarded. This can happen when a member changes their report, is removed + /// from the set, or when the quorum value gets increased. + /// + /// Only called when, for the given reference slot: + /// + /// 1. there previously was a consensus report; AND + /// 1. processing of the consensus report hasn't started yet; AND + /// 2. report processing deadline is not expired yet; AND + /// 3. there's no consensus report now (otherwise, `submitConsensusReport` is called instead). + /// + /// Can be called even when there's no submitted non-discarded consensus report for the current + /// reference slot, i.e. can be called multiple times in succession. + /// + function discardConsensusReport(uint256 refSlot) external; + + /// @notice Returns the last reference slot for which processing of the report was started. + /// + /// HashConsensus won't submit reports for any slot less than or equal to this slot. + /// + function getLastProcessingRefSlot() external view returns (uint256); + + /// @notice Returns the current consensus version. + /// + /// Consensus version must change every time consensus rules change, meaning that + /// an oracle looking at the same reference slot would calculate a different hash. + /// + /// HashConsensus won't accept member reports any consensus version different form the + /// one returned from this function. + /// + function getConsensusVersion() external view returns (uint256); +} + + +/// @notice A contract managing oracle members committee and allowing the members to reach +/// consensus on a hash for each reporting frame. +/// +/// Time is divided in frames of equal length, each having reference slot and processing +/// deadline. Report data must be gathered by looking at the world state at the moment of +/// the frame's reference slot (including any state changes made in that slot), and must +/// be processed before the frame's processing deadline. +/// +/// Frame length is defined in Ethereum consensus layer epochs. Reference slot for each +/// frame is set to the last slot of the epoch preceding the frame's first epoch. The +/// processing deadline is set to the last slot of the last epoch of the frame. +/// +/// This means that all state changes a report processing could entail are guaranteed to be +/// observed while gathering data for the next frame's report. This is an important property +/// given that oracle reports sometimes have to contain diffs instead of the full state which +/// might be impractical or even impossible to transmit and process. +/// +contract HashConsensus is AccessControlEnumerable { + using SafeCast for uint256; + + error InvalidChainConfig(); + error NumericOverflow(); + error AdminCannotBeZero(); + error ReportProcessorCannotBeZero(); + error DuplicateMember(); + error AddressCannotBeZero(); + error InitialEpochIsYetToArrive(); + error InitialEpochAlreadyArrived(); + error InitialEpochRefSlotCannotBeEarlierThanProcessingSlot(); + error EpochsPerFrameCannotBeZero(); + error NonMember(); + error UnexpectedConsensusVersion(uint256 expected, uint256 received); + error QuorumTooSmall(uint256 minQuorum, uint256 receivedQuorum); + error InvalidSlot(); + error DuplicateReport(); + error EmptyReport(); + error StaleReport(); + error NonFastLaneMemberCannotReportWithinFastLaneInterval(); + error NewProcessorCannotBeTheSame(); + error ConsensusReportAlreadyProcessing(); + error FastLanePeriodCannotBeLongerThanFrame(); + + event FrameConfigSet(uint256 newInitialEpoch, uint256 newEpochsPerFrame); + event FastLaneConfigSet(uint256 fastLaneLengthSlots); + event MemberAdded(address indexed addr, uint256 newTotalMembers, uint256 newQuorum); + event MemberRemoved(address indexed addr, uint256 newTotalMembers, uint256 newQuorum); + event QuorumSet(uint256 newQuorum, uint256 totalMembers, uint256 prevQuorum); + event ReportReceived(uint256 indexed refSlot, address indexed member, bytes32 report); + event ConsensusReached(uint256 indexed refSlot, bytes32 report, uint256 support); + event ConsensusLost(uint256 indexed refSlot); + event ReportProcessorSet(address indexed processor, address indexed prevProcessor); + + struct FrameConfig { + uint64 initialEpoch; + uint64 epochsPerFrame; + uint64 fastLaneLengthSlots; + } + + /// @dev Oracle reporting is divided into frames, each lasting the same number of slots. + /// + /// The start slot of the next frame is always the next slot after the end slot of the previous + /// frame. + /// + /// Each frame also has a reference slot: if the oracle report contains any data derived from + /// onchain data, the onchain data should be sampled at the reference slot. + /// + struct ConsensusFrame { + // frame index; increments by 1 with each frame but resets to zero on frame size change + uint256 index; + // the slot at which to read the state around which consensus is being reached; + // if the slot contains a block, the state should include all changes from that block + uint256 refSlot; + // the last slot at which a report can be reported and processed + uint256 reportProcessingDeadlineSlot; + } + + struct ReportingState { + // the last reference slot any report was received for + uint64 lastReportRefSlot; + // the last reference slot a consensus was reached for + uint64 lastConsensusRefSlot; + // the last consensus variant index + uint64 lastConsensusVariantIndex; + } + + struct MemberState { + // the last reference slot a report from this member was received for + uint64 lastReportRefSlot; + // the variant index of the last report from this member + uint64 lastReportVariantIndex; + } + + struct ReportVariant { + // the reported hash + bytes32 hash; + // how many unique members from the current set reported this hash in the current frame + uint64 support; + } + + /// @notice An ACL role granting the permission to modify members list members and + /// change the quorum by calling addMember, removeMember, and setQuorum functions. + bytes32 public constant MANAGE_MEMBERS_AND_QUORUM_ROLE = + keccak256("MANAGE_MEMBERS_AND_QUORUM_ROLE"); + + /// @notice An ACL role granting the permission to disable the consensus by calling + /// the disableConsensus function. Enabling the consensus back requires the possession + /// of the MANAGE_QUORUM_ROLE. + bytes32 public constant DISABLE_CONSENSUS_ROLE = keccak256("DISABLE_CONSENSUS_ROLE"); + + /// @notice An ACL role granting the permission to change reporting interval duration + /// and fast lane reporting interval length by calling setFrameConfig. + bytes32 public constant MANAGE_FRAME_CONFIG_ROLE = keccak256("MANAGE_FRAME_CONFIG_ROLE"); + + /// @notice An ACL role granting the permission to change fast lane reporting interval + /// length by calling setFastLaneLengthSlots. + bytes32 public constant MANAGE_FAST_LANE_CONFIG_ROLE = keccak256("MANAGE_FAST_LANE_CONFIG_ROLE"); + + /// @notice An ACL role granting the permission to change еру report processor + /// contract by calling setReportProcessor. + bytes32 public constant MANAGE_REPORT_PROCESSOR_ROLE = keccak256("MANAGE_REPORT_PROCESSOR_ROLE"); + + /// Chain specification + uint64 internal immutable SLOTS_PER_EPOCH; + uint64 internal immutable SECONDS_PER_SLOT; + uint64 internal immutable GENESIS_TIME; + + /// @dev A quorum value that effectively disables the oracle. + uint256 internal constant UNREACHABLE_QUORUM = type(uint256).max; + bytes32 internal constant ZERO_HASH = bytes32(0); + + /// @dev An offset from the processing deadline slot of the previous frame (i.e. the last slot + /// at which a report for the prev. frame can be submitted and its processing started) to the + /// reference slot of the next frame (equal to the last slot of the previous frame). + /// frame[i].reportProcessingDeadlineSlot := frame[i + 1].refSlot - DEADLINE_SLOT_OFFSET + uint256 internal constant DEADLINE_SLOT_OFFSET = 0; + + /// @dev Reporting frame configuration + FrameConfig internal _frameConfig; + + /// @dev Oracle committee members states array + MemberState[] internal _memberStates; + + /// @dev Oracle committee members' addresses array + address[] internal _memberAddresses; + + /// @dev Mapping from an oracle committee member address to the 1-based index in the + /// members array + mapping(address => uint256) internal _memberIndices1b; + + /// @dev A structure containing the last reference slot any report was received for, the last + /// reference slot consensus report was achieved for, and the last consensus variant index + ReportingState internal _reportingState; + + /// @dev Oracle committee members quorum value, must be larger than totalMembers // 2 + uint256 internal _quorum; + + /// @dev Mapping from a report variant index to the ReportVariant structure + mapping(uint256 => ReportVariant) internal _reportVariants; + + /// @dev The number of report variants + uint256 internal _reportVariantsLength; + + /// @dev The address of the report processor contract + address internal _reportProcessor; + + /// + /// Initialization + /// + + constructor( + uint256 slotsPerEpoch, + uint256 secondsPerSlot, + uint256 genesisTime, + uint256 epochsPerFrame, + uint256 fastLaneLengthSlots, + address admin, + address reportProcessor + ) { + if (slotsPerEpoch == 0) revert InvalidChainConfig(); + if (secondsPerSlot == 0) revert InvalidChainConfig(); + + SLOTS_PER_EPOCH = slotsPerEpoch.toUint64(); + SECONDS_PER_SLOT = secondsPerSlot.toUint64(); + GENESIS_TIME = genesisTime.toUint64(); + + if (admin == address(0)) revert AdminCannotBeZero(); + if (reportProcessor == address(0)) revert ReportProcessorCannotBeZero(); + + _setupRole(DEFAULT_ADMIN_ROLE, admin); + + uint256 farFutureEpoch = _computeEpochAtTimestamp(type(uint64).max); + _setFrameConfig(farFutureEpoch, epochsPerFrame, fastLaneLengthSlots, FrameConfig(0, 0, 0)); + + _reportProcessor = reportProcessor; + } + + /// + /// Time + /// + + /// @notice Returns the immutable chain parameters required to calculate epoch and slot + /// given a timestamp. + /// + function getChainConfig() external view returns ( + uint256 slotsPerEpoch, + uint256 secondsPerSlot, + uint256 genesisTime + ) { + return (SLOTS_PER_EPOCH, SECONDS_PER_SLOT, GENESIS_TIME); + } + + /// @notice Returns the time-related configuration. + /// + /// @return initialEpoch Epoch of the frame with zero index. + /// @return epochsPerFrame Length of a frame in epochs. + /// @return fastLaneLengthSlots Length of the fast lane interval in slots; see `getIsFastLaneMember`. + /// + function getFrameConfig() external view returns ( + uint256 initialEpoch, + uint256 epochsPerFrame, + uint256 fastLaneLengthSlots + ) { + FrameConfig memory config = _frameConfig; + return (config.initialEpoch, config.epochsPerFrame, config.fastLaneLengthSlots); + } + + /// @notice Returns the current reporting frame. + /// + /// @return refSlot The frame's reference slot: if the data the consensus is being reached upon + /// includes or depends on any onchain state, this state should be queried at the + /// reference slot. If the slot contains a block, the state should include all changes + /// from that block. + /// + /// @return reportProcessingDeadlineSlot The last slot at which the report can be processed by + /// the report processor contract. + /// + function getCurrentFrame() external view returns ( + uint256 refSlot, + uint256 reportProcessingDeadlineSlot + ) { + ConsensusFrame memory frame = _getCurrentFrame(); + return (frame.refSlot, frame.reportProcessingDeadlineSlot); + } + + /// @notice Returns the earliest possible reference slot, i.e. the reference slot of the + /// reporting frame with zero index. + /// + function getInitialRefSlot() external view returns (uint256) { + return _getInitialFrame().refSlot; + } + + /// @notice Sets a new initial epoch given that the current initial epoch is in the future. + /// + /// @param initialEpoch The new initial epoch. + /// + function updateInitialEpoch(uint256 initialEpoch) external onlyRole(DEFAULT_ADMIN_ROLE) { + FrameConfig memory prevConfig = _frameConfig; + + if (_computeEpochAtTimestamp(_getTime()) >= prevConfig.initialEpoch) { + revert InitialEpochAlreadyArrived(); + } + + _setFrameConfig( + initialEpoch, + prevConfig.epochsPerFrame, + prevConfig.fastLaneLengthSlots, + prevConfig + ); + + if (_getInitialFrame().refSlot < _getLastProcessingRefSlot()) { + revert InitialEpochRefSlotCannotBeEarlierThanProcessingSlot(); + } + } + + /// @notice Updates the time-related configuration. + /// + /// @param epochsPerFrame Length of a frame in epochs. + /// @param fastLaneLengthSlots Length of the fast lane interval in slots; see `getIsFastLaneMember`. + /// + function setFrameConfig(uint256 epochsPerFrame, uint256 fastLaneLengthSlots) + external onlyRole(MANAGE_FRAME_CONFIG_ROLE) + { + // Updates epochsPerFrame in a way that either keeps the current reference slot the same + // or increases it by at least the minimum of old and new frame sizes. + uint256 timestamp = _getTime(); + uint256 currentFrameStartEpoch = _computeFrameStartEpoch(timestamp, _frameConfig); + _setFrameConfig(currentFrameStartEpoch, epochsPerFrame, fastLaneLengthSlots, _frameConfig); + } + + /// + /// Members + /// + + /// @notice Returns whether the given address is currently a member of the consensus. + /// + function getIsMember(address addr) external view returns (bool) { + return _isMember(addr); + } + + /// @notice Returns whether the given address is a fast lane member for the current reporting + /// frame. + /// + /// Fast lane members is a subset of all members that changes each reporting frame. These + /// members can, and are expected to, submit a report during the first part of the frame called + /// the "fast lane interval" and defined via `setFrameConfig` or `setFastLaneLengthSlots`. Under + /// regular circumstances, all other members are only allowed to submit a report after the fast + /// lane interval passes. + /// + /// The fast lane subset consists of `quorum` members; selection is implemented as a sliding + /// window of the `quorum` width over member indices (mod total members). The window advances + /// by one index each reporting frame. + /// + /// This is done to encourage each member from the full set to participate in reporting on a + /// regular basis, and identify any malfunctioning members. + /// + /// With the fast lane mechanism active, it's sufficient for the monitoring to check that + /// consensus is consistently reached during the fast lane part of each frame to conclude that + /// all members are active and share the same consensus rules. + /// + /// However, there is no guarantee that, at any given time, it holds true that only the current + /// fast lane members can or were able to report during the currently-configured fast lane + /// interval of the current frame. In particular, this assumption can be violated in any frame + /// during which the members set, initial epoch, or the quorum number was changed, or the fast + /// lane interval length was increased. Thus, the fast lane mechanism should not be used for any + /// purpose other than monitoring of the members liveness, and monitoring tools should take into + /// consideration the potential irregularities within frames with any configuration changes. + /// + function getIsFastLaneMember(address addr) external view returns (bool) { + uint256 index1b = _memberIndices1b[addr]; + unchecked { + return index1b > 0 && _isFastLaneMember(index1b - 1, _getCurrentFrame().index); + } + } + + /// @notice Returns all current members, together with the last reference slot each member + /// submitted a report for. + /// + function getMembers() external view returns ( + address[] memory addresses, + uint256[] memory lastReportedRefSlots + ) { + return _getMembers(false); + } + + /// @notice Returns the subset of the oracle committee members (consisting of `quorum` items) + /// that changes each frame. + /// + /// See `getIsFastLaneMember`. + /// + function getFastLaneMembers() external view returns ( + address[] memory addresses, + uint256[] memory lastReportedRefSlots + ) { + return _getMembers(true); + } + + /// @notice Sets the duration of the fast lane interval of the reporting frame. + /// + /// See `getIsFastLaneMember`. + /// + /// @param fastLaneLengthSlots The length of the fast lane reporting interval in slots. Setting + /// it to zero disables the fast lane subset, allowing any oracle to report starting from + /// the first slot of a frame and until the frame's reporting deadline. + /// + function setFastLaneLengthSlots(uint256 fastLaneLengthSlots) + external onlyRole(MANAGE_FAST_LANE_CONFIG_ROLE) + { + _setFastLaneLengthSlots(fastLaneLengthSlots); + } + + function addMember(address addr, uint256 quorum) + external + onlyRole(MANAGE_MEMBERS_AND_QUORUM_ROLE) + { + _addMember(addr, quorum); + } + + function removeMember(address addr, uint256 quorum) + external + onlyRole(MANAGE_MEMBERS_AND_QUORUM_ROLE) + { + _removeMember(addr, quorum); + } + + function getQuorum() external view returns (uint256) { + return _quorum; + } + + function setQuorum(uint256 quorum) external { + // access control is performed inside the next call + _setQuorumAndCheckConsensus(quorum, _memberStates.length); + } + + /// @notice Disables the oracle by setting the quorum to an unreachable value. + /// + function disableConsensus() external { + // access control is performed inside the next call + _setQuorumAndCheckConsensus(UNREACHABLE_QUORUM, _memberStates.length); + } + + /// + /// Report processor + /// + + function getReportProcessor() external view returns (address) { + return _reportProcessor; + } + + function setReportProcessor(address newProcessor) + external + onlyRole(MANAGE_REPORT_PROCESSOR_ROLE) + { + _setReportProcessor(newProcessor); + } + + /// + /// Consensus + /// + + /// @notice Returns info about the current frame and consensus state in that frame. + /// + /// @return refSlot Reference slot of the current reporting frame. + /// + /// @return consensusReport Consensus report for the current frame, if any. + /// Zero bytes otherwise. + /// + /// @return isReportProcessing If consensus report for the current frame is already + /// being processed. Consensus can be changed before the processing starts. + /// + function getConsensusState() external view returns ( + uint256 refSlot, + bytes32 consensusReport, + bool isReportProcessing + ) { + refSlot = _getCurrentFrame().refSlot; + (consensusReport,,) = _getConsensusReport(refSlot, _quorum); + isReportProcessing = _getLastProcessingRefSlot() == refSlot; + } + + /// @notice Returns report variants and their support for the current reference slot. + /// + function getReportVariants() external view returns ( + bytes32[] memory variants, + uint256[] memory support + ) { + if (_reportingState.lastReportRefSlot != _getCurrentFrame().refSlot) { + return (variants, support); + } + + uint256 variantsLength = _reportVariantsLength; + variants = new bytes32[](variantsLength); + support = new uint256[](variantsLength); + + for (uint256 i = 0; i < variantsLength; ++i) { + ReportVariant memory variant = _reportVariants[i]; + variants[i] = variant.hash; + support[i] = variant.support; + } + } + + struct MemberConsensusState { + /// @notice Current frame's reference slot. + uint256 currentFrameRefSlot; + + /// @notice Consensus report for the current frame, if any. Zero bytes otherwise. + bytes32 currentFrameConsensusReport; + + /// @notice Whether the provided address is a member of the oracle committee. + bool isMember; + + /// @notice Whether the oracle committee member is in the fast lane members subset + /// of the current reporting frame. See `getIsFastLaneMember`. + bool isFastLane; + + /// @notice Whether the oracle committee member is allowed to submit a report at + /// the moment of the call. + bool canReport; + + /// @notice The last reference slot for which the member submitted a report. + uint256 lastMemberReportRefSlot; + + /// @notice The hash reported by the member for the current frame, if any. + /// Zero bytes otherwise. + bytes32 currentFrameMemberReport; + } + + /// @notice Returns the extended information related to an oracle committee member with the + /// given address and the current consensus state. Provides all the information needed for + /// an oracle daemon to decide if it needs to submit a report. + /// + /// @param addr The member address. + /// @return result See the docs for `MemberConsensusState`. + /// + function getConsensusStateForMember(address addr) + external view returns (MemberConsensusState memory result) + { + ConsensusFrame memory frame = _getCurrentFrame(); + result.currentFrameRefSlot = frame.refSlot; + (result.currentFrameConsensusReport,,) = _getConsensusReport(frame.refSlot, _quorum); + + uint256 index = _memberIndices1b[addr]; + result.isMember = index != 0; + + if (index != 0) { + unchecked { --index; } // convert to 0-based + MemberState memory memberState = _memberStates[index]; + + result.lastMemberReportRefSlot = memberState.lastReportRefSlot; + result.currentFrameMemberReport = + result.lastMemberReportRefSlot == frame.refSlot + ? _reportVariants[memberState.lastReportVariantIndex].hash + : ZERO_HASH; + + uint256 slot = _computeSlotAtTimestamp(_getTime()); + + result.canReport = slot <= frame.reportProcessingDeadlineSlot && + frame.refSlot > _getLastProcessingRefSlot(); + + result.isFastLane = _isFastLaneMember(index, frame.index); + + if (!result.isFastLane && result.canReport) { + result.canReport = slot > frame.refSlot + _frameConfig.fastLaneLengthSlots; + } + } + } + + /// @notice Used by oracle members to submit hash of the data calculated for the given + /// reference slot. + /// + /// @param slot The reference slot the data was calculated for. Reverts if doesn't match + /// the current reference slot. + /// + /// @param report Hash of the data calculated for the given reference slot. + /// + /// @param consensusVersion Version of the oracle consensus rules. Reverts if doesn't + /// match the version returned by the currently set consensus report processor, + /// or zero if no report processor is set. + /// + function submitReport(uint256 slot, bytes32 report, uint256 consensusVersion) external { + _submitReport(slot, report, consensusVersion); + } + + /// + /// Implementation: time + /// + + function _setFrameConfig( + uint256 initialEpoch, + uint256 epochsPerFrame, + uint256 fastLaneLengthSlots, + FrameConfig memory prevConfig + ) internal { + if (epochsPerFrame == 0) revert EpochsPerFrameCannotBeZero(); + + if (fastLaneLengthSlots > epochsPerFrame * SLOTS_PER_EPOCH) { + revert FastLanePeriodCannotBeLongerThanFrame(); + } + + _frameConfig = FrameConfig( + initialEpoch.toUint64(), + epochsPerFrame.toUint64(), + fastLaneLengthSlots.toUint64() + ); + + if (initialEpoch != prevConfig.initialEpoch || epochsPerFrame != prevConfig.epochsPerFrame) { + emit FrameConfigSet(initialEpoch, epochsPerFrame); + } + + if (fastLaneLengthSlots != prevConfig.fastLaneLengthSlots) { + emit FastLaneConfigSet(fastLaneLengthSlots); + } + } + + function _getCurrentFrame() internal view returns (ConsensusFrame memory) { + return _getFrameAtTimestamp(_getTime(), _frameConfig); + } + + function _getInitialFrame() internal view returns (ConsensusFrame memory) { + return _getFrameAtIndex(0, _frameConfig); + } + + function _getFrameAtTimestamp(uint256 timestamp, FrameConfig memory config) + internal view returns (ConsensusFrame memory) + { + return _getFrameAtIndex(_computeFrameIndex(timestamp, config), config); + } + + function _getFrameAtIndex(uint256 frameIndex, FrameConfig memory config) + internal view returns (ConsensusFrame memory) + { + uint256 frameStartEpoch = _computeStartEpochOfFrameWithIndex(frameIndex, config); + uint256 frameStartSlot = _computeStartSlotAtEpoch(frameStartEpoch); + uint256 nextFrameStartSlot = frameStartSlot + config.epochsPerFrame * SLOTS_PER_EPOCH; + + return ConsensusFrame({ + index: frameIndex, + refSlot: uint64(frameStartSlot - 1), + reportProcessingDeadlineSlot: uint64(nextFrameStartSlot - 1 - DEADLINE_SLOT_OFFSET) + }); + } + + function _computeFrameStartEpoch(uint256 timestamp, FrameConfig memory config) + internal view returns (uint256) + { + return _computeStartEpochOfFrameWithIndex(_computeFrameIndex(timestamp, config), config); + } + + function _computeStartEpochOfFrameWithIndex(uint256 frameIndex, FrameConfig memory config) + internal pure returns (uint256) + { + return config.initialEpoch + frameIndex * config.epochsPerFrame; + } + + function _computeFrameIndex(uint256 timestamp, FrameConfig memory config) + internal view returns (uint256) + { + uint256 epoch = _computeEpochAtTimestamp(timestamp); + if (epoch < config.initialEpoch) { + revert InitialEpochIsYetToArrive(); + } + return (epoch - config.initialEpoch) / config.epochsPerFrame; + } + + function _computeTimestampAtSlot(uint256 slot) internal view returns (uint256) { + // See: github.com/ethereum/consensus-specs/blob/dev/specs/bellatrix/beacon-chain.md#compute_timestamp_at_slot + return GENESIS_TIME + slot * SECONDS_PER_SLOT; + } + + function _computeSlotAtTimestamp(uint256 timestamp) internal view returns (uint256) { + return (timestamp - GENESIS_TIME) / SECONDS_PER_SLOT; + } + + function _computeEpochAtSlot(uint256 slot) internal view returns (uint256) { + // See: github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_epoch_at_slot + return slot / SLOTS_PER_EPOCH; + } + + function _computeEpochAtTimestamp(uint256 timestamp) internal view returns (uint256) { + return _computeEpochAtSlot(_computeSlotAtTimestamp(timestamp)); + } + + function _computeStartSlotAtEpoch(uint256 epoch) internal view returns (uint256) { + // See: github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_start_slot_at_epoch + return epoch * SLOTS_PER_EPOCH; + } + + function _getTime() internal virtual view returns (uint256) { + return block.timestamp; // solhint-disable-line not-rely-on-time + } + + /// + /// Implementation: members + /// + + function _isMember(address addr) internal view returns (bool) { + return _memberIndices1b[addr] != 0; + } + + function _getMemberIndex(address addr) internal view returns (uint256) { + uint256 index1b = _memberIndices1b[addr]; + if (index1b == 0) { + revert NonMember(); + } + unchecked { + return uint256(index1b - 1); + } + } + + function _addMember(address addr, uint256 quorum) internal { + if (_isMember(addr)) revert DuplicateMember(); + if (addr == address(0)) revert AddressCannotBeZero(); + + _memberStates.push(MemberState(0, 0)); + _memberAddresses.push(addr); + + uint256 newTotalMembers = _memberStates.length; + _memberIndices1b[addr] = newTotalMembers; + + emit MemberAdded(addr, newTotalMembers, quorum); + + _setQuorumAndCheckConsensus(quorum, newTotalMembers); + } + + function _removeMember(address addr, uint256 quorum) internal { + uint256 index = _getMemberIndex(addr); + uint256 newTotalMembers = _memberStates.length - 1; + + assert(index <= newTotalMembers); + MemberState memory memberState = _memberStates[index]; + + if (index != newTotalMembers) { + address addrToMove = _memberAddresses[newTotalMembers]; + _memberAddresses[index] = addrToMove; + _memberStates[index] = _memberStates[newTotalMembers]; + _memberIndices1b[addrToMove] = index + 1; + } + + _memberStates.pop(); + _memberAddresses.pop(); + _memberIndices1b[addr] = 0; + + emit MemberRemoved(addr, newTotalMembers, quorum); + + if (memberState.lastReportRefSlot > 0) { + // member reported at least once + ConsensusFrame memory frame = _getCurrentFrame(); + + if (memberState.lastReportRefSlot == frame.refSlot && + _getLastProcessingRefSlot() < frame.refSlot + ) { + // member reported for the current ref. slot and the consensus report + // is not processing yet => need to cancel the member's report + --_reportVariants[memberState.lastReportVariantIndex].support; + } + } + + _setQuorumAndCheckConsensus(quorum, newTotalMembers); + } + + function _setFastLaneLengthSlots(uint256 fastLaneLengthSlots) internal { + FrameConfig memory frameConfig = _frameConfig; + if (fastLaneLengthSlots > frameConfig.epochsPerFrame * SLOTS_PER_EPOCH) { + revert FastLanePeriodCannotBeLongerThanFrame(); + } + if (fastLaneLengthSlots != frameConfig.fastLaneLengthSlots) { + _frameConfig.fastLaneLengthSlots = fastLaneLengthSlots.toUint64(); + emit FastLaneConfigSet(fastLaneLengthSlots); + } + } + + /// @dev Returns start and past-end incides (mod totalMembers) of the fast lane members subset. + /// + function _getFastLaneSubset(uint256 frameIndex, uint256 totalMembers) + internal view returns (uint256 startIndex, uint256 pastEndIndex) + { + uint256 quorum = _quorum; + if (quorum >= totalMembers) { + startIndex = 0; + pastEndIndex = totalMembers; + } else { + startIndex = frameIndex % totalMembers; + pastEndIndex = startIndex + quorum; + } + } + + /// @dev Tests whether the member with the given `index` is in the fast lane subset for the + /// given reporting `frameIndex`. + /// + function _isFastLaneMember(uint256 index, uint256 frameIndex) internal view returns (bool) { + uint256 totalMembers = _memberStates.length; + (uint256 flLeft, uint256 flPastRight) = _getFastLaneSubset(frameIndex, totalMembers); + unchecked { + return ( + flPastRight != 0 && + Math.pointInClosedIntervalModN(index, flLeft, flPastRight - 1, totalMembers) + ); + } + } + + function _getMembers(bool fastLane) internal view returns ( + address[] memory addresses, + uint256[] memory lastReportedRefSlots + ) { + uint256 totalMembers = _memberStates.length; + uint256 left; + uint256 right; + + if (fastLane) { + (left, right) = _getFastLaneSubset(_getCurrentFrame().index, totalMembers); + } else { + right = totalMembers; + } + + addresses = new address[](right - left); + lastReportedRefSlots = new uint256[](addresses.length); + + for (uint256 i = left; i < right; ++i) { + uint256 iModTotal = i % totalMembers; + MemberState memory memberState = _memberStates[iModTotal]; + uint256 k = i - left; + addresses[k] = _memberAddresses[iModTotal]; + lastReportedRefSlots[k] = memberState.lastReportRefSlot; + } + } + + /// + /// Implementation: consensus + /// + + function _submitReport(uint256 slot, bytes32 report, uint256 consensusVersion) internal { + if (slot == 0) revert InvalidSlot(); + if (slot > type(uint64).max) revert NumericOverflow(); + if (report == ZERO_HASH) revert EmptyReport(); + + uint256 memberIndex = _getMemberIndex(_msgSender()); + MemberState memory memberState = _memberStates[memberIndex]; + + uint256 expectedConsensusVersion = _getConsensusVersion(); + if (consensusVersion != expectedConsensusVersion) { + revert UnexpectedConsensusVersion(expectedConsensusVersion, consensusVersion); + } + + uint256 timestamp = _getTime(); + uint256 currentSlot = _computeSlotAtTimestamp(timestamp); + FrameConfig memory config = _frameConfig; + ConsensusFrame memory frame = _getFrameAtTimestamp(timestamp, config); + + if (slot != frame.refSlot) revert InvalidSlot(); + if (currentSlot > frame.reportProcessingDeadlineSlot) revert StaleReport(); + + if (currentSlot <= frame.refSlot + config.fastLaneLengthSlots && + !_isFastLaneMember(memberIndex, frame.index) + ) { + revert NonFastLaneMemberCannotReportWithinFastLaneInterval(); + } + + if (slot <= _getLastProcessingRefSlot()) { + // consensus for the ref. slot was already reached and consensus report is processing + if (slot == memberState.lastReportRefSlot) { + // member sends a report for the same slot => let them know via a revert + revert ConsensusReportAlreadyProcessing(); + } else { + // member hasn't sent a report for this slot => normal operation, do nothing + return; + } + } + + uint256 variantsLength; + + if (_reportingState.lastReportRefSlot != slot) { + // first report for a new slot => clear report variants + _reportingState.lastReportRefSlot = uint64(slot); + variantsLength = 0; + } else { + variantsLength = _reportVariantsLength; + } + + uint64 varIndex = 0; + bool prevConsensusLost = false; + + while (varIndex < variantsLength && _reportVariants[varIndex].hash != report) { + ++varIndex; + } + + if (slot == memberState.lastReportRefSlot) { + uint64 prevVarIndex = memberState.lastReportVariantIndex; + assert(prevVarIndex < variantsLength); + if (varIndex == prevVarIndex) { + revert DuplicateReport(); + } else { + uint256 support = --_reportVariants[prevVarIndex].support; + if (support == _quorum - 1) { + prevConsensusLost = true; + } + } + } + + uint256 support; + + if (varIndex < variantsLength) { + support = ++_reportVariants[varIndex].support; + } else { + support = 1; + _reportVariants[varIndex] = ReportVariant({hash: report, support: 1}); + _reportVariantsLength = ++variantsLength; + } + + _memberStates[memberIndex] = MemberState({ + lastReportRefSlot: uint64(slot), + lastReportVariantIndex: varIndex + }); + + emit ReportReceived(slot, _msgSender(), report); + + if (support >= _quorum) { + _consensusReached(frame, report, varIndex, support); + } else if (prevConsensusLost) { + _consensusNotReached(frame); + } + } + + function _consensusReached( + ConsensusFrame memory frame, + bytes32 report, + uint256 variantIndex, + uint256 support + ) internal { + if (_reportingState.lastConsensusRefSlot != frame.refSlot || + _reportingState.lastConsensusVariantIndex != variantIndex + ) { + _reportingState.lastConsensusRefSlot = uint64(frame.refSlot); + _reportingState.lastConsensusVariantIndex = uint64(variantIndex); + emit ConsensusReached(frame.refSlot, report, support); + _submitReportForProcessing(frame, report); + } + } + + function _consensusNotReached(ConsensusFrame memory frame) internal { + if (_reportingState.lastConsensusRefSlot == frame.refSlot) { + _reportingState.lastConsensusRefSlot = 0; + emit ConsensusLost(frame.refSlot); + _cancelReportProcessing(frame); + } + } + + function _setQuorumAndCheckConsensus(uint256 quorum, uint256 totalMembers) internal { + if (quorum <= totalMembers / 2) { + revert QuorumTooSmall(totalMembers / 2 + 1, quorum); + } + + // we're explicitly allowing quorum values greater than the number of members to + // allow effectively disabling the oracle in case something unpredictable happens + + uint256 prevQuorum = _quorum; + if (quorum != prevQuorum) { + _checkRole( + quorum == UNREACHABLE_QUORUM ? DISABLE_CONSENSUS_ROLE : MANAGE_MEMBERS_AND_QUORUM_ROLE, + _msgSender() + ); + _quorum = quorum; + emit QuorumSet(quorum, totalMembers, prevQuorum); + } + + if (_computeEpochAtTimestamp(_getTime()) >= _frameConfig.initialEpoch) { + _checkConsensus(quorum); + } + } + + function _checkConsensus(uint256 quorum) internal { + uint256 timestamp = _getTime(); + ConsensusFrame memory frame = _getFrameAtTimestamp(timestamp, _frameConfig); + + if (_computeSlotAtTimestamp(timestamp) > frame.reportProcessingDeadlineSlot) { + // a report for the current ref. slot cannot be processed anymore + return; + } + + if (_getLastProcessingRefSlot() >= frame.refSlot) { + // a consensus report for the current ref. slot is already being processed + return; + } + + (bytes32 consensusReport, int256 consensusVariantIndex, uint256 support) = + _getConsensusReport(frame.refSlot, quorum); + + if (consensusVariantIndex >= 0) { + _consensusReached(frame, consensusReport, uint256(consensusVariantIndex), support); + } else { + _consensusNotReached(frame); + } + } + + function _getConsensusReport(uint256 currentRefSlot, uint256 quorum) + internal view returns (bytes32 report, int256 variantIndex, uint256 support) + { + if (_reportingState.lastReportRefSlot != currentRefSlot) { + // there were no reports for the current ref. slot + return (ZERO_HASH, -1, 0); + } + + uint256 variantsLength = _reportVariantsLength; + variantIndex = -1; + report = ZERO_HASH; + support = 0; + + for (uint256 i = 0; i < variantsLength; ++i) { + uint256 iSupport = _reportVariants[i].support; + if (iSupport >= quorum) { + variantIndex = int256(i); + report = _reportVariants[i].hash; + support = iSupport; + break; + } + } + + return (report, variantIndex, support); + } + + /// + /// Implementation: report processing + /// + + function _setReportProcessor(address newProcessor) internal { + address prevProcessor = _reportProcessor; + if (newProcessor == address(0)) revert ReportProcessorCannotBeZero(); + if (newProcessor == prevProcessor) revert NewProcessorCannotBeTheSame(); + + _reportProcessor = newProcessor; + emit ReportProcessorSet(newProcessor, prevProcessor); + + ConsensusFrame memory frame = _getCurrentFrame(); + uint256 lastConsensusRefSlot = _reportingState.lastConsensusRefSlot; + + uint256 processingRefSlotPrev = IReportAsyncProcessor(prevProcessor).getLastProcessingRefSlot(); + uint256 processingRefSlotNext = IReportAsyncProcessor(newProcessor).getLastProcessingRefSlot(); + + if ( + processingRefSlotPrev < frame.refSlot && + processingRefSlotNext < frame.refSlot && + lastConsensusRefSlot == frame.refSlot + ) { + bytes32 report = _reportVariants[_reportingState.lastConsensusVariantIndex].hash; + _submitReportForProcessing(frame, report); + } + } + + function _getLastProcessingRefSlot() internal view returns (uint256) { + return IReportAsyncProcessor(_reportProcessor).getLastProcessingRefSlot(); + } + + function _submitReportForProcessing(ConsensusFrame memory frame, bytes32 report) internal { + IReportAsyncProcessor(_reportProcessor).submitConsensusReport( + report, + frame.refSlot, + _computeTimestampAtSlot(frame.reportProcessingDeadlineSlot) + ); + } + + function _cancelReportProcessing(ConsensusFrame memory frame) internal { + IReportAsyncProcessor(_reportProcessor).discardConsensusReport(frame.refSlot); + } + + function _getConsensusVersion() internal view returns (uint256) { + return IReportAsyncProcessor(_reportProcessor).getConsensusVersion(); + } +} diff --git a/lib/base-oracle/utils/PausableUntil.sol b/lib/base-oracle/utils/PausableUntil.sol new file mode 100644 index 00000000..18ef10a5 --- /dev/null +++ b/lib/base-oracle/utils/PausableUntil.sol @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.9; + +import "../lib/UnstructuredStorage.sol"; + + +contract PausableUntil { + using UnstructuredStorage for bytes32; + + /// Contract resume/pause control storage slot + bytes32 internal constant RESUME_SINCE_TIMESTAMP_POSITION = keccak256("lido.PausableUntil.resumeSinceTimestamp"); + /// Special value for the infinite pause + uint256 public constant PAUSE_INFINITELY = type(uint256).max; + + /// @notice Emitted when paused by the `pauseFor` or `pauseUntil` call + event Paused(uint256 duration); + /// @notice Emitted when resumed by the `resume` call + event Resumed(); + + error ZeroPauseDuration(); + error PausedExpected(); + error ResumedExpected(); + error PauseUntilMustBeInFuture(); + + /// @notice Reverts when resumed + modifier whenPaused() { + _checkPaused(); + _; + } + + /// @notice Reverts when paused + modifier whenResumed() { + _checkResumed(); + _; + } + + function _checkPaused() internal view { + if (!isPaused()) { + revert PausedExpected(); + } + } + + function _checkResumed() internal view { + if (isPaused()) { + revert ResumedExpected(); + } + } + + /// @notice Returns whether the contract is paused + function isPaused() public view returns (bool) { + return block.timestamp < RESUME_SINCE_TIMESTAMP_POSITION.getStorageUint256(); + } + + /// @notice Returns one of: + /// - PAUSE_INFINITELY if paused infinitely returns + /// - first second when get contract get resumed if paused for specific duration + /// - some timestamp in past if not paused + function getResumeSinceTimestamp() external view returns (uint256) { + return RESUME_SINCE_TIMESTAMP_POSITION.getStorageUint256(); + } + + function _resume() internal { + _checkPaused(); + RESUME_SINCE_TIMESTAMP_POSITION.setStorageUint256(block.timestamp); + emit Resumed(); + } + + function _pauseFor(uint256 _duration) internal { + _checkResumed(); + if (_duration == 0) revert ZeroPauseDuration(); + + uint256 resumeSince; + if (_duration == PAUSE_INFINITELY) { + resumeSince = PAUSE_INFINITELY; + } else { + resumeSince = block.timestamp + _duration; + } + _setPausedState(resumeSince); + } + + function _pauseUntil(uint256 _pauseUntilInclusive) internal { + _checkResumed(); + if (_pauseUntilInclusive < block.timestamp) revert PauseUntilMustBeInFuture(); + + uint256 resumeSince; + if (_pauseUntilInclusive != PAUSE_INFINITELY) { + resumeSince = _pauseUntilInclusive + 1; + } else { + resumeSince = PAUSE_INFINITELY; + } + _setPausedState(resumeSince); + } + + function _setPausedState(uint256 _resumeSince) internal { + RESUME_SINCE_TIMESTAMP_POSITION.setStorageUint256(_resumeSince); + if (_resumeSince == PAUSE_INFINITELY) { + emit Paused(PAUSE_INFINITELY); + } else { + emit Paused(_resumeSince - block.timestamp); + } + } +} diff --git a/lib/base-oracle/utils/Versioned.sol b/lib/base-oracle/utils/Versioned.sol new file mode 100644 index 00000000..8d713f5a --- /dev/null +++ b/lib/base-oracle/utils/Versioned.sol @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2022 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.9; + + +import "../lib/UnstructuredStorage.sol"; + + +contract Versioned { + using UnstructuredStorage for bytes32; + + event ContractVersionSet(uint256 version); + + error NonZeroContractVersionOnInit(); + error InvalidContractVersionIncrement(); + error UnexpectedContractVersion(uint256 expected, uint256 received); + + /// @dev Storage slot: uint256 version + /// Version of the initialized contract storage. + /// The version stored in CONTRACT_VERSION_POSITION equals to: + /// - 0 right after the deployment, before an initializer is invoked (and only at that moment); + /// - N after calling initialize(), where N is the initially deployed contract version; + /// - N after upgrading contract by calling finalizeUpgrade_vN(). + bytes32 internal constant CONTRACT_VERSION_POSITION = keccak256("lido.Versioned.contractVersion"); + + uint256 internal constant PETRIFIED_VERSION_MARK = type(uint256).max; + + constructor() { + // lock version in the implementation's storage to prevent initialization + CONTRACT_VERSION_POSITION.setStorageUint256(PETRIFIED_VERSION_MARK); + } + + /// @notice Returns the current contract version. + function getContractVersion() public view returns (uint256) { + return CONTRACT_VERSION_POSITION.getStorageUint256(); + } + + function _checkContractVersion(uint256 version) internal view { + uint256 expectedVersion = getContractVersion(); + if (version != expectedVersion) { + revert UnexpectedContractVersion(expectedVersion, version); + } + } + + /// @dev Sets the contract version to N. Should be called from the initialize() function. + function _initializeContractVersionTo(uint256 version) internal { + if (getContractVersion() != 0) revert NonZeroContractVersionOnInit(); + _setContractVersion(version); + } + + /// @dev Updates the contract version. Should be called from a finalizeUpgrade_vN() function. + function _updateContractVersion(uint256 newVersion) internal { + if (newVersion != getContractVersion() + 1) revert InvalidContractVersionIncrement(); + _setContractVersion(newVersion); + } + + function _setContractVersion(uint256 version) private { + CONTRACT_VERSION_POSITION.setStorageUint256(version); + emit ContractVersionSet(version); + } +} diff --git a/lib/base-oracle/utils/access/AccessControl.sol b/lib/base-oracle/utils/access/AccessControl.sol new file mode 100644 index 00000000..731a1a3e --- /dev/null +++ b/lib/base-oracle/utils/access/AccessControl.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (access/AccessControl.sol) +// +// A modified AccessControl contract using unstructured storage. Copied from tree: +// https://github.com/OpenZeppelin/openzeppelin-contracts/tree/6bd6b76/contracts/access +// +/* See contracts/COMPILERS.md */ +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts-v4.4/access/IAccessControl.sol"; +import "@openzeppelin/contracts-v4.4/utils/Context.sol"; +import "@openzeppelin/contracts-v4.4/utils/Strings.sol"; +import "@openzeppelin/contracts-v4.4/utils/introspection/ERC165.sol"; + +/** + * @dev Contract module that allows children to implement role-based access + * control mechanisms. This is a lightweight version that doesn't allow enumerating role + * members except through off-chain means by accessing the contract event logs. Some + * applications may benefit from on-chain enumerability, for those cases see + * {AccessControlEnumerable}. + * + * Roles are referred to by their `bytes32` identifier. These should be exposed + * in the external API and be unique. The best way to achieve this is by + * using `public constant` hash digests: + * + * ``` + * bytes32 public constant MY_ROLE = keccak256("MY_ROLE"); + * ``` + * + * Roles can be used to represent a set of permissions. To restrict access to a + * function call, use {hasRole}: + * + * ``` + * function foo() public { + * require(hasRole(MY_ROLE, msg.sender)); + * ... + * } + * ``` + * + * Roles can be granted and revoked dynamically via the {grantRole} and + * {revokeRole} functions. Each role has an associated admin role, and only + * accounts that have a role's admin role can call {grantRole} and {revokeRole}. + * + * By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE`, which means + * that only accounts with this role will be able to grant or revoke other + * roles. More complex role relationships can be created by using + * {_setRoleAdmin}. + * + * WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to + * grant and revoke this role. Extra precautions should be taken to secure + * accounts that have been granted it. + */ +abstract contract AccessControl is Context, IAccessControl, ERC165 { + struct RoleData { + mapping(address => bool) members; + bytes32 adminRole; + } + + /// @dev Storage slot: mapping(bytes32 => RoleData) _roles + bytes32 private constant ROLES_POSITION = keccak256("openzeppelin.AccessControl._roles"); + + function _storageRoles() private pure returns (mapping(bytes32 => RoleData) storage _roles) { + bytes32 position = ROLES_POSITION; + assembly { _roles.slot := position } + } + + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + /** + * @dev Modifier that checks that an account has a specific role. Reverts + * with a standardized message including the required role. + * + * The format of the revert reason is given by the following regular expression: + * + * /^AccessControl: account (0x[0-9a-f]{40}) is missing role (0x[0-9a-f]{64})$/ + * + * _Available since v4.1._ + */ + modifier onlyRole(bytes32 role) { + _checkRole(role, _msgSender()); + _; + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IAccessControl).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev Returns `true` if `account` has been granted `role`. + */ + function hasRole(bytes32 role, address account) public view override returns (bool) { + return _storageRoles()[role].members[account]; + } + + /** + * @dev Revert with a standard message if `account` is missing `role`. + * + * The format of the revert reason is given by the following regular expression: + * + * /^AccessControl: account (0x[0-9a-f]{40}) is missing role (0x[0-9a-f]{64})$/ + */ + function _checkRole(bytes32 role, address account) internal view { + if (!hasRole(role, account)) { + revert( + string( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(account), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ) + ); + } + } + + /** + * @dev Returns the admin role that controls `role`. See {grantRole} and + * {revokeRole}. + * + * To change a role's admin, use {_setRoleAdmin}. + */ + function getRoleAdmin(bytes32 role) public view override returns (bytes32) { + return _storageRoles()[role].adminRole; + } + + /** + * @dev Grants `role` to `account`. + * + * If `account` had not been already granted `role`, emits a {RoleGranted} + * event. + * + * Requirements: + * + * - the caller must have ``role``'s admin role. + */ + function grantRole(bytes32 role, address account) public virtual override onlyRole(getRoleAdmin(role)) { + _grantRole(role, account); + } + + /** + * @dev Revokes `role` from `account`. + * + * If `account` had been granted `role`, emits a {RoleRevoked} event. + * + * Requirements: + * + * - the caller must have ``role``'s admin role. + */ + function revokeRole(bytes32 role, address account) public virtual override onlyRole(getRoleAdmin(role)) { + _revokeRole(role, account); + } + + /** + * @dev Revokes `role` from the calling account. + * + * Roles are often managed via {grantRole} and {revokeRole}: this function's + * purpose is to provide a mechanism for accounts to lose their privileges + * if they are compromised (such as when a trusted device is misplaced). + * + * If the calling account had been revoked `role`, emits a {RoleRevoked} + * event. + * + * Requirements: + * + * - the caller must be `account`. + */ + function renounceRole(bytes32 role, address account) public virtual override { + require(account == _msgSender(), "AccessControl: can only renounce roles for self"); + + _revokeRole(role, account); + } + + /** + * @dev Grants `role` to `account`. + * + * If `account` had not been already granted `role`, emits a {RoleGranted} + * event. Note that unlike {grantRole}, this function doesn't perform any + * checks on the calling account. + * + * [WARNING] + * ==== + * This function should only be called from the constructor when setting + * up the initial roles for the system. + * + * Using this function in any other way is effectively circumventing the admin + * system imposed by {AccessControl}. + * ==== + * + * NOTE: This function is deprecated in favor of {_grantRole}. + */ + function _setupRole(bytes32 role, address account) internal virtual { + _grantRole(role, account); + } + + /** + * @dev Sets `adminRole` as ``role``'s admin role. + * + * Emits a {RoleAdminChanged} event. + */ + function _setRoleAdmin(bytes32 role, bytes32 adminRole) internal virtual { + bytes32 previousAdminRole = getRoleAdmin(role); + _storageRoles()[role].adminRole = adminRole; + emit RoleAdminChanged(role, previousAdminRole, adminRole); + } + + /** + * @dev Grants `role` to `account`. + * + * Internal function without access restriction. + */ + function _grantRole(bytes32 role, address account) internal virtual { + if (!hasRole(role, account)) { + _storageRoles()[role].members[account] = true; + emit RoleGranted(role, account, _msgSender()); + } + } + + /** + * @dev Revokes `role` from `account`. + * + * Internal function without access restriction. + */ + function _revokeRole(bytes32 role, address account) internal virtual { + if (hasRole(role, account)) { + _storageRoles()[role].members[account] = false; + emit RoleRevoked(role, account, _msgSender()); + } + } +} diff --git a/lib/base-oracle/utils/access/AccessControlEnumerable.sol b/lib/base-oracle/utils/access/AccessControlEnumerable.sol new file mode 100644 index 00000000..1b319938 --- /dev/null +++ b/lib/base-oracle/utils/access/AccessControlEnumerable.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (access/AccessControlEnumerable.sol) +// +// A modified AccessControlEnumerable contract using unstructured storage. Copied from tree: +// https://github.com/OpenZeppelin/openzeppelin-contracts/tree/6bd6b76/contracts/access +// +/* See contracts/COMPILERS.md */ +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts-v4.4/access/IAccessControlEnumerable.sol"; +import "@openzeppelin/contracts-v4.4/utils/structs/EnumerableSet.sol"; + +import "./AccessControl.sol"; + +/** + * @dev Extension of {AccessControl} that allows enumerating the members of each role. + */ +abstract contract AccessControlEnumerable is IAccessControlEnumerable, AccessControl { + using EnumerableSet for EnumerableSet.AddressSet; + + /// @dev Storage slot: mapping(bytes32 => EnumerableSet.AddressSet) _roleMembers + bytes32 private constant ROLE_MEMBERS_POSITION = keccak256("openzeppelin.AccessControlEnumerable._roleMembers"); + + function _storageRoleMembers() private pure returns ( + mapping(bytes32 => EnumerableSet.AddressSet) storage _roleMembers + ) { + bytes32 position = ROLE_MEMBERS_POSITION; + assembly { _roleMembers.slot := position } + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IAccessControlEnumerable).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev Returns one of the accounts that have `role`. `index` must be a + * value between 0 and {getRoleMemberCount}, non-inclusive. + * + * Role bearers are not sorted in any particular way, and their ordering may + * change at any point. + * + * WARNING: When using {getRoleMember} and {getRoleMemberCount}, make sure + * you perform all queries on the same block. See the following + * https://forum.openzeppelin.com/t/iterating-over-elements-on-enumerableset-in-openzeppelin-contracts/2296[forum post] + * for more information. + */ + function getRoleMember(bytes32 role, uint256 index) public view override returns (address) { + return _storageRoleMembers()[role].at(index); + } + + /** + * @dev Returns the number of accounts that have `role`. Can be used + * together with {getRoleMember} to enumerate all bearers of a role. + */ + function getRoleMemberCount(bytes32 role) public view override returns (uint256) { + return _storageRoleMembers()[role].length(); + } + + /** + * @dev Overload {_grantRole} to track enumerable memberships + */ + function _grantRole(bytes32 role, address account) internal virtual override { + super._grantRole(role, account); + _storageRoleMembers()[role].add(account); + } + + /** + * @dev Overload {_revokeRole} to track enumerable memberships + */ + function _revokeRole(bytes32 role, address account) internal virtual override { + super._revokeRole(role, account); + _storageRoleMembers()[role].remove(account); + } +} diff --git a/lib/openzeppelin-contracts-v4.4 b/lib/openzeppelin-contracts-v4.4 new file mode 160000 index 00000000..6bd6b76d --- /dev/null +++ b/lib/openzeppelin-contracts-v4.4 @@ -0,0 +1 @@ +Subproject commit 6bd6b76d1156e20e45d1016f355d154141c7e5b9 diff --git a/lib/proxy/OssifiableProxy.sol b/lib/proxy/OssifiableProxy.sol new file mode 100644 index 00000000..b188fac6 --- /dev/null +++ b/lib/proxy/OssifiableProxy.sol @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +/* See contracts/COMPILERS.md */ +pragma solidity ^0.8.9; + +import {Address} from "@openzeppelin/contracts-v4.4/utils/Address.sol"; +import {StorageSlot} from "@openzeppelin/contracts-v4.4/utils/StorageSlot.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts-v4.4/proxy/ERC1967/ERC1967Proxy.sol"; + +/// @notice An ossifiable proxy contract. Extends the ERC1967Proxy contract by +/// adding admin functionality +contract OssifiableProxy is ERC1967Proxy { + /// @dev Initializes the upgradeable proxy with the initial implementation and admin + /// @param implementation_ Address of the implementation + /// @param admin_ Address of the admin of the proxy + /// @param data_ Data used in a delegate call to implementation. The delegate call will be + /// skipped if the data is empty bytes + constructor( + address implementation_, + address admin_, + bytes memory data_ + ) ERC1967Proxy(implementation_, data_) { + _changeAdmin(admin_); + } + + /// @notice Returns the current admin of the proxy + function proxy__getAdmin() external view returns (address) { + return _getAdmin(); + } + + /// @notice Returns the current implementation address + function proxy__getImplementation() external view returns (address) { + return _implementation(); + } + + /// @notice Returns whether the implementation is locked forever + function proxy__getIsOssified() external view returns (bool) { + return _getAdmin() == address(0); + } + + /// @notice Allows to transfer admin rights to zero address and prevent future + /// upgrades of the proxy + function proxy__ossify() external onlyAdmin { + address prevAdmin = _getAdmin(); + StorageSlot.getAddressSlot(_ADMIN_SLOT).value = address(0); + emit AdminChanged(prevAdmin, address(0)); + emit ProxyOssified(); + } + + /// @notice Changes the admin of the proxy + /// @param newAdmin_ Address of the new admin + function proxy__changeAdmin(address newAdmin_) external onlyAdmin { + _changeAdmin(newAdmin_); + } + + /// @notice Upgrades the implementation of the proxy + /// @param newImplementation_ Address of the new implementation + function proxy__upgradeTo(address newImplementation_) external onlyAdmin { + _upgradeTo(newImplementation_); + } + + /// @notice Upgrades the proxy to a new implementation, optionally performing an additional + /// setup call. + /// @param newImplementation_ Address of the new implementation + /// @param setupCalldata_ Data for the setup call. The call is skipped if setupCalldata_ is + /// empty and forceCall_ is false + /// @param forceCall_ Forces make delegate call to the implementation even with empty data_ + function proxy__upgradeToAndCall( + address newImplementation_, + bytes memory setupCalldata_, + bool forceCall_ + ) external onlyAdmin { + _upgradeToAndCall(newImplementation_, setupCalldata_, forceCall_); + } + + /// @dev Validates that proxy is not ossified and that method is called by the admin + /// of the proxy + modifier onlyAdmin() { + address admin = _getAdmin(); + if (admin == address(0)) { + revert ProxyIsOssified(); + } + if (admin != msg.sender) { + revert NotAdmin(); + } + _; + } + + event ProxyOssified(); + + error NotAdmin(); + error ProxyIsOssified(); +} diff --git a/remappings.txt b/remappings.txt index 27a02a91..d2fb6ff0 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,3 +1,4 @@ -@openzeppelin/=lib/openzeppelin-contracts/ +@openzeppelin/contracts=lib/openzeppelin-contracts/contracts/ ds-test/=lib/forge-std/lib/ds-test/src/ forge-std/=lib/forge-std/src/ +@openzeppelin/contracts-v4.4=lib/openzeppelin-contracts-v4.4/contracts/ diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol deleted file mode 100644 index a7359aa8..00000000 --- a/script/Deploy.s.sol +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Lido -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.21; - -import "forge-std/Script.sol"; - -import { ILidoLocator } from "../src/interfaces/ILidoLocator.sol"; -import { CSModule } from "../src/CSModule.sol"; -import { CSAccounting, IWstETH } from "../src/CSAccounting.sol"; -import { CSFeeDistributor } from "../src/CSFeeDistributor.sol"; -import { CSFeeOracle } from "../src/CSFeeOracle.sol"; - -contract Deploy is Script { - ILidoLocator public locator; - IWstETH public wstETH; - - address LIDO_LOCATOR_ADDRESS; - address WSTETH_ADDRESS; - uint256 CL_GENESIS_TIME; - uint256 INITIALIZATION_EPOCH; - - function run() external { - // TODO: proxy ??? - LIDO_LOCATOR_ADDRESS = vm.envAddress("LIDO_LOCATOR_ADDRESS"); - WSTETH_ADDRESS = vm.envAddress("WSTETH_ADDRESS"); - CL_GENESIS_TIME = vm.envUint("CL_GENESIS_TIME"); - INITIALIZATION_EPOCH = vm.envUint("INITIALIZATION_EPOCH"); - - uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); - address deployerAddress = vm.addr(deployerPrivateKey); - address[] memory penalizers = new address[](1); - penalizers[0] = deployerAddress; // TODO: temporary - - vm.startBroadcast(deployerPrivateKey); - locator = ILidoLocator(LIDO_LOCATOR_ADDRESS); - wstETH = IWstETH(WSTETH_ADDRESS); - CSModule csm = new CSModule( - "community-staking-module", - address(locator) - ); - CSAccounting accounting = new CSAccounting({ - commonBondSize: 2 ether, - admin: deployerAddress, - lidoLocator: address(locator), - communityStakingModule: address(csm), - wstETH: address(wstETH), - penalizeRoleMembers: penalizers - }); - CSFeeOracle feeOracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: uint64(CL_GENESIS_TIME) - }); - CSFeeDistributor feeDistributor = new CSFeeDistributor({ - _CSM: address(csm), - stETH: locator.lido(), - oracle: address(feeOracle), - accounting: address(accounting) - }); - feeOracle.initialize({ - initializationEpoch: uint64(INITIALIZATION_EPOCH), - reportInterval: 6300, // 28 days - _feeDistributor: address(feeDistributor), - admin: deployerAddress - }); - accounting.setFeeDistributor(address(feeDistributor)); - // TODO: csm.setBondManager(address(accounting)); - - vm.stopBroadcast(); - } -} diff --git a/script/DeployBase.s.sol b/script/DeployBase.s.sol new file mode 100644 index 00000000..310d9d3d --- /dev/null +++ b/script/DeployBase.s.sol @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import "forge-std/Script.sol"; + +import { HashConsensus } from "../lib/base-oracle/oracle/HashConsensus.sol"; +import { OssifiableProxy } from "../lib/proxy/OssifiableProxy.sol"; +import { CSModule } from "../src/CSModule.sol"; +import { CSAccounting, IWstETH } from "../src/CSAccounting.sol"; +import { CSFeeDistributor } from "../src/CSFeeDistributor.sol"; +import { CSFeeOracle } from "../src/CSFeeOracle.sol"; + +import { ILidoLocator } from "../src/interfaces/ILidoLocator.sol"; + +abstract contract DeployBase is Script { + // TODO: some contracts of the module probably should be deployed behind a proxy + uint256 immutable CHAIN_ID; + uint256 immutable SECONDS_PER_SLOT; + uint256 immutable SLOTS_PER_EPOCH; + uint256 immutable CL_GENESIS_TIME; + uint256 immutable INITIALIZATION_EPOCH; + address immutable LIDO_LOCATOR_ADDRESS; + address immutable WSTETH_ADDRESS; + + ILidoLocator private locator; + IWstETH private wstETH; + + address private deployer; + uint256 private pk; + + error ChainIdMismatch(uint256 actual, uint256 expected); + + constructor( + uint256 chainId, + uint256 secondsPerSlot, + uint256 slotsPerEpoch, + uint256 clGenesisTime, + uint256 initializationEpoch, + address lidoLocatorAddress, + address wstETHAddress + ) { + CHAIN_ID = chainId; + SECONDS_PER_SLOT = secondsPerSlot; + SLOTS_PER_EPOCH = slotsPerEpoch; + CL_GENESIS_TIME = clGenesisTime; + INITIALIZATION_EPOCH = initializationEpoch; + LIDO_LOCATOR_ADDRESS = lidoLocatorAddress; + WSTETH_ADDRESS = wstETHAddress; + + vm.label(LIDO_LOCATOR_ADDRESS, "LIDO_LOCATOR"); + vm.label(WSTETH_ADDRESS, "WSTETH"); + + locator = ILidoLocator(LIDO_LOCATOR_ADDRESS); + wstETH = IWstETH(WSTETH_ADDRESS); + } + + function run() external { + if (CHAIN_ID != block.chainid) { + revert ChainIdMismatch({ + actual: block.chainid, + expected: CHAIN_ID + }); + } + + pk = vm.envUint("DEPLOYER_PRIVATE_KEY"); + deployer = vm.addr(pk); + vm.label(deployer, "DEPLOYER"); + + vm.startBroadcast(pk); + { + CSModule csm = new CSModule({ + moduleType: "community-staking-module", + locator: address(locator) + }); + CSAccounting accounting = new CSAccounting({ + commonBondSize: 2 ether, + admin: deployer, + lidoLocator: address(locator), + communityStakingModule: address(csm), + wstETH: address(wstETH), + // todo: arguable. should be discussed + _blockedBondRetentionPeriod: 8 weeks, + _blockedBondManagementPeriod: 1 weeks + }); + + CSFeeOracle oracleImpl = new CSFeeOracle({ + secondsPerSlot: SECONDS_PER_SLOT, + genesisTime: CL_GENESIS_TIME + }); + + CSFeeOracle oracle = CSFeeOracle( + _deployProxy({ + admin: deployer, + implementation: address(oracleImpl) + }) + ); + + CSFeeDistributor feeDistributor = new CSFeeDistributor({ + _CSM: address(csm), + stETH: locator.lido(), + oracle: address(oracle), + accounting: address(accounting) + }); + accounting.setFeeDistributor(address(feeDistributor)); + // TODO: csm.setBondManager(address(accounting)); + + HashConsensus hashConsensus = new HashConsensus({ + slotsPerEpoch: SLOTS_PER_EPOCH, + secondsPerSlot: SECONDS_PER_SLOT, + genesisTime: CL_GENESIS_TIME, + epochsPerFrame: 225 * 28, // 28 days + fastLaneLengthSlots: 0, + admin: deployer, + reportProcessor: address(oracle) + }); + hashConsensus.updateInitialEpoch(INITIALIZATION_EPOCH); + + oracle.initialize({ + admin: deployer, + feeDistributorContract: address(feeDistributor), + consensusContract: address(hashConsensus), + consensusVersion: 1, + lastProcessingRefSlot: _refSlotFromEpoch(INITIALIZATION_EPOCH) + }); + } + + vm.stopBroadcast(); + } + + function _deployProxy( + address admin, + address implementation + ) internal returns (address) { + OssifiableProxy proxy = new OssifiableProxy({ + implementation_: implementation, + data_: new bytes(0), + admin_: admin + }); + + return address(proxy); + } + + function _deployGateSeal() internal returns (address) { + // PAUSE_ROLE for some contracts should be granted to GateSeals + revert("Not yet implemented"); + } + + function _refSlotFromEpoch(uint256 epoch) internal view returns (uint256) { + return epoch * SLOTS_PER_EPOCH - 1; + } +} diff --git a/script/DeployGoerli.s.sol b/script/DeployGoerli.s.sol new file mode 100644 index 00000000..b5c51794 --- /dev/null +++ b/script/DeployGoerli.s.sol @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import { DeployBase } from "./DeployBase.s.sol"; + +contract DeployGoerli is DeployBase { + constructor() + DeployBase( + // chainId + 5, + // secondsPerSlot + 12, + // slotsPerEpoch + 32, + // clGenesisTime + 1614588812, + // initializationEpoch + 215502, + // lidoLocatorAddress + 0x1eDf09b5023DC86737b59dE68a8130De878984f5, + // wstETHAddress + 0x6320cD32aA674d2898A68ec82e869385Fc5f7E2f + ) + {} +} diff --git a/script/DeployHolesky.s.sol b/script/DeployHolesky.s.sol new file mode 100644 index 00000000..a297f81b --- /dev/null +++ b/script/DeployHolesky.s.sol @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import { DeployBase } from "./DeployBase.s.sol"; + +contract DeployHolesky is DeployBase { + constructor() + DeployBase( + // chainId + 17000, + // secondsPerSlot + 12, + // slotsPerEpoch + 32, + // clGenesisTime + 1695902100, + // initializationEpoch + 8888, + // lidoLocatorAddress + 0x28FAB2059C713A7F9D8c86Db49f9bb0e96Af1ef8, + // wstETHAddress + 0x8d09a4502Cc8Cf1547aD300E066060D043f6982D + ) + {} +} diff --git a/script/DeployMainnetish.s.sol b/script/DeployMainnetish.s.sol new file mode 100644 index 00000000..b67813cd --- /dev/null +++ b/script/DeployMainnetish.s.sol @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import { DeployBase } from "./DeployBase.s.sol"; + +contract DeployMainnetish is DeployBase { + constructor() + DeployBase( + // chainId + 1, + // secondsPerSlot + 12, + // slotsPerEpoch + 32, + // clGenesisTime + 1606824023, + // initializationEpoch + 239853, + // lidoLocatorAddress + 0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb, + // wstETHAddress + 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 + ) + {} +} diff --git a/src/CSAccounting.sol b/src/CSAccounting.sol index bf229d58..20c013a7 100644 --- a/src/CSAccounting.sol +++ b/src/CSAccounting.sol @@ -28,11 +28,6 @@ contract CSAccountingBase { address from, uint256 amount ); - event BondPenalized( - uint256 indexed nodeOperatorId, - uint256 penaltyShares, - uint256 burnedShares - ); event StETHRewardsClaimed( uint256 indexed nodeOperatorId, address to, @@ -48,6 +43,29 @@ contract CSAccountingBase { address to, uint256 amount ); + event ELRewardsStealingPenaltyInitiated( + uint256 indexed nodeOperatorId, + uint256 proposedBlockNumber, + uint256 stolenAmount + ); + event BlockedBondChanged( + uint256 indexed nodeOperatorId, + uint256 newAmountETH, + uint256 retentionUntil + ); + event BlockedBondCompensated( + uint256 indexed nodeOperatorId, + uint256 amountETH + ); + event BlockedBondReleased( + uint256 indexed nodeOperatorId, + uint256 amountETH + ); + event BondPenalized( + uint256 indexed nodeOperatorId, + uint256 penaltyETH, + uint256 coveringETH + ); } contract CSAccounting is CSAccountingBase, AccessControlEnumerable { @@ -58,35 +76,58 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { bytes32 r; bytes32 s; } + struct BlockedBond { + uint256 ETHAmount; + uint256 retentionUntil; + } - bytes32 public constant PENALIZE_BOND_ROLE = - keccak256("PENALIZE_BOND_ROLE"); + bytes32 public constant INSTANT_PENALIZE_BOND_ROLE = + keccak256("INSTANT_PENALIZE_BOND_ROLE"); + bytes32 public constant EL_REWARDS_STEALING_PENALTY_INIT_ROLE = + keccak256("EL_REWARDS_STEALING_PENALTY_INIT_ROLE"); + bytes32 public constant EL_REWARDS_STEALING_PENALTY_SETTLE_ROLE = + keccak256("EL_REWARDS_STEALING_PENALTY_SETTLE_ROLE"); + + // todo: should be reconsidered + uint256 public constant MIN_BLOCKED_BOND_RETENTION_PERIOD = 4 weeks; + uint256 public constant MAX_BLOCKED_BOND_RETENTION_PERIOD = 365 days; + uint256 public constant MIN_BLOCKED_BOND_MANAGEMENT_PERIOD = 1 days; + uint256 public constant MAX_BLOCKED_BOND_MANAGEMENT_PERIOD = 7 days; + + uint256 public immutable COMMON_BOND_SIZE; ILidoLocator private immutable LIDO_LOCATOR; ICSModule private immutable CSM; IWstETH private immutable WSTETH; - uint256 private immutable COMMON_BOND_SIZE; address public FEE_DISTRIBUTOR; uint256 public totalBondShares; - mapping(uint256 => uint256) private _bondShares; + uint256 public blockedBondRetentionPeriod; + uint256 public blockedBondManagementPeriod; + + mapping(uint256 => uint256) internal _bondShares; + mapping(uint256 => BlockedBond) internal _blockedBondEther; error NotOwnerToClaim(address msgSender, address owner); + error InvalidBlockedBondRetentionPeriod(); + error InvalidStolenAmount(); /// @param commonBondSize common bond size in ETH for all node operators. /// @param admin admin role member address /// @param lidoLocator lido locator contract address /// @param wstETH wstETH contract address /// @param communityStakingModule community staking module contract address - /// @param penalizeRoleMembers list of addresses with PENALIZE_BOND_ROLE + /// @param _blockedBondRetentionPeriod retention period for blocked bond in seconds + /// @param _blockedBondManagementPeriod management period for blocked bond in seconds constructor( uint256 commonBondSize, address admin, address lidoLocator, address wstETH, address communityStakingModule, - address[] memory penalizeRoleMembers + uint256 _blockedBondRetentionPeriod, + uint256 _blockedBondManagementPeriod ) { // check zero addresses require(admin != address(0), "admin is zero address"); @@ -96,25 +137,20 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { "community staking module is zero address" ); require(wstETH != address(0), "wstETH is zero address"); - require( - penalizeRoleMembers.length > 0, - "penalize role members is empty" + _validateBlockedBondPeriods( + _blockedBondRetentionPeriod, + _blockedBondManagementPeriod ); - _setupRole(DEFAULT_ADMIN_ROLE, admin); - for (uint256 i; i < penalizeRoleMembers.length; ++i) { - require( - penalizeRoleMembers[i] != address(0), - "penalize role member is zero address" - ); - _setupRole(PENALIZE_BOND_ROLE, penalizeRoleMembers[i]); - } LIDO_LOCATOR = ILidoLocator(lidoLocator); CSM = ICSModule(communityStakingModule); WSTETH = IWstETH(wstETH); COMMON_BOND_SIZE = commonBondSize; + + blockedBondRetentionPeriod = _blockedBondRetentionPeriod; + blockedBondManagementPeriod = _blockedBondManagementPeriod; } function setFeeDistributor( @@ -123,6 +159,29 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { FEE_DISTRIBUTOR = fdAddress; } + function setBlockedBondPeriods( + uint256 retention, + uint256 management + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + _validateBlockedBondPeriods(retention, management); + blockedBondRetentionPeriod = retention; + blockedBondManagementPeriod = management; + } + + function _validateBlockedBondPeriods( + uint256 retention, + uint256 management + ) internal pure { + if ( + retention < MIN_BLOCKED_BOND_RETENTION_PERIOD || + retention > MAX_BLOCKED_BOND_RETENTION_PERIOD || + management < MIN_BLOCKED_BOND_MANAGEMENT_PERIOD || + management > MAX_BLOCKED_BOND_MANAGEMENT_PERIOD + ) { + revert InvalidBlockedBondRetentionPeriod(); + } + } + /// @notice Returns the bond shares for the given node operator. /// @param nodeOperatorId id of the node operator to get bond for. /// @return bond shares. @@ -219,7 +278,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { /// @return missing bond ETH. function getMissingBondETH( uint256 nodeOperatorId - ) public view returns (uint256) { + ) public view onlyExistingNodeOperator(nodeOperatorId) returns (uint256) { (uint256 current, uint256 required) = _bondETHSummary(nodeOperatorId); return required > current ? required - current : 0; } @@ -242,6 +301,18 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { return WSTETH.getWstETHByStETH(getMissingBondStETH(nodeOperatorId)); } + /// @notice Returns the amount of ETH blocked by the given node operator. + function getBlockedBondETH( + uint256 nodeOperatorId + ) public view returns (uint256) { + if ( + _blockedBondEther[nodeOperatorId].retentionUntil >= block.timestamp + ) { + return _blockedBondEther[nodeOperatorId].ETHAmount; + } + return 0; + } + /// @notice Returns the required bond ETH (inc. missed and excess) for the given node operator to upload new keys. /// @param nodeOperatorId id of the node operator to get required bond for. /// @return required bond ETH. @@ -362,13 +433,13 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { function depositETH( address from, uint256 nodeOperatorId - ) external payable returns (uint256) { + ) + external + payable + onlyExistingNodeOperator(nodeOperatorId) + returns (uint256) + { from = (from == address(0)) ? msg.sender : from; - // TODO: should be modifier. condition might be changed as well - require( - nodeOperatorId < CSM.getNodeOperatorsCount(), - "node operator does not exist" - ); uint256 shares = _lido().submit{ value: msg.value }(address(0)); _bondShares[nodeOperatorId] += shares; totalBondShares += shares; @@ -383,7 +454,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { address from, uint256 nodeOperatorId, uint256 stETHAmount - ) external returns (uint256) { + ) external onlyExistingNodeOperator(nodeOperatorId) returns (uint256) { from = (from == address(0)) ? msg.sender : from; return _depositStETH(from, nodeOperatorId, stETHAmount); } @@ -397,7 +468,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId, uint256 stETHAmount, PermitInput calldata permit - ) external returns (uint256) { + ) external onlyExistingNodeOperator(nodeOperatorId) returns (uint256) { from = (from == address(0)) ? msg.sender : from; // solhint-disable-next-line func-named-parameters _lido().permit( @@ -417,10 +488,6 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId, uint256 stETHAmount ) internal returns (uint256) { - require( - nodeOperatorId < CSM.getNodeOperatorsCount(), - "node operator does not exist" - ); uint256 shares = _sharesByEth(stETHAmount); _lido().transferSharesFrom(from, address(this), shares); _bondShares[nodeOperatorId] += shares; @@ -437,7 +504,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { address from, uint256 nodeOperatorId, uint256 wstETHAmount - ) external returns (uint256) { + ) external onlyExistingNodeOperator(nodeOperatorId) returns (uint256) { from = (from == address(0)) ? msg.sender : from; return _depositWstETH(from, nodeOperatorId, wstETHAmount); } @@ -452,7 +519,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId, uint256 wstETHAmount, PermitInput calldata permit - ) external returns (uint256) { + ) external onlyExistingNodeOperator(nodeOperatorId) returns (uint256) { // solhint-disable-next-line func-named-parameters WSTETH.permit( from, @@ -471,10 +538,6 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId, uint256 wstETHAmount ) internal returns (uint256) { - require( - nodeOperatorId < CSM.getNodeOperatorsCount(), - "node operator does not exist" - ); WSTETH.transferFrom(from, address(this), wstETHAmount); uint256 stETHAmount = WSTETH.unwrap(wstETHAmount); uint256 shares = _sharesByEth(stETHAmount); @@ -494,7 +557,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId, uint256 cumulativeFeeShares, uint256 stETHAmount - ) external { + ) external onlyExistingNodeOperator(nodeOperatorId) { ( address managerAddress, address rewardAddress @@ -532,7 +595,7 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId, uint256 cumulativeFeeShares, uint256 wstETHAmount - ) external { + ) external onlyExistingNodeOperator(nodeOperatorId) { ( address managerAddress, address rewardAddress @@ -568,7 +631,11 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId, uint256 cumulativeFeeShares, uint256 ETHAmount - ) external returns (uint256[] memory requestIds) { + ) + external + onlyExistingNodeOperator(nodeOperatorId) + returns (uint256[] memory requestIds) + { ( address managerAddress, address rewardAddress @@ -579,6 +646,10 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { nodeOperatorId, cumulativeFeeShares ); + if (claimableShares == 0) { + emit ETHRewardsRequested(nodeOperatorId, rewardAddress, 0); + return requestIds; + } uint256 toClaim = ETHAmount < _ethByShares(claimableShares) ? _sharesByEth(ETHAmount) : claimableShares; @@ -594,23 +665,156 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { return requestIds; } - /// @notice Penalize bond by burning shares - /// @param nodeOperatorId id of the node operator to penalize bond for. - /// @param shares amount shares to burn. + /// @notice Reports EL rewards stealing for the given node operator. + /// @param nodeOperatorId id of the node operator to report EL rewards stealing for. + /// @param blockNumber consensus layer block number of the proposed block with EL rewards stealing. + /// @param amount amount of stolen EL rewards. + function initELRewardsStealingPenalty( + uint256 nodeOperatorId, + uint256 blockNumber, + uint256 amount + ) + external + onlyRole(EL_REWARDS_STEALING_PENALTY_INIT_ROLE) + onlyExistingNodeOperator(nodeOperatorId) + { + if (amount == 0) { + revert InvalidStolenAmount(); + } + emit ELRewardsStealingPenaltyInitiated( + nodeOperatorId, + blockNumber, + amount + ); + _changeBlockedBondState({ + nodeOperatorId: nodeOperatorId, + ETHAmount: _blockedBondEther[nodeOperatorId].ETHAmount + amount, + retentionUntil: block.timestamp + blockedBondRetentionPeriod + }); + } + + /// @notice Releases blocked bond ETH for the given node operator. + /// @param nodeOperatorId id of the node operator to release blocked bond for. + /// @param amount amount of ETH to release. + function releaseBlockedBondETH( + uint256 nodeOperatorId, + uint256 amount + ) + external + onlyRole(EL_REWARDS_STEALING_PENALTY_INIT_ROLE) + onlyExistingNodeOperator(nodeOperatorId) + { + emit BlockedBondReleased(nodeOperatorId, amount); + _reduceBlockedBondETH(nodeOperatorId, amount); + } + + /// @notice Compensates blocked bond ETH for the given node operator. + /// @param nodeOperatorId id of the node operator to compensate blocked bond for. + function compensateBlockedBondETH( + uint256 nodeOperatorId + ) external payable onlyExistingNodeOperator(nodeOperatorId) { + require(msg.value > 0, "value should be greater than zero"); + payable(LIDO_LOCATOR.elRewardsVault()).transfer(msg.value); + emit BlockedBondCompensated(nodeOperatorId, msg.value); + _reduceBlockedBondETH(nodeOperatorId, msg.value); + } + + function _reduceBlockedBondETH( + uint256 nodeOperatorId, + uint256 amount + ) internal { + uint256 blocked = getBlockedBondETH(nodeOperatorId); + require(blocked > 0, "no blocked bond to release"); + require( + _blockedBondEther[nodeOperatorId].ETHAmount >= amount, + "blocked bond is less than amount to release" + ); + _changeBlockedBondState( + nodeOperatorId, + _blockedBondEther[nodeOperatorId].ETHAmount - amount, + _blockedBondEther[nodeOperatorId].retentionUntil + ); + } + + /// @dev Should be called by the committee. Doesn't settle blocked bond if it is in the safe frame (1 day) + /// @notice Settles blocked bond for the given node operators. + /// @param nodeOperatorIds ids of the node operators to settle blocked bond for. + function settleBlockedBondETH( + uint256[] memory nodeOperatorIds + ) external onlyRole(EL_REWARDS_STEALING_PENALTY_SETTLE_ROLE) { + uint256 nosCount = CSM.getNodeOperatorsCount(); + for (uint256 i; i < nodeOperatorIds.length; ++i) { + uint256 nodeOperatorId = nodeOperatorIds[i]; + BlockedBond storage blockedBond = _blockedBondEther[nodeOperatorId]; + if ( + block.timestamp + + blockedBondRetentionPeriod - + blockedBond.retentionUntil < + blockedBondManagementPeriod + ) { + // blocked bond in safe frame to manage it by committee or node operator + continue; + } + uint256 uncovered; + if ( + blockedBond.ETHAmount > 0 && + blockedBond.retentionUntil >= block.timestamp + ) { + uncovered = _penalize(nodeOperatorId, blockedBond.ETHAmount); + } + _changeBlockedBondState({ + nodeOperatorId: nodeOperatorId, + ETHAmount: uncovered, + retentionUntil: blockedBond.retentionUntil + }); + } + } + + function _changeBlockedBondState( + uint256 nodeOperatorId, + uint256 ETHAmount, + uint256 retentionUntil + ) internal { + if (ETHAmount == 0) { + delete _blockedBondEther[nodeOperatorId]; + emit BlockedBondChanged(nodeOperatorId, 0, 0); + return; + } + _blockedBondEther[nodeOperatorId] = BlockedBond({ + ETHAmount: ETHAmount, + retentionUntil: retentionUntil + }); + emit BlockedBondChanged(nodeOperatorId, ETHAmount, retentionUntil); + } + + /// @notice Penalize bond by burning shares of the given node operator. function penalize( uint256 nodeOperatorId, - uint256 shares - ) external onlyRole(PENALIZE_BOND_ROLE) { - uint256 currentBond = getBondShares(nodeOperatorId); - uint256 coveringShares = shares < currentBond ? shares : currentBond; + uint256 ETHAmount + ) public onlyRole(INSTANT_PENALIZE_BOND_ROLE) { + _penalize(nodeOperatorId, ETHAmount); + } + + function _penalize( + uint256 nodeOperatorId, + uint256 ETHAmount + ) internal onlyExistingNodeOperator(nodeOperatorId) returns (uint256) { + uint256 penaltyShares = _sharesByEth(ETHAmount); + uint256 currentShares = getBondShares(nodeOperatorId); + uint256 sharesToBurn = penaltyShares < currentShares + ? penaltyShares + : currentShares; _lido().transferSharesFrom( address(this), LIDO_LOCATOR.burner(), - coveringShares + sharesToBurn ); - _bondShares[nodeOperatorId] -= coveringShares; - totalBondShares -= coveringShares; - emit BondPenalized(nodeOperatorId, shares, coveringShares); + _bondShares[nodeOperatorId] -= sharesToBurn; + totalBondShares -= sharesToBurn; + uint256 penaltyEth = _ethByShares(penaltyShares); + uint256 coveringEth = _ethByShares(sharesToBurn); + emit BondPenalized(nodeOperatorId, penaltyEth, coveringEth); + return penaltyEth - coveringEth; } function _lido() internal view returns (ILido) { @@ -673,18 +877,22 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 nodeOperatorId ) internal view returns (uint256 current, uint256 required) { current = _ethByShares(getBondShares(nodeOperatorId)); - required = getRequiredBondETHForKeys( - _getNodeOperatorActiveKeys(nodeOperatorId) - ); + required = + getRequiredBondETHForKeys( + _getNodeOperatorActiveKeys(nodeOperatorId) + ) + + getBlockedBondETH(nodeOperatorId); } function _bondSharesSummary( uint256 nodeOperatorId ) internal view returns (uint256 current, uint256 required) { current = getBondShares(nodeOperatorId); - required = _getRequiredBondSharesForKeys( - _getNodeOperatorActiveKeys(nodeOperatorId) - ); + required = + _getRequiredBondSharesForKeys( + _getNodeOperatorActiveKeys(nodeOperatorId) + ) + + _sharesByEth(getBlockedBondETH(nodeOperatorId)); } function _sharesByEth(uint256 ethAmount) internal view returns (uint256) { @@ -694,4 +902,12 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { function _ethByShares(uint256 shares) internal view returns (uint256) { return _lido().getPooledEthByShares(shares); } + + modifier onlyExistingNodeOperator(uint256 nodeOperatorId) { + require( + nodeOperatorId < CSM.getNodeOperatorsCount(), + "node operator does not exist" + ); + _; + } } diff --git a/src/CSFeeDistributor.sol b/src/CSFeeDistributor.sol index 9a72695b..345a0c50 100644 --- a/src/CSFeeDistributor.sol +++ b/src/CSFeeDistributor.sol @@ -3,21 +3,37 @@ pragma solidity 0.8.21; import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; -import { CSFeeDistributorBase } from "./CSFeeDistributorBase.sol"; - +import { ICSFeeDistributor } from "./interfaces/ICSFeeDistributor.sol"; import { ICSFeeOracle } from "./interfaces/ICSFeeOracle.sol"; import { IStETH } from "./interfaces/IStETH.sol"; /// @author madlabman -contract CSFeeDistributor is CSFeeDistributorBase { +contract CSFeeDistributorBase { + /// @dev Emitted when fees are distributed + event FeeDistributed(uint256 indexed nodeOperatorId, uint256 shares); + + error ZeroAddress(string field); + + error NotBondManager(); + error NotOracle(); + + error InvalidShares(); + error InvalidProof(); +} + +/// @author madlabman +contract CSFeeDistributor is ICSFeeDistributor, CSFeeDistributorBase { + using SafeCast for uint256; + address public immutable CSM; address public immutable STETH; address public immutable ORACLE; address public immutable ACCOUNTING; /// @notice Amount of shares sent to the BondManager in favor of the NO - mapping(uint64 => uint64) public distributedShares; + mapping(uint256 => uint256) public distributedShares; constructor( address _CSM, @@ -38,46 +54,50 @@ contract CSFeeDistributor is CSFeeDistributorBase { /// @notice Returns the amount of shares that can be distributed in favor of the NO /// @param proof Merkle proof of the leaf - /// @param noIndex Index of the NO + /// @param nodeOperatorId ID of the NO /// @param shares Total amount of shares earned as fees function getFeesToDistribute( bytes32[] calldata proof, - uint64 noIndex, - uint64 shares - ) public view returns (uint64) { + uint256 nodeOperatorId, + uint256 shares + ) public view returns (uint256) { bool isValid = MerkleProof.verifyCalldata( proof, - ICSFeeOracle(ORACLE).reportRoot(), - ICSFeeOracle(ORACLE).hashLeaf(noIndex, shares) + ICSFeeOracle(ORACLE).treeRoot(), + ICSFeeOracle(ORACLE).hashLeaf(nodeOperatorId, shares) ); if (!isValid) revert InvalidProof(); - if (distributedShares[noIndex] > shares) { + if (distributedShares[nodeOperatorId] > shares) { revert InvalidShares(); } - return shares - distributedShares[noIndex]; + return shares - distributedShares[nodeOperatorId]; } /// @notice Distribute fees to the BondManager in favor of the NO /// @param proof Merkle proof of the leaf - /// @param noIndex Index of the NO + /// @param nodeOperatorId ID of the NO /// @param shares Total amount of shares earned as fees function distributeFees( bytes32[] calldata proof, - uint64 noIndex, - uint64 shares - ) external returns (uint64) { + uint256 nodeOperatorId, + uint256 shares + ) external returns (uint256) { if (msg.sender != ACCOUNTING) revert NotBondManager(); - uint64 sharesToDistribute = getFeesToDistribute(proof, noIndex, shares); + uint256 sharesToDistribute = getFeesToDistribute( + proof, + nodeOperatorId, + shares + ); if (sharesToDistribute == 0) { // To avoid breaking claim rewards logic return 0; } - distributedShares[noIndex] += sharesToDistribute; + distributedShares[nodeOperatorId] += sharesToDistribute; IStETH(STETH).transferShares(ACCOUNTING, sharesToDistribute); - emit FeeDistributed(noIndex, sharesToDistribute); + emit FeeDistributed(nodeOperatorId, sharesToDistribute); return sharesToDistribute; } diff --git a/src/CSFeeDistributorBase.sol b/src/CSFeeDistributorBase.sol deleted file mode 100644 index fc21a9d5..00000000 --- a/src/CSFeeDistributorBase.sol +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Lido -// SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.21; - -/// @author madlabman -contract CSFeeDistributorBase { - /// @dev Emitted when fees are distributed - event FeeDistributed(uint64 indexed noIndex, uint64 shares); - - error ZeroAddress(string field); - - error NotBondManager(); - error NotOracle(); - - error InvalidShares(); - error InvalidProof(); -} diff --git a/src/CSFeeOracle.sol b/src/CSFeeOracle.sol index 9ae523e6..7bc7e2d8 100644 --- a/src/CSFeeOracle.sol +++ b/src/CSFeeOracle.sol @@ -1,273 +1,187 @@ // SPDX-FileCopyrightText: 2023 Lido // SPDX-License-Identifier: GPL-3.0 + pragma solidity 0.8.21; -import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; -import { AccessControlEnumerable } from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import { PausableUntil } from "base-oracle/utils/PausableUntil.sol"; +import { BaseOracle } from "base-oracle/oracle/BaseOracle.sol"; -import { CSFeeOracleBase } from "./CSFeeOracleBase.sol"; +import { ICSFeeDistributor } from "./interfaces/ICSFeeDistributor.sol"; +import { ICSFeeOracle } from "./interfaces/ICSFeeOracle.sol"; -interface IFeeDistributor { - function receiveFees(uint256 shares) external; -} +contract CSFeeOracle is ICSFeeOracle, BaseOracle, PausableUntil { + struct ReportData { + /// @dev Version of the oracle consensus rules. Current version expected + /// by the oracle can be obtained by calling getConsensusVersion(). + uint256 consensusVersion; + /// @dev Reference slot for which the report was calculated. If the slot + /// contains a block, the state being reported should include all state + /// changes resulting from that block. The epoch containing the slot + /// should be finalized prior to calculating the report. + uint256 refSlot; + /// @notice Merkle Tree root + bytes32 treeRoot; + /// @notice CID of the published Merkle tree + string treeCid; + /// @notice Total amount of fees distributed + uint256 distributed; + } + + /// @notice An ACL role granting the permission to submit the data for a committee report. + bytes32 public constant MANAGE_FEE_DISTRIBUTOR_CONTRACT_ROLE = + keccak256("MANAGE_FEE_DISTRIBUTOR_CONTRACT_ROLE"); -/// @author madlabman -contract CSFeeOracle is CSFeeOracleBase, AccessControlEnumerable { - bytes32 public constant ORACLE_MEMBER_ROLE = - keccak256("ORACLE_MEMBER_ROLE"); + /// @notice An ACL role granting the permission to submit the data for a committee report. + bytes32 public constant SUBMIT_DATA_ROLE = keccak256("SUBMIT_DATA_ROLE"); - uint64 public immutable SECONDS_PER_BLOCK; - uint64 public immutable BLOCKS_PER_EPOCH; - uint64 public immutable GENESIS_TIME; + /// @notice An ACL role granting the permission to pause accepting oracle reports + bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); + + /// @notice An ACL role granting the permission to resume accepting oracle reports + bytes32 public constant RESUME_ROLE = keccak256("RESUME_ROLE"); + + ICSFeeDistributor public feeDistributor; /// @notice Merkle Tree root - bytes32 public reportRoot; + bytes32 public treeRoot; /// @notice CID of the published Merkle tree string public treeCid; - /// @notice Map to track the amount of submissions for the given report hash - mapping(bytes32 => address[]) public submissions; - - /// @notice Number of reports that must match to consolidate a new report - /// root (N/M) - uint64 public quorum; + /// @dev Emitted when a new fee distributor contract is set + event FeeDistributorContractSet(address feeDistributorContract); - address public feeDistributor; - - /// @dev Make it possible to traverse report intervals - uint64 public prevConsolidatedEpoch; - uint64 public lastConsolidatedEpoch; - /// @notice Interval between reports - uint64 public reportIntervalEpochs; + /// @dev Emitted when a report is consolidated + event ReportConsolidated( + uint256 indexed refSlot, + uint256 distributed, + bytes32 newRoot, + string treeCid + ); - bool public initialized; + error TreeRootCannotBeZero(); + error TreeCidCannotBeEmpty(); + error NothingToDistribute(); + error AdminCannotBeZero(); + error SenderNotAllowed(); constructor( - uint64 secondsPerBlock, - uint64 blocksPerEpoch, - uint64 genesisTime - ) { - if (genesisTime > block.timestamp) { - revert GenesisTimeNotReached(); - } + uint256 secondsPerSlot, + uint256 genesisTime + ) BaseOracle(secondsPerSlot, genesisTime) {} - SECONDS_PER_BLOCK = secondsPerBlock; - BLOCKS_PER_EPOCH = blocksPerEpoch; - GENESIS_TIME = genesisTime; - } - - /// @notice Initialize the contract function initialize( - uint64 initializationEpoch, - uint64 reportInterval, - address _feeDistributor, - address admin + address admin, + address feeDistributorContract, + address consensusContract, + uint256 consensusVersion, + uint256 lastProcessingRefSlot // will be the first ref slot in getConsensusReport() ) external { - if (initialized) revert AlreadyInitialized(); - - if (admin == address(0)) revert ZeroAddress("admin"); + if (admin == address(0)) revert AdminCannotBeZero(); _setupRole(DEFAULT_ADMIN_ROLE, admin); - if (_feeDistributor == address(0)) { - revert ZeroAddress("_feeDistributor"); - } - feeDistributor = _feeDistributor; - - prevConsolidatedEpoch = initializationEpoch; - lastConsolidatedEpoch = initializationEpoch; - - _setReportInterval(reportInterval); - - initialized = true; - } - - /// @notice Get current epoch - function currentEpoch() public view returns (uint64) { - return - (SafeCast.toUint64(block.timestamp) - GENESIS_TIME) / - SECONDS_PER_BLOCK / - BLOCKS_PER_EPOCH; - } - - /// @notice Returns the next epoch to report - function nextReportEpoch() public view onlyInitializied returns (uint64) { - // NOTE: underflow is expected here when lastConsolidatedEpoch in the future - uint64 epochsElapsed = currentEpoch() - lastConsolidatedEpoch; - if (epochsElapsed < reportIntervalEpochs) { - return lastConsolidatedEpoch + reportIntervalEpochs; - } - - uint64 fullIntervals = epochsElapsed / reportIntervalEpochs; - return lastConsolidatedEpoch + reportIntervalEpochs * fullIntervals; - } - - /// @notice Get the current report frame slots - function reportFrame() - external - view - onlyInitializied - returns (uint64, uint64) - { - return ( - lastConsolidatedEpoch * BLOCKS_PER_EPOCH + 1, - nextReportEpoch() * BLOCKS_PER_EPOCH + BaseOracle._initialize( + consensusContract, + consensusVersion, + lastProcessingRefSlot ); + /// @dev _setFeeDistributorContract() reverts if zero address + _setFeeDistributorContract(feeDistributorContract); + } + + function setFeeDistributorContract( + address feeDistributorContract + ) external onlyRole(MANAGE_FEE_DISTRIBUTOR_CONTRACT_ROLE) { + _setFeeDistributorContract(feeDistributorContract); + } + + function submitReportData( + ReportData calldata data, + uint256 contractVersion + ) external whenResumed { + _checkMsgSenderIsAllowedToSubmitData(); + _checkContractVersion(contractVersion); + _checkConsensusData( + data.refSlot, + data.consensusVersion, + // it's a waste of gas to copy the whole calldata into mem but seems there's no way around + keccak256(abi.encode(data)) + ); + _startProcessing(); + _handleConsensusReportData(data); } - /// @notice Set the report interval - /// @param reportInterval Interval between reports in epochs - function setReportInterval( - uint64 reportInterval - ) external onlyRole(DEFAULT_ADMIN_ROLE) { - _setReportInterval(reportInterval); - emit ReportIntervalSet(reportInterval); + function resume() external whenPaused onlyRole(RESUME_ROLE) { + _resume(); } - function _setReportInterval(uint64 reportInterval) internal { - if (reportInterval == 0) revert ZeroInterval(); - reportIntervalEpochs = reportInterval; - } - - /// @notice Submit a report for a new report root - /// If the quorum is reached, consolidate the report root - /// @param epoch Epoch number - /// @param newRoot Proposed report root - /// @param _treeCid CID of the published Merkle tree - function submitReport( - uint64 epoch, - bytes32 newRoot, - uint256 distributed, - string memory _treeCid - ) external onlyInitializied onlyRole(ORACLE_MEMBER_ROLE) whenNotPaused { - uint64 _currentEpoch = currentEpoch(); - if (_currentEpoch < epoch) { - revert ReportTooEarly(); - } - - if (epoch <= lastConsolidatedEpoch) { - revert ReportTooLate(); - } - - if (epoch != nextReportEpoch()) { - revert InvalidEpoch(epoch, nextReportEpoch()); - } - - // Get the current report - bytes32 reportHash = _getReportHash(epoch, newRoot, _treeCid); - - // Check for double vote - for (uint64 i; i < submissions[reportHash].length; ) { - if (msg.sender == submissions[reportHash][i]) { - revert DoubleVote(); - } - - unchecked { - i++; - } - } - - // Emit Submit report before check the quorum - emit ReportSubmitted(epoch, msg.sender, newRoot, _treeCid); - // Store submitted report with a new added vote - submissions[reportHash].push(msg.sender); - - // Check if it reaches the quorum - if (submissions[reportHash].length == quorum) { - delete submissions[reportHash]; - - // Consolidate report - - prevConsolidatedEpoch = lastConsolidatedEpoch; - lastConsolidatedEpoch = epoch; - reportRoot = newRoot; - treeCid = _treeCid; - - IFeeDistributor(feeDistributor).receiveFees(distributed); - emit ReportConsolidated(epoch, newRoot, distributed, _treeCid); - } - } - - /// @notice Get the report hash given the report root and slot - /// @param slot Slot - /// @param _reportRoot Report Merkle tree root - function _getReportHash( - uint64 slot, - bytes32 _reportRoot, - string memory _treeCid - ) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(slot, _reportRoot, _treeCid)); + function pauseFor(uint256 duration) external onlyRole(PAUSE_ROLE) { + _pauseFor(duration); } /// @notice Get a hash of a leaf - /// @param noIndex NO index + /// @param nodeOperatorId ID of the node operator /// @param shares Amount of shares /// @dev Double hash the leaf to prevent second preimage attacks function hashLeaf( - uint64 noIndex, - uint64 shares + uint256 nodeOperatorId, + uint256 shares ) public pure returns (bytes32) { return keccak256( - bytes.concat(keccak256(abi.encodePacked(noIndex, shares))) + bytes.concat(keccak256(abi.encode(nodeOperatorId, shares))) ); } - /// @notice Add a new oracle member - /// @param member Address of the new member - /// @param _quorum New quorum - function addMember( - address member, - uint64 _quorum - ) external onlyRole(DEFAULT_ADMIN_ROLE) { - // NOTE: check for the member existence? - grantRole(ORACLE_MEMBER_ROLE, member); - emit MemberAdded(member); - _setQuorum(_quorum); + function pauseUntil( + uint256 pauseUntilInclusive + ) external onlyRole(PAUSE_ROLE) { + _pauseUntil(pauseUntilInclusive); } - /// @notice Remove an oracle member - /// @param member Address of the member to remove - /// @param _quorum New quorum - function removeMember( - address member, - uint64 _quorum - ) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (!hasRole(ORACLE_MEMBER_ROLE, member)) revert NotMember(member); - revokeRole(ORACLE_MEMBER_ROLE, member); - emit MemberRemoved(member); - _setQuorum(_quorum); + function _setFeeDistributorContract( + address feeDistributorContract + ) internal { + if (feeDistributorContract == address(0)) revert AddressCannotBeZero(); + feeDistributor = ICSFeeDistributor(feeDistributorContract); + emit FeeDistributorContractSet(feeDistributorContract); } - /// @notice Set the quorum - /// @param _quorum New quorum - function setQuorum(uint64 _quorum) external onlyRole(DEFAULT_ADMIN_ROLE) { - _setQuorum(_quorum); + function _handleConsensusReport( + ConsensusReport memory /* report */, + uint256 /* prevSubmittedRefSlot */, + uint256 /* prevProcessingRefSlot */ + ) internal override { + // NOTE: if we implement sending all leafs directly, we probably will need to support the sending in batches, + // which means, we'll be ought to check the processing state and revert if not all data was send so far. + // For reference look at the ValidatorExitBusOracle. } - /// @notice Set the quorum - /// @param _quorum New quorum - function _setQuorum(uint64 _quorum) internal { - if (_quorum <= getRoleMemberCount(ORACLE_MEMBER_ROLE) / 2) { - revert QuorumTooSmall(); - } - - quorum = _quorum; - emit QuorumSet(_quorum); - } + function _handleConsensusReportData(ReportData calldata data) internal { + _reportDataSanityCheck(data); - /// @notice Unpause the contract - function unpause() public onlyRole(DEFAULT_ADMIN_ROLE) { - _unpause(); + feeDistributor.receiveFees(data.distributed); + treeRoot = data.treeRoot; + treeCid = data.treeCid; + emit ReportConsolidated( + data.refSlot, + data.distributed, + data.treeRoot, + data.treeCid + ); } - /// @notice Pause the contract - function pause() public onlyRole(DEFAULT_ADMIN_ROLE) { - _pause(); + function _reportDataSanityCheck(ReportData calldata data) internal pure { + if (bytes(data.treeCid).length == 0) revert TreeCidCannotBeEmpty(); + if (data.treeRoot == bytes32(0)) revert TreeRootCannotBeZero(); + if (data.distributed == 0) revert NothingToDistribute(); + // refSlot is checked by HashConsensus } - modifier onlyInitializied() { - if (!initialized) revert NotInitialized(); - _; + function _checkMsgSenderIsAllowedToSubmitData() internal view { + address sender = _msgSender(); + if (!hasRole(SUBMIT_DATA_ROLE, sender) && !_isConsensusMember(sender)) { + revert SenderNotAllowed(); + } } } diff --git a/src/CSFeeOracleBase.sol b/src/CSFeeOracleBase.sol deleted file mode 100644 index e726017e..00000000 --- a/src/CSFeeOracleBase.sol +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Lido -// SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.21; - -import { Pausable } from "@openzeppelin/contracts/security/Pausable.sol"; - -/// @author madlabman -contract CSFeeOracleBase is Pausable { - event ReportIntervalSet(uint64 reportInterval); - event MemberRemoved(address member); - event MemberAdded(address member); - event QuorumSet(uint64 quorum); - - /// @dev Emitted when a report is submitted - event ReportSubmitted( - uint256 indexed epoch, - address oracleMember, - bytes32 newRoot, - string treeCid - ); - - /// @dev Emitted when a report is consolidated - // forgefmt: disable-next-item - event ReportConsolidated( - uint256 indexed epoch, - bytes32 newRoot, - uint256 distributed, - string treeCid - ); - - error AlreadyMember(address member); - error NotMember(address member); - - error InvalidEpoch(uint64 actual, uint64 expected); - error ZeroAddress(string field); - error GenesisTimeNotReached(); - error AlreadyInitialized(); - error NotInitialized(); - error QuorumTooSmall(); - error ReportTooEarly(); - error ReportTooLate(); - error ZeroInterval(); - error DoubleVote(); -} diff --git a/src/CSModule.sol b/src/CSModule.sol index 67b8c9d0..9f571657 100644 --- a/src/CSModule.sol +++ b/src/CSModule.sol @@ -573,8 +573,10 @@ contract CSModule is IStakingModule, CSModuleBase { } } + // called when rewards minted for the module + // seems to be empty implementation due to oracle using csm balance for distribution function onRewardsMinted(uint256 /*_totalShares*/) external { - // TODO: implement + // TODO: staking router role only } function updateStuckValidatorsCount( diff --git a/src/interfaces/ICSFeeDistributor.sol b/src/interfaces/ICSFeeDistributor.sol index 36c11261..91e35a64 100644 --- a/src/interfaces/ICSFeeDistributor.sol +++ b/src/interfaces/ICSFeeDistributor.sol @@ -15,4 +15,6 @@ interface ICSFeeDistributor { uint256 noIndex, uint256 shares ) external returns (uint256); + + function receiveFees(uint256 shares) external; } diff --git a/src/interfaces/ICSFeeOracle.sol b/src/interfaces/ICSFeeOracle.sol index 18809c3c..57725196 100644 --- a/src/interfaces/ICSFeeOracle.sol +++ b/src/interfaces/ICSFeeOracle.sol @@ -4,8 +4,8 @@ pragma solidity 0.8.21; interface ICSFeeOracle { /// @notice Merkle Tree root - function reportRoot() external view returns (bytes32); + function treeRoot() external view returns (bytes32); /// @notice Merkle Tree leaf hash - function hashLeaf(uint64, uint64) external view returns (bytes32); + function hashLeaf(uint256, uint256) external view returns (bytes32); } diff --git a/test/CSAccounting.blockedBond.t.sol b/test/CSAccounting.blockedBond.t.sol new file mode 100644 index 00000000..df109088 --- /dev/null +++ b/test/CSAccounting.blockedBond.t.sol @@ -0,0 +1,735 @@ +// 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, + uint256 blockedBondManagementPeriod + ) + CSAccounting( + commonBondSize, + admin, + lidoLocator, + wstETH, + communityStakingModule, + blockedBondRetentionPeriod, + blockedBondManagementPeriod + ) + {} + + function _bondShares_set_value( + uint256 nodeOperatorId, + uint256 value + ) public { + _bondShares[nodeOperatorId] = value; + } + + function _blockedBondEther_get_value( + uint256 nodeOperatorId + ) public view returns (BlockedBond memory) { + return _blockedBondEther[nodeOperatorId]; + } + + function _blockedBondEther_set_value( + uint256 nodeOperatorId, + BlockedBond 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, + 1 days + ); + feeDistributor = new CommunityStakingFeeDistributorMock( + address(locator), + address(accounting) + ); + vm.startPrank(admin); + accounting.setFeeDistributor(address(feeDistributor)); + accounting.grantRole(accounting.INSTANT_PENALIZE_BOND_ROLE(), admin); + accounting.grantRole( + accounting.EL_REWARDS_STEALING_PENALTY_INIT_ROLE(), + admin + ); + accounting.grantRole( + accounting.EL_REWARDS_STEALING_PENALTY_SETTLE_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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond 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, + blockNumber: proposedBlockNumber, + amount: 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, + blockNumber: proposedBlockNumber, + amount: 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, + blockNumber: 100500, + amount: 100 ether + }); + } + + function test_initELRewardsStealingPenalty_revertWhenZero() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + vm.expectRevert(0x7edd4cfd); + + vm.prank(admin); + accounting.initELRewardsStealingPenalty({ + nodeOperatorId: 0, + blockNumber: 100500, + amount: 0 + }); + } + + function test_initELRewardsStealingPenalty_revertWhenNoRole() public { + _createNodeOperator({ ongoingVals: 1, withdrawnVals: 0 }); + + vm.expectRevert( + "AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0xcc2e7ce7be452f766dd24d55d87a3d42901c31ffa5b600cd1dff475abec91c1f" + ); + + accounting.initELRewardsStealingPenalty({ + nodeOperatorId: 0, + blockNumber: 100500, + amount: 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.BlockedBond({ + 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.BlockedBond 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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond({ + 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.BlockedBond 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.BlockedBond({ + 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.BlockedBond({ + 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 0xcc2e7ce7be452f766dd24d55d87a3d42901c31ffa5b600cd1dff475abec91c1f" + ); + 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.BlockedBond({ + 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/CSAccounting.t.sol b/test/CSAccounting.t.sol index e2ff1a02..07f3bcde 100644 --- a/test/CSAccounting.t.sol +++ b/test/CSAccounting.t.sol @@ -45,9 +45,6 @@ contract CSAccountingTest is user = address(2); stranger = address(777); - address[] memory penalizeRoleMembers = new address[](1); - penalizeRoleMembers[0] = admin; - (locator, wstETH, stETH, burner) = initLido(); stakingModule = new CommunityStakingModuleMock(); @@ -57,14 +54,25 @@ contract CSAccountingTest is address(locator), address(wstETH), address(stakingModule), - penalizeRoleMembers + 8 weeks, + 1 days ); feeDistributor = new CommunityStakingFeeDistributorMock( address(locator), address(accounting) ); - vm.prank(admin); + vm.startPrank(admin); accounting.setFeeDistributor(address(feeDistributor)); + accounting.grantRole(accounting.INSTANT_PENALIZE_BOND_ROLE(), admin); + accounting.grantRole( + accounting.EL_REWARDS_STEALING_PENALTY_INIT_ROLE(), + admin + ); + accounting.grantRole( + accounting.EL_REWARDS_STEALING_PENALTY_SETTLE_ROLE(), + admin + ); + vm.stopPrank(); } function test_totalBondShares() public { @@ -134,12 +142,12 @@ contract CSAccountingTest is _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); vm.deal(user, 64 ether); vm.startPrank(user); - accounting.depositETH{ value: 64 ether }(user, 0); + accounting.depositETH{ value: 63 ether }(user, 0); assertApproxEqAbs( accounting.getRequiredBondETH(0, 16), - 0, + 1 ether, 1, // max accuracy error - "required ETH should be ~0 for the next 16 validators to deposit" + "required ETH should be ~1 ether for the next 16 validators to deposit" ); } @@ -148,12 +156,12 @@ contract CSAccountingTest is vm.deal(user, 64 ether); vm.startPrank(user); stETH.submit{ value: 64 ether }({ _referal: address(0) }); - accounting.depositStETH(user, 0, 64 ether); + accounting.depositStETH(user, 0, 63 ether); assertApproxEqAbs( accounting.getRequiredBondStETH(0, 16), - 0, + 1 ether, 1, // max accuracy error - "required stETH should be ~0 for the next 16 validators to deposit" + "required stETH should be ~1 ether for the next 16 validators to deposit" ); } @@ -162,13 +170,13 @@ contract CSAccountingTest is vm.deal(user, 64 ether); vm.startPrank(user); stETH.submit{ value: 64 ether }({ _referal: address(0) }); - uint256 amount = wstETH.wrap(64 ether); + uint256 amount = wstETH.wrap(63 ether); accounting.depositWstETH(user, 0, amount); assertApproxEqAbs( accounting.getRequiredBondWstETH(0, 16), - 0, - 1, // max accuracy error - "required wstETH should be ~0 for the next 16 validators to deposit" + stETH.getSharesByPooledEth(1 ether), + 2, // max accuracy error + "required wstETH should be ~1 ether for the next 16 validators to deposit" ); } @@ -593,6 +601,42 @@ contract CSAccountingTest is assertEq(accounting.getUnbondedKeysCount(0), 7); } + function test_getKeysCountByBondETH() public { + assertEq(accounting.getKeysCountByBondETH(0), 0); + assertEq(accounting.getKeysCountByBondETH(1.99 ether), 0); + assertEq(accounting.getKeysCountByBondETH(2 ether), 1); + assertEq(accounting.getKeysCountByBondETH(4 ether), 2); + } + + function test_getKeysCountByBondStETH() public { + assertEq(accounting.getKeysCountByBondStETH(0), 0); + assertEq(accounting.getKeysCountByBondStETH(1.99 ether), 0); + assertEq(accounting.getKeysCountByBondStETH(2 ether), 1); + assertEq(accounting.getKeysCountByBondStETH(4 ether), 2); + } + + function test_getKeysCountByBondWstETH() public { + assertEq(accounting.getKeysCountByBondWstETH(0), 0); + assertEq( + accounting.getKeysCountByBondWstETH( + wstETH.getWstETHByStETH(1.99 ether) + ), + 0 + ); + assertEq( + accounting.getKeysCountByBondWstETH( + wstETH.getWstETHByStETH(2 ether) + ), + 1 + ); + assertEq( + accounting.getKeysCountByBondWstETH( + wstETH.getWstETHByStETH(4 ether) + ), + 2 + ); + } + function test_claimRewardsStETH() public { _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); vm.deal(address(feeDistributor), 0.1 ether); @@ -1048,26 +1092,28 @@ contract CSAccountingTest is accounting.depositStETH(user, 0, 32 ether); vm.stopPrank(); + uint256 shares = stETH.getSharesByPooledEth(1 ether); + uint256 penalized = stETH.getPooledEthByShares(shares); vm.expectEmit(true, true, true, true, address(accounting)); - emit BondPenalized(0, 1e18, 1e18); + emit BondPenalized(0, penalized, penalized); uint256 bondSharesBefore = accounting.getBondShares(0); vm.prank(admin); - accounting.penalize(0, 1e18); + accounting.penalize(0, 1 ether); assertEq( accounting.getBondShares(0), - bondSharesBefore - 1e18, + bondSharesBefore - shares, "bond shares should be decreased by penalty" ); assertEq( stETH.sharesOf(address(accounting)), - bondSharesBefore - 1e18, + bondSharesBefore - shares, "bond manager shares should be decreased by penalty" ); assertEq( stETH.sharesOf(address(burner)), - 1e18, + shares, "burner shares should be equal to penalty" ); } @@ -1081,12 +1127,16 @@ contract CSAccountingTest is vm.stopPrank(); uint256 bondSharesBefore = accounting.getBondShares(0); - + uint256 penaltyShares = stETH.getSharesByPooledEth(33 ether); vm.expectEmit(true, true, true, true, address(accounting)); - emit BondPenalized(0, 32 * 1e18, bondSharesBefore); + emit BondPenalized( + 0, + stETH.getPooledEthByShares(penaltyShares), + stETH.getPooledEthByShares(bondSharesBefore) + ); vm.prank(admin); - accounting.penalize(0, 32 * 1e18); + accounting.penalize(0, 33 ether); assertEq( accounting.getBondShares(0), @@ -1114,11 +1164,12 @@ contract CSAccountingTest is vm.stopPrank(); uint256 shares = stETH.getSharesByPooledEth(32 ether); + uint256 penalized = stETH.getPooledEthByShares(shares); vm.expectEmit(true, true, true, true, address(accounting)); - emit BondPenalized(0, shares, shares); + emit BondPenalized(0, penalized, penalized); vm.prank(admin); - accounting.penalize(0, shares); + accounting.penalize(0, 32 ether); assertEq( accounting.getBondShares(0), @@ -1139,7 +1190,7 @@ contract CSAccountingTest is function test_penalize_RevertWhenCallerHasNoRole() public { vm.expectRevert( - "AccessControl: account 0x0000000000000000000000000000000000000309 is missing role 0xf3c54f9b8dbd8c6d8596d09d52b61d4bdce01620000dd9d49c5017dca6e62158" + "AccessControl: account 0x0000000000000000000000000000000000000309 is missing role 0x9909cf24c2d3bafa8c229558d86a1b726ba57c3ef6350848dcf434a4181b56c7" ); vm.prank(stranger); accounting.penalize(0, 20); diff --git a/test/CSFeeDistributor.t.sol b/test/CSFeeDistributor.t.sol index e3329d1f..3a3eb37b 100644 --- a/test/CSFeeDistributor.t.sol +++ b/test/CSFeeDistributor.t.sol @@ -4,8 +4,7 @@ pragma solidity 0.8.21; import "forge-std/Test.sol"; -import { CSFeeDistributorBase } from "../src/CSFeeDistributorBase.sol"; -import { CSFeeDistributor } from "../src/CSFeeDistributor.sol"; +import { CSFeeDistributorBase, CSFeeDistributor } from "../src/CSFeeDistributor.sol"; import { CSFeeOracle } from "../src/CSFeeOracle.sol"; import { ICSFeeOracle } from "../src/interfaces/ICSFeeOracle.sol"; @@ -52,20 +51,20 @@ contract CSFeeDistributorTest is Test, Fixtures, CSFeeDistributorBase { } function test_distributeFeesHappyPath() public { - uint64 noIndex = 42; - uint64 shares = 100; - tree.pushLeaf(noIndex, shares); + uint256 nodeOperatorId = 42; + uint256 shares = 100; + tree.pushLeaf(nodeOperatorId, shares); bytes32[] memory proof = tree.getProof(0); stETH.mintShares(address(feeDistributor), shares); vm.expectEmit(true, true, false, true, address(feeDistributor)); - emit FeeDistributed(noIndex, shares); + emit FeeDistributed(nodeOperatorId, shares); vm.prank(address(bondManager)); feeDistributor.distributeFees({ proof: proof, - noIndex: noIndex, + nodeOperatorId: nodeOperatorId, shares: shares }); @@ -77,7 +76,7 @@ contract CSFeeDistributorTest is Test, Fixtures, CSFeeDistributorBase { feeDistributor.distributeFees({ proof: new bytes32[](1), - noIndex: 0, + nodeOperatorId: 0, shares: 0 }); } @@ -88,49 +87,49 @@ contract CSFeeDistributorTest is Test, Fixtures, CSFeeDistributorBase { vm.prank(address(bondManager)); feeDistributor.distributeFees({ proof: new bytes32[](1), - noIndex: 0, + nodeOperatorId: 0, shares: 0 }); } function test_RevertIf_InvalidShares() public { - uint64 noIndex = 42; - uint64 shares = 100; - tree.pushLeaf(noIndex, shares); + uint256 nodeOperatorId = 42; + uint256 shares = 100; + tree.pushLeaf(nodeOperatorId, shares); bytes32[] memory proof = tree.getProof(0); stdstore .target(address(feeDistributor)) - .sig("distributedShares(uint64)") - .with_key(noIndex) + .sig("distributedShares(uint256)") + .with_key(nodeOperatorId) .checked_write(shares + 99); vm.expectRevert(InvalidShares.selector); vm.prank(address(bondManager)); feeDistributor.distributeFees({ proof: proof, - noIndex: noIndex, + nodeOperatorId: nodeOperatorId, shares: shares }); } function test_Returns0If_NothingToDistribute() public { - uint64 noIndex = 42; - uint64 shares = 100; - tree.pushLeaf(noIndex, shares); + uint256 nodeOperatorId = 42; + uint256 shares = 100; + tree.pushLeaf(nodeOperatorId, shares); bytes32[] memory proof = tree.getProof(0); stdstore .target(address(feeDistributor)) - .sig("distributedShares(uint64)") - .with_key(noIndex) + .sig("distributedShares(uint256)") + .with_key(nodeOperatorId) .checked_write(shares); vm.recordLogs(); vm.prank(address(bondManager)); - uint64 sharesToDistribute = feeDistributor.distributeFees({ + uint256 sharesToDistribute = feeDistributor.distributeFees({ proof: proof, - noIndex: noIndex, + nodeOperatorId: nodeOperatorId, shares: shares }); Vm.Log[] memory logs = vm.getRecordedLogs(); diff --git a/test/CSFeeOracle.t.sol b/test/CSFeeOracle.t.sol index 66ccb51e..224cbc2c 100644 --- a/test/CSFeeOracle.t.sol +++ b/test/CSFeeOracle.t.sol @@ -5,647 +5,280 @@ pragma solidity 0.8.21; import "forge-std/Test.sol"; import "forge-std/console.sol"; -import { CSFeeOracleBase } from "../src/CSFeeOracleBase.sol"; -import { CSFeeOracle } from "../src/CSFeeOracle.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { UnstructuredStorage } from "../lib/base-oracle/lib/UnstructuredStorage.sol"; +import { HashConsensus } from "../lib/base-oracle/oracle/HashConsensus.sol"; import { DistributorMock } from "./helpers/mocks/DistributorMock.sol"; +import { CSFeeOracle } from "../src/CSFeeOracle.sol"; import { Utilities } from "./helpers/Utilities.sol"; - -contract FeeOracleTest is Test, Utilities, CSFeeOracleBase { - using stdStorage for StdStorage; - - address internal constant ORACLE_ADMIN = - address(uint160(uint256(keccak256("oracle admin")))); - - uint64 internal constant SECONDS_PER_EPOCH = 32 * 12; - - address internal FEE_DISTRIBUTOR; - - address[] internal members; - CSFeeOracle internal oracle; - - function setUp() public { - FEE_DISTRIBUTOR = address(new DistributorMock()); - - vm.label(FEE_DISTRIBUTOR, "FEE_DISTRIBUTOR"); - vm.label(ORACLE_ADMIN, "ORACLE_ADMIN"); - } - - function test_RevertIf_GenesisTimeInFuture() public { - vm.expectRevert(GenesisTimeNotReached.selector); - vm.warp(1); - new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 2 // > block.timestamp - }); - } - - function test_Initialize() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 42, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - assertTrue(oracle.initialized(), "not initialized"); - - assertEq(oracle.lastConsolidatedEpoch(), 42); - assertEq(oracle.prevConsolidatedEpoch(), 42); - assertEq(oracle.reportIntervalEpochs(), 2); +import { Stub } from "./helpers/mocks/Stub.sol"; + +contract CSFeeOracleForTest is CSFeeOracle { + using UnstructuredStorage for bytes32; + + constructor( + uint256 secondsPerSlot, + uint256 genesisTime + ) CSFeeOracle(secondsPerSlot, genesisTime) { + // Version.sol constructor sets the storage value to uint256.max and effectively + // prevents the deployed contract from being initialized. To be able to use the + // contract in tests with no proxy above, we need to set the version to 0. + CONTRACT_VERSION_POSITION.setStorageUint256(0); } +} - function test_currentEpoch() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - assertEq(oracle.currentEpoch(), 0); - - _vmSetEpoch(10); - assertEq(oracle.currentEpoch(), 10); - - _vmSetEpoch(13); - assertEq(oracle.currentEpoch(), 13); - } - - function test_nextReportEpoch() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 8, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _vmSetEpoch(8); - assertEq(oracle.nextReportEpoch(), 10); - - _vmSetEpoch(13); - assertEq(oracle.nextReportEpoch(), 12); - } - - function test_RevertIf_LastConsolidationEpochInFuture() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 42, - reportInterval: 1, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _vmSetEpoch(41); - vm.expectRevert(stdError.arithmeticError); - oracle.nextReportEpoch(); - } - - function test_reportFrame() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 8, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _vmSetEpoch(8); - (uint64 start, uint64 end) = oracle.reportFrame(); - assertEq(start, 257); - assertEq(end, 320); - } - - function test_setReportInterval() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - vm.expectEmit(true, false, false, true, address(oracle)); - emit ReportIntervalSet(2); - - vm.prank(ORACLE_ADMIN); - oracle.setReportInterval(2); - - assertEq(oracle.reportIntervalEpochs(), 2); - } - - function test_submitReport() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(3); - bytes32 newRoot = keccak256("new root"); - - _vmSetEpoch(7); - - // Memeber 0 submits a report - vm.expectEmit(true, false, false, true, address(oracle)); - emit ReportSubmitted(6, members[0], newRoot, "tree"); - - vm.prank(members[0]); - oracle.submitReport({ - epoch: 6, - newRoot: newRoot, - distributed: 42, - _treeCid: "tree" - }); - - // Consensus is not reached yet - assertEq(oracle.reportRoot(), bytes32(0)); - - // Member 1 submits a report - vm.expectEmit(true, false, false, true, address(oracle)); - emit ReportSubmitted(6, members[1], newRoot, "tree"); - - vm.expectEmit(true, false, false, true, address(oracle)); - emit ReportConsolidated(6, newRoot, 42, "tree"); - - vm.prank(members[1]); - oracle.submitReport({ - epoch: 6, - newRoot: newRoot, - distributed: 42, - _treeCid: "tree" - }); - - // Consensus is reached - assertEq(oracle.reportRoot(), newRoot); - assertEq(oracle.treeCid(), "tree"); - } - - function test_submitReport_NoQuorum() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(3); - bytes32 newRoot = keccak256("new root"); - - _vmSetEpoch(7); - - // Memeber 0 submits a report - vm.expectEmit(true, false, false, true, address(oracle)); - emit ReportSubmitted(6, members[0], newRoot, "tree"); - - vm.prank(members[0]); - oracle.submitReport({ - epoch: 6, - newRoot: newRoot, - distributed: 42, - _treeCid: "tree" - }); - - // Consensus is not reached yet - assertEq(oracle.reportRoot(), bytes32(0)); - assertEq(oracle.treeCid(), ""); - - // Member 1 submits a report - vm.expectEmit(true, false, false, true, address(oracle)); - emit ReportSubmitted(6, members[1], newRoot, "IT DIFFERS"); - - vm.prank(members[1]); - oracle.submitReport({ - epoch: 6, - newRoot: newRoot, - distributed: 42, - _treeCid: "IT DIFFERS" - }); - - // Consensus is not reached - assertEq(oracle.reportRoot(), bytes32(0)); - - // Member 1 submits a report - vm.expectEmit(true, false, false, true, address(oracle)); - emit ReportSubmitted(6, members[1], keccak256("IT DIFFERS"), "tree"); - - vm.prank(members[1]); - oracle.submitReport({ - epoch: 6, - newRoot: keccak256("IT DIFFERS"), - distributed: 42, - _treeCid: "tree" - }); - - // Consensus is not reached - assertEq(oracle.reportRoot(), bytes32(0)); - } - - function test_RevertIf_TooEarly() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(3); - _vmSetEpoch(7); - - vm.prank(members[0]); - vm.expectRevert(ReportTooEarly.selector); - oracle.submitReport({ - epoch: 99, - newRoot: bytes32(0), - distributed: 42, - _treeCid: "tree" - }); - } - - function test_RevertIf_TooLate() public { - _vmSetEpoch(8); - - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 7, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(3); +contract CSFeeOracleTest is Test, Utilities { + using Strings for uint256; - vm.prank(members[0]); - vm.expectRevert(ReportTooLate.selector); - oracle.submitReport({ - epoch: 7, - newRoot: bytes32(0), - distributed: 42, - _treeCid: "tree" - }); + struct ChainConfig { + uint256 secondsPerSlot; + uint256 slotsPerEpoch; + uint256 genesisTime; } - function test_hashLeaf() public { - uint64 noIndex = 42; - uint64 shares = 999; - - bytes32 hash = 0x20b6ee98002cfd33f27ed874d1aaebcd4ed99991dc504b273af77a78553c4afe; + address internal constant ORACLE_ADMIN = + address(uint160(uint256(keccak256("ORACLE_ADMIN")))); + + uint256 internal constant CONSENSUS_VERSION = 1; + uint256 internal constant INITIAL_EPOCH = 17; + + CSFeeOracleForTest public oracle; + HashConsensus public consensus; + ChainConfig public chainConfig; + address[] public members; + uint256 public quorum; + + event ReportReceived( + uint256 indexed refSlot, + address indexed member, + bytes32 report + ); + + event ReportConsolidated( + uint256 indexed refSlot, + uint256 distributed, + bytes32 newRoot, + string treeCid + ); - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, + function setUp() public { + chainConfig = ChainConfig({ + secondsPerSlot: 12, + slotsPerEpoch: 32, genesisTime: 0 }); - assertEq(oracle.hashLeaf(noIndex, shares), hash); + vm.label(ORACLE_ADMIN, "ORACLE_ADMIN"); + _vmSetEpoch(INITIAL_EPOCH); } - function test_setQuorum() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(10); + function test_happyPath() public { + { + _deployFeeOracleAndHashConsensus(_lastSlotOfEpoch(1)); + _grantAllRolesToAdmin(); + _assertNoReportOnInit(); + _setInitialEpoch(); + _seedMembers(3); + } - vm.expectEmit(true, false, false, true, address(oracle)); - emit QuorumSet(8); + uint256 startSlot; + uint256 refSlot; - vm.prank(ORACLE_ADMIN); - oracle.setQuorum(8); - assertEq(oracle.quorum(), 8); - } + (, startSlot, , ) = oracle.getConsensusReport(); + (refSlot, ) = consensus.getCurrentFrame(); + // INITIAL_EPOCH is far above the lastProcessingRefSlot's epoch + assertNotEq(startSlot, refSlot); - function test_RevertIf_SetQuorumNotAdmin() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 + CSFeeOracle.ReportData memory data = CSFeeOracle.ReportData({ + consensusVersion: oracle.getConsensusVersion(), + refSlot: refSlot, + treeRoot: keccak256("root"), + treeCid: "QmCID0", + distributed: 1337 }); - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); + bytes32 reportHash = keccak256(abi.encode(data)); + _reachConsensus(refSlot, reportHash); - vm.expectRevert( - bytes( - "AccessControl: account 0x0000000000000000000000000000000000000001 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" - ) + vm.expectEmit(true, true, true, true, address(oracle)); + emit ReportConsolidated( + refSlot, + data.distributed, + data.treeRoot, + data.treeCid ); - vm.prank(address(1)); - oracle.setQuorum(2); - - assertEq(oracle.quorum(), 0, "quorum is not 0"); - } - - function test_RevertIf_QuorumTooSmall() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(3); - - vm.expectRevert(QuorumTooSmall.selector); - vm.prank(ORACLE_ADMIN); - oracle.setQuorum(1); + vm.prank(members[0]); + oracle.submitReportData({ data: data, contractVersion: 1 }); + + assertEq(oracle.treeRoot(), data.treeRoot); + assertEq(oracle.treeCid(), data.treeCid); + + (, startSlot, , ) = oracle.getConsensusReport(); + (refSlot, ) = consensus.getCurrentFrame(); + assertEq(startSlot, refSlot); + + // Advance block.timestamp to the middle of the frame + _vmSetEpoch(INITIAL_EPOCH + _epochsInDays(14)); + (, startSlot, , ) = oracle.getConsensusReport(); + (refSlot, ) = consensus.getCurrentFrame(); + assertEq(startSlot, refSlot); + + // Advance block.timestamp to the end of the frame + _vmSetEpoch(INITIAL_EPOCH + _epochsInDays(28)); + (, startSlot, , ) = oracle.getConsensusReport(); + (refSlot, ) = consensus.getCurrentFrame(); + assertLt(startSlot, refSlot); } - function test_addMember() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - uint64 quorum = 17; - address newMember = nextAddress(); - - vm.expectEmit(true, true, false, true, address(oracle)); - emit MemberAdded(newMember); - - vm.expectEmit(true, false, false, true, address(oracle)); - emit QuorumSet(quorum); - - vm.prank(ORACLE_ADMIN); - oracle.addMember(newMember, quorum); - - bool hasRole = oracle.hasRole(oracle.ORACLE_MEMBER_ROLE(), newMember); - assertTrue(hasRole, "new member has no role"); + function test_reportFrame() public { + { + _deployFeeOracleAndHashConsensus(_lastSlotOfEpoch(INITIAL_EPOCH)); + _grantAllRolesToAdmin(); + _assertNoReportOnInit(); + _setInitialEpoch(); + } - assertEq(oracle.quorum(), quorum, "quorum mismatch"); + uint256 startSlot; + uint256 refSlot; + uint256 tmp; + + // Check the startSlot at the very beginning of the frame + (, startSlot, , ) = oracle.getConsensusReport(); + (refSlot, ) = consensus.getCurrentFrame(); + assertEq(startSlot, refSlot); + + // Advance block.timestamp to the middle of the frame + _vmSetEpoch(INITIAL_EPOCH + _epochsInDays(14)); + (, startSlot, , ) = oracle.getConsensusReport(); + (refSlot, ) = consensus.getCurrentFrame(); + assertEq(startSlot, refSlot); + + // Advance block.timestamp to the end of the frame + _vmSetEpoch(INITIAL_EPOCH + _epochsInDays(28)); + (, startSlot, , ) = oracle.getConsensusReport(); + (refSlot, ) = consensus.getCurrentFrame(); + assertGt(refSlot, startSlot); + + tmp = startSlot; + // Advance block.timestamp far above the first frame + _vmSetEpoch(INITIAL_EPOCH + _epochsInDays(999)); + (, startSlot, , ) = oracle.getConsensusReport(); + assertEq(tmp, startSlot, "startSlot must not change"); } - function test_RevertIf_NotAdmin_AddMember() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - address newMember = nextAddress(); - vm.expectRevert( - bytes( - "AccessControl: account 0x0000000000000000000000000000000000000001 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" - ) + function _deployFeeOracleAndHashConsensus( + uint256 lastProcessingRefSlot + ) internal { + oracle = new CSFeeOracleForTest({ + secondsPerSlot: chainConfig.secondsPerSlot, + genesisTime: chainConfig.genesisTime + }); + + consensus = new HashConsensus({ + slotsPerEpoch: chainConfig.slotsPerEpoch, + secondsPerSlot: chainConfig.secondsPerSlot, + genesisTime: chainConfig.genesisTime, + epochsPerFrame: _epochsInDays(28), + fastLaneLengthSlots: 0, + admin: ORACLE_ADMIN, + reportProcessor: address(oracle) + }); + + oracle.initialize( + ORACLE_ADMIN, + address(new DistributorMock()), + address(consensus), + CONSENSUS_VERSION, + lastProcessingRefSlot ); - vm.prank(address(1)); - oracle.addMember(newMember, 17); - - bool hasRole = oracle.hasRole(oracle.ORACLE_MEMBER_ROLE(), newMember); - assertFalse(hasRole); } - function test_removeMember() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(2); - - address churnedMember = _popMember(); - uint64 quorum = 1; - - vm.expectEmit(true, true, false, true, address(oracle)); - emit MemberRemoved(churnedMember); - - vm.expectEmit(true, false, false, true, address(oracle)); - emit QuorumSet(quorum); - - vm.prank(ORACLE_ADMIN); - oracle.removeMember(churnedMember, quorum); - - bool hasRole = oracle.hasRole( - oracle.ORACLE_MEMBER_ROLE(), - churnedMember - ); - assertFalse(hasRole, "churned member still has role"); - - assertEq(oracle.quorum(), quorum, "quorum mismatch"); + function _grantAllRolesToAdmin() internal { + vm.startPrank(ORACLE_ADMIN); + /* prettier-ignore */ + { + consensus.grantRole(consensus.MANAGE_MEMBERS_AND_QUORUM_ROLE(), ORACLE_ADMIN); + consensus.grantRole(consensus.DISABLE_CONSENSUS_ROLE(), ORACLE_ADMIN); + consensus.grantRole(consensus.MANAGE_FRAME_CONFIG_ROLE(), ORACLE_ADMIN); + consensus.grantRole(consensus.MANAGE_FAST_LANE_CONFIG_ROLE(), ORACLE_ADMIN); + consensus.grantRole(consensus.MANAGE_REPORT_PROCESSOR_ROLE(), ORACLE_ADMIN); + + oracle.grantRole(oracle.MANAGE_CONSENSUS_CONTRACT_ROLE(), ORACLE_ADMIN); + oracle.grantRole(oracle.MANAGE_CONSENSUS_VERSION_ROLE(), ORACLE_ADMIN); + oracle.grantRole(oracle.PAUSE_ROLE(), ORACLE_ADMIN); + oracle.grantRole(oracle.RESUME_ROLE(), ORACLE_ADMIN); + } + vm.stopPrank(); } - function test_RevertIF_NotAdmin_RemoveMember() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(2); - - address churnedMember = _popMember(); - uint64 oldQ = oracle.quorum(); - uint64 newQ = oldQ - 1; - - vm.expectRevert( - bytes( - "AccessControl: account 0x0000000000000000000000000000000000000001 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" - ) - ); - vm.prank(address(1)); - oracle.removeMember(churnedMember, newQ); - - bool hasRole = oracle.hasRole( - oracle.ORACLE_MEMBER_ROLE(), - churnedMember - ); - assertTrue(hasRole, "churned member has no role"); - - assertEq(oracle.quorum(), oldQ, "quorum has been changed"); + function _seedMembers(uint256 count) internal { + for (uint256 i = 0; i < count; i++) { + uint256 q = (members.length + 1) / 2 + 1; // 50% + 1 + address newMember = nextAddress(); + vm.label(newMember, string.concat("MEMBER", i.toString())); + vm.startPrank(ORACLE_ADMIN); + { + consensus.addMember(newMember, q); + oracle.grantRole(oracle.SUBMIT_DATA_ROLE(), newMember); + } + vm.stopPrank(); + members.push(newMember); + quorum = q; + } } - function test_RevertIF_NotExistent_RemoveMember() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - _seedMembers(2); - - address noMember = nextAddress(); - uint64 oldQ = oracle.quorum(); - uint64 newQ = oldQ - 1; - - vm.expectRevert(abi.encodeWithSelector(NotMember.selector, noMember)); - vm.prank(ORACLE_ADMIN); - oracle.removeMember(noMember, newQ); - - assertEq(oracle.quorum(), oldQ, "quorum has been changed"); + function _assertNoReportOnInit() internal { + ( + bytes32 hash, // refSlot + , + uint256 processingDeadlineTime, + bool processingStarted + ) = oracle.getConsensusReport(); + + // Skips the check for refSlot, see test_reportFrame + assertEq(hash, bytes32(0)); + assertEq(processingDeadlineTime, 0); + assertEq(processingStarted, false); } - function test_pause() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); - - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - vm.expectEmit(true, false, false, true, address(oracle)); - emit Paused(ORACLE_ADMIN); - + function _setInitialEpoch() internal { vm.prank(ORACLE_ADMIN); - oracle.pause(); - - assertTrue(oracle.paused(), "not paused"); + consensus.updateInitialEpoch(INITIAL_EPOCH); } - function test_unpause() public { - oracle = new CSFeeOracle({ - secondsPerBlock: 12, - blocksPerEpoch: 32, - genesisTime: 0 - }); + function _reachConsensus(uint256 refSlot, bytes32 hash) internal { + for (uint256 i; i < quorum; i++) { + vm.expectEmit(true, true, true, true, address(consensus)); + emit ReportReceived(refSlot, members[i], hash); - oracle.initialize({ - initializationEpoch: 0, - reportInterval: 2, - _feeDistributor: FEE_DISTRIBUTOR, - admin: ORACLE_ADMIN - }); - - vm.prank(ORACLE_ADMIN); - oracle.pause(); - - vm.expectEmit(true, false, false, true, address(oracle)); - emit Unpaused(ORACLE_ADMIN); - - vm.prank(ORACLE_ADMIN); - oracle.unpause(); + vm.prank(members[i]); + consensus.submitReport(refSlot, hash, CONSENSUS_VERSION); + } - assertFalse(oracle.paused(), "still paused"); + (uint256 _refSlot, bytes32 _hash, ) = consensus.getConsensusState(); + assertEq(_refSlot, refSlot, "_reachConsensus: refSlot mismatch"); + assertEq(_hash, hash, "_reachConsensus: hash mismatch"); } - function _popMember() internal returns (address) { - address m = members[members.length - 1]; - members.pop(); - return m; + function _epochsInDays(uint256 daysCount) internal view returns (uint256) { + return + (daysCount * 24 * 60 * 60) / + chainConfig.secondsPerSlot / + chainConfig.slotsPerEpoch; } - function _seedMembers(uint64 count) internal { - for (uint64 i = 0; i < count; i++) { - uint64 q = (i + 1) / 2 + 1; // 50% + 1 - vm.prank(ORACLE_ADMIN); - members.push(nextAddress()); - oracle.addMember(members[i], q); - } + function _lastSlotOfEpoch(uint256 epoch) internal pure returns (uint256) { + require(epoch > 0, "epoch must be greater than 0"); + return epoch * 32 - 1; } - function _vmSetEpoch(uint64 epoch) internal { - vm.warp(epoch * SECONDS_PER_EPOCH); + function _vmSetEpoch(uint256 epoch) internal { + /* prettier-ignore */ + vm.warp( + epoch * + chainConfig.secondsPerSlot * + chainConfig.slotsPerEpoch + ); } } diff --git a/test/CSMAddValidator.t.sol b/test/CSMAddValidator.t.sol index 7398533a..b8f504e0 100644 --- a/test/CSMAddValidator.t.sol +++ b/test/CSMAddValidator.t.sol @@ -49,7 +49,8 @@ contract CSMCommon is Test, Fixtures, Utilities, CSModuleBase { address(locator), address(wstETH), address(csm), - penalizeRoleMembers + 8 weeks, + 1 days ); csm.setAccounting(address(accounting)); } diff --git a/test/CSMInit.t.sol b/test/CSMInit.t.sol index c7d88191..db7ec6d6 100644 --- a/test/CSMInit.t.sol +++ b/test/CSMInit.t.sol @@ -42,7 +42,8 @@ contract CSMInitTest is Test, Fixtures { address(locator), address(wstETH), address(csm), - penalizeRoleMembers + 8 weeks, + 1 days ); } diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index b9c83a03..3e4fb4b4 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -8,6 +8,7 @@ import { WstETHMock } from "./mocks/WstETHMock.sol"; import { LidoLocatorMock } from "./mocks/LidoLocatorMock.sol"; import { WithdrawalQueueMock } from "./mocks/WithdrawalQueueMock.sol"; import { Stub } from "./mocks/Stub.sol"; +import "forge-std/Test.sol"; contract Fixtures is StdCheats { function initLido() @@ -25,12 +26,36 @@ 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)); } } + +contract IntegrationFixtures is StdCheats, Test { + struct Env { + string RPC_URL; + } + + address internal immutable LOCATOR_ADDRESS = + 0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb; + address internal immutable WSTETH_ADDRESS = + 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + + function envVars() public returns (Env memory) { + Env memory env = Env(vm.envOr("RPC_URL", string(""))); + vm.skip(_isEmpty(env.RPC_URL)); + return env; + } + + function _isEmpty(string memory s) internal pure returns (bool) { + return + keccak256(abi.encodePacked(s)) == keccak256(abi.encodePacked("")); + } +} diff --git a/test/helpers/MerkleTree.sol b/test/helpers/MerkleTree.sol index 1f333fea..8388a624 100644 --- a/test/helpers/MerkleTree.sol +++ b/test/helpers/MerkleTree.sol @@ -30,7 +30,7 @@ contract MerkleTree { return proof; } - function pushLeaf(uint64 noIndex, uint64 shares) external { + function pushLeaf(uint256 noIndex, uint256 shares) external { bytes32 leaf = this.hashLeaf(abi.encodePacked(noIndex, shares)); leaves.push(leaf); _buildTree(); diff --git a/test/helpers/Utilities.sol b/test/helpers/Utilities.sol index 5bab9b0d..cbd01853 100644 --- a/test/helpers/Utilities.sol +++ b/test/helpers/Utilities.sol @@ -41,4 +41,10 @@ contract Utilities { } return (keys, signatures); } + + function checkChainId(uint256 chainId) public view { + if (chainId != block.chainid) { + revert("wrong chain id"); + } + } } 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/OracleMock.sol b/test/helpers/mocks/OracleMock.sol index a8617e06..a441d654 100644 --- a/test/helpers/mocks/OracleMock.sol +++ b/test/helpers/mocks/OracleMock.sol @@ -2,19 +2,20 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.21; +import { ICSFeeOracle } from "../../../src/interfaces/ICSFeeOracle.sol"; import { MerkleTree } from "../../helpers/MerkleTree.sol"; -contract OracleMock { +contract OracleMock is ICSFeeOracle { MerkleTree public merkleTree = new MerkleTree(); function hashLeaf( - uint64 noIndex, - uint64 shares + uint256 noIndex, + uint256 shares ) external view returns (bytes32) { - return merkleTree.hashLeaf(abi.encodePacked(noIndex, shares)); + return merkleTree.hashLeaf(abi.encode(noIndex, shares)); } - function reportRoot() external view returns (bytes32) { + function treeRoot() external view returns (bytes32) { return merkleTree.root(); } } 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 {} +} diff --git a/test/integration/DepositInTokens.t.sol b/test/integration/DepositInTokens.t.sol index 68c76806..3a370a3a 100644 --- a/test/integration/DepositInTokens.t.sol +++ b/test/integration/DepositInTokens.t.sol @@ -4,15 +4,23 @@ pragma solidity 0.8.21; import "forge-std/Test.sol"; + import { CSModule } from "../../src/CSModule.sol"; import { CSAccounting } from "../../src/CSAccounting.sol"; -import { PermitHelper } from "../helpers/Permit.sol"; -import { CommunityStakingModuleMock } from "../helpers/mocks/CommunityStakingModuleMock.sol"; import { IWstETH } from "../../src/interfaces/IWstETH.sol"; import { ILido } from "../../src/interfaces/ILido.sol"; import { ILidoLocator } from "../../src/interfaces/ILidoLocator.sol"; +import { Utilities } from "../helpers/Utilities.sol"; +import { PermitHelper } from "../helpers/Permit.sol"; +import { IntegrationFixtures } from "../helpers/Fixtures.sol"; +import { CommunityStakingModuleMock } from "../helpers/mocks/CommunityStakingModuleMock.sol"; -contract DepositIntegrationTest is Test, PermitHelper { +contract DepositIntegrationTest is + Test, + Utilities, + PermitHelper, + IntegrationFixtures +{ uint256 networkFork; CommunityStakingModuleMock public csm; @@ -26,37 +34,22 @@ contract DepositIntegrationTest is Test, PermitHelper { uint256 internal userPrivateKey; uint256 internal strangerPrivateKey; - string RPC_URL; - string LIDO_LOCATOR_ADDRESS; - string WSTETH_ADDRESS; - function setUp() public { - RPC_URL = vm.envOr("RPC_URL", string("")); - LIDO_LOCATOR_ADDRESS = vm.envOr("LIDO_LOCATOR_ADDRESS", string("")); - WSTETH_ADDRESS = vm.envOr("WSTETH_ADDRESS", string("")); - vm.skip( - keccak256(abi.encodePacked(RPC_URL)) == - keccak256(abi.encodePacked("")) || - keccak256(abi.encodePacked(LIDO_LOCATOR_ADDRESS)) == - keccak256(abi.encodePacked("")) || - keccak256(abi.encodePacked(WSTETH_ADDRESS)) == - keccak256(abi.encodePacked("")) - ); + Env memory env = envVars(); - networkFork = vm.createFork(RPC_URL); + networkFork = vm.createFork(env.RPC_URL); vm.selectFork(networkFork); + checkChainId(1); - locator = ILidoLocator(vm.parseAddress(LIDO_LOCATOR_ADDRESS)); + locator = ILidoLocator(LOCATOR_ADDRESS); csm = new CommunityStakingModuleMock(); - wstETH = IWstETH(vm.parseAddress(WSTETH_ADDRESS)); + wstETH = IWstETH(WSTETH_ADDRESS); userPrivateKey = 0xa11ce; user = vm.addr(userPrivateKey); strangerPrivateKey = 0x517a4637; stranger = vm.addr(strangerPrivateKey); - address[] memory penalizeRoleMembers = new address[](1); - penalizeRoleMembers[0] = user; accounting = new CSAccounting( 2 ether, @@ -64,7 +57,8 @@ contract DepositIntegrationTest is Test, PermitHelper { address(locator), address(wstETH), address(csm), - penalizeRoleMembers + 8 weeks, + 1 days ); csm.setNodeOperator({ diff --git a/test/integration/StakingRouter.t.sol b/test/integration/StakingRouter.t.sol index 88e189b7..990249b0 100644 --- a/test/integration/StakingRouter.t.sol +++ b/test/integration/StakingRouter.t.sol @@ -4,15 +4,17 @@ pragma solidity 0.8.21; import "forge-std/Test.sol"; + import { CSModule, NodeOperator } from "../../src/CSModule.sol"; import { ILidoLocator } from "../../src/interfaces/ILidoLocator.sol"; import { IStakingRouter } from "../../src/interfaces/IStakingRouter.sol"; import { CSAccounting } from "../../src/CSAccounting.sol"; import { ILido } from "../../src/interfaces/ILido.sol"; import { IWstETH } from "../../src/interfaces/IWstETH.sol"; -import "../helpers/Utilities.sol"; +import { Utilities } from "../helpers/Utilities.sol"; +import { IntegrationFixtures } from "../helpers/Fixtures.sol"; -contract StakingRouterIntegrationTest is Test, Utilities { +contract StakingRouterIntegrationTest is Test, Utilities, IntegrationFixtures { uint256 networkFork; CSModule public csm; @@ -23,41 +25,29 @@ contract StakingRouterIntegrationTest is Test, Utilities { address internal agent; - string RPC_URL; - string LIDO_LOCATOR_ADDRESS; - string WSTETH_ADDRESS; - function setUp() public { - RPC_URL = vm.envOr("RPC_URL", string("")); - LIDO_LOCATOR_ADDRESS = vm.envOr("LIDO_LOCATOR_ADDRESS", string("")); - WSTETH_ADDRESS = vm.envOr("WSTETH_ADDRESS", string("")); - vm.skip( - keccak256(abi.encodePacked(RPC_URL)) == - keccak256(abi.encodePacked("")) || - keccak256(abi.encodePacked(LIDO_LOCATOR_ADDRESS)) == - keccak256(abi.encodePacked("")) - ); + Env memory env = envVars(); - networkFork = vm.createFork(RPC_URL); + networkFork = vm.createFork(env.RPC_URL); vm.selectFork(networkFork); + checkChainId(1); - locator = ILidoLocator(vm.parseAddress(LIDO_LOCATOR_ADDRESS)); + locator = ILidoLocator(LOCATOR_ADDRESS); stakingRouter = IStakingRouter(payable(locator.stakingRouter())); lido = ILido(locator.lido()); - wstETH = IWstETH(vm.parseAddress(WSTETH_ADDRESS)); + wstETH = IWstETH(WSTETH_ADDRESS); vm.label(address(lido), "lido"); vm.label(address(stakingRouter), "stakingRouter"); csm = new CSModule("community-staking-module", address(locator)); - address[] memory penalizeRoleMembers = new address[](1); - penalizeRoleMembers[0] = address(csm); CSAccounting accounting = new CSAccounting( 2 ether, address(csm), address(locator), address(wstETH), address(csm), - penalizeRoleMembers + 8 weeks, + 1 days ); csm.setAccounting(address(accounting)); @@ -70,10 +60,14 @@ contract StakingRouterIntegrationTest is Test, Utilities { stakingRouter.STAKING_MODULE_MANAGE_ROLE(), agent ); + stakingRouter.grantRole( + stakingRouter.REPORT_REWARDS_MINTED_ROLE(), + agent + ); vm.stopPrank(); } - function test_connectCSMToRouter() public { + function addCsmModule() public returns (uint256) { vm.prank(agent); stakingRouter.addStakingModule({ _name: "community-staking-v1", @@ -83,21 +77,18 @@ contract StakingRouterIntegrationTest is Test, Utilities { _treasuryFee: 500 }); uint256[] memory ids = stakingRouter.getStakingModuleIds(); + return ids[ids.length - 1]; + } + + function test_connectCSMToRouter() public { + uint256 moduleId = addCsmModule(); IStakingRouter.StakingModule memory module = stakingRouter - .getStakingModule(ids[ids.length - 1]); + .getStakingModule(moduleId); assertTrue(module.stakingModuleAddress == address(csm)); } function test_RouterDeposit() public { - vm.prank(agent); - stakingRouter.addStakingModule({ - _name: "community-staking-v1", - _stakingModuleAddress: address(csm), - _targetShare: 10000, - _stakingModuleFee: 500, - _treasuryFee: 500 - }); - uint256[] memory ids = stakingRouter.getStakingModuleIds(); + uint256 moduleId = addCsmModule(); (bytes memory keys, bytes memory signatures) = keysSignatures(2); address nodeOperator = address(2); vm.deal(nodeOperator, 4 ether); @@ -117,9 +108,21 @@ contract StakingRouterIntegrationTest is Test, Utilities { lido.submit{ value: 1e5 ether }(address(0)); vm.prank(locator.depositSecurityModule()); - lido.deposit(1, ids[ids.length - 1], ""); + lido.deposit(1, moduleId, ""); (, , , , , , uint256 totalDepositedValidators, ) = csm .getNodeOperatorSummary(0); assertEq(totalDepositedValidators, 1); } + + function test_routerReportRewardsMinted() public { + uint256 moduleId = addCsmModule(); + uint256[] memory moduleIds = new uint256[](1); + uint256[] memory rewards = new uint256[](1); + + moduleIds[0] = moduleId; + rewards[0] = 100; + vm.prank(agent); + vm.expectCall(address(csm), abi.encodeCall(csm.onRewardsMinted, (100))); + stakingRouter.reportRewardsMinted(moduleIds, rewards); + } }