Update 2024/4/7

2024-04-07 事後報告

strapi-blog-api-image

背景

3月30日午前11時頃 (UTC)、Toshi: https://twitter.com/toshiya_ma さんが、自分のノードで問題が発生していることを NEM & Symbol Discord: https://discord.com/channels/856325968096133191/865026859423105043/1223586504304103564 で報告しました。ブロック高 3,191,538 でフォークが発生しているという内容でした。
著者注: 迅速な報告に感謝します。ノード運営者の方々には、異常を発見した場合はいつでもすぐにご連絡いただくようお願いいたします。
我々が彼に回答する頃には、すでにデータフォルダが削除されており、さらなる診断のためのデータが残っていませんでした。誤って彼のノードは復旧したものと判断し、問題を軽視してしまいました。
コミュニティの一部の人々からも同様の問題が報告されましたが、決定的な手がかりは見つからず、最終的にはネットワークと同期がとれるだろうと考えていました。
4月1日、Toshiさんから同じ問題が再発したと報告を受けました。事態を深刻に受け止め、人員を投入して問題解決に取り掛かりました。
Dusan: https://twitter.com/dusanjp さん、Toshiさん、そしてコミュニティの皆さんが診断情報や原因の可能性について提案をしてくれるなど、協力してくれました。この時点で、フォークが発生していたのは少数のノード (10-20) であり、ネットワーク全体の2%未満を占めていたため、まだ大きなリスクはありませんでした。
問題に取り組んでいることを知らせるために ツイート: https://x.com/0x6861746366574/status/1774879447487013033 を発信し、調査を開始しました。

データの調査

フォークが発生していたノードのログには、次のようなエラーが共通して報告されていました: processing of block 3191538 failed with Failure_LockSecret_Hash_Already_Exists
このエラーは、アクティブな SECRET_LOCK が置き換えられようとしていることを示しており、これはネットワークのルールで許可されていません。にもかかわらず、このブロックは、少数のフォーク側で確認され かつ 最終化されていました。これが分岐点であるため、ここに焦点を当てる必要があるとわかりました。
直面していた最大の課題は、ゼロから同期するか、バックアップ (チェックポイント) から復元しようとしても、ブロック 3191538 を超えて同期できないということでした。当初はコミュニティでバックアップが維持されていることに気づいておらず、確認してみましたが異常は見つかりませんでした。
複合ハッシュ値が 0D0D03B478CBFA3A9C0A0A292E2FECB2C09A5DA01659CFF20C199823D0E79E81 であるシークレットロックに何かしらの異常があることがすぐに明らかになりました。シンボルのことをよくご存知な方はご存知かと思いますが、シークレットロックの複合ハッシュ値は、シークレットロックのシークレットと受け手から導出されます。どちらかが変更されると、シークレットロックの複合ハッシュ値も変わります。
ブロックチェーン上で、この複合ハッシュ値を持つシークレットロックについて次のような利用状況が確認できました。
  • ブロック 3,191,088: アクティベート | SECRET_LOCK が作成されました (有効期限は 420 ブロック)
  • ブロック 3,191,098: ディアクティベート | SECRET_LOCK が完了しました
  • ブロック 3,191,525: アクティベート | SECRET_LOCK が作成されました (有効期限は 420 ブロック)
  • ブロック 3,191,538: アクティベート | SECRET_LOCK が作成されました (有効期限は 420 ブロック)
  • ブロック 3,191,576: ディアクティベート | SECRET_LOCK が完了しました
シンボルでは、前のロックが非アクティブ (完了済み (COMPLETED) または期限切れ (EXPIRED) の場合) であれば、複合ハッシュ値を再利用できます。したがって、ブロック 3,191,525 でのロックの作成は、正当な理由があり、前のロックがブロック高 3,191,098 で完了しているため妥当です。しかし、ブロック高 3,191,538 でのロックの作成は 予期せぬもの です。なぜなら、前のロックはまだアクティブであるはずだからです。
著者の注: この問題とは無関係ですが、シークレットの再利用は一般的に安全でないことを強調しておく必要があります。シンボルは (ほとんどのチェーンと同様に) メモリ使用量を削減するために、使用されるすべてのシークレットを追跡しません。したがって、シークレットロックを使用する際は、この点に注意してください。
もう一つの重要な発見は、この重複ロックの作成記録があるのは、長時間稼働しているノードのみであるということでした。ゼロから同期したノードは、ブロック高 3,191,538 で作成された重複ロックを拒否しました。これが、この問題を診断して修正するために必要な情報の大半でした。何が起きているのかを突き止めるために、多くの 特設の テストと分析を行う必要がありました。
最初に考えついたのは、ロールバックの問題ではないかということでしたが、なぜ 長時間稼働しているノードだけが影響を受けたのか説明できませんでした。ロールバックの問題は 通常 少数のノードにしか影響を与えません。この考えはすぐに捨て、次の手がかりであるトランザクションに移りました。
シークレットロックのプルーニング (および最終化) で何らかの問題が発生していました。
まず、背景説明です。シンボルでは、複合ハッシュ値を内部的に再利用できるようにするため、複合ハッシュ値ごとにシークレットロックのリストが保持されています。このデータ構造は基本的にスタックです。
さらに、シークレットロックは時間制限があるため、ブロックチェーンの状態を変更できなくなった時点でキャッシュから削除 (プルーニング) することができます。実際には、シークレットロックは、有効期限が切れる可能性のあるブロックが最終化されるときにプルーニングされます。
これを念頭に置くと、ブロック 3,191,098 で作成されたシークレットロックは、ブロック高 3,191,508 (420 + 58) でプルーニングされることになります。
我々は、多数派フォークにおける可能性が高いシーケンスを piecing together (断片をつなぎ合わせる) ことができました。
  1. ブロック 3,191,525 で SECRET_LOCK が作成されました。
  2. ブロック 3,191,508 が最終化されました。
  3. 一致するすべての SECRET_LOCK が削除されました。
  4. ブロック 3,191,538 で SECRET_LOCK が作成されました。
しかし、バグは、どの シークレットロックが期限切れになっても、シークレットロックの履歴全体がプルーニングされてしまう ことでした。

解決方法

問題のあるコードは次のとおりです。
	auto groupIter = groupedSet.find(key);
	const auto* pGroup = groupIter.get();
	if (!pGroup)
		return;

	for (const auto& identifier : pGroup->identifiers())
		set.remove(identifier);

	groupedSet.remove(key);
pGroup->identifiers() は、指定された高さ (key) で期限切れになるすべての複合ハッシュを返します。
set.remove(identifier); は、期限切れの部分だけではなく、シークレットロックの履歴全体を削除します。
修正箇所がわかりますか? シークレットロックの履歴全体ではなく、個別に プルーニングする必要があります。
修正後のコードは以下のとおりです。
	std::unordered_set<typename TDescriptor::KeyType, utils::ArrayHasher<typename TDescriptor::KeyType>> pendingRemovalIds;
	ForEachIdentifierWithGroup(*m_pDelta, *m_pHeightGroupingDelta, height, [height, &pendingRemovalIds](auto& history) {
		history.prune(height);

		if (history.empty())
			pendingRemovalIds.insert(history.id());
	});

	for (const auto& identifier : pendingRemovalIds)
		m_pDelta->remove(identifier);

	m_pHeightGroupingDelta->remove(height);
ここでは、残りのシークレットロックが ない 履歴のみが削除されます。重要なのは、履歴が空の場合 (history.empty()) にのみ削除対象としてマークすることです。
すでにチェーン内にある重複シークレットロック (ブロック *538) を回避するために、新しい設定が追加されました。
skipSecretLockUniquenessCheck は、メインネットで 3'191'538、他のすべてのチェーンで 0 に設定する必要があります。
シークレットロックの重複チェックは、設定された高さでスキップされます。
著者の注: このプロセスを通じてステートハッシュ検証が成功したのは興味深い点です。ブロック高 3,191,525 で作成された (2番目の) シークレットロックがキャッシュに追加され、ステートハッシュが変更されました。ブロック *526 と *537 の間のどこかで、シークレットロックはプルーニングされました。 プルーニングは、ステートに影響を与えることができなくなったエンティティのみを削除するため、ステートに影響を与えないと考えられています。ブロック *538 で作成された (3番目の) シークレットロックがキャッシュに追加され、ステートハッシュが変更されました。 実質的に、これはシークレットロックの更新としてキャプチャされました。
シークレットロックの履歴が正しく処理されなかったため、このバグの別の2つの症状が特定されました。
一つ目は、シークレットロックが複数回期限切れになる場合です。例えば、次のようなシーケンスを考えてみましょう。
ブロック 3197038 - 有効期限420ブロックのシークレットロックが作成されました ブロック 3197075 - シークレットロックが完了しました ブロック 3197446 - 有効期限420ブロックのシークレットロックが作成されました
最初のロックはブロック高 *(038 + 420) で期限切れになるはずですが、完了しているためステートに変更はありません。
しかし、残念なことに、期限切れ時には最新のロックの情報しかチェックされません (これは *446 で作成されたロックです)。このロックは未完了なので、シンボルは誤って失効レシートと振替を生成してしまいます。
これは、期限切れになるロックが最新のロックかどうかを明示的にチェックし、最新のロックである場合のみ処理を継続するように修正されました。
	if (height == lockInfo.EndHeight || forceHeights.cend() != forceHeights.find(height))
		accountStateConsumer(accountStateCache.find(lockInfo.OwnerAddress).get());
最初のロックは誤って完了してから期限切れになったため、実質的には相殺されます。この 2 つの事象は、合わせて 2 番目のロックの有効期間を 1 番目のロックの有効期間と同じ長さに短縮してしまうことに注意してください。
メインネットでこのバグの影響を受けたロックは 2 つありました。
ブロック 3197440 で作成された、モザイク 00E26AEDCE86A630 を 1 uint 保持するロック ブロック 3197446 で作成された、モザイク 0BCF7F87A4175ABE を 1 uint 保持するロック
skipSecretLockExpirations 設定が追加され、同期中のノードが本来の (誤った) 動作をエミュレートできるようにすることができました。
メインネットでゼロから正しく同期するための推奨設定は次のとおりです。
skipSecretLockUniquenessChecks = 3'191'538 skipSecretLockExpirations = 3'197'860, 3'197'866 forceSecretLockExpirations = 3'197'458, 3'197'781
できるだけ早くバージョン 1.0.3.7 にアップグレードしてください。 これは必須のアップグレードであり、同様の問題が将来的に発生することを防ぎます。
特に toshi、dusan、bootaru (および NFTDriveEX チーム)、neluta さんをはじめ、協力してくれたコミュニティメンバー全員に感謝します。

News
Community
Docs
Contact