This content originally appeared on DEV Community and was authored by Saurav Kumar
Learn how to build a provably-fair on-chain lottery using Chainlink VRF (subscription) for randomness and Chainlink Automation to auto-run draws — complete Foundry project with full Solidity source, tests, and deploy instructions.
TL;DR / What you’ll build
A Lottery smart contract that:
- Accepts player entries (ETH) during an open period.
- Automatically triggers a draw every X seconds using Chainlink Automation (Upkeep).
- Requests randomness via Chainlink VRF (subscription) to pick a fair winner.
- Sends the pot to the winner (with safety considerations). All implemented and tested using Foundry. ([Chainlink Documentation][1])
Why this matters
Blockchains alone can’t generate secure randomness (blockhashes are manipulable). Chainlink VRF provides verifiable, tamper-proof randomness. Pairing that with Chainlink Automation offloads the cron-like responsibility to a decentralized automation layer so draws happen automatically and transparently. ([Chainlink Documentation][2])
Full source — Lottery.sol (foundry-ready)
This contract uses Chainlink VRF (subscription method) and Chainlink Automation (Upkeep interface) to run automatic draws every
intervalseconds. It’s written for Solidity^0.8.19.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/*
Lottery.sol
- Players enter by sending >= entranceFee
- Uses Chainlink Automation (Upkeep) to trigger draws automatically every `interval` seconds
- Uses Chainlink VRF v2 subscription to get a provably-fair random winner
- Foundry-friendly imports assumed via `forge install` of chainlink + openzeppelin
*/
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/interfaces/AutomationCompatibleInterface.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Lottery is VRFConsumerBaseV2, AutomationCompatibleInterface, Ownable, ReentrancyGuard {
/* Type declarations */
enum LotteryState { OPEN, CALCULATING }
/* State variables */
uint256 public entranceFee;
address payable[] private players;
LotteryState public lotteryState;
// Automation variables
uint256 public lastTimeStamp;
uint256 public interval; // seconds between automatic draws
// VRF variables
VRFCoordinatorV2Interface public COORDINATOR;
bytes32 public keyHash; // gasLane / keyHash
uint64 public subscriptionId;
uint32 public callbackGasLimit;
uint16 public requestConfirmations;
uint32 public numWords;
// recent winner
address public recentWinner;
uint256 public lastRequestId;
/* Events */
event LotteryEntered(address indexed player);
event LotteryRequested(uint256 requestId);
event WinnerPicked(address indexed winner, uint256 amount);
event LotteryOpened();
constructor(
uint256 _entranceFee,
uint256 _interval,
address _vrfCoordinator,
bytes32 _keyHash,
uint64 _subscriptionId,
uint32 _callbackGasLimit,
uint16 _requestConfirmations
) VRFConsumerBaseV2(_vrfCoordinator) {
entranceFee = _entranceFee;
interval = _interval;
COORDINATOR = VRFCoordinatorV2Interface(_vrfCoordinator);
keyHash = _keyHash;
subscriptionId = _subscriptionId;
callbackGasLimit = _callbackGasLimit;
requestConfirmations = _requestConfirmations;
numWords = 1;
lotteryState = LotteryState.OPEN;
lastTimeStamp = block.timestamp;
emit LotteryOpened();
}
/* Public user functions */
function enterLottery() external payable nonReentrant {
require(lotteryState == LotteryState.OPEN, "Lottery not open");
require(msg.value >= entranceFee, "Not enough ETH to enter");
players.push(payable(msg.sender));
emit LotteryEntered(msg.sender);
}
function getPlayers() external view returns (address payable[] memory) {
return players;
}
function getPlayersCount() external view returns (uint256) {
return players.length;
}
/* ========== Chainlink Automation (Upkeep) ========== */
// checkUpkeep is called off-chain by Chainlink nodes to see if performUpkeep should be executed.
// We check: interval passed, lottery open, and at least 1 player and contract has balance
function checkUpkeep(bytes calldata /* checkData */) external view override returns (bool upkeepNeeded, bytes memory /* performData */) {
bool timePassed = (block.timestamp - lastTimeStamp) >= interval;
bool hasPlayers = players.length > 0;
bool isOpen = lotteryState == LotteryState.OPEN;
bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && hasPlayers && isOpen && hasBalance);
}
// performUpkeep is executed by Chainlink nodes when checkUpkeep returns true.
// We mark the lottery as CALCULATING and request randomness from VRF.
function performUpkeep(bytes calldata /* performData */) external override {
// double-check (safety)
(bool upkeepNeeded,) = this.checkUpkeep("");
require(upkeepNeeded, "Upkeep not needed");
lotteryState = LotteryState.CALCULATING;
uint256 requestId = COORDINATOR.requestRandomWords(
keyHash,
subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
lastRequestId = requestId;
emit LotteryRequested(requestId);
}
/* ========== Chainlink VRF callback ========== */
function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
require(lotteryState == LotteryState.CALCULATING, "Not calculating");
uint256 winnerIndex = randomWords[0] % players.length;
address payable winner = players[winnerIndex];
uint256 prize = address(this).balance;
// Reset state before sending to prevent reentrancy problems
players = new address payable;
lotteryState = LotteryState.OPEN;
lastTimeStamp = block.timestamp;
recentWinner = winner;
// transfer prize using call pattern
(bool sent, ) = winner.call{value: prize}("");
require(sent, "Transfer failed");
emit WinnerPicked(winner, prize);
emit LotteryOpened();
}
/* Owner admin helpers */
function setInterval(uint256 _interval) external onlyOwner {
interval = _interval;
}
function setEntranceFee(uint256 _fee) external onlyOwner {
entranceFee = _fee;
}
function withdrawLink() external onlyOwner {
// if you store LINK or native token, withdraw logic goes here
}
// Convenience receive to allow direct send -> enterLottery
receive() external payable {
enterLottery();
}
}
How it works — short explanation
- Players call
enterLottery()sending at leastentranceFee(or send ETH directly to the contract which callsenterLottery()on receive). - Chainlink nodes call
checkUpkeep()periodically; ifintervalseconds passed, there is at least one player and the contract has ETH,upkeepNeeded==true. The Automation node executesperformUpkeep(). ([Chainlink Documentation][3]) -
performUpkeep()switcheslotteryStatetoCALCULATINGand callsrequestRandomWords()on the VRF coordinator with your subscription. ([Chainlink Documentation][1]) - Chainlink VRF fulfills by calling your contract’s
fulfillRandomWords()— you pick the winner usingrandomWords[0]moduloplayers.length, send the prize, reset state, and open lottery for next round. ([Chainlink Documentation][2])
Foundry project & tests (complete)
Project setup (commands)
# 1. Create project
mkdir day-22-lottery-foundry && cd day-22-lottery-foundry
forge init --template default
rm -rf src && mkdir -p src test script
# 2. Install dependencies (OpenZeppelin + Chainlink)
forge install OpenZeppelin/openzeppelin-contracts
forge install smartcontractkit/chainlink
# 3. Add files (create files below in src/, test/, script/)
Simplified VRFCoordinatorV2Mock for local testing (use Chainlink's official mock if you prefer)
Create src/mocks/VRFCoordinatorV2Mock.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
/*
A minimal mock implementing the subset of VRFCoordinatorV2Interface we use in tests.
For more fidelity use Chainlink's official VRFCoordinatorV2Mock.
*/
contract VRFCoordinatorV2Mock is VRFCoordinatorV2Interface {
uint64 public nextSubId = 1;
mapping(uint64 => uint256) public funded;
uint256 public latestRequestId;
event RandomnessRequested(uint256 requestId, address consumer);
function createSubscription() external returns (uint64) {
uint64 id = nextSubId++;
funded[id] = 0;
return id;
}
function fundSubscription(uint64 subId, uint256 amount) external {
funded[subId] += amount;
}
function requestRandomWords(
bytes32, /* keyHash */
uint64 subId,
uint16, /* requestConfirmations */
uint32, /* callbackGasLimit */
uint32 /* numWords */
) external override returns (uint256) {
require(funded[subId] > 0, "Sub not funded");
latestRequestId++;
emit RandomnessRequested(latestRequestId, msg.sender);
return latestRequestId;
}
// Simulate VRF fulfillment by calling into consumer's rawFulfillRandomWords
function fulfillRandomWords(uint256 requestId, address consumer, uint256 random) external {
uint256;
words[0] = random;
// call consumer's rawFulfillRandomWords (VRFConsumerBaseV2 exposes rawFulfillRandomWords)
(bool ok, ) = consumer.call(abi.encodeWithSignature("rawFulfillRandomWords(uint256,uint256[])", requestId, words));
require(ok, "fulfill failed");
}
// Stubs to satisfy interface - not used for tests
function getRequestConfig() external pure returns (uint16, uint32, bytes32[] memory) { bytes32[] memory arr; return (0,0,arr); }
function createSubscription() external override returns (uint64) { return 0; }
function getSubscription(uint64) external view override returns (address, uint64, uint64, address[] memory) { address[] memory a; return (address(0),0,0,a); }
function requestSubscriptionOwnerTransfer(uint64, address) external override {}
function acceptSubscriptionOwnerTransfer(uint64) external override {}
function addConsumer(uint64, address) external override {}
function removeConsumer(uint64, address) external override {}
function cancelSubscription(uint64, address) external override {}
function pendingRequestExists(uint64) external view override returns (bool) { return false; }
}
Tip: For production-like tests, prefer Chainlink's official
VRFCoordinatorV2Mockincluded withforge install smartcontractkit/chainlink.
Foundry Test: test/Lottery.t.sol
Create this file to run a full local simulation (start lottery, players enter, trigger performUpkeep, simulate VRF fulfil, assert winner).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/Lottery.sol";
import "../src/mocks/VRFCoordinatorV2Mock.sol";
contract LotteryTest is Test {
Lottery public lottery;
VRFCoordinatorV2Mock public vrfMock;
address public owner = address(0xABCD);
address public player1 = address(0x1);
address public player2 = address(0x2);
function setUp() public {
vm.deal(owner, 10 ether);
vm.deal(player1, 10 ether);
vm.deal(player2, 10 ether);
// deploy mock
vrfMock = new VRFCoordinatorV2Mock();
// create & fund a subscription (mock)
uint64 subId = vrfMock.createSubscription();
vrfMock.fundSubscription(subId, 1 ether);
// deploy the lottery (entranceFee = 0.1 ether, interval = 1 minute (60s))
vm.prank(owner);
lottery = new Lottery(
0.1 ether,
60, // interval seconds
address(vrfMock),
bytes32("0xkeyhash"),
subId,
200000,
3
);
}
function test_autoDrawFlow() public {
// two players enter
vm.prank(player1);
lottery.enterLottery{value: 0.1 ether}();
vm.prank(player2);
lottery.enterLottery{value: 0.1 ether}();
// fast forward time beyond interval
vm.warp(block.timestamp + 61);
// simulate Chainlink Automation node calling checkUpkeep -> performUpkeep
// In production, Chainlink nodes call performUpkeep directly when checkUpkeep returns true,
// but we call performUpkeep() here as the node would.
lottery.performUpkeep("");
// Now VRFCoordinator mock should have a latestRequestId
uint256 reqId = vrfMock.latestRequestId();
// Simulate VRF callback with a random number, e.g., 777
vrfMock.fulfillRandomWords(reqId, address(lottery), 777);
// Ensure a winner was picked and the winner got the prize of 0.2 ETH
address winner = lottery.recentWinner();
assertTrue(winner == player1 || winner == player2);
// Can't check exact balances from here as vm.deal on addresses was performed,
// but we ensure the contract's players array got reset and lottery opened
assertEq(lottery.getPlayersCount(), 0);
assertEq(uint(lottery.lotteryState()), uint(Lottery.LotteryState.OPEN));
}
}
Run tests:
forge test -vv
Foundry deploy script (optional) — script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Script.sol";
import "../src/Lottery.sol";
contract DeployScript is Script {
function run() external {
vm.startBroadcast();
uint256 entranceFee = 0.01 ether;
uint256 interval = 3600; // 1 hour
address vrfCoordinator = 0x...; // set network coordinator
bytes32 keyHash = 0x...; // gasLane for the network
uint64 subId = 0; // your Chainlink subscription id
uint32 callbackGas = 200000;
uint16 confirmations = 3;
new Lottery(entranceFee, interval, vrfCoordinator, keyHash, subId, callbackGas, confirmations);
vm.stopBroadcast();
}
}
To run:
forge script script/Deploy.s.sol --rpc-url <RPC_URL> --private-key <PRIV> --broadcast
Chainlink steps you must perform on testnet / mainnet
- Create a VRF subscription via Chainlink’s Subscription Manager (https://vrf.chain.link/). Fund it with testnet LINK. Add your deployed contract as a consumer. ([Chainlink Documentation][4])
- Find the appropriate VRF coordinator address and keyHash for your target network (Chainlink docs list them). Use those values when deploying. ([Chainlink Documentation][2])
-
Register an Automation upkeep (via Chainlink Automation app or programmatically) for your contract so Chainlink nodes will call
checkUpkeep/performUpkeep. You can register a “custom logic” upkeep in the Automation DApp and fund it with LINK/native so the node can execute. ([Chainlink Documentation][3])
Notes:
- You can register upkeeps programmatically from a contract as well; Chainlink docs describe programmatic registration when you want dynamic upkeeps. ([Chainlink Documentation][5])
- Chainlink VRF v2 deprecation / v2.5 notes: VRF v2/v1 deprecation messages exist — consider migrating to VRF v2.5 for newer features and future-proofing. See migration docs. ([Chainlink Documentation][6])
Security & gas considerations (short)
- Use
ReentrancyGuardand reset state before transferring funds (we reset players and state before sending the prize). - Carefully set
callbackGasLimitlarge enough to cover yourfulfillRandomWordslogic. - Consider pull-over-push pattern for very large pots (let winners withdraw instead of sending in
fulfillRandomWords). - For production, use Chainlink’s official mocks for testing to reduce mismatch surprises. ([Chainlink Documentation][7])
Frontend (quick example) — React + Ethers.js snippet
// src/App.jsx (snippet)
import { useEffect, useState } from "react";
import { ethers } from "ethers";
import LotteryABI from "./abi/Lottery.json";
const LOTTERY_ADDRESS = "<DEPLOYED_ADDRESS>";
function App() {
const [fee, setFee] = useState("0");
const [players, setPlayers] = useState(0);
const [status, setStatus] = useState("");
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(LOTTERY_ADDRESS, LotteryABI, provider.getSigner());
useEffect(() => {
async function load() {
const f = await contract.entranceFee();
setFee(ethers.utils.formatEther(f));
const count = await contract.getPlayersCount();
setPlayers(count.toNumber());
}
load();
}, []);
async function enter() {
setStatus("Sending tx...");
const tx = await contract.enterLottery({ value: ethers.utils.parseEther(fee) });
await tx.wait();
setStatus("Entered!");
}
return (
<div>
<h1>Lottery</h1>
<p>Entrance fee: {fee} ETH</p>
<p>Players: {players}</p>
<button onClick={enter}>Enter</button>
<p>{status}</p>
</div>
);
}
export default App;
Frontend notes:
- Watch events
LotteryRequestedandWinnerPickedto update UI in real-time. - Ensure MetaMask/network is set to the same chain you deployed to.
Extra resources & references
Chainlink VRF (v2 subscription) — create & fund subscription, request random words.
👉 https://docs.chain.link/vrf/v2/subscription
Chainlink Automation (Upkeeps) — register custom logic upkeeps and programmatic registration.
👉 https://docs.chain.link/chainlink-automation
Local testing using VRFCoordinatorV2Mock — test your consumer locally with mocks.
👉 https://docs.chain.link/vrf/v2/subscription/examples/get-a-random-number#local-testing
VRF v2 → v2.5 migration notes (future-proofing) — upgrade safely to the latest version.
👉 https://docs.chain.link/vrf/v2-5
This content originally appeared on DEV Community and was authored by Saurav Kumar
Saurav Kumar | Sciencx (2025-10-24T03:50:38+00:00) 🎲 Day 22 — Fair & Random Lottery using Chainlink VRF. Retrieved from https://www.scien.cx/2025/10/24/%f0%9f%8e%b2-day-22-fair-random-lottery-using-chainlink-vrf/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.