Over the past two days we reviewed six real, deployed contracts — lotteries, raffles, and a "lossless" vault, all holding or moving real money on mainnet. Six different codebases, six different teams. And all six fail the same way: they pick a winner using values that the block builder controls.
This isn't a theoretical nitpick. On a single-sequencer L2 — or on any chain where a builder can see pending transactions and choose block contents — every one of these is steerable. The builder can try candidate blocks until the "random" result lands where they want it, and pocket the pot.
The flaw is always the same shape: keccak256(block-controlled values) % N, where "block-controlled values" means block.prevrandao, blockhash(block.number - k), or block.timestamp. None of these is random. All of them are chosen by — or predictable to — whoever proposes the block.
Rule of thumb: if your winner is derived from anything in block.*, it's not a lottery, it's a donation to the builder.
| Variant | What it draws from | Why it breaks |
|---|---|---|
| 1. The kitchen-sink hash | prevrandao + timestamp + msg.sender + block number | Every term is builder-visible or builder-set; no term is secret. |
| 2. The history guess | blockhash(block.number-3), -7, -13 | Past block hashes are fully known at draw time — not random, just another input. |
| 3. The truncation trick | blockhash(block.number-1) & 0xFFFF plus prevrandao | Masking to 16 bits shrinks the space a builder has to search to steer it. |
| 4. The ignored oracle | Draw accepts a caller-supplied index and ignores the VRF result | The "randomness" is an argument, not a source — anyone can redirect the payout. |
| 5. The permissionless settle | keccak256(prevrandao, timestamp, …), anyone can call settle | No authority and no delay: a builder both picks the block and triggers the draw. |
| 6. The uncooled jackpot | keccak256(…) % 10000 with a mega-jackpot branch and no cooldown | A 50%-of-vault prize behind a 20-in-10,000 roll, retryable every block. |
Each is a different codebase, but the failure mode is one family. Three of them are pure "builder can steer the outcome." The fourth is worse — the random input is discarded entirely. The sixth compounds the bias with an outsized prize and no rate limit, so a patient builder gets repeated, cheap shots at a jackpot.
Note what these are not: they're not "the contract can be drained by anyone." Most of them still only pay the declared winner. The theft is subtler — the winner is chosen by the attacker, through block selection rather than code execution. That's why these stay live longer than a straightforward reentrancy bug: the exploit leaves no obvious trace in a single transaction. It's a series of perfectly ordinary blocks.
block.*, and never let the same call both trigger and settle the draw.If you're not willing to add a VRF or a commit-reveal phase, the honest engineering answer is to make the stakes small enough that steering isn't worth a builder's block. A fun micro-raffle with a bounded pot is a different risk profile than a vault that settles six figures from block.prevrandao.
Disclosure note. We report these patterns without naming the projects, per our policy: the point is the defect class, not the victim. Findings are shared with maintainers first. This post is the anonymized summary of a real review pass.