diff --git a/script/arb1-deploy.s.sol b/script/arb1-deploy.s.sol index cff56fa..5a099fb 100644 --- a/script/arb1-deploy.s.sol +++ b/script/arb1-deploy.s.sol @@ -19,25 +19,25 @@ contract DeployScript is Script { vm.startBroadcast(); recipients[0] = l2TreasuryTimelock; - RewardDistributor rd_l2base = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd_l2base = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); rd_l2base.transferOwnership(l2Executor); console.log("Deployed L2 Base at: "); console.log(address(rd_l2base)); recipients[0] = l2TreasuryTimelock; - RewardDistributor rd_l2surplus = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd_l2surplus = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); rd_l2surplus.transferOwnership(l2Executor); console.log("Deployed L2 Surplus at: "); console.log(address(rd_l2surplus)); recipients[0] = l2OffchainLabsMultisig; - RewardDistributor rd_l1base = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd_l1base = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); rd_l1base.transferOwnership(l2Executor); console.log("Deployed L1 Base at: "); console.log(address(rd_l1base)); recipients[0] = l2TreasuryTimelock; - RewardDistributor rd_l1surplus = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd_l1surplus = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); rd_l1surplus.transferOwnership(l2Executor); console.log("Deployed L1 Surplus at: "); console.log(address(rd_l1surplus)); diff --git a/script/deploy.s.sol b/script/deploy.s.sol index 5c70936..7b12063 100644 --- a/script/deploy.s.sol +++ b/script/deploy.s.sol @@ -15,7 +15,7 @@ contract DeployScript is Script { weights[0] = 10000; vm.startBroadcast(); - RewardDistributor rd = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); console.log("Deployed RewardDistributor at: "); console.log(address(rd)); vm.stopBroadcast(); diff --git a/script/nova-deploy.s.sol b/script/nova-deploy.s.sol index 249d923..2098cef 100644 --- a/script/nova-deploy.s.sol +++ b/script/nova-deploy.s.sol @@ -42,7 +42,7 @@ contract DeployScript is Script { weights[5] = 373; recipients[6] = Opensea; weights[6] = 133; - RewardDistributor rd_l2base = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd_l2base = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); rd_l2base.transferOwnership(l2ExecutorNova); console.log("Deployed Nova L2 Base at: "); console.log(address(rd_l2base)); @@ -52,19 +52,19 @@ contract DeployScript is Script { weights[0] = 10000; recipients[0] = l1TimelockAlias; - RewardDistributor rd_l2surplus = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd_l2surplus = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); rd_l2surplus.transferOwnership(l2ExecutorNova); console.log("Deployed Nova L2 Surplus at: "); console.log(address(rd_l2surplus)); recipients[0] = l2OffchainLabsMultisigNova; - RewardDistributor rd_l1base = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd_l1base = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); rd_l1base.transferOwnership(l2ExecutorNova); console.log("Deployed Nova L1 Base at: "); console.log(address(rd_l1base)); recipients[0] = l1TimelockAlias; - RewardDistributor rd_l1surplus = new RewardDistributor({recipients: recipients, weights: weights}); + RewardDistributor rd_l1surplus = new RewardDistributor({_token: address(0), recipients: recipients, weights: weights}); rd_l1surplus.transferOwnership(l2ExecutorNova); console.log("Deployed Nova L1 Surplus at: "); console.log(address(rd_l1surplus)); diff --git a/src/RewardDistributor.sol b/src/RewardDistributor.sol index d3278f4..500c4ef 100644 --- a/src/RewardDistributor.sol +++ b/src/RewardDistributor.sol @@ -3,7 +3,10 @@ pragma solidity ^0.8.16; import {BASIS_POINTS, hashAddresses, hashWeights, uncheckedInc} from "./Util.sol"; import "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +error CannotReceiveNative(); error TooManyRecipients(); error EmptyRecipients(); error InvalidRecipientGroup(bytes32 currentRecipientGroup, bytes32 providedRecipientGroup); @@ -12,11 +15,17 @@ error OwnerFailedRecieve(address owner, address recipient, uint256 value); error NoFundsToDistribute(); error InputLengthMismatch(); error InvalidTotalWeight(uint256 totalWeight); +error CannotCallRescueToToken(); +error CannotCallRescueWithValue(); -/// @title A distributor of ether -/// @notice You can use this contract to distribute ether according to defined weights between a group of participants managed by an owner. +/// @title A distributor of ether or an ERC20 token +/// @notice You can use this contract to distribute ether/token according to defined weights between a group of participants managed by an owner. /// @dev If a particular recipient is not able to recieve funds at their address, the payment will fallback to the owner. +/// A RewardDistributor can only handle a single, specific asset defined at deployment. +/// This contract assumes that the token does not have a blacklist, callback or other non standard ERC20 behaviors. contract RewardDistributor is Ownable { + using SafeERC20 for IERC20; + /// @notice Amount of gas forwarded to each transfer call. /// @dev The recipient group is assumed to be a known group of contracts that won't consume more than this amount. uint256 public constant PER_RECIPIENT_GAS = 100_000; @@ -25,6 +34,8 @@ contract RewardDistributor is Ownable { /// @dev This ensures that all sends may always happen within a block. uint64 public constant MAX_RECIPIENTS = 64; + IERC20 public immutable token; + /// @notice Hash of concat'ed recipient group. bytes32 public currentRecipientGroup; /// @notice Hash of concat'ed recipient weights. @@ -40,15 +51,21 @@ contract RewardDistributor is Ownable { event RecipientsUpdated(bytes32 recipientGroup, address[] recipients, bytes32 recipientWeights, uint256[] weights); /// @notice It is assumed that all recipients are able to receive eth when called with value but no data + /// @param _token Address of the ERC20 token to distribute. Use address(0) for ether. /// @param recipients Addresses to receive rewards. /// @param weights Weights of each recipient in basis points. - constructor(address[] memory recipients, uint256[] memory weights) Ownable() { + constructor(address _token, address[] memory recipients, uint256[] memory weights) Ownable() { setRecipients(recipients, weights); + token = IERC20(_token); } /// @notice allows eth to be deposited into this contract - /// @dev this contract is expected to handle ether appearing in its balance as well as an explicit deposit - receive() external payable {} + /// @dev this contract is expected to handle ether appearing in its balance as well as an explicit deposit as long as token == address(0) + receive() external payable { + if (address(token) != address(0)) { + revert CannotReceiveNative(); + } + } /** * @notice Distributes previous rewards then updates the recipients to a new group. @@ -93,7 +110,7 @@ contract RewardDistributor is Ownable { } // calculate individual reward - uint256 rewards = address(this).balance; + uint256 rewards = address(token) == address(0) ? address(this).balance : token.balanceOf(address(this)); // the reminder will be kept in the contract uint256 rewardPerBps = rewards / BASIS_POINTS; if (rewardPerBps == 0) { @@ -109,13 +126,21 @@ contract RewardDistributor is Ownable { // if the recipient reentry to steal funds, the contract will not have sufficient // funds and revert when trying to send fund to the next recipient // if the recipient is the last, it doesn't matter since there are no extra fund to steal - (bool success,) = recipients[r].call{value: individualRewards, gas: PER_RECIPIENT_GAS}(""); + bool success; + if (address(token) == address(0)) { + (success,) = recipients[r].call{value: individualRewards, gas: PER_RECIPIENT_GAS}(""); + } else { + // we assume that this will never revert, because we know we have enough token and the token is "normal" + token.safeTransfer(recipients[r], individualRewards); + success = true; + } // if the funds failed to send we send them to the owner for safe keeping // then the owner will have the opportunity to distribute them out of band if (success) { emit RecipientRecieved(recipients[r], individualRewards); } else { + // this case will never be hit if we are using an ERC20 token // cache owner in memory address _owner = owner(); (bool ownerSuccess,) = _owner.call{value: individualRewards}(""); @@ -167,4 +192,27 @@ contract RewardDistributor is Ownable { emit RecipientsUpdated(recipientGroup, recipients, recipientWeights, weights); } + + /** + * @notice Allows the owner to call any address with a value and data. + * If the wrong asset is sent to the contract then this allows the owner to recover it. + * @dev Calls to the token address are not allowed. + * Calls with value are not allowed if the token address is 0. + * @param to Address to call + * @param value Callvalue to send + * @param data Calldata to send + */ + function rescue(address to, uint256 value, bytes memory data) external onlyOwner { + if (address(token) == address(0) && value > 0) { + revert CannotCallRescueWithValue(); + } + if (address(token) == to) { + revert CannotCallRescueToToken(); + } + + (bool success,) = to.call{value: value}(data); + if (!success) { + revert OwnerFailedRecieve(owner(), to, value); + } + } } diff --git a/test/RewardDistributor.t.sol b/test/RewardDistributor.t.sol index 41a1e7c..d1b488e 100644 --- a/test/RewardDistributor.t.sol +++ b/test/RewardDistributor.t.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.16; import "../src/RewardDistributor.sol"; import "./Reverter.sol"; import "./Empty.sol"; +import {ERC20PresetMinterPauser} from "openzeppelin-contracts/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; import "forge-std/Test.sol"; contract RewardDistributorTest is Test { @@ -61,7 +62,7 @@ contract RewardDistributorTest is Test { emit RecipientsUpdated( keccak256(abi.encodePacked(recipients)), recipients, keccak256(abi.encodePacked(weights)), weights ); - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); assertEq(rd.currentRecipientGroup(), keccak256(abi.encodePacked(recipients))); assertEq(rd.owner(), owner); @@ -69,12 +70,12 @@ contract RewardDistributorTest is Test { function testConstructorDoesNotAcceptEmpty() public withContext(0) { vm.expectRevert(EmptyRecipients.selector); - new RewardDistributor(recipients, weights); + new RewardDistributor(address(0), recipients, weights); } function testConstructorDoesNotAcceptPastLimit() public withContext(65) { vm.expectRevert(TooManyRecipients.selector); - new RewardDistributor(recipients, weights); + new RewardDistributor(address(0), recipients, weights); } function testConstructorInputLengthMismatch() public withContext(3) { @@ -82,11 +83,11 @@ contract RewardDistributorTest is Test { shortWeights[0] = weights[0]; shortWeights[1] = weights[1]; vm.expectRevert(InputLengthMismatch.selector); - new RewardDistributor(recipients, shortWeights); + new RewardDistributor(address(0), recipients, shortWeights); } function testUpdateDoesNotAcceptInvalidValues() public withContext(5) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); // increase the balance of rd uint256 reward = 1e8; @@ -124,7 +125,7 @@ contract RewardDistributorTest is Test { } function testDistributeAndUpdateRecipients() public withContext(64) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); // increase the balance of rd uint256 reward = 1e8; @@ -145,7 +146,7 @@ contract RewardDistributorTest is Test { } function testDistributeAndUpdateRecipientsNotOwner() public withContext(64) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); vm.stopPrank(); vm.startPrank(nobody); @@ -159,7 +160,7 @@ contract RewardDistributorTest is Test { } function testDistributeAndUpdateRecipientsBadPrevious() public withContext(64) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); uint256 reward = 1e8; vm.deal(address(rd), reward); @@ -177,15 +178,17 @@ contract RewardDistributorTest is Test { address zero = 0x0000000000000000000000000000000000000000; - function testDistributeRewards(uint256 reward) public withContext(3) { + function _distributeRewards(uint256 reward, bool useToken) public { // If reward is less than recipient.length, we expect to throw an error // see testLowSend vm.assume(reward >= BASIS_POINTS); - RewardDistributor rd = new RewardDistributor(recipients, weights); + ERC20PresetMinterPauser token = new ERC20PresetMinterPauser("token", "TKN"); + + RewardDistributor rd = new RewardDistributor(useToken ? address(token) : address(0), recipients, weights); // increase the balance of rd - vm.deal(address(rd), reward); + useToken ? token.mint(address(rd), reward) : vm.deal(address(rd), reward); vm.expectEmit(true, false, false, true); emit RecipientRecieved(recipients[0], reward / BASIS_POINTS * weights[0]); @@ -199,18 +202,23 @@ contract RewardDistributorTest is Test { // anyone should be able to call distributeRewards rd.distributeRewards(recipients, weights); - assertEq(recipients[0].balance, reward / BASIS_POINTS * weights[0], "a balance"); - assertEq(recipients[1].balance, reward / BASIS_POINTS * weights[1], "b balance"); - assertEq(recipients[2].balance, reward / BASIS_POINTS * weights[2], "c balance"); - assertEq(owner.balance, 0, "owner balance"); - assertEq(nobody.balance, 0, "nobody balance"); - assertEq(address(rd).balance, reward % BASIS_POINTS, "rewards balance"); + assertEq(useToken ? token.balanceOf(recipients[0]) : recipients[0].balance, reward / BASIS_POINTS * weights[0], "a balance"); + assertEq(useToken ? token.balanceOf(recipients[1]) : recipients[1].balance, reward / BASIS_POINTS * weights[1], "b balance"); + assertEq(useToken ? token.balanceOf(recipients[2]) : recipients[2].balance, reward / BASIS_POINTS * weights[2], "c balance"); + assertEq(useToken ? token.balanceOf(owner) : owner.balance, 0, "owner balance"); + assertEq(useToken ? token.balanceOf(nobody) : nobody.balance, 0, "nobody balance"); + assertEq(useToken ? token.balanceOf(address(rd)) : address(rd).balance, reward % BASIS_POINTS, "rewards balance"); + } + + function testDistributeRewards(uint256 reward) public withContext(3) { + _distributeRewards(reward, false); + _distributeRewards(reward, true); } function testLowSend(uint256 rewards) public withContext(8) { vm.assume(rewards < BASIS_POINTS); - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); vm.deal(address(rd), rewards); @@ -220,7 +228,7 @@ contract RewardDistributorTest is Test { function testDistributeRewardsDoesRefundsOwner(uint256 reward) public withContext(3) { vm.assume(reward >= BASIS_POINTS); - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); // the empty contract will revert when sending funds to it, as it doesn't // have a fallback. We set the c address to have this code @@ -248,7 +256,7 @@ contract RewardDistributorTest is Test { } function testDistributeRewardsDoesNotDistributeToEmpty() public withContext(3) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); // increase the balance of rd uint256 reward = 1e8; @@ -262,7 +270,7 @@ contract RewardDistributorTest is Test { } function testDistributeRewardsDoesNotDistributeWrongRecipients() public withContext(3) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); // increase the balance of rd uint256 reward = 1e8; @@ -282,7 +290,7 @@ contract RewardDistributorTest is Test { } function testDistributeRewardsDoesNotDistributeWrongWeights() public withContext(3) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); // increase the balance of rd uint256 reward = 1e8; @@ -303,7 +311,7 @@ contract RewardDistributorTest is Test { } function testDistributeRewardsDoesNotDistributeToWrongCount() public withContext(3) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); // increase the balance of rd uint256 reward = 1e8; @@ -320,7 +328,7 @@ contract RewardDistributorTest is Test { } function testDistributeRewardsFailsToRefundsOwner() public withContext(3) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); // the empty contract will revert when sending funds to it, as it doesn't // have a fallback. We set the c address and the owner to have this code @@ -342,7 +350,7 @@ contract RewardDistributorTest is Test { } function testDistributeRewardsInputLengthMismatch() public withContext(3) { - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); uint256[] memory shortWeights = new uint256[](2); shortWeights[0] = weights[0]; shortWeights[1] = weights[1]; @@ -356,7 +364,7 @@ contract RewardDistributorTest is Test { for (uint256 i = 0; i < recipients.length; i++) { recipients[i] = address(new Reverter()); } - RewardDistributor rd = new RewardDistributor(recipients, weights); + RewardDistributor rd = new RewardDistributor(address(0), recipients, weights); assertEq(MAX_RECIPIENTS, rd.MAX_RECIPIENTS()); uint256 rewards = 5 ether; @@ -414,4 +422,36 @@ contract RewardDistributorTest is Test { actual = uncheckedInc(type(uint256).max); assertEq(actual, expected, "incorrect overflow increment"); } + + function testCannotReceiveInERC20Mode() public { + recipients = makeRecipientGroup(3); + weights = makeRecipientWeights(3); + RewardDistributor rd = new RewardDistributor(address(1), recipients, weights); + + vm.deal(address(this), 1); + (bool success, bytes memory ret) = address(rd).call{value: 1}(""); + + assertFalse(success); + assertEq(ret, abi.encodeWithSelector(CannotReceiveNative.selector)); + } + + function testRescue() public { + recipients = makeRecipientGroup(3); + weights = makeRecipientWeights(3); + RewardDistributor rdEth = new RewardDistributor(address(0), recipients, weights); + RewardDistributor rdToken = new RewardDistributor(address(1), recipients, weights); + + // rescue should be only owner + vm.prank(nobody); + vm.expectRevert("Ownable: caller is not the owner"); + rdEth.rescue(address(0), 1, ""); + + // cannot send value in rdEth.rescue + vm.expectRevert(CannotCallRescueWithValue.selector); + rdEth.rescue(address(0), 1, ""); + + // cannot call token contract in rdToken.rescue + vm.expectRevert(CannotCallRescueToToken.selector); + rdToken.rescue(address(1), 1, ""); + } } diff --git a/test/e2e/FundRouters.test.ts b/test/e2e/FundRouters.test.ts index 342e87d..771c2f0 100644 --- a/test/e2e/FundRouters.test.ts +++ b/test/e2e/FundRouters.test.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import { TestSetup, testSetup } from "./testSetup"; -import { BigNumber, utils, Wallet } from "ethers"; +import { BigNumber, ethers, utils, Wallet } from "ethers"; import { ParentToChildRewardRouter__factory, ParentToChildRewardRouter, @@ -115,7 +115,7 @@ describe("Router e2e test", () => { rewardDistributor = await new RewardDistributor__factory( setup.l2Signer - ).deploy([childToParentRewardRouter.address], [10000]); + ).deploy(ethers.constants.AddressZero, [childToParentRewardRouter.address], [10000]); console.log("Reward Distributor deployed:", rewardDistributor.address); });