Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions script/arb1-deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion script/deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 4 additions & 4 deletions script/nova-deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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));
Expand Down
37 changes: 30 additions & 7 deletions src/RewardDistributor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -13,10 +16,14 @@ error NoFundsToDistribute();
error InputLengthMismatch();
error InvalidTotalWeight(uint256 totalWeight);

/// @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 or other non standard behavior.
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;
Expand All @@ -25,6 +32,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.
Expand All @@ -40,15 +49,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.
Expand Down Expand Up @@ -93,7 +108,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) {
Expand All @@ -109,13 +124,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}("");
Expand Down
72 changes: 46 additions & 26 deletions test/RewardDistributor.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -61,32 +62,32 @@ 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);
}

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) {
uint256[] memory shortWeights = new uint256[](2);
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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]);
Expand All @@ -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);

Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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];
Expand All @@ -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;
Expand Down Expand Up @@ -414,4 +422,16 @@ 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));
}
}
4 changes: 2 additions & 2 deletions test/e2e/FundRouters.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});

Expand Down
Loading