├── .husky └── pre-commit ├── remappings.txt ├── .gitignore ├── .vscode └── settings.json ├── foundry.toml ├── yarn.lock ├── package.json ├── script └── DeployUniversalRewardsDistributor.s.sol ├── .gitmodules ├── .github └── workflows │ └── test.yml ├── README.md ├── src ├── interfaces │ └── IUniversalRewardsDistributor.sol └── UniversalRewardsDistributor.sol └── test └── UniversalRouterDistributor.t.sol /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | forge fmt -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | src/=src/ 2 | test/=test/ 3 | 4 | @forge-std/=lib/forge-std/src/ 5 | @ds-test/=lib/forge-std/lib/ds-test/src/ 6 | 7 | @openzeppelin/=lib/openzeppelin-contracts/ 8 | @solmate/=lib/solmate/ 9 | @murky/=lib/murky/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | 16 | # npm 17 | package-lock.json 18 | node_modules -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[solidity]": { 3 | "editor.formatOnSave": false 4 | }, 5 | "emeraldwalk.runonsave": { 6 | "commands": [ 7 | { 8 | "match": ".sol", 9 | "isAsync": true, 10 | "cmd": "forge fmt ${file}" 11 | }, 12 | ] 13 | } 14 | } -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | 6 | [invariant] 7 | runs = 4 8 | depth = 64 9 | 10 | [fuzz] 11 | runs = 512 12 | 13 | [profile.ci.fuzz] 14 | runs = 512 15 | 16 | [profile.ci.invariant] 17 | runs = 8 18 | depth = 256 19 | 20 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | husky@^8.0.3: 6 | version "8.0.3" 7 | resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" 8 | integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "universal-rewards-distributor", 3 | "version": "1.0.0", 4 | "description": "A universal rewards distributor written in Solidity.", 5 | "main": "index.js", 6 | "repository": "git@github.com:MerlinEgalite/universal-rewards-distributor.git", 7 | "author": "MerlinEgalite ", 8 | "license": "MIT", 9 | "devDependencies": { 10 | "husky": "^8.0.3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /script/DeployUniversalRewardsDistributor.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.0; 3 | 4 | import "@forge-std/Script.sol"; 5 | 6 | import {UniversalRewardsDistributor} from "src/UniversalRewardsDistributor.sol"; 7 | 8 | contract DeployUniversalRewardsDistributor is Script { 9 | function run() public { 10 | vm.broadcast(); 11 | console.log(address(new UniversalRewardsDistributor())); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/solmate"] 5 | path = lib/solmate 6 | url = https://github.com/transmissions11/solmate 7 | [submodule "lib/openzeppelin-contracts"] 8 | path = lib/openzeppelin-contracts 9 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 10 | [submodule "lib/murky"] 11 | path = lib/murky 12 | url = https://github.com/dmfxyz/murky 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: push 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | check: 10 | strategy: 11 | fail-fast: true 12 | 13 | name: Foundry project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | submodules: recursive 19 | 20 | - name: Install Foundry 21 | uses: foundry-rs/foundry-toolchain@v1 22 | with: 23 | version: nightly 24 | 25 | - name: Run Forge build 26 | run: | 27 | forge --version 28 | forge build --sizes 29 | id: build 30 | 31 | - name: Run Forge tests 32 | run: | 33 | forge test -vvv 34 | id: test 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Universal Rewards Distributor 2 | 3 | A universal rewards distributor written in Solidity. It allows the distribution of any reward token (different reward tokens are possible simultaneously) based on a Merkle tree distribution. 4 | 5 | Based on [Morpho's rewards distributor](https://github.com/morpho-dao/morpho-v1/blob/main/src/common/rewards-distribution/RewardsDistributor.sol), itself based on [Euler's rewards distributor](https://github.com/euler-xyz/euler-contracts/blob/master/contracts/mining/EulDistributor.sol). 6 | 7 | Tests are using [Murky](https://github.com/dmfxyz/murky), to generate Merkle trees in Solidity. 8 | 9 | ## Usage 10 | Merkle trees should be generated with [Openzeppelin library](https://github.com/OpenZeppelin/merkle-tree). 11 | It will ensure that trees will be secure for on-chain verification. 12 | 13 | ## Installation 14 | 15 | Download foundry: 16 | ```bash 17 | curl -L https://foundry.paradigm.xyz | bash 18 | ``` 19 | 20 | Install it: 21 | ```bash 22 | foundryup 23 | ``` 24 | 25 | Install dependencies: 26 | ```bash 27 | git submodule update --init --recursive 28 | ``` 29 | 30 | Now you can run tests, using forge: 31 | ```bash 32 | forge test 33 | ``` 34 | -------------------------------------------------------------------------------- /src/interfaces/IUniversalRewardsDistributor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity >=0.5.0; 3 | 4 | /// @title IUniversalRewardsDistributor 5 | /// @author MerlinEgalite 6 | /// @notice UniversalRewardsDistributor's interface. 7 | interface IUniversalRewardsDistributor { 8 | /* EVENTS */ 9 | 10 | /// @notice Emitted when the merkle tree's root is updated. 11 | /// @param newRoot The new merkle tree's root. 12 | event RootUpdated(bytes32 newRoot); 13 | 14 | /// @notice Emitted when rewards are claimed. 15 | /// @param account The address for which rewards are claimd rewards for. 16 | /// @param reward The address of the reward token. 17 | /// @param amount The amount of reward token claimed. 18 | event RewardsClaimed(address account, address reward, uint256 amount); 19 | 20 | /* ERRORS */ 21 | 22 | /// @notice Thrown when the merkle proof is invalid or expired. 23 | error ProofInvalidOrExpired(); 24 | 25 | /// @notice Thrown when the rewards have already been claimed. 26 | error AlreadyClaimed(); 27 | 28 | /* EXTERNAL */ 29 | 30 | function updateRoot(bytes32 newRoot) external; 31 | 32 | function skim(address token) external; 33 | 34 | function claim(address account, address reward, uint256 claimable, bytes32[] calldata proof) external; 35 | } 36 | -------------------------------------------------------------------------------- /src/UniversalRewardsDistributor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {IUniversalRewardsDistributor} from "./interfaces/IUniversalRewardsDistributor.sol"; 5 | 6 | import {ERC20, SafeTransferLib} from "@solmate/src/utils/SafeTransferLib.sol"; 7 | 8 | import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; 9 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 10 | 11 | /// @title UniversalRewardsDistributor 12 | /// @author MerlinEgalite 13 | /// @notice This contract allows to distribute different rewards tokens to multiple accounts using a Merkle tree. 14 | /// It is largely inspired by Morpho's current rewards distributor: 15 | /// https://github.com/morpho-dao/morpho-v1/blob/main/src/common/rewards-distribution/RewardsDistributor.sol 16 | contract UniversalRewardsDistributor is IUniversalRewardsDistributor, Ownable { 17 | using SafeTransferLib for ERC20; 18 | 19 | /* STORAGE */ 20 | 21 | /// @notice The merkle tree's root of the current rewards distribution. 22 | bytes32 public root; 23 | 24 | /// @notice The `amount` of `reward` token already claimed by `account`. 25 | mapping(address account => mapping(address reward => uint256 amount)) public claimed; 26 | 27 | /* EXTERNAL */ 28 | 29 | /// @notice Updates the current merkle tree's root. 30 | /// @param newRoot The new merkle tree's root. 31 | function updateRoot(bytes32 newRoot) external onlyOwner { 32 | root = newRoot; 33 | emit RootUpdated(newRoot); 34 | } 35 | 36 | /// @notice Transfers the `token` balance from this contract to the owner. 37 | function skim(address token) external onlyOwner { 38 | ERC20(token).safeTransfer(msg.sender, ERC20(token).balanceOf(address(this))); 39 | } 40 | 41 | /// @notice Claims rewards. 42 | /// @param account The address to claim rewards for. 43 | /// @param reward The address of the reward token. 44 | /// @param claimable The overall claimable amount of token rewards. 45 | /// @param proof The merkle proof that validates this claim. 46 | function claim(address account, address reward, uint256 claimable, bytes32[] calldata proof) external { 47 | if ( 48 | !MerkleProof.verifyCalldata( 49 | proof, root, keccak256(bytes.concat(keccak256(abi.encode(account, reward, claimable)))) 50 | ) 51 | ) { 52 | revert ProofInvalidOrExpired(); 53 | } 54 | 55 | uint256 amount = claimable - claimed[account][reward]; 56 | if (amount == 0) revert AlreadyClaimed(); 57 | 58 | claimed[account][reward] = claimable; 59 | 60 | ERC20(reward).safeTransfer(account, amount); 61 | emit RewardsClaimed(account, reward, amount); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /test/UniversalRouterDistributor.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.0; 3 | 4 | import "@solmate/src/test/utils/mocks/MockERC20.sol"; 5 | import "src/UniversalRewardsDistributor.sol"; 6 | 7 | import {Merkle} from "@murky/src/Merkle.sol"; 8 | 9 | import "@forge-std/Test.sol"; 10 | 11 | contract UniversalRouterDistributor is Test { 12 | uint256 internal constant MAX_RECEIVERS = 20; 13 | 14 | UniversalRewardsDistributor internal distributor; 15 | Merkle merkle = new Merkle(); 16 | MockERC20 internal token1; 17 | MockERC20 internal token2; 18 | 19 | event RootUpdated(bytes32 newRoot); 20 | event RewardsClaimed(address account, address reward, uint256 amount); 21 | 22 | function setUp() public { 23 | distributor = new UniversalRewardsDistributor(); 24 | token1 = new MockERC20("Token1", "TKN1", 18); 25 | token2 = new MockERC20("Token2", "TKN2", 18); 26 | } 27 | 28 | function testUpdateRoot(bytes32 root) public { 29 | vm.expectEmit(true, true, true, true, address(distributor)); 30 | emit RootUpdated(root); 31 | distributor.updateRoot(root); 32 | 33 | assertEq(distributor.root(), root); 34 | } 35 | 36 | function testUpdateRootShouldReversWhenNotOwner(bytes32 root, address caller) public { 37 | vm.assume(caller != distributor.owner()); 38 | 39 | vm.prank(caller); 40 | vm.expectRevert("Ownable: caller is not the owner"); 41 | distributor.updateRoot(root); 42 | } 43 | 44 | function testSkim(uint256 amount) public { 45 | deal(address(token1), address(distributor), amount); 46 | 47 | distributor.skim(address(token1)); 48 | 49 | assertEq(ERC20(address(token1)).balanceOf(address(distributor)), 0); 50 | assertEq(ERC20(address(token1)).balanceOf(address(this)), amount); 51 | } 52 | 53 | function testSkimShouldReversWhenNotOwner(address caller) public { 54 | vm.assume(caller != distributor.owner()); 55 | 56 | vm.prank(caller); 57 | vm.expectRevert("Ownable: caller is not the owner"); 58 | distributor.skim(address(token1)); 59 | } 60 | 61 | function testRewards(uint256 claimable, uint8 size) public { 62 | claimable = bound(claimable, 1 ether, type(uint256).max); 63 | uint256 boundedSize = bound(size, 2, MAX_RECEIVERS); 64 | 65 | bytes32[] memory data = _setupRewards(claimable, boundedSize); 66 | _claimAndVerifyRewards(data, claimable); 67 | } 68 | 69 | function testRewardsWithUpdate(uint256 claimable1, uint256 claimable2, uint8 size) public { 70 | claimable1 = bound(claimable1, 1 ether, type(uint128).max); 71 | claimable2 = bound(claimable2, claimable1 * 2, type(uint256).max); 72 | uint256 boundedSize = bound(size, 2, MAX_RECEIVERS); 73 | 74 | bytes32[] memory data = _setupRewards(claimable1, boundedSize); 75 | _claimAndVerifyRewards(data, claimable1); 76 | 77 | data = _setupRewards(claimable2, boundedSize); 78 | _claimAndVerifyRewards(data, claimable2); 79 | } 80 | 81 | function testRewardsShouldRevertWhenAlreadyClaimed(uint256 claimable, uint8 size) public { 82 | claimable = bound(claimable, 1 ether, type(uint256).max); 83 | uint256 boundedSize = bound(size, 2, MAX_RECEIVERS); 84 | 85 | bytes32[] memory data = _setupRewards(claimable, boundedSize); 86 | 87 | bytes32[] memory proof = merkle.getProof(data, 0); 88 | deal(address(token1), address(distributor), claimable); 89 | distributor.claim(vm.addr(1), address(token1), claimable, proof); 90 | 91 | vm.expectRevert(IUniversalRewardsDistributor.AlreadyClaimed.selector); 92 | distributor.claim(vm.addr(1), address(token1), claimable, proof); 93 | } 94 | 95 | function testRewardsShouldRevertWhenInvalidProofAndCorrectInputs( 96 | bytes32[] memory proof, 97 | uint256 claimable, 98 | uint8 size 99 | ) public { 100 | claimable = bound(claimable, 1 ether, type(uint256).max); 101 | uint256 boundedSize = bound(size, 2, MAX_RECEIVERS); 102 | 103 | _setupRewards(claimable, boundedSize); 104 | 105 | deal(address(token1), address(distributor), claimable); 106 | vm.expectRevert(IUniversalRewardsDistributor.ProofInvalidOrExpired.selector); 107 | distributor.claim(vm.addr(1), address(token1), claimable, proof); 108 | } 109 | 110 | function testRewardsShouldRevertWhenValidProofButIncorrectInputs( 111 | address account, 112 | address reward, 113 | uint256 amount, 114 | uint256 claimable, 115 | uint8 size 116 | ) public { 117 | claimable = bound(claimable, 1 ether, type(uint256).max); 118 | uint256 boundedSize = bound(size, 2, MAX_RECEIVERS); 119 | 120 | bytes32[] memory data = _setupRewards(claimable, boundedSize); 121 | 122 | bytes32[] memory proof = merkle.getProof(data, 0); 123 | deal(address(token1), address(distributor), claimable); 124 | vm.expectRevert(IUniversalRewardsDistributor.ProofInvalidOrExpired.selector); 125 | distributor.claim(account, reward, amount, proof); 126 | } 127 | 128 | /// @dev In the implementation, claimed rewards are stored as a mapping. 129 | /// The test function use vm.store to emulate assignations. 130 | /// | Name | Type | Slot | Offset | Bytes | 131 | /// |---------|-------------------------------------------------|------|--------|-------| 132 | /// | _owner | address | 0 | 0 | 20 | 133 | /// | root | bytes32 | 1 | 0 | 32 | 134 | /// | claimed | mapping(address => mapping(address => uint256)) | 2 | 0 | 32 | 135 | function testClaimedGetter(address token, address account, uint256 amount) public { 136 | vm.store( 137 | address(distributor), 138 | keccak256(abi.encode(address(token), keccak256(abi.encode(account, uint256(2))))), 139 | bytes32(amount) 140 | ); 141 | assertEq(distributor.claimed(account, token), amount); 142 | } 143 | 144 | function _setupRewards(uint256 claimable, uint256 size) internal returns (bytes32[] memory data) { 145 | data = new bytes32[](size); 146 | 147 | uint256 i; 148 | while (i < size / 2) { 149 | uint256 index = i + 1; 150 | data[i] = keccak256( 151 | bytes.concat(keccak256(abi.encode(vm.addr(index), address(token1), uint256(claimable / index)))) 152 | ); 153 | data[i + 1] = keccak256( 154 | bytes.concat(keccak256(abi.encode(vm.addr(index), address(token2), uint256(claimable / index)))) 155 | ); 156 | 157 | i += 2; 158 | } 159 | 160 | bytes32 root = merkle.getRoot(data); 161 | distributor.updateRoot(root); 162 | } 163 | 164 | function _claimAndVerifyRewards(bytes32[] memory data, uint256 claimable) internal { 165 | uint256 i; 166 | while (i < data.length / 2) { 167 | bytes32[] memory proof1 = merkle.getProof(data, i); 168 | bytes32[] memory proof2 = merkle.getProof(data, i + 1); 169 | 170 | uint256 index = i + 1; 171 | uint256 claimableInput = claimable / index; 172 | uint256 claimableAdjusted1 = claimableInput - distributor.claimed(vm.addr(index), address(token1)); 173 | uint256 claimableAdjusted2 = claimableInput - distributor.claimed(vm.addr(index), address(token2)); 174 | deal(address(token1), address(distributor), claimableAdjusted1); 175 | deal(address(token2), address(distributor), claimableAdjusted2); 176 | uint256 balanceBefore1 = ERC20(address(token1)).balanceOf(vm.addr(index)); 177 | uint256 balanceBefore2 = ERC20(address(token2)).balanceOf(vm.addr(index)); 178 | 179 | // Claim token1 180 | vm.expectEmit(true, true, true, true, address(distributor)); 181 | emit RewardsClaimed(vm.addr(index), address(token1), claimableAdjusted1); 182 | distributor.claim(vm.addr(index), address(token1), claimableInput, proof1); 183 | // Claim token2 184 | vm.expectEmit(true, true, true, true, address(distributor)); 185 | emit RewardsClaimed(vm.addr(index), address(token2), claimableAdjusted2); 186 | distributor.claim(vm.addr(index), address(token2), claimableInput, proof2); 187 | 188 | uint256 balanceAfter1 = balanceBefore1 + claimableAdjusted1; 189 | uint256 balanceAfter2 = balanceBefore2 + claimableAdjusted2; 190 | 191 | assertEq(ERC20(address(token1)).balanceOf(address(distributor)), 0); 192 | assertEq(ERC20(address(token1)).balanceOf(vm.addr(index)), balanceAfter1); 193 | assertEq(ERC20(address(token2)).balanceOf(address(distributor)), 0); 194 | assertEq(ERC20(address(token2)).balanceOf(vm.addr(index)), balanceAfter2); 195 | // Assert claimed getter 196 | assertEq(distributor.claimed(vm.addr(index), address(token1)), balanceAfter1); 197 | assertEq(distributor.claimed(vm.addr(index), address(token2)), balanceAfter2); 198 | 199 | i += 2; 200 | } 201 | } 202 | } 203 | --------------------------------------------------------------------------------