BitcoinプロトコルをRustでお話してみる(前編)

蟹 お知らせ
Table of Contents

この記事はBlockchain Advent Calendar 2019の13日目の記事です。

初めに

ご存じの方も多いと思いますが、先月の11/15にBitcoin Cash(BTC)のハードフォーク(HF)がありました。BTCは律儀に半年に一度HFすると決めていて、今回はそれほど大きな仕様変更はなかったものの、HFであることには変わりがありません。そのため、弊社(フレセッツ)のメンバも自社製品が本番稼働で万が一にも問題を起こさないためにBCHブロックチェーンの監視を行っておりました。実際、前回(2019年5月)のHF時にはちょっと問題があったため、万全の体制で望んでいたというわけです。

そんな中で、弊社エンジニアの一人が面白いツールを作りました。"ChainWatcher"と名付けたそのツールは、世の中に稼働しているBCHのフルノードサーバにアクセスして伝搬してきたブロックの情報をかき集めてくるというものです。通常時には、時折分岐をしながらもPoW (Proof of Work)の原則に則って一本のチェーンが全てのノード間で共有されます。ところが、HF時にはPoW的には正義でも仕様的にはダメなブロックが伸びてしまう可能性があります。そういった状況が起きていないかを観察するためのツールになります。

そして彼はそのツールをPythonで書いたわけです。ちなみに弊社の製品開発は基本は TypeScriptとScalaでちょっとしたツールとかはTypeScriptで書くことが多いのですが、今回はなぜかPython。理由を聞いてみたら「たまには別の言語も良いかと思い」(意訳)。まあ確かに勝手知ったる言語で書くのは楽なんだけどたまには少し羽根を伸ばしてみたい。その気持はわかります。

で、そのコードを眺めながら「ここはこう書いたらスッキリするかもな」とか思っていたわけです。というのも、TypeScriptとScalaの会社にいながら、私も一つなにかクイックにかけと言われたらPythonを選ぶ人なのです。Qiitaで書いている記事もPython関連が一番多い。ただなんかそれをするのも癪に障るというか、どうせなら自分も別のチャレンジをしてみたいなと思い、そのツールを Rustで書き換えてみようかなと思い立ったのでした。この記事ではその過程をつらつらと書いていきます。

なお、Qiitaではこんな記事も書いていたりしますが、Rust初心者です(^^; 色々と勘違いしちゃっているところもあるかもですがご容赦を。

Bitcoin プロトコル

Bittcoinのブロックチェーン情報は bitcoind というサーバ(ノードと呼ばれる)経由でやり取りされています。p2p(peer-to-peer)でノード間で直説情報をやり取りして、全てのノードで同じ情報を保持できるようになっています。そのためのプロトコルがここで定義されていたりします。

中身はよくあるバイナリのデータフォーマットで、最初にマジックナンバー(フォーマットの識別子)があり、その後に、コマンド、ペイロードの長さ、チェックサム、そしてペイロードそのものが続きます。

そしてコマンドには addr (接続しているノードに関する情報を伝達)とか inv (取得したデータに関する情報を伝達)とかgetdata (データの詳細を要求)などがあります。これらを使いながら、

「おれ、こんな友達知っているんだけど、興味あったら連絡してみて」とか、
「こんなデータを貰っちゃった。どうどう?」とか、
「お、それいいじゃん。詳細教えて!」とか、

とお話しながらブロックチェーンの情報をやり取りしてます。この仕組みを使ってインターネット上にあるフルノードのサーバから情報をかき集めたいと思います。

処理の大まかな流れ

「インターネット上のフルノードから情報を集めてくる」と一言で言っても、そこに至るには幾つかステップがあります。大まかに書くとこんな感じになります。

  1. 繋ぎに行くサーバの情報を取得する
  2. サーバと接続して最初のご挨拶
  3. 相手が生きているかどうか、定期的にチェック
  4. ブロックが到達したという情報を待つ
  5. 「到達した」という情報が来たら、さらに詳しいブロック情報をリクエストしゲット!

もう少し詳しくシーケンス図で書くとこのようになります。

これらのステップをコードを交えて解説していきます。ただ、少し分量が多くなってしまったので今回はステップ3までをカバーし、残りはまた後編ということで別途書きたいと思います。

繋ぎに行くサーバの情報を取得

上で書いたようにBitcoindはp2pで多くのノードと接続して情報を取得してきます。そして繋がっている相手からは「俺、このノード知っているよ」という風に他のノードの情報を教えてくれたりします。それでそこにも繋に行き、更にそこからまた別のノードの情報を得たりします。友が友を呼ぶ的な感じですね。

では、一番最初はどうすればよいのか? 立ち上がった直後は誰も友達がいないはずです。それを解決するために、DNS Seedという仕掛けがあります。これは何かというと、幾つかのドメイン付きホスト名(FQDN: fully qualified domain name)が用意されていて、そのホスト名のIPアドレスをDNSで引くと、あら不思議。接続可能なノードのIPアドレスが返ってきます。

試しにdigというプログラムを使って確認してみます。DNS Seedとして知られているホストの一つ seed.bitcoin.sipa.beを使います。

$ dig seed.bitcoin.sipa.be

; <<>> DiG 9.10.6 <<>> seed.bitcoin.sipa.be
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 15614
;; flags: qr rd ra; QUERY: 1, ANSWER: 25, AUTHORITY: 1, ADDITIONAL: 3

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;seed.bitcoin.sipa.be.      IN  A

;; ANSWER SECTION:
seed.bitcoin.sipa.be.   618 IN  A   81.147.157.223
seed.bitcoin.sipa.be.   618 IN  A   87.121.37.156
seed.bitcoin.sipa.be.   618 IN  A   185.145.129.155
seed.bitcoin.sipa.be.   618 IN  A   206.54.209.61
seed.bitcoin.sipa.be.   618 IN  A   124.57.198.211
seed.bitcoin.sipa.be.   618 IN  A   34.205.147.134
seed.bitcoin.sipa.be.   618 IN  A   194.5.159.197
seed.bitcoin.sipa.be.   618 IN  A   45.116.164.195
seed.bitcoin.sipa.be.   618 IN  A   3.0.50.189
...  (割愛しますが 25のアドレスが返ってきます)

パッと見ると一見、seed.bitcoin.sipa.beのフロントが沢山用意されていてDNSラウンドロビンしているようにも見えるのですが、IPアドレスがバラエティに富んでいて、実際 whoisで調べてみると、UKとかブルガリアとかシンガポールとか出てきます。つまり、ここに並んでいるのは世界中に点在するBTCのノードのリストです。

で、いよいよRustのプログラミングに入ります。DNSルックアップには dns-lookup クレート(Rustのライブラリモジュール)を使います。Cargo.toml[dependencies]セクションに

[dependencies]
dns-lookup = "1.0.1"

と入れて、コードはこんな感じ。

extern crate dns_lookup;
use dns_lookup::{getaddrinfo, AddrInfoHints, SockType};

pub fn main() {
    let hostname = "seed.bitcoin.sipa.be";
    let service = "8333";
    let hints = AddrInfoHints {
        socktype: SockType::Stream.into(),
        .. AddrInfoHints::default()
    };
    let addr_info_list = getaddrinfo(Some(hostname), Some(service), Some(hints))
        .unwrap().collect::<std::io::Result<Vec<_>>>().unwrap();

    for addr_info in addr_info_list {
        println!("{}", addr_info.sockaddr)
    }
}

libcのgetaddrinfoを使ったことがあれば呼び出し方はほぼそのままであることがわかります。hostnameserviceパラメータにはそれぞれseed.bitcoin.sipa.beとBitcoind mainnetの標準ポートである 8333 を指定します。そして hintsとしてSockType::Streamを指定して結果をTCP接続に関する情報だけに絞るようにします。

これを、getaddrinfoに渡し、出てきた結果をエラー処理とかをぶっ飛ばして全部 unwrap()した結果が addr_info_listというリスト(実際にはVec型)に入ります。これを順番にプリントアウトして表示するだけのプログラムです。結果はこのような形になります(長いので一部割愛しています。)。

91.226.212.39:8333
54.226.19.210:8333
61.69.254.98:8333
31.21.184.95:8333
195.154.187.6:8333
....
[2001:982:27f2:1:7271:bcff:fe94:d5bb]:8333
[2400:8902::f03c:91ff:fea5:ebb7]:8333
[2001:470:a068::2]:8333
[2001:678:7dc:8::2]:8333
[2a01:4f9:2b:2d5d::2]:8333
...

これを見るとIPv4だけでなくIPv6のアドレスも返ってきているのがわかりますね。

サーバーと接続して最初のご挨拶

さて、繋先がわかったら早速接続するのですが、繋がったら何をすれば良いでしょう?ご挨拶ですね。

いや、冗談ではなく、Bitcoinのノードは繋がったらまず互いに自分の情報を相手に送り、情報を受け取ったら「受け取ったよ」という返事をします。前者が Version メッセージで後者が Verack メッセージになります。

Versionメッセージでは自分が話せるプロトコルバージョンや利用可能なサービスのリスト、ユーザーエージェント文字列などを送ることができます。そして送り先の方はこれを受け取ることによってこれからお話をする相手がどんな素性なのかを知ることができるということです。もし相手の話すプロトコルバージョンが自分よりも低ければそれに合わせたやり取りをすることになります。なお、現在のbitcoindがサポートしている最新のプロトコルバージョンは 70015 ですが、それ以前のバージョンについてはこちらを参照して下さい。

これをRustで実装してみましょう。Bitcoin プロトコルのメッセージはバイナリフォーマットで、その仕様に従ったメッセージのパースやメッセージの構築が必要になります。実は、Scalaを使った実装に関して他のメンバが既に弊社ブログに書いている(「Bitcoin の P2P に Scala で通信してみる(前編)」)のでご興味があればそちらも眺めてみて欲しいのですが、ここでは少し横着をしてそのあたりの処理をやってくれるクレートを利用して実装してみます。

今回使ったのが bitcoinというそのまんまな名前のクレートですが、これを使うとお手軽にメッセージのパースや構築ができます。とは言え、ドキュメントがあまりなく試行錯誤しながらだったので思ったより時間がかかってしまいました。意外と役に立ったのがテストコードで、他のクレートとかでも使い方がわからない時に見ると良いかもです。

その他、TCP接続を行ったり乱数生成したり時刻の変換をしたりするので、依存関係を

[dependencies]
...
bitcoin = "0.21"
rand = "0.7.2"
chrono = "0.4.10"

という感じで追加してから、それぞれのクレートを使う宣言をしておきます。

use std::net::TcpStream;
use std::io::BufReader;
use std::io::Write;

use std::thread;
use std::time::Duration;

extern crate rand;
use rand::Rng;

extern crate chrono;
use chrono::Utc;
use chrono::TimeZone;

extern crate bitcoin;
use bitcoin::consensus::encode::serialize;
use bitcoin::consensus::encode::Error;
use bitcoin::network::constants::Network;
use bitcoin::network::address::Address;
use bitcoin::blockdata::block;
use bitcoin::network::message::{RawNetworkMessage,NetworkMessage};
use bitcoin::network::message_network::VersionMessage;
use bitcoin::network::message_blockdata::Inventory;
use bitcoin::network::message_blockdata::InvType;
use bitcoin::network::stream_reader::StreamReader;
use bitcoin::BitcoinHash;

そしてまずはVersionメッセージですが、bitcoin::network::message_network::VersionMessagenewメソッドがあるので、ペイロード(中身)はそれを使えば簡単に作れます。

let mut rng = rand::thread_rng();
let sock_addr = "0.0.0.0:8333".parse::<SocketAddr>().unwrap();
let version_msg = VersionMessage::new (
    1,
    SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs() as i64,
    Address::new(&sock_addr, 1),
    Address::new(&sock_addr, 1),
    rng.gen(),
    "/ChainWatcher:0.0.01/".to_string(),
    -1,
);

Rustはキーワード引数的なのが無いのでちょっとわかりにくいですが、定義は

pub fn new(
    services: u64,
    timestamp: i64,
    receiver: Address,
    sender: Address,
    nonce: u64,
    user_agent: String,
    start_height: i32
) -> VersionMessage

となっていて、それぞれに適当な値を入れればOK。

そして、この version_msg を送るには、まずTCPStreamを作ってノードと接続する。そして、 RawNetworkMessage型にペイロードを収めて、それをシリアライズしたものをそのストリームに載せて送るという段取りになります。

pub fn main() {
    ....

    let host = addr_info_list[0].sockaddr.to_string(); // 取り合えず1つ目のノードに送ってみる
    let stream = TcpStream::connect(host).unwrap();
    let mut writer = BufWriter::new(&stream);

    let raw_message = RawNetworkMessage {
        magic: 0xd9b4bef9,
        payload: NetworkMessage::Version(version_msg),
    };
    writer.write_all(&serialize(&raw_message)).unwrap();
    writer.flush.unwrap();
}

ここでRawNetworkMessageを作る時に指定しているmagicですが、プロトコルメッセージのマジックナンバーで通貨やネットワークによって違う値が指定されます。例えば上の例ではBTC mainnetなので 0xd9b4bef9ですが、テストネットだと0x0709110bだったり、BCH mainnet では0xe8f3e1e3になったりします。

さて、ご挨拶のメッセージを送るだけではなく相手から送られたメッセージを受け取ってその返事もしなければなりません。メッセージの受信には bitcoin::network::stream_reader::StreamReaderを使います。接続したノードと接続したtcpストリームを引数にStreamReader を作成し、read_next()メソッドを繰り返し呼んで「次のメッセージ」を取りながら処理を進めます。

pub fn main() {
    ....

    let mut reader = StreamReader::new(BufReader::new(&stream), None);
    loop {
        let message: Result<RawNetworkMessage, _> = reader.read_next();
        let payload = message.unwrap().payload;
        match payload {
            NetworkMessage::Version(ref dat) => {
                println!("Peer version {} {}", dat.version, dat.user_agent);
                send_verack(&mut writer);
            },
            NetworkMessage::Verack => {
                println!("Get Verack");
                send_ping_task(&stream);
            },
            _ => {
                println!("{:?}", payload);
            }
        }
    }
}

先ほどのメッセージ構築とは逆にメッセージのパースをしますが、メッセージ型の判定はbitcoinクレートに任せ、ペイロードにはEnum型のNetworkMessageが返ってきます。それを match 構文でメッセージ型毎に振り分けて処理をしていきます。上の例ではVersionメッセージ、Verackメッセージを受け取った時の処理が書かれていて、それ以外は _ => { ... } に行き、ペイロードの内容をプリントアウトするだけ、というコードになっています。

RustのEnum型は面白くて、単純な列挙型というだけでなく、それぞれに値を持つことができます。しかも持つ値は型が異なっていても問題ない!今回のような、メッセージの型によってペイロードに持つ値が変わるデータ構造を表すにはうってつけです。

上記の例では、Version型のメッセージが来たらそのversionフィールドとuser-agentフィールドをプリントアウトしてVerackメッセージを送ります。また相手方からVerackメッセージが来たらそれが来たことをプリントアウトしてから、次で述べる ping のメッセージ送信タスクの作成を行います。

上の例では send__verack()という定義されていない関数を使っていましたが、それは以下のように書けます。メッセージを送るという操作はメッセージ型に依らず一緒なのでそれをsend_message()という形で括りだしていて、ついでにVersionメッセージを送るコードもそれを使うように関数化してリファクタリングしています。

fn send_message(writer: &mut BufWriter<&TcpStream>, payload: NetworkMessage) {
    let raw_message = RawNetworkMessage {
        magic: 0xd9b4bef9,
        payload: payload,
    };
    writer.write_all(&serialize(&raw_message)).unwrap();
    writer.flush().unwrap();
}

fn send_ver(writer: &mut BufWriter<&TcpStream>) {
    let mut rng = rand::thread_rng();
    let sock_addr = "0.0.0.0:8333".parse::<SocketAddr>().unwrap();
    let version_msg = VersionMessage::new (
        1,
        SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs() as i64,
        Address::new(&sock_addr, 1),
        Address::new(&sock_addr, 1),
        rng.gen(),
        "/ChainWatcher:0.0.01/".to_string(),
        -1,
    );
    send_message(writer, NetworkMessage::Version(version_msg));
}

fn send_verack(writer: &mut BufWriter<&TcpStream>) {
    send_message(writer, NetworkMessage::Verack);
}

相手が生きているかどうか、定期的にチェック

接続したらそこからの情報を待つのですが、相手がダウンしていてもわからないという問題があります。相手からブロックの情報が来れば「あ、生きているな」とわかりますが、来ない場合に、単にブロックの生成が遅れているのかあるいは相手がダウンしているかの区別がつかない(まあ、TCPの接続が切れればわかるんだけど)。BTCの場合ブロック生成間隔が約10分なのですが多少の前後があるので、例えば20分くらいしても来なければ「これはなんかおかしい」という事がようやくわかるということになります。

確認するのにはなんかメッセージを送ってみれば良いのですが、無駄に情報取得するのもアレなので死活監視の為のメッセージ型が用意されています。それがPingPongです。Pinguint64の乱数を送ってPongで受け取った値を返す、ただそれだけです。コードはこんな感じになります。

fn send_ping_task(stream: &TcpStream) {
    let stream = stream.try_clone().unwrap();
    let mut rnd = rand::thread_rng().gen();
    thread::spawn(move || {
        let mut writer = BufWriter::new(&stream);
        loop {
            println!("<- ping: {}", rnd);
            send_message(&mut writer, NetworkMessage::Ping(rnd));
            rnd += 1;
            thread::sleep(Duration::new(180, 0));
        }
    });
}

定期的に送るというタスクのため、別スレッドを作成し、そこで無限ループを回しています。送って、乱数値を一つインクリメントして一定時間(ここでは3分)寝るを繰り返します。Pingを送るとPongが返ってくるのでそれを受ける部分を作ります。加えて、相手側からもPingがやってくるのでそれを受けてPongを返すコードも追加するとこんな感じ。

fn send_pong(writer: &mut BufWriter<&TcpStream>, nonce: u64) {
    println!("<- pong: {}", nonce);
    send_message(writer, NetworkMessage::Pong(nonce));
}

pub fn main() {
    ....

    loop {
        let message: Result<RawNetworkMessage, _> = reader.read_next();
        let payload = message.unwrap().payload;
        match payload {
          ...

          NetworkMessage::Ping(dat) => {
                println!("-> ping: {}", dat);
              send_pong(&mut writer, dat);
          },
          NetworkMessage::Pong(dat) => {
              println!("-> pong: {}", dat);
          }
    }
}

これを実行するとこんな感じになります。

-> ping: 17865074641668153924
<- pong: 17865074641668153924
<- ping: 1638141400611630976
-> pong: 1638141400611630976

1行目で相手側から送られてきたPingに対して2行目でPongを返しています。3行目でこちらからもPingを送っていてそのPongの返りが4行目。死活監視という意味では、例えばPingを送って一定時間内に返答がなければその相手との接続を切る、という処理を入れる必要がありますがここでは割愛します。

終わりに

肝心のブロック情報を取るというところまで到達しませんでしたが、RustとBitcoinクレートを使ってBitcoinプロトコルをお話するお作法はなんとなく伝えられたのではないかと思います。また後編で残りのステップと、並行して複数のノードとお話する方法などについて解説できたら良いかなと思っています。

タイトルとURLをコピーしました