-
Notifications
You must be signed in to change notification settings - Fork 598
feat: add flushing rewarder #17335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
just-mitch
merged 1 commit into
next
from
lh/tmnt-309-ensure-that-queue-is-getting-flushed
Oct 2, 2025
Merged
feat: add flushing rewarder #17335
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feels like a previous design miss that this doesn't return the number flushed. |
||
| 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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
121 changes: 121 additions & 0 deletions
121
l1-contracts/test/staking/rewarded-flushing/rewardedFlushing.t.sol
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if the contract doesn't have sufficient rewards to cover the claim? On a related note, should we add a getter for the "shortfall" i.e. the amount of funds that would need to be added to this contract to cover all outstanding claims?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nevermind- I missed the
Math.min(insertions * rewardPerInsertion, rewardsAvailable());👍