From 5e79dbe33ea7e883d54b8fccbad3b2a46c671577 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Sat, 21 Mar 2026 19:05:22 +0300 Subject: [PATCH 1/4] fix: initialize quorum connections at startup on idle chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After PR #7063 (commit 1360d9d61b), CQuorumManager::UpdatedBlockTip was moved from CDSNotificationInterface::UpdatedBlockTip (which is called directly at startup via InitializeCurrentBlockTip) to the ActiveContext/ObserverContext CValidationInterface subscribers. These subscribers only receive UpdatedBlockTip via GetMainSignals() broadcast, which is never triggered at startup on idle chains — ActivateBestChain early-returns when pindexMostWork == m_chain.Tip(). This means that after a full restart with no new blocks: - QuorumObserver::UpdatedBlockTip never fires - CheckQuorumConnections is never called - Quorum connections are never re-established - MNs cannot exchange signing shares - InstantSend and ChainLock signing fails Fix by adding InitializeCurrentBlockTip() to ActiveContext and ObserverContext, called from the loadblk thread right after CDSNotificationInterface::InitializeCurrentBlockTip(). This method also calls the new QuorumObserver::InitializeQuorumConnections(), which bypasses the IsBlockchainSynced() guard (mnsync is not yet complete at that point in the startup sequence). Co-Authored-By: Claude Opus 4.6 --- src/active/context.cpp | 8 ++++++++ src/active/context.h | 1 + src/init.cpp | 9 +++++++++ src/llmq/observer/context.cpp | 8 ++++++++ src/llmq/observer/context.h | 1 + src/llmq/observer/quorums.cpp | 7 +++++++ src/llmq/observer/quorums.h | 1 + 7 files changed, 35 insertions(+) diff --git a/src/active/context.cpp b/src/active/context.cpp index 831a1b4ccc67..e2c5c215d874 100644 --- a/src/active/context.cpp +++ b/src/active/context.cpp @@ -97,6 +97,14 @@ void ActiveContext::SetCJServer(gsl::not_null cj_server) m_cj_server = cj_server; } +void ActiveContext::InitializeCurrentBlockTip(const CBlockIndex* tip, bool ibd) +{ + UpdatedBlockTip(tip, nullptr, ibd); + if (tip) { + qman_handler->InitializeQuorumConnections(tip); + } +} + void ActiveContext::UpdatedBlockTip(const CBlockIndex* pindexNew, const CBlockIndex* pindexFork, bool fInitialDownload) { if (fInitialDownload || pindexNew == pindexFork) // In IBD or blocks were disconnected without any new ones diff --git a/src/active/context.h b/src/active/context.h index c93690c4bc4a..2f45443d819c 100644 --- a/src/active/context.h +++ b/src/active/context.h @@ -74,6 +74,7 @@ struct ActiveContext final : public CValidationInterface { void Start(CConnman& connman, PeerManager& peerman, int16_t worker_count); void Stop(); + void InitializeCurrentBlockTip(const CBlockIndex* tip, bool ibd); CCoinJoinServer& GetCJServer() const; void SetCJServer(gsl::not_null cj_server); diff --git a/src/init.cpp b/src/init.cpp index db99f54a42a3..3352c17cf983 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2417,6 +2417,15 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) // but don't call it directly to prevent triggering of other listeners like zmq etc. // GetMainSignals().UpdatedBlockTip(::ChainActive().Tip()); g_ds_notification_interface->InitializeCurrentBlockTip(); + { + const CBlockIndex* tip = WITH_LOCK(::cs_main, return chainman.ActiveTip()); + const bool ibd = chainman.ActiveChainstate().IsInitialBlockDownload(); + if (node.active_ctx) { + node.active_ctx->InitializeCurrentBlockTip(tip, ibd); + } else if (node.observer_ctx) { + node.observer_ctx->InitializeCurrentBlockTip(tip, ibd); + } + } { // Get all UTXOs for each MN collateral in one go so that we can fill coin cache early diff --git a/src/llmq/observer/context.cpp b/src/llmq/observer/context.cpp index 717cd58f143b..00e4ce2ef784 100644 --- a/src/llmq/observer/context.cpp +++ b/src/llmq/observer/context.cpp @@ -47,6 +47,14 @@ void ObserverContext::Stop() qman_handler->Stop(); } +void ObserverContext::InitializeCurrentBlockTip(const CBlockIndex* tip, bool ibd) +{ + UpdatedBlockTip(tip, nullptr, ibd); + if (tip) { + qman_handler->InitializeQuorumConnections(tip); + } +} + void ObserverContext::UpdatedBlockTip(const CBlockIndex* pindexNew, const CBlockIndex* pindexFork, bool fInitialDownload) { if (fInitialDownload || pindexNew == pindexFork) // In IBD or blocks were disconnected without any new ones diff --git a/src/llmq/observer/context.h b/src/llmq/observer/context.h index 0f6c2b5501cf..c373508dd386 100644 --- a/src/llmq/observer/context.h +++ b/src/llmq/observer/context.h @@ -49,6 +49,7 @@ struct ObserverContext final : public CValidationInterface { void Start(int16_t worker_count); void Stop(); + void InitializeCurrentBlockTip(const CBlockIndex* tip, bool ibd); protected: // CValidationInterface diff --git a/src/llmq/observer/quorums.cpp b/src/llmq/observer/quorums.cpp index 80084dd29ab5..69c727df9f47 100644 --- a/src/llmq/observer/quorums.cpp +++ b/src/llmq/observer/quorums.cpp @@ -59,6 +59,13 @@ void QuorumObserver::Stop() workerPool.stop(true); } +void QuorumObserver::InitializeQuorumConnections(gsl::not_null pindexNew) const +{ + for (const auto& params : Params().GetConsensus().llmqs) { + CheckQuorumConnections(params, pindexNew); + } +} + void QuorumObserver::UpdatedBlockTip(const CBlockIndex* pindexNew, bool fInitialDownload) const { if (!pindexNew) return; diff --git a/src/llmq/observer/quorums.h b/src/llmq/observer/quorums.h index 7b7bb68f79d4..6f60a734515d 100644 --- a/src/llmq/observer/quorums.h +++ b/src/llmq/observer/quorums.h @@ -96,6 +96,7 @@ class QuorumObserver void Stop(); void UpdatedBlockTip(const CBlockIndex* pindexNew, bool fInitialDownload) const; + void InitializeQuorumConnections(gsl::not_null pindexNew) const; public: virtual bool SetQuorumSecretKeyShare(CQuorum& quorum, Span skContributions) const; From c25c61a8d9a61a681f1171e909f2cdd02f95e038 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Sat, 21 Mar 2026 19:06:28 +0300 Subject: [PATCH 2/4] test: add InstantSend after full restart test Tests that InstantSend works after a full cluster restart without mining any new blocks. This exercises the quorum connection re-establishment path added in the previous commit. The test: - Funds a sender, chainlocks the tip - Restarts all nodes (simple nodes and masternodes) - Reconnects all nodes to node 0 - Bumps mocktime past WAIT_FOR_ISLOCK_TIMEOUT - Sends a transaction and verifies it gets an IS lock Co-Authored-By: Claude Opus 4.6 --- test/functional/p2p_instantsend.py | 64 +++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/test/functional/p2p_instantsend.py b/test/functional/p2p_instantsend.py index b92d563f9abc..749bf2162eb1 100755 --- a/test/functional/p2p_instantsend.py +++ b/test/functional/p2p_instantsend.py @@ -4,7 +4,7 @@ # file COPYING or http://www.opensource.org/licenses/mit-license.php. from test_framework.test_framework import DashTestFramework -from test_framework.util import assert_equal, assert_raises_rpc_error +from test_framework.util import assert_equal, assert_raises_rpc_error, force_finish_mnsync ''' p2p_instantsend.py @@ -36,6 +36,7 @@ def run_test(self): self.test_mempool_doublespend() self.test_block_doublespend() + self.test_instantsend_after_restart() def test_block_doublespend(self): sender = self.nodes[self.sender_idx] @@ -143,5 +144,66 @@ def test_mempool_doublespend(self): # mine more blocks self.generate(self.nodes[0], 2) + def test_instantsend_after_restart(self): + self.log.info("Testing InstantSend works after full restart without new blocks") + + # fund sender with confirmed coins + sender = self.nodes[self.sender_idx] + receiver = self.nodes[self.receiver_idx] + sender_addr = sender.getnewaddress() + fund_id = self.nodes[0].sendtoaddress(sender_addr, 1) + self.bump_mocktime(30) + self.sync_mempools() + for node in self.nodes: + self.wait_for_instantlock(fund_id, node) + tip = self.generate(self.nodes[0], 2)[-1] + self.bump_mocktime(30) + self.wait_for_chainlocked_block_all_nodes(tip) + self.sync_blocks() + assert sender.getbalance() >= 0.5 + + receiver_addr = receiver.getnewaddress() + + # restart all nodes without mining new blocks + self.log.info("Restarting all nodes") + num_simple_nodes = self.num_nodes - self.mn_count + self.stop_nodes() + + for i in range(num_simple_nodes): + self.start_node(i) + for mn_info in self.mninfo: + self.start_masternode(mn_info) + + # reconnect: simple nodes to node 0, MNs to node 0 + # quorum connections are re-established automatically via InitializeCurrentBlockTip + for i in range(1, num_simple_nodes): + self.connect_nodes(i, 0) + for mn_info in self.mninfo: + self.connect_nodes(mn_info.nodeIdx, 0) + for i in range(num_simple_nodes): + force_finish_mnsync(self.nodes[i]) + + # bump past WAIT_FOR_ISLOCK_TIMEOUT so txFirstSeenTime loss doesn't + # block chainlock signing for TXs mined before restart + self.bump_mocktime(10 * 60 + 1) + self.sync_blocks() + + # re-grab references after restart + sender = self.nodes[self.sender_idx] + receiver = self.nodes[self.receiver_idx] + + # send a TX — needs IS lock from all restarted MNs, no new blocks mined + is_id = sender.sendtoaddress(receiver_addr, 0.5) + self.bump_mocktime(30) + self.sync_mempools() + self.wait_for_instantlock(is_id, sender) + self.log.info("InstantSend lock succeeded after full restart") + + # clean up + receiver.sendtoaddress(self.nodes[0].getnewaddress(), 0.5, "", "", True) + self.bump_mocktime(30) + self.sync_mempools() + self.generate(self.nodes[0], 2) + if __name__ == '__main__': InstantSendTest().main() From ecd5c907c1af42a05ac20ac1a64d0c89f892b2cc Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Sun, 22 Mar 2026 02:34:47 +0300 Subject: [PATCH 3/4] refactor: defer active_ctx block tip init until after nodeman->Init() Move InitializeCurrentBlockTip for active_ctx (masternode mode) to after nodeman->Init() so that GetProTxHash() is guaranteed to be set before EnsureQuorumConnections runs. Currently this works by accident: UpdatedBlockTip calls Init() internally when the MN state is not READY. But this is a non-obvious side effect and would silently break quorum connections if someone refactored UpdatedBlockTip to add an early return. Also, if IsInitialBlockDownload() is true, UpdatedBlockTip returns early and Init() never runs, leaving a null proTxHash for quorum setup. Making the dependency explicit improves robustness without changing observable behavior in normal operation. Also add a MN-to-MN quorum connection assertion in the restart test to verify that quorum connections form between masternodes after restart, not just that IS locks work (which can succeed via concentrated sigshare sending over any authenticated connection). Co-Authored-By: Claude Opus 4.6 --- src/init.cpp | 13 ++++++++++--- test/functional/p2p_instantsend.py | 22 ++++++++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index 3352c17cf983..1a3656130f89 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2420,11 +2420,11 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) { const CBlockIndex* tip = WITH_LOCK(::cs_main, return chainman.ActiveTip()); const bool ibd = chainman.ActiveChainstate().IsInitialBlockDownload(); - if (node.active_ctx) { - node.active_ctx->InitializeCurrentBlockTip(tip, ibd); - } else if (node.observer_ctx) { + if (node.observer_ctx && !node.active_ctx) { node.observer_ctx->InitializeCurrentBlockTip(tip, ibd); } + // Note: active_ctx initialization is deferred until after nodeman->Init() + // so that GetProTxHash() is available for quorum connection setup. } { @@ -2490,6 +2490,13 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) if (node.active_ctx) { node.active_ctx->nodeman->Init(chainman.ActiveTip()); + // Initialize current block tip after nodeman->Init() so that + // GetProTxHash() is available for quorum connection setup. + // Without this ordering, EnsureQuorumConnections returns early + // because the null proTxHash makes the MN appear as a non-member. + const CBlockIndex* tip = WITH_LOCK(::cs_main, return chainman.ActiveTip()); + const bool ibd = chainman.ActiveChainstate().IsInitialBlockDownload(); + node.active_ctx->InitializeCurrentBlockTip(tip, ibd); } }); #ifdef ENABLE_WALLET diff --git a/test/functional/p2p_instantsend.py b/test/functional/p2p_instantsend.py index 749bf2162eb1..df40ba33a3e2 100755 --- a/test/functional/p2p_instantsend.py +++ b/test/functional/p2p_instantsend.py @@ -174,8 +174,10 @@ def test_instantsend_after_restart(self): for mn_info in self.mninfo: self.start_masternode(mn_info) - # reconnect: simple nodes to node 0, MNs to node 0 - # quorum connections are re-established automatically via InitializeCurrentBlockTip + # reconnect: simple nodes to node 0, MNs to node 0 only. + # Quorum connections between MNs must be re-established automatically + # via InitializeCurrentBlockTip → EnsureQuorumConnections, NOT via + # manual connect_nodes between MN pairs. for i in range(1, num_simple_nodes): self.connect_nodes(i, 0) for mn_info in self.mninfo: @@ -188,6 +190,22 @@ def test_instantsend_after_restart(self): self.bump_mocktime(10 * 60 + 1) self.sync_blocks() + # Verify that MNs formed quorum connections to other MNs after restart. + # InitializeCurrentBlockTip → EnsureQuorumConnections must populate + # masternodeQuorumNodes so ThreadOpenMasternodeConnections establishes + # MN-to-MN links beyond the manual connections to node 0. + self.log.info("Verifying MN-to-MN quorum connections formed after restart") + for mn_info in self.mninfo: + mn_node = self.nodes[mn_info.nodeIdx] + + def check_mn_peers(node=mn_node, my_hash=mn_info.proTxHash): + peers = node.getpeerinfo() + mn_peers = set(p['verified_proregtx_hash'] for p in peers + if p.get('verified_proregtx_hash', '') != '') + other_mn_peers = mn_peers - {my_hash} + return len(other_mn_peers) > 0 + self.wait_until(check_mn_peers, timeout=30) + # re-grab references after restart sender = self.nodes[self.sender_idx] receiver = self.nodes[self.receiver_idx] From 5a430037bd87a6211861ca3872614dc9cce52d00 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Sun, 22 Mar 2026 02:38:02 +0300 Subject: [PATCH 4/4] test: verify post-restart IS lock on all nodes, not just sender Co-Authored-By: Claude Opus 4.6 --- test/functional/p2p_instantsend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/p2p_instantsend.py b/test/functional/p2p_instantsend.py index df40ba33a3e2..b6ff0a364809 100755 --- a/test/functional/p2p_instantsend.py +++ b/test/functional/p2p_instantsend.py @@ -214,7 +214,8 @@ def check_mn_peers(node=mn_node, my_hash=mn_info.proTxHash): is_id = sender.sendtoaddress(receiver_addr, 0.5) self.bump_mocktime(30) self.sync_mempools() - self.wait_for_instantlock(is_id, sender) + for node in self.nodes: + self.wait_for_instantlock(is_id, node) self.log.info("InstantSend lock succeeded after full restart") # clean up