From a14098777730699d2c3c4ca18c00a001bedad788 Mon Sep 17 00:00:00 2001 From: Dmitry Gusakov Date: Wed, 15 Jan 2025 16:00:28 +0100 Subject: [PATCH] feat: Add distribution data history --- src/CSFeeDistributor.sol | 45 +++++- src/CSFeeOracle.sol | 13 +- src/interfaces/ICSFeeDistributor.sol | 27 +++- src/interfaces/ICSFeeOracle.sol | 2 +- test/CSFeeDistributor.t.sol | 171 +++++++++++++++++++--- test/fork/integration/ClaimInTokens.t.sol | 12 +- test/helpers/mocks/DistributorMock.sol | 3 +- 7 files changed, 233 insertions(+), 40 deletions(-) diff --git a/src/CSFeeDistributor.sol b/src/CSFeeDistributor.sol index f6043d7b..1da2d68f 100644 --- a/src/CSFeeDistributor.sol +++ b/src/CSFeeDistributor.sol @@ -25,13 +25,13 @@ contract CSFeeDistributor is address public immutable ACCOUNTING; address public immutable ORACLE; - /// @notice Merkle Tree root + /// @notice The latest Merkle Tree root bytes32 public treeRoot; - /// @notice CID of the published Merkle tree + /// @notice CID of the last published Merkle tree string public treeCid; - /// @notice CID of the file with log of the last frame reported + /// @notice CID of the file with log for the last frame reported string public logCid; /// @notice Amount of stETH shares sent to the Accounting in favor of the NO @@ -40,6 +40,12 @@ contract CSFeeDistributor is /// @notice Total Amount of stETH shares available for claiming by NOs uint256 public totalClaimableShares; + /// @notice Array of the distribution data history + mapping(uint256 => DistributionData) internal _distributionDataHistory; + + /// @notice The number of _distributionDataHistory records + uint256 public distributionDataHistoryCount; + constructor(address stETH, address accounting, address oracle) { if (accounting == address(0)) revert ZeroAccountingAddress(); if (oracle == address(0)) revert ZeroOracleAddress(); @@ -90,16 +96,18 @@ contract CSFeeDistributor is bytes32 _treeRoot, string calldata _treeCid, string calldata _logCid, - uint256 distributed + uint256 _distributedShares, + uint256 refSlot ) external { if (msg.sender != ORACLE) revert NotOracle(); if ( - totalClaimableShares + distributed > STETH.sharesOf(address(this)) + totalClaimableShares + _distributedShares > + STETH.sharesOf(address(this)) ) { revert InvalidShares(); } - if (distributed > 0) { + if (_distributedShares > 0) { if (bytes(_treeCid).length == 0) revert InvalidTreeCID(); if (keccak256(bytes(_treeCid)) == keccak256(bytes(treeCid))) revert InvalidTreeCID(); @@ -108,7 +116,7 @@ contract CSFeeDistributor is // Doesn't overflow because of the very first check. unchecked { - totalClaimableShares += distributed; + totalClaimableShares += _distributedShares; } treeRoot = _treeRoot; @@ -121,7 +129,7 @@ contract CSFeeDistributor is ); } - emit ModuleFeeDistributed(distributed); + emit ModuleFeeDistributed(_distributedShares); // NOTE: Make sure off-chain tooling provides a distinct CID of a log even for empty reports, e.g. by mixing // in a frame identifier such as reference slot to a file. @@ -131,6 +139,20 @@ contract CSFeeDistributor is logCid = _logCid; emit DistributionLogUpdated(_logCid); + + _distributionDataHistory[ + distributionDataHistoryCount + ] = DistributionData({ + refSlot: refSlot, + treeRoot: treeRoot, + treeCid: treeCid, + logCid: _logCid, + distributed: _distributedShares + }); + + unchecked { + ++distributionDataHistoryCount; + } } /// @inheritdoc AssetRecoverer @@ -147,6 +169,13 @@ contract CSFeeDistributor is return STETH.sharesOf(address(this)) - totalClaimableShares; } + /// @inheritdoc ICSFeeDistributor + function getHistoricalDistributionData( + uint256 index + ) external view returns (DistributionData memory) { + return _distributionDataHistory[index]; + } + /// @inheritdoc ICSFeeDistributor function getFeesToDistribute( uint256 nodeOperatorId, diff --git a/src/CSFeeOracle.sol b/src/CSFeeOracle.sol index b62b8ece..16ebf002 100644 --- a/src/CSFeeOracle.sol +++ b/src/CSFeeOracle.sol @@ -138,12 +138,13 @@ contract CSFeeOracle is } function _handleConsensusReportData(ReportData calldata data) internal { - feeDistributor.processOracleReport( - data.treeRoot, - data.treeCid, - data.logCid, - data.distributed - ); + feeDistributor.processOracleReport({ + _treeRoot: data.treeRoot, + _treeCid: data.treeCid, + _logCid: data.logCid, + _distributedShares: data.distributed, + refSlot: data.refSlot + }); } function _checkMsgSenderIsAllowedToSubmitData() internal view { diff --git a/src/interfaces/ICSFeeDistributor.sol b/src/interfaces/ICSFeeDistributor.sol index 928373af..feb1fd48 100644 --- a/src/interfaces/ICSFeeDistributor.sol +++ b/src/interfaces/ICSFeeDistributor.sol @@ -7,6 +7,22 @@ import { IStETH } from "./IStETH.sol"; pragma solidity 0.8.24; interface ICSFeeDistributor is IAssetRecovererLib { + struct DistributionData { + /// @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 CID of the file with log of the frame reported. + string logCid; + /// @notice Total amount of fees distributed in the report. + uint256 distributed; + } + /// @dev Emitted when fees are distributed event OperatorFeeDistributed( uint256 indexed nodeOperatorId, @@ -85,17 +101,26 @@ interface ICSFeeDistributor is IAssetRecovererLib { /// @param _treeRoot Root of the Merkle tree /// @param _treeCid an IPFS CID of the tree /// @param _logCid an IPFS CID of the log + /// @param _distributedShares an amount of the distributed shares + /// @param refSlot refSlot of the report function processOracleReport( bytes32 _treeRoot, string calldata _treeCid, string calldata _logCid, - uint256 _distributedShares + uint256 _distributedShares, + uint256 refSlot ) external; /// @notice Get the Amount of stETH shares that are pending to be distributed /// @return pendingShares Amount shares that are pending to distribute function pendingSharesToDistribute() external view returns (uint256); + /// @notice Get the historical record of distribution data + /// @return index Historical entry index + function getHistoricalDistributionData( + uint256 index + ) external view returns (DistributionData memory); + /// @notice Get a hash of a leaf /// @param nodeOperatorId ID of the Node Operator /// @param shares Amount of stETH shares diff --git a/src/interfaces/ICSFeeOracle.sol b/src/interfaces/ICSFeeOracle.sol index ebafabd4..972b6a1f 100644 --- a/src/interfaces/ICSFeeOracle.sol +++ b/src/interfaces/ICSFeeOracle.sol @@ -20,7 +20,7 @@ interface ICSFeeOracle is IAssetRecovererLib { bytes32 treeRoot; /// @notice CID of the published Merkle tree. string treeCid; - /// @notice CID of the file with log of the last frame reported. + /// @notice CID of the file with log of the frame reported. string logCid; /// @notice Total amount of fees distributed in the report. uint256 distributed; diff --git a/test/CSFeeDistributor.t.sol b/test/CSFeeDistributor.t.sol index 34ab111f..6e9d71d8 100644 --- a/test/CSFeeDistributor.t.sol +++ b/test/CSFeeDistributor.t.sol @@ -147,6 +147,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { function test_distributeFeesHappyPath() public assertInvariants { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 refSlot = 154; tree.pushLeaf(abi.encode(nodeOperatorId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); @@ -157,7 +158,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); vm.expectEmit(true, true, true, true, address(feeDistributor)); @@ -179,6 +181,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 refSlot = 154; tree.pushLeaf(abi.encode(nodeOperatorId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); @@ -189,7 +192,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); uint256 sharesToDistribute = feeDistributor.getFeesToDistribute({ @@ -207,6 +211,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 refSlot = 154; tree.pushLeaf(abi.encode(nodeOperatorId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); @@ -217,7 +222,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); vm.prank(address(accounting)); @@ -254,6 +260,83 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { }); } + function test_getHistoricalDistributionData() public { + uint256 nodeOperatorId = 42; + uint256 shares = 100; + uint256 refSlot = 154; + tree.pushLeaf(abi.encode(nodeOperatorId, shares)); + bytes32 root = tree.root(); + string memory treeCid = someCIDv0(); + string memory logCid = someCIDv0(); + + stETH.mintShares(address(feeDistributor), shares); + vm.prank(oracle); + feeDistributor.processOracleReport( + root, + treeCid, + logCid, + shares, + refSlot + ); + + ICSFeeDistributor.DistributionData memory data = feeDistributor + .getHistoricalDistributionData(0); + + assertEq(data.refSlot, refSlot); + assertEq(data.treeRoot, root); + assertEq(data.treeCid, treeCid); + assertEq(data.logCid, logCid); + assertEq(data.distributed, shares); + } + + function test_getHistoricalDistributionData_multipleRecords() public { + uint256 nodeOperatorId = 42; + uint256 shares = 100; + uint256 refSlot = 154; + tree.pushLeaf(abi.encode(nodeOperatorId, shares)); + bytes32 root = tree.root(); + string memory treeCid = someCIDv0(); + string memory logCid = someCIDv0(); + stETH.mintShares(address(feeDistributor), shares); + vm.prank(oracle); + feeDistributor.processOracleReport( + root, + treeCid, + logCid, + shares, + refSlot + ); + + nodeOperatorId = 4; + shares = 120; + refSlot = 155; + tree.pushLeaf(abi.encode(nodeOperatorId, shares)); + root = tree.root(); + treeCid = someCIDv0(); + logCid = someCIDv0(); + + stETH.mintShares(address(feeDistributor), shares); + vm.prank(oracle); + feeDistributor.processOracleReport( + root, + treeCid, + logCid, + shares, + refSlot + ); + + uint256 historyLength = feeDistributor.distributionDataHistoryCount(); + + ICSFeeDistributor.DistributionData memory data = feeDistributor + .getHistoricalDistributionData(historyLength - 1); + + assertEq(data.refSlot, refSlot); + assertEq(data.treeRoot, root); + assertEq(data.treeCid, treeCid); + assertEq(data.logCid, logCid); + assertEq(data.distributed, shares); + } + function test_hashLeaf() public assertInvariants { // keccak256(bytes.concat(keccak256(abi.encode(1, 1000)))) == 0xe2ad525aaaf1fb7709959cc06e210437a97f34a5833e3a5c90d2099c5373116a assertEq( @@ -295,6 +378,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 refSlot = 154; tree.pushLeaf(abi.encode(nodeOperatorId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); @@ -305,7 +389,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); stdstore @@ -329,6 +414,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 refSlot = 154; tree.pushLeaf(abi.encode(nodeOperatorId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); @@ -339,7 +425,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { root, someCIDv0(), someCIDv0(), - shares - 1 + shares - 1, + refSlot ); vm.expectRevert(ICSFeeDistributor.NotEnoughShares.selector); @@ -357,6 +444,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 refSlot = 154; tree.pushLeaf(abi.encode(nodeOperatorId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); @@ -367,7 +455,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); stdstore @@ -390,6 +479,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { function test_pendingSharesToDistribute() public assertInvariants { uint256 totalShares = 1000; + uint256 refSlot = 154; stETH.mintShares(address(feeDistributor), totalShares); vm.prank(oracle); @@ -397,7 +487,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someBytes32(), someCIDv0(), someCIDv0(), - 899 + 899, + refSlot ); assertEq(feeDistributor.pendingSharesToDistribute(), 101); @@ -406,6 +497,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { function test_processOracleReport_HappyPath() public assertInvariants { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 refSlot = 154; stETH.mintShares(address(feeDistributor), shares); string memory treeCid = someCIDv0(); @@ -426,7 +518,13 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { emit ICSFeeDistributor.DistributionLogUpdated(logCid); vm.prank(oracle); - feeDistributor.processOracleReport(treeRoot, treeCid, logCid, shares); + feeDistributor.processOracleReport( + treeRoot, + treeCid, + logCid, + shares, + refSlot + ); assertEq(feeDistributor.treeRoot(), treeRoot); assertEq(feeDistributor.treeCid(), treeCid); @@ -437,9 +535,10 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { function test_processOracleReport_EmptyInitialReport() public { string memory logCid = someCIDv0(); + uint256 refSlot = 154; vm.prank(oracle); - feeDistributor.processOracleReport(bytes32(0), "", logCid, 0); + feeDistributor.processOracleReport(bytes32(0), "", logCid, 0, refSlot); assertEq(feeDistributor.treeRoot(), bytes32(0)); assertEq(feeDistributor.treeCid(), ""); @@ -448,6 +547,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { function test_processOracleReport_EmptySubsequentReport() public { uint256 shares = 1_000_000; + uint256 refSlot = 154; _makeInitialReport(shares); string memory lastTreeCid = feeDistributor.treeCid(); @@ -455,7 +555,13 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { string memory newLogCid = someCIDv0(); vm.prank(oracle); - feeDistributor.processOracleReport(lastRoot, lastTreeCid, newLogCid, 0); + feeDistributor.processOracleReport( + lastRoot, + lastTreeCid, + newLogCid, + 0, + refSlot + ); assertEq(feeDistributor.treeRoot(), lastRoot); assertEq(feeDistributor.treeCid(), lastTreeCid); @@ -469,6 +575,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 refSlot = 154; tree.pushLeaf(abi.encode(nodeOperatorId, shares)); stETH.mintShares(address(feeDistributor), shares); @@ -481,7 +588,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { root, someCIDv0(), someCIDv0(), - shares + 1 + shares + 1, + refSlot ); } @@ -490,6 +598,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertInvariants { uint256 shares = 1_000_000; + uint256 refSlot = 154; _makeInitialReport(shares); stETH.mintShares(address(feeDistributor), shares); @@ -500,7 +609,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { bytes32(0), someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); } @@ -509,6 +619,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertInvariants { uint256 shares = 1_000_000; + uint256 refSlot = 154; _makeInitialReport(shares); stETH.mintShares(address(feeDistributor), shares); @@ -520,7 +631,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); } @@ -529,6 +641,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertInvariants { uint256 shares = 1_000_000; + uint256 refSlot = 154; _makeInitialReport(shares); stETH.mintShares(address(feeDistributor), shares); @@ -539,7 +652,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someBytes32(), "", someCIDv0(), - shares + shares, + refSlot ); } @@ -548,6 +662,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertInvariants { uint256 shares = 1_000_000; + uint256 refSlot = 154; _makeInitialReport(shares); stETH.mintShares(address(feeDistributor), shares); @@ -559,18 +674,27 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someBytes32(), lastTreeCid, someCIDv0(), - shares + shares, + refSlot ); } function test_processOracleReport_RevertWhen_ZeroLogCid() public { + uint256 refSlot = 154; vm.expectRevert(ICSFeeDistributor.InvalidLogCID.selector); vm.prank(oracle); - feeDistributor.processOracleReport(someBytes32(), someCIDv0(), "", 0); + feeDistributor.processOracleReport( + someBytes32(), + someCIDv0(), + "", + 0, + refSlot + ); } function test_processOracleReport_RevertWhen_SameLogCid() public { uint256 shares = 1_000_000; + uint256 refSlot = 154; _makeInitialReport(shares); string memory lastLogCid = feeDistributor.logCid(); @@ -581,7 +705,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someBytes32(), someCIDv0(), lastLogCid, - 0 + 0, + refSlot ); } @@ -590,6 +715,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertInvariants { uint256 shares = 1_000_000; + uint256 refSlot = 154; _makeInitialReport(shares); vm.expectRevert(ICSFeeDistributor.InvalidShares.selector); @@ -598,7 +724,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someBytes32(), someCIDv0(), someCIDv0(), - 1 + 1, + refSlot ); } @@ -606,25 +733,29 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { public assertInvariants { + uint256 refSlot = 154; vm.expectRevert(ICSFeeDistributor.NotOracle.selector); vm.prank(stranger); feeDistributor.processOracleReport( someBytes32(), someCIDv0(), someCIDv0(), - 1 + 1, + refSlot ); } function _makeInitialReport(uint256 shares) internal { stETH.mintShares(address(feeDistributor), shares); + uint256 refSlot = 154; vm.prank(oracle); feeDistributor.processOracleReport( someBytes32(), someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); } } diff --git a/test/fork/integration/ClaimInTokens.t.sol b/test/fork/integration/ClaimInTokens.t.sol index 7ccabc50..c0c645f2 100644 --- a/test/fork/integration/ClaimInTokens.t.sol +++ b/test/fork/integration/ClaimInTokens.t.sol @@ -294,13 +294,15 @@ contract ClaimIntegrationTest is tree.pushLeaf(abi.encode(defaultNoId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); + uint256 refSlot = 154; vm.prank(feeDistributor.ORACLE()); feeDistributor.processOracleReport( root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); vm.prank(nodeOperator); @@ -342,13 +344,15 @@ contract ClaimIntegrationTest is tree.pushLeaf(abi.encode(defaultNoId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); + uint256 refSlot = 154; vm.prank(feeDistributor.ORACLE()); feeDistributor.processOracleReport( root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); vm.prank(nodeOperator); @@ -404,13 +408,15 @@ contract ClaimIntegrationTest is tree.pushLeaf(abi.encode(defaultNoId, shares)); bytes32[] memory proof = tree.getProof(0); bytes32 root = tree.root(); + uint256 refSlot = 154; vm.prank(feeDistributor.ORACLE()); feeDistributor.processOracleReport( root, someCIDv0(), someCIDv0(), - shares + shares, + refSlot ); uint256 accountingSharesBefore = lido.sharesOf(address(accounting)); diff --git a/test/helpers/mocks/DistributorMock.sol b/test/helpers/mocks/DistributorMock.sol index c33008d6..d180121c 100644 --- a/test/helpers/mocks/DistributorMock.sol +++ b/test/helpers/mocks/DistributorMock.sol @@ -18,7 +18,8 @@ contract DistributorMock { bytes32 /* treeRoot */, string calldata /* treeCid */, string calldata /* logCid */, - uint256 /* distributedShares */ + uint256 /* distributedShares */, + uint256 /* refSlot */ ) external { // do nothing }