diff --git a/contracts-abi/abi/BlockRewardManager.abi b/contracts-abi/abi/BlockRewardManager.abi new file mode 100644 index 000000000..aeba0abee --- /dev/null +++ b/contracts-abi/abi/BlockRewardManager.abi @@ -0,0 +1,495 @@ +[ + { + "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": "TreasuryTransferFailed", + "inputs": [ + { + "name": "treasury", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "UUPSUnauthorizedCallContext", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnsupportedProxiableUUID", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } +] diff --git a/contracts-abi/abi/RewardDistributor.abi b/contracts-abi/abi/RewardDistributor.abi new file mode 100644 index 000000000..c53f47090 --- /dev/null +++ b/contracts-abi/abi/RewardDistributor.abi @@ -0,0 +1,1184 @@ +[ + { + "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": "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[]" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "claimRewards", + "inputs": [ + { + "name": "recipients", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "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" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint128", + "internalType": "uint128" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantETHRewards", + "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" + } + ] + } + ], + "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", + "type": "address", + "internalType": "address" + }, + { + "name": "_rewardManager", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "migrateExistingRewards", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" + } + ], + "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", + "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": "reclaimStipendsToOwner", + "inputs": [ + { + "name": "operators", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "recipients", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "tokenID", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "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", + "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": "setOperatorGlobalOverride", + "inputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setRewardManager", + "inputs": [ + { + "name": "_rewardManager", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setRewardToken", + "inputs": [ + { + "name": "_rewardToken", + "type": "address", + "internalType": "address" + }, + { + "name": "_id", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "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": "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", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OperatorGlobalOverrideSet", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "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": "RewardManagerSet", + "inputs": [ + { + "name": "rewardManager", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardTokenSet", + "inputs": [ + { + "name": "rewardToken", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "tokenID", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardsBatchGranted", + "inputs": [ + { + "name": "tokenID", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "amount", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "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", + "indexed": true, + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint128", + "indexed": false, + "internalType": "uint128" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardsReclaimed", + "inputs": [ + { + "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": "TokenRewardsClaimed", + "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": "TokensGranted", + "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": "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": "AddressInsufficientBalance", + "inputs": [ + { + "name": "account", + "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": "IncorrectPaymentAmount", + "inputs": [ + { + "name": "received", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "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": "InvalidRewardToken", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidTokenID", + "inputs": [] + }, + { + "type": "error", + "name": "LengthMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "NoClaimableRewards", + "inputs": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "NotOwnerOrRewardManager", + "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": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "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": [] + } +] diff --git a/contracts-abi/abi/RewardsManagerV2.abi b/contracts-abi/abi/RewardsManagerV2.abi new file mode 100644 index 000000000..bf5716d44 --- /dev/null +++ b/contracts-abi/abi/RewardsManagerV2.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 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": "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 71f42e050..0806383fb 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/RewardDistributor.sol/RewardDistributor.json" "$ABI_DIR/RewardDistributor.abi" + +extract_and_save_abi "$BASE_DIR/out/BlockRewardManager.sol/BlockRewardManager.json" "$ABI_DIR/BlockRewardManager.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/RewardDistributor.abi" "RewardDistributor" "rewarddistributor" + +generate_go_code "$ABI_DIR/BlockRewardManager.abi" "BlockRewardManager" "blockrewardmanager" + echo "External ABI downloaded and processed successfully." echo "Go code generated successfully in separate folders." diff --git a/contracts/contracts/interfaces/IBlockRewardManager.sol b/contracts/contracts/interfaces/IBlockRewardManager.sol new file mode 100644 index 000000000..9e22f568f --- /dev/null +++ b/contracts/contracts/interfaces/IBlockRewardManager.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + + +interface IBlockRewardManager { + // -------- 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 OnlyOwnerOrTreasury(); + error RewardsPctTooHigh(); + 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; + + function withdrawToTreasury() external; + + function setRewardsPctBps(uint256 rewardsPctBps) external; + + function setTreasury(address treasury) external; + + // -------- Admin -------- + function initialize(address initialOwner, uint256 rewardsPctBps, address treasury) external; + + + +} \ No newline at end of file diff --git a/contracts/contracts/interfaces/IRewardDistributor.sol b/contracts/contracts/interfaces/IRewardDistributor.sol new file mode 100644 index 000000000..0e3a1ec45 --- /dev/null +++ b/contracts/contracts/interfaces/IRewardDistributor.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +/// @title IStipendDistributor +/// @notice Interface for stipend distribution and claims. +interface IRewardDistributor { + + struct Distribution { + address operator; + address recipient; + 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 RewardManagerSet(address indexed rewardManager); + + /// @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 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); + + /// @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 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); + + /// @dev Emitted when accrued rewards are migrated from one recipient to another for an operator. + 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 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 NotOwnerOrRewardManager(); + error InvalidRewardToken(); + error ZeroAddress(); + error InvalidTokenID(); + error InvalidBLSPubKeyLength(); + error InvalidRecipient(); + error InvalidOperator(); + error InvalidClaimDelegate(); + error LengthMismatch(); + error NoClaimableRewards(address operator, address recipient); + error RewardsTransferFailed(address recipient); + error IncorrectPaymentAmount(uint256 received, uint256 expected); + + // -------- Externals -------- + /// @notice Initialize the proxy. + function initialize(address owner, address rewardManager) external; + + /// @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, uint256 tokenID) external; + + /// @notice Claim rewards on behalf of an operator to specific recipients (must be delegated). + 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; + + /// @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, 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 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, uint256 tokenID) external view returns (uint128); + +} \ No newline at end of file diff --git a/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol b/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol new file mode 100644 index 000000000..dcbaf971d --- /dev/null +++ b/contracts/contracts/validator-registry/rewards/BlockRewardManager.sol @@ -0,0 +1,96 @@ +// 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 {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; + +import {IBlockRewardManager} from "../../interfaces/IBlockRewardManager.sol"; +import {BlockRewardManagerStorage} from "./BlockRewardManagerStorage.sol"; +import {Errors} from "../../utils/Errors.sol"; + +contract BlockRewardManager is + Initializable, + Ownable2StepUpgradeable, + ReentrancyGuardUpgradeable, + BlockRewardManagerStorage, + IBlockRewardManager, + UUPSUpgradeable +{ + uint256 constant _BPS_DENOMINATOR = 10_000; + + modifier onlyOwnerOrTreasury() { + require(msg.sender == owner() || msg.sender == treasury, OnlyOwnerOrTreasury()); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + 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 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 { + 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 + emit ProposerPaid(feeRecipient, totalAmt, 0); + } else { + uint256 amtForRewards = totalAmt * bps / _BPS_DENOMINATOR; + uint256 proposerAmt = totalAmt - amtForRewards; + toTreasury += amtForRewards; + (bool success, ) = feeRecipient.call{value: proposerAmt}(""); + require(success, ProposerTransferFailed(feeRecipient, proposerAmt)); //revert if transfer fails + emit ProposerPaid(feeRecipient, proposerAmt, amtForRewards); + } + } + + function withdrawToTreasury() external onlyOwnerOrTreasury { + require(toTreasury > 0, NoFundsToWithdraw()); + uint256 treasuryAmt = toTreasury; + toTreasury = 0; + (bool success, ) = treasury.call{value: treasuryAmt}(""); //Treasury will not revert + require(success, TreasuryTransferFailed(treasury, treasuryAmt)); //revert if transfer fails + emit TreasuryWithdrawn(treasuryAmt); + } + + function setRewardsPctBps(uint256 rewardsPctBps) external onlyOwner { + _setRewardsPctBps(rewardsPctBps); + } + + function setTreasury(address treasury) external onlyOwner { + _setTreasury(treasury); + } + + function _setTreasury(address _treasury) internal { + require(_treasury != address(0), TreasuryIsZero()); + treasury = payable(_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 {} +} diff --git a/contracts/contracts/validator-registry/rewards/BlockRewardManagerStorage.sol b/contracts/contracts/validator-registry/rewards/BlockRewardManagerStorage.sol new file mode 100644 index 000000000..4ee70ab6d --- /dev/null +++ b/contracts/contracts/validator-registry/rewards/BlockRewardManagerStorage.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +abstract contract BlockRewardManagerStorage { + + 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/README.md b/contracts/contracts/validator-registry/rewards/README.md index 35b658adc..6c8fe4219 100644 --- a/contracts/contracts/validator-registry/rewards/README.md +++ b/contracts/contracts/validator-registry/rewards/README.md @@ -1,25 +1,142 @@ -# 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 example -## Overriding the receiver address +**Solidity (from a builder integration):** +```solidity +IBlockRewardManager(brm).payProposer{value: reward}(feeRecipient); +``` -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. -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 +# Reward Distributor — Overview (ETH-focused) -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. +For further details, see the Reward Distributor Design Doc: https://www.notion.so/primev/RewardDistributor-Design-2696865efd6f80b2a4f0e6b8fc3ab0c4 -## Manual Claim +`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. -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. +> **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):** + `setOperatorGlobalOverride(address recipient)` + Sets a default recipient for the operator’s keys. + +- **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. + +- **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) + +- **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 + +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. + +--- + +## Notes on future token support + +- **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`. + +--- diff --git a/contracts/contracts/validator-registry/rewards/RewardDistributor.sol b/contracts/contracts/validator-registry/rewards/RewardDistributor.sol new file mode 100644 index 000000000..292c6c750 --- /dev/null +++ b/contracts/contracts/validator-registry/rewards/RewardDistributor.sol @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +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 {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()); + _; + } + + /// @dev See https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializing_the_implementation_contract + /// @custom:oz-upgrades-unsafe-allow constructor + 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 _rewardManager + ) external initializer { + __Ownable_init(_owner); + __ReentrancyGuard_init(); + __Pausable_init(); + __UUPSUpgradeable_init(); + _setRewardManager(_rewardManager); + } + + /// @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 += 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); + } + emit RewardsBatchGranted(0, totalAmount); + require(msg.value == totalAmount, IncorrectPaymentAmount(msg.value, totalAmount)); + } + + /// @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(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. 0 for ETH. + 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. 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) { + require(claimDelegate[operator][recipients[i]][msg.sender], InvalidClaimDelegate()); + } + _claimRewards(operator, recipients, tokenID); + } + + /// @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. + /// @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 { + require(recipient != address(0), ZeroAddress()); + 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); + operatorKeyOverrides[msg.sender][pkHash] = recipient; + emit RecipientSet(msg.sender, pubkey, 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 setOperatorGlobalOverride(address recipient) external whenNotPaused nonReentrant { + require(recipient != address(0), ZeroAddress()); + 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. + 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. + /// @param tokenID The ID of the token to migrate rewards for. + function migrateExistingRewards(address from, address to, uint256 tokenID) external whenNotPaused nonReentrant { + 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); + } + + /// @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; + require(len == recipients.length, LengthMismatch()); + for (uint256 i = 0; i < len; ++i) { + address operator = operators[i]; + address recipient = recipients[i]; + uint128 claimableAmt = getPendingRewards(operator, recipient, tokenID); + rewardData[operator][recipient][tokenID].accrued -= claimableAmt; + toWithdraw += claimableAmt; + emit RewardsReclaimed(tokenID, operator, recipient, claimableAmt); + } + require(toWithdraw > 0, NoClaimableRewards(_owner, _owner)); + _transferFunds(_owner, _owner, toWithdraw, tokenID); + } + + /// @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 stipend manager address. + 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 + 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)) { + return operatorKeyOverrides[operator][pkHash]; + } + // If no key override, return the default recipient + address defaultOverride = operatorGlobalOverride[operator]; + if (defaultOverride != address(0)) { + return defaultOverride; + } + // If no default override, return the operator + return operator; + } + + 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, uint256 tokenID) internal { + require(operator != address(0), InvalidOperator()); + require(tokenID == 0 || rewardTokens[tokenID] != address(0), InvalidRewardToken()); + uint256 len = recipients.length; + uint128[] memory claimAmounts = new uint128[](len); + for (uint256 i = 0; i < len; ++i) { + address recipient = recipients[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) { + _transferFunds(operator, recipient, claimAmounts[i], tokenID); + } + } + } + + 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]).safeTransfer(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(_id != 0, InvalidTokenID()); + rewardTokens[_id] = _rewardToken; + emit RewardTokenSet(_rewardToken, _id); + } +} diff --git a/contracts/contracts/validator-registry/rewards/RewardDistributorStorage.sol b/contracts/contracts/validator-registry/rewards/RewardDistributorStorage.sol new file mode 100644 index 000000000..0f57e9afe --- /dev/null +++ b/contracts/contracts/validator-registry/rewards/RewardDistributorStorage.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +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 => 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[48] private __gap; +} \ No newline at end of file diff --git a/contracts/l1-deployer-cli.sh b/contracts/l1-deployer-cli.sh index 8afe9395b..cb67c27a3 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_block_rewards_flag=false +deploy_reward_distributor_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-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')." @@ -122,6 +126,14 @@ parse_args() { deploy_router_flag=true shift ;; + deploy-block-rewards) + deploy_block_rewards_flag=true + shift + ;; + deploy-reward-distributor) + deploy_reward_distributor_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_block_rewards_flag deploy_reward_distributor_flag; do if [[ "${!flag}" == true ]]; then ((commands_specified++)) fi @@ -386,6 +398,14 @@ deploy_router() { deploy_contract_generic "scripts/validator-registry/DeployValidatorOptInRouter.s.sol" } +deploy_block_rewards() { + deploy_contract_generic "scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol" +} + +deploy_reward_distributor() { + deploy_contract_generic "scripts/validator-registry/rewards/DeployRewardDistributor.s.sol" +} + main() { check_dependencies parse_args "$@" @@ -409,6 +429,10 @@ main() { deploy_middleware elif [[ "${deploy_router_flag}" == true ]]; then deploy_router + 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/DeployBlockRewardManager.s.sol b/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.s.sol new file mode 100644 index 000000000..2be2fc2d0 --- /dev/null +++ b/contracts/scripts/validator-registry/rewards/DeployBlockRewardManager.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 {BlockRewardManager} from "../../../contracts/validator-registry/rewards/BlockRewardManager.sol"; +import {MainnetConstants} from "../../MainnetConstants.sol"; + +contract BaseDeploy is Script { + function deployBlockRewardManager( + address owner, + uint256 rewardsPctBps, + address treasury + ) public returns (address) { + console.log("Deploying BlockRewardManager on chain:", block.chainid); + address proxy = Upgrades.deployUUPSProxy( + "BlockRewardManager.sol", + abi.encodeCall( + BlockRewardManager.initialize, + (owner, rewardsPctBps, payable(treasury)) + ) + ); + console.log("BlockRewardManager UUPS proxy deployed to:", address(proxy)); + BlockRewardManager rewardsV2 = BlockRewardManager(payable(proxy)); + console.log("BlockRewardManager owner:", rewardsV2.owner()); + return proxy; + } +} + +contract DeployMainnet is BaseDeploy { + address constant public OWNER = MainnetConstants.PRIMEV_TEAM_MULTISIG; + 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(); + + deployBlockRewardManager( + 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(); + deployBlockRewardManager( + OWNER, + REWARDS_PCT_BPS, + TREASURY + ); + vm.stopBroadcast(); + } +} diff --git a/contracts/scripts/validator-registry/rewards/DeployRewardDistributor.s.sol b/contracts/scripts/validator-registry/rewards/DeployRewardDistributor.s.sol new file mode 100644 index 000000000..8d0868936 --- /dev/null +++ b/contracts/scripts/validator-registry/rewards/DeployRewardDistributor.s.sol @@ -0,0 +1,60 @@ +// 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 {RewardDistributor} from "../../../contracts/validator-registry/rewards/RewardDistributor.sol"; +import {MainnetConstants} from "../../MainnetConstants.sol"; + +contract BaseDeploy is Script { + function deployRewardDistributor( + address owner, + address rewardManager + ) public returns (address) { + console.log("Deploying RewardDistributor on chain:", block.chainid); + address proxy = Upgrades.deployUUPSProxy( + "RewardDistributor.sol", + abi.encodeCall( + RewardDistributor.initialize, + (owner, rewardManager) + ) + ); + 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 REWARD_MANAGER + + function run() external { + require(block.chainid == 1, "must deploy on mainnet"); + vm.startBroadcast(); + //deploy call here + vm.stopBroadcast(); + } +} + +contract DeployHoodi is BaseDeploy { + address constant public OWNER = 0x1623fE21185c92BB43bD83741E226288B516134a; + address constant public REWARD_MANAGER = 0x1623fE21185c92BB43bD83741E226288B516134a; + + function run() external { + require(block.chainid == 560048, "must deploy on Hoodi"); + + vm.startBroadcast(); + deployRewardDistributor( + OWNER, + REWARD_MANAGER + ); + vm.stopBroadcast(); + } +} diff --git a/contracts/test/validator-registry/rewards/BlockRewardManagerTest.sol b/contracts/test/validator-registry/rewards/BlockRewardManagerTest.sol new file mode 100644 index 000000000..23a812cb2 --- /dev/null +++ b/contracts/test/validator-registry/rewards/BlockRewardManagerTest.sol @@ -0,0 +1,213 @@ +// 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 {BlockRewardManager} from "../../../contracts/validator-registry/rewards/BlockRewardManager.sol"; +import {IBlockRewardManager} from "../../../contracts/interfaces/IBlockRewardManager.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +contract BlockRewardManagerTest is Test { + BlockRewardManager 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% + + BlockRewardManager implementation = new BlockRewardManager(); + bytes memory initData = abi.encodeCall( + BlockRewardManager.initialize, + (ownerAddress, initialRewardsPctBps, treasuryAddress) + ); + + address proxy = address(new ERC1967Proxy(address(implementation), initData)); + rewardsManager = BlockRewardManager(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(IBlockRewardManager.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(IBlockRewardManager.OnlyOwnerOrTreasury.selector)); + rewardsManager.withdrawToTreasury(); + } + + function test_setTreasury_revertsIfTreasuryZero() public { + vm.prank(ownerAddress); + 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(IBlockRewardManager.NoFundsToWithdraw.selector); + rewardsManager.withdrawToTreasury(); + } + + // 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/RewardDistributorTest.sol b/contracts/test/validator-registry/rewards/RewardDistributorTest.sol new file mode 100644 index 000000000..9cb5696dc --- /dev/null +++ b/contracts/test/validator-registry/rewards/RewardDistributorTest.sol @@ -0,0 +1,832 @@ +// 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 RewardDistributor` +contract RewardDistributorTest 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_andRejectsTokenIdZero() public { + vm.expectRevert(); // onlyOwner + rewardDistributor.setRewardToken(address(rewardTokenOne), 9); + vm.startPrank(contractOwner); + 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(0, 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); + } +}