Backend / DevOps / Architect
Brit by birth,
located worldwide

All content © Alex Shepherd 2008-2024
unless otherwise noted

Learning Smart Contract Security - Part 1

Published
8 min read
image
Image Credit: ethereum.org

After setting up a Solidity Smart Contract development environment, let's get started learning both how to use Hardhat, and Smart Contract security at the same time, by completing some of the challenges on Capture The Ether.

Let's start from the place we left off last time, and check out warmup challenge #1: "Deploy a contract". Rather than go through the relatively arduous process of getting some Ropsten testnet Ethereum, what we'll do is use Hardhat to simulate the entire thing (albeit without the Metamask integration - we're focusing on practicing building unit tests here).

Our challenge is simple - deploy a Smart Contract, and then verify it's deployed by interacting with it. The challenge's code is as follows:

contracts/warmup/DeployChallenge.sol
pragma solidity ^0.4.21;

contract DeployChallenge {
    // This tells the CaptureTheFlag contract that the challenge is complete.
    function isComplete() public pure returns (bool) {
        return true;
    }
}

This Smart Contract just does one thing - returns true when we call the isComplete method. So all we need to do is deploy it. Start by creating the file contracts/warmup/DeployChallenge.sol, and pasting in the above Solidity code. First, note the pragma solidity line at the top. This defines the spec for the version of solc that will be used to compile this version. In order to compile this particular contract, we will need to tell Hardhat which versions of the Solidity compiler to download. To do this, edit your hardhat.config.ts file in your repository root.

You need to change the solidity member. It should start out containing a simple string; something like solidity: "0.8.4". We want to be able to support multiple versions, so let's change this to the following, to keep support for 0.8.4, and also add support for ^0.4.21:

hardhat.config.ts
  solidity: {
    compilers: [
      {
        version: "0.8.4",
      },
      {
        version: "0.4.25",
      },
    ],
  },

0.4.25 is the latest version in the 0.4 series, so we'll use that. It is possible to upgrade most of these to use the latest version of Solidity, but some of them rely on old compiler bugs, and there's not a huge amount of point in doing so this early in our learning journey. Now, let's create our unit test.

Create a new folder in the test folder called warmup, and then create a file in there called deploy-challenge.ts. Paste the following code in there:

test/warmup/deploy-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("DeployChallenge", function () {
  it("should return true when deployed", async function () {
    const Challenge = await ethers.getContractFactory("DeployChallenge");
    const challenge = await Challenge.deploy();
    await challenge.deployed();

    expect(await challenge.isComplete()).to.equal(true);
  });
});

This defines a basic mocha test group called "DeployChallenge", and a single test case within it called "should return true when deployed". Test cases should almost always be named in terms of the specific result that should occur when a given event is encountered. This makes it clear to others exactly what is being tested for.

We only have one thing that we need to check for the unit tests we'll build to solve these challenges, which is that we can cause the challenge's isComplete method to return true. In this case, all we have to do is deploy the challenge in order to pass the test.

Running through the test line by line, we have a number of things happening. The first line retrieves an instance of a ethers.js ContractFactory. We pass it the Contract's name, and it will return us something that we can deploy. For simplicity, we will usually be deploying to the Hardhat network in our tests. This is a slightly contrived environment, but it allows us a lot of control over what happens, allowing us to perform tests that otherwise might take hours, in a matter of seconds.

The second line asynchronously deploys the contract, returning an instance we can interact with. In order to interact with the contract, we need to then wait for the transaction to be mined by awaiting the async deployed method. Fortunately the Hardhat Network automatically mines transactions in the order they are received, so this call is almost instant.

Finally, the test makes sure that we've successfully deployed the contract by calling the isComplete method, and using the chai assertion library to confirm that it returns true. We will use this pattern at, or near the end of each test, to prove that we have solved the task.

To run this test, run npx hardhat test test/warmup/deploy-challenge.ts. This should produce a successful test run as so:

VS Code Terminal - Test Run Success

Success! We've completed the task without going and searching for Ropsten testnet Ethereum! Now, let's try the next warmup - Call Me. This challenge tests that we're able to interact with a Smart Contract. This challenge is simpler to complete inside a Hardhat development environment than it is to do via a wallet like MyEtherWallet. In fact, our test to complete this challenge will look almost identical to the last one.

Again, the first thing we'll do is create contracts/warmup/CallMeChallenge.sol and paste in the challenge's Smart Contract code:

contracts/warmup/CallMeChallenge.sol
pragma solidity ^0.4.21;

contract CallMeChallenge {
    bool public isComplete = false;

    function callme() public {
        isComplete = true;
    }
}

And again, let's create a new file test/warmup/call-me-challenge.ts with the following content:

test/warmup/call-me-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("CallMeChallenge", function () {
  it("should return true after we call the method", async function () {
    const Challenge = await ethers.getContractFactory("CallMeChallenge");
    const challenge = await Challenge.deploy();
    await challenge.deployed();

    expect(await challenge.isComplete()).to.equal(true);
  });
});

You can see here that this is exactly the same code as the previous challenge, but our test has been renamed, and we're looking up the right challenge. If we run this now, the test will fail, because we are not calling the callme method yet. Try it - run npx hardhat test tests/callme-challenge.ts.

To make this test pass, we need to add a new line to our test:

test/warmup/call-me-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("CallMeChallenge", function () {
  it("should return true after we call the method", async function () {
    const Challenge = await ethers.getContractFactory("CallMeChallenge");
    const challenge = await Challenge.deploy();
    await challenge.deployed();

    // When we call this method, the challenge will be marked as complete
    await challenge.callme();

    expect(await challenge.isComplete()).to.equal(true);
  });
});

Now, running npx hardhat test test/warmup/call-me-challenge.ts will pass! Success!

Let's run through the final warmup challenge before we sign off for today. This one's a bit more interesting than the last couple, as not only does it show how 2 smart contracts can interact with each other, but we also need to make a small modification to it in order to make it work in Hardhat. The reason we have to do this is that the code shown on the challenge's page is not exactly the same code that is running on at the contract's actual address. It is basically just enough information to pass the challenge using traditional tools like Metamask and MyEtherWallet.

To start off this challenge, create a contract with the following code:

contracts/warmup/NicknameChallenge.sol
pragma solidity ^0.4.21;

// Relevant part of the CaptureTheEther contract.
contract CaptureTheEther {
    mapping(address => bytes32) public nicknameOf;

    function setNickname(bytes32 nickname) public {
        nicknameOf[msg.sender] = nickname;
    }
}

// Challenge contract. You don't need to do anything with this; it just verifies
// that you set a nickname for yourself.
contract NicknameChallenge {
    address player;

    // Your address gets passed in as a constructor parameter.
    function NicknameChallenge(address _player) public {
        player = _player;
    }

    // Check that the first character is not null.
    function isComplete(address deployedAt) public view returns (bool) {
        CaptureTheEther cte = CaptureTheEther(deployedAt);
        return cte.nicknameOf(player)[0] != 0;
    }
}

You can see here that we've moved the lookup of the referred Smart Contract inside the isComplete method, and added a deployedAt address parameter. This lets us tell our NicknameChallenge contract where to find the instance of the CaptureTheEther contract on which to check that your nickname has been set.

Now, let's set up our unit test. This one's going to be a little more advanced than the tests we've done so far, too - so that we can more closely mimic how we'd solve this challenge using a Hardhat script, and also explore the mocha testing environment in a little more detail, too. Create your test file as follows:

test/warmup/nickname-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("NicknameChallenge", function () {
  let backendAddress: string;

  before(async () => {
    const Backend = await ethers.getContractFactory("CaptureTheEther");
    const backend = await Backend.deploy();
    await backend.deployed();
    backendAddress = backend.address;
  });

  it("should return true after we set our nickname", async function () {
    const [player] = await ethers.getSigners();

    const Challenge = await ethers.getContractFactory("NicknameChallenge");
    const challenge = await Challenge.deploy(player.address);
    await challenge.deployed();

    const Backend = await ethers.getContractFactory("CaptureTheEther");
    const backend = Backend.attach(backendAddress);

    const setNicknameTx = await backend
      .connect(player)
      .setNickname(ethers.utils.formatBytes32String("n00bsys0p"));
    await setNicknameTx.wait();

    expect(await challenge.isComplete(backend.address)).to.equal(true);
  });
});

Here, we use mocha's before method to set up our "Backend" contract, which is our deployed instance of the CaptureTheEther contract. We also assign the variable backendAddress within the before method, so that we can interact with it, as we need to know where it's been deployed to in order to do so.

The meat of this test is where we attach to the instance of CaptureTheEther deployed at backendAddress, call the setNickname method and wait for the transaction to be mined. We then call our modified isComplete method, passing in the address at which the backend is deployed. If all has gone well, we've got a third and final successful call on the Capture The Ether warmup section. As before, run npx hardhat test test/warmup/nickname-challenge.ts to see that the test passes successfully.

That's all we're going to cover for today, but we'll soon have a go at section 2 - Lotteries. That's where things start getting a lot more fun. The code for today's article is all available on the same Github repository I pushed for the previous article.