スマートコントラクトを作成して、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)
で、トランザクションを作成するアカウントを変えているところです.
これによって、create
と solve
で、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); }); }); });