From b87149588b8fcba15b5ad543eed9e73a12d9323b Mon Sep 17 00:00:00 2001 From: Oliver Anyanwu Date: Sun, 17 May 2026 12:30:12 +0100 Subject: [PATCH] feat: add TimeLock contract with queue/execute/cancel Single-admin timelock used to practice vm.warp timing tests. Custom errors throughout (NotAdmin / AlreadyQueued / NotQueued / TooEarly / CallFailed). Operation ID hashes (target, value, data, salt) so the same call can be queued multiple times via distinct salts. --- src/TimeLock.sol | 64 +++++++++++++++++++++++ test/TimeLock.t.sol | 123 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/TimeLock.sol create mode 100644 test/TimeLock.t.sol diff --git a/src/TimeLock.sol b/src/TimeLock.sol new file mode 100644 index 0000000..9936d8e --- /dev/null +++ b/src/TimeLock.sol @@ -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 {} +} diff --git a/test/TimeLock.t.sol b/test/TimeLock.t.sol new file mode 100644 index 0000000..3785512 --- /dev/null +++ b/test/TimeLock.t.sol @@ -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); + } +}