Skip to content

Commit 44c333d

Browse files
committed
(+) feat: mini-game reward
1 parent e431c3c commit 44c333d

4 files changed

Lines changed: 884 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// SPDX-License-Identifier: GPL-3.0
2+
pragma solidity ^0.8.23;
3+
4+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6+
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
7+
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
8+
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
9+
import {MiniGamePoolRewardErrors} from "../interfaces/MiniGamePoolRewardErrors.sol";
10+
11+
/**
12+
* @title MiniGamePoolReward
13+
* @notice Contract for distributing ERC20 tokens using merkle trees with time-based claim periods
14+
* @dev Allows admin to add merkle roots with end times, users can claim rewards if they provide valid proofs
15+
*/
16+
contract MiniGamePoolReward is Ownable, ReentrancyGuard, MiniGamePoolRewardErrors {
17+
using SafeERC20 for IERC20;
18+
19+
/// @notice The ERC20 token used for rewards
20+
IERC20 public immutable rewardToken;
21+
22+
/// @notice Structure to store merkle root information
23+
struct MerkleRootInfo {
24+
bytes32 root;
25+
uint256 endTime;
26+
bool exists;
27+
}
28+
29+
/// @notice Mapping from merkle root to its information
30+
mapping(bytes32 => MerkleRootInfo) public merkleRoots;
31+
32+
/// @notice Mapping to track if an address has claimed from a specific merkle root
33+
/// @dev merkleRoot => claimer => hasClaimed
34+
mapping(bytes32 => mapping(address => bool)) public hasClaimed;
35+
36+
/// @notice Array to keep track of all merkle roots for enumeration
37+
bytes32[] public merkleRootsList;
38+
39+
/// @notice Emitted when a new merkle root is added
40+
event MerkleRootAdded(bytes32 indexed root, uint256 endTime);
41+
42+
/// @notice Emitted when a merkle root is updated
43+
event MerkleRootUpdated(bytes32 indexed root, uint256 newEndTime);
44+
45+
/// @notice Emitted when tokens are claimed
46+
event TokensClaimed(
47+
address indexed claimer,
48+
bytes32 indexed merkleRoot,
49+
uint256 amount
50+
);
51+
52+
/// @notice Emitted when tokens are withdrawn by admin
53+
event TokensWithdrawn(address indexed to, uint256 amount);
54+
55+
/**
56+
* @notice Constructor to initialize the contract
57+
* @param _rewardToken The ERC20 token to be distributed as rewards
58+
* @param _owner The owner of the contract
59+
*/
60+
constructor(IERC20 _rewardToken, address _owner) Ownable(_owner) {
61+
if (address(_rewardToken) == address(0)) revert ZeroAddress();
62+
if (_owner == address(0)) revert ZeroAddress();
63+
rewardToken = _rewardToken;
64+
}
65+
66+
/**
67+
* @notice Add a new merkle root with an end time for claims
68+
* @param _merkleRoot The merkle root to add
69+
* @param _endTime The timestamp when claims for this root expire
70+
*/
71+
function addMerkleRoot(bytes32 _merkleRoot, uint256 _endTime) external onlyOwner {
72+
if (_merkleRoot == bytes32(0)) revert InvalidMerkleRoot();
73+
if (_endTime <= block.timestamp) revert InvalidEndTime(_endTime);
74+
75+
// If root already exists, update it
76+
if (merkleRoots[_merkleRoot].exists) {
77+
merkleRoots[_merkleRoot].endTime = _endTime;
78+
emit MerkleRootUpdated(_merkleRoot, _endTime);
79+
} else {
80+
merkleRoots[_merkleRoot] = MerkleRootInfo({
81+
root: _merkleRoot,
82+
endTime: _endTime,
83+
exists: true
84+
});
85+
merkleRootsList.push(_merkleRoot);
86+
emit MerkleRootAdded(_merkleRoot, _endTime);
87+
}
88+
}
89+
90+
/**
91+
* @notice Claim tokens using a merkle proof
92+
* @param _merkleRoot The merkle root to claim from
93+
* @param _amount The amount to claim
94+
* @param _merkleProof The merkle proof for the claim
95+
*/
96+
function claimTokens(
97+
bytes32 _merkleRoot,
98+
uint256 _amount,
99+
bytes32[] calldata _merkleProof
100+
) external nonReentrant {
101+
if (_amount == 0) revert ZeroAmount();
102+
103+
MerkleRootInfo storage rootInfo = merkleRoots[_merkleRoot];
104+
if (!rootInfo.exists) revert RootNotFound(_merkleRoot);
105+
if (block.timestamp > rootInfo.endTime) revert ClaimPeriodExpired(_merkleRoot, rootInfo.endTime);
106+
if (hasClaimed[_merkleRoot][msg.sender]) revert AlreadyClaimed(msg.sender, _merkleRoot);
107+
108+
// Verify merkle proof
109+
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, _amount));
110+
if (!MerkleProof.verify(_merkleProof, _merkleRoot, leaf)) revert InvalidProof();
111+
112+
// Check if contract has sufficient balance
113+
uint256 contractBalance = rewardToken.balanceOf(address(this));
114+
if (contractBalance < _amount) revert InsufficientBalance(_amount, contractBalance);
115+
116+
// Mark as claimed
117+
hasClaimed[_merkleRoot][msg.sender] = true;
118+
119+
// Transfer tokens
120+
rewardToken.safeTransfer(msg.sender, _amount);
121+
122+
emit TokensClaimed(msg.sender, _merkleRoot, _amount);
123+
}
124+
125+
/**
126+
* @notice Check if an address has claimed from a specific merkle root
127+
* @param _merkleRoot The merkle root to check
128+
* @param _claimer The address to check
129+
* @return True if the address has claimed, false otherwise
130+
*/
131+
function hasClaimedFromRoot(bytes32 _merkleRoot, address _claimer) external view returns (bool) {
132+
return hasClaimed[_merkleRoot][_claimer];
133+
}
134+
135+
/**
136+
* @notice Get information about a merkle root
137+
* @param _merkleRoot The merkle root to query
138+
* @return root The merkle root
139+
* @return endTime The end time for claims
140+
* @return exists Whether the root exists
141+
*/
142+
function getMerkleRootInfo(bytes32 _merkleRoot) external view returns (bytes32 root, uint256 endTime, bool exists) {
143+
MerkleRootInfo storage info = merkleRoots[_merkleRoot];
144+
return (info.root, info.endTime, info.exists);
145+
}
146+
147+
/**
148+
* @notice Get all merkle roots
149+
* @return Array of all merkle roots
150+
*/
151+
function getAllMerkleRoots() external view returns (bytes32[] memory) {
152+
return merkleRootsList;
153+
}
154+
155+
/**
156+
* @notice Get the number of merkle roots
157+
* @return The count of merkle roots
158+
*/
159+
function getMerkleRootsCount() external view returns (uint256) {
160+
return merkleRootsList.length;
161+
}
162+
163+
/**
164+
* @notice Check if a merkle root is active (exists and not expired)
165+
* @param _merkleRoot The merkle root to check
166+
* @return True if active, false otherwise
167+
*/
168+
function isRootActive(bytes32 _merkleRoot) external view returns (bool) {
169+
MerkleRootInfo storage info = merkleRoots[_merkleRoot];
170+
return info.exists && block.timestamp <= info.endTime;
171+
}
172+
173+
/**
174+
* @notice Get the contract's token balance
175+
* @return The current token balance of the contract
176+
*/
177+
function getContractBalance() external view returns (uint256) {
178+
return rewardToken.balanceOf(address(this));
179+
}
180+
181+
/**
182+
* @notice Withdraw tokens from the contract (admin only)
183+
* @param _to The address to send tokens to
184+
* @param _amount The amount to withdraw
185+
*/
186+
function withdrawTokens(address _to, uint256 _amount) external onlyOwner {
187+
if (_to == address(0)) revert ZeroAddress();
188+
if (_amount == 0) revert ZeroAmount();
189+
190+
uint256 contractBalance = rewardToken.balanceOf(address(this));
191+
if (contractBalance < _amount) revert InsufficientBalance(_amount, contractBalance);
192+
193+
rewardToken.safeTransfer(_to, _amount);
194+
emit TokensWithdrawn(_to, _amount);
195+
}
196+
197+
/**
198+
* @notice Emergency withdraw all tokens (admin only)
199+
* @param _to The address to send tokens to
200+
*/
201+
function emergencyWithdraw(address _to) external onlyOwner {
202+
if (_to == address(0)) revert ZeroAddress();
203+
204+
uint256 balance = rewardToken.balanceOf(address(this));
205+
if (balance > 0) {
206+
rewardToken.safeTransfer(_to, balance);
207+
emit TokensWithdrawn(_to, balance);
208+
}
209+
}
210+
}

src/distribute/README.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# MiniGamePoolReward Contract
2+
3+
## Overview
4+
5+
The `MiniGamePoolReward` contract is designed for distributing ERC20 tokens to users using merkle trees with time-based claim periods. This allows for efficient, gas-optimized distribution of rewards to large numbers of users while providing strong security guarantees.
6+
7+
## Features
8+
9+
- **ERC20 Token Distribution**: Distribute any ERC20 token as rewards
10+
- **Merkle Tree Verification**: Use merkle proofs to verify user eligibility
11+
- **Time-Based Claims**: Set expiration times for each merkle root
12+
- **Claim Tracking**: Prevent double-claiming from the same merkle root
13+
- **Owner Controls**: Admin functions for managing roots and withdrawing tokens
14+
- **Emergency Functions**: Emergency withdrawal capabilities
15+
16+
## Core Components
17+
18+
### Constructor
19+
```solidity
20+
constructor(IERC20 _rewardToken, address _owner)
21+
```
22+
- `_rewardToken`: The ERC20 token to be distributed
23+
- `_owner`: The owner/admin of the contract
24+
25+
### Key Functions
26+
27+
#### Adding Merkle Roots (Owner Only)
28+
```solidity
29+
function addMerkleRoot(bytes32 _merkleRoot, uint256 _endTime) external onlyOwner
30+
```
31+
- Adds a new merkle root with an expiration time
32+
- Can update existing roots with new end times
33+
34+
#### Claiming Tokens
35+
```solidity
36+
function claimTokens(
37+
bytes32 _merkleRoot,
38+
uint256 _amount,
39+
bytes32[] calldata _merkleProof
40+
) external nonReentrant
41+
```
42+
- Users provide merkle proof to claim their allocated tokens
43+
- Validates proof against the merkle root
44+
- Prevents double-claiming and expired claims
45+
46+
#### Utility Functions
47+
- `hasClaimedFromRoot(bytes32, address)`: Check if address has claimed
48+
- `getMerkleRootInfo(bytes32)`: Get root information
49+
- `getAllMerkleRoots()`: Get all merkle roots
50+
- `isRootActive(bytes32)`: Check if root is still active
51+
- `getContractBalance()`: Get current token balance
52+
53+
#### Admin Functions
54+
- `withdrawTokens(address, uint256)`: Withdraw specific amount
55+
- `emergencyWithdraw(address)`: Withdraw all tokens
56+
57+
## Usage Example
58+
59+
### 1. Deploy Contract
60+
```solidity
61+
MiniGamePoolReward reward = new MiniGamePoolReward(
62+
IERC20(tokenAddress),
63+
ownerAddress
64+
);
65+
```
66+
67+
### 2. Fund Contract
68+
Transfer tokens to the contract address for distribution.
69+
70+
### 3. Create Merkle Tree Off-Chain
71+
```javascript
72+
// Example user data
73+
const users = [
74+
{ address: "0x123...", amount: "100000000000000000000" }, // 100 tokens
75+
{ address: "0x456...", amount: "200000000000000000000" }, // 200 tokens
76+
{ address: "0x789...", amount: "300000000000000000000" } // 300 tokens
77+
];
78+
79+
// Create leaves (hash of address + amount)
80+
const leaves = users.map(user =>
81+
keccak256(solidityPack(["address", "uint256"], [user.address, user.amount]))
82+
);
83+
84+
// Build merkle tree and get root
85+
const tree = new MerkleTree(leaves, keccak256, { sort: true });
86+
const root = tree.getHexRoot();
87+
```
88+
89+
### 4. Add Merkle Root
90+
```solidity
91+
// Set claim period (e.g., 30 days from now)
92+
uint256 endTime = block.timestamp + 30 days;
93+
94+
// Add the merkle root
95+
reward.addMerkleRoot(root, endTime);
96+
```
97+
98+
### 5. Users Claim Tokens
99+
```solidity
100+
// Generate proof off-chain for specific user
101+
const proof = tree.getHexProof(leaf);
102+
103+
// User claims tokens
104+
reward.claimTokens(root, amount, proof);
105+
```
106+
107+
## Security Features
108+
109+
### Access Control
110+
- Only owner can add merkle roots
111+
- Only owner can withdraw tokens
112+
- Users can only claim with valid proofs
113+
114+
### Reentrancy Protection
115+
- `nonReentrant` modifier on claim function
116+
- Uses OpenZeppelin's ReentrancyGuard
117+
118+
### Double-Claim Prevention
119+
- Tracks claimed status per (merkleRoot, user) pair
120+
- Prevents multiple claims from same root
121+
122+
### Time-Based Expiration
123+
- Claims expire after specified end time
124+
- Prevents indefinite claim periods
125+
126+
## Error Handling
127+
128+
The contract includes comprehensive error handling:
129+
130+
- `InvalidMerkleRoot()`: Zero merkle root provided
131+
- `InvalidEndTime()`: End time in the past
132+
- `AlreadyClaimed()`: User already claimed from this root
133+
- `InvalidProof()`: Merkle proof verification failed
134+
- `ClaimPeriodExpired()`: Claim period has ended
135+
- `InsufficientBalance()`: Contract lacks sufficient tokens
136+
- `ZeroAmount()`: Zero amount specified
137+
- `ZeroAddress()`: Zero address provided
138+
- `RootNotFound()`: Merkle root doesn't exist
139+
140+
## Best Practices
141+
142+
### For Administrators
143+
1. **Test Merkle Trees**: Always verify merkle tree construction off-chain
144+
2. **Reasonable End Times**: Set appropriate claim periods (not too short/long)
145+
3. **Fund Contract**: Ensure contract has sufficient tokens before adding roots
146+
4. **Monitor Claims**: Track claim activity and remaining balances
147+
148+
### For Users
149+
1. **Claim Early**: Don't wait until the last minute to claim
150+
2. **Verify Proofs**: Ensure merkle proofs are generated correctly
151+
3. **Check Eligibility**: Verify you're included in the merkle tree
152+
4. **One Claim Per Root**: Remember you can only claim once per merkle root
153+
154+
## Gas Optimization
155+
156+
- Uses merkle trees for O(log n) verification instead of storing all user data
157+
- Minimal storage per user (just claimed status)
158+
- Efficient proof verification using OpenZeppelin's MerkleProof library
159+
- Single transaction per claim
160+
161+
## Integration Notes
162+
163+
### With Frontend
164+
- Generate merkle trees server-side or in secure environment
165+
- Provide users with their proofs via API
166+
- Cache proofs for better UX
167+
- Show claim status and remaining time
168+
169+
### With Other Contracts
170+
- Can be integrated with gaming contracts
171+
- Supports any ERC20 token
172+
- Events emitted for tracking
173+
- View functions for status queries
174+
175+
## Testing
176+
177+
The contract includes comprehensive test coverage:
178+
- Unit tests for all functions
179+
- Edge case testing
180+
- Fuzz testing for robustness
181+
- Integration test scenarios
182+
- Gas usage optimization tests
183+
184+
See `test/distribute/MiniGamePoolReward.t.sol` for complete test suite.

0 commit comments

Comments
 (0)