Skip to content

Commit f42e843

Browse files
authored
feat: add merkle distributor contract (#185)
* feat: add merkle distributor contract Signed-off-by: amatei <[email protected]> * Replace assert with expect Signed-off-by: amatei <[email protected]> Signed-off-by: amatei <[email protected]>
1 parent 9656a97 commit f42e843

File tree

5 files changed

+1920
-67
lines changed

5 files changed

+1920
-67
lines changed
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
pragma solidity ^0.8.0;
3+
4+
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
5+
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
6+
import "@openzeppelin/contracts/access/Ownable.sol";
7+
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
8+
9+
/**
10+
* Inspired by:
11+
* - https://github.com/pie-dao/vested-token-migration-app
12+
* - https://github.com/Uniswap/merkle-distributor
13+
* - https://github.com/balancer-labs/erc20-redeemable
14+
*
15+
* @title MerkleDistributor contract.
16+
* @notice Allows an owner to distribute any reward ERC20 to claimants according to Merkle roots. The owner can specify
17+
* multiple Merkle roots distributions with customized reward currencies.
18+
* @dev The Merkle trees are not validated in any way, so the system assumes the contract owner behaves honestly.
19+
*/
20+
contract MerkleDistributor is Ownable {
21+
using SafeERC20 for IERC20;
22+
23+
// A Window maps a Merkle root to a reward token address.
24+
struct Window {
25+
// Merkle root describing the distribution.
26+
bytes32 merkleRoot;
27+
// Remaining amount of deposited rewards that have not yet been claimed.
28+
uint256 remainingAmount;
29+
// Currency in which reward is processed.
30+
IERC20 rewardToken;
31+
// IPFS hash of the merkle tree. Can be used to independently fetch recipient proofs and tree. Note that the canonical
32+
// data type for storing an IPFS hash is a multihash which is the concatenation of <varint hash function code>
33+
// <varint digest size in bytes><hash function output>. We opted to store this in a string type to make it easier
34+
// for users to query the ipfs data without needing to reconstruct the multihash. to view the IPFS data simply
35+
// go to https://cloudflare-ipfs.com/ipfs/<IPFS-HASH>.
36+
string ipfsHash;
37+
}
38+
39+
// Represents an account's claim for `amount` within the Merkle root located at the `windowIndex`.
40+
struct Claim {
41+
uint256 windowIndex;
42+
uint256 amount;
43+
uint256 accountIndex; // Used only for bitmap. Assumed to be unique for each claim.
44+
address account;
45+
bytes32[] merkleProof;
46+
}
47+
48+
// Windows are mapped to arbitrary indices.
49+
mapping(uint256 => Window) public merkleWindows;
50+
51+
// Index of next created Merkle root.
52+
uint256 public nextCreatedIndex;
53+
54+
// Track which accounts have claimed for each window index.
55+
// Note: uses a packed array of bools for gas optimization on tracking certain claims. Copied from Uniswap's contract.
56+
mapping(uint256 => mapping(uint256 => uint256)) private claimedBitMap;
57+
58+
/****************************************
59+
* EVENTS
60+
****************************************/
61+
event Claimed(
62+
address indexed caller,
63+
uint256 windowIndex,
64+
address indexed account,
65+
uint256 accountIndex,
66+
uint256 amount,
67+
address indexed rewardToken
68+
);
69+
event CreatedWindow(
70+
uint256 indexed windowIndex,
71+
uint256 rewardsDeposited,
72+
address indexed rewardToken,
73+
address owner
74+
);
75+
event WithdrawRewards(address indexed owner, uint256 amount, address indexed currency);
76+
event DeleteWindow(uint256 indexed windowIndex, address owner);
77+
78+
/****************************
79+
* ADMIN FUNCTIONS
80+
****************************/
81+
82+
/**
83+
* @notice Set merkle root for the next available window index and seed allocations.
84+
* @notice Callable only by owner of this contract. Caller must have approved this contract to transfer
85+
* `rewardsToDeposit` amount of `rewardToken` or this call will fail. Importantly, we assume that the
86+
* owner of this contract correctly chooses an amount `rewardsToDeposit` that is sufficient to cover all
87+
* claims within the `merkleRoot`.
88+
* @param rewardsToDeposit amount of rewards to deposit to seed this allocation.
89+
* @param rewardToken ERC20 reward token.
90+
* @param merkleRoot merkle root describing allocation.
91+
* @param ipfsHash hash of IPFS object, conveniently stored for clients
92+
*/
93+
function setWindow(
94+
uint256 rewardsToDeposit,
95+
address rewardToken,
96+
bytes32 merkleRoot,
97+
string calldata ipfsHash
98+
) external onlyOwner {
99+
uint256 indexToSet = nextCreatedIndex;
100+
nextCreatedIndex = indexToSet + 1;
101+
102+
_setWindow(indexToSet, rewardsToDeposit, rewardToken, merkleRoot, ipfsHash);
103+
}
104+
105+
/**
106+
* @notice Delete merkle root at window index.
107+
* @dev Callable only by owner. Likely to be followed by a withdrawRewards call to clear contract state.
108+
* @param windowIndex merkle root index to delete.
109+
*/
110+
function deleteWindow(uint256 windowIndex) external onlyOwner {
111+
delete merkleWindows[windowIndex];
112+
emit DeleteWindow(windowIndex, msg.sender);
113+
}
114+
115+
/**
116+
* @notice Emergency method that transfers rewards out of the contract if the contract was configured improperly.
117+
* @dev Callable only by owner.
118+
* @param rewardCurrency rewards to withdraw from contract.
119+
* @param amount amount of rewards to withdraw.
120+
*/
121+
function withdrawRewards(IERC20 rewardCurrency, uint256 amount) external onlyOwner {
122+
rewardCurrency.safeTransfer(msg.sender, amount);
123+
emit WithdrawRewards(msg.sender, amount, address(rewardCurrency));
124+
}
125+
126+
/****************************
127+
* NON-ADMIN FUNCTIONS
128+
****************************/
129+
130+
/**
131+
* @notice Batch claims to reduce gas versus individual submitting all claims. Method will fail
132+
* if any individual claims within the batch would fail.
133+
* @dev Optimistically tries to batch together consecutive claims for the same account and same
134+
* reward token to reduce gas. Therefore, the most gas-cost-optimal way to use this method
135+
* is to pass in an array of claims sorted by account and reward currency. It also reverts
136+
* when any of individual `_claim`'s `amount` exceeds `remainingAmount` for its window.
137+
* @param claims array of claims to claim.
138+
*/
139+
function claimMulti(Claim[] memory claims) external {
140+
uint256 batchedAmount;
141+
uint256 claimCount = claims.length;
142+
for (uint256 i = 0; i < claimCount; i++) {
143+
Claim memory _claim = claims[i];
144+
_verifyAndMarkClaimed(_claim);
145+
batchedAmount += _claim.amount;
146+
147+
// If the next claim is NOT the same account or the same token (or this claim is the last one),
148+
// then disburse the `batchedAmount` to the current claim's account for the current claim's reward token.
149+
uint256 nextI = i + 1;
150+
IERC20 currentRewardToken = merkleWindows[_claim.windowIndex].rewardToken;
151+
if (
152+
nextI == claimCount ||
153+
// This claim is last claim.
154+
claims[nextI].account != _claim.account ||
155+
// Next claim account is different than current one.
156+
merkleWindows[claims[nextI].windowIndex].rewardToken != currentRewardToken
157+
// Next claim reward token is different than current one.
158+
) {
159+
merkleWindows[_claim.windowIndex].remainingAmount -= batchedAmount;
160+
currentRewardToken.safeTransfer(_claim.account, batchedAmount);
161+
batchedAmount = 0;
162+
}
163+
}
164+
}
165+
166+
/**
167+
* @notice Claim amount of reward tokens for account, as described by Claim input object.
168+
* @dev If the `_claim`'s `amount`, `accountIndex`, and `account` do not exactly match the
169+
* values stored in the merkle root for the `_claim`'s `windowIndex` this method
170+
* will revert. It also reverts when `_claim`'s `amount` exceeds `remainingAmount` for the window.
171+
* @param _claim claim object describing amount, accountIndex, account, window index, and merkle proof.
172+
*/
173+
function claim(Claim memory _claim) external {
174+
_verifyAndMarkClaimed(_claim);
175+
merkleWindows[_claim.windowIndex].remainingAmount -= _claim.amount;
176+
merkleWindows[_claim.windowIndex].rewardToken.safeTransfer(_claim.account, _claim.amount);
177+
}
178+
179+
/**
180+
* @notice Returns True if the claim for `accountIndex` has already been completed for the Merkle root at
181+
* `windowIndex`.
182+
* @dev This method will only work as intended if all `accountIndex`'s are unique for a given `windowIndex`.
183+
* The onus is on the Owner of this contract to submit only valid Merkle roots.
184+
* @param windowIndex merkle root to check.
185+
* @param accountIndex account index to check within window index.
186+
* @return True if claim has been executed already, False otherwise.
187+
*/
188+
function isClaimed(uint256 windowIndex, uint256 accountIndex) public view returns (bool) {
189+
uint256 claimedWordIndex = accountIndex / 256;
190+
uint256 claimedBitIndex = accountIndex % 256;
191+
uint256 claimedWord = claimedBitMap[windowIndex][claimedWordIndex];
192+
uint256 mask = (1 << claimedBitIndex);
193+
return claimedWord & mask == mask;
194+
}
195+
196+
/**
197+
* @notice Returns True if leaf described by {account, amount, accountIndex} is stored in Merkle root at given
198+
* window index.
199+
* @param _claim claim object describing amount, accountIndex, account, window index, and merkle proof.
200+
* @return valid True if leaf exists.
201+
*/
202+
function verifyClaim(Claim memory _claim) public view returns (bool valid) {
203+
bytes32 leaf = keccak256(abi.encodePacked(_claim.account, _claim.amount, _claim.accountIndex));
204+
return MerkleProof.verify(_claim.merkleProof, merkleWindows[_claim.windowIndex].merkleRoot, leaf);
205+
}
206+
207+
/****************************
208+
* PRIVATE FUNCTIONS
209+
****************************/
210+
211+
// Mark claim as completed for `accountIndex` for Merkle root at `windowIndex`.
212+
function _setClaimed(uint256 windowIndex, uint256 accountIndex) private {
213+
uint256 claimedWordIndex = accountIndex / 256;
214+
uint256 claimedBitIndex = accountIndex % 256;
215+
claimedBitMap[windowIndex][claimedWordIndex] =
216+
claimedBitMap[windowIndex][claimedWordIndex] |
217+
(1 << claimedBitIndex);
218+
}
219+
220+
// Store new Merkle root at `windowindex`. Pull `rewardsDeposited` from caller to seed distribution for this root.
221+
function _setWindow(
222+
uint256 windowIndex,
223+
uint256 rewardsDeposited,
224+
address rewardToken,
225+
bytes32 merkleRoot,
226+
string memory ipfsHash
227+
) private {
228+
Window storage window = merkleWindows[windowIndex];
229+
window.merkleRoot = merkleRoot;
230+
window.remainingAmount = rewardsDeposited;
231+
window.rewardToken = IERC20(rewardToken);
232+
window.ipfsHash = ipfsHash;
233+
234+
emit CreatedWindow(windowIndex, rewardsDeposited, rewardToken, msg.sender);
235+
236+
window.rewardToken.safeTransferFrom(msg.sender, address(this), rewardsDeposited);
237+
}
238+
239+
// Verify claim is valid and mark it as completed in this contract.
240+
function _verifyAndMarkClaimed(Claim memory _claim) private {
241+
// Check claimed proof against merkle window at given index.
242+
require(verifyClaim(_claim), "Incorrect merkle proof");
243+
// Check the account has not yet claimed for this window.
244+
require(!isClaimed(_claim.windowIndex, _claim.accountIndex), "Account has already claimed for this window");
245+
246+
// Proof is correct and claim has not occurred yet, mark claimed complete.
247+
_setClaimed(_claim.windowIndex, _claim.accountIndex);
248+
emit Claimed(
249+
msg.sender,
250+
_claim.windowIndex,
251+
_claim.account,
252+
_claim.accountIndex,
253+
_claim.amount,
254+
address(merkleWindows[_claim.windowIndex].rewardToken)
255+
);
256+
}
257+
}

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"type": "git",
88
"url": "git+https://github.com/across-protocol/across-smart-contracts-v2.git"
99
},
10+
"engines": {
11+
"node": ">=8.3.0"
12+
},
1013
"files": [
1114
"/contracts/**/*.sol",
1215
"/artifacts/**/*",
@@ -35,6 +38,7 @@
3538
"@uma/common": "^2.17.0",
3639
"@uma/contracts-node": "^0.2.0",
3740
"@uma/core": "^2.24.0",
41+
"@uma/merkle-distributor": "^1.3.27",
3842
"arb-bridge-eth": "^0.7.4",
3943
"arb-bridge-peripherals": "^1.0.5"
4044
},

0 commit comments

Comments
 (0)