There are a few different ways to write a timelock smart contract. But when it comes to writing one, the Solidity programming language is one of the most popular choices. And for good reason: it’s designed specifically for writing contracts on Ethereum, and it’s relatively easy to learn (compared to other languages). But before we get our hands dirty, What in the world is a timelock smart contract?
A timelock smart contract is a type of digital contract that allows for the release of funds or other assets at a specific future date or time. Timelock smart contracts are used to create escrow agreements, deferred payments, and other types of delayed transactions. It contains a set of rules and conditions that specify when and how the contract can be executed. When the contract is created, the parties involved agree to the terms of the timelock.
OpenZeppelin defined a Timelock as a “smart contract that delays function calls of another smart contract after a predetermined amount of time has passed”. Business applications and other organizations could leverage this for governance or administrative purposes.
If you want a little more literature about this topic, I wrote a separate blog post about called What Is A Timelock Smart Contract?. Go check it out.
In this article, we are going to Write a Timelock Smart Contract that implements the following scenario:
- Bob is a very successful business man and has to stay on top of all things family, business, charity, etc.
- Alice is Bob’s daughter and she studies psychology.
- Bob pays for Alice’s tuition, but wants Alice to access the funds after a specific date.
- If Alice tries to withdraw before the specified date, the transaction won’t go through
- Bob can change the withdrawal date or cancel the transfer within a certain period
- If Bob decides to update a deposit and the new amount to transfer is lower than the original amount, Bob will be reimbursed the difference by the smart contract
- There is a MIN_DELAY that Bob has to wait before any transaction can be queued
- There is a MAX_DELAY after which a transaction cannot be queued
- There is a GRACE_PERIOD during which any queued transaction can be executed. Deposit.timestamp + GRACE_PERIOD should be greater than the current timestamp.
The blog post will briefly explain the basic development setup environment. Next, we will Write a Timelock Smart Contract, a factory contract, and a test script to ensure all Smart contracts execute as intended. Finally, we will learn how to compile and deploy our smart contract to a testnet of our choice (Goerli).
The full source code of this repository can be found here.
Development Environment Setup
Before we start to Write the Timelock Smart Contract, make sure you have the following dependencies and necessary software and development environment installed to follow along: Node.js, Git, OpenZeppelin Contracts, Truffle
- Node.js: To install Node.js, go to Nodejs.org, download the corresponding version for your operating system and install it. To verify that node.js was successfully installed, run the command
// As long as you are above version 8.0.0, you are fine
$ node -v
V16.18.0
$ npm -v
8.19.2
First order of business, let’s create a project directory called Timelock and CD into that directory. Our entire project will live inside it.
$ mkdir Timelock
$ cd Timelock
Next, inside the Timelock directory, we need to initialize a package.json file that will track all our dependencies. Run the following code
Timelock $ npm init -y
A package.json file will be created, along with a node_module directory where our dependencies will live. Next, let’s create a .gitignore file and add files and directories we do not want our project to track.
// Create the .gitignore file
Timelock $ touch .gitignore
Inside that file, add the following code
node_modules/
.env
package-lock.json
.gitignore
- Git: for version control and code tracking.
- OpenZeppelin Contracts: To install the Smart Contract library from OpenZeppelin, run the following command
Timelock $ npm install @openzeppelin/contracts
Installing [============] 100%|
+ @openzeppelin/contracts
- Truffle: The most popular development framework for EVM-compatible chains.
To install Truffle, run the following command:
$ npm install -g truffle
To make sure Truffle was installed successfully, run the following command
$ truffle version
//The output should look like this
Truffle v5.5.22 (core: 5.5.22)
Ganache v7.3.2
Solidity v0.5.16 (solc-js)
Node v16.18.0
Web3.js v1.7.4
Create/Initialize a New Truffle Project
In order to write a Timelock Smart Contract, we need a development framework that will make our lives easier to write contracts. Truffle is my framework of choice. To get started with Truffle, we will initialize a new project.
//Inside Project folder, initialize truffle
$ truffle init
When you run the initialization command, you should see an output that looks like this:
Starting init...
================
> Copying project files to <PATH_TO_TIMELOCK_FOLDER>/Timelock
Init successful, sweet!
Try our scaffold commands to get started:
$ truffle create contract YourContractName # scaffold a contract
$ truffle create test YourTestName # scaffold a test
http://trufflesuite.com/docs
The project directory will look like this
-
- The contracts directory is where we will write all our Smart Contracts code.
- The migrations directory is where we will write all our deployment scripts, following a specific convention.
- The test directory is where we will write all our Smart Contract tests.
- The truffle-config.js file is where we will set up network configuration, builds and artifacts directory, and more.
Now that we have initialized a new truffle project, we are ready to Write our Timelock Smart Contract.
Write a Timelock Smart Contract
To create one, make sure you are inside the project folder (Timelock) and run the following command:
//Inside Project folder, create new contract
$ truffle create contract Timelock
This command will create a new file inside the contracts folder called “Timelock.sol” as seen in the screenshot below
This is the contract that publishes a transaction to be executed in the future. After a minimum waiting period, the transaction can be executed.
To ensure everything is still compiling so far, run the following command inside your terminal
$ truffle compile
Compiling your contracts...
===========================
> Compiling ./contracts/Migrations.sol
> Compiling ./contracts/Timelock.sol
> Compilation warnings encountered:
Warning: Visibility for constructor is ignored. If you want the contract to be non-deployable, making it "abstract" is sufficient.
--> project:/contracts/Timelock.sol:5:3:
|
5 | constructor() public {
| ^ (Relevant source part starts here and spans across multiple lines).
> Artifacts written to /PATH_TO_PROJECT_DIRECTORY/timelock/build/contracts
> Compiled successfully using:
- solc: 0.8.15+commit.e14f2714.Emscripten.clang
Everything is compiling fine, except for a warning which is not relevant at the moment.
Our contract will consist of multiple functions that we will describe shortly, but all those functions will revolve around these main two main functions:
-
-
-
-
-
- queue(): This function will be used to broadcast transactions that are going to execute sometimes in the future (after a certain amount of time).
-
-
-
-
-
- execute(): Once the waiting time of a specific transaction has passed, it can now be called by the execute().
-
-
This strategy gives users enough flexibility to make modifications/changes to their transactions before it gets executed (Updating the amount to transfer, change the waiting time, cancel the transaction, etc).Inside our Timelock contract, let’s declare two variables: owner and description, along with the two functions mentioned above.
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
contract Timelock {
address public owner;
string public description;
constructor() public {}
function queue()external {}
function execute()external {}
}
-
-
-
-
-
- Owner: Represents the owner of the smart contract / the account the deployed it
-
-
-
-
-
- Description: A description of the timelock contract (e.g. Bob’s Timelock Family Wallet)
-
-
The functions are marked “external” because we want them to be called by other contracts and addresses, but the Timelock contract itself.
Function Modifier & Ownable Contracts
When we Write A Timelock Smart Contract, we need to ensure that only addresses with the right authorizations can execute certain functions on our contract. To do so, we will make use of function modifiers.
Inside our Timelock Smart Contract, let’s create two modifiers that will restrict access to certain functions.
-
-
-
-
-
- onlyOwner(): this modifier will ensure that only the owner for the smart contract can access a specific resource inside the smart contract.
-
-
-
-
-
- isValidTimestamp(uint256 _timestamp): this modifier will ensure that the timestamp passed as argument meets the requirements (e.g. The timelock period has to be in the future)
-
-
We will keep the isValidTimestamp(uint256 _timestamp) empty for now and we will update it later in the tutorial.
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
contract Timelock {
address public owner;
string public description;
modifier onlyOwner(){
require(msg.sender == owner, “Only Owner can execute this function”);
_;
}
// Ensures that only the timestamp passed meets the requirements
modifier isValidTimestamp(uint256 _timestamp) {
_;
}
constructor() public {}
[...omitted code…]
}
Constructor()
Before a contract is deployed on the blockchain, it might or might not require arguments to be passed to its constructor. In our case, we would like to define the owner of the contract and add a description inside the constructor. So we will pass our parameters to the constructor like so:
pragma solidity >=0.4.22 <0.9.0;
contract Timelock {
[...omitted code…]
constructor(string memory _description, address _owner) {
description = _description;
owner = _owner;
}
[...omitted code…]
}
So far, our contract should look like below and should still compile with this command
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
contract Timelock {
// Owner of the timelock contract
address public owner;
// Description of the contract (e.g. Bob's Timelocked Family Wallet)
string public description;
// Modifier function
modifier onlyOwner() {
require(owner == msg.sender, "Only Owner can execute this function");
_;
}
// Ensures that only the timestamp passed meets the requirements
modifier isValidTimestamp(uint256 _timestamp) {
_;
}
constructor(string memory _description, address _owner) {
description = _description;
owner = _owner;
}
function queue() external {}
function execute() external {}
}
Solidity Constant
Inside our code, we will declare 3 public constants that we will use throughout the application: MIN_DELAY, MAX_DELAY, GRACE_PERIOD.
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
contract Timelock {
// Owner of the timelock contract
address public owner;
// Description of the contract (e.g. Bob's Timelocked Family Wallet)
string public description;
// Constant Variables
// MIN_DELAY deposit.timestamp > block.timestamp + MIN_DELAY
// MAX_DELAY deposit.timestamp , block.timestamp + MAX_DELAY
// MAX_DELAY deposit.timestamp , block.timestamp + MAX_DELAY
uint256 public constant MIN_DELAY = 10; // 10s
uint256 public constant MAX_DELAY = 172800; // 2days =172800=86400s * 2
uint256 public constant GRACE_PERIOD = 432000; // 5days =432000=86400s * 5
[...omitted code…]
}
Solidity Struct
Inside our smart contract, we will represent a deposit as a solidity struct, which allows us to organize data records. Our Solidity struct will look like so:
[... omitted code ...]
uint256 public constant GRACE_PERIOD = 432000; // 5days =432000=86400s * 5
struct Deposit {
bytes32 depositId;
string description;
address from;
address to;
uint256 amount;
uint256 timestamp;
bool claimed;
}
[... omitted code ...]
-
-
-
-
-
- depositId: which is a hash of other properties of Deposit (description, from, to, amount, timestamp)
-
-
-
-
-
- description: which is a short description of the Deposit. Kinda like a memo
-
-
-
-
-
- from: the account initiating the deposit
-
-
-
-
-
- to: the account receiving the transfer of funds
-
-
-
-
-
- amount: The amount to be deposited/transferred
-
-
-
-
-
- timestamp: which is the time when the receiver can withdraw the funds (Also a wait period)
-
-
-
-
-
- claimed: A boolean value which tells if the Deposit has been claimed or not.
-
-
Solidity Mapping
Mapping in Solidity acts like a hashtable or dictionary in any other language. These are used to store the data in the form of key-value pairs. Mappings are mostly used to associate the unique Ethereum address with the associated value type.
In our case, we will declare 3 different mappings:
[... omitted code ...]
struct Deposit {
[... omitted code ...]
}
// Maps an address to a list of Deposits
mapping(address => Deposit[]) public deposits;
// Maps a depositId to a Deposit
mapping(bytes32 => Deposit) public depositIdToDeposit;
// Maps a txId(Queued Tx) to a Deposit (tx id => queued)
mapping(bytes32 => Deposit) public queued;
[... omitted code ...]
-
-
-
-
-
- Deposits: this mapping will store all Deposits made by a specific account
-
-
-
-
-
- depositIdToDeposit: this mapping will store and retrieve a specific Deposit by its depositId.
-
-
-
-
-
- Queued: this mapping will store and retrieve a specific Deposit by its transactionId or txId
-
-
Solidity Events
An event is an inheritable member of the contract, which stores the arguments passed in the transaction logs when emitted. Generally, events are used to inform the calling application about the current state of the contract. It is always recommended to emit an event every single time the state of the blockchain is mutated.
Creating an Event in Solidity
In our case, we will create a total of 6 events:
-
-
-
-
-
- DepositedFundsEvent: which emits an event whenever a deposit occurs.
-
-
-
-
-
- UpdatedDepositEvent: Emits an event whenever a deposit is updated
-
-
-
-
-
- QueuedEvent: Emits an event whenever a transaction is successfully queued for withdrawal
-
-
-
-
-
- ExecutedTxEvent: Emits an event whenever a queued transaction is successfully executed
-
-
-
-
-
- CanceledTxEvent: Emits an event whenever a queued transaction is successfully canceled
-
-
-
-
-
- ClaimedDepositEvent: Emits an event whenever a deposit has been claimed by the receiver
-
-
// Maps a txId(Queued Tx) to a Deposit (tx id => queued)
mapping(bytes32 => Deposit) public queued;
// Events Declarations
event DepositedFundsEvent(
address indexed _from,
address indexed _to,
uint256 _amount,
uint256 _timestamp
);
event ExecutedTxEvent(
bytes32 indexed _txId,
address indexed _target,
address indexed _to,
uint256 _amount,
uint256 _timestamp
);
event UpdatedDepositEvent(
string _description,
address indexed _from,
address indexed _to,
uint256 _amount,
uint256 _timestamp
);
event CanceledTxEvent(
bytes32 indexed _txId
);
event QueuedEvent(
bytes32 indexed _txId,
address indexed _target,
address indexed _to,
uint256 _amount,
string _func,
uint256 _timestamp
);
event ClaimedDepositEvent(
bytes32 indexed _depositId
);
[... omitted code ...]
Testing Smart Contracts
Testing smart contracts is one of the most important measures for improving smart contract security.
In this section, we will Write a test suite in JavaScript for our Timelock Smart Contracts to make sure they are deployed correctly, and we will be using a TDD (Test Driven Development) approach throughout this tutorial. It is important to remember that if using Truffle for development, web3 will come pre-installed and will be injected in your file. So for our test we do not need to require(‘web3’)
Inside our test sub-directory, let’s create a file called Timelock.test.js with the following command:
$ touch test/Timelock.test.js
Inside the test file, we will add the following code:
let Timelock = artifacts.require('./Timelock');
let ethToDeposit = web3.utils.toWei("0.05", "ether");
let depositReceipt;
let bob;
let alice;
let timelockContract;
let timestamp;
contract("Timelock", async function(accounts) { //
before("Deploy Contracts", async function() {
bob = accounts[0];
alice = accounts[1];
timelockContract = await Timelock.deployed();
timestamp = Date.parse("Sun Nov 27 2022 10:00:50 GMT-0800 (Pacific Standard Time)") / 1000
});
it('Initializes the contract with the correct values', async function() {
assert.notEqual(await timelockContract.owner(), 0x0, 'The Owner of the smart contract was set')
assert.notEqual(await timelockContract.address, 0x0, 'The smart contract address was set')
assert.equal(await timelockContract.owner(), bob, 'The Owner of the smart contract was not set properly')
assert.equal(await timelockContract.description(), "Family Timelock Funds", 'The Timelock description was not set')
});
});
-
-
-
-
-
- We load and interact with compiled contracts through artifacts.require() function. The name of the contract, not the name of the file is passed as an argument to the function.
-
-
-
-
-
- Truffle uses the Mocha framework, but with the benefits of Truffle’s clean room, which means that contracts are deployed before tests are executed and Web3 is already injected.
-
-
-
-
-
- Because of the asynchronous nature of the Blockchain, we leverage the async/await syntax of JavaScript.
- It is very important to remember that we will need to manually change the timestamp date to a date in the future that is within the constraints of our Timelock Smart Contract.
-
-
To run our test, type the following command in the terminal:
$ truffle test
Compiling your contracts...
===========================
> Compiling ./contracts/Migrations.sol
> Compiling ./contracts/Timelock.sol
> Artifacts written to /var/folders/0s/30jwb8rs4mn_p_61st6_78j80000gn/T/test--11853-whPVZvlrC0Nn
> Compiled successfully using:
- solc: 0.8.15+commit.e5eed63a.Emscripten.clang
Contract: Timelock
1) Contract Deployed Successfully!
> No events were emitted
0 passing (99ms)
1 failing
1) Contract: Timelock
Contract Deployed Successfully!:
Error: Timelock has not been deployed to detected network (network/artifact mismatch)
at Object.checkNetworkArtifactMatch (/usr/local/lib/node_modules/truffle/build/webpack:/packages/contract/lib/utils/index.js:247:1)
at Function.deployed (/usr/local/lib/node_modules/truffle/build/webpack:/packages/contract/lib/contract/constructorMethods.js:83:1)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
at Context.<anonymous> (test/myContract.test.js:5:28)
This error indicates that our contract was not successfully deployed on any network. This is because the truffle test command executes truffle compile and then truffle migrate in one shot. To fix this issue, we need to create a migration script for our Smart Contract. That will be the focus of the next section of this tutorial.
Migrating and Deploying Smart Contracts
In this section, we will Write our first migration script in JavaScript for the Timelock Smart Contract, that will ensure that it gets deployed properly.
First, let’s create a migration file called 2_deploy_Contracts.js inside our migrations/ folder
$ touch migrations/2_deploy_Contracts.js
Inside the file, copy and paste the following code:
const Timelock = artifacts.require("Timelock");
module.exports = async function (deployer, network, accounts) {
// Pass initial supply as argument of the deployer.
await deployer.deploy(Timelock, "Family Timelock Funds", accounts[0]);
};
Now, let’s run our test once again, with the “truffle test” command. The output should look like this:
$ truffle test
Compiling your contracts...
===========================
> Compiling ./contracts/Migrations.sol
> Compiling ./contracts/Timelock.sol
> Artifacts written to /var/folders/0s/30jwb8rs4mn_p_61st6_78j80000gn/T/test--11894-7Pqa3CknRN8n
> Compiled successfully using:
- solc: 0.8.15+commit.e5eed63a.Emscripten.clang
Contract: Timelock
✓ Contract Deployed Successfully!
1 passing (116ms)
At this stage, my Timelock contract should look like this:
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
contract Timelock {
// Owner of the timelock contract
address public owner;
// A description of the timelocked wallet (e.g. Bob's Timelocked Wallet)
string public description;
// Modifier function
modifier onlyOwner() {
require(owner == msg.sender, "Only Owner can execute this function");
_;
}
// Ensures that only the timestamp passed meets the requirements
modifier isValidTimestamp(uint256 _timestamp) {
_;
}
uint256 public constant MIN_DELAY = 10; // 10 s
uint256 public constant MAX_DELAY = 172800; // 2 days = 172800 = 86400s*2
uint256 public constant GRACE_PERIOD = 432000; // 5 days = 432000 = 86400s*5
// Deposits struct/mapping
struct Deposit {
bytes32 depositId;
string description;
address from;
address to;
uint256 amount;
uint256 timestamp;
bool claimed;
}
// Maps an address to a list of Deposits
mapping(address => Deposit[]) public deposits;
// Maps a depositId to a Deposit
mapping(bytes32 => Deposit) public depositIdToDeposit;
// Maps a txId to a Deposit (tx id => queued)
mapping(bytes32 => Deposit) public queued;
// Events
event DepositedFundsEvent(
address indexed _from,
address indexed _to,
uint256 _amount,
uint256 _timestamp
);
event UpdatedDepositEvent(
string _description,
address indexed _from,
address indexed _to,
uint256 _amount,
uint256 _timestamp
);
event QueuedEvent(
bytes32 indexed _txId,
address indexed _target,
address indexed _to,
uint256 _amount,
string _func,
uint256 _timestamp
);
event ExecutedTxEvent(
bytes32 indexed _txId,
address indexed _target,
address indexed _to,
uint256 _amount,
uint256 _timestamp
);
event CanceledTxEvent(bytes32 indexed txId);
event ClaimedDepositEvent(bytes32 indexed depositId);
constructor(string memory _description, address _owner) {
description = _description;
owner = _owner;
}
}
Solidity Functions
A function is basically a group of code that can be reused anywhere in the program, which generally saves the excessive use of memory and decreases the runtime of the program.
In our case, we will couple functions that will directly or indirectly depend on the Queue() and Execute() functions of the timelock smart contract;
receive() function
In order for our Timelock smart contract to receive funds and store them on behalf of our users, we need to enable this fallback function inside our smart contract. If this function is not present, the Smart Contract will not be able to receive funds. The function has to have a visibility external so it cannot be called from within the contract itself and should also be marked as payable
[... omitted code ...]
constructor(string memory _description, address _owner) {
description = _description;
owner = _owner;
}
// Enables contract to receive funds
receive() external payable {}
[... omitted code ...]
getDepositTxId() function
This function returns a computed version of the Keccak-256 hash of the inputs. The returned value is of type bytes32 and will be used to identify a single Deposit inside our smart contract and its mappings (represents the DepositId). This function takes 5 arguments: _description, _from, _to, _amount, and _timestamp. The function is defined as pure because it is not reading or writing from the blockchain, but simply computing some values passed as parameters. Its visibility is public so anybody can execute it.
[... omitted code ...]
// Enables contract to receive funds
receive() external payable {}
function getDepositTxId(
string memory _description,
address _from,
address _to,
uint256 _amount,
uint256 _timestamp
) public pure returns (bytes32) {
return
keccak256(
abi.encode(_description, _from, _to, _amount, _timestamp)
);
}
[... omitted code ...]
validTimestamp() function
This function evaluates if the timestamp passed to it is greater than the current time/block.timestamp. It takes a uint256 timestamp as an argument and returns a boolean.
[... omitted code ...]
function getDepositTxId(...) public pure returns (bytes32) {...}
function validTimestamp(uint256 _timestamp) internal view returns (bool) {
return (block.timestamp) < _timestamp;
}
[... omitted code ...]
Now we need to go back to the isValidTimestamp() modifier and update it like so
modifier isValidTimestamp(uint256 _timestamp) {
require(
validTimestamp(_timestamp),
"The timelock period has to be in the future"
);
_;
}
getDeposits() function
This function will result in a Deposit type array of all deposits made by the user calling the function. It is marked as view because it reads its data from the blockchain. Its viability is public so anyone can call it.
[... Omitted Code ...]
function validTimestamp(uint256 _timestamp) internal view returns (bool) {...}
function getDeposits() public view returns (Deposit[] memory) {
return deposits[msg.sender];
}
[... Omitted Code ...]
getOneDeposit() function
This function Returns a specific deposit be depositId for the account calling the function, along with the index of the deposit in the Deposit type array.
[... Omitted Code ...]
function getDeposits() public view returns (Deposit[] memory) {..}
function getOneDeposit(bytes32 _depositTxId)
public
view
returns (Deposit memory deposit, uint256 index)
{
for (uint256 i = 0; i < deposits[msg.sender].length; i++) {
if (deposits[msg.sender][i].depositId == _depositTxId)
return (deposits[msg.sender][i], i);
}
}
[... Omitted Code ...]
fetchDeposit() function
This function Returns a specific deposit by a _user and _depositTxId, along with the index of the deposit in the array. Its visibility is internal because we only want the smart contract to be able to call this function. The function will be set view because we are reading from the smart contract / blockchain.
[... Omitted Code ...]
function getOneDeposit(bytes32 _depositTxId) public view returns (Deposit memory deposit, uint256 index) {..}
function fetchDeposit(address _user, bytes32 _depositTxId)internal view
returns (Deposit memory deposit, uint256 index)
{
for (uint256 i = 0; i < deposits[_user].length; i++) {
if (deposits[_user][i].depositId == _depositTxId)
return (deposits[_user][i], i);
}
}
[... Omitted Code ...]
reimburseUser() function
This function reimburses a user in case they decide to upload their deposit with a smaller amount or in case of a cancellation. This function will take 2 arguments: _user (account to reimburse), _amount (amount to transfer back)
[... Omitted Code ...]
function fetchDeposit(address _user, bytes32 _depositTxId)internal view
returns (Deposit memory deposit, uint256 index)
{...}
function reimburseUser(address _user, uint256 _amount) internal {
(bool sent, ) = payable(_user).call{value: _amount}("");
require(sent, "Failed to send Ether");
}
[... Omitted Code ...]
updateDeposit() function
This function updates a specific deposit by its _depositId. It takes 5 arguments: _depositId to update, _description, _to, _amount, _timestamp. This function’s visibility will be set to “public” and will be payable because it will be used to move funds around. Before running the function, we will ensure that the _timestamp is a valid one.
[... Omitted Code ...]
function fetchDeposit(address _user, bytes32 _depositTxId)internal view
returns (Deposit memory deposit, uint256 index)
{...}
function updateDeposit(
bytes32 _depositId,
string memory _description,
address _to,
uint256 _amount,
uint256 _timestamp
) public payable isValidTimestamp(_timestamp) {
(Deposit memory deposit, uint256 index) = getOneDeposit(_depositId);
bytes32 depositId = getDepositTxId(
_description,
msg.sender,
_to,
_amount,
_timestamp
);
require(_amount > 0, "AmountLowError");
require(deposit.amount > 0, "NoDepositFoundError");
if (_amount > deposit.amount) {
// Ensure there is enough funds in the user account
require(
msg.sender.balance > (_amount - deposit.amount),
"Balance low. Topup your account"
);
(bool sent, ) = payable(address(this)).call{
value: _amount - deposit.amount
}("");
require(sent, "Failed to send Ether");
} else if (_amount < deposit.amount) {
reimburseUser(msg.sender, deposit.amount - _amount);
}
deposits[msg.sender][index] = Deposit(
getDepositTxId(_description, msg.sender, _to, _amount, _timestamp),
_description,
msg.sender,
_to,
_amount,
_timestamp,
false
);
// Update depositId => Deposit Mapping
require(
depositIdToDeposit[deposit.depositId].amount > 0,
"There is no deposit associated with this id"
);
// Delete old entry in the mapping
delete depositIdToDeposit[deposit.depositId];
// Update the mapping with new entry
depositIdToDeposit[depositId] = Deposit(
depositId,
_description,
msg.sender,
_to,
_amount,
_timestamp,
false
);
emit UpdatedDepositEvent(
_description,
msg.sender,
_to,
_amount,
_timestamp
);
}
[... Omitted Code ...]
removeDepositByIndex() function
This function removes a specific deposit from the deposits mapping for a specific user. This function is internal so only the contract can execute it, and it takes 2 arguments: _user (the account that initiated the deposit), _index (the position or index of the deposit inde the Deposit type array of the _user)
[... Omitted Code ...]
function updateDeposit(
bytes32 _depositId,
string memory _description,
address _to,
uint256 _amount,
uint256 _timestamp
) public payable isValidTimestamp(_timestamp) {...}
function removeDepositByIndex(address _depositor, uint256 _index) internal {
if (_index >= deposits[_depositor].length) return;
deposits[_depositor][_index] = deposits[_depositor][
deposits[_depositor].length - 1
];
deposits[_depositor].pop();
}
[... Omitted Code ...]
depositFunds() function
This is the function anyone can call to deposit funds in the timelock contract for future withdrawals. This function will take a couple arguments: _description, _to, _amount, _timestamp. Its visibility will be public so anyone can call it and it be set as payable because this function is used to move funds around (between accounts and smart contracts). We will also apply the isValidTimestamp() modifier to ensure that only a valid timestamp is passed to the function.
[... omitted code ...]
function getDepositTxId(...) public pure returns (bytes32) {...}
function depositFunds(
string memory _description,
address _to,
uint256 _amount,
uint256 _timestamp
) public payable isValidTimestamp(_timestamp) {
require(msg.sender.balance > _amount, "Balance is low.");
(bool sent, ) = payable(address(this)).call{value: _amount}("");
require(sent, "Failed to send Ether");
bytes32 depositId = getDepositTxId(
_description,
msg.sender,
_to,
_amount,
_timestamp
);
deposits[msg.sender].push(
Deposit(
depositId,
_description,
msg.sender,
_to,
_amount,
_timestamp,
false
)
);
// Update depositId => Deposit Mapping
depositIdToDeposit[depositId] = Deposit(
depositId,
_description,
msg.sender,
_to,
_amount,
_timestamp,
false
);
// Emit/Broadcast an event
emit DepositedFundsEvent(msg.sender, _to, _amount, _timestamp);
}
[... omitted code ...]
depositFunds() Test Script
In this section, we will run four major tests.
-
-
-
-
-
- Fund the smart contract so it can pay for transactions and ensure that it was funded properly with the correct amount.
-
-
-
-
-
- Ensure that a deposit with an invalid timestamp will successfully fail
-
-
-
-
-
- Deposit funds inside the smart contract and ensure that the transaction was successful
-
-
-
-
-
- Ensure that the function emitted an event (DepositedFundsEvent) that was broadcasted to the network after the transaction, by inspecting the receipt logs of the transaction.
-
-
Inside our Timelock.test.js, we will add the following scripts
[... Omitted Code …]
it('Initializes the contract with the correct values', async function(){...})
it('Funds Smart Contract Account', async function(){
try {
await web3.eth.sendTransaction({
from: (bob),
to: (timelockContract.address),
value: (await web3.utils.toWei("5", "ether")),
})
assert.equal(await web3.eth.getBalance(timelockContract.address), await web3.utils.toWei("5", "ether"), "Contract Not funded properly")
} catch (error) {
console.log(error)
}
})
it('Ensures the timestamp is in the future', async function() {
try {
let receipt = await timelockContract.depositFunds.call("Tuition Fees 2022", alice, ethToDeposit, Date.parse("2022-05-14") / 1000, {from:bob, value:ethToDeposit})
assert.notEqual(receipt, true);
} catch (error) {
assert(error.message.indexOf('revert') >= 0, 'Timestamp is in the past. Should be in the future');
return true;
}
})
it('Deposits funds', async function() {
depositReceipt = await timelockContract.depositFunds("Tuition Fees 2022", alice, ethToDeposit, (timestamp), {from:bob, value:ethToDeposit})
depositId = await timelockContract.getDepositTxId("Tuition Fees 2022", bob,alice, ethToDeposit, (timestamp),{from:bob})
res = (await timelockContract.getOneDeposit(depositId, {from:bob}))
assert(res[0].amount > 0, "The deposit was not successful");
})
it('Ensures that the deposit was broadcasted to the network', async function() {
let events = depositReceipt.logs.filter((log) => log.event == "DepositedFundsEvent");
if (events.length > 0) {
assert.equal(events[0].args._from, bob)
assert.equal(events[0].args._to, alice)
assert.equal(Number(events[0].args._amount), ethToDeposit)
assert.equal(Number(events[0].args._timestamp), timestamp)
} else{
assert(false)
}
})
[... Omitted Code …]
From the console, let’s run our test and see the output
$ truffle test
// The output should look like this
Using network 'test'.
Compiling your contracts...
===========================
> Compiling ./contracts/Timelock.sol
> Artifacts written to /var/folders/g5/wdhk0_qj4d11p4x83llpljwm0000gn/T/test--71066-Lqh4DzkfXl6K
> Compiled successfully using:
- solc: 0.8.15+commit.e14f2714.Emscripten.clang
Contract: Timelock
✔ Initializes the contract with the correct values
✔ Funds Smart Contract Account (1015ms)
✔ Ensures the timestamp is in the future (207ms)
✔ Deposits funds (1069ms)
✔ Ensures that the deposit was broadcasted to the network
5 passing (2.291s)
updateDeposit() Test Script
In this section, we will run a few tests.
-
-
-
-
-
- Update a specific Deposit by changing the _description argument
-
-
-
-
-
- Ensure that the function emitted an event (UpdatedDepositedEvent) that was broadcasted to the network with correct values after the update, by inspecting the receipt logs of the transaction.
-
-
Inside our Timelock.test.js, we will add the following scripts
[... Omitted Code ...]
it('Ensures that the deposit was broadcasted...', async function() {..})
it('Ensures that Deposit is updated properly', async function() {
try {
let receipt = await timelockContract.updateDeposit(depositId, "New Description", alice, await web3.utils.toWei("0.01", "ether"), (timestamp),{from:bob})
let events = receipt.logs.filter((log) => log.event == "UpdatedDepositEvent");
if (events.length > 0) {
assert.equal(events[0].args._from, bob)
assert.equal(events[0].args._to, alice)
assert.equal((events[0].args._description), "New Description")
assert.equal(Number(events[0].args._amount), +web3.utils.toWei("0.01", "ether"))
assert.equal((events[0].args._timestamp), timestamp)
assert.equal((events[0].event), "UpdatedDepositEvent")
} else{
assert(false)
}
} catch (error) {
console.log(error)
}
})
[... Omitted Code ...]
If we run our test, they should all pass
$ truffle test
// output should look like this
Using network 'test'.
Compiling your contracts...
===========================
> Compiling ./contracts/Timelock.sol
> Artifacts written to /var/folders/g5/wdhk0_qj4d11p4x83llpljwm0000gn/T/test--71612-mn81OD57qMCb
> Compiled successfully using:
- solc: 0.8.15+commit.e14f2714.Emscripten.clang
Contract: Timelock
✔ Initializes the contract with the correct values
✔ Funds Smart Contract Account (1010ms)
✔ Ensures the timestamp is in the future (193ms)
✔ Deposits funds (1071ms)
✔ Ensures that the deposit was broadcasted to the network
✔ Ensures that Deposit is updated properly (1053ms)
6 passing (3.134s)
getTxId() function
This function returns a computed version of the keccak-256 hash of its inputs and represents the txId of the deposit to be queued for execution. The function takes 3 arguments: _target (The target contract that will be in charge of kickstarting the transfer process), depositId, _func (the function that will execute the withdrawal of funds and transfer it to the receiver. This function will be located inside the _target contract). This _target contract is the TimelockFactory that we will create later. The function will be marked as public and will be pure. (txId ≠ depositId)
[... Omitted Code ...]
function depositFunds(..) public payable isValidTimestamp(_timestamp) {...}
function getTxId(
address _target,
bytes32 depositId,
string calldata _func
) public pure returns (bytes32) {
return keccak256(abi.encode(_target, depositId, _func));
}
[... Omitted Code ...]
isQueued() function
This function evaluates if a specific transaction has been queued by its _txId. The function takes one argument: _txId and returns a boolean value
[... Omitted Code ...]
function getTxId(...) public pure returns (bytes32) {..}
function isQueued(bytes32 _txId) public view returns (bool _isQueued) {
if (queued[_txId].to != address(0)) return true;
}
[... Omitted Code ...]
Cancel() function
This function cancels a queued transaction by its _txId. The function is only callable by the owner of the smart contract. It takes one argument: _txId. It emits an event (CanceledTxEvent) after the cancellation is successful. This function visibility is set to external because we do not want the smart contract to be able to call this function.
[... Omitted Code ...]
function isQueued(...) public view returns (bool _isQueued) {...}
function cancel(bytes32 _txId) external onlyOwner {
require(isQueued(_txId) == true, "NotQueuedError");
// require(queued[_txId].amount > 0, "NotQueuedError");
Deposit memory deposit = queued[_txId];
(, uint256 index) = getOneDeposit(deposit.depositId);
removeDepositByIndex(deposit.from, index);
// Reimburse the depositor
(bool ok, ) = (deposit.from).call{value: deposit.amount}("");
require(ok, "Reimbursement Error");
// Clear the memory
delete queued[_txId];
delete deposit;
delete index;
// Emit the event
emit CanceledTxEvent(_txId);
}
[... Omitted Code ...]
Queue() function
As explained previously, the queue() function will broadcast future transactions to be executed after a certain period. To ensure that only the owner of the smart contract can call the queue(), we will apply the onlyOwner() modifier. The function takes 3 arguments: _target (the contract that contains the function we would like to execute in the future), _depositId (the id that uniquely identifies a Deposit), _func (the function to call from the target contract that will execute the transfer of funds)
[... Omitted Code ...]
function cancel(bytes32 _txId) external onlyOwner {...}
function queue(
address _target,
bytes32 _depositId,
string calldata _func
) external onlyOwner {
Deposit memory deposit = depositIdToDeposit[_depositId];
bytes32 txId = getTxId(_target, _depositId, _func);
// Ensure that the deposit has not been queued yet
require(isQueued(txId) == false, "AlreadyQueuedError");
// ---|---------------|---------------------------|-------
// block block + MIN_DELAY block + MAX_DELAY
// Ensure the timestamp is within the allowed range
require(
deposit.timestamp > block.timestamp + MIN_DELAY &&
deposit.timestamp < block.timestamp + MAX_DELAY,
"TimestampNotInRangeError"
);
// Queue the deposit for execution by txId
queued[txId] = deposit;
// Emit an event
emit QueuedEvent(
txId,
_target,
deposit.to,
deposit.amount,
_func,
deposit.timestamp
);
// Free memory space
delete deposit;
delete txId;
}
[... Omitted Code ...]
Execute() function
As we previously discussed, Once the waiting time of a queued transaction has passed, meaning once the transaction/deposit is eligible for execution, the execute() function can now be called for that queued transaction/deposit. This function will take 3 arguments: _target (the timelock factory contract that will call the _func function), _depositId (the id that uniquely identifies the Deposit), _func (the function to call from the target contract that will execute the transfer of funds). The visibility of the function will be external, so it can be called by other addresses or smart contracts. The function is payable because it is responsible for moving funds between accounts. We will also apply the onlyOwner modifier to our function so only the account that deployed this contract can call it.
[... Omitted Code ...]
function queue(...) external onlyOwner {...}
function execute(
address _target,
bytes32 _depositId,
string calldata _func
) external payable onlyOwner returns (bytes memory) {
bytes32 txId = getTxId(_target, _depositId, _func);
Deposit memory deposit = queued[txId];
// Ensure the transaction is queued
require(queued[txId].amount > 0, "NotQueuedError");
// Ensure the delay has passed or been reached
require(
queued[txId].timestamp < block.timestamp,
"TimestampNotPassedError"
);
// Ensure the grace period has not expired yet
require(
block.timestamp < queued[txId].timestamp + GRACE_PERIOD,
"TimestampExpiredError"
);
// prepare data
bytes memory data;
data = abi.encodePacked(bytes4(keccak256(bytes(_func))), txId);
// call target
(bool ok, bytes memory res) = (deposit.to).call{value: deposit.amount}(
data
);
require(ok, "TxFailedError");
// Emit an event
emit ExecutedTxEvent(
txId,
_target,
deposit.to,
deposit.amount,
deposit.timestamp
);
// Free memory space
delete queued[txId];
delete deposit;
delete data;
// Return the receipt of the transaction
return res;
}
[... Omitted Code ...]
Once our deposit has been executed, we need a way to update the claim property of the deposit inside our mappings. To do so, we can create another function that will take care of that.
claim() function
This function will update the claimed field of an already executed Deposit/transaction. This function will take only one argument: _depositId. Its visibility will be set to external so it is only called outside the smart contract. In our case, onlyOwner modifier ensures that only the account that deployed the contract can call the function.
[... Omitted Code ...]
function execute(...) external payable onlyOwner returns (...) {..}
function claim(bytes32 _depositId) external onlyOwner {
(Deposit memory oneDeposit, uint256 index) = getOneDeposit(_depositId);
// Ensure that the deposit has not been claimed yet
require(
oneDeposit.claimed == false &&
depositIdToDeposit[_depositId].claimed == false,
"This deposit has been claimed already"
);
// Update the claim field on the deposits mapping
deposits[oneDeposit.from][index] = Deposit(
_depositId,
oneDeposit.description,
oneDeposit.from,
oneDeposit.to,
oneDeposit.amount,
oneDeposit.timestamp,
true
);
// Update the claim field on the depositIdToDeposit mapping
depositIdToDeposit[_depositId].claimed = true;
// Free memory space
delete oneDeposit;
// Emit an event
emit ClaimedDepositEvent(_depositId);
}
[... Omitted Code ...]
If we try to compile the Timelock smart contract, everything should still work
$ truffle compile
// The output should look like this
Compiling your contracts...
===========================
> Compiling ./contracts/Timelock.sol
> Artifacts written to <PATH TO YOUR PROJECT>/timelock/build/contracts
> Compiled successfully using:
- solc: 0.8.15+commit.e14f2714.Emscripten.clang
In this section, we learned how to code a Timelock Smart Contract, which allows users to deposit funds to be withdrawn at a later date. So far, we have been able to only test the deposit function and the broadcasting event that is emitted to the network, as a result of a successful deposit. In order to test the rest of our code (queuing a transaction, executing the queued transaction and claiming the funds), we will need to create the _target smart contract and write the _func that is going to be called inside our Queue() and Execute() functions. This _target contract will work as a Timelock Factory contract. Let’s get into it
Write a Timelock Smart Contract Factory
To write a timelock smart contract factory, make sure we are inside the project folder (Timelock) and run the following command:
//Inside Project folder, create new contract
$ truffle create contract TimelockFactory
When you open the TimelockFactory.sol, inside your contracts/ folder, your file should look like this
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
contract TimelockFactory {
constructor(){
}
}
In order to use our Timelock contract inside the factory contract, we will import it like so:
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
import "./Timelock.sol";
contract TimelockFactory {
constructor(){
}
}
The factory contract will have 2 main variables and a mapping
-
-
-
-
-
- timelock: this variable is of type Timelock and will provide us with an instance/reference of the Timelock contract that we can use inside the factory to call functions and check for deposits
-
-
-
-
-
- owner: this variable represents the owner or the account that owns the factory contract.
-
-
-
-
-
- wallets: this mapping stores all the wallets created by a specific account
-
-
[... Omitted Code ...]
contract TimelockFactory {
// Timelock Smart Contract variable
Timelock timelock;
// Owner of the factory contract
address public owner;
// Mapping which stores all wallets created by an account (account => wallet[])
mapping(address => address[]) wallets;
constructor(){
}
}
onlyTimeLock() modifier
This modifier will ensure that only the Timelock contract can access a specific resource or call a function
[... Omitted Code ...]
mapping(address => address[]) wallets;
// Modifiers
// Ensures that only the timelock contract can access a specific resource
modifier onlyTimelock() {
require(
msg.sender == address(timelock),
"Only Timelock can access this resource"
);
_;
}
[... Omitted Code ...]
WalletCreatedEvent() event
As previously discussed, it is recommended to always emit an event whenever the state of the blockchain is updated / changed / mutated. Our Timelock factory allows us to create new instances of the Timelock contract; so we will emit the WalletCreatedEvent() whenever a Timelock wallet is successfully created. This event takes 4 arguments: _wallet (the wallet newly created), _owner (the account that created the wallet), _description (a brief description of the wallet), _createdAt (timestamp when the wallet was created)
[... Omitted Code ...]
modifier onlyTimelock() {...}
event WalletCreatedEvent(
address indexed _wallet,
address indexed _owner,
string _description,
uint256 _createdAt
);
[... Omitted Code ...]
Constructor() function
Inside our constructor function, pass the timelock smart contract address as an argument and set its instance, then set the owner of the factory contract on deployment
[... Omitted Code ...]
event WalletCreatedEvent(...);
constructor(address payable _timelockContractAddress) {
// initialize the timelock contract variable
timelock = Timelock(_timelockContractAddress);
// Set the owner of the timelock factory
owner = msg.sender;
}
[... Omitted Code ...]
The contract should compile with this command
$ truffle compile
Compiling your contracts...
===========================
> Compiling ./contracts/Timelock.sol
> Compiling ./contracts/TimelockFactory.sol
> Artifacts written to <PATH TO YOUR PROJECT FOLDER>/timelock/build/contracts
> Compiled successfully using:
- solc: 0.8.15+commit.e14f2714.Emscripten.clang
Now is a good time to test our contract and make sure it deploys as expected and returns the correct values.
Write A Test For The Timelock Smart Contract Factory
Inside our test sub-directory, let’s create a file called TimelockFactory.test.js with the following command:
$ touch test/TimelockFactory.test.js
Then add the following code
let Timelock = artifacts.require('./Timelock');
let TimelockFactory = artifacts.require('./TimelockFactory');
let ethToDeposit = web3.utils.toWei("1", "ether");
let creator;
let bob;
let alice;
let timelockContract;
let timelockFactoryContract;
let timestamp;
let depositId;
let deposit;
let timelockedWallets;
let timelockedWalletInstance;
contract('Timelock Factory', async (accounts, network, deployer) => {
// Before running the tests, deploy contracts and setup accounts
before("Deploy Contracts", async function() {
web3Accounts = await web3.eth.getAccounts()
creator = accounts[0] || web3Accounts[0];
bob = accounts[1] || web3Accounts[1];
alice = accounts[9] || web3Accounts[9];
timelockContract = await Timelock.deployed();
timelockFactoryContract = await TimelockFactory.deployed();
// Set a timestamp. This should be a date in the future
timestamp = Date.parse("Thu Nov 24 2022 10:00:50 GMT-0800 (Pacific Standard Time)") / 1000
});
it('Initializes the contract with the correct values', async function() {
assert.equal(await timelockFactoryContract.owner(), creator, 'Contract not owned by Timelock')
assert.notEqual(await timelockFactoryContract.address, 0x0, 'The smart contract address was set')
});
})
The next thing we need to do is update our deployment script file to take the new factory contract into account.Go to the migrations/ folder, open the 2_deploy_contracts.js file and update it like so:
const Timelock = artifacts.require("Timelock");
const TimelockFactory = artifacts.require("TimelockFactory");
module.exports = async function (deployer, network, accounts) {
// Pass initial supply as argument of the deployer.
await deployer.deploy(Timelock, "Family Timelock Funds", accounts[0]);
await deployer.deploy(TimelockFactory, await Timelock.address);
};
If we now run our test, with truffle test, the output should look like so
[... Omitted Output ...]
Contract: Timelock Factory
✔ Initializes the contract with the correct values
[... Omitted Output ...]
getWallets() function
This function returns an array of wallets created by the account calling the function
[... Omitted Code ...]
constructor(...){...}
function getWallets() public view returns (address[] memory _wallets) {
return wallets[msg.sender];
}
[... Omitted Code ...]
newTimeLockedWallet() function
This function creates a new Timelock wallet and returns the address of the newly created wallet by the account calling the function. It takes one argument: _description (a description of the newly created wallet). The visibility of this function will be public so anyone can create a new Timelock wallet.
[... Omitted Code ...]
function getWallets() public view returns(...){...})
function newTimeLockedWallet(string memory _description)
public
returns (address wallet)
{
// Create new instance of the Timelock contract and return the address
wallet = address(new Timelock(_description, msg.sender));
// Store the wallet inside the wallets mapping for the current account
wallets[msg.sender].push(wallet);
// Emit an event
emit WalletCreatedEvent(
wallet,
msg.sender,
_description,
block.timestamp
);
// Return the newly created wallet
return wallet;
}
[... Omitted Code ...]
transferFunds() function (_func from the Timelock contract)
This function is responsible for transferring funds to the recipient. The function will take one argument: _txId (the id of the transaction/deposit to be executed). This can only be called by the timelock contract. To ensure that, we will apply the onlyTimelock() modifier we created earlier. The visibility of the function is external so the factory contract cannot invoke it. Because this function is also responsible for moving funds around between accounts, it will be marked as payable.
[... Omitted Code ...]
function newTimeLockedWallet(...)public returns (...){...})
function transferFunds(bytes32 _txId) external payable onlyTimelock {
// Extract the values that we need from the queued mapping
// We only need the recipient and the amount of the deposit
(,,,address to,uint256 amount,,) = timelock.queued(_txId);
(bool sent, ) = payable(to).call{value: amount}("");
require(sent, "Failed to send Ether");
}
[... Omitted Code ...]
Queue() function test script
Now that we have defined our _target contract (Timelock Factory) and the _func function (transferFunds()), it is the time to write a complete test script that will ensure that a deposit is queued.
We will write these test units inside TimelockFactory.test.js file. Add 2 test units to ensure that:
-
-
-
-
-
- The factory creates a new instance of the Timelock contract
-
-
-
-
-
- The new instance of the Timelock contract gets funded, to pay for transactions
-
-
[... Omitted Code ...]
it('Initializes the contract with...', async function() {...})
it('Creates a new Timelocked Wallet Instance', async function() {
await timelockFactoryContract.newTimeLockedWallet("Bob's Family Funds", {
from: bob
})
timelockedWallets = await timelockFactoryContract.getWallets({from:bob});
timelockedWalletInstance = await new web3.eth.Contract(timelockContract.abi, timelockedWallets[0]);
assert.equal(await timelockedWalletInstance.methods.owner().call(), bob, "The owner of the timelocked instance was not set properly")
assert.equal(await timelockedWalletInstance.methods.description().call(), "Bob's Family Funds", "The description was not set properly")
})
it('Funds Smart Contracts Accounts', async function(){
try {
const receipt = await web3.eth.sendTransaction({
from: String(accounts[7]),
to: String(timelockedWallets[0]),
value: String(await web3.utils.toWei("50", "ether")),
})
assert.equal(await web3.eth.getBalance(timelockedWallets[0]), await web3.utils.toWei("50", "ether"), "Timelocked Wallet Not funded properly");
} catch (error) {
console.log(error)
}
})
[... Omitted Code ...]
By running our test, everything should still work
$ truffle test
[... Omitted Output ...]
Contract: Timelock Factory
✔ Initializes the contract with the correct values
✔ Creates a new Timelocked Wallet Instance (1046ms)
✔ Funds Smart Contracts Accounts (1011ms)
[... Omitted Output ...]
In order to test that the deposit function works, from the Timelock Factory perspective, we will add the following test unit
[... Omitted Code ...]
it('Creates a new Timelocked Wallet Instance', async function() {...})
it('Deposits funds', async function() {
try {
await timelockedWalletInstance.methods.depositFunds("Tuition Fees 2022", alice, ethToDeposit, (timestamp)).send({from:bob, value:ethToDeposit, gas:"2100000"})
depositId = await timelockedWalletInstance.methods.getDepositTxId("Tuition Fees 2022", bob,alice, ethToDeposit, (timestamp)).call({from:bob})
const res = await (timelockedWalletInstance.methods.getOneDeposit(depositId)).call({from:bob})
deposit = res[0]
assert.equal(deposit.description, "Tuition Fees 2022", "Description was not properly set on the Deposit")
assert.equal(deposit.from, bob, "The sender was not properly set on the Deposit")
assert.equal(deposit.to, alice, "The receiver was not properly set on the Deposit")
} catch (error) {
console.log(error)
}
})
[... Omitted Code ...]
By running the test once again, everything should still work
$ truffle test
[... Omitted Output ...]
Contract: Timelock Factory
✔ Initializes the contract with the correct values
✔ Creates a new Timelocked Wallet Instance (1046ms)
✔ Funds Smart Contracts Accounts (1011ms)
✔ Deposits funds (1038ms)
[... Omitted Output ...]
Now that we have the funds successfully deposited inside the Timelock instance, we are now going to queue it for future withdrawal. But before we do so, we will need to write a few helper functions, inside our TimelockFactory.test.js.
getABIEntry() function: Helper
Declare this function outside of your test block (contract(‘’, async(…)=>{…})). It will return an ABI entry from the TimelockFactory.json artifact. The function takes 3 arguments: _contractArtifact (the artifact of the contract to access), _parameterType (the type of entry we want to access; could be a variable, a function, etc.), and _parameterName (The name of the entry we would like to extract or filter by)
[... Omitted Code ...]
function getABIEntry(_contractArtifact, _parameterType, _parameterName){
const abi = _contractArtifact.abi;
const filtered = abi.filter((interface) => interface.type == _parameterType && interface.name == _parameterName)
return filtered[0];
}
contract(‘Timelock Factory’, async(...)=>{...})
[... Omitted Code ...]
addDays() function: Helper
Declare this function outside of your test block (contract(‘’, async(…)=>{…})). The goal of this function is to add x number of days to a specific date, and return the new date. It takes 2 arguments: date (which is the date we would like to add days to), days (the number of days to add to the date). We will use this function later in the tutorial
[... Omitted Code ...]
function getABIEntry(...){...}
async function addDays(date, days) {
var result = new Date(date);
result.setDate(result.getDate() + days);
return Date.parse(result);
}
contract(‘Timelock Factory’, async(...)=>{...})
Let’s test the Queue() function. Inside the TimelockFactory.test.js add the test unit below. This unit test ensures that a transaction is successfully queued and that an event is emitted as a result of the queuing succeeding.
[... Omitted Code ...]
it('Deposits funds', async function() {})
it('Queues a Withdrawal transaction after the deposit', async function() {
const abiEntry = await getABIEntry(TimelockFactory, "function", "transferFunds")
try {
const txId = await timelockedWalletInstance.methods.getTxId(
timelockFactoryContract.address, // Target Smart Contract to Execute
depositId, // The deposit Id (which contains all information about a deposit)
abiEntry.name + "(bytes32)", // The function to run from the _target contract
).call({from:bob})
const receipt = await timelockedWalletInstance.methods.queue(await timelockFactoryContract.address, depositId, abiEntry.name + "(bytes32)").send({from: bob, gas:"2100000"});
assert.equal(receipt.events.QueuedEvent.event, "QueuedEvent", "The QueuedEvent was not fired")
assert.equal(Number(receipt.events.QueuedEvent.returnValues._amount), ethToDeposit, "The amount is incorrect")
assert.equal(await timelockedWalletInstance.methods.isQueued(txId).call({from:bob}), true, "Transaction was not properly queued")
} catch (error) {
console.log(error)
}
})
[... Omitted Code ...]
By running the test once again, everything should still work
$ truffle test
[... Omitted Output ...]
Contract: Timelock Factory
✔ Initializes the contract with the correct values
✔ Creates a new Timelocked Wallet Instance (1046ms)
✔ Funds Smart Contracts Accounts (1011ms)
✔ Deposits funds (1038ms)
✔ Queues a Withdrawal transaction after the deposit (1030ms)
[... Omitted Output ...]
Because we need to wait a certain period of time before we can execute a queued transaction or deposit, we will need to make use of a library from OpenZeppelin that will allow us to manipulate the block.timestamp and go into the future in order to execute our queued transaction.
Inside the terminal, type
$ npm install --save-dev @openzeppelin/test-helpers
We will import this package inside our TimelockFactory.test.js, right at the top of the file, like so
require('@openzeppelin/test-helpers/configure')({ environment: 'web3', provider: web3.currentProvider});
const { BN, constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers');
let Timelock = artifacts.require('./Timelock');
let TimelockFactory = artifacts.require('./TimelockFactory');
[... Omitted Code ...]
Let’s add the test unit, which will ensure that the current time (block.timestamp) was successfully manipulated and that we are presently in the future, where our transaction should be ready to execute
[... Omitted Code ...]
it('Queues a Withdrawal...', async function() {...})
it('Advances the time to execute the future transaction', async function(){
const res = await timelockedWalletInstance.methods.getOneDeposit(depositId).call({from:bob});
let currentBlock = await web3.eth.getBlock('latest');
let blockTimestamp = currentBlock.timestamp;
assert(res[0].timestamp > blockTimestamp, "TimestampNotPassedError")
// Set a Future date. In this case, 1 days after the timestamp
const futureDate = (await addDays(Date.parse(new Date(timestamp*1000)), 1))
// Go Back to the Future (1 days from now)
await time.increaseTo(futureDate/1000);
currentBlock = await web3.eth.getBlock('latest');
blockTimestamp = currentBlock.timestamp;
// Ensure GRACE_PERIOD for executing the function has not expired yet:
// block.timestamp < deposit.timestamp + GRACE_PERIOD
assert(
res[0].timestamp + await timelockContract.GRACE_PERIOD() > blockTimestamp,
"TimestampExpiredError"
)
})
[... Omitted Code ...]
The test will output something like this
$ truffle test
[... Omitted Output ...]
Contract: Timelock Factory
✔ Initializes the contract with the correct values
✔ Creates a new Timelocked Wallet Instance (1046ms)
✔ Funds Smart Contracts Accounts (1011ms)
✔ Deposits funds (1038ms)
✔ Queues a Withdrawal transaction after the deposit (1030ms)
✔ Advances the time to execute the future transaction
[... Omitted Output ...]
Execute() function test script
Now that we can deposit funds, queue the deposit for withdrawal and advance block.timestamp into the future, it is the time to write a test script to ensure that a queued transaction/deposit is executed successfully.
[... Omitted Code ...]
it('Advances the time to execute...', async function(){...})
it('Executes the Queued Transaction', async function() {
const abiEntry = await getABIEntry(TimelockFactory, "function", "transferFunds")
const userBalanceBeforeExecution = await web3.utils.fromWei(await web3.eth.getBalance(alice))
const calculated = Number(await web3.utils.fromWei((ethToDeposit))) + Number(userBalanceBeforeExecution)
try {
// Execute the transaction
const receiptExec = await timelockedWalletInstance.methods.execute(
await timelockFactoryContract.address,
depositId,
abiEntry.name + "(bytes32)",
).send({from: bob, gas:"2100000"})
assert.equal(Number(await web3.utils.fromWei(await web3.eth.getBalance(alice))).toFixed(3), calculated.toFixed(3), "The transfer of funds was not successful")
} catch (error) {
console.log(error)
}
})
[... Omitted Code ...]
The final test ensures that the claim field is successfully updated once the transfer has been executed. Use of the claim() function to achieve this. Inside out TimelockFactory.test.js file, add the following test unit.
[... Omitted Code ...]
it('Executes the Queued Transaction', async function() {..})
it('Updates the Claimed field of the Deposit to TRUE', async function(){
await timelockedWalletInstance.methods.claim(depositId).send({from:bob, gas:"2100000"});
const oneDeposit = await timelockedWalletInstance.methods.getOneDeposit(depositId).call({from:bob});
const depositIdToDepositMapping = await timelockedWalletInstance.methods.depositIdToDeposit(depositId).call({from:bob});
assert.equal(oneDeposit[0].claimed, true, "The Deposit was not updated successfully")
assert.equal(depositIdToDepositMapping.claimed, true, "The depositIdToDepositMapping was not updated successfully")
})
[... Omitted Code ...]
The test will output something like this
$ truffle test
[... Omitted Output ...]
Contract: Timelock Factory
✔ Initializes the contract with the correct values
✔ Creates a new Timelocked Wallet Instance (1046ms)
✔ Funds Smart Contracts Accounts (1011ms)
✔ Deposits funds (1038ms)
✔ Queues a Withdrawal transaction after the deposit (1030ms)
✔ Advances the time to execute the future transaction
✔ Executes the Queued Transaction (1013ms)
✔ Updates the Claimed field of the Deposit to TRUE (1037ms)
17 passing (12s)
The test suite is now complete and all tests should work as expected. The full truffle test should return the following output
Using network 'test'.
Compiling your contracts...
===========================
> Compiling ./contracts/Timelock.sol
> Compiling ./contracts/TimelockFactory.sol
> Artifacts written to /var/folders/g5/wdhk0_qj4d11p4x83llpljwm0000gn/T/test--74323-eAl3Rj1HgEoX
> Compiled successfully using:
- solc: 0.8.15+commit.e14f2714.Emscripten.clang
Contract: Timelock
✔ Initializes the contract with the correct values
✔ Funds Smart Contract Account (1014ms)
✔ Ensures the timestamp is in the future (179ms)
✔ Deposits funds (1078ms)
✔ Ensures that the deposit was broadcasted to the network
✔ Ensures that Deposit is updated properly (1050ms)
✔ Queues a Withdrawal transaction after the deposit (1056ms)
✔ Cancels an already queued transaction (1078ms)
✔ Successfully Fails at Cancelling an already cancelled transaction
Contract: Timelock Factory
✔ Initializes the contract with the correct values
✔ Creates a new Timelocked Wallet Instance (1044ms)
✔ Funds Smart Contracts Accounts (1011ms)
✔ Deposits funds (1036ms)
✔ Queues a Withdrawal transaction after the deposit (1022ms)
✔ Advances the time to execute the future transaction
✔ Executes the Queued Transaction (1013ms)
✔ Updates the Claimed field of the Deposit to TRUE (1037ms)
17 passing (12s)
Let’s say we remove the following unit test from TimelockFactory.test.js,
[... Omitted Code ...]
it('Advances the time to execute...', async function(){...})
[... Omitted Code ...]
And then, let’s run our test once again.
$ truffle test
[... Omitted Output ...]
Contract: Timelock Factory
✔ Initializes the contract with the correct values
✔ Creates a new Timelocked Wallet Instance (1045ms)
✔ Funds Smart Contracts Accounts (1014ms)
✔ Deposits funds (1029ms)
✔ Queues a Withdrawal transaction after the deposit (1034ms)
Error: Transaction has been reverted by the EVM:
{
"transactionHash": "0xb234e59cb5ac02d84fdb7e0a83378f4c798b2a57503a1c46a03b1711a24143c7",
"transactionIndex": 0,
"blockNumber": 10,
"blockHash": "0xa0e5c852b88a634b81070dca0e7059333c10b54bebcb1d26891f0d2cb32ff37a",
"from": "0xf17f52151ebef6c7334fad080c5704d77216b732",
"to": "0xb9462ef3441346dbc6e49236edbb0df207db09b7",
"cumulativeGasUsed": 43019,
"gasUsed": 43019,
"contractAddress": null,
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"status": false,
"effectiveGasPrice": 2961212234,
"type": "0x2",
"events": {}
}
at Object.TransactionError (/usr/local/lib/node_modules/truffle/build/webpack:/node_modules/web3-core-helpers/lib/errors.js:87:1)
at Object.TransactionRevertedWithoutReasonError (/usr/local/lib/node_modules/truffle/build/webpack:/node_modules/web3-core-helpers/lib/errors.js:98:1)
at /usr/local/lib/node_modules/truffle/build/webpack:/node_modules/web3-eth/node_modules/web3-core-method/lib/index.js:396:1
at processTicksAndRejections (node:internal/process/task_queues:96:5) {
receipt: {
transactionHash: '0xb234e59cb5ac02d84fdb7e0a83378f4c798b2a57503a1c46a03b1711a24143c7',
transactionIndex: 0,
blockNumber: 10,
blockHash: '0xa0e5c852b88a634b81070dca0e7059333c10b54bebcb1d26891f0d2cb32ff37a',
from: '0xf17f52151ebef6c7334fad080c5704d77216b732',
to: '0xb9462ef3441346dbc6e49236edbb0df207db09b7',
cumulativeGasUsed: 43019,
gasUsed: 43019,
contractAddress: null,
logsBloom: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
status: false,
effectiveGasPrice: 2961212234,
type: '0x2',
events: {}
}
}
✔ Executes the Queued Transaction (1024ms)
✔ Updates the Claimed field of the Deposit to TRUE (1045ms)
16 passing (12s)
This error is occurring because the current block.timestamp is still less than the GRACE_PERIOD + timestamp (of the deposit). That is the reason why we had to go to the future in order to execute our transaction.
If you put the unit test back, everything should now work again.
So far, we have learnt how to write, test and deploy our smart contract on our local machine, using the local truffle blockchain. It is time to step back and dive a little deeper into how Smart Contract Deployment works and learn how to deploy a Smart Contract on a public test net, such as Goerli using an Infura node.
Write A Timelock Smart Contract Deployment Script
In our previous examples, we use the truffle test command which runs the truffle compile and the truffle migrate commands consecutively. In the background, Truffle launches a local/virtual blockchain to compile, deploy, and then test our smart contract. At the end of the process, Truffle destroys the virtual network and its associated data. Nothing persists once the test is complete.
To deploy to a local or public network that will persist our contract and its data, we need to explicitly use the truffle migrate command, and specify the network we would like to deploy to, with the –network flag. Truffle is smart enough to look up those configurations inside the truffle-config.js file, located in the root of your project folder.
Deployment Configuration
Now that we understand how the deployment process of a smart contract works, let’s learn how to configure Truffle to specify where to deploy our artifacts/builds, and what network to deploy our smart contracts to. To change the directory where our artifacts and builds are generated, modify our truffle-config.js file, and add the following line of code:
module.exports = {
contracts_build_directory: "./client/src/contracts",
// [...omitted code...]
}
If we recompile our project, we will have a new directory ./client/src/contracts, and inside we will have our artifacts files.
$ truffle compile
// command output
Compiling your contracts...
===========================
> Compiling ./contracts/Timelock.sol
> Compiling ./contracts/TimelockFactory.sol
> Artifacts written to ./client/src/contracts
> Compiled successfully using:
- solc: 0.8.15+commit.e14f2714.Emscripten.clang
Now our artifact files are available to our client-side application, inside the clients/src/contracts/ folder.
Deploy A Timelock Smart Contract To Goerli Testnet With Infura & Metamask
Using the Goerli network, along with Infura we avoid downloading a local blockchain or an Ethereum client. Infura will allow us to connect to an Ethereum node that it manages. Infura facilitates deployments to the mainnet, Sepolia, and Goerli.
In order to test our deployed Smart contracts on these Networks, we will need some FaucetETH. For Goerli, go to https://faucet.goerli.mudit.blog/ or https://faucets.chain.link/ or https://goerlifaucet.com/ to request some test Ether. For Sepolia, go to https://faucet.sepolia.dev/ or https://fauceth.komputing.org/
To get started with Infura, go to https://infura.io/register and sign up for a free account. Once your create an account is verified, you will get forwarded to the dashboard with a blank screen. Click on the Create New Project button. Give your project a name like Timelock. Once created, you will get forwarded to the project page that provides critical variables such as project id, project secret, Infura endpoints that we will use to connect to our node instance on Infura.
It is important to note that because we chose to connect to the Goerli network, we should change the Endpoints field from MAINNET to GOERLI. This is also going to update the Goerli endpoint we will use.
The first thing we need to do is store our Infura project ID into environment variables. To do so, we will do a couple of things:
-
-
-
-
-
- Install the dotenv package inside your project
-
-
-
-
-
- Create a .env file that will store our environment variables
-
-
// Install dotenv package
$ npm i --save dotenv
// Create a dotenv file to store environment variables
$ touch .env
Inside the .env file, add the following environment variables:
MNEMONIC="<YOUR_METAMASK_MNEMONIC>"
INFURA_PROJECT_ID="<YOUR_INFURA_PROJECT_ID>"
To get the mnemonic for your MetaMask accounts,
-
-
-
-
-
- Make sure you are on the Goerli test network
-
-
-
-
-
- Click on the account Icon
-
-
-
-
-
- Click Settings and select Security & Privacy
-
-
-
-
-
- On the Security & Privacy screen, click on Reveal Secret Recovery Phrase
-
-
-
-
-
- Enter your password and the next screen will reveal your seed phrase
-
-
-
-
-
- Copy your see phrase and update your .env file with the new value
-
-
The next step is to update our truffle-config.js file to include the Goerli configuration. But first, let’s import the dotenv package so we can access our environment variables.
require('dotenv').config();
module.exports = {
[...omitted code...]
}
Next, we will install a package called @truffle/hdwallet-provider,which is a Web3 provider that we will use to sign transactions for addresses derived from a 12 or 24-word mnemonic. To install it, run the command
$ npm i --save-dev @truffle/hdwallet-provider
Once installed, we will require it inside our truffle-config.js file, and the code will look like this:
// Require the dotenv package inside our project
require('dotenv').config();
// Require the HDWalletProvider inside our project
const HDWalletProvider = require('@truffle/hdwallet-provider');
module.exports = {
[...omitted code...]
networks: {
goerli:{
provider: () => {
return new HDWalletProvider(
process.env["MNEMONIC"],
'https://goerli.infura.io/v3/' + process.env["INFURA_PROJECT_ID"]
);
},
network_id: 5,
gas: 6700000,
gasPrice: 10000000000,
skipDryRun: true,
},
[...omitted code]
},
[...omitted code]
}
With the Goerli configuration complete, the next step is to fund our account with some test ether. Head over to https://goerlifaucet.com/ and follow the steps to fund your account. Make sure that you switch to the Goerli testnet on MetaMask and that you have your MetaMask address handy, and ready to use.
Once funds have successfully transferred into your MetaMask account, you are ready to deploy. Use the command:
$ truffle migrate --network goerli --reset
Compiling your contracts...
===========================
> Everything is up to date, there is nothing to compile.
Starting migrations...
======================
> Network name: 'goerli'
> Network id: 5
> Block gas limit: 30000000 (0x1c9c380)
1_initial_migration.js
======================
Replacing 'Migrations'
----------------------
> transaction hash: 0x03698f56403be2d85595293f7092d18c8e54ab00eee337dd33b36ff0c819c2ca
> Blocks: 0 Seconds: 8
> contract address: 0x934AAbed845B65DCE4d5B3330Dc1719b142E44d5
> block number: 8008403
> block timestamp: 1669254180
> account: 0x800705369a9244e399250574B7f8Fa41F81CbcCc
> balance: 0.093714564994246458
> gas used: 250154 (0x3d12a)
> gas price: 2.500000023 gwei
> value sent: 0 ETH
> total cost: 0.000625385005753542 ETH
Pausing for 2 confirmations...
-------------------------------
> confirmation number: 1 (block: 8008404)
> confirmation number: 2 (block: 8008405)
> Saving migration to chain.
> Saving artifacts
-------------------------------------
> Total cost: 0.000625385005753542 ETH
2_deploy_contracts.js
=====================
Deploying 'Timelock'
--------------------
> transaction hash: 0x9095f7914fca222e2678de096e282d1dd34b0fe3d86a77a14b6dd2aa64953ffa
> Blocks: 1 Seconds: 16
> contract address: 0x6FFF51144e39084A0e02D4834Fb13EE1B8eab310 👈
> block number: 8008408
> block timestamp: 1669254264
> account: 0x800705369a9244e399250574B7f8Fa41F81CbcCc
> balance: 0.083017437349041002
> gas used: 4232938 (0x4096ea)
> gas price: 2.500000034 gwei
> value sent: 0 ETH
> total cost: 0.010582345143919892 ETH
Pausing for 2 confirmations...
-------------------------------
> confirmation number: 1 (block: 8008409)
> confirmation number: 2 (block: 8008410)
Deploying 'TimelockFactory'
---------------------------
> transaction hash: 0xa28eba6af921020693f0a0b3c1bdfb48b91f15811b601b45beb5936c7c752ee7
> Blocks: 1 Seconds: 8
> contract address: 0x3EedA472f9b6441F2C7C12445975EdCbB00Ef589 👈
> block number: 8008411
> block timestamp: 1669254300
> account: 0x800705369a9244e399250574B7f8Fa41F81CbcCc
> balance: 0.269746874615479102
> gas used: 5308225 (0x50ff41)
> gas price: 2.500000044 gwei
> value sent: 0 ETH
> total cost: 0.0132705627335619 ETH
Pausing for 2 confirmations...
-------------------------------
> confirmation number: 1 (block: 8008412)
> confirmation number: 2 (block: 8008413)
> Saving migration to chain.
> Saving artifacts
-------------------------------------
> Total cost: 0.023852907877481792 ETH
Summary
=======
> Total deployments: 3
> Final cost: 0.024478292883235334 ETH
Let’s analyze the output above. As you can see, the contracts have been successfully migrated to the Goerli testnet and two transaction hashes were generated as a result of deploying each contract.
Each contract has an address and the account that deployed the contract is identified as 0x800705369a9244e399250574B7f8Fa41F81CbcCc.
To verify that our transaction got published to the Goerli testnet, copy one of the transaction hashes and head over to https://goerli.etherscan.io/. On that page, paste the transaction hash you copied earlier. You should see detailed information about the transaction itself. Use the transaction hash that was generated when the Timelock.sol contract was deployed: 0x9095f7914fca222e2678de096e282d1dd34b0fe3d86a77a14b6dd2aa64953ffa
That’s it! Congratulations. You have been able to Write A Timelock Smart Contract, a Timelock Factory Smart Contract and deploy them to the Goerli testnet using the Infura node manager. Go ahead and flex on your friends. Your contract lives on a public testnet.
Conclusion
In this blog post, we learnt how to set up our project to work like a blockchain project using the truffle init command. This command generated a boilerplate for us to use. Next, we wrote two smart contracts and their associated tests. We also went deep into advanced concepts, such as Solidity state variables and functions. We learnt how to use a factory contract to deploy new versions of a contract. Next, we explored in detail different ways to deploy our contract. We learned how to test our smart contract on a local blockchain like the Truffle blockchain, and finally, we deployed our contract to a public testnet Goerli, with the help of Infura, a service provider for managed Ethereum nodes, and Metamask.
Now that you know how to Write a Timelock Smart Contract, I hope you are ready to take your professional career to the next level!
Cheers and Happy Coding!