A Practical Introduction to Developing Web3 Apps Using Solidity and React - Part 1

book cover page

I have spent many weekends learning as much as I can on writing smart contracts and developing applications on the blockchain. I wrote this short and accessible book to share my learnings and show you how to build a Web3 app powered by a smart contract from scratch using Solidity, Hardhat, React, and Next.js.

Below, you can read the first part of the book.

Developing a Smart Contract

Imagine building a project with someone. You are planning on monetizing this project by accepting payments in cryptocurrency. You would want to distribute the earnings fairly in between yourselves. Partnerly.co helps you do this. It is an app that helps you create a smart contract on the blockchain that can distribute the collected payments between two (or more) parties in a predetermined split ratio.

I will show you how I built Partnerly from scratch. First, you will learn how to create the smart contract that can be deployed on the Ethereum blockchain using Solidity and then we will be looking at building a front-end for this contract using React in Next.js.

The code for the smart contract is available at https://github.com/hibernationTheory/partnerly.

Disclaimer

I am not a Blockchain developer. In fact, I am just learning this stuff myself. However, I am a software developer and an educator who taught programming from scratch to thousands of students. I can't claim that you will become a Blockchain development expert by just following this walkthrough, but I am pretty confident that you will learn practical skills that will make this a valuable stepping stone in your journey.

Let's get started!

Getting Started

This walkthrough assumes you have a working knowledge of JavaScript, Node.js and React. We will use the Solidity language to develop the smart contract, which is relatively similar to JavaScript. The user interface we will build for this contract will use React, Next.js and Tailwind libraries.

We are developing our smart contract to work on the Ethereum Blockchain. The Ethereum Blockchain is one of the most prevalent and battle-tested blockchains. Its popularity ensures that we are building on a platform that most blockchain users use. Solidity is a programming language used to develop smart contracts on Ethereum.

We will be using React and Next.js to develop an interface for this contract so that the users can interact with it easily. React is a JavaScript library for developing dynamic and interactive web applications. It allows us to architect the front-end application using an abstraction/concept called components which helps with development experience and maintainability. React is one of the most popular front-end libraries out there. We will also be using Next.js, a framework built on top of React. Using Next.js will make it easy to introduce routing and bundling into our project. We will explore the front-end tech stack choices in more detail in the second half of this project when we start to build the user interface.

I use Visual Studio Code (VS Code) for coding. VS Code has an extension that makes working with Solidity (sol) files easy, so I recommend you download that for a better development experience.

If you are yet to install Node.js, make sure to install it using nvm. nvm makes it easy to manage multiple Node versions on the same system.

We will first need to install a library called Hardhat. Hardhat is a local development tool that makes developing, testing and deploying smart contracts easy.

Let's create a folder for our smart contract and initialize npm to install packages from the node registry.

mkdir partnerly-contract
cd partnerly-contract
npm init -y
npm install --save-dev hardhat@^2.8.4
npx hardhat

When you run npx hardhat you will be presented with some options. Select > Create a basic sample project and say yes to all the questions. This will set up a sample project with Hardhat, further dependencies will get installed.

Initializing the Contract

We will be developing our contract inside the contracts folder. Let's delete the sample contract in there and create a new file called Partnership.sol. Below is what the skeleton of our contract looks like. I will explain every line that is in there.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;

import "hardhat/console.sol";

contract Partnership {
    constructor() {
        console.log("Contract is deployed");
    }
}

The first line inside the contract is a comment that defines the licence for this contract. We will just leave it to be unlicensed.

//SPDX-License-Identifier: Unlicense

The second line is for the Solidity version you want for this contract. We will be using version 0.8.4.

pragma solidity ^0.8.4;

Hardhat created a config file called hardhat.config.js when we initiated this sample project. You should set the version declaration in there to match the contract version.

module.exports = {
  solidity: "0.8.4",
};

Next, we are importing another contract into our contract called hardhat/console.sol. This is similar to how you would import libraries when coding in Node.js to enhance the functionality of your programs. console.sol gives our contract the ability to call the console.log function, which will make debugging the contract easier as we are developing it. We will remove this function before deploying the contract to a live network since it costs some gas to execute the console.log statements on the Ethereum network. You can think of gas as the price you need to pay for things to get executed on the Ethereum blockchain.

