Timestamp Manipulation Remediation
How to eliminate block.timestamp dependency vulnerabilities by replacing miner-manipulable timestamp randomness and time-locks with Chainlink VRF and block-number-based logic.
Timestamp Manipulation Remediation
Overview
block.timestamp vulnerabilities arise when contracts use the current block timestamp as a source of randomness or as the sole guard for time-sensitive operations. Ethereum validators can set block.timestamp to any value within approximately ±900 seconds (15 minutes) of the real current time without violating consensus rules. This manipulation window is sufficient to determine the outcome of lottery-style timestamp % N randomness, bypass time-locked vesting positions early, or shift the effective window of an auction.
The remediation is to replace block.timestamp randomness with a verifiable random function (Chainlink VRF), and for time-locks, either use block numbers or apply a minimum lock duration that far exceeds the 900-second manipulation window.
Related Detector: Timestamp Dependence Detector
Recommended Fix
Before (Vulnerable)
contract VulnerableLottery {
address[] public players;
uint256 public drawBlock;
function draw() external {
require(block.number >= drawBlock, "Too early");
// VULNERABLE: miner selects block.timestamp to win
uint256 winnerIndex = block.timestamp % players.length;
address winner = players[winnerIndex];
payable(winner).transfer(address(this).balance);
}
}
contract VulnerableVesting {
mapping(address => uint256) public unlockTime;
function claim() external {
// VULNERABLE: miner advances timestamp by up to 900s
require(block.timestamp >= unlockTime[msg.sender], "Still locked");
_release(msg.sender);
}
}
After (Fixed)
import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
contract SafeLottery is VRFConsumerBaseV2 {
VRFCoordinatorV2Interface public coordinator;
bytes32 public keyHash;
uint64 public subscriptionId;
uint16 public constant REQUEST_CONFIRMATIONS = 3;
address[] public players;
mapping(uint256 => bool) public pendingRequest;
function draw() external returns (uint256 requestId) {
// Chainlink VRF: unpredictable randomness, cannot be manipulated
requestId = coordinator.requestRandomWords(
keyHash,
subscriptionId,
REQUEST_CONFIRMATIONS,
200000,
1
);
pendingRequest[requestId] = true;
}
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
require(pendingRequest[requestId], "Unknown request");
delete pendingRequest[requestId];
uint256 winnerIndex = randomWords[0] % players.length;
payable(players[winnerIndex]).transfer(address(this).balance);
}
}
contract SafeVesting {
mapping(address => uint256) public unlockBlock;
uint256 public constant BLOCKS_PER_HOUR = 300; // ~12s blocks
function createVest(address beneficiary, uint256 lockHours) external {
// Block numbers cannot be manipulated by validators
unlockBlock[beneficiary] = block.number + (lockHours * BLOCKS_PER_HOUR);
}
function claim() external {
require(block.number >= unlockBlock[msg.sender], "Still locked");
_release(msg.sender);
}
}
Alternative Mitigations
Commit-reveal scheme — for protocols that cannot integrate Chainlink VRF, a commit-reveal scheme distributes the randomness contribution across multiple transactions. No single party can predict the final result:
contract CommitRevealLottery {
mapping(address => bytes32) public commitments;
uint256 public revealDeadline;
bytes32 private accumulatedSeed;
// Phase 1: Players commit a hash of their secret
function commit(bytes32 commitment) external {
require(block.number < revealDeadline - 100, "Too late to commit");
commitments[msg.sender] = commitment;
}
// Phase 2: Players reveal their secret — each one mixes into the seed
function reveal(bytes32 secret) external {
require(block.number <= revealDeadline, "Reveal period over");
require(commitments[msg.sender] == keccak256(abi.encodePacked(secret)), "Bad reveal");
accumulatedSeed = keccak256(abi.encodePacked(accumulatedSeed, secret));
delete commitments[msg.sender];
}
function draw() external {
require(block.number > revealDeadline, "Reveal not complete");
uint256 winnerIndex = uint256(accumulatedSeed) % players.length;
// ...
}
}
Minimum lock duration buffer — for time-locks where the exact timestamp is less critical, apply a minimum duration of at least one hour. A 900-second manipulation window is irrelevant against a 7-day lock:
uint256 public constant MIN_LOCK_DURATION = 7 days;
function lock(uint256 duration) external {
require(duration >= MIN_LOCK_DURATION, "Lock too short");
unlockTime[msg.sender] = block.timestamp + duration;
}
Auction deadlines with block numbers — for auctions where the end time must be predictable:
contract SafeAuction {
uint256 public endBlock;
constructor(uint256 durationBlocks) {
endBlock = block.number + durationBlocks;
}
function bid() external payable {
require(block.number < endBlock, "Auction ended");
// ...
}
}
Common Mistakes
TWAP period too short for randomness — a Uniswap TWAP is flash-loan resistant but is still predictable over time. Do not use TWAP values as randomness seeds; use Chainlink VRF instead.
Using block.number as randomness — block numbers are sequential and predictable. They eliminate the ±900-second manipulation window but are not a source of randomness.
Using blockhash — blockhash(block.number - 1) is available to miners before the block is finalized and can be manipulated by them. It is only available for the last 256 blocks and is zero for older blocks.
Using block.difficulty / block.prevrandao — on proof-of-stake Ethereum, block.prevrandao provides better randomness than block.difficulty, but it is still controllable by validators with significant stake and is not suitable for high-value applications.