Created
August 15, 2025 01:50
-
-
Save rfikki/08cc1084a0e665d3c1978d8e8fdd1897 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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