-
Notifications
You must be signed in to change notification settings - Fork 75
feat: add merkle distributor contract #185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nicholaspai
merged 2 commits into
master
from
amatei/acx-148-move-merkledistributor-contracts-scripts
Sep 17, 2022
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| // SPDX-License-Identifier: AGPL-3.0-only | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; | ||
| import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; | ||
| import "@openzeppelin/contracts/access/Ownable.sol"; | ||
| import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
|
|
||
| /** | ||
| * Inspired by: | ||
| * - https://github.com/pie-dao/vested-token-migration-app | ||
| * - https://github.com/Uniswap/merkle-distributor | ||
| * - https://github.com/balancer-labs/erc20-redeemable | ||
| * | ||
| * @title MerkleDistributor contract. | ||
| * @notice Allows an owner to distribute any reward ERC20 to claimants according to Merkle roots. The owner can specify | ||
| * multiple Merkle roots distributions with customized reward currencies. | ||
| * @dev The Merkle trees are not validated in any way, so the system assumes the contract owner behaves honestly. | ||
| */ | ||
| contract MerkleDistributor is Ownable { | ||
| using SafeERC20 for IERC20; | ||
|
|
||
| // A Window maps a Merkle root to a reward token address. | ||
| struct Window { | ||
| // Merkle root describing the distribution. | ||
| bytes32 merkleRoot; | ||
| // Remaining amount of deposited rewards that have not yet been claimed. | ||
| uint256 remainingAmount; | ||
| // Currency in which reward is processed. | ||
| IERC20 rewardToken; | ||
| // IPFS hash of the merkle tree. Can be used to independently fetch recipient proofs and tree. Note that the canonical | ||
| // data type for storing an IPFS hash is a multihash which is the concatenation of <varint hash function code> | ||
| // <varint digest size in bytes><hash function output>. We opted to store this in a string type to make it easier | ||
| // for users to query the ipfs data without needing to reconstruct the multihash. to view the IPFS data simply | ||
| // go to https://cloudflare-ipfs.com/ipfs/<IPFS-HASH>. | ||
| string ipfsHash; | ||
| } | ||
|
|
||
| // Represents an account's claim for `amount` within the Merkle root located at the `windowIndex`. | ||
| struct Claim { | ||
| uint256 windowIndex; | ||
| uint256 amount; | ||
| uint256 accountIndex; // Used only for bitmap. Assumed to be unique for each claim. | ||
| address account; | ||
| bytes32[] merkleProof; | ||
| } | ||
|
|
||
| // Windows are mapped to arbitrary indices. | ||
| mapping(uint256 => Window) public merkleWindows; | ||
|
|
||
| // Index of next created Merkle root. | ||
| uint256 public nextCreatedIndex; | ||
|
|
||
| // Track which accounts have claimed for each window index. | ||
| // Note: uses a packed array of bools for gas optimization on tracking certain claims. Copied from Uniswap's contract. | ||
| mapping(uint256 => mapping(uint256 => uint256)) private claimedBitMap; | ||
|
|
||
| /**************************************** | ||
| * EVENTS | ||
| ****************************************/ | ||
| event Claimed( | ||
| address indexed caller, | ||
| uint256 windowIndex, | ||
| address indexed account, | ||
| uint256 accountIndex, | ||
| uint256 amount, | ||
| address indexed rewardToken | ||
| ); | ||
| event CreatedWindow( | ||
| uint256 indexed windowIndex, | ||
| uint256 rewardsDeposited, | ||
| address indexed rewardToken, | ||
| address owner | ||
| ); | ||
| event WithdrawRewards(address indexed owner, uint256 amount, address indexed currency); | ||
| event DeleteWindow(uint256 indexed windowIndex, address owner); | ||
|
|
||
| /**************************** | ||
| * ADMIN FUNCTIONS | ||
| ****************************/ | ||
|
|
||
| /** | ||
| * @notice Set merkle root for the next available window index and seed allocations. | ||
| * @notice Callable only by owner of this contract. Caller must have approved this contract to transfer | ||
| * `rewardsToDeposit` amount of `rewardToken` or this call will fail. Importantly, we assume that the | ||
| * owner of this contract correctly chooses an amount `rewardsToDeposit` that is sufficient to cover all | ||
| * claims within the `merkleRoot`. | ||
| * @param rewardsToDeposit amount of rewards to deposit to seed this allocation. | ||
| * @param rewardToken ERC20 reward token. | ||
| * @param merkleRoot merkle root describing allocation. | ||
| * @param ipfsHash hash of IPFS object, conveniently stored for clients | ||
| */ | ||
| function setWindow( | ||
| uint256 rewardsToDeposit, | ||
| address rewardToken, | ||
| bytes32 merkleRoot, | ||
| string calldata ipfsHash | ||
| ) external onlyOwner { | ||
| uint256 indexToSet = nextCreatedIndex; | ||
| nextCreatedIndex = indexToSet + 1; | ||
|
|
||
| _setWindow(indexToSet, rewardsToDeposit, rewardToken, merkleRoot, ipfsHash); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Delete merkle root at window index. | ||
| * @dev Callable only by owner. Likely to be followed by a withdrawRewards call to clear contract state. | ||
| * @param windowIndex merkle root index to delete. | ||
| */ | ||
| function deleteWindow(uint256 windowIndex) external onlyOwner { | ||
| delete merkleWindows[windowIndex]; | ||
| emit DeleteWindow(windowIndex, msg.sender); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Emergency method that transfers rewards out of the contract if the contract was configured improperly. | ||
| * @dev Callable only by owner. | ||
| * @param rewardCurrency rewards to withdraw from contract. | ||
| * @param amount amount of rewards to withdraw. | ||
| */ | ||
| function withdrawRewards(IERC20 rewardCurrency, uint256 amount) external onlyOwner { | ||
| rewardCurrency.safeTransfer(msg.sender, amount); | ||
| emit WithdrawRewards(msg.sender, amount, address(rewardCurrency)); | ||
| } | ||
|
|
||
| /**************************** | ||
| * NON-ADMIN FUNCTIONS | ||
| ****************************/ | ||
|
|
||
| /** | ||
| * @notice Batch claims to reduce gas versus individual submitting all claims. Method will fail | ||
| * if any individual claims within the batch would fail. | ||
| * @dev Optimistically tries to batch together consecutive claims for the same account and same | ||
| * reward token to reduce gas. Therefore, the most gas-cost-optimal way to use this method | ||
| * is to pass in an array of claims sorted by account and reward currency. It also reverts | ||
| * when any of individual `_claim`'s `amount` exceeds `remainingAmount` for its window. | ||
| * @param claims array of claims to claim. | ||
| */ | ||
| function claimMulti(Claim[] memory claims) external { | ||
| uint256 batchedAmount; | ||
| uint256 claimCount = claims.length; | ||
| for (uint256 i = 0; i < claimCount; i++) { | ||
| Claim memory _claim = claims[i]; | ||
| _verifyAndMarkClaimed(_claim); | ||
| batchedAmount += _claim.amount; | ||
|
|
||
| // If the next claim is NOT the same account or the same token (or this claim is the last one), | ||
| // then disburse the `batchedAmount` to the current claim's account for the current claim's reward token. | ||
| uint256 nextI = i + 1; | ||
| IERC20 currentRewardToken = merkleWindows[_claim.windowIndex].rewardToken; | ||
| if ( | ||
| nextI == claimCount || | ||
| // This claim is last claim. | ||
| claims[nextI].account != _claim.account || | ||
| // Next claim account is different than current one. | ||
| merkleWindows[claims[nextI].windowIndex].rewardToken != currentRewardToken | ||
| // Next claim reward token is different than current one. | ||
| ) { | ||
| merkleWindows[_claim.windowIndex].remainingAmount -= batchedAmount; | ||
| currentRewardToken.safeTransfer(_claim.account, batchedAmount); | ||
| batchedAmount = 0; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @notice Claim amount of reward tokens for account, as described by Claim input object. | ||
| * @dev If the `_claim`'s `amount`, `accountIndex`, and `account` do not exactly match the | ||
| * values stored in the merkle root for the `_claim`'s `windowIndex` this method | ||
| * will revert. It also reverts when `_claim`'s `amount` exceeds `remainingAmount` for the window. | ||
| * @param _claim claim object describing amount, accountIndex, account, window index, and merkle proof. | ||
| */ | ||
| function claim(Claim memory _claim) external { | ||
| _verifyAndMarkClaimed(_claim); | ||
| merkleWindows[_claim.windowIndex].remainingAmount -= _claim.amount; | ||
| merkleWindows[_claim.windowIndex].rewardToken.safeTransfer(_claim.account, _claim.amount); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Returns True if the claim for `accountIndex` has already been completed for the Merkle root at | ||
| * `windowIndex`. | ||
| * @dev This method will only work as intended if all `accountIndex`'s are unique for a given `windowIndex`. | ||
| * The onus is on the Owner of this contract to submit only valid Merkle roots. | ||
| * @param windowIndex merkle root to check. | ||
| * @param accountIndex account index to check within window index. | ||
| * @return True if claim has been executed already, False otherwise. | ||
| */ | ||
| function isClaimed(uint256 windowIndex, uint256 accountIndex) public view returns (bool) { | ||
| uint256 claimedWordIndex = accountIndex / 256; | ||
| uint256 claimedBitIndex = accountIndex % 256; | ||
| uint256 claimedWord = claimedBitMap[windowIndex][claimedWordIndex]; | ||
| uint256 mask = (1 << claimedBitIndex); | ||
| return claimedWord & mask == mask; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Returns True if leaf described by {account, amount, accountIndex} is stored in Merkle root at given | ||
| * window index. | ||
| * @param _claim claim object describing amount, accountIndex, account, window index, and merkle proof. | ||
| * @return valid True if leaf exists. | ||
| */ | ||
| function verifyClaim(Claim memory _claim) public view returns (bool valid) { | ||
| bytes32 leaf = keccak256(abi.encodePacked(_claim.account, _claim.amount, _claim.accountIndex)); | ||
| return MerkleProof.verify(_claim.merkleProof, merkleWindows[_claim.windowIndex].merkleRoot, leaf); | ||
| } | ||
|
|
||
| /**************************** | ||
| * PRIVATE FUNCTIONS | ||
| ****************************/ | ||
|
|
||
| // Mark claim as completed for `accountIndex` for Merkle root at `windowIndex`. | ||
| function _setClaimed(uint256 windowIndex, uint256 accountIndex) private { | ||
| uint256 claimedWordIndex = accountIndex / 256; | ||
| uint256 claimedBitIndex = accountIndex % 256; | ||
| claimedBitMap[windowIndex][claimedWordIndex] = | ||
| claimedBitMap[windowIndex][claimedWordIndex] | | ||
| (1 << claimedBitIndex); | ||
| } | ||
|
|
||
| // Store new Merkle root at `windowindex`. Pull `rewardsDeposited` from caller to seed distribution for this root. | ||
| function _setWindow( | ||
| uint256 windowIndex, | ||
| uint256 rewardsDeposited, | ||
| address rewardToken, | ||
| bytes32 merkleRoot, | ||
| string memory ipfsHash | ||
| ) private { | ||
| Window storage window = merkleWindows[windowIndex]; | ||
| window.merkleRoot = merkleRoot; | ||
| window.remainingAmount = rewardsDeposited; | ||
| window.rewardToken = IERC20(rewardToken); | ||
| window.ipfsHash = ipfsHash; | ||
|
|
||
| emit CreatedWindow(windowIndex, rewardsDeposited, rewardToken, msg.sender); | ||
|
|
||
| window.rewardToken.safeTransferFrom(msg.sender, address(this), rewardsDeposited); | ||
| } | ||
|
|
||
| // Verify claim is valid and mark it as completed in this contract. | ||
| function _verifyAndMarkClaimed(Claim memory _claim) private { | ||
| // Check claimed proof against merkle window at given index. | ||
| require(verifyClaim(_claim), "Incorrect merkle proof"); | ||
| // Check the account has not yet claimed for this window. | ||
| require(!isClaimed(_claim.windowIndex, _claim.accountIndex), "Account has already claimed for this window"); | ||
|
|
||
| // Proof is correct and claim has not occurred yet, mark claimed complete. | ||
| _setClaimed(_claim.windowIndex, _claim.accountIndex); | ||
| emit Claimed( | ||
| msg.sender, | ||
| _claim.windowIndex, | ||
| _claim.account, | ||
| _claim.accountIndex, | ||
| _claim.amount, | ||
| address(merkleWindows[_claim.windowIndex].rewardToken) | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When you say "based on" do you mean directly copied? Or are there changes? If there are changes, we will likely need to have this re-audited.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The contract itself is identical