nodejs
Blockchain
Ethereum
SmartContract
FUJITSUDay 21

Ganache CLI + web3.jsでEthereum簡易投票アプリを構築

はじめに

FUJITSU Advent Calendar 2017の21日目です。
今年の10月ぐらいまでBlockchainやBitcoinという単語は聞いたことがあったものの、興味がありませんでした。
ただ、最近職場でブロックチェーンについて軽く触れる機会があり、徐々に興味を持ち始めました。
本記事では、分散型アプリケーション(DApps: Distributed Apps)を構築するためのプラットフォームであるEthereumの勉強の一環として、Webブラウザで動く簡単な投票アプリを作ってみました。
この記事を書くにあたって、Mediumの記事Solidityの例を参考にさせていただきました。

Ethereumやブロックチェーンに関する理解がまだまだ浅いので、説明が間違っているかもしれません。
指摘していただけるとありがたいです。

Ethereumとは

Ethereumはスマートコントラクトというプログラムを実行することができる分散型プラットフォームの一種です。
Ethereumではスマートコントラクトを記述するために、Solidityという言語を使います。
EthereumやSolidityに関しては多くの記事が既に書かれているので、詳しくはそちらを参照してください。

投票アプリの概要

今回はSolidity公式サイトの例にならって簡易投票アプリを作りたいと思います。
投票権を持った人間(以下、投票者)は次の行動のうちのどちらかのみを取ることができるものとします。

  1. 候補者一人に投票する
  2. 他の投票者に投票を委譲する

もし委譲を受けた投票者が既に投票をしている場合は、その投票者が投票した候補者の投票数が加算されるものとします。

以下が今回作った簡易投票アプリの動作画面です。
今回は簡単に動作確認ができるように、一つのページでどのユーザーとしてでも投票、委譲ができるようにしました。
image.png

ソースコードはGithubで公開しています。

環境設定

今回は、実際のブロックチェーンは使わず、ローカルで擬似的なブロックチェーンを構築するために、Ganache CLIを使います。Ethereumのアプリ開発フレームワークとしてTruffleがあるのですが、Ethereumの基本を理解するためにあえて今回は採用しませんでした。また、ブラウザからブロックチェーンノードに接続するためにweb3.jsを使います。

以下が環境設定の流れです。

$ yarn -v
1.3.2

$ node -v                               
v9.3.0

$ mkdir ethereum-voting
$ cd ethereum-voting
$ yarn init
# いろいろ聞かれますが特にこだわりがなければすべてEnterで良いと思います。
$ yarn add solc ganache-cli web3 # solcはSolidityのコンパイラ

投票のコントラクト

投票のコントラクトは以下のとおりです。注目すべき点としては、

  • publicなフィールドに関しては、ゲッタが自動的に生成されるようなのですが、リストやマップのゲッタを用いててもそれらすべてを取得することはできないようです。リストの場合はインデックスが、マップの場合はキーをゲッタの第一引数として渡す必要があります。したがって、後述するinit.jsではリスト内の要素をすべて取得するために、まずリストの長さを取得して、一個一個ゲッタで取得しています。
  • マップのキーには可変型は使用できない。したがって、votesReceivedbytes32をキーとしています。可変型が扱えないのはめんどくさいですね:bomb:
  • 可読性を高めるため、bytes32をstring型に変換しています。参考サイト
Ballot.sol
pragma solidity ^0.4.16;