Next, we define a contract called Partnership that console.log's the message Contract is deployed when it is deployed.

contract Partnership {
    constructor() {
        console.log("Contract is deployed");
    }
}

Notice the definition of a contract is pretty similar to the definition of a class in JavaScript. Just like a class, a contract has a constructor function that gets executed when the contract is initialized (deployed in our case). It potentially can have other attributes and methods that will help us define the contract's data and behaviour. But for now, let's just console.log a string to indicate that the contract is deployed.

Running the Contract

Using Hardhat, we can define a script that will simulate the deployment of a contract. Let's create a script called run.js inside the scripts folder.

const hre = require("hardhat");

async function main() {
  await hre.run("compile");

  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );
  const contract = await Contract.deploy();

  await contract.deployed();
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

We can execute this script by running this statement inside the terminal:

npx hardhat run scripts/run.js

We should now see our console.log message get executed. The contract gets deployed successfully.

This run.js file has a bunch of boilerplate that we will skim over. You won't need to adjust some of the statements in there.

const hre = require("hardhat");

The above statement imports the hardhat library into the run script. You might see some other examples where this line is omitted. That is because this dependency gets automatically injected when you call this script using npx hardhat.

It is the same with the first statement inside the main function. You might see it getting omitted in some examples. I have included in my examples for the sake of completeness.

async function main() {
  await hre.run("compiler");
}

The following statement executes the async main function and exits from the script once the execution is completed. It additionally logs errors if there are any.

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

What we need to care about are these three lines of code.

// We get the contract to deploy
const Contract = await hre.ethers.getContractFactory(
  "Partnership"
);
const contract = await Contract.deploy();

await contract.deployed();

We first compile the smart contract with the given name and then deploy the contract using the deploy method. Then, we ensure that the contract is deployed using the deployed method. Note that all these operations are asynchronous, so we use the async-await pattern.

We can continue developing the contract now that we have a script to execute it.

Terms of the Contract

Let's think of what we would like to do with this contract.

  • We want to create a contract that requires two or more wallet addresses that can accept payments.
  • The contract will also require split ratios to be defined for each given address. 1 to 1 split ratio means an equal split. 2 to 1 split means that the first address will get double the amount of the second one.
  • The contract will store these given addresses and split ratios.
  • The contract should be able to receive and store payments.
  • When a withdrawal is requested, the stored amount should be transferred to the contract's parties in the designated split ratio.

At this point, I should mention that I am not a lawyer. In fact, I am just a simple programmer, and as a programmer, I write bugs all the time. This poses some problems when it comes to developing smart contracts. It is impossible to ensure that a contract (or a program, for that matter) doesn't have a bug, and once a contract is deployed, you can't change it. The implication is that it is possible to deploy a buggy program that you can't alter after the fact.

The code for the smart contract is available on the blockchain for anyone to see. This transparency is great since you can verify that the contract is working as intended by reading the code. However, it also means that any actor could be dissecting the contract to take advantage of its vulnerabilities.

Have a look at the above terms. Does it look airtight? When I started to code this, it looked pretty good to me until I realized the users could pass a split ratio equivalent to 0, which would cause the contract to fail (You can't divide a number with 0). This is only a single oversight I could catch, and I can't guarantee there aren't others!

So let's ensure we update our requirements to include that the split ratios should be bigger than 0.

  • The contract will also need split ratios to be defined for each given address. Each split ratio should be bigger than 0.

Passing and Storing Values inside the Smart Contract

You can store data (state) inside the contract by defining variables inside the contract body. Any value passed into these variables will be stored inside the contract permanently.

Let's define a contract that has some state variables.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;

import "hardhat/console.sol";

contract Partnership {
    string private deploymentMessage = "Contract is deployed";
    uint256 private partnerAmount = 2;
    address[] public addresses;

    constructor(address[] memory _addresses) {
        require(
            _addresses.length == partnerAmount,
            "You can't have more than 2 partners"
        );

        addresses = _addresses;

        console.log(deploymentMessage);
    }
}

Solidity is a typed language meaning that we need to define our variable types.

We can also define the visibility and accessibility of these variables by using the keywords public and private. A private variable is meant to be accessible only inside the contract. In contrast, Solidity creates a getter function for a public variable, meaning that you could read the value of this variable by calling an automagically generated method of the same name (partnerAmount() and addresses() in this case).

We have defined three variables at the top of this contract. A private string variable called deploymentMessage, a public uint256 (an unsigned 256 bit integer) variable called partnerAmount, and a public array of address type called addresses. The addresses variable doesn't have a value yet. It is going to be populated by the caller of this contract.

We have updated the constructor function to accept _addresses as an argument. It is conventional to prefix function arguments with an underscore (_) to differentiate them from the state variables.

We use a keyword called memory before the _addresses argument. The memory keyword instructs Solidity that this data doesn't need to be part of the permanent storage. The array function arguments are always declared with the memory keyword. You don't need to use this keyword for more primitive types like string or uint256.

Constructor function makes use of a built-in function called require that checks to see if the given conditional is true. If not, it reverts the operation (an operation is called transaction in Blockchain terms) with the given message.

In our case, we require the given address amount to be equal to the defined partnerAmount. If that is not the case, the transaction will not go through. If there aren't any errors, our contract will deploy, and the passed arguments will permanently get stored inside this smart contract.

If we tried running the run script as is, we would get an error since our script now expects an array of two addresses to be passed in. Let's update our run script to pass addresses inside this contract.

const hre = require("hardhat");

async function main() {
  await hre.run("compile");
  const [
    owner,
    person1,
  ] = await hre.ethers.getSigners();
  const addresses = [
    owner.address,
    person1.address,
  ];

  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );
  const contract = await Contract.deploy(
    addresses
  );

  await contract.deployed();

  await console.log(
    await contract.partnerAmount()
  );
  await console.log(await contract.addresses(0));
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

We use the hre.ethers.getSigners function to get objects representing Ethereum accounts. We create an array of addresses by using the addresses of these accounts by using their address property.

const [owner, person1] = await hre.ethers.getSigners();
const addresses = [owner.address, person1.address];

We then pass these addresses into our contract during the initialization. Our contract successfully deploys when provided with two addresses.

Another way of ensuring that our contract would only accept two addresses would have been to define the array to have a fixed size. That way, we wouldn't need the require statement since the condition would be enforced at a data type level.

address[2] public addresses;

We are also logging the partnerAmount and the first address inside the addresses array by using the getter functions that Solidity exposes since these are defined as public variables.

While the run script is helpful to us in understanding the runtime behaviour of our contract, it is not a reliable way to ensure that our contract behaves correctly for our use cases. To develop confidence in our contract, we should write some tests that cover its behaviour.

Create a file called Partnership.js inside the test folder. This is what it will look like:

describe("Partnership", () => {
  it("can be deployed by providing two addresses on initialization", async () => {
    const Contract = await hre.ethers.getContractFactory(
      "Partnership"
    );

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

    const addresses = [
      owner.address,
      person1.address,
    ];

    const contract = await Contract.deploy(
      addresses
    );
    await contract.deployed();
  });
});

We will call this file using npx hardhat test test/Partnership.js. You should see a nicely formatted terminal output that tells us that 1 test has passed. As you can see, this file is super similar to our run script. The only difference is that it includes a test suite that gets executed whenever this script is called. The describe function describes what this test suite is for. The test block that starts with the function name it defines a test block that tests one aspect of our contract. In this case, it just tests to see if the contract is deploying successfully.

Let's write a test to see if our contract is failing as expected when no addresses are provided. First, we will need to import an expect method to help us make assertations.

const { expect } = require("chai");

And then, we will add this test block right after the previous one.

it("can NOT be deployed when NOT providing addresses on initialization", async () => {
  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );

  let error;
  try {
    const contract = await Contract.deploy();
    await contract.deployed();
  } catch (_error) {
    error = _error;
  } finally {
    expect(error).to.be.ok;
  }
});

You would get an error thrown when a contract that expects arguments is deployed without being provided with those arguments. Here we start by initializing an empty error variable.

let error;

And then, we use the try-catch block to see if the error is populated or not. The error gets populated because the deployment fails when addresses are not provided. We check for the error to exist (to be truthy) inside the finally block:

expect(error).to.be.ok;

Tests are a super important part of any software development process. They not only help you ascertain the behaviour of a program (or a smart contract), but they can also give you confidence that the existing behaviour of the contract doesn't break when you modify it. You can ensure this by running the test suite whenever you update the contract.

Tests are an essential part of the software development process. There is a software development paradigm called Test-Driven Development (TDD), where we first write a failing test for each feature. We then implement the bare minimum code that will make this test pass. This methodology forces us to be iterative with our development process. We will try to follow this methodology while developing our smart contract.

Smart Contract Continued

In the previous section, we wrote a contract that takes two addresses. But we would like our eventual contract to work with two or more addresses and equivalent amounts of split ratios.

Let's change our tests to encode this expectation. We will be deleting the tests from before and writing new ones. Here is the first test block we will have. It simply tests if the contract deploys without errors when provided with the required amount of addresses and split ratios.

it("can be deployed by providing at least two addresses and equal amounts of split ratios on initialization", async () => {
  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );

  const [
    owner,
    person1,
  ] = await hre.ethers.getSigners();
  const addresses = [
    owner.address,
    person1.address,
  ];

  const splitRatios = [1, 1];

  const contract = await Contract.deploy(
    addresses,
    splitRatios
  );
  await contract.deployed();
});

Our second test will check to see if our contract fails as expected when the address amount and the split ratio amount are different. In this test, we will have two addresses but three split ratios.

it("can NOT be deployed when the split ratios are not equivalent to the address amount", async () => {
  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );

  const [
    owner,
    person1,
  ] = await hre.ethers.getSigners();
  const addresses = [
    owner.address,
    person1.address,
  ];

  const splitRatios = [1, 1, 1];

  await expect(
    Contract.deploy(addresses, splitRatios)
  ).to.be.revertedWith(
    "The address amount and the split ratio amount should be equal"
  );
});

We use a new method on the expect function called revertedWith that checks to see if the transactions are reverted with the expected message.

We will add one more test block to ensure that the contract fails to deploy when it is called with less than two addresses.

it("can NOT be deployed when the the address amount is less than two", async () => {
  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );

  const [owner] = await hre.ethers.getSigners();
  const addresses = [owner.address];

  const splitRatios = [1];

  await expect(
    Contract.deploy(addresses, splitRatios)
  ).to.be.revertedWith(
    "More than one address should be provided to establish a partnership"
  );
});

All these tests will currently fail when called. Let's update our contract to make them pass. Our new contract will look like this.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;

import "hardhat/console.sol";

contract Partnership {
    address[] public addresses;
    uint256[] public splitRatios;

    constructor(address[] memory _addresses, uint256[] memory _splitRatios) {
        require(
            _addresses.length > 1,
            "More than one address should be provided to establish a partnership"
        );

        require(
            _splitRatios.length == _addresses.length,
            "The address amount and the split ratio amount should be equal"
        );
        addresses = _addresses;
        splitRatios = _splitRatios;
        console.log("Contract is Deployed");
    }
}

We now have two require statements. The first one is to ensure that more than one address is passed into the contract, and the second is to ensure that an equal amount of split ratios is provided. We get the _addresses and _splitRatios and store them inside the contract.

If we want to update our run script, we can do so by including splitRatios as an argument.

const hre = require("hardhat");

async function main() {
  await hre.run("compile");
  const [
    owner,
    person1,
  ] = await hre.ethers.getSigners();
  const addresses = [
    owner.address,
    person1.address,
  ];
  const splitRatios = [1, 1];

  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );
  const contract = await Contract.deploy(
    addresses,
    splitRatios
  );

  await contract.deployed();

  await console.log(await contract.addresses(0));
  await console.log(
    await contract.splitRatios(0)
  );
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

We are now defining and passing split ratios to the contract as well inside the run script.

Requiring Valid Arguments

We also need to ensure that there are no split ratios that are less than or equal to 0. We should iterate through every split ratio to see if they are all above one. The test for this functionality would look like this.

it("can NOT be deployed when any of the split ratios is less than one", async () => {
  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );

  const [
    owner,
    person1,
  ] = await hre.ethers.getSigners();
  const addresses = [
    owner.address,
    person1.address,
  ];

  const splitRatios = [1, 0];

  await expect(
    Contract.deploy(addresses, splitRatios)
  ).to.be.revertedWith(
    "Split ratio can not be 0 or less"
  );
});

As mentioned previously, this test will initially fail. Let's implement the code that would make it pass.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;

import "hardhat/console.sol";

contract Partnership {
    address[] public addresses;
    uint256[] public splitRatios;
    uint256 private splitRatiosTotal;

    constructor(address[] memory _addresses, uint256[] memory _splitRatios) {
        require(
            _addresses.length > 1,
            "More than one address should be provided to establish a partnership"
        );

        require(
            _splitRatios.length == _addresses.length,
            "The address amount and the split ratio amount should be equal"
        );
        addresses = _addresses;
        splitRatios = _splitRatios;
        splitRatiosTotal = getSplitRatiosTotal(splitRatios);

        console.log("Contract is Deployed");
    }

    function getSplitRatiosTotal(uint256[] memory _splitRatios)
        private
        pure
        returns (uint256)
    {
        uint256 total = 0;

        for (uint256 i = 0; i < _splitRatios.length; i++) {
            require(_splitRatios[i] > 0, "Split ratio can not be 0 or less");
            total += _splitRatios[i];
        }

        return total;
    }
}

We now added a new variable called splitRatiosTotal that is private. This means that it won't be accessible from outside of the contract.

uint256 private splitRatiosTotal;

We have defined a new function called getSplitRatiosTotal that goes through every item in a given uint256[] and checks to see if they are bigger than 0. It also calculates the sum of those items and stores it inside a state variable splitRatiosTotal. We will use this variable when we want to distribute the funds to the contract addresses.

function getSplitRatiosTotal(uint256[] memory _splitRatios)
    private
    pure
    returns (uint256)
{
    uint256 total = 0;

    for (uint256 i = 0; i < _splitRatios.length; i++) {
        require(_splitRatios[i] > 0, "Split ratio can not be 0 or less");
        total += _splitRatios[i];
    }

    return total;
}

We have defined this function as private and pure. A pure function means that the operation inside the function doesn't rely on any external state. It would return the same output for the same inputs on every execution. Pure functions are highly desirable in programming since their behaviour is very predictable.

Having a pure function in Solidity is also great since they don't cost any gas when executed. As mentioned previously, gas is the computational and monetary cost of doing operations on the Ethereum blockchain.

Receiving Payments

We can now store addresses and split ratios inside the smart contract. Let's update our contract so that it can also receive payments. Let's start by writing a failing test for this.

it("can receive transactions in Ether", async () => {
  const Contract = await ethers.getContractFactory(
    "Partnership"
  );

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

  const addresses = [
    owner.address,
    person1.address,
  ];
  const splitRatios = [1, 1];

  const contract = await Contract.deploy(
    addresses,
    splitRatios
  );
  await contract.deployed();

  // send ether to contract
  await owner.sendTransaction({
    to: contract.address,
    value: ethers.utils.parseEther("1.0"),
  });
});

We are using the following function call to send Ether to the contract from the owner account.

// send ether to contract
await owner.sendTransaction({
  to: contract.address,
  value: ethers.utils.parseEther("1.0"),
});

Our test currently fails because the contract can't receive funds. Making it to receive funds is super easy to do so. We just need to add a single line of code into our contract.

receive() external payable {}

Run the test again. It should pass. The contract can now receive funds!

We will also add a function called getBalance that will get the current balance of the contract. We can test this function by checking if the balance increased by the expected amount after receiving a transaction.

it("has a balance that increases after receiving a transaction", async () => {
  const Contract = await ethers.getContractFactory(
    "Partnership"
  );

  const [
    owner,
    person1,
    person2,
  ] = await hre.ethers.getSigners();

  const addresses = [
    person1.address,
    person2.address,
  ];
  const splitRatios = [1, 1];

  const contract = await Contract.deploy(
    addresses,
    splitRatios
  );
  await contract.deployed();

  expect(await contract.getBalance()).to.equal(0);

  const value = ethers.utils.parseEther("1.0");

  // send ether to contract
  await owner.sendTransaction({
    to: contract.address,
    value,
  });

  expect(await contract.getBalance()).to.equal(
    value
  );
});

We first check to see if the contract balance is 0 to begin with.

expect(await contract.getBalance()).to.equal(0);

We then use the parseEther method to convert a single Ether's value to its Wei equivalent.

const value = ethers.utils.parseEther("1.0");

A Wei is the smallest unit of Ether. Think of it as what a penny is to a dollar. The similarity ends there, though, since a dollar is only 100 times more valuable than a penny, whereas an Ether is 10^18 times more valuable than a Wei!

We then execute a transaction with this Wei value and check to see if the balance has increased.

expect(await contract.getBalance()).to.equal(
  value
);

Let's implement the getBalance method inside our contract so that the test would pass. This is again straightforward to do so.

function getBalance() public view returns (uint256) {
    return address(this).balance;
}

Note the usage of the view keyword in the function definition. A view function is a function that doesn't alter the state but merely reads it.

Our tests should start passing with the implementation of this function.

Sending Payments

We will be implementing a function called withdraw. When called, this function will initiate the transfer of the funds to the recorded addresses according to the provided split ratio.

We can create a new describe block called withdraw inside the existing one. Using describe function allows us to categorize related tests together. We will include any withdrawal-related test inside this new block.

describe("withdraw", () => {
  it("can be called if the contract balance is more than 0", async () => {
    const Contract = await ethers.getContractFactory(
      "Partnership"
    );

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

    const addresses = [
      owner.address,
      person1.address,
    ];
    const splitRatios = [1, 1];

    expect(addresses.length).to.equal(
      splitRatios.length
    );

    const contract = await Contract.deploy(
      addresses,
      splitRatios
    );
    await contract.deployed();

    // send ether to contract
    await owner.sendTransaction({
      to: contract.address,
      value: ethers.utils.parseEther("5.0"),
    });

    expect(
      await contract.getBalance()
    ).to.not.equal(0);

    await contract.withdraw();
  });
});

We check to see if the contract balance is non-zero after the transfer.

expect(await contract.getBalance()).to.not.equal(
  0
);

Then we call the withdraw function. The test fails because the withdraw function doesn't exist. Let's fix that. We will add a withdraw function inside the contract.

function withdraw() public {
    uint256 addressesLength = addresses.length;

    for (uint256 i = 0; i < addressesLength; i++) {
        addresses[i].transfer(
            (balance / splitRatiosTotal) * splitRatios[i]
        );
    }
}

This function doesn't quite work yet. The reason for that is that we should define the addresses as payable for us to invoke the transfer method on them. We need to update the state variable for addresses and the argument inside the constructor with the payable keyword.

address payable[] public addresses;

And inside the constructor:

constructor(
    address payable[] memory _addresses,
    uint256[] memory _splitRatios
) {
    // constructor body
}

With this change, the withdraw function should start working!

We would like to have the withdraw function fail if the balance is less than 0. Let's write the test for that.

it("can NOT be called if the contract balance is less than or equal to 0", async () => {
  const Contract = await ethers.getContractFactory(
    "Partnership"
  );

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

  const addresses = [
    owner.address,
    person1.address,
  ];
  const splitRatios = [1, 1];

  expect(addresses.length).to.equal(
    splitRatios.length
  );

  const contract = await Contract.deploy(
    addresses,
    splitRatios
  );
  await contract.deployed();

  expect(await contract.getBalance()).to.equal(0);

  await expect(
    contract.withdraw()
  ).to.be.revertedWith("Insufficient balance");
});

We first ensure that the balance of the contract is 0.

expect(await contract.getBalance()).to.equal(0);

Then we try to call the withdraw function. We should make it fail with the "Insufficient balance" error.

await expect(
  contract.withdraw()
).to.be.revertedWith("Insufficient balance");

To implement this inside the contract, we should get the contract balance inside the withdraw function and ensure it is bigger than 0.

function withdraw() public {
    uint256 balance = getBalance();
    uint256 addressesLength = addresses.length;

    require(balance > 0, "Insufficient balance");

    for (uint256 i = 0; i < addressesLength; i++) {
        addresses[i].transfer(
            (balance / splitRatiosTotal) * splitRatios[i]
        );
    }
}

Ensuring that Split Ratio Total is Smaller Than the Balance

We should ensure that the total split ratio is smaller than the balance. This way, we won't get any numerical errors when dividing the balance with the split ratio. Here is the test for that looks like.

it("can NOT be called when the total split ratio is greater than the contract balance", async () => {
  const Contract = await ethers.getContractFactory(
    "Partnership"
  );

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

  const addresses = [
    owner.address,
    person1.address,
  ];
  const splitRatios = [10, 10];

  expect(addresses.length).to.equal(
    splitRatios.length
  );

  const contract = await Contract.deploy(
    addresses,
    splitRatios
  );
  await contract.deployed();

  expect(await contract.getBalance()).to.equal(0);

  // send ether to contract
  await owner.sendTransaction({
    to: contract.address,
    value: ethers.utils.parseEther(
      "0.00000000000000001"
    ),
  });

  expect(await contract.getBalance()).to.equal(
    10
  );

  await expect(
    contract.withdraw()
  ).to.be.revertedWith(
    "Balance should be greater than the total split ratios"
  );
});

We are sending 10 Wei to the contract.

// send ether to contract
await owner.sendTransaction({
  to: contract.address,
  value: ethers.utils.parseEther(
    "0.00000000000000001"
  ),
});

expect(await contract.getBalance()).to.equal(10);

The total of the split ratios in this test is greater than 10 (it is 20). This causes the contract to fail with the "Balance should be greater than the total split ratios" message. We can implement this inside the contract by adding a simple require statement.

require(balance >=
  splitRatiosTotal, "Balance should be greater than the total split ratios");

Finalizing the Run Script

Now that we have finished the contract logic, let's update the run script to simulate how someone would interact with this contract.

const hre = require("hardhat");

async function main() {
  await hre.run("compile");
  const [
    owner,
    person1,
    person2,
  ] = await hre.ethers.getSigners();
  const addresses = [
    person1.address,
    person2.address,
  ];
  const splitRatios = [4, 1];

  const Contract = await hre.ethers.getContractFactory(
    "Partnership"
  );
  const provider = hre.ethers.provider;
  const contract = await Contract.deploy(
    addresses,
    splitRatios
  );

  await contract.deployed();

  let balance1 = await provider.getBalance(
    person1.address
  );
  console.log(
    `The balance for the person1 is: ${balance1}`
  );

  let balance2 = await provider.getBalance(
    person2.address
  );
  console.log(
    `The balance for the person2 is: ${balance2}`
  );

  let contractBalance = await contract.getBalance();
  console.log(
    `The balance in the contract is: ${contractBalance}`
  );

  await owner.sendTransaction({
    to: contract.address,
    value: ethers.utils.parseEther("5.0"),
  });

  console.log(
    `The balance in the contract after receiving funds is: ${await contract.getBalance()}`
  );

  await contract.withdraw();

  console.log(
    `The balance in the contract after withdrawal is: ${await contract.getBalance()}`
  );

  balance1 = await provider.getBalance(
    person1.address
  );
  console.log(
    `The new balance for the person1 is:${balance1}`
  );

  balance2 = await provider.getBalance(
    person2.address
  );
  console.log(
    `The balance for the person2 is: ${balance2}`
  );
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Here, we simulate the contract deployment with two addresses and a 4 to 1 split ratio. We need to define a provider to get the balance from the Ethereum accounts.

const provider = hre.ethers.provider;

We can then call the getBalance method on the provider to see the contract balance.

let balance1 = await provider.getBalance(
  person1.address
);

Hardhat initializes these accounts with a balance of 10000 ETH.

After the deployment, we get another user to send 5000 ETH into the contract. The balance of the contract changes accordingly.

await owner.sendTransaction({
  to: contract.address,
  value: ethers.utils.parseEther("5000.0"),
});

We then call the withdraw function on the contract. This distributes the funds inside the contract in between the addresses. We then check to ensure the balance of the accounts has changed accordingly.

await contract.withdraw();

It seems like things are working as expected!

Believe it or not, this is it! We now have a program that can permanently store data, securely receive transactions, store funds and transfer those funds to specified accounts (wallet addresses). And it took less than 100 lines to write it! Building something like this makes you truly appreciate the power of blockchains and smart contract platforms like Ethereum. The amount of code and complexity to achieve something similar using centralized solutions would have been substantial.

How can other people make use of this smart contract? We could upload the contract to a public repository like Github and have other people clone it from there. They would need to change the run script to handle the deployment of the contract on the Ethereum blockchain themselves. Obviously, this is not an ideal or feasible solution for those who don't have any familiarity with working with smart contracts.

We could do so much better than this! In the second part of this walkthrough, we will be building a web application that will handle the deployment of this contract.

Check out the second part at: https://leanpub.com/develop-web3-apps-using-solidity-and-react/