Skip to content

About

A Merkle-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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

Wave Leaderboard Rewards Automator

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.


Table of Contents


How It Works

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.


Architecture

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

Project Structure

.
├── 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

Smart Contract

WaveLeaderboardRewards.sol

The core contract. Inherits OpenZeppelin Ownable and uses MerkleProof for verification.

State

IERC20 public immutable rewardToken;

struct Sprint {
    bytes32 merkleRoot;
    uint256 totalPool;
    bool finalized;
}

mapping(uint256 => Sprint) public sprints;
mapping(uint256 => mapping(address => bool)) public claimed;

Functions

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.

Events

Event Emitted When
SprintPosted(sprintId, merkleRoot, pool) Oracle posts a new sprint root
RewardClaimed(sprintId, contributor, amount) A contributor successfully claims

Leaf encoding

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.


Off-Chain Oracle Helper

src/merkle.ts

Two exported functions:

buildRewardTree(contributors, poolWei)

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-chain

Amount formula:

contributorAmount = (poolWei * contributorPoints) / totalPoints

getProofForAddress(tree, address)

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);

Scripts

scripts/deploy.ts

Deploys MockERC20 and WaveLeaderboardRewards, then funds the contract with 10,000 tokens.

# Local
npm run deploy:local

# Sepolia
npm run deploy:sepolia

scripts/postAndClaim.ts

Full simulation on a local Hardhat network:

  1. Deploys both contracts and funds the pool
  2. Builds a Merkle tree for Alice (600 points) and Bob (400 points) from a 1,000 token pool
  3. Posts the Merkle root on-chain as the oracle
  4. Both contributors claim their tokens
npm run simulate

Tests

Four 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

Quick Start

# 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

Environment Variables

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

Deploying to Sepolia

  1. Set RPC_URL and PRIVATE_KEY in .env
  2. Make sure the deployer wallet has Sepolia ETH
  3. Run:
npm run deploy:sepolia

The script will log the deployed contract addresses. Fund WaveLeaderboardRewards with your reward token before calling postSprint.


End-to-End Claim Flow

// 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);

Security Notes

  • Immutable roots — once postSprint is 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"] with StandardMerkleTree).
  • Token approval — the contract uses transfer, not transferFrom. The reward tokens must be sent directly to the contract address before postSprint is called.

Tech Stack

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

License

MIT

About

A Merkle-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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages