自作の heika(Symbol の Rust のツールキット。試験的にノードも動く)の REST に、ブロックを出す口を付けた。トランザクションは PUT /transactions で REST から出せるのに、ブロックは出せない。それなら付けてしまえ、というだけの動機である。
付けたあとで、ふと思った。「よりよい分岐を持っていたら、これでどう出すんだろう?」。調べていったら、catapult(Symbol の本家のノード)では、ほかのノードから到達できないノードの分岐は、スコアで勝っていても捨てられることがあるとわかった。ついでに heika の穴も見つかったので直した。その話である。
PUT /blocks
先に、付けたものを簡単に。
PUT /blocks
Content-Type: application/json {"payload": "<ブロックの hex>"}
Content-Type: application/octet-stream <ブロックのバイト列そのまま>
- 受け取ったノードは、署名などを確かめてからチェーンに足し、つないでいるピアに流す
- 返すのは
202 Acceptedだけ。足せたかどうかは待たない(PUT /transactionsと同じ) - Content-Type が無いもの、ほかのものは
415 - 大きさは P2P の Push_Block と同じ 32 MiB まで
これで、ブロックを作るところとノードを分けられる。手元で組んだブロックを、curl で投げればいい。便利〜✨
……って思うでしょ? 思うよね? はい! ここでさっきの疑問である。
Symbol は「一番長いチェーン」を選ばない
Symbol のノードがチェーンを選ぶ基準は、長さではなくスコアである。ブロック 1 つのスコアは、catapult の BlockScorer.cpp にこう書いてある。
// r = difficulty(1) - (t(1) - t(0)) / MS_In_S
auto timeDiff = TimeBetweenBlocks(parentBlock, currentBlock);
return currentBlock.Difficulty.unwrap() - timeDiff.seconds();
難易度から、親のブロックからの経過秒数を引いたもの。チェーンのスコアは、これを足し合わせたものである。
だから、同じ親の上に 2 つのブロックができたら、難易度は同じなので、早く作ったほうが勝つ。分岐が何ブロックか続いた場合も、長さではなく合計で比べる。短い分岐でも、スコアが高ければそちらに乗り換える。
ここまではいい。もうぜんぜん納得。でも問題は、その「よりよい分岐」がどうやってほかのノードに届くか、である。
ブロックの届き方は 2 つある
catapult のノードにブロックが届く道は、2 つある。
- プッシュ型(Push_Block): ブロックを作ったノードや、受け取ったノードが、つないでいるピアに送りつける
- プル型(同期): ノードが自分から相手に「チェーンのスコアいくつ?」と聞き、自分より高ければブロックを取りに行く
この 2 つで、巻き戻せる深さが違う。BlockchainSyncConsumer.cpp の、届いたブロックが手元のチェーンにつながるかを見るところである。
static constexpr bool IsLinked(Height peerStartHeight, Height localChainHeight, InputSource source) {
// peer should never return nemesis block
return peerStartHeight >= Height(2)
// peer chain should connect to local chain
&& peerStartHeight <= localChainHeight + Height(1)
// remote pull is allowed to cause (deep) rollback, but other sources
// are only allowed to rollback the last block
&& (InputSource::Remote_Pull == source || localChainHeight <= peerStartHeight);
}
コメントのとおりで、深く巻き戻していいのは、自分から取りに行ったとき(プル型、Remote_Pull)だけ。プッシュされたブロックで巻き戻せるのは、最後の 1 ブロックまでである。プッシュ型で分岐を全部送ったとしても、届いた分岐の始まりが先端より下なら、それだけで捨てられる。
プッシュ型で深い巻き戻しを許すと、サービス拒否攻撃が成立する隙を与えると判断したのではないかと私は勝手に思っている。だってそういうことありそうなんだもん。
プル型で取りに来てもらえるのは、到達できるノードだけ
では、プル型はどうか。catapult の同期のサービス(SyncService.cpp)は、こうなっている。
auto& packetWriters = *GetPacketWriters(locator);
// add tasks
state.tasks().push_back(CreateConnectPeersTask(state, packetWriters));
state.tasks().push_back(CreateSynchronizerTask(state, packetWriters));
同期は、自分から接続した相手(packetWriters)にだけ聞きに行く。向こうから接続してきた相手には、聞きに行かない。
つまり、ほかのノードから到達できないノードには、誰も取りに来ない。実際によくあるのは、NAT の背後にいるノードや、ポートを開けていないノードなどである。そういうノードがよりよい分岐を持っていても、その分岐が広まる道はプッシュしかない。プッシュでは、2 ブロック以上前から分かれた分岐は通らない。
どういうときに困るのか
ふだんは、まず困らない。
- 先端に 1 つ足すだけなら、プッシュで普通に届く
- 同じ高さで競り合った場合も、プッシュで最後の 1 ブロックを差し替えられる。早く作ったほうが勝つので、到達できるかどうかは関係ない
困るのは、到達できないノードが、しばらく自分だけの分岐を伸ばしたときである。たとえば、そのノードの接続がしばらく切れていて、その間にハーベストしたとする。
みんな: … ─ A ─ B
到達できないノード: … ─ A'─ B'
A'─B' の合計のスコアが A─B より高くても、このノードがプッシュできるのは、新しく作った B' くらいである。B' の親の A' は、ほかのノードの手元にない。つながらないので捨てられる。そして、誰も取りに来ないので、A'─B' を知る機会がない。
でも相手から到達できるノードなら、話は違う。相手が同期で聞きに来たときに、自分のチェーンのスコアのほうが高ければ、相手は分岐ごとごっそり取っていってその分岐に乗り換える。
なので、「到達できないとちょっと不利」くらいの話である。分岐が何ブロックも続くこと自体がめったにないので、気にしすぎるほどではない。ノードを立てるとき、ポートを開けておく理由の一つ、くらいに覚えておくといいと思う。
ちなみに、ここで言う「到達できる」は、インターネットからとは限らない。プライベートネットなら、ほかのノードから接続できればいい。
heika の穴
ここまで調べて、自分のノードを見直したら、穴があった。普通にやらかしてた。
heika は、プッシュされたブロックを先端の次の高さのものしか受け取っていなかった。同じ高さで、より早く作られたライバルをプッシュされても、知らないふりをしていたのである。catapult なら差し替えるところだ。ひえ〜🥺
もちろん直した。先端と同じ親の上にあって、スコアが先端より高いブロックをプッシュされたら、先端を巻き戻して差し替える。
- 難易度が合っていないものは弾く(同じ親なので、難易度は同じになるはず)
- スコアが同じなら差し替えない(catapult と同じ。同じスコアで行ったり来たりしないように)
- 差し替えたら、つないでいるピアにも流す
PUT /blocks で届いたブロックも、同じ道を通る。なので REST からでも、先端の差し替えまではできる。
ただし、heika の PUT /blocks は 1 回に 1 ブロックしか受け取らないので、上の「2 ブロック以上前から分かれた分岐」は、今のところ出せない。複数のブロックをまとめて受け取って、分岐ごとスコアを比べる、というのは考え中である。ほんとうにどうしよう……🥺。仮に受け取れても、catapult に広まるかどうかは、結局「取りに来てもらえるか」次第なのだけれど。
おわりに
REST からブロックを出したい、というだけで始めたら、「到達できないノードの分岐は捨てられることがある」という、catapult の作りの話にたどり着いた。ついでに自分の穴も見つかった。あはれ、しつるせうとくかな。
ふだんは気にしなくていい話である。でも、ポートを開けておくことは、ほかのノードのためだけでなく、自分のブロックのためにもなる、というお話でした。めでたしめでたし、おしまい。