
Chainlink VRF: Secure, Verifiable On-Chain Randomness
Intermediate guide (~2200 words) to integrating VRF v2 safely and efficiently.
Abstract
Chainlink VRF (Verifiable Random Function) provides unbiased, tamper-evident randomness to smart contracts. This guide explains how it works, why it’s secure despite blockchain determinism, and how to implement it safely with VRF v2 subscriptions. You’ll learn common pitfalls (modulo bias, gas griefing, shared subscription risks), how to test locally using mocks, and practical patterns for lotteries, NFTs, and game logic.
1. Why On-Chain Randomness Is Hard
Smart contract platforms are public and deterministic. Naive sources (timestamps, blockhash, user input) are predictable and may be influenced by miners or transaction ordering. Yet applications need randomness for:
- Lottery winner selection
- NFT trait assignment
- Fair reward distribution / airdrops
- Game state shuffling / loot tables
A robust system requires:
- Unpredictability before revelation
- Cryptographic proof of correctness
- Public, on-chain verifiability
Chainlink VRF provides these guarantees.
2. How Chainlink VRF Works: The Cryptographic Core
Chainlink VRF uses a keypair:
- Private key (sk) held by the oracle
- Public key (pk) registered on-chain
High-level derivation:
randomness = Hash(sk * H(seed))
The oracle returns both the random output and a proof. The VRF Coordinator verifies the proof using elliptic-curve cryptography. If valid, randomness is accepted. Deterministic mapping (sk, seed) → unique output enables trustless verification while remaining unpredictable because sk is secret and seed is only finalized at block inclusion.
Deterministic vs Unpredictable:
(sk, seed) => one unique random output (determinism for verification)
But the oracle cannot precompute future outputs since final seed components are unknown ahead of block execution.
3. VRF v2 Overview: Subscriptions and Consumers
VRF v2 introduces subscription-based billing in LINK:
- A subscription funds multiple requests and optional multiple consumers.
- The VRF Coordinator validates proofs and calls back fulfillRandomWords.
Key parameters:
| Parameter | Purpose | Notes |
|---|---|---|
| keyHash | Identifies oracle key | Network-specific value |
| subId | Subscription ID | Must be funded with LINK |
| confirmations | Blocks to wait | Trade latency vs reorg safety |
| callbackGasLimit | Max gas in fulfill | Keep minimal logic here |
| numWords | Count of random words | Multiple words for shuffles/multi-winner |
Network specifics (Coordinator address, keyHash) must be sourced from official Chainlink docs. Pin versions for audits.
Info: Higher confirmations reduce reorg-induced rollbacks but increase latency. Start with 3, adjust based on network stability.
4. Request Lifecycle
- Request: consumer calls
requestRandomWords(...). - Oracle compute: oracle derives randomness + proof.
- Oracle submit: proof + output delivered to Coordinator.
- On-chain verify: proof validated; if success, Coordinator invokes
fulfillRandomWords. - Consume: contract stores/uses randomness. Result now publicly verifiable.
Sequence: requestRandomWords → event → oracle proof → on-chain verify → fulfillRandomWords callback.
5. Seeds, Request IDs, and Unpredictability
Seed components often include keyHash, subId, consumer address, nonce, and block data. Even if some values feel predictable to users, the oracle cannot reliably precompute them due to:
- Unknown transaction ordering
- Private transactions
- Dynamic nonce changes
- Previous blockhash only known at mining time
Request ID formation (conceptually):
requestId = keccak256(keyHash, subId, consumerAddress, nonce)
Oracle ignorance persists until block finalization.
Seed and requestId inputs: keyHash, subId, consumer address, nonce, block data; highlighting unknown-to-oracle timing.
6. Security and Threat Model
What attackers can do:
- Delay transaction inclusion / fulfillment (censorship)
- Observe all public inputs
- Grief shared subscriptions (e.g., repeated heavy callbacks)
What attackers cannot do:
- Predict random output
- Forge a VRF proof
- Bias output via transaction ordering
- Selectively reveal valid proofs
Mitigations:
- confirmations tuning for reorg resistance
- Minimal work in callback (avoid gas griefing)
- Dedicated subscriptions for high-value apps
- Timeouts/fallback logic for delayed fulfillment
Threat matrix mapping delay, gas griefing, shared subscription abuse, reorg risk to mitigations: confirmations, minimal callbacks, dedicated subs, timeouts.
7. Using Randomness Safely: Patterns & Recipes
Avoid Modulo Bias
Warning:
random % Ncan distort probabilities unless N divides 2^256 evenly. Use rejection sampling instead.
Rejection sampling:
function uniform(uint256 rand, uint256 n) internal pure returns (uint256) {
require(n > 0, "n=0");
unchecked {
uint256 threshold = type(uint256).max - (type(uint256).max % n);
while (rand >= threshold) {
rand = uint256(keccak256(abi.encode(rand))); // expand entropy
}
return rand % n;
}
}
Fisher–Yates Shuffle (Indices)
Use multiple random words for efficiency:
function shuffle(uint256[] memory arr, uint256[] memory words) internal pure {
uint256 w;
for (uint256 i = arr.length - 1; i > 0; i--) {
uint256 j = uniform(words[w], i + 1);
(arr[i], arr[j]) = (arr[j], arr[i]);
w++;
if (w == words.length) {
words[0] = uint256(keccak256(abi.encode(words[0])));
w = 0;
}
}
}
Multi-Winner Selection
- Shuffle indices and take first k
- Or maintain a bitmap for uniqueness over repeated draws
Multi-Word Draws
Request additional words when doing complex operations (e.g., multi-winner lotteries + trait assignment simultaneously).
8. Implementation Guide (Solidity)
Consumer contract scaffold:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {VRFCoordinatorV2Interface} from "./interfaces/VRFCoordinatorV2Interface.sol";
import {VRFConsumerBaseV2} from "./base/VRFConsumerBaseV2.sol";
error UnknownRequest(uint256 requestId);
error BadParams();
contract Lottery is VRFConsumerBaseV2 {
VRFCoordinatorV2Interface private immutable COORD;
uint64 public immutable subId;
bytes32 public immutable keyHash;
uint16 public confirmations = 3;
uint32 public callbackGasLimit = 250000;
uint32 public numWords = 2;
uint256 public lastRequestId;
uint256[] public lastRandomWords;
event DrawRequested(uint256 requestId);
event DrawFulfilled(uint256 requestId, uint256[] words);
constructor(address coordinator, uint64 _subId, bytes32 _keyHash)
VRFConsumerBaseV2(coordinator)
{
require(coordinator != address(0) && _subId != 0, "init");
COORD = VRFCoordinatorV2Interface(coordinator);
subId = _subId;
keyHash = _keyHash;
}
function setParams(uint16 conf, uint32 gasLimit, uint32 words) external {
if (conf < 1 || conf > 200 || gasLimit < 100000 || words < 1) revert BadParams();
confirmations = conf;
callbackGasLimit = gasLimit;
numWords = words;
}
function draw() external {
uint256 reqId = COORD.requestRandomWords(
keyHash,
subId,
confirmations,
callbackGasLimit,
numWords
);
lastRequestId = reqId;
emit DrawRequested(reqId);
}
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
internal
override
{
if (requestId != lastRequestId) revert UnknownRequest(requestId);
delete lastRandomWords; // reset
lastRandomWords = randomWords;
emit DrawFulfilled(requestId, randomWords);
// Heavy logic deferred to external view/state-changing functions
}
}
Error Handling (No try/catch)
- Use custom errors for predictable revert reasons.
- Guard parameter ranges early.
- Keep fulfillRandomWords minimal to reduce griefing surface.
Gas Optimization Checklist
- Immutable storage for coordinator, subId, keyHash
- Pack smaller integers (uint16/uint32) when possible
- Minimize storage writes in fulfillment
- Use events for heavy output vs storing large arrays repeatedly
- Unchecked increments in tight loops after bounds checks
9. Local Development & Testing (VRFCoordinatorV2Mock)
Testing flow (pseudocode):
- Deploy VRFCoordinatorV2Mock
- Create subscription:
createSubscription() - Fund subscription with mock LINK value
- Deploy consumer contract (Lottery)
- Add consumer to subscription
- Call
draw()→ obtain requestId - Simulate oracle:
mock.fulfillRandomWords(requestId, consumer) - Assert randomness consumed as expected
Test unpredictability proxies by adjusting confirmations. For deterministic test expansion, derive second-level pseudo-random values via keccak256 as shown in uniform.
10. Costing, Funding, and Operations
Cost drivers:
- Gas price, callbackGasLimit, base fee per VRF proof Strategies:
- Estimate per-request cost; multiply by expected daily volume + safety buffer
- Monitor subscription balance; alert on thresholds (subgraph or cron job)
- Isolate high-value apps in dedicated subscriptions to avoid cross-consumer grief
- Plan migrations: deploy new consumer with upgraded params before deprecating old
Info: Use off-chain monitoring (subgraph indexing) to track request volume and LINK depletion over time.
11. Expanded FAQ
Is the seed unpredictable? The oracle lacks full seed inputs before block finalization, preventing precomputation.
Can miners or MEV bots manipulate VRF? They may delay inclusion but cannot bias or forge proofs.
Does user seed matter? Mostly for domain separation or labeling; security relies on the oracle’s private key and proof verification.
Can the oracle cheat? Only by forging a cryptographic proof—currently infeasible.
How should I choose confirmations? Start at 3; raise in environments with frequent reorgs or higher economic value at stake.
How do I estimate costs and avoid underfunding? Combine expected proof cost + callback gas * gas price. Maintain surplus LINK and alert on low balance.
How do I prevent callback gas griefing and shared subscription abuse? Keep callbacks lean; move heavy logic outside; prefer dedicated subs; enforce parameter guards.
12. Conclusion & Further Reading
Chainlink VRF bridges deterministic smart contracts with unpredictable randomness using verifiable cryptography. With safe parameter choices, unbiased mapping techniques, minimal callbacks, and proper subscription management, you can build fair lotteries, NFTs, and games resilient to adversarial strategies.
Further reading:
