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
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:
// 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:
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.
// 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:
Recommended by LinkedIn
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
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);
}
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.