13 - EIP-712 Signaturen¶
BuyPermit, ClaimPermit, Domain Separators, Typehashes
Übersicht¶
Das System verwendet zwei EIP-712 Signatur-Typen für Meta-Transaktionen:
| Signatur | Contract | Zweck |
|---|---|---|
| BuyPermit | GameManagerV3 | Gasloser Ticket-Kauf |
| ClaimPermit | ClaimRouterV2 | Gasloser Batch-Claim |
Spieler signieren strukturierte Daten off-chain, der Relayer (Backend) submitted die Transaktionen on-chain.
BuyPermit (GameManagerV3)¶
Domain Separator¶
{
"name": "ChainbetsTicketBuy",
"version": "2",
"chainId": 421614,
"verifyingContract": "<GameManagerV3 Address>"
}
Struct & Typehash¶
struct BuyPermit {
address buyer;
uint8 poolId;
uint8 tipCount;
bytes32 tipsHash;
address affiliate;
uint256 gasCostUsdt;
uint64 deadline;
bytes16 nonce;
}
bytes32 constant BUYPERMIT_TYPEHASH = keccak256(
"BuyPermit(address buyer,uint8 poolId,uint8 tipCount,bytes32 tipsHash,"
"address affiliate,uint256 gasCostUsdt,uint64 deadline,bytes16 nonce)"
);
Signatur-Flow¶
1. Frontend generiert BuyPermit mit allen Feldern
2. Frontend berechnet EIP-712 Hash:
structHash = keccak256(abi.encode(BUYPERMIT_TYPEHASH, permit))
digest = keccak256("\x19\x01" || domainSeparator || structHash)
3. Spieler signiert digest mit Web3Auth Wallet
4. Frontend sendet Permit + Signatur an Backend
5. Backend submitted GameManagerV3.buyTicketFor(permit, signature)
6. Contract recovered Signer und prüft: signer == buyer
Felder im Detail¶
| Feld | Typ | Beschreibung |
|---|---|---|
buyer |
address | Spieler-Wallet (muss == Signer sein) |
poolId |
uint8 | Pool 0-4 |
tipCount |
uint8 | Anzahl gekaufter Tips |
tipsHash |
bytes32 | keccak256 der Tip-Zahlen (off-chain Verifizierung) |
affiliate |
address | Affiliate-Wallet (oder address(0)) |
gasCostUsdt |
uint256 | Gas-Kosten die der Spieler akzeptiert |
deadline |
uint64 | Ablauf-Timestamp (Unix Seconds) |
nonce |
bytes16 | Einmalig pro Spieler (16 Bytes Random) |
ClaimPermit (ClaimRouterV2)¶
Domain Separator¶
{
"name": "ChainbetsClaimRouter",
"version": "2",
"chainId": 421614,
"verifyingContract": "<ClaimRouterV2 Address>"
}
Struct & Typehash¶
struct ClaimPermit {
address claimer;
uint256 totalWinnerAmount;
uint256 totalAffiliateAmount;
uint256 maxGasCostUsdt;
uint64 deadline;
bytes16 nonce;
bytes32 claimsHash;
}
bytes32 constant CLAIMPERMIT_TYPEHASH = keccak256(
"ClaimPermit(address claimer,uint256 totalWinnerAmount,"
"uint256 totalAffiliateAmount,uint256 maxGasCostUsdt,"
"uint64 deadline,bytes16 nonce,bytes32 claimsHash)"
);
Claims Hash Berechnung¶
// Winner Claims: [{dayId, originalWallet, amount, proof}, ...]
// Affiliate Claims: [{dayId, affiliate, amount, proof}, ...]
bytes32 claimsHash = keccak256(abi.encode(
keccak256(abi.encode(winnerClaims)),
keccak256(abi.encode(affiliateClaims))
));
Zweck: Der Claims Hash stellt sicher, dass der Relayer die Claims nicht austauschen oder manipulieren kann. Der Spieler signiert exakt die Claims, die er erwartet.
Felder im Detail¶
| Feld | Typ | Beschreibung |
|---|---|---|
claimer |
address | Spieler-Wallet (muss == Signer sein) |
totalWinnerAmount |
uint256 | Summe aller Gewinn-Claims |
totalAffiliateAmount |
uint256 | Summe aller Affiliate-Claims |
maxGasCostUsdt |
uint256 | Max. akzeptierter Gas-Abzug |
deadline |
uint64 | Ablauf-Timestamp |
nonce |
bytes16 | Einmalig pro Spieler |
claimsHash |
bytes32 | Hash aller Einzel-Claims |
Replay Protection¶
Nonce-System¶
// In GameManagerV3 und ClaimRouterV2:
mapping(address => mapping(bytes16 => bool)) public usedNonce;
// Bei Signatur-Verifizierung:
require(!usedNonce[signer][nonce], "Nonce already used");
usedNonce[signer][nonce] = true;
- bytes16 Nonce: 16 Bytes Random, vom Frontend generiert
- Kein sequentieller Counter: Parallele Transaktionen möglich
- Einmal-Use: Kann nur von
false → truewechseln (nie zurück)
Deadline¶
- Typisch: 5-10 Minuten in die Zukunft
- Verhindert unbegrenzt gültige Signaturen
Chain ID¶
- Verhindert Cross-Chain Replay
- Signatur ist nur auf der richtigen Chain gültig
Signatur-Verifikation On-Chain¶
// Digest berechnen
bytes32 structHash = keccak256(abi.encode(TYPEHASH, ...fields));
bytes32 digest = _hashTypedDataV4(structHash);
// Signer recovern
address signer = ECDSA.recover(digest, signature);
// Prüfen
require(signer == expectedSigner, "Invalid signature");
require(signer != address(0), "Invalid signature");
OpenZeppelin's EIP712 Base-Contract wird für _hashTypedDataV4() verwendet.
Frontend Integration (ethers.js)¶
BuyPermit signieren¶
const domain = {
name: "ChainbetsTicketBuy",
version: "2",
chainId: 421614,
verifyingContract: GAME_MANAGER_ADDRESS
};
const types = {
BuyPermit: [
{ name: "buyer", type: "address" },
{ name: "poolId", type: "uint8" },
{ name: "tipCount", type: "uint8" },
{ name: "tipsHash", type: "bytes32" },
{ name: "affiliate", type: "address" },
{ name: "gasCostUsdt", type: "uint256" },
{ name: "deadline", type: "uint64" },
{ name: "nonce", type: "bytes16" }
]
};
const signature = await signer.signTypedData(domain, types, permit);
ClaimPermit signieren¶
const domain = {
name: "ChainbetsClaimRouter",
version: "2",
chainId: 421614,
verifyingContract: CLAIM_ROUTER_ADDRESS
};
const types = {
ClaimPermit: [
{ name: "claimer", type: "address" },
{ name: "totalWinnerAmount", type: "uint256" },
{ name: "totalAffiliateAmount", type: "uint256" },
{ name: "maxGasCostUsdt", type: "uint256" },
{ name: "deadline", type: "uint64" },
{ name: "nonce", type: "bytes16" },
{ name: "claimsHash", type: "bytes32" }
]
};
const signature = await signer.signTypedData(domain, types, permit);
Audit-Hinweise¶
Domain Separator = Contract-spezifisch
Jeder Contract hat seinen eigenen Domain Separator. BuyPermit-Signaturen funktionieren NUR mit GameManagerV3, ClaimPermit-Signaturen NUR mit ClaimRouterV2.
Version String
Beide verwenden Version "2". Bei einem Contract-Upgrade mit geändertem Signatur-Schema muss die Version erhöht werden, damit alte Signaturen ungültig werden.
Web3Auth Kompatibilität
Web3Auth Wallets unterstützen signTypedData (EIP-712). Die Signatur-Implementierung ist kompatibel mit allen gängigen Wallet-Providern.