前回・前々回はステーキング関連のコードを読みましたが、今回はノードの動きに注目します。

暗号資産(仮想通貨)のネットワークには、中心となるサーバーがありません。そのため、参加している個々のノードが互いにつながって、新しいブロックやトランザクションについて「ねぇねぇ、こんなのが来たよ」と伝播し合っている・・・というイメージが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(&param);

	// 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()で終了処理をしています。

まとめ

今回は、テストコードからノードの流れを追いました。ソースコードよりもシンプルなので迷子になりにくいですし、どういう観点でテストしているのか?という部分も勉強になります。

次は、この流れの裏側を、ソースで追って見てゆこうと思います・・・!

→ 続きはこちら:Codablecashのソースコードで、ノードが「仲間」を見つける仕組みを追う