Zum Inhalt

03 - GameManagerV3

Ticket-Kauf, EIP-712 BuyPermit, RNG-Generierung, Affiliate Binding


Übersicht

GameManagerV3 ist der Einstiegspunkt für alle Ticket-Käufe. Spieler signieren einen EIP-712 BuyPermit, der vom Relayer (Backend) on-chain submitted wird. Der Contract generiert Zufallszahlen (RNG) und speichert Tickets gas-optimiert.

Datei: contracts/contracts/GameManagerV3.sol


State Variables

// Ticket Storage
struct Ticket {
    address player;           // 20 bytes
    uint8 poolIdRaw;         // 1 byte (gepackt)
    uint8 tipCount;          // 1 byte (gepackt)
    uint8 bonusTipCount;     // 1 byte (gepackt)
    uint256 dayId;
    bytes32 tipsHash;        // Hash der Tip-Array (Gas-Optimierung)
    bytes rngNumbers;        // Gepackte uint8[] der RNG-Zahlen
}

mapping(uint256 => Ticket) public tickets;
mapping(uint256 => mapping(uint8 => uint256[])) public ticketsByDayAndPool;
mapping(address => address) public affiliateOf;
mapping(address => mapping(bytes16 => bool)) public usedNonce;  // Replay Protection

uint256 public nextTicketId;
uint256 public lastDayWithTickets;
uint256 public vrfSeed;           // Von VRF Callback
uint256 public drandSeed;         // Optional DRAND (aktuell 0)
mapping(uint8 => bool) public poolUsesVRF;
mapping(uint8 => uint8) public minTipsPerPool;
mapping(uint8 => uint8) public rngCountPerPool;

Pool-Konfiguration

Pool ID Preis pro Tip VRF RNG Count Beschreibung
0 0.25 USDT Nein 6 Low Risk
1 0.50 USDT Ja 6 Standard
2 1.00 USDT Ja 6 Medium
3 5.00 USDT Ja 6 High
4 10.00 USDT Ja 6 Premium

Bonus-Tip Berechnung

if (tipCount < 5) return 0;
return 3 + ((tipCount - 5) / 5) * 4;
Gekaufte Tips Bonus Tips Gesamt
1-4 0 1-4
5 3 8
10 7 17
15 11 26
20 15 35

buyTicketFor() - Hauptfunktion

function buyTicketFor(
    address buyer,
    uint8 poolId,
    uint8 tipCount,
    bytes32 tipsHash,
    address affiliate,
    uint256 gasCostUsdt,
    uint64 deadline,
    bytes16 nonce,
    bytes calldata signature
) external onlyOperator nonReentrant

Ablauf (8 Schritte)

Schritt 1: EIP-712 Signatur-Verifikation

bytes32 structHash = keccak256(abi.encode(
    BUYPERMIT_TYPEHASH,
    buyer, poolId, tipCount, tipsHash, affiliate,
    gasCostUsdt, deadline, nonce
));
address signer = ECDSA.recover(domainSeparator, structHash, signature);
require(signer == buyer, "Invalid signature");

Schritt 2: Nonce-Check (Replay Protection)

require(!usedNonce[buyer][nonce], "Nonce already used");
usedNonce[buyer][nonce] = true;

Schritt 3: Deadline-Check

require(block.timestamp <= deadline, "Permit expired");

Schritt 4: Settlement Guard

if (lastDayWithTickets > 0 && lastDayWithTickets < currentDay) {
    require(settlement.daySettled(lastDayWithTickets), "Previous day not settled");
}
Verhindert Ticket-Käufe für "morgen" bevor "gestern" abgerechnet ist.

Schritt 5: Affiliate Binding

if (affiliateOf[buyer] == address(0) && affiliate != address(0)) {
    affiliateOf[buyer] = affiliate;  // Einmalig, immutable
}

Schritt 6: Treasury Deposit

gameTreasury.depositFromPlayer(buyer, dayId, poolId, ticketAmount, gasCostUsdt);
// → Treasury ruft ReserveVault.depositFrom() auf
// → USDT.transferFrom(buyer → ReserveVault)

Schritt 7: RNG-Generierung

// Seed-Auswahl basierend auf Pool-Konfiguration
if (poolUsesVRF[poolId]) {
    require(vrfSeed != 0, "VRF seed missing");
    baseSeed = keccak256(abi.encodePacked(vrfSeed, drandSeed, blockhash(block.number - 1)));
} else {
    baseSeed = blockhash(block.number - 1);
}

// Ticket-spezifische Entropy
entropy = keccak256(abi.encodePacked(baseSeed, ticketId, buyer, poolId));

// Generiere 6 unique Zahlen (1-40)
for (uint i = 0; i < rngCount; i++) {
    seed = keccak256(abi.encodePacked(seed, i));
    number = (uint8(seed) % 40) + 1;
    // Duplikat-Check, Safety Limit: 100 Versuche
}

Schritt 8: Ticket Storage

tickets[ticketId] = Ticket({
    player: buyer,
    poolIdRaw: poolId,
    tipCount: tipCount,
    bonusTipCount: bonusTipCount,
    dayId: dayId,
    tipsHash: tipsHash,
    rngNumbers: packedRngNumbers
});
ticketsByDayAndPool[dayId][poolId].push(ticketId);
emit TicketCreated(ticketId, buyer, dayId, poolId, tipCount, bonusTipCount);


EIP-712 BuyPermit

// Domain Separator
{
    name: "ChainbetsTicketBuy",
    version: "2",
    chainId: 421614,        // Arbitrum Sepolia
    verifyingContract: gameManagerAddress
}

// Typehash
BUYPERMIT_TYPEHASH = keccak256(
    "BuyPermit(address buyer,uint8 poolId,uint8 tipCount,bytes32 tipsHash,"
    "address affiliate,uint256 gasCostUsdt,uint64 deadline,bytes16 nonce)"
);

RNG-Architektur

Entropy-Quellen

Quelle Sicherheit Status
Chainlink VRF Kryptografisch verifizierbar Aktiv (Pool 1-4)
DRAND Distributed Randomness Beacon Geplant (aktuell 0)
prevrandao Block-Randomness (L2) Fallback (Pool 0)

Hybrid-Seed-Berechnung

baseSeed = keccak256(vrfSeed ∥ drandSeed ∥ blockhash)
ticketEntropy = keccak256(baseSeed ∥ ticketId ∥ player ∥ poolId)
number[i] = (keccak256(ticketEntropy ∥ i) % 40) + 1

RNG-Sicherheitsanalyse

Angriff Machbarkeit Mitigation
Validator Manipulation L2-Sequencer kontrolliert prevrandao VRF + DRAND als unabhängige Quellen
VRF DoS Chainlink Callback könnte blockiert werden try/catch im VRFReceiver, prevrandao Fallback
Operator Timing Operator wählt Zeitpunkt des Ticket-Buys Seed ist bereits committed bevor Ticket kommt

Access Control

Funktion Owner (SAFE) Operator VRFReceiver Anyone
buyTicketFor - - -
setVrfSeed - -
setDrandSeed - - -
setOperator - - -
setPoolUsesVRF - - -
setRngCountPerPool - - -

Events

event TicketCreated(uint256 ticketId, address player, uint256 dayId, uint8 poolId, uint8 tipCount, uint8 bonusTipCount);
event VrfSeedUpdated(uint256 newSeed);
event AffiliateSet(address player, address affiliate);
event DrandSeedUpdated(uint256 newSeed);

Audit-Hinweise

Settlement Guard

Ohne Settlement Guard könnten Tickets für zukünftige Tage gekauft werden, während vergangene Tage noch nicht abgerechnet sind. Dies würde die Pot-Berechnung verfälschen.

Tips als Hash

Die tatsächlichen Tip-Zahlen werden als tipsHash gespeichert (nicht die Zahlen selbst). Die Verifizierung erfolgt off-chain im Settlement-Backend. On-chain wird nur der Hash verglichen.

Gas-Optimierung

Das Ticket-Struct nutzt Slot-Packing: player (20 bytes) + poolIdRaw + tipCount + bonusTipCount (je 1 byte) = 23 bytes in einem 32-byte Slot. Spart ~15k-55k Gas pro Ticket.