前回・前々回はステーキング関連のコードを読みましたが、今回はノードの動きに注目します。
暗号資産(仮想通貨)のネットワークには、中心となるサーバーがありません。そのため、参加している個々のノードが互いにつながって、新しいブロックやトランザクションについて「ねぇねぇ、こんなのが来たよ」と伝播し合っている・・・というイメージがP2P(ピア・ツー・ピア)にはあると思いますが、実際のコードではどうなっているのでしょう。
記事内のコード引用は、Codablecashのソースコード967b7eb(2026年9月8日)のコミットからお借りしています。
テストコードを読む
ここまでの記事では対象のクラスを1つずつ追う形でしたが、今回は少しやり方をかえて、テストコードから読んでみることにします。
私のブロックチェーンの知識が浅いということに加え、マイニングでもない、ステーキングでもない、何しているのかあまりよくわからないノードという存在がいまいちピンとこず・・・。そこでまずは、1つのファイル内に上から順にシナリオが書かれたテストコードを追ってみることにした次第です。
Codablecashのsrc_test/には、3台のノードを起動して互いにつなぐtest_first_net.cppというテストファイルがあります。ノード起動〜終了の流れが書かれていますが、少し長いテストなので4段落に区切って見てゆきます。
テストの準備
さっそくテストコードです。以下はテストの冒頭箇所で、色々なパラメータやインスタンスを準備しているようです。
// src_test/blockchain/testnet/test_first_net.cpp
TEST(TestFirstNetGroup, case01){
// ・・・・・
CodablecashSystemParam param;
DebugCodablecashSystemParamSetup::setupConfig02(param); // テスト用のパラメータ
CodablecashNetworkNodeConfig nwconfig;
nwconfig.setSysConfig(¶m);
// network config
CodablecashNetworkNodeConfig* config01 = new CodablecashNetworkNodeConfig(nwconfig); __STP(config01);
config01->setCanonicalName(L"node_01");
CodablecashNetworkNodeConfig* config02 = new CodablecashNetworkNodeConfig(nwconfig); __STP(config02);
config02->setOriginalNwtworkKey(); // 2台目のネットワークキーを新しく作る
config02->setCanonicalName(L"node_02");
CodablecashNetworkNodeConfig* config03 = new CodablecashNetworkNodeConfig(nwconfig); __STP(config03);
config03->setOriginalNwtworkKey(); // 3台目のネットワークキーを新しく作る
config03->setCanonicalName(L"node_03");
// ・・・・・(1台目にウォレット・最初の残高・マイナーの設定、そしてネットワークキーを作る)
NodeIdentifierSource* networkKey = NodeIdentifierSource::create(); __STP(networkKey);
config01->setNetworkKey(networkKey);
// ・・・・・
NodeIdentifier nodeId01 = config01->getNodeId(); // ネットワークキーからノードIDを取り出す
NodeIdentifier nodeId02 = config02->getNodeId();
NodeIdentifier nodeId03 = config03->getNodeId();
テストで使う3台ぶんのノードの設定です。それぞれにノード固有のネットワークキー(NodeIdentifierSource)を持たせ、最後にconfig01->getNodeId()の部分でノードID(NodeIdentifier)を取り出しています。
ノードIDとは、ランダムな番号ではなく先述のネットワークキーから作られた公開鍵を指しているようです。以下の箇所で取り出していました。
// src_blockchain/bc_network_instance/CodablecashNetworkNodeConfig.cpp
NodeIdentifier CodablecashNetworkNodeConfig::getNodeId() const noexcept {
return this->networkKeys->toNodeIdentifier();
}
// src_blockchain/bc_network/NodeIdentifierSource.cpp
NodeIdentifier NodeIdentifierSource::toNodeIdentifier() const noexcept {
const BigInteger* pubkey = this->pair->getPubKey();
return NodeIdentifier(pubkey);
}
ちなみに前々回の記事で、投票プールを登録するときにもNodeIdentifierが出てきましたが、同じものです。
1台目のノードを起動
準備が終わったので、1台目、node01の部分を見てゆきます。
ArrayNetworkSheeder seeder; // 空の「シーダー」
CodablecashNetworkNode node01(dirNode01, config01, &logger);
{
// First
node01.setNodeName(L"node01");
node01.generateNetwork(0, 1); // zone 0 // 最初のブロックを作る
// after init
node01.startNetwork(&seeder, false); // 空のシーダーで起動
node01.startMiner();
node01.startStakePool();
{
uint16_t zone = 0;
const NodeIdentifierSource* source = config01->getNetworkKey();
NodeIdentifier nodeId = source->toNodeIdentifier();
const wchar_t* host = L"::1";
int port = config01->getPort();
const UnicodeString* cname = config01->getCanonicalName();
P2pNodeRecord* rec = P2pNodeRecord::createIpV6Record(zone, &nodeId, cname, host, port); __STP(rec);
seeder.addRecord(rec); // 1台目の連絡先をシーダーに入れる
}
}
まずArrayNetworkSheederという空のシーダーを用意しています。そしてこのシーダーは、最初の連絡先を渡すという役割を持っています。
名前(node01)をセットしたあとに、generateNetwork()でジェネシスブロック(マイニングで掘られたブロックではなく、ネットワークを始める人が最初に1つ置くブロック)を作ります。
そして、ノードがstartNetwork()で起動。そのとき先ほどの空のシーダーを渡しています。続いてマイニング・ステーキングの機能が動き始めます。
その後の{}で括った部分は、1台目のノードの情報(ゾーン、ノードID、このノードの正式名称node_01、ホスト、ポート)を取得して、空のシーダーに記入しています。
このシードデータは、次に起動するノードに「とりあえずここに仲間がいるよ」という最初の連絡先を渡す役目となっているので、2台目がそれをどう使うかを見てみます。
2台目のノードを起動
ジェネシスブロックを生成した1台目のノードの次は、2台目のノードです。
ノードの名前をセットしてからstartNetwork()を呼びます。1台目のときと比べると引数が少し違っていますね。第一引数のシーダーにはデータが入っていますし、第二引数もtrueになっています。
{
config02->setPort(port02);
}
CodablecashNetworkNode node02(dirNode02, config02, &logger);
{
// second
node02.initBlank(0, 1); // zone 0 // 空のデータフォルダを用意
// after init
node02.setNodeName(L"node02");
node02.startNetwork(&seeder, true); // 1台目のデータ入りシーダーで起動
node02.syncNetwork(); // 仲間に追いつく
// test commections
{
// node01
{
BlockchainNodeHandshake* hs01 = node01.getNodeHandshakeByNodeId(&nodeId01);
StackHandshakeReleaser __releaser(hs01);
CHECK(hs01 == nullptr); // 1台目は、自分自身とはつながっていない
}
{
BlockchainNodeHandshake* hs02 = node01.getNodeHandshakeByNodeId(&nodeId02);
StackHandshakeReleaser __releaser(hs02);
CHECK(hs02 != nullptr); // 1台目から見て、2台目がつながっている
}
// node02
{
BlockchainNodeHandshake* hs01 = node02.getNodeHandshakeByNodeId(&nodeId01);
StackHandshakeReleaser __releaser(hs01);
CHECK(hs01 != nullptr); // 2台目から見て、1台目がつながっている
}
// ・・・・・
}
// update seeder
{
// ・・・・・(1台目のときと同じ手順で、2台目の連絡先もシーダーに足す)
seeder.addRecord(rec);
}
}
node02.startNetwork(&seeder, true);が何をしているかというと、以下のような感じです。
// src_blockchain/bc_network_instance/CodablecashNetworkNode.cpp
void CodablecashNetworkNode::startNetwork(INetworkSeeder* seeder, bool pending) {
// ・・・・・
this->inst->startNetwork(host, port); // 自分のポートで待ち受けを始める
// start processors
{
const NodeIdentifierSource* networkKey = this->nwconfig->getNetworkKey();
this->inst->startProcessors(networkKey, pending); // 命令の処理担当を立ち上げる。pendingはここへ
}
// init database
if(seeder != nullptr){
ArrayList<P2pNodeRecord>* seedList = seeder->getSeedNodes(); __STP(seedList); // シーダーの連絡先を取り出す
// ・・・・・
if(seedList != nullptr && !seedList->isEmpty()){
initDatabase(seedList); // 連絡先が1件でもあれば、それを元に台帳を作る
}
}
// init connections
maintainNetwork(); // 台帳から相手を選んでつなぐ
}
引数で渡したシーダー(seeder)の中の連絡先をgetSeedNodes()で取り出し、データが1件でもあればinitDatabase()に渡します。
(1台目のときはシーダーが空だったのでここの処理はおこらない)
そしてシーダーに入っている1台目の連絡先をもとにinitDatabase()が走り、続くmaintainNetwork()でつなぎに行きます。
2つめのtrueと渡した引数、pendingは、startProcessors()に渡されていました。コメントにも// start processorsとあるように、ここで、届いた命令の処理担当(スレッド)を立ち上げているようです。
// src_blockchain_p2p/bc_p2p_processor/P2pRequestProcessor.cpp
void P2pRequestProcessor::open(const NodeIdentifierSource *networkKey, CodablecashNodeInstance *inst, bool suspend) {
// ・・・・・
this->queueProcessor = new P2pRequestQueueProcessor(this->baseDir, this->p2pManager, this->transferProcessor, inst, this->logger);
this->queueProcessor->open(suspend);
// ・・・・・
}
引数の名前がpendingからsuspendに変わっていますが同じ値を指しています。suspendがtrueだと、この担当を立ち上げつつ止めておくという意味でした(suspendの意味のとおり、吊るしておくということでしょうか)。
そして、node02.syncNetwork();で仲間のノードに追いつく処理に移ります。
// src_blockchain/bc_network_instance/CodablecashNetworkNode.cpp
void CodablecashNetworkNode::syncNetwork() {
NetworkNodeSync sync(this);
sync.sync();
this->inst->resumeRequestProcessor();
}
最後の行のresumeRequestProcessor()が、さっきtrueで止めておいた担当を動かす合図です。「仲間に追いつくまでは新着を処理しない。追いついたら溜めていたぶんから処理を始める」という対になっているようです(resume:再開)。
テストコードからちょっと離れてしまいましたが、テストコード側であとに続いているCHECKが、このテストで確かめたい「期待値」です。
getNodeHandshakeByNodeId(&nodeId01)は、引数で渡したノードと今つながっているか?を返すもので、以下のようにnode01がnodeId01とつながっているか?というチェックの場合、同じノード同士を並べているので、つながっていない(nullptr=相手がいなくて空)をチェックしています。
BlockchainNodeHandshake* hs01 = node01.getNodeHandshakeByNodeId(&nodeId01);
StackHandshakeReleaser __releaser(hs01);
CHECK(hs01 == nullptr);
- 1台目 → 1台目(自分とはつながらないので
nullptr) - 1台目 → 2台目(
nullptrではなく、つながっている) - 2台目 → 1台目(
nullptrではなく、つながっている) - 2台目 → 2台目(自分とはつながらないので
nullptr)
3台目が加わる。そしてノード終了まで
最後に、3台目のノードが起動します。このコードの直前に2台目のノード情報をシーダーに加える処理が走るので、3台目のstartNetwork()には、1台目・2台目の情報が入ったシーダーが渡ることになります。
{
config03->setPort(port03);
}
CodablecashNetworkNode node03(dirNode03, config03, &logger);
{
// third
node03.initBlank(0, 1); // zone 0
// after init
node03.setNodeName(L"node03");
node03.startNetwork(&seeder, true); // 1台目・2台目入りのシーダーで起動
node03.syncNetwork();
// test connections
{
// ・・・・・(3台×3台の9通りを全部確かめる。自分自身の3通りだけがnullptr)
{
BlockchainNodeHandshake* hs03 = node01.getNodeHandshakeByNodeId(&nodeId03);
StackHandshakeReleaser __releaser(hs03);
CHECK(hs03 != nullptr); // 1台目から見て、3台目がつながっている
}
// ・・・・・
}
}
int maxHeight = 0;
while(maxHeight < 5){
Os::usleep(100000);
maxHeight = node03.getMaxHeight(0); // 3台目のブロックが高さ5になるまで待つ
}
node01.suspendMining();
node03.shutdown();
node02.shutdown();
node01.shutdown();
}
ここでは、3台×3台の9通りを全部CHECKしています。自分自身とはつながらないからnullptrで、それ以外の6通りはつながっている、というテストです。
最後のint maxHeight = 0;からは、ノードではなくブロックの確認になっています。1台目のノードはstartMiner()が走っているのでマイニングが動いています。その1台目が掘ったブロックが3台目までちゃんと届いて、ブロック高が5になるのを待っています。
それが確認できたら、1台目のマイニングを止めて、そのあと3台目から順にshutdown()で終了処理をしています。
まとめ
今回は、テストコードからノードの流れを追いました。ソースコードよりもシンプルなので迷子になりにくいですし、どういう観点でテストしているのか?という部分も勉強になります。
次は、この流れの裏側を、ソースで追って見てゆこうと思います・・・!