Skip to content
Merged
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
64 changes: 41 additions & 23 deletions src/FeeRouterEarningsAirdrop.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerklePr
import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

struct AirdropClaim {
uint256 round;
uint256 amount;
bytes32[] merkleProof;
}

contract FeeRouterEarningsAirdrop is Ownable, ReentrancyGuard {
////////////////////////////// Libraries //////////////////////////////

Expand All @@ -24,6 +30,7 @@ contract FeeRouterEarningsAirdrop is Ownable, ReentrancyGuard {
error MerkleRootAlreadySet(uint256 round);
error AlreadyClaimed(uint256 round, address account);
error InvalidProof();
error EmptyAirdropClaim();

////////////////////////////// Events /////////////////////////////

Expand All @@ -38,32 +45,15 @@ contract FeeRouterEarningsAirdrop is Ownable, ReentrancyGuard {

////////////////////////////// External Methods //////////////////////////////

function claim(uint256 round, uint256 amount, bytes32[] calldata merkleProof) external nonReentrant {
address account = msg.sender;
bytes32 merkleRoot = merkleRoots[round];

// Throws an error if the merkle root is not set for the selected round
if (merkleRoot == bytes32(0)) {
revert MerkleRootNotSet(round);
function claim(AirdropClaim[] calldata airdropClaims) external nonReentrant {
// Revert if the airdrop claims are empty
if (airdropClaims.length == 0) {
revert EmptyAirdropClaim();
}

// Revert if the user has already claimed
if (hasClaimed[round][account]) {
revert AlreadyClaimed(round, account);
for (uint256 i = 0; i < airdropClaims.length; i++) {
_claim(airdropClaims[i]);
}

// Verify the merkle proof
bytes32 leaf = _getMerkleLeaf(account, amount);
if (!MerkleProof.verify(merkleProof, merkleRoot, leaf)) {
revert InvalidProof();
}

// Reentrancy guard
hasClaimed[round][account] = true;
emit Claimed(round, account, amount);

// Transfer tokens to claimer
AIRDROP_TOKEN.safeTransfer(account, amount);
}

function setRound(uint256 round, bytes32 merkleRoot) external onlyOwner {
Expand All @@ -86,4 +76,32 @@ contract FeeRouterEarningsAirdrop is Ownable, ReentrancyGuard {
function _getMerkleLeaf(address account, uint256 amount) internal pure returns (bytes32) {
return keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
}

function _claim(AirdropClaim calldata airdropClaim) internal {
address account = msg.sender;
bytes32 merkleRoot = merkleRoots[airdropClaim.round];

// Throws an error if the merkle root is not set for the selected round
if (merkleRoot == bytes32(0)) {
revert MerkleRootNotSet(airdropClaim.round);
}

// Revert if the user has already claimed
if (hasClaimed[airdropClaim.round][account]) {
revert AlreadyClaimed(airdropClaim.round, account);
}

// Verify the merkle proof
bytes32 leaf = _getMerkleLeaf(account, airdropClaim.amount);
if (!MerkleProof.verify(airdropClaim.merkleProof, merkleRoot, leaf)) {
revert InvalidProof();
}

// Reentrancy guard
hasClaimed[airdropClaim.round][account] = true;
emit Claimed(airdropClaim.round, account, airdropClaim.amount);

// Transfer tokens to claimer
AIRDROP_TOKEN.safeTransfer(account, airdropClaim.amount);
}
}
98 changes: 90 additions & 8 deletions tests/unit/FeeRouterEarningsAirdrop.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pragma solidity 0.8.25;
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { USDC } from "tests/utils/Usdc.sol";
import { BaseTest } from "tests/utils/BaseTest.t.sol";
import { FeeRouterEarningsAirdrop } from "src/FeeRouterEarningsAirdrop.sol";
import { FeeRouterEarningsAirdrop, AirdropClaim } from "src/FeeRouterEarningsAirdrop.sol";
import { FeeRouterEarningsAirdropMock } from "tests/utils/FeeRouterEarningsAirdropMock.sol";

contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {
Expand Down Expand Up @@ -53,6 +53,52 @@ contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {

////////////////////////////// Success Tests //////////////////////////////

function test_FeeRouterEarningsAirdrop_claim_userCanClaimBatchRounds() external {
// Rounds:
//
// [
// [claimer1, "5000000000000000000"],
// [claimer2, "2500000000000000000"],
// [claimer3, "6000000000000000000"],
// ],
// [
// [claimer1, "7000000000000000000"],
// [claimer2, "1000000000000000000"],
// [claimer4, "4000000000000000000"],
// ],
bytes32 merkleRootRound0 = 0x27191b0744a7ae16d1ec59ef3fb4c022bbe1059b923fa7aad35d47eb67cab2ea;
bytes32 merkleRootRound1 = 0xc4dce927b32b87e9cbfd770921d2318a1927cf86d680d27c2d2adfe9ce083f93;

// Owner sets round 0 and 1
_setRound(0, merkleRootRound0);
_setRound(1, merkleRootRound1);

uint256 initialClaimer1Balance = usdc.balanceOf(claimer1);

// Claimer 1 claims rounds 0 and 1 in a single batch call
uint256 claimer1Round0Amount = 500_000_000;
bytes32[] memory claimer1Round0Proof = new bytes32[](2);
claimer1Round0Proof[0] = 0x0cb2b7d731cbaeebfbbc3676f83397e7e289d3c1d1a91190a3f4894e80ea3d80;
claimer1Round0Proof[1] = 0xc2bd8716fae39aa0452a47ebb514064f4a67aa2a538642dca2ece2184670d2f9;

uint256 claimer1Round1Amount = 700_000_000;
bytes32[] memory claimer1Round1Proof = new bytes32[](2);
claimer1Round1Proof[0] = 0x92bd9a02fe0969d0afcdb8ed002dede4fc36e005d11cc22d99b47fc8e1a2f9aa;
claimer1Round1Proof[1] = 0xc787cb2c74fdbd3348290caa5b15facfef32cffd20f8aaab5ff5103db5548f0a;

AirdropClaim[] memory claimer1Claim = new AirdropClaim[](2);
claimer1Claim[0] = AirdropClaim({ round: 0, amount: claimer1Round0Amount, merkleProof: claimer1Round0Proof });
claimer1Claim[1] = AirdropClaim({ round: 1, amount: claimer1Round1Amount, merkleProof: claimer1Round1Proof });

vm.prank(claimer1);
feeRouterEarningsAirdrop.claim(claimer1Claim);

uint256 finalClaimer1Balance = usdc.balanceOf(claimer1);

// Assert that claimer1's balance increased correctly
assertEq(finalClaimer1Balance - initialClaimer1Balance, claimer1Round0Amount + claimer1Round1Amount);
}

function test_FeeRouterEarningsAirdrop_claim_userCanClaim() external {
// Rounds:
//
Expand Down Expand Up @@ -81,7 +127,10 @@ contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {
claimer1Round0Proof[0] = 0x0cb2b7d731cbaeebfbbc3676f83397e7e289d3c1d1a91190a3f4894e80ea3d80;
claimer1Round0Proof[1] = 0xc2bd8716fae39aa0452a47ebb514064f4a67aa2a538642dca2ece2184670d2f9;

feeRouterEarningsAirdrop.claim(0, claimer1Round0Amount, claimer1Round0Proof);
AirdropClaim[] memory claimer1Round0Claim = new AirdropClaim[](1);
claimer1Round0Claim[0] =
AirdropClaim({ round: 0, amount: claimer1Round0Amount, merkleProof: claimer1Round0Proof });
feeRouterEarningsAirdrop.claim(claimer1Round0Claim);

uint256 round0Claimer1UsdcBalance = usdc.balanceOf(claimer1);
// Assert that claimer1's balance increased correctly
Expand All @@ -92,7 +141,10 @@ contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {
claimer1Round1Proof[0] = 0x92bd9a02fe0969d0afcdb8ed002dede4fc36e005d11cc22d99b47fc8e1a2f9aa;
claimer1Round1Proof[1] = 0xc787cb2c74fdbd3348290caa5b15facfef32cffd20f8aaab5ff5103db5548f0a;

feeRouterEarningsAirdrop.claim(1, claimer1Round1Amount, claimer1Round1Proof);
AirdropClaim[] memory claimer1Round1Claim = new AirdropClaim[](1);
claimer1Round1Claim[0] =
AirdropClaim({ round: 1, amount: claimer1Round1Amount, merkleProof: claimer1Round1Proof });
feeRouterEarningsAirdrop.claim(claimer1Round1Claim);
vm.stopPrank();
uint256 round1Claimer1UsdcBalance = usdc.balanceOf(claimer1);
// Assert that claimer1's balance increased correctly
Expand All @@ -105,8 +157,12 @@ contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {
claimer4Round1Proof[0] = 0x81e631cb8bcea4c9b1e7805242d4ae38704c02046314ee4937595a47359f0c26;
claimer4Round1Proof[1] = 0xc787cb2c74fdbd3348290caa5b15facfef32cffd20f8aaab5ff5103db5548f0a;

AirdropClaim[] memory claimer4Round1Claim = new AirdropClaim[](1);
claimer4Round1Claim[0] =
AirdropClaim({ round: 1, amount: claimer4Round1Amount, merkleProof: claimer4Round1Proof });

vm.prank(claimer4);
feeRouterEarningsAirdrop.claim(1, claimer4Round1Amount, claimer4Round1Proof);
feeRouterEarningsAirdrop.claim(claimer4Round1Claim);

uint256 round1Claimer4UsdcBalance = usdc.balanceOf(claimer4);
// Assert that claimer4's balance increased correctly
Expand All @@ -133,7 +189,7 @@ contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {
assertEq(actualMerkleLeaf, expectedMerkleLeaf);
}

////////////////////////////// Failure Tests //////////////////////////////
// ////////////////////////////// Failure Tests //////////////////////////////

function test_Fuzz_FeeRouterEarningsAirdrop_claim_RevertIf_MerkleRootNotSet(
uint256 round,
Expand All @@ -142,9 +198,11 @@ contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {
)
external
{
AirdropClaim[] memory airdropClaim = new AirdropClaim[](1);
airdropClaim[0] = AirdropClaim({ round: round, amount: amount, merkleProof: merkleProof });
// Should revert if round not set
vm.expectRevert(abi.encodeWithSelector(FeeRouterEarningsAirdrop.MerkleRootNotSet.selector, round));
feeRouterEarningsAirdrop.claim(round, amount, merkleProof);
feeRouterEarningsAirdrop.claim(airdropClaim);
}

function test_Fuzz_FeeRouterEarningsAirdrop_claim_RevertIf_AlreadyClaimed(
Expand All @@ -162,10 +220,31 @@ contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {
// Mock a claim
feeRouterEarningsAirdrop.mock_setHasClaimed(round, account, true);

AirdropClaim[] memory airdropClaim = new AirdropClaim[](1);
airdropClaim[0] = AirdropClaim({ round: round, amount: amount, merkleProof: merkleProof });

// Should revert if account has already claimed for round
vm.expectRevert(abi.encodeWithSelector(FeeRouterEarningsAirdrop.AlreadyClaimed.selector, round, account));
vm.prank(account);
feeRouterEarningsAirdrop.claim(round, amount, merkleProof);
feeRouterEarningsAirdrop.claim(airdropClaim);
}

function test_Fuzz_FeeRouterEarningsAirdrop_claim_RevertIf_EmptyAirdropClaim(
uint256 round,
address account,
bytes32 merkleRoot
)
external
{
// Setup Merkle Root for round
_setRound(round, merkleRoot);

AirdropClaim[] memory airdropClaim = new AirdropClaim[](0);

// Should revert if the airdrop claim is empty
vm.expectRevert(FeeRouterEarningsAirdrop.EmptyAirdropClaim.selector);
vm.prank(account);
feeRouterEarningsAirdrop.claim(airdropClaim);
}

function test_Fuzz_FeeRouterEarningsAirdrop_claim_RevertIf_InvalidProof(
Expand All @@ -180,10 +259,13 @@ contract FeeRouterEarningsAirdrop_Unit_Test is BaseTest {
// Setup Merkle Root for round
_setRound(round, merkleRoot);

AirdropClaim[] memory airdropClaim = new AirdropClaim[](1);
airdropClaim[0] = AirdropClaim({ round: round, amount: amount, merkleProof: merkleProof });

// Should revert if the proof is invalid
vm.expectRevert(FeeRouterEarningsAirdrop.InvalidProof.selector);
vm.prank(account);
feeRouterEarningsAirdrop.claim(round, amount, merkleProof);
feeRouterEarningsAirdrop.claim(airdropClaim);
}

function test_Fuzz_FeeRouterEarningsAirdrop_setRound_RevertIf_NotOwner(
Expand Down
Loading