TokenOpsMerkleDistributor
The TokenOpsMerkleDistributor
is the main contract for distributing ERC20 tokens using Merkle trees. It extends the base functionality with ERC20-specific features including staking integration, bonus calculations, and token-based fee collection.
Overview
This contract enables:
- ERC20 Token Airdrops: Distribute any ERC20 token to multiple recipients
- Staking Integration: Direct claiming and staking in a single transaction
- Flexible Fee Structure: Support for both gas fees and token fees
- Bonus Rewards: Additional rewards for users who stake their tokens
- Admin Controls: Comprehensive withdrawal and fee management
Key Features
🎯 Core Functionality
- Merkle-based Claims: Efficient proof-based claiming system
- ERC20 Integration: Full support for any ERC20 token
- Reentrancy Protection: Safe against reentrancy attacks
- Batch Operations: Efficient gas usage for multiple operations
💰 Staking Integration
- Claim and Stake: Single transaction for claiming and staking
- Bonus Calculation: Automatic bonus rewards for stakers
- Flexible Staking: Support for external staking contracts
- Reward Management: Separate reward owner role
🔄 Fee Management
- Dual Fee Types: Gas fees (ETH) or token fees
- Fee Collector Role: Separate role for fee management
- Admin Separation: Fee collector independent from distributor admin
- Flexible Withdrawals: Withdraw fees to any address
Contract Details
Core Functions
Claiming Functions
claim
Standard claim function for receiving ERC20 tokens.
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) external payable;
Parameters:
index
: Position in the Merkle treeaccount
: Address that should receive the tokensamount
: Amount of tokens to claimmerkleProof
: Merkle proof validating the claim
Requirements:
- Must be within claim window (startTime to endTime)
- Must provide valid Merkle proof
- Index must not be already claimed
- Must pay required fee (if gas fee type)
Example:
const tx = await distributor.claim(
42, // index
userAddress, // account
ethers.parseEther("100"), // amount
merkleProof, // proof array
{ value: gasFee } // gas fee if required
);
claimAndStake
Claim tokens and immediately stake them in a single transaction.
function claimAndStake(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) external payable nonReentrant;
Parameters:
- Same as
claim
function
Benefits:
- Gas Efficient: Single transaction for both operations
- Bonus Rewards: Automatic bonus calculation and distribution
- Atomic: Either both succeed or both fail
Bonus Calculation:
bonus = (amount * bonusPercentage) / BASIS_POINTS;
Example:
const tx = await distributor.claimAndStake(
42, // index
userAddress, // account
ethers.parseEther("100"), // amount
merkleProof, // proof array
{ value: gasFee } // gas fee if required
);
Admin Functions
withdraw
Allows distributor admin to withdraw remaining tokens after claim period.
function withdraw() external onlyOwner;
Access: Only contract owner (distributor admin)
Requirements:
- Grace period must have passed (7 days after first claim)
- Cannot withdraw during active claim period (unless expired)
Protection Logic:
if (!hasExpired() && !_hasGracePeriodPassed()) {
revert NoWithdrawDuringClaim();
}
withdrawOtherToken
Withdraw accidentally sent tokens (not the main distribution token).
function withdrawOtherToken(address _tokenAddress) external onlyOwner;
Parameters:
_tokenAddress
: Address of the token to withdraw
Access: Only contract owner
Safety: Cannot withdraw the main distribution token
Fee Collector Functions
withdrawTokenFee
Withdraw collected token fees.
function withdrawTokenFee(address recipient, uint256 amount) external onlyFeeCollector;
Parameters:
recipient
: Address to receive the feesamount
: Amount to withdraw (0 = withdraw all)
Access: Only fee collector
Example:
// Withdraw all collected token fees
await distributor.withdrawTokenFee(feeCollectorAddress, 0);
// Withdraw specific amount
await distributor.withdrawTokenFee(feeCollectorAddress, ethers.parseEther("50"));
withdrawGasFee
Withdraw collected gas fees (ETH).
function withdrawGasFee(address recipient, uint256 amount) external onlyFeeCollector;
Parameters:
recipient
: Address to receive the feesamount
: Amount to withdraw (0 = withdraw all)
Access: Only fee collector
State Variables
Immutable Configuration
uint256 public constant BASIS_POINTS = 10000; // 100.00%
address public immutable TOKEN; // ERC20 token address
ITokenOpsStaking public immutable STAKING; // Staking contract
address public immutable REWARD_OWNER; // Bonus reward owner
uint256 public immutable BONUS_PERCENTAGE; // Bonus percentage (basis points)
Dynamic State
uint256 public numTokenReservedForFee; // Tokens reserved for fees
Events
Claimed
Emitted when tokens are claimed.
event Claimed(
uint256 indexed index,
address indexed account,
uint256 amount
);
Withdrawn
Emitted when admin withdraws tokens.
event Withdrawn(
address indexed admin,
address indexed token,
uint256 amount
);
TokenFeeWithdrawn
Emitted when fee collector withdraws token fees.
event TokenFeeWithdrawn(
address indexed recipient,
address indexed token,
uint256 amount
);
GasFeeWithdrawn
Emitted when fee collector withdraws gas fees.
event GasFeeWithdrawn(
address indexed recipient,
uint256 amount
);
TokensBonus
Emitted when bonus tokens are distributed to stakers.
event TokensBonus(
address indexed staker,
uint256 bonus
);
Implementation Details
Fee Handling
The contract supports two fee types:
Gas Fee (ETH)
if (feeType == ITypes.FeeType.Gas) {
require(msg.value >= fee, "Insufficient fee");
// ETH fee is collected in contract balance
}
Token Fee
if (feeType == ITypes.FeeType.DistributionToken) {
numTokenReservedForFee += fee;
// Fee is deducted from token allocation
}
Staking Integration
Bonus Calculation
Bonus is optional and can be set to 0 or not approving from the token will ignore bonus addition.
function _calculateBonus(uint256 amount) internal view returns (uint256) {
if (BONUS_PERCENTAGE == 0) return 0;
return (amount * BONUS_PERCENTAGE) / BASIS_POINTS;
}
Staking Flow
function _claimAndStake(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) internal {
// Verify and mark claimed
_verifyAndMarkClaimed(index, account, amount, merkleProof);
// Calculate bonus
uint256 bonus = _calculateBonus(amount);
// Transfer to staking contract
IERC20(TOKEN).safeTransfer(address(STAKING), amount);
// Stake tokens
STAKING.stake(amount, account);
// Handle bonus if applicable
if (bonus > 0) {
_handleBonus(account, bonus);
}
}
Access Control
The contract implements dual access control:
Owner Role (Distributor Admin)
- Deploy the contract
- Withdraw remaining tokens
- Withdraw accidentally sent tokens
- Cannot access fee functions
Fee Collector Role
- Withdraw gas fees
- Withdraw token fees
- Transfer fee collector role
- Cannot access admin functions
Reentrancy Protection
The contract uses OpenZeppelin's ReentrancyGuardTransient
for protection:
function claimAndStake(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) external payable nonReentrant {
// Safe from reentrancy attacks
}
Usage Examples
Basic Token Claim
import { ethers } from "ethers";
// Connect to contract
const distributor = new ethers.Contract(
distributorAddress,
distributorABI,
signer
);
// Prepare claim data
const claimData = {
index: 42,
account: userAddress,
amount: ethers.parseEther("100"),
proof: merkleProof
};
// Check if already claimed
const hasClaimed = await distributor.isClaimed(claimData.index);
if (hasClaimed) {
throw new Error("Already claimed");
}
// Get fee information
const feeType = await distributor.feeType();
const fee = await distributor.fee();
// Claim tokens
const tx = await distributor.claim(
claimData.index,
claimData.account,
claimData.amount,
claimData.proof,
{ value: feeType === 0 ? fee : 0 } // Gas fee if required
);
await tx.wait();
console.log("Tokens claimed successfully");
Claim and Stake
// Check staking configuration
const stakingContract = await distributor.STAKING();
const bonusPercentage = await distributor.BONUS_PERCENTAGE();
console.log("Staking contract:", stakingContract);
console.log("Bonus percentage:", bonusPercentage.toString(), "basis points");
// Claim and stake in one transaction
const tx = await distributor.claimAndStake(
claimData.index,
claimData.account,
claimData.amount,
claimData.proof,
{ value: feeType === 0 ? fee : 0 }
);
await tx.wait();
console.log("Tokens claimed and staked successfully");
Admin Operations
// Check withdrawal eligibility
const hasExpired = await distributor.hasExpired();
const gracePeriodPassed = await distributor.gracePeriodStatus();
if (!hasExpired && !gracePeriodPassed) {
console.log("Cannot withdraw yet - grace period active");
return;
}
// Withdraw remaining tokens
const tx = await distributor.withdraw();
await tx.wait();
console.log("Remaining tokens withdrawn");
Fee Collection
// Fee collector operations
const feeCollectorSigner = // ... get fee collector signer
// Withdraw gas fees
const gasFeeBalance = await ethers.provider.getBalance(distributorAddress);
if (gasFeeBalance > 0) {
const tx = await distributor.connect(feeCollectorSigner)
.withdrawGasFee(feeCollectorAddress, 0); // 0 = withdraw all
await tx.wait();
}
// Withdraw token fees
const tokenFeeBalance = await distributor.numTokenReservedForFee();
if (tokenFeeBalance > 0) {
const tx = await distributor.connect(feeCollectorSigner)
.withdrawTokenFee(feeCollectorAddress, 0); // 0 = withdraw all
await tx.wait();
}
Security Considerations
Claim Security
- Merkle Proof Validation: All claims must provide valid cryptographic proof
- Single Claim: Each index can only be claimed once
- Time Windows: Claims only allowed within specified periods
- Fee Validation: Proper fee payment required
Admin Security
- Grace Period: 7-day protection against premature withdrawals
- Role Separation: Admin and fee collector roles are separate
- Expiration Logic: Proper handling of expired campaigns
Token Security
- SafeERC20: Uses OpenZeppelin's SafeERC20 for secure transfers
- Reentrancy Protection: Protected against reentrancy attacks
- Balance Checks: Proper balance validation before transfers
Error Handling
Common Errors
Error | Cause | Solution |
---|---|---|
InvalidProof() | Invalid Merkle proof | Regenerate proof from valid tree |
ClaimWindowFinished() | Past end time | Wait for admin to deploy new campaign |
InsufficientFee() | Insufficient fee payment | Send correct fee amount |
AlreadyClaimed() | Index already claimed | Check claim status first |
NoWithdrawDuringClaim() | Grace period active | Wait for grace period to pass |
NotEnoughTokens() | Insufficient balance | Ensure contract has enough tokens |
Best Practices
- Always check claim status before attempting to claim
- Validate timing - ensure claim window is active
- Handle fees properly - pay correct amount for gas fees
- Monitor grace periods - understand withdrawal restrictions
- Use events for tracking operations
Related Contracts
- BaseTokenOpsMerkleDistributor - Base contract
- TokenOpsMerkleDistributorFactory - Factory contract
- ITokenOpsMerkleDistributor - Interface
This contract provides a complete ERC20 token distribution solution with advanced features like staking integration and flexible fee structures.