Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/release-notes-6236.md
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 32 additions & 0 deletions src/chainlock/handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
#include <chainlock/clsig.h>
#include <chainparams.h>
#include <consensus/validation.h>
#include <deploymentstatus.h>
#include <evo/cbtx.h>
#include <evo/specialtx.h>
#include <instantsend/instantsend.h>
#include <llmq/quorumsman.h>
#include <masternode/sync.h>
Expand Down Expand Up @@ -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<CCbTx>(*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<uint32_t>(pindex->nHeight)) {
return {};
}
const int32_t height = pindex->nHeight - static_cast<int32_t>(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)
{
Expand Down
4 changes: 4 additions & 0 deletions src/chainlock/handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
2 changes: 2 additions & 0 deletions src/net_processing.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2126,6 +2126,8 @@ void PeerManagerImpl::StartScheduledTasks(CScheduler& scheduler)
*/
void PeerManagerImpl::BlockConnected(const std::shared_ptr<const CBlock>& 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);
Expand Down
89 changes: 89 additions & 0 deletions src/test/llmq_chainlock_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
#include <test/util/net.h>
#include <test/util/setup_common.h>

#include <evo/cbtx.h>
#include <evo/specialtx.h>
#include <hash.h>
#include <masternode/meta.h>
#include <masternode/sync.h>
#include <net.h>
#include <net_processing.h>
#include <netaddress.h>
Expand Down Expand Up @@ -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<CBlockIndex> indexes(501);
std::vector<uint256> 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<int32_t>::max()}, std::numeric_limits<uint32_t>::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.
Expand Down
83 changes: 83 additions & 0 deletions test/functional/feature_llmq_chainlocks_automatic.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions test/functional/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down