ブロックチェーン技術ブログ

ブロックチェーンと英語、数学に関するブログ

スマートコントラクトを作成して、Hardhatでテストをしてみる

今回は、Solidityで作成したスマートコントラクトをHardhatでテストしてみたいと思います.

スマートコントラクトの概要

作成したスマートコントラクトは、クイズを題材にしたものです.
まず、問題の出題者がETHの送金とともに、問題を作成するトランザクションを作成します.(受け渡しするものをトークンにした方が面白そうです)
次に、解答者が問題を選択して、その問題を解答するトランザクションを作成します.
正解の場合、出題者が問題作成時に送金したETHが解答者に送金されます.
また、解答期限を過ぎた場合、出題者が問題作成時に送金したETHを自身のアカウントに引き落とすことができます.

スマートコントラクトのコード

まず、スマートコントラクトのコードをみてみましょう.

github.com
(上記のリンクからソースコード全体を確認できます. )

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.9;

contract Quiz {
    struct Question {
        address payable questioner;
        string sentence;
        bytes32 answer;
        uint reward;
        uint expiration;
        address payable recipient;
    }

    Question[] public questions;

    uint constant DURATION = 1 weeks;

    event Created(uint questionId, address questioner);
    event Solved(uint questionId, address recipient);

    function create(string calldata sentence, string calldata answer) external payable {
        require(bytes(sentence).length > 0, "Sentence is required.");
        require(bytes(answer).length > 0, "Answer is required.");
        require(msg.value > 0, "Reward is required.");

        Question memory question = Question(
            payable(msg.sender), sentence, keccak256(abi.encodePacked(answer)), msg.value, block.timestamp + DURATION, payable(0)
        );
        questions.push(question);

        emit Created(questions.length - 1, msg.sender);
    }

    function solve(uint questionId, string calldata answer) external {
        require(questionId < questions.length, "Question doesn't exist.");

        Question storage question = questions[questionId];

        require(question.questioner != msg.sender, "Questioner is not allowed to answer their question.");
        require(question.recipient == address(0), "Question has already been solved.");
        require(question.expiration >= block.timestamp, "Question has already been closed.");
        require(question.answer == keccak256(abi.encodePacked(answer)), "Answer is not correct.");

        (bool sent,) = payable(msg.sender).call{value : question.reward}("");
        require(sent, "Failed to send Ether");

        question.recipient = payable(msg.sender);

        emit Solved(questionId, msg.sender);
    }

    function withdraw(uint questionId) external {
        require(questionId < questions.length, "Question doesn't exist.");

        Question storage question = questions[questionId];

        require(question.questioner == msg.sender, "Questioner is only allowed to withdraw.");
        require(question.recipient == address(0), "Question has already been solved.");
        require(question.expiration < block.timestamp, "Question is open.");

        (bool sent,) = payable(msg.sender).call{value : question.reward}("");
        require(sent, "Failed to send Ether");

        question.recipient = payable(msg.sender);
    }
}

問題を表すStruct・変数

今回は、Structを使用して、問題を表しました.
また、問題を配列で管理することで、一つのコントラクトで、複数の問題の出題・回答をできるようにしました.

QuestionのStructの各メンバに説明を記載しました.

pragma solidity ^0.8.9;

contract Quiz {
    struct Question {
        // 出題者のアカウントのアドレス
        address payable questioner;
        // 出題文
        string sentence;
        // 解答をkeccak256を使用して、ハッシュした値
        // ※ State変数は、privateにしても外部から参照可能なので、機密情報は保存でないので注意!!
        bytes32 answer;
        // 解答者が受け取る報酬
        uint reward;
        // 解答の有効期限
        uint expiration;
        // 報酬を受け取った人のアドレス
        // 解答期限内に正答した人がいた場合、解答者のアカウントのアドレス
        // 解答期限を過ぎて、出題者自らETHを引き落とした場合、出題者のアカウントのアドレス
        address payable recipient;
    }

    Question[] public questions;
   ...
}

問題の作成

問題の作成は、引数の出題文とその答えを引数にして、create 関数を呼び出します.
また、修飾子に payable をつけて、create 関数の呼び出しと同時にETHを送金できるようにしています.

pragma solidity ^0.8.9;

contract Quiz {
    ...
    // 問題作成時に発火するイベント
    event Created(uint questionId, address questioner);
    ...
    function create(string calldata sentence, string calldata answer) external payable {
        require(bytes(sentence).length > 0, "Sentence is required.");
        require(bytes(answer).length > 0, "Answer is required.");
        // 問題作成のトランザクションは、ETHの送金を必須にする
        require(msg.value > 0, "Reward is required.");

        // answerは、keccak256でハッシュしておく
        Question memory question = Question(
            payable(msg.sender), sentence, keccak256(abi.encodePacked(answer)), msg.value, block.timestamp + DURATION, payable(0)
        );
        questions.push(question);

        emit Created(questions.length - 1, msg.sender);
    }
    ...
}

問題の回答

問題の解答は、問題のID(questionsのインデックス)と解答を引数にして、solve 関数を呼び出します.

※ちなみに、Question.answer を外部から参照することで、オフチェーンで解答の検証ができます.

pragma solidity ^0.8.9;

contract Quiz {
    ...
    // 問題の解答が正解の時に発火するイベント
    event Solved(uint questionId, address recipient);
    ...
    function solve(uint questionId, string calldata answer) external {
        require(questionId < questions.length, "Question doesn't exist.");

        Question storage question = questions[questionId];

        // 出題者本人は解答できない
        // ※ ただし、アカウントを変えた場合は、解答できる
        require(question.questioner != msg.sender, "Questioner is not allowed to answer their question.");
        // 既に解答済みの場合、解答不可
        require(question.recipient == address(0), "Question has already been solved.");
        // 解答期限切れの場合、解答不可
        require(question.expiration >= block.timestamp, "Question has already been closed.");
        // 解答が不正解の場合、不可
        // 返り値をtrue/falseにして、不正解の場合falseにするか迷ったが
        // 解答が不一致の場合もエラーにした方がコードがシンプルになるので、この方法にした
        require(question.answer == keccak256(abi.encodePacked(answer)), "Answer is not correct.");

        // 正解の場合、解答者のアカウントに送金する
        (bool sent,) = payable(msg.sender).call{value : question.reward}("");
        require(sent, "Failed to send Ether");

        // recipient に 解答者のアカウントのアドレスを設定して、解答済みにする
        question.recipient = payable(msg.sender);

        emit Solved(questionId, msg.sender);
    }
    ...
}

解答期限が切れた問題からETHを引き落とす

問題の解答期限が過ぎても、解答されなかった場合、出題者は問題作成時に送金したETHを引き落とすことができます.
問題ID(questionsのインデックス)を引数にして、withdrawを呼び出します.

pragma solidity ^0.8.9;

contract Quiz {
    ...
    function withdraw(uint questionId) external {
        require(questionId < questions.length, "Question doesn't exist.");

        Question storage question = questions[questionId];

        // 問題の出題者のみ引き落とし可能
        require(question.questioner == msg.sender, "Questioner is only allowed to withdraw.");
        // 未解答もしくは、まだ引き落とししていない場合のみ、引き落とし可能
        require(question.recipient == address(0), "Question has already been solved.");
        // 解答期限が過ぎている場合のみ、引き落とし可能
        require(question.expiration < block.timestamp, "Question is open.");

        (bool sent,) = payable(msg.sender).call{value : question.reward}("");
        require(sent, "Failed to send Ether");

        question.recipient = payable(msg.sender);
    }
}

スマートコントラクトのテストコード

テストコード全体は、以下のリンクで確認できます.
https://github.com/threeislands/quiz-dapp/blob/main/test/Quiz.js

上から順に抜粋して、みていきます.

Fixture

deployQuizFixture で、他のテストでも使用可能なFixtureを作成しておきます.

describe("Quiz contract", function () {
  // We define a fixture to reuse the same setup in every test. We use
  // loadFixture to run this setup once, snapshot that state, and reset Hardhat
  // Network to that snapshot in every test.
  async function deployQuizFixture() {
    // Get the ContractFactory and Signers here.
    const Quiz = await ethers.getContractFactory("Quiz");
    const [owner, addr1] = await ethers.getSigners();

    // To deploy our contract, we just have to call Token.deploy() and await
    // for it to be deployed(), which happens onces its transaction has been
    // mined.
    const hardhatQuiz = await Quiz.deploy();

    await hardhatQuiz.deployed();

    // Fixtures can return anything you consider useful for your tests
    return {Quiz, hardhatQuiz, owner, addr1};
  }
  ...
}

create のテスト

1,2番目のテスト(Should create question)では、100weiの送金と一緒に、問題文、解答を引数にして、create を呼び出します.
1番目では、questions 関数を使用して、作成した question の各メンバが期待する値と一致することをテストしています.
2番目では、Created イベントが発火することをテストしています.
3~5番目のテストで、誤りのあるデータで、期待するエラーが発生することをテストしています.

describe("Quiz contract", function () {
  ...
  describe("Create", function () {
    it("Should create question", async function () {
      const {hardhatQuiz, owner} = await loadFixture(deployQuizFixture);

      await hardhatQuiz.create("4 + 4", "8", {value: 100});

      const question = await hardhatQuiz.questions(0);
      expect(question.questioner).to.equal(owner.address);
      expect(question.sentence).to.equal("4 + 4");
      expect(question.answer).to.equal(ethers.utils.keccak256(ethers.utils.toUtf8Bytes("8")));
      expect(question.reward).to.equal("100");
      expect(question.recipient).to.equal(ethers.constants.AddressZero);
    });

    it("Should emit `Created`", async function () {
      const {hardhatQuiz, owner} = await loadFixture(deployQuizFixture);

      await expect(hardhatQuiz.create("4 + 4", "8", {value: 100}))
          .to.emit(hardhatQuiz, "Created").withArgs(0, owner.address);
    });

    it("Should fail if sentence is empty", async function () {
      const {hardhatQuiz} = await loadFixture(deployQuizFixture);

      await expect(hardhatQuiz.create("", "8", {value: 100}))
          .to.be.revertedWith("Sentence is required.");
    });

    it("Should fail if answer is empty", async function () {
      const {hardhatQuiz} = await loadFixture(deployQuizFixture);

      await expect(hardhatQuiz.create("4 + 4", "", {value: 100}))
          .to.be.revertedWith("Answer is required.");
    });

    it("Should fail if value is 0", async function () {
      const {hardhatQuiz} = await loadFixture(deployQuizFixture);

      await expect(hardhatQuiz.create("4 + 4", "8"))
          .to.be.revertedWith("Reward is required.");
    });
  });
  ...
});

※ 以降のテストケースでは、エラーのテストケースを網羅していません.

solve のテスト

ポイントは、solve の前に connect(addr1) で、トランザクションを作成するアカウントを変えているところです.
これによって、createsolve で、msg.sender を切り変えています.

1番目のテストでは、解答後にコントラクト(hardhatQuiz)から解答者(addr1)に送金されることをテストしています.
また、question.recipient に解答者がセットされることもテストしています.

describe("Quiz contract", function () {
  ...
  describe("Solve", function () {
    it("Should create question", async function () {
      const {hardhatQuiz, addr1} = await loadFixture(deployQuizFixture);

      await hardhatQuiz.create("4 + 4", "8", {value: 100});

      await expect(hardhatQuiz.connect(addr1).solve(0, "8"))
          .to.changeEtherBalances(
              [hardhatQuiz, addr1],
              [-100, 100],
          )

      const question = await hardhatQuiz.questions(0);
      expect(question.recipient).to.equal(addr1.address);
    });

    it("Should emit `Solved`", async function () {
      const {hardhatQuiz, addr1} = await loadFixture(deployQuizFixture);

      await hardhatQuiz.create("4 + 4", "8", {value: 100});

      await expect(hardhatQuiz.connect(addr1).solve(0, "8"))
          .to.emit(hardhatQuiz, "Solved").withArgs(0, addr1.address)
    });
  });
  ...
});

withdraw のテスト

ポイントは、evm_increaseTime を使用して、次に作成するブロックの日時を進めいています.
これによって、実際に解答期限が過ぎるのを待たなくても、解答期限が過ぎた状態を作り出すことができます.

describe("Quiz contract", function () {
  ...
  describe("Withdraw", function () {
    it("Should withdraw ETH", async function () {
      const {hardhatQuiz, owner} = await loadFixture(deployQuizFixture);

      await hardhatQuiz.create("4 + 4", "8", {value: 100});

      await network.provider.send("evm_increaseTime", [60 * 60 * 24 * 7])
      await network.provider.send("evm_mine")

      await expect(hardhatQuiz.withdraw(0))
          .to.changeEtherBalances(
              [hardhatQuiz, owner],
              [-100, 100],
          )

      const question = await hardhatQuiz.questions(0);
      expect(question.recipient).to.equal(owner.address);
    });
  });
});