Deploying A Governance Smart Contract on Lisk Testnet: A Step-by-Step Guide
Comprehensive guide to writing, testing, deploying, and verifying a smart contract to the LISK Testnet.

I am a skilled full-stack developer experienced in JavaScript, TypeScript, react, PHP, and Laravel. I create visually appealing and responsive user interfaces. I have a strong background in back-end development with proficiency in Laravel, enabling me to build robust and scalable server-side solutions. My skills extend to data management using tools like SQL, and POSGRESQL for efficient storage and real-time communication. Additionally, I actively contribute to open-source projects and have a knack for BAAS like supabase.
As a budding smart contract engineer, knowing how to write secure smart contracts is just the beginning. The rest of the journey involves understanding the right networks to deploy on and knowing how to deploy and verify your smart contracts safely.
What exactly is Lisk?
Lisk provides a cost-efficient, fast, and scalable Layer 2 (L2) network based on Optimism (OP) that is secured by Ethereum. Built on the MIT-licensed OP Stack and partnered with Gelato as a RaaS provider, Lisk contributes to scaling Ethereum to a level where blockchain infrastructure is ready for mass adoption.
Lisk creates an interoperable, seamless base layer that offers a great user experience, shared liquidity, and low-cost transactions to ensure that web3 revolutionizes people's lives just as web2 did.
Lisk's Mainnet keeps it in sync with the Ethereum L1 after regular intervals to ensure the security and validity of its network. This means that apart from the measures taken to secure the Superchain and Lisk's Mainnet, the L1 security of Ethereum is also available to users interacting with Lisk L2.
For more information, dive into the Lisk documentation to start exploring and experimenting with Lisk's amazing features.
Let's get our hands dirty
Scope of the project
Developing a decentralized governance smart contract that enables the ability to vote, fetch available votes, and checkmate against double-voting.
Testing and compiling with Hardhat - a development environment for Ethereum software.
Requesting faucet tokens from Sepolia and bridging to Lisk Sepolia testnet (video guide) - This is necessary because deploying a smart contract costs gas fees.
Deploying our script to the Lisk testnet and viewing on block scout
Verifying our script. - Verifying a smart contract improves Transparency and Trust, Ease of Interaction, Security, and auditing, amongst many others.
PREREQUISITES
Node v18+ (Download here)
An Evm wallet - Metamask (Learn how to setups here)
Set... Go!
Create a node project
Create a new project folder or navigate into an already existing one from your terminal. For instance:cd new-governance assuming the name of your folder is new-governance.
Then run the following command to initialize a node project
npm init --y
Next, we need to install HardHat
npm install --save-dev hardhat
Great job, now we need to create a new Hardhat project
npx hardhat init
Select Create a TypeScript project, then press Enter to confirm the installation at your project root.
Select y for both adding a .gitignore and loading the sample project. Optionally, you can decide to share crash reports and usage data with HardHat.
✔ What do you want to do? · Create a TypeScript project
✔ Hardhat project root: · (confirm your project root)
✔ Do you want to add a .gitignore? (Y/n) · y
✔ Help us improve Hardhat with anonymous crash reports & basic usage data? (Y/n) · y
✔ Do you want to install this sample project's dependencies with npm (@nomicfoundation/hardhat-toolbox)? (Y/n) · y
This will take a while for the process to complete.
Configuring HardHart with Lisk
To deploy smart contracts to the Lisk network, or any network for that matter, you need to configure your Hardhat project and add the desired network. In this case, the Lisk network. Ready? Let's go.
For this project, we are going to be using DOTENV to control our environment variables. So we need to install it in our project. Don't fret... it's just a lightweight package.
Now in your Terminal run the following command to install DOTENV
npm install --save-dev dotenv
Once you have your dotenv installed, You can spin up the project in your IDE from a terminal by running code . or open manually from your IDE.
Create a .env file at the root of your project, this is where we will be saving our wallet private keys (hope you have Metamask installed? check prerequisites for "how to") and or any other sensitive information we do not want to be exposed.
In your .env file, declare your private key like so: WALLET_KEY=<YOUR_PRIVATE_KEY>
Make sure to replace <YOUR_PRIVATE_KEY> with your actual metamask's private keys. See how you can find your private keys here
To configure Hardhat to use Lisk, add Lisk as a network to your project's hardhat.config.ts file:
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
require('dotenv').config();
const config: HardhatUserConfig = {
solidity: "0.8.23",
networks: {
// for testnet
'lisk-sepolia': {
url: 'https://rpc.sepolia-api.lisk.com',
accounts: [process.env.WALLET_KEY as string],
gasPrice: 1000000000,
},
},
};
export default config;
Under networks you may notice how we are specifying the desired network'lisk-sepolia' and passing the RPC (Remote procedure call), the accounts is invoked from the .env file where we specified our account's private keys; which signifies ownership of an account
Let's write some code!
If you're familiar with writing smart contracts then this will be a breeze, else don't worry I'll try my best to hold your hands through it.
The project you created would most likely have a file Lock.sol under the contracts folder, what you want to do is to get rid of it and create another file. Call it Governance.sol and paste the following code
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.24;
contract Governance {
// undefined variable to hold account owner
address public owner;
// initialize an empty array to hold vote options
string[] public options;
// A record of casted votes, this will hold an option and its count.
mapping (string => uint) private votes;
// A record of addresses/accounts that has casted a vote
mapping (address => bool) private hasVoted;
// This creates an event that is logged everytime a vote is cast
event VoteLog(address indexed _user, string _option);
// constructor function to initialize the voting options and instantiate owner as the tx signer
constructor(string[] memory _options) {
options = _options;
owner = msg.sender;
}
// this is a modifier that specifies who must perform an action.
// in this case the caller must be the message signer.
modifier onlyOwner() {
require(msg.sender == owner, 'You are not the owner');
_;
}
}
Take some time and go through the code, study the comments, and try it out yourself.
What's going on?
We are creating a contract with the
contractkeyword and calling it GovernanceThe variable
ownerprefixed with anaddresstype is used to control the signer of every transactionThen we initialize an empty array of strings named
optionsthis will hold the options which users can vote onNext, we have a record of
votes(key-value pair of votes and their count) andhasVoted(Key value pair of addresses that have voted and their votes)The constructor initializes the contract with a list of options provided when the contract is deployed. It also sets the owner as the message signer.
The modifier
onlyOwnerensures that only the signed owner of an account can perform an action where it is applied.
Now that we're all familiar with the basis of our contract, let's go ahead and add functions to our contract.
Inside of the same Governance contract, paste the following functions
function vote(string memory _option) public onlyOwner {
// Check if the sender has already voted and revert if true
require(!hasVoted[msg.sender], "You have already voted.");
// Variable to track if the provided option is valid
bool validOption = false;
// Loop through the available options to check if the provided option exists
for (uint i = 0; i < options.length; i++) {
// Compare the provided option with the current option in the list using keccak256 hash
// keccak256 is used to ensure the comparison is done on the hashed value, avoiding issues with string comparison
if (keccak256(abi.encodePacked(options[i])) == keccak256(abi.encodePacked(_option))) {
validOption = true; // If a match is found, set validOption to true
break; // Exit the loop early since we found a valid option
}
}
// Ensure that the provided option is valid
require(validOption, "Invalid voting option.");
// Increment the vote count for the chosen option
votes[_option] += 1;
// Mark the sender's address as having voted to prevent multiple votes
hasVoted[msg.sender] = true;
// emit event log
emit VoteLog(msg.sender, _option);
}
/**
* This function gets the available votes for a given option
* It takes ion a parameter of option that matches to the votes map memory
*/
function getVotes(string memory _option) public view returns (uint256) {
return votes[_option];
}
/**
* This function checks if a given user has casted a vote
* It takes in a parameter of user with type address and matches it to the hasVoted map memory
*/
function checkIfUserVoted(address _user) public view returns (bool) {
return hasVoted[_user];
}
The
votefunction takes an argument_optionand uses the custom modifieronlyOwnerto ensure that only the signed owner can call the vote function, effectively casting votes.It makes sure that a user has not cast a vote before by ensuring that the user's address does not exist in the
hasVotedrecord with arequirestatement.The next action in this function verifies the validity of the user choice to ascertain that it is a valid option ie: an option provided as a parameter to the argument in the constructor
If all checks pass, including the
requiremethod that makes sure that avalidOptionis provided and is true. The vote is cast by increasing the vote count of the_optionprovided, the user and their vote are added to the record of users who have voted in thehasVotedmapping.The
emitmethod triggers theeventand logs the details of the vote action.The Function
getVotesgets the number of votes cast for a given_optionand returns an unsigned integer type. It does not alter the state so it possesses aviewmodifierThe function
checkIfUserVotedchecks if a given user has cast a vote, and returns aboolean,trueorfalse. It does not alter the state so it possesses aviewmodifier
Great job! If you have just written your first smart contract, congratulations! 🎉
Your contract should now look something like this
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.24;
// pragma solidity >=0.7.0 <0.9.0;
contract Governance {
address public owner;
// initialize an empty array to hold vote options
string[] public options;
// A record of casted votes, this will hold an option and its count.
mapping (string => uint) private votes;
// A record of addresses/accounts that has voted
mapping (address => bool) private hasVoted;
event VoteLog(address indexed _user, string _option);
// constructor function to initialize the voting options
constructor(string[] memory _options) {
options = _options;
owner = msg.sender;
}
// this modifies who sends a message. in this case the sender must be the owner.
modifier onlyOwner() {
require(msg.sender == owner, 'You are not the owner');
_;
}
function vote(string memory _option) public onlyOwner {
// Check if the sender has already voted
require(!hasVoted[msg.sender], "You have already voted.");
// Variable to track if the provided option is valid
bool validOption = false;
// Loop through the available options to check if the provided option exists
for (uint i = 0; i < options.length; i++) {
// Compare the provided option with the current option in the list using keccak256 hash
// keccak256 is used to ensure the comparison is done on the hashed value, avoiding issues with string comparison
if (keccak256(abi.encodePacked(options[i])) == keccak256(abi.encodePacked(_option))) {
validOption = true; // If a match is found, set validOption to true
break; // Exit the loop early since we found a valid option
}
}
// Ensure that the provided option is valid
require(validOption, "Invalid voting option.");
// Increment the vote count for the chosen option
votes[_option] += 1;
// Mark the sender's address as having voted to prevent multiple votes
hasVoted[msg.sender] = true;
emit VoteLog(msg.sender, _option);
}
/**
* This function gets the available votes for a given option
* It takes ion a parameter of option that matches to the votes map memory
*/
function getVotes(string memory _option) public view returns (uint256) {
return votes[_option];
}
/**
* This function checks if a given user has casted a vote
* It takes in a parameter of user with type address and matches it to the hasVoted map memory
*/
function checkIfUserVoted(address _user) public view returns (bool) {
return hasVoted[_user];
}
}
Take some time to go through the code and understand why and how each component works. Once you're done, let's test our contract.
Testing the contract
Hardhat comes automatically with its testing suite, so we do not need to install any external dependencies.
You may notice a test folder in your project with a TypeScript file called Lock.ts. Go ahead and delete it. Then, create a new file, which you can name Vote.ts or any name you prefer. Next, paste in the following code:
import { expect } from "chai";
import hre from "hardhat";
describe('Voting', function () {
it("Owner should be able to vote and the vote count should increase", async function () {
// grab the tx signer and assign it to the owner variable
const [owner] = await hre.ethers.getSigners();
// Define valid options for users to choose from
const options = ["Option1", "Option2", "Option3"];
// Grab our contract and assign it to a variable Voting
const Voting = await hre.ethers.getContractFactory("Governance");
// Deploy the contract virtually with the options parameters
const castVote = await Voting.deploy(options);
// Owner casts vote for Option1
await castVote.connect(owner).vote("Option1");
// Call the getVotes function to get current state of the vote option
const voteCount = await castVote.getVotes(options[0]);
// Verify that Option1's vote count is now 1
expect(voteCount).to.equal(1);
});
// Lets test to make sure a user cannot vote twice
it("Signer should not vote twice", async function () {
// grab the tx signer and assign it to the owner variable
const [owner] = await hre.ethers.getSigners();
// Define valid options for users to choose from
const options = ["Option1", "Option2", "Option3"];
// Grab our contract and assign it to a variable Voting
const Voting = await hre.ethers.getContractFactory("Governance");
// Deploy the contract virtually with the options parameters
const castVote = await Voting.deploy(options);
// Owner votes for Option1
await castVote.connect(owner).vote("Option1");
// We should expect this action to fail because we hav a require statement in our contract that ensures user cannot vote twice
await expect(castVote.vote("Option1")).to.be.reverted;
});
});
As usual, take some time to experiment with the test to understand why and how it works. For more Hardhat Chai matchers and examples, refer to the official documentation.
Now we can run our test to see if they passed, run this command in your terminal
npx hardhat test test/Vote.ts
$ npx hardhat compile$ npx hardhat testThis is what the output of npx hardhat test should look like for the full test suite:
Voting
✔ Owner should be able to vote and the vote count should increase (772ms)
✔ Signer should not vote twice (44ms)
2 passing (820ms)
2 passing means that the two of our tests passed.
Deploying to the network
Now that we have our contracts and tests all set, we want to deploy our contract to the live network (Lisk testnet network)
Once your contract has been successfully compiled, you can deploy the contract to the Lisk Sepolia test network.
To deploy the contract to the Lisk Sepolia test network, you'll need to create a scripts folder at the root of your project. Inside that folder, create a deploy.ts file and paste the following code:
import { ethers } from 'hardhat';
async function main() {
// define options variabel, these are the parameters to our contract's constructor
const options = ["Option1", "Option2", "Option3"];
const governance = await ethers.deployContract('Governance', options);
await governance.waitForDeployment();
console.log('Governance Contract Deployed at ' + governance.target);
}
// this pattern is recommended to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
This is a deploy script with instructions that deploys our contract to the Lisk sepolia testnet.
You'll also need Testnet ETH in your wallet. I've made a video guide on requesting sepolia ETH and bridging the test tokens to the Lisk sepolia network.
Some important links
Once you've gotten your sepolia ETH and bridged to the Lisk network, you can go ahead and deploy by running the following command
npx hardhat run scripts/deploy.ts --network lisk-sepolia
This is what the output should look like if everything goes right
Governance Contract Deployed at <DEPLOY_ADDRESS>
The contract will be deployed on the Lisk Sepolia Testnet and assigned a contract address <DEPLOY_ADDRESS>. You can check the deployment status and view the contract by using a block explorer (click to visit) and searching for the address returned by your deploy script.
Congratulations once again on deploying your contract. 🎉
Verifying the contract
If you want to interact with your contract on the block explorer, you, or someone else needs to verify it first. We'll walk through how to verify your contract on the Lisk Sepolia Testnet.
In hardhat.config.ts, configure Lisk Sepolia as a custom network. Add the following to your HardhatUserConfig:
// Hardhat expects etherscan here, even if you're using Blockscout.
etherscan: {
// Use "123" as a placeholder, because Blockscout doesn't need a real API key, and Hardhat will complain if this property isn't set.
apiKey: {
"lisk-sepolia": "123"
},
customChains: [
{
network: "lisk-sepolia",
chainId: 4202,
urls: {
apiURL: "https://sepolia-blockscout.lisk.com/api",
browserURL: "https://sepolia-blockscout.lisk.com"
}
}
]
},
sourcify: {
enabled: false
},
Next, you'd want to create a script file to pass the arguments at verification time
Create a file and name it anything. You can name it args.js for instance. Then add the arguments you want to pass as the constructor argument to the args.js file in this format.
module.exports = [
["Option1", "Option2", "Option3"]
];
Now, you can verify your contract. Grab the deployed address and run:
npx hardhat verify --network lisk-sepolia --constructor-args args.js DEPLOYED_CONTRACT_ADDRESS
DEPLOYED_CONTRACT_ADDRESS with your actual deployed contract address.You should see an output similar to:
Successfully submitted source code for contract
contracts/Governance.sol:Governance at 0x12ccF0F4A22454d53aBdA56a796a08e93E947256
for verification on the block explorer. Waiting for verification result...
Successfully verified contract Governance on the block explorer.
https://sepolia-blockscout.lisk.com/address/0x12ccF0F4A22454d53aBdA56a796a08e93E947256#code
The contract 0x12ccF0F4A22454d53aBdA56a796a08e93E947256 has already been verified on Etherscan.
https://sepolia-blockscout.lisk.com/address/0x12ccF0F4A22454d53aBdA56a796a08e93E947256#code
View your contract on BlockScout, by following the link to the deployed contract displayed in the previous steps output message. The block explorer will confirm that the contract is verified and allow you to interact with it.
Big congratulations on writing, deploying, and verifying a smart contract on the Lisk Sepolia Network. It's not an easy task, so give yourself a pat on the back and start building something new. 🥳 🎉



