Blockchain
Ethereum
React
solidity
geth

React+Redux と web3.js を使ったスマートコントラクトアプリ開発

実用的なスマートコントラクトアプリを開発するための準備として,最も簡単なスマートコントラクトである greeter を使ったアプリを作ってみました。

環境

$ geth version
Geth
Version: 1.7.1-stable
Git Commit: 05101641455a754936acc5ddff92f35f5e33181c
Architecture: amd64
Protocol Versions: [63 62]
Network Id: 1
Go Version: go1.9.1
Operating System: darwin
GOPATH=/Users/arosh/go
GOROOT=/usr/local/Cellar/go/1.9.1/libexec
  • MetaMask 3.10.9
  • browser-solidity e6f4dc2

MetaMaskについて

MetaMask は Chrome や Firefox のブラウザ拡張として使うことができるウォレットアプリです。このウォレットの大きな特徴は,Mist のようにブラウザ上で動作する Dapp に対応していて,アカウントを設定済みの web3.js オブジェクトをグローバル空間に注入してくれることです。オープンソースで開発されていて,接続先のネットワークをプライベート・ネットに切り替えることもできます。

アカウント作成画面です。左上の「Private Network」の部分をクリックすると,接続先のネットワークを Mainnet,Ropsten,Korvan,Rinkeby,localhost:8545,Custom RPC から選ぶことができます。

スクリーンショット 2017-10-08 16.41.05.png

アカウントの管理画面です。現在の残高や過去のトランザクションが確認できます。

スクリーンショット 2017-10-09 22.34.42.png

コントラクトに変更を加えるような操作をした時に表示されるポップアップです。消費される ether をユーザが確認して許可するまで ether が使用されることはありません。

スクリーンショット 2017-10-09 22.32.57.png

プライベート・ネットの準備

開発用のプライベート・ネットを構築するために,以下のコマンドを実行します。

$ geth --datadir . --dev --networkid 1337 --gasprice 18000000000 --rpc --rpccorsdomain "*"

--datadir オプションはデータベースとキーストアの場所を指定するオプションで,ここではカレントディレクトリを指定しています。--dev オプションは開発用のプライベート・ネットを構築するのに有用なオプションで,P2Pネットワークを構築しなくなったりマイニングが簡単になったりします(詳細は 過去の記事 を参照してください)。--networkid オプションについては後述します。--gasprice オプションは送金やスマートコントラクトの処理の大きさの単位 (gas) あたりの価格 (gas/wei) を指定するオプションで,--dev オプションを指定したときには 0 gas/wei になっているのですが,現実のネットワークとの差異が大きすぎるので適当な値に設定しています。現在の gas/wei は ethstats.net などを参照してください。記事を執筆している時点では 21 gwei (21000000000 wei) でした。

--networkid 1337 を指定している理由ですが,このオプションを指定せずにMetaMaskでトランザクションに署名をしようとすると "rpc error with payload", "Error: invalid sender" というエラーが出てしまったからです。

スクリーンショット 2017-10-09 23.14.07.png

このエラーは EIP 155 という Ethereum の仕様が有効化されていないネットワークでMetaMaskを使おうとしたときに発生したり,genesis.jsonconfig.chainID と Geth の --networkid が一致していないときに発生したり,様々な場面で発生します。

最初は --dev オプションを使ったときに EIP 155 が有効になっていないのかと思ったのですが,ソースコードを見る限りでは0ブロック目から有効になっているように見えるのでこれは違うようでした (config.go#L88, genesis.go#L348)。よく見ると,gethを起動した画面でもその旨が表示されています。

スクリーンショット 2017-10-10 11.51.25.png

あとは genesis.jsonconfig.chainID と Geth の --networkid が一致していないことによる問題が考えられるのですが,上記の画像によると chainID は 1337 に設定されているようなので --networkid 1337 と指定するとうまく動きました。ただ,マイニングをしたりetherを送金したりするときにはこのエラーは表示されなかったので,以前に体験した不具合 とは性質が違うような気がしています。

アカウントの準備

アカウントを2つ用意します。

  1. マイニング用アカウント
  2. MetaMask 用アカウント

最初に 1. のアカウントでマイニングを行い適量の ether を入手します。次に,得られた ether を 1 ether ほど 2. のアカウントに送金します。

私は 1. のアカウントの作成と送金はMistを使って行いました(EthereumのDappをスマートフォンから利用する方法について)。

スクリーンショット 2017-10-08 16.47.59.png

Solidityを使ったスマートコントラクトの記述

browser-solidity を使ってスマートコントラクトを記述します。今回は solidity-baby-steps の greeter.sol を改変したものを作成しました。作成したコントラクトを以下に示します。

pragma solidity ^0.4.17;

contract Greeter {
    string public message;

    event greetEvent(string _message);

    function Greeter(string _message) public {
        message = _message;
    }

    function setMessage(string _message) public {
        message = _message;
        greetEvent(_message);
    }

    function greet() public {
        greetEvent(message);
    }
}

このコントラクトは基本的には message というプロパティを set して get するだけのものです。greetEvent という event を用意しているので,web3.js でこの event を .watch() すると setMessage(_message) もしくは greet() が呼ばれたときに何か処理を行うこともできます。ところが,後述しますが MetaMask の制限により event の機能は使えなかったので,set と get の機能しか使っていません。

コントラクトのデプロイには browser-solidity を使います。最初に browser-solidity の gh-pages ブランチ から remix-XXXXXXX.zip をダウンロードして展開します。その中の index.html を開けば browser-solidity が起動するのですが,MetaMask は http もしくは https でしか使えないという制約がある ので http-server などを使って http で開きます。

$ npm i -g http-server
$ http-server

browser-solidityが開いたら中央のペインにコントラクトのソースコードを貼り付けます。ファイル名は "Greeter.sol" などに変更しておきましょう。

スクリーンショット 2017-10-10 12.11.47.png

次にコントラクトのデプロイを行います。右のペインの Run タブの Environment の項目の中から "Injected Web3" を選びます。これは MetaMask から供給される Web3 のアカウントを使用するという設定です。「Web3 が見つからない」という旨のエラーが出たときには MetaMask のアカウントの作成がうまくいっているか,MetaMask のネットワークの設定で localhost:8545 が選択されているかなどを確認してください。設定変更後は Chrome のタブを一度閉じて開き直さないと変更が読み込まれないようです。

スクリーンショット 2017-10-08 16.49.02.png

Injected Web3 が選択できたら "Create" というボタンの右にあるテキストボックスに json 形式でコントラクトのコンストラクタの引数を与えます。今回は string 型の引数なのでダブルクォートで囲いましょう。

スクリーンショット 2017-10-08 16.52.34.png

"Create" ボタンを押すとコントラクトのデプロイが行えます。MetaMask から確認が求められるので Submit を押します。

スクリーンショット 2017-10-10 12.23.48.png

コントラクトがデプロイできたら,メモしておく必要がある項目が2つあります。1つめはコントラクトの ABI で,Web3 からコントラクトを利用する際に必要となります。browser-solidity の右のペインの Compile タブの Detail ボタンを押し,出てきたダイアログの中の INTERFACE という項目の部分にあるコピーボタンを押すとクリップボードにコピーできます。

スクリーンショット 2017-10-10 12.22.34.png

スクリーンショット 2017-10-10 12.22.44.png

コピーされた文字列は以下のようなものです。

[{"constant":false,"inputs":[{"name":"_message","type":"string"}],"name":"setMessage","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[],"name":"greet","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"message","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[{"name":"_message","type":"string"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"name":"_message","type":"string"}],"name":"greetEvent","type":"event"}]

2つ目はコントラクトのアドレスです。これはコントラクトのデプロイ後に Run タブのコントラクト・インスタンスの一覧が表示されている部分でコピーボタンを押すとクリップボードにコピーできます。

スクリーンショット 2017-10-10 12.23.57.png

コピーされた文字列はたとえば以下のようなものです。

0x7599c8900e6c9f57078d2248d2596fd3a4c4f8c2

React+Redux を使ったアプリケーション開発

作成したコントラクトをブラウザで利用するために以下のようなコントラクトを作成しました(クリックするとYouTubeが開きます)。

React+Redux を使ったアプリケーション開発

ソースコードは以下のリポジトリにあります。

https://github.com/arosh/greeter

詳細な実装はリポジトリのソースコードを見てもらうとして,ここでは要点だけ説明します。

Web3 のセットアップを行っている部分です。MetaMask Compatibility Guide に載っているものをコピペしています。MetaMask や Mist などで Web3 オブジェクトが供給されている場合にはそれを利用しています。

src/infra/ethereum.js
export function setupWeb3() {
  if (typeof window.web3 !== 'undefined') {
    window.web3 = new Web3(window.web3.currentProvider);
  } else {
    window.web3 = new Web3(
      new Web3.providers.HttpProvider('http://localhost:8545')
    );
  }
}

コントラクトのインスタンスを取得する処理です。browser-solidity で取得したコントラクトの ABI とコントラクトアドレスが必要になります。自分で動かしたい場合にはコントラクトのアドレスを書き換えてください。

src/infra/ethereum.js
const contractAddress = '0x7599c8900e6c9f57078d2248d2596fd3a4c4f8c2';

// prettier-ignore
const contractABI = [{"constant":false,"inputs":[{"name":"_message","type":"string"}],"name":"setMessage","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[],"name":"greet","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"message","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[{"name":"_message","type":"string"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"name":"_message","type":"string"}],"name":"greetEvent","type":"event"}];

export function getInstance() {
  const web3 = window.web3;
  const contract = web3.eth.contract(contractABI);
  const instance = contract.at(contractAddress);
  return instance;
}

トランザクションに署名を行うアカウントの設定です。MetaMask Compatibility Guide によれば,MetaMask を使う場合には web3.eth.accounts[0] を使えば MetaMask で設定したアカウントが利用されるようになっているようなのですが,Mist など他のウォレットではそのような仕様にはなっていないので,別の処理を書く必要があります。

src/infra/ethereum.js
export function setupDefaultAccount() {
  return new Promise((resolve, reject) => {
    window.web3.eth.getAccounts((err, accounts) => {
      if (err) {
        reject(err);
        return;
      }
      window.web3.eth.defaultAccount = accounts[0];
      resolve();
    });
  });
}

コントラクトから message を取り出す処理です。

src/infra/ethereum.js
export function getMessage(): Promise<string> {
  const instance = getInstance();
  return new Promise((resolve, reject) => {
    instance.message((err, message) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(message);
    });
  });
}

message の内容を書き換える処理です。instance.setMessage(_message) を呼び出すだけで,署名を行うかどうかの確認などの処理は MetaMask がよろしくやってくれます。

src/infra/ethereum.js
export async function setMessage(message: string): Promise<void> {
  await setupDefaultAccount();
  const instance = getInstance();
  return new Promise((resolve, reject) => {
    instance.setMessage(message, err => {
      if (err) {
        reject(err);
        return;
      }
      resolve();
    });
  });
}

最初に呼び出される部分です。setupWeb3() の呼び出しのほかに,setInterval で定期的に getMessage する処理などを書いています。取得した値は Flux パターンの Store に dispatch しています。

src/index.js
// @flow
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import App from './components/App';
import createStore from './store/create';
import * as eth from './infra/ethereum';
import { updateBlockNumber, updateMessage } from './reducers';

const store = createStore();

window.addEventListener('load', function() {
  eth.setupWeb3();

  eth.setOnGreet(() => {});
  setInterval(async () => {
    const blockNumber = await eth.getBlockNumber();
    if (store.getState().blockNumber !== blockNumber) {
      store.dispatch(updateBlockNumber(blockNumber));
    }
    const message = await eth.getMessage();
    if (store.getState().message !== message) {
      store.dispatch(updateMessage(message));
    }
  }, 100);

  ReactDOM.render(
    <Provider store={store}>
      <App />
    </Provider>,
    document.getElementById('react-root')
  );
});

event の処理について

コントラクトの setMessagegreet を呼び出したときには greetEvent という event を発生させるようなコントラクトを記述したのですが,MetaMask経由では event を取得することができませんでした。以下の画像のように geth のコンソールからは event が取得できています。

スクリーンショット 2017-10-09 22.11.29.png

参考資料