From a5b81dc5b468065b89cf0767742e927e6b70c380 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Tue, 20 Oct 2026 07:49:14 -0700 Subject: [PATCH 1/2] feat(dash-spv): expose the coinbase payload of the newest validated diff Platform needs the coinbase payload version of the tip to tell whether V24 is active, and fetched the block again over DAPI to get it. The masternode list engine already proves each diff's coinbase in its block, so it now keeps the payload of the highest one, and `DashSpvClient::latest_coinbase` returns it. Closes #1198 Co-Authored-By: Claude Opus 5.5 --- dash-spv/src/client/queries.rs | 8 ++++++++ dash-spv/src/sml_engine.rs | 30 +++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/dash-spv/src/client/queries.rs b/dash-spv/src/client/queries.rs index a56ee8150..ca264c674 100644 --- a/dash-spv/src/client/queries.rs +++ b/dash-spv/src/client/queries.rs @@ -14,6 +14,7 @@ use dashcore::hashes::Hash; use dashcore::sml::llmq_type::LLMQType; use dashcore::sml::masternode_list::MasternodeList; use dashcore::sml::quorum_entry::qualified_quorum_entry::QualifiedQuorumEntry; +use dashcore::transaction::special_transaction::coinbase::CoinbasePayload; use dashcore::{BlockHash, QuorumHash}; use key_wallet_manager::WalletInterface; use std::sync::Arc; @@ -67,6 +68,13 @@ impl DashSpvClient { engine.blocking_read().latest_masternode_list().cloned() } + /// The coinbase payload of the newest block a masternode list diff proved it + /// in, `None` while masternode sync is off or has validated none yet. + pub async fn latest_coinbase(&self) -> Option { + let engine = self.masternode_engine.as_ref()?; + engine.read().await.latest_coinbase().cloned() + } + /// Get a quorum entry by type and hash at a specific block height. A height /// whose lists the engine no longer holds is rebuilt from storage. /// Returns `SpvError::QuorumLookupError` if the quorum is not found. diff --git a/dash-spv/src/sml_engine.rs b/dash-spv/src/sml_engine.rs index 0434c0480..9e7d7efb8 100644 --- a/dash-spv/src/sml_engine.rs +++ b/dash-spv/src/sml_engine.rs @@ -22,9 +22,10 @@ use dashcore::sml::masternode_list::MasternodeList; use dashcore::sml::quorum_entry::qualified_quorum_entry::QualifiedQuorumEntry; use dashcore::sml::quorum_entry::qualified_quorum_entry::VerifyingChainLockSignaturesType; use dashcore::sml::quorum_validation_error::QuorumValidationError; +use dashcore::transaction::special_transaction::coinbase::CoinbasePayload; use dashcore::transaction::special_transaction::quorum_commitment::QuorumEntry; use dashcore::Network; -use dashcore::{BlockHash, QuorumHash}; +use dashcore::{BlockHash, QuorumHash, Transaction}; use tokio::sync::RwLock; /// Blocks between a rotation cycle's base and its work block (Dash Core's `WORK_DIFF_DEPTH`). @@ -59,6 +60,7 @@ pub struct MasternodeListEngine { pub masternode_lists: BTreeMap, rotated_quorums_per_cycle: BTreeMap>, network: Network, + latest_coinbase: Option, } /// The most quorums of `llmq_type` a run of `blocks` blocks can add: one per @@ -166,9 +168,15 @@ impl MasternodeListEngine { masternode_lists: BTreeMap::new(), rotated_quorums_per_cycle: BTreeMap::new(), network, + latest_coinbase: None, } } + /// The coinbase payload of the newest block a validated diff proved it in. + pub fn latest_coinbase(&self) -> Option<&CoinbasePayload> { + self.latest_coinbase.as_ref() + } + /// The height of `block_hash` in the header chain. The genesis block, and /// the all-zero hash standing for it, is at 0 even when the chain starts /// at a checkpoint. @@ -818,6 +826,7 @@ impl MasternodeListEngine { let masternode_list = MasternodeList::from_diff(masternode_list_diff, diff_end_height, self.network)?; self.masternode_lists.insert(diff_end_height, masternode_list); + self.observe_coinbase_if_higher(coinbase_tx); return Ok(()); } @@ -843,12 +852,28 @@ impl MasternodeListEngine { } } self.masternode_lists.insert(diff_end_height, masternode_list); + self.observe_coinbase_if_higher(coinbase_tx); for (quorum_type, quorum_hash, status) in changes { self.set_quorum_status(quorum_type, quorum_hash, &status); } Ok(()) } + fn observe_coinbase_if_higher(&mut self, coinbase_tx: Option) { + let Some(payload) = coinbase_tx + .and_then(|tx| tx.special_transaction_payload) + .and_then(|payload| payload.to_coinbase_payload().ok()) + else { + return; + }; + + if self.latest_coinbase.as_ref().is_some_and(|latest| latest.height > payload.height) { + return; + } + + self.latest_coinbase = Some(payload); + } + /// Verifies the newest list's unverified non-rotating quorums against their /// work-block lists (DIP-6). Rotated ones are verified per QRInfo. async fn verify_newest_quorums(&mut self) { @@ -1401,6 +1426,9 @@ mod tests { .await .expect("first QRInfo on a fresh engine must feed cleanly"); + let coinbase = engine.latest_coinbase().expect("observes the tip's coinbase"); + assert_eq!((coinbase.height, coinbase.version), (2518986, 3)); + assert_eq!(feed_result.rotated_quorum_count, 32); assert_eq!( feed_result.fully_verified_count, 32, From 0be8b1249f5e5468cb196da632bc8e4441541351 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Tue, 20 Oct 2026 08:47:40 -0700 Subject: [PATCH 2/2] fix(dash-spv): drop the latest coinbase above a followed fork The engine now drops lists built above a fork, but kept the coinbase of a dropped block as the latest one. It goes too when it is above the fork. Co-Authored-By: Claude Opus 5.5 --- dash-spv/src/sml_engine/helpers.rs | 17 ++++++++++++++++- dash/src/test_utils/transaction.rs | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/dash-spv/src/sml_engine/helpers.rs b/dash-spv/src/sml_engine/helpers.rs index 83f387aeb..815f214bb 100644 --- a/dash-spv/src/sml_engine/helpers.rs +++ b/dash-spv/src/sml_engine/helpers.rs @@ -103,6 +103,10 @@ impl MasternodeListEngine { } } self.rotated_quorums_per_cycle.retain(|hash, _| !dropped.contains(hash)); + + if self.latest_coinbase.as_ref().is_some_and(|coinbase| coinbase.height > height) { + self.latest_coinbase = None; + } } /// Sets the verification status of a quorum in every list holding it. @@ -426,7 +430,9 @@ mod prune_tests { use super::*; use crate::sml_engine::test_support::TestEngine; use dashcore::network::message_sml::MnListDiff; + use dashcore::transaction::special_transaction::coinbase::CoinbasePayload; use dashcore::{BlockHash, Network}; + use test_case::test_case; const TIP: CoreBlockHeight = 1_000_000; /// Within the window at `TIP` and below the next QRInfo's h-4c reach. @@ -463,8 +469,14 @@ mod prune_tests { /// the dropped blocks may answer for them: the lists above the fork go, and /// so does a rotated cycle whose base left the chain or whose commitments /// could have been mined above the fork. + #[test_case(TIP - 1, true; "keeps a coinbase below the fork")] + #[test_case(TIP, true; "keeps a coinbase at the fork")] + #[test_case(TIP + 1, false; "drops a coinbase above the fork")] #[tokio::test] - async fn truncating_above_a_fork_drops_what_the_dropped_blocks_built() { + async fn truncating_above_a_fork_drops_what_the_dropped_blocks_built( + coinbase_height: CoreBlockHeight, + coinbase_kept: bool, + ) { let fork = TIP; let window_end = Network::Mainnet.isd_llmq_type().params().dkg_params.mining_window_end; let mut engine = TestEngine::dummy_with_lists(&[fork - 100, fork, fork + 1, fork + 50]); @@ -478,9 +490,12 @@ mod prune_tests { for height in [fork + 1, left_the_chain, fork + 50] { engine.forget_block(&BlockHash::dummy(height)); } + engine.latest_coinbase = Some(CoinbasePayload::dummy(coinbase_height)); engine.truncate_above(fork).await; + assert_eq!(engine.latest_coinbase.is_some(), coinbase_kept); + assert_eq!(heights(&engine), vec![fork - 100, fork]); assert_eq!( engine.rotated_quorums_per_cycle.keys().collect::>(), diff --git a/dash/src/test_utils/transaction.rs b/dash/src/test_utils/transaction.rs index 53acd33f5..c51178625 100644 --- a/dash/src/test_utils/transaction.rs +++ b/dash/src/test_utils/transaction.rs @@ -1,5 +1,9 @@ use std::ops::Range; +use hashes::Hash; + +use crate::hash_types::{MerkleRootMasternodeList, MerkleRootQuorums}; +use crate::transaction::special_transaction::coinbase::CoinbasePayload; use crate::{Address, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; /// A special transaction type no Dash Core version assigns @@ -76,3 +80,19 @@ impl Transaction { } } } + +impl CoinbasePayload { + /// A current version payload at `height` committing to empty lists. + pub fn dummy(height: u32) -> Self { + CoinbasePayload { + version: CoinbasePayload::CURRENT_VERSION, + height, + merkle_root_masternode_list: MerkleRootMasternodeList::all_zeros(), + merkle_root_quorums: MerkleRootQuorums::all_zeros(), + best_cl_height: None, + best_cl_signature: None, + asset_locked_amount: None, + merkle_root_asset_unlocks: None, + } + } +}