Symbol の REST に「このトランザクションある?」と聞くと、JSON で「あるよ」と返ってくる。でも、それはノードがそう言っているだけである。ノードが嘘をついていたら、こちらにはわからない。いや、そりゃそういうものなのはわかるんだけどさ。
なので、ノードの言うことを申し訳程度にできるだけ鵜呑みにせず、手元で計算し直して確かめてみた。ついさっき自作の heika の v0.6.0 で、REST の JSON をそのまま確かめられるようになったので、そのおためしである。
先に言っておくこと
これはまだまだトラストレスではない。
最後に1つだけ、ブロックのハッシュを信じる必要がある。そこから先は、全部手元の計算でつながる。言い換えると、「REST を丸ごと信じる」を「ブロックのハッシュ1つを信じる」まで小さくする話である。
信じるブロックのハッシュは、別のノードの REST、エクスプローラー、自分のノードなど、REST とは別のところと見比べればよい。2か所が口裏を合わせていない限りどうせ大丈夫そうということになる。
何を確かめるか
流れはこうなる。
- トランザクション。 JSON から catbuffer のバイト列を組み直して、署名を確かめ、ハッシュを計算し直す。REST が返したハッシュと合わなければ止める。
- ブロックのヘッダー。 同じくバイト列を組み直して、署名とブロックのハッシュを確かめる。ヘッダーの StateHash が、REST が返した9本の状態の木の根から計算し直したものと合うかも見る。
- inclusion proof。 トランザクションの Merkle の葉から、REST が返した経路をたどって、ヘッダーの TransactionsHash に着くかを確かめる。
- アカウントの状態。 アカウントの JSON から、ノードが状態の木に入れているバイト列を組み直してハッシュを取る。それが、状態の経路(Patricia の木)をたどって、最新のブロックのアカウントの木の根に着くかを確かめる。
3 と 4 が通れば、「このトランザクションはこのブロックに入っている」「このアカウントの今の状態はこれ」が、ブロックのハッシュだけから言えるようになる。やったぜぃ🎉
コード
依存はこれだけである。heika はまだ crates.io に出していないので、git のタグで指定する。
[dependencies]
heika-core = { git = "https://github.com/HinaBlogChannel/heika", tag = "v0.6.0" }
heika-rest = { git = "https://github.com/HinaBlogChannel/heika", tag = "v0.6.0" }
serde = "1"
serde_json = "1"
ureq = { version = "3.4.2", default-features = false, features = ["rustls"] }
heika-rest は serde の型と確かめる処理だけで、HTTP は持っていない。というか持たせてない。というわけで取ってくるところは ureq で書く。
use std::env;
use std::error::Error;
use heika_core::address::Address;
use heika_core::merkle::{self, MerklePathNode};
use heika_core::patricia::SubCache;
use heika_rest::block::BlockInfo;
use heika_rest::merkle::{MerkleProofInfo, MerkleStateInfo};
use heika_rest::state::AccountInfo;
use heika_rest::transaction::TransactionInfo;
use serde::de::DeserializeOwned;
type Result<T> = std::result::Result<T, Box<dyn Error>>;
fn get<T: DeserializeOwned>(origin: &str, path: &str) -> Result<T> {
let body = ureq::get(format!("{origin}{path}"))
.call()?
.body_mut()
.read_to_string()?;
Ok(serde_json::from_str(&body)?)
}
fn main() -> Result<()> {
let origin = env::var("HEIKA_REST")?;
let origin = origin.trim_end_matches('/');
let hash = env::args()
.nth(1)
.ok_or("トランザクションのハッシュを渡してね")?;
// 1. トランザクション:署名とハッシュを手元で計算し直す
let info: TransactionInfo = get(origin, &format!("/transactions/confirmed/{hash}"))?;
let transaction = info.verify()?;
let height = info.meta.height.as_deref().ok_or("高さがない")?;
println!("トランザクション {hash}: 署名 OK(高さ {height})");
// 2. ブロックのヘッダー:署名、ハッシュ、9本の根からの StateHash
let block_info: BlockInfo = get(origin, &format!("/blocks/{height}"))?;
let block = block_info.verify()?;
println!("ブロック {height}: 署名 OK、ハッシュ {}", block.hash());
// 3. inclusion proof:Merkle の葉から、ヘッダーの TransactionsHash までたどる
let proof: MerkleProofInfo = get(
origin,
&format!("/blocks/{height}/transactions/{hash}/merkle"),
)?;
let path = Vec::<MerklePathNode>::try_from(&proof)?;
let seed = &heika_core::network::NetworkParameters::from_identifier(transaction.network)
.ok_or("知らないネットワーク")?
.generation_hash_seed;
let leaf = transaction.merkle_component_hash(seed);
if !merkle::verify_path(&leaf, &path, &block.transactions_hash) {
return Err("Merkle の経路が TransactionsHash につながらない".into());
}
println!("ブロック {height} に入っている(経路 {} 段)", path.len());
// 4. 署名した人のアカウントの状態を、最新のブロックの状態の木でたどる
let address = Address::from_public_key(&transaction.signer_public_key, transaction.network);
let latest: serde_json::Value = get(origin, "/chain/info")?;
let latest = latest["height"].as_str().ok_or("高さがない")?;
let latest_info: BlockInfo = get(origin, &format!("/blocks/{latest}"))?;
let latest_block = latest_info.verify()?;
let account: AccountInfo = get(origin, &format!("/accounts/{address}"))?;
let state: MerkleStateInfo = get(origin, &format!("/accounts/{address}/merkle"))?;
let root = latest_info.sub_cache_merkle_roots()?[SubCache::AccountState.index()];
let state_hash = account.account.state_hash()?;
if state.prove(&account.account.tree_key()?, &root)? != Some(state_hash) {
// 取っているあいだにブロックが進むと根が変わるので、そのときはもう一度
return Err("状態の経路がつながらない(ブロックが進んだかも)".into());
}
println!("アカウント {address}: 高さ {latest} の状態に入っている");
println!();
println!("信じたのは、この2つのブロックのハッシュだけ:");
println!(" {height}: {}", block.hash());
println!(" {latest}: {}", latest_block.hash());
Ok(())
}
動かすときは、REST の URL を環境変数で渡して、トランザクションのハッシュを引数に置く。
HEIKA_REST=https://<REST のホスト>:3001 cargo run -q -- <トランザクションのハッシュ>
例に使ったのは、メインネットの高さ 689761 のトランザクションである。キプロスのハードフォークの高さで、このブロックには、設定(config-network.properties の treasury_reissuance_transaction_signatures)で名指しされた、コンセンサスで特別に通したトランザクションが入っている。誰でも知っているものなので、出力をそのまま貼れる。
トランザクション 49D878BCEF90E851B23601758915A7B3315CEA2F57A94007B49CA76102264ADE: 署名 OK(高さ 689761)
ブロック 689761: 署名 OK、ハッシュ 23366DD49B13FC104154ADAC03419C3D4837F0D7A80A05F46AD9DDBA9A4EB4B2
ブロック 689761 に入っている(経路 3 段)
アカウント NASYMBOLLK6FSL7GSEMQEAWN7VW55ZSZU25TBOA: 高さ 5861952 の状態に入っている
信じたのは、この2つのブロックのハッシュだけ:
689761: 23366DD49B13FC104154ADAC03419C3D4837F0D7A80A05F46AD9DDBA9A4EB4B2
5861952: FB9A4CD5A3B8DB08B5388BAB8255A042AEF781DC02D2E51F3AF641B9AAD4A018
通った!やったぜぃ🎉 経路の3段をたどって、ヘッダーの TransactionsHash にちゃんと着いている。署名したのも NASYMBOL… という、いかにも特別そうなアドレスで、特例のトランザクションでも手元の計算でブロックに入っていると言えた。
最初はネメシスでやって、こけた
本当は、当たり障りのないチョイスとして、最初のブロック(ネメシス)の最初のトランザクションで見せるつもりだった。ところが、トランザクションとブロックの署名は通るのに、Merkle の経路だけがつながらない。
しばらく悩んで思い出したのが、ネメシスだけは TransactionsHash の葉の出し方が違うことである。普通のブロックでは、葉はトランザクションの種類ごとの決まりで求める(署名する範囲を種類で切り、アグリゲートなら連署した人も混ぜる)。ネメシスを作る nemgen は、その決まりを使わずにトランザクションを丸ごとハッシュしている。なんかさみしい。
上のコードは普通のブロックの求め方しかしていないので、ネメシスでは通らない。……と思ったら、そう単純でもなかった。(追記)
あとで catapult のソースを追ってみると、REST が経路を出す木と、ヘッダーの TransactionsHash は、葉の出し方が違う別々の木の根だった。nemgen は、ヘッダーに署名するときは種類ごとの決まりを使わずに丸ごとハッシュするのに、ノードに保存する木を作るときは決まりを使っている。違いが出るのはアグリゲートの葉だけだが、ネメシスにはアグリゲートが入っている。4件で例えるとこうなる。
H(AB, C'D) ← ヘッダーの TransactionsHash
┌────┴────┐
H(AB) H(C'D)
┌─┴─┐ ┌─┴─┐
A B C' D
H(AB, CD) ← REST が経路を出す木の根
┌────┴────┐
H(AB) H(CD) ← ここの節が違う
┌─┴─┐ ┌─┴─┐
A B C D
さすが Claude 一発で絵がきれい✨
C がアグリゲートで、C' は中身も連署も丸ごとハッシュした葉、C はふつうの求め方の葉である。A の経路として REST が返すのは [B, H(CD)] で、A と B は合っているのに、H(CD) がヘッダーの木にはない節なので、たどると下の根に着いてしまう。
なので、葉の出し方をネメシスに合わせても、REST の経路ではネメシスは確かめられない。ノードはネメシスをファイルから読むだけで TransactionsHash を確かめないので、この食い違いは誰にも咎められずにそのまま残っている。ネメシスを確かめたいなら、トランザクションを全部取ってきて、自分で丸ごとハッシュした葉から根を組むしかない。
そういうものである。もーわたしったらどじっこなんだからー(棒読み)。
中で何をしているか
JSON からバイト列に戻す
REST の JSON は、ノードの中のバイト列をデータベースに入れて、それを JSON にしたものである。署名もハッシュもバイト列に対して計算されているので、確かめるにはバイト列に戻す必要がある。
ここが地味に面倒だった。数値は10進の文字列、ID は16桁の大文字の16進、アドレスは16進……と、項目ごとに形が違う。形は OpenAPI の例ではなく、catapult-rest のソースを読んで合わせた。OpenAPI の例は、署名が合わないものが混じっていたので使えなかった。
組み直したものが本当に合っているかは、同じブロックを REST の JSON とノードのバイナリの両方で取って、突き合わせて確かめた。
状態のハッシュ
アカウントの状態は、ノードが状態の木に入れるときのバイト列(バージョン2バイトと中身)の SHA3-256 である。ここで1つハマったのが、アカウントの形で、活動の記録(activityBuckets)がある大口のアカウントと、ない普通のアカウントで、バイト列の形が違う。
ネームスペースはさらに曲者で、REST はルートとその子を別々に返すのに、木に入っているのはルートごとにまとめたものである。heika-rest では RootNamespaceState::collect でまとめ直している。
最新の状態しか取れない
状態の経路は、最新のブロックの根に対してしか取れない。取っているあいだにブロックが進むと根が変わって、経路がつながらなくなる。上のコードでは失敗にしているが、本気で使うなら何回か取り直すとよい。もしかしてアーカイブノードなどなかったのか?Merkle Patricia Trieをもっているのに。
おわりに
ブロックのハッシュ1つを信じれば、REST の答えを手元で確かめられるようになった。
ブロックのハッシュを何で信じるかは、まだ決めていない。いくつかのノードに聞いて多数決にする、ファイナライズの証明をたどる、などが候補である。そのあたりは、また今度というかその今度が来る保証はない。どうせキリなさそうなんだもん。