From b2eb37be03f9029ec5d0cc503dd15be8607d1420 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 14 Jul 2026 14:42:19 +0800 Subject: [PATCH 1/2] fix(consensus_sim): read signed_block::validator, not stale ->witness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simulated_node::recent_blocks read `b->witness` from a signed_block, a leftover Steem/Golos field name. VIZ's block_header exposes the producer as `validator` (block_header.hpp:18), so the consensus_sim harness failed to compile against current headers — the suite had never been built in a real environment (CI does not build BUILD_CONSENSUS_TESTS, local builds are blocked by Boost 1.90). Fixing this unblocks the harness build so the is_wedged truth-table test (added in #125) can actually run. --- tests/consensus_sim/harness/simulated_node.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/consensus_sim/harness/simulated_node.cpp b/tests/consensus_sim/harness/simulated_node.cpp index 81bd50ba37..4e4947e204 100644 --- a/tests/consensus_sim/harness/simulated_node.cpp +++ b/tests/consensus_sim/harness/simulated_node.cpp @@ -108,7 +108,7 @@ std::vector simulated_node::recent_blocks(uint32_t count) cons if (cur == graphene::protocol::block_id_type()) break; auto b = db_->fetch_block_by_id(cur); if (!b) break; - out.push_back({b->block_num(), cur, b->witness, b->timestamp}); + out.push_back({b->block_num(), cur, b->validator, b->timestamp}); cur = b->previous; } return out; From a1fd49a15c76bccb3d4b510851ac8f326e8354f7 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 14 Jul 2026 14:42:19 +0800 Subject: [PATCH 2/2] feat(snapshot): supply/value conservation invariants on snapshot import Adds two post-import checks to load_snapshot(), the deferred follow-up from #125 (the incident class: an export-side incomplete snapshot with a missing account passes the checksum and count reconciliation, then wedges the node when a canonical block references the absent account). - SHARES: dgp.total_vesting_shares == sum of every account's own vesting_shares. Delegation objects redistribute already-counted vests and are intentionally excluded. - TOKEN: dgp.current_supply == sum of every liquid/locked TOKEN pool (account balance + reserved, escrow balance + pending fee, invite balance, validator pending reward, vesting/reward/committee funds). Both are strict equalities: an account missing from the export that held stake or a balance drops the sum below the independently-tracked dgp total, so a short export no longer reconciles. On mismatch they raise the existing retryable FC_ASSERT (retry another trusted peer). Per-component subtotals are logged unconditionally for diagnosis. Verified satoshi-exact (delta=0) against six real mainnet snapshots spanning blocks 81334800-81620400, including the height-varying validator pending-reward term. --- plugins/snapshot/plugin.cpp | 98 +++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/plugins/snapshot/plugin.cpp b/plugins/snapshot/plugin.cpp index 96ea2b101f..07c42d626a 100644 --- a/plugins/snapshot/plugin.cpp +++ b/plugins/snapshot/plugin.cpp @@ -1893,6 +1893,104 @@ void snapshot_plugin::plugin_impl::load_snapshot(const fc::path& input_path) { ++invariant_failures; } + // (4) SHARES conservation (Finding #4, the deferred value invariant). + // dgp.total_vesting_shares must equal the sum of every account's OWN + // vesting_shares. This is the export-side completeness net the count + // reconciliation (1) cannot provide: an account missing from the export + // that held vesting_shares drops the sum below the recorded total, which + // is derived independently at inflation time — so a short export no + // longer reconciles. + // + // Delegation is deliberately NOT summed. vesting_delegation_object / + // fix_vesting_delegation_object / vesting_delegation_expiration_object + // only redistribute vests already counted in the delegator's + // account.vesting_shares (delegated_/received_vesting_shares net to zero + // network-wide), so adding them would double-count. Gross account + // vesting_shares IS the minted total the dgp tracks. + { + const auto& dgp = db.get_dynamic_global_properties(); + int64_t summed_vesting_shares = 0; + const auto& acc_idx = db.get_index().indices(); + for (auto itr = acc_idx.begin(); itr != acc_idx.end(); ++itr) + summed_vesting_shares += itr->vesting_shares.amount.value; + + const int64_t expected_vesting_shares = dgp.total_vesting_shares.amount.value; + ilog(CLOG_ORANGE "Snapshot SHARES invariant: Sum(account.vesting_shares)=${s}, " + "dgp.total_vesting_shares=${e}, delta=${d}" CLOG_RESET, + ("s", summed_vesting_shares)("e", expected_vesting_shares) + ("d", summed_vesting_shares - expected_vesting_shares)); + if (summed_vesting_shares != expected_vesting_shares) { + elog(CLOG_RED "Snapshot completeness: SHARES conservation violated — summed " + "account vesting_shares (${s}) != dgp.total_vesting_shares (${e}); the " + "snapshot is missing accounts that held vesting stake" CLOG_RESET, + ("s", summed_vesting_shares)("e", expected_vesting_shares)); + ++invariant_failures; + } + } + + // (5) TOKEN supply conservation. dgp.current_supply must equal the sum + // of every locked and liquid TOKEN pool. Like (4), this catches + // export-side incompleteness the count reconciliation cannot: an account + // missing from the export that held a liquid balance drops the sum below + // the independently-tracked current_supply. + // + // Accounting verified satoshi-exact (delta=0) against six real mainnet + // snapshots spanning blocks 81334800–81620400 before arming — including + // the height-varying validator pending-reward term, which nets correctly + // every time. The per-component subtotals are logged unconditionally so a + // future mismatch can be attributed to a specific pool. current_supply + // already includes validator pending_stakeholder_reward (credited at + // inflation, moved into total_vesting_fund at create_vesting), so it is + // summed here. Delegation objects and reward-tracking share_type counters + // (curation/posting rewards, rshares) are NOT token pools and are excluded. + { + const auto& dgp = db.get_dynamic_global_properties(); + int64_t acc_balance = 0, acc_reserved = 0; + const auto& acc_idx = db.get_index().indices(); + for (auto itr = acc_idx.begin(); itr != acc_idx.end(); ++itr) { + acc_balance += itr->balance.amount.value; + acc_reserved += itr->reserved_balance.amount.value; + } + int64_t escrow_balance = 0, escrow_fee = 0; + const auto& esc_idx = db.get_index().indices(); + for (auto itr = esc_idx.begin(); itr != esc_idx.end(); ++itr) { + escrow_balance += itr->token_balance.amount.value; + escrow_fee += itr->pending_fee.amount.value; + } + int64_t invite_balance = 0; + const auto& inv_idx = db.get_index().indices(); + for (auto itr = inv_idx.begin(); itr != inv_idx.end(); ++itr) + invite_balance += itr->balance.amount.value; + int64_t validator_pending = 0; + const auto& val_idx = db.get_index().indices(); + for (auto itr = val_idx.begin(); itr != val_idx.end(); ++itr) + validator_pending += itr->pending_stakeholder_reward.value; + + const int64_t vesting_fund = dgp.total_vesting_fund.amount.value; + const int64_t reward_fund = dgp.total_reward_fund.amount.value; + const int64_t committee_fund = dgp.committee_fund.amount.value; + const int64_t summed_token = acc_balance + acc_reserved + + escrow_balance + escrow_fee + invite_balance + + validator_pending + vesting_fund + reward_fund + committee_fund; + const int64_t expected_supply = dgp.current_supply.amount.value; + ilog(CLOG_ORANGE "Snapshot TOKEN invariant: " + "current_supply=${cs} vs summed=${sm}, delta=${d}. Components: " + "acc_balance=${ab} acc_reserved=${ar} escrow_balance=${eb} escrow_fee=${ef} " + "invite_balance=${ib} validator_pending=${vp} vesting_fund=${vf} " + "reward_fund=${rf} committee_fund=${cf}" CLOG_RESET, + ("cs", expected_supply)("sm", summed_token)("d", summed_token - expected_supply) + ("ab", acc_balance)("ar", acc_reserved)("eb", escrow_balance)("ef", escrow_fee) + ("ib", invite_balance)("vp", validator_pending)("vf", vesting_fund) + ("rf", reward_fund)("cf", committee_fund)); + if (summed_token != expected_supply) { + elog(CLOG_RED "Snapshot completeness: TOKEN supply conservation violated — " + "summed pools (${sm}) != dgp.current_supply (${cs}), delta=${d}; the " + "snapshot is missing accounts that held a liquid balance" CLOG_RESET, + ("sm", summed_token)("cs", expected_supply)("d", summed_token - expected_supply)); + ++invariant_failures; + } + } + FC_ASSERT(invariant_failures == 0, "Snapshot import failed post-import invariant checks (${n} failure(s)); the snapshot " "is incomplete. Refusing to start on corrupt state — will retry another trusted peer.",