Zum Inhalt

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.