diff --git a/l1-contracts/src/core/interfaces/IRollup.sol b/l1-contracts/src/core/interfaces/IRollup.sol index f50f0f97dd0c..e480ab5c71a3 100644 --- a/l1-contracts/src/core/interfaces/IRollup.sol +++ b/l1-contracts/src/core/interfaces/IRollup.sol @@ -142,6 +142,7 @@ interface IRollupCore { function setRewardConfig(RewardConfig memory _config) external; function updateManaTarget(uint256 _manaTarget) external; + function isRewardsClaimable() external view returns (bool); // solhint-disable-next-line func-name-mixedcase function L1_BLOCK_AT_GENESIS() external view returns (uint256); } diff --git a/l1-contracts/src/periphery/FlushRewarder.sol b/l1-contracts/src/periphery/FlushRewarder.sol new file mode 100644 index 000000000000..4a4bec7b9884 --- /dev/null +++ b/l1-contracts/src/periphery/FlushRewarder.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Ownable} from "@oz/access/Ownable.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@oz/utils/math/Math.sol"; +import {IFlushRewarder} from "./interfaces/IFlushRewarder.sol"; + +contract FlushRewarder is Ownable, IFlushRewarder { + using SafeERC20 for IERC20; + + uint256 public constant DEFAULT_MAX_ADD_PER_FLUSH = 16; + + IInstance public immutable ROLLUP; + IERC20 public immutable REWARD_ASSET; + + uint256 public rewardPerInsertion; + + uint256 internal debt; + + mapping(address => uint256) public rewardsOf; + + constructor(address _owner, IInstance _rollup, IERC20 _rewardAsset, uint256 _rewardPerInsertion) Ownable(_owner) { + ROLLUP = _rollup; + REWARD_ASSET = _rewardAsset; + rewardPerInsertion = _rewardPerInsertion; + } + + function setRewardPerInsertion(uint256 _rewardPerInsertion) external override(IFlushRewarder) onlyOwner { + rewardPerInsertion = _rewardPerInsertion; + emit RewardPerInsertionUpdated(_rewardPerInsertion); + } + + function recover(address _asset, address _to, uint256 _amount) external override(IFlushRewarder) onlyOwner { + if (_asset == address(REWARD_ASSET)) { + require(_amount <= rewardsAvailable(), InsufficientRewardsAvailable()); + } + IERC20(_asset).safeTransfer(_to, _amount); + } + + function flushEntryQueue() external override(IFlushRewarder) { + flushEntryQueue(DEFAULT_MAX_ADD_PER_FLUSH); + } + + function claimRewards() external override(IFlushRewarder) { + require(ROLLUP.isRewardsClaimable(), Errors.Rollup__RewardsNotClaimable()); + + uint256 rewardsToClaim = rewardsOf[msg.sender]; + if (rewardsToClaim > 0) { + rewardsOf[msg.sender] = 0; + debt -= rewardsToClaim; + REWARD_ASSET.safeTransfer(msg.sender, rewardsToClaim); + } + } + + function flushEntryQueue(uint256 _toAdd) public override(IFlushRewarder) { + uint256 validatorSetSizeBefore = ROLLUP.getActiveAttesterCount(); + ROLLUP.flushEntryQueue(_toAdd); + uint256 insertions = ROLLUP.getActiveAttesterCount() - validatorSetSizeBefore; + + if (insertions > 0) { + uint256 rewards = Math.min(insertions * rewardPerInsertion, rewardsAvailable()); + debt += rewards; + rewardsOf[msg.sender] += rewards; + } + } + + function rewardsAvailable() public view override(IFlushRewarder) returns (uint256) { + return REWARD_ASSET.balanceOf(address(this)) - debt; + } +} diff --git a/l1-contracts/src/periphery/interfaces/IFlushRewarder.sol b/l1-contracts/src/periphery/interfaces/IFlushRewarder.sol new file mode 100644 index 000000000000..547db24dfde2 --- /dev/null +++ b/l1-contracts/src/periphery/interfaces/IFlushRewarder.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +interface IFlushRewarder { + event RewardPerInsertionUpdated(uint256 rewardPerInsertion); + + error InsufficientRewardsAvailable(); + + function setRewardPerInsertion(uint256 _rewardPerInsertion) external; + function flushEntryQueue() external; + function flushEntryQueue(uint256 _toAdd) external; + function claimRewards() external; + function recover(address _asset, address _to, uint256 _amount) external; + + function rewardsAvailable() external view returns (uint256); + function rewardsOf(address _account) external view returns (uint256); +} diff --git a/l1-contracts/test/staking/rewarded-flushing/rewardedFlushing.t.sol b/l1-contracts/test/staking/rewarded-flushing/rewardedFlushing.t.sol new file mode 100644 index 000000000000..ee341ec907a3 --- /dev/null +++ b/l1-contracts/test/staking/rewarded-flushing/rewardedFlushing.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable func-name-mixedcase +// solhint-disable imports-order +// solhint-disable comprehensive-interface +// solhint-disable ordering + +pragma solidity >=0.8.27; + +import {StakingBase} from "../base.t.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Epoch, Timestamp} from "@aztec/shared/libraries/TimeMath.sol"; +import {Status, AttesterView, IStakingCore} from "@aztec/core/interfaces/IStaking.sol"; +import {Math} from "@oz/utils/math/Math.sol"; +import {GSE, IGSECore} from "@aztec/governance/GSE.sol"; +import {StakingQueueLib, DepositArgs} from "@aztec/core/libraries/StakingQueue.sol"; +import {StakingQueueConfig, StakingQueueConfigLib} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; +import {Rollup} from "@aztec/core/Rollup.sol"; +import {BN254Lib, G1Point, G2Point} from "@aztec/shared/libraries/BN254Lib.sol"; +import {FlushRewarder, IFlushRewarder} from "@aztec/periphery/FlushRewarder.sol"; +import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; + +contract RewardedFlushingTest is StakingBase { + uint256 public constant MAX_QUEUE_FLUSH_SIZE = 48; + FlushRewarder public flushRewarder; + + function setUp() public override { + super.setUp(); + flushRewarder = new FlushRewarder(address(this), IInstance(address(staking)), stakingAsset, 50e18); + + StakingQueueConfig memory stakingQueueConfig = StakingQueueConfig({ + bootstrapValidatorSetSize: 0, + bootstrapFlushSize: 0, + normalFlushSizeMin: 8, + normalFlushSizeQuotient: 1, + maxQueueFlushSize: MAX_QUEUE_FLUSH_SIZE + }); + Rollup rollup = Rollup(address(registry.getCanonicalRollup())); + vm.prank(rollup.owner()); + rollup.updateStakingQueueConfig(stakingQueueConfig); + + for (uint256 i = 1; i <= 10; i++) { + _help_deposit(address(uint160(i)), address(uint160(i)), true); + } + } + + function test_givenNoFunding() external { + uint256 attestersBefore = staking.getActiveAttesterCount(); + flushRewarder.flushEntryQueue(); + + assertEq(staking.getActiveAttesterCount(), attestersBefore + 8, "invalid active attester count"); + assertEq(stakingAsset.balanceOf(address(flushRewarder)), 0, "invalid balance"); + assertEq(flushRewarder.rewardsOf(address(this)), 0, "invalid rewards"); + } + + modifier givenFunding() { + vm.prank(stakingAsset.owner()); + stakingAsset.mint(address(flushRewarder), 1_000_000e18); + _; + } + + function test_givenRewardsNotClaimable() external givenFunding { + uint256 attestersBefore = staking.getActiveAttesterCount(); + flushRewarder.flushEntryQueue(); + + assertEq(staking.getActiveAttesterCount(), attestersBefore + 8, "invalid active attester count"); + assertEq(stakingAsset.balanceOf(address(flushRewarder)), 1_000_000e18, "invalid balance"); + assertEq(flushRewarder.rewardsOf(address(this)), 8 * flushRewarder.rewardPerInsertion(), "invalid rewards"); + + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__RewardsNotClaimable.selector)); + flushRewarder.claimRewards(); + + vm.expectRevert(abi.encodeWithSelector(IFlushRewarder.InsufficientRewardsAvailable.selector)); + flushRewarder.recover(address(stakingAsset), address(this), 1_000_000e18); + } + + modifier givenRewardsClaimable() { + vm.prank(address(staking.getGSE().getGovernance())); + Rollup(address(staking)).setRewardsClaimable(true); + _; + } + + function test_givenFundingGivenRewardsClaimable() external givenFunding givenRewardsClaimable { + uint256 attestersBefore = staking.getActiveAttesterCount(); + flushRewarder.flushEntryQueue(); + + assertEq(staking.getActiveAttesterCount(), attestersBefore + 8, "invalid active attester count"); + assertEq(stakingAsset.balanceOf(address(flushRewarder)), 1_000_000e18, "invalid balance"); + uint256 rewards = flushRewarder.rewardsOf(address(this)); + assertEq(rewards, 8 * flushRewarder.rewardPerInsertion(), "invalid rewards"); + + flushRewarder.claimRewards(); + assertEq(stakingAsset.balanceOf(address(flushRewarder)), 1_000_000e18 - rewards, "invalid balance"); + assertEq(flushRewarder.rewardsOf(address(this)), 0, "invalid rewards"); + assertEq(stakingAsset.balanceOf(address(this)), rewards, "invalid balance"); + } + + function _help_deposit(address _attester, address _withdrawer, bool _moveWithLatestRollup) internal { + mint(address(this), ACTIVATION_THRESHOLD); + stakingAsset.approve(address(staking), ACTIVATION_THRESHOLD); + uint256 balance = stakingAsset.balanceOf(address(staking)); + + staking.deposit({ + _attester: _attester, + _withdrawer: _withdrawer, + _publicKeyInG1: BN254Lib.g1Zero(), + _publicKeyInG2: BN254Lib.g2Zero(), + _proofOfPossession: BN254Lib.g1Zero(), + _moveWithLatestRollup: _moveWithLatestRollup + }); + + assertEq(stakingAsset.balanceOf(address(staking)), balance + ACTIVATION_THRESHOLD, "invalid balance"); + + DepositArgs memory validator = staking.getEntryQueueAt(staking.getEntryQueueLength() - 1); + assertEq(validator.attester, _attester, "invalid attester"); + assertEq(validator.withdrawer, _withdrawer, "invalid withdrawer"); + assertTrue(BN254Lib.isZero(validator.publicKeyInG1), "invalid public key in G1"); + assertTrue(BN254Lib.isZero(validator.publicKeyInG2), "invalid public key in G2"); + assertTrue(BN254Lib.isZero(validator.proofOfPossession), "invalid proof of possession"); + assertEq(validator.moveWithLatestRollup, _moveWithLatestRollup, "invalid move with latest rollup"); + } +}