12 - Merkle Claim System¶
Tree-Konstruktion, Proof-Verifikation, Batch Claims
Übersicht¶
Das Merkle Claim System ermöglicht gas-effiziente Gewinn-Auszahlungen. Statt jeden Gewinner on-chain zu speichern (teuer), wird nur ein Merkle Root committed. Gewinner beweisen ihren Anspruch mit einem kryptografischen Proof.
Architektur¶
Off-Chain (Backend) On-Chain
───────────────── ──────────
Settlement-Engine SettlementV5
│ │
├── Berechne Gewinne │
├── Baue Merkle Tree │
├── Generiere Root │
│ │
└── commitMerkleSettlement(root) ──────────► setMerkleRootForDay()
│
│ (Root gespeichert)
│
Spieler will claimen │
│ │
├── Hole Proof vom Backend ◄──────────────── │
│ │
└── claimMerkle(proof) ──────────────────► PrizeVaultV3
│
├── MerkleProof.verify()
├── claimed[day][wallet] = true
└── Settlement.payoutFromPrizeVault()
└── ReserveVault.payPrize()
Separate Trees pro Tag¶
Jeder Tag hat zwei unabhängige Merkle Trees:
| Tree | Gespeichert in | Leaf-Typ | Claim via |
|---|---|---|---|
| Prize Tree | PrizeVault.merkleRootByDay[dayId] |
Winner Leaf | PrizeVault.claimMerkle() |
| Affiliate Tree | Settlement.affiliateMerkleRootByDay[dayId] |
Affiliate Leaf | AffiliateVault.claimAffiliate() |
Leaf-Definitionen¶
Prize Leaf (Gewinner)¶
bytes32 leaf = keccak256(abi.encode(
dayId, // uint256 - verhindert Cross-Day Replay
originalWallet, // address - kanonische Spieler-Identität
amount // uint256 - Gesamtgewinn für diesen Tag
));
Wichtig: Pro Spieler und Tag gibt es einen Leaf mit dem Gesamt-Gewinnbetrag (nicht pro Ticket).
Affiliate Leaf¶
bytes32 leaf = keccak256(abi.encode(
dayId, // uint256
affiliate, // address - Affiliate-Wallet
amount // uint256 - Gesamte Provision für diesen Tag
));
Tree-Konstruktion (Off-Chain)¶
JavaScript Implementation (Backend)¶
const { MerkleTree } = require('merkletreejs');
const { ethers } = require('ethers');
function buildMerkleTree(winners) {
// winners = [{ dayId, wallet, amount }, ...]
const leaves = winners.map(w =>
ethers.solidityPackedKeccak256(
['uint256', 'address', 'uint256'],
[w.dayId, w.wallet, w.amount]
)
);
const tree = new MerkleTree(leaves, ethers.keccak256, {
sortPairs: true // Wichtig: Konsistente Sortierung
});
return {
root: tree.getHexRoot(),
proofs: winners.map((w, i) => tree.getHexProof(leaves[i]))
};
}
Proof-Generierung¶
function generateProof(tree, dayId, wallet, amount) {
const leaf = ethers.solidityPackedKeccak256(
['uint256', 'address', 'uint256'],
[dayId, wallet, amount]
);
return tree.getHexProof(leaf);
}
On-Chain Verifikation¶
OpenZeppelin MerkleProof¶
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
// In PrizeVaultV3:
bytes32 leaf = keccak256(abi.encode(dayId, originalWallet, amount));
bool valid = MerkleProof.verifyCalldata(proof, merkleRootByDay[dayId], leaf);
require(valid, "Invalid Merkle proof");
Proof-Beispiel¶
Tree mit 4 Gewinnern:
Root
/ \
H12 H34
/ \ / \
L1 L2 L3 L4
Proof für L1: [L2, H34]
Verifikation: hash(hash(L1, L2), H34) == Root ✓
Claim-Flows¶
Direkter Claim (Spieler selbst)¶
Spieler → PrizeVault.claimMerkle(dayId, wallet, wallet, amount, proof)
→ Merkle Proof Verifikation ✅
→ Settlement.payoutFromPrizeVault()
→ ReserveVault.payPrize(wallet, amount)
→ USDT Transfer an Spieler
Batch Claim (via ClaimRouter)¶
Spieler signiert ClaimPermit (EIP-712)
→ Relayer submitted claimAllFor()
→ ClaimRouter baut Multicall3 Calls
→ Multicall3.aggregate3([
PrizeVault.claimMerkle(day1, wallet, wallet, amt1, proof1),
PrizeVault.claimMerkle(day2, wallet, wallet, amt2, proof2),
AffiliateVault.claimAffiliate(day1, wallet, wallet, amt3, proof3),
])
→ Alle Claims atomar ausgeführt
→ Gas-Kosten vom Gewinn abgezogen
Claim mit Wallet-Reassignment¶
Alter Wallet verloren, neuer Wallet zugewiesen:
reassignedTo[oldWallet] = newWallet
Claim:
PrizeVault.claimMerkle(dayId, oldWallet, newWallet, amount, proof)
→ Proof verwendet oldWallet (Original-Leaf)
→ paidTo = newWallet (nach Reassignment)
→ USDT geht an newWallet
No-Winner-Day Handling¶
Problem¶
Wenn ein Tag keine Gewinner hat, gibt es keinen Merkle Tree. Aber commitMerkleSettlement erfordert einen nicht-leeren Root.
Lösung: Dummy Merkle Leaf¶
// Backend generiert Dummy-Leaf
const dummyLeaf = ethers.solidityPackedKeccak256(
['uint256', 'address', 'uint256'],
[dayId, ethers.ZeroAddress, 0] // address(0), amount = 0
);
const tree = new MerkleTree([dummyLeaf], ethers.keccak256);
const root = tree.getHexRoot();
// Settlement commit mit Zero-Payouts
commitMerkleSettlement(dayId, [0,0,0,0,0], [0,0,0,0,0], 0, root, ...);
Effekt:
- daySettled[dayId] = true → Nächster Tag ist unblocked
- Kein Claim möglich (amount = 0, wallet = address(0))
- Pot + Rollover bleiben erhalten für nächsten Tag
Gas-Vergleich¶
| Methode | Gas pro Settlement | Gas pro Claim |
|---|---|---|
| On-Chain Storage (alle Gewinner) | ~50k × N Gewinner | ~5k |
| Merkle Tree | ~30k (nur Root) | ~80k |
Einsparung: Bei 1000 Gewinnern spart Merkle ~49.97M Gas beim Settlement.
Die höheren Claim-Kosten werden durch den ClaimRouter (Batch) und die Relayer-Infrastruktur ausgeglichen.
Audit-Hinweise¶
Root Immutability
Merkle Roots können nie geändert werden. Ein fehlerhafter Root bedeutet, dass betroffene Gewinner nicht claimen können. Die Backend-Berechnung ist daher audit-kritisch.
sortPairs: true
Die Tree-Konstruktion muss sortPairs: true verwenden für Konsistenz mit OpenZeppelin's MerkleProof.verify().
Cross-Day Protection
Das dayId im Leaf verhindert, dass ein Proof für Tag X bei Tag Y verwendet werden kann. Ohne dayId wäre Cross-Day Replay möglich.