
Testing Solidity Time-Dependent Logic with Block Timestamps and Block Numbers
Why time-dependent testing needs special care
Unlike pure business logic, time-based contract behavior depends on the blockchain environment. A function may behave differently before or after a deadline, or only after a certain number of blocks have passed. If tests rely on real wall-clock time, they become slow and flaky. If they assume exact timestamps without controlling the test chain, they can fail unpredictably.
The goal is to make time an explicit test input. Instead of waiting in real time, you advance the local chain to the desired timestamp or block height and assert the contract’s behavior at each boundary.
Common patterns that depend on time
- Timelocks and delayed execution
- Vesting schedules
- Dutch auctions and bidding windows
- Governance voting periods
- Cooldowns and rate limits
- Subscription renewals and grace periods
These are all excellent candidates for deterministic time manipulation in tests.
block.timestamp vs block.number
Solidity exposes two common ways to model time:
| Mechanism | Meaning | Best for | Caveats |
|---|---|---|---|
block.timestamp | Unix timestamp of the current block | Real-world deadlines, vesting, expiration | Miners/validators can influence it slightly within protocol limits |
block.number | Current block height | Relative progression, block-based voting or delays | Not tied to wall-clock time; block times vary by network |
Use block.timestamp when the contract logic is meant to reflect elapsed time in seconds. Use block.number when the logic is intentionally tied to block progression, such as “wait 10 blocks before execution.”
A practical rule: if users would describe the rule using “minutes,” “hours,” or “days,” use timestamps. If the rule is about “after N blocks,” use block numbers.
A minimal example: testing a timelock
Consider a simple contract that allows withdrawal only after a release time.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract TimelockVault {
address public immutable beneficiary;
uint256 public immutable releaseTime;
uint256 public deposited;
constructor(address _beneficiary, uint256 _releaseTime) payable {
require(_beneficiary != address(0), "invalid beneficiary");
require(_releaseTime > block.timestamp, "release time must be future");
beneficiary = _beneficiary;
releaseTime = _releaseTime;
deposited = msg.value;
}
function withdraw() external {
require(msg.sender == beneficiary, "not beneficiary");
require(block.timestamp >= releaseTime, "too early");
uint256 amount = deposited;
deposited = 0;
payable(beneficiary).transfer(amount);
}
}This contract is straightforward, but testing it correctly requires controlling the chain timestamp.
What to verify
- Withdrawal fails before
releaseTime - Withdrawal succeeds at or after
releaseTime - Funds cannot be withdrawn twice
- Only the beneficiary can withdraw
The key is to test both sides of the boundary, not just the “happy path.”
How to advance time in tests
Most Solidity testing frameworks provide utilities to manipulate the local chain. The exact API differs, but the concepts are the same:
- Set the next block timestamp
- Mine a new block
- Increase block number
- Warp time forward
- Roll to a specific block
The table below summarizes the most common operations:
| Goal | Typical action | Why it matters |
|---|---|---|
| Move time forward | Warp to a future timestamp | Simulates deadlines and expirations |
| Trigger state update | Mine a block after changing time | Ensures the new timestamp is actually used |
| Move block height | Roll to a later block number | Tests block-based logic |
| Test exact boundary | Warp to releaseTime or deadline | Catches off-by-one errors |
Example test flow
A robust time-based test usually follows this pattern:
- Deploy contract with a future timestamp
- Assert the action fails immediately
- Advance time to just before the boundary
- Assert it still fails
- Advance to the exact boundary
- Assert it succeeds
- Assert the state cannot be reused
That sequence catches many subtle bugs that a single success test would miss.
Boundary testing: the most important part
Time bugs often appear at the edges. If a function should be callable “after 1 hour,” you need to test both 59 minutes 59 seconds and 60 minutes 0 seconds.
Useful boundary cases
- Exactly at the deadline
- One second before the deadline
- One second after the deadline
- First eligible block
- One block before eligibility
- Repeated calls after success
These tests are especially important when using comparisons like:
block.timestamp >= deadlineblock.timestamp > deadlineblock.number >= startBlock + delay
A single character in the comparison operator changes behavior at the boundary. Tests should make that explicit.
Avoiding off-by-one mistakes
If your contract uses >=, then the action should be allowed at the exact timestamp. If it uses >, then it should only be allowed after that timestamp. Choose deliberately and document the rule in tests.
For example, if a governance vote ends at endTime, should a vote cast at exactly endTime be valid? Your tests should answer that question unambiguously.
Testing block-number-based logic
Some contracts use block numbers instead of timestamps. This is common in protocols that want deterministic progression independent of wall-clock time.
Example: a function becomes available 20 blocks after deployment.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract BlockDelay {
uint256 public immutable startBlock;
uint256 public constant DELAY = 20;
constructor() {
startBlock = block.number;
}
function execute() external view returns (bool) {
require(block.number >= startBlock + DELAY, "too early");
return true;
}
}What to test
- The function reverts before
startBlock + DELAY - The function succeeds at
startBlock + DELAY - The function remains callable after the threshold
Because block numbers are discrete, the boundary is simpler than timestamps, but the same off-by-one risks still apply.
When block numbers are preferable
- You need a fixed number of confirmations
- You want relative progression, not real time
- You are modeling protocol phases that advance per block
However, avoid using block numbers to represent user-facing time delays like “24 hours.” On networks with variable block times, that becomes misleading.
Best practices for deterministic time tests
1. Use explicit constants
Hard-coded magic numbers make tests harder to understand. Prefer named constants for durations.
uint256 constant ONE_DAY = 1 days;
uint256 constant THREE_DAYS = 3 days;This makes intent clear and reduces mistakes when calculating future timestamps.
2. Test both success and failure paths
A time-based feature is incomplete if you only test the valid time window. Always include:
- Too early
- Exactly on time
- Too late
3. Mine a block after changing time
Changing the timestamp alone is not enough in many test environments. The contract reads the timestamp from the current block, so you must ensure a new block is produced after the time change.
4. Keep tests independent
Each test should set up its own time state. Do not rely on a previous test having advanced the chain. Shared mutable chain state is a common source of flaky tests.
5. Prefer local chain control over real waiting
Never use sleep, timers, or real delays in automated tests. They slow down CI and do not improve correctness.
A practical test matrix
For a contract with a deadline, a compact test matrix can cover the important cases:
| Case | Timestamp relative to deadline | Expected result |
|---|---|---|
| Too early | deadline - 1 | Revert |
| Exact boundary | deadline | Succeed or revert, depending on design |
| After deadline | deadline + 1 | Succeed or revert, depending on design |
| Reuse after success | Any later time | Revert if one-time action |
This matrix is small but effective. It forces you to define the contract’s exact temporal semantics.
Testing time-dependent state transitions
Time-based contracts often change state when a deadline passes. For example, a vesting contract might move from “locked” to “claimable,” or an auction might move from “open” to “ended.”
In these cases, you should test not only the external behavior but also the internal state transitions.
Example assertions
claimable == trueafter the unlock timeauctionEnded == trueafter the auction deadlinewithdrawn == trueafter successful withdrawalremainingAmount == 0after payout
If the contract caches state based on time, verify that the cached state updates correctly when the chain advances.
Beware of lazy evaluation
Some contracts do not store a “current phase” variable. Instead, they compute the phase on demand from block.timestamp. That is fine, but your tests must still verify the computed phase at multiple timestamps.
Handling time in integration tests
In integration tests, time manipulation often interacts with multiple contracts. For example:
- A token vesting contract may depend on ERC20 balances
- A governance module may depend on proposal lifecycle and voting power snapshots
- A marketplace may depend on auction timing and bid settlement
In these scenarios, time should be advanced only after the surrounding setup is complete. Otherwise, you may accidentally test a different state than intended.
Recommended sequence
- Deploy all contracts
- Set up balances, approvals, or roles
- Advance time or blocks
- Trigger the time-sensitive action
- Assert the resulting cross-contract effects
This order keeps the test readable and reduces accidental coupling between setup and timing.
Common mistakes to avoid
Using the wrong time source
Do not use block.timestamp when you really mean “after N confirmations.” Likewise, do not use block.number to represent a 24-hour delay.
Forgetting the boundary
Testing only deadline + 1 misses off-by-one errors. Always test the exact threshold.
Assuming timestamps are perfectly stable
On public networks, timestamps can vary slightly. Contracts should tolerate reasonable timing variation, and tests should not assume exact real-world alignment beyond what the local chain provides.
Relying on previous test state
Each test should be self-contained. If one test advances time, reset or redeploy before the next test.
Ignoring repeated execution
After a time-gated action succeeds once, test whether it can be called again. Many bugs appear only after the first successful call.
A concise checklist for time-based Solidity tests
Before shipping a time-dependent contract, confirm that your tests cover:
- The initial precondition before time advances
- The exact threshold condition
- The post-threshold behavior
- Repeated calls after success
- State changes caused by the time transition
- Both timestamp-based and block-based semantics, if applicable
If all of these are covered, your tests are much more likely to catch real production issues.
Conclusion
Testing time-dependent Solidity logic is mostly about precision and discipline. Treat time as a controlled input, not a background assumption. Use timestamps for real-world deadlines, block numbers for protocol progression, and always test the boundary conditions where most bugs occur.
Well-designed time tests are deterministic, fast, and expressive. They document contract behavior as clearly as the code itself, which makes future maintenance much safer.