contract Ballot {
    struct Voter {
        uint weight; // 重みは投票権の委譲によって加算されていく
        bool voted;  // 既に投票したかどうか
        address delegate; // 投票権の委譲先
        bytes32 vote;   // 投票した候補者
    }

    address public chairperson; // 投票の管理者

    mapping(address => Voter) public voters; // 投票者へのマッピングをする

    address[] public voterAddresses; // 投票者のリスト

    mapping (bytes32 => uint) public votesReceived; // 候補者の獲得投票数
    bytes32[] public candidates; // 候補者のリスト

    function Ballot(bytes32[] candidateNames) public {
        chairperson = msg.sender;
        voters[chairperson].weight = 1;

        for (uint i = 0; i < candidateNames.length; i++) {
            candidates.push(candidateNames[i]);
            votesReceived[candidateNames[i]] = 0;
        }

    }

    // _votersで表されるアドレス全てに投票権を与える
    // 管理者に投票権は与えない
    function giveRightToVote(address[] _voters) public {
        for (uint i = 0; i < _voters.length; i++) {
            address voter = _voters[i];
            if (voter == chairperson) {
                continue;
            }

            require((msg.sender == chairperson) && !voters[voter].voted && (voters[voter].weight == 0));
            voters[voter].weight = 1; // 重みを与える
            voterAddresses.push(voter);
        }
    }

    // toで表される投票者に投票権を委譲する
    function delegate(address to) public {
        Voter storage sender = voters[msg.sender];
        require(!sender.voted); // 委譲元が投票していないことが条件
        require(to != msg.sender);
        while (voters[to].delegate != address(0)) {
            to = voters[to].delegate;
            require(to != msg.sender);
        }
        sender.voted = true;
        sender.delegate = to;
        Voter storage delegate = voters[to];

        // 委譲先が既に投票していれば委譲先の投票先に委譲元の重みを加算
        // そうでなければ、委譲先の重みに委譲元との重みを加算 
        if (delegate.voted) {
            votesReceived[delegate.vote] += sender.weight;
        } else {
            delegate.weight += sender.weight;
        }
    }

    // candidatesで表される候補者に投票する
    function vote(bytes32 candidate) public {
        Voter storage sender = voters[msg.sender];
        require(!sender.voted); // 投票していないことが条件
        sender.voted = true;
        sender.vote = candidate;

        votesReceived[candidate] += sender.weight; // 候補者の獲得投票数を加算
    }

    // 投票者数を返す  
    function getVoterLength() public constant returns (uint) {
        return voterAddresses.length;
    }

    // 候補者数を返す
    function getCandidateLength() public constant returns (uint) {
        return candidates.length;
    }

    // candiatesのi番目の候補者名をstring型に変換して返す
    function getCandidate(uint32 i) public constant returns (string) {
        return bytes32ToStr(candidates[i]); 
    }

    // bytes32をstringに変換する
    function bytes32ToStr(bytes32 _bytes32) public constant returns (string) {

        bytes memory bytesArray = new bytes(32);
        for (uint256 i; i < 32; i++) {
            bytesArray[i] = _bytes32[i];
        }
        return string(bytesArray);
    }

}

Node.jsでコントラクトをデプロイする

次に、コントラクトをブロックチェーンにデプロイして、投票権を管理者以外のアカウントに与えます。
ここでは管理者を適当に最初のアカウントにしました。注目すべき点としては、

  • compiledCode.contracts[':Ballot'].interfaceabiとよばれるコントラクトのインターフェース(どういうメソッドが存在するか)をコンソールに出力してます。これは、後にブラウザからブロックチェーンノードに対してコマンドを実行するためのオブジェクトを作成するのに必要となります。
  • compiledCode.contracts[':Ballot'].bytecodeは実際にブロックチェーンにデプロイされるコードです。BallotContract.deployの第一引数のオブジェクトでdataとして渡してやります。
  • deployを実行した時点ではまだデプロイされません。sendして初めてデプロイされます。
  • 文字列は16進数に変換する必要がるようです。(web3.utils.asciiToHexを使いました。)
init.js
const ganache = require('ganache-cli');
const solc = require('solc');
const Web3 = require('web3');
const fs = require('fs');

const web3 = new Web3('http://localhost:8545');

web3.eth.getAccounts().then(accs => {
  // ブロックチェーン上のアカウント(アドレス)をすべて取得
  const accounts = accs;
  const code = fs.readFileSync('Ballot.sol').toString();
  const compiledCode = solc.compile(code); // Ballot.solをコンパイル
  console.log(compiledCode.contracts[':Ballot'].interface);
  const abiDefinition = JSON.parse(compiledCode.contracts[':Ballot'].interface); // コントラクトにどういうメソッドが存在するか
  const BallotContract = new web3.eth.Contract(abiDefinition); // コントラクトに関する情報やメソッドを含む
  const byteCode = compiledCode.contracts[':Ballot'].bytecode; // ブロックチェーンに実際にデプロイされるバイトコード

  // ブロックチェーンにコントラクトをデプロイする
  // デプロイする際にコンストラクタが呼ばれるので引数をargumentsで渡す
  BallotContract.deploy({
    data: byteCode, // ここでバイトコードが必要になる
    arguments: [
      [
        // 文字列は16進数に変換してあげないといけないらしい
        web3.utils.asciiToHex('Bulbasaur'),
        web3.utils.asciiToHex('Charmander'),
        web3.utils.asciiToHex('Squirtle'),
      ],
    ],
  })
    .send({from: accounts[0], gas: 10000000}) // sendして初めてブロックチェーンにコードが送られる
    .then(instance => {
      console.log('Contract Address:', instance._address);
      return instance.methods
        .giveRightToVote(accounts) // ブロックチェーン上のアカウントすべてに投票権を与える (管理者は除く)
        .send({from: accounts[0], gas: 1000000});
    });
});

ウェブページの作成

次にブラウザから投票、委譲できるように以下の項目をindex.htmlに作成します。

  • 候補者とその獲得投票数
  • 投票フォーム
  • delegateフォーム
index.html
<!DOCTYPE html>
<html>
    <head>
        <title>簡易投票アプリ</title>
        <link href='https://fonts.googleapis.com/css?family=Open+Sans:400,700' rel='stylesheet' type='text/css'>
        <link href='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css' rel='stylesheet' type='text/css'>
    </head>
    <body class="container">
        <h1>簡易投票アプリ</h1>
        <div class="table-responsive">
            <table class="table table-bordered">
                <thead>
                    <tr>
                        <th>候補者</th>
                        <th>投票数</th>
                    </tr>
                </thead>
                <tbody id="report">
                </tbody>
            </table>
        </div>
        <label for="vote-as">Vote as</label>
        <select id="vote-as" name="vote-as">
        </select>
        <label for="vote-as">Vote to</label>
        <select id="vote-to" name="vote-to">
        </select>

        <a href="#" onclick="voteForCandidate()" class="btn btn-primary">Vote</a>

        <section>

        <section id="delegation">
            <h2>Delegation</h2>
            <label for="delegate-source">From</label>
            <select id="delegate-source" name="delegate-source">
            </select>
            <label for="delegate-target">To</label>
            <select id="delegate-target" name="delegate-target">
            </select>
            <a href="#" onclick="delegate()" class="btn btn-primary">Delegate</a>
        </section>

    </body>
    <script src="https://cdn.rawgit.com/ethereum/web3.js/develop/dist/web3.js"></script>
    <script src="https://code.jquery.com/jquery-3.1.1.slim.min.js"></script>
    <script src="./index.js"></script>
</html>

次にブラウザで実行させるJSファイルを記述します。ポイントは、

  • init.jsで出力したcompiledCode.contracts[':Ballot'].interfaceの内容を用いてコントラクトオブジェクト(BallotContract)を生成しています。
  • 実際のブロックチェーン上にはデプロイされたコントラクトが無数にあります。あるコントラクトに対して処理を実行させるには、コントラクトを識別する必要があります。識別に用いられるのが、コントラクトアドレスです。これは、init.jsで出力していますので、BallotContract.atの引数として渡してあげます。
index.js
const web3 = new Web3(new Web3.providers.HttpProvider('http://localhost:8545'));

// abiをパースする
const abi = JSON.parse(
  '[{"constant":true,"inputs":[],"name":"getVoterLength","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"i","type":"uint32"}],"name":"getCandidate","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"chairperson","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"","type":"uint256"}],"name":"candidates","outputs":[{"name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"to","type":"address"}],"name":"delegate","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"","type":"bytes32"}],"name":"votesReceived","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_voters","type":"address[]"}],"name":"giveRightToVote","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"}],"name":"voters","outputs":[{"name":"weight","type":"uint256"},{"name":"voted","type":"bool"},{"name":"delegate","type":"address"},{"name":"vote","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"candidate","type":"bytes32"}],"name":"vote","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"","type":"uint256"}],"name":"voterAddresses","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"_bytes32","type":"bytes32"}],"name":"bytes32ToStr","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getCandidateLength","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[{"name":"candidateNames","type":"bytes32[]"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"}]',
);

const BallotContract = web3.eth.contract(abi);

// コントラクトのインスタンスを取得する
const contractInstance = BallotContract.at(
  '0xa751f8014ff6f54DcFf37861e86BAC02F1A8de17',
);

// 候補者の名前を保持するリスト
let candidates = [];

// 選択ボックスで選択された投票者に投票をする
// エラー処理はしてません (汗)
function voteForCandidate() {
  const candidateName = $('#vote-to').val();
  console.log(candidateName);
  contractInstance.vote(candidateName, {from: $('#vote-as').val()}, () => {
    $('#voteNum-' + candidateName).html(
      contractInstance.votesReceived.call(candidateName).toString(),
    );
  });
}

// 投票権を委譲する
function delegate() {
  const delegateSource = $('#delegate-source').val();
  const delegateTarget = $('#delegate-target').val();
  contractInstance.delegate(delegateTarget, {from: delegateSource}, err => {
    if (err) {
      alert('Could not delegate');
    }
  });
}

// 候補者のリストをブロックチェーンから取得して選択ボックスを初期化する
$(document).ready(function() {
  const voterLen = Number(contractInstance.getVoterLength.call().toString());
  let addresses = [];
  for (let i = 0; i < voterLen; ++i) {
    addresses.push(contractInstance.voterAddresses.call(i).toString());
  }

  addresses.forEach(addr => {
    $('#vote-as').append("<option value='" + addr + "'>" + addr + '</option>');
    $('#delegate-source').append(
      "<option value='" + addr + "'>" + addr + '</option>',
    );
    $('#delegate-target').append(
      "<option value='" + addr + "'>" + addr + '</option>',
    );
  });

  const candidateLen = Number(
    contractInstance.getCandidateLength.call().toString(),
  );
  for (let i = 0; i < candidateLen; ++i) {
    candidates.push(contractInstance.getCandidate.call(i).toString());
  }

  candidates.forEach(candidate => {
    $('#vote-to').append(
      "<option value='" + candidate + "'>" + candidate + '</option>',
    );
    $('#report').append(
      '<tr><td>' +
        candidate +
        '</td><td id="voteNum-' +
        candidate +
        '">' +
        contractInstance.votesReceived.call(candidate).toString() +
        '</td></tr>',
    );
  });
});

動作確認

$ yarn start
$ node app.js # 別ターミナルで
...
Contract Address: 0x11e3c353414F7ad8602fE0afDf6f72360D98A80F

このコントラクトのアドレスをindex.jsの該当箇所に貼り付けます。

index.js
const contractInstance = BallotContract.at(
    'コントラクトアドレスを入れる', 
);

次に、お好きなブラウザでindex.htmlを開きます。

投票権委譲の動作確認のために0xe2df...(以下Aさんとよびます)の投票権を0x3439...(以下Bさんと呼びます)に委譲します。見た目は何も起きません (UI的にダメだろ)
image.png

次にBさんとしてSquirtleに投票します。Squirtleの投票数がちゃんと2になっていることが確認できました。
image.png

次にAさんとしてBulbasaurに投票してみます。が、何も起きないのが確認できました。
image.png

おわりに

今回はいろんなウェブサイトを参考にしながら、ブラウザで動く簡易投票アプリを作ることでGanache CLI, web3.js, Ethereumに触れました。ただ、まだまだブロックチェーンに対する理解は浅いので、継続的に学習を続けていきたいと思っています。とりあえず来年の1月までにCourseraのコースをやり終えたいと思います!もしおすすめのサイトや書籍などありましたらぜひ教えてください:bow: