Chainlink VRF Integration for Casino Random Number Generation
A blockchain casino with fair randomness — this is not a marketing claim, it's a technical architecture. Chainlink VRF (Verifiable Random Function) generates a random number with a cryptographic proof of its correctness. On-chain verification of the proof happens before the number is used in game logic — manipulation is excluded mathematically, not organizationally.
VRF v2.5: Subscription vs Direct Funding
The current version is VRF v2.5. Two payment models:
Subscription model. A subscription is created at vrf.chain.link, replenished with LINK. Multiple casino contracts use one balance. Suitable for products with regular requests — roulette, slots, card games.
Direct Funding (VRFV2PlusWrapper). The contract pays for each request itself in LINK or native token (ETH, MATIC). Simpler to launch, no need to manage a separate subscription. But each request is slightly more expensive.
For high-load casinos — subscription. For NFT minting with random attributes or one-off tournaments — Direct Funding is operationally simpler.
Integration into a Casino Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
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 RouletteGame is VRFConsumerBaseV2Plus {
uint256 public immutable subscriptionId;
bytes32 public immutable keyHash;
struct Bet {
address player;
uint256 amount;
uint8 betType; // 0=red, 1=black, 2=number
uint8 number;
}
mapping(uint256 requestId => Bet) public pendingBets;
event BetPlaced(uint256 indexed requestId, address indexed player);
event GameResult(uint256 indexed requestId, uint8 result, bool won);
function placeBet(uint8 betType, uint8 number) external payable {
require(msg.value >= MIN_BET, "Below minimum");
require(msg.value <= MAX_BET, "Above maximum");
uint256 requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 150_000,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
pendingBets[requestId] = Bet({
player: msg.sender,
amount: msg.value,
betType: betType,
number: number
});
emit BetPlaced(requestId, msg.sender);
}
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords)
internal override {
Bet memory bet = pendingBets[requestId];
delete pendingBets[requestId];
uint8 result = uint8(randomWords[0] % 37); // 0-36
bool won = _checkWin(bet, result);
if (won) {
uint256 payout = _calculatePayout(bet);
payable(bet.player).transfer(payout);
}
emit GameResult(requestId, result, won);
}
}
Critical Details
callbackGasLimit must have a buffer. If there's not enough gas in fulfillRandomWords — Chainlink won't retry automatically. The request will be lost, the bet will hang. Calculate real gas: forge test --gas-report. For roulette with payout and events — 150K gas is enough. For complex logic with multiple bets — increase.
requestConfirmations: 3 — minimum. On Ethereum with a 2-block reorg, Chainlink might get a different seed for random. 3 confirmations — a reasonable compromise between speed and security. For jackpot bets, use 5-10.
Don't store bets in an array. Mapping requestId => Bet is the right approach. An array of bets with requestId search — O(n) in the callback, gas griefing with a large number of pending bets.
keyHash — Lane Selection
Chainlink provides several keyHash-s for one network — they differ in maximum gas price Chainlink is willing to spend on delivering random. On Ethereum mainnet:
-
0x8af...— 200 gwei lane: Chainlink delays delivery if gas price is higher -
0x9fe...— 500 gwei lane: delivery even during network congestion (more expensive)
For a casino with instant games, use the 500 gwei lane — a player shouldn't wait hours during peak load.
Protection Against Abuse
A bet is placed, random is in transit. What if a player places a bet and waits for the result on another device, then cancels if they don't like the result? In the current architecture, cancellation is impossible — funds are locked in the contract until fulfillRandomWords. This is correct.
But what if random doesn't arrive within 24 hours (Chainlink is unavailable, subscription balance exhausted)? We need a bet refund function with timeout check:
function refundExpiredBet(uint256 requestId) external {
Bet memory bet = pendingBets[requestId];
require(bet.player == msg.sender, "Not your bet");
require(block.timestamp > betTimestamps[requestId] + 24 hours, "Not expired");
delete pendingBets[requestId];
payable(msg.sender).transfer(bet.amount);
}
Testing
Chainlink provides VRFCoordinatorV2_5Mock for Foundry tests — it imitates the Coordinator and lets you manually call fulfillRandomWords with a given random:
vrfCoordinator.fulfillRandomWords(requestId, address(game));
Fuzz test: check payout correctness for all randomWords[0] values from 0 to 2^256-1. Edge case: randomWords[0] % 37 == 0 — zero on the roulette, a rare edge case.
Timelines
Basic VRF integration into an existing game contract: 1-2 days. New casino contract with VRF, payouts, and timeout protection: 2-3 days. Testing on Sepolia with real VRF — included.
Cost is calculated after clarifying the game type and betting mechanics.







