Zum Inhalt

09 - ClaimRouterV2

Gaslose Batch Claims via EIP-712 + Multicall3


Übersicht

ClaimRouterV2 ermöglicht gaslose Batch Claims für Spieler. Der Spieler signiert einen EIP-712 ClaimPermit, der Relayer (Backend) submitted die Transaktion und zieht die Gas-Kosten vom Gewinn ab.

Datei: contracts/contracts/ClaimRouterV2.sol


State Variables

IERC20 public immutable usdt;
address public prizeVault;
address public affiliateVault;
address public relayer;
address public operatorFeeReceiver;

mapping(address => mapping(bytes16 => bool)) public usedNonce;  // Replay Protection

EIP-712 ClaimPermit

struct ClaimPermit {
    address claimer;              // Spieler-Wallet
    uint256 totalWinnerAmount;    // Summe aller Gewinn-Claims
    uint256 totalAffiliateAmount; // Summe aller Affiliate-Claims
    uint256 maxGasCostUsdt;       // Max. Gas-Abzug (Spieler-Limit)
    uint64 deadline;              // Ablaufzeit
    bytes16 nonce;                // Einmal-Nonce
    bytes32 claimsHash;           // Hash aller Claims (Manipulation-Schutz)
}

Domain Separator

{
    name: "ChainbetsClaimRouter",
    version: "2",
    chainId: 421614,           // Arbitrum Sepolia
    verifyingContract: claimRouterAddress
}

Claims Hash Berechnung

bytes32 claimsHash = keccak256(abi.encode(
    keccak256(abi.encode(winnerClaims)),    // Alle Winner Claims
    keccak256(abi.encode(affiliateClaims))  // Alle Affiliate Claims
));

Claim-Strukturen

struct WinnerClaim {
    uint256 dayId;
    address originalWallet;
    uint256 amount;
    bytes32[] proof;
}

struct AffiliateClaim {
    uint256 dayId;
    address affiliate;
    uint256 amount;
    bytes32[] proof;
}

claimAllFor() - Hauptfunktion

function claimAllFor(
    ClaimPermit calldata permit,
    bytes calldata signature,
    WinnerClaim[] calldata winnerClaims,
    AffiliateClaim[] calldata affiliateClaims,
    uint256 actualGasCostUsdt
) external onlyRelayer nonReentrant

Ablauf (8 Schritte)

1. EIP-712 Signatur-Verifikation

bytes32 structHash = keccak256(abi.encode(CLAIMPERMIT_TYPEHASH, permit));
address signer = ECDSA.recover(domainSeparator, structHash, signature);
require(signer == permit.claimer, "Invalid signature");

2. Deadline & Nonce Check

require(block.timestamp <= permit.deadline, "Expired");
require(!usedNonce[permit.claimer][permit.nonce], "Nonce used");
usedNonce[permit.claimer][permit.nonce] = true;

3. Claims Hash Verifizierung

bytes32 computedHash = keccak256(abi.encode(
    keccak256(abi.encode(winnerClaims)),
    keccak256(abi.encode(affiliateClaims))
));
require(computedHash == permit.claimsHash, "Claims hash mismatch");
Verhindert, dass der Relayer Claims austauscht oder manipuliert.

4. Totals Validierung

uint256 winnerSum = sum(winnerClaims[].amount);
uint256 affiliateSum = sum(affiliateClaims[].amount);
require(winnerSum == permit.totalWinnerAmount, "Winner total mismatch");
require(affiliateSum == permit.totalAffiliateAmount, "Affiliate total mismatch");
require(actualGasCostUsdt <= permit.maxGasCostUsdt, "Gas exceeds max");

5. Multicall3 Calls bauen

IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](
    winnerClaims.length + affiliateClaims.length
);

for (uint i = 0; i < winnerClaims.length; i++) {
    calls[i] = IMulticall3.Call3({
        target: prizeVault,
        allowFailure: false,
        callData: abi.encodeCall(
            IPrizeVaultV3.claimMerkle,
            (wc.dayId, wc.originalWallet, permit.claimer, wc.amount, wc.proof)
        )
    });
}
// + affiliateClaims analog

6. Atomare Ausführung

uint256 balanceBefore = usdt.balanceOf(permit.claimer);
IMulticall3(MULTICALL3).aggregate3(calls);
uint256 balanceAfter = usdt.balanceOf(permit.claimer);
uint256 actualReceived = balanceAfter - balanceBefore;

7. Gas-Kosten abziehen

if (actualReceived > 0 && actualGasCostUsdt > 0) {
    uint256 effectiveGasCost = min(actualGasCostUsdt, actualReceived / 2);
    usdt.safeTransferFrom(permit.claimer, operatorFeeReceiver, effectiveGasCost);
}

8. Event emittieren

emit ClaimExecuted(permit.claimer, actualReceived, effectiveGasCost);


previewValidClaims()

function previewValidClaims(
    WinnerClaim[] calldata winnerClaims,
    AffiliateClaim[] calldata affiliateClaims
) external view returns (bool[] memory winnerValid, bool[] memory affiliateValid)
  • View-Funktion zum Vorprüfen welche Claims erfolgreich wären
  • Vom Backend verwendet um nur gültige Claims zu submitten

Gas-Kosten Berechnung

Backend berechnet:
    gasCostWei = estimatedGas × gasPrice
    gasCostEth = gasCostWei / 1e18
    gasCostUsdt = gasCostEth × ethPrice × 1.2 (Safety Multiplier)
    gasCostUsdt = min(gasCostUsdt, maxGasCap)  // z.B. 0.10 USDT

Access Control

Funktion Owner (SAFE) Relayer Anyone
claimAllFor - -
previewValidClaims - - ✅ (view)
setRelayer - -
setPrizeVault - -
setAffiliateVault - -
setMaxGasCap - -

Events

event ClaimExecuted(address claimer, uint256 totalReceived, uint256 gasCostDeducted);
event ClaimFailed(address claimer, string reason);
event GasCapUpdated(uint256 newCap);

Sicherheitsanalyse

Angriff Schutz
Replay Attack Nonce + Deadline
Claim Substitution Claims Hash Verifizierung
Überhöhte Gas-Kosten maxGasCostUsdt + Cap auf 50% des Gewinns
Relayer-Manipulation EIP-712 Signatur vom Spieler
Partial Execution allowFailure: false → All-or-Nothing

Audit-Hinweise

Multicall3 Adresse

Die Multicall3-Adresse ist im Contract hardcoded. Bei einer Network-Migration muss diese geprüft werden.

Gas-Cap

Der maxGasCap wird vom SAFE gesetzt und begrenzt den maximalen Gas-Abzug unabhängig vom maxGasCostUsdt im Permit.

Kein direkter Fund-Transfer an Relayer

Gas-Kosten gehen an operatorFeeReceiver, nicht direkt an den Relayer. Dies ermöglicht zentrale Abrechnung.