The Determinism Problem

The core challenge is that blockchains are deterministic by design — every node must compute identical results to maintain consensus. This makes true randomness impossible on-chain in any pure sense. A value computed from on-chain state can be computed by every node, every MEV bot, and every adversarial contract executing in the same block. Blockchains are entirely deterministic. Every input is public, and every calculation is predictable. If your smart contract generates “random” numbers using block data, you aren’t creating a fair game — you’re handing attackers a crystal ball. They can see the outcome before the transaction even executes.

This article tears apart every randomness source available to Solidity developers, exposes each one’s attack surface, and provides implementation guidance grounded in the actual threat models you’ll face in production. We begin at the bottom of the trust hierarchy and work our way up.


Part 1: The Naive Sources — block.timestamp, block.hash, and blockhash()

block.timestamp

The most common beginners’ mistake. A timestamp-based “random” number looks like this:

// ❌ CRITICALLY VULNERABLE
contract TimestampLottery {
    address[] public players;

    function pickWinner() external returns (address) {
        uint256 index = uint256(
            keccak256(abi.encodePacked(block.timestamp, players.length))
        ) % players.length;
        return players[index];
    }
}

The vulnerability is immediate: true randomness is, by definition, non-deterministic. If a smart contract simply used the block timestamp as a source of randomness, a malicious miner could manipulate the time of their block to ensure they win the loot box. On Ethereum’s proof-of-stake network, validators have a 12-second slot window and can adjust block.timestamp within a tolerated drift. For a lottery with meaningful value, the economic incentive to manipulate that window is obvious.

Timestamp manipulation has been exploited historically — it exacerbated vulnerabilities in the 2016 GovernMental Ponzi scheme and remains a risk in any contract relying on time for outcome determination.

The deeper issue: keccak256 is a deterministic function — same inputs produce the same output every time. If the inputs are on-chain values like block.timestamp, block.number, or msg.sender, any attacker contract executing in the same block can compute the identical result before calling your function.

block.hash and blockhash()

Slightly more sophisticated but still trivially broken:

// ❌ ALSO VULNERABLE
contract HashLottery {
    mapping(uint256 => address) public roundPlayer;
    mapping(uint256 => uint256) public roundBlock;

    function enter(uint256 roundId) external payable {
        roundPlayer[roundId] = msg.sender;
        roundBlock[roundId] = block.number;
    }

    function resolve(uint256 roundId) external {
        // blockhash of the block AFTER the player entered
        bytes32 hash = blockhash(roundBlock[roundId] + 1);
        uint256 result = uint256(hash) % 2;
        // 0 = lose, 1 = win
        if (result == 1) {
            payable(roundPlayer[roundId]).transfer(address(this).balance);
        }
    }
}

blockhash is only available for the last 256 blocks and returns 0 afterward. When blockhash() returns 0 — because more than 256 blocks have elapsed — uint256(0) % 2 == 0, meaning the house always wins. But the manipulation vector is worse: since miners can influence the block hash, they could potentially manipulate the timing of block creation to achieve a favorable outcome, thereby skewing the results of the lottery. This manipulation not only affects fairness but also presents a risk of financial loss to other participants.

An attacker calling resolve() from another smart contract can check the result before committing the state change:

// ❌ Attacker contract exploiting same-block computation
contract HashAttacker {
    HashLottery public target;

    function exploitResolve(uint256 roundId, uint256 storedBlock) external {
        // Attacker computes the SAME result the lottery will compute
        bytes32 hash = blockhash(storedBlock + 1);
        uint256 result = uint256(hash) % 2;
        // Only call resolve if we'd win
        if (result == 1) {
            target.resolve(roundId);
        }
        // Otherwise, just revert or do nothing
    }
}

This is the root cause of nearly every randomness exploit in blockchain history. Any contract executing in the same block can compute the exact same value.


Part 2: block.prevrandao and the RANDAO Beacon

What RANDAO Is

After the Merge, Ethereum replaced block.difficulty with block.prevrandao. To maintain compatibility with smart contracts using DIFFICULTY for randomness, it was decided to rename DIFFICULTY to PREVRANDAO and make it return the previous block’s RANDAO mix. Solidity deprecated block.difficulty and introduced block.prevrandao in version 0.8.18.

A RANDAO is simply an accumulator that incrementally gathers randomness from contributors. With each block, the proposer mixes in a random contribution to the existing RANDAO value. The beacon chain uses RANDAO to generate reasonably random numbers, which are important for the security of proposer selection and committee assignments for each epoch.

An initial seed gets updated every block through a constant XOR-ing of random hashes generated locally from each proposer of each block. The hash is unknown ahead of time, but through magic of pre-commitment by BLS-signing the current epoch, this hash becomes the only valid hash that the proposer could submit.

The 1-Bit Bias Attack

Here is what EIP-4399 actually says about the security model: the beacon chain RANDAO implementation gives every block proposer 1 bit of influence power per slot. A proposer may deliberately refuse to propose a block on the opportunity cost of proposer and transaction fees to prevent beacon chain randomness from being updated in a particular slot.

This matters a great deal in practice. Validators know prevrandao before proposing and can skip their slot to bias the output by 1 bit. Colluding consecutive validators multiply this influence. With a sufficiently large reward, multiple validators could collude to steal the prize by delaying the execution of the transaction until the desired result is achieved, which can be predicted based on the previous block and timestamp.

The cost of this attack is bounded by the opportunity cost of the skipped slot: there is a non-zero opportunity cost for a validator who was chosen to be a proposer to refuse to publish a block for their assigned slot. This opportunity cost is the block reward — roughly 0.04 ETH, or ~$100 at the time of writing. If your lottery jackpot exceeds ~$100 per 1-bit of bias, this attack is economically rational.

There’s a further amplification vector: a trustless bribery market can be built that enables a briber to auction off their manipulative power over the RANDAO, Ethereum’s distributed randomness beacon. Smart contracts that pay validators to skip slots transform what was a costly attack into a coordinated marketplace.

// ❌ Still vulnerable despite using prevrandao
contract PrevrandaoRoulette {
    function spin() external view returns (uint256) {
        // Validator can skip slot if this produces unfavorable outcome
        return uint256(
            keccak256(abi.encodePacked(block.prevrandao, msg.sender))
        ) % 37;
    }
}

The proposing validator knows prevrandao in advance and can choose to skip their slot if the outcome is unfavorable. All transactions in a block also see the same prevrandao value — meaning multiple attackers can race to call your contract within the same slot window.

When prevrandao Is Acceptable

Use prevrandao only for low-stakes scenarios where validator manipulation is economically irrational — for example, cosmetic NFT traits worth less than block rewards. For low-stakes use cases such as cosmetic NFT traits or non-financial games, it may be acceptable.

EIP-4399 itself offers a mitigation pattern for slightly higher-value uses: applications should rely on future randomness with a reasonably high lookahead. For example, an application stops accepting bids at the end of epoch K and uses a RANDAO mix produced in slot K + N + ε to roll the dice, where N is a lookahead in epochs.

// Slightly better: commit to a FUTURE prevrandao value
contract FutureRandao {
    struct Round {
        address player;
        uint256 commitBlock;
        bool resolved;
    }

    mapping(uint256 => Round) public rounds;
    uint256 public roundCounter;
    uint256 constant LOOKAHEAD_BLOCKS = 32; // ~1 epoch

    function enter() external payable returns (uint256 roundId) {
        roundId = ++roundCounter;
        rounds[roundId] = Round({
            player: msg.sender,
            commitBlock: block.number,
            resolved: false
        });
    }

    function resolve(uint256 roundId) external {
        Round storage r = rounds[roundId];
        require(!r.resolved, "Already resolved");
        // Only resolvable after the lookahead window
        require(block.number >= r.commitBlock + LOOKAHEAD_BLOCKS, "Too early");
        r.resolved = true;

        // The prevrandao of the resolution block was unknown at entry time
        uint256 result = uint256(
            keccak256(abi.encodePacked(block.prevrandao, roundId, r.player))
        ) % 2;

        if (result == 1) {
            payable(r.player).transfer(address(this).balance);
        }
    }
}

By waiting for a future block to become a mined and thus historic block, we can then extract the RANDAO value and use that randomness more securely. Forcing users to associate a future block with their address explicitly makes the on-chain randomness unpredictable. This does not eliminate the validator bias attack, but it raises its complexity and cost considerably.


Part 3: Commit-Reveal Schemes

How Commit-Reveal Works

The commit-reveal scheme uses cryptographic hashing to hide player moves during a commit phase, then reveals them in a separate phase. Players submit keccak256(move + secret) during the commit phase, then reveal the actual move and secret for verification.

The classic multi-party randomness construction:

// Commit-Reveal Randomness Beacon
contract CommitRevealRNG {
    enum Phase { COMMIT, REVEAL, DONE }

    struct Participant {
        bytes32 commitment; // keccak256(abi.encodePacked(secret, value))
        uint256 value;
        bool revealed;
    }

    mapping(address => Participant) public participants;
    address[] public participantList;
    uint256 public combinedRandom;
    Phase public phase;
    uint256 public commitDeadline;
    uint256 public revealDeadline;

    event Committed(address indexed participant, bytes32 commitment);
    event Revealed(address indexed participant, uint256 value);
    event RandomnessGenerated(uint256 result);

    constructor(uint256 commitDuration, uint256 revealDuration) {
        commitDeadline = block.timestamp + commitDuration;
        revealDeadline = commitDeadline + revealDuration;
        phase = Phase.COMMIT;
    }

    function commit(bytes32 _commitment) external {
        require(phase == Phase.COMMIT, "Not in commit phase");
        require(block.timestamp <= commitDeadline, "Commit phase ended");
        require(participants[msg.sender].commitment == bytes32(0), "Already committed");

        participants[msg.sender].commitment = _commitment;
        participantList.push(msg.sender);
        emit Committed(msg.sender, _commitment);
    }

    function reveal(uint256 _value, bytes32 _secret) external {
        require(block.timestamp > commitDeadline, "Commit phase still active");
        require(block.timestamp <= revealDeadline, "Reveal phase ended");

        Participant storage p = participants[msg.sender];
        require(p.commitment != bytes32(0), "No commitment found");
        require(!p.revealed, "Already revealed");

        // Verify the commitment
        require(
            keccak256(abi.encodePacked(_secret, _value)) == p.commitment,
            "Invalid reveal"
        );

        p.value = _value;
        p.revealed = true;
        // XOR all revealed values together
        combinedRandom ^= _value;
        emit Revealed(msg.sender, _value);
    }

    function finalize() external {
        require(block.timestamp > revealDeadline, "Reveal phase not ended");
        require(phase == Phase.COMMIT, "Already finalized");
        phase = Phase.DONE;
        emit RandomnessGenerated(combinedRandom);
    }

    function getResult() external view returns (uint256) {
        require(phase == Phase.DONE, "Not finalized");
        return combinedRandom;
    }
}

Failure Modes of Commit-Reveal

Despite its elegance, commit-reveal has several critical failure modes that make it unsuitable for many high-value applications.

1. The Last-Revealer Attack

Traditional commit-reveal mechanisms are susceptible to last revealer attacks, where an adversary can manipulate the random outcome by withholding their reveal. The final participant to reveal in a multi-party scheme sees the running XOR of all previous values and can compute what the final result will be. They then choose to reveal or abstain based on whether the outcome favors them.

The commit-reveal mechanism offers strong safety but suffers from poor liveness. If any participant fails to reveal their secret, the process halts. This vulnerability is exploited by the last revealer attack, where the final participant strategically decides whether to reveal based on potential outcomes — a concern amplified when randomness impacts financial incentives.

2. Front-Running on Reveal

Blockchain transparency opens the door to front-running attacks. Front-running occurs when someone monitors the pending transactions in the mempool and identifies a profitable transaction before it is executed. They can then copy and submit a similar transaction with a higher gas fee to be processed first.

When a participant broadcasts their reveal transaction, validators and MEV searchers can see both the _value and _secret in plaintext. Block proposers have a short window of opportunity to take advantage of that information and front-run you.

3. Griefing / Withholding

A malicious participant can simply refuse to reveal their secret after the commit phase. This is especially damaging if the protocol has no fallback mechanism. Even slashing mechanisms (penalizing unrevealed deposits) don’t recover liveness — they only make griefing costly.

// ✅ Griefing mitigation: slash unrevealed deposits
contract SlashingCommitReveal is CommitRevealRNG {
    mapping(address => uint256) public deposits;
    uint256 constant DEPOSIT_AMOUNT = 0.1 ether;

    constructor(uint256 c, uint256 r) CommitRevealRNG(c, r) {}

    function commitWithDeposit(bytes32 _commitment) external payable {
        require(msg.value == DEPOSIT_AMOUNT, "Wrong deposit");
        deposits[msg.sender] = msg.value;
        this.commit(_commitment); // simplified for illustration
    }

    function claimSlash(address nonRevealer) external {
        require(block.timestamp > revealDeadline, "Not finalized");
        require(!participants[nonRevealer].revealed, "Did reveal");
        require(participants[nonRevealer].commitment != bytes32(0), "Not a participant");

        uint256 slashAmount = deposits[nonRevealer];
        deposits[nonRevealer] = 0;
        // Distribute slash to caller as incentive
        payable(msg.sender).transfer(slashAmount);
    }
}

4. Single-Participant Degradation

If only one participant reveals, they trivially control the output. Commit-reveal requires a minimum honest participant threshold. A well-known mitigation is the Commit-Reveal-Recover scheme, which employs VDF-based timed commitments to recover randomness as long as a minimum threshold of honest participants exists.


How VRF Works

Chainlink VRF is an on-chain randomness oracle that returns random values plus a cryptographic proof showing those values were generated from a known seed and could not be manipulated by the oracle, miners, or your dApp. The proof is verified by the VRF Coordinator contract before your consumer contract receives the random numbers, making the process tamper-evident and publicly auditable.

The blockchain is used to check a cryptographic proof submitted by the oracle prior to acceptance of the random number, which proves that the random number was generated in a tamper-proof and unpredictable manner based on a given seed value and the oracle’s private key.

A deterministic mapping of (sk, seed) → unique output enables trustless verification while remaining unpredictable because sk is secret and the seed is only finalized at block inclusion.

The request-fulfillment flow:

[Your Contract] ──requestRandomWords()──► [VRF Coordinator]

                                          emits RequestSent event

                                      [Chainlink Oracle Node]
                                        computes VRF proof

                                    [VRF Coordinator] ◄──fulfillRandomWords()
                                      verifies proof on-chain

                              [Your Contract.fulfillRandomWords()] ◄──callback

Minimal VRF Consumer

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract SecureLottery is VRFConsumerBaseV2Plus {
    // ── VRF Configuration ────────────────────────────────────────────
    uint256 private immutable s_subscriptionId;
    bytes32 private immutable s_keyHash;
    uint32 private constant CALLBACK_GAS_LIMIT = 100_000;
    uint16 private constant REQUEST_CONFIRMATIONS = 3;
    uint32 private constant NUM_WORDS = 1;

    // ── Game State ───────────────────────────────────────────────────
    enum RoundState { OPEN, PENDING_VRF, CLOSED }

    struct Round {
        address[] players;
        uint256 requestId;
        address winner;
        RoundState state;
    }

    mapping(uint256 => Round) public rounds;
    mapping(uint256 => uint256) public requestIdToRound; // requestId → roundId
    uint256 public currentRound;

    event RoundStarted(uint256 indexed roundId);
    event RandomnessRequested(uint256 indexed roundId, uint256 indexed requestId);
    event WinnerPicked(uint256 indexed roundId, address indexed winner);

    error NotOpen();
    error NotEnoughPlayers();
    error TransferFailed();
    error UnknownRequest();

    constructor(
        address vrfCoordinator,
        uint256 subscriptionId,
        bytes32 keyHash
    ) VRFConsumerBaseV2Plus(vrfCoordinator) {
        s_subscriptionId = subscriptionId;
        s_keyHash = keyHash;
        currentRound = 1;
        rounds[currentRound].state = RoundState.OPEN;
    }

    // ── Entry ─────────────────────────────────────────────────────────
    function enter() external payable {
        Round storage round = rounds[currentRound];
        if (round.state != RoundState.OPEN) revert NotOpen();
        round.players.push(msg.sender);
    }

    // ── Request Randomness ────────────────────────────────────────────
    function closeAndRequestRandom() external onlyOwner {
        Round storage round = rounds[currentRound];
        if (round.state != RoundState.OPEN) revert NotOpen();
        if (round.players.length < 2) revert NotEnoughPlayers();

        round.state = RoundState.PENDING_VRF;

        uint256 requestId = s_vrfCoordinator.requestRandomWords(
            VRFV2PlusClient.RandomWordsRequest({
                keyHash: s_keyHash,
                subId: s_subscriptionId,
                requestConfirmations: REQUEST_CONFIRMATIONS,
                callbackGasLimit: CALLBACK_GAS_LIMIT,
                numWords: NUM_WORDS,
                extraArgs: VRFV2PlusClient._argsToBytes(
                    VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
                )
            })
        );

        round.requestId = requestId;
        requestIdToRound[requestId] = currentRound;
        emit RandomnessRequested(currentRound, requestId);
    }

    // ── VRF Callback ──────────────────────────────────────────────────
    function fulfillRandomWords(
        uint256 requestId,
        uint256[] calldata randomWords
    ) internal override {
        uint256 roundId = requestIdToRound[requestId];
        if (roundId == 0) revert UnknownRequest();

        Round storage round = rounds[roundId];

        uint256 winnerIndex = randomWords[0] % round.players.length;
        address winner = round.players[winnerIndex];
        round.winner = winner;
        round.state = RoundState.CLOSED;

        // ✅ State fully written BEFORE any external call
        emit WinnerPicked(roundId, winner);

        // Start next round
        currentRound++;
        rounds[currentRound].state = RoundState.OPEN;

        // External call LAST (Checks-Effects-Interactions)
        (bool success, ) = payable(winner).call{value: address(this).balance}("");
        if (!success) revert TransferFailed();
    }

    receive() external payable {}
}

VRF is not trustless — it has a specific, bounded trust model that developers must understand:

Oracle key custody: The randomness depends on the Chainlink node’s private key never being compromised or colluded with. VRF guarantees that neither the oracle operator, the contract owner, nor any miner/validator can predict or tamper with the random value — provided the private key is secure.

Reorg attacks: In principle, miners/validators of the underlying blockchain could rewrite the chain’s history to put a randomness request from your contract into a different block, which would result in a different VRF output. Note that this does not enable a miner to determine the random value in advance. It only enables them to get a fresh random value that might or might not be to their advantage. By analogy, they can only re-roll the dice, not predetermine or predict which side it will land on.

You must choose an appropriate confirmation time for the randomness requests you make. Confirmation time is how many blocks the VRF service waits before writing a fulfillment to the chain, to make potential rewrite attacks unprofitable in the context of your application and its value-at-risk.

Re-requesting is forbidden: Re-requesting randomness through VRFv2 is considered improper usage. This practice would potentially allow the VRF service provider to withhold a VRF fulfillment if the initial outcome doesn’t align with their preferences. They could then wait for the re-request in the expectation of obtaining a more favorable outcome.


Part 5: The VRF Callback Reentrancy Surface

The fulfillRandomWords() callback is an external call originating from the VRF Coordinator. This creates a classic reentrancy surface if the callback makes further external calls before completing state writes.

The Vulnerable Pattern

// ❌ REENTRANCY VULNERABLE CALLBACK
contract VulnerableVRFGame is VRFConsumerBaseV2Plus {
    mapping(uint256 => address) public requestToPlayer;
    mapping(address => uint256) public balances;

    constructor(address coordinator) VRFConsumerBaseV2Plus(coordinator) {}

    function fulfillRandomWords(
        uint256 requestId,
        uint256[] calldata randomWords
    ) internal override {
        address player = requestToPlayer[requestId];
        uint256 winAmount = (randomWords[0] % 2 == 0) ? 1 ether : 0;

        // ❌ WRONG ORDER: External call before state update
        // If `player` is a contract, it can reenter here
        (bool sent, ) = payable(player).call{value: winAmount}("");
        require(sent, "Transfer failed");

        // State written AFTER the external call — already exploited
        delete requestToPlayer[requestId];
    }
}

A malicious player contract can reenter fulfillRandomWords() — or any other state-reading function — before requestToPlayer[requestId] is cleared, potentially triggering duplicate payouts or corrupting game state.

The Secure Pattern

// ✅ SECURE CALLBACK — Checks-Effects-Interactions
contract SecureVRFGame is VRFConsumerBaseV2Plus {
    // Use ReentrancyGuard from OpenZeppelin
    bool private _locked;

    modifier nonReentrant() {
        require(!_locked, "Reentrant call");
        _locked = true;
        _;
        _locked = false;
    }

    mapping(uint256 => address) public requestToPlayer;
    mapping(address => uint256) public pendingWithdrawals; // Pull pattern

    constructor(address coordinator) VRFConsumerBaseV2Plus(coordinator) {}

    function fulfillRandomWords(
        uint256 requestId,
        uint256[] calldata randomWords
    ) internal override nonReentrant {
        address player = requestToPlayer[requestId];

        // ✅ 1. CHECKS: validate state
        require(player != address(0), "Unknown request");

        // ✅ 2. EFFECTS: write all state changes first
        delete requestToPlayer[requestId];
        if (randomWords[0] % 2 == 0) {
            pendingWithdrawals[player] += 1 ether;
        }

        // ✅ 3. INTERACTIONS: external calls last (or deferred entirely)
        // No direct ETH transfer here — player pulls funds
    }

    // ✅ Pull-over-push withdrawal pattern
    function withdraw() external nonReentrant {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        pendingWithdrawals[msg.sender] = 0;
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Key principles:

  • Always apply Checks-Effects-Interactions in fulfillRandomWords().
  • Prefer pull-over-push for value distribution — write to a pendingWithdrawals mapping and let users claim.
  • Apply nonReentrant guard on the callback and any function that reads outcome-critical state.
  • Keep callbacks lightweight. Do minimal work in fulfillRandomWords; expensive logic may exceed callbackGasLimit and cause reverts. Use the randomness to cache outcomes and handle heavy work in subsequent calls.

Part 6: Request-Fulfillment Ordering Attacks

When a contract can have multiple VRF requests in flight simultaneously, a new class of attacks emerges.

The Ordering Attack Surface

If your contract could have multiple VRF requests in flight simultaneously, you must ensure that the order in which the VRF fulfillments arrive cannot be used to manipulate your contract’s user-significant behavior. Blockchain miners/validators can control the order in which your requests appear on-chain, and hence the order in which your contract responds to them.

Consider a game where players initiate VRF requests and the fulfillment order determines jackpot tiers:

// ❌ ORDERING ATTACK VULNERABLE
contract TieredLottery is VRFConsumerBaseV2Plus {
    uint256 public jackpotThreshold = 3; // First 3 fulfilled get jackpot
    uint256 public fulfilledCount;
    mapping(uint256 => address) public requestToPlayer;

    constructor(address coordinator) VRFConsumerBaseV2Plus(coordinator) {}

    function fulfillRandomWords(
        uint256 requestId,
        uint256[] calldata randomWords
    ) internal override {
        address player = requestToPlayer[requestId];
        fulfilledCount++;

        // ❌ Fulfillment ORDER determines payout tier
        // Validator can reorder fulfillments to route jackpot to accomplice
        if (fulfilledCount <= jackpotThreshold) {
            payable(player).transfer(10 ether); // jackpot
        } else {
            payable(player).transfer(0.1 ether); // consolation
        }
    }
}

A validator who processes the fulfillRandomWords transaction can reorder multiple pending fulfillments in a block to ensure a colluding player’s request is processed first — receiving the jackpot while later requests receive consolation prizes. The fix is to make payout tier independent of fulfillment order: derive the prize from the random word itself, not from position in a sequence.

// SAFE: payout determined by the random word, not by fulfillment order
function fulfillRandomWords(
    uint256 requestId,
    uint256[] calldata randomWords
) internal override {
    address player = requestToPlayer[requestId];
    // Jackpot if top 1% of range — unaffected by ordering
    bool isJackpot = randomWords[0] % 100 == 0;
    payable(player).transfer(isJackpot ? 10 ether : 0.1 ether);
}

On-Chain Randomness Audit Checklist

Source integrity

  • No contract uses block.timestamp, block.number, block.prevrandao, or blockhash as the sole entropy source for any outcome with economic value
  • blockhash is never used for a block more than 256 blocks in the past (returns zero)
  • No contract uses msg.sender, tx.origin, or gasleft() as entropy

Chainlink VRF integration

  • fulfillRandomWords is internal override and cannot be called externally
  • The requestId is validated against a stored mapping before any state change
  • The contract uses VRFConsumerBaseV2Plus (V2.5) or VRFConsumerBaseV2 — not a custom callback
  • numWords is the minimum required for the game logic
  • Payout or game outcome is determined by the random word value, not by fulfillment order or call count
  • Subscription funding and cancellation are access-controlled

Commit-reveal schemes

  • The reveal deadline is enforced — unrevealed commits forfeit or are penalized
  • Committers cannot benefit from not revealing (the “last revealer” advantage is neutralized)
  • The entropy source in the reveal phase is not itself manipulable

MEV and ordering

  • Game outcomes do not depend on the order in which VRF fulfillments are processed
  • Validators cannot gain advantage by censoring, delaying, or reordering reveal transactions
  • High-value randomness requests use sufficient minimumRequestConfirmations (≥ 3 on mainnet)