🎲 Day 22 — Fair & Random Lottery using Chainlink VRF

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 / …


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 interval seconds. 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

  1. Players call enterLottery() sending at least entranceFee (or send ETH directly to the contract which calls enterLottery() on receive).
  2. Chainlink nodes call checkUpkeep() periodically; if interval seconds passed, there is at least one player and the contract has ETH, upkeepNeeded == true. The Automation node executes performUpkeep(). ([Chainlink Documentation][3])
  3. performUpkeep() switches lotteryState to CALCULATING and calls requestRandomWords() on the VRF coordinator with your subscription. ([Chainlink Documentation][1])
  4. Chainlink VRF fulfills by calling your contract’s fulfillRandomWords() — you pick the winner using randomWords[0] modulo players.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 VRFCoordinatorV2Mock included with forge 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

  1. 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])
  2. Find the appropriate VRF coordinator address and keyHash for your target network (Chainlink docs list them). Use those values when deploying. ([Chainlink Documentation][2])
  3. 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 ReentrancyGuard and reset state before transferring funds (we reset players and state before sending the prize).
  • Carefully set callbackGasLimit large enough to cover your fulfillRandomWords logic.
  • 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 LotteryRequested and WinnerPicked to 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


Print Share Comment Cite Upload Translate Updates
APA

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/

MLA
" » 🎲 Day 22 — Fair & Random Lottery using Chainlink VRF." Saurav Kumar | Sciencx - Friday October 24, 2025, https://www.scien.cx/2025/10/24/%f0%9f%8e%b2-day-22-fair-random-lottery-using-chainlink-vrf/
HARVARD
Saurav Kumar | Sciencx Friday October 24, 2025 » 🎲 Day 22 — Fair & Random Lottery using Chainlink VRF., viewed ,<https://www.scien.cx/2025/10/24/%f0%9f%8e%b2-day-22-fair-random-lottery-using-chainlink-vrf/>
VANCOUVER
Saurav Kumar | Sciencx - » 🎲 Day 22 — Fair & Random Lottery using Chainlink VRF. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/10/24/%f0%9f%8e%b2-day-22-fair-random-lottery-using-chainlink-vrf/
CHICAGO
" » 🎲 Day 22 — Fair & Random Lottery using Chainlink VRF." Saurav Kumar | Sciencx - Accessed . https://www.scien.cx/2025/10/24/%f0%9f%8e%b2-day-22-fair-random-lottery-using-chainlink-vrf/
IEEE
" » 🎲 Day 22 — Fair & Random Lottery using Chainlink VRF." Saurav Kumar | Sciencx [Online]. Available: https://www.scien.cx/2025/10/24/%f0%9f%8e%b2-day-22-fair-random-lottery-using-chainlink-vrf/. [Accessed: ]
rf:citation
» 🎲 Day 22 — Fair & Random Lottery using Chainlink VRF | Saurav Kumar | Sciencx | 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.

You must be logged in to translate posts. Please log in or register.