Skip to content

Instantly share code, notes, and snippets.

@TomiOhl
Last active March 5, 2023 13:41
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save TomiOhl/ad3cb022a737dd60153382371de8ce0d to your computer and use it in GitHub Desktop.
Save TomiOhl/ad3cb022a737dd60153382371de8ce0d to your computer and use it in GitHub Desktop.
Timelock possibilities

Timelock implementation possibilities in smart contracts

A timelock is used in smart contracts as a way to restrict the spending of some tokens until a specified future time or block number.

Possible solutions

Some solutions to the problem are described below, from the most primitive to the best we came up with. Note that the term expiration time is used, but it can also refer to block numbers.

Less precise solutions

Method 1: store the expiration time of the last deposit

Requires storing just one state variable. Fast and gas-effective, but it affects all users, so it's unusable in production.

Method 2: store the expiration times of the last deposits per user

Requires storing a mapping of last deposit times to addresses that made them. Since you can access elements of a mapping in O(1), it's fast and gas-effective. The downside is that a deposit locks all the depositor's tokens again. This solution is used by duckstarter.io, for example (find it on Etherscan).

More precise solutions

A logical next step after the above methods would be to store the expiration time of all deposits for all users. These are a lot of data, and there are some different ways to store them.

Method 1: EIP1132

The idea behind this method is to extend the ERC20 standard with addtitional locking capabilities. On every deposit a new lock entry is stored in the token's contract itself, which prevents a certain amount from being withdrawn by a specific address for a period of time. Every lock entry is assigned to a reason that also serves as an identifier. More info on this solution can be found here.

Method 2: use OpenZeppelin's TokenTimelock.sol

The idea behind this method is to deploy a contract on every deposit with the beneficiary's address and the release time. After the given time, the beneficiary calls the contract's release() function, which transfers the tokens to it's address in return.
In our case, the beneficiary is the contract holding the staked tokens. This means we have to store all the addresses of the deployed timelock contracts. If a user wants to withdraw their funds, we have to check the release times of all the contracts.
So, using this method would probably cost a lot of gas. The timelock contract can be found on OpenZeppelin's GitHub.

Method 3: mint NFTs with timelock data

The idea behind this method is to mint a Non-Fungible Token (NFT) for every deposit. The NFT is minted to the depositor's address and holds the locked token's amount and expiration time.
In our case, if a depositor wants to withdraw funds, the contract has to get their NFT balance and iterate through all the ids, get the expiration times of the indivual NFTs and burn them if they are expired.
The benefit of using this method is that the data is stored directly in the depositor's wallet, which is a user-friendly approach indeed. However, this method requires a lot of code, which consequently results in high gas usage - especially minting and burning tokens.
A similar solution is used by PancakeSwap's lottery (find it on BscScan or on GitHub).

Method 4: map arrays of timelock data to addresses

The idea behind this method is to keep records of the individual deposits in arrays and map those arrays to the addresses of the depositors. A few lines of code say more than a thousand words:

struct lockedItem {
    uint256 expires;
    uint256 amount;
}
mapping(address => lockedItem[]) public timelocks;

In our case, if a depositor wants to withdraw funds, the contract simply iterates through their array and deletes the expired timelock entries. On blockchains, deleting array elements actually refunds some gas, so withdraw operations can be surprisingly cheap.
Based on our tests on BSC Testnet, deposits using this method are 3-4x cheaper compared to using NFTs. In the case of withdraws, the situation is similar or even better. When using NFTs, we managed to burn maximally 166 NFTs in one transaction - which means 166 timelock entries in other words - before running out of gas (note: the gas limit was set to the highest possible value). In contrast, using the method described in this section, we've successfully managed to remove more than a thousand entries with several times lower gas usage. Currently, we're using this solution in agora.space (GitHub) and utopia-fund.

Conclusion

For timelocks managed by a contract, method 4 from the previous section seems the least complex and most effective. However, just like the above methods, that is not perfect either. Even though getting a reference to the desired array from the mapping is O(1), iterating on it is O(n), what can become problematic if n is too big. In other words, if a user deposits too many times in a row, without waiting for their timelocks to expire, their array can become too big, what can result in enormous gas costs. Currently we've limited the number of timelock entries to 600 to mitigate this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment