Skip to main content

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 tree
  • account: Address that should receive the tokens
  • amount: Amount of tokens to claim
  • merkleProof: 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 fees
  • amount: 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 fees
  • amount: 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

ErrorCauseSolution
InvalidProof()Invalid Merkle proofRegenerate proof from valid tree
ClaimWindowFinished()Past end timeWait for admin to deploy new campaign
InsufficientFee()Insufficient fee paymentSend correct fee amount
AlreadyClaimed()Index already claimedCheck claim status first
NoWithdrawDuringClaim()Grace period activeWait for grace period to pass
NotEnoughTokens()Insufficient balanceEnsure contract has enough tokens

Best Practices

  1. Always check claim status before attempting to claim
  2. Validate timing - ensure claim window is active
  3. Handle fees properly - pay correct amount for gas fees
  4. Monitor grace periods - understand withdrawal restrictions
  5. Use events for tracking operations

This contract provides a complete ERC20 token distribution solution with advanced features like staking integration and flexible fee structures.