エンジニアHubproduced by エン

若手Webエンジニアのための情報メディア

ブロックチェーン入門 ─ JavaScriptで学ぶブロックチェーンとBitcoinウォレットの仕組みと実装

ブロックチェーンは、分散環境の新しいデータ構造であり分散合意のアルゴリズムです。本稿は、Node.jsでブロックチェーンおよびBitcoinウォレットを実装するハンズオンです。

OGP image

フリーランスでエンジニアとライティングなどをゆるゆる行っているerukitiと申します。
個人のサークル「東京ラビットハウス」から「Modern JavaScript」「簡単JavaScript AST入門」「JavaScriptで覚える暗号通貨入門#1 Bitcoin完全に理解した」といったJavaScript関連の技術同人誌を単著で発行しています。

この記事では、ブロックチェーンの仕組みを解説し、実際にブロックチェーンやBitcoinウォレットを作ってみることをゴールとします。少しでもこれらの技術への理解を深める一助になれば幸いです。

ブロックチェーンとは何か?

ブロックチェーンとは、謎の人物Satoshi Nakamotoが生み出した暗号通貨Bitcoinのコアとなる技術で、分散環境の世界にまったく新しい考え方として導入された、データ構造および分散合意のためのアルゴリズム です。

The network timestamps transactions by hashing them into an ongoing chain of hash-based proof-of-work, forming a record that cannot be changed without redoing the proof-of-work.
出典:Bitcoin: A Peer-to-Peer Electronic Cash System [PDF]

非中央集権型ネットワーク(分散環境、参加するすべてのノードが対等な立場にある)上でP2P電子キャッシュシステムを実現する方法として、時系列に従ったトランザクションをハッシュ計算を用いたProof of Work(計算による証明)なしでは変更不可能なデータを積み重ねるという手法が、2008年11月1日に投稿されました。

Bitcoin P2P e-cash paper – Satoshi Nakamoto

ブロックチェーンは、信頼のできないノードがつながってる分散環境でどうやってデータ更新をするのかという意味で、とても興味深い技術です。

ブロックチェーンが活用されている分野

ブロックチェーンは、情報の改ざんがされにくく堅牢性が高いことから、暗号通貨の取引だけでなく、さまざまな業界での利活用が進められています。

食品
食品の産地や流通経路を明らかにするため、ブロックチェーンを使いトレーサビリティを確保している。ジビエ肉、ワイン、有機農作物などの各分野で 取引透明化を試みる企業もある
不動産管理システム
積水ハウス株式会社と株式会社bitflyerの共同事業では、賃貸住宅の情報管理システムにブロックチェーンを活用。不動産データや入居者情報、クレーム 情報などを記録し、追跡できるようなシステムを構築している
bitFlyerが語る近未来、次世代ブロックチェーン「miyabi」の特徴と活用は?【Blockchain for Enterprise 2018】 - INTERNET Watch
電力取引
2018年、関西電力は余剰電力のP2P取引の実証実験を開始。ブロックチェーンを使い、太陽光発電の生産者と消費者が電力を直接取り引きできるプラットフォームの形成を検討中。
豪州パワーレッジャー社とのブロックチェーン技術を活用した電力直接取引プラットフォーム事業に係る実証研究の開始について|2018|プレスリリース|企業情報|関西電力

これらの事例で本当にブロックチェーンが適しているのか? は、まだわからない手探りの状況です。だからこそ、ブロックチェーンとは何か? 何ができて、何ができないのか? を見極めることが、重要になってきます。

データ構造から見たブロックチェーン

ブロックチェーンは、日本語ではよく「分散型台帳」などと呼ばれます。台帳=ブロックが連なった(チェーンになった)データ構造なので、ブロックチェーンです。

ブロックには、電子署名を施したトランザクションや、前のブロックのハッシュ値が含まれています。トランザクションもブロックもイミュータブル(不変)で、追記オンリーのデータ構造で成り立っているといえます。新しいブロックを発行するためには膨大なハッシュ値の演算(PoW、Proof of Work)が必要であり、そのため、古いブロックであればあるほど改ざんが難しくなります。

それでは、データ構造の側面からブロックチェーンについて見ていきましょう。

トランザクション

仮想通貨として知られているBitcoinを例に挙げて説明していきます。

Bitcoinは、Linux財団のbitcoin-mlで議論が行われ、リファレンス実装でもあるOSSソフトウェア「Bitcoin Core」が使われています。つまり、BitCoinはデータ構造や通信方法を規定したプロトコルであり、そのプロトコルに従ったソフトウェアで運用されているネットワークそのものでもあるといえます。

Bitcoinのトランザクションとは、送金情報をシリアライズして電子署名を施したもので、取引の基本単位となるものです。電子署名を施すことで、発行主、つまりBitcoinの持ち主が送金しているという証明を行います。

シリアライズとは、あるデータ・オブジェクトなどを符号化し、異なるプロセスやマシンなどの間でやりとりしやすいように、文字列かバイナリデータにすることです。文字列による汎用のシリアライズフォーマットとしては、JSONや、YAMLがあります。バイナリであれば、Protocol Buffersや、MessagePackなどが有名です。

const data = { hoge: 'ほげ', fuga: 'ふが' }
const serializedData = JSON.stringify(data)
console.log(serializedData)

プロトコルとしては、曖昧性が残ると計算結果が異なることになります。そのため、ハッシュ値をどうやって取るのか、そのハッシュ値を元に電子署名を施すのか、シリアライズのルールや、そのデータをどの順番でどうやって処理するのかが重要になります。

例えば、Bitcoinの古いトランザクションでは、電子署名の領域にいったんダミーを埋め込んでから電子署名を施していました。去年のアップデートで採用されたSegwit2xではそれらのエリアを分離(segregated witness)しています。

古いトランザクションにあった脆弱性への対応という側面も強いのですが、これによってトランザクションの作成と電子署名を分離できるようになります。分離によって処理がシンプルになり、新しい仕組みを導入できるという利点があります。

Bitcoinのトランザクションを高速化し、手数料を減らせるライトニングネットワーク(マイクロペイメント手法のひとつで、小額取引の実用化が期待できる技術)に欠かせないものです。

ブロック

ブロックは、トランザクションの集合体です。トランザクションが取引の基本単位だとすると、ブロックは記録の基本単位です。トランザクション単体では記録としては認められず、ブロックに取り込まれて初めて取引として成立するのです。

ブロックチェーンでは、前述の通り、ブロックには前のブロックのハッシュ値がポインタとして含まれています。最初のブロックには前のブロックというものが存在しないため、原初となるハッシュ値(genesis hash)が使われます。これはソースコードにハードコーディングされたもので、Bitcoinの本番ネットワークでは次の値がそれに該当します。

000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f

ブロックは、最初から最新まで全部合わせて矛盾がないように作られます。トランザクションが正しいこと、ブロックに矛盾がないこと、これらの条件を満たさないブロックはブロックとして認められず、他のノードからは弾かれてしまいます。

gitはブロックチェーンか?

gitは、一番ルートとなる最初のコミットのハッシュ値を出発点として、イミュータブルなデータが連なる構造です。データ構造として見ればブロックチェーンと類似しています。

ただし、gitとブロックチェーンでは目的の違いから、設計がかなり異なっています。

gitの目的は、ファイルの変更を履歴として残すことです。そのためgitの各ファイル(blob)は、それぞれ単独のファイルとしてgitのリポジトリ内に別々に存在し、複数ブランチを持って枝分かれすることを許容しています。

一方、暗号通貨としてのブロックチェーンでは、枝分かれしていると通貨の取引に支障が出ます。例えば、Bitcoinには「最長のチェーンを正しいチェーンと見なす」というルールがあり、瞬間的に複数のファイルが発生しても1つに収束するように設計されています。最長のチェーン以外は最終的に破棄されるのです。

この点は、forkやブランチを前提にしているgitと大きく異なるといえるでしょう。

分散合意アルゴリズムとしてのブロックチェーン

ブロックチェーンはデータ構造が特徴的ではありますが、より重要なのはアルゴリズム面です。

ブロックチェーンに使われている分散合意アルゴリズムとは、分散された複数のノードで正しくデータを更新するために合意を取るためのものです。もし合意を取らずに好き勝手にデータが更新できてしまうのであれば、それはただのでたらめなネットワークにすぎないからです。

分散合意アルゴリズムは、分散ファイルシステム、分散データベース、システムオーケストレーションツールなどさまざまな分散システムで使われています。

let counter = 0
console.log(counter) // --> 0
counter++            // データ更新
console.log(counter) // --> 1

このシンプルなJavaScriptのコードでは、counterという変数には初期値0が入っていて、counter++というコードによって、データが書き換えられ、1という値になります。このコードは、シングルスレッドで、同時に一カ所からしか参照されないからこそ成立します。

分散環境では、分散された複数台のマシンで1つの共有されたcounterを安全に更新するために、分散合意アルゴリズムなどを利用します。「安全」とは、以下のようなことを示します。

  • データが変な値にならない
  • データをロストしない
  • 更新に失敗したら、ちゃんと把握できる

データの積み重ねで更新を表現する

Bitcoinのようなブロックチェーンではcounterを更新しません。データを書き換えずに、トランザクションの積み重ねで通貨の移動を表現します。データベースやファイルシステムの世界でログやジャーナルと呼ばれる構造に近いです。

const counterLogs = []

const printCounter = () => {
  console.log(counterLogs.reduce((acc, n) => acc + n, 0))
}

printCounter()       // --> 0
counterLogs.push(1)  // データ更新
printCounter()       // --> 1

counterLogsは、counterの増減をログとして保存するための配列です。表示するときには、配列のすべての数値を合計します。

printCounterで使われているreduceメソッドでは、配列の全要素を足し合わせて、その合計値を求めています。

counterLogsに何も入っていない状態では、初期値の0になります。counterLogs.push(1)で配列に1という数値を追加すると、counterは1になります。例えば、さらにpush(2)するとcounterは3になります。

データ更新を分散環境で合意する

多くの分散合意アルゴリズムでは、データ更新を一手に引き受けるリーダーを選出し、リーダーがメンバーたちの更新要求を受け取って、データ更新を行ってからその変更情報をブロードキャストするような仕組みになっています。興味のある方は、PaxosRaftを調べるといいでしょう。特にRaftは扱いやすく、理解もしやすいでしょう。

しかし、暗号通貨のように、嘘をつくことで得をする可能性があるようなノードがいる環境では、Raftなど既存のアルゴリズムでは対応しきれません。そのため、ブロックチェーンでは、データ記録の基本単位であるブロックの発行手順が、分散合意アルゴリズムそのものになっています。

トランザクションが電子署名によって当人のものであることが証明されているとして、なぜブロックで合意を取らなければならないのでしょうか? それは個々のトランザクションが正しくても、時系列で並べたときに矛盾が生じる可能性があるからです。

例1. 10BTCを持っている人が10BTCを支払うトランザクションを発行するのは正しいでしょう(いったん手数料は考えません)。このとき10BTCを支払うトランザクションが1つだけなら問題はありませんが、複数の宛先に対して10BTCを支払うトランザクションが生じた場合、矛盾することになります。
例2. 例えば、ネットワークが分断されていて、あるネットワークではAliceがBobに10BTCを支払い、別のネットワークではAliceがCarolに10BTCを支払ったとします。これらは別々のネットワークでなら矛盾なく成立しますが、ネットワークの分断が解決されたとき、どちらのトランザクションが認められるべきでしょうか?

Bitcoinでは、次に述べるPoWを用いて、ブロックの発行について合意を取ります。

PoWという発明

Proof of Workは、Bitcoinで採用された分散合意技術です。これは確率論と、ゲーム理論的なインセンティブの考え方に裏打ちされています。

Bitcoinでは、トランザクションをとりまとめてブロックを発行するとマイニング報酬(一番最初の頃で50BTC、2018年現在は12.5BTC)を得ることができます。例えば、1BTCが60万円だとすれば、12.5BTCは7500万円です。ブロックを発行できればこのような大金が手に入るため、世界中の人がこぞってブロック発行できる権利を奪い合っています。

しかし、ブロックを発行するのは決して容易ではありません。256bitのハッシュを計算して、しかもその結果が、指定された数値(難易度)未満でないといけないという、特殊な制約があります。

これを満たすために、ダミーデータを付与してハッシュ値を調整する必要があります。SHA256では狙ったハッシュ値を算出するような方法はまだ見つかっていないため、総当たりで計算しなければなりません。

難易度は、おおよそ1週間(正確には2016ブロック)ごとに、Bitcoinネットワークにあるすべての計算力を費やして、平均的に10分で1つのハッシュ値が見つかる確率に設定されます。

  • 1つ前のブロックのハッシュ値をブロックの中に入れる
  • ブロックの中にあるすべてのトランザクションは、過去のブロックすべてのトランザクションと矛盾がないようにする
  • ほか、決められたプロトコルに従っている
  • ダミーデータを挿入してハッシュ値を調整する

これらの条件を満たせばブロックを発行できますが、これだけでは10BTCだけしか持っていないAliceが、BobとCarolの双方に10BTCを支払おうとしたときの問題(二重支払い問題)を解決できません。

正当なハッシュ値を持ち、複数のブロックがある、つまり枝分かれ状態をどう扱うかというルールが必要だからです。

そこで、ブロックチェーンの長さ(Bitcoinではheightと呼ぶ)が一番長いブロックが正しいとされるというルールがあります。

世界中のノードがこぞってこの計算を行っていますが、ブロックを1つ発行できたとしても、自分のブロックのあとにチェーンが続いてくれなければ、その計算は無意味になります。

自分の発行したブロックに別のブロックが続くことを、暗号通貨の世界では承認を得ると呼びます。ブロックに含まれるトランザクションが正しいこと、ハッシュ値として参照するブロックに不正がないことを積極的に確認し、かつ自分が参照するブロックがその時点で最長でないとおそらく計算が無駄になるため、ブロックが続けばもとのブロックが承認されていると見なせるからです。

このように、インセンティブを得たいという欲望にまみれた全世界の計算量の投入により、不正が生まれる確率を極端に減らしているのです。

AliceがBobとCarolのどちらに送金したことになるのかは、そのトランザクションが含まれたブロックに多くブロックが積み重なれば、それが覆されることはまずなくなります。

例えば、1024ブロック目でAliceがBobに10BTCを送金したトランザクションが含まれているなら、1025ブロック目くらいではまだ覆る可能性があります。ただし、1026、1027と続いていくと覆せる確率は減っていき、歴史は収束することになります。

Bitcoinの取引には時間が掛かるという問題があります。オルトコインと呼ばれるBitcoin以外の暗号通貨では難易度を下げて、もっと短いスパンでブロックが発行されるようにしていますが、それは不正ができる確率が上がることを意味していて、実際にMonacoinではそのような不正により取引所が被害を受けています。

既存の分散合意技術では、こんな莫大な計算量が必要になるようなアルゴリズムを採用しません。ですが、暗号通貨では、ビザンチン将軍問題と呼ばれる、他のノードをだまして金をかすめ取ろうとする悪意のあるノードに対応するため、このようなアルゴリズムが必要になるのです。

ブロックチェーンを作ってみよう

理屈の説明はここまでとして、実際にブロックチェーンを作ってみましょう。ここで挙げるサンプルはNode.jsでの実行を前提としており、LTSの最新版(執筆時点で10.13.0)で確認をしていますが、それ以前のバージョンでも動くでしょう。工程は以下のとおりです。

  1. ハッシュ値をとる
  2. パケットを作成する
  3. トランザクションのパケットを作成する
  4. ブロックを作成する
  5. デシリアライズする
  6. トランザクションを検証する
  7. ブロックを検証する
  8. Nodeクラスを作る

1. ハッシュ値をとる

Node.js APIのcryptoパッケージにあるcreateHashという関数を使います。この関数の引数に、使いたいハッシュ関数の名前を文字列で指定すれば、ハッシュをストリームで処理できるオブジェクトが返ってきます。

const { createHash } = require('crypto')

const sha256s = buf => {
  const hash = createHash('sha256')
  // hash はハッシュ値を扱えるストリームオブジェクト
  hash.write(buf)
  return hash
    .digest()
    .toString('hex')
    .substr(-40)
}

hash.write()の引数にハッシュ値に含めたいデータを書き込みます。hashはストリームオブジェクトなので、hash.write()を複数回に分けて実行できるため、大きなサイズのデータを分割処理できます。ただし、今回のような目的ではそのような使い方をしません。

hash.digest()によって、ハッシュ値を納めたバイナリデータがBuffer型として帰ってくるので、扱いやすいようにtoString('hex')で16進数文字列に変換します。

最後にsubstr(-40)しているのは、文字列の先頭40文字以外を切り捨てているためです。

今回はブロックチェーン構造を作るという実験のため、256bit(16進数文字で64文字)という長さは不要なので省略しています。しかし、きちんとブロックチェーンを構築する場合は、切り捨て処理をしません。

2. パケットを作成する

P2Pのブロックチェーンネットワークでは、トランザクション、ブロックあるいは他のデータをやりとりするためにシリアライズすると都合がよいので、まずはシリアライズする関数を作成します。

const serialize = (type, data) => JSON.stringify({ type, ...data })

JSON.stringifyは引数に指定したデータをJSON文字列に変換する関数で、JavaScriptの標準機能です。typeはパケットのタイプを指します。本稿では、トランザクションならtxで、ブロックならblockとします。

const createPacket = (type, data, rawdata = data) => {
  const serialized = serialize(type, data)
  const hash = sha256s(serialized)
  return { type, serialized, hash, ...rawdata }
}

まず、typedataをもとにシリアライズし、シリアライズされた文字列serializedをもとにハッシュ値hashを計算します。createPacketは、シリアライズされた文字列とそれ以外のデータを効果的に管理するための関数です。

rawdataは何のためにあるのでしょうか? これはデータの管理上、シリアライズされる前のデータを保持していた方が楽だからという理由です。ブロックを作成するときに意味がでてきます。

関数の引数としてrawdata = dataは初期値です。3つ目の引数を指定しなければ、2つ目の引数がそのまま使われます。

...rawdataは、オブジェクトの中身をここの変数として展開するというオブジェクトスプレッド構文です。JavaScriptの言語仕様の最新版であるECMAScript 2018で追加され、とても便利です。

3. トランザクションのパケットを作成する

本稿では、トランザクションには、送信元アドレス・送信先アドレス・金額だけを書き込むようにします。

const createTx = (from, sendTo, amount) => {
  const data = { from, sendTo, amount }
  return createPacket('tx', data)
}

さて、トランザクションの特殊な形としてコインベーストランザクションがあると書きました。コインベーストランザクションでは、送信元がnullで、金額が50固定という形にします。

const createCoinbaseTx = sendTo => {
  return createTx(null, sendTo, 50)
}

4. ブロックを作成する

ブロックの生成には最低限、トランザクションの集まりと、前のブロックのハッシュ値が必要になります。

const createBlock = (txs, prevHash) => {
  const data = {
    txs: txs.map(tx => ({ hash: tx.hash, data: tx.serialized })),
    prevHash
  }
  const rawdata = { txs, prevHash }
  return createPacket('block', data, rawdata)
}

このtxsは、すでにパケットにしたものの配列です。個々の要素txには、createPacket関数で作成したtypehashserializedなどが納められています。

txs.map配列の中身を加工するmapメソッドです。引数に関数を指定すると、要素の1つずつを加工できます。

const data =で、txsメンバーにはトランザクションのシリアライズされたデータとトランザクションのハッシュ値、prevHashとして前のブロックのハッシュ値をまとめたデータを作成しています。

createPacketで、rawdataはトランザクションのシリアライズされる前のデータを保持するために使われています。これにより、ブロックからダイレクトにトランザクションの中身sendToなど)にアクセスできるのです。

ここまではパケットの作成に必要な関数を作ってきましたが、パケットを受け取って内部データとして扱うための処理も必要になります。

5. デシリアライズする

まずは、シリアライズされたデータをJavaScriptのデータ・オブジェクトに変換(デシリアライズ)できる関数を作成します。

const deserialize = serialized => {
  const hash = sha256s(serialized)
  const rawdata = JSON.parse(serialized)
  return { type: rawdata.type, serialized, hash, ...rawdata }
}

JSON.parse関数は、JSON文字列としてシリアライズされたデータを、JavaScriptのオブジェクトに変換するものです。もしJSONの仕様として正しくないデータであれば例外が生じるので、真面目に書くならtry/catch構文などを使う必要があるでしょう。

sha256s関数でシリアライズされた文字列のハッシュ値をとり、JSON.parsetypeや生のデータを取り出し、createPacketで作成されるのと同じデータを作成しています。

6. トランザクションを検証する

P2Pブロックチェーンネットワークでは、受け取ったデータが本当に正しいのか? という検証が必須です。そこでトランザクションを検証する関数を作成します。

検証はtxs.every()というメソッドで行います。配列のeveryメソッドは、map関数のように要素それぞれについて関数を呼び出し、その関数がすべてtrueを返してきた場合のみtrue、それ以外だとfalseを返すようにしています。検証に成功するのはtrueが返ってきたときだけです。falseなら失敗したということです。

const validateTxs = txs => {
  const wallets = {}

  return txs.every(({ from, sendTo, amount }) => {
    if (!from) {
      if (amount !== 50) {
        return false
      }
    } else {
      wallets[from] = (wallets[from] || 0) - amount
      if (wallets[from] < 0) {
        return false
      }
    }

    wallets[sendTo] = (wallets[sendTo] || 0) + amount
    return true
  })
}

今回作成しているプログラムの仕様として、コインベーストランザクションfromがnullでamountが50のもの)によってコインが生じて、トランザクションではコインを送金するだけのものとしています。

そこで、アドレスごとのコインのやりとりを追いかけると、トランザクションが正しいかどうかの検証ができます。

walletsは全員のウォレット(財布、wallet)を表現したオブジェクトです。wallets['hoge']で、hogeというアドレスの人の残高にアクセスします。

ここではまず、if (!from)fromがnullかを確認します。nullの場合、amountが50のものだけが正しいので、それ以外は検証が失敗します。

fromが空ではない場合は通常のトランザクションなので、送信元の残高からトランザクションの金額を引きます。(wallets[from] || 0)はJavaScriptなどスクリプト言語でよくあるイディオムです。論理演算子||では、||の前がtrueとして判断できる場合は前の値がそのまま使われ、そうでない場合は、後者がそのまま使われるというものです。

つまり、wallets[from]にまだ何も入っていない、一番最初の状態では0が採用されるというものです。こうして口座残高を減らしてみてマイナスになれば検証失敗です。

ここまでで検証が失敗しなければ、wallets[sendTo]の金額を増やします。この手順で一通りのトランザクションをチェックして、エラーが生じなければ検証は成功です。

7. ブロックを検証する

トランザクションの検証も重要ですが、それ以上にブロックの検証が重要です。

ブロック検証の関数は、引数にシリアライズされたブロックのパケットの配列を取る仕様にします。このブロックのパケットをdeserializeすると、txsprevHashというそれぞれのデータが出てきます。

const createInvalidBlockError = message =>
  new Error(`Invalid Block: ${message}`)

const validateBlocks = serializedBlocks => {
  let allTx = []
  let prevHash = '0000000000000000000000000000000000000000'

  serializedBlocks.forEach(serializedBlock => {
    const { data, hash } = deserialize(serializedBlock)
    if (prevHash !== data.prevHash) {
      throw createInvalidBlockError(
        `block hash error: ${prevHash} !== ${data.prevHash}`
      )
    }
    prevHash = hash

ここまでのコードで、前のブロックであるprevHashの検証を行っています。このプログラムではgenesis hashを0000000000000000000000000000000000000000にしているため、let prevHash =で初期化しています。あとはブロックをループで処理しながら、prevHashの判定と更新を行います。

const txs = data.txs.map(serialized => {
  const tx = deserialize(serialized).data
  if (tx.type !== 'tx') {
    throw createInvalidBlockError(`Tx packet type error: ${tx.type} !== tx`)
  }
  return tx
})

deserializeされたブロックに含まれるtxsは、トランザクションをシリアライズしたパケットなので、さらにdeserializeする必要があります。このとき、txsにパケットタイプとしてtx以外が含まれていればエラーとしています。

if (txs.length < 1) {
  throw createInvalidBlockError('Empty Txs')
}

txsの長さが1未満、つまり0であれば何もトランザクションが含まれておらず、エラーとしています。

if (!isCoinbaseTx(txs[0])) {
  throw createInvalidBlockError('first Tx must be CoinbaseTx')
}

最初のトランザクションは、必ずコインベーストランザクションです。

if (txs.length > 1 && !txs.slice(1).every(tx => !isCoinbaseTx(tx))) {
  throw createInvalidBlockError('Illegal CoinbaseTx')
}

コインベーストランザクション以外のトランザクションがある場合、それらはすべてコインベースじゃない通常のトランザクションです。

    allTx = allTx.concat(txs)
  })

  evaluateTxs(allTx)
}

ここまで一通りチェックすれば、ブロックの検証は完了です。

allTxは、トランザクションをすべて連結したもので、さっき作ったevaluateTxsでさらにトランザクションの検証を行います。

8. Nodeクラスを作る

今回はP2P通信するコードまでは作りませんが、各ノード(ピア)を実験するためのクラスを作ります。

class Node {
  constructor(seed = null) {
    this.address = sha256s(seed || randomBytes(32))
    this.pendingTxs = []
    this.blocks = []
    this.peers = []
    this.prevHash = '0000000000000000000000000000000000000000'
  }

Nodeクラスのメンバーには、送金用のラベルであるaddressと、ブロックにまだ入っていないpendingTxsと、既に発行されているblocksと、接続一覧であるpeersと、prevHashを持ちます。

  _getAllTx() {
    return [].concat(
      ...this.blocks.map(block => {
        return deserialize(block).data.txs.map(tx => deserialize(tx).data)
      }),
      this.pendingTxs.map(tx => deserialize(tx).data)
    )
  }
  getBalance(address = this.address) {
    const txs = this._getAllTx()
    const wallets = evaluateTxs(txs)
    return wallets[address] || 0
  }

まずは、現時点での各アドレスの残高を確認するgetBalanceメソッドです。引数省略時には自分自身の残高を返します。

_getAllTxは、this.blockに含まれるトランザクションとthis.pendingTxsをすべてdeserializeして1つの配列に入れるものです。JavaScriptでは慣習として、_で始まるメンバーはプライベート扱いにするというものがあります。TypeScriptであれば、private修飾子が使えます。

evaluateTxsはトランザクションの検証で、各ウォレットの資金移動を順に追いかけているため、最終結果は各アドレスの残高ということになります。まだ送信されていないアドレスの場合は、先ほど紹介した||によるイディオムを使って0を返します。

  generate() {
    const coinbaseTx = createCoinbaseTx(this.address)
    const txs = [coinbaseTx.serialized, ...this.pendingTxs]
    const block = createBlock(txs, this.prevHash)
    this.pendingTxs = []
    this.prevHash = block.hash
    this.blocks.push(block.serialized)
    this.broadcast(block.serialized)
  }

generateメソッドは、マイニングが成功したということにしてブロックを生成します。

ブロックに含まれるトランザクションは、自分宛のコインベーストランザクションと、this.pendingTxsです。

ブロックを作成したら、this.pendingTxsを空に戻してthis.prevHashを更新し、this.blocksにブロックを追加し、後ほど説明するbroadcastメソッドで他のノードにブロックを流します。

  send(sendTo, amount) {
    if (amount > this.getBalance()) {
      throw new Error('Wallet error: Insufficient funds')
    }
    const tx = createTx(this.address, sendTo, amount)
    this.pendingTxs.push(tx.serialized)
    this.broadcast(tx.serialized)
  }

sendメソッドで送金を行います。残高が足りない場合はエラーになります。

  connect(peer) {
    this.peers.push(peer)
    peer.peers.push(this)
  }

connectメソッドは、別のノード(ピア)をリストに追加し、相手のピアに自分を登録します。

  broadcast(packet) {
    this.peers.forEach(peer => {
      peer.recv(packet)
    })
  }

broadcastメソッドは、接続しているピアすべてのパケットを送信します。実際のコードとしては、相手のrecvメソッドを叩いているだけです。

  recv(packet) {
    const { type } = deserialize(packet).data
    switch (type) {
      case 'tx': {
        this._receiveTx(packet)
        break
      }
      case 'block': {
        this._receiveBlock(packet)
        break
      }
    }
  }

recvメソッドでは、受け取ったパケットのtypeを見て、txblockで処理を分けています。

  _receiveTx(packet) {
    const { from } = deserialize(packet).data
    if (from === null) {
      throw new Error('Invalid Tx: reject CoinbaseTx')
    }
    const txs = this._getAllTx()
    txs.push(deserialize(packet).data)
    evaluateTxs(txs)

    this.pendingTxs.push(packet)
  }

トランザクションの場合、まず受け取ったトランザクションがコインベースならエラーとします。実際のP2Pプログラムの場合、recvがエラーをthrowするのは良くないため、ログに残しつつパケットを無視するという挙動になるでしょう。

トランザクションをすべて検証してエラーが出なければ、this.pendingTxsにパケットを追加します。

  _receiveBlock(packet) {
    const blocks = [...this.blocks, packet]
    validateBlocks(blocks)

    this.blocks.push(packet)
  }

ブロックの場合も、新しいブロックを加えてvalidateBlocksで検証してエラーが出なければ、this.blockにパケットを追加します。

Bitcoinウォレットを作ってみよう

最後に実際のBitcoinのウォレットプログラムを作ってみましょう。

Bitcoinの通信全部を実装するのは大変なため、Bitcoin公式ウォレットのBitcoin Coreを動かして、JSON-RPC経由で制御するというパターンでやります。

mkdir bitcoin-wallet
cd bitcoin-wallet

今回のプログラムは「bitcoin-wallet」という名前で作ります。大まかな手順は以下のとおりです。

  1. パッケージのインストールと起動
  2. 関数を作成
  3. Walletの作成

Bitcoin Coreをインストールする

macOSでパッケージ管理システムHomebrewを使っていれば、bitcoindパッケージをインストールするだけです。

brew install bitcoind

bitcoindを起動する

データ用のディレクトリを作成しておいて、bitcoindを起動します。

$ mkdir data
$ bitcoind -testnet -datadir=data -txindex -server -rpcuser=u -rpcpassword=p

bitcoindを起動するときの注意点として、-testnetでテスト用のネットワークに接続しましょう。testnetでは、Bitcoin testnet3 faucetのようなサイトで、無料でtestnet専用のBitcoinを受け取れます。このコインを使って、実際のネットワーク上でのテストをするのです。

-datadir=dataでデータディレクトリの位置を指定します。ちなみに、現時点で26GBもの容量を消費するので、テストを終えて不要になったら削除するのをおすすめします。

JSON-RPCでは、ユーザー名やパスワードをコマンドラインもしくは設定ファイルで定義しますが、他人に見られる可能性があるものとして注意してください。

また、本運用で使う場合、ウォレットのパスワードロックなど、気をつけないといけないことがあります。

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

npm init -y
npm i request-promise

今回は、JSON-RPCを叩くために、request-promiseというnpmパッケージを使います。

検索すれば、Bitcoin CoreのJSON-RPCを叩くためのパッケージがいくつか見つかりますが、どれも古く、メンテナンスもされていません。そもそも自前で叩いてもそんなに長いコードにならないため、今回は自作してしまいます。

Bitcoin Coreを叩く

JSON-RPCでは、HTTP POSTでmethodとparamsというそれぞれのパラメータを送りつけます。methodは、helpgetnewaddressなどの名前を持ちます。

ちなみにBitcoinでの開発では、bitcoin-cliというCLIでBitcoin Coreを制御するコマンドを叩くのが定番ですが、やっていることはまったく同じです。

const rp = require('request-promise')

rp(`http://localhost:18332`, {
  method: 'POST',
  body: JSON.stringify({ method, params }),
  auth: { user, pass }
})

最近のJavaScriptでは、非同期処理はPromiseが定番です。rp関数を叩くとPromiseが返ってきます。

rp(...).then(response => console.log(response))

Promiseではthenメソッドを叩くことで非同期処理の続きを書くことができます。つまり、request-promiseの場合、HTTP POSTを実行したあとthenの中身が実行されます。

thenの戻り値はPromiseオブジェクトなため、さらにthenをつなげられます。

ここでは、さらに一歩踏み込んで非同期処理のasync/awaitを使ってみます。

const dispatch = async (user, pass, method, ...params) => {
  const { result, error } = JSON.parse(await rp(...))
}

async宣言された関数の中では、awaitというキーワードをつけることでPromiseを同期的に扱えます。具体的にはawaitのあとに続くコードは、thenの中身が呼び出されるまで待機します。

const { result, error } = JSON.parse(await rp())というコードは、rp().then(({ result, error }) => {....})と同じ意味を持ちます。

thenをひたすら連鎖させるのはそれなりに面倒ですが、awaitを並べるだけなら書きやすく、理解しやすいコードになります。

Promisethenだけではなく、catchというメソッドも持ちます。これはエラー時のリカバリーをどうするかというものです。

今回の事例では、HTTPの接続にエラーが生じた、あるいはJSON-RPCでmethod名が正しくないなどのエラーが生じた場合の処理が必要になります。

catch(e => {
  if (e.statusCode) {
    return JSON.stringify({ error: JSON.parse(e.error).error })
  } else {
    return JSON.stringify({ error: e.error })
  }
})

通信自体は成功するものの、Bitcoin JSON-RPCのプロトコル的な問題があるときにはe.statusCodeに500などがセットされています。また、その場合、e.errorにJSONでエンコードされたエラー情報が入っているという微妙にややこしいことになっています。

クライアントを作る関数を作る

さきほどまでのコードでは、接続先がハードコーディングされていることと、dispatch関数で、毎回userpassを指定しています。

const createClient = ({ host, rpcport, user, pass }) => {
  // dispatch関数にあたるものを返す
}

そのため、hostrpcportuserpassという引数でまず初期化して、dispatchにあたる関数を返すようにします。

以下、client.jsで定義します。

const rp = require('request-promise')

const createClient = ({ host, rpcport, user, pass }) => {
  return async (method, ...params) => {
    const { result, error } = JSON.parse(
      await rp(`http://${host}:${rpcport}`, {
        method: 'POST',
        body: JSON.stringify({ method, params }),
        auth: { user, pass }
      }).catch(e => {
        if (e.statusCode) {
          return JSON.stringify({ error: JSON.parse(e.error).error })
        } else {
          return JSON.stringify({ error: e.error })
        }
      })
    )
    if (error) {
      throw error
    } else {
      return result
    }
  }
}

module.exports = { createClient }

ウォレットの作成

さて、ウォレットを作ります。コマンドラインで動くツールとして作るので、コマンドライン引数を扱います。

Node.jsでは、process.argvでコマンドライン引数にアクセスできます。 process.argv[0]にはNode.jsのコマンドそのものが入り、process.argv[1]にはスクリプト名が入ります。そのため、引数はprocess.argv[2]から始まります。

const { createClient } = require('./client')

const wallet = async () => {
  if (process.argv.length < 3) {
    console.log('usage: wallet <command> [option...]')
    process.exit(1)
  }

  const command = process.argv[2]
  if (!(command in commands)) {
    console.log(`unknown command: ${command}`)
    process.exit(1)
  }

  const conf = {
    host: 'localhost',
    rpcport: 18332,
    user: 'u',
    pass: 'p'
  }
  const cl = createClient(conf)
  await commands[command](cl, ...process.argv.slice(3))
}

wallet().catch(err => console.error(err))

process.argv.lengthが3未満の場合、usageを表示して終了します。また、引数で指定するcommandが、後ほど説明するコマンド配列にない場合も、unknown commandを表示して終了します。

client.jsで定義したcreateClientでクライアントを作成します。このとき、設定はtestnet向けに決め打ちで書いています。

ウォレットのコマンド定義

コマンドはこのように定義しています。

const newAddress = async cl => {
  const address = await cl('getnewaddress', 'my address')

  console.log(address)
}
// 中略
const commands = { newAddress, info, send, dump, importPriv }
newAddressコマンド
$ node src/wallet/cli.js newAddress
2N7wqmAYRhiDUXhnZKtMbWBDSeXG4ddYvSy

起動した直後は、ウォレットとして使うためのアドレスがありません。まずはnewAddressコマンドでアドレスを作成します。

const newAddress = async cl => {
  const address = await cl('getnewaddress', 'my address')

  console.log(address)
}

getnewaddressというJSON-RPCメソッドは、引数に文字列を渡すとラベルとして扱います。処理の都合上、ラベルはmy address決め打ちとします。

infoコマンド
$ node src/wallet/cli.js info
addresses: [2N7wqmAYRhiDUXhnZKtMbWBDSeXG4ddYvSy]
balance: 0
block height: 273905

infoコマンドで、自分の持っているアドレスと、残高、ブロック長情報を表示します。

const info = async cl => {
  const addresses = await cl('getaddressesbylabel', 'my address')
  const balance = await cl('getbalance')
  const blockchainInfo = await cl('getblockchaininfo')

  console.log(`addresses: [${Object.keys(addresses).join(', ')}]`)
  console.log(`balance: ${balance}`)
  console.log(`block height: ${blockchainInfo.blocks}`)
}

前述のmy addressというラベルは、getaddressesbylabelJSON-RPCメソッドで使っています。

getblockchaininfoは、Bitcoinネットワークの状況を知るためのJSON-RPCメソッドです。

sendコマンド
$ node src/wallet/cli.js send 2N6PaxqoUFbWYiumFq3i6nuQPQ1LG8VJrE1 0.04
bef4f254851ff8cff12c398978f45bb3d73c713c38238330f2869898e0547151

sendコマンドは、送金するコマンドです。送金に成功すれば送金トランザクションのIDが表示されます。

トランザクションを見ることができるサイト、例えばBlockCypherのBitcoin Testnet Block Explorerで、確認できます。

const send = async (cl, address, amount) => {
  const txid = await cl('sendtoaddress', address, amount)
  console.log(txid)
}

sendtoaddressJSON-RPCメソッドで送金を行います。

dumpコマンド
$ node src/wallet/cli.js dump 2N6PaxqoUFbWYiumFq3i6nuQPQ1LG8VJrE1
<プライベートキー>

dumpは、自分の持っているアドレスに対応する秘密鍵を出力するコマンドです。この秘密鍵の文字列を他人に見られると、Bitcoinを盗まれ放題なのでご注意ください。

const dump = async (cl, address) => {
  const priv = await cl('dumpprivkey', address)
  console.log(priv)
}

dumpprivkeyJSON-RPCメソッドは、指定したアドレスの秘密鍵がある場合に、それを得るものです。

importPrivコマンド
$ node src/wallet/cli.js importPriv <プライベートキー>

importPrivコマンドは、秘密鍵をインポートするものです。これができるため、秘密鍵を他人に知られてはいけないのです。

const importPriv = async (cl, priv) => {
  await cl('importprivkey', priv, 'my address')
}

importprivkeyJSON-RPCメソッドは、指定した秘密鍵をインポートします。オプションで第2引数にラベルを指定できます。

最後に

ここまで、ブロックチェーンの仕組みを解説し、実際にブロックチェーンやBitcoinウォレットのサンプルプログラムを見てきました。サンプルの全ソースは、GitHubの次のアドレスからダウンロードできます。ぜひ自分で試してみてください。

GitHub - erukiti/bitcoin-samples

erukiti(えるきち)erukiti erukiti erukiti erukiti

バックエンド・フロントエンド・一部インフラなどやったりしているフリーランスエンジニャー。メタプログラミングやブロックチェーンをやったり、それらの同人誌を書いたり、それを商業化したりしている。VSCode+TypeScript最高。
主な著書に、同人誌をNextPublishing(技術書典シリーズ)で商業化した「最新JavaScript開発」「JavaScript AST入門」がある。ほか同人では共著の合同誌に「ワンストップ!技術同人誌を書こう」「Alchemist Vol.1」「OneStop転職」があり、「ワンストップ!〜」制作のノウハウや知見を「本文212Pの分厚い薄い本の共同執筆を支える技術」や「コミケの技術評論島で2日間で200冊売った話する?」でも公開している。

編集:薄井千春(ZINE)