Blog

Time-restricted functions in Solidity

Time-Restricted Functions in Solidity: Building a Secure Timelock

Introduction
Time-locked contracts are an essential pattern in the Ethereum ecosystem, used to delay function execution until a specified time has passed. This design is common in decentralized finance (DeFi) for vesting tokens, in DAO governance to allow for community review, and in various other use cases where actions should not happen prematurely. By leveraging Solidity’s built-in block.timestamp, you can enforce time-based restrictions that ensure contract functions can only be called after a certain deadline. In this article, we’ll explore the concept of a timelock, walk through a practical code example, and highlight important security considerations.
For developers who want a deeper audit of their time-based logic or broader contract security, consider Bailsec’s audit services. Our team specializes in ensuring your smart contracts adhere to best practices and robust security standards.

1. What Is a Timelock and Why Is It Important?

A timelock is a mechanism that prevents certain functions from being executed until a specific time has elapsed. Typically, this logic involves:
  1. Storing a timestamp when the function becomes available.
  2. Checking block.timestamp against that stored value.
  3. Reverting if the current time is still before the designated unlock time.

Common Use Cases

  • DAO Governance: Proposals might require a 48-hour delay before execution, allowing token holders to review and possibly intervene.
  • DeFi Vesting Schedules: Tokens allocated to a project team can be locked, ensuring they can’t withdraw funds before a certain period, boosting investor confidence.
  • Delayed Upgrades: A protocol may enforce a time delay for contract upgrades, so the community can examine changes and voice concerns.
In each scenario, a timelock adds transparency and security by preventing hasty actions.

2. Basic Timelock Contract Structure

Below is a minimal contract that locks Ether until a specified releaseTime. The snippet references two images: one showing the code for a contract named TimeLock with a constructor, state variables, and a withdraw function, and another demonstrating an if-statement revert using a custom error.
solidity

contract TimeLock {
address public beneficiary;
uint256 public releaseTime;
uint256 public amount;

constructor(address _beneficiary, uint256 _releaseInMinutes, uint256 _amount) payable {
beneficiary = _beneficiary;
// Convert minutes to seconds for block.timestamp comparisons
releaseTime = block.timestamp + (_releaseInMinutes * 1 minutes);
amount = _amount;
}

function withdraw() public {
require(block.timestamp >= releaseTime, "TimeLock: Release time not reached");
require(msg.sender == beneficiary, "TimeLock: You are not the beneficiary");

(bool success, ) = beneficiary.call{value: amount}("");
require(success, "TimeLock: Transfer failed");
}
}

Key Components

  1. State Variables
  • beneficiary: The address authorized to withdraw once the time has passed.
  • releaseTime: The timestamp after which withdrawal is permitted.
  • amount: The total Ether locked in the contract.
  1. Constructor
  • Initializes beneficiary with _beneficiary.
  • Sets releaseTime by adding _releaseInMinutes to the current block.timestamp.
  • Records the locked amount, and marks the constructor as payable so the contract can receive Ether at deployment.
  1. withdraw Function
  • Uses require statements to ensure the current time is >= releaseTime and that the caller is the beneficiary.
  • Uses call to transfer Ether, returning an error message if the transfer fails.
In the second snippet, you might see a line such as:
solidity

if (block.timestamp < _timeLockTime) {
revert ErrorNotReady(block.timestamp, _timeLockTime);
}
This demonstrates how you can define a custom error in Solidity 0.8.4+ for more descriptive error messages. Either approach—custom errors or require statements—serves to enforce time restrictions.

3. How the Timelock Mechanism Works

When TimeLock is deployed, it receives Ether (equal to amount in the constructor). The contract then stores:
  • beneficiary: The only address permitted to call withdraw.
  • releaseTime: A future point in time (in seconds).
If someone tries to withdraw Ether before the designated time, the transaction reverts. After the releaseTime has passed, the withdraw function becomes callable by the beneficiary, sending the locked Ether to their address.

4. Security Considerations

4.1. Miner Manipulation of block.timestamp

While block.timestamp is generally reliable, miners can manipulate it slightly by a few seconds. This is usually not enough to bypass a large delay (e.g., 24 hours), but if your time delay is extremely short, miner manipulation could become relevant. A best practice is to ensure your timelock durations are significant enough that minor timestamp adjustments don’t undermine security.

4.2. Reentrancy Risks

In the above snippet, the withdraw function uses call to send Ether. Although the code is relatively straightforward, be mindful of reentrancy attacks if your contract performs more complex state changes. To mitigate reentrancy, consider:
  • Checks-Effects-Interactions pattern: Update state (like zeroing out amount) before making external calls.
  • Reentrancy Guards: A nonReentrant modifier (e.g., from OpenZeppelin) can prevent malicious fallback calls from re-entering the contract.

4.3. Zero Address and Additional Checks

In more complex designs, you may want to check for the zero address in the constructor to prevent a beneficiary of address(0) from locking the contract’s funds indefinitely. Additionally, if your contract allows updating the beneficiary or extending the releaseTime, ensure those functions have proper access control or require the user’s explicit permission.

4.4. Upgradability

If your project uses an upgradeable proxy pattern, ensure the timelock logic remains consistent through upgrades. Changing critical variables like releaseTime or introducing new logic for time checks could create inconsistencies or security gaps. Thoroughly test upgrade scenarios and confirm storage layouts remain stable. For advanced guidance on upgradeable timelocks, refer to Bailsec’s workflow documentation.

5. Common Use Cases in DeFi and DAOs

  1. Token Vesting
  • Teams, advisors, or partners often have tokens locked for a set period, reassuring investors that tokens can’t be dumped prematurely.
  1. DAO Governance Delay
  • Proposals might queue for 24 or 48 hours before execution, giving the community time to review and, if necessary, exit or protest.
  1. Contract Upgrades
  • Protocols can implement a timelock on upgrades, preventing immediate changes that might compromise user funds.
  1. Payment Scheduling
  • A merchant or escrow scenario could release funds to a service provider only after a time-based milestone is reached.
By applying timelocks, you introduce transparency and trust into your project’s operations.

6. Best Practices and Enhancements

  1. Event Emissions
  • Consider emitting events (e.g., FundsLocked on deployment or FundsWithdrawn upon successful withdrawal) for better on-chain tracking.
  1. Custom Errors
  • Solidity 0.8.4+ supports custom errors for more descriptive revert messages, reducing gas costs compared to string-based require messages.
  1. Modifiers
  • Create a timeRestricted modifier that checks block.timestamp >= releaseTime. This can simplify your code if you have multiple time-locked functions.
  1. Grace Periods
  • In governance, you might implement a grace period after releaseTime in which the function can be executed. If that period passes, the transaction is canceled, preventing indefinite queued actions.
  1. Testing
  • Always test across various scenarios: calling too early, calling right at the deadline, and calling after an extended period. Confirm that the Ether or tokens are handled as intended.

Conclusion

Time-restricted functions are a cornerstone of secure and transparent smart contract design, especially in DeFi and DAO governance contexts. By leveraging a timelock mechanism, you ensure that sensitive actions—like fund withdrawals or protocol upgrades—can’t be executed prematurely. The TimeLock example above illustrates a straightforward approach: store a releaseTime, check block.timestamp, and revert if the current time is insufficient.
While this method is generally robust, be mindful of potential pitfalls like minor miner manipulation, reentrancy concerns, and the complexities of upgradeable systems. Always consider your contract’s broader architecture and the significance of your time delay when implementing timelocks. If you need expert assistance, Bailsec’s services can provide a comprehensive security audit, ensuring your time-based logic is as safe and effective as possible. For more in-depth articles on Solidity security patterns, check out our blog.
With careful planning, thorough testing, and attention to best practices, you can confidently deploy time-restricted functions that enhance your project’s reliability, security, and trustworthiness.
Disclaimer: This article is provided for informational purposes only and does not constitute legal or financial advice. Always conduct extensive testing and consider professional audits to ensure your smart contracts meet robust security standards.