どうも、DRIP エンジニアの小川です。
突然ですが Dapps ってご存知ですか?
Decentralized Applications の略で、非中央集権型アプリケーション、分散型アプリケーションと呼ばれています。
ブロックチェーンを用いた分散台帳上にアプリケーションおよびデータが存在し、特定管理者が存在せず、ユーザー間で直接データをやり取りできる特徴があります。
現在では Ethereum(イーサリアム)という分散型アプリケーションプラットフォームを用いることが多く、Ðapps とも言われます。Ð は単体だと「イーサ」と読むので Ethereum の Dapps という意味ですね。
2017年末に仮想の猫をやり取りできる Web サービスが公開され一部界隈で人気になりましたが、あれも Ðapps です。
Ethereum 上で動く機械語である Ethereum Virtual Machine Code が記述できれば、誰でも Ðapps を作成し、世の中に公開することが出来ます。
機械語を人間が直接書くのはツライということで、EVM Code にコンパイル可能な言語が複数生まれました。今のところ Solidity という JavaScript ライクなコントラクト指向言語のシェアが大きいです。
というわけで今回は筆者が実際に調査、検証して得た知見を踏まえ、Solidity を使ってシンプルな Ðapps を作成し、実際の動作を見ていこうと思います。
Windows / Mac / Linux で開発可能ですが、筆者の環境が Mac のため、以下は Mac をベースとした説明となります。あらかじめご容赦ください。

作業の流れ

ざっくり、以下のようになります。

  • Truffle、Ganache インストール
  • Ganache 起動
  • Truffle プロジェクト初期設定
  • Truffle にてコントラクトの作成、デプロイ
  • Metamask インストール
  • html および js 作成
  • Metamask(web3.js) での動作確認
  • コントラクト、html、js を適宜修正して再び動作確認のループ

環境構築

Truffleインストール

EVM Code 開発フレームワークです。言語は Solidity となります。
インストールには npm を使うため、Node.js が必要となります。事前にインストールしておきましょう。

> npm install -g truffle

Ganacheインストール

ローカルで動作する Ethereum プライベートブロックチェーンです。
起動するとすぐに ETH を持つアカウントが作成されていたり、ボタン一発でチェーンをリセットしたりなど、開発する上で便利な機能を多く有しています。
こちらは公式サイトからインストーラーを DL してインストールします。
執筆現在、stable バージョンは 1.0.2 となります。

ETH とは

Ethereum プラットフォーム上で使用される仮想通貨です。
仮想通貨取引所で扱っているものと同名ですが、今回は「プライベート環境の ETH」を扱うので、お金がかかる事はありません。ご安心ください。

Ethereum トランザクションについて

  • 誰かに ETH を送金する。
  • EVM Code をデプロイする。
  • デプロイされている EVM Code に書き込みが必要な処理を実行する。

上記を行うとネットワークに対してトランザクションが発行され、その際に少量の手数料を与えておきます。
ネットワーク上では1つまたは複数の未処理トランザクションが1つのブロックにまとめられ、直前のブロック情報を用いて採掘者(マイナー)が計算作業を行い、作業が終わるとブロックが連結されます。
この時点でブロックに含まれているトランザクションは承認され、実行されたものとしてみなされます。
そして計算作業のお礼の一部として、トランザクションに乗せていた手数料が採掘者に与えられます。
この手数料はトランザクションを発行したアドレスから支払われるものなので、例えば Aさんが Bさんに 1.0 ETH 送金する場合、Aさんの財布からは 1.0 + 手数料 ETH が支払われます。
手数料はトランザクションを発行する側が自由に設定できます。高ければ早く承認されやすいです。

Ganache 起動

インストールした Ganache を起動します。

起動すると 100 ETH 持つアカウントが自動的に 10個 生成されます。
開発中は起動しっぱなしで良いです。

Truffle プロジェクト構築

初期化

まずは適当な場所でプロジェクトデータを作成しましょう。
以下の例では、自身のホームディレクトリ以下にプロジェクトを作成しています。

> cd ~
> mkdir hello_world_solidity
> cd hello_world_solidity
> truffle init

こんなツリーが出来上がります。

hello_world_solidity
├ contracts
│ └ Migrations.sol
├ migrations
│ └ 1_initial_migration.js
├ test
├ truffle-config.js
└ truffle.js

contracts の中に Solidity で記述されたプログラムを配置します。クラスのような概念で記述される contract がメインとなるため、コントラクトまたはコントラクトコードと呼ばれます。
Migrations.sol および 1_initial_migration.js は、作成したコントラクトをネットワークにデプロイする際に使用します。truffle 特有のものです。
truffle-config.js および truffle.js は設定ファイルです。詳細は以下で説明します。

Truffle 設定

設定ファイルは2つありますが、両方とも同じ設定ファイルであるため片方は削除しても構いません。
削除する場合、結論から言うと truffle.js を削除すると覚えておくのが良いです。
Windows 環境で truffle xxxx と何かしらのコマンドを打った場合、コマンドではなく truffle.js を実行しようとしてこのようなエラーが表示されます。

Truffle 公式のドキュメント を見るといくつか対策方法が書いてありますが、「truffle.js を消す」と覚えておけば「truffle.jstruffle-config.js にリネームする」に準拠するため、Windows 環境においては安全です。
Mac / Linux については削除してもしなくても良いですが、両方のファイルが存在した場合 truffle.js が優先される点は覚えておいてください。

さて、Ganache と連携するために truffle-config.js または truffle.js を以下のように編集します。

module.exports = {
  networks: {
    development: {
      host: "localhost",
      port: 7545,
      network_id: "*"
    }
  }
};

ネットワーク名は自由にできますが Truffle のデフォルトネットワーク名 development にしておくとコマンドを短縮できるので便利です。
コンソールへ接続して確認します。

> truffle console
truffle(development)>

こうなれば OK です。.exit をタイプするか Ctrl + C を2回押せばコンソールから抜けられます。

HelloWorld コントラクト作成

毎度おなじみ HelloWorld を作成します。

> truffle create contract HelloWorld

contracts の中に HelloWorld.sol ができました。
これを編集して、以下のようにします。

contracts/HelloWorld.sol

pragma solidity ^0.4.4;

contract HelloWorld {

  function get() public pure returns (string) {
    return "Hello World!";
  }
}

Solidity ではいわゆるクラス宣言のように contract 宣言を行い、その中に変数やメソッドを記述していきます。
変数やメソッドにはアクセス修飾子を付与します。今回は public ですが、private や internal なども可能です。詳しくはこちらをご参照ください。
通常、コントラクトのメソッドを呼び出すと手数料が発生しますが、コントラクトが持つ変数への書き込みが発生しない場合は手数料無しで呼び出せます。
そういったメソッドには view や pure といった修飾子を付与します。
大雑把に言うと変数からの読み取りが発生する場合は view を使い、変数に一切触らない場合は pure を使います。
constant もありますが、これは view と同じ効果です。)
これらを付与しないでコンパイルするとコンパイラが警告を出してくれるので、基本的にはそれに従うのが早いです。

コンパイル

> truffle compile
Compiling ./contracts/HelloWorld.sol...
Compiling ./contracts/Migrations.sol...
Writing artifacts to ./build/contracts

無事にコンパイルされました。成果物は記載の通り build/contracts フォルダ直下にあります。
コードに不備がある場合は、この時点でエラーが返ってきます。

マイグレーションファイル作成

HelloWorld コントラクトをネットワークにデプロイするマイグレーションファイルを作成します。

> truffle create migration HelloWorld

migrations の中に 15xxxxxxxx_hello_world.js ができました。ファイル名の先頭にあるのは作成時のタイムスタンプです。
これを編集して、以下のようにします。

migrations/15xxxxxxxx_hello_world.js

var HelloWorld = artifacts.require("HelloWorld");

module.exports = function(deployer) {
  deployer.deploy(HelloWorld);
};

デプロイ

では早速デプロイしてみましょう。

> truffle migrate
Using network 'development'.

Running migration: 1_initial_migration.js
  Deploying Migrations...
  ... 0x8a7e6502e536a6a640efa00ab0e1bc553b0a41d683dc94bd143f99c18fe0e472
  Migrations: 0x8cdaf0cd259887258bc13a92c0a6da92698644c0
Saving successful migration to network...
  ... 0xd7bc86d31bee32fa3988f1c1eabce403a1b5d570340a3a9cdba53a472ee8c956
Saving artifacts...
Running migration: 1521091959_hello_world.js
  Deploying HelloWorld...
  ... 0xa178ff87309f0c68c254b1c190267691a86e519b227f0a5347bfe4fda2962a0e
  HelloWorld: 0x345ca3e014aaf5dca488057592ee47305d9b3e10
Saving successful migration to network...
  ... 0xfff6656d04fc734483f3600e0187c7d659fd7e301aaf64260e07df8f81efc0dd
Saving artifacts...

うまくいきました。何か楽しそうな16進数がたくさん出てきましたね。
ここで一旦 Ganache を見てみましょう。

INDEX 0 の持つ ETH が 99.95 に減少し、TX COUNT が 4 に上昇しています。
これは、今回のデプロイに使用したトランザクションは 4つ であり、そのトランザクションを発行したのは INDEX 0 のアドレスである。
各トランザクションには手数料を合計 0.05 ETH 付与しており、トランザクションが承認されたため財布から 0.05 ETH 減った、という事象を表しています。
では TRANSACTIONS タブをクリックしてみましょう。

truffle migrate 時に出力された16進数が、この画面内に記載されていることがわかります。
下から順番にコントラクトの生成、コントラクトの(何かしらのメソッドの)呼び出し、同じくコントラクトの生成、コントラクトの呼び出しが行われています。
デプロイは migrations にあるファイル名先頭にあるタイムスタンプ順に行われるので、最初のコントラクト生成は Migrations コントラクトの生成を意味します。
コントラクトをチェーン上に実体化させると Contract Address というアドレスが与えられ、このアドレスを使ってコントラクトを呼び出して使います。
Migrations コントラクトは「現在どのマイグレーションまで完了しているか」を記憶するコントラクトです。
次のコントラクト呼び出しでは、デプロイしてチェーン上に実体化した Migrations コントラクトの setCompleted(uint completed) に 1 を付与して呼び出しています。
つまり「現在マイグレーションは 1 まで完了しています」という情報をチェーン上に保存したわけです。
次のコントラクト生成で HelloWorld コントラクトが生成され、その次のコントラクト呼び出しではMigrations コントラクトの setCompleted(uint completed) に 15xxxxxxxx を付与して呼び出しています。
この番号を保存しておくことで、次回 truffle migrate を叩いた際は 15xxxxxxxx 以降の migrations ファイルのみが実行されるようになります。

デプロイした HelloWorld の動作確認

コンソールに入って、HelloWorld の動作確認を行います。

> truffle console
truffle(development)> var helloWorld; HelloWorld.deployed().then((obj) => { helloWorld = obj; })
undefined
truffle(development)> helloWorld.get()
'Hello World!'

成功しました!
デプロイすると build/contracts 内にあるビルド済データにアドレスが記載されているため HelloWorld.deployed() だけでコントラクトを呼び出せます。

Web フロントエンド構築

次に web3.js を使ってブラウザからコントラクトを呼び出してみます。
web3.js は Ethereum JavaScript API と呼ばれるもので、HTTP や ICP を用いて Ethereum クライアントと通信する JavaScript ライブラリです。

Metamask インストール

Metamask は Ethereum のウォレットで、Chrome または Firefox の拡張機能として提供されています。
仮装猫もそうですが昨今の Ðapps は Metamask を介してユーザー認証やトランザクション発行を行なっており、秘密鍵管理やトランザクション確認の機能を任せられるため Ðapps 開発者に人気です。
というわけで早速 Metamask をインストールしましょう。ブラウザはどちらでも良いです。

Metamask 設定

起動して利用規約等に同意するとアカウント作成画面になります。好きなパスワードを決めて CREATE しましょう。
作成すると 12個 のワードが表示されるので、どこかにバックアップして「I’ve〜〜」のボタンを押します。

左上のネットワーク切り替えボタンを押すと Mainnet / Testnet など切り替えられます。
今回は Ganache が持つプライベートネットワークにアクセスしたいので、右上のメニューボタンを押して Settings を開きます。

画像のように http://localhost:7545 を入力し Save を押します。
ついでに Current Conversion も JPY にしておきましょう。

左上のネットワーク切り替えボタンを押すと http://localhost:7545 が追加されていることを確認できます。このまま http://localhost:7545 を選択しておきます。
これで Metamask から Ganache のネットワークに対してトランザクションを発行できるようになりましたが、Metamask が最初に作ったアカウントのアドレスには ETH が入っていません。
なので、Ganache で自動生成されたアドレスを Metamask にインポートします。

インポートしたいアドレスの鍵マークをクリックして、秘密鍵をコピーします。今回は INDEX 1 を使ってみます。
Metamask の右上にある人アイコンから Import Account をクリックします。

先ほどコピーした秘密鍵をペーストして IMPORT をクリックすると、

Ganache のアドレスがインポートできました。
ちゃんと 100 ETH 持ってます。

Mainnet / Testnet

Mainnet とはその名のごとく Ethereum のメインネットとなり、仮想通貨取引所で売買されている ETH は Mainnet にある ETH です。Mainnet を使ってデプロイすると当然 Mainnet の ETH が消費されるため、今回の開発では絶対に使用しないでください。
Testnet はテスト用のネットワークで、こちらの ETH は売買されるものではありませんが、ETH を手に入れるためには誰かから貰うか採掘する必要があります。

html および js 作成

Metamask 経由で HelloWorld コントラクトを呼び出すサンプルとして、以下の html および js ファイルを作成します。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <title>Ðapps - Hello World</title>
  <script type="text/javascript" src="hello_world.js"></script>
</head>
<body>
  <div id="contract_result">loading...</div>
</body>
</html>

hello_world.js

var abi = [
  {
    "constant": true,
    "inputs": [],
    "name": "get",
    "outputs": [
      {
        "name": "",
        "type": "string"
      }
    ],
    "payable": false,
    "stateMutability": "pure",
    "type": "function"
  }
];
var address = "0x0000000000000000000000000000000000000000"; // コントラクトアドレス

window.onload = function() {
  var contract = web3.eth.contract(abi).at(address);
  contract.get((error, result) => {
    document.getElementById("contract_result").textContent = result;
  });
};

Ganache チェーン上にある HelloWorld コントラクトにアクセスするための var contract を作り、Helloworld コントラクトが持つ get() メソッドにアクセスし、html 側に反映するシンプルなものとなっています。
var abi および var address に入れるデータはビルド成果物である build/contracts/HelloWorld.json から取り出します。
var abi には ["abi"] キーの中身を、var address には ["networks"]["5777"]["address"] キーの中身をコピペすれば OK です。
Metamask が有効なブラウザでページを開くと、グローバル領域に web3 オブジェクトが生成されます。
名前の通り web3.js から生成されており、Metamask を介して Ethereum ネットワークと通信できる特殊な web3 オブジェクトになります。

web3 のバージョンについて

Metamask が生成する web3 は現行バージョンの 1.0 ではなく 0.20.3 となるため、見るべきドキュメントは 1.0 ではなく 0.2x.x となります。ご注意ください。

live-server インストール

index.html ファイルを直接ブラウザで開いても Metamask が web3 オブジェクトを生成してくれないので(おそらく http もしくは https プロトコルじゃないと有効にならない)、簡易サーバーを使えるようにします。
以下のコマンドで live-server をインストールします。

> npm install -g live-server

live-server 起動

プロジェクトルートにて

> live-server .

を打てばサーバーが起動し、index.html を表示してくれます。

無事 Hello World! が表示されました!
Ctrl + C でサーバーを停止できます。

HelloWorld にトランザクションを発行

最もシンプルな getter を用いてコントラクトから値を取ってくる事には成功しました。
次は setter を使ってトランザクションを発行し、チェーン上のコントラクトに対して別の値を設定してみましょう。

HelloWorld コントラクト改造

以下のように編集します。

contracts/HelloWorld.sol

pragma solidity ^0.4.4;

contract HelloWorld {

  string public word;

  event Set(address sender, string newWord);

  function HelloWorld() public {
    word = "Hello World!";
  }

  function get() public view returns (string) {
    return word;
  }

  function set(string newWord) public {
    word = newWord;
    Set(msg.sender, newWord);
  }
}

変更点

  • 変数 word を付与。
    • public 修飾子を付けると自動的に getter が生成されます。setter は生成されません。
  • イベント Set(address sender, string newWord) を定義。
    • トランザクションレシートの中に記載するログとして event 型を定義できます。
    • 呼び出すと web3.js を介してイベント発火をキャッチできるようになります。
  • コンストラクタを追加。
    • 最初は Hello World! を持ちます。
  • get() を pure から view に変更。
    • 変数からの読み取りをしている(word を返す)ので。
  • set(string newWord) を追加。
    • 変数への書き込みをしているので view pure どちらも不要。
    • Web フロントエンド側に word の書き換わりを伝えたいため、イベントを発火。

truffle compile しましょう。

HelloWorld を再びデプロイ

前回デプロイした HelloWorld は破棄(忘却)しても大丈夫なので、新しい migration ファイルを生成するのではなくイチからデプロイをやり直します。以下のコマンドで全ての migration を最初から走らせます。

> truffle migrate --reset

HelloWorld テスト追加

コントラクトを書いたけどちゃんと動作するか心配、でもいちいち truffle console に入って手動で動作確認するのも面倒だし…
というわけで、Truffle のテスト機能を使ってみましょう。

> truffle create test HelloWorld

このコマンドを打てば test の下に hello_world.js ができます。
これを以下のように編集します。

test/hello_world.js

var HelloWorld = artifacts.require("HelloWorld");

contract('HelloWorld', function(accounts) {
  describe('word method', function() {
    var obj;
    let str = "TestTest";

    before(async function () {
      obj = await HelloWorld.new();
    });

    it("should set new word", async function() {
      await obj.set(str);
      let result = await obj.get();
      assert.equal(result, str);
    });
  });
});

set(string newWord) メソッド後に get() を行い、セットした文字列と一致するかをチェックするテストです。
before 句の中で HelloWorld.new() しています。これはテスト内で使うためだけに、新しい HelloWorld コントラクトを Ganache のチェーンにデプロイするコマンドです。
以下のコマンドでテストを走らせます。

> truffle test test/hello_world.js
Using network 'development'.

Compiling ./contracts/HelloWorld.sol...


  Contract: HelloWorld
    word method
      ✓ should set new word (74ms)


  1 passing (175ms)

成功しました!

html および js の修正

新しい HelloWorld コントラクトに対応する修正を加えます。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <title>Ðapps - Hello World</title>
  <script type="text/javascript" src="hello_world.js"></script>
</head>
<body>
  <div id="contract_result">loading...</div>
  <button type="button" id="button_set">更新</button>
</body>
</html>

変更点

  • 更新ボタンを付与
    • HelloWorld にトランザクションを発行する処理を走らせるために。

hello_world.js

var abi = [
  {
    "constant": true,
    "inputs": [],
    "name": "word",
    "outputs": [
      {
        "name": "",
        "type": "string"
      }
    ],
    "payable": false,
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "payable": false,
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "name": "sender",
        "type": "address"
      },
      {
        "indexed": false,
        "name": "newWord",
        "type": "string"
      }
    ],
    "name": "Set",
    "type": "event"
  },
  {
    "constant": true,
    "inputs": [],
    "name": "get",
    "outputs": [
      {
        "name": "",
        "type": "string"
      }
    ],
    "payable": false,
    "stateMutability": "view",
    "type": "function"
  },
  {
    "constant": false,
    "inputs": [
      {
        "name": "newWord",
        "type": "string"
      }
    ],
    "name": "set",
    "outputs": [],
    "payable": false,
    "stateMutability": "nonpayable",
    "type": "function"
  }
];
var address = "0x0000000000000000000000000000000000000000"; // コントラクトアドレス
var web3Local;

window.onload = function() {
  var contract = web3.eth.contract(abi).at(address);
  contract.get((error, result) => {
    document.getElementById("contract_result").textContent = result;
  });

  web3Local = new Web3(new Web3.providers.HttpProvider("http://localhost:7545"));
  var eventContract = web3Local.eth.contract(abi).at(address);
  eventContract.Set((error, data) => {
    console.log("event callback.");
    document.getElementById("contract_result").textContent = data.args.newWord;
  });

  document.getElementById("button_set").onclick = () => {
    let time = Math.floor(new Date().getTime() / 1000);
    console.log(time);
    contract.set("" + time, (error, txid) => {
      console.log(txid);
    });
  };
};

変更点

  • var abi var address の中身を新しくデプロイした HelloWorld コントラクトのものに変更。
  • イベント監視用に var web3Local を定義。
    • Metamask を介さず直接 Ganache と繋がる Web3 オブジェクトです。
    • グローバルにある web3 を使ってイベント監視しようとしても上手く監視してくれなかったので、web3Local を使ってイベントを監視します。
  • Set イベントを監視。
    • 新しい word を検知したら、それを html 側に反映します。
  • 更新ボタンが押されたら現在時間を新しい word としてセットするトランザクション発行。

live-server 起動

> live-server .

更新ボタンが付きました。
押してみましょう。

Metamask のウィンドウが開き、トランザクションを送るかどうか確認します。
ネットワークが Private Network になっている点、送り先のアドレスが HelloWorld コントラクトのアドレス(hello_world.js に設定した var address)と同じか確認し、SUBMIT を押します。
成功すればトランザクションが発行され、ブロック採掘待ちになります。
Ganache はトランザクションが発生したら自動で採掘する設定になっているため、このトランザクションはすぐに承認され、イベントが発火します。
すると、

Set イベントをキャッチして html が更新されました!

まとめ

駆け足ではありましたが、以上がシンプルな Ðapps の作り方になります。
Solidity のドキュメント や Truffle のチュートリアル を見ればもっと複雑なコントラクトも作れるようになるはずです。
ただし、処理が複雑になればなるほどトランザクション発行時の手数料は高くなるため、どこまでをチェーンに乗せ、どこまでを Web フロントエンドでやるか、という設計は慎重にやるべきかと思います。
この記事が Ðapps エンジニアリングを志す人の一歩目になれば幸いです。