Solidity Timelocks for Dummies

Solidity Timelocks for Dummies

Timelocks in Solidity are smart contract mechanisms designed to restrict the execution of certain smart contract functions until a specified time has passed.

This feature is crucial for various applications such as governance mechanisms in DAOs, vesting schedules in DeFi, and security measures.

This article aims to provide a comprehensive understanding of timelocks, how they can be used by using one of the Openzeppelin library contracts, and the security risks involved, by building upon a simple Kickstarter contract to understand the concepts.

Where are they used?

In the context of smart contracts, timelocks are implemented to ensure that certain actions are delayed until a predetermined time.

They would be used in situations such as

  • Delayed Governance DecisionsDAOs can use TimeLock contracts to introduce time-bound constraints on governance proposals. This allows members enough time to review the proposal, preventing hasty decisions and enhancing the security and transparency of governance processes.
  • Secure Fund Transfers: By scheduling transfers with Timelock, users can ensure that funds are released only after a specified waiting period. This reduces the risk of unauthorized or accidental transfers, providing an additional layer of security.
  • Smart Contract Upgrades: Timelock contracts can be employed to schedule upgrades or changes to existing smart contracts. This provides a safety window for potential rollback in case of unforeseen issues, ensuring that upgrades are only implemented after thorough review and testing.
  • Vesting and Token Lock-ups: Timelocks can be used to manage the release of tokens over time usually in an Initial Coin Offering (ICO) scenario. It's helpful when you want to spread out the distribution of tokens, to make sure the project can succeed long-term without being affected by immediate cash needs.

Understanding Timelocks

A timelock is a piece of code that checks if an action defined in the contract can be performed after a specific time condition is met.

It uses block.timestamp global variable that the solidity provides to check if the predefined time conditions are met. block.timestamp returns a uint256 value of the current block’s timestamp in seconds since the UNIX epoch (i.e. January 1, 1970).

Note: now was a global variable that was used as block.timestamp is depreciated after solidity version 0.7.0.

Token Transfer (Kickstarter example).

Let's take the well-known Kickstarter contract to implement a timelock mechanism and build upon it.

Kickstarter is a crowdfunding platform where people can financially support a project they believe in. The person who initiated the project (the contract owner) can then withdraw the raised funds to begin working on the project.

So, the initial contract will contain the following functionality:

  1. Create a function to fund ETH into the contract.
  2. Handle basic errors effectively.
  3. The constructor should include the contract owner as one of its parameters and save the owner in a state variable.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @title Kickstarter Contract
contract Kickstarter {
    /// @dev Error type for unauthorized actions.
    error Kickstarter__Unauthorised(string message);
    /// @dev Error type for insufficient funds.
    error Kickstarter__InsufficientFunds(string message);

    /// @notice The owner of the contract.
    address public s_owner;

    /// @notice Constructs the Kickstarter contract.
    /// @param _owner The address of the contract owner.
    constructor(address _owner) {
        s_owner = _owner;
    }

    /// @notice Allows users to fund the project.
    /// @dev Requires the sent amount to be at least 1 ETH.
    function fund() public payable {
        uint256 minimumAmount = 1*10**18; // 1 ETH in Wei

        if (msg.value < minimumAmount) {
            revert Kickstarter__InsufficientFunds(
                "The amount sent is less than the minimum required."
            );
        }
    }
}        

Let's introduce a feature that allows the owner to withdraw funds only after the contract has been deployed on the blockchain for 10 days.

Solidity offers the ability to define time units such as seconds, minutes, hours, days, and weeks.

However, it's more accurate to define the time units in seconds. This is because not every year consists of 365 days and not every day has 24 hours due to leap seconds.

To convert time to seconds:

10 days = 10 * 24 hours = 10 * 24 * 60 minutes = 10 * 24 * 60 * 60 seconds

Therefore, 10 days = 864,000 seconds

Now, let's define the 10 days as a constant in the contract.

uint256 public constant releaseTimeInSeconds = block.timestamp + 864000 seconds;        

Let's define a custom error rather than writing a require statement. This approach is more gas-efficient and makes the code easier to read.

  error Kickstarter__Locked(string message);        

Now, let's implement the withdraw(). This function takes into account the following scenarios:

  1. Only the contract's owner can call the withdraw().
  2. The contract call is reversed if the function is invoked before the releaseTimeInSeconds.

    function withdraw() public {
        if(block.timestamp >= i_releaseTimeInDays){
            revert Kickstarter__Locked("Funds are locked until the release time.");
        }

        if(msg.sender != s_owner){
            revert Kickstarter__Unauthorised("Only the owner can withdraw funds.");
        }

        (bool success, ) = s_owner.call{value: address(this).balance}("");
        require(success, "Transfer failed");
    }        

We have now modified the withdraw() function to prevent the contract owner from withdrawing funds before the specified time interval.

Article content
Image describing the working of timelocks in the Kickstarter Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @title Kickstarter Contract
/// @notice A contract for funding projects with a minimum amount of ETH.
contract Kickstarter {
    /// @dev Error type for unauthorized actions.
    error Kickstarter__Unauthorised(string message);
    /// @dev Error type for insufficient funds.
    error Kickstarter__InsufficientFunds(string message);
    /// @dev Error type for locked funds.
    error Kickstarter__Locked(string message);

    /// @notice The time when the project can be released, calculated from the contract deployment.
    uint256 public immutable i_releaseTimeInSeconds = block.timestamp + 864000 seconds;

    /// @notice The owner of the contract.
    address public s_owner;

    /// @notice Constructs the Kickstarter contract.
    /// @param _owner The address of the contract owner.
    constructor(address _owner) {
        s_owner = _owner;
    }

    /// @notice Allows users to fund the project.
    /// @dev Requires the sent amount to be at least 1 ETH.
    function fund() public payable {
        uint256 minimumAmount = 1*10**18; // 1 ETH in Wei

        if (msg.value < minimumAmount) {
            revert Kickstarter__InsufficientFunds(
                "The amount sent is less than the minimum required."
            );
        }
    }

    /// @notice Allows the owner to withdraw funds.
    /// @dev Requires the current time to be past the release time and the caller to be the owner.
    function withdraw() public {
        if (block.timestamp >= i_releaseTimeInSeconds) {
            revert Kickstarter__Locked(
                "Funds are locked until the release time."
            );
        }

        if (msg.sender != s_owner) {
            revert Kickstarter__Unauthorised(
                "Only the owner can withdraw funds."
            );
        }

        (bool success, ) = s_owner.call{value: address(this).balance}("");
        require(success, "Transfer failed");
    }
}
        

Openzeppelin’s TimelockController Contract.

OpenZeppelin's contract library provides battle-tested code with robust functionalities that can be incorporated into the contracts you are developing.

One such contract is TimelockController.sol. It can be found here and can be set as the owner of another contract. This provides access control functionality along with timelock features.

This contract stipulates that major decisions, such as contract upgrades, changes to important state variables, or updates to the fee structure, can only be made after a specified amount of time. This approach gives other contract users sufficient time to make informed decisions.

Major features

The TimelockedController.sol smart contract is designed with several key features to ensure secure and controlled execution of operations. These features include:

  • Role-Based Access Control: The constructor of TimelockedController.sol assigns specific roles such as admin, provider, executor, and canceller to different addresses. This role-based access control system ensures that only authorized addresses can perform certain actions, enhancing the security and governance of the contract.
  • Proposer The proposers are in charge of scheduling (and cancelling) operations. This is a critical role, that should be given to governing entities. This could be an Externally Owned Account(EOA), a multi-sig wallet, or a DAO.
  • Executor The executors are in charge of executing the operations scheduled by the proposers once the timelock expires. Logic dictates that multi-sig wallets or DAOs that are proposers should also be executors in order to guarantee operations that have been scheduled will eventually be executed.Canceller: The cancellers are in charge of canceling the proposed operations by the proposer.
  • Scheduling and Batching of Operations: The contract supports the scheduling and batching of operations. This feature allows for the execution of one or multiple subsequent calls atomically, depending on the requirement. This is particularly useful for operations that need to be executed in a specific order or as a group to maintain the integrity of the system.
  • Salting and Hashing of Operations: To ensure the uniqueness and security of operations, the contract employs salting and hashing mechanisms. Salting involves using a random value to differentiate identical operations, while hashing ensures that the operation's details are securely encoded. This approach prevents unauthorized or malicious modifications to the operations.
  • Security Mechanisms: The contract incorporates various security mechanisms to protect against unauthorized access and potential attacks. These mechanisms include timelocks, which delay the execution of critical functions, providing a window for review and potential intervention by stakeholders. Additionally, the contract utilizes access control features to restrict the execution of sensitive operations to designated roles, further enhancing the security posture of the system.

Interfacing TimelockController with Kickstarter

Let's take the example of the Kickstarter Contract. Let's make the contract address of TimelockController.sol as the owner of the Kickstarter.sol

Article content
Image descibing access control features of TimelockController.sol

So TimelockController.sol becomes the CONTROLLER contract and Kickstarter.sol becomes the CONTROLLED contract.

Let's assume that Alice wants to use TimelockController.sol contract provided by OpenZeppelin Library. Here’s how she would do it.

Deploy the TimelockController.sol Contract

First, deploy an instance of the TimelockController contract, specifying the minimum delay and the addresses of proposers, executors, and a contract admin.

The contract admin would be the wallet address of Alice through which she would deploy the TimelockController contract.

// @dev adminAddress == Wallet address of ALICE
TimelockController timelock = new TimelockController(minDelay, proposersArray, executorsArray, adminAddress);        

Set the Timelock as the Owner of Kickstarter

Make the TimelockController contract address as the owner of your Kickstarter contract. This will allow the timelock to control certain operations in the Kickstarter contract.

So, s_owner state variable in kickstarter.sol would be the contract address of TimelockController.sol that is deployed to the blockchain.

// @dev _owner is the contract address of the deployed Kickstarter Instance.
Kickstarter public kickstarter = new Kickstarter(address _owner);        

Implement Timelock for Withdrawal Function

Lets modify our withdraw() in the Kickstarter contract to utilize the TimelockController for authorization and timing checks.

function withdraw() public {
    // Check if the operation is scheduled and ready in the timelock
    bytes32 operationId = timelock.hashOperation(address(this), 0, abi.encodeWithSignature("withdraw"));
    
    require(timelock.isOperationReady(operationId), "Withdrawal operation not yet ready in Timelock");

    // Execute the withdrawal
    (bool success, ) = address(this).call{value: address(this).balance}("");
    require(success, "Transfer failed");

    // Mark the operation as executed in the timelock
    timelock.execute(address(this), 0, abi.encodeWithSignature("withdraw"));
}        

Schedule Withdrawal Operation in Timelock

Modify your withdrawal process to schedule the withdrawal operation in the TimelockController.

function scheduleWithdrawal() external onlyOwner {
    // Define parameters for withdrawal operation
    address target = address(this);
    uint256 value = address(this).balance;
    bytes memory data = abi.encodeWithSignature("withdraw");

    // Schedule the withdrawal operation in Timelock with a delay
    uint256 delay = 86400; // 1 day (in seconds)
    timelock.schedule(target, value, data, bytes32(0), bytes32(0), delay);
}        
Article content
Image describing the data flow for withdrawal of funds from Kickstarter Contract

Security Considerations

Using timelocks in Solidity can introduce several security vulnerabilities, primarily revolving around the manipulation of block timestamps and more. A few of them are:

Miner Manipulation

block.timestamp is generally used to perform a transaction or to update the contract after a certain time interval.

But what if we try to use it to determine randomness?

Let's consider a Roulette contract that allows only ONE bet to be placed every block to the contract and a random winner is picked based on the randomness using block.timestamp.

contract Roulette {
    uint public pastBlockTime; // Forces one bet per block

    constructor() public payable {} // Initially fund contract

	//Fallback function used to make a bet.
    function () public payable {
        require(msg.value == 10 ether); // must send 10 ether to play
        
        require(now != pastBlockTime); // only 1 transaction per block
        
        pastBlockTime = block.timestamp;
        
        //Logic to pick the winner.
        if(block.timestamp % 12 == 0) { 
            msg.sender.transfer(this.balance);
        }
    }
}        

In the above contract, only a single bet can be placed per block by any random person and with a minimum of 10 ETH.

Then once the bet is placed to the contract, if the transaction of placing the bet is mined and added to the block which has a block.timestamp that is divisible by 12, then the balance of the Roulette contract is transferred to the user.

Since in Ethereum, a block is mined every 12 seconds after the merge, a miner can modify the block.timstamp value and obtain the funds stored in the contract.

Check out this Stack Exchange discussion to know more about how a miner can manipulate the block.timestamp value in Ethereum after the merge.

How to prevent it?

Do not use block.timestamp as a means to generate randomness in your contracts. Instead, use an Oracle such as Chainlink Verifiable Random Function (VRF) to use randomness functionality in your contract.

Conclusion

Timelocks are a critical component in the design of secure and robust smart contracts. By understanding their implementation and potential security considerations, developers can leverage timelocks to create more secure and reliable blockchain applications. As the blockchain ecosystem continues to evolve, the importance of timelocks in smart contract design will only increase.

References

A great collection of solidity security attack vectors by Dr Adrian Manning

To view or add a comment, sign in

More articles by Rakshith Rajkumar

  • Understanding Leader Election in RAFT consensus mechanism

    What is a consensus mechanism? Imagine multiple computers trying to agree on a single version of truth without a…

  • Understanding literals in Rust

    As I learn Rust, I encounter concepts that are either unique to the language or challenging to grasp. This article…

    1 Comment
  • Pyth Network: A case study.

    Introduction The Pyth Network, established during the DeFi Summer of 2020, has emerged as a leading oracle within the…

    1 Comment
  • Understanding Merkle Trees

    The Merkle tree is an important data structure used in blockchain to verify the integrity of the data or transactions…

    3 Comments
  • Role of Miners in Blockchain Networks

    Miners are nodes who play the role of validating the transactions and proposing blocks in a blockchain network that has…

  • How is a transaction executed in Proof-of-Stake blockchains?

    Let's try to understand how is a transaction executed in a blockchain that follows a Proof-of-stake consensus algorithm…

  • Understanding Proof-of-Stake

    Proof-of-Stake is the consensus mechanism that the Ethereum network currently follows. Before Sept 15, 2022, the…

  • What are validators ?

    Validators are nodes that are involved in the verification of transactions broadcasted in the blockchain and they get…

    2 Comments
  • Understanding Consensus mechanisms

    To understand the consensus mechanism, let's first understand what consensus means. In simple words, Consensus means…

  • What are nodes?

    Nodes are computers that run software that can verify blocks and transaction data. Since blockchain is a network of…

    2 Comments

Others also viewed

Explore content categories