Truffle+VueでDApp開発入門

以前 geth を使って開発環境作りみたいなことしましたが,Truffle と Ganache を使ってより簡単に DApp を作っていきたいと思います.

今回作ったもの

初めての DApp 開発なのでアイディアを書き込んで参照するだけの簡単なものを作ってみます.

デモアプリ は Heroku で公開しています.Free Dyno なので,最初の接続に時間がかかります.試す際は必ず Ropsten ネットワークに切り替えてください.

mktia/truffle-test | GitHub にも上げています.

開発環境

  • macOS High Sierra
  • Truffle: v4.1.14
  • Solidity: v0.4.24
  • Vue: v2.9.6
  • Ganache: v1.2.1

あらかじめ Vue (vue-cli) を入れておきます.macOS に Vue を入れる方法は以下の記事でご確認ください.

https://blog.mktia.com/install-vue-in-macos

Truffle インストール

Truffle を入れるとプロジェクトの管理が楽になります.

terminal

$ npm install -g truffle

Ganache インストール

geth は色々設定することがありますが,Ganache (GUI 版) は起動するとアカウントが最初から用意されていて楽に開発できます.

Ganache ダウンロード

プロジェクトの初期化

terminal

$ truffle init

Truffle での開発に必要なディレクトリが作られます.

コントラクト

コーディング

Solidity で Ethereum ブロックチェーン上に置くコントラクトのコードを書きます.コーディングに使う言語は他にも Vyper とかあります.

一応コーディング規約もあります.誰でも使えるようなコントラクトならやはり見やすいほうが望ましいので,命名規則などは統一すべきと思われます.JS ライクなのでだいたい CapsWords か mixedCase です.

コントラクトのコードは contracts ディレクトリに保存します.

pragma solidity ^0.4.24;

contract IdeaFactory {

  event NewIdea(uint id, string content);

  struct Idea {
    uint ideaId;
    string content;
  }

  Idea[] public ideas;

  mapping(uint => address) public ideaToOwner;

  function comeUpWithIdea(string _content) public returns (uint) {
    uint id = ideas.length;
    ideas.push(Idea(id, _content));
    ideaToOwner[id] = msg.sender;
    emit NewIdea(id, _content);

    return id;
  }

  function getIdea(uint _id) public view returns (string) {
    return ideas[_id].content;
  }

  function getIdeaCount() public view returns (uint) {
    return ideas.length;
  }

}

Solidity のコーディングで注意すべき点

  • Struct は返せないので,複数の戻り値を使うなどして対応する
  • string の配列も返せない
  • Struct 外で uint32 など変数の使用領域を指定するのは意味がない

Struct の配列を返したかったら基本型の配列を作って返すことで対応できますが,string が含まれる場合は詰みます.その場合は JS 側で片付けましょう.

コンパイル

terminal

$ truffle compile

コンパイルすると build/contracts に JSON ファイルが作られます.

ローカルネットワークへのデプロイ

予め起動した Ganache にデプロイします.

設定

truffle init した状態では白紙になっている truffle.js に設定を書き込みます.

truffle.js

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

development というネットワークの環境を設定します.ホストとポートは Ganache のものを書き込みます.network_id はワイルドカードになっています.

127.0.0.1 の代わりに localhost で設定すると何らかの問題が起きる的なことをどこかで見た気がするので,前者で書いています.

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

var IdeaFactory = artifacts.require("./IdeaFactory.sol");

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

migrations ディレクトリに保存します.Truffle はファイル名の最初の数字の昇順にマイグレーションを実行します.

デプロイ

terminal

$ truffle migrate

ネットワークを選択する場合は --network development のようにオプションで指定できます.truffle.js の設定を参照しています.

マイグレーションをし直したい場合は --reset オプションを付けます.

テスト

test ディレクトリにテストコードを保存し,想定通りの動きをしているか確認してみます.

pragma solidity ^0.4.24;

import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../contracts/IdeaFactory.sol";

contract TestIdea {
  IdeaFactory ideaFactory = IdeaFactory(DeployedAddresses.IdeaFactory());

  function testComeUpWithIdea() public {
    uint returned = ideaFactory.comeUpWithIdea("Get more money!");

    uint expected = 0;

    Assert.equal(returned, expected, "Idea ID 0 should be recorded.");
  }

  function testGetIdea() public {
    string memory expected = "Get more money!";

    string memory returned = ideaFactory.getIdea(0);

    Assert.equal(returned, expected, "'Get more money!' should be recorded.");
  }
}

testGetIdea() では変数を memory にしています.明示的に宣言しなかったら Type error: memory is not implicity convertible to expected type 的な感じで怒られました.

ちなみに変数は格納領域が三つあり,それぞれコストのかかり方が異なります.

  • storage: 参照型の変数 (Struct, 配列, mapping)
  • memory: 関数の引数
  • stack: 基本型の変数 (uint, string etc)### ###

参考:Solidity の変数の格納領域 (storage, memory, stack) | Qiita

terminal

$ truffle test test/testIdeaFactory.sol

ファイル名を指定しなければ,ディレクトリ内のすべてのファイルに対してテストします.

Vue

デプロイが成功したら,フロントエンドも用意します.今回は Vue を使います.

Vue プロジェクトの初期化

terminal

$ vue init

vue-cli を使って Vue のプロジェクトを作ります.全て初期設定です.

Truffle のルートディレクトリで実行します.ディレクトリが空でないため,注意が表示されますがそのまま実行できます.

ちなみに, truffle init は空のディレクトリじゃないと実行できないので, vue init からの truffle init はできません.

必要なパッケージをインストール

terminal

$ npm install web3
$ npm install truffle-contract

フロントエンドでブロックチェーンを扱うためのパッケージを入れます.

Vue Component を書く

vue-cli を使うと基本形が用意されるので,それを少しいじって作ることにします.コントラクトを使って動いていることが最低限分かればいいので,ここは適当に書きます.

<template>
  <div class="app">
    <h2>POST YOUR IDEA</h2>
    <p v-if="account">アカウント: {{ account }}</p>
    <p v-if="!account">アカウントが見つからないよ</p>
    <input
      v-model="newIdea"
      type="text"
      name=""
      value=""
      placeholder="なんか書いてみたら?"
    />
    <button @click="postIdea">投稿</button>
    <ul>
      <li class="idea-box" v-for="idea in ideas" :key="idea">
        {{ idea }}
      </li>
    </ul>
    <p v-if="contractAddress">コントラクトアドレス: {{ contractAddress }}</p>
    <p v-if="!contractAddress">コントラクトアドレスが見つからないよ</p>
  </div>
</template>

<script>
import Web3 from "web3";
import contract from "truffle-contract";
import artifacts from "../../build/contracts/IdeaFactory.json";
const IdeaFactory = contract(artifacts);
export default {
  name: "TopPage",
  data() {
    return {
      contractAddress: null,
      account: null,
      newIdea: null,
      ideas: [],
    };
  },
  created() {
    if (typeof web3 !== "undefined") {
      web3 = new Web3(web3.currentProvider);
    } else {
      console.warn(
        "No web3 detected. Falling back to http://127.0.0.1:7545. You should remove this fallback when you deploy live, as it's inherently insecure. Consider switching to Metamask for development. More info here: http://truffleframework.com/tutorials/truffle-and-metamask"
      );
      web3 = new Web3(new Web3.providers.HttpProvider("http://127.0.0.1:7545"));
    }

    IdeaFactory.setProvider(web3.currentProvider);
    web3.eth.getCoinbase().then((coinbase) => {
      IdeaFactory.defaults({ from: coinbase });
    });

    web3.eth.getAccounts((error, accounts) => {
      if (error != null) {
        console.error(error);
        this.message =
          "There was an error fetching your accounts. Do you have Metamask, Mist installed or an Ethereum node running? If not, you might want to look into that.";
        return;
      }
      if (accounts.length === 0) {
        this.message =
          "Couldn't get any accounts! Make sure your Ethereum client is configured correctly.";
        return;
      }
      this.account = accounts[0];
    });

    IdeaFactory.deployed().then((instance) => {
      var event = instance.NewIdea();
      event.watch((error, result) => {
        if (!error) {
          this.ideas.unshift(result.args.content);
          this.newIdea = null;
          console.log(result.args.content);
        }
      });
      this.contractAddress = instance.address;
    });
  },
  beforeMount() {
    IdeaFactory.deployed().then((instance) => {
      var contract = instance;
      contract.getIdeaCount().then((count) => {
        for (var i = 0; i < parseInt(count.toString(10)); i++) {
          contract.getIdea(i).then((idea) => this.ideas.unshift(idea));
        }
      });
    });
  },
  methods: {
    postIdea() {
      return IdeaFactory.deployed()
        .then((instance) => {
          var contract = instance;
          contract
            .comeUpWithIdea(this.newIdea)
            .then((id) => contract.getIdeaCount())
            .then((count) => contract.getIdea(count.toString(10)))
            .then((idea) => this.ideas.unshift(idea));
        })
        .catch((error) => {
          console.error(error);
        });
    },
  },
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
li.idea-box {
  border: 1px solid;
  display: block;
  margin: 0.5em auto;
  padding: 0.5em;
  width: 320px;
}
ul {
  padding-left: 0;
}
</style>

コンポーネント名を変更したので src/router/index.js も書き換えます.

src/router/index.js

import Vue from 'vue'
import Router from 'vue-router'
import TopPage from '@/components/TopPage'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'TopPage',
      component: TopPage
    }
  ]
})

JS 側で注意すべき点

  • 非同期なので Promise で返ってくる
  • JSON RPC エラーで泣くことがある
  • BigNumber で返ってくる
  • Metamask のエラーが出て混乱することがある (これは無視)

JSON RPC エラーはパッケージの使い方が正しくなかったりプロバイダ指定が間違っていたりすると起きるようですが,詳細不明です.前者に関しては正しくなくても一見動いているように見えるので,要確認です.

returns (uint) だから int で返ってくるのかと思いきや,BigNumber という型で返ってくることがあります. obj.toString(10) でいいよって Q&A サイトには書かれていましたが,望ましい書き方かどうかはわかりません. obj.toNumber() かもしれませんし,int 型にパースしたほうがいいのかもしれません.

利用環境の準備

Chrome ブラウザで Metamask が使える環境を作っておきます.Chrome ウェブストアから拡張機能としてインストールできます.

Metamask を開き,Ganache 上のアカウントをインポートして使います.Main Ethereum Network になっているので Custom RPC に切り替え,Ganache の動いているポートを指定します.初期設定では http://localhost:7545 です.

ネットワークを切り替えたら Import existing DEN で進みます.12 単語を入力する画面が出るので,Ganache の MNEMONIC をコピペするとインポートできます.

開発環境で実行

terminal

$ npm run dev

webpack-dev-server を使ってローカルサーバで起動します.ポートは 8080 です.(http://localhost:8080)

Ropsten ネットワークへのデプロイ

現時点では Ganache を使ってローカルで動いています.本番環境ではメインネットを使いますが,デプロイ後はコントラクトを書き換えられないのでテストネットを使ってテストします.

Infura の登録

はじめに Infura に登録します.CREATE NEW PROJECT でエンドポイントが生成されます.

設定の更新

設定に使うパッケージを入れます.

terminal

$ npm install truffle-hdwallet-provider

truffle.js に Ropsten の設定を加えます.

truffle.js

var HDWalletProvider = require('truffle-hdwallet-provider');
const MNEMONIC = 'Truffleからコピペ'

module.exports = {
  networks: {
    development: {
      ...
    },
    ropsten: {
      provider: function() {
        return new HDWalletProvider(MNEMONIC, 'https://ropsten.infura.io/v3/xxxxxxx')
      },
      network_id: '3'
    }
  }
}

HDWalletProvider() の第二引数には Infura で発行されたエンドポイントを入れます.エンドポイントはユーザーの固有値となるため,誤って Git の管理下にしないように .gitignore で設定して下さい.

network_id は 3 を指定すると Ropsten ネットワークが選択されます.その他の ID にも対応するネットワークがあり,1 がメインネットです.

テスト用 ETH の受け取り

ETH がないと何もできないので,テスト用に配布されているものを受け取ります.

アカウント画面で BUY > ROPSTEN TEST FAUCET > request 1 ether from faucet で Ropsten ネットワークで使える 1ETH が送られてきます.テストネットでブロックが承認されたら受け取れるので,数十秒かかります.

デプロイ

terminal

$ truffle migrate --network ropsten

デプロイした後は Metamask を Ropsten ネットワークに切り替えても DApp が動くようになります.

デプロイ時のエラー

Error encountered, bailing. Network state unknown. Review successful transactions manually. insufficient funds for gas * price + value

このエラーはデプロイに使うアカウントに必要な分のガスがなかったときに出ます.恐らく初期設定では一つ目のアカウントが coinbase になっているので,一つ目のアカウントに ETH を入れておくとデプロイできます.

参考:Ropsten デプロイ時に躓いたことメモ | Qiita

Node.js サーバへのデプロイ

ローカルサーバで動かしているものを公開するためには Node.js に対応する Web サーバに上げる必要があります.GCP や Heroku を使うと楽です.

Express の導入

今回は Express で動く環境を作ります.

terminal

$ npm install --save express

Express の設定

設定ファイルを書きます.

var express = require("express");
var path = require("path");
var serveStatic = require("serve-static");

app = express();
app.use(serveStatic(__dirname + "/dist"));

var port = process.env.PORT || 5000;

app.listen(port);

console.log("server started " + port);

npm でビルドしたファイルは dist ディレクトリ下に生成されるので,Express がそこを参照するように設定します.

ビルド

terminal

$ npm run build

ビルドすると Vue で書いた内容をコンパイルしてくれます.

起動

terminal

$ node server.js

起動コマンドの設定

上記のコマンドでも動きますが,package.json に設定を加えておくと良いと思います.

初期設定では "dev""start" の内容が同じなので,後者を書き換えます.

package.json

{
  ...
  "scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "node server.js",
    ...
  },
  ...
}

これ以降は次のコマンドで起動できます.

terminal

$ npm run start

Heroku へのデプロイ

書くのに疲れてきてしまったので,ざっくりとした説明になります....

Git の初期化

以下の記事の「1.開発環境の構築」が終わってるものとして,プロジェクトのルートディレクトリで以下のコマンドを実行します.

https://blog.mktia.com/how-to-get-started-on-heroku-with-nodejs

terminal

$ git init

.gitignore の編集

初期設定では動かなくなるので,必要に応じてデプロイするディレクトリ,ファイルを変更します.

.gitignore

.DS_Store
node_modules/
dist/  # 消す
npm-debug.log*
yarn-debug.log*
yarn-error.log*
test/unit/coverage
test/e2e/reports
selenium-debug.log
truffle.js  # 追加する

# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln

デプロイ

terminal

$ git add .
$ git commit -m "init"
$ heroku create --name YOURAPPNAME
$ heroku git:remote --app YOURAPPNAME
$ git push heroku master

Heroku 上にアプリを作成し,Git をデプロイします.

参考

Easily deploy a Vue + Webpack App to Heroku in 5 Steps tutorial | Medium