Skip to content
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

Contract changes #2

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
6 changes: 3 additions & 3 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[submodule "contracts/lib/ds-test"]
path = contracts/lib/ds-test
url = https://github.com/dapphub/ds-test
[submodule "contracts/lib/solmate"]
path = contracts/lib/solmate
url = https://github.com/rari-capital/solmate
[submodule "contracts/lib/openzeppelin-contracts"]
path = contracts/lib/openzeppelin-contracts
url = https://github.com/OpenZeppelin/openzeppelin-contracts
[submodule "contracts/lib/forge-std"]
path = contracts/lib/forge-std
url = https://github.com/foundry-rs/forge-std
34 changes: 34 additions & 0 deletions contracts/.github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: test

on: workflow_dispatch

env:
FOUNDRY_PROFILE: ci

jobs:
check:
strategy:
fail-fast: true

name: Foundry project
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly

- name: Run Forge build
run: |
forge --version
forge build --sizes
id: build

- name: Run Forge tests
run: |
forge test -vvv
id: test
12 changes: 12 additions & 0 deletions contracts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Compiler files
cache/
out/

# Ignores development broadcast logs
broadcast

# Docs
docs/

# Dotenv file
.env
6 changes: 0 additions & 6 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ forge test --root .
forge test --root . -vvvv
```

## Deploy

Follow the `forge create` instructions ([CLI README](https://github.com/gakonst/foundry/blob/master/cli/README.md#build)) to deploy your contracts or use [Remix](https://remix.ethereum.org/).

You can specify the token `name`, `symbol`, `decimals`, and airdrop `merkleRoot` upon deploy.

## Credits

- [@brockelmore](https://github.com/Anish-Agnihotri/merkle-airdrop-starter/issues?q=is%3Apr+author%3Abrockelmore) for [#1](https://github.com/Anish-Agnihotri/merkle-airdrop-starter/pull/1)
Expand Down
12 changes: 12 additions & 0 deletions contracts/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[profile.default]
src = "src"
out = "out"
libs = ["lib"]

[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"

[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }

# See more config options https://github.com/foundry-rs/foundry/tree/master/config
2 changes: 1 addition & 1 deletion contracts/lib/ds-test
1 change: 1 addition & 0 deletions contracts/lib/forge-std
Submodule forge-std added at f73c73
2 changes: 1 addition & 1 deletion contracts/lib/openzeppelin-contracts
1 change: 0 additions & 1 deletion contracts/lib/solmate
Submodule solmate deleted from fab107
1 change: 1 addition & 0 deletions contracts/remappings.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
ds-test/=lib/ds-test/src/
forge-std/=lib/forge-std/src/
@solmate/=lib/solmate/src/
@openzeppelin/=lib/openzeppelin-contracts/contracts/
24 changes: 24 additions & 0 deletions contracts/script/DeployMerkleClaim.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "forge-std/Script.sol";
import {IERC20} from "@openzeppelin/token/ERC20/IERC20.sol";
import {MerkleClaimERC20} from "../src/MerkleClaimERC20.sol";

// Deploy with:
// source .env
// forge script script/DeployMerkleClaim.s.sol:DeployScript --rpc-url $SEPOLIA_RPC_URL --broadcast --verify -vvvv

contract DeployScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);

IERC20 ovl = IERC20(0x088feB3063d118c037ecAc999AD53Ec532780614);
bytes32 merkleRoot = 0x0e4db3677f424b884357f6f95d4a5e2273e600875b8599841fcfe68539080562;

MerkleClaimERC20 merkleClaim = new MerkleClaimERC20(ovl, merkleRoot);

vm.stopBroadcast();
}
}
36 changes: 36 additions & 0 deletions contracts/script/DeployOVL.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "forge-std/Script.sol";
import {OverlayV1Token} from "../src/OverlayV1Token.sol";
import {MINTER_ROLE} from "../src/interfaces/IOverlayV1Token.sol";

// Deploy with:
// source .env
// forge script script/DeployOVL.s.sol:DeployOvlScript --rpc-url $SEPOLIA_RPC_URL --broadcast --verify -vvvv

/* If it doesn't verify automatically, run:
forge verify-contract \
--chain-id 421614 \
--num-of-optimizations 200 \
--watch \
--etherscan-api-key <key> \
--compiler-version v0.8.19+commit.7dd6d404 \
<contract_address> \
src/OverlayV1Token.sol:OverlayV1Token
*/

contract DeployOvlScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);

OverlayV1Token ovl = new OverlayV1Token();

// Mint 100k OVL to deployer
ovl.grantRole(MINTER_ROLE, 0xaf7F476a8C72de272Fc9A4b6153BB1B8Caa843bF);
ovl.mint(0xaf7F476a8C72de272Fc9A4b6153BB1B8Caa843bF, 100_000 ether);

vm.stopBroadcast();
}
}
48 changes: 30 additions & 18 deletions contracts/src/MerkleClaimERC20.sol
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity >=0.8.0;
pragma solidity 0.8.19;

/// ============ Imports ============

import {ERC20} from "@solmate/tokens/ERC20.sol"; // Solmate: ERC20
import {MerkleProof} from "@openzeppelin/utils/cryptography/MerkleProof.sol"; // OZ: MerkleProof
import {IERC20} from "@openzeppelin/token/ERC20/IERC20.sol";
import {MerkleProof} from "@openzeppelin/utils/cryptography/MerkleProof.sol";
import {Ownable} from "@openzeppelin/access/Ownable.sol";

/// @title MerkleClaimERC20
/// @notice ERC20 claimable by members of a merkle tree
/// @author Anish Agnihotri <[email protected]>
/// @dev Solmate ERC20 includes unused _burn logic that can be removed to optimize deployment cost
contract MerkleClaimERC20 is ERC20 {
contract MerkleClaimERC20 is Ownable {
/// ============ Immutable storage ============

/// @notice ERC20-claimee inclusion root
bytes32 public immutable merkleRoot;
bytes32 public merkleRoot;

/// @notice Contract address of airdropped token
IERC20 public immutable token;

/// ============ Mutable storage ============

Expand All @@ -25,20 +27,21 @@ contract MerkleClaimERC20 is ERC20 {

/// @notice Thrown if address has already claimed
error AlreadyClaimed();

/// @notice Thrown if address/amount are not part of Merkle tree
error NotInMerkle();

/// @notice Thrown if claim contract doesn't have enough tokens to payout
error NotEnoughRewards();

/// ============ Constructor ============

/// @notice Creates a new MerkleClaimERC20 contract
/// @param _name of token
/// @param _symbol of token
/// @param _decimals of token
/// @param _merkleRoot of claimees
constructor(string memory _name, string memory _symbol, uint8 _decimals, bytes32 _merkleRoot)
ERC20(_name, _symbol, _decimals)
{
merkleRoot = _merkleRoot; // Update root
/// @param _token address of airdropped token
/// @param _merkleRoot merkle root of claimees
constructor(IERC20 _token, bytes32 _merkleRoot) {
token = _token;
merkleRoot = _merkleRoot;
}

/// ============ Events ============
Expand All @@ -58,18 +61,27 @@ contract MerkleClaimERC20 is ERC20 {
// Throw if address has already claimed tokens
if (hasClaimed[to]) revert AlreadyClaimed();

// Throw if the contract doesn't hold enough tokens for claimee
if (amount > token.balanceOf(address(this))) revert NotEnoughRewards();

// Verify merkle proof, or revert if not in tree
bytes32 leaf = keccak256(abi.encodePacked(to, amount));
bool isValidLeaf = MerkleProof.verify(proof, merkleRoot, leaf);
bool isValidLeaf = MerkleProof.verifyCalldata(proof, merkleRoot, leaf);
if (!isValidLeaf) revert NotInMerkle();

// Set address to claimed
hasClaimed[to] = true;

// Mint tokens to address
_mint(to, amount);
// Award tokens to address
token.transfer(to, amount);

// Emit claim event
emit Claim(to, amount);
}

/// @notice Allows owner to update merkle root
/// @param _merkleRoot new merkle root
function updateMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}
}
31 changes: 31 additions & 0 deletions contracts/src/OverlayV1Token.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/token/ERC20/ERC20.sol";
import "@openzeppelin/access/AccessControlEnumerable.sol";

import "./interfaces/IOverlayV1Token.sol";

contract OverlayV1Token is IOverlayV1Token, AccessControlEnumerable, ERC20("Overlay", "OVL") {
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

modifier onlyMinter() {
require(hasRole(MINTER_ROLE, msg.sender), "ERC20: !minter");
_;
}

modifier onlyBurner() {
require(hasRole(BURNER_ROLE, msg.sender), "ERC20: !burner");
_;
}

function mint(address _recipient, uint256 _amount) external onlyMinter {
_mint(_recipient, _amount);
}

function burn(uint256 _amount) external onlyBurner {
_burn(msg.sender, _amount);
}
}
17 changes: 17 additions & 0 deletions contracts/src/interfaces/IOverlayV1Token.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/token/ERC20/IERC20.sol";
import "@openzeppelin/access/IAccessControlEnumerable.sol";

bytes32 constant MINTER_ROLE = keccak256("MINTER");
bytes32 constant BURNER_ROLE = keccak256("BURNER");
bytes32 constant GOVERNOR_ROLE = keccak256("GOVERNOR");
bytes32 constant GUARDIAN_ROLE = keccak256("GUARDIAN");

interface IOverlayV1Token is IAccessControlEnumerable, IERC20 {
// mint/burn
function mint(address _recipient, uint256 _amount) external;

function burn(uint256 _amount) external;
}
18 changes: 18 additions & 0 deletions contracts/src/test/MerkleClaimERC20.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,22 @@ contract Tests is MerkleClaimERC20Test {
// Assert Alice balance before + 100 tokens = after balance
assertEq(alicePostBalance, alicePreBalance + 100e18);
}

function testAdminUpdatesMerkleRoot() public {
bytes32 newMerkleRoot = bytes32(uint256(1));
assertFalse(merkleClaim.merkleRoot() == newMerkleRoot);

merkleClaim.updateMerkleRoot(newMerkleRoot);

assertEq(merkleClaim.merkleRoot(), newMerkleRoot);
}

function testUserUpdatesMerkleRoot() public {
bytes32 newMerkleRoot = bytes32(uint256(1));

vm.startPrank(address(BOB));

vm.expectRevert("Ownable: caller is not the owner");
merkleClaim.updateMerkleRoot(newMerkleRoot);
}
}
37 changes: 21 additions & 16 deletions contracts/src/test/utils/MerkleClaimERC20Test.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,42 @@ pragma solidity >=0.8.0;

/// ============ Imports ============

import {DSTest} from "ds-test/test.sol"; // DSTest
import {Test} from "forge-std/Test.sol";

import {MerkleClaimERC20} from "../../MerkleClaimERC20.sol"; // MerkleClaimERC20
import {OverlayV1Token} from "../../OverlayV1Token.sol"; // OverlayV1Token
import {MerkleClaimERC20User} from "./MerkleClaimERC20User.sol"; // MerkleClaimERC20 user

/// @title MerkleClaimERC20Test
/// @notice Scaffolding for MerkleClaimERC20 tests
/// @author Anish Agnihotri <[email protected]>
contract MerkleClaimERC20Test is DSTest {
contract MerkleClaimERC20Test is Test {
/// ============ Storage ============

/// @dev MerkleClaimERC20 contract
MerkleClaimERC20 internal TOKEN;
MerkleClaimERC20 internal merkleClaim;
/// @dev User: Alice (in merkle tree)
MerkleClaimERC20User internal ALICE;
MerkleClaimERC20User internal ALICE = MerkleClaimERC20User(0x185a4dc360CE69bDCceE33b3784B0282f7961aea);
/// @dev User: Bob (not in merkle tree)
MerkleClaimERC20User internal BOB;
MerkleClaimERC20User internal BOB = MerkleClaimERC20User(0xEFc56627233b02eA95bAE7e19F648d7DcD5Bb132);

/// ============ Setup test suite ============

function setUp() public virtual {
OverlayV1Token ovl = new OverlayV1Token();

// Create airdrop token
TOKEN = new MerkleClaimERC20(
"My Token",
"MT",
18,
// Merkle root containing ALICE with 100e18 tokens but no BOB
0xd0aa6a4e5b4e13462921d7518eebdb7b297a7877d6cfe078b0c318827392fb55
);

// Setup airdrop users
ALICE = new MerkleClaimERC20User(TOKEN); // 0x185a4dc360ce69bdccee33b3784b0282f7961aea
BOB = new MerkleClaimERC20User(TOKEN); // 0xefc56627233b02ea95bae7e19f648d7dcd5bb132
merkleClaim = new MerkleClaimERC20(
ovl,
// Merkle root containing ALICE with 100e18 tokens but no BOB
0xd0aa6a4e5b4e13462921d7518eebdb7b297a7877d6cfe078b0c318827392fb55
);

deal(address(ovl), address(this), 100e18); // total amount of tokens to be claimed
ovl.transfer(address(merkleClaim), 100e18);

// Setup airdrop users with custom addresses (to match merkle tree)
deployCodeTo("MerkleClaimERC20User.sol", abi.encode(merkleClaim), address(ALICE));
deployCodeTo("MerkleClaimERC20User.sol", abi.encode(merkleClaim), address(BOB));
}
}
Loading