スマートコントラクトの開発環境「waffle」を使用してみた

by AIGRAM

Ethereumの開発環境を網羅的に勉強中です。今回はスマートコントラクトの開発環境「waffle」を使用してみました。waffleでは「mocha」というテスティングライブラリや「chai」というアサーションライブラリなどと組み合わせて利用することで、開発を大幅に精度高く・素早く行うことができます。

目次

waffleとは

特徴

  • Waffleを一言で言うと、スマートコントラクトを書いてテストするためのライブラリ
  • Truffleよりも簡単で動作が早い

環境設定

Getting Started

以下を実行して環境を整備する

コード
1
2
yarn add --dev ethereum-waffle
yarn add @openzeppelin/contracts@^4.6.0 -D

ERC20

ファイル構成

ファイル構成
1
2
3
4
5
6
# ファイル構成
.
├── package.json
├── src
│   └── BasicToken.sol
├── waffle.json

package.json

コード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# package.json
{
  "scripts": {
    "build": "waffle"
  },
  "devDependencies": {
    "@openzeppelin/contracts": "^4.6.0",
    "ethereum-waffle": "^3.4.4"
  }
}

waffle.json

コード
1
2
3
4
5
6
7
# waffle.json
{
  "compilerType": "solcjs",
  "compilerVersion": "0.8.13",
  "sourceDirectory": "./src",
  "outputDirectory": "./build"
}

src/BasicToken.sol

コード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// src/BasicToken.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

// Example class - a mock class using delivering from ERC20
contract BasicToken is ERC20 {
  constructor(uint256 initialBalance) ERC20("Basic", "BSC") {
      _mint(msg.sender, initialBalance);
  }
}

Screen Shot 2022-11-24 at 20.06.11.png

上記のようにVSCodeでimport "@openzeppelin/contracts/token/ERC20/ERC20.sol";が波線になっており、Source "@openzeppelin/contracts/token/ERC20/ERC20.sol" not found: File import callback not supported というエラーが出ている場合

Screen Shot 2022-11-24 at 20.08.48.png

VSCodeの拡張パッケージからSolidity Extentionをインストールして、コードを右クリック Solidity: Change the default workspace ompiler to Remote, Local, NodeModule, Embeddedを選択する。

Screen Shot 2022-11-24 at 20.10.14.png

localNodeModuleを選択することで波線のエラーマークが消える

ビルドコマンド

ビルドコマンド
1
2
# ビルドコマンド
yarn build

これだけでビルドファイルがbuildに生成されます。 スマートコントラクトを書いてビルドするまでが大分簡単になりました。

ERC20ビルドファイル

ファイル構成

ERC20ビルドファイル
1
2
3
4
5
6
7
8
# ERC20ビルドファイル
.
├── build
│   ├── BasicToken.json
│   ├── Context.json
│   ├── ERC20.json
│   ├── IERC20.json
│   └── IERC20Metadata.json

出力例

コード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 出力の一部(build/BasicToken.json
{
  "abi": [
    {
      "inputs": [
        {
          "internalType": "uint256",
          "name": "initialBalance",
          "type": "uint256"
        }
      ],
      "stateMutability": "nonpayable",
      "type": "constructor"
    },
    {...

abiはApplication Binary Interfaceの略であり、デプロイしたコントラクトの使用方法がまとめられている。 スマートコントラクトを外部から呼び出すための仕様書の役割を果たす。

他の出力内容についてもみてみる。 出力ファイルをまとめると以下の構成となる

コード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 出力ファイルの構成(Contract JSON ABI)
- abi: []
- evm:
  - bytecode
    - functionDebugData
    - generatedSources
    - linkReferences
    - object
    - opcodes
    - sourceMap
  - deployedBytecode
- bytecode: str

このファイルはContract JSON ABI (Application Binary Interface) と呼ばれている。

bytecode

一般的にこのビルドと同時にプログラムがコンパイルされたバイトコードも出力される。

waffleの場合は、jsonファイル内のbytecodeに出力される。 ブロックチェーン上にスマートコントラクトをデプロイするときにはこのbytecodeを使用する。

コード
1
2
# bytecodeの一部
  "bytecode": "60806040523480156200001157600080fd5b506040516200179438038062001794833981810160405281019062000037919062000357565b604051806

bytecodeの中身を見てみると上記のような16進数の羅列となっている。 bytecodeは機械語に変換されているため、人間には読むことが困難です。

object

コード
1
2
# objectの一部
      "object": "608060405234801561001057600080fd5b50600436106100a95760003560e01c80633950935111610071578063395093511461016857806370a082311

objectの中身もbytecodeと同じ内容となっています。コンパイルされたスマートコントラクトのbytecodeが記述されています。

opcodes

実際にこのbytecodeを元にシステム上でどのような処理を行うかを記述したものが、opcodesです。 以下、jsonファイル内のopcodesの一部を載せます。

コード
1
2
# opcodesの一部
      "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x4 CALLDATASIZE LT PUSH2 0xA9 JUMPI PUSH1 0x0 CALLDATALOAD PUSH1 0xE0 SHR DUP1 PUSH4 

opcodes一覧にbytecodeからopcodesに変換するルールがまとめられています。 bytecodeは流石に読めませんが、opcodesなら頑張れば読めそうです。

sourceMap

コード
1
2
# sourceMapの一部
      "sourceMap": "175:139:4:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2154:98:0;;;:::i;::

sourceMapはソースコードとopcodesの位置関係をまとめたファイルです。 opcodesは実際に動作する機械語に変換されたコードでした。 opcodesを実行中にエラーが発生した場合に、ソースコード上のどの位置が原因でそのエラーが発生したのかを特定するときに、sourceMapが使用されます。

sourceMapは最大で4つの組み合わせ(:で区切られる)で表現されます。 一番最初の組み合わせ(;で区切られる)には175:139:4:-:0と書かれていますが、これは、以下のルールに基づいて処理されます。

ファイル内の開始位置:ファイル内での長さ:ファイルindex:jump命令

詳細はこちらを参照してください

functionDebugData

コード
1
2
3
4
5
6
7
8
# functionDebugDataの一部
      "functionDebugData": {
        "@_44": {
          "entryPoint": null,
          "id": 44,
          "parameterSlots": 2,
          "returnSlots": 0
        },

functionDebugDataは関数レベルでデバッグをするときの情報です。

  • entryPoint:関数の場所を表す
  • id:AST (Abstract Syntax Tree)のidを表す
  • parameterSlots:関数の引数に使用するスタックスロット
  • returnSlots:関数の返り値に使用するスタックスロット

generatedSources

コード
1
2
3
4
5
6
7
8
# generatedSourcesの一部
      "generatedSources": [
        {
          "ast": {
            "nodeType": "YulBlock",
            "src": "0:3568:5",
            "statements": [
              {

バグが発生した場合により詳細なバグの発生場所を特定するために、generatedSourcesを使用します。

詳細はこちらを参照してください

linkReferences

Remix公式ページによると、linkReferencesにはコントラクトに依存関係があるライブラリが記述されます。

MochaとChai

ビルドされたファイルの実行テストにはMoachaやChaiを使用します。 テストライブラリは任意のものが使えるので、JestやAvaに慣れている人はそちらも使用可能です。

Moachaはテスティングフレームワークで、Chaiはアサーションライブラリと呼ばれています。

以下のコマンドでMochaとChaiをインストールします

MoachaとChaiのインストールコマンド
1
2
3
4
# MoachaとChaiのインストールコマンド
yarn add --dev mocha chai @types/mocha
# Typescriptのインストールコマンド
yarn add --dev typescript ts-node

package.jsonが更新されます

コード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# package.json
{
  "scripts": {
    "build": "waffle"
  },
  "devDependencies": {
    "@openzeppelin/contracts": "^4.6.0",
    "chai": "^4.3.7",
    "ethereum-waffle": "^3.4.4",
    "mocha": "^10.1.0"
  }
}

公式のテストコード

とりあえず公式のテストコードを実行してみます。

test/BasicToken.test.ts

コード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# test/BasicToken.test.ts

import {expect, use} from 'chai';
import {Contract} from 'ethers';
import {deployContract, MockProvider, solidity} from 'ethereum-waffle';
import BasicToken from '../build/BasicToken.json';

use(solidity);

describe('BasicToken', () => {
  const [wallet, walletTo] = new MockProvider().getWallets();
  let token: Contract;

  beforeEach(async () => {
    token = await deployContract(wallet, BasicToken, [1000]);
  });

  it('Assigns initial balance', async () => {
    expect(await token.balanceOf(wallet.address)).to.equal(1000);
  });

  it('Transfer adds amount to destination account', async () => {
    await token.transfer(walletTo.address, 7);
    expect(await token.balanceOf(walletTo.address)).to.equal(7);
  });

  it('Transfer emits event', async () => {
    await expect(token.transfer(walletTo.address, 7))
      .to.emit(token, 'Transfer')
      .withArgs(wallet.address, walletTo.address, 7);
  });

  it('Can not transfer above the amount', async () => {
    await expect(token.transfer(walletTo.address, 1007)).to.be.reverted;
  });

  it('Can not transfer from empty account', async () => {
    const tokenFromOtherWallet = token.connect(walletTo);
    await expect(tokenFromOtherWallet.transfer(wallet.address, 1))
      .to.be.reverted;
  });

  it('Calls totalSupply on BasicToken contract', async () => {
    await token.totalSupply();
    expect('totalSupply').to.be.calledOnContract(token);
  });

  it('Calls balanceOf with sender address on BasicToken contract', async () => {
    await token.balanceOf(wallet.address);
    expect('balanceOf').to.be.calledOnContractWith(token, [wallet.address]);
  });
});

.mocharc.json

Mocha用の設定ファイルを新しく生成します。

コード
1
2
3
4
5
# .mocharc.json
{
  "require": "ts-node/register/transpile-only",
  "spec": "test/**/*.test.{js,ts}"
}

package.json

諸々あってpackage.jsonは以下のようになりました。 公式のまま作成すると、Error: error:0308010C:digital envelope routines::unsupportedというエラーが出て実行できなかったのでNODE_ENV=test mocha --openssl-legacy-providerと変更しています。 nodeのバージョンが高すぎると発生するらしいです。

コード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# package.json
{
  "scripts": {
    "build": "waffle",
    "test": "NODE_ENV=test mocha --openssl-legacy-provider"
  },
  "devDependencies": {
    "@openzeppelin/contracts": "^4.6.0",
    "@types/mocha": "^10.0.0",
    "chai": "^4.3.7",
    "ethereum-waffle": "^3.4.4",
    "mocha": "^10.1.0"
  },
  "dependencies": {
    "ts-node": "^10.9.1",
    "typescript": "^4.9.3"
  }
}

以下、テスト結果です。

テスト結果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# テスト結果
yarn test
yarn run v1.22.19
warning package.json: No license field
$ NODE_ENV=test mocha --openssl-legacy-provider


  BasicToken
    ✔ Assigns initial balance
    ✔ Transfer adds amount to destination account (59ms)
    ✔ Transfer emits event (49ms)
    ✔ Can not transfer above the amount
    ✔ Can not transfer from empty account
    ✔ Calls totalSupply on BasicToken contract
    ✔ Calls balanceOf with sender address on BasicToken contract


  7 passing (773ms)

記載してあるテストコードは問題なく完了していることが確認できました

Mochaによる単純テストコード

それではテストコードを理解していきます。 まずはchaiの機能は使用せずに、mochaの機能だけを使用します。

通常は全ての条件をパスすることを前提にコードを書くかと思いますが、あくまで練習なのでわざとエラーを発生させる条件を使用します。

コード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Mochaによる単純テスト

describe('tests', () => {

  let v = 1

  it('is equal to 5', async () => {
    if (v===5) {
      return
    }
    throw '5ではありません'
  });

  it('is bigger than 4', async () => {
    if (v>4) {
      return
    }
    throw '5以下です'
  });

  it('is smaller than 6', async () => {
    if (v<6) {
      return
    }
    throw '6以上です'
  });

});

describeやitがMochaによって使用可能になった機能です。 describeではテスト単位を定義します。 itでは単体のテストを定義します。

今回の例では、「tests」というテスト単位に「is equal to 5」「is bigger than 4」「is smaller than 6」という3つのテストを用意しています。

「1」という数字に対して、「5と一致するか」「4より大きいか」「6より小さいか」を検証しています。

こちらは当然、三つ目の「6より小さいか」だけ正常で、残りの二つは以上値になることが考えられます。

こちらを実行すると、以下のような出力が得られます。

Mochaによる単純テストの出力結果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Mochaによる単純テストの出力結果

yarn test
yarn run v1.22.19
warning package.json: No license field
$ NODE_ENV=test mocha --openssl-legacy-provider


  tests
    1) is equal to 5
    2) is bigger than 4
    ✔ is smaller than 6


  1 passing (5ms)
  2 failing

  1) tests
       is equal to 5:
     Error: the string "5ではありません" was thrown, throw an Error :)


  2) tests
       is bigger than 4:
     Error: the string "5以下です" was thrown, throw an Error :)

想定通りの結果になっていることが確認できました。 エラーが発生した後も他のテスト内容が実行されていることが確認できます。

vを5にすると、全ての条件が満たされるので、以下のような出力になります。

Mochaによる単純テストの出力結果(全てパス)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Mochaによる単純テストの出力結果(全てパス)
yarn test
yarn run v1.22.19
warning package.json: No license field
$ NODE_ENV=test mocha --openssl-legacy-provider


  tests
    ✔ is equal to 5
    ✔ is bigger than 4
    ✔ is smaller than 6


  3 passing (2ms)

Mochaでテスト毎の前処理を追加

Mochaではテスト毎の前処理を追加することができます。

Mochaでテスト毎の前処理を追加
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Mochaでテスト毎の前処理を追加

describe('tests', () => {

  let v = 6

  beforeEach(async () => {
    v = 5
  });

  it('is equal to 5', async () => {
    if (v===5) {
      return
    }
    throw '5ではありません'
  });

});

ReactでいうuseEffectのようなイメージでしょうか。 beforeEachに関数を設定することで、テスト毎(it毎)に毎回beforeEachを実行してくれます。 上記の例では、初期値としてv=6を設定していますが、前処理でv=5に変更しています。

実行結果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 実行結果
yarn run test
yarn run v1.22.19
warning package.json: No license field
$ NODE_ENV=test mocha --openssl-legacy-provider


  tests
    ✔ is equal to 5


  1 passing (2ms)

前処理が動作したことによって、v=5で処理されていることが確認できます。

Chaiを加えた単純テストコード

Mochaだけでは、単純な比較にも関わらず4行の分量になっており、少し冗長に感じます。 こちらをChaiを導入したコードに変換してみます。

Chaiを加えた単純テストコード
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Chaiを加えた単純テストコード

import {expect} from 'chai';

describe('tests', () => {

  let v = 1

  it('is equal to 5', async () => {
    expect(v).to.equal(5);
  });

  it('is bigger than 4', async () => {
    expect(v).to.be.above(4);
  });

  it('is smaller than 6', async () => {
    expect(v).to.be.below(6)
  });
});

コードが単純化していることが確認できます。 また、chaiではエラーのメッセージを指定する必要がありません。

このテストコードの実行結果は以下の通りでした。

Chaiを加えた単純テストコードの出力結果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Chaiを加えた単純テストコードの出力結果

yarn test
yarn run v1.22.19
warning package.json: No license field
$ NODE_ENV=test mocha --openssl-legacy-provider


  tests
    1) is equal to 5
    2) is bigger than 4
    ✔ is smaller than 6


  1 passing (6ms)
  2 failing

  1) tests
       is equal to 5:

      AssertionError: expected 1 to equal 5
      + expected - actual

      -1
      +5

      at Context.<anonymous> (test/BasicToken.test.ts:13:18)
      at processImmediate (node:internal/timers:471:21)

  2) tests
       is bigger than 4:

      AssertionError: expected 1 to be above 4
      + expected - actual

      -1
      +4

      at Context.<anonymous> (test/BasicToken.test.ts:17:21)
      at processImmediate (node:internal/timers:471:21)

エラーメッセージを指定していないにも関わらず、なぜエラーになったかがわかるのは良いですね。

今回は以下の三つの機能を使用しました。

今回使用した3つの機能
1
2
3
4
// 今回使用した3つの機能
expect(v).to.equal(5);
expect(v).to.be.above(4);
expect(v).to.be.below(6)

こちら、equal(5)があるので残りの二つのチェックは必要ないかと思います。 chaiでは機能として、little thanやgreater thanに対応する機能もありますが、これは非推奨な使い方です。 想定通りにコードが動いている = 何かを実行して帰ってくる値が想定している値と一致することを確認するというのがテストコードの役割なんだと思います。

Chaiへの自作アサーションの追加

chainのuseという機能を利用することで自作のアサーションを登録することができます。

数値が想定通りに得られるかを計算する自作アサーション
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 数値が想定通りに得られるかを計算する自作アサーション

import {expect, use} from 'chai';

const f = (chai, _)=>{
  var Assertion = chai.Assertion;

  Assertion.addMethod("myequal", function(val, msg) {
    if (msg) {
      _.flag(this, "message", msg)
    }
    var obj = _.flag(this, "object")

    this.assert(
      val === obj,
      "expected #{this} to equal #{exp}",
      "expected #{this} to not equal #{exp}",
      val
    );
  });
}

use(f)

describe('tests', () => {

  let v = 6

  it('is my equal to 5', async () => {
    expect(v).to.myequal(5)
  });

  it('is my not equal to 5', async () => {
    expect(v).to.not.myequal(5)
  });

  it('is my equal to 6', async () => {
    expect(v).to.not.myequal(6)
  });

関数を用意して、useに渡すだけなので、非常に簡単です。

このアサーションライブラリでは、notを付与することで、反転させたアサーションを利用することができます。

注意点としては、以下のように、追加するアサーションでも、反転した場合のアサーションログを設定する必要があります。 expected #{this} to not equal #{exp}

出力結果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 出力結果

yarn run test
yarn run v1.22.19
warning package.json: No license field
$ NODE_ENV=test mocha --openssl-legacy-provider


  tests
    1) is my equal to 5
    ✔ is my not equal to 5
    2) is my equal to 6


  1 passing (7ms)
  2 failing

  1) tests
       is my equal to 5:

      AssertionError: expected 6 to equal 5
      + expected - actual

      -6
      +5

      at Context.<anonymous> (test/BasicToken.test.ts:33:18)
      at processImmediate (node:internal/timers:471:21)

  2) tests
       is my equal to 6:

      AssertionError: expected 6 to not equal 6
      + expected - actual


      at Context.<anonymous> (test/BasicToken.test.ts:41:22)
      at processImmediate (node:internal/timers:471:21)

設定したアサーションログが出力されていることが確認できます。

Mocha+ChaiへのEthereumアサーションの追加

Chaiで使用可能なEthereum用アサーションはethereum-waffleからインポートできます。

Chaiで使用可能なEthereum用アサーション
1
2
3
4
5
// Chaiで使用可能なEthereum用アサーション

import {solidity} from 'ethereum-waffle';

use(solidity);

ethereum-waffleにはchai用のアサーションライブラリがあるので、それをuseするだけで利用することができます。

コントラクトをデプロイして発行したトークン量が正しい値かを検証

コントラクトをデプロイして発行したトークン量が正しい値かを検証する
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// コントラクトをデプロイして発行したトークン量が正しい値かを検証する

import {expect, use} from 'chai';
import {Contract} from 'ethers';
import {deployContract, MockProvider, solidity} from 'ethereum-waffle';
import BasicToken from '../build/BasicToken.json';

use(solidity);

describe('BasicToken', () => {
  const [wallet] = new MockProvider().getWallets();
  let token: Contract;

  beforeEach(async () => {
    token = await deployContract(wallet, BasicToken, [1000]);
  });

  it('Assigns initial balance', async () => {
    expect(await token.balanceOf(wallet.address)).to.equal(1000);
  });

});

walletの取得

ethereum-waffleには専用のProvider(MockProvider)があります。

MockProviderをethereum-waffleからインポートして、new MockProvider().getWallets()を実行することで、walletリストを返り値として作成することができます。

スマートコントラクトのデプロイ

ethereum-waffleには検証用にスマートコントラクトをデプロイする機能(deployContract)があります。 こちらを実行して、以下の情報を引数に渡します。

  • デプロイするwallet
  • デプロイするコントラクト
  • コントラクト内のconstructorに対応した情報リスト

テスト毎に新しいスマートコントラクトを生成

beforeEachでテスト毎にスマートコントラクトをデプロイしています

デプロイしたコントラクトのトークン量を検証

ethereum-waffleの返り値として、スマートコントラクトを実行するためのインターフェイスファイルが取得できます(token) このインターフェイスファイルのbalanceOfを呼び出すことで、スマートコントラクトのbalanceOfを実行したものと同等の値を得ることができます。 balanceOfで取得された値が正しいかをchaiのto.equalで想定した結果が得られているかを検証します。

その他、ethereum-waffleのchaiで使用できる機能はこちらにまとめられています。