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