Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions contracts/MerkleDistributor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @notice Distributes ETH via Merkle proof verification. 90-day claim period.
contract MerkleDistributor is Ownable, Pausable, ReentrancyGuard {
uint256 public constant CLAIM_PERIOD = 90 days;
uint256 public constant DEPOSIT_AMOUNT = 1036920000000700000000;
bytes32 public constant MERKLE_ROOT =
0xe07775359b09f65b178a4d87b31a37a0c67b6bdee74fe620edc57b3e7d87208b;

address public immutable dao;
bytes32 public merkleRoot;
Expand All @@ -34,6 +37,8 @@ contract MerkleDistributor is Ownable, Pausable, ReentrancyGuard {
error ClaimPeriodExpired();
error ClaimPeriodNotExpired();
error TransferFailed();
error InvalidDaoAddress();
error InvalidMerkleRoot();

modifier onlyDAO() {
if (msg.sender != dao) revert OnlyDAO();
Expand All @@ -46,13 +51,17 @@ contract MerkleDistributor is Ownable, Pausable, ReentrancyGuard {
}

constructor(address _dao, address _owner) Ownable(_owner) {
require(_dao != address(0), "Invalid DAO address");
if (_dao == address(0)) revert InvalidDaoAddress();
dao = _dao;
}

function createDistributor(bytes32 _merkleRoot) external payable onlyDAO {
//Sanity check: Prevent DAO from opening claims with an invalid merkle root
if (_merkleRoot != MERKLE_ROOT) revert InvalidMerkleRoot();
if (merkleRoot != bytes32(0)) revert ClaimsAlreadyOpened();
if (msg.value == 0) revert InvalidDepositAmount();

//Sanity check: Prevent DAO from opening claims with an invalid deposit amount
if (msg.value != DEPOSIT_AMOUNT) revert InvalidDepositAmount();

merkleRoot = _merkleRoot;
claimStartTime = block.timestamp;
Expand Down Expand Up @@ -129,4 +138,8 @@ contract MerkleDistributor is Ownable, Pausable, ReentrancyGuard {
);
return MerkleProof.verify(proof, merkleRoot, leaf);
}

function hasClaimed(address claimant) external view returns (bool) {
return claimed[claimant];
}
}
85 changes: 84 additions & 1 deletion test/MerkleDistributor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { time, setBalance } from "@nomicfoundation/hardhat-network-helpers";
const CSV_URL =
"https://raw.githubusercontent.com/pgendreau/aavegotchi-ptd/refs/heads/main/TotalDistributionAmounts.csv";

// Must match contract constants
const EXPECTED_MERKLE_ROOT =
"0xe07775359b09f65b178a4d87b31a37a0c67b6bdee74fe620edc57b3e7d87208b";
const EXPECTED_DEPOSIT_AMOUNT = 1036920000000700000000n;

interface Claim {
address: string;
amount: bigint;
Expand Down Expand Up @@ -94,6 +99,16 @@ describe("MerkleDistributor", function () {
const { root, claimsMap } = buildMerkleTree(claims);
merkleRoot = root;
claimsWithProof = claimsMap;

// Sanity check: verify CSV-generated values match contract constants
expect(merkleRoot).to.equal(
EXPECTED_MERKLE_ROOT,
"CSV merkle root must match contract MERKLE_ROOT constant"
);
expect(totalAmount).to.equal(
EXPECTED_DEPOSIT_AMOUNT,
"CSV total must match contract DEPOSIT_AMOUNT constant"
);
});

beforeEach(async function () {
Expand Down Expand Up @@ -127,7 +142,7 @@ describe("MerkleDistributor", function () {
});

describe("createDistributor", function () {
it("should allow DAO to open claims with live CSV total", async function () {
it("should allow DAO to open claims with correct merkle root and amount", async function () {
const tx = await distributor
.connect(dao)
.createDistributor(merkleRoot, { value: totalAmount });
Expand All @@ -142,6 +157,13 @@ describe("MerkleDistributor", function () {
expect(await distributor.totalDistribution()).to.equal(totalAmount);
});

it("should have correct constants matching expected values", async function () {
expect(await distributor.MERKLE_ROOT()).to.equal(EXPECTED_MERKLE_ROOT);
expect(await distributor.DEPOSIT_AMOUNT()).to.equal(
EXPECTED_DEPOSIT_AMOUNT
);
});

it("should revert if called by non-DAO", async function () {
await expect(
distributor
Expand All @@ -150,12 +172,40 @@ describe("MerkleDistributor", function () {
).to.be.revertedWithCustomError(distributor, "OnlyDAO");
});

it("should revert if called with wrong merkle root", async function () {
const wrongRoot = ethers.keccak256(ethers.toUtf8Bytes("wrong"));
await expect(
distributor
.connect(dao)
.createDistributor(wrongRoot, { value: totalAmount })
).to.be.revertedWithCustomError(distributor, "InvalidMerkleRoot");
});

it("should revert if called with zero ETH", async function () {
await expect(
distributor.connect(dao).createDistributor(merkleRoot, { value: 0 })
).to.be.revertedWithCustomError(distributor, "InvalidDepositAmount");
});

it("should revert if called with wrong deposit amount", async function () {
const wrongAmount = totalAmount + 1n;
await setBalance(dao.address, wrongAmount + ethers.parseEther("100"));
await expect(
distributor
.connect(dao)
.createDistributor(merkleRoot, { value: wrongAmount })
).to.be.revertedWithCustomError(distributor, "InvalidDepositAmount");
});

it("should revert if called with less than required deposit amount", async function () {
const lessAmount = totalAmount - 1n;
await expect(
distributor
.connect(dao)
.createDistributor(merkleRoot, { value: lessAmount })
).to.be.revertedWithCustomError(distributor, "InvalidDepositAmount");
});

it("should revert if called twice", async function () {
await distributor
.connect(dao)
Expand Down Expand Up @@ -564,5 +614,38 @@ describe("MerkleDistributor", function () {
await time.increase(CLAIM_PERIOD + 1);
expect(await distributor.timeRemaining()).to.equal(0n);
});

it("should return false for hasClaimed before user claims", async function () {
await distributor
.connect(dao)
.createDistributor(merkleRoot, { value: totalAmount });

const [firstClaim] = claims;
expect(await distributor.hasClaimed(firstClaim.address)).to.be.false;
});

it("should return true for hasClaimed after user claims", async function () {
await distributor
.connect(dao)
.createDistributor(merkleRoot, { value: totalAmount });

const [firstClaim] = claims;
const claim = claimsWithProof.get(firstClaim.address)!;
const claimant = await ethers.getImpersonatedSigner(claim.address);
await setBalance(claim.address, ethers.parseEther("1"));

await distributor.connect(claimant).claim(claim.amount, claim.proof);

expect(await distributor.hasClaimed(claim.address)).to.be.true;
});

it("should return false for hasClaimed for address not in tree", async function () {
await distributor
.connect(dao)
.createDistributor(merkleRoot, { value: totalAmount });

// Use owner address which is not in the claims tree
expect(await distributor.hasClaimed(owner.address)).to.be.false;
});
});
});