Weak Randomness in Solidity: Why block.timestamp Isn't Random
If your contract uses block.timestamp or blockhash as a random seed, a validator can predict — or outright pick — the outcome. SmartBillions lost 400 ETH to this exact pattern in 2018, and NFT mint sniping bots still print money off it today. Here's why on-chain randomness is a contradiction, and…
The Bug: Everything On-Chain Is Public
Solidity has no native randomness. The EVM is deterministic — every node must reach the same state, so true entropy is impossible by construction. Yet developers keep reaching for `block.timestamp`, `blockhash`, or `keccak256` over block variables as if they were `Math.random()`.
They're not. They're public inputs that a validator controls or that anyone can read in the same transaction.
```solidity
// VULNERABLE — do not ship this
contract Lottery {
address[] public players;
uint256 public ticketPrice = 0.1 ether;
function enter() external payable {
require(msg.value == ticketPrice);
players.push(msg.sender);
}
function drawWinner() external {
uint256 random = uint256(
keccak256(abi.encodePacked(
block.timestamp,
block.difficulty,
players.length
))
);
address winner = players[random % players.length];
payable(winner).transfer(address(this).balance);
}
}
```
Three distinct ways to break this:
1. **Same-transaction prediction.** An attacker contract calls `drawWinner()` after computing the same hash. If they don't like the result, they revert. Free retries until they win.
2. **Validator manipulation.** Post-merge, the block proposer chooses `block.timestamp` within a small window and influences `prevrandao`. For a high-value lottery, they will absolutely do that.
3. **MEV bots.** A searcher sees a pending mint or draw, simulates the outcome, and front-runs with calldata that hits the favorable branch.
`block.difficulty` was renamed to `block.prevrandao` after The Merge. It's better than `timestamp` — it incorporates beacon-chain RANDAO — but the proposer of the next block knows the value one slot in advance and can decide whether to publish their block. For anything worth more than the block reward, it's still manipulable.
Real Incidents That Cost Real Money
**SmartBillions Lottery (2018).** SmartBillions ran a lottery on Ethereum that used `blockhash(block.number - N)` for randomness. The attacker computed winning numbers off-chain using public block data, then submitted only winning tickets. They drained roughly **400 ETH** before the team noticed. The contract was technically working as written — the bug was the assumption that `blockhash` is unpredictable.
**NFT mint sniping.** Countless NFT collections use weak randomness to assign rarities at mint time. Bots monitor the mempool, simulate the mint transaction against the next block's predictable state, and only submit mints that yield rare traits. Meebits, Loot derivatives, and dozens of mid-tier collections shipped this exact pattern. The 'fair launch' is decided by whoever has the fastest simulator.
**Fomo3D-style games.** Multiple copycats used `block.timestamp % N` to pick winners. Miners colluded with players to mine blocks with favorable timestamps. The original Fomo3D itself was won by an attacker who congested the network with high-gas transactions to ensure no one else could enter before the timer expired — a related but distinct manipulation.
Fix #1: Chainlink VRF
Chainlink VRF (Verifiable Random Function) produces randomness off-chain along with a cryptographic proof that the value was generated honestly. The contract verifies the proof on-chain before accepting the value. Even Chainlink can't tamper with the output without breaking the proof.
```solidity
// FIXED — Chainlink VRF v2
import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
contract Lottery is VRFConsumerBaseV2 {
VRFCoordinatorV2Interface immutable COORDINATOR;
uint64 immutable subId;
bytes32 immutable keyHash;
address[] public players;
uint256 public requestId;
constructor(address coord, uint64 s, bytes32 k) VRFConsumerBaseV2(coord) {
COORDINATOR = VRFCoordinatorV2Interface(coord);
subId = s;
keyHash = k;
}
function drawWinner() external {
require(players.length > 0, "no players");
requestId = COORDINATOR.requestRandomWords(
keyHash, subId, 3, 200_000, 1
);
}
function fulfillRandomWords(uint256, uint256[] memory words) internal override {
address winner = players[words[0] % players.length];
delete players;
payable(winner).transfer(address(this).balance);
}
}
```
Key properties:
The randomness arrives in a **separate transaction** (`fulfillRandomWords`), so no attacker can revert based on the outcome.
- Use enough `requestConfirmations` (3+) to make re-org attacks uneconomical.
- Lock state when the request is made. Don't let new players enter between `drawWinner()` and fulfillment.
If you're not on a chain Chainlink supports, alternatives are API3 QRNG, Gelato VRF, or drand beacons. Same principle: randomness must come from outside the EVM, with a proof.
Fix #2: Commit-Reveal
When you don't want an oracle dependency, commit-reveal works for two-party or small-group randomness:
```solidity
contract CommitReveal {
mapping(address => bytes32) public commits;
mapping(address => uint256) public revealBlock;
uint256 constant REVEAL_DELAY = 10;
function commit(bytes32 hash) external {
commits[msg.sender] = hash;
revealBlock[msg.sender] = block.number + REVEAL_DELAY;
}
function reveal(uint256 secret) external returns (uint256) {
require(block.number >= revealBlock[msg.sender], "too early");
require(
keccak256(abi.encodePacked(secret, msg.sender)) == commits[msg.sender],
"bad reveal"
);
delete commits[msg.sender];
return uint256(keccak256(abi.encodePacked(secret, blockhash(revealBlock[msg.sender]))));
}
}
```
The user commits to a secret before the outcome can be known. They reveal after a future block whose hash is now fixed. Combining `secret` with `blockhash` means neither party alone controls the result. Add a slashing mechanism if a player refuses to reveal, or their commit becomes a loss by default.
What Not To Do
Don't hash multiple block variables together. `keccak256(timestamp, prevrandao, number)` is exactly as predictable as its inputs.
- Don't use `blockhash(block.number - 1)`. It's known the moment the block is mined, and the proposer can choose to withhold.
- Don't use private state as a seed. Storage isn't private — it's just not indexed by Etherscan.
- Don't trust `tx.origin`, `msg.sender`, or `gasleft()` as entropy. All controlled by the caller.
Audit Checklist
Before deploying anything that depends on randomness, run a [free AI audit](https://www.cryptohawking.com/audit) to catch SWC-120 patterns and the common variants. For lotteries, gambling protocols, or anything where weak randomness directly equals stolen funds, get a [manual review](https://www.cryptohawking.com/audit/manual) — automated tools flag the obvious cases, but the subtle ones (reveal-block griefing, oracle callback re-entrancy, subscription draining) need human eyes.
The rule is simple: if the value is on-chain when the decision is made, it's not random. Push randomness off-chain with proofs, or push the commitment off-chain in time. There is no third option.
FAQ
Is block.prevrandao safe to use as randomness after The Merge?
Not for high-value applications. `block.prevrandao` is derived from the beacon chain's RANDAO, which mixes contributions from many validators — much better than pre-merge `block.difficulty`. But the proposer of the next block knows the value one slot in advance and can choose whether to propose their block. For a payout worth more than the block reward (~0.05 ETH plus MEV), it's economically rational for them to skip the block to influence the outcome. Use it for tie-breakers and cosmetic features, never for money.
Why can't I just use a private variable as a random seed?
Because Solidity's `private` keyword only restricts other contracts from reading via the ABI. It does nothing to hide the value from the rest of the world. All contract storage is public on the blockchain — anyone can call `eth_getStorageAt` with the storage slot and read it directly. Tools like Etherscan even decode it for you. Treating private storage as a secret is one of the most common beginner mistakes, and it has cost protocols millions.
How much does Chainlink VRF cost per request?
On Ethereum mainnet, a VRF v2.5 request typically costs 0.25 LINK plus the gas to execute your `fulfillRandomWords` callback. On L2s like Arbitrum or Polygon it's substantially cheaper — often a few cents in LINK. You pre-fund a subscription account so users don't need LINK themselves. For most applications the cost is negligible compared to the value being secured. If your protocol can't afford VRF, it can't afford to be exploited either.
Can I use a commit-reveal scheme for an NFT mint with thousands of users?
Not directly — making thousands of users submit two transactions is terrible UX, and griefing (users refusing to reveal) becomes a coordination nightmare. A common pattern instead: the team commits to a random seed (or merkle root of a shuffled metadata mapping) before mint, then reveals after mint closes. Combine the team's revealed seed with a future block hash to prevent the team from cherry-picking. For per-user randomness during mint, VRF is the standard answer.
What about using an off-chain server to generate randomness and signing it?
It works mechanically — your contract verifies an ECDSA signature from a trusted signer over a nonce — but you've reintroduced a trusted party. If the signer key leaks or the operator is malicious, they can grind outcomes. This is the model many centralized GameFi projects use, and it's fine if users understand the trust assumption. If you want trust-minimized randomness with similar latency, Chainlink VRF or drand are strictly better because the proof is verifiable on-chain.