Skip to main content

Command Palette

Search for a command to run...

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.

Updated
16 min read
Deploying A Governance Smart Contract on Lisk Testnet: A Step-by-Step Guide
N

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

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

💡
The private key of an account is used when deploying a contract. You mustn't commit this to a public repository as it may lead to a compromise of your account. Please, ensure that the .env file is included in your .gitignore file.

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

💡
To understand more about Accounts, Private keys, and public keys, and how they signify ownership of accounts, you should check out the Ethereum book

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 contract keyword and calling it Governance

  • The variable owner prefixed with an address type is used to control the signer of every transaction

  • Then we initialize an empty array of strings named options this will hold the options which users can vote on

  • Next, we have a record of votes (key-value pair of votes and their count) and hasVoted (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 onlyOwner ensures 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 vote function takes an argument _option and uses the custom modifier onlyOwner to 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 hasVoted record with a require statement.

  • 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 require method that makes sure that a validOption is provided and is true. The vote is cast by increasing the vote count of the _option provided, the user and their vote are added to the record of users who have voted in the hasVoted mapping.

  • The emit method triggers the event and logs the details of the vote action.

  • The Function getVotes gets the number of votes cast for a given _option and returns an unsigned integer type. It does not alter the state so it possesses a view modifier

  • The function checkIfUserVoted checks if a given user has cast a vote, and returns a boolean, true or false. It does not alter the state so it possesses a view modifier

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
💡
The above command automatically compiles and runs your test. To compile separately without running a test you should run $ npx hardhat compile
After compilation, ensure that an Artifact folder is generated. This folder contains the ABI code necessary for interacting with the front end.
💡
The above command runs the tests inside a particular test file, in this case, test/Vote.ts. In a case where you'd like to run tests in all available test files you should go with $ npx hardhat test

This 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 ⁠
💡
Be sure to replace 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
💡
You can't re-verify a contract identical to one that has already been verified. If you attempt to do so, such as verifying the above contract, you'll get a message similar to:
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. 🥳 🎉