Developing Blockchain Lottery Contracts
The most common mistake in developing a blockchain lottery — using block.timestamp, block.prevrandao (formerly block.difficulty) or block hash as a randomness source. The miner/validator controls these values within a reasonable range. For a $100 bet, the attack is not cost-effective, for a $500K jackpot — quite so.
Why On-Chain Randomness Doesn't Work Without VRF
block.prevrandao in Ethereum after The Merge provides 1 bit of validator influence (include or not include the block). This is better than block.difficulty, but still isn't suitable for lottery. RANDAO is aggregated entropy from all validators in an epoch, the last reveal has 1 bit influence on the final value. For a lottery with pool >$1M, this is economically attackable: a validator can hide a reveal and not finalize a block if the random number doesn't suit them.
Chainlink VRF v2 solves the problem cryptographically: a random number is generated off-chain with proof of correctness (VRF proof), which is verified on-chain before use. It's impossible to fake random even for Chainlink. This isn't "trusting the oracle", it's verifiable randomness.
Architecture of Lottery Contract with VRF
Basic structure: contract accepts participants, accumulates pool, at draw time requests random number from Chainlink VRF, receives it in callback, picks winner.
// 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 Lottery is VRFConsumerBaseV2Plus {
uint256 public subscriptionId;
bytes32 public keyHash;
uint32 public callbackGasLimit = 100000;
uint16 public requestConfirmations = 3;
address[] public participants;
uint256 public pendingRequestId;
LotteryState public state;
enum LotteryState { OPEN, DRAWING, CLOSED }
function drawWinner() external onlyOwner {
require(state == LotteryState.OPEN, "Not open");
require(participants.length > 0, "No participants");
state = LotteryState.DRAWING;
pendingRequestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: subscriptionId,
requestConfirmations: requestConfirmations,
callbackGasLimit: callbackGasLimit,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
}
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
require(requestId == pendingRequestId, "Wrong requestId");
uint256 winnerIndex = randomWords[0] % participants.length;
address winner = participants[winnerIndex];
state = LotteryState.CLOSED;
// payout to winner
payable(winner).transfer(address(this).balance);
}
}
Critical Implementation Details
requestConfirmations — how many blocks to wait before generating random. Minimum 3, recommended for mainnet — from 5. Less — faster, but theoretically opens window for chain reorg attack (though on Ethereum with ~12.8 minute finality this is practically excluded).
callbackGasLimit — gas limit for fulfillRandomWords. If logic inside callback uses more gas — Chainlink transaction will fail, random will be lost, contract will hang in DRAWING state. Count gas carefully: iteration over 1000 participants inside callback requires ~21K gas just for reading addresses from storage. Better to store only winnerIndex in callback and allow winner to claim prize themselves.
Subscription vs Direct Funding. Subscription model (recommended): prepaid LINK deposit, multiple contracts use one balance. Direct Funding: each VRF request is paid from contract balance. For lotteries with regular draws — subscription is cheaper operationally.
Lottery Contract Vulnerabilities
Front-Running the Draw
If draw moment is known in advance (e.g., timestamp), MEV-bots can buy the last ticket in one block with drawWinner transaction. With few participants this changes probabilities. Solution: commit-reveal for ticket purchase, or closing sales N blocks before draw.
Reentrancy on Payout
Classic. Push pattern (transfer ETH to winner in fulfillRandomWords) + external code of winner = reentrancy. Use pull pattern: winner themselves calls claimPrize(), where we update state before transferring funds.
Centralization of Management
onlyOwner on drawWinner — this is centralization. Contract owner can delay the draw, wait for favorable moment (though they don't control VRF randomness). Best option: automated draw through Chainlink Automation or Gelato, without manual call.
Integration with Chainlink Automation
Draw by schedule or condition (N participants accumulated) without manual call:
function checkUpkeep(bytes calldata)
external view override returns (bool upkeepNeeded, bytes memory) {
upkeepNeeded = (
state == LotteryState.OPEN &&
participants.length >= minParticipants &&
block.timestamp >= nextDrawTime
);
}
function performUpkeep(bytes calldata) external override {
// called by Chainlink Automation when checkUpkeep == true
drawWinner();
}
This eliminates single point of failure and centralization of management.
Testing and Audit
Tests on Foundry with mock VRF Coordinator are mandatory before deployment. Chainlink provides VRFCoordinatorV2_5Mock for local testing. Fuzzing on parameters numParticipants, randomWord — verify winner is always in bounds [0, participants.length).
For testnet — Sepolia with real VRF Coordinator. You need LINK on testnet (faucet.chain.link) and subscription on vrf.chain.link.
Timeline
Basic lottery contract with VRF and Automation: 3-5 days development + 1-2 days testing. Contract with extended tokenomics (multiple pools, NFT tickets, referral system) — 2-3 weeks. Audit recommended for any contract with pool >$50K.
Cost is calculated individually after clarifying draw mechanics and tokenomics requirements.







