Time locked functions with Solidity

oli8

Olivier

Posted on March 12, 2023

Time locked functions with Solidity

 Time locked functions in smart contracts with Solidity

You may know that access control is a pretty big deal in smart contracts development and might have heard of OpenZeppelin Ownable contract that provides a way to restrict function access to a single "owner" so that this user can perform administrative actions and whatnot, which is the go-to when it comes to access control in Solidity. Here we'll see how we can limit our users from calling a function again in a certain time span.

Basically, we want our users to only be able to call a given function once in a given period.
This could be useful to limit some actions in community driven applications.


Let's get to work! You can code along if you wish by cloning this repository.

Let's say you're a generous fellow and created a contract that allows anyone to call the withdraw function and get 0.1 ETH.
Now your friends heard about it and they're a bunch of jerks so they want to get all the ethers for themselves, therefore you decide to limit the withdraw function to one call a day by user.

Here is the contract we'll start with:

contract Vault {
    uint256 constant private _WITHDRAW_AMOUNT = 0.1 ether;

    function deposit() external payable { }

    function withdraw() external {
        require(
            address(this).balance >= _WITHDRAW_AMOUNT,
            "Insufficient funds"
        );

        _transferEth(msg.sender, _WITHDRAW_AMOUNT);
    }

    function _transferEth(address to, uint256 amount) private {
        (bool sent,) = to.call{value: amount}("");
        require(sent, "Transfer failed");
    }
}
Enter fullscreen mode Exit fullscreen mode

We created a a payable deposit function so we can fund the contract, the remaining code is pretty straightforward, the withdraw function checks that the contract has enough funds before sending ethers to the caller.

Here are our test cases:

const Vault = artifacts.require('Vault')
const { expectRevert, time } = require('@openzeppelin/test-helpers')
const { web3 } = require('@openzeppelin/test-helpers/src/setup')
const { toWei } = web3.utils

contract('Vault', ([alice, bob]) => {
  let contract
  beforeEach(async () => {
    contract = await Vault.new()
    await contract.deposit({ from: alice, value: toWei('0.2', 'ether') })
  })

  it('should allow withdrawing', async () => {
    const withdraw = await contract.withdraw({ from: bob })

    expect(withdraw.receipt.status).to.be.true
  })

  it('should prevent from withdrawing again on the same day', async () => {
    await contract.withdraw({ from: bob })
    await time.increase(time.duration.hours(12))

    await expectRevert(contract.withdraw({ from: bob }), 'Account under timelock')
  })

  it('should allow another withdrawal the next day', async () => {
    await contract.withdraw({ from: bob })
    await time.increase(time.duration.hours(36))
    const lastWithdraw = await contract.withdraw({ from: bob })

    expect(lastWithdraw.receipt.status).to.be.true
  })
})

Enter fullscreen mode Exit fullscreen mode

We deploy a new contract and fund it with 0.2 ETH before each test.
We use openzeppelin test-helpers to simulate the passing of time.
You can guess from the code the features we will implement:

  • Allowing a first withdrawal.
  • Preventing another withdrawal within a 24 hours timespan.
  • Allowing another withdrawal that happens more than 24 hours after the first one.

Let's start by adding a mapping that will keep track of the release time - at which point a user can call our function again - :

mapping(address => uint256) private _timestamps;
Enter fullscreen mode Exit fullscreen mode

Now we need to update it in the withdraw function.
We add it at the end but before the _transferEth to avoid reentrancy vulnerabilities.
We set the release time for the caller to one day from now using block.timestamp - the current time - and the days unit providing the number of seconds for a given amount.

function withdraw() external {
    address caller = msg.sender;
    require(
        address(this).balance >= _WITHDRAW_AMOUNT,
        "Insufficient funds"
    );

    _timestamps[caller] = block.timestamp + 1 days;
    _transferEth(caller, _WITHDRAW_AMOUNT);
}
Enter fullscreen mode Exit fullscreen mode

Note that we also added a caller variable as we'll use its value again later on.

Now that we have a record of the release time for a user, we can check it against the current time :

require(
    block.timestamp > _timestamps[caller],
    "Account under timelock"
);
Enter fullscreen mode Exit fullscreen mode

If block.timestamp is greater than _timestamps[caller] then we've passed the release time, otherwise it is still ahead in the future.
If a user didn't call our function before then we've never set _timestamps for that address and _timestamps[caller]would equal the default value for an uint type: 0, therefore passing our require clause.

The withdraw function now looks like this:

function withdraw() external {
    address caller = msg.sender;
    require(
        block.timestamp > _timestamps[caller],
        "Account under timelock"
    );
    require(
        address(this).balance >= _WITHDRAW_AMOUNT,
        "Insufficient funds"
    );

    _timestamps[caller] = block.timestamp + 1 days;
    _transferEth(caller, _WITHDRAW_AMOUNT);
}
Enter fullscreen mode Exit fullscreen mode

Let's run our tests with truffle test:

Contract: Vault
    ✔ should allow withdrawing
    ✔ should prevent from withdrawing again on the same day 
    ✔ should allow another withdrawal the next day

3 passing
Enter fullscreen mode Exit fullscreen mode

Yeah, we did it!
But what if we wanted to share the same time lock with another function? We'd have to rewrite pretty much the same code in each functions, let's see how we can extract that logic to another contract and write a modifier.

abstract contract TimeLock {
    uint256 private _timeLockDuration;

    mapping(address => uint256) private _timestamps;

    constructor(uint256 timeLockDuration) {
        _timeLockDuration = timeLockDuration;
    }

    modifier timeLocked() {
        address caller = msg.sender;
        require(
            block.timestamp > _timestamps[caller],
            "TimeLock: Account under timelock"
        );
        _timestamps[caller] = block.timestamp + _timeLockDuration;
        _;
    }
}
Enter fullscreen mode Exit fullscreen mode

We now set the duration in the constructor and made a modifier.
Let's update our Vault contract:

import "./TimeLock.sol";

contract Vault is TimeLock {
    uint256 constant private _WITHDRAW_AMOUNT = 0.1 ether;

    constructor() TimeLock(1 days) { }

    function deposit() external payable { }

    function withdraw() external timeLocked {
        require(
            address(this).balance >= _WITHDRAW_AMOUNT,
            "Insufficient funds"
        );

        _transferEth(msg.sender, _WITHDRAW_AMOUNT);
    }

    // [...]
}
Enter fullscreen mode Exit fullscreen mode

We've changed a few things:

  • Import the TimeLock contract: import "./TimeLock.sol";
  • Inherit our new contract: contract Vault is TimeLock {
  • Initialize the lock duration in the constructor: constructor() TimeLock(1 days) { }
  • Add the modifier on the relevant function: function withdraw() external timeLocked {
  • We remove the _timestamps mapping as it is now declared in the TimeLock contract

Let's run our tests again, hopefully we didn't break anything :D

Contract: Vault
    ✔ should allow withdrawing
    ✔ should prevent from withdrawing again on the same day 
    ✔ should allow another withdrawal the next day

3 passing
Enter fullscreen mode Exit fullscreen mode

There we have it! A clean way to control access to our functions with a modifier.
You can check the code on the final branch of the repository.
Hopefully you've learned a few things about smart contracts and Solidity :)
Be wary that block.timestamp can be manipulated by miners therefore you shouldn't rely on it for a duration under 15 minutes or for critical features.

By the way, if you ever encounter such a use case, you're welcome to check out the soliv library that contains a TimeLock and TimeLockGroups contract handling what we've seen with additional features ✌️

💖 💪 🙅 🚩
oli8
Olivier

Posted on March 12, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Time locked functions with Solidity
smartcontract Time locked functions with Solidity

March 12, 2023

How To Call Other Contracts In Solidity
smartcontract How To Call Other Contracts In Solidity

April 25, 2022