paint-brush
Dive into Web3 Contracts with Hardhat and Node.js Vol.2by@vivalaakam

Dive into Web3 Contracts with Hardhat and Node.js Vol.2

by Andrey MakarovOctober 10th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

The article provides a step-by-step guide to creating a blockchain-based contest using smart contracts.It simplifies the concepts of automatic execution, transparency, and the process of participant entry and winner selection.
featured image - Dive into Web3 Contracts with Hardhat and Node.js Vol.2
Andrey Makarov HackerNoon profile picture

1. Why did I choose these instruments?

It's easy; to prevent copy-paste, you can read it in this article.

2. I still don’t understand what these smart contracts of yours are.

Let's imagine a vending machine, and we try to compare smart contract behavior with it:

  1. Pre-set Rules: You put in a dollar, select a soda, and the machine gives you that soda. Why? Because it has a pre-set "contract" or program that says to do so when you choose to put in the correct amount of money.
  2. Automation: Like a vending machine, a smart contract automatically performs actions when certain conditions are met. You get a soda if the money is given and the right choice is made on a vending machine. If certain conditions are met on a smart contract, it automatically does its job (like sending tokens).
  3. No Middlemen: There's no need for a cashier or shopkeeper to oversee the vending machine. Similarly, smart contracts work on a blockchain without intermediaries like banks or lawyers (Instead of one middleman, you have many of them as network validators, which check that is all right with your transaction and, if all is, approve).
  4. Security and Transparency: Once a smart contract is on the blockchain, it can't be easily tampered with. It's like trying to change the program inside a vending machine – it's hard and usually impossible without taking the whole thing apart. This ensures people can trust the contract, knowing the "rules of the game" won't suddenly change.

But today we are going to make a vending machine. I thought for a long time (about 5 minutes) what to do, so exciting and vital, and realized that humanity has nothing more essential than the opportunity to invest a dollar and break the bank in the form of millions.

Unfortunately, not everyone will win, but a certain number of participants, for example, 10 (or less if fewer people are willing)

3. Initialise environment

Initialise the project and all other stuff

mkdir contest
cd contest
npm init -y
npm install --save-dev hardhat
npx hardhat init

4. Skeleton

In the contracts directory, create a new file named Contest.sol:

pragma solidity ^0.8.9;

contract Contest {
		event ContestClosed(address contest);
    event ContestWinner(address winner, uint256 ticket, uint256 amount);
    event ContestParticipate(address winner, uint amount);

		enum Status {
        OPEN,
        CLOSED
    }

    constructor(uint _totalWinners, string memory _name, uint _endTime, uint256 _ticketPrice ) {

    }

    function participate() public payable {
        
    }

    function getWinners() public {
        
    }

    function getWinnersLength() public view returns (uint256) {
        
    }
}

This is skeleton for contract. Let’s dive into it

contract Contest {
		...
    constructor(uint _totalWinners, string memory _name, uint _endTime, uint256 _ticketPrice ) {

    }
		...
}

Constructor for contract, that describes the basic rules of our contract, such as

  • total winners
  • contract name (just for fun, it's not required, but necessary)
  • ticket price
  • and when the contract will end
contract Contest {
		...
    function participate() public payable {
        
    }
	  ...
}

participate function, where if you pay enough, take a ticket that gives you a chance to get a prize, marked as public (available outside contract) and payable, that indicates that this function can receive Ethers. When calling this function, the caller can send along some Ether.

contract Contest {
		...
    function getWinners() public {
        
    }
		...
}

choose winners, marked only as public and in code later we made that call it can only contract creator

contract Contest {
		...
    function getWinnersLength() public view returns (uint256) {
        
    }
}

simple function that count total winners for our contest

contract Contest {
		event ContestClosed(address contest);
    event ContestWinner(address winner, uint256 ticket, uint256 amount);
    event ContestParticipate(address winner, uint amount);
		...
}

Here is few events for our functions. Events is like announcements from a smart contract. When something important happens in the contract, it "shouts out" using an event, and anyone interested can listen to that shout and know about it.

contract Contest {
		...
		enum Status {
        OPEN,
        CLOSED
    }
		...
}

Enum which represent status out contract.

5. Constructor

pragma solidity ^0.8.9;

contract Contest {
		...
	address public admin;
    string public name;
    uint public endTime;
    uint public totalWinners;
    uint256 public ticketPrice;

    uint counter;
    mapping(address => uint) public balance;
    mapping(address => uint[]) public ownedTickets;
    mapping(uint => address) public tickets;
    uint[] ticketKeys;
    uint[] public winners;
    bytes32 seed;
    Status public status = Status.OPEN;
		...

    constructor(uint _totalWinners, string memory _name, uint _endTime, uint256 _ticketPrice ) {
				admin = msg.sender;
        name = _name;
        endTime = _endTime;
        totalWinners = _totalWinners;
        ticketPrice = _ticketPrice;
    }
		...
}

We little bit enrich our constructor. At first we store initial data in variables for next using.

Also we add few mappings

  • balance - this map contain information about how much tickets contains each address
  • tickets - contains information about connection ticketId and address

And arrays which will be used later

  • ticketKeys
  • winners - list with winner ticketIds

And variables

  • seed - string which used to calculation winner. I will describe variable later
  • status - current status of contest

6. Participate

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.9;

import "hardhat/console.sol";

contract Contest {
    ...
    function participate() public payable {
        require(status == Status.OPEN, "Contest closed");
        require(endTime == 0 || block.timestamp < endTime, "Contest closed");
        require(msg.value >= ticketPrice, "Not enough ether to purchase Ticket.");

        uint ticketId = counter;
        ownedTickets[msg.sender].push(ticketId);

        balance[msg.sender] += 1;
        tickets[ticketId] = msg.sender;
        ticketKeys.push(ticketId);

        seed = keccak256(
            abi.encodePacked(
                seed,
                block.timestamp,
                block.prevrandao,
                msg.sender
            )
        );

        counter += 1;
        emit ContestParticipate(msg.sender, ticketId);
    }
		...
}

in this function at first we check required conditions, contest should be opened, current time less than max time and user send enough ethers for participate.

After all conditions passed, we get current counter number which will be our ticketId and associate it with current user, increase users balance, update seed and send event that user participated

Why we update seed on every time than someone participate?

Good question! Solidity doesn't have random function and one of best practice for it - make hash from string. And in order to reduce the dependence on external parameters and reduce the likelihood that this phrase can be calculated for the next steps, we update it every time through pseudo-random parameters.

7. Get Winners

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.9;

import "hardhat/console.sol";

contract Contest {
		...
function getWinners() public {
        require(msg.sender == admin, "Only admin node can get winners.");
        require(status == Status.OPEN, "Contest closed");
        uint total = totalWinners < counter ? totalWinners : counter;

        if (total == 0) {
            status = Status.CLOSED;
            emit ContestClosed(address(this));
            return;
        }

        uint256 amount = address(this).balance / total;

        for (uint i = 0; i < total; i++) {
            getWinner(amount);
        }

        status = Status.CLOSED;
        emit ContestClosed(address(this));
    }

    function getWinner(uint256 amount) private {
        seed = keccak256(
            abi.encodePacked(
                seed,
                block.timestamp,
                block.prevrandao,
                msg.sender
            )
        );

        uint256 winnerKey = uint(seed) % ticketKeys.length;
        uint winnerTicket = ticketKeys[winnerKey];
        address to = tickets[winnerTicket];

        ticketKeys[winnerKey] = ticketKeys[ticketKeys.length - 1];
        ticketKeys.pop();

        winners.push(winnerTicket);

        payable(to).transfer(amount);

        emit ContestWinner(to, winnerTicket, amount);
    }
		...
}

And finally, the selection of winners. I moved the method for selecting a specific winner into a separate private function. Let's consider the getWinners function. Everything is as simple as possible here, we check access rights and that the draw is not closed yet and find the actual number of winners. We calculate the size of the prize for each participant and select the winner from the list of tickets. Once we have found all the winners, we mark the giveaway closed and send the corresponding event

The getWinner function is a little more interesting. For each winner, we need to find a new number, which we divide by the number of tickets. This is how we get the index of the winning ticket. Having received the index, we find the ticket and there is already a winner based on it. To prevent the same ticket from entering twice, we replace it with the last ticket and remove the last ticket from the list of winners. Well, at the same time we send the winner the winnings due to him and create an event for this

8. Tests

import { ethers } from "hardhat";
import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";

const CONTEST_NAME = "test name";
const AMOUNT = ethers.utils.parseEther("1");

describe("Contest", function () {
  async function deployOneYearLockFixture() {
    const endTime = (await time.latest()) + 60;
    const [owner, participant1, participant2, participant3, participant4, participant5 ] = await ethers.getSigners();

    const ticketPrise = ethers.utils.parseEther("1");

    const Contest = await ethers.getContractFactory("Contest");
    const contest = await Contest.deploy(10, CONTEST_NAME, endTime, ticketPrise);

    return { contest, endTime, owner, participant1, participant2, participant3, participant4, participant5, };
  }

  it("should get name and description", async () => {
    const { contest, endTime } = await loadFixture(deployOneYearLockFixture);
    const balance0ETH = await contest.provider.getBalance(contest.address);
    expect(await contest.name()).to.be.equal("test name");
    expect(await contest.endTime()).to.be.equal(endTime);
    expect(balance0ETH.toHexString()).to.be.equal("0x00");
  });

  it("should provide to participate", async () => {
    const { contest, participant1, participant2 } = await loadFixture(deployOneYearLockFixture);
    expect(
      await contest.connect(participant1).participate({value: AMOUNT})
    ).to.be.exist;

    expect(
      await contest.connect(participant1).participate({value: AMOUNT})
    ).to.be.exist;

    expect(
      await contest.connect(participant2).participate({value: AMOUNT})
    ).to.be.exist;

    expect(await contest.balance(participant1.getAddress())).to.be.equal(2);
    expect(await contest.balance(participant2.getAddress())).to.be.equal(1);

    const participant1Balance = await contest.provider.getBalance(participant1.getAddress());
    const participant2Balance = await contest.provider.getBalance(participant2.getAddress());

    await contest.getWinners();

    expect(await contest.getWinnersLength()).to.be.equal(3);

    const participant1BalanceAfter = await contest.provider.getBalance(participant1.getAddress());
    expect(participant1BalanceAfter.toHexString()).to.be.equal(participant1Balance.add(AMOUNT.mul(2)).toHexString());

    const participant2BalanceAfter = await contest.provider.getBalance(participant2.getAddress());
    expect(participant2BalanceAfter.toHexString()).to.be.equal(participant2Balance.add(AMOUNT).toHexString());
  });

  it("should select winners", async () => {
    const {
      contest,
      participant1,
      participant2,
      participant3,
      participant4,
      participant5,
    } = await loadFixture(deployOneYearLockFixture);

    for (let i = 0; i < 3; i += 1) {
      await contest.connect(participant1).participate({value: ethers.utils.parseEther("1")});
      await contest.connect(participant2).participate({value: ethers.utils.parseEther("1")});
      await contest.connect(participant3).participate({value: ethers.utils.parseEther("1")});
      await contest.connect(participant4).participate({value: ethers.utils.parseEther("1")});
      await contest.connect(participant5).participate({value: ethers.utils.parseEther("1")});
    }

    expect(await contest.balance(participant1.getAddress())).to.be.equal(3);
    expect(await contest.balance(participant2.getAddress())).to.be.equal(3);
    expect(await contest.balance(participant3.getAddress())).to.be.equal(3);
    expect(await contest.balance(participant4.getAddress())).to.be.equal(3);
    expect(await contest.balance(participant5.getAddress())).to.be.equal(3);

    expect(await contest.getWinners()).to.be.exist;
    expect(await contest.getWinnersLength()).to.be.equal(10);
  });

  it("should not apply participant if date is end", async () => {
    const endTime = (await time.latest()) - 60;

    const [owner, participant1] = await ethers.getSigners();

    const Contest = await ethers.getContractFactory("Contest");
    const contest = await Contest.deploy(10, CONTEST_NAME, endTime, ethers.utils.parseEther("1"));

    await expect(
      contest.connect(participant1).participate({value: ethers.utils.parseEther("1")})
    ).to.be.revertedWith("Contest closed");
  });

  it("should apply participant if date is 0", async () => {
    const [owner, participant1] = await ethers.getSigners();

    const Contest = await ethers.getContractFactory("Contest");
    const contest = await Contest.deploy( 10, CONTEST_NAME, 0, ethers.utils.parseEther("1"));

    await contest.connect(participant1).participate({value: ethers.utils.parseEther("1")});
    expect(await contest.balance(participant1.getAddress())).to.be.equal(1);
  });
});

These are tests, I don’t know what else to describe) Before each new iteration, we deploy a new contract and check all cases

  • that deployed
  • that several participants can participate
  • that a winner is chosen
  • that you cannot participate in a closed contest

9. Conclusion

In this article, I tried to collect my experience in developing a smart contract (a slightly more advanced version is used in production). Next time we will make NFTs that can be added via Telegram and consider IPFS. The source code for this article is available at https://github.com/vivalaakam/contest

Thank you for your time =)