前回の記事で Hardhat を使って Ethereum スマートコントラクトを開発・テスト・デプロイする方法を紹介した。

Hardhat を使った Ethereum スマートコントラクト開発メモ
本記事では、Hardhat を使った Ethereum スマートコントラクト開発について紹介する。Hardhat はスマートコントラクト開発に必要なものが揃った開発環境であり、プロジェクトテンプレートの生成、コントラクトのコンパイルやシミュレーター上でのテスト、デプロイのいずれをも `hardhat` コマンドを通して行うことができる。さらに、TypeScript をサポートしており、TypeScript 版のテンプレートを生成できる上に、typechain を使って、コントラクトに対応する型定義ファイルを出力して開発に利用できる。

前回は全体の流れを紹介したが、本記事ではより踏み込んだシナリオでのテスト方法を紹介する。

複数のアドレスを使う

コントラクトをデプロイしたアドレスとそれ以外とか、トークンの送り手側と受け手側など、複数のアドレスを使ったテストが必要な状況がある。この場合、以下のようにして別のアドレスを使ってコントラクトを操作できる。

const [signer1, signer2, signer3] = await ethers.getSigners();
const Greeter = await ethers.getContractFactory("Greeter");

// signer1 としてデプロイ
const greeter = await Greeter.connect(signer1).deploy("Hello, world!");
await greeter.deployed();

// signer2 としてコントラクト呼び出し
await greeter.connect(signer2).greet();
// signer3 としてコントラクト呼び出し
await greeter.connect(signer3).greet();

connect() を省略した場合、暗黙的に最初の signer (signer1) を利用して操作が行われると考えると分かりやすい。

シミュレーション時は 20 個のテスト用アドレスが元から用意されている。実際にネットワークと通信する場合は、hardhat.config.ts 内の accounts に含めた秘密鍵に対応した signer になると思うが確認はしていない。

コントラクトのログを取得する

コントラクトのログを取得するためには、await tx.wait() の返り値(ContractReceipt)の events 配列を取得する。 この配列の各要素内の args フィールド(events[].args)には、発生した順にログが格納されている。Solidity で定義したイベントの型は、TypeChain により生成される型定義ファイルの中に ${イベント名}Event という名前で定義されており、ログの発生順が分かっていれば events の各要素を対応するイベント型にキャストできる。

例として、Greeter.sol を変更して、setGreeting() が呼ばれた時にログを出力する以下のようなコントラクトの場合

pragma solidity ^0.8.0;

contract Greeter {
    event SetGreetingEvent(string oldGreeting, string newGreeting);
    string private greeting;

    constructor(string memory _greeting) {
        console.log("Deploying a Greeter with greeting:", _greeting);
        greeting = _greeting;
    }

    function greet() public view returns (string memory) {
        return greeting;
    }

    function setGreeting(string memory _greeting) public {
        emit SetGreetingEvent(greeting, _greeting);
        greeting = _greeting;
    }
}

以下のようなコードでログを取得できる。

import { SetGreetingEventEvent as SetGreetingEvent } from '../typechain/Greeter';

//...

const Greeter = await ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy("Hello, world!");
await greeter.deployed();

expect(await greeter.greet()).to.equal("Hello, world!");

const setGreetingTx = await greeter.setGreeting("Hola, mundo!");

// wait until the transaction is mined
const receipt = await setGreetingTx.wait();
const { args } = receipt.events?.[0] as SetGreetingEvent;

expect([args[0], args[1]]).to.eql(["Hello, world!", "Hola, mundo!"]); // 配列インデックスでのアクセス
expect([args.oldGreeting, args.newGreeting]).to.eql(["Hello, world!", "Hola, mundo!"]); //プロパティ名でのアクセス

型定義はイベント型の名前にサフィックスとして Event とつけるので、元々の(コントラクト内での)イベント名を xxxEvent としていた場合、型名が xxxEventEvent となってしまうので注意したい。インポート時に名前を修正するのが良いと思う。イベント内の値には、配列インデックスとプロパティ名での両方でアクセスできる。

シミュレーション時のネットワークへの介入

テスト時に、ブロックのタイムスタンプを調整したいという状況がある。例えばタイムスタンプが重要なコントラクトをテストする場合だ。また、同じブロック内でコントラクトが実行された場合のテストをするために、マイニングのタイミングを調整したいこともある。このような場合、以下のように network.provider.send() でシミュレーターにコマンドを発行し、ネットワークに介入することができる。

await ethers.provider.send('hardhat_reset', []); // ネットワークをリセットする
await ethers.provider.send('evm_setAutomine', [false]) // ブロックが自動でマイニングされないようにする
await ethers.provider.send("evm_setNextBlockTimestamp", [0]); // 次にマイニングされるブロックの timestamp を 0 にする
await ethers.provider.send("evm_mine", []);  // マイニングをする。

以上の例では、自動でマイニングされないようにした上で、timestamp が 0 のブロックをマイニングしている。最新のブロックよりも古い timestamp は設定できないので、事前にチェーンをリセットしている。自動マイニングを切った場合、evm_mine を発行するたびにブロックがマイニングされる。

他に利用できるコマンドは Hardhat Network Reference で調べることができる。

以上、テストでの小ネタを紹介した。特に最後のネットワークに介入する機能は便利で、様々なシナリオのテストに役立つと思う。