From 763ceca848ae961afb1a53536fdeb68c4d26fa19 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Sat, 6 Sep 2025 01:52:50 -0400 Subject: [PATCH 01/20] feat: RewardsManagerV2 and StipendDistributor contracts --- .../interfaces/IRewardsManagerV2.sol | 47 ++ .../interfaces/IStipendDistributor.sol | 103 ++++ .../utils/TransientReentrancyGuard.sol | 18 + .../rewards/RewardsManagerV2.sol | 92 +++ .../rewards/RewardsManagerV2Storage.sol | 14 + .../rewards/StipendDistributor.sol | 263 ++++++++ .../rewards/StipendDistributorStorage.sol | 33 + .../rewards/RewardsManagerV2Test.sol | 234 +++++++ .../rewards/StipendDistributorTest.sol | 575 ++++++++++++++++++ 9 files changed, 1379 insertions(+) create mode 100644 contracts/contracts/interfaces/IRewardsManagerV2.sol create mode 100644 contracts/contracts/interfaces/IStipendDistributor.sol create mode 100644 contracts/contracts/utils/TransientReentrancyGuard.sol create mode 100644 contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol create mode 100644 contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol create mode 100644 contracts/contracts/validator-registry/rewards/StipendDistributor.sol create mode 100644 contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol create mode 100644 contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol create mode 100644 contracts/test/validator-registry/rewards/StipendDistributorTest.sol diff --git a/contracts/contracts/interfaces/IRewardsManagerV2.sol b/contracts/contracts/interfaces/IRewardsManagerV2.sol new file mode 100644 index 000000000..f1742c0eb --- /dev/null +++ b/contracts/contracts/interfaces/IRewardsManagerV2.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity ^0.8.26; + + +interface IRewardsManagerV2 { + /// @dev Config mode: 0=unset, 1=primaryOnly (100% to feeRecipient), 2=withSecondary (secBps to `secondary`) + + + error RewardsPctTooHigh(); + error TreasuryIsZero(); + error NoFundsToWithdraw(); + error ProposerTransferFailed(address feeRecipient, uint256 amount); + + // -------- Views -------- + + + /// @notice Builders/relays call this to route EL rewards *through* this contract. + /// If no config / not opted in / operator changed, pays 100% to `feeRecipient`. + function payProposer(address payable feeRecipient) external payable; + + function withdrawToTreasury() external; + + function setRewardsPctBps(uint256 rewardsPctBps) external; + + function setTreasury(address payable treasury) external; + + // -------- Admin -------- + function initialize(address initialOwner, uint256 rewardsPctBps, address payable treasury) external; + + // -------- Events -------- + + /// @notice Emitted for each proposer payment routed by this contract + event ProposerPaid( + address indexed feeRecipient, + uint256 indexed proposerAmt, + uint256 indexed rewardAmt + ); + /// @notice Emitted when the treasury is withdrawn + event TreasuryWithdrawn(uint256 indexed treasuryAmt); + /// @notice Emitted when the rewards pct is set + event RewardsPctBpsSet(uint256 indexed rewardsPctBps); + /// @notice Emitted when the treasury is set + event TreasurySet(address indexed treasury); + + + +} \ No newline at end of file diff --git a/contracts/contracts/interfaces/IStipendDistributor.sol b/contracts/contracts/interfaces/IStipendDistributor.sol new file mode 100644 index 000000000..0d93a40d0 --- /dev/null +++ b/contracts/contracts/interfaces/IStipendDistributor.sol @@ -0,0 +1,103 @@ +// Suggested filename: contracts/interfaces/IStipendDistributor.sol +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + + +/// @title IStipendDistributor +/// @notice Interface for stipend distribution and claims. +interface IStipendDistributor { + // ========================= + // ERRORS + // ========================= + error NotOwnerOrOracle(); + error ZeroAddress(); + error InvalidBLSPubKeyLength(uint256 expectedLength, uint256 actualLength); + error InvalidRecipient(); + error InvalidOperator(); + error InvalidClaimDelegate(); + error LengthMismatch(); + error NoClaimableRewards(address recipient); + error RewardsTransferFailed(address recipient); + + + // ========================= + // EVENTS + // ========================= + /// @dev Emitted when the oracle address is updated. + event OracleSet(address indexed oracle); + + /// @dev Emitted when stipends are granted. + event StipendsGranted(address indexed operator, address indexed recipient, uint256 amount); + + /// @dev Emitted when rewards are claimed by a recipient for an operator. + event RewardsClaimed(address indexed operator, address indexed recipient, uint256 amount); + + /// @dev Emitted when a recipient mapping is overridden for a specific pubkey. + event RecipientSet(address indexed operator, bytes pubkey, uint256 indexed registryID, address indexed recipient); + + /// @dev Emitted when an operator sets/updates their default recipient. + event DefaultRecipientSet(address indexed operator, address indexed recipient); + + /// @dev Emitted when an operator sets/updates a claim delegate for a given recipient. + event ClaimDelegateSet(address indexed operator, address indexed recipient, address indexed delegate, bool status); + + /// @dev Emitted when accrued rewards are migrated from one recipient to another for an operator. + event RewardsMigrated(address indexed from, address indexed to, uint256 amount); + + /// @dev Emitted when a registry is set. + event VanillaRegistrySet(address indexed vanillaRegistry); + event MevCommitAVSSet(address indexed mevCommitAVS); + event MevCommitMiddlewareSet(address indexed mevCommitMiddleware); + + + // ========================= + // EXTERNALS + // ========================= + + /// @notice Initialize the proxy. + function initialize(address owner, address oracle, address vanillaRegistry, address mevCommitAVS, address mevCommitMiddleware) external; + + + function grantStipends( + address[] calldata operators, + address[] calldata recipients, + uint256[] calldata amounts + ) external payable; + + + /// @notice Claim rewards for the caller (as operator) to specific recipients. + function claimRewards(address payable[] calldata recipients) external; + + + /// @notice Claim rewards on behalf of an operator to specific recipients (must be delegated). + function claimOnbehalfOfOperator(address operator, address payable[] calldata recipients) external; + + + /// @notice Override recipient for a list of BLS pubkeys in a registry. + function overrideRecipientByPubkey(bytes[] calldata pubkeys, uint256 registryID, address recipient) external; + + + /// @notice Set the caller's default recipient for any non-overridden keys. + function setDefaultRecipient(address recipient) external; + + + /// @notice Allow or revoke a delegate to claim for a given recipient of the caller (operator). + function setClaimDelegate(address delegate, address recipient, bool status) external; + + + /// @notice Migrate unclaimed rewards from one recipient to another for the caller (operator). + function migrateExistingRewards(address from, address to) external; + + + /// @notice Pause / Unpause admin controls. + function pause() external; + function unpause() external; + + + /// @notice Admin setters. + function setOracle(address _oracle) external; + function setVanillaRegistry(address vanillaRegistry) external; + function setMevCommitAVS(address mevCommitAVS) external; + function setMevCommitMiddleware(address mevCommitMiddleware) external; + +} \ No newline at end of file diff --git a/contracts/contracts/utils/TransientReentrancyGuard.sol b/contracts/contracts/utils/TransientReentrancyGuard.sol new file mode 100644 index 000000000..fdcbc7098 --- /dev/null +++ b/contracts/contracts/utils/TransientReentrancyGuard.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity ^0.8.26; + +abstract contract TransientReentrancyGuard { + bytes32 private constant _SLOT = keccak256("primev.reentrancy.guard.transient"); + + modifier nonReentrant() { + bytes32 slot = _SLOT; + assembly ("memory-safe") { + if tload(slot) { revert(0, 0) } + tstore(slot, 1) + } + _; + assembly ("memory-safe") { + tstore(slot, 0) + } + } +} \ No newline at end of file diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol new file mode 100644 index 000000000..4700c6853 --- /dev/null +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity ^0.8.26; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import {IRewardsManagerV2} from "../../interfaces/IRewardsManagerV2.sol"; +import {RewardsManagerV2Storage} from "./RewardsManagerV2Storage.sol"; +import {Errors} from "../../utils/Errors.sol"; +import {TransientReentrancyGuard} from "../../utils/TransientReentrancyGuard.sol"; + + +contract RewardsManagerV2 is + Initializable, + Ownable2StepUpgradeable, + TransientReentrancyGuard, + RewardsManagerV2Storage, + IRewardsManagerV2, + UUPSUpgradeable +{ + uint256 constant BPS_DENOMINATOR = 10_000; + + constructor() { + _disableInitializers(); + } + + // -------- Initializer -------- + function initialize(address initialOwner, uint256 rewardsPctBps, address payable treasury) external initializer override { + __Ownable_init(initialOwner); + __UUPSUpgradeable_init(); + _setRewardsPctBps(rewardsPctBps); + _setTreasury(treasury); + } + + // -------- Proposer payment (EL rewards routed through this contract) -------- + function payProposer(address payable feeRecipient) external payable nonReentrant { + uint256 totalAmt = msg.value; + uint256 bps = rewardsPctBps; + if (bps == 0) { + (bool success, ) = feeRecipient.call{value: totalAmt}(""); + require(success, ProposerTransferFailed(feeRecipient, totalAmt)); + emit ProposerPaid(feeRecipient, totalAmt, 0); + } else { + uint256 amtForRewards = totalAmt * bps / BPS_DENOMINATOR; + uint256 proposerAmt = totalAmt - amtForRewards; + unchecked { toTreasury += amtForRewards; } + (bool success, ) = feeRecipient.call{value: proposerAmt}(""); + require(success, ProposerTransferFailed(feeRecipient, proposerAmt)); + emit ProposerPaid(feeRecipient, proposerAmt, amtForRewards); + } + } + + // -------- Owner Functions-------- + + function withdrawToTreasury() external nonReentrant onlyOwner { + require(treasury != address(0), TreasuryIsZero()); + require(toTreasury > 0, NoFundsToWithdraw()); + uint256 treasuryAmt = toTreasury; + toTreasury = 0; + treasury.call{value: treasuryAmt}(""); + emit TreasuryWithdrawn(treasuryAmt); + } + + function setRewardsPctBps(uint256 rewardsPctBps) external onlyOwner { + _setRewardsPctBps(rewardsPctBps); + } + + function setTreasury(address payable treasury) external onlyOwner { + _setTreasury(treasury); + } + + // -------- Internal -------- + + function _setTreasury(address payable _treasury) internal { + treasury = _treasury; + emit TreasurySet(treasury); + } + + function _setRewardsPctBps(uint256 _rewardsPctBps) internal { + require (_rewardsPctBps <= 2500, RewardsPctTooHigh()); + rewardsPctBps = _rewardsPctBps; + emit RewardsPctBpsSet(rewardsPctBps); + } + + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner {} + + // -------- Receive/Fallback (explicitly disabled) -------- + receive() external payable { revert Errors.InvalidReceive(); } + fallback() external payable { revert Errors.InvalidFallback(); } +} diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol new file mode 100644 index 000000000..44e9e6044 --- /dev/null +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IRewardsManagerV2} from "../../interfaces/IRewardsManagerV2.sol"; + +abstract contract RewardsManagerV2Storage { + + uint256 public toTreasury; + uint256 public rewardsPctBps; + address payable public treasury; + + uint256[42] private __gap; // reserve slots for upgrades + +} \ No newline at end of file diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol new file mode 100644 index 000000000..3ab8bd89a --- /dev/null +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import {IRewardManager} from "../../interfaces/IRewardManager.sol"; +import {RewardManagerStorage} from "./RewardManagerStorage.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {IStipendDistributor} from "../../interfaces/IStipendDistributor.sol"; +import {StipendDistributorStorage} from "./StipendDistributorStorage.sol"; +import {TransientReentrancyGuard} from "../../utils/TransientReentrancyGuard.sol"; +import {Errors} from "../../utils/Errors.sol"; + +import {VanillaRegistryStorage} from "../VanillaRegistryStorage.sol"; +import {MevCommitAVSStorage} from "../avs/MevCommitAVSStorage.sol"; +import {MevCommitMiddlewareStorage} from "../middleware/MevCommitMiddlewareStorage.sol"; + + +contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, + Ownable2StepUpgradeable, TransientReentrancyGuard, PausableUpgradeable, UUPSUpgradeable { + + + modifier onlyOwnerOrOracle() { + require(msg.sender == oracle || msg.sender == owner(), NotOwnerOrOracle()); + _; + } + + /// @dev See https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializing_the_implementation_contract + constructor() { + _disableInitializers(); + } + + /// @dev Receive function is disabled prevent misc transfers. + receive() external payable { + revert Errors.InvalidReceive(); + } + + /// @dev Fallback function disabled to prevent misc transfers. + fallback() external payable { + revert Errors.InvalidFallback(); + } + + /// @dev Initializes the RewardManager contract. + function initialize( + address owner, + address oracle, + address vanillaRegistry, + address mevCommitAVS, + address mevCommitMiddleware + ) external initializer { + __Ownable_init(owner); + __Pausable_init(); + __UUPSUpgradeable_init(); + _setOracle(oracle); + _setVanillaRegistry(vanillaRegistry); + _setMevCommitAVS(mevCommitAVS); + _setMevCommitMiddleware(mevCommitMiddleware); + } + + /// @dev Grant stipends to multiple (operator, recipient) pairs. + /// @param operators Array of operator addresses. + /// @param recipients Array of recipient addresses of the corresponding operator. + /// @param amounts Array of stipend amounts. + function grantStipends(address[] calldata operators, address[] calldata recipients, uint256[] calldata amounts) external payable nonReentrant whenNotPaused onlyOwnerOrOracle { + require(operators.length == amounts.length && operators.length == recipients.length, LengthMismatch()); + uint256 len = operators.length; + for (uint256 i = 0; i < len; ++i) { + accrued[operators[i]][recipients[i]] += amounts[i]; + emit StipendsGranted(operators[i], recipients[i], amounts[i]); + } + } + + /// @notice Allows an operator to claim their rewards for specified recipients. + function claimRewards(address payable[] calldata recipients) external whenNotPaused nonReentrant { + _claimRewards(msg.sender, recipients); + } + + /// @notice Claims rewards accrued by an operator to a specific recipient. Must be authorized by the specified operator. + /// @dev Caller must be an authorized delegate for every (operator → recipient) pair. + function claimOnbehalfOfOperator(address operator, address payable[] calldata recipients) external whenNotPaused nonReentrant { + uint256 len = recipients.length; + for (uint256 i = 0; i < len; ++i) { + require(claimDelegate[operator][recipients[i]][msg.sender], InvalidClaimDelegate()); + } + _claimRewards(operator, recipients); + } + + /// @notice Allows an operator to set the recipient for a list of pubkeys. + /// @dev If operator is no longer valid at the time of stipend distribution, the recipient will not receive the stipend. + /// If the key has a new operator that has not updated the key's recipient, the new operator will receive the stipend. + /// @param pubkeys List of pubkeys to set the recipient for. + /// @param registryID Registry in which the pubkeys are registered. + /// @param recipient Recipient to set for the pubkeys. + function overrideRecipientByPubkey(bytes[] calldata pubkeys, uint256 registryID, address recipient) external whenNotPaused nonReentrant { + require(recipient != address(0), ZeroAddress()); + for (uint256 i = 0; i < pubkeys.length; ++i) { + bytes calldata pubkey = pubkeys[i]; + require(pubkey.length == 48, InvalidBLSPubKeyLength(48, pubkey.length)); + require(msg.sender == findOperator(pubkey, registryID), InvalidRecipient()); + bytes32 pkHash = keccak256(pubkey); + operatorKeyOverrides[msg.sender][pkHash] = recipient; + emit RecipientSet(msg.sender, pubkey, registryID, recipient); + } + } + + /// @dev Allows an operator to set a default recipient for all non-overridden keys. + /// If a recipient is set for a specific key, it will override the default recipient. + /// @param recipient Default recipient to set for all non-overridden keys of the operator. + function setDefaultRecipient(address recipient) external whenNotPaused nonReentrant { + require(recipient != address(0), ZeroAddress()); + defaultRecipient[msg.sender] = recipient; + emit DefaultRecipientSet(msg.sender, recipient); + } + + /// @dev Allows an operator to set a delegate to claim rewards for one of their recipients. + function setClaimDelegate(address delegate, address recipient, bool status) external whenNotPaused nonReentrant { + claimDelegate[msg.sender][recipient][delegate] = status; + emit ClaimDelegateSet(msg.sender, recipient, delegate, status); + } + + /// @dev Allows an operator to migrate unclaimed recipient rewards to a different address. + function migrateExistingRewards(address from, address to) external whenNotPaused nonReentrant { + uint256 claimableAmt = accrued[msg.sender][from] - claimed[msg.sender][from]; + require(claimableAmt > 0, NoClaimableRewards(from)); + require(to != address(0), ZeroAddress()); + claimed[msg.sender][from] += claimableAmt; + accrued[msg.sender][to] += claimableAmt; + emit RewardsMigrated(from, to, claimableAmt); + } + + /// @dev Enables the owner to pause the contract. + function pause() external onlyOwner { + _pause(); + } + + /// @dev Enables the owner to unpause the contract. + function unpause() external onlyOwner { + _unpause(); + } + + /// @dev Allows the owner to set the oracle address. + function setOracle(address _oracle) external onlyOwner { + _setOracle(_oracle); + } + + /// @dev Allows the owner to set the vanilla registry address. + function setVanillaRegistry(address vanillaRegistry) external onlyOwner { + _setVanillaRegistry(vanillaRegistry); + } + + /// @dev Allows the owner to set the mev commit avs address. + function setMevCommitAVS(address mevCommitAVS) external onlyOwner { + _setMevCommitAVS(mevCommitAVS); + } + + /// @dev Allows the owner to set the mev commit middleware address. + function setMevCommitMiddleware(address mevCommitMiddleware) external onlyOwner { + _setMevCommitMiddleware(mevCommitMiddleware); + } + + // --- Getters --- + + // Retreives the recipient for an operator's registered key + function getKeyRecipient(bytes calldata pubkey) external view returns (address) { + require(pubkey.length == 48, InvalidBLSPubKeyLength(48, pubkey.length)); + bytes32 pkHash = keccak256(pubkey); + address registeredOperator = findOperator(pubkey, 0); + // If the key is not registered, return 0 + if (registeredOperator == address(0)) { + return address(0); + } + // Individual key overrides take priority over the default recipient + if (operatorKeyOverrides[registeredOperator][pkHash] != address(0)) { + return operatorKeyOverrides[registeredOperator][pkHash]; + } + // If no key override, return the default recipient + address defaultOverride = defaultRecipient[registeredOperator]; + if (defaultOverride != address(0)) { + return defaultOverride; + } + // If no default override, return the operator + return registeredOperator; + } + + function getPendingRewards(address operator, address recipient) public view returns (uint256) { + return accrued[operator][recipient] - claimed[operator][recipient]; + } + + /// @dev Finds the operator address for a given validator pubkey based on the registry ID. + // A registry id of 0 can be used to check all registries + function findOperator(bytes calldata pubkey, uint256 registryID) public view returns (address) { + if (registryID == 1) { + (bool existsAvs,address podOwner,,) = _mevCommitAVS.validatorRegistrations(pubkey); + if (existsAvs && podOwner != address(0)) { + return podOwner; + } + } else if (registryID == 2) { + (,address operatorAddr,bool existsMiddleware,) = _mevCommitMiddleware.validatorRecords(pubkey); + if (existsMiddleware && operatorAddr != address(0)) { + return operatorAddr; + } + } else if (registryID == 3) { + (bool existsVanilla,address vanillaWithdrawalAddr,,) = _vanillaRegistry.stakedValidators(pubkey); + if (existsVanilla && vanillaWithdrawalAddr != address(0)) { + return vanillaWithdrawalAddr; + } + } else if (registryID == 0) { + for (uint256 i = 1; i < 4; ++i) { + address operator = findOperator(pubkey, i); + if (operator != address(0)) { + return operator; + } + } + } + return address(0); + } + + // --- Internal Functions --- + + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner {} + + /// @dev Allows a reward recipient to claim their rewards. + function _claimRewards(address operator, address payable[] calldata recipients) internal { + require(operator != address(0), InvalidOperator()); + uint256 len = recipients.length; + uint256[] memory claimAmounts = new uint256[](len); + for (uint256 i = 0; i < len; ++i) { + address recipient = recipients[i]; + claimAmounts[i] = getPendingRewards(operator, recipient); + claimed[operator][recipient] += claimAmounts[i]; + } + for (uint256 i = 0; i < len; ++i) { + address recipient = recipients[i]; + if (claimAmounts[i] > 0) { + (bool success, ) = recipient.call{value: claimAmounts[i]}(""); + require(success, RewardsTransferFailed(recipient)); + emit RewardsClaimed(operator, recipient, claimAmounts[i]); + } + } + } + + function _setOracle(address _oracle) internal { + oracle = _oracle; + emit OracleSet(oracle); + } + + function _setVanillaRegistry(address vanillaRegistry) internal { + _vanillaRegistry = VanillaRegistryStorage(vanillaRegistry); + emit VanillaRegistrySet(vanillaRegistry); + } + + function _setMevCommitAVS(address mevCommitAVS) internal { + _mevCommitAVS = MevCommitAVSStorage(mevCommitAVS); + emit MevCommitAVSSet(mevCommitAVS); + } + + function _setMevCommitMiddleware(address mevCommitMiddleware) internal { + _mevCommitMiddleware = MevCommitMiddlewareStorage(mevCommitMiddleware); + emit MevCommitMiddlewareSet(mevCommitMiddleware); + } + +} diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol b/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol new file mode 100644 index 000000000..61f69725c --- /dev/null +++ b/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import {VanillaRegistryStorage} from "../VanillaRegistryStorage.sol"; +import {MevCommitAVSStorage} from "../avs/MevCommitAVSStorage.sol"; +import {MevCommitMiddlewareStorage} from "../middleware/MevCommitMiddlewareStorage.sol"; + +/// @title StipendDistributorStorage +/// @notice Storage layout for StipendDistributor +abstract contract StipendDistributorStorage { + /// @dev Address authorized to grant stipends. + address public oracle; + + /// @dev Default recipient per operator (used when no pubkey-specific override exists). + mapping(address operator => address recipient) public defaultRecipient; + + /// @dev Recipient override by BLS pubkey hash (keccak256(pubkey)). + mapping(address operator => mapping(bytes32 keyhash => address recipient)) public operatorKeyOverrides; + + /// @dev Accrued and claimed amounts per (operator, recipient). + mapping(address operator => mapping(address recipient => uint256 amount)) public accrued; + mapping(address operator => mapping(address recipient => uint256 amount)) public claimed; + + /// @dev Operator → recipient → delegate → isAuthorized + mapping(address operator => mapping(address recipient => mapping(address delegate => bool))) public claimDelegate; + + MevCommitAVSStorage internal _mevCommitAVS; + MevCommitMiddlewareStorage internal _mevCommitMiddleware; + VanillaRegistryStorage internal _vanillaRegistry; + + // === Storage gap for future upgrades === + uint256[40] private __gap; +} \ No newline at end of file diff --git a/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol b/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol new file mode 100644 index 000000000..6fc7e9b6a --- /dev/null +++ b/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {RewardsManagerV2} from "../../../contracts/validator-registry/rewards/RewardsManagerV2.sol"; +import {IRewardsManagerV2} from "../../../contracts/interfaces/IRewardsManagerV2.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +contract RewardsManagerV2Test is Test { + RewardsManagerV2 internal rewardsManager; + + address internal ownerAddress; + address payable internal treasuryAddress; + address internal payerOne; + address internal payerTwo; + address internal feeRecipientOne; + address internal feeRecipientTwo; + + // Events mirrored from V2 (for expectEmit) + event ProposerPaid(address indexed feeRecipient, uint256 indexed proposerAmt, uint256 indexed rewardAmt); + event TreasuryWithdrawn(uint256 indexed treasuryAmt); + event RewardsPctBpsSet(uint256 indexed rewardsPctBps); + event TreasurySet(address indexed treasury); + + function setUp() public { + ownerAddress = address(0xA11CE); + treasuryAddress = payable(address(0x12345)); + payerOne = address(0xBEEF1); + payerTwo = address(0xBEEF2); + feeRecipientOne = address(0xFEE01); + feeRecipientTwo = address(0xFEE02); + + vm.deal(payerOne, 100 ether); + vm.deal(payerTwo, 100 ether); + + uint256 initialRewardsPctBps = 1500; // 15% + + RewardsManagerV2 implementation = new RewardsManagerV2(); + bytes memory initData = abi.encodeCall( + RewardsManagerV2.initialize, + (ownerAddress, initialRewardsPctBps, treasuryAddress) + ); + + address proxy = address(new ERC1967Proxy(address(implementation), initData)); + rewardsManager = RewardsManagerV2(payable(proxy)); + } + + // initialize + function test_Initialize_setsOwnerBpsTreasury() public { + address ownerAfterInit = rewardsManager.owner(); + assertEq(ownerAfterInit, ownerAddress); + + uint256 bpsAfterInit = rewardsManager.rewardsPctBps(); + assertEq(bpsAfterInit, 1500); + + address treasuryAfterInit = rewardsManager.treasury(); + assertEq(treasuryAfterInit, treasuryAddress); + + uint256 toTreasuryAfterInit = rewardsManager.toTreasury(); + assertEq(toTreasuryAfterInit, 0); + } + + // owner-only setters and bounds + function test_SetTreasury_onlyOwner_and_emits() public { + address payable newTreasury = payable(address(0x56789)); + + vm.prank(ownerAddress); + vm.expectEmit(); + emit TreasurySet(newTreasury); + rewardsManager.setTreasury(newTreasury); + + address treasuryAfterSet = rewardsManager.treasury(); + assertEq(treasuryAfterSet, newTreasury); + + vm.prank(payerOne); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, payerOne)); + rewardsManager.setTreasury(payable(address(123))); + } + + // updating rewards pct + function test_SetRewardsPctBps_onlyOwner_and_bounds() public { + vm.prank(ownerAddress); + vm.expectEmit(); + emit RewardsPctBpsSet(2000); + rewardsManager.setRewardsPctBps(2000); + + uint256 bpsAfterUpdate = rewardsManager.rewardsPctBps(); + assertEq(bpsAfterUpdate, 2000); + + vm.prank(ownerAddress); + vm.expectRevert(IRewardsManagerV2.RewardsPctTooHigh.selector); + rewardsManager.setRewardsPctBps(2501); + + vm.prank(payerOne); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, payerOne)); + rewardsManager.setRewardsPctBps(1000); + } + + // payProposer with bps=0: all to recipient + function test_PayProposer_bpsZero_allToRecipient() public { + vm.prank(ownerAddress); + vm.expectEmit(); + emit RewardsPctBpsSet(0); + rewardsManager.setRewardsPctBps(0); + + uint256 transferAmount = 5 ether; + uint256 recipientBalanceBefore = feeRecipientOne.balance; + uint256 toTreasuryBefore = rewardsManager.toTreasury(); + + vm.prank(payerOne); + vm.expectEmit(); + emit ProposerPaid(feeRecipientOne, transferAmount, 0); + rewardsManager.payProposer{value: transferAmount}(payable(feeRecipientOne)); + + uint256 recipientBalanceAfter = feeRecipientOne.balance; + assertEq(recipientBalanceAfter, recipientBalanceBefore + transferAmount); + + uint256 toTreasuryAfter = rewardsManager.toTreasury(); + assertEq(toTreasuryAfter, toTreasuryBefore); + } + + // payProposer with bps>0: split and accrue treasury + function test_PayProposer_withBps_splits_andAccruesTreasury() public { + vm.prank(ownerAddress); + rewardsManager.setRewardsPctBps(1500); + + uint256 transferAmount = 10 ether; + uint256 rewardPortion = (transferAmount * 1500) / 10_000; // 1.5e + uint256 proposerPortion = transferAmount - rewardPortion; // 8.5e + + uint256 recipientBalanceBefore = feeRecipientTwo.balance; + uint256 toTreasuryBefore = rewardsManager.toTreasury(); + + vm.prank(payerTwo); + vm.expectEmit(); + emit ProposerPaid(feeRecipientTwo, proposerPortion, rewardPortion); + rewardsManager.payProposer{value: transferAmount}(payable(feeRecipientTwo)); + + uint256 recipientBalanceAfter = feeRecipientTwo.balance; + assertEq(recipientBalanceAfter, recipientBalanceBefore + proposerPortion); + + uint256 toTreasuryAfter = rewardsManager.toTreasury(); + assertEq(toTreasuryAfter, toTreasuryBefore + rewardPortion); + } + + // withdraw to treasury + function test_WithdrawToTreasury_transfers_and_resets() public { + vm.prank(ownerAddress); + rewardsManager.setRewardsPctBps(2000); // 20% + + uint256 transferAmount = 5 ether; + uint256 expectedRewardPortion = (transferAmount * 2000) / 10_000; // 1e + + vm.prank(payerOne); + rewardsManager.payProposer{value: transferAmount}(payable(feeRecipientOne)); + + uint256 toTreasuryBeforeWithdraw = rewardsManager.toTreasury(); + assertEq(toTreasuryBeforeWithdraw, expectedRewardPortion); + + uint256 treasuryBalanceBefore = treasuryAddress.balance; + + vm.prank(ownerAddress); + vm.expectEmit(); + emit TreasuryWithdrawn(expectedRewardPortion); + rewardsManager.withdrawToTreasury(); + + uint256 treasuryBalanceAfter = treasuryAddress.balance; + assertEq(treasuryBalanceAfter, treasuryBalanceBefore + expectedRewardPortion); + + uint256 toTreasuryAfterWithdraw = rewardsManager.toTreasury(); + assertEq(toTreasuryAfterWithdraw, 0); + } + + // withdraw to treasury only owner + function test_WithdrawToTreasury_onlyOwner() public { + vm.prank(payerOne); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, payerOne)); + rewardsManager.withdrawToTreasury(); + } + + function test_WithdrawToTreasury_revertsIfTreasuryZero() public { + vm.prank(ownerAddress); + rewardsManager.setTreasury(payable(address(0))); + + vm.prank(ownerAddress); + rewardsManager.setRewardsPctBps(1000); + + vm.prank(payerOne); + rewardsManager.payProposer{value: 1 ether}(payable(feeRecipientOne)); + + vm.prank(ownerAddress); + vm.expectRevert(IRewardsManagerV2.TreasuryIsZero.selector); + rewardsManager.withdrawToTreasury(); + } + + // revert when no funds to withdraw + function test_WithdrawToTreasury_revertsIfNoFunds() public { + vm.prank(ownerAddress); + vm.expectRevert(IRewardsManagerV2.NoFundsToWithdraw.selector); + rewardsManager.withdrawToTreasury(); + } + + // recipient rejects eth → revert + function test_PayProposer_revertsWhenRecipientRejects() public { + RejectingRecipient rejectingRecipient = new RejectingRecipient(); + + vm.prank(ownerAddress); + rewardsManager.setRewardsPctBps(0); + + vm.prank(payerOne); + vm.expectRevert(); // ProposerTransferFailed + rewardsManager.payProposer{value: 1 ether}(payable(address(rejectingRecipient))); + } + + // receive/fallback revert + function test_Receive_and_Fallback_revert() public { + vm.expectRevert(); + (bool successReceive, ) = address(rewardsManager).call{value: 1}(""); + successReceive; + + vm.expectRevert(); + (bool successFallback, ) = address(rewardsManager).call(abi.encodeWithSignature("nonexistentFunction()")); + successFallback; + } +} + +/// @dev Simple recipient that rejects any ETH transfers. +contract RejectingRecipient { + receive() external payable { + revert(); + } +} diff --git a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol new file mode 100644 index 000000000..af6787792 --- /dev/null +++ b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol @@ -0,0 +1,575 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {StipendDistributor} from "../../../contracts/validator-registry/rewards/StipendDistributor.sol"; +import {IStipendDistributor} from "../../../contracts/interfaces/IStipendDistributor.sol"; // events/types only + +// helper registries & harnesses (same style/paths as your example tests) +import {VanillaRegistry} from "../../../contracts/validator-registry/VanillaRegistry.sol"; +import {ValidatorOptInRouter} from "../../../contracts/validator-registry/ValidatorOptInRouter.sol"; +import {MevCommitAVS} from "../../../contracts/validator-registry/avs/MevCommitAVS.sol"; +import {VanillaRegistryTest} from "../VanillaRegistryTest.sol"; +import {MevCommitAVSTest} from "../avs/MevCommitAVSTest.sol"; +import {IMevCommitMiddleware} from "../../../contracts/interfaces/IMevCommitMiddleware.sol"; +import {MevCommitMiddleware} from "../../../contracts/validator-registry/middleware/MevCommitMiddleware.sol"; +import {MevCommitMiddlewareTestCont} from "../middleware/MevCommitMiddlewareTestCont.sol"; +import {IVanillaRegistry} from "../../../contracts/interfaces/IVanillaRegistry.sol"; +import {IMevCommitAVS} from "../../../contracts/interfaces/IMevCommitAVS.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {PausableUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol"; + +contract StipendDistributorTest is Test { + // system under test + StipendDistributor internal distributor; + + // test registries used by tests + VanillaRegistry public vanillaRegistry; + VanillaRegistryTest public vanillaRegistryTest; + MevCommitAVS public mevCommitAVS; + MevCommitAVSTest public mevCommitAVSTest; + MevCommitMiddleware public mevCommitMiddleware; + MevCommitMiddlewareTestCont public mevCommitMiddlewareTest; + + // actors + address internal owner; + address internal oracle; + address internal operator1; + address internal operator2; + address internal delegate1; + address internal recipient1; + address internal recipient2; + address internal recipient3; + + // sample pubkey (48 bytes) + bytes public samplePubkey1 = hex"b61a6e5f09217278efc7ddad4dc4b0553b2c076d4a5fef6509c233a6531c99146347193467e84eb5ca921af1b8254b3f"; + + // events from interface for expectEmit + event RecipientSet(address indexed operator, bytes pubkey, uint256 indexed registryID, address indexed recipient); + event StipendsGranted(address indexed operator, address indexed recipient, uint256 amount); + event RewardsClaimed(address indexed operator, address indexed recipient, uint256 amount); + event DefaultRecipientSet(address indexed operator, address indexed recipient); + + // setup: deploy registries + distributor and fund oracle for payable calls + function setUp() public { + // Test actors + owner = address(0xA11CE); + oracle = address(0x04AC1E); + operator1 = address(0x111); + operator2 = address(0x222); + delegate1 = address(0xD311); + recipient1 = address(0xAAA1); + recipient2 = address(0xAAA2); + recipient3 = address(0xAAA3); + + // Bring up helper test environments (they seed their internal state in setUp) + vanillaRegistryTest = new VanillaRegistryTest(); + vanillaRegistryTest.setUp(); + vanillaRegistry = vanillaRegistryTest.validatorRegistry(); + + mevCommitAVSTest = new MevCommitAVSTest(); + mevCommitAVSTest.setUp(); + mevCommitAVS = mevCommitAVSTest.mevCommitAVS(); + + mevCommitMiddlewareTest = new MevCommitMiddlewareTestCont(); + mevCommitMiddlewareTest.setUp(); + mevCommitMiddleware = mevCommitMiddlewareTest.mevCommitMiddleware(); + + // Deploy distributor proxy with registries + StipendDistributor implementation = new StipendDistributor(); + bytes memory initData = abi.encodeCall( + StipendDistributor.initialize, + (owner, oracle, address(vanillaRegistry), address(mevCommitAVS), address(mevCommitMiddleware)) + ); + + address proxy = address(new ERC1967Proxy(address(implementation), initData)); + distributor = StipendDistributor(payable(proxy)); + + vm.deal(oracle, 1_000 ether); // for payable grant calls + } + + // helper: grant three combos (op1→r1:1e, op1→r2:2e, op2→r3:3e) + function _grantThreeCombos( + address addr1, + address addr2, + address addr3, + address op1, + address op2 + ) internal { + address[] memory operators = new address[](3); + address[] memory receivers = new address[](3); + uint256[] memory amounts = new uint256[](3); + + operators[0] = op1; + receivers[0] = addr1; + amounts[0] = 1 ether; + + operators[1] = op1; + receivers[1] = addr2; + amounts[1] = 2 ether; + + operators[2] = op2; + receivers[2] = addr3; + amounts[2] = 3 ether; + + vm.prank(oracle); + distributor.grantStipends{value: amounts[0] + amounts[1] + amounts[2]}(operators, receivers, amounts); + } + + // default recipient: set and read mapping + function test_SetDefaultRecipient_setsMapping() public { + // starts empty + assertEq(distributor.defaultRecipient(operator1), address(0)); + + // operator sets default + vm.prank(operator1); + distributor.setDefaultRecipient(recipient1); + + // mapping reflects default + assertEq(distributor.defaultRecipient(operator1), recipient1); + } + + // override by pubkey: same operator sets 3 keys → recipient2, then 2 keys → recipient3 (middleware registry id=2) + function test_OverrideRecipientByPubkey_multipleBatches() public { + // seed middleware validators for operator vm.addr(0x1117) + mevCommitMiddlewareTest.test_registerValidators(); + address opFromMiddlewareTest = vm.addr(0x1117); + + // fetch 5 registered pubkeys + bytes memory pubkey1 = mevCommitMiddlewareTest.sampleValPubkey1(); + bytes memory pubkey2 = mevCommitMiddlewareTest.sampleValPubkey2(); + bytes memory pubkey3 = mevCommitMiddlewareTest.sampleValPubkey3(); + bytes memory pubkey4 = mevCommitMiddlewareTest.sampleValPubkey4(); + bytes memory pubkey5 = mevCommitMiddlewareTest.sampleValPubkey5(); + + // batch 1: 3 keys → recipient2 + bytes[] memory firstBatch = new bytes[](3); + firstBatch[0] = pubkey1; + firstBatch[1] = pubkey2; + firstBatch[2] = pubkey3; + vm.prank(opFromMiddlewareTest); + distributor.overrideRecipientByPubkey(firstBatch, 2, recipient2); + assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey1)), recipient2); + assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey2)), recipient2); + assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey3)), recipient2); + + // batch 2: 2 keys → recipient3 + bytes[] memory secondBatch = new bytes[](2); + secondBatch[0] = pubkey4; + secondBatch[1] = pubkey5; + vm.prank(opFromMiddlewareTest); + distributor.overrideRecipientByPubkey(secondBatch, 2, recipient3); + assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey4)), recipient3); + assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey5)), recipient3); + } + + // override by pubkey: reverts when caller isn't the registered operator + function test_OverrideRecipientByPubkey_wrongOperator_reverts() public { + // seed validators to establish key → operator mapping + mevCommitMiddlewareTest.test_registerValidators(); + address rightfulOperator = vm.addr(0x1117); + bytes memory pubkey = mevCommitMiddlewareTest.sampleValPubkey2(); + + // different operator tries to override → revert + bytes[] memory pubs = new bytes[](1); + pubs[0] = pubkey; + vm.prank(operator2); + vm.expectRevert(); + distributor.overrideRecipientByPubkey(pubs, 2, recipient1); + + // mapping unchanged + assertEq(distributor.operatorKeyOverrides(rightfulOperator, keccak256(pubkey)), address(0)); + + // rightful operator can set it + vm.prank(rightfulOperator); + distributor.overrideRecipientByPubkey(pubs, 2, recipient1); + assertEq(distributor.operatorKeyOverrides(rightfulOperator, keccak256(pubkey)), recipient1); + } + + // grantStipends: three combos accrue correctly (no claim here) + function test_GrantStipends_threeCombos_setsAccrued() public { + _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); + + // accrued reflects grants + assertEq(distributor.accrued(operator1, recipient1), 1 ether); + assertEq(distributor.accrued(operator1, recipient2), 2 ether); + assertEq(distributor.accrued(operator2, recipient3), 3 ether); + } + + // claim: operator can claim; delegate can claim when authorized + function test_Claim_byOperator_and_byDelegate() public { + _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); + + // operator1 claims 2e for recipient2 + address payable[] memory toClaim = new address payable[](1); + toClaim[0] = payable(recipient2); + uint256 r2Before = recipient2.balance; + vm.prank(operator1); + distributor.claimRewards(toClaim); + assertEq(recipient2.balance, r2Before + 2 ether); + + // operator1 authorizes delegate for recipient1 + vm.prank(operator1); + distributor.setClaimDelegate(delegate1, recipient1, true); + + // delegate claims 1e for recipient1 + address payable[] memory one = new address payable[](1); + one[0] = payable(recipient1); + uint256 r1Before = recipient1.balance; + vm.prank(delegate1); + distributor.claimOnbehalfOfOperator(operator1, one); + assertEq(recipient1.balance, r1Before + 1 ether); + } + + // claim: unauthorized caller cannot claim on behalf of another operator + function test_ClaimOnBehalf_unauthorized_reverts() public { + _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); + + address payable[] memory ask = new address payable[](1); + ask[0] = payable(recipient3); + + // operator2 tries to claim as if for operator1 → revert + vm.expectRevert(); + vm.prank(operator2); + distributor.claimOnbehalfOfOperator(operator1, ask); + } + + // pending rewards: increments on grant, clears on claim, and stacks across grants + function test_PendingRewards_increment_and_clear() public { + // 1) first grant (1e) to operator1→recipient1 + address[] memory ops = new address[](1); + address[] memory recs = new address[](1); + uint256[] memory amts = new uint256[](1); + ops[0] = operator1; + recs[0] = recipient1; + amts[0] = 1 ether; + vm.prank(oracle); + distributor.grantStipends{value: amts[0]}(ops, recs, amts); + assertEq(distributor.accrued(operator1, recipient1), 1 ether); + + // claim pays 1e + address payable[] memory list = new address payable[](1); + list[0] = payable(recipient1); + uint256 before = recipient1.balance; + vm.prank(operator1); + distributor.claimRewards(list); + assertEq(recipient1.balance, before + 1 ether); + + // immediate re-claim is no-op + before = recipient1.balance; + vm.prank(operator1); + distributor.claimRewards(list); + assertEq(recipient1.balance, before); + + // 2) second grant (2e) → total accrued becomes 3e + amts[0] = 2 ether; + vm.prank(oracle); + distributor.grantStipends{value: amts[0]}(ops, recs, amts); + assertEq(distributor.accrued(operator1, recipient1), 3 ether); + + // 3) third grant (3e) without claiming → total accrued becomes 6e + amts[0] = 3 ether; + vm.prank(oracle); + distributor.grantStipends{value: amts[0]}(ops, recs, amts); + assertEq(distributor.accrued(operator1, recipient1), 6 ether); + + // claim now pays 5e (the unclaimed 2e + 3e) + before = recipient1.balance; + vm.prank(operator1); + distributor.claimRewards(list); + assertEq(recipient1.balance, before + 5 ether); + + // re-claim still no-op + before = recipient1.balance; + vm.prank(operator1); + distributor.claimRewards(list); + assertEq(recipient1.balance, before); + } + + // getKeyRecipient: baseline → default → override (registry 0 routes to owning registry) + function test_GetKeyRecipient_and_registry0_routing() public { + // seed middleware so key belongs to operator vm.addr(0x1117) under registry id 2 + mevCommitMiddlewareTest.test_registerValidators(); + address opFromMiddlewareTest = vm.addr(0x1117); + bytes memory key = mevCommitMiddlewareTest.sampleValPubkey2(); + + // 1) baseline: no default/override → resolves to operator + address rec0 = distributor.getKeyRecipient(key); + assertEq(rec0, opFromMiddlewareTest, "registry 0 should resolve to owning operator"); + + // 2) set default for operator → returns default + vm.prank(opFromMiddlewareTest); + distributor.setDefaultRecipient(recipient1); + address rec1 = distributor.getKeyRecipient(key); + assertEq(rec1, recipient1, "default recipient should be returned"); + + // 3) set explicit override for this key → precedence over default + bytes[] memory oneKey = new bytes[](1); + oneKey[0] = key; + vm.prank(opFromMiddlewareTest); + distributor.overrideRecipientByPubkey(oneKey, 2, recipient2); + address rec2 = distributor.getKeyRecipient(key); + assertEq(rec2, recipient2, "override should take precedence"); + } + + // pause: user funcs revert when paused; owner can pause/unpause; grant is blocked; unpause restores + function test_Pause_allPausableFunctions() public { + // works unpaused + vm.prank(operator1); + distributor.setDefaultRecipient(recipient1); + + // pause as owner + vm.prank(owner); + distributor.pause(); + assertTrue(distributor.paused()); + + // pausable funcs revert when paused + vm.prank(operator1); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + distributor.setDefaultRecipient(recipient2); + + bytes[] memory pubs = new bytes[](1); + pubs[0] = samplePubkey1; + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + distributor.overrideRecipientByPubkey(pubs, 3, recipient2); + + address[] memory ops = new address[](1); + address[] memory recs = new address[](1); + uint256[] memory amts = new uint256[](1); + ops[0] = operator1; + recs[0] = recipient1; + amts[0] = 1 ether; + vm.prank(oracle); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + distributor.grantStipends{value: amts[0]}(ops, recs, amts); + + address payable[] memory list = new address payable[](1); + list[0] = payable(recipient1); + vm.prank(operator1); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + distributor.claimRewards(list); + + vm.prank(operator1); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + distributor.setClaimDelegate(delegate1, recipient1, true); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + distributor.claimOnbehalfOfOperator(operator1, list); + + // unpause restores + vm.prank(owner); + distributor.unpause(); + vm.prank(operator1); + distributor.setDefaultRecipient(recipient2); + } + + // reentrancy: malicious recipient can't reenter claimRewards + function test_ReentrancyGuard_onClaimRewards() public { + // grant to a recipient that tries to reenter + ReenteringRecipient attacker = new ReenteringRecipient(); + address[] memory ops = new address[](1); + address[] memory recs = new address[](1); + uint256[] memory amts = new uint256[](1); + ops[0] = operator1; + recs[0] = address(attacker); + amts[0] = 1 ether; + vm.prank(oracle); + distributor.grantStipends{value: amts[0]}(ops, recs, amts); + + // claim once → paid exactly once; inner call blocked by nonReentrant + address payable[] memory list = new address payable[](1); + list[0] = payable(address(attacker)); + uint256 before = address(attacker).balance; + vm.prank(operator1); + distributor.claimRewards(list); + assertEq(address(attacker).balance, before + 1 ether); + } + + // avs path (registry id=1): default vs override precedence + function test_AVS_Override_and_GetKeyRecipient() public { + // seed avs validators (pod owner is 0x420 in harness) + mevCommitAVSTest.testRegisterValidatorsByPodOwners(); + address podOwner = address(0x420); + bytes memory key = mevCommitAVSTest.sampleValPubkey2(); + + // baseline → pod owner + address base = distributor.getKeyRecipient(key); + assertEq(base, podOwner, "avs baseline should return pod owner"); + + // set default → returned + vm.prank(podOwner); + distributor.setDefaultRecipient(recipient1); + address def = distributor.getKeyRecipient(key); + assertEq(def, recipient1); + + // set override (id=1) → takes precedence + bytes[] memory oneKey = new bytes[](1); + oneKey[0] = key; + vm.prank(podOwner); + distributor.overrideRecipientByPubkey(oneKey, 1, recipient2); + address over = distributor.getKeyRecipient(key); + assertEq(over, recipient2); + + // mapping is scoped by operator + assertEq(distributor.operatorKeyOverrides(podOwner, keccak256(key)), recipient2); + assertEq(distributor.operatorKeyOverrides(operator2, keccak256(key)), address(0)); + } + + // vanilla path (registry id=3): default vs override precedence + function test_Vanilla_Override_and_GetKeyRecipient() public { + // seed a vanilla validator owned by vanillaRegistryTest.user1() + vanillaRegistryTest.testSelfStake(); + address valOperator = vanillaRegistryTest.user1(); + bytes memory key = vanillaRegistryTest.user1BLSKey(); + + // baseline → operator + address base = distributor.getKeyRecipient(key); + assertEq(base, valOperator, "vanilla baseline should return operator"); + + // set default → returned + vm.prank(valOperator); + distributor.setDefaultRecipient(recipient1); + address def = distributor.getKeyRecipient(key); + assertEq(def, recipient1); + + // set override (id=3) → takes precedence + bytes[] memory oneKey = new bytes[](1); + oneKey[0] = key; + vm.prank(valOperator); + distributor.overrideRecipientByPubkey(oneKey, 3, recipient2); + address over = distributor.getKeyRecipient(key); + assertEq(over, recipient2); + + // mapping is scoped by operator + assertEq(distributor.operatorKeyOverrides(valOperator, keccak256(key)), recipient2); + assertEq(distributor.operatorKeyOverrides(operator2, keccak256(key)), address(0)); + } + + // wrong registry id: override must revert when ownership doesn't match + function test_Override_wrongRegistryID_reverts() public { + // seed middleware validators for operator vm.addr(0x1117) + mevCommitMiddlewareTest.test_registerValidators(); + address mwOperator = vm.addr(0x1117); + bytes memory key = mevCommitMiddlewareTest.sampleValPubkey2(); + bytes[] memory pubs = new bytes[](1); + pubs[0] = key; + + // wrong id (1 = avs) → revert + vm.prank(mwOperator); + vm.expectRevert(); + distributor.overrideRecipientByPubkey(pubs, 1, recipient1); + + // correct id (2 = middleware) → ok + vm.prank(mwOperator); + distributor.overrideRecipientByPubkey(pubs, 2, recipient1); + assertEq(distributor.operatorKeyOverrides(mwOperator, keccak256(key)), recipient1); + } + + // invalid pubkey length: revert + function test_Override_invalidPubkeyLength_reverts() public { + // length check happens first → caller doesn't matter + bytes memory bad = hex"1234"; // 2 bytes, not 48 + bytes[] memory pubs = new bytes[](1); + pubs[0] = bad; + vm.prank(operator1); + vm.expectRevert(); + distributor.overrideRecipientByPubkey(pubs, 2, recipient1); + } + + // only oracle can grant stipends + function test_Grant_onlyOracle_revertsForOthers() public { + address[] memory ops = new address[](1); + address[] memory recs = new address[](1); + uint256[] memory amts = new uint256[](1); + ops[0] = operator1; + recs[0] = recipient1; + amts[0] = 1 ether; + vm.deal(operator1, 10 ether); + + // non-oracle caller → revert + vm.prank(operator1); + vm.expectRevert(); + distributor.grantStipends{value: amts[0]}(ops, recs, amts); + } + + // wrong operator can't claim another operator's recipients + function test_ClaimRewards_wrongOperator_reverts() public { + _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); + + address payable[] memory list = new address payable[](1); + list[0] = payable(recipient2); + + uint256 before = recipient2.balance; + vm.prank(operator2); + distributor.claimRewards(list); + assertEq(recipient2.balance, before); + } + + // grantStipends: arrays length mismatch reverts + function test_Grant_arraysLengthMismatch_reverts() public { + address[] memory ops = new address[](2); + address[] memory recs = new address[](1); + uint256[] memory amts = new uint256[](1); + ops[0] = operator1; + ops[1] = operator2; + recs[0] = recipient1; + amts[0] = 1 ether; + + vm.prank(oracle); + vm.expectRevert(); + distributor.grantStipends{value: amts[0]}(ops, recs, amts); + } + + // zero-address guards + function test_SetDefaultRecipient_zero_reverts() public { + vm.prank(operator1); + vm.expectRevert(); + distributor.setDefaultRecipient(address(0)); + } + + function test_Override_zeroRecipient_reverts() public { + mevCommitMiddlewareTest.test_registerValidators(); + address mwOperator = vm.addr(0x1117); + bytes memory key = mevCommitMiddlewareTest.sampleValPubkey2(); + bytes[] memory pubs = new bytes[](1); + pubs[0] = key; + vm.prank(mwOperator); + vm.expectRevert(); + distributor.overrideRecipientByPubkey(pubs, 2, address(0)); + } + + // batch claim: multiple recipients in one call + function test_Claim_batchMultipleRecipients() public { + _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); + + address payable[] memory list = new address payable[](2); + list[0] = payable(recipient1); // 1 ether + list[1] = payable(recipient2); // 2 ether + + uint256 r1Before = recipient1.balance; + uint256 r2Before = recipient2.balance; + + vm.prank(operator1); + distributor.claimRewards(list); + + assertEq(recipient1.balance, r1Before + 1 ether); + assertEq(recipient2.balance, r2Before + 2 ether); + } +} + +// recipient that attempts to re-enter claimRewards during payout +contract ReenteringRecipient { + fallback() external payable { + // try to re-enter claimRewards(address[]) + bytes memory data = abi.encodeWithSignature("claimRewards(address[])", _arr()); + (bool ok, ) = msg.sender.call(data); // blocked by nonReentrant + ok; // silence warning + } + + function _arr() internal view returns (address[] memory a) { + a = new address[](1); + a[0] = address(this); + } +} From b330bbd2bfbdddcbdb958c9b36cb07a557bb4bd8 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Mon, 8 Sep 2025 17:44:50 -0400 Subject: [PATCH 02/20] various design updates and addressing comments --- contracts-abi/script.sh | 8 + .../interfaces/IRewardsManagerV2.sol | 5 +- .../interfaces/IStipendDistributor.sol | 26 +-- .../utils/TransientReentrancyGuard.sol | 18 -- .../rewards/RewardsManagerV2.sol | 18 +- .../rewards/RewardsManagerV2Storage.sol | 2 +- .../rewards/StipendDistributor.sol | 126 ++--------- .../rewards/StipendDistributorStorage.sol | 12 +- .../rewards/RewardsManagerV2Test.sol | 12 - .../rewards/StipendDistributorTest.sol | 214 ++++-------------- 10 files changed, 99 insertions(+), 342 deletions(-) delete mode 100644 contracts/contracts/utils/TransientReentrancyGuard.sol diff --git a/contracts-abi/script.sh b/contracts-abi/script.sh index 71f42e050..15152232b 100755 --- a/contracts-abi/script.sh +++ b/contracts-abi/script.sh @@ -48,6 +48,10 @@ extract_and_save_abi "$BASE_DIR/out/RewardManager.sol/RewardManager.json" "$ABI_ extract_and_save_abi "$BASE_DIR/out/DepositManager.sol/DepositManager.json" "$ABI_DIR/DepositManager.abi" +extract_and_save_abi "$BASE_DIR/out/StipendDistributor.sol/StipendDistributor.json" "$ABI_DIR/StipendDistributor.abi" + +extract_and_save_abi "$BASE_DIR/out/RewardsManagerV2.sol/RewardsManagerV2.json" "$ABI_DIR/RewardsManagerV2.abi" + echo "ABI files extracted successfully." GO_CODE_BASE_DIR="./clients" @@ -115,6 +119,10 @@ generate_go_code "$ABI_DIR/RewardManager.abi" "RewardManager" "rewardmanager" generate_go_code "$ABI_DIR/DepositManager.abi" "DepositManager" "depositmanager" +generate_go_code "$ABI_DIR/StipendDistributor.abi" "StipendDistributor" "stipenddistributor" + +generate_go_code "$ABI_DIR/RewardsManagerV2.abi" "RewardsManagerV2" "rewardsmanagerv2" + echo "External ABI downloaded and processed successfully." echo "Go code generated successfully in separate folders." diff --git a/contracts/contracts/interfaces/IRewardsManagerV2.sol b/contracts/contracts/interfaces/IRewardsManagerV2.sol index f1742c0eb..8c35a72d6 100644 --- a/contracts/contracts/interfaces/IRewardsManagerV2.sol +++ b/contracts/contracts/interfaces/IRewardsManagerV2.sol @@ -1,10 +1,8 @@ // SPDX-License-Identifier: BSL 1.1 -pragma solidity ^0.8.26; +pragma solidity 0.8.26; interface IRewardsManagerV2 { - /// @dev Config mode: 0=unset, 1=primaryOnly (100% to feeRecipient), 2=withSecondary (secBps to `secondary`) - error RewardsPctTooHigh(); error TreasuryIsZero(); @@ -15,7 +13,6 @@ interface IRewardsManagerV2 { /// @notice Builders/relays call this to route EL rewards *through* this contract. - /// If no config / not opted in / operator changed, pays 100% to `feeRecipient`. function payProposer(address payable feeRecipient) external payable; function withdrawToTreasury() external; diff --git a/contracts/contracts/interfaces/IStipendDistributor.sol b/contracts/contracts/interfaces/IStipendDistributor.sol index 0d93a40d0..3319a7df1 100644 --- a/contracts/contracts/interfaces/IStipendDistributor.sol +++ b/contracts/contracts/interfaces/IStipendDistributor.sol @@ -1,4 +1,3 @@ -// Suggested filename: contracts/interfaces/IStipendDistributor.sol // SPDX-License-Identifier: BSL 1.1 pragma solidity 0.8.26; @@ -9,9 +8,9 @@ interface IStipendDistributor { // ========================= // ERRORS // ========================= - error NotOwnerOrOracle(); + error NotOwnerOrStipendManager(); error ZeroAddress(); - error InvalidBLSPubKeyLength(uint256 expectedLength, uint256 actualLength); + error InvalidBLSPubKeyLength(); error InvalidRecipient(); error InvalidOperator(); error InvalidClaimDelegate(); @@ -24,7 +23,7 @@ interface IStipendDistributor { // EVENTS // ========================= /// @dev Emitted when the oracle address is updated. - event OracleSet(address indexed oracle); + event StipendManagerSet(address indexed stipendManager); /// @dev Emitted when stipends are granted. event StipendsGranted(address indexed operator, address indexed recipient, uint256 amount); @@ -33,7 +32,7 @@ interface IStipendDistributor { event RewardsClaimed(address indexed operator, address indexed recipient, uint256 amount); /// @dev Emitted when a recipient mapping is overridden for a specific pubkey. - event RecipientSet(address indexed operator, bytes pubkey, uint256 indexed registryID, address indexed recipient); + event RecipientSet(address indexed operator, bytes pubkey, address indexed recipient); /// @dev Emitted when an operator sets/updates their default recipient. event DefaultRecipientSet(address indexed operator, address indexed recipient); @@ -44,18 +43,12 @@ interface IStipendDistributor { /// @dev Emitted when accrued rewards are migrated from one recipient to another for an operator. event RewardsMigrated(address indexed from, address indexed to, uint256 amount); - /// @dev Emitted when a registry is set. - event VanillaRegistrySet(address indexed vanillaRegistry); - event MevCommitAVSSet(address indexed mevCommitAVS); - event MevCommitMiddlewareSet(address indexed mevCommitMiddleware); - - // ========================= // EXTERNALS // ========================= /// @notice Initialize the proxy. - function initialize(address owner, address oracle, address vanillaRegistry, address mevCommitAVS, address mevCommitMiddleware) external; + function initialize(address owner, address stipendManager) external; function grantStipends( @@ -74,7 +67,7 @@ interface IStipendDistributor { /// @notice Override recipient for a list of BLS pubkeys in a registry. - function overrideRecipientByPubkey(bytes[] calldata pubkeys, uint256 registryID, address recipient) external; + function overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) external; /// @notice Set the caller's default recipient for any non-overridden keys. @@ -95,9 +88,8 @@ interface IStipendDistributor { /// @notice Admin setters. - function setOracle(address _oracle) external; - function setVanillaRegistry(address vanillaRegistry) external; - function setMevCommitAVS(address mevCommitAVS) external; - function setMevCommitMiddleware(address mevCommitMiddleware) external; + function setStipendManager(address _stipendManager) external; + function getKeyRecipient(address operator, bytes calldata pubkey) external view returns (address); + function getPendingRewards(address operator, address recipient) external view returns (uint256); } \ No newline at end of file diff --git a/contracts/contracts/utils/TransientReentrancyGuard.sol b/contracts/contracts/utils/TransientReentrancyGuard.sol deleted file mode 100644 index fdcbc7098..000000000 --- a/contracts/contracts/utils/TransientReentrancyGuard.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: BSL 1.1 -pragma solidity ^0.8.26; - -abstract contract TransientReentrancyGuard { - bytes32 private constant _SLOT = keccak256("primev.reentrancy.guard.transient"); - - modifier nonReentrant() { - bytes32 slot = _SLOT; - assembly ("memory-safe") { - if tload(slot) { revert(0, 0) } - tstore(slot, 1) - } - _; - assembly ("memory-safe") { - tstore(slot, 0) - } - } -} \ No newline at end of file diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol index 4700c6853..e2f0747f0 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol @@ -1,20 +1,21 @@ // SPDX-License-Identifier: BSL 1.1 -pragma solidity ^0.8.26; +pragma solidity 0.8.26; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import {IRewardsManagerV2} from "../../interfaces/IRewardsManagerV2.sol"; import {RewardsManagerV2Storage} from "./RewardsManagerV2Storage.sol"; import {Errors} from "../../utils/Errors.sol"; -import {TransientReentrancyGuard} from "../../utils/TransientReentrancyGuard.sol"; + contract RewardsManagerV2 is Initializable, Ownable2StepUpgradeable, - TransientReentrancyGuard, + ReentrancyGuardUpgradeable, RewardsManagerV2Storage, IRewardsManagerV2, UUPSUpgradeable @@ -28,25 +29,26 @@ contract RewardsManagerV2 is // -------- Initializer -------- function initialize(address initialOwner, uint256 rewardsPctBps, address payable treasury) external initializer override { __Ownable_init(initialOwner); + __ReentrancyGuard_init(); __UUPSUpgradeable_init(); _setRewardsPctBps(rewardsPctBps); _setTreasury(treasury); } // -------- Proposer payment (EL rewards routed through this contract) -------- - function payProposer(address payable feeRecipient) external payable nonReentrant { + function payProposer(address payable feeRecipient) external payable { uint256 totalAmt = msg.value; uint256 bps = rewardsPctBps; if (bps == 0) { (bool success, ) = feeRecipient.call{value: totalAmt}(""); - require(success, ProposerTransferFailed(feeRecipient, totalAmt)); + require(success, ProposerTransferFailed(feeRecipient, totalAmt)); //revert if transfer fails emit ProposerPaid(feeRecipient, totalAmt, 0); } else { uint256 amtForRewards = totalAmt * bps / BPS_DENOMINATOR; uint256 proposerAmt = totalAmt - amtForRewards; - unchecked { toTreasury += amtForRewards; } + toTreasury += amtForRewards; (bool success, ) = feeRecipient.call{value: proposerAmt}(""); - require(success, ProposerTransferFailed(feeRecipient, proposerAmt)); + require(success, ProposerTransferFailed(feeRecipient, proposerAmt)); //revert if transfer fails emit ProposerPaid(feeRecipient, proposerAmt, amtForRewards); } } @@ -58,7 +60,7 @@ contract RewardsManagerV2 is require(toTreasury > 0, NoFundsToWithdraw()); uint256 treasuryAmt = toTreasury; toTreasury = 0; - treasury.call{value: treasuryAmt}(""); + treasury.call{value: treasuryAmt}(""); //Treasury will not revert emit TreasuryWithdrawn(treasuryAmt); } diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol index 44e9e6044..f4e9cfc15 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.26; +pragma solidity 0.8.26; import {IRewardsManagerV2} from "../../interfaces/IRewardsManagerV2.sol"; diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index 3ab8bd89a..8993e1ccc 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -6,22 +6,17 @@ import {RewardManagerStorage} from "./RewardManagerStorage.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import {IStipendDistributor} from "../../interfaces/IStipendDistributor.sol"; import {StipendDistributorStorage} from "./StipendDistributorStorage.sol"; -import {TransientReentrancyGuard} from "../../utils/TransientReentrancyGuard.sol"; import {Errors} from "../../utils/Errors.sol"; -import {VanillaRegistryStorage} from "../VanillaRegistryStorage.sol"; -import {MevCommitAVSStorage} from "../avs/MevCommitAVSStorage.sol"; -import {MevCommitMiddlewareStorage} from "../middleware/MevCommitMiddlewareStorage.sol"; - - contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, - Ownable2StepUpgradeable, TransientReentrancyGuard, PausableUpgradeable, UUPSUpgradeable { + Ownable2StepUpgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable, UUPSUpgradeable { - modifier onlyOwnerOrOracle() { - require(msg.sender == oracle || msg.sender == owner(), NotOwnerOrOracle()); + modifier onlyOwnerOrStipendManager() { + require(msg.sender == stipendManager || msg.sender == owner(), NotOwnerOrStipendManager()); _; } @@ -43,25 +38,20 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @dev Initializes the RewardManager contract. function initialize( address owner, - address oracle, - address vanillaRegistry, - address mevCommitAVS, - address mevCommitMiddleware + address stipendManager ) external initializer { __Ownable_init(owner); + __ReentrancyGuard_init(); __Pausable_init(); __UUPSUpgradeable_init(); - _setOracle(oracle); - _setVanillaRegistry(vanillaRegistry); - _setMevCommitAVS(mevCommitAVS); - _setMevCommitMiddleware(mevCommitMiddleware); + _setStipendManager(stipendManager); } /// @dev Grant stipends to multiple (operator, recipient) pairs. /// @param operators Array of operator addresses. /// @param recipients Array of recipient addresses of the corresponding operator. /// @param amounts Array of stipend amounts. - function grantStipends(address[] calldata operators, address[] calldata recipients, uint256[] calldata amounts) external payable nonReentrant whenNotPaused onlyOwnerOrOracle { + function grantStipends(address[] calldata operators, address[] calldata recipients, uint256[] calldata amounts) external payable nonReentrant whenNotPaused onlyOwnerOrStipendManager { require(operators.length == amounts.length && operators.length == recipients.length, LengthMismatch()); uint256 len = operators.length; for (uint256 i = 0; i < len; ++i) { @@ -89,17 +79,15 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @dev If operator is no longer valid at the time of stipend distribution, the recipient will not receive the stipend. /// If the key has a new operator that has not updated the key's recipient, the new operator will receive the stipend. /// @param pubkeys List of pubkeys to set the recipient for. - /// @param registryID Registry in which the pubkeys are registered. /// @param recipient Recipient to set for the pubkeys. - function overrideRecipientByPubkey(bytes[] calldata pubkeys, uint256 registryID, address recipient) external whenNotPaused nonReentrant { + function overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) external whenNotPaused nonReentrant { require(recipient != address(0), ZeroAddress()); for (uint256 i = 0; i < pubkeys.length; ++i) { bytes calldata pubkey = pubkeys[i]; - require(pubkey.length == 48, InvalidBLSPubKeyLength(48, pubkey.length)); - require(msg.sender == findOperator(pubkey, registryID), InvalidRecipient()); + require(pubkey.length == 48, InvalidBLSPubKeyLength()); bytes32 pkHash = keccak256(pubkey); - operatorKeyOverrides[msg.sender][pkHash] = recipient; - emit RecipientSet(msg.sender, pubkey, registryID, recipient); + operatorKeyOverrides[msg.sender][pkHash] = recipient; + emit RecipientSet(msg.sender, pubkey, recipient); } } @@ -138,84 +126,34 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, _unpause(); } - /// @dev Allows the owner to set the oracle address. - function setOracle(address _oracle) external onlyOwner { - _setOracle(_oracle); - } - - /// @dev Allows the owner to set the vanilla registry address. - function setVanillaRegistry(address vanillaRegistry) external onlyOwner { - _setVanillaRegistry(vanillaRegistry); - } - - /// @dev Allows the owner to set the mev commit avs address. - function setMevCommitAVS(address mevCommitAVS) external onlyOwner { - _setMevCommitAVS(mevCommitAVS); - } - - /// @dev Allows the owner to set the mev commit middleware address. - function setMevCommitMiddleware(address mevCommitMiddleware) external onlyOwner { - _setMevCommitMiddleware(mevCommitMiddleware); + /// @dev Allows the owner to set the stipend manager address. + function setStipendManager(address _stipendManager) external onlyOwner { + _setStipendManager(_stipendManager); } // --- Getters --- // Retreives the recipient for an operator's registered key - function getKeyRecipient(bytes calldata pubkey) external view returns (address) { - require(pubkey.length == 48, InvalidBLSPubKeyLength(48, pubkey.length)); + function getKeyRecipient(address operator, bytes calldata pubkey) external view returns (address) { + require(pubkey.length == 48, InvalidBLSPubKeyLength()); bytes32 pkHash = keccak256(pubkey); - address registeredOperator = findOperator(pubkey, 0); - // If the key is not registered, return 0 - if (registeredOperator == address(0)) { - return address(0); - } // Individual key overrides take priority over the default recipient - if (operatorKeyOverrides[registeredOperator][pkHash] != address(0)) { - return operatorKeyOverrides[registeredOperator][pkHash]; + if (operatorKeyOverrides[operator][pkHash] != address(0)) { + return operatorKeyOverrides[operator][pkHash]; } // If no key override, return the default recipient - address defaultOverride = defaultRecipient[registeredOperator]; + address defaultOverride = defaultRecipient[operator]; if (defaultOverride != address(0)) { return defaultOverride; } // If no default override, return the operator - return registeredOperator; + return operator; } function getPendingRewards(address operator, address recipient) public view returns (uint256) { return accrued[operator][recipient] - claimed[operator][recipient]; } - /// @dev Finds the operator address for a given validator pubkey based on the registry ID. - // A registry id of 0 can be used to check all registries - function findOperator(bytes calldata pubkey, uint256 registryID) public view returns (address) { - if (registryID == 1) { - (bool existsAvs,address podOwner,,) = _mevCommitAVS.validatorRegistrations(pubkey); - if (existsAvs && podOwner != address(0)) { - return podOwner; - } - } else if (registryID == 2) { - (,address operatorAddr,bool existsMiddleware,) = _mevCommitMiddleware.validatorRecords(pubkey); - if (existsMiddleware && operatorAddr != address(0)) { - return operatorAddr; - } - } else if (registryID == 3) { - (bool existsVanilla,address vanillaWithdrawalAddr,,) = _vanillaRegistry.stakedValidators(pubkey); - if (existsVanilla && vanillaWithdrawalAddr != address(0)) { - return vanillaWithdrawalAddr; - } - } else if (registryID == 0) { - for (uint256 i = 1; i < 4; ++i) { - address operator = findOperator(pubkey, i); - if (operator != address(0)) { - return operator; - } - } - } - return address(0); - } - - // --- Internal Functions --- // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address) internal override onlyOwner {} @@ -240,24 +178,8 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } } - function _setOracle(address _oracle) internal { - oracle = _oracle; - emit OracleSet(oracle); - } - - function _setVanillaRegistry(address vanillaRegistry) internal { - _vanillaRegistry = VanillaRegistryStorage(vanillaRegistry); - emit VanillaRegistrySet(vanillaRegistry); + function _setStipendManager(address _stipendManager) internal { + stipendManager = _stipendManager; + emit StipendManagerSet(_stipendManager); } - - function _setMevCommitAVS(address mevCommitAVS) internal { - _mevCommitAVS = MevCommitAVSStorage(mevCommitAVS); - emit MevCommitAVSSet(mevCommitAVS); - } - - function _setMevCommitMiddleware(address mevCommitMiddleware) internal { - _mevCommitMiddleware = MevCommitMiddlewareStorage(mevCommitMiddleware); - emit MevCommitMiddlewareSet(mevCommitMiddleware); - } - } diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol b/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol index 61f69725c..f9a0d8729 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol @@ -1,15 +1,11 @@ // SPDX-License-Identifier: BSL 1.1 pragma solidity 0.8.26; -import {VanillaRegistryStorage} from "../VanillaRegistryStorage.sol"; -import {MevCommitAVSStorage} from "../avs/MevCommitAVSStorage.sol"; -import {MevCommitMiddlewareStorage} from "../middleware/MevCommitMiddlewareStorage.sol"; - /// @title StipendDistributorStorage /// @notice Storage layout for StipendDistributor abstract contract StipendDistributorStorage { /// @dev Address authorized to grant stipends. - address public oracle; + address public stipendManager; /// @dev Default recipient per operator (used when no pubkey-specific override exists). mapping(address operator => address recipient) public defaultRecipient; @@ -23,11 +19,7 @@ abstract contract StipendDistributorStorage { /// @dev Operator → recipient → delegate → isAuthorized mapping(address operator => mapping(address recipient => mapping(address delegate => bool))) public claimDelegate; - - MevCommitAVSStorage internal _mevCommitAVS; - MevCommitMiddlewareStorage internal _mevCommitMiddleware; - VanillaRegistryStorage internal _vanillaRegistry; - + // === Storage gap for future upgrades === uint256[40] private __gap; } \ No newline at end of file diff --git a/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol b/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol index 6fc7e9b6a..784b43812 100644 --- a/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol +++ b/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol @@ -202,18 +202,6 @@ contract RewardsManagerV2Test is Test { rewardsManager.withdrawToTreasury(); } - // recipient rejects eth → revert - function test_PayProposer_revertsWhenRecipientRejects() public { - RejectingRecipient rejectingRecipient = new RejectingRecipient(); - - vm.prank(ownerAddress); - rewardsManager.setRewardsPctBps(0); - - vm.prank(payerOne); - vm.expectRevert(); // ProposerTransferFailed - rewardsManager.payProposer{value: 1 ether}(payable(address(rejectingRecipient))); - } - // receive/fallback revert function test_Receive_and_Fallback_revert() public { vm.expectRevert(); diff --git a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol index af6787792..6a9b50a19 100644 --- a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol +++ b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol @@ -7,17 +7,6 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import {StipendDistributor} from "../../../contracts/validator-registry/rewards/StipendDistributor.sol"; import {IStipendDistributor} from "../../../contracts/interfaces/IStipendDistributor.sol"; // events/types only -// helper registries & harnesses (same style/paths as your example tests) -import {VanillaRegistry} from "../../../contracts/validator-registry/VanillaRegistry.sol"; -import {ValidatorOptInRouter} from "../../../contracts/validator-registry/ValidatorOptInRouter.sol"; -import {MevCommitAVS} from "../../../contracts/validator-registry/avs/MevCommitAVS.sol"; -import {VanillaRegistryTest} from "../VanillaRegistryTest.sol"; -import {MevCommitAVSTest} from "../avs/MevCommitAVSTest.sol"; -import {IMevCommitMiddleware} from "../../../contracts/interfaces/IMevCommitMiddleware.sol"; -import {MevCommitMiddleware} from "../../../contracts/validator-registry/middleware/MevCommitMiddleware.sol"; -import {MevCommitMiddlewareTestCont} from "../middleware/MevCommitMiddlewareTestCont.sol"; -import {IVanillaRegistry} from "../../../contracts/interfaces/IVanillaRegistry.sol"; -import {IMevCommitAVS} from "../../../contracts/interfaces/IMevCommitAVS.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {PausableUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol"; @@ -25,17 +14,9 @@ contract StipendDistributorTest is Test { // system under test StipendDistributor internal distributor; - // test registries used by tests - VanillaRegistry public vanillaRegistry; - VanillaRegistryTest public vanillaRegistryTest; - MevCommitAVS public mevCommitAVS; - MevCommitAVSTest public mevCommitAVSTest; - MevCommitMiddleware public mevCommitMiddleware; - MevCommitMiddlewareTestCont public mevCommitMiddlewareTest; - // actors address internal owner; - address internal oracle; + address internal stipendManager; address internal operator1; address internal operator2; address internal delegate1; @@ -43,8 +24,12 @@ contract StipendDistributorTest is Test { address internal recipient2; address internal recipient3; - // sample pubkey (48 bytes) - bytes public samplePubkey1 = hex"b61a6e5f09217278efc7ddad4dc4b0553b2c076d4a5fef6509c233a6531c99146347193467e84eb5ca921af1b8254b3f"; + // sample 48-byte pubkeys + bytes internal pubkey1 = hex"b61a6e5f09217278efc7ddad4dc4b0553b2c076d4a5fef6509c233a6531c99146347193467e84eb5ca921af1b8254b3f"; + bytes internal pubkey2 = hex"aca4b5c5daf5b39514b8aa6e5f303d29f6f1bd891e5f6b6b2ae6e2ae5d95dee31cd78630c1115b6e90f3da1a66cf8edb"; + bytes internal pubkey3 = hex"cca4b5c5daf5b39514b8aa6e5f303d29f6f1bd891e5f6b6b2ae6e2ae5d95dee31cd78630c1115b6e90f3da1a66cf8edb"; + bytes internal pubkey4 = hex"dca4b5c5daf5b39514b8aa6e5f303d29f6f1bd891e5f6b6b2ae6e2ae5d95dee31cd78630c1115b6e90f3da1a66cf8edb"; + bytes internal pubkey5 = hex"eca4b5c5daf5b39514b8aa6e5f303d29f6f1bd891e5f6b6b2ae6e2ae5d95dee31cd78630c1115b6e90f3da1a66cf8edb"; // events from interface for expectEmit event RecipientSet(address indexed operator, bytes pubkey, uint256 indexed registryID, address indexed recipient); @@ -52,11 +37,11 @@ contract StipendDistributorTest is Test { event RewardsClaimed(address indexed operator, address indexed recipient, uint256 amount); event DefaultRecipientSet(address indexed operator, address indexed recipient); - // setup: deploy registries + distributor and fund oracle for payable calls + // setup: deploy registries + distributor and fund stipendManager for payable calls function setUp() public { // Test actors owner = address(0xA11CE); - oracle = address(0x04AC1E); + stipendManager = address(0x04AC1E); operator1 = address(0x111); operator2 = address(0x222); delegate1 = address(0xD311); @@ -64,30 +49,17 @@ contract StipendDistributorTest is Test { recipient2 = address(0xAAA2); recipient3 = address(0xAAA3); - // Bring up helper test environments (they seed their internal state in setUp) - vanillaRegistryTest = new VanillaRegistryTest(); - vanillaRegistryTest.setUp(); - vanillaRegistry = vanillaRegistryTest.validatorRegistry(); - - mevCommitAVSTest = new MevCommitAVSTest(); - mevCommitAVSTest.setUp(); - mevCommitAVS = mevCommitAVSTest.mevCommitAVS(); - - mevCommitMiddlewareTest = new MevCommitMiddlewareTestCont(); - mevCommitMiddlewareTest.setUp(); - mevCommitMiddleware = mevCommitMiddlewareTest.mevCommitMiddleware(); - - // Deploy distributor proxy with registries + // Deploy distributor proxy StipendDistributor implementation = new StipendDistributor(); bytes memory initData = abi.encodeCall( StipendDistributor.initialize, - (owner, oracle, address(vanillaRegistry), address(mevCommitAVS), address(mevCommitMiddleware)) + (owner, stipendManager) ); address proxy = address(new ERC1967Proxy(address(implementation), initData)); distributor = StipendDistributor(payable(proxy)); - vm.deal(oracle, 1_000 ether); // for payable grant calls + vm.deal(stipendManager, 1_000 ether); // for payable grant calls } // helper: grant three combos (op1→r1:1e, op1→r2:2e, op2→r3:3e) @@ -114,7 +86,7 @@ contract StipendDistributorTest is Test { receivers[2] = addr3; amounts[2] = 3 ether; - vm.prank(oracle); + vm.prank(stipendManager); distributor.grantStipends{value: amounts[0] + amounts[1] + amounts[2]}(operators, receivers, amounts); } @@ -133,24 +105,15 @@ contract StipendDistributorTest is Test { // override by pubkey: same operator sets 3 keys → recipient2, then 2 keys → recipient3 (middleware registry id=2) function test_OverrideRecipientByPubkey_multipleBatches() public { - // seed middleware validators for operator vm.addr(0x1117) - mevCommitMiddlewareTest.test_registerValidators(); address opFromMiddlewareTest = vm.addr(0x1117); - // fetch 5 registered pubkeys - bytes memory pubkey1 = mevCommitMiddlewareTest.sampleValPubkey1(); - bytes memory pubkey2 = mevCommitMiddlewareTest.sampleValPubkey2(); - bytes memory pubkey3 = mevCommitMiddlewareTest.sampleValPubkey3(); - bytes memory pubkey4 = mevCommitMiddlewareTest.sampleValPubkey4(); - bytes memory pubkey5 = mevCommitMiddlewareTest.sampleValPubkey5(); - // batch 1: 3 keys → recipient2 bytes[] memory firstBatch = new bytes[](3); firstBatch[0] = pubkey1; firstBatch[1] = pubkey2; firstBatch[2] = pubkey3; vm.prank(opFromMiddlewareTest); - distributor.overrideRecipientByPubkey(firstBatch, 2, recipient2); + distributor.overrideRecipientByPubkey(firstBatch, recipient2); assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey1)), recipient2); assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey2)), recipient2); assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey3)), recipient2); @@ -160,32 +123,20 @@ contract StipendDistributorTest is Test { secondBatch[0] = pubkey4; secondBatch[1] = pubkey5; vm.prank(opFromMiddlewareTest); - distributor.overrideRecipientByPubkey(secondBatch, 2, recipient3); + distributor.overrideRecipientByPubkey(secondBatch, recipient3); assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey4)), recipient3); assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey5)), recipient3); } // override by pubkey: reverts when caller isn't the registered operator function test_OverrideRecipientByPubkey_wrongOperator_reverts() public { - // seed validators to establish key → operator mapping - mevCommitMiddlewareTest.test_registerValidators(); address rightfulOperator = vm.addr(0x1117); - bytes memory pubkey = mevCommitMiddlewareTest.sampleValPubkey2(); - - // different operator tries to override → revert - bytes[] memory pubs = new bytes[](1); - pubs[0] = pubkey; - vm.prank(operator2); - vm.expectRevert(); - distributor.overrideRecipientByPubkey(pubs, 2, recipient1); - - // mapping unchanged - assertEq(distributor.operatorKeyOverrides(rightfulOperator, keccak256(pubkey)), address(0)); - // rightful operator can set it + bytes[] memory pubs = new bytes[](1); + pubs[0] = pubkey1; vm.prank(rightfulOperator); - distributor.overrideRecipientByPubkey(pubs, 2, recipient1); - assertEq(distributor.operatorKeyOverrides(rightfulOperator, keccak256(pubkey)), recipient1); + distributor.overrideRecipientByPubkey(pubs, recipient1); + assertEq(distributor.operatorKeyOverrides(rightfulOperator, keccak256(pubkey1)), recipient1); } // grantStipends: three combos accrue correctly (no claim here) @@ -245,7 +196,7 @@ contract StipendDistributorTest is Test { ops[0] = operator1; recs[0] = recipient1; amts[0] = 1 ether; - vm.prank(oracle); + vm.prank(stipendManager); distributor.grantStipends{value: amts[0]}(ops, recs, amts); assertEq(distributor.accrued(operator1, recipient1), 1 ether); @@ -265,13 +216,13 @@ contract StipendDistributorTest is Test { // 2) second grant (2e) → total accrued becomes 3e amts[0] = 2 ether; - vm.prank(oracle); + vm.prank(stipendManager); distributor.grantStipends{value: amts[0]}(ops, recs, amts); assertEq(distributor.accrued(operator1, recipient1), 3 ether); // 3) third grant (3e) without claiming → total accrued becomes 6e amts[0] = 3 ether; - vm.prank(oracle); + vm.prank(stipendManager); distributor.grantStipends{value: amts[0]}(ops, recs, amts); assertEq(distributor.accrued(operator1, recipient1), 6 ether); @@ -290,27 +241,24 @@ contract StipendDistributorTest is Test { // getKeyRecipient: baseline → default → override (registry 0 routes to owning registry) function test_GetKeyRecipient_and_registry0_routing() public { - // seed middleware so key belongs to operator vm.addr(0x1117) under registry id 2 - mevCommitMiddlewareTest.test_registerValidators(); address opFromMiddlewareTest = vm.addr(0x1117); - bytes memory key = mevCommitMiddlewareTest.sampleValPubkey2(); // 1) baseline: no default/override → resolves to operator - address rec0 = distributor.getKeyRecipient(key); + address rec0 = distributor.getKeyRecipient(opFromMiddlewareTest, pubkey1); assertEq(rec0, opFromMiddlewareTest, "registry 0 should resolve to owning operator"); // 2) set default for operator → returns default vm.prank(opFromMiddlewareTest); distributor.setDefaultRecipient(recipient1); - address rec1 = distributor.getKeyRecipient(key); + address rec1 = distributor.getKeyRecipient(opFromMiddlewareTest, pubkey1); assertEq(rec1, recipient1, "default recipient should be returned"); // 3) set explicit override for this key → precedence over default bytes[] memory oneKey = new bytes[](1); - oneKey[0] = key; + oneKey[0] = pubkey1; vm.prank(opFromMiddlewareTest); - distributor.overrideRecipientByPubkey(oneKey, 2, recipient2); - address rec2 = distributor.getKeyRecipient(key); + distributor.overrideRecipientByPubkey(oneKey, recipient2); + address rec2 = distributor.getKeyRecipient(opFromMiddlewareTest, pubkey1); assertEq(rec2, recipient2, "override should take precedence"); } @@ -331,9 +279,9 @@ contract StipendDistributorTest is Test { distributor.setDefaultRecipient(recipient2); bytes[] memory pubs = new bytes[](1); - pubs[0] = samplePubkey1; + pubs[0] = pubkey1; vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.overrideRecipientByPubkey(pubs, 3, recipient2); + distributor.overrideRecipientByPubkey(pubs, recipient2); address[] memory ops = new address[](1); address[] memory recs = new address[](1); @@ -341,7 +289,7 @@ contract StipendDistributorTest is Test { ops[0] = operator1; recs[0] = recipient1; amts[0] = 1 ether; - vm.prank(oracle); + vm.prank(stipendManager); vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); distributor.grantStipends{value: amts[0]}(ops, recs, amts); @@ -375,7 +323,7 @@ contract StipendDistributorTest is Test { ops[0] = operator1; recs[0] = address(attacker); amts[0] = 1 ether; - vm.prank(oracle); + vm.prank(stipendManager); distributor.grantStipends{value: amts[0]}(ops, recs, amts); // claim once → paid exactly once; inner call blocked by nonReentrant @@ -387,99 +335,27 @@ contract StipendDistributorTest is Test { assertEq(address(attacker).balance, before + 1 ether); } - // avs path (registry id=1): default vs override precedence - function test_AVS_Override_and_GetKeyRecipient() public { - // seed avs validators (pod owner is 0x420 in harness) - mevCommitAVSTest.testRegisterValidatorsByPodOwners(); - address podOwner = address(0x420); - bytes memory key = mevCommitAVSTest.sampleValPubkey2(); - - // baseline → pod owner - address base = distributor.getKeyRecipient(key); - assertEq(base, podOwner, "avs baseline should return pod owner"); - - // set default → returned - vm.prank(podOwner); - distributor.setDefaultRecipient(recipient1); - address def = distributor.getKeyRecipient(key); - assertEq(def, recipient1); - - // set override (id=1) → takes precedence - bytes[] memory oneKey = new bytes[](1); - oneKey[0] = key; - vm.prank(podOwner); - distributor.overrideRecipientByPubkey(oneKey, 1, recipient2); - address over = distributor.getKeyRecipient(key); - assertEq(over, recipient2); - - // mapping is scoped by operator - assertEq(distributor.operatorKeyOverrides(podOwner, keccak256(key)), recipient2); - assertEq(distributor.operatorKeyOverrides(operator2, keccak256(key)), address(0)); - } - - // vanilla path (registry id=3): default vs override precedence - function test_Vanilla_Override_and_GetKeyRecipient() public { - // seed a vanilla validator owned by vanillaRegistryTest.user1() - vanillaRegistryTest.testSelfStake(); - address valOperator = vanillaRegistryTest.user1(); - bytes memory key = vanillaRegistryTest.user1BLSKey(); - - // baseline → operator - address base = distributor.getKeyRecipient(key); - assertEq(base, valOperator, "vanilla baseline should return operator"); - - // set default → returned - vm.prank(valOperator); - distributor.setDefaultRecipient(recipient1); - address def = distributor.getKeyRecipient(key); - assertEq(def, recipient1); - - // set override (id=3) → takes precedence - bytes[] memory oneKey = new bytes[](1); - oneKey[0] = key; - vm.prank(valOperator); - distributor.overrideRecipientByPubkey(oneKey, 3, recipient2); - address over = distributor.getKeyRecipient(key); - assertEq(over, recipient2); - - // mapping is scoped by operator - assertEq(distributor.operatorKeyOverrides(valOperator, keccak256(key)), recipient2); - assertEq(distributor.operatorKeyOverrides(operator2, keccak256(key)), address(0)); - } - - // wrong registry id: override must revert when ownership doesn't match - function test_Override_wrongRegistryID_reverts() public { - // seed middleware validators for operator vm.addr(0x1117) - mevCommitMiddlewareTest.test_registerValidators(); + function test_OverrideByPubkey() public { address mwOperator = vm.addr(0x1117); - bytes memory key = mevCommitMiddlewareTest.sampleValPubkey2(); bytes[] memory pubs = new bytes[](1); - pubs[0] = key; + pubs[0] = pubkey1; - // wrong id (1 = avs) → revert vm.prank(mwOperator); - vm.expectRevert(); - distributor.overrideRecipientByPubkey(pubs, 1, recipient1); - - // correct id (2 = middleware) → ok - vm.prank(mwOperator); - distributor.overrideRecipientByPubkey(pubs, 2, recipient1); - assertEq(distributor.operatorKeyOverrides(mwOperator, keccak256(key)), recipient1); + distributor.overrideRecipientByPubkey(pubs, recipient1); + assertEq(distributor.operatorKeyOverrides(mwOperator, keccak256(pubkey1)), recipient1); } - // invalid pubkey length: revert - function test_Override_invalidPubkeyLength_reverts() public { - // length check happens first → caller doesn't matter + function test_OverrideByPubkeyFailsOnInvalidPubkeyLength() public { bytes memory bad = hex"1234"; // 2 bytes, not 48 bytes[] memory pubs = new bytes[](1); pubs[0] = bad; vm.prank(operator1); - vm.expectRevert(); - distributor.overrideRecipientByPubkey(pubs, 2, recipient1); + vm.expectRevert(IStipendDistributor.InvalidBLSPubKeyLength.selector); + distributor.overrideRecipientByPubkey(pubs, recipient1); } - // only oracle can grant stipends - function test_Grant_onlyOracle_revertsForOthers() public { + // only stipendManager can grant stipends + function test_Grant_onlystipendManager_revertsForOthers() public { address[] memory ops = new address[](1); address[] memory recs = new address[](1); uint256[] memory amts = new uint256[](1); @@ -488,7 +364,7 @@ contract StipendDistributorTest is Test { amts[0] = 1 ether; vm.deal(operator1, 10 ether); - // non-oracle caller → revert + // non-stipendManager caller → revert vm.prank(operator1); vm.expectRevert(); distributor.grantStipends{value: amts[0]}(ops, recs, amts); @@ -517,7 +393,7 @@ contract StipendDistributorTest is Test { recs[0] = recipient1; amts[0] = 1 ether; - vm.prank(oracle); + vm.prank(stipendManager); vm.expectRevert(); distributor.grantStipends{value: amts[0]}(ops, recs, amts); } @@ -530,14 +406,12 @@ contract StipendDistributorTest is Test { } function test_Override_zeroRecipient_reverts() public { - mevCommitMiddlewareTest.test_registerValidators(); address mwOperator = vm.addr(0x1117); - bytes memory key = mevCommitMiddlewareTest.sampleValPubkey2(); bytes[] memory pubs = new bytes[](1); - pubs[0] = key; + pubs[0] = pubkey1; vm.prank(mwOperator); vm.expectRevert(); - distributor.overrideRecipientByPubkey(pubs, 2, address(0)); + distributor.overrideRecipientByPubkey(pubs, address(0)); } // batch claim: multiple recipients in one call From 4d737763efc4028065403755c782bf2ef99d456d Mon Sep 17 00:00:00 2001 From: owen-eth Date: Mon, 8 Sep 2025 18:24:10 -0400 Subject: [PATCH 03/20] updated abis --- contracts-abi/abi/RewardsManagerV2.abi | 474 ++++++++++++ contracts-abi/abi/StipendDistributor.abi | 892 +++++++++++++++++++++++ 2 files changed, 1366 insertions(+) create mode 100644 contracts-abi/abi/RewardsManagerV2.abi create mode 100644 contracts-abi/abi/StipendDistributor.abi diff --git a/contracts-abi/abi/RewardsManagerV2.abi b/contracts-abi/abi/RewardsManagerV2.abi new file mode 100644 index 000000000..5d2cc641d --- /dev/null +++ b/contracts-abi/abi/RewardsManagerV2.abi @@ -0,0 +1,474 @@ +[ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "fallback", + "stateMutability": "payable" + }, + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "UPGRADE_INTERFACE_VERSION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "acceptOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "initialOwner", + "type": "address", + "internalType": "address" + }, + { + "name": "rewardsPctBps", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "treasury", + "type": "address", + "internalType": "address payable" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "payProposer", + "inputs": [ + { + "name": "feeRecipient", + "type": "address", + "internalType": "address payable" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "pendingOwner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "proxiableUUID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "rewardsPctBps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setRewardsPctBps", + "inputs": [ + { + "name": "rewardsPctBps", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setTreasury", + "inputs": [ + { + "name": "treasury", + "type": "address", + "internalType": "address payable" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "toTreasury", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "treasury", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address payable" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "upgradeToAndCall", + "inputs": [ + { + "name": "newImplementation", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "withdrawToTreasury", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferStarted", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ProposerPaid", + "inputs": [ + { + "name": "feeRecipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "proposerAmt", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "rewardAmt", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardsPctBpsSet", + "inputs": [ + { + "name": "rewardsPctBps", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TreasurySet", + "inputs": [ + { + "name": "treasury", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TreasuryWithdrawn", + "inputs": [ + { + "name": "treasuryAmt", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Upgraded", + "inputs": [ + { + "name": "implementation", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967InvalidImplementation", + "inputs": [ + { + "name": "implementation", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967NonPayable", + "inputs": [] + }, + { + "type": "error", + "name": "FailedInnerCall", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidFallback", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidReceive", + "inputs": [] + }, + { + "type": "error", + "name": "NoFundsToWithdraw", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ProposerTransferFailed", + "inputs": [ + { + "name": "feeRecipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "RewardsPctTooHigh", + "inputs": [] + }, + { + "type": "error", + "name": "TreasuryIsZero", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnauthorizedCallContext", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnsupportedProxiableUUID", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } +] diff --git a/contracts-abi/abi/StipendDistributor.abi b/contracts-abi/abi/StipendDistributor.abi new file mode 100644 index 000000000..61b620b59 --- /dev/null +++ b/contracts-abi/abi/StipendDistributor.abi @@ -0,0 +1,892 @@ +[ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "fallback", + "stateMutability": "payable" + }, + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "UPGRADE_INTERFACE_VERSION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "acceptOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "accrued", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "claimDelegate", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "delegate", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "claimOnbehalfOfOperator", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipients", + "type": "address[]", + "internalType": "address payable[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "claimRewards", + "inputs": [ + { + "name": "recipients", + "type": "address[]", + "internalType": "address payable[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "claimed", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "defaultRecipient", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getKeyRecipient", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "pubkey", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPendingRewards", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantStipends", + "inputs": [ + { + "name": "operators", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "recipients", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "amounts", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "stipendManager", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "migrateExistingRewards", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "operatorKeyOverrides", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "keyhash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "overrideRecipientByPubkey", + "inputs": [ + { + "name": "pubkeys", + "type": "bytes[]", + "internalType": "bytes[]" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pause", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pendingOwner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "proxiableUUID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setClaimDelegate", + "inputs": [ + { + "name": "delegate", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "status", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setDefaultRecipient", + "inputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setStipendManager", + "inputs": [ + { + "name": "_stipendManager", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "stipendManager", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "unpause", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "upgradeToAndCall", + "inputs": [ + { + "name": "newImplementation", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "event", + "name": "ClaimDelegateSet", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "delegate", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "status", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "DefaultRecipientSet", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferStarted", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RecipientSet", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "pubkey", + "type": "bytes", + "indexed": false, + "internalType": "bytes" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardsClaimed", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardsMigrated", + "inputs": [ + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "StipendManagerSet", + "inputs": [ + { + "name": "stipendManager", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "StipendsGranted", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Upgraded", + "inputs": [ + { + "name": "implementation", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967InvalidImplementation", + "inputs": [ + { + "name": "implementation", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967NonPayable", + "inputs": [] + }, + { + "type": "error", + "name": "EnforcedPause", + "inputs": [] + }, + { + "type": "error", + "name": "ExpectedPause", + "inputs": [] + }, + { + "type": "error", + "name": "FailedInnerCall", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidBLSPubKeyLength", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidClaimDelegate", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidFallback", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidOperator", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidReceive", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidRecipient", + "inputs": [] + }, + { + "type": "error", + "name": "LengthMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "NoClaimableRewards", + "inputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "NotOwnerOrStipendManager", + "inputs": [] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "RewardsTransferFailed", + "inputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UUPSUnauthorizedCallContext", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnsupportedProxiableUUID", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + } +] From 2baebef49bcf55c1e8673032d55e63b89e25fea8 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Mon, 8 Sep 2025 21:49:35 -0400 Subject: [PATCH 04/20] fix solhint errors --- .../interfaces/IRewardsManagerV2.sol | 31 +++++++++---------- .../interfaces/IStipendDistributor.sol | 27 ++++++++-------- .../rewards/RewardsManagerV2.sol | 14 ++++----- .../rewards/RewardsManagerV2Storage.sol | 2 -- .../rewards/StipendDistributor.sol | 5 ++- 5 files changed, 38 insertions(+), 41 deletions(-) diff --git a/contracts/contracts/interfaces/IRewardsManagerV2.sol b/contracts/contracts/interfaces/IRewardsManagerV2.sol index 8c35a72d6..16abdafc8 100644 --- a/contracts/contracts/interfaces/IRewardsManagerV2.sol +++ b/contracts/contracts/interfaces/IRewardsManagerV2.sol @@ -3,13 +3,27 @@ pragma solidity 0.8.26; interface IRewardsManagerV2 { + // -------- Events -------- + /// @notice Emitted for each proposer payment routed by this contract + event ProposerPaid( + address indexed feeRecipient, + uint256 indexed proposerAmt, + uint256 indexed rewardAmt + ); + /// @notice Emitted when the treasury is withdrawn + event TreasuryWithdrawn(uint256 indexed treasuryAmt); + /// @notice Emitted when the rewards pct is set + event RewardsPctBpsSet(uint256 indexed rewardsPctBps); + /// @notice Emitted when the treasury is set + event TreasurySet(address indexed treasury); + + // -------- Errors -------- error RewardsPctTooHigh(); error TreasuryIsZero(); error NoFundsToWithdraw(); error ProposerTransferFailed(address feeRecipient, uint256 amount); - // -------- Views -------- /// @notice Builders/relays call this to route EL rewards *through* this contract. @@ -24,21 +38,6 @@ interface IRewardsManagerV2 { // -------- Admin -------- function initialize(address initialOwner, uint256 rewardsPctBps, address payable treasury) external; - // -------- Events -------- - - /// @notice Emitted for each proposer payment routed by this contract - event ProposerPaid( - address indexed feeRecipient, - uint256 indexed proposerAmt, - uint256 indexed rewardAmt - ); - /// @notice Emitted when the treasury is withdrawn - event TreasuryWithdrawn(uint256 indexed treasuryAmt); - /// @notice Emitted when the rewards pct is set - event RewardsPctBpsSet(uint256 indexed rewardsPctBps); - /// @notice Emitted when the treasury is set - event TreasurySet(address indexed treasury); - } \ No newline at end of file diff --git a/contracts/contracts/interfaces/IStipendDistributor.sol b/contracts/contracts/interfaces/IStipendDistributor.sol index 3319a7df1..40da26fc5 100644 --- a/contracts/contracts/interfaces/IStipendDistributor.sol +++ b/contracts/contracts/interfaces/IStipendDistributor.sol @@ -5,19 +5,6 @@ pragma solidity 0.8.26; /// @title IStipendDistributor /// @notice Interface for stipend distribution and claims. interface IStipendDistributor { - // ========================= - // ERRORS - // ========================= - error NotOwnerOrStipendManager(); - error ZeroAddress(); - error InvalidBLSPubKeyLength(); - error InvalidRecipient(); - error InvalidOperator(); - error InvalidClaimDelegate(); - error LengthMismatch(); - error NoClaimableRewards(address recipient); - error RewardsTransferFailed(address recipient); - // ========================= // EVENTS @@ -43,6 +30,20 @@ interface IStipendDistributor { /// @dev Emitted when accrued rewards are migrated from one recipient to another for an operator. event RewardsMigrated(address indexed from, address indexed to, uint256 amount); + + // ========================= + // ERRORS + // ========================= + error NotOwnerOrStipendManager(); + error ZeroAddress(); + error InvalidBLSPubKeyLength(); + error InvalidRecipient(); + error InvalidOperator(); + error InvalidClaimDelegate(); + error LengthMismatch(); + error NoClaimableRewards(address recipient); + error RewardsTransferFailed(address recipient); + // ========================= // EXTERNALS // ========================= diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol index e2f0747f0..98e1b4857 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol @@ -20,12 +20,16 @@ contract RewardsManagerV2 is IRewardsManagerV2, UUPSUpgradeable { - uint256 constant BPS_DENOMINATOR = 10_000; + uint256 constant _BPS_DENOMINATOR = 10_000; constructor() { _disableInitializers(); } + // -------- Receive/Fallback (explicitly disabled) -------- + receive() external payable { revert Errors.InvalidReceive(); } + fallback() external payable { revert Errors.InvalidFallback(); } + // -------- Initializer -------- function initialize(address initialOwner, uint256 rewardsPctBps, address payable treasury) external initializer override { __Ownable_init(initialOwner); @@ -44,7 +48,7 @@ contract RewardsManagerV2 is require(success, ProposerTransferFailed(feeRecipient, totalAmt)); //revert if transfer fails emit ProposerPaid(feeRecipient, totalAmt, 0); } else { - uint256 amtForRewards = totalAmt * bps / BPS_DENOMINATOR; + uint256 amtForRewards = totalAmt * bps / _BPS_DENOMINATOR; uint256 proposerAmt = totalAmt - amtForRewards; toTreasury += amtForRewards; (bool success, ) = feeRecipient.call{value: proposerAmt}(""); @@ -71,7 +75,7 @@ contract RewardsManagerV2 is function setTreasury(address payable treasury) external onlyOwner { _setTreasury(treasury); } - + // -------- Internal -------- function _setTreasury(address payable _treasury) internal { @@ -87,8 +91,4 @@ contract RewardsManagerV2 is // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address) internal override onlyOwner {} - - // -------- Receive/Fallback (explicitly disabled) -------- - receive() external payable { revert Errors.InvalidReceive(); } - fallback() external payable { revert Errors.InvalidFallback(); } } diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol index f4e9cfc15..0fa1808d4 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol @@ -1,8 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -import {IRewardsManagerV2} from "../../interfaces/IRewardsManagerV2.sol"; - abstract contract RewardsManagerV2Storage { uint256 public toTreasury; diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index 8993e1ccc..e34892582 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -1,8 +1,6 @@ // SPDX-License-Identifier: BSL 1.1 pragma solidity 0.8.26; -import {IRewardManager} from "../../interfaces/IRewardManager.sol"; -import {RewardManagerStorage} from "./RewardManagerStorage.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; @@ -82,7 +80,8 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @param recipient Recipient to set for the pubkeys. function overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) external whenNotPaused nonReentrant { require(recipient != address(0), ZeroAddress()); - for (uint256 i = 0; i < pubkeys.length; ++i) { + uint256 len = pubkeys.length; + for (uint256 i = 0; i < len; ++i) { bytes calldata pubkey = pubkeys[i]; require(pubkey.length == 48, InvalidBLSPubKeyLength()); bytes32 pkHash = keccak256(pubkey); From 13f480ffead8a72c066e32bb5329dc88a8ac3e19 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Tue, 9 Sep 2025 13:17:55 -0400 Subject: [PATCH 05/20] updates from comments --- contracts/contracts/interfaces/IRewardsManagerV2.sol | 1 + .../validator-registry/rewards/RewardsManagerV2.sol | 9 +++++++-- .../validator-registry/rewards/StipendDistributor.sol | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/contracts/contracts/interfaces/IRewardsManagerV2.sol b/contracts/contracts/interfaces/IRewardsManagerV2.sol index 16abdafc8..d1a2e56b7 100644 --- a/contracts/contracts/interfaces/IRewardsManagerV2.sol +++ b/contracts/contracts/interfaces/IRewardsManagerV2.sol @@ -19,6 +19,7 @@ interface IRewardsManagerV2 { event TreasurySet(address indexed treasury); // -------- Errors -------- + error OnlyOwnerOrTreasury(); error RewardsPctTooHigh(); error TreasuryIsZero(); error NoFundsToWithdraw(); diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol index 98e1b4857..44808cb02 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol @@ -26,6 +26,11 @@ contract RewardsManagerV2 is _disableInitializers(); } + modifier onlyOwnerOrTreasury() { + require(msg.sender == owner() || msg.sender == treasury, OnlyOwnerOrTreasury()); + _; + } + // -------- Receive/Fallback (explicitly disabled) -------- receive() external payable { revert Errors.InvalidReceive(); } fallback() external payable { revert Errors.InvalidFallback(); } @@ -59,8 +64,7 @@ contract RewardsManagerV2 is // -------- Owner Functions-------- - function withdrawToTreasury() external nonReentrant onlyOwner { - require(treasury != address(0), TreasuryIsZero()); + function withdrawToTreasury() external onlyOwnerOrTreasury { require(toTreasury > 0, NoFundsToWithdraw()); uint256 treasuryAmt = toTreasury; toTreasury = 0; @@ -79,6 +83,7 @@ contract RewardsManagerV2 is // -------- Internal -------- function _setTreasury(address payable _treasury) internal { + require(treasury != address(0), TreasuryIsZero()); treasury = _treasury; emit TreasurySet(treasury); } diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index e34892582..f4a823987 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -50,8 +50,8 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @param recipients Array of recipient addresses of the corresponding operator. /// @param amounts Array of stipend amounts. function grantStipends(address[] calldata operators, address[] calldata recipients, uint256[] calldata amounts) external payable nonReentrant whenNotPaused onlyOwnerOrStipendManager { - require(operators.length == amounts.length && operators.length == recipients.length, LengthMismatch()); uint256 len = operators.length; + require(len == amounts.length && len == recipients.length, LengthMismatch()); for (uint256 i = 0; i < len; ++i) { accrued[operators[i]][recipients[i]] += amounts[i]; emit StipendsGranted(operators[i], recipients[i], amounts[i]); From da35e59d79b92b2cc8ce4802dbb46f755caeb759 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Tue, 9 Sep 2025 15:28:32 -0400 Subject: [PATCH 06/20] additional minor changes --- .../interfaces/IRewardsManagerV2.sol | 3 - .../interfaces/IStipendDistributor.sol | 45 +++---- .../rewards/RewardsManagerV2.sol | 12 +- .../rewards/StipendDistributor.sol | 27 ++-- .../rewards/StipendDistributorStorage.sol | 4 +- .../rewards/RewardsManagerV2Test.sol | 15 +-- .../rewards/StipendDistributorTest.sol | 117 +++++++----------- 7 files changed, 78 insertions(+), 145 deletions(-) diff --git a/contracts/contracts/interfaces/IRewardsManagerV2.sol b/contracts/contracts/interfaces/IRewardsManagerV2.sol index d1a2e56b7..ac3daccd3 100644 --- a/contracts/contracts/interfaces/IRewardsManagerV2.sol +++ b/contracts/contracts/interfaces/IRewardsManagerV2.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.26; interface IRewardsManagerV2 { // -------- Events -------- - /// @notice Emitted for each proposer payment routed by this contract event ProposerPaid( address indexed feeRecipient, @@ -25,8 +24,6 @@ interface IRewardsManagerV2 { error NoFundsToWithdraw(); error ProposerTransferFailed(address feeRecipient, uint256 amount); - - /// @notice Builders/relays call this to route EL rewards *through* this contract. function payProposer(address payable feeRecipient) external payable; diff --git a/contracts/contracts/interfaces/IStipendDistributor.sol b/contracts/contracts/interfaces/IStipendDistributor.sol index 40da26fc5..54627249c 100644 --- a/contracts/contracts/interfaces/IStipendDistributor.sol +++ b/contracts/contracts/interfaces/IStipendDistributor.sol @@ -1,14 +1,17 @@ // SPDX-License-Identifier: BSL 1.1 pragma solidity 0.8.26; - /// @title IStipendDistributor /// @notice Interface for stipend distribution and claims. interface IStipendDistributor { - // ========================= - // EVENTS - // ========================= + struct Stipend { + address operator; + address recipient; + uint256 amount; + } + + // -------- Events -------- /// @dev Emitted when the oracle address is updated. event StipendManagerSet(address indexed stipendManager); @@ -21,8 +24,8 @@ interface IStipendDistributor { /// @dev Emitted when a recipient mapping is overridden for a specific pubkey. event RecipientSet(address indexed operator, bytes pubkey, address indexed recipient); - /// @dev Emitted when an operator sets/updates their default recipient. - event DefaultRecipientSet(address indexed operator, address indexed recipient); + /// @dev Emitted when an operator sets/updates their global override recipient. + event OperatorGlobalOverrideSet(address indexed operator, address indexed recipient); /// @dev Emitted when an operator sets/updates a claim delegate for a given recipient. event ClaimDelegateSet(address indexed operator, address indexed recipient, address indexed delegate, bool status); @@ -31,9 +34,7 @@ interface IStipendDistributor { event RewardsMigrated(address indexed from, address indexed to, uint256 amount); - // ========================= - // ERRORS - // ========================= + // -------- Errors -------- error NotOwnerOrStipendManager(); error ZeroAddress(); error InvalidBLSPubKeyLength(); @@ -44,51 +45,33 @@ interface IStipendDistributor { error NoClaimableRewards(address recipient); error RewardsTransferFailed(address recipient); - // ========================= - // EXTERNALS - // ========================= - + // -------- Externals -------- /// @notice Initialize the proxy. function initialize(address owner, address stipendManager) external; - - function grantStipends( - address[] calldata operators, - address[] calldata recipients, - uint256[] calldata amounts - ) external payable; - + function grantStipends(Stipend[] calldata stipends) external payable; /// @notice Claim rewards for the caller (as operator) to specific recipients. function claimRewards(address payable[] calldata recipients) external; - /// @notice Claim rewards on behalf of an operator to specific recipients (must be delegated). function claimOnbehalfOfOperator(address operator, address payable[] calldata recipients) external; - /// @notice Override recipient for a list of BLS pubkeys in a registry. function overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) external; - - /// @notice Set the caller's default recipient for any non-overridden keys. - function setDefaultRecipient(address recipient) external; - + /// @notice Set the caller's global override recipient for any non-overridden keys. + function setOperatorGlobalOverride(address recipient) external; /// @notice Allow or revoke a delegate to claim for a given recipient of the caller (operator). function setClaimDelegate(address delegate, address recipient, bool status) external; - /// @notice Migrate unclaimed rewards from one recipient to another for the caller (operator). function migrateExistingRewards(address from, address to) external; - /// @notice Pause / Unpause admin controls. function pause() external; function unpause() external; - - - /// @notice Admin setters. function setStipendManager(address _stipendManager) external; function getKeyRecipient(address operator, bytes calldata pubkey) external view returns (address); function getPendingRewards(address operator, address recipient) external view returns (uint256); diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol index 44808cb02..a4bea5168 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol @@ -10,8 +10,6 @@ import {IRewardsManagerV2} from "../../interfaces/IRewardsManagerV2.sol"; import {RewardsManagerV2Storage} from "./RewardsManagerV2Storage.sol"; import {Errors} from "../../utils/Errors.sol"; - - contract RewardsManagerV2 is Initializable, Ownable2StepUpgradeable, @@ -62,8 +60,6 @@ contract RewardsManagerV2 is } } - // -------- Owner Functions-------- - function withdrawToTreasury() external onlyOwnerOrTreasury { require(toTreasury > 0, NoFundsToWithdraw()); uint256 treasuryAmt = toTreasury; @@ -80,18 +76,16 @@ contract RewardsManagerV2 is _setTreasury(treasury); } - // -------- Internal -------- - function _setTreasury(address payable _treasury) internal { - require(treasury != address(0), TreasuryIsZero()); + require(_treasury != address(0), TreasuryIsZero()); treasury = _treasury; - emit TreasurySet(treasury); + emit TreasurySet(_treasury); } function _setRewardsPctBps(uint256 _rewardsPctBps) internal { require (_rewardsPctBps <= 2500, RewardsPctTooHigh()); rewardsPctBps = _rewardsPctBps; - emit RewardsPctBpsSet(rewardsPctBps); + emit RewardsPctBpsSet(_rewardsPctBps); } // solhint-disable-next-line no-empty-blocks diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index f4a823987..f5f5654a0 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -12,7 +12,6 @@ import {Errors} from "../../utils/Errors.sol"; contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, Ownable2StepUpgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable, UUPSUpgradeable { - modifier onlyOwnerOrStipendManager() { require(msg.sender == stipendManager || msg.sender == owner(), NotOwnerOrStipendManager()); _; @@ -46,15 +45,12 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } /// @dev Grant stipends to multiple (operator, recipient) pairs. - /// @param operators Array of operator addresses. - /// @param recipients Array of recipient addresses of the corresponding operator. - /// @param amounts Array of stipend amounts. - function grantStipends(address[] calldata operators, address[] calldata recipients, uint256[] calldata amounts) external payable nonReentrant whenNotPaused onlyOwnerOrStipendManager { - uint256 len = operators.length; - require(len == amounts.length && len == recipients.length, LengthMismatch()); + /// @param stipends Array of stipends. + function grantStipends(Stipend[] calldata stipends) external payable nonReentrant whenNotPaused onlyOwnerOrStipendManager { + uint256 len = stipends.length; for (uint256 i = 0; i < len; ++i) { - accrued[operators[i]][recipients[i]] += amounts[i]; - emit StipendsGranted(operators[i], recipients[i], amounts[i]); + accrued[stipends[i].operator][stipends[i].recipient] += stipends[i].amount; + emit StipendsGranted(stipends[i].operator, stipends[i].recipient, stipends[i].amount); } } @@ -75,7 +71,6 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @notice Allows an operator to set the recipient for a list of pubkeys. /// @dev If operator is no longer valid at the time of stipend distribution, the recipient will not receive the stipend. - /// If the key has a new operator that has not updated the key's recipient, the new operator will receive the stipend. /// @param pubkeys List of pubkeys to set the recipient for. /// @param recipient Recipient to set for the pubkeys. function overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) external whenNotPaused nonReentrant { @@ -93,10 +88,10 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @dev Allows an operator to set a default recipient for all non-overridden keys. /// If a recipient is set for a specific key, it will override the default recipient. /// @param recipient Default recipient to set for all non-overridden keys of the operator. - function setDefaultRecipient(address recipient) external whenNotPaused nonReentrant { + function setOperatorGlobalOverride(address recipient) external whenNotPaused nonReentrant { require(recipient != address(0), ZeroAddress()); - defaultRecipient[msg.sender] = recipient; - emit DefaultRecipientSet(msg.sender, recipient); + operatorGlobalOverride[msg.sender] = recipient; + emit OperatorGlobalOverrideSet(msg.sender, recipient); } /// @dev Allows an operator to set a delegate to claim rewards for one of their recipients. @@ -130,8 +125,6 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, _setStipendManager(_stipendManager); } - // --- Getters --- - // Retreives the recipient for an operator's registered key function getKeyRecipient(address operator, bytes calldata pubkey) external view returns (address) { require(pubkey.length == 48, InvalidBLSPubKeyLength()); @@ -141,7 +134,7 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, return operatorKeyOverrides[operator][pkHash]; } // If no key override, return the default recipient - address defaultOverride = defaultRecipient[operator]; + address defaultOverride = operatorGlobalOverride[operator]; if (defaultOverride != address(0)) { return defaultOverride; } @@ -153,7 +146,6 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, return accrued[operator][recipient] - claimed[operator][recipient]; } - // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address) internal override onlyOwner {} @@ -178,6 +170,7 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } function _setStipendManager(address _stipendManager) internal { + require(_stipendManager != address(0), ZeroAddress()); stipendManager = _stipendManager; emit StipendManagerSet(_stipendManager); } diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol b/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol index f9a0d8729..52ae223b5 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol @@ -8,7 +8,7 @@ abstract contract StipendDistributorStorage { address public stipendManager; /// @dev Default recipient per operator (used when no pubkey-specific override exists). - mapping(address operator => address recipient) public defaultRecipient; + mapping(address operator => address recipient) public operatorGlobalOverride; /// @dev Recipient override by BLS pubkey hash (keccak256(pubkey)). mapping(address operator => mapping(bytes32 keyhash => address recipient)) public operatorKeyOverrides; @@ -19,7 +19,7 @@ abstract contract StipendDistributorStorage { /// @dev Operator → recipient → delegate → isAuthorized mapping(address operator => mapping(address recipient => mapping(address delegate => bool))) public claimDelegate; - + // === Storage gap for future upgrades === uint256[40] private __gap; } \ No newline at end of file diff --git a/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol b/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol index 784b43812..904b9d41c 100644 --- a/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol +++ b/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol @@ -176,23 +176,14 @@ contract RewardsManagerV2Test is Test { // withdraw to treasury only owner function test_WithdrawToTreasury_onlyOwner() public { vm.prank(payerOne); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, payerOne)); + vm.expectRevert(abi.encodeWithSelector(IRewardsManagerV2.OnlyOwnerOrTreasury.selector)); rewardsManager.withdrawToTreasury(); } - function test_WithdrawToTreasury_revertsIfTreasuryZero() public { - vm.prank(ownerAddress); - rewardsManager.setTreasury(payable(address(0))); - - vm.prank(ownerAddress); - rewardsManager.setRewardsPctBps(1000); - - vm.prank(payerOne); - rewardsManager.payProposer{value: 1 ether}(payable(feeRecipientOne)); - + function test_setTreasury_revertsIfTreasuryZero() public { vm.prank(ownerAddress); vm.expectRevert(IRewardsManagerV2.TreasuryIsZero.selector); - rewardsManager.withdrawToTreasury(); + rewardsManager.setTreasury(payable(address(0))); } // revert when no funds to withdraw diff --git a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol index 6a9b50a19..bb2540039 100644 --- a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol +++ b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol @@ -35,7 +35,7 @@ contract StipendDistributorTest is Test { event RecipientSet(address indexed operator, bytes pubkey, uint256 indexed registryID, address indexed recipient); event StipendsGranted(address indexed operator, address indexed recipient, uint256 amount); event RewardsClaimed(address indexed operator, address indexed recipient, uint256 amount); - event DefaultRecipientSet(address indexed operator, address indexed recipient); + event OperatorGlobalOverrideSet(address indexed operator, address indexed recipient); // setup: deploy registries + distributor and fund stipendManager for payable calls function setUp() public { @@ -70,37 +70,35 @@ contract StipendDistributorTest is Test { address op1, address op2 ) internal { - address[] memory operators = new address[](3); - address[] memory receivers = new address[](3); - uint256[] memory amounts = new uint256[](3); + IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](3); - operators[0] = op1; - receivers[0] = addr1; - amounts[0] = 1 ether; + stipends[0].operator = op1; + stipends[0].recipient = addr1; + stipends[0].amount = 1 ether; - operators[1] = op1; - receivers[1] = addr2; - amounts[1] = 2 ether; + stipends[1].operator = op1; + stipends[1].recipient = addr2; + stipends[1].amount = 2 ether; - operators[2] = op2; - receivers[2] = addr3; - amounts[2] = 3 ether; + stipends[2].operator = op2; + stipends[2].recipient = addr3; + stipends[2].amount = 3 ether; vm.prank(stipendManager); - distributor.grantStipends{value: amounts[0] + amounts[1] + amounts[2]}(operators, receivers, amounts); + distributor.grantStipends{value: stipends[0].amount + stipends[1].amount + stipends[2].amount}(stipends); } // default recipient: set and read mapping - function test_SetDefaultRecipient_setsMapping() public { + function test_SetOperatorGlobalOverride_setsMapping() public { // starts empty - assertEq(distributor.defaultRecipient(operator1), address(0)); + assertEq(distributor.operatorGlobalOverride(operator1), address(0)); // operator sets default vm.prank(operator1); - distributor.setDefaultRecipient(recipient1); + distributor.setOperatorGlobalOverride(recipient1); // mapping reflects default - assertEq(distributor.defaultRecipient(operator1), recipient1); + assertEq(distributor.operatorGlobalOverride(operator1), recipient1); } // override by pubkey: same operator sets 3 keys → recipient2, then 2 keys → recipient3 (middleware registry id=2) @@ -190,14 +188,12 @@ contract StipendDistributorTest is Test { // pending rewards: increments on grant, clears on claim, and stacks across grants function test_PendingRewards_increment_and_clear() public { // 1) first grant (1e) to operator1→recipient1 - address[] memory ops = new address[](1); - address[] memory recs = new address[](1); - uint256[] memory amts = new uint256[](1); - ops[0] = operator1; - recs[0] = recipient1; - amts[0] = 1 ether; + IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](1); + stipends[0].operator = operator1; + stipends[0].recipient = recipient1; + stipends[0].amount = 1 ether; vm.prank(stipendManager); - distributor.grantStipends{value: amts[0]}(ops, recs, amts); + distributor.grantStipends{value: stipends[0].amount}(stipends); assertEq(distributor.accrued(operator1, recipient1), 1 ether); // claim pays 1e @@ -215,15 +211,15 @@ contract StipendDistributorTest is Test { assertEq(recipient1.balance, before); // 2) second grant (2e) → total accrued becomes 3e - amts[0] = 2 ether; + stipends[0].amount = 2 ether; vm.prank(stipendManager); - distributor.grantStipends{value: amts[0]}(ops, recs, amts); + distributor.grantStipends{value: stipends[0].amount}(stipends); assertEq(distributor.accrued(operator1, recipient1), 3 ether); // 3) third grant (3e) without claiming → total accrued becomes 6e - amts[0] = 3 ether; + stipends[0].amount = 3 ether; vm.prank(stipendManager); - distributor.grantStipends{value: amts[0]}(ops, recs, amts); + distributor.grantStipends{value: stipends[0].amount}(stipends); assertEq(distributor.accrued(operator1, recipient1), 6 ether); // claim now pays 5e (the unclaimed 2e + 3e) @@ -249,7 +245,7 @@ contract StipendDistributorTest is Test { // 2) set default for operator → returns default vm.prank(opFromMiddlewareTest); - distributor.setDefaultRecipient(recipient1); + distributor.setOperatorGlobalOverride(recipient1); address rec1 = distributor.getKeyRecipient(opFromMiddlewareTest, pubkey1); assertEq(rec1, recipient1, "default recipient should be returned"); @@ -266,7 +262,7 @@ contract StipendDistributorTest is Test { function test_Pause_allPausableFunctions() public { // works unpaused vm.prank(operator1); - distributor.setDefaultRecipient(recipient1); + distributor.setOperatorGlobalOverride(recipient1); // pause as owner vm.prank(owner); @@ -276,22 +272,20 @@ contract StipendDistributorTest is Test { // pausable funcs revert when paused vm.prank(operator1); vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.setDefaultRecipient(recipient2); + distributor.setOperatorGlobalOverride(recipient2); bytes[] memory pubs = new bytes[](1); pubs[0] = pubkey1; vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); distributor.overrideRecipientByPubkey(pubs, recipient2); - address[] memory ops = new address[](1); - address[] memory recs = new address[](1); - uint256[] memory amts = new uint256[](1); - ops[0] = operator1; - recs[0] = recipient1; - amts[0] = 1 ether; + IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](1); + stipends[0].operator = operator1; + stipends[0].recipient = recipient1; + stipends[0].amount = 1 ether; vm.prank(stipendManager); vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.grantStipends{value: amts[0]}(ops, recs, amts); + distributor.grantStipends{value: stipends[0].amount}(stipends); address payable[] memory list = new address payable[](1); list[0] = payable(recipient1); @@ -310,21 +304,19 @@ contract StipendDistributorTest is Test { vm.prank(owner); distributor.unpause(); vm.prank(operator1); - distributor.setDefaultRecipient(recipient2); + distributor.setOperatorGlobalOverride(recipient2); } // reentrancy: malicious recipient can't reenter claimRewards function test_ReentrancyGuard_onClaimRewards() public { // grant to a recipient that tries to reenter ReenteringRecipient attacker = new ReenteringRecipient(); - address[] memory ops = new address[](1); - address[] memory recs = new address[](1); - uint256[] memory amts = new uint256[](1); - ops[0] = operator1; - recs[0] = address(attacker); - amts[0] = 1 ether; + IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](1); + stipends[0].operator = operator1; + stipends[0].recipient = address(attacker); + stipends[0].amount = 1 ether; vm.prank(stipendManager); - distributor.grantStipends{value: amts[0]}(ops, recs, amts); + distributor.grantStipends{value: stipends[0].amount}(stipends); // claim once → paid exactly once; inner call blocked by nonReentrant address payable[] memory list = new address payable[](1); @@ -356,18 +348,16 @@ contract StipendDistributorTest is Test { // only stipendManager can grant stipends function test_Grant_onlystipendManager_revertsForOthers() public { - address[] memory ops = new address[](1); - address[] memory recs = new address[](1); - uint256[] memory amts = new uint256[](1); - ops[0] = operator1; - recs[0] = recipient1; - amts[0] = 1 ether; + IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](1); + stipends[0].operator = operator1; + stipends[0].recipient = recipient1; + stipends[0].amount = 1 ether; vm.deal(operator1, 10 ether); // non-stipendManager caller → revert vm.prank(operator1); vm.expectRevert(); - distributor.grantStipends{value: amts[0]}(ops, recs, amts); + distributor.grantStipends{value: stipends[0].amount}(stipends); } // wrong operator can't claim another operator's recipients @@ -383,26 +373,11 @@ contract StipendDistributorTest is Test { assertEq(recipient2.balance, before); } - // grantStipends: arrays length mismatch reverts - function test_Grant_arraysLengthMismatch_reverts() public { - address[] memory ops = new address[](2); - address[] memory recs = new address[](1); - uint256[] memory amts = new uint256[](1); - ops[0] = operator1; - ops[1] = operator2; - recs[0] = recipient1; - amts[0] = 1 ether; - - vm.prank(stipendManager); - vm.expectRevert(); - distributor.grantStipends{value: amts[0]}(ops, recs, amts); - } - // zero-address guards - function test_SetDefaultRecipient_zero_reverts() public { + function test_SetOperatorGlobalOverride_zero_reverts() public { vm.prank(operator1); vm.expectRevert(); - distributor.setDefaultRecipient(address(0)); + distributor.setOperatorGlobalOverride(address(0)); } function test_Override_zeroRecipient_reverts() public { From 540c9779fcbb80101a87d0e2f7d7357b0513da68 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Tue, 9 Sep 2025 15:30:16 -0400 Subject: [PATCH 07/20] solhint err fix --- .../validator-registry/rewards/RewardsManagerV2.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol index a4bea5168..595239f02 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol @@ -20,15 +20,15 @@ contract RewardsManagerV2 is { uint256 constant _BPS_DENOMINATOR = 10_000; - constructor() { - _disableInitializers(); - } - modifier onlyOwnerOrTreasury() { require(msg.sender == owner() || msg.sender == treasury, OnlyOwnerOrTreasury()); _; } + constructor() { + _disableInitializers(); + } + // -------- Receive/Fallback (explicitly disabled) -------- receive() external payable { revert Errors.InvalidReceive(); } fallback() external payable { revert Errors.InvalidFallback(); } From 504345f7c3e5650da645cb0f9a2ecdf60b4d03fa Mon Sep 17 00:00:00 2001 From: owen-eth Date: Tue, 9 Sep 2025 17:35:33 -0400 Subject: [PATCH 08/20] fix abi --- contracts-abi/abi/RewardsManagerV2.abi | 5 ++ contracts-abi/abi/StipendDistributor.abi | 101 ++++++++++++----------- 2 files changed, 59 insertions(+), 47 deletions(-) diff --git a/contracts-abi/abi/RewardsManagerV2.abi b/contracts-abi/abi/RewardsManagerV2.abi index 5d2cc641d..bf5716d44 100644 --- a/contracts-abi/abi/RewardsManagerV2.abi +++ b/contracts-abi/abi/RewardsManagerV2.abi @@ -402,6 +402,11 @@ "name": "NotInitializing", "inputs": [] }, + { + "type": "error", + "name": "OnlyOwnerOrTreasury", + "inputs": [] + }, { "type": "error", "name": "OwnableInvalidOwner", diff --git a/contracts-abi/abi/StipendDistributor.abi b/contracts-abi/abi/StipendDistributor.abi index 61b620b59..c9952ca07 100644 --- a/contracts-abi/abi/StipendDistributor.abi +++ b/contracts-abi/abi/StipendDistributor.abi @@ -140,25 +140,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "defaultRecipient", - "inputs": [ - { - "name": "operator", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "recipient", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "getKeyRecipient", @@ -212,19 +193,26 @@ "name": "grantStipends", "inputs": [ { - "name": "operators", - "type": "address[]", - "internalType": "address[]" - }, - { - "name": "recipients", - "type": "address[]", - "internalType": "address[]" - }, - { - "name": "amounts", - "type": "uint256[]", - "internalType": "uint256[]" + "name": "stipends", + "type": "tuple[]", + "internalType": "struct IStipendDistributor.Stipend[]", + "components": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] } ], "outputs": [], @@ -266,6 +254,25 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "operatorGlobalOverride", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "operatorKeyOverrides", @@ -399,7 +406,7 @@ }, { "type": "function", - "name": "setDefaultRecipient", + "name": "setOperatorGlobalOverride", "inputs": [ { "name": "recipient", @@ -507,7 +514,20 @@ }, { "type": "event", - "name": "DefaultRecipientSet", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OperatorGlobalOverrideSet", "inputs": [ { "name": "operator", @@ -524,19 +544,6 @@ ], "anonymous": false }, - { - "type": "event", - "name": "Initialized", - "inputs": [ - { - "name": "version", - "type": "uint64", - "indexed": false, - "internalType": "uint64" - } - ], - "anonymous": false - }, { "type": "event", "name": "OwnershipTransferStarted", From 59cf9bd11e562de17f455f370241f478ec9bf419 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 10 Sep 2025 14:49:01 -0400 Subject: [PATCH 09/20] added initial deployment scripts --- .../rewards/StipendDistributor.sol | 1 - contracts/l1-deployer-cli.sh | 42 ++++++++--- .../rewards/DeployRewardsManagerV2.s.sol | 69 +++++++++++++++++++ .../rewards/DeployStipendDistributor.s.sol | 64 +++++++++++++++++ 4 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 contracts/scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol create mode 100644 contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index f5f5654a0..cfedf4c9e 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -170,7 +170,6 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } function _setStipendManager(address _stipendManager) internal { - require(_stipendManager != address(0), ZeroAddress()); stipendManager = _stipendManager; emit StipendManagerSet(_stipendManager); } diff --git a/contracts/l1-deployer-cli.sh b/contracts/l1-deployer-cli.sh index 8afe9395b..4d47034ab 100755 --- a/contracts/l1-deployer-cli.sh +++ b/contracts/l1-deployer-cli.sh @@ -5,6 +5,8 @@ deploy_vanilla_flag=false deploy_avs_flag=false deploy_middleware_flag=false deploy_router_flag=false +deploy_rewardsV2_flag=false +deploy_stipend_flag=false skip_release_verification_flag=false resume_flag=false wallet_type="" @@ -24,6 +26,8 @@ help() { echo " deploy-avs Deploy and verify the MevCommitAVS contract to L1." echo " deploy-middleware Deploy and verify the MevCommitMiddleware contract to L1." echo " deploy-router Deploy and verify the ValidatorOptInRouter contract to L1." + echo " deploy-rewardsV2 Deploy and verify the RewardsV2 contract to L1." + echo " deploy-stipend Deploy and verify the StipendDistributor contract to L1." echo echo "Required Options:" echo " --chain, -c Specify the chain to deploy to ('mainnet', 'holesky', or 'hoodi')." @@ -122,6 +126,14 @@ parse_args() { deploy_router_flag=true shift ;; + deploy-rewardsV2) + deploy_rewardsV2_flag=true + shift + ;; + deploy-stipend) + deploy_stipend_flag=true + shift + ;; --chain|-c) if [[ -z "$2" ]]; then echo "Error: --chain requires an argument." @@ -203,7 +215,7 @@ parse_args() { fi commands_specified=0 - for flag in deploy_all_flag deploy_vanilla_flag deploy_avs_flag deploy_middleware_flag deploy_router_flag; do + for flag in deploy_all_flag deploy_vanilla_flag deploy_avs_flag deploy_middleware_flag deploy_router_flag deploy_rewardsV2_flag deploy_stipend_flag; do if [[ "${!flag}" == true ]]; then ((commands_specified++)) fi @@ -255,15 +267,15 @@ get_chain_params() { } check_git_status() { - if ! current_tag=$(git describe --tags --exact-match 2>/dev/null); then - echo "Error: Current commit is not tagged. Please ensure the commit is tagged before deploying." - exit 1 - fi + # if ! current_tag=$(git describe --tags --exact-match 2>/dev/null); then + # echo "Error: Current commit is not tagged. Please ensure the commit is tagged before deploying." + # exit 1 + # fi - if [[ -n "$(git status --porcelain)" ]]; then - echo "Error: There are uncommitted changes. Please commit or stash them before deploying." - exit 1 - fi + # if [[ -n "$(git status --porcelain)" ]]; then + # echo "Error: There are uncommitted changes. Please commit or stash them before deploying." + # exit 1 + # fi if [[ "$skip_release_verification_flag" != true ]]; then releases_url="https://api.github.com/repos/primev/mev-commit/releases?per_page=100" @@ -386,6 +398,14 @@ deploy_router() { deploy_contract_generic "scripts/validator-registry/DeployValidatorOptInRouter.s.sol" } +deploy_rewardsV2() { + deploy_contract_generic "scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol" +} + +deploy_stipend() { + deploy_contract_generic "scripts/validator-registry/rewards/DeployStipendDistributor.s.sol" +} + main() { check_dependencies parse_args "$@" @@ -409,6 +429,10 @@ main() { deploy_middleware elif [[ "${deploy_router_flag}" == true ]]; then deploy_router + elif [[ "${deploy_rewardsV2_flag}" == true ]]; then + deploy_rewardsV2 + elif [[ "${deploy_stipend_flag}" == true ]]; then + deploy_stipend else usage fi diff --git a/contracts/scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol b/contracts/scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol new file mode 100644 index 000000000..67c538b72 --- /dev/null +++ b/contracts/scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: BSL 1.1 + +// solhint-disable no-console +// solhint-disable one-contract-per-file + +pragma solidity 0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {RewardsManagerV2} from "../../../contracts/validator-registry/rewards/RewardsManagerV2.sol"; +import {MainnetConstants} from "../../MainnetConstants.sol"; + +contract BaseDeploy is Script { + function deployRewardsManagerV2( + address owner, + uint256 rewardsPctBps, + address treasury + ) public returns (address) { + console.log("Deploying RewardsManagerV2 on chain:", block.chainid); + address proxy = Upgrades.deployUUPSProxy( + "RewardsManagerV2.sol", + abi.encodeCall( + RewardsManagerV2.initialize, + (owner, rewardsPctBps, payable(treasury)) + ) + ); + console.log("RewardsManagerV2 UUPS proxy deployed to:", address(proxy)); + RewardsManagerV2 rewardsV2 = RewardsManagerV2(payable(proxy)); + console.log("RewardsManagerV2 owner:", rewardsV2.owner()); + return proxy; + } +} + +contract DeployMainnet is BaseDeploy { + address constant public OWNER = MainnetConstants.PRIMEV_TEAM_MULTISIG; + address constant public TREASURY = MainnetConstants.PRIMEV_TEAM_MULTISIG; + uint256 constant public REWARDS_PCT_BPS = 0; + + function run() external { + require(block.chainid == 1, "must deploy on mainnet"); + vm.startBroadcast(); + + deployRewardsManagerV2( + OWNER, + REWARDS_PCT_BPS, + TREASURY + ); + vm.stopBroadcast(); + } +} + +contract DeployHoodi is BaseDeploy { + address constant public OWNER = 0x1623fE21185c92BB43bD83741E226288B516134a; + address constant public TREASURY = 0x1623fE21185c92BB43bD83741E226288B516134a; + uint256 constant public REWARDS_PCT_BPS = 0; + + function run() external { + require(block.chainid == 560048, "must deploy on Hoodi"); + + vm.startBroadcast(); + deployRewardsManagerV2( + OWNER, + REWARDS_PCT_BPS, + TREASURY + ); + vm.stopBroadcast(); + } +} diff --git a/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol b/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol new file mode 100644 index 000000000..c3d980cda --- /dev/null +++ b/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BSL 1.1 + +// solhint-disable no-console +// solhint-disable one-contract-per-file + +pragma solidity 0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {StipendDistributor} from "../../../contracts/validator-registry/rewards/StipendDistributor.sol"; +import {MainnetConstants} from "../../MainnetConstants.sol"; + +contract BaseDeploy is Script { + function deployStipendDistributor( + address owner, + address stipendManager + ) public returns (address) { + console.log("Deploying StipendDistributor on chain:", block.chainid); + address proxy = Upgrades.deployUUPSProxy( + "StipendDistributor.sol", + abi.encodeCall( + StipendDistributor.initialize, + (owner, stipendManager) + ) + ); + console.log("StipendDistributor UUPS proxy deployed to:", address(proxy)); + StipendDistributor stipendDistributor = StipendDistributor(payable(proxy)); + console.log("StipendDistributor owner:", stipendDistributor.owner()); + return proxy; + } +} + +contract DeployMainnet is BaseDeploy { + address constant public OWNER = MainnetConstants.PRIMEV_TEAM_MULTISIG; + address constant public STIPEND_MANAGER = MainnetConstants.PRIMEV_TEAM_MULTISIG; + + function run() external { + require(block.chainid == 1, "must deploy on mainnet"); + vm.startBroadcast(); + + deployStipendDistributor( + OWNER, + STIPEND_MANAGER + ); + vm.stopBroadcast(); + } +} + +contract DeployHoodi is BaseDeploy { + address constant public OWNER = 0x1623fE21185c92BB43bD83741E226288B516134a; + address constant public STIPEND_MANAGER = 0x1623fE21185c92BB43bD83741E226288B516134a; + + function run() external { + require(block.chainid == 560048, "must deploy on Hoodi"); + + vm.startBroadcast(); + deployStipendDistributor( + OWNER, + STIPEND_MANAGER + ); + vm.stopBroadcast(); + } +} From 923913afa6fe56e710dc25de32f0aac22799dd66 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 10 Sep 2025 16:17:46 -0400 Subject: [PATCH 10/20] add constructor exception line to new contracts + minor fixes --- .../rewards/RewardsManagerV2.sol | 1 + .../rewards/StipendDistributor.sol | 2 ++ contracts/l1-deployer-cli.sh | 18 +++++++++--------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol index 595239f02..9dc853f2a 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol +++ b/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol @@ -25,6 +25,7 @@ contract RewardsManagerV2 is _; } + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index cfedf4c9e..a6afb9319 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -18,6 +18,7 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } /// @dev See https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializing_the_implementation_contract + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } @@ -170,6 +171,7 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } function _setStipendManager(address _stipendManager) internal { + require(_stipendManager != address(0), ZeroAddress()); stipendManager = _stipendManager; emit StipendManagerSet(_stipendManager); } diff --git a/contracts/l1-deployer-cli.sh b/contracts/l1-deployer-cli.sh index 4d47034ab..69cbd30e4 100755 --- a/contracts/l1-deployer-cli.sh +++ b/contracts/l1-deployer-cli.sh @@ -267,15 +267,15 @@ get_chain_params() { } check_git_status() { - # if ! current_tag=$(git describe --tags --exact-match 2>/dev/null); then - # echo "Error: Current commit is not tagged. Please ensure the commit is tagged before deploying." - # exit 1 - # fi - - # if [[ -n "$(git status --porcelain)" ]]; then - # echo "Error: There are uncommitted changes. Please commit or stash them before deploying." - # exit 1 - # fi + if ! current_tag=$(git describe --tags --exact-match 2>/dev/null); then + echo "Error: Current commit is not tagged. Please ensure the commit is tagged before deploying." + exit 1 + fi + + if [[ -n "$(git status --porcelain)" ]]; then + echo "Error: There are uncommitted changes. Please commit or stash them before deploying." + exit 1 + fi if [[ "$skip_release_verification_flag" != true ]]; then releases_url="https://api.github.com/repos/primev/mev-commit/releases?per_page=100" From e96c0e5bfdd809569be67d6b817c1638dfa13289 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 10 Sep 2025 22:53:36 -0400 Subject: [PATCH 11/20] renamed RewardsManagerV2 -> BlockRewardManager --- contracts-abi/abi/BlockRewardManager.abi | 479 ++++++++++++++++++ contracts-abi/script.sh | 4 +- ...sManagerV2.sol => IBlockRewardManager.sol} | 6 +- ...dsManagerV2.sol => BlockRewardManager.sol} | 18 +- ...rage.sol => BlockRewardManagerStorage.sol} | 2 +- contracts/l1-deployer-cli.sh | 18 +- ...2.s.sol => DeployBlockRewardManager.s.sol} | 20 +- ...rV2Test.sol => BlockRewardManagerTest.sol} | 22 +- 8 files changed, 524 insertions(+), 45 deletions(-) create mode 100644 contracts-abi/abi/BlockRewardManager.abi rename contracts/contracts/interfaces/{IRewardsManagerV2.sol => IBlockRewardManager.sol} (90%) rename contracts/contracts/validator-registry/rewards/{RewardsManagerV2.sol => BlockRewardManager.sol} (87%) rename contracts/contracts/validator-registry/rewards/{RewardsManagerV2Storage.sol => BlockRewardManagerStorage.sol} (82%) rename contracts/scripts/validator-registry/rewards/{DeployRewardsManagerV2.s.sol => DeployBlockRewardManager.s.sol} (73%) rename contracts/test/validator-registry/rewards/{RewardsManagerV2Test.sol => BlockRewardManagerTest.sol} (90%) diff --git a/contracts-abi/abi/BlockRewardManager.abi b/contracts-abi/abi/BlockRewardManager.abi new file mode 100644 index 000000000..31393d642 --- /dev/null +++ b/contracts-abi/abi/BlockRewardManager.abi @@ -0,0 +1,479 @@ +[ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "fallback", + "stateMutability": "payable" + }, + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "UPGRADE_INTERFACE_VERSION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "acceptOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "initialOwner", + "type": "address", + "internalType": "address" + }, + { + "name": "rewardsPctBps", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "treasury", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "payProposer", + "inputs": [ + { + "name": "feeRecipient", + "type": "address", + "internalType": "address payable" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "pendingOwner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "proxiableUUID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "rewardsPctBps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setRewardsPctBps", + "inputs": [ + { + "name": "rewardsPctBps", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setTreasury", + "inputs": [ + { + "name": "treasury", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "toTreasury", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "treasury", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address payable" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "upgradeToAndCall", + "inputs": [ + { + "name": "newImplementation", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "withdrawToTreasury", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferStarted", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ProposerPaid", + "inputs": [ + { + "name": "feeRecipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "proposerAmt", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "rewardAmt", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardsPctBpsSet", + "inputs": [ + { + "name": "rewardsPctBps", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TreasurySet", + "inputs": [ + { + "name": "treasury", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TreasuryWithdrawn", + "inputs": [ + { + "name": "treasuryAmt", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Upgraded", + "inputs": [ + { + "name": "implementation", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967InvalidImplementation", + "inputs": [ + { + "name": "implementation", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967NonPayable", + "inputs": [] + }, + { + "type": "error", + "name": "FailedInnerCall", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidFallback", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidReceive", + "inputs": [] + }, + { + "type": "error", + "name": "NoFundsToWithdraw", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "OnlyOwnerOrTreasury", + "inputs": [] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ProposerTransferFailed", + "inputs": [ + { + "name": "feeRecipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "RewardsPctTooHigh", + "inputs": [] + }, + { + "type": "error", + "name": "TreasuryIsZero", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnauthorizedCallContext", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnsupportedProxiableUUID", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } +] diff --git a/contracts-abi/script.sh b/contracts-abi/script.sh index 15152232b..d84dbd369 100755 --- a/contracts-abi/script.sh +++ b/contracts-abi/script.sh @@ -50,7 +50,7 @@ extract_and_save_abi "$BASE_DIR/out/DepositManager.sol/DepositManager.json" "$AB extract_and_save_abi "$BASE_DIR/out/StipendDistributor.sol/StipendDistributor.json" "$ABI_DIR/StipendDistributor.abi" -extract_and_save_abi "$BASE_DIR/out/RewardsManagerV2.sol/RewardsManagerV2.json" "$ABI_DIR/RewardsManagerV2.abi" +extract_and_save_abi "$BASE_DIR/out/BlockRewardManager.sol/BlockRewardManager.json" "$ABI_DIR/BlockRewardManager.abi" echo "ABI files extracted successfully." @@ -121,7 +121,7 @@ generate_go_code "$ABI_DIR/DepositManager.abi" "DepositManager" "depositmanager" generate_go_code "$ABI_DIR/StipendDistributor.abi" "StipendDistributor" "stipenddistributor" -generate_go_code "$ABI_DIR/RewardsManagerV2.abi" "RewardsManagerV2" "rewardsmanagerv2" +generate_go_code "$ABI_DIR/BlockRewardManager.abi" "BlockRewardManager" "BlockRewardManager" echo "External ABI downloaded and processed successfully." diff --git a/contracts/contracts/interfaces/IRewardsManagerV2.sol b/contracts/contracts/interfaces/IBlockRewardManager.sol similarity index 90% rename from contracts/contracts/interfaces/IRewardsManagerV2.sol rename to contracts/contracts/interfaces/IBlockRewardManager.sol index ac3daccd3..849cd498d 100644 --- a/contracts/contracts/interfaces/IRewardsManagerV2.sol +++ b/contracts/contracts/interfaces/IBlockRewardManager.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.26; -interface IRewardsManagerV2 { +interface IBlockRewardManager { // -------- Events -------- /// @notice Emitted for each proposer payment routed by this contract event ProposerPaid( @@ -31,10 +31,10 @@ interface IRewardsManagerV2 { function setRewardsPctBps(uint256 rewardsPctBps) external; - function setTreasury(address payable treasury) external; + function setTreasury(address treasury) external; // -------- Admin -------- - function initialize(address initialOwner, uint256 rewardsPctBps, address payable treasury) external; + function initialize(address initialOwner, uint256 rewardsPctBps, address treasury) external; diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol b/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol similarity index 87% rename from contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol rename to contracts/contracts/validator-registry/rewards/BlockRewardManager.sol index 9dc853f2a..1413cf8e8 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2.sol +++ b/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol @@ -6,16 +6,16 @@ import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/acces import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; -import {IRewardsManagerV2} from "../../interfaces/IRewardsManagerV2.sol"; -import {RewardsManagerV2Storage} from "./RewardsManagerV2Storage.sol"; +import {IBlockRewardManager} from "../../interfaces/IBlockRewardManager.sol"; +import {BlockRewardManagerStorage} from "./BlockRewardManagerStorage.sol"; import {Errors} from "../../utils/Errors.sol"; -contract RewardsManagerV2 is +contract BlockRewardManager is Initializable, Ownable2StepUpgradeable, ReentrancyGuardUpgradeable, - RewardsManagerV2Storage, - IRewardsManagerV2, + BlockRewardManagerStorage, + IBlockRewardManager, UUPSUpgradeable { uint256 constant _BPS_DENOMINATOR = 10_000; @@ -35,7 +35,7 @@ contract RewardsManagerV2 is fallback() external payable { revert Errors.InvalidFallback(); } // -------- Initializer -------- - function initialize(address initialOwner, uint256 rewardsPctBps, address payable treasury) external initializer override { + function initialize(address initialOwner, uint256 rewardsPctBps, address treasury) external initializer override { __Ownable_init(initialOwner); __ReentrancyGuard_init(); __UUPSUpgradeable_init(); @@ -73,13 +73,13 @@ contract RewardsManagerV2 is _setRewardsPctBps(rewardsPctBps); } - function setTreasury(address payable treasury) external onlyOwner { + function setTreasury(address treasury) external onlyOwner { _setTreasury(treasury); } - function _setTreasury(address payable _treasury) internal { + function _setTreasury(address _treasury) internal { require(_treasury != address(0), TreasuryIsZero()); - treasury = _treasury; + treasury = payable(_treasury); emit TreasurySet(_treasury); } diff --git a/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol b/contracts/contracts/validator-registry/rewards/BlockRewardManagerStorage.sol similarity index 82% rename from contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol rename to contracts/contracts/validator-registry/rewards/BlockRewardManagerStorage.sol index 0fa1808d4..4ee70ab6d 100644 --- a/contracts/contracts/validator-registry/rewards/RewardsManagerV2Storage.sol +++ b/contracts/contracts/validator-registry/rewards/BlockRewardManagerStorage.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -abstract contract RewardsManagerV2Storage { +abstract contract BlockRewardManagerStorage { uint256 public toTreasury; uint256 public rewardsPctBps; diff --git a/contracts/l1-deployer-cli.sh b/contracts/l1-deployer-cli.sh index 69cbd30e4..e261cc99b 100755 --- a/contracts/l1-deployer-cli.sh +++ b/contracts/l1-deployer-cli.sh @@ -5,7 +5,7 @@ deploy_vanilla_flag=false deploy_avs_flag=false deploy_middleware_flag=false deploy_router_flag=false -deploy_rewardsV2_flag=false +deploy_rewards_flag=false deploy_stipend_flag=false skip_release_verification_flag=false resume_flag=false @@ -26,7 +26,7 @@ help() { echo " deploy-avs Deploy and verify the MevCommitAVS contract to L1." echo " deploy-middleware Deploy and verify the MevCommitMiddleware contract to L1." echo " deploy-router Deploy and verify the ValidatorOptInRouter contract to L1." - echo " deploy-rewardsV2 Deploy and verify the RewardsV2 contract to L1." + echo " deploy-rewards Deploy and verify the BlockRewardManager contract to L1." echo " deploy-stipend Deploy and verify the StipendDistributor contract to L1." echo echo "Required Options:" @@ -126,8 +126,8 @@ parse_args() { deploy_router_flag=true shift ;; - deploy-rewardsV2) - deploy_rewardsV2_flag=true + deploy-rewards) + deploy_rewards_flag=true shift ;; deploy-stipend) @@ -215,7 +215,7 @@ parse_args() { fi commands_specified=0 - for flag in deploy_all_flag deploy_vanilla_flag deploy_avs_flag deploy_middleware_flag deploy_router_flag deploy_rewardsV2_flag deploy_stipend_flag; do + for flag in deploy_all_flag deploy_vanilla_flag deploy_avs_flag deploy_middleware_flag deploy_router_flag deploy_rewards_flag deploy_stipend_flag; do if [[ "${!flag}" == true ]]; then ((commands_specified++)) fi @@ -398,8 +398,8 @@ deploy_router() { deploy_contract_generic "scripts/validator-registry/DeployValidatorOptInRouter.s.sol" } -deploy_rewardsV2() { - deploy_contract_generic "scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol" +deploy_rewards() { + deploy_contract_generic "scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol" } deploy_stipend() { @@ -429,8 +429,8 @@ main() { deploy_middleware elif [[ "${deploy_router_flag}" == true ]]; then deploy_router - elif [[ "${deploy_rewardsV2_flag}" == true ]]; then - deploy_rewardsV2 + elif [[ "${deploy_rewards_flag}" == true ]]; then + deploy_rewards elif [[ "${deploy_stipend_flag}" == true ]]; then deploy_stipend else diff --git a/contracts/scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol b/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol similarity index 73% rename from contracts/scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol rename to contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol index 67c538b72..87cededbe 100644 --- a/contracts/scripts/validator-registry/rewards/DeployRewardsManagerV2.s.sol +++ b/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol @@ -8,26 +8,26 @@ pragma solidity 0.8.26; import {Script} from "forge-std/Script.sol"; import {console} from "forge-std/console.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; -import {RewardsManagerV2} from "../../../contracts/validator-registry/rewards/RewardsManagerV2.sol"; +import {BlockRewardManager} from "../../../contracts/validator-registry/rewards/BlockRewardManager.sol"; import {MainnetConstants} from "../../MainnetConstants.sol"; contract BaseDeploy is Script { - function deployRewardsManagerV2( + function deployBlockRewardManager( address owner, uint256 rewardsPctBps, address treasury ) public returns (address) { - console.log("Deploying RewardsManagerV2 on chain:", block.chainid); + console.log("Deploying BlockRewardManager on chain:", block.chainid); address proxy = Upgrades.deployUUPSProxy( - "RewardsManagerV2.sol", + "BlockRewardManager.sol", abi.encodeCall( - RewardsManagerV2.initialize, + BlockRewardManager.initialize, (owner, rewardsPctBps, payable(treasury)) ) ); - console.log("RewardsManagerV2 UUPS proxy deployed to:", address(proxy)); - RewardsManagerV2 rewardsV2 = RewardsManagerV2(payable(proxy)); - console.log("RewardsManagerV2 owner:", rewardsV2.owner()); + console.log("BlockRewardManager UUPS proxy deployed to:", address(proxy)); + BlockRewardManager rewardsV2 = BlockRewardManager(payable(proxy)); + console.log("BlockRewardManager owner:", rewardsV2.owner()); return proxy; } } @@ -41,7 +41,7 @@ contract DeployMainnet is BaseDeploy { require(block.chainid == 1, "must deploy on mainnet"); vm.startBroadcast(); - deployRewardsManagerV2( + deployBlockRewardManager( OWNER, REWARDS_PCT_BPS, TREASURY @@ -59,7 +59,7 @@ contract DeployHoodi is BaseDeploy { require(block.chainid == 560048, "must deploy on Hoodi"); vm.startBroadcast(); - deployRewardsManagerV2( + deployBlockRewardManager( OWNER, REWARDS_PCT_BPS, TREASURY diff --git a/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol b/contracts/test/validator-registry/rewards/BlockRewardManagerTest.sol similarity index 90% rename from contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol rename to contracts/test/validator-registry/rewards/BlockRewardManagerTest.sol index 904b9d41c..23a812cb2 100644 --- a/contracts/test/validator-registry/rewards/RewardsManagerV2Test.sol +++ b/contracts/test/validator-registry/rewards/BlockRewardManagerTest.sol @@ -4,12 +4,12 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {RewardsManagerV2} from "../../../contracts/validator-registry/rewards/RewardsManagerV2.sol"; -import {IRewardsManagerV2} from "../../../contracts/interfaces/IRewardsManagerV2.sol"; +import {BlockRewardManager} from "../../../contracts/validator-registry/rewards/BlockRewardManager.sol"; +import {IBlockRewardManager} from "../../../contracts/interfaces/IBlockRewardManager.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -contract RewardsManagerV2Test is Test { - RewardsManagerV2 internal rewardsManager; +contract BlockRewardManagerTest is Test { + BlockRewardManager internal rewardsManager; address internal ownerAddress; address payable internal treasuryAddress; @@ -37,14 +37,14 @@ contract RewardsManagerV2Test is Test { uint256 initialRewardsPctBps = 1500; // 15% - RewardsManagerV2 implementation = new RewardsManagerV2(); + BlockRewardManager implementation = new BlockRewardManager(); bytes memory initData = abi.encodeCall( - RewardsManagerV2.initialize, + BlockRewardManager.initialize, (ownerAddress, initialRewardsPctBps, treasuryAddress) ); address proxy = address(new ERC1967Proxy(address(implementation), initData)); - rewardsManager = RewardsManagerV2(payable(proxy)); + rewardsManager = BlockRewardManager(payable(proxy)); } // initialize @@ -90,7 +90,7 @@ contract RewardsManagerV2Test is Test { assertEq(bpsAfterUpdate, 2000); vm.prank(ownerAddress); - vm.expectRevert(IRewardsManagerV2.RewardsPctTooHigh.selector); + vm.expectRevert(IBlockRewardManager.RewardsPctTooHigh.selector); rewardsManager.setRewardsPctBps(2501); vm.prank(payerOne); @@ -176,20 +176,20 @@ contract RewardsManagerV2Test is Test { // withdraw to treasury only owner function test_WithdrawToTreasury_onlyOwner() public { vm.prank(payerOne); - vm.expectRevert(abi.encodeWithSelector(IRewardsManagerV2.OnlyOwnerOrTreasury.selector)); + vm.expectRevert(abi.encodeWithSelector(IBlockRewardManager.OnlyOwnerOrTreasury.selector)); rewardsManager.withdrawToTreasury(); } function test_setTreasury_revertsIfTreasuryZero() public { vm.prank(ownerAddress); - vm.expectRevert(IRewardsManagerV2.TreasuryIsZero.selector); + vm.expectRevert(IBlockRewardManager.TreasuryIsZero.selector); rewardsManager.setTreasury(payable(address(0))); } // revert when no funds to withdraw function test_WithdrawToTreasury_revertsIfNoFunds() public { vm.prank(ownerAddress); - vm.expectRevert(IRewardsManagerV2.NoFundsToWithdraw.selector); + vm.expectRevert(IBlockRewardManager.NoFundsToWithdraw.selector); rewardsManager.withdrawToTreasury(); } From 5eab8a05594194d934bf6992f7ac020cc929be7a Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 10 Sep 2025 23:24:12 -0400 Subject: [PATCH 12/20] updated README --- .../validator-registry/rewards/README.md | 84 +++++++++++++++---- 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/contracts/contracts/validator-registry/rewards/README.md b/contracts/contracts/validator-registry/rewards/README.md index 35b658adc..a0f3bdc03 100644 --- a/contracts/contracts/validator-registry/rewards/README.md +++ b/contracts/contracts/validator-registry/rewards/README.md @@ -1,25 +1,81 @@ -# Reward Manager +# Block Reward Manager -The reward manager contract allows mev-commit providers (usually L1 block builders) to send mev-boost and/or mev-commit rewards to an L1 smart contract, instead of paying proposers directly. This design enables future use-cases of the mev-commit protocol. +`BlockRewardManager` should be used by mev-commit providers (builders) to pay a validator’s **fee recipient**. -To pay a proposer, the mev-commit provider calls `payProposer` with the reward set as msg.value. `payProposer` only accepts a validator's BLS pubkey as an argument. The reward contract will attempt to map a pubkey to it's associated reward receiver address, checking all three methods of validator opt-in to mev-commit. So long as the provided pubkey is valid and represents a validator who's currently opted-in to mev-commit, a valid receiver address will be found. +## How it works -## What is a receiver address? +- To pay a proposer, call: + ```solidity + payProposer(address payable feeRecipient) + ``` + (funds provided to function via msg.value) +- `feeRecipient` must be the validator’s **execution-layer fee recipient** for the block you’re paying. +- Payment is immediately forwarded to the fee recipient address. If a protocol fee is enabled, a small percentage of payment is reserved in the contract for mev-commit participant rewards. This fee will initially be switched off. -* For vanilla opted-in valiators, the receiver is the address that originally called `stake` -* For symbiotic opted-in validators, the receiver is the operator address -* For eigenlayer opted-in validators, the receiver is the validator's Eigenpod owner +## Usage examples -## Overriding the receiver address +**Foundry (cast):** +```bash +cast send \ + "payProposer(address)" \ + --value --private-key $PK +``` -Receiver addresses have the ability to set an override address which will accumulate or be transferred rewards instead of the receiver address. Custom reward splitting logic can be implemented by the override address. +**Solidity (from a builder integration):** +```solidity +IBlockRewardManager(brm).payProposer{value: reward}(feeRecipient); +``` -It is assumed a receiver address and its override address are the same entity and/or fully trust one another. The ability to set an override address purely exists as a convenience, and for customization/flexibility. -## Auto Claim -Receive addresses have the ability to enable and disable auto-claim. When auto-claim is enabled, rewards will automatically be transferred to the receiver or override address during `payProposer`. If an auto-claim transfer fails, the relevant receiver address may be blacklisted from auto-claim. Auto-claim can only be enabled and disabled by the receiver, NOT its override address. +# Stipend Distributor — Overview -## Manual Claim +`StipendDistributor` pays periodic (e.g., weekly) stipends to operator-defined recipients based on validator-key participation. Operators map their validator BLS pubkeys to payout addresses (“recipients”) and may authorize delegates to claim on their behalf. For more details, see the [design doc](https://www.notion.so/primev/StipendDistributor-Design-2696865efd6f80b2a4f0e6b8fc3ab0c4). -To manually claim rewards, call `claimRewards`. This will transfer all available rewards to the calling address. Note manual claims should be made by the override address if set. If no override address is set, the receiver claims rewards. +## Setting recipients + +- **Global default (applies to all keys unless overridden):** + ```solidity + setOperatorGlobalOverride(address recipient) + ``` + Sets a default recipient for the operator’s keys. + +- **Per-key override (takes precedence over the default):** + ```solidity + overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) + ``` + Assigns a specific recipient for one or more BLS pubkeys (48-byte). + +- **(Optional) Migrate unclaimed accruals between addresses:** + ```solidity + migrateExistingRewards(address from, address to) + ``` + Moves **unclaimed** stipend accrued to `from` over to `to` for the calling operator. + +## Delegation (optional) + +- **Allow a delegate to claim for a given recipient:** + ```solidity + setClaimDelegate(address delegate, address recipient, bool status) + ``` + When `status = true`, `delegate` can claim stipends for the `(operator → recipient)` pair; set `false` to revoke. + +## Rewards & claiming + +1. **Accrual:** Each distribution period (e.g., weekly), stipends are granted to `(operator, recipient)` pairs in proportion to validator-key participation recorded for that period. +2. **Claim by operator (pull to recipients):** + ```solidity + claimRewards(address payable[] calldata recipients) + ``` + Transfers accrued amounts for the listed `recipients` to those addresses. +3. **Claim by delegate (on behalf of operator):** + ```solidity + claimOnbehalfOfOperator(address operator, address payable[] calldata recipients) + ``` + Authorized delegates can trigger transfers for the specified `operator` to the listed `recipients`. + +## Typical flow + +1. Operator sets a **global default** recipient and (optionally) **per-key overrides**. +2. Over each period, keys that participate accrue stipends to their mapped recipients. +3. After the period, the **operator** or an **authorized delegate** calls the claim function to pay out recipients. From 08d554a105228975690c70b8ee578f7f5cd2cffa Mon Sep 17 00:00:00 2001 From: owen-eth Date: Thu, 11 Sep 2025 14:19:08 -0400 Subject: [PATCH 13/20] Sanity check grantStipends and minor README update --- contracts/contracts/interfaces/IStipendDistributor.sol | 1 + contracts/contracts/validator-registry/rewards/README.md | 9 +-------- .../validator-registry/rewards/StipendDistributor.sol | 3 +++ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/contracts/contracts/interfaces/IStipendDistributor.sol b/contracts/contracts/interfaces/IStipendDistributor.sol index 54627249c..6a7f0aeeb 100644 --- a/contracts/contracts/interfaces/IStipendDistributor.sol +++ b/contracts/contracts/interfaces/IStipendDistributor.sol @@ -44,6 +44,7 @@ interface IStipendDistributor { error LengthMismatch(); error NoClaimableRewards(address recipient); error RewardsTransferFailed(address recipient); + error IncorrectPaymentAmount(uint256 received, uint256 expected); // -------- Externals -------- /// @notice Initialize the proxy. diff --git a/contracts/contracts/validator-registry/rewards/README.md b/contracts/contracts/validator-registry/rewards/README.md index a0f3bdc03..b82c78f10 100644 --- a/contracts/contracts/validator-registry/rewards/README.md +++ b/contracts/contracts/validator-registry/rewards/README.md @@ -12,14 +12,7 @@ - `feeRecipient` must be the validator’s **execution-layer fee recipient** for the block you’re paying. - Payment is immediately forwarded to the fee recipient address. If a protocol fee is enabled, a small percentage of payment is reserved in the contract for mev-commit participant rewards. This fee will initially be switched off. -## Usage examples - -**Foundry (cast):** -```bash -cast send \ - "payProposer(address)" \ - --value --private-key $PK -``` +## Usage example **Solidity (from a builder integration):** ```solidity diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index a6afb9319..b45d56492 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -49,10 +49,13 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @param stipends Array of stipends. function grantStipends(Stipend[] calldata stipends) external payable nonReentrant whenNotPaused onlyOwnerOrStipendManager { uint256 len = stipends.length; + uint256 totalAmount = 0; for (uint256 i = 0; i < len; ++i) { + totalAmount += stipends[i].amount; accrued[stipends[i].operator][stipends[i].recipient] += stipends[i].amount; emit StipendsGranted(stipends[i].operator, stipends[i].recipient, stipends[i].amount); } + require(msg.value == totalAmount, IncorrectPaymentAmount(msg.value, totalAmount)); } /// @notice Allows an operator to claim their rewards for specified recipients. From c7d613b9e9ec4f850474c01c9b1054dda9ea5dee Mon Sep 17 00:00:00 2001 From: owen-eth Date: Thu, 11 Sep 2025 19:57:25 -0400 Subject: [PATCH 14/20] cleaner migrateRewards(), more sanity checks --- contracts/contracts/interfaces/IBlockRewardManager.sol | 1 + .../validator-registry/rewards/BlockRewardManager.sol | 3 ++- .../validator-registry/rewards/StipendDistributor.sol | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/contracts/contracts/interfaces/IBlockRewardManager.sol b/contracts/contracts/interfaces/IBlockRewardManager.sol index 849cd498d..9e22f568f 100644 --- a/contracts/contracts/interfaces/IBlockRewardManager.sol +++ b/contracts/contracts/interfaces/IBlockRewardManager.sol @@ -23,6 +23,7 @@ interface IBlockRewardManager { error TreasuryIsZero(); error NoFundsToWithdraw(); error ProposerTransferFailed(address feeRecipient, uint256 amount); + error TreasuryTransferFailed(address treasury, uint256 amount); /// @notice Builders/relays call this to route EL rewards *through* this contract. function payProposer(address payable feeRecipient) external payable; diff --git a/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol b/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol index 1413cf8e8..8adf10433 100644 --- a/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol +++ b/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol @@ -65,7 +65,8 @@ contract BlockRewardManager is require(toTreasury > 0, NoFundsToWithdraw()); uint256 treasuryAmt = toTreasury; toTreasury = 0; - treasury.call{value: treasuryAmt}(""); //Treasury will not revert + (bool success, ) = treasury.call{value: treasuryAmt}(""); //Treasury will not revert + require(success, TreasuryTransferFailed(treasury, treasuryAmt)); //revert if transfer fails emit TreasuryWithdrawn(treasuryAmt); } diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index b45d56492..afa5d6897 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -109,7 +109,8 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, uint256 claimableAmt = accrued[msg.sender][from] - claimed[msg.sender][from]; require(claimableAmt > 0, NoClaimableRewards(from)); require(to != address(0), ZeroAddress()); - claimed[msg.sender][from] += claimableAmt; + require(to != from, InvalidRecipient()); + accrued[msg.sender][from] -= claimableAmt; accrued[msg.sender][to] += claimableAmt; emit RewardsMigrated(from, to, claimableAmt); } From e22e7081cf0cea44c284d06fccdfe6e5f3789dde Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 12 Sep 2025 09:47:24 -0400 Subject: [PATCH 15/20] Minor Readme changes, comment in BlockRewardsManager, removed mainnet deployment script placeholders --- .../validator-registry/rewards/BlockRewardManager.sol | 1 + contracts/contracts/validator-registry/rewards/README.md | 7 +++++-- .../rewards/DeployBlockRewardManager.s.sol | 9 ++------- .../rewards/DeployStipendDistributor.s.sol | 8 ++------ 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol b/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol index 8adf10433..dcbaf971d 100644 --- a/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol +++ b/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol @@ -47,6 +47,7 @@ contract BlockRewardManager is function payProposer(address payable feeRecipient) external payable { uint256 totalAmt = msg.value; uint256 bps = rewardsPctBps; + //two paths here for gas savings if (bps == 0) { (bool success, ) = feeRecipient.call{value: totalAmt}(""); require(success, ProposerTransferFailed(feeRecipient, totalAmt)); //revert if transfer fails diff --git a/contracts/contracts/validator-registry/rewards/README.md b/contracts/contracts/validator-registry/rewards/README.md index b82c78f10..6117e0620 100644 --- a/contracts/contracts/validator-registry/rewards/README.md +++ b/contracts/contracts/validator-registry/rewards/README.md @@ -23,7 +23,9 @@ IBlockRewardManager(brm).payProposer{value: reward}(feeRecipient); # Stipend Distributor — Overview -`StipendDistributor` pays periodic (e.g., weekly) stipends to operator-defined recipients based on validator-key participation. Operators map their validator BLS pubkeys to payout addresses (“recipients”) and may authorize delegates to claim on their behalf. For more details, see the [design doc](https://www.notion.so/primev/StipendDistributor-Design-2696865efd6f80b2a4f0e6b8fc3ab0c4). +For futher details, see the [Stipend Distributor Design Doc](https://www.notion.so/primev/StipendDistributor-Design-2696865efd6f80b2a4f0e6b8fc3ab0c4). + +`StipendDistributor` pays periodic (e.g., weekly) stipends to operator-defined recipients based on validator-key participation. Operators map their validator BLS pubkeys to payout addresses (“recipients”) and may authorize delegates to claim on their behalf. ## Setting recipients @@ -55,7 +57,8 @@ IBlockRewardManager(brm).payProposer{value: reward}(feeRecipient); ## Rewards & claiming -1. **Accrual:** Each distribution period (e.g., weekly), stipends are granted to `(operator, recipient)` pairs in proportion to validator-key participation recorded for that period. +1. **Accrual:** StipendManager service monitors blocks won by mev-commit registered validators, resolves the operator’s recipient for the pubkey via `StipendDistributor.getKeyRecipient(operator, pubkey)`, and adds it to the operator/recipient pair’s cumulative stipend rewards (off chain). At the end of the week, the service grants a array of stipends to each operator-recipient combo, with each stipend representing the total stipend rewards earned by that operator/recipient pair. + 2. **Claim by operator (pull to recipients):** ```solidity claimRewards(address payable[] calldata recipients) diff --git a/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol b/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol index 87cededbe..0f490d156 100644 --- a/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol +++ b/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol @@ -34,18 +34,13 @@ contract BaseDeploy is Script { contract DeployMainnet is BaseDeploy { address constant public OWNER = MainnetConstants.PRIMEV_TEAM_MULTISIG; - address constant public TREASURY = MainnetConstants.PRIMEV_TEAM_MULTISIG; + //address public TREASURY; uint256 constant public REWARDS_PCT_BPS = 0; function run() external { require(block.chainid == 1, "must deploy on mainnet"); vm.startBroadcast(); - - deployBlockRewardManager( - OWNER, - REWARDS_PCT_BPS, - TREASURY - ); + //deploy call here vm.stopBroadcast(); } } diff --git a/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol b/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol index c3d980cda..b319c043e 100644 --- a/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol +++ b/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol @@ -33,16 +33,12 @@ contract BaseDeploy is Script { contract DeployMainnet is BaseDeploy { address constant public OWNER = MainnetConstants.PRIMEV_TEAM_MULTISIG; - address constant public STIPEND_MANAGER = MainnetConstants.PRIMEV_TEAM_MULTISIG; + // address constant public STIPEND_MANAGER function run() external { require(block.chainid == 1, "must deploy on mainnet"); vm.startBroadcast(); - - deployStipendDistributor( - OWNER, - STIPEND_MANAGER - ); + //deploy call here vm.stopBroadcast(); } } From fce65c1af64dfb6ce3a37f2b36cc424486c10e57 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 12 Sep 2025 09:53:54 -0400 Subject: [PATCH 16/20] updated abis --- contracts-abi/abi/BlockRewardManager.abi | 16 ++++++++++++++++ contracts-abi/abi/StipendDistributor.abi | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/contracts-abi/abi/BlockRewardManager.abi b/contracts-abi/abi/BlockRewardManager.abi index 31393d642..aeba0abee 100644 --- a/contracts-abi/abi/BlockRewardManager.abi +++ b/contracts-abi/abi/BlockRewardManager.abi @@ -460,6 +460,22 @@ "name": "TreasuryIsZero", "inputs": [] }, + { + "type": "error", + "name": "TreasuryTransferFailed", + "inputs": [ + { + "name": "treasury", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "UUPSUnauthorizedCallContext", diff --git a/contracts-abi/abi/StipendDistributor.abi b/contracts-abi/abi/StipendDistributor.abi index c9952ca07..5507ba7a4 100644 --- a/contracts-abi/abi/StipendDistributor.abi +++ b/contracts-abi/abi/StipendDistributor.abi @@ -776,6 +776,22 @@ "name": "FailedInnerCall", "inputs": [] }, + { + "type": "error", + "name": "IncorrectPaymentAmount", + "inputs": [ + { + "name": "received", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "InvalidBLSPubKeyLength", From 9cf438157cc718a3ee4e5316d0882aa27c7e50cf Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 12 Sep 2025 11:26:00 -0400 Subject: [PATCH 17/20] update BlockRewardManager mainnet script --- .../rewards/DeployBlockRewardManager.s.sol | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol b/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol index 0f490d156..2be2fc2d0 100644 --- a/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol +++ b/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol @@ -34,13 +34,18 @@ contract BaseDeploy is Script { contract DeployMainnet is BaseDeploy { address constant public OWNER = MainnetConstants.PRIMEV_TEAM_MULTISIG; - //address public TREASURY; + address constant public TREASURY = MainnetConstants.COMMITMENT_HOLDINGS_MULTISIG; uint256 constant public REWARDS_PCT_BPS = 0; function run() external { require(block.chainid == 1, "must deploy on mainnet"); vm.startBroadcast(); - //deploy call here + + deployBlockRewardManager( + OWNER, + REWARDS_PCT_BPS, + TREASURY + ); vm.stopBroadcast(); } } From 624af7b2bfde10d48e8c766bf45997257da1b9c7 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 12 Sep 2025 15:53:22 -0400 Subject: [PATCH 18/20] added owner migration to StipendDistributor --- contracts-abi/abi/StipendDistributor.abi | 52 +++++- .../interfaces/IStipendDistributor.sol | 8 +- .../rewards/StipendDistributor.sol | 33 +++- .../rewards/StipendDistributorTest.sol | 154 ++++++++++++++++-- 4 files changed, 221 insertions(+), 26 deletions(-) diff --git a/contracts-abi/abi/StipendDistributor.abi b/contracts-abi/abi/StipendDistributor.abi index 5507ba7a4..8d6244837 100644 --- a/contracts-abi/abi/StipendDistributor.abi +++ b/contracts-abi/abi/StipendDistributor.abi @@ -97,7 +97,7 @@ { "name": "recipients", "type": "address[]", - "internalType": "address payable[]" + "internalType": "address[]" } ], "outputs": [], @@ -110,7 +110,7 @@ { "name": "recipients", "type": "address[]", - "internalType": "address payable[]" + "internalType": "address[]" } ], "outputs": [], @@ -374,6 +374,24 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "reclaimGrantsToOwner", + "inputs": [ + { + "name": "operators", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "recipients", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "renounceOwnership", @@ -708,6 +726,31 @@ ], "anonymous": false }, + { + "type": "event", + "name": "StipendsReclaimed", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "Unpaused", @@ -836,6 +879,11 @@ "type": "error", "name": "NoClaimableRewards", "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, { "name": "recipient", "type": "address", diff --git a/contracts/contracts/interfaces/IStipendDistributor.sol b/contracts/contracts/interfaces/IStipendDistributor.sol index 6a7f0aeeb..015c4da17 100644 --- a/contracts/contracts/interfaces/IStipendDistributor.sol +++ b/contracts/contracts/interfaces/IStipendDistributor.sol @@ -33,6 +33,8 @@ interface IStipendDistributor { /// @dev Emitted when accrued rewards are migrated from one recipient to another for an operator. event RewardsMigrated(address indexed from, address indexed to, uint256 amount); + /// @dev Emitted when accrued rewards are reclaimed by the owner. + event StipendsReclaimed(address indexed operator, address indexed recipient, uint256 amount); // -------- Errors -------- error NotOwnerOrStipendManager(); @@ -42,7 +44,7 @@ interface IStipendDistributor { error InvalidOperator(); error InvalidClaimDelegate(); error LengthMismatch(); - error NoClaimableRewards(address recipient); + error NoClaimableRewards(address operator, address recipient); error RewardsTransferFailed(address recipient); error IncorrectPaymentAmount(uint256 received, uint256 expected); @@ -53,10 +55,10 @@ interface IStipendDistributor { function grantStipends(Stipend[] calldata stipends) external payable; /// @notice Claim rewards for the caller (as operator) to specific recipients. - function claimRewards(address payable[] calldata recipients) external; + function claimRewards(address[] calldata recipients) external; /// @notice Claim rewards on behalf of an operator to specific recipients (must be delegated). - function claimOnbehalfOfOperator(address operator, address payable[] calldata recipients) external; + function claimOnbehalfOfOperator(address operator, address[] calldata recipients) external; /// @notice Override recipient for a list of BLS pubkeys in a registry. function overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) external; diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol index afa5d6897..f9886f890 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/StipendDistributor.sol @@ -59,13 +59,13 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } /// @notice Allows an operator to claim their rewards for specified recipients. - function claimRewards(address payable[] calldata recipients) external whenNotPaused nonReentrant { + function claimRewards(address[] calldata recipients) external whenNotPaused nonReentrant { _claimRewards(msg.sender, recipients); } /// @notice Claims rewards accrued by an operator to a specific recipient. Must be authorized by the specified operator. /// @dev Caller must be an authorized delegate for every (operator → recipient) pair. - function claimOnbehalfOfOperator(address operator, address payable[] calldata recipients) external whenNotPaused nonReentrant { + function claimOnbehalfOfOperator(address operator, address[] calldata recipients) external whenNotPaused nonReentrant { uint256 len = recipients.length; for (uint256 i = 0; i < len; ++i) { require(claimDelegate[operator][recipients[i]][msg.sender], InvalidClaimDelegate()); @@ -106,15 +106,34 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @dev Allows an operator to migrate unclaimed recipient rewards to a different address. function migrateExistingRewards(address from, address to) external whenNotPaused nonReentrant { - uint256 claimableAmt = accrued[msg.sender][from] - claimed[msg.sender][from]; - require(claimableAmt > 0, NoClaimableRewards(from)); + uint256 claimableAmt = getPendingRewards(msg.sender, from); + require(claimableAmt > 0, NoClaimableRewards(msg.sender, from)); require(to != address(0), ZeroAddress()); require(to != from, InvalidRecipient()); accrued[msg.sender][from] -= claimableAmt; accrued[msg.sender][to] += claimableAmt; emit RewardsMigrated(from, to, claimableAmt); } - + + /// @dev Allows the owner to reclaim stipends that were incorrectly granted or unable to be claimed by an operator. + function reclaimGrantsToOwner(address[] calldata operators, address[] calldata recipients) external onlyOwner { + address _owner = owner(); + uint256 toWithdraw = 0; + uint256 len = operators.length; + require(len == recipients.length, LengthMismatch()); + for (uint256 i = 0; i < len; ++i) { + address operator = operators[i]; + address recipient = recipients[i]; + uint256 claimableAmt = getPendingRewards(operator, recipient); + accrued[operator][recipient] -= claimableAmt; + toWithdraw += claimableAmt; + emit StipendsReclaimed(operator, recipient, claimableAmt); + } + require(toWithdraw > 0, NoClaimableRewards(_owner, _owner)); + (bool success, ) = payable(_owner).call{value: toWithdraw}(""); + require(success, RewardsTransferFailed(_owner)); + } + /// @dev Enables the owner to pause the contract. function pause() external onlyOwner { _pause(); @@ -155,7 +174,7 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, function _authorizeUpgrade(address) internal override onlyOwner {} /// @dev Allows a reward recipient to claim their rewards. - function _claimRewards(address operator, address payable[] calldata recipients) internal { + function _claimRewards(address operator, address[] calldata recipients) internal { require(operator != address(0), InvalidOperator()); uint256 len = recipients.length; uint256[] memory claimAmounts = new uint256[](len); @@ -167,7 +186,7 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, for (uint256 i = 0; i < len; ++i) { address recipient = recipients[i]; if (claimAmounts[i] > 0) { - (bool success, ) = recipient.call{value: claimAmounts[i]}(""); + (bool success, ) = payable(recipient).call{value: claimAmounts[i]}(""); require(success, RewardsTransferFailed(recipient)); emit RewardsClaimed(operator, recipient, claimAmounts[i]); } diff --git a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol index bb2540039..144c3378d 100644 --- a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol +++ b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol @@ -36,6 +36,8 @@ contract StipendDistributorTest is Test { event StipendsGranted(address indexed operator, address indexed recipient, uint256 amount); event RewardsClaimed(address indexed operator, address indexed recipient, uint256 amount); event OperatorGlobalOverrideSet(address indexed operator, address indexed recipient); + event StipendsReclaimed(address indexed operator, address indexed recipient, uint256 amount); + // setup: deploy registries + distributor and fund stipendManager for payable calls function setUp() public { @@ -152,8 +154,8 @@ contract StipendDistributorTest is Test { _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); // operator1 claims 2e for recipient2 - address payable[] memory toClaim = new address payable[](1); - toClaim[0] = payable(recipient2); + address[] memory toClaim = new address[](1); + toClaim[0] = recipient2; uint256 r2Before = recipient2.balance; vm.prank(operator1); distributor.claimRewards(toClaim); @@ -164,8 +166,8 @@ contract StipendDistributorTest is Test { distributor.setClaimDelegate(delegate1, recipient1, true); // delegate claims 1e for recipient1 - address payable[] memory one = new address payable[](1); - one[0] = payable(recipient1); + address[] memory one = new address[](1); + one[0] = recipient1; uint256 r1Before = recipient1.balance; vm.prank(delegate1); distributor.claimOnbehalfOfOperator(operator1, one); @@ -176,8 +178,8 @@ contract StipendDistributorTest is Test { function test_ClaimOnBehalf_unauthorized_reverts() public { _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); - address payable[] memory ask = new address payable[](1); - ask[0] = payable(recipient3); + address[] memory ask = new address[](1); + ask[0] = recipient3; // operator2 tries to claim as if for operator1 → revert vm.expectRevert(); @@ -197,8 +199,8 @@ contract StipendDistributorTest is Test { assertEq(distributor.accrued(operator1, recipient1), 1 ether); // claim pays 1e - address payable[] memory list = new address payable[](1); - list[0] = payable(recipient1); + address[] memory list = new address[](1); + list[0] = recipient1; uint256 before = recipient1.balance; vm.prank(operator1); distributor.claimRewards(list); @@ -287,8 +289,8 @@ contract StipendDistributorTest is Test { vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); distributor.grantStipends{value: stipends[0].amount}(stipends); - address payable[] memory list = new address payable[](1); - list[0] = payable(recipient1); + address[] memory list = new address[](1); + list[0] = recipient1; vm.prank(operator1); vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); distributor.claimRewards(list); @@ -319,8 +321,8 @@ contract StipendDistributorTest is Test { distributor.grantStipends{value: stipends[0].amount}(stipends); // claim once → paid exactly once; inner call blocked by nonReentrant - address payable[] memory list = new address payable[](1); - list[0] = payable(address(attacker)); + address[] memory list = new address[](1); + list[0] = address(attacker); uint256 before = address(attacker).balance; vm.prank(operator1); distributor.claimRewards(list); @@ -364,7 +366,7 @@ contract StipendDistributorTest is Test { function test_ClaimRewards_wrongOperator_reverts() public { _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); - address payable[] memory list = new address payable[](1); + address[] memory list = new address[](1); list[0] = payable(recipient2); uint256 before = recipient2.balance; @@ -393,7 +395,7 @@ contract StipendDistributorTest is Test { function test_Claim_batchMultipleRecipients() public { _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); - address payable[] memory list = new address payable[](2); + address[] memory list = new address[](2); list[0] = payable(recipient1); // 1 ether list[1] = payable(recipient2); // 2 ether @@ -406,8 +408,132 @@ contract StipendDistributorTest is Test { assertEq(recipient1.balance, r1Before + 1 ether); assertEq(recipient2.balance, r2Before + 2 ether); } + + // Helper: grant arbitrary pairs in one go (prank as stipendManager and fund it) + function _grantPairs(address[] memory ops, address[] memory recs, uint256[] memory amts) internal { + uint256 len = ops.length; + assertEq(len, recs.length, "setup: length mismatch"); + assertEq(len, amts.length, "setup: length mismatch"); + + IStipendDistributor.Stipend[] memory s = new IStipendDistributor.Stipend[](len); + uint256 total = 0; + for (uint256 i = 0; i < len; ++i) { + s[i] = IStipendDistributor.Stipend({ operator: ops[i], recipient: recs[i], amount: amts[i] }); + total += amts[i]; + } + + vm.deal(stipendManager, stipendManager.balance + total); + vm.prank(stipendManager); + distributor.grantStipends{value: total}(s); + } + + function test_reclaimGrantsToOwner() public { + address opA = makeAddr("opA"); + address rcA = makeAddr("rcA"); + address opB = makeAddr("opB"); + address rcB = makeAddr("rcB"); + + address[] memory ops = new address[](2); + address[] memory recs = new address[](2); + uint256[] memory amts = new uint256[](2); + ops[0] = opA; recs[0] = rcA; amts[0] = 5 ether; + ops[1] = opB; recs[1] = rcB; amts[1] = 7 ether; + + _grantPairs(ops, recs, amts); + + address ownerAddr = distributor.owner(); + uint256 ownerBefore = ownerAddr.balance; + uint256 contractBefore = address(distributor).balance; + + // (optional) check events + vm.expectEmit(true, true, false, true); + emit StipendsReclaimed(opA, rcA, 5 ether); + vm.expectEmit(true, true, false, true); + emit StipendsReclaimed(opB, rcB, 7 ether); + + vm.prank(ownerAddr); + distributor.reclaimGrantsToOwner(ops, recs); + + assertEq(ownerAddr.balance, ownerBefore + 12 ether, "owner did not receive reclaimed ETH"); + assertEq(address(distributor).balance, contractBefore - 12 ether, "contract balance mismatch"); + assertEq(distributor.getPendingRewards(opA, rcA), 0, "pending not cleared for A"); + assertEq(distributor.getPendingRewards(opB, rcB), 0, "pending not cleared for B"); + } + + function test_reclaimGrantsToOwner_RespectsClaimedAndReclaimsOnlyUnclaimed() public { + address operator = makeAddr("op"); + address recipient = makeAddr("rc"); + address ownerAddr = distributor.owner(); + + // ---------- setup: grant #1 (5 ether) ---------- + IStipendDistributor.Stipend[] memory s1 = new IStipendDistributor.Stipend[](1); + s1[0] = IStipendDistributor.Stipend({operator: operator, recipient: recipient, amount: 5 ether}); + + vm.deal(ownerAddr, ownerAddr.balance + 5 ether); + vm.prank(ownerAddr); + distributor.grantStipends{value: 5 ether}(s1); + + // operator fully claims grant #1 + address[] memory recs = new address[](1); + recs[0] = recipient; + uint256 rcBefore = recipient.balance; + uint256 contractBeforeClaim = address(distributor).balance; + + vm.prank(operator); + distributor.claimRewards(recs); + + assertEq(recipient.balance, rcBefore + 5 ether, "recipient did not receive claim #1"); + assertEq(address(distributor).balance, contractBeforeClaim - 5 ether, "contract balance mismatch after claim #1"); + assertEq(distributor.getPendingRewards(operator, recipient), 0, "pending should be zero after claim #1"); + + // ---------- part A: reclaim when fully claimed -> revert & no payout ---------- + uint256 ownerBeforeReclaimA = ownerAddr.balance; + uint256 contractBeforeReclaimA = address(distributor).balance; + + address[] memory opsA = new address[](1); + address[] memory recsA = new address[](1); + opsA[0] = operator; + recsA[0] = recipient; + + vm.prank(ownerAddr); + vm.expectRevert(abi.encodeWithSignature("NoClaimableRewards(address,address)", ownerAddr, ownerAddr)); + distributor.reclaimGrantsToOwner(opsA, recsA); + + // balances unchanged + assertEq(ownerAddr.balance, ownerBeforeReclaimA, "owner balance changed on failed reclaim"); + assertEq(address(distributor).balance, contractBeforeReclaimA, "contract balance changed on failed reclaim"); + + // ---------- grant #2 (9 ether) ---------- + IStipendDistributor.Stipend[] memory s2 = new IStipendDistributor.Stipend[](1); + s2[0] = IStipendDistributor.Stipend({operator: operator, recipient: recipient, amount: 9 ether}); + + vm.deal(ownerAddr, ownerAddr.balance + 9 ether); + vm.prank(ownerAddr); + distributor.grantStipends{value: 9 ether}(s2); + + assertEq(distributor.getPendingRewards(operator, recipient), 9 ether, "pending should equal grant #2"); + + // ---------- part B: reclaim pulls only unclaimed (9 ether) ---------- + uint256 ownerBeforeReclaimB = ownerAddr.balance; + uint256 contractBeforeReclaimB = address(distributor).balance; + + address[] memory opsB = new address[](1); + address[] memory recsB = new address[](1); + opsB[0] = operator; + recsB[0] = recipient; + + vm.prank(ownerAddr); + distributor.reclaimGrantsToOwner(opsB, recsB); + + assertEq(ownerAddr.balance, ownerBeforeReclaimB + 9 ether, "owner did not receive only unclaimed amount"); + assertEq(address(distributor).balance, contractBeforeReclaimB - 9 ether, "contract balance mismatch after reclaim"); + assertEq(distributor.getPendingRewards(operator, recipient), 0, "pending should be zero after reclaim"); + } + } + + // recipient that attempts to re-enter claimRewards during payout contract ReenteringRecipient { fallback() external payable { From 2f2f4a1e39732f697ea42ac062617769f25e1bdc Mon Sep 17 00:00:00 2001 From: owen-eth Date: Sat, 13 Sep 2025 03:28:35 -0400 Subject: [PATCH 19/20] renamed stipenddistributor to rewarddistributor, added erc20 distribution support --- ...dDistributor.abi => RewardDistributor.abi} | 347 ++++++-- contracts-abi/script.sh | 6 +- ...Distributor.sol => IRewardDistributor.sol} | 53 +- .../validator-registry/rewards/README.md | 141 ++- ...dDistributor.sol => RewardDistributor.sol} | 139 ++- ...orage.sol => RewardDistributorStorage.sol} | 19 +- contracts/l1-deployer-cli.sh | 32 +- ...or.s.sol => DeployRewardDistributor.s.sol} | 28 +- .../rewards/RewardDistributorTest.sol | 835 ++++++++++++++++++ .../rewards/StipendDistributorTest.sol | 550 ------------ 10 files changed, 1377 insertions(+), 773 deletions(-) rename contracts-abi/abi/{StipendDistributor.abi => RewardDistributor.abi} (79%) rename contracts/contracts/interfaces/{IStipendDistributor.sol => IRewardDistributor.sol} (56%) rename contracts/contracts/validator-registry/rewards/{StipendDistributor.sol => RewardDistributor.sol} (53%) rename contracts/contracts/validator-registry/rewards/{StipendDistributorStorage.sol => RewardDistributorStorage.sol} (57%) rename contracts/scripts/validator-registry/rewards/{DeployStipendDistributor.s.sol => DeployRewardDistributor.s.sol} (59%) create mode 100644 contracts/test/validator-registry/rewards/RewardDistributorTest.sol delete mode 100644 contracts/test/validator-registry/rewards/StipendDistributorTest.sol diff --git a/contracts-abi/abi/StipendDistributor.abi b/contracts-abi/abi/RewardDistributor.abi similarity index 79% rename from contracts-abi/abi/StipendDistributor.abi rename to contracts-abi/abi/RewardDistributor.abi index 8d6244837..604e61095 100644 --- a/contracts-abi/abi/StipendDistributor.abi +++ b/contracts-abi/abi/RewardDistributor.abi @@ -32,30 +32,6 @@ "outputs": [], "stateMutability": "nonpayable" }, - { - "type": "function", - "name": "accrued", - "inputs": [ - { - "name": "operator", - "type": "address", - "internalType": "address" - }, - { - "name": "recipient", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "claimDelegate", @@ -98,6 +74,11 @@ "name": "recipients", "type": "address[]", "internalType": "address[]" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" } ], "outputs": [], @@ -111,34 +92,15 @@ "name": "recipients", "type": "address[]", "internalType": "address[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "claimed", - "inputs": [ - { - "name": "operator", - "type": "address", - "internalType": "address" }, { - "name": "recipient", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "amount", + "name": "tokenID", "type": "uint256", "internalType": "uint256" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", @@ -177,25 +139,30 @@ "name": "recipient", "type": "address", "internalType": "address" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" } ], "outputs": [ { "name": "", - "type": "uint256", - "internalType": "uint256" + "type": "uint128", + "internalType": "uint128" } ], "stateMutability": "view" }, { "type": "function", - "name": "grantStipends", + "name": "grantETHRewards", "inputs": [ { - "name": "stipends", + "name": "rewardList", "type": "tuple[]", - "internalType": "struct IStipendDistributor.Stipend[]", + "internalType": "struct IRewardDistributor.Distribution[]", "components": [ { "name": "operator", @@ -209,8 +176,8 @@ }, { "name": "amount", - "type": "uint256", - "internalType": "uint256" + "type": "uint128", + "internalType": "uint128" } ] } @@ -218,17 +185,52 @@ "outputs": [], "stateMutability": "payable" }, + { + "type": "function", + "name": "grantTokenRewards", + "inputs": [ + { + "name": "rewardList", + "type": "tuple[]", + "internalType": "struct IRewardDistributor.Distribution[]", + "components": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint128", + "internalType": "uint128" + } + ] + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "payable" + }, { "type": "function", "name": "initialize", "inputs": [ { - "name": "owner", + "name": "_owner", "type": "address", "internalType": "address" }, { - "name": "stipendManager", + "name": "_rewardManager", "type": "address", "internalType": "address" } @@ -249,6 +251,11 @@ "name": "to", "type": "address", "internalType": "address" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" } ], "outputs": [], @@ -376,7 +383,7 @@ }, { "type": "function", - "name": "reclaimGrantsToOwner", + "name": "reclaimStipendsToOwner", "inputs": [ { "name": "operators", @@ -387,6 +394,11 @@ "name": "recipients", "type": "address[]", "internalType": "address[]" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" } ], "outputs": [], @@ -399,6 +411,72 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "rewardData", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "accrued", + "type": "uint128", + "internalType": "uint128" + }, + { + "name": "claimed", + "type": "uint128", + "internalType": "uint128" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "rewardManager", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "rewardTokens", + "inputs": [ + { + "name": "id", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "setClaimDelegate", @@ -437,10 +515,10 @@ }, { "type": "function", - "name": "setStipendManager", + "name": "setRewardManager", "inputs": [ { - "name": "_stipendManager", + "name": "_rewardManager", "type": "address", "internalType": "address" } @@ -450,16 +528,21 @@ }, { "type": "function", - "name": "stipendManager", - "inputs": [], - "outputs": [ + "name": "setRewardToken", + "inputs": [ { - "name": "", + "name": "_rewardToken", "type": "address", "internalType": "address" + }, + { + "name": "_id", + "type": "uint256", + "internalType": "uint256" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", @@ -530,6 +613,56 @@ ], "anonymous": false }, + { + "type": "event", + "name": "ETHGranted", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ETHRewardsClaimed", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "Initialized", @@ -640,24 +773,44 @@ }, { "type": "event", - "name": "RewardsClaimed", + "name": "RewardManagerSet", "inputs": [ { - "name": "operator", + "name": "rewardManager", "type": "address", "indexed": true, "internalType": "address" - }, + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardTokenSet", + "inputs": [ { - "name": "recipient", + "name": "rewardToken", "type": "address", "indexed": true, "internalType": "address" }, + { + "name": "tokenID", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardsBatchGranted", + "inputs": [ { "name": "amount", "type": "uint256", - "indexed": false, + "indexed": true, "internalType": "uint256" } ], @@ -667,6 +820,18 @@ "type": "event", "name": "RewardsMigrated", "inputs": [ + { + "name": "tokenID", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, { "name": "from", "type": "address", @@ -681,29 +846,47 @@ }, { "name": "amount", - "type": "uint256", + "type": "uint128", "indexed": false, - "internalType": "uint256" + "internalType": "uint128" } ], "anonymous": false }, { "type": "event", - "name": "StipendManagerSet", + "name": "RewardsReclaimed", "inputs": [ { - "name": "stipendManager", + "name": "tokenID", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", "type": "address", "indexed": true, "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" } ], "anonymous": false }, { "type": "event", - "name": "StipendsGranted", + "name": "TokenRewardsClaimed", "inputs": [ { "name": "operator", @@ -720,7 +903,7 @@ { "name": "amount", "type": "uint256", - "indexed": false, + "indexed": true, "internalType": "uint256" } ], @@ -728,7 +911,7 @@ }, { "type": "event", - "name": "StipendsReclaimed", + "name": "TokensGranted", "inputs": [ { "name": "operator", @@ -745,7 +928,7 @@ { "name": "amount", "type": "uint256", - "indexed": false, + "indexed": true, "internalType": "uint256" } ], @@ -870,6 +1053,16 @@ "name": "InvalidRecipient", "inputs": [] }, + { + "type": "error", + "name": "InvalidRewardToken", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidTokenID", + "inputs": [] + }, { "type": "error", "name": "LengthMismatch", @@ -898,7 +1091,7 @@ }, { "type": "error", - "name": "NotOwnerOrStipendManager", + "name": "NotOwnerOrRewardManager", "inputs": [] }, { diff --git a/contracts-abi/script.sh b/contracts-abi/script.sh index d84dbd369..0806383fb 100755 --- a/contracts-abi/script.sh +++ b/contracts-abi/script.sh @@ -48,7 +48,7 @@ extract_and_save_abi "$BASE_DIR/out/RewardManager.sol/RewardManager.json" "$ABI_ extract_and_save_abi "$BASE_DIR/out/DepositManager.sol/DepositManager.json" "$ABI_DIR/DepositManager.abi" -extract_and_save_abi "$BASE_DIR/out/StipendDistributor.sol/StipendDistributor.json" "$ABI_DIR/StipendDistributor.abi" +extract_and_save_abi "$BASE_DIR/out/RewardDistributor.sol/RewardDistributor.json" "$ABI_DIR/RewardDistributor.abi" extract_and_save_abi "$BASE_DIR/out/BlockRewardManager.sol/BlockRewardManager.json" "$ABI_DIR/BlockRewardManager.abi" @@ -119,9 +119,9 @@ generate_go_code "$ABI_DIR/RewardManager.abi" "RewardManager" "rewardmanager" generate_go_code "$ABI_DIR/DepositManager.abi" "DepositManager" "depositmanager" -generate_go_code "$ABI_DIR/StipendDistributor.abi" "StipendDistributor" "stipenddistributor" +generate_go_code "$ABI_DIR/RewardDistributor.abi" "RewardDistributor" "rewarddistributor" -generate_go_code "$ABI_DIR/BlockRewardManager.abi" "BlockRewardManager" "BlockRewardManager" +generate_go_code "$ABI_DIR/BlockRewardManager.abi" "BlockRewardManager" "blockrewardmanager" echo "External ABI downloaded and processed successfully." diff --git a/contracts/contracts/interfaces/IStipendDistributor.sol b/contracts/contracts/interfaces/IRewardDistributor.sol similarity index 56% rename from contracts/contracts/interfaces/IStipendDistributor.sol rename to contracts/contracts/interfaces/IRewardDistributor.sol index 015c4da17..3abf04dbf 100644 --- a/contracts/contracts/interfaces/IStipendDistributor.sol +++ b/contracts/contracts/interfaces/IRewardDistributor.sol @@ -3,23 +3,31 @@ pragma solidity 0.8.26; /// @title IStipendDistributor /// @notice Interface for stipend distribution and claims. -interface IStipendDistributor { +interface IRewardDistributor { - struct Stipend { + struct Distribution { address operator; address recipient; - uint256 amount; + uint128 amount; + } + + /// @dev Pack both counters into a single slot for each asset. + struct RewardData { + uint128 accrued; + uint128 claimed; } // -------- Events -------- /// @dev Emitted when the oracle address is updated. - event StipendManagerSet(address indexed stipendManager); + event RewardManagerSet(address indexed rewardManager); /// @dev Emitted when stipends are granted. - event StipendsGranted(address indexed operator, address indexed recipient, uint256 amount); - + event ETHGranted(address indexed operator, address indexed recipient, uint256 indexed amount); + event TokensGranted(address indexed operator, address indexed recipient, uint256 indexed amount); + event RewardsBatchGranted(uint256 indexed amount); /// @dev Emitted when rewards are claimed by a recipient for an operator. - event RewardsClaimed(address indexed operator, address indexed recipient, uint256 amount); + event ETHRewardsClaimed(address indexed operator, address indexed recipient, uint256 indexed amount); + event TokenRewardsClaimed(address indexed operator, address indexed recipient, uint256 indexed amount); /// @dev Emitted when a recipient mapping is overridden for a specific pubkey. event RecipientSet(address indexed operator, bytes pubkey, address indexed recipient); @@ -31,14 +39,19 @@ interface IStipendDistributor { event ClaimDelegateSet(address indexed operator, address indexed recipient, address indexed delegate, bool status); /// @dev Emitted when accrued rewards are migrated from one recipient to another for an operator. - event RewardsMigrated(address indexed from, address indexed to, uint256 amount); + event RewardsMigrated(uint256 tokenID, address indexed operator, address indexed from, address indexed to, uint128 amount); /// @dev Emitted when accrued rewards are reclaimed by the owner. - event StipendsReclaimed(address indexed operator, address indexed recipient, uint256 amount); + event RewardsReclaimed(uint256 indexed tokenID, address indexed operator, address indexed recipient, uint256 amount); + + /// @dev Emitted when the reward token address is updated. + event RewardTokenSet(address indexed rewardToken, uint256 indexed tokenID); // -------- Errors -------- - error NotOwnerOrStipendManager(); + error NotOwnerOrRewardManager(); + error InvalidRewardToken(); error ZeroAddress(); + error InvalidTokenID(); error InvalidBLSPubKeyLength(); error InvalidRecipient(); error InvalidOperator(); @@ -50,15 +63,19 @@ interface IStipendDistributor { // -------- Externals -------- /// @notice Initialize the proxy. - function initialize(address owner, address stipendManager) external; + function initialize(address owner, address rewardManager) external; - function grantStipends(Stipend[] calldata stipends) external payable; + /// @notice Grant ETH rewards to multiple (operator, recipient) pairs. + function grantETHRewards(Distribution[] calldata rewardList) external payable; + + /// @notice Grant token rewards to multiple (operator, recipient) pairs. + function grantTokenRewards(Distribution[] calldata rewardList, uint256 tokenID) external payable; /// @notice Claim rewards for the caller (as operator) to specific recipients. - function claimRewards(address[] calldata recipients) external; + function claimRewards(address[] calldata recipients, uint256 tokenID) external; /// @notice Claim rewards on behalf of an operator to specific recipients (must be delegated). - function claimOnbehalfOfOperator(address operator, address[] calldata recipients) external; + function claimOnbehalfOfOperator(address operator, address[] calldata recipients, uint256 tokenID) external; /// @notice Override recipient for a list of BLS pubkeys in a registry. function overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) external; @@ -70,13 +87,15 @@ interface IStipendDistributor { function setClaimDelegate(address delegate, address recipient, bool status) external; /// @notice Migrate unclaimed rewards from one recipient to another for the caller (operator). - function migrateExistingRewards(address from, address to) external; + function migrateExistingRewards(address from, address to, uint256 tokenID) external; /// @notice Pause / Unpause admin controls. + function reclaimStipendsToOwner(address[] calldata operators, address[] calldata recipients, uint256 tokenID) external; function pause() external; function unpause() external; - function setStipendManager(address _stipendManager) external; + function setRewardManager(address _rewardManager) external; + function setRewardToken(address _rewardToken, uint256 _id) external; function getKeyRecipient(address operator, bytes calldata pubkey) external view returns (address); - function getPendingRewards(address operator, address recipient) external view returns (uint256); + function getPendingRewards(address operator, address recipient, uint256 tokenID) external view returns (uint128); } \ No newline at end of file diff --git a/contracts/contracts/validator-registry/rewards/README.md b/contracts/contracts/validator-registry/rewards/README.md index 6117e0620..6c8fe4219 100644 --- a/contracts/contracts/validator-registry/rewards/README.md +++ b/contracts/contracts/validator-registry/rewards/README.md @@ -21,57 +21,122 @@ IBlockRewardManager(brm).payProposer{value: reward}(feeRecipient); -# Stipend Distributor — Overview +# Reward Distributor — Overview (ETH-focused) -For futher details, see the [Stipend Distributor Design Doc](https://www.notion.so/primev/StipendDistributor-Design-2696865efd6f80b2a4f0e6b8fc3ab0c4). +For further details, see the Reward Distributor Design Doc: https://www.notion.so/primev/RewardDistributor-Design-2696865efd6f80b2a4f0e6b8fc3ab0c4 -`StipendDistributor` pays periodic (e.g., weekly) stipends to operator-defined recipients based on validator-key participation. Operators map their validator BLS pubkeys to payout addresses (“recipients”) and may authorize delegates to claim on their behalf. +`RewardDistributor` tracks and pays **ETH stipends** to operator-defined recipients based on validator-key participation. Operators map their validator BLS pubkeys to payout addresses (“recipients”) and may authorize delegates to claim on their behalf. + +> **Note:** The contract also supports granting **ERC20 token rewards** (single-token per `tokenID`) and will utilize this in the future. In the near term, **ETH (tokenID `0`)** is the primary path. + +--- ## Setting recipients -- **Global default (applies to all keys unless overridden):** - ```solidity - setOperatorGlobalOverride(address recipient) - ``` +- **Global default (applies to all keys unless overridden):** + `setOperatorGlobalOverride(address recipient)` Sets a default recipient for the operator’s keys. -- **Per-key override (takes precedence over the default):** - ```solidity - overrideRecipientByPubkey(bytes[] calldata pubkeys, address recipient) - ``` - Assigns a specific recipient for one or more BLS pubkeys (48-byte). +- **Per-key override (takes precedence over the default):** + `overrideRecipientByPubkey(bytes[] pubkeys, address recipient)` + Assigns a recipient for one or more BLS pubkeys (each must be 48 bytes). + **Precedence:** per-key override → global override → fallback to the operator address. -- **(Optional) Migrate unclaimed accruals between addresses:** - ```solidity - migrateExistingRewards(address from, address to) - ``` - Moves **unclaimed** stipend accrued to `from` over to `to` for the calling operator. +- **Resolve the active recipient for a key:** + `getKeyRecipient(address operator, bytes pubkey) → address` + Returns the payout address considering per-key overrides, global override, or operator fallback. + +- **Migrate unclaimed accruals between recipients (for the calling operator):** + `migrateExistingRewards(address from, address to, uint256 tokenID)` + Moves **unclaimed** rewards for `(msg.sender, from, tokenID)` into `(msg.sender, to, tokenID)`. + Use `tokenID = 0` for ETH; future token IDs correspond to configured ERC20s. + +--- ## Delegation (optional) -- **Allow a delegate to claim for a given recipient:** - ```solidity - setClaimDelegate(address delegate, address recipient, bool status) - ``` - When `status = true`, `delegate` can claim stipends for the `(operator → recipient)` pair; set `false` to revoke. +- **Authorize a delegate to claim for a specific recipient:** + `setClaimDelegate(address delegate, address recipient, bool status)` + When `status = true`, `delegate` may claim for `(operator, recipient)`; set `false` to revoke. + +- **Delegate claim (on behalf of operator):** + `claimOnbehalfOfOperator(address operator, address payable[] recipients, uint256 tokenID)` + Delegate must be authorized **per recipient** by that operator. + +--- + +## Rewards & claiming (ETH-first) + +1. **Accrual off-chain; batched grants on-chain.** + A RewardManager service monitors blocks won by mev-commit–registered validators, resolves recipients via `getKeyRecipient`, and tallies `(operator, recipient)` off-chain. At period end, it submits **batched grants**: + + - **ETH grants (primary path):** + `grantETHRewards(Distribution[] distributions)` + The transaction `msg.value` must equal the sum of `distributions[i].amount`. + + - **Token grants (future use):** + `grantTokenRewards(Distribution[] distributions, uint256 tokenID)` + Pulls tokens from `msg.sender` via `transferFrom`. Requires prior `approve`. + + A `Distribution` item packs: `{operator, recipient, amount}`. + +2. **Claim by operator (pull-to-recipient):** + `claimRewards(address payable[] recipients, uint256 tokenID)` + For each recipient listed, transfers the **full pending** amount for that `(operator, recipient, tokenID)` bucket to the recipient. + - ETH: use `tokenID = 0`. + - Tokens: use the configured nonzero `tokenID` (future use). + +3. **Get pending rewards:** + `getPendingRewards(address operator, address recipient, uint256 tokenID) → uint128` + Computed as `accrued - claimed` for that bucket. + +> **Isolation guarantee:** Balances are **strictly partitioned** by `(operator, recipient, tokenID)`. Multiple operators can share a recipient, but each operator can only claim their own bucket for that recipient. + +--- + +## Reclaim by owner (administrative) + +- **Owner can reclaim accrued rewards to itself:** + `reclaimStipendsToOwner(address[] operators, address[] recipients, uint256 tokenID)` + Sums claimable amounts across the provided pairs, transfers them to the **owner**, and zeroes the accruals. + Requirements: arrays must be equal length; total claimable must be nonzero. + +--- + +## Access control & safety + +- **Grant permissions:** Only the **owner** or the **reward manager** may call `grantETHRewards` / `grantTokenRewards`. +- **Pause:** Owner can `pause()`/`unpause()` to block mutating endpoints (grants, claims, and—if configured—delegation/override changes). +- **Zero-address checks & input validation:** Functions validate parameters (e.g., nonzero addresses, 48-byte pubkeys, registered token IDs, consistent array lengths). + +--- + +## Events (key ones) + +- **ETH grants:** `ETHGranted(address indexed operator, address indexed recipient, uint256 indexed amount)` +- **ETH claims:** `ETHRewardsClaimed(address indexed operator, address indexed recipient, uint256 indexed amount)` +- **Operator Reward Migrations** `RewardsMigrated(uint256 tokenID, address indexed operator, address indexed from, address indexed to, uint128 amount)` + +- **(Future) token grants:** Implementation emits analogous token-grant events and batch totals where applicable. +- **Admin updates:** Events are emitted for reward manager and token registration changes. + +> Event names above match the interface (`IRewardDistributor`) for ETH. Token event names may vary depending on your current implementation; update this section if you finalize them. + +--- + +## Typical ETH stipend flow -## Rewards & claiming +1. Operator sets a **global default** recipient and optional **per-key overrides**. +2. Over the period, off-chain tally adds up ETH stipends per `(operator, recipient)`. +3. After the period, RewardManager calls `grantETHRewards([...])` with the **consolidated totals**. +4. Operator (or an authorized delegate) calls `claimRewards([recipients], 0)` to transfer ETH to each listed recipient. -1. **Accrual:** StipendManager service monitors blocks won by mev-commit registered validators, resolves the operator’s recipient for the pubkey via `StipendDistributor.getKeyRecipient(operator, pubkey)`, and adds it to the operator/recipient pair’s cumulative stipend rewards (off chain). At the end of the week, the service grants a array of stipends to each operator-recipient combo, with each stipend representing the total stipend rewards earned by that operator/recipient pair. +--- -2. **Claim by operator (pull to recipients):** - ```solidity - claimRewards(address payable[] calldata recipients) - ``` - Transfers accrued amounts for the listed `recipients` to those addresses. -3. **Claim by delegate (on behalf of operator):** - ```solidity - claimOnbehalfOfOperator(address operator, address payable[] calldata recipients) - ``` - Authorized delegates can trigger transfers for the specified `operator` to the listed `recipients`. +## Notes on future token support -## Typical flow +- **Registration:** Owner maps an ERC20 to a nonzero `tokenID` via `setRewardToken(address token, uint256 tokenID)`. +- **Grants:** Use `grantTokenRewards(distributions, tokenID)`; caller must hold tokens and `approve` the distributor. +- **Claims:** Same claim APIs as ETH but pass the nonzero `tokenID`. Buckets remain isolated per `tokenID`. -1. Operator sets a **global default** recipient and (optionally) **per-key overrides**. -2. Over each period, keys that participate accrue stipends to their mapped recipients. -3. After the period, the **operator** or an **authorized delegate** calls the claim function to pay out recipients. +--- diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol b/contracts/contracts/validator-registry/rewards/RewardDistributor.sol similarity index 53% rename from contracts/contracts/validator-registry/rewards/StipendDistributor.sol rename to contracts/contracts/validator-registry/rewards/RewardDistributor.sol index f9886f890..bf475dd57 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/RewardDistributor.sol @@ -5,15 +5,16 @@ import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/acces import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; -import {IStipendDistributor} from "../../interfaces/IStipendDistributor.sol"; -import {StipendDistributorStorage} from "./StipendDistributorStorage.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IRewardDistributor} from "../../interfaces/IRewardDistributor.sol"; +import {RewardDistributorStorage} from "./RewardDistributorStorage.sol"; import {Errors} from "../../utils/Errors.sol"; -contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, +contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, Ownable2StepUpgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable, UUPSUpgradeable { - modifier onlyOwnerOrStipendManager() { - require(msg.sender == stipendManager || msg.sender == owner(), NotOwnerOrStipendManager()); + modifier onlyOwnerOrRewardManager() { + require(msg.sender == rewardManager || msg.sender == owner(), NotOwnerOrRewardManager()); _; } @@ -35,42 +36,60 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, /// @dev Initializes the RewardManager contract. function initialize( - address owner, - address stipendManager + address _owner, + address _rewardManager ) external initializer { - __Ownable_init(owner); + __Ownable_init(_owner); __ReentrancyGuard_init(); __Pausable_init(); __UUPSUpgradeable_init(); - _setStipendManager(stipendManager); + _setRewardManager(_rewardManager); } - /// @dev Grant stipends to multiple (operator, recipient) pairs. - /// @param stipends Array of stipends. - function grantStipends(Stipend[] calldata stipends) external payable nonReentrant whenNotPaused onlyOwnerOrStipendManager { - uint256 len = stipends.length; + /// @param rewardList Array of ETH Distributions. + function grantETHRewards(Distribution[] calldata rewardList) external payable nonReentrant whenNotPaused onlyOwnerOrRewardManager { + uint256 len = rewardList.length; uint256 totalAmount = 0; for (uint256 i = 0; i < len; ++i) { - totalAmount += stipends[i].amount; - accrued[stipends[i].operator][stipends[i].recipient] += stipends[i].amount; - emit StipendsGranted(stipends[i].operator, stipends[i].recipient, stipends[i].amount); + totalAmount += rewardList[i].amount; + rewardData[rewardList[i].operator][rewardList[i].recipient][0].accrued += rewardList[i].amount; + emit ETHGranted(rewardList[i].operator, rewardList[i].recipient, rewardList[i].amount); } require(msg.value == totalAmount, IncorrectPaymentAmount(msg.value, totalAmount)); } - /// @notice Allows an operator to claim their rewards for specified recipients. - function claimRewards(address[] calldata recipients) external whenNotPaused nonReentrant { - _claimRewards(msg.sender, recipients); + /// @param rewardList Array of token Distributions. + function grantTokenRewards(Distribution[] calldata rewardList, uint256 tokenID) external payable nonReentrant whenNotPaused onlyOwnerOrRewardManager { + uint256 len = rewardList.length; + uint256 totalAmount = 0; + address rewardToken = rewardTokens[tokenID]; + require(rewardToken != address(0), InvalidRewardToken()); + for (uint256 i = 0; i < len; ++i) { + totalAmount += rewardList[i].amount; + rewardData[rewardList[i].operator][rewardList[i].recipient][tokenID].accrued += rewardList[i].amount; + emit TokensGranted(rewardList[i].operator, rewardList[i].recipient, rewardList[i].amount); + } + emit RewardsBatchGranted(totalAmount); + IERC20(rewardToken).transferFrom(msg.sender, address(this), totalAmount); } - /// @notice Claims rewards accrued by an operator to a specific recipient. Must be authorized by the specified operator. - /// @dev Caller must be an authorized delegate for every (operator → recipient) pair. - function claimOnbehalfOfOperator(address operator, address[] calldata recipients) external whenNotPaused nonReentrant { + /// @notice Claim rewards for the caller (as operator) to specific recipients. + /// @param recipients List of recipients to claim rewards for. + /// @param tokenID The ID of the token to claim rewards for. + function claimRewards(address[] calldata recipients, uint256 tokenID) external whenNotPaused nonReentrant { + _claimRewards(msg.sender, recipients, tokenID); + } + + /// @notice Claim rewards on behalf of an operator to specific recipients (must be delegated). + /// @param operator Operator to claim rewards for. + /// @param recipients List of recipients to claim rewards for. + /// @param tokenID The ID of the token to claim rewards for. + function claimOnbehalfOfOperator(address operator, address[] calldata recipients, uint256 tokenID) external whenNotPaused nonReentrant { uint256 len = recipients.length; for (uint256 i = 0; i < len; ++i) { require(claimDelegate[operator][recipients[i]][msg.sender], InvalidClaimDelegate()); } - _claimRewards(operator, recipients); + _claimRewards(operator, recipients, tokenID); } /// @notice Allows an operator to set the recipient for a list of pubkeys. @@ -105,18 +124,19 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } /// @dev Allows an operator to migrate unclaimed recipient rewards to a different address. - function migrateExistingRewards(address from, address to) external whenNotPaused nonReentrant { - uint256 claimableAmt = getPendingRewards(msg.sender, from); + /// @param tokenID The ID of the token to migrate rewards for. + function migrateExistingRewards(address from, address to, uint256 tokenID) external whenNotPaused nonReentrant { + uint128 claimableAmt = getPendingRewards(msg.sender, from, tokenID); require(claimableAmt > 0, NoClaimableRewards(msg.sender, from)); require(to != address(0), ZeroAddress()); require(to != from, InvalidRecipient()); - accrued[msg.sender][from] -= claimableAmt; - accrued[msg.sender][to] += claimableAmt; - emit RewardsMigrated(from, to, claimableAmt); + rewardData[msg.sender][from][tokenID].accrued -= claimableAmt; + rewardData[msg.sender][to][tokenID].accrued += claimableAmt; + emit RewardsMigrated(tokenID, msg.sender, from, to, claimableAmt); } /// @dev Allows the owner to reclaim stipends that were incorrectly granted or unable to be claimed by an operator. - function reclaimGrantsToOwner(address[] calldata operators, address[] calldata recipients) external onlyOwner { + function reclaimStipendsToOwner(address[] calldata operators, address[] calldata recipients, uint256 tokenID) external onlyOwner { address _owner = owner(); uint256 toWithdraw = 0; uint256 len = operators.length; @@ -124,14 +144,13 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, for (uint256 i = 0; i < len; ++i) { address operator = operators[i]; address recipient = recipients[i]; - uint256 claimableAmt = getPendingRewards(operator, recipient); - accrued[operator][recipient] -= claimableAmt; + uint128 claimableAmt = getPendingRewards(operator, recipient, tokenID); + rewardData[operator][recipient][tokenID].accrued -= claimableAmt; toWithdraw += claimableAmt; - emit StipendsReclaimed(operator, recipient, claimableAmt); + emit RewardsReclaimed(tokenID, operator, recipient, claimableAmt); } require(toWithdraw > 0, NoClaimableRewards(_owner, _owner)); - (bool success, ) = payable(_owner).call{value: toWithdraw}(""); - require(success, RewardsTransferFailed(_owner)); + _transferFunds(_owner, _owner, toWithdraw, tokenID); } /// @dev Enables the owner to pause the contract. @@ -145,8 +164,13 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, } /// @dev Allows the owner to set the stipend manager address. - function setStipendManager(address _stipendManager) external onlyOwner { - _setStipendManager(_stipendManager); + function setRewardManager(address _rewardManager) external onlyOwner { + _setRewardManager(_rewardManager); + } + + /// @dev Allows the owner to set a reward token address for a given id. + function setRewardToken(address _rewardToken, uint256 _id) external onlyOwner { + _setRewardToken(_rewardToken, _id); } // Retreives the recipient for an operator's registered key @@ -166,36 +190,53 @@ contract StipendDistributor is IStipendDistributor, StipendDistributorStorage, return operator; } - function getPendingRewards(address operator, address recipient) public view returns (uint256) { - return accrued[operator][recipient] - claimed[operator][recipient]; + function getPendingRewards(address operator, address recipient, uint256 tokenID) public view returns (uint128) { + return rewardData[operator][recipient][tokenID].accrued - rewardData[operator][recipient][tokenID].claimed; } // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address) internal override onlyOwner {} /// @dev Allows a reward recipient to claim their rewards. - function _claimRewards(address operator, address[] calldata recipients) internal { + function _claimRewards(address operator, address[] calldata recipients, uint256 tokenID) internal { require(operator != address(0), InvalidOperator()); + require(tokenID == 0 || rewardTokens[tokenID] != address(0), InvalidRewardToken()); uint256 len = recipients.length; - uint256[] memory claimAmounts = new uint256[](len); + uint128[] memory claimAmounts = new uint128[](len); for (uint256 i = 0; i < len; ++i) { address recipient = recipients[i]; - claimAmounts[i] = getPendingRewards(operator, recipient); - claimed[operator][recipient] += claimAmounts[i]; + claimAmounts[i] = getPendingRewards(operator, recipient, tokenID); + rewardData[operator][recipient][tokenID].claimed += claimAmounts[i]; } for (uint256 i = 0; i < len; ++i) { address recipient = recipients[i]; if (claimAmounts[i] > 0) { - (bool success, ) = payable(recipient).call{value: claimAmounts[i]}(""); - require(success, RewardsTransferFailed(recipient)); - emit RewardsClaimed(operator, recipient, claimAmounts[i]); + _transferFunds(operator, recipient, claimAmounts[i], tokenID); } } } - function _setStipendManager(address _stipendManager) internal { - require(_stipendManager != address(0), ZeroAddress()); - stipendManager = _stipendManager; - emit StipendManagerSet(_stipendManager); + function _transferFunds(address operator, address recipient, uint256 amount, uint256 tokenID) internal { + if (tokenID == 0) { + (bool success, ) = payable(recipient).call{value: amount}(""); + require(success, RewardsTransferFailed(recipient)); + emit ETHRewardsClaimed(operator, recipient, amount); + } else { + IERC20(rewardTokens[tokenID]).transfer(recipient, amount); + emit TokenRewardsClaimed(operator, recipient, amount); + } + } + + function _setRewardManager(address _rewardManager) internal { + require(_rewardManager != address(0), ZeroAddress()); + rewardManager = _rewardManager; + emit RewardManagerSet(_rewardManager); + } + + function _setRewardToken(address _rewardToken, uint256 _id) internal { + require(_rewardToken != address(0), ZeroAddress()); + require(_id != 0, InvalidTokenID()); + rewardTokens[_id] = _rewardToken; + emit RewardTokenSet(_rewardToken, _id); } } diff --git a/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol b/contracts/contracts/validator-registry/rewards/RewardDistributorStorage.sol similarity index 57% rename from contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol rename to contracts/contracts/validator-registry/rewards/RewardDistributorStorage.sol index 52ae223b5..0f57e9afe 100644 --- a/contracts/contracts/validator-registry/rewards/StipendDistributorStorage.sol +++ b/contracts/contracts/validator-registry/rewards/RewardDistributorStorage.sol @@ -1,25 +1,26 @@ // SPDX-License-Identifier: BSL 1.1 pragma solidity 0.8.26; -/// @title StipendDistributorStorage -/// @notice Storage layout for StipendDistributor -abstract contract StipendDistributorStorage { - /// @dev Address authorized to grant stipends. - address public stipendManager; +import {IRewardDistributor} from "../../interfaces/IRewardDistributor.sol"; + +/// @title RewardDistributorStorage +/// @notice Storage layout for RewardDistributor +abstract contract RewardDistributorStorage { + /// @dev Address authorized to grant ETH and token rewards. + address public rewardManager; + mapping(uint256 id => address token) public rewardTokens; /// @dev Default recipient per operator (used when no pubkey-specific override exists). mapping(address operator => address recipient) public operatorGlobalOverride; - /// @dev Recipient override by BLS pubkey hash (keccak256(pubkey)). mapping(address operator => mapping(bytes32 keyhash => address recipient)) public operatorKeyOverrides; /// @dev Accrued and claimed amounts per (operator, recipient). - mapping(address operator => mapping(address recipient => uint256 amount)) public accrued; - mapping(address operator => mapping(address recipient => uint256 amount)) public claimed; + mapping(address operator => mapping(address recipient => mapping(uint256 tokenID => IRewardDistributor.RewardData))) public rewardData; /// @dev Operator → recipient → delegate → isAuthorized mapping(address operator => mapping(address recipient => mapping(address delegate => bool))) public claimDelegate; // === Storage gap for future upgrades === - uint256[40] private __gap; + uint256[48] private __gap; } \ No newline at end of file diff --git a/contracts/l1-deployer-cli.sh b/contracts/l1-deployer-cli.sh index e261cc99b..cb67c27a3 100755 --- a/contracts/l1-deployer-cli.sh +++ b/contracts/l1-deployer-cli.sh @@ -5,8 +5,8 @@ deploy_vanilla_flag=false deploy_avs_flag=false deploy_middleware_flag=false deploy_router_flag=false -deploy_rewards_flag=false -deploy_stipend_flag=false +deploy_block_rewards_flag=false +deploy_reward_distributor_flag=false skip_release_verification_flag=false resume_flag=false wallet_type="" @@ -26,8 +26,8 @@ help() { echo " deploy-avs Deploy and verify the MevCommitAVS contract to L1." echo " deploy-middleware Deploy and verify the MevCommitMiddleware contract to L1." echo " deploy-router Deploy and verify the ValidatorOptInRouter contract to L1." - echo " deploy-rewards Deploy and verify the BlockRewardManager contract to L1." - echo " deploy-stipend Deploy and verify the StipendDistributor contract to L1." + echo " deploy-block-rewards Deploy and verify the BlockRewardManager contract to L1." + echo " deploy-reward-distributor Deploy and verify the RewardDistributor contract to L1." echo echo "Required Options:" echo " --chain, -c Specify the chain to deploy to ('mainnet', 'holesky', or 'hoodi')." @@ -126,12 +126,12 @@ parse_args() { deploy_router_flag=true shift ;; - deploy-rewards) - deploy_rewards_flag=true + deploy-block-rewards) + deploy_block_rewards_flag=true shift ;; - deploy-stipend) - deploy_stipend_flag=true + deploy-reward-distributor) + deploy_reward_distributor_flag=true shift ;; --chain|-c) @@ -215,7 +215,7 @@ parse_args() { fi commands_specified=0 - for flag in deploy_all_flag deploy_vanilla_flag deploy_avs_flag deploy_middleware_flag deploy_router_flag deploy_rewards_flag deploy_stipend_flag; do + for flag in deploy_all_flag deploy_vanilla_flag deploy_avs_flag deploy_middleware_flag deploy_router_flag deploy_block_rewards_flag deploy_reward_distributor_flag; do if [[ "${!flag}" == true ]]; then ((commands_specified++)) fi @@ -398,12 +398,12 @@ deploy_router() { deploy_contract_generic "scripts/validator-registry/DeployValidatorOptInRouter.s.sol" } -deploy_rewards() { +deploy_block_rewards() { deploy_contract_generic "scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol" } -deploy_stipend() { - deploy_contract_generic "scripts/validator-registry/rewards/DeployStipendDistributor.s.sol" +deploy_reward_distributor() { + deploy_contract_generic "scripts/validator-registry/rewards/DeployRewardDistributor.s.sol" } main() { @@ -429,10 +429,10 @@ main() { deploy_middleware elif [[ "${deploy_router_flag}" == true ]]; then deploy_router - elif [[ "${deploy_rewards_flag}" == true ]]; then - deploy_rewards - elif [[ "${deploy_stipend_flag}" == true ]]; then - deploy_stipend + elif [[ "${deploy_block_rewards_flag}" == true ]]; then + deploy_block_rewards + elif [[ "${deploy_reward_distributor_flag}" == true ]]; then + deploy_reward_distributor else usage fi diff --git a/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol b/contracts/scripts/validator-registry/rewards/DeployRewardDistributor.s.sol similarity index 59% rename from contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol rename to contracts/scripts/validator-registry/rewards/DeployRewardDistributor.s.sol index b319c043e..8d0868936 100644 --- a/contracts/scripts/validator-registry/rewards/DeployStipendDistributor.s.sol +++ b/contracts/scripts/validator-registry/rewards/DeployRewardDistributor.s.sol @@ -8,32 +8,32 @@ pragma solidity 0.8.26; import {Script} from "forge-std/Script.sol"; import {console} from "forge-std/console.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; -import {StipendDistributor} from "../../../contracts/validator-registry/rewards/StipendDistributor.sol"; +import {RewardDistributor} from "../../../contracts/validator-registry/rewards/RewardDistributor.sol"; import {MainnetConstants} from "../../MainnetConstants.sol"; contract BaseDeploy is Script { - function deployStipendDistributor( + function deployRewardDistributor( address owner, - address stipendManager + address rewardManager ) public returns (address) { - console.log("Deploying StipendDistributor on chain:", block.chainid); + console.log("Deploying RewardDistributor on chain:", block.chainid); address proxy = Upgrades.deployUUPSProxy( - "StipendDistributor.sol", + "RewardDistributor.sol", abi.encodeCall( - StipendDistributor.initialize, - (owner, stipendManager) + RewardDistributor.initialize, + (owner, rewardManager) ) ); - console.log("StipendDistributor UUPS proxy deployed to:", address(proxy)); - StipendDistributor stipendDistributor = StipendDistributor(payable(proxy)); - console.log("StipendDistributor owner:", stipendDistributor.owner()); + console.log("RewardDistributor UUPS proxy deployed to:", address(proxy)); + RewardDistributor rewardDistributor = RewardDistributor(payable(proxy)); + console.log("RewardDistributor owner:", rewardDistributor.owner()); return proxy; } } contract DeployMainnet is BaseDeploy { address constant public OWNER = MainnetConstants.PRIMEV_TEAM_MULTISIG; - // address constant public STIPEND_MANAGER + // address constant public REWARD_MANAGER function run() external { require(block.chainid == 1, "must deploy on mainnet"); @@ -45,15 +45,15 @@ contract DeployMainnet is BaseDeploy { contract DeployHoodi is BaseDeploy { address constant public OWNER = 0x1623fE21185c92BB43bD83741E226288B516134a; - address constant public STIPEND_MANAGER = 0x1623fE21185c92BB43bD83741E226288B516134a; + address constant public REWARD_MANAGER = 0x1623fE21185c92BB43bD83741E226288B516134a; function run() external { require(block.chainid == 560048, "must deploy on Hoodi"); vm.startBroadcast(); - deployStipendDistributor( + deployRewardDistributor( OWNER, - STIPEND_MANAGER + REWARD_MANAGER ); vm.stopBroadcast(); } diff --git a/contracts/test/validator-registry/rewards/RewardDistributorTest.sol b/contracts/test/validator-registry/rewards/RewardDistributorTest.sol new file mode 100644 index 000000000..4789125cc --- /dev/null +++ b/contracts/test/validator-registry/rewards/RewardDistributorTest.sol @@ -0,0 +1,835 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {RewardDistributor} from "../../../contracts/validator-registry/rewards/RewardDistributor.sol"; +import {IRewardDistributor} from "../../../contracts/interfaces/IRewardDistributor.sol"; +// Minimal mintable ERC20 for tests +contract ERC20Mintable is IERC20 { + string public name; + string public symbol; + uint8 public immutable decimals = 18; + + uint256 public override totalSupply; + mapping(address => uint256) public override balanceOf; + mapping(address => mapping(address => uint256)) public override allowance; + + constructor(string memory tokenName, string memory tokenSymbol) { + name = tokenName; + symbol = tokenSymbol; + } + + function transfer(address to, uint256 amount) external override returns (bool) { + _move(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external override returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external override returns (bool) { + uint256 allowedAmount = allowance[from][msg.sender]; + require(allowedAmount >= amount, "allowance"); + allowance[from][msg.sender] = allowedAmount - amount; + _move(from, to, amount); + return true; + } + + function mint(address to, uint256 amount) external { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function _move(address from, address to, uint256 amount) internal { + require(balanceOf[from] >= amount, "balance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} + +// Name chosen to match `--match-contract RewardsDistributor` +contract RewardsDistributor is Test { + RewardDistributor internal rewardDistributor; + + // Roles / actors + address internal contractOwner = address(0xA11CE); + address internal rewardManager = address(0xB0B); + address internal operatorAlpha = address(0xA0A); + address internal operatorBeta = address(0xB0B0); + address internal claimDelegateAlpha = address(0xD1); + + // Recipients + address internal recipientOne = address(0x111); + address internal recipientTwo = address(0x222); + address internal recipientThree = address(0x333); + + // Tokens + ERC20Mintable internal rewardTokenOne; // tokenId = 1 + ERC20Mintable internal rewardTokenTwo; // tokenId = 2 + + // 48-byte BLS pubkeys + bytes internal pubkeyOne = hex"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + bytes internal pubkeyTwo = hex"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + // Convenience + function _getPending(address operator, address recipient, uint256 tokenId) internal view returns (uint128) { + return rewardDistributor.getPendingRewards(operator, recipient, tokenId); + } + + function setUp() public { + // Deploy implementation & proxy, then initialize + RewardDistributor implementation = new RewardDistributor(); + bytes memory initializerData = abi.encodeWithSelector( + RewardDistributor.initialize.selector, + contractOwner, + rewardManager + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initializerData); + rewardDistributor = RewardDistributor(payable(address(proxy))); + + // Deploy and register tokens + rewardTokenOne = new ERC20Mintable("TokenOne", "T1"); + rewardTokenTwo = new ERC20Mintable("TokenTwo", "T2"); + + vm.startPrank(contractOwner); + rewardDistributor.setRewardToken(address(rewardTokenOne), 1); + rewardDistributor.setRewardToken(address(rewardTokenTwo), 2); + vm.stopPrank(); + + // Fund manager with ERC20s (pulled during grant) + rewardTokenOne.mint(rewardManager, 1_000 ether); + rewardTokenTwo.mint(rewardManager, 500 ether); + + // Fund ETH + vm.deal(rewardManager, 1_000 ether); + vm.deal(operatorAlpha, 1 ether); + vm.deal(operatorBeta, 1 ether); + vm.deal(contractOwner, 1_000 ether); + } + + // ───────────────────────── Helpers + + function _grantETHRewards(address caller, RewardDistributor.Distribution[] memory distributions) internal { + uint256 totalGrantAmount = 0; + for (uint256 i = 0; i < distributions.length; ++i) { + totalGrantAmount += distributions[i].amount; + } + vm.prank(caller); + rewardDistributor.grantETHRewards{value: totalGrantAmount}(distributions); + } + + function _grantTokenRewards(address caller, RewardDistributor.Distribution[] memory distributions, uint256 tokenId) internal { + uint256 totalGrantAmount = 0; + for (uint256 i = 0; i < distributions.length; ++i) { + totalGrantAmount += distributions[i].amount; + } + vm.startPrank(caller); + IERC20(rewardDistributor.rewardTokens(tokenId)).approve(address(rewardDistributor), totalGrantAmount); + rewardDistributor.grantTokenRewards(distributions, tokenId); + vm.stopPrank(); + } + + function _distribution(address operator, address recipient, uint128 amount) + internal + pure + returns (RewardDistributor.Distribution memory entry) + { + entry.operator = operator; + entry.recipient = recipient; + entry.amount = amount; + } + + // ───────────────────────── Grants: ETH + + function test_grantETHRewards_accruesAndPartitionsByOperatorRecipient() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](3); + distributions[0] = _distribution(operatorAlpha, recipientOne, 10 ether); + distributions[1] = _distribution(operatorAlpha, recipientTwo, 5 ether); + distributions[2] = _distribution(operatorBeta, recipientOne, 7 ether); + + _grantETHRewards(rewardManager, distributions); + + assertEq(_getPending(operatorAlpha, recipientOne, 0), 10 ether); + assertEq(_getPending(operatorAlpha, recipientTwo, 0), 5 ether); + assertEq(_getPending(operatorBeta, recipientOne, 0), 7 ether); + assertEq(_getPending(operatorBeta, recipientTwo, 0), 0); + } + + function test_grantETHRewards_revertsOnMismatchedMsgValue() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + + vm.prank(rewardManager); + vm.expectRevert(); // IncorrectPaymentAmount + rewardDistributor.grantETHRewards{value: 0}(distributions); + } + + function test_grantETHRewards_onlyOwnerOrManager() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + + vm.expectRevert(); // NotOwnerOrRewardManager + rewardDistributor.grantETHRewards{value: 1 ether}(distributions); + + vm.prank(contractOwner); + rewardDistributor.grantETHRewards{value: 1 ether}(distributions); // ok + } + + // ───────────────────────── Grants: Token + + function test_grantTokenRewards_accruesAndPullsFromCaller_tokenId1() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](2); + distributions[0] = _distribution(operatorAlpha, recipientOne, 100 ether); + distributions[1] = _distribution(operatorAlpha, recipientTwo, 50 ether); + + _grantTokenRewards(rewardManager, distributions, 1); + + assertEq(rewardTokenOne.balanceOf(address(rewardDistributor)), 150 ether); + assertEq(_getPending(operatorAlpha, recipientOne, 1), 100 ether); + assertEq(_getPending(operatorAlpha, recipientTwo, 1), 50 ether); + } + + function test_grantTokenRewards_revertsIfTokenNotRegistered() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + + vm.startPrank(rewardManager); + rewardTokenOne.approve(address(rewardDistributor), type(uint256).max); + vm.expectRevert(); // InvalidRewardToken + rewardDistributor.grantTokenRewards(distributions, 9_999); + vm.stopPrank(); + } + + // ───────────────────────── Claims: operator self-claim + + function test_claimRewards_byOperator_ETH_transfersAndZeroesPending() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](2); + distributions[0] = _distribution(operatorAlpha, recipientOne, 2 ether); + distributions[1] = _distribution(operatorAlpha, recipientTwo, 3 ether); + _grantETHRewards(contractOwner, distributions); + + address[] memory recipientsToClaim = new address[](2); + recipientsToClaim[0] = recipientOne; + recipientsToClaim[1] = recipientTwo; + + uint256 recipientOneBalanceBefore = recipientOne.balance; + uint256 recipientTwoBalanceBefore = recipientTwo.balance; + + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 0); + + assertEq(recipientOne.balance, recipientOneBalanceBefore + 2 ether); + assertEq(recipientTwo.balance, recipientTwoBalanceBefore + 3 ether); + assertEq(_getPending(operatorAlpha, recipientOne, 0), 0); + assertEq(_getPending(operatorAlpha, recipientTwo, 0), 0); + } + + function test_claimRewards_byOperator_Token_transfersAndZeroesPending() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](2); + distributions[0] = _distribution(operatorAlpha, recipientOne, 20 ether); + distributions[1] = _distribution(operatorAlpha, recipientTwo, 10 ether); + _grantTokenRewards(rewardManager, distributions, 1); + + uint256 recipientOneTokenBefore = rewardTokenOne.balanceOf(recipientOne); + uint256 recipientTwoTokenBefore = rewardTokenOne.balanceOf(recipientTwo); + + address[] memory recipientsToClaim = new address[](2); + recipientsToClaim[0] = recipientOne; + recipientsToClaim[1] = recipientTwo; + + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 1); + + assertEq(rewardTokenOne.balanceOf(recipientOne), recipientOneTokenBefore + 20 ether); + assertEq(rewardTokenOne.balanceOf(recipientTwo), recipientTwoTokenBefore + 10 ether); + assertEq(_getPending(operatorAlpha, recipientOne, 1), 0); + assertEq(_getPending(operatorAlpha, recipientTwo, 1), 0); + } + + function test_claimRewards_zeroAmountNoop_noRevert() public { + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientThree; + + vm.prank(operatorBeta); + rewardDistributor.claimRewards(recipientsToClaim, 0); // should not revert + } + + // ───────────────────────── Delegated claim + + function test_claimOnBehalf_requiresPerRecipientAuthorization() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + _grantETHRewards(rewardManager, distributions); + + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + + vm.expectRevert(); // InvalidClaimDelegate + vm.prank(claimDelegateAlpha); + rewardDistributor.claimOnbehalfOfOperator(operatorAlpha, recipientsToClaim, 0); + + vm.prank(operatorAlpha); + rewardDistributor.setClaimDelegate(claimDelegateAlpha, recipientOne, true); + + uint256 recipientOneBalanceBefore = recipientOne.balance; + vm.prank(claimDelegateAlpha); + rewardDistributor.claimOnbehalfOfOperator(operatorAlpha, recipientsToClaim, 0); + assertEq(recipientOne.balance, recipientOneBalanceBefore + 1 ether); + + vm.prank(operatorAlpha); + rewardDistributor.setClaimDelegate(claimDelegateAlpha, recipientOne, false); + + vm.expectRevert(); // InvalidClaimDelegate + vm.prank(claimDelegateAlpha); + rewardDistributor.claimOnbehalfOfOperator(operatorAlpha, recipientsToClaim, 0); + } + + // ───────────────────────── Recipient resolution & overrides + + function test_getKeyRecipient_precedence_perKey_over_global_over_operator() public { + // Default fallback: operator itself + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, pubkeyOne), operatorAlpha); + + // Global override + vm.prank(operatorAlpha); + rewardDistributor.setOperatorGlobalOverride(recipientTwo); + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, pubkeyOne), recipientTwo); + + // Per-key override beats global + bytes[] memory pubkeysToOverride = new bytes[](1); + pubkeysToOverride[0] = pubkeyOne; + + vm.prank(operatorAlpha); + rewardDistributor.overrideRecipientByPubkey(pubkeysToOverride, recipientOne); + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, pubkeyOne), recipientOne); + + // Another key still resolves to global + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, pubkeyTwo), recipientTwo); + } + + function test_getKeyRecipient_revertsOnInvalidPubkeyLength() public { + bytes memory invalidLengthPubkey = hex"01"; // not 48 bytes + vm.expectRevert(); // InvalidBLSPubKeyLength + rewardDistributor.getKeyRecipient(operatorAlpha, invalidLengthPubkey); + } + + // ───────────────────────── Admin & pause + + function test_onlyOwner_canSetRewardToken_andRejectsZeroAddressAndTokenIdZero() public { + vm.expectRevert(); // onlyOwner + rewardDistributor.setRewardToken(address(rewardTokenOne), 9); + + vm.startPrank(contractOwner); + vm.expectRevert(); // ZeroAddress + rewardDistributor.setRewardToken(address(0), 4); + vm.expectRevert(); // InvalidTokenID + rewardDistributor.setRewardToken(address(rewardTokenOne), 0); + vm.stopPrank(); + } + + function test_onlyOwner_canSetRewardManager() public { + address newRewardManager = address(0xDEAD); + + vm.expectRevert(); // onlyOwner + rewardDistributor.setRewardManager(newRewardManager); + + vm.startPrank(contractOwner); + vm.expectRevert(); // ZeroAddress + rewardDistributor.setRewardManager(address(0)); + rewardDistributor.setRewardManager(newRewardManager); + vm.stopPrank(); + } + + function test_pause_blocksMutatingEndpoints_unpauseRestores() public { + vm.prank(contractOwner); + rewardDistributor.pause(); + + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + + vm.expectRevert(); vm.prank(rewardManager); + rewardDistributor.grantETHRewards{value: 1 ether}(distributions); + + vm.expectRevert(); vm.prank(rewardManager); + rewardDistributor.grantTokenRewards(distributions, 1); + + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + + vm.expectRevert(); vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 0); + + vm.prank(contractOwner); + rewardDistributor.unpause(); + + vm.prank(rewardManager); + rewardDistributor.grantETHRewards{value: 1 ether}(distributions); // ok after unpause + } + + // ───────────────────────── Multi-token sanity + + function test_secondToken_tokenId2_isIndependentOfTokenId1() public { + // Grant tokenId 1 + RewardDistributor.Distribution[] memory distributionsToken1 = new RewardDistributor.Distribution[](1); + distributionsToken1[0] = _distribution(operatorAlpha, recipientOne, 5 ether); + _grantTokenRewards(rewardManager, distributionsToken1, 1); + + // Grant tokenId 2 + RewardDistributor.Distribution[] memory distributionsToken2 = new RewardDistributor.Distribution[](2); + distributionsToken2[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + distributionsToken2[1] = _distribution(operatorAlpha, recipientTwo, 2 ether); + _grantTokenRewards(rewardManager, distributionsToken2, 2); + + // Pending are independent per token + assertEq(_getPending(operatorAlpha, recipientOne, 1), 5 ether); + assertEq(_getPending(operatorAlpha, recipientOne, 2), 1 ether); + assertEq(_getPending(operatorAlpha, recipientTwo, 2), 2 ether); + + // Claim only tokenId 2 + address[] memory recipientsToClaim = new address[](2); + recipientsToClaim[0] = recipientOne; + recipientsToClaim[1] = recipientTwo; + + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 2); + + // tokenId 1 still pending + assertEq(_getPending(operatorAlpha, recipientOne, 1), 5 ether); + assertEq(_getPending(operatorAlpha, recipientOne, 2), 0); + assertEq(_getPending(operatorAlpha, recipientTwo, 2), 0); + } + + // 1) Multiple ETH grants accumulate into the same (operator, recipient) bucket. + function test_multipleGrants_accumulatePending_ETH() public { + RewardDistributor.Distribution[] memory firstBatch = new RewardDistributor.Distribution[](2); + firstBatch[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + firstBatch[1] = _distribution(operatorAlpha, recipientTwo, 2 ether); + _grantETHRewards(rewardManager, firstBatch); + + RewardDistributor.Distribution[] memory secondBatch = new RewardDistributor.Distribution[](2); + secondBatch[0] = _distribution(operatorAlpha, recipientOne, 3 ether); + secondBatch[1] = _distribution(operatorAlpha, recipientTwo, 4 ether); + _grantETHRewards(rewardManager, secondBatch); + + assertEq(_getPending(operatorAlpha, recipientOne, 0), 4 ether); + assertEq(_getPending(operatorAlpha, recipientTwo, 0), 6 ether); + } + + // 2) Partial claim leaves remainder; next claim pays the remainder. + function test_partialClaim_leavesRemainder_ETH() public { + + // app for deterministic partial: + // Grant 2 first + RewardDistributor.Distribution[] memory firstGrant = new RewardDistributor.Distribution[](1); + firstGrant[0] = _distribution(operatorAlpha, recipientOne, 2 ether); + _grantETHRewards(rewardManager, firstGrant); + + // Claim now (drains 2) + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + uint256 r1Before = recipientOne.balance; + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 0); + assertEq(recipientOne.balance, r1Before + 2 ether); + + // Grant additional 3, ensure only the remainder is pending + RewardDistributor.Distribution[] memory secondGrant = new RewardDistributor.Distribution[](1); + secondGrant[0] = _distribution(operatorAlpha, recipientOne, 3 ether); + _grantETHRewards(rewardManager, secondGrant); + assertEq(_getPending(operatorAlpha, recipientOne, 0), 3 ether); + + // Claim again, should transfer 3 + r1Before = recipientOne.balance; + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 0); + assertEq(recipientOne.balance, r1Before + 3 ether); + assertEq(_getPending(operatorAlpha, recipientOne, 0), 0); + } + + // 3) Double-claim after full claim is a no-op (no revert, no transfer). + function test_doubleClaim_afterFullClaim_noop_ETH() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + _grantETHRewards(rewardManager, distributions); + + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + + // First claim pays 1 ether + uint256 r1Before = recipientOne.balance; + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 0); + assertEq(recipientOne.balance, r1Before + 1 ether); + assertEq(_getPending(operatorAlpha, recipientOne, 0), 0); + + // Second claim is a no-op + r1Before = recipientOne.balance; + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 0); + assertEq(recipientOne.balance, r1Before); // unchanged + } + + // 4) Operator isolation: same recipient cannot be claimed by the wrong operator. + function test_operatorIsolation_sameRecipient_cannotCrossClaim_ETH() public { + // Grant to (alpha, r1) and (beta, r1) + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](2); + distributions[0] = _distribution(operatorAlpha, recipientOne, 2 ether); + distributions[1] = _distribution(operatorBeta, recipientOne, 3 ether); + _grantETHRewards(rewardManager, distributions); + + // OperatorAlpha claims: only its 2 ether should transfer + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + uint256 before = recipientOne.balance; + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 0); + assertEq(recipientOne.balance, before + 2 ether); + + // OperatorBeta still has 3 pending + assertEq(_getPending(operatorBeta, recipientOne, 0), 3 ether); + } + + // 5) Delegate auth is bound to the operator as well; cannot claim for a different operator. + function test_delegateCannotClaimForDifferentOperator_evenIfAuthorizedForRecipient() public { + // Authorize delegate for (operatorAlpha, recipientOne) + vm.prank(operatorAlpha); + rewardDistributor.setClaimDelegate(claimDelegateAlpha, recipientOne, true); + + // Grant to (operatorBeta, recipientOne) + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorBeta, recipientOne, 1 ether); + _grantETHRewards(rewardManager, distributions); + + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + + // Delegate cannot claim for operatorBeta + vm.expectRevert(); + vm.prank(claimDelegateAlpha); + rewardDistributor.claimOnbehalfOfOperator(operatorBeta, recipientsToClaim, 0); + } + + // 6) Token grants also require owner/manager permissions (mirror of ETH). + function test_grantTokenRewards_onlyOwnerOrManager() public { + // Prepare a simple distribution + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 10 ether); + + // 1) Unauthorized caller (this contract) → revert + vm.expectRevert(); // NotOwnerOrRewardManager (or equivalent) + rewardDistributor.grantTokenRewards(distributions, 1); + + // 2) Owner path → must have balance + allowance + vm.startPrank(contractOwner); + // Give owner enough token #1 so transferFrom(owner → distributor) can succeed + rewardTokenOne.mint(contractOwner, 10 ether); + rewardTokenOne.approve(address(rewardDistributor), 10 ether); + rewardDistributor.grantTokenRewards(distributions, 1); + vm.stopPrank(); + + assertEq(_getPending(operatorAlpha, recipientOne, 1), 10 ether); + + // 3) Reward manager path → already funded in setUp(); just approve and call + RewardDistributor.Distribution[] memory distributions2 = new RewardDistributor.Distribution[](1); + distributions2[0] = _distribution(operatorAlpha, recipientTwo, 1 ether); + + vm.startPrank(rewardManager); + IERC20(rewardDistributor.rewardTokens(1)).approve(address(rewardDistributor), 1 ether); + rewardDistributor.grantTokenRewards(distributions2, 1); + vm.stopPrank(); + + assertEq(_getPending(operatorAlpha, recipientTwo, 1), 1 ether); + } + + // 7) Token grants without sufficient allowance should revert. + function test_grantTokenRewards_withoutAllowance_reverts() public { + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 5 ether); + + // No approve done → transferFrom should fail inside grant + vm.expectRevert(); + vm.prank(rewardManager); + rewardDistributor.grantTokenRewards(distributions, 1); + } + + // 8) Per-key override with multiple keys updates each independently. + function test_overrideRecipientByPubkey_multipleKeys_updatesEach() public { + // Ensure globals are not interfering + vm.prank(operatorAlpha); + rewardDistributor.setOperatorGlobalOverride(recipientOne); + + // Build two distinct 48-byte keys + bytes[] memory keys = new bytes[](2); + keys[0] = hex"101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"; + keys[1] = hex"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; // 48 bytes of 0xaa + + // Override both to recipientTwo + vm.prank(operatorAlpha); + rewardDistributor.overrideRecipientByPubkey(keys, recipientTwo); + + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, keys[0]), recipientTwo); + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, keys[1]), recipientTwo); + } + + // 9) Global override applies to keys without per-key overrides, and changing it updates resolution. + function test_setOperatorGlobalOverride_updatesAllKeysWithoutPerKey() public { + // No per-key override set → default to operator + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, pubkeyOne), operatorAlpha); + + // Set global override → resolution updates + vm.prank(operatorAlpha); + rewardDistributor.setOperatorGlobalOverride(recipientTwo); + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, pubkeyOne), recipientTwo); + + // Switch global again → resolution follows + vm.prank(operatorAlpha); + rewardDistributor.setOperatorGlobalOverride(recipientThree); + assertEq(rewardDistributor.getKeyRecipient(operatorAlpha, pubkeyOne), recipientThree); + } + + // 10) Cross-token independence with mixed grants: claim ETH only; ERC20 remains. + function test_crossTokenIndependence_mixedGrants_claimOnlyOneToken() public { + // Grant: ETH 2 to (alpha,r1), ERC20(1) 3 to (alpha,r1) + { + RewardDistributor.Distribution[] memory ethBatch = new RewardDistributor.Distribution[](1); + ethBatch[0] = _distribution(operatorAlpha, recipientOne, 2 ether); + _grantETHRewards(rewardManager, ethBatch); + + RewardDistributor.Distribution[] memory tknBatch = new RewardDistributor.Distribution[](1); + tknBatch[0] = _distribution(operatorAlpha, recipientOne, 3 ether); + _grantTokenRewards(rewardManager, tknBatch, 1); + } + + // Claim ETH only + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + uint256 r1Before = recipientOne.balance; + + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 0); + + // ETH cleared, token still pending + assertEq(recipientOne.balance, r1Before + 2 ether); + assertEq(_getPending(operatorAlpha, recipientOne, 0), 0); + assertEq(_getPending(operatorAlpha, recipientOne, 1), 3 ether); + } + + // MIGRATION: move accrued from one recipient bucket to another for the CALLER (operator) + + function test_migrateExistingRewards_happyPath_ETH() public { + // Accrue (operatorAlpha → recipientOne) 4 ETH + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 4 ether); + _grantETHRewards(rewardManager, distributions); + + assertEq(_getPending(operatorAlpha, recipientOne, 0), 4 ether); + assertEq(_getPending(operatorAlpha, recipientTwo, 0), 0); + + // OperatorAlpha migrates its own accrued from recipientOne → recipientTwo + vm.prank(operatorAlpha); + rewardDistributor.migrateExistingRewards(recipientOne, recipientTwo, 0); + + assertEq(_getPending(operatorAlpha, recipientOne, 0), 0); + assertEq(_getPending(operatorAlpha, recipientTwo, 0), 4 ether); + } + + function test_migrateExistingRewards_happyPath_Token() public { + // Accrue (operatorAlpha → recipientOne) 7 tokens (id=1) + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 7 ether); + _grantTokenRewards(rewardManager, distributions, 1); + + // Migrate by the operator (caller) + vm.prank(operatorAlpha); + rewardDistributor.migrateExistingRewards(recipientOne, recipientTwo, 1); + + assertEq(_getPending(operatorAlpha, recipientOne, 1), 0); + assertEq(_getPending(operatorAlpha, recipientTwo, 1), 7 ether); + } + + function test_migrateExistingRewards_revert_ifNoClaimableRewardsForCaller() public { + // Accrue to operatorAlpha, but attempt migration from operatorBeta (caller) + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + _grantETHRewards(rewardManager, distributions); + + vm.expectRevert(); // NoClaimableRewards(msg.sender, from) + vm.prank(operatorBeta); + rewardDistributor.migrateExistingRewards(recipientOne, recipientTwo, 0); + } + + function test_migrateExistingRewards_revert_zeroRecipient_orSameRecipient() public { + // Accrue a small amount so the "has rewards" guard passes + RewardDistributor.Distribution[] memory distributions = new RewardDistributor.Distribution[](1); + distributions[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + _grantETHRewards(rewardManager, distributions); + + // to == address(0) + vm.expectRevert(); // ZeroAddress() + vm.prank(operatorAlpha); + rewardDistributor.migrateExistingRewards(recipientOne, address(0), 0); + + // to == from + vm.expectRevert(); // InvalidRecipient() + vm.prank(operatorAlpha); + rewardDistributor.migrateExistingRewards(recipientOne, recipientOne, 0); + } + + // ─────────────────────────────────────────────────────────────────────────── + // OWNER RECLAIM: pull multiple buckets back to the owner + + function test_reclaimStipendsToOwner_happyPath_ETH_and_Token() public { + // Accrue mixed (ETH + token) for two buckets of the same operator + { + RewardDistributor.Distribution[] memory ethBatch = new RewardDistributor.Distribution[](2); + ethBatch[0] = _distribution(operatorAlpha, recipientOne, 2 ether); + ethBatch[1] = _distribution(operatorAlpha, recipientTwo, 3 ether); + _grantETHRewards(rewardManager, ethBatch); + + RewardDistributor.Distribution[] memory tokenBatch = new RewardDistributor.Distribution[](2); + tokenBatch[0] = _distribution(operatorAlpha, recipientOne, 5 ether); + tokenBatch[1] = _distribution(operatorAlpha, recipientTwo, 7 ether); + _grantTokenRewards(rewardManager, tokenBatch, 1); + } + + address[] memory operatorList = new address[](2); + address[] memory recipientList = new address[](2); + operatorList[0] = operatorAlpha; + operatorList[1] = operatorAlpha; + recipientList[0] = recipientOne; + recipientList[1] = recipientTwo; + + // Reclaim ETH buckets to owner + uint256 ownerEthBefore = contractOwner.balance; + vm.prank(contractOwner); + rewardDistributor.reclaimStipendsToOwner(operatorList, recipientList, 0); + + assertEq(contractOwner.balance, ownerEthBefore + 5 ether); // 2 + 3 + assertEq(_getPending(operatorAlpha, recipientOne, 0), 0); + assertEq(_getPending(operatorAlpha, recipientTwo, 0), 0); + + // Reclaim token buckets to owner + uint256 ownerTokenBefore = rewardTokenOne.balanceOf(contractOwner); + vm.prank(contractOwner); + rewardDistributor.reclaimStipendsToOwner(operatorList, recipientList, 1); + + assertEq(rewardTokenOne.balanceOf(contractOwner), ownerTokenBefore + 12 ether); // 5 + 7 + assertEq(_getPending(operatorAlpha, recipientOne, 1), 0); + assertEq(_getPending(operatorAlpha, recipientTwo, 1), 0); + } + + function test_reclaimStipendsToOwner_revert_lengthMismatch() public { + address[] memory operatorList = new address[](2); + address[] memory recipientList = new address[](1); + operatorList[0] = operatorAlpha; + operatorList[1] = operatorBeta; + recipientList[0] = recipientOne; + + vm.expectRevert(); // LengthMismatch() + vm.prank(contractOwner); + rewardDistributor.reclaimStipendsToOwner(operatorList, recipientList, 0); + } + + function test_reclaimStipendsToOwner_revert_noClaimableAcrossSet() public { + // Ensure no pending in the targeted (operator, recipient) pairs + address[] memory operatorList = new address[](1); + address[] memory recipientList = new address[](1); + operatorList[0] = operatorAlpha; + recipientList[0] = recipientOne; + + vm.expectRevert(); // NoClaimableRewards(owner, owner) + vm.prank(contractOwner); + rewardDistributor.reclaimStipendsToOwner(operatorList, recipientList, 0); + } + + // ─────────────────────────────────────────────────────────────────────────── + // CLAIM: invalid inputs + + function test_claimRewards_revert_invalidOperatorZero_viaOnBehalf() public { + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + + // claimOnbehalfOfOperator passes operator arg → zero should revert InvalidOperator() + vm.expectRevert(); + vm.prank(claimDelegateAlpha); + rewardDistributor.claimOnbehalfOfOperator(address(0), recipientsToClaim, 0); + } + + function test_claimRewards_revert_invalidTokenId() public { + // InvalidRewardToken() in _claimRewards should guard this + address[] memory recipientsToClaim = new address[](1); + recipientsToClaim[0] = recipientOne; + + vm.expectRevert(); + vm.prank(operatorAlpha); + rewardDistributor.claimRewards(recipientsToClaim, 9_999); + } + + // ─────────────────────────────────────────────────────────────────────────── + // EVENTS + CONSOLIDATION + + function test_events_emitted_onGrants_and_setters() public { + // ETH grant emits ETHGranted for each item and a value check + RewardDistributor.Distribution[] memory ethBatch = new RewardDistributor.Distribution[](1); + ethBatch[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + + vm.expectEmit(true, true, true, true); + emit IRewardDistributor.ETHGranted(operatorAlpha, recipientOne, 1 ether); + vm.prank(rewardManager); + rewardDistributor.grantETHRewards{value: 1 ether}(ethBatch); + + // Token grant emits TokensGranted and RewardsBatchGranted with total + RewardDistributor.Distribution[] memory tokenBatch = new RewardDistributor.Distribution[](2); + tokenBatch[0] = _distribution(operatorAlpha, recipientOne, 2 ether); + tokenBatch[1] = _distribution(operatorAlpha, recipientTwo, 3 ether); + + vm.startPrank(rewardManager); + IERC20(rewardDistributor.rewardTokens(1)).approve(address(rewardDistributor), 5 ether); + vm.expectEmit(true, true, true, true); + emit IRewardDistributor.TokensGranted(operatorAlpha, recipientOne, 2 ether); + vm.expectEmit(true, true, true, true); + emit IRewardDistributor.TokensGranted(operatorAlpha, recipientTwo, 3 ether); + vm.expectEmit(false, false, false, true); + emit IRewardDistributor.RewardsBatchGranted(5 ether); + rewardDistributor.grantTokenRewards(tokenBatch, 1); + vm.stopPrank(); + + // Setters + vm.expectEmit(true, true, true, true); + emit IRewardDistributor.RewardManagerSet(address(0xBEEF)); + vm.prank(contractOwner); + rewardDistributor.setRewardManager(address(0xBEEF)); + + vm.expectEmit(true, true, true, true); + emit IRewardDistributor.RewardTokenSet(address(rewardTokenTwo), 42); + vm.prank(contractOwner); + rewardDistributor.setRewardToken(address(rewardTokenTwo), 42); + } + + function test_grant_duplicateEntries_consolidates_ETH_and_Token() public { + // ETH: three entries for same (operatorAlpha, recipientOne) + RewardDistributor.Distribution[] memory ethBatch = new RewardDistributor.Distribution[](3); + ethBatch[0] = _distribution(operatorAlpha, recipientOne, 1 ether); + ethBatch[1] = _distribution(operatorAlpha, recipientOne, 2 ether); + ethBatch[2] = _distribution(operatorAlpha, recipientOne, 4 ether); + _grantETHRewards(rewardManager, ethBatch); + + assertEq(_getPending(operatorAlpha, recipientOne, 0), 7 ether); + + // Token: two entries for same (operatorAlpha, recipientOne) on token 1 + RewardDistributor.Distribution[] memory tknBatch = new RewardDistributor.Distribution[](2); + tknBatch[0] = _distribution(operatorAlpha, recipientOne, 3 ether); + tknBatch[1] = _distribution(operatorAlpha, recipientOne, 5 ether); + _grantTokenRewards(rewardManager, tknBatch, 1); + + assertEq(_getPending(operatorAlpha, recipientOne, 1), 8 ether); + } +} diff --git a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol b/contracts/test/validator-registry/rewards/StipendDistributorTest.sol deleted file mode 100644 index 144c3378d..000000000 --- a/contracts/test/validator-registry/rewards/StipendDistributorTest.sol +++ /dev/null @@ -1,550 +0,0 @@ -// SPDX-License-Identifier: BSL 1.1 -pragma solidity 0.8.26; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -import {StipendDistributor} from "../../../contracts/validator-registry/rewards/StipendDistributor.sol"; -import {IStipendDistributor} from "../../../contracts/interfaces/IStipendDistributor.sol"; // events/types only - -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {PausableUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol"; - -contract StipendDistributorTest is Test { - // system under test - StipendDistributor internal distributor; - - // actors - address internal owner; - address internal stipendManager; - address internal operator1; - address internal operator2; - address internal delegate1; - address internal recipient1; - address internal recipient2; - address internal recipient3; - - // sample 48-byte pubkeys - bytes internal pubkey1 = hex"b61a6e5f09217278efc7ddad4dc4b0553b2c076d4a5fef6509c233a6531c99146347193467e84eb5ca921af1b8254b3f"; - bytes internal pubkey2 = hex"aca4b5c5daf5b39514b8aa6e5f303d29f6f1bd891e5f6b6b2ae6e2ae5d95dee31cd78630c1115b6e90f3da1a66cf8edb"; - bytes internal pubkey3 = hex"cca4b5c5daf5b39514b8aa6e5f303d29f6f1bd891e5f6b6b2ae6e2ae5d95dee31cd78630c1115b6e90f3da1a66cf8edb"; - bytes internal pubkey4 = hex"dca4b5c5daf5b39514b8aa6e5f303d29f6f1bd891e5f6b6b2ae6e2ae5d95dee31cd78630c1115b6e90f3da1a66cf8edb"; - bytes internal pubkey5 = hex"eca4b5c5daf5b39514b8aa6e5f303d29f6f1bd891e5f6b6b2ae6e2ae5d95dee31cd78630c1115b6e90f3da1a66cf8edb"; - - // events from interface for expectEmit - event RecipientSet(address indexed operator, bytes pubkey, uint256 indexed registryID, address indexed recipient); - event StipendsGranted(address indexed operator, address indexed recipient, uint256 amount); - event RewardsClaimed(address indexed operator, address indexed recipient, uint256 amount); - event OperatorGlobalOverrideSet(address indexed operator, address indexed recipient); - event StipendsReclaimed(address indexed operator, address indexed recipient, uint256 amount); - - - // setup: deploy registries + distributor and fund stipendManager for payable calls - function setUp() public { - // Test actors - owner = address(0xA11CE); - stipendManager = address(0x04AC1E); - operator1 = address(0x111); - operator2 = address(0x222); - delegate1 = address(0xD311); - recipient1 = address(0xAAA1); - recipient2 = address(0xAAA2); - recipient3 = address(0xAAA3); - - // Deploy distributor proxy - StipendDistributor implementation = new StipendDistributor(); - bytes memory initData = abi.encodeCall( - StipendDistributor.initialize, - (owner, stipendManager) - ); - - address proxy = address(new ERC1967Proxy(address(implementation), initData)); - distributor = StipendDistributor(payable(proxy)); - - vm.deal(stipendManager, 1_000 ether); // for payable grant calls - } - - // helper: grant three combos (op1→r1:1e, op1→r2:2e, op2→r3:3e) - function _grantThreeCombos( - address addr1, - address addr2, - address addr3, - address op1, - address op2 - ) internal { - IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](3); - - stipends[0].operator = op1; - stipends[0].recipient = addr1; - stipends[0].amount = 1 ether; - - stipends[1].operator = op1; - stipends[1].recipient = addr2; - stipends[1].amount = 2 ether; - - stipends[2].operator = op2; - stipends[2].recipient = addr3; - stipends[2].amount = 3 ether; - - vm.prank(stipendManager); - distributor.grantStipends{value: stipends[0].amount + stipends[1].amount + stipends[2].amount}(stipends); - } - - // default recipient: set and read mapping - function test_SetOperatorGlobalOverride_setsMapping() public { - // starts empty - assertEq(distributor.operatorGlobalOverride(operator1), address(0)); - - // operator sets default - vm.prank(operator1); - distributor.setOperatorGlobalOverride(recipient1); - - // mapping reflects default - assertEq(distributor.operatorGlobalOverride(operator1), recipient1); - } - - // override by pubkey: same operator sets 3 keys → recipient2, then 2 keys → recipient3 (middleware registry id=2) - function test_OverrideRecipientByPubkey_multipleBatches() public { - address opFromMiddlewareTest = vm.addr(0x1117); - - // batch 1: 3 keys → recipient2 - bytes[] memory firstBatch = new bytes[](3); - firstBatch[0] = pubkey1; - firstBatch[1] = pubkey2; - firstBatch[2] = pubkey3; - vm.prank(opFromMiddlewareTest); - distributor.overrideRecipientByPubkey(firstBatch, recipient2); - assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey1)), recipient2); - assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey2)), recipient2); - assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey3)), recipient2); - - // batch 2: 2 keys → recipient3 - bytes[] memory secondBatch = new bytes[](2); - secondBatch[0] = pubkey4; - secondBatch[1] = pubkey5; - vm.prank(opFromMiddlewareTest); - distributor.overrideRecipientByPubkey(secondBatch, recipient3); - assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey4)), recipient3); - assertEq(distributor.operatorKeyOverrides(opFromMiddlewareTest, keccak256(pubkey5)), recipient3); - } - - // override by pubkey: reverts when caller isn't the registered operator - function test_OverrideRecipientByPubkey_wrongOperator_reverts() public { - address rightfulOperator = vm.addr(0x1117); - // rightful operator can set it - bytes[] memory pubs = new bytes[](1); - pubs[0] = pubkey1; - vm.prank(rightfulOperator); - distributor.overrideRecipientByPubkey(pubs, recipient1); - assertEq(distributor.operatorKeyOverrides(rightfulOperator, keccak256(pubkey1)), recipient1); - } - - // grantStipends: three combos accrue correctly (no claim here) - function test_GrantStipends_threeCombos_setsAccrued() public { - _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); - - // accrued reflects grants - assertEq(distributor.accrued(operator1, recipient1), 1 ether); - assertEq(distributor.accrued(operator1, recipient2), 2 ether); - assertEq(distributor.accrued(operator2, recipient3), 3 ether); - } - - // claim: operator can claim; delegate can claim when authorized - function test_Claim_byOperator_and_byDelegate() public { - _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); - - // operator1 claims 2e for recipient2 - address[] memory toClaim = new address[](1); - toClaim[0] = recipient2; - uint256 r2Before = recipient2.balance; - vm.prank(operator1); - distributor.claimRewards(toClaim); - assertEq(recipient2.balance, r2Before + 2 ether); - - // operator1 authorizes delegate for recipient1 - vm.prank(operator1); - distributor.setClaimDelegate(delegate1, recipient1, true); - - // delegate claims 1e for recipient1 - address[] memory one = new address[](1); - one[0] = recipient1; - uint256 r1Before = recipient1.balance; - vm.prank(delegate1); - distributor.claimOnbehalfOfOperator(operator1, one); - assertEq(recipient1.balance, r1Before + 1 ether); - } - - // claim: unauthorized caller cannot claim on behalf of another operator - function test_ClaimOnBehalf_unauthorized_reverts() public { - _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); - - address[] memory ask = new address[](1); - ask[0] = recipient3; - - // operator2 tries to claim as if for operator1 → revert - vm.expectRevert(); - vm.prank(operator2); - distributor.claimOnbehalfOfOperator(operator1, ask); - } - - // pending rewards: increments on grant, clears on claim, and stacks across grants - function test_PendingRewards_increment_and_clear() public { - // 1) first grant (1e) to operator1→recipient1 - IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](1); - stipends[0].operator = operator1; - stipends[0].recipient = recipient1; - stipends[0].amount = 1 ether; - vm.prank(stipendManager); - distributor.grantStipends{value: stipends[0].amount}(stipends); - assertEq(distributor.accrued(operator1, recipient1), 1 ether); - - // claim pays 1e - address[] memory list = new address[](1); - list[0] = recipient1; - uint256 before = recipient1.balance; - vm.prank(operator1); - distributor.claimRewards(list); - assertEq(recipient1.balance, before + 1 ether); - - // immediate re-claim is no-op - before = recipient1.balance; - vm.prank(operator1); - distributor.claimRewards(list); - assertEq(recipient1.balance, before); - - // 2) second grant (2e) → total accrued becomes 3e - stipends[0].amount = 2 ether; - vm.prank(stipendManager); - distributor.grantStipends{value: stipends[0].amount}(stipends); - assertEq(distributor.accrued(operator1, recipient1), 3 ether); - - // 3) third grant (3e) without claiming → total accrued becomes 6e - stipends[0].amount = 3 ether; - vm.prank(stipendManager); - distributor.grantStipends{value: stipends[0].amount}(stipends); - assertEq(distributor.accrued(operator1, recipient1), 6 ether); - - // claim now pays 5e (the unclaimed 2e + 3e) - before = recipient1.balance; - vm.prank(operator1); - distributor.claimRewards(list); - assertEq(recipient1.balance, before + 5 ether); - - // re-claim still no-op - before = recipient1.balance; - vm.prank(operator1); - distributor.claimRewards(list); - assertEq(recipient1.balance, before); - } - - // getKeyRecipient: baseline → default → override (registry 0 routes to owning registry) - function test_GetKeyRecipient_and_registry0_routing() public { - address opFromMiddlewareTest = vm.addr(0x1117); - - // 1) baseline: no default/override → resolves to operator - address rec0 = distributor.getKeyRecipient(opFromMiddlewareTest, pubkey1); - assertEq(rec0, opFromMiddlewareTest, "registry 0 should resolve to owning operator"); - - // 2) set default for operator → returns default - vm.prank(opFromMiddlewareTest); - distributor.setOperatorGlobalOverride(recipient1); - address rec1 = distributor.getKeyRecipient(opFromMiddlewareTest, pubkey1); - assertEq(rec1, recipient1, "default recipient should be returned"); - - // 3) set explicit override for this key → precedence over default - bytes[] memory oneKey = new bytes[](1); - oneKey[0] = pubkey1; - vm.prank(opFromMiddlewareTest); - distributor.overrideRecipientByPubkey(oneKey, recipient2); - address rec2 = distributor.getKeyRecipient(opFromMiddlewareTest, pubkey1); - assertEq(rec2, recipient2, "override should take precedence"); - } - - // pause: user funcs revert when paused; owner can pause/unpause; grant is blocked; unpause restores - function test_Pause_allPausableFunctions() public { - // works unpaused - vm.prank(operator1); - distributor.setOperatorGlobalOverride(recipient1); - - // pause as owner - vm.prank(owner); - distributor.pause(); - assertTrue(distributor.paused()); - - // pausable funcs revert when paused - vm.prank(operator1); - vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.setOperatorGlobalOverride(recipient2); - - bytes[] memory pubs = new bytes[](1); - pubs[0] = pubkey1; - vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.overrideRecipientByPubkey(pubs, recipient2); - - IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](1); - stipends[0].operator = operator1; - stipends[0].recipient = recipient1; - stipends[0].amount = 1 ether; - vm.prank(stipendManager); - vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.grantStipends{value: stipends[0].amount}(stipends); - - address[] memory list = new address[](1); - list[0] = recipient1; - vm.prank(operator1); - vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.claimRewards(list); - - vm.prank(operator1); - vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.setClaimDelegate(delegate1, recipient1, true); - - vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - distributor.claimOnbehalfOfOperator(operator1, list); - - // unpause restores - vm.prank(owner); - distributor.unpause(); - vm.prank(operator1); - distributor.setOperatorGlobalOverride(recipient2); - } - - // reentrancy: malicious recipient can't reenter claimRewards - function test_ReentrancyGuard_onClaimRewards() public { - // grant to a recipient that tries to reenter - ReenteringRecipient attacker = new ReenteringRecipient(); - IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](1); - stipends[0].operator = operator1; - stipends[0].recipient = address(attacker); - stipends[0].amount = 1 ether; - vm.prank(stipendManager); - distributor.grantStipends{value: stipends[0].amount}(stipends); - - // claim once → paid exactly once; inner call blocked by nonReentrant - address[] memory list = new address[](1); - list[0] = address(attacker); - uint256 before = address(attacker).balance; - vm.prank(operator1); - distributor.claimRewards(list); - assertEq(address(attacker).balance, before + 1 ether); - } - - function test_OverrideByPubkey() public { - address mwOperator = vm.addr(0x1117); - bytes[] memory pubs = new bytes[](1); - pubs[0] = pubkey1; - - vm.prank(mwOperator); - distributor.overrideRecipientByPubkey(pubs, recipient1); - assertEq(distributor.operatorKeyOverrides(mwOperator, keccak256(pubkey1)), recipient1); - } - - function test_OverrideByPubkeyFailsOnInvalidPubkeyLength() public { - bytes memory bad = hex"1234"; // 2 bytes, not 48 - bytes[] memory pubs = new bytes[](1); - pubs[0] = bad; - vm.prank(operator1); - vm.expectRevert(IStipendDistributor.InvalidBLSPubKeyLength.selector); - distributor.overrideRecipientByPubkey(pubs, recipient1); - } - - // only stipendManager can grant stipends - function test_Grant_onlystipendManager_revertsForOthers() public { - IStipendDistributor.Stipend[] memory stipends = new IStipendDistributor.Stipend[](1); - stipends[0].operator = operator1; - stipends[0].recipient = recipient1; - stipends[0].amount = 1 ether; - vm.deal(operator1, 10 ether); - - // non-stipendManager caller → revert - vm.prank(operator1); - vm.expectRevert(); - distributor.grantStipends{value: stipends[0].amount}(stipends); - } - - // wrong operator can't claim another operator's recipients - function test_ClaimRewards_wrongOperator_reverts() public { - _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); - - address[] memory list = new address[](1); - list[0] = payable(recipient2); - - uint256 before = recipient2.balance; - vm.prank(operator2); - distributor.claimRewards(list); - assertEq(recipient2.balance, before); - } - - // zero-address guards - function test_SetOperatorGlobalOverride_zero_reverts() public { - vm.prank(operator1); - vm.expectRevert(); - distributor.setOperatorGlobalOverride(address(0)); - } - - function test_Override_zeroRecipient_reverts() public { - address mwOperator = vm.addr(0x1117); - bytes[] memory pubs = new bytes[](1); - pubs[0] = pubkey1; - vm.prank(mwOperator); - vm.expectRevert(); - distributor.overrideRecipientByPubkey(pubs, address(0)); - } - - // batch claim: multiple recipients in one call - function test_Claim_batchMultipleRecipients() public { - _grantThreeCombos(recipient1, recipient2, recipient3, operator1, operator2); - - address[] memory list = new address[](2); - list[0] = payable(recipient1); // 1 ether - list[1] = payable(recipient2); // 2 ether - - uint256 r1Before = recipient1.balance; - uint256 r2Before = recipient2.balance; - - vm.prank(operator1); - distributor.claimRewards(list); - - assertEq(recipient1.balance, r1Before + 1 ether); - assertEq(recipient2.balance, r2Before + 2 ether); - } - - // Helper: grant arbitrary pairs in one go (prank as stipendManager and fund it) - function _grantPairs(address[] memory ops, address[] memory recs, uint256[] memory amts) internal { - uint256 len = ops.length; - assertEq(len, recs.length, "setup: length mismatch"); - assertEq(len, amts.length, "setup: length mismatch"); - - IStipendDistributor.Stipend[] memory s = new IStipendDistributor.Stipend[](len); - uint256 total = 0; - for (uint256 i = 0; i < len; ++i) { - s[i] = IStipendDistributor.Stipend({ operator: ops[i], recipient: recs[i], amount: amts[i] }); - total += amts[i]; - } - - vm.deal(stipendManager, stipendManager.balance + total); - vm.prank(stipendManager); - distributor.grantStipends{value: total}(s); - } - - function test_reclaimGrantsToOwner() public { - address opA = makeAddr("opA"); - address rcA = makeAddr("rcA"); - address opB = makeAddr("opB"); - address rcB = makeAddr("rcB"); - - address[] memory ops = new address[](2); - address[] memory recs = new address[](2); - uint256[] memory amts = new uint256[](2); - ops[0] = opA; recs[0] = rcA; amts[0] = 5 ether; - ops[1] = opB; recs[1] = rcB; amts[1] = 7 ether; - - _grantPairs(ops, recs, amts); - - address ownerAddr = distributor.owner(); - uint256 ownerBefore = ownerAddr.balance; - uint256 contractBefore = address(distributor).balance; - - // (optional) check events - vm.expectEmit(true, true, false, true); - emit StipendsReclaimed(opA, rcA, 5 ether); - vm.expectEmit(true, true, false, true); - emit StipendsReclaimed(opB, rcB, 7 ether); - - vm.prank(ownerAddr); - distributor.reclaimGrantsToOwner(ops, recs); - - assertEq(ownerAddr.balance, ownerBefore + 12 ether, "owner did not receive reclaimed ETH"); - assertEq(address(distributor).balance, contractBefore - 12 ether, "contract balance mismatch"); - assertEq(distributor.getPendingRewards(opA, rcA), 0, "pending not cleared for A"); - assertEq(distributor.getPendingRewards(opB, rcB), 0, "pending not cleared for B"); - } - - function test_reclaimGrantsToOwner_RespectsClaimedAndReclaimsOnlyUnclaimed() public { - address operator = makeAddr("op"); - address recipient = makeAddr("rc"); - address ownerAddr = distributor.owner(); - - // ---------- setup: grant #1 (5 ether) ---------- - IStipendDistributor.Stipend[] memory s1 = new IStipendDistributor.Stipend[](1); - s1[0] = IStipendDistributor.Stipend({operator: operator, recipient: recipient, amount: 5 ether}); - - vm.deal(ownerAddr, ownerAddr.balance + 5 ether); - vm.prank(ownerAddr); - distributor.grantStipends{value: 5 ether}(s1); - - // operator fully claims grant #1 - address[] memory recs = new address[](1); - recs[0] = recipient; - uint256 rcBefore = recipient.balance; - uint256 contractBeforeClaim = address(distributor).balance; - - vm.prank(operator); - distributor.claimRewards(recs); - - assertEq(recipient.balance, rcBefore + 5 ether, "recipient did not receive claim #1"); - assertEq(address(distributor).balance, contractBeforeClaim - 5 ether, "contract balance mismatch after claim #1"); - assertEq(distributor.getPendingRewards(operator, recipient), 0, "pending should be zero after claim #1"); - - // ---------- part A: reclaim when fully claimed -> revert & no payout ---------- - uint256 ownerBeforeReclaimA = ownerAddr.balance; - uint256 contractBeforeReclaimA = address(distributor).balance; - - address[] memory opsA = new address[](1); - address[] memory recsA = new address[](1); - opsA[0] = operator; - recsA[0] = recipient; - - vm.prank(ownerAddr); - vm.expectRevert(abi.encodeWithSignature("NoClaimableRewards(address,address)", ownerAddr, ownerAddr)); - distributor.reclaimGrantsToOwner(opsA, recsA); - - // balances unchanged - assertEq(ownerAddr.balance, ownerBeforeReclaimA, "owner balance changed on failed reclaim"); - assertEq(address(distributor).balance, contractBeforeReclaimA, "contract balance changed on failed reclaim"); - - // ---------- grant #2 (9 ether) ---------- - IStipendDistributor.Stipend[] memory s2 = new IStipendDistributor.Stipend[](1); - s2[0] = IStipendDistributor.Stipend({operator: operator, recipient: recipient, amount: 9 ether}); - - vm.deal(ownerAddr, ownerAddr.balance + 9 ether); - vm.prank(ownerAddr); - distributor.grantStipends{value: 9 ether}(s2); - - assertEq(distributor.getPendingRewards(operator, recipient), 9 ether, "pending should equal grant #2"); - - // ---------- part B: reclaim pulls only unclaimed (9 ether) ---------- - uint256 ownerBeforeReclaimB = ownerAddr.balance; - uint256 contractBeforeReclaimB = address(distributor).balance; - - address[] memory opsB = new address[](1); - address[] memory recsB = new address[](1); - opsB[0] = operator; - recsB[0] = recipient; - - vm.prank(ownerAddr); - distributor.reclaimGrantsToOwner(opsB, recsB); - - assertEq(ownerAddr.balance, ownerBeforeReclaimB + 9 ether, "owner did not receive only unclaimed amount"); - assertEq(address(distributor).balance, contractBeforeReclaimB - 9 ether, "contract balance mismatch after reclaim"); - assertEq(distributor.getPendingRewards(operator, recipient), 0, "pending should be zero after reclaim"); - } - -} - - - -// recipient that attempts to re-enter claimRewards during payout -contract ReenteringRecipient { - fallback() external payable { - // try to re-enter claimRewards(address[]) - bytes memory data = abi.encodeWithSignature("claimRewards(address[])", _arr()); - (bool ok, ) = msg.sender.call(data); // blocked by nonReentrant - ok; // silence warning - } - - function _arr() internal view returns (address[] memory a) { - a = new address[](1); - a[0] = address(this); - } -} From b98b72f0d677084fc8de63ad4fb2c37ddc6b4993 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Sat, 13 Sep 2025 12:14:59 -0400 Subject: [PATCH 20/20] added SafeERC20, some minor adjustments and checks --- contracts-abi/abi/RewardDistributor.abi | 28 +++++++++++++++++++ .../interfaces/IRewardDistributor.sol | 2 +- .../rewards/RewardDistributor.sol | 23 +++++++++------ .../rewards/RewardDistributorTest.sol | 11 +++----- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/contracts-abi/abi/RewardDistributor.abi b/contracts-abi/abi/RewardDistributor.abi index 604e61095..c53f47090 100644 --- a/contracts-abi/abi/RewardDistributor.abi +++ b/contracts-abi/abi/RewardDistributor.abi @@ -807,6 +807,12 @@ "type": "event", "name": "RewardsBatchGranted", "inputs": [ + { + "name": "tokenID", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, { "name": "amount", "type": "uint256", @@ -971,6 +977,17 @@ } ] }, + { + "type": "error", + "name": "AddressInsufficientBalance", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, { "type": "error", "name": "ERC1967InvalidImplementation", @@ -1132,6 +1149,17 @@ } ] }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, { "type": "error", "name": "UUPSUnauthorizedCallContext", diff --git a/contracts/contracts/interfaces/IRewardDistributor.sol b/contracts/contracts/interfaces/IRewardDistributor.sol index 3abf04dbf..0e3a1ec45 100644 --- a/contracts/contracts/interfaces/IRewardDistributor.sol +++ b/contracts/contracts/interfaces/IRewardDistributor.sol @@ -24,7 +24,7 @@ interface IRewardDistributor { /// @dev Emitted when stipends are granted. event ETHGranted(address indexed operator, address indexed recipient, uint256 indexed amount); event TokensGranted(address indexed operator, address indexed recipient, uint256 indexed amount); - event RewardsBatchGranted(uint256 indexed amount); + event RewardsBatchGranted(uint256 indexed tokenID, uint256 indexed amount); /// @dev Emitted when rewards are claimed by a recipient for an operator. event ETHRewardsClaimed(address indexed operator, address indexed recipient, uint256 indexed amount); event TokenRewardsClaimed(address indexed operator, address indexed recipient, uint256 indexed amount); diff --git a/contracts/contracts/validator-registry/rewards/RewardDistributor.sol b/contracts/contracts/validator-registry/rewards/RewardDistributor.sol index bf475dd57..292c6c750 100644 --- a/contracts/contracts/validator-registry/rewards/RewardDistributor.sol +++ b/contracts/contracts/validator-registry/rewards/RewardDistributor.sol @@ -6,12 +6,14 @@ import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/Pau import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IRewardDistributor} from "../../interfaces/IRewardDistributor.sol"; import {RewardDistributorStorage} from "./RewardDistributorStorage.sol"; import {Errors} from "../../utils/Errors.sol"; contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, Ownable2StepUpgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable, UUPSUpgradeable { + using SafeERC20 for IERC20; modifier onlyOwnerOrRewardManager() { require(msg.sender == rewardManager || msg.sender == owner(), NotOwnerOrRewardManager()); @@ -55,6 +57,7 @@ contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, rewardData[rewardList[i].operator][rewardList[i].recipient][0].accrued += rewardList[i].amount; emit ETHGranted(rewardList[i].operator, rewardList[i].recipient, rewardList[i].amount); } + emit RewardsBatchGranted(0, totalAmount); require(msg.value == totalAmount, IncorrectPaymentAmount(msg.value, totalAmount)); } @@ -66,16 +69,16 @@ contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, require(rewardToken != address(0), InvalidRewardToken()); for (uint256 i = 0; i < len; ++i) { totalAmount += rewardList[i].amount; - rewardData[rewardList[i].operator][rewardList[i].recipient][tokenID].accrued += rewardList[i].amount; + rewardData[rewardList[i].operator][rewardList[i].recipient][tokenID].accrued += rewardList[i].amount; emit TokensGranted(rewardList[i].operator, rewardList[i].recipient, rewardList[i].amount); } - emit RewardsBatchGranted(totalAmount); - IERC20(rewardToken).transferFrom(msg.sender, address(this), totalAmount); + emit RewardsBatchGranted(tokenID, totalAmount); + IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), totalAmount); } /// @notice Claim rewards for the caller (as operator) to specific recipients. /// @param recipients List of recipients to claim rewards for. - /// @param tokenID The ID of the token to claim rewards for. + /// @param tokenID The ID of the token to claim rewards for. 0 for ETH. function claimRewards(address[] calldata recipients, uint256 tokenID) external whenNotPaused nonReentrant { _claimRewards(msg.sender, recipients, tokenID); } @@ -83,7 +86,7 @@ contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, /// @notice Claim rewards on behalf of an operator to specific recipients (must be delegated). /// @param operator Operator to claim rewards for. /// @param recipients List of recipients to claim rewards for. - /// @param tokenID The ID of the token to claim rewards for. + /// @param tokenID The ID of the token to claim rewards for. 0 for ETH. function claimOnbehalfOfOperator(address operator, address[] calldata recipients, uint256 tokenID) external whenNotPaused nonReentrant { uint256 len = recipients.length; for (uint256 i = 0; i < len; ++i) { @@ -126,10 +129,11 @@ contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, /// @dev Allows an operator to migrate unclaimed recipient rewards to a different address. /// @param tokenID The ID of the token to migrate rewards for. function migrateExistingRewards(address from, address to, uint256 tokenID) external whenNotPaused nonReentrant { - uint128 claimableAmt = getPendingRewards(msg.sender, from, tokenID); - require(claimableAmt > 0, NoClaimableRewards(msg.sender, from)); require(to != address(0), ZeroAddress()); require(to != from, InvalidRecipient()); + require(tokenID == 0 || rewardTokens[tokenID] != address(0), InvalidRewardToken()); + uint128 claimableAmt = getPendingRewards(msg.sender, from, tokenID); + require(claimableAmt > 0, NoClaimableRewards(msg.sender, from)); rewardData[msg.sender][from][tokenID].accrued -= claimableAmt; rewardData[msg.sender][to][tokenID].accrued += claimableAmt; emit RewardsMigrated(tokenID, msg.sender, from, to, claimableAmt); @@ -137,6 +141,7 @@ contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, /// @dev Allows the owner to reclaim stipends that were incorrectly granted or unable to be claimed by an operator. function reclaimStipendsToOwner(address[] calldata operators, address[] calldata recipients, uint256 tokenID) external onlyOwner { + require(tokenID == 0 || rewardTokens[tokenID] != address(0), InvalidRewardToken()); address _owner = owner(); uint256 toWithdraw = 0; uint256 len = operators.length; @@ -176,6 +181,7 @@ contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, // Retreives the recipient for an operator's registered key function getKeyRecipient(address operator, bytes calldata pubkey) external view returns (address) { require(pubkey.length == 48, InvalidBLSPubKeyLength()); + require(operator != address(0), InvalidOperator()); bytes32 pkHash = keccak256(pubkey); // Individual key overrides take priority over the default recipient if (operatorKeyOverrides[operator][pkHash] != address(0)) { @@ -222,7 +228,7 @@ contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, require(success, RewardsTransferFailed(recipient)); emit ETHRewardsClaimed(operator, recipient, amount); } else { - IERC20(rewardTokens[tokenID]).transfer(recipient, amount); + IERC20(rewardTokens[tokenID]).safeTransfer(recipient, amount); emit TokenRewardsClaimed(operator, recipient, amount); } } @@ -234,7 +240,6 @@ contract RewardDistributor is IRewardDistributor, RewardDistributorStorage, } function _setRewardToken(address _rewardToken, uint256 _id) internal { - require(_rewardToken != address(0), ZeroAddress()); require(_id != 0, InvalidTokenID()); rewardTokens[_id] = _rewardToken; emit RewardTokenSet(_rewardToken, _id); diff --git a/contracts/test/validator-registry/rewards/RewardDistributorTest.sol b/contracts/test/validator-registry/rewards/RewardDistributorTest.sol index 4789125cc..9cb5696dc 100644 --- a/contracts/test/validator-registry/rewards/RewardDistributorTest.sol +++ b/contracts/test/validator-registry/rewards/RewardDistributorTest.sol @@ -55,8 +55,8 @@ contract ERC20Mintable is IERC20 { } } -// Name chosen to match `--match-contract RewardsDistributor` -contract RewardsDistributor is Test { +// Name chosen to match `--match-contract RewardDistributor` +contract RewardDistributorTest is Test { RewardDistributor internal rewardDistributor; // Roles / actors @@ -323,13 +323,10 @@ contract RewardsDistributor is Test { // ───────────────────────── Admin & pause - function test_onlyOwner_canSetRewardToken_andRejectsZeroAddressAndTokenIdZero() public { + function test_onlyOwner_canSetRewardToken_andRejectsTokenIdZero() public { vm.expectRevert(); // onlyOwner rewardDistributor.setRewardToken(address(rewardTokenOne), 9); - vm.startPrank(contractOwner); - vm.expectRevert(); // ZeroAddress - rewardDistributor.setRewardToken(address(0), 4); vm.expectRevert(); // InvalidTokenID rewardDistributor.setRewardToken(address(rewardTokenOne), 0); vm.stopPrank(); @@ -798,7 +795,7 @@ contract RewardsDistributor is Test { vm.expectEmit(true, true, true, true); emit IRewardDistributor.TokensGranted(operatorAlpha, recipientTwo, 3 ether); vm.expectEmit(false, false, false, true); - emit IRewardDistributor.RewardsBatchGranted(5 ether); + emit IRewardDistributor.RewardsBatchGranted(0, 5 ether); rewardDistributor.grantTokenRewards(tokenBatch, 1); vm.stopPrank();