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");
}
}
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
})
})
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;
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);
}
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"
);
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);
}
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
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;
_;
}
}
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);
}
// [...]
}
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
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 ✌️
Top comments (0)