diff --git a/script/DeployBase.s.sol b/script/DeployBase.s.sol index 5fd8868b..c95d2dc8 100644 --- a/script/DeployBase.s.sol +++ b/script/DeployBase.s.sol @@ -168,7 +168,8 @@ abstract contract DeployBase is Script { CSFeeDistributor feeDistributorImpl = new CSFeeDistributor({ stETH: locator.lido(), accounting: address(accounting), - oracle: address(oracle) + oracle: address(oracle), + rebateRecipient: config.aragonAgent }); feeDistributor = CSFeeDistributor( _deployProxy(config.proxyAdmin, address(feeDistributorImpl)) diff --git a/script/DeployImplementationsBase.s.sol b/script/DeployImplementationsBase.s.sol index 4448f875..3a4bb0f7 100644 --- a/script/DeployImplementationsBase.s.sol +++ b/script/DeployImplementationsBase.s.sol @@ -62,7 +62,8 @@ abstract contract DeployImplementationsBase is DeployBase { CSFeeDistributor feeDistributorImpl = new CSFeeDistributor({ stETH: locator.lido(), accounting: address(accounting), - oracle: address(oracle) + oracle: address(oracle), + rebateRecipient: config.aragonAgent }); verifier = new CSVerifier({ diff --git a/src/CSFeeDistributor.sol b/src/CSFeeDistributor.sol index 1da2d68f..b26cafe6 100644 --- a/src/CSFeeDistributor.sol +++ b/src/CSFeeDistributor.sol @@ -24,6 +24,7 @@ contract CSFeeDistributor is IStETH public immutable STETH; address public immutable ACCOUNTING; address public immutable ORACLE; + address public immutable REBATE_RECIPIENT; /// @notice The latest Merkle Tree root bytes32 public treeRoot; @@ -46,14 +47,21 @@ contract CSFeeDistributor is /// @notice The number of _distributionDataHistory records uint256 public distributionDataHistoryCount; - constructor(address stETH, address accounting, address oracle) { + constructor( + address stETH, + address accounting, + address oracle, + address rebateRecipient + ) { if (accounting == address(0)) revert ZeroAccountingAddress(); if (oracle == address(0)) revert ZeroOracleAddress(); if (stETH == address(0)) revert ZeroStEthAddress(); + if (rebateRecipient == address(0)) revert ZeroRebateRecipientAddress(); ACCOUNTING = accounting; STETH = IStETH(stETH); ORACLE = oracle; + REBATE_RECIPIENT = rebateRecipient; _disableInitializers(); } @@ -97,11 +105,12 @@ contract CSFeeDistributor is string calldata _treeCid, string calldata _logCid, uint256 _distributedShares, + uint256 rebateShares, uint256 refSlot ) external { if (msg.sender != ORACLE) revert NotOracle(); if ( - totalClaimableShares + _distributedShares > + totalClaimableShares + _distributedShares + rebateShares > STETH.sharesOf(address(this)) ) { revert InvalidShares(); @@ -131,6 +140,11 @@ contract CSFeeDistributor is emit ModuleFeeDistributed(_distributedShares); + if (rebateShares > 0) { + STETH.transferShares(REBATE_RECIPIENT, rebateShares); + emit RebateTransferred(rebateShares); + } + // 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. if (bytes(_logCid).length == 0) revert InvalidLogCID(); @@ -147,7 +161,8 @@ contract CSFeeDistributor is treeRoot: treeRoot, treeCid: treeCid, logCid: _logCid, - distributed: _distributedShares + distributed: _distributedShares, + rebate: rebateShares }); unchecked { diff --git a/src/CSFeeOracle.sol b/src/CSFeeOracle.sol index 16ebf002..52b65ab0 100644 --- a/src/CSFeeOracle.sol +++ b/src/CSFeeOracle.sol @@ -143,6 +143,7 @@ contract CSFeeOracle is _treeCid: data.treeCid, _logCid: data.logCid, _distributedShares: data.distributed, + rebateShares: data.rebate, refSlot: data.refSlot }); } diff --git a/src/interfaces/ICSFeeDistributor.sol b/src/interfaces/ICSFeeDistributor.sol index feb1fd48..b8dc4c27 100644 --- a/src/interfaces/ICSFeeDistributor.sol +++ b/src/interfaces/ICSFeeDistributor.sol @@ -21,6 +21,8 @@ interface ICSFeeDistributor is IAssetRecovererLib { string logCid; /// @notice Total amount of fees distributed in the report. uint256 distributed; + /// @notice Amount of the rebate shares in the report + uint256 rebate; } /// @dev Emitted when fees are distributed @@ -42,10 +44,14 @@ interface ICSFeeDistributor is IAssetRecovererLib { /// @dev It logs how many shares were distributed in the latest report event ModuleFeeDistributed(uint256 shares); + /// @dev Emitted when rebate is transferred + event RebateTransferred(uint256 shares); + error ZeroAccountingAddress(); error ZeroStEthAddress(); error ZeroAdminAddress(); error ZeroOracleAddress(); + error ZeroRebateRecipientAddress(); error NotAccounting(); error NotOracle(); @@ -65,6 +71,8 @@ interface ICSFeeDistributor is IAssetRecovererLib { function ORACLE() external view returns (address); + function REBATE_RECIPIENT() external view returns (address); + function treeRoot() external view returns (bytes32); function treeCid() external view returns (string calldata); @@ -102,12 +110,14 @@ interface ICSFeeDistributor is IAssetRecovererLib { /// @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 rebateShares an amount of the rebate shares /// @param refSlot refSlot of the report function processOracleReport( bytes32 _treeRoot, string calldata _treeCid, string calldata _logCid, uint256 _distributedShares, + uint256 rebateShares, uint256 refSlot ) external; diff --git a/src/interfaces/ICSFeeOracle.sol b/src/interfaces/ICSFeeOracle.sol index 972b6a1f..a0c12dbb 100644 --- a/src/interfaces/ICSFeeOracle.sol +++ b/src/interfaces/ICSFeeOracle.sol @@ -24,6 +24,8 @@ interface ICSFeeOracle is IAssetRecovererLib { string logCid; /// @notice Total amount of fees distributed in the report. uint256 distributed; + /// @notice Amount of the rebate shares in the report + uint256 rebate; } /// @dev Emitted when a new fee distributor contract is set diff --git a/test/CSFeeDistributor.t.sol b/test/CSFeeDistributor.t.sol index 6e9d71d8..3502260d 100644 --- a/test/CSFeeDistributor.t.sol +++ b/test/CSFeeDistributor.t.sol @@ -32,6 +32,7 @@ contract CSFeeDistributorTestBase is address internal stranger; address internal oracle; + address internal rebateRecipient; CSFeeDistributor internal feeDistributor; Stub internal csm; Stub internal accounting; @@ -50,6 +51,7 @@ contract CSFeeDistributorConstructorTest is CSFeeDistributorTestBase { function setUp() public { stranger = nextAddress("STRANGER"); oracle = nextAddress("ORACLE"); + rebateRecipient = nextAddress("REBATE_RECIPIENT"); csm = new Stub(); accounting = new Stub(); @@ -60,7 +62,8 @@ contract CSFeeDistributorConstructorTest is CSFeeDistributorTestBase { feeDistributor = new CSFeeDistributor( address(stETH), address(accounting), - oracle + oracle, + rebateRecipient ); assertEq(feeDistributor.ACCOUNTING(), address(accounting)); @@ -72,7 +75,8 @@ contract CSFeeDistributorConstructorTest is CSFeeDistributorTestBase { feeDistributor = new CSFeeDistributor( address(stETH), address(accounting), - oracle + oracle, + rebateRecipient ); vm.expectRevert(Initializable.InvalidInitialization.selector); @@ -81,17 +85,42 @@ contract CSFeeDistributorConstructorTest is CSFeeDistributorTestBase { function test_initialize_RevertWhen_ZeroAccountingAddress() public { vm.expectRevert(ICSFeeDistributor.ZeroAccountingAddress.selector); - new CSFeeDistributor(address(stETH), address(0), oracle); + new CSFeeDistributor( + address(stETH), + address(0), + oracle, + rebateRecipient + ); } function test_initialize_RevertWhen_ZeroStEthAddress() public { vm.expectRevert(ICSFeeDistributor.ZeroStEthAddress.selector); - new CSFeeDistributor(address(0), address(accounting), oracle); + new CSFeeDistributor( + address(0), + address(accounting), + oracle, + rebateRecipient + ); } function test_initialize_RevertWhen_ZeroOracleAddress() public { vm.expectRevert(ICSFeeDistributor.ZeroOracleAddress.selector); - new CSFeeDistributor(address(stETH), address(accounting), address(0)); + new CSFeeDistributor( + address(stETH), + address(accounting), + address(0), + rebateRecipient + ); + } + + function test_initialize_RevertWhen_ZeroRebateREcipientAddress() public { + vm.expectRevert(ICSFeeDistributor.ZeroRebateRecipientAddress.selector); + new CSFeeDistributor( + address(stETH), + address(accounting), + oracle, + address(0) + ); } } @@ -99,6 +128,7 @@ contract CSFeeDistributorInitTest is CSFeeDistributorTestBase { function setUp() public { stranger = nextAddress("STRANGER"); oracle = nextAddress("ORACLE"); + rebateRecipient = nextAddress("REBATE_RECIPIENT"); csm = new Stub(); accounting = new Stub(); @@ -107,7 +137,8 @@ contract CSFeeDistributorInitTest is CSFeeDistributorTestBase { feeDistributor = new CSFeeDistributor( address(stETH), address(accounting), - oracle + oracle, + rebateRecipient ); } @@ -123,6 +154,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { function setUp() public { stranger = nextAddress("STRANGER"); oracle = nextAddress("ORACLE"); + rebateRecipient = nextAddress("REBATE_RECIPIENT"); csm = new Stub(); accounting = new Stub(); @@ -131,7 +163,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { feeDistributor = new CSFeeDistributor( address(stETH), address(accounting), - oracle + oracle, + rebateRecipient ); _enableInitializers(address(feeDistributor)); @@ -159,6 +192,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares, + 0, refSlot ); @@ -193,6 +227,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares, + 0, refSlot ); @@ -223,6 +258,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares, + 0, refSlot ); @@ -263,19 +299,21 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { function test_getHistoricalDistributionData() public { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 rebate = 10; 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); + stETH.mintShares(address(feeDistributor), shares + rebate); vm.prank(oracle); feeDistributor.processOracleReport( root, treeCid, logCid, shares, + rebate, refSlot ); @@ -287,41 +325,46 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertEq(data.treeCid, treeCid); assertEq(data.logCid, logCid); assertEq(data.distributed, shares); + assertEq(data.rebate, rebate); } function test_getHistoricalDistributionData_multipleRecords() public { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 rebate = 0; 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); + stETH.mintShares(address(feeDistributor), shares + rebate); vm.prank(oracle); feeDistributor.processOracleReport( root, treeCid, logCid, shares, + rebate, refSlot ); nodeOperatorId = 4; shares = 120; + rebate = 10; refSlot = 155; tree.pushLeaf(abi.encode(nodeOperatorId, shares)); root = tree.root(); treeCid = someCIDv0(); logCid = someCIDv0(); - stETH.mintShares(address(feeDistributor), shares); + stETH.mintShares(address(feeDistributor), shares + rebate); vm.prank(oracle); feeDistributor.processOracleReport( root, treeCid, logCid, shares, + rebate, refSlot ); @@ -335,6 +378,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertEq(data.treeCid, treeCid); assertEq(data.logCid, logCid); assertEq(data.distributed, shares); + assertEq(data.rebate, rebate); } function test_hashLeaf() public assertInvariants { @@ -390,6 +434,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares, + 0, refSlot ); @@ -426,6 +471,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares - 1, + 0, refSlot ); @@ -456,6 +502,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares, + 0, refSlot ); @@ -488,6 +535,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), 899, + 0, refSlot ); @@ -497,8 +545,9 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { function test_processOracleReport_HappyPath() public assertInvariants { uint256 nodeOperatorId = 42; uint256 shares = 100; + uint256 rebate = 10; uint256 refSlot = 154; - stETH.mintShares(address(feeDistributor), shares); + stETH.mintShares(address(feeDistributor), shares + rebate); string memory treeCid = someCIDv0(); string memory logCid = someCIDv0(); @@ -514,6 +563,9 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { vm.expectEmit(true, true, true, true, address(feeDistributor)); emit ICSFeeDistributor.ModuleFeeDistributed(shares); + vm.expectEmit(true, true, true, true, address(feeDistributor)); + emit ICSFeeDistributor.RebateTransferred(rebate); + vm.expectEmit(true, true, true, true, address(feeDistributor)); emit ICSFeeDistributor.DistributionLogUpdated(logCid); @@ -523,6 +575,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { treeCid, logCid, shares, + rebate, refSlot ); @@ -531,6 +584,8 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertEq(feeDistributor.logCid(), logCid); assertEq(feeDistributor.pendingSharesToDistribute(), 0); assertEq(feeDistributor.totalClaimableShares(), shares); + assertEq(stETH.sharesOf(rebateRecipient), rebate); + assertEq(stETH.sharesOf(address(feeDistributor)), shares); } function test_processOracleReport_EmptyInitialReport() public { @@ -538,7 +593,14 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { uint256 refSlot = 154; vm.prank(oracle); - feeDistributor.processOracleReport(bytes32(0), "", logCid, 0, refSlot); + feeDistributor.processOracleReport( + bytes32(0), + "", + logCid, + 0, + 0, + refSlot + ); assertEq(feeDistributor.treeRoot(), bytes32(0)); assertEq(feeDistributor.treeCid(), ""); @@ -560,6 +622,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { lastTreeCid, newLogCid, 0, + 0, refSlot ); @@ -569,7 +632,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { assertEq(feeDistributor.totalClaimableShares(), shares); } - function test_processOracleReport_RevertWhen_InvalidShares() + function test_processOracleReport_RevertWhen_InvalidShares_TooMuchDistributed() public assertInvariants { @@ -589,6 +652,32 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares + 1, + 0, + refSlot + ); + } + + function test_processOracleReport_RevertWhen_InvalidShares_NotEnoughForRebate() + public + assertInvariants + { + uint256 nodeOperatorId = 42; + uint256 shares = 100; + uint256 refSlot = 154; + tree.pushLeaf(abi.encode(nodeOperatorId, shares)); + + stETH.mintShares(address(feeDistributor), shares); + + bytes32 root = tree.root(); + + vm.expectRevert(ICSFeeDistributor.InvalidShares.selector); + vm.prank(oracle); + feeDistributor.processOracleReport( + root, + someCIDv0(), + someCIDv0(), + shares, + 1, refSlot ); } @@ -610,6 +699,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares, + 0, refSlot ); } @@ -632,6 +722,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares, + 0, refSlot ); } @@ -653,6 +744,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { "", someCIDv0(), shares, + 0, refSlot ); } @@ -675,6 +767,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { lastTreeCid, someCIDv0(), shares, + 0, refSlot ); } @@ -688,6 +781,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), "", 0, + 0, refSlot ); } @@ -706,6 +800,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), lastLogCid, 0, + 0, refSlot ); } @@ -725,6 +820,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), 1, + 0, refSlot ); } @@ -741,6 +837,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), 1, + 0, refSlot ); } @@ -755,6 +852,7 @@ contract CSFeeDistributorTest is CSFeeDistributorTestBase { someCIDv0(), someCIDv0(), shares, + 0, refSlot ); } @@ -772,10 +870,13 @@ contract CSFeeDistributorAssetRecovererTest is CSFeeDistributorTestBase { recoverer = nextAddress("RECOVERER"); stranger = nextAddress("STRANGER"); + rebateRecipient = nextAddress("REBATE_RECIPIENT"); + feeDistributor = new CSFeeDistributor( address(stETH), address(accounting), - nextAddress("ORACLE") + nextAddress("ORACLE"), + rebateRecipient ); _enableInitializers(address(feeDistributor)); diff --git a/test/CSFeeOracle.t.sol b/test/CSFeeOracle.t.sol index cdc51aa6..249eee6b 100644 --- a/test/CSFeeOracle.t.sol +++ b/test/CSFeeOracle.t.sol @@ -88,7 +88,8 @@ contract CSFeeOracleTest is Test, Utilities { treeRoot: keccak256("root"), treeCid: someCIDv0(), logCid: someCIDv0(), - distributed: 1337 + distributed: 1337, + rebate: 154 }); bytes32 reportHash = keccak256(abi.encode(data)); @@ -137,7 +138,8 @@ contract CSFeeOracleTest is Test, Utilities { treeRoot: keccak256("root"), treeCid: someCIDv0(), logCid: someCIDv0(), - distributed: 1337 + distributed: 1337, + rebate: 154 }); bytes32 reportHash = keccak256(abi.encode(data)); @@ -174,7 +176,8 @@ contract CSFeeOracleTest is Test, Utilities { treeRoot: keccak256("root"), treeCid: someCIDv0(), logCid: someCIDv0(), - distributed: 1337 + distributed: 1337, + rebate: 154 }); vm.expectRevert(PausableUntil.ResumedExpected.selector); @@ -208,7 +211,8 @@ contract CSFeeOracleTest is Test, Utilities { treeRoot: keccak256("root"), treeCid: someCIDv0(), logCid: someCIDv0(), - distributed: 1337 + distributed: 1337, + rebate: 154 }); vm.expectRevert(PausableUntil.ResumedExpected.selector); @@ -244,7 +248,8 @@ contract CSFeeOracleTest is Test, Utilities { treeRoot: keccak256("root"), treeCid: someCIDv0(), logCid: someCIDv0(), - distributed: 1337 + distributed: 1337, + rebate: 154 }); bytes32 reportHash = keccak256(abi.encode(data)); diff --git a/test/fork/deployment/Upgradability.sol b/test/fork/deployment/Upgradability.sol index ba3710d2..c264617f 100644 --- a/test/fork/deployment/Upgradability.sol +++ b/test/fork/deployment/Upgradability.sol @@ -154,7 +154,8 @@ contract UpgradabilityTest is Test, Utilities, DeploymentFixtures { CSFeeDistributor newFeeDistributor = new CSFeeDistributor({ stETH: locator.lido(), accounting: address(1337), - oracle: address(oracle) + oracle: address(oracle), + rebateRecipient: address(locator.treasury()) }); vm.prank(proxy.proxy__getAdmin()); proxy.proxy__upgradeTo(address(newFeeDistributor)); diff --git a/test/fork/integration/ClaimInTokens.t.sol b/test/fork/integration/ClaimInTokens.t.sol index c0c645f2..7c241ad5 100644 --- a/test/fork/integration/ClaimInTokens.t.sol +++ b/test/fork/integration/ClaimInTokens.t.sol @@ -302,6 +302,7 @@ contract ClaimIntegrationTest is someCIDv0(), someCIDv0(), shares, + 0, refSlot ); @@ -352,6 +353,7 @@ contract ClaimIntegrationTest is someCIDv0(), someCIDv0(), shares, + 0, refSlot ); @@ -416,6 +418,7 @@ contract ClaimIntegrationTest is someCIDv0(), someCIDv0(), shares, + 0, refSlot ); diff --git a/test/helpers/mocks/DistributorMock.sol b/test/helpers/mocks/DistributorMock.sol index d180121c..1e3071a2 100644 --- a/test/helpers/mocks/DistributorMock.sol +++ b/test/helpers/mocks/DistributorMock.sol @@ -19,6 +19,7 @@ contract DistributorMock { string calldata /* treeCid */, string calldata /* logCid */, uint256 /* distributedShares */, + uint256 /* rebateShares */, uint256 /* refSlot */ ) external { // do nothing