
Testing Solidity Time Locks with Deterministic Timestamp Manipulation
Why time-lock testing matters
Time-based logic is deceptively simple. A contract may work correctly when deployed, yet fail in production because of off-by-one errors, incorrect assumptions about timestamp progression, or missing checks around initialization.
Typical examples include:
- token vesting contracts
- timelocked withdrawals
- delayed admin actions
- auction end times
- cooldown windows for minting or claiming
If you only test “happy path” timestamps, you can miss bugs such as:
- unlocking one second too early
- allowing repeated claims after the deadline
- rejecting valid actions at the exact boundary
- using the wrong time source for comparisons
The goal of time-lock testing is to prove that the contract behaves correctly at the exact moments that matter.
A minimal time-lock contract
Consider a simple contract that locks funds until a specific timestamp. The owner deposits ETH, and the beneficiary can withdraw only after the unlock time.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract TimeLock {
address public immutable beneficiary;
uint256 public immutable unlockTime;
bool public withdrawn;
constructor(address _beneficiary, uint256 _unlockTime) payable {
require(_beneficiary != address(0), "beneficiary required");
require(_unlockTime > block.timestamp, "unlock time must be in future");
beneficiary = _beneficiary;
unlockTime = _unlockTime;
}
function withdraw() external {
require(msg.sender == beneficiary, "not beneficiary");
require(block.timestamp >= unlockTime, "still locked");
require(!withdrawn, "already withdrawn");
withdrawn = true;
payable(beneficiary).transfer(address(this).balance);
}
}This contract has three important behaviors to test:
- deployment rejects invalid unlock times
- withdrawal fails before the deadline
- withdrawal succeeds at or after the deadline, but only once
Choosing a time-control strategy
Different testing environments expose different ways to manipulate time. The right choice depends on your toolchain and how much control you need.
| Strategy | Best for | Notes |
|---|---|---|
evm_increaseTime | Fast-forwarding local chain time | Common in JSON-RPC test runners |
evm_setNextBlockTimestamp | Exact timestamp assertions | Useful for boundary testing |
vm.warp | Foundry tests | Directly sets the next block timestamp |
time.increaseTo | Hardhat tests | High-level helper for timestamp jumps |
For deterministic tests, prefer setting the exact next timestamp rather than incrementing by an approximate duration. That makes boundary conditions much easier to reason about.
Testing the boundary conditions
The most important test cases are usually the ones closest to the deadline. For a timelock, you should verify behavior at:
- one second before unlock
- exactly at unlock
- one second after unlock
That pattern catches off-by-one mistakes immediately.
Example test in Foundry
The following example uses Foundry’s vm.warp cheatcode to control time precisely.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/TimeLock.sol";
contract TimeLockTest is Test {
TimeLock lock;
address beneficiary = address(0xBEEF);
function setUp() public {
uint256 unlockTime = block.timestamp + 1 days;
lock = new TimeLock{value: 1 ether}(beneficiary, unlockTime);
}
function testWithdrawRevertsBeforeUnlock() public {
vm.prank(beneficiary);
vm.expectRevert(bytes("still locked"));
lock.withdraw();
}
function testWithdrawSucceedsAtUnlock() public {
vm.warp(lock.unlockTime());
uint256 balanceBefore = beneficiary.balance;
vm.prank(beneficiary);
lock.withdraw();
assertEq(beneficiary.balance, balanceBefore + 1 ether);
assertTrue(lock.withdrawn());
}
function testWithdrawRevertsAfterSecondAttempt() public {
vm.warp(lock.unlockTime());
vm.prank(beneficiary);
lock.withdraw();
vm.prank(beneficiary);
vm.expectRevert(bytes("already withdrawn"));
lock.withdraw();
}
}This test suite is intentionally small, but it covers the core contract invariants:
- the lock prevents early withdrawal
- the unlock boundary is accepted
- the funds can only be claimed once
Testing exact timestamps, not just durations
A common mistake is to test only relative time jumps, such as “advance by one day.” That can hide bugs if the contract uses >= where it should use >, or vice versa.
Instead, compute the unlock timestamp explicitly and assert against it.
Good practice
- store the target timestamp in a local variable
- warp to
unlockTime - 1 - assert revert
- warp to
unlockTime - assert success
This approach makes the test self-documenting and reduces ambiguity.
Example with exact boundary checks
function testBoundaryBehavior() public {
uint256 unlockTime = lock.unlockTime();
vm.warp(unlockTime - 1);
vm.prank(beneficiary);
vm.expectRevert(bytes("still locked"));
lock.withdraw();
vm.warp(unlockTime);
vm.prank(beneficiary);
lock.withdraw();
assertTrue(lock.withdrawn());
}If this test fails, you know the bug is specifically in the comparison logic around the deadline.
Testing time-dependent initialization
Time-based bugs often begin at deployment. If a contract accepts a future timestamp, it should reject values that are already expired or equal to the current block time when the business rule requires a future-only schedule.
For example:
function testConstructorRejectsPastUnlockTime() public {
vm.expectRevert(bytes("unlock time must be in future"));
new TimeLock(address(0xBEEF), block.timestamp);
}You should also test invalid addresses and any other constructor constraints. Time-lock contracts often become part of larger systems, so deployment safety matters as much as runtime behavior.
Avoiding flaky tests
Time-based tests can become flaky if they rely on real wall-clock delays or approximate timestamp advancement. The blockchain test environment should be fully deterministic.
Avoid these patterns
sleeporsetTimeoutin test code- waiting for actual time to pass
- asserting on “about one day later”
- using the current timestamp without controlling it
Prefer these patterns
- explicit timestamp setup in
setUp - exact
warporsetNextBlockTimestamp - fixed constants for durations
- assertions around boundary values
A deterministic test suite should produce the same result every time, regardless of machine speed or CI load.
Testing multi-step time flows
Many real contracts have more than one time gate. For example, a vesting contract may allow partial claims after a cliff, then periodic releases afterward.
When testing multi-step logic, model the entire timeline as a sequence of checkpoints.
Example timeline
| Time | Expected behavior |
|---|---|
| before cliff | claim reverts |
| at cliff | first claim allowed |
| between periods | partial claim depends on schedule |
| after full vesting | all remaining tokens claimable |
A useful technique is to write one test per milestone instead of one giant test with many assertions. Smaller tests are easier to debug and maintain.
Testing with multiple actors
Time-locks often involve different roles: beneficiary, owner, guardian, or emergency admin. Each actor may have different permissions at different times.
For example, a governance timelock might allow:
- the proposer to queue an action
- anyone to inspect the queued operation
- only the executor to execute after delay
- the admin to cancel before execution
In such cases, combine timestamp manipulation with caller impersonation. That lets you verify both authorization and timing in the same scenario.
function testOnlyBeneficiaryCanWithdrawAfterUnlock() public {
vm.warp(lock.unlockTime());
vm.prank(address(0xCAFE));
vm.expectRevert(bytes("not beneficiary"));
lock.withdraw();
}This kind of test is especially valuable because timing bugs and access-control bugs often interact.
Common pitfalls in time-lock logic
1. Using block.timestamp for precision-sensitive scheduling
block.timestamp is suitable for coarse-grained deadlines, but not for exact real-world timing. Miners or validators have limited flexibility in choosing timestamps, so do not use it for logic that requires second-level external guarantees.
2. Forgetting the one-time execution guard
A timelock that transfers funds should usually mark itself as withdrawn before or during the transfer. Otherwise, repeated calls may drain funds if the contract is not otherwise protected.
3. Mixing duration and absolute timestamp semantics
Some contracts accept a duration like “7 days,” while others accept an absolute unlock timestamp. Tests should reflect the intended meaning clearly. If the constructor accepts a timestamp, do not pass a duration by mistake.
4. Not testing the exact equality case
The difference between > and >= matters. If the contract should unlock at the exact timestamp, test that exact moment explicitly.
Best practices for maintainable time tests
To keep your suite readable and robust:
- define named constants for durations
- centralize timestamp setup in
setUp - test one behavior per function
- include boundary tests for every time gate
- use descriptive revert messages or custom errors
- keep the contract’s time semantics documented in comments or NatSpec
If your contract has several time-dependent functions, consider creating helper methods in the test contract:
function warpToUnlock() internal {
vm.warp(lock.unlockTime());
}
function warpBeforeUnlock() internal {
vm.warp(lock.unlockTime() - 1);
}Helpers reduce duplication and make the intent of each test clearer.
When to use custom errors for time checks
String-based revert messages are easy to understand, but custom errors are more gas-efficient and easier to assert precisely in larger systems. For example:
error StillLocked(uint256 unlockTime);
error AlreadyWithdrawn();
error NotBeneficiary();Then the contract can revert with structured data, and the test suite can validate the exact error type and parameters. This is especially useful when the unlock time is part of the error context.
Summary
Testing Solidity time locks is about more than advancing the clock. The real value comes from verifying exact boundary behavior, deployment safety, and multi-step flows under deterministic conditions.
A strong time-based test suite should:
- control timestamps explicitly
- check behavior before, at, and after deadlines
- verify constructor validation
- test one-time execution and role restrictions
- avoid real-time delays and nondeterministic assumptions
When done well, these tests give you confidence that your contract will behave correctly under the precise timing rules your application depends on.
