Skip to content

Instantly share code, notes, and snippets.

@rfikki
Created August 15, 2025 01:50
Show Gist options
  • Select an option

  • Save rfikki/08cc1084a0e665d3c1978d8e8fdd1897 to your computer and use it in GitHub Desktop.

Select an option

Save rfikki/08cc1084a0e665d3c1978d8e8fdd1897 to your computer and use it in GitHub Desktop.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
/**
* @title CurrencyAirdropClaim
* @notice A contract for managing a token airdrop with Merkle-based whitelist verification.
* @dev
* - Each whitelisted address can claim once.
* - Any wallet may perform one delegated claim for a friend.
* - Tokens are always sent to the beneficiary address.
* - A wallet that has received tokens cannot act as a delegate later.
* - Claiming is blocked until `claimOpensAt`.
* - First-come-first-served until the pool balance is empty.
*/
contract CurrencyAirdropClaim is Ownable, Pausable, ReentrancyGuard {
using SafeERC20 for IERC20;
IERC20 public immutable token;
bytes32 public merkleRoot;
uint256 public immutable claimAmount;
uint64 public claimOpensAt;
mapping(address => bool) public claimed;
mapping(address => bool) public delegateUsed;
/* -------------------------------- events -------------------------------- */
/**
* @notice Emitted when the Merkle root is set or updated.
* @param root The new Merkle root.
* @param claimAmount The amount of tokens that can be claimed.
*/
event RootSet(bytes32 indexed root, uint256 claimAmount);
/**
* @notice Emitted when the contract is funded with tokens.
* @param amount The amount of tokens transferred to the contract.
* @param opensAt The Unix timestamp when claiming starts.
*/
event Funded(uint256 amount, uint64 opensAt);
/**
* @notice Emitted when a claim is made.
* @param beneficiary The address of the beneficiary.
* @param claimer The address of the claimer.
* @param amount The amount of tokens claimed.
*/
event Claimed(
address indexed beneficiary,
address indexed claimer,
uint256 amount
);
/**
* @notice Emitted when tokens are rescued from the contract.
* @param to The address to which tokens are transferred.
* @param amount The amount of tokens rescued.
*/
event Rescue(address indexed to, uint256 amount);
/* ----------------------------- constructor ------------------------------ */
/**
* @notice Constructor for the CurrencyAirdropClaim contract.
* @param _token The ERC20 token contract address.
* @param _root The initial Merkle root.
* @param _claimAmount The amount of tokens that can be claimed.
* @param _initialOwner The initial owner of the contract.
*/
constructor(
IERC20 _token,
bytes32 _root,
uint256 _claimAmount,
address _initialOwner
) Ownable(_initialOwner) {
require(address(_token) != address(0), "token zero");
require(_root != bytes32(0), "root zero");
require(_claimAmount > 0, "amount zero");
token = _token;
merkleRoot = _root;
claimAmount = _claimAmount;
emit RootSet(_root, _claimAmount);
}
/* ------------------------- owner funding logic -------------------------- */
/**
* @notice Transfer `amount` tokens from the caller and set the Unix timestamp (`opensAt`) when claiming starts.
* @dev `opensAt` must be in the future.
* @param amount The amount of tokens to transfer.
* @param opensAt The Unix timestamp when claiming starts.
*/
function fundAndSchedule(uint256 amount, uint64 opensAt)
external
onlyOwner
{
require(amount > 0, "amount zero");
require(opensAt > block.timestamp, "opensAt not future");
token.safeTransferFrom(msg.sender, address(this), amount);
claimOpensAt = opensAt;
emit Funded(amount, opensAt);
}
/* ---------------------------- claim function ---------------------------- */
/**
* @notice Claim `claimAmount` tokens for a `beneficiary`.
* @dev
* - Each caller can use this function once (tracked by `delegateUsed`).
* - A self-claim also consumes that slot, preventing future delegation.
* @param beneficiary Address whitelisted in the Merkle tree.
* @param proof Merkle proof for `beneficiary`.
*/
function claim(address beneficiary, bytes32[] calldata proof)
external
nonReentrant
whenNotPaused
{
// Claim window guard
require(
claimOpensAt == 0 || block.timestamp >= claimOpensAt,
"claim not open"
);
require(!claimed[msg.sender], "caller already claimed");
require(!claimed[beneficiary], "beneficiary already claimed");
require(!delegateUsed[msg.sender], "caller slot used");
// Verify snapshot membership
bytes32 leaf = keccak256(abi.encodePacked(beneficiary));
require(
MerkleProof.verifyCalldata(proof, merkleRoot, leaf),
"bad proof"
);
claimed[beneficiary] = true;
delegateUsed[msg.sender] = true;
token.safeTransfer(beneficiary, claimAmount);
emit Claimed(beneficiary, msg.sender, claimAmount);
}
/* ------------------------------ owner ops ------------------------------ */
/**
* @notice Replace the Merkle root — already-claimed wallets stay claimed.
* @param newRoot The new Merkle root.
*/
function setMerkleRoot(bytes32 newRoot) external onlyOwner {
require(newRoot != bytes32(0), "root zero");
merkleRoot = newRoot;
emit RootSet(newRoot, claimAmount);
}
/* ----------------------------- pause / resume --------------------------- */
/**
* @notice Pause the contract.
*/
function pause() external onlyOwner {
_pause();
}
/**
* @notice Unpause the contract.
*/
function unpause() external onlyOwner {
_unpause();
}
/* ------------------------- emergency token rescue ----------------------- */
/**
* @notice Rescue tokens from the contract in case of emergency.
* @dev Can only be called when the contract is paused.
* @param tokenAddress The address of the token to rescue.
* @param to The address to which tokens are transferred.
*/
function rescue(address tokenAddress, address to)
external
onlyOwner
whenPaused
{
require(to != address(0), "zero addr");
uint256 bal = IERC20(tokenAddress).balanceOf(address(this));
IERC20(tokenAddress).safeTransfer(to, bal);
emit Rescue(to, bal);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment