Sign in

Randomness on chain

Smart contracts can't generate random numbers on their own. The reasons are structural, not solvable by writing cleverer code, and the workarounds you'll see in tutorials are mostly broken in ways that real money has been stolen over. This lecture covers why randomness is hard on chain, how Chainlink VRF solves it cryptographically, and how to wire a consumer contract to receive verified random numbers in production.

Why a blockchain can't roll dice

A blockchain is a deterministic state machine. Every node must execute every transaction and arrive at the same result, otherwise consensus breaks. That requirement is incompatible with native randomness. If a contract called some rand() function and each node returned a different value, no two nodes would agree on the chain state.

The standard workaround in beginner tutorials is to derive "randomness" from values that already exist on chain. Block timestamp, block hash, previous block's randao value, the sender's address, transaction hashes. These are deterministic for everyone reading the chain, so consensus is preserved. They are also all manipulable by the entity proposing the block.

Why on-chain "randomness" is manipulable A lottery contract picks a winner using block.timestamp as the seed: uint256 winner = uint256(keccak256(abi.encodePacked( block.timestamp, block.prevrandao )) % participants.length; The problem: every input is controlled or visible to the block proposer. block.timestamp Proposer picks the timestamp within a wide window block.prevrandao Proposer sees its value before committing the block tx ordering Proposer decides which transactions go in, in what order The attack 1. A malicious validator simulates the lottery locally before publishing. 2. If the result picks them (or their colluding wallet), they publish the block. 3. If not, they reroll: skip the block, retry with different transactions or timestamp. 4. The chain has no way to tell what was "rolled away."

The attack does not require the validator to be the lottery's intended target. It requires only that the validator has any financial interest in the outcome and the option to suppress an unfavorable block. The cost of dropping a block is the lost block reward. If the lottery payout exceeds that, the attack is profitable. For pools worth more than a few ETH, this math works out in the attacker's favor every time.

The fundamental issue: anything visible inside the block is visible to whoever is proposing it, and the proposer chooses what to publish. You cannot patch this by combining more sources. Any input the contract reads is an input the proposer can either control or see, and any deterministic function of public inputs produces an output the proposer can predict.

What VRF actually is

The Verifiable Random Function (VRF) protocol is a cryptographic construction that produces two things at once: a pseudorandom output, and a proof that the output was generated correctly from a specific seed using a specific private key.

The setup involves a key pair. The party generating randomness (the VRF oracle service) holds the private key. The public key is published on chain in advance. The protocol works like this:

  1. Someone supplies a seed, which can be anything: a block hash, a request ID, a sequence number.
  2. The oracle signs the seed with its private key using the VRF algorithm. This produces a random output and a proof.
  3. Anyone with the public key can verify, by examining the proof, that the output was generated from exactly that seed using exactly that key, and that the oracle had no freedom to choose the output.

The third point is the load-bearing one. The oracle cannot try multiple seeds, see the outputs, and publish only the one it likes, because the seed is committed to in the proof. The oracle cannot reuse a previously favorable output for a new seed, because the proof will not verify. The output is bound to the seed and the key in a way that cannot be forged or selected.

For the math, see the VRF protocol description on Chainlink's docs. The summary is: the oracle has nowhere to hide. Either it returns the cryptographically determined output, or its proof fails verification and the chain rejects the response.

The request-and-receive cycle

VRF cannot be a single function call. The proof must be generated off chain by an entity holding the private key, and that work cannot happen inside a normal contract call. The pattern is asynchronous: your contract submits a request in one transaction, and receives the result in a second transaction some blocks later.

A VRF request takes two transactions, separated by an off-chain step Your contract VRF Coordinator (on chain) VRF Service (off chain) requestRandomWords() TX 1 — user pays gas emit event with seed listens for event (off-chain log subscription) signs seed with private VRF key, produces (number, proof) waits N block confirmations submits (number, proof) TX 2 — service pays gas verifies proof against public VRF key on-chain fulfillRandomWords() callback into your contract stores the result

The implication for your contract design is that you cannot use a random number in the same transaction that requests it. The number does not exist yet. Your requestRandomWords call returns a request ID. The number arrives in a separate transaction via the callback function. Anything the contract needs to do with the number (pick a winner, reveal an NFT, settle a bet) happens inside that callback, not the original user transaction. This async shape is the biggest design constraint in working with VRF and it shapes every contract you'll build with it.

The number of block confirmations the service waits before responding is configurable per request. The current minimum on Sepolia is 3. Higher values give you better protection against shallow reorgs, at the cost of waiting longer for the result. The longer the node waits, the more secure the random value is.

The subscription model

VRF requests cost gas. Someone has to pay for both the request transaction and the response transaction, plus a premium that compensates the oracle service. The current production version uses a subscription account model where you pre-fund a balance once and consumer contracts draw from it for each request.

One subscription funds multiple consumer contracts Subscription ID: 42 Balance: 10 LINK 0.5 ETH Owner wallet manages + funds funds Lottery.sol consumer #1 NFTReveal.sol consumer #2 Raffle.sol consumer #3 Up to 100 consumer addresses per subscription. Each request bills the shared balance.

You create a subscription once through the Subscription Manager UI, fund it with LINK or native ETH (the current version supports both), and register the addresses of any contracts that should be allowed to spend from it. A contract that has not been added as an approved consumer cannot make requests against the subscription, even if it has the correct interface. This authorization step is enforced by the VRF Coordinator itself.

Building a consumer contract

Your contract inherits from VRFConsumerBaseV2Plus, which provides the callback receiver and the coordinator reference. The current import paths are:

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";

contract Randomizer is VRFConsumerBaseV2Plus {
    // ...
}

The constructor takes the VRF Coordinator address for the network you're deploying to. Each chain has its own coordinator address, listed on the supported networks page. You also pass the subscription ID that will fund this contract's requests.

solidity
uint256 public immutable subscriptionId;
bytes32 public immutable keyHash;
uint32 public callbackGasLimit = 100_000;
uint16 public requestConfirmations = 3;
uint32 public numWords = 1;

constructor(
    uint256 _subscriptionId,
    address _vrfCoordinator,
    bytes32 _keyHash
) VRFConsumerBaseV2Plus(_vrfCoordinator) {
    subscriptionId = _subscriptionId;
    keyHash = _keyHash;
}

The keyHash identifies which off-chain VRF job runs for your request. Different gas lanes (lower gas tolerance vs higher) have different key hashes. The gas lane key hash value is the maximum gas price you are willing to pay for a request in wei. The supported networks page lists the valid key hashes for each chain.

The request function builds a struct and calls the coordinator:

solidity
function requestRandomNumber() external returns (uint256 requestId) {
    requestId = s_vrfCoordinator.requestRandomWords(
        VRFV2PlusClient.RandomWordsRequest({
            keyHash: keyHash,
            subId: subscriptionId,
            requestConfirmations: requestConfirmations,
            callbackGasLimit: callbackGasLimit,
            numWords: numWords,
            extraArgs: VRFV2PlusClient._argsToBytes(
                VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
            )
        })
    );
}

The extraArgs field is where you choose how to pay. Setting nativePayment: false deducts the request cost in LINK from the subscription balance. Setting it to true deducts in the network's native token instead. Both options are supported simultaneously on the same subscription.

The callback function is what your contract overrides to receive the result:

solidity
function fulfillRandomWords(
    uint256 requestId,
    uint256[] calldata randomWords
) internal override {
    // Whatever you do with the random number happens HERE.
    // The user's original request transaction has long since returned.
    uint256 result = randomWords[0];
    // ... store it, pick a winner, reveal an NFT, etc.
}

The function is internal and override. The base class exposes a public wrapper that checks the caller is the coordinator before invoking your override. You do not need to write that check yourself, but you also cannot bypass it. If anything other than the coordinator calls into the contract attempting to deliver a result, it gets rejected before reaching your code.

The randomWords array length matches the numWords you requested. For most use cases that's one. Asking for several at once is cheaper per number when you need multiple values for the same request (for example, shuffling a deck), since the cryptographic overhead is paid once.

Working with the random number

The number you receive is a uint256, distributed essentially uniformly across the full range of that type. To use it for a specific range, take the modulo:

solidity
uint256 diceRoll = (randomWords[0] % 20) + 1;       // 1 to 20
uint256 percent = randomWords[0] % 100;             // 0 to 99
address winner = participants[randomWords[0] % participants.length];

Modulo introduces very slight bias when the modulus does not divide cleanly into the range of uint256, but for any modulus you'd use in a contract the bias is undetectable. For a 20-sided die, the bias is on the order of one part in 2^251.

The number is fully revealed on chain the moment the coordinator delivers it. If your contract logic depends on keeping the number hidden until later, that's not something VRF gives you. The fulfillment transaction publishes everything, and anyone watching the mempool sees the result as soon as it's mined. Use cases that need committed-but-hidden randomness need a different protocol.

What can go wrong

Three considerations the security page makes explicit and that production contracts get wrong.

The callback gas limit can be exhausted. If your fulfillRandomWords runs out of gas (because it does too much work, or the limit you set is too low), the random number is delivered to the coordinator but never reaches your contract's storage. The subscription is still charged for the work. Keep the callback minimal: store the result and any derived values, then handle complex logic in a separate user-triggered transaction that reads from storage.

Reorgs can re-fulfill the same request. A request submitted near the tip of the chain can be confirmed in one block ordering and then re-organized into a different one. The same VRF response would still be valid (the seed and proof are deterministic), but your contract might process it twice if it doesn't track which requests have already been fulfilled. Use a flag in the request record to mark fulfilled requests and reject double-delivery.

You cannot use the random number in the request transaction. A common beginner mistake is to write something like "request a number and then check if msg.sender won." There is no number yet. The check has to happen in the callback, and the user has to either send a second transaction to claim a win or have the callback automatically settle the outcome.