A decentralized, pull-based reward distribution system for Sprint leaderboards. At the end of each Sprint, an off-chain oracle reads GitHub contribution points, builds a Merkle tree, and posts the root on-chain. Contributors then submit a Merkle proof to claim their share of the reward pool — no admin can redirect funds, every payout is verifiable on-chain, and each address can only claim once per sprint.
- How It Works
- Architecture
- Project Structure
- Smart Contract
- Off-Chain Oracle Helper
- Scripts
- Tests
- Quick Start
- Environment Variables
- Deploying to Sepolia
- End-to-End Claim Flow
- Security Notes
- Tech Stack
- License
1. Sprint ends
│
▼
2. Oracle reads GitHub contribution points
│
▼
3. Off-chain: buildRewardTree() converts points → proportional token amounts
and builds a Merkle tree
│
▼
4. Oracle calls postSprint(sprintId, merkleRoot, pool) on-chain
│
▼
5. Contributors call claim(sprintId, amount, proof)
│
▼
6. Contract verifies proof against stored root → transfers ERC-20 tokens
The key insight: the contract never needs to store every contributor's address or amount. It only stores a single 32-byte Merkle root per sprint. All the heavy lifting (computing amounts, generating proofs) happens off-chain.
GitHub API
│ (points snapshot)
▼
Oracle / Wave Admin ──postSprint(sprintId, merkleRoot, pool)──▶ WaveLeaderboardRewards.sol
│
Contributor ──claim(sprintId, amount, proof)──────────────────────────▶ │
│
ERC-20 transfer to contributor
| Layer | File | Description |
|---|---|---|
| Smart contract | contracts/WaveLeaderboardRewards.sol |
Stores Merkle roots, validates proofs, transfers tokens |
| Test token | contracts/MockERC20.sol |
Minimal ERC-20 for local testing |
| Oracle helper | src/merkle.ts |
Converts raw points into proportional amounts and builds the Merkle tree |
| Deploy script | scripts/deploy.ts |
Deploys both contracts and funds the reward pool |
| Simulation script | scripts/postAndClaim.ts |
Full end-to-end oracle + claim simulation |
| Tests | test/WaveLeaderboardRewards.test.ts |
Happy path + edge cases |
.
├── contracts/
│ ├── WaveLeaderboardRewards.sol # Core reward distributor
│ └── MockERC20.sol # Test token (local only)
├── scripts/
│ ├── deploy.ts # Deploy + fund the contract
│ └── postAndClaim.ts # Oracle simulation (post root + claim)
├── src/
│ └── merkle.ts # Off-chain Merkle tree builder
├── test/
│ └── WaveLeaderboardRewards.test.ts
├── hardhat.config.ts
├── package.json
├── tsconfig.json
└── .env.example
The core contract. Inherits OpenZeppelin Ownable and uses MerkleProof for verification.
IERC20 public immutable rewardToken;
struct Sprint {
bytes32 merkleRoot;
uint256 totalPool;
bool finalized;
}
mapping(uint256 => Sprint) public sprints;
mapping(uint256 => mapping(address => bool)) public claimed;| Function | Access | Description |
|---|---|---|
postSprint(sprintId, merkleRoot, pool) |
onlyOwner |
Posts the Merkle root for a completed sprint. Immutable once set. |
claim(sprintId, amount, proof) |
Public | Contributor claims their reward using a Merkle proof. One claim per address per sprint. |
| Event | Emitted When |
|---|---|
SprintPosted(sprintId, merkleRoot, pool) |
Oracle posts a new sprint root |
RewardClaimed(sprintId, contributor, amount) |
A contributor successfully claims |
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));The leaf is a hash of the contributor's address and their exact reward amount. This must match exactly what was used when building the tree off-chain.
Two exported functions:
Takes a list of contributors with their GitHub points and the total pool in wei. Converts points to proportional token amounts and returns a StandardMerkleTree.
import { buildRewardTree } from "./src/merkle";
const tree = buildRewardTree(
[
{ address: "0xAlice...", points: 600 },
{ address: "0xBob...", points: 400 },
],
ethers.parseEther("1000")
);
console.log(tree.root); // post this on-chainAmount formula:
contributorAmount = (poolWei * contributorPoints) / totalPoints
Returns the { amount, proof } for a specific address. Used by contributors (or an API serving proofs) to prepare a claim transaction.
const { amount, proof } = getProofForAddress(tree, aliceAddress);
await rewards.connect(alice).claim(sprintId, amount, proof);Deploys MockERC20 and WaveLeaderboardRewards, then funds the contract with 10,000 tokens.
# Local
npm run deploy:local
# Sepolia
npm run deploy:sepoliaFull simulation on a local Hardhat network:
- Deploys both contracts and funds the pool
- Builds a Merkle tree for Alice (600 points) and Bob (400 points) from a 1,000 token pool
- Posts the Merkle root on-chain as the oracle
- Both contributors claim their tokens
npm run simulateFour test cases covering the full contract surface:
| Test | What it checks |
|---|---|
| Valid claim | Correct proof → RewardClaimed event emitted, tokens transferred |
| Double claim | Second claim from same address → reverts with "already claimed" |
| Invalid proof | Wrong proof or non-existent address → reverts with "invalid proof" |
| Claim before post | Sprint not yet posted → reverts with "sprint not posted" |
npm test# Install dependencies
npm install
# Copy env file and fill in values
cp .env.example .env
# Compile contracts
npm run compile
# Run tests
npm test
# Run end-to-end simulation on local network
npm run simulate| Variable | Required | Description |
|---|---|---|
RPC_URL |
For Sepolia | Your Sepolia RPC endpoint (e.g. from Alchemy or Infura) |
PRIVATE_KEY |
For Sepolia | Deployer wallet private key (without 0x prefix) |
REWARD_TOKEN |
Optional | Address of an existing ERC-20 to use instead of deploying MockERC20 |
- Set
RPC_URLandPRIVATE_KEYin.env - Make sure the deployer wallet has Sepolia ETH
- Run:
npm run deploy:sepoliaThe script will log the deployed contract addresses. Fund WaveLeaderboardRewards with your reward token before calling postSprint.
// 1. Oracle builds the tree off-chain from GitHub points
const tree = buildRewardTree(
[
{ address: "0xAlice...", points: 600 },
{ address: "0xBob...", points: 400 },
],
ethers.parseEther("1000")
);
// 2. Oracle posts the Merkle root on-chain (owner only)
await rewards.postSprint(sprintId, tree.root, ethers.parseEther("1000"));
// 3. Publish the tree to IPFS so anyone can verify independently
// 4. Contributor fetches their proof and claims
const { amount, proof } = getProofForAddress(tree, aliceAddress);
await rewards.connect(alice).claim(sprintId, amount, proof);- Immutable roots — once
postSprintis called for a sprint ID, the Merkle root cannot be changed or overwritten. - One claim per address — the
claimed[sprintId][address]mapping prevents double-spending. - No admin withdrawal — the contract has no function to pull tokens out. Unclaimed tokens remain locked. If you need a sweep mechanism, add one with a time-lock (e.g. 90 days after sprint close).
- Proof transparency — the points snapshot and generated Merkle tree should be published to IPFS so any contributor can independently verify the root and generate their own proof.
- Leaf collision resistance — leaves are encoded as
keccak256(abi.encodePacked(address, uint256)). Ensure the off-chain tree uses the same encoding (["address", "uint256"]withStandardMerkleTree). - Token approval — the contract uses
transfer, nottransferFrom. The reward tokens must be sent directly to the contract address beforepostSprintis called.
| Tool | Version | Purpose |
|---|---|---|
| Solidity | ^0.8.24 |
Smart contract language |
| Hardhat | ^2.22.0 |
Development environment, testing, deployment |
| OpenZeppelin Contracts | ^5.0.0 |
MerkleProof, IERC20, Ownable, ERC20 |
| OpenZeppelin Merkle Tree | ^1.0.7 |
Off-chain StandardMerkleTree builder |
| ethers.js | ^6.11.0 |
Blockchain interaction |
| TypeScript | ^5.4.0 |
Type-safe scripts and tests |
| Chai | via hardhat-toolbox | Test assertions |
MIT