Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/TimeLock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// Minimal timelock: queue a call, wait `delay`, then execute. Single admin.
/// Used to practice testing block.timestamp manipulation with vm.warp.
contract TimeLock {
address public admin;
uint256 public immutable delay;

mapping(bytes32 => uint256) public queuedAt; // 0 means not queued

event Queued(bytes32 indexed id, address target, uint256 value, bytes data, uint256 eta);
event Executed(bytes32 indexed id, address target, uint256 value, bytes data);
event Cancelled(bytes32 indexed id);

error NotAdmin();
error AlreadyQueued();
error NotQueued();
error TooEarly(uint256 eta, uint256 now_);
error CallFailed(bytes returndata);

modifier onlyAdmin() {
if (msg.sender != admin) revert NotAdmin();
_;
}

constructor(address admin_, uint256 delay_) {
admin = admin_;
delay = delay_;
}

function hashOp(address target, uint256 value, bytes calldata data, bytes32 salt) public pure returns (bytes32) {
return keccak256(abi.encode(target, value, data, salt));
}

function queue(address target, uint256 value, bytes calldata data, bytes32 salt) external onlyAdmin returns (bytes32 id) {
id = hashOp(target, value, data, salt);
if (queuedAt[id] != 0) revert AlreadyQueued();
uint256 eta = block.timestamp + delay;
queuedAt[id] = eta;
emit Queued(id, target, value, data, eta);
}

function cancel(bytes32 id) external onlyAdmin {
if (queuedAt[id] == 0) revert NotQueued();
delete queuedAt[id];
emit Cancelled(id);
}

function execute(address target, uint256 value, bytes calldata data, bytes32 salt) external payable onlyAdmin returns (bytes memory) {
bytes32 id = hashOp(target, value, data, salt);
uint256 eta = queuedAt[id];
if (eta == 0) revert NotQueued();
if (block.timestamp < eta) revert TooEarly(eta, block.timestamp);

delete queuedAt[id];
(bool ok, bytes memory ret) = target.call{value: value}(data);
if (!ok) revert CallFailed(ret);
emit Executed(id, target, value, data);
return ret;
}

receive() external payable {}
}
123 changes: 123 additions & 0 deletions test/TimeLock.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {TimeLock} from "../src/TimeLock.sol";
import {Counter} from "../src/Counter.sol";

contract TimeLockTest is Test {
TimeLock public timelock;
Counter public counter;
address admin = makeAddr("admin");
address attacker = makeAddr("attacker");
uint256 constant DELAY = 2 days;

function setUp() public {
timelock = new TimeLock(admin, DELAY);
counter = new Counter();
}

function _setNumberCalldata(uint256 n) internal pure returns (bytes memory) {
return abi.encodeWithSelector(Counter.setNumber.selector, n);
}

function test_QueueThenExecute() public {
bytes memory data = _setNumberCalldata(42);
bytes32 salt = bytes32(uint256(1));

vm.prank(admin);
timelock.queue(address(counter), 0, data, salt);

vm.warp(block.timestamp + DELAY);

vm.prank(admin);
timelock.execute(address(counter), 0, data, salt);

assertEq(counter.number(), 42);
}

function test_Execute_RevertWhenTooEarly() public {
bytes memory data = _setNumberCalldata(1);
bytes32 salt = bytes32(uint256(1));

vm.prank(admin);
timelock.queue(address(counter), 0, data, salt);

// 1 second before eta
vm.warp(block.timestamp + DELAY - 1);

vm.expectRevert(); // TooEarly
vm.prank(admin);
timelock.execute(address(counter), 0, data, salt);
}

function test_Execute_RevertWhenNotQueued() public {
vm.expectRevert(TimeLock.NotQueued.selector);
vm.prank(admin);
timelock.execute(address(counter), 0, _setNumberCalldata(1), bytes32(0));
}

function test_Queue_RevertOnDuplicate() public {
bytes memory data = _setNumberCalldata(1);
bytes32 salt = bytes32(uint256(7));

vm.startPrank(admin);
timelock.queue(address(counter), 0, data, salt);
vm.expectRevert(TimeLock.AlreadyQueued.selector);
timelock.queue(address(counter), 0, data, salt);
vm.stopPrank();
}

function test_Cancel_ClearsQueue() public {
bytes memory data = _setNumberCalldata(1);
bytes32 salt = bytes32(uint256(1));

vm.prank(admin);
bytes32 id = timelock.queue(address(counter), 0, data, salt);

vm.prank(admin);
timelock.cancel(id);

assertEq(timelock.queuedAt(id), 0);

vm.warp(block.timestamp + DELAY);
vm.expectRevert(TimeLock.NotQueued.selector);
vm.prank(admin);
timelock.execute(address(counter), 0, data, salt);
}

function test_OnlyAdmin_CanQueue() public {
vm.expectRevert(TimeLock.NotAdmin.selector);
vm.prank(attacker);
timelock.queue(address(counter), 0, _setNumberCalldata(1), bytes32(0));
}

function test_OnlyAdmin_CanExecute() public {
bytes memory data = _setNumberCalldata(1);
bytes32 salt = bytes32(uint256(1));

vm.prank(admin);
timelock.queue(address(counter), 0, data, salt);

vm.warp(block.timestamp + DELAY);

vm.expectRevert(TimeLock.NotAdmin.selector);
vm.prank(attacker);
timelock.execute(address(counter), 0, data, salt);
}

function testFuzz_ExecuteAtAnyTimeAfterEta(uint256 wait) public {
wait = bound(wait, DELAY, DELAY + 365 days);
bytes memory data = _setNumberCalldata(99);
bytes32 salt = bytes32(uint256(1));

vm.prank(admin);
timelock.queue(address(counter), 0, data, salt);

vm.warp(block.timestamp + wait);

vm.prank(admin);
timelock.execute(address(counter), 0, data, salt);
assertEq(counter.number(), 99);
}
}
Loading