Skip to content

Commit 3d6bdb2

Browse files
authored
GIP-4: Decentralized Payout/Slashing (#244)
* GIP-4: Decentralized Payout/Slashing (#244)
1 parent 71d1888 commit 3d6bdb2

File tree

26 files changed

+2333
-0
lines changed

26 files changed

+2333
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.16;
3+
4+
// Allows anyone to claim a token if they exist in a merkle root.
5+
interface IPayoutsController {
6+
// Returns the address of the token distributed by this contract.
7+
function getToken() external view returns (address);
8+
9+
// Returns the merkle root of the merkle tree containing account balances available to claim.
10+
function getMerkleRoot(uint256 epochId) external view returns (bytes32);
11+
12+
// Returns the last epoch added.
13+
function getLastEpoch() external view returns (uint256);
14+
15+
// Returns true if the index has been marked claimed.
16+
function isClaimed(uint256 epochId, uint256 index)
17+
external
18+
view
19+
returns (bool);
20+
21+
// Add a new Merkle Root and epoch.
22+
function addMerkleRoot(bytes32 merkleRoot) external;
23+
24+
// Claim the given amount of the token to the given address. Reverts if the inputs are invalid.
25+
function claim(
26+
uint256 epochId,
27+
uint256 index,
28+
address account,
29+
uint256 amount,
30+
bytes32[] calldata merkleProof
31+
) external;
32+
33+
event Claimed(
34+
uint256 epochId,
35+
uint256 index,
36+
address account,
37+
uint256 amount
38+
);
39+
event MerkleRootAdded(uint256 newEpochId, bytes32 merkleRoot);
40+
event RewardPricesChanged(uint256[] rewardPrices);
41+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.16;
3+
4+
import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol';
5+
import {MerkleProof} from '@openzeppelin/contracts/utils/cryptography/MerkleProof.sol';
6+
import '@openzeppelin/contracts/utils/Counters.sol';
7+
import './IPayoutsController.sol';
8+
import {IERC20, SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
9+
10+
error AlreadyClaimed();
11+
error InvalidProof();
12+
13+
/// @custom:security-contact [email protected]
14+
contract PayoutsController is
15+
Initializable,
16+
OwnableUpgradeable,
17+
IPayoutsController
18+
{
19+
using SafeERC20 for IERC20;
20+
using Counters for Counters.Counter;
21+
22+
// ============ Private Storage ============
23+
24+
address private _token;
25+
mapping(uint256 => bytes32) private _merkleRoots;
26+
Counters.Counter private _epochIds;
27+
28+
// This is a mapping from epochIds to a packed array of booleans.
29+
mapping(uint256 => mapping(uint256 => uint256))
30+
private claimedBitMapByEpoch;
31+
32+
function getToken() external view returns (address) {
33+
return _token;
34+
}
35+
36+
function setToken(address token) external onlyOwner {
37+
_token = token;
38+
}
39+
40+
function getLastEpoch() external view returns (uint256) {
41+
return _epochIds.current();
42+
}
43+
44+
function getMerkleRoot(uint256 epochId) external view returns (bytes32) {
45+
return _merkleRoots[epochId];
46+
}
47+
48+
function initialize(address token) public initializer {
49+
__Ownable_init();
50+
_token = token;
51+
}
52+
53+
function addMerkleRoot(bytes32 merkleRoot) external onlyOwner {
54+
require(merkleRoot.length > 0, 'Empty merkleRoot');
55+
// Start at index 1
56+
_epochIds.increment();
57+
uint256 newEpochId = _epochIds.current();
58+
_merkleRoots[newEpochId] = merkleRoot;
59+
emit MerkleRootAdded(newEpochId, merkleRoot);
60+
}
61+
62+
function isClaimed(uint256 epochId, uint256 index)
63+
public
64+
view
65+
returns (bool)
66+
{
67+
uint256 claimedWordIndex = index / 256;
68+
uint256 claimedBitIndex = index % 256;
69+
uint256 claimedWord = claimedBitMapByEpoch[epochId][claimedWordIndex];
70+
uint256 mask = (1 << claimedBitIndex);
71+
return claimedWord & mask == mask;
72+
}
73+
74+
function _setClaimed(uint256 epochId, uint256 index) private {
75+
uint256 claimedWordIndex = index / 256;
76+
uint256 claimedBitIndex = index % 256;
77+
claimedBitMapByEpoch[epochId][claimedWordIndex] =
78+
claimedBitMapByEpoch[epochId][claimedWordIndex] |
79+
(1 << claimedBitIndex);
80+
}
81+
82+
function claim(
83+
uint256 epochId,
84+
uint256 index,
85+
address account,
86+
uint256 amount,
87+
bytes32[] calldata merkleProof
88+
) public {
89+
if (isClaimed(index, epochId)) revert AlreadyClaimed();
90+
bytes32 merkleRoot = _merkleRoots[epochId];
91+
require(merkleRoot.length > 0, 'Empty merkleRoot');
92+
93+
// Verify the merkle proof.
94+
bytes32 node = keccak256(abi.encodePacked(index, account, amount));
95+
require(node.length > 0, 'Empty node');
96+
if (!MerkleProof.verify(merkleProof, merkleRoot, node))
97+
revert InvalidProof();
98+
99+
// Mark it claimed and send the token.
100+
_setClaimed(epochId, index);
101+
IERC20(_token).safeTransfer(account, amount);
102+
103+
emit Claimed(epochId, index, account, amount);
104+
}
105+
}

deploy/5_PayoutsController.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { HardhatRuntimeEnvironment } from 'hardhat/types';
2+
import { DeployFunction } from 'hardhat-deploy/types';
3+
import { network } from 'hardhat';
4+
5+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
6+
// @ts-ignore
7+
import testHelpersConfig from '@openzeppelin/test-helpers/configure';
8+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9+
// @ts-ignore
10+
import { singletons } from '@openzeppelin/test-helpers';
11+
import { deployerAddress } from '../hardhat.config';
12+
13+
testHelpersConfig({ provider: network.provider });
14+
15+
const contractName = 'PayoutsController';
16+
17+
const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
18+
const { deployments, getNamedAccounts, getUnnamedAccounts, network } = hre;
19+
const { deploy, catchUnknownSigner } = deployments;
20+
21+
const { deployer } = await getNamedAccounts();
22+
23+
const dev = ['hardhat', 'localhost'].includes(network.name);
24+
25+
const GoldenTokenDeployment = await deployments.get('GoldenToken');
26+
const goldenTokenAddress = GoldenTokenDeployment.address;
27+
28+
if (network.name === 'hardhat') {
29+
const users = await getUnnamedAccounts();
30+
await singletons.ERC1820Registry(users[0]);
31+
}
32+
const depl = dev ? deployer : deployerAddress;
33+
await catchUnknownSigner(
34+
deploy(contractName, {
35+
from: depl,
36+
log: true,
37+
proxy: {
38+
proxyContract: 'OpenZeppelinTransparentProxy',
39+
owner: depl,
40+
execute: {
41+
init: {
42+
methodName: 'initialize',
43+
args: [goldenTokenAddress],
44+
},
45+
},
46+
},
47+
})
48+
);
49+
};
50+
51+
deploy.id = 'deploy_payouts_controller';
52+
deploy.tags = [contractName];
53+
deploy.dependencies = ['GoldenToken'];
54+
55+
export default deploy;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"dependencies": {
3939
"@gnosis.pm/safe-core-sdk": "^3.1.1",
4040
"@gnosis.pm/safe-ethers-lib": "^1.6.1",
41+
"ethereumjs-util": "^7.1.5",
4142
"global": "^4.4.0"
4243
},
4344
"devDependencies": {

test/payouts/PayoutsController.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import chai, { expect } from 'chai';
2+
import { deployments, ethers } from 'hardhat';
3+
import type { PayoutsController } from '../../typechain/contracts/payouts/PayoutsController';
4+
import { BigNumber } from 'ethers';
5+
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers';
6+
import getRandomBytesHexString from '../utils/getRandomBytesHexString';
7+
import BalanceTree from '../../trees/balance-tree';
8+
import { GoldenToken } from '../../typechain';
9+
chai.config.includeStack = true;
10+
chai.Assertion.includeStack = true;
11+
12+
const address1 = '0x9ED5724f7dc9eCDd6b48185F875A8779c2059533';
13+
const address2 = '0x61bfCd8d7fcbd61508027Ba5935176A3298E941e';
14+
15+
const ownableError = 'Ownable: caller is not the owner';
16+
const overrides = {
17+
gasLimit: 9999999,
18+
};
19+
20+
describe('PayoutsController', function () {
21+
let PayoutsController: PayoutsController;
22+
let GoldenToken: GoldenToken;
23+
let owner: SignerWithAddress;
24+
25+
beforeEach(async function () {
26+
await deployments.fixture(['GoldenToken']);
27+
await deployments.fixture(['PayoutsController']);
28+
PayoutsController = await ethers.getContract('PayoutsController');
29+
GoldenToken = await ethers.getContract('GoldenToken');
30+
expect(await PayoutsController.getToken()).to.equal(GoldenToken.address);
31+
const [deployer] = await ethers.getSigners();
32+
owner = deployer;
33+
GoldenToken.connect(owner);
34+
await GoldenToken.transfer(
35+
PayoutsController.address,
36+
'1000000000000000000000'
37+
);
38+
expect(await GoldenToken.balanceOf(PayoutsController.address)).to.equal(
39+
'1000000000000000000000'
40+
);
41+
});
42+
43+
describe('Test functions', function () {
44+
it('Should test contract functions', async () => {
45+
expect(await PayoutsController.getLastEpoch()).to.equal(0);
46+
const randomMerkleTree = getRandomBytesHexString(32);
47+
await PayoutsController.addMerkleRoot(randomMerkleTree);
48+
expect(await PayoutsController.getLastEpoch()).to.equal(1);
49+
});
50+
});
51+
52+
describe('Access control', function () {
53+
it('Should test onlyOwner functions', async () => {
54+
await PayoutsController.setToken(address1);
55+
expect(await PayoutsController.getToken()).to.equal(address1);
56+
const randomMerkleTree = getRandomBytesHexString(32);
57+
await PayoutsController.addMerkleRoot(randomMerkleTree);
58+
expect(
59+
await PayoutsController.getMerkleRoot(
60+
await PayoutsController.getLastEpoch()
61+
)
62+
).to.equal(randomMerkleTree);
63+
const randomMerkleTree2 = getRandomBytesHexString(32);
64+
await PayoutsController.addMerkleRoot(randomMerkleTree2);
65+
expect(
66+
await PayoutsController.getMerkleRoot(
67+
await PayoutsController.getLastEpoch()
68+
)
69+
).to.equal(randomMerkleTree2);
70+
expect(
71+
await PayoutsController.getMerkleRoot(
72+
(await PayoutsController.getLastEpoch()).sub(1)
73+
)
74+
).to.equal(randomMerkleTree);
75+
});
76+
it('Should fail calling onlyOwner functions', async () => {
77+
await PayoutsController.transferOwnership(
78+
'0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65'
79+
);
80+
await expect(PayoutsController.setToken(address1)).to.be.revertedWith(
81+
ownableError
82+
);
83+
const randomMerkleTree = getRandomBytesHexString(32);
84+
await expect(
85+
PayoutsController.addMerkleRoot(randomMerkleTree)
86+
).to.be.revertedWith(ownableError);
87+
});
88+
it('Should test claiming functions', async () => {
89+
const tree = new BalanceTree([
90+
{ account: address1, amount: BigNumber.from(100) },
91+
{ account: address2, amount: BigNumber.from(101) },
92+
]);
93+
await PayoutsController.addMerkleRoot(tree.getHexRoot());
94+
const proof1 = tree.getProof(0, address1, BigNumber.from(100));
95+
expect(await PayoutsController.isClaimed(1, 0)).to.equal(false);
96+
expect(await PayoutsController.isClaimed(1, 1)).to.equal(false);
97+
expect(await GoldenToken.balanceOf(address1)).to.equal('0');
98+
expect(await GoldenToken.balanceOf(address2)).to.equal('0');
99+
await expect(
100+
PayoutsController.claim(
101+
1,
102+
0,
103+
address1,
104+
BigNumber.from(100),
105+
proof1,
106+
overrides
107+
)
108+
)
109+
.to.emit(PayoutsController, 'Claimed')
110+
.withArgs(1, 0, address1, 100);
111+
expect(await GoldenToken.balanceOf(address1)).to.equal('100');
112+
expect(await GoldenToken.balanceOf(address2)).to.equal('0');
113+
expect(await PayoutsController.isClaimed(1, 0)).to.equal(true);
114+
expect(await PayoutsController.isClaimed(1, 1)).to.equal(false);
115+
const proof2 = tree.getProof(1, address2, BigNumber.from(101));
116+
await expect(
117+
PayoutsController.claim(
118+
1,
119+
1,
120+
address2,
121+
BigNumber.from(101),
122+
proof2,
123+
overrides
124+
)
125+
)
126+
.to.emit(PayoutsController, 'Claimed')
127+
.withArgs(1, 1, address2, 101);
128+
expect(await PayoutsController.isClaimed(1, 0)).to.equal(true);
129+
expect(await PayoutsController.isClaimed(1, 1)).to.equal(true);
130+
expect(await GoldenToken.balanceOf(address1)).to.equal('100');
131+
expect(await GoldenToken.balanceOf(address2)).to.equal('101');
132+
});
133+
});
134+
});

0 commit comments

Comments
 (0)