diff --git a/doc/release-notes-6236.md b/doc/release-notes-6236.md new file mode 100644 index 000000000000..17c5df40e6e8 --- /dev/null +++ b/doc/release-notes-6236.md @@ -0,0 +1,6 @@ +Miscellaneous +------------- + +- Synced nodes now recover and relay ChainLocks embedded in newly connected + blocks, even when the original ChainLock message was missed. This applies + after v20 activation while ChainLocks are enabled. (#6236) diff --git a/src/chainlock/handler.cpp b/src/chainlock/handler.cpp index 2201f47a4efe..7dacdd7b0fa5 100644 --- a/src/chainlock/handler.cpp +++ b/src/chainlock/handler.cpp @@ -9,6 +9,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -98,6 +101,35 @@ void ChainlockHandler::UpdateTxFirstSeenMap(const Uint256HashSet& tx, const int6 } } +MessageProcessingResult ChainlockHandler::ProcessCoinbaseChainLock(const CBlock& block, const CBlockIndex* pindex, + const llmq::CQuorumManager& qman) +{ + if (!m_mn_sync.IsBlockchainSynced() || !m_chainlocks.IsEnabled() || pindex == nullptr || + !DeploymentActiveAt(*pindex, Params().GetConsensus(), Consensus::DEPLOYMENT_V20) || block.vtx.empty()) { + return {}; + } + + const auto cbtx = GetTxPayload(*block.vtx.front(), /*assert_type=*/false); + if (!cbtx || cbtx->nVersion < CCbTx::Version::CLSIG_AND_BALANCE || !cbtx->bestCLSignature.IsValid()) { + return {}; + } + + // The offset is relative to the containing block's parent. Check it before converting to a signed height. + if (pindex->nHeight <= 0 || cbtx->bestCLHeightDiff >= static_cast(pindex->nHeight)) { + return {}; + } + const int32_t height = pindex->nHeight - static_cast(cbtx->bestCLHeightDiff) - 1; + if (height <= m_chainlocks.GetBestChainLockHeight()) { + return {}; + } + const auto* ancestor = pindex->GetAncestor(height); + if (ancestor == nullptr) { + return {}; + } + const ChainLockSig clsig{height, ancestor->GetBlockHash(), cbtx->bestCLSignature}; + return ProcessNewChainLock(/*from=*/-1, clsig, qman, ::SerializeHash(clsig)); +} + MessageProcessingResult ChainlockHandler::ProcessNewChainLock(const NodeId from, const chainlock::ChainLockSig& clsig, const llmq::CQuorumManager& qman, const uint256& hash) { diff --git a/src/chainlock/handler.h b/src/chainlock/handler.h index d369113eeb4f..76dca1672a95 100644 --- a/src/chainlock/handler.h +++ b/src/chainlock/handler.h @@ -79,6 +79,10 @@ class ChainlockHandler final : public CValidationInterface void UpdateTxFirstSeenMap(const Uint256HashSet& tx, const int64_t& time) EXCLUSIVE_LOCKS_REQUIRED(!cs); size_t SeenChainLockCacheSizeForTesting() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + [[nodiscard]] MessageProcessingResult ProcessCoinbaseChainLock(const CBlock& block, const CBlockIndex* pindex, + const llmq::CQuorumManager& qman) + EXCLUSIVE_LOCKS_REQUIRED(!cs); + [[nodiscard]] MessageProcessingResult ProcessNewChainLock(NodeId from, const chainlock::ChainLockSig& clsig, const llmq::CQuorumManager& qman, diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 62071ca1dcf1..53da54e460f1 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -2126,6 +2126,8 @@ void PeerManagerImpl::StartScheduledTasks(CScheduler& scheduler) */ void PeerManagerImpl::BlockConnected(const std::shared_ptr& pblock, const CBlockIndex* pindex) { + PostProcessMessage(m_clhandler.ProcessCoinbaseChainLock(*pblock, pindex, *m_llmq_ctx.qman), /*node=*/-1); + // Orphans included in or conflicted by the block can never be accepted, so drop them before // reconsidering the ones the block may have just made acceptable. m_orphanage.EraseForBlock(*pblock); diff --git a/src/test/llmq_chainlock_tests.cpp b/src/test/llmq_chainlock_tests.cpp index 008a5c261b1f..c9244de5f431 100644 --- a/src/test/llmq_chainlock_tests.cpp +++ b/src/test/llmq_chainlock_tests.cpp @@ -6,8 +6,11 @@ #include #include +#include +#include #include #include +#include #include #include #include @@ -262,6 +265,92 @@ namespace { constexpr const char* REGTEST_SPORK_PRIVKEY{"cP4EKFyJsHT39LDqgdcB43Y3YXjNyjb5Fuas1GQSeAtjnZWmZEQK"}; } // namespace +BOOST_FIXTURE_TEST_CASE(coinbase_chainlock_processing, RegTestingSetup) +{ + BOOST_REQUIRE(m_node.sporkman->SetSporkAddress(Params().SporkAddress())); + BOOST_REQUIRE(m_node.sporkman->SetPrivKey(REGTEST_SPORK_PRIVKEY)); + BOOST_REQUIRE(m_node.sporkman->UpdateSpork(SPORK_19_CHAINLOCKS_ENABLED, 0).has_value()); + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced()); + + std::vector indexes(501); + std::vector hashes(indexes.size()); + for (size_t i = 0; i < indexes.size(); ++i) { + hashes[i] = GetTestBlockHash(i); + indexes[i].nHeight = i; + indexes[i].phashBlock = &hashes[i]; + if (i > 0) indexes[i].pprev = &indexes[i - 1]; + indexes[i].BuildSkip(); + } + + CCbTx cbtx; + cbtx.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; + cbtx.bestCLSignature = CreateRandomBLSSignature(); + CMutableTransaction tx; + tx.nVersion = CTransaction::SPECIAL_VERSION; + tx.nType = TRANSACTION_COINBASE; + tx.vin.resize(1); + CBlock block; + const auto set_payload = [&] { + SetTxPayload(tx, cbtx); + block.vtx = {MakeTransactionRef(tx)}; + }; + const auto process = [&](const CBlockIndex* index) { + const auto result = m_node.clhandler->ProcessCoinbaseChainLock(block, index, *m_node.llmq_ctx->qman); + BOOST_CHECK(!result.m_error); + BOOST_CHECK(result.m_inventory.empty()); + // A structurally valid signature still requires quorum verification. + BOOST_CHECK(m_node.chainlocks->GetBestChainLock().IsNull()); + }; + + // ProcessNewChainLock records the derived signature before verification. No quorum exists in this fixture. + for (const uint32_t offset : {0U, 5U, 499U}) { + cbtx.bestCLHeightDiff = offset; + set_payload(); + process(&indexes.back()); + const int32_t height = 499 - offset; + const ChainLockSig expected{height, hashes[height], cbtx.bestCLSignature}; + BOOST_CHECK(m_node.clhandler->AlreadyHave(CInv{MSG_CLSIG, ::SerializeHash(expected)})); + } + const auto seen = m_node.clhandler->SeenChainLockCacheSizeForTesting(); + BOOST_CHECK_EQUAL(seen, 3U); + + for (const uint32_t offset : + {500U, 501U, uint32_t{std::numeric_limits::max()}, std::numeric_limits::max()}) { + cbtx.bestCLHeightDiff = offset; + set_payload(); + process(&indexes.back()); + } + cbtx.bestCLHeightDiff = 1; + set_payload(); + process(nullptr); + process(&indexes.front()); + process(&indexes[431]); // Before v20 activation on regtest. + + m_node.mn_sync->Reset(/*fForce=*/true); + process(&indexes.back()); + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.sporkman->UpdateSpork(SPORK_19_CHAINLOCKS_ENABLED, 4070908800).has_value()); + process(&indexes.back()); + BOOST_REQUIRE(m_node.sporkman->UpdateSpork(SPORK_19_CHAINLOCKS_ENABLED, 0).has_value()); + + cbtx.bestCLSignature = {}; + set_payload(); + process(&indexes.back()); + cbtx.nVersion = CCbTx::Version::MERKLE_ROOT_QUORUMS; + set_payload(); + process(&indexes.back()); + tx.vExtraPayload = {0xff}; + block.vtx = {MakeTransactionRef(tx)}; + process(&indexes.back()); + tx.nType = TRANSACTION_NORMAL; + block.vtx = {MakeTransactionRef(tx)}; + process(&indexes.back()); + block.vtx.clear(); + process(&indexes.back()); + BOOST_CHECK_EQUAL(m_node.clhandler->SeenChainLockCacheSizeForTesting(), seen); +} + // A CLSIG is only ever sent in reply to a GETDATA, so one that the peer neither announced nor was // asked for must be dropped before ProcessNewChainLock -- which would otherwise remember its hash // and do that work again for every distinct signature blob, at no cost to the sender. diff --git a/test/functional/feature_llmq_chainlocks_automatic.py b/test/functional/feature_llmq_chainlocks_automatic.py new file mode 100755 index 000000000000..0ac539749b22 --- /dev/null +++ b/test/functional/feature_llmq_chainlocks_automatic.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025-2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +"""Recover ChainLocks from blocks without receiving CLSIG messages.""" + +from test_framework.test_framework import DashTestFramework +from test_framework.util import assert_equal, force_finish_mnsync + + +class LLMQChainLocksAutomaticTest(DashTestFramework): + def add_options(self, parser): + self.add_wallet_options(parser) + + def set_test_params(self): + self.set_dash_test_params(2, 1) + self.set_dash_llmq_test_params(1, 1) + self.delay_v20_and_mn_rr(height=200) + self.extra_args[1].append("-sporkkey=cP4EKFyJsHT39LDqgdcB43Y3YXjNyjb5Fuas1GQSeAtjnZWmZEQK") + + def run_test(self): + self.activate_v20(expected_activation_height=200) + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + self.nodes[0].sporkupdate("SPORK_19_CHAINLOCKS_ENABLED", 0) + self.wait_for_sporks_same() + self.mine_quorum_single_member() + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + + receiver, producer = self.nodes + self.log.info("Isolate the regular node and deliver only raw blocks over RPC") + self.isolate_node(0) + signed_hash = self.generate(producer, 1, sync_fun=self.no_op)[0] + self.wait_for_chainlocked_block(producer, signed_hash) + expected = producer.getbestchainlock() + assert_equal(expected["blockhash"], signed_hash) + assert_equal(receiver.submitblock(producer.getblock(signed_hash, 0)), None) + assert receiver.getbestchainlock()["height"] < expected["height"] + + # Stop signing so every subsequent coinbase repeats the same signature. + self.bump_mocktime(1) + producer.sporkupdate("SPORK_19_CHAINLOCKS_ENABLED", 1) + assert_equal(producer.spork("show")["SPORK_19_CHAINLOCKS_ENABLED"], 1) + carrier = self.generate(producer, 1, sync_fun=self.no_op)[0] + cbtx = producer.getblock(carrier, 2)["cbTx"] + assert_equal(cbtx["bestCLHeightDiff"], 0) + assert_equal(cbtx["bestCLSignature"], expected["signature"]) + assert_equal(receiver.submitblock(producer.getblock(carrier, 0)), None) + self.wait_for_chainlocked_block(receiver, signed_hash) + assert_equal(receiver.getbestchainlock(), expected) + assert_equal(receiver.getconnectioncount(), 0) + + self.log.info("Recover a nonzero-offset ChainLock after restart") + self.restart_node(0, extra_args=self.extra_args[0] + ["-connect=0"]) + receiver.setnetworkactive(False) + force_finish_mnsync(receiver) + carrier = self.generate(producer, 1, sync_fun=self.no_op)[0] + cbtx = producer.getblock(carrier, 2)["cbTx"] + assert_equal(cbtx["bestCLHeightDiff"], 1) + assert_equal(cbtx["height"] - cbtx["bestCLHeightDiff"] - 1, expected["height"]) + assert_equal(receiver.submitblock(producer.getblock(carrier, 0)), None) + self.wait_for_chainlocked_block(receiver, signed_hash) + assert_equal(receiver.getbestchainlock(), expected) + assert_equal(receiver.getconnectioncount(), 0) + + self.log.info("Repeated coinbase signatures leave the best ChainLock unchanged") + carrier = self.generate(producer, 1, sync_fun=self.no_op)[0] + assert_equal(receiver.submitblock(producer.getblock(carrier, 0)), None) + receiver.syncwithvalidationinterfacequeue() + assert_equal(receiver.getbestchainlock(), expected) + + self.log.info("Normal ChainLock relay continues after reconnection") + self.reconnect_isolated_node(0, 1) + self.bump_mocktime(1) + producer.sporkupdate("SPORK_19_CHAINLOCKS_ENABLED", 0) + self.wait_for_sporks_same() + self.sync_blocks() + block_hash = self.generate(producer, 1)[0] + self.wait_for_chainlocked_block_all_nodes(block_hash) + + +if __name__ == '__main__': + LLMQChainLocksAutomaticTest().main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index adf7588f3f4b..8563d10f7861 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -115,6 +115,7 @@ 'feature_llmq_signing.py', # NOTE: needs dash_hash to pass 'feature_llmq_is_retroactive.py', # NOTE: needs dash_hash to pass 'feature_llmq_chainlocks.py', # NOTE: needs dash_hash to pass + 'feature_llmq_chainlocks_automatic.py', # NOTE: needs dash_hash to pass 'feature_masternode_payout_shares.py', 'feature_llmq_signing.py --spork21', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose.py --disable-spork23', # NOTE: needs dash_hash to pass