How To Build Timelock Smart Contracts
This technical tutorial will teach you what timelock smart contracts (or time-based smart contracts) are and how to build them. You will make a smart contract that enables queueing ERC-20 token minting into a time-based window.
This tutorial will use:
Code for this tutorial can be found in our examples GitHub
What Is a Timelock Smart Contract?
At its core, a timelock is an additional piece of code that restricts functionality within a smart contract to a specific window of time. The simplest form could look something like this simple “if” statement:
if (block.timestamp < _timelockTime) { revert ErrorNotReady(block.timestamp, _timelockTime); }
Timelock Contract Use Cases
There are many potential use cases for timelock contracts. They are commonly used within initial coin offerings (ICOs) to help power the crowd-funded sale of tokens. They can also be used to implement a vesting schedule, with users able to withdraw their funds once a set period of time has passed.
Another possible use is to function as a form of will based on a smart contract. Using Chainlink Automation, you can periodically check on the owner of the will and, once a death certificate has been filed, the smart contract for the will could unlock.
These are just a few examples, but the possibilities are endless. In this example, we will focus on creating an ERC-20 contract that enforces a timelock queue to mint coins.
How To Create a Timelock Smart Contract
In this tutorial, we will use Foundry to build and test our time-based smart contract in Solidity. You can find more details on the foundry GitHub.
Initialize the Project
You will initialize the project using forge init
. Once the project is initialized, forge test
will act as a sanity check to ensure everything is set up correctly.
❯ forge init timelocked-contract Initializing /Users/rg/Development/timelocked-contract... Installing ds-test in "/Users/rg/Development/timelocked-contract/lib/ds-test", (url: https://github.com/dapphub/ds-test, tag: None) Installed ds-test Initialized forge project. ❯ cd timelocked-contract ❯ forge test [⠒] Compiling... [⠰] Compiling 3 files with 0.8.10 [⠔] Solc finished in 143.06ms Compiler run successful Running 1 test for src/test/Contract.t.sol:ContractTest [PASS] testExample() (gas: 190) Test result: ok. 1 passed; 0 failed; finished in 469.71µs
Create Your Tests
You will create tests to ensure the contract meets all the requirements for timelocking. The main functionality you will be checking is the ability to:
- Enqueue token minting
- Mint once the window is reached
- Cancel a queued mint
In addition to these functions, you will also ensure the contract doesn’t allow misuses such as double queueing or minting without queueing first.
Once the project is initialized, you will be able to run tests. You will use tests to ensure that your project is working as expected. The tests belong in src/test/Contract.t.sol
. Foundry uses test names to indicate if they should succeed or fail. testThisShouldWork
would be expected to pass while testFailShouldNotWork
passes if the test reverts. This enables us to test cases that should and shouldn’t pass.
There are also a few conventions to explain first. The timelock is based upon a queue that will use a hash of the _toAddress
, _amount
, and time
parameters. These values will be hashed using keccak256
.
// Create hash of transaction data for use in the queue function generateTxnHash( address _to, uint256 _amount, uint256 _timestamp ) public pure returns (bytes32) { return keccak256(abi.encode(_to, _amount, _timestamp)); }
Additionally, you will need to manipulate the time of the test blockchain to simulate time passing. This is achieved via Foundry CheatCodes
.
interface CheatCodes { function warp(uint256) external; }
Warp allows you to manipulate the current block timestamp. The function takes in a uint for the new timestamp. We will use it to “add time” to our current time, simulating the passage of time. This will allow us to create a suite of tests to ensure that our timelock is functioning as desired.
Replace the contents of src/test/Contract.t.sol
with the following:
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.10; import "ds-test/test.sol"; import "../Contract.sol"; interface CheatCodes { function warp(uint256) external; } contract ContractTest is DSTest { // HEVM_ADDRESS is the pre-defined contract that contains the cheatcodes CheatCodes constant cheats = CheatCodes(HEVM_ADDRESS); Contract public c; address toAddr = 0x1234567890123456789012345678901234567890; function setUp() public { c = new Contract(); c.queueMint( toAddr, 100, block.timestamp + 600 ); } // Ensure you can't double queue function testFailDoubleQueue() public { c.queueMint( toAddr, 100, block.timestamp + 600 ); } // Ensure you can't queue in the past function testFailPastQueue() public { c.queueMint( toAddr, 100, block.timestamp - 600 ); } // Minting should work after the time has passed function testMintAfterTen() public { uint256 targetTime = block.timestamp + 600; cheats.warp(targetTime); c.executeMint( toAddr, 100, targetTime ); } // Minting should fail if you mint too soon function testFailMintNow() public { c.executeMint( toAddr, 100, block.timestamp + 600 ); } // Minting should fail if you didn't queue function testFailMintNonQueued() public { c.executeMint( toAddr, 999, block.timestamp + 600 ); } // Minting should fail if try to mint twice function testFailDoubleMint() public { uint256 targetTime = block.timestamp + 600; cheats.warp(block.timestamp + 600); c.executeMint( toAddr, 100, targetTime ); c.executeMint( toAddr, 100, block.timestamp + 600 ); } // Minting should fail if you try to mint too late function testFailLateMint() public { uint256 targetTime = block.timestamp + 600; cheats.warp(block.timestamp + 600 + 1801); emit log_uint(block.timestamp); c.executeMint( toAddr, 100, targetTime ); } // you should be able to cancel a mint function testCancelMint() public { bytes32 txnHash = c.generateTxnHash( toAddr, 100, block.timestamp + 600 ); c.cancelMint(txnHash); } // you should be able to cancel a mint once but not twice function testFailCancelMint() public { bytes32 txnHash = c.generateTxnHash( toAddr, 999, block.timestamp + 600 ); c.cancelMint(txnHash); c.cancelMint(txnHash); } // you shouldn't be able to cancel a mint that doesn't exist function testFailCancelMintNonQueued() public { bytes32 txnHash = c.generateTxnHash( toAddr, 999, block.timestamp + 600 ); c.cancelMint(txnHash); } }
Building the Contract
You should now be able to run forge test
and see many errors. Now it’s time to make your tests pass.
We will start with a basic ERC-20 contract. All of this work belongs in src/Contract.sol
.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract Contract is ERC20, Ownable { constructor() ERC20("TimeLock Token", "TLT") {} }
To use the OpenZeppelin contracts, you will need to install them and point Foundry to them.
To install the contracts, run
❯ forge install openzeppelin/openzeppelin-contracts
You will also need to map the imports by creating remappings.txt
.
@openzeppelin/=lib/openzeppelin-contracts/ ds-test/=lib/ds-test/src/
This remapping file lets you use things like OpenZeppelin contracts and import them in the way you would typically use other tools such as Hardhat or Remix. This file remaps the import to the directory where they are housed. I’ve also installed the OpenZeppelin contracts via forge install openzeppelin/openzeppelin-contracts
. These will be used to create the ERC-721 contract.
If everything is working correctly, you can run forge build
to compile the contract.
At this point, you can build out the contract below. This contract will allow you to queue a mint and return to execute that mint during the proper window.
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.10; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract Contract is ERC20, Ownable { // Error Messages for the contract error ErrorAlreadyQueued(bytes32 txnHash); error ErrorNotQueued(bytes32 txnHash); error ErrorTimeNotInRange(uint256 blockTimestmap, uint256 timestamp); error ErrorNotReady(uint256 blockTimestmap, uint256 timestamp); error ErrorTimeExpired(uint256 blockTimestamp, uint256 expiresAt); // Queue Minting Event event QueueMint( bytes32 indexed txnHash, address indexed to, uint256 amount, uint256 timestamp ); // Mint Event event ExecuteMint( bytes32 indexed txnHash, address indexed to, uint256 amount, uint256 timestamp ); // Cancel Mint Event event CancelMint(bytes32 indexed txnHash); // Constants for minting window uint256 public constant MIN_DELAY = 60; // 1 minute uint256 public constant MAX_DELAY = 3600; // 1 hour uint256 public constant GRACE_PERIOD = 1800; // 30 minutes // Minting Queue mapping(bytes32 => bool) public mintQueue; constructor() ERC20("TimeLock Token", "TLT") {} // Create hash of transaction data for use in the queue function generateTxnHash( address _to, uint256 _amount, uint256 _timestamp ) public pure returns (bytes32) { return keccak256(abi.encode(_to, _amount, _timestamp)); } // Queue a mint for a given address amount, and timestamp function queueMint( address _to, uint256 _amount, uint256 _timestamp ) public onlyOwner { // Generate the transaction hash bytes32 txnHash = generateTxnHash(_to, _amount, _timestamp); // Check if the transaction is already in the queue if (mintQueue[txnHash]) { revert ErrorAlreadyQueued(txnHash); } // Check if the time is in the range if ( _timestamp < block.timestamp + MIN_DELAY || _timestamp > block.timestamp + MAX_DELAY ) { revert ErrorTimeNotInRange(_timestamp, block.timestamp); } // Queue the transaction mintQueue[txnHash] = true; // Emit the QueueMint event emit QueueMint(txnHash, _to, _amount, _timestamp); } // Execute a mint for a given address, amount, and timestamp function executeMint( address _to, uint256 _amount, uint256 _timestamp ) external onlyOwner { // Generate the transaction hash bytes32 txnHash = generateTxnHash(_to, _amount, _timestamp); // Check if the transaction is in the queue if (!mintQueue[txnHash]) { revert ErrorNotQueued(txnHash); } // Check if the time has passed if (block.timestamp < _timestamp) { revert ErrorNotReady(block.timestamp, _timestamp); } // Check if the window has expired if (block.timestamp > _timestamp + GRACE_PERIOD) { revert ErrorTimeExpired(block.timestamp, _timestamp); } // Remove the transaction from the queue mintQueue[txnHash] = false; // Execute the mint mint(_to, _amount); // Emit the ExecuteMint event emit ExecuteMint(txnHash, _to, _amount, _timestamp); } // Cancel a mint for a given transaction hash function cancelMint(bytes32 _txnHash) external onlyOwner { // Check if the transaction is in the queue if (!mintQueue[_txnHash]) { revert ErrorNotQueued(_txnHash); } // Remove the transaction from the queue mintQueue[_txnHash] = false; // Emit the CancelMint event emit CancelMint(_txnHash); } // Mint tokens to a given address function mint(address to, uint256 amount) internal { _mint(to, amount); } }
Where To Go From Here
Timelock contracts are powerful by themselves. They provide safety measures and transparency to transactions within smart contracts. They don’t work on their own, though. You will need to come back and execute functions within the proper window—unless you automate your contracts.
Chainlink Automation allow you to automate function calls within smart contracts. This would enable you to create functions queued to automatically execute within a predefined window, eliminating the risk of missing the execution window. To find out more, head to Automation Documentation
If you’re a developer and want to integrate Chainlink into your smart contract applications, check out the blockchain education hub, developer documentation or reach out to an expert. You can also dive right into hooking up your smart contracts to real world data via decentralized oracles.