How To Build Timelock Smart Contracts

This technical tutorial will teach you what timelock 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 Keepers, 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 Solidity contract. 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 Keepers 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 Keepers 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.