Ethereum上のトークンを送金するWebアプリを作ってDecentralizedな方法で公開してみる

この記事はEthereum Advent Calendar 2017の13日目の記事です。

はじめに

Ethereum Advent Calendar 2日目の記事ではamachinoさんがERC20トークンの作り方を、6日目の記事ではあんちぽちゃんさんがトークンをRopstenテストネットにデプロイする方法を記事にされていました。この記事ではこの流れを勝手に引き継いで、さらにMetaMaskと連携してトークンの送金ができるWebUIを作成しDistributed Webに公開するまでを見ていきたいと思います。

ERC20トークンの作り方

この章では2日目の記事を参考にERC20トークンを作っていきます。

$ # truffle のインストール
$ npm install -g truffle
$ # 作業用ディレクトリの作成 $ mkdir my_token && cd my_token $ truffle init
$ # truffleが作ってくれるファイルはこんな感じです $ tree . . ├── contracts │ └── Migrations.sol ├── migrations │ └── 1_initial_migration.js ├── test ├── truffle-config.js └── truffle.js
$ # 開発に必要なライブラリをインストール $ npm init -f $ npm install --save zeppelin-solidity truffle-hdwallet-provider
$ # トークンのスマートコントラクトを作成します $ vim contracts/MyToken.sol

MyToken.sol の中身は以下のようにします。

pragma solidity ^0.4.18;
import "zeppelin-solidity/contracts/token/StandardToken.sol";

contract MyToken is StandardToken {
  string public name = "MyToken";
  string public symbol = "MTKN";
  uint public decimals = 18;

  function MyToken(uint initialSupply) public {
    totalSupply = initialSupply;
    balances[msg.sender] = initialSupply;
  }
}

 コンパイルできるか確認してみましょう。

$ # コンパイル
$ truffle compile
Compiling ./contracts/Migrations.sol...
Compiling ./contracts/MyToken.sol...
...
Writing artifacts to ./build/contracts

$ # migration手順を設定するファイルを作ります
$ vim migrations/2_deploy_my_token.js

2_deploy_my_token.js の中身は以下のようにします。

const MyToken = artifacts.require('./MyToken.sol')

module.exports = (deployer) => {
  let initialSupply = 50000e18
  deployer.deploy(MyToken, initialSupply)
}    

Ropstenテストネットにデプロイ

この章では6日目の記事を参考に先程作ったスマートコントラクトをRopstenにデプロイします。RopstenへのデプロイにはInfuraを使用するので予めアクセストークンを取得しておいて下さい。

$ # デプロイに使用するウォレットのニーモニックコードを環境変数に設定する
$ export ROPSTEN_MNEMONIC="orange apple banana..."

$ # Infuraのアクセストークンを環境変数に設定する
$ export INFURA_ACCESS_TOKEN=INFRACCESSTOKEN...

$ # Truffleの設定ファイルを編集する
$ vim truffle.js

truffle.js を以下のようにします。

var HDWalletProvider = require("truffle-hdwallet-provider");
var mnemonic = process.env.ROPSTEN_MNEMONIC;
var accessToken = process.env.INFURA_ACCESS_TOKEN;

module.exports = {
  networks: {
    ropsten: {
      provider: function() {
        return new HDWalletProvider(
          mnemonic,
          "https://ropsten.infura.io/" + accessToken
        );
      },
      network_id: 3,
      gas: 2000000
    }
  }
};

デプロイします。

$ # Ropstenテストネットを指定してデプロイ
$ truffle migrate --network ropsten Using network 'ropsten'. Running migration: 1_initial_migration.js Deploying Migrations... ... 0x3b2b3e004d058eae6ea6e03c1fcce49b31dd7e71bd5adbf5fd060c0d94cdf1d5 Migrations: 0x66e133bb6153040594cba6604f8808dfb287c748 Saving successful migration to network... ... 0x69153d9daf2a275abb4a1ab2032bf59c5bc846f2c385a6e929a148fefd39bf4d Saving artifacts... Running migration: 2_deploy_my_token.js Deploying MyToken... ... 0x1eb465be09fa45e4992acc85ed2b404076b035ee72d70c8b580a4e630f058057 MyToken: 0xd7182bd804caad7b93d607a1bf769d5c7354afa4 Saving successful migration to network... ... 0x46c5e96ab6454e148dda4556addf68a35b02af14c608aa79ffda55bcfe9323ab Saving artifacts...

今回作ったスマートコントラクトMyTokenのコントラクトアドレスである 0xd7182bd804caad7b93d607a1bf769d5c7354afa4 は忘れないようにメモしておきましょう。最後にMetaMask上で自分のアドレスにトークンが付与されていることが確認できれば準備は終わりです!

f:id:lotz84:20171212211657p:plain 

WebUIを作ってみる

いよいよ本編です。MetaMaskと連携して先程作ったトークンを送金できるWebUIを作っていきましょう。MetaMaskはweb3というEthereumのJavaScript APIを提供してくれます。もし今すでにMetaMask が入っていたらブラウザのJavaScriptコンソールを開いてweb3と打ってみて下さい。

f:id:lotz84:20171212215241p:plain

web3というオブジェクトが存在しているのがわかると思います。このオブジェクトを利用してEthereumブロックチェーンとやり取りをするアプリを作っていきましょう。ただMetaMaskがweb3オブジェクトを用意してくれるのは http:// もしくは https:// の時だけなので開発に取り掛かる前に手頃なサーバーを立てる必要があります。幸いtruffleがserveというサブコマンドを用意してくれていて、serveを利用すると build/ 以下のファイルをlocalhostホスティングしてくれてファイルに変更があれば反映もしてくれます。

しかし残念ながら 2017/12/12 時点では truffle serve にバグがあって動きません。なので harp などを使って別のサーバーを立てて開発してもいいですし、どうしても truffle serve が使いたい人は workaround があるのでそれを実行することをオススメします。

これから作るアプリの完成形は以下のようなイメージです。

f:id:lotz84:20171213011216p:plain

トークンの保有残高がわかりアドレスを指定して送金ができるというシンプルなものです。さっそく作り始めましょう。 build/index.html として以下のようなファイルを作ります。

<!DOCTYPE html>
<html>
<head>
<title>Token Wallet</title>
</head>
<body>

<div id="js-app">
<h1>{{ name }} Wallet</h1>
<p>あなたのアドレス: {{ defaultAccount }}</p>
<p>{{ name }}の保有量: {{ showBalance(balance) }} {{ symbol }}</p>
<p>
送金先: <br />
<input v-model="to" type="text" />
</p>
<p>
送る量: <br />
<input v-model="amount" type="number" />
</p>
<p>
<button @click="send">送金</button>
</p>
<p v-if="history">
送金成功: <a :href="'https://ropsten.etherscan.io/tx/' + history">{{ history }}
</a>
</p>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
// ここにコードを書いていきます
</script>
</body>
</html>

 JavaScriptコードの解説に集中するためにそれ以外のHTMLの部分を一気に書きました。フレームワークには Vue.js を使っています。

ブロックチェーンに接続するコードを書く前にまずMetaMaskがインストールされているか、そしてRopstenテストネットに繋がっているかを確認しましょう。MetaMask Compatibility Guide にはこういった処理のベストプラクティスが載っていてとても参考になります。

MetaMaskがインストールされているかを確認するには web3 が定義されているかを見ればいいでしょう。

window.addEventListener('load', function() {
  if (typeof web3 !== 'undefined') {
    window.web3 = new Web3(web3.currentProvider);
    onlyRopstenTestNetwork(main);
  } else {
    document.write("Please install <a href="\"https://metamask.io/\"">MetaMask</a>.")
  }
})

web3が定義されていれば onlyRopstenTestNetwork(main); を実行し、そうでなければMetaMaskのインストールを促しています。

Ropstenに繋がっているかは onlyRopstenTestNetwork で確認していて、実装は以下のようになっています。

function onlyRopstenTestNetwork(cb) {
  web3.version.getNetwork(function(err, netId) {
    if (netId === "3") {
      cb();
    } else {
      document.write("Please switch MetaMask to Ropsten Test Network and reload page.");
    }
  });
}

RopstenのnetIdは3なのでそうでない場合は繋ぎ先を変更するように求めています。

準備は整ったので main 関数の実装を見ていきましょう。

var contractAddress = "0xd7182bd804caad7b93d607a1bf769d5c7354afa4";
var abiArray = ...;

function main() { var defaultAccount = web3.eth.defaultAccount; var contract = web3.eth.contract(abiArray).at(contractAddress); contract.name(function(err, name){ if(err) throw err; contract.symbol(function(err, symbol){ if(err) throw err; contract.balanceOf(defaultAccount, function(err, balance){ if(err) throw err; initializeApp(defaultAccount, contract, name, symbol, balance); }); }); }); }

まずweb3.eth.defaultAccount でMetaMaskで現在指定されているアカウントのアドレスを取得しています。contract を定義している処理

var contract = web3.eth.contract(abiArray).at(contractAddress);

は一番大事なところで web3.eth.contract(abiArray)コントラクトオブジェクトを生成し、at に実際のコントラクトがデプロイされているアドレスを渡すことで具体的なインスタンスを生成しています。contract.name, contract.symbol, contract.balanceOf はスマートコントラクトに自分で定義した name, symbol, balanceOf を呼び出す関数です。結果はコールバック関数を渡すことで取得することができます。コールバック関数なので簡単にネストが深くなってしまいますが ethjs や ether.js などを使えば Promise に変換してくれたりするので実際はこういったライブラリを利用するのが良いでしょう。

説明を後回しにしていましたが abiArrayコントラクトのABI (Application Binary Interface)が定義された配列で、以下のようにスマートコントラクトのコンパイル時に作成された build/contracts/MyToken.json に定義されている値をそのまま使用します。

$ cat build/contracts/MyToken.json | jq -c .abi
[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_from","type":"address"},{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_subtractedValue","type":"uint256"}],"name":"decreaseApproval","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_addedValue","type":"uint256"}],"name":"increaseApproval","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"},{"name":"_spender","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[{"name":"initialSupply","type":"uint256"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"name":"owner","type":"address"},{"indexed":true,"name":"spender","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"from","type":"address"},{"indexed":true,"name":"to","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Transfer","type":"event"}]

次にinitializeApp を見てみましょう。

function initializeApp(defaultAccount, contract, name, symbol, balance) {
  new Vue({
    el: '#js-app',
    data: {
      defaultAccount: defaultAccount, // 選択されているEhtereumアカウント
      name: name,                     // トークンの名前
      symbol: symbol,                 // トークンのシンボル
      balance: balance,               // トークンをいくら所持しているか
      to: "",                         // 送金先アドレス
      amount: 0,                      // 送金する量
      history: ""                     // 送金トランザクションのハッシュ
    },
    methods: {
// 残高の表示を整形するメソッド showBalance: function(balance) { return (balance / 1e18).toFixed(2); },
// 送金するメソッド send: function() { var $this = this; var sendAmount = this.amount * 1e18; contract.transfer(this.to, sendAmount, {from: defaultAccount}, function(err, txhash){ if (err) throw err; $this.history = txhash; contract.balanceOf(defaultAccount, function(err, balance){ $this.balance = balance; }); }); } } }) }

ほとんどViewに値を渡しているだけですが、 send は重要なので見ていきましょう。send は送金ボタンが押されたら実行されるメソッドで contract.transfer を呼んで defaultAccount から to で指定されたアドレスに amount × 1018 だけトークンを送金します。これもスマートコントラクトに定義したメソッドを呼び出しているだけです。実際に送金ボタンを押すとMetaMaskのUIが立ち上がり送金することができます。 

アプリのコードは以上になります。

Distributed Web

さて、せっかくアプリを作ったので次はたくさんの人に使ってもらえるように公開したいですよね。もちろん1つのHTMLファイルなのでAmazon S3Dropboxにでも上げれば済む話なのですが、せっかくバックエンドにEhtereumブロックチェーンを利用して分散化されているのにアプリが特定の企業のサーバーに乗っているのはちょっともったいないですよね。どうせならアプリも分散化された環境で公開することにしましょう!

そこでIPFSという分散化Webの登場です*1。IPFSはP2Pネットワークで維持されているバージョン管理されたファイルシステムで、まさに求めていた分散化された環境です。技術的な説明はWhitePaperPOSTDの解説記事を見てもらうとして、ここではとにかく使ってみたいと思います。

まずIPFSのインストールはMacだとbrewですぐに入ります。

$ brew install ipfs

他の環境でも公式の丁寧なインストールガイドに沿って進めれば簡単なはずです。

初めてインストールした場合は以下のように初期化を行って ~/.ipfs を生成しておきます。

$ ipfs init

IPFSを利用する際はP2Pネットワークに接続するためにデーモンを立ち上げておきます。

$ ipfs daemon
Initializing daemon...
...
Gateway (readonly) server listening on /ip4/127.0.0.1/tcp/8080
Daemon is ready

さて準備は整いました。作ったアプリをIPFSに保存しましょう。デーモンを起動した状態で以下のように実行します。

$ ipfs add build/index.html
added QmPhxGvZ3N9gdbW8UTf4E2kLs56uTuU1CQS1Ahi8TpewJc index.html

一瞬で index.html はIPFSに保存されました。IPFSから取り出す時は Qm から始まるハッシュ値を利用します。

$ ipfs cat QmPhxGvZ3N9gdbW8UTf4E2kLs56uTuU1CQS1Ahi8TpewJc > index.html

 これで index.html を取り出すことができました。このハッシュ値さえあればIPFSにつながっているどの環境からでもファイルにアクセスすることができます。

IPFSは https://ipfs.io/ipfs/ハッシュ値 というURLでブラウザからアクセスするサービスも提供してくれています。なので今公開した index.html は以下のURLからも見ることが可能です。

https://ipfs.io/ipfs/QmPhxGvZ3N9gdbW8UTf4E2kLs56uTuU1CQS1Ahi8TpewJc

まとめ

Ethereumブロックチェーンにデプロイしたスマートコントラクトと連携するWebアプリをIPFSという分散化されたWebにデプロイして公開しました。これでこのアプリはもう何があっても止まることはありません*2

 

*1:Ethereum Advent Calendarなので本当はEthereum Swarmを使いたかったのですが何故かうまく動かず断念しました…

*2:ネットワークを維持するノードがなくならない限りは…