diff --git a/dash-spv/src/client/queries.rs b/dash-spv/src/client/queries.rs index 4b773929e..1d6073723 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::QuorumHash; use key_wallet_manager::WalletInterface; use std::sync::Arc; @@ -62,6 +63,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. /// /// Only heights the engine holds lists for resolve. It keeps lists within diff --git a/dash-spv/src/sml_engine.rs b/dash-spv/src/sml_engine.rs index f24e9a1a0..109b62c1e 100644 --- a/dash-spv/src/sml_engine.rs +++ b/dash-spv/src/sml_engine.rs @@ -25,9 +25,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`). @@ -98,6 +99,7 @@ pub struct MasternodeListEngine { rotated_quorums_per_cycle: BTreeMap>, network: Network, validator: Option>, + latest_coinbase: Option, } /// The most quorums of `llmq_type` a run of `blocks` blocks can add: one per @@ -193,16 +195,17 @@ fn cycle_quarter_work_heights( .map(|cycles_back: u32| cycle_base.checked_sub(cycles_back * interval + WORK_DIFF_DEPTH)) } -/// An engine's lists and rotated cycles. +/// An engine's lists, rotated cycles and latest coinbase. type ListsAndCycles = ( BTreeMap, BTreeMap>, + Option, ); -/// The lists and rotated cycles of an engine as a QRInfo feed found them, put -/// back when this drops unless the feed's changes are kept: a feed that fails -/// or is dropped part way leaves the engine as it was. The copy shares the -/// lists' maps; the few stored cycles are copied. +/// The lists, rotated cycles and latest coinbase of an engine as a QRInfo feed +/// found them, put back when this drops unless the feed's changes are kept: a +/// feed that fails or is dropped part way leaves the engine as it was. The copy +/// shares the lists' maps; the few stored cycles are copied. struct QRInfoRollback<'a, H: BlockHeaderStorage> { engine: &'a mut MasternodeListEngine, saved: Option, @@ -210,7 +213,11 @@ struct QRInfoRollback<'a, H: BlockHeaderStorage> { impl<'a, H: BlockHeaderStorage> QRInfoRollback<'a, H> { fn new(engine: &'a mut MasternodeListEngine) -> Self { - let saved = (engine.masternode_lists.clone(), engine.rotated_quorums_per_cycle.clone()); + let saved = ( + engine.masternode_lists.clone(), + engine.rotated_quorums_per_cycle.clone(), + engine.latest_coinbase.clone(), + ); Self { engine, saved: Some(saved), @@ -225,9 +232,12 @@ impl<'a, H: BlockHeaderStorage> QRInfoRollback<'a, H> { impl Drop for QRInfoRollback<'_, H> { fn drop(&mut self) { - if let Some((masternode_lists, rotated_quorums_per_cycle)) = self.saved.take() { + if let Some((masternode_lists, rotated_quorums_per_cycle, latest_coinbase)) = + self.saved.take() + { self.engine.masternode_lists = masternode_lists; self.engine.rotated_quorums_per_cycle = rotated_quorums_per_cycle; + self.engine.latest_coinbase = latest_coinbase; } } } @@ -255,15 +265,23 @@ impl MasternodeListEngine { rotated_quorums_per_cycle: BTreeMap::new(), network, validator, + latest_coinbase: None, } } - /// The engine's lists and rotated cycles as bytes, see [`Self::from_snapshot`]. + /// 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 engine's lists, rotated cycles and latest coinbase as bytes, see + /// [`Self::from_snapshot`]. pub fn snapshot(&self) -> Result, bincode::error::EncodeError> { bincode::encode_to_vec(self, snapshot_codec::config()) } - /// An engine holding the lists and rotated cycles [`Self::snapshot`] encoded. + /// An engine holding the lists, rotated cycles and latest coinbase + /// [`Self::snapshot`] encoded. pub fn from_snapshot( network: Network, headers: Arc>, @@ -1061,6 +1079,7 @@ impl MasternodeListEngine { } self.masternode_lists.insert(diff_end_height, masternode_list); + self.observe_coinbase_if_higher(coinbase_tx); return Ok(()); } @@ -1091,12 +1110,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) { @@ -1880,6 +1915,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, diff --git a/dash-spv/src/sml_engine/helpers.rs b/dash-spv/src/sml_engine/helpers.rs index e81f61a75..260ea1726 100644 --- a/dash-spv/src/sml_engine/helpers.rs +++ b/dash-spv/src/sml_engine/helpers.rs @@ -100,6 +100,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. @@ -420,7 +424,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. @@ -457,8 +463,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().dkg_window_at_cycle(0).mining_end; let mut engine = TestEngine::dummy_with_lists(&[fork - 100, fork, fork + 1, fork + 50]); @@ -472,9 +484,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-spv/src/sml_engine/snapshot_codec.rs b/dash-spv/src/sml_engine/snapshot_codec.rs index 556af1a2e..942140777 100644 --- a/dash-spv/src/sml_engine/snapshot_codec.rs +++ b/dash-spv/src/sml_engine/snapshot_codec.rs @@ -17,6 +17,7 @@ use dashcore::sml::llmq_type::LLMQType; use dashcore::sml::masternode_list::{MasternodeList, MasternodeMap, QuorumMap}; use dashcore::sml::masternode_list_entry::qualified_masternode_list_entry::QualifiedMasternodeListEntry; use dashcore::sml::quorum_entry::qualified_quorum_entry::QualifiedQuorumEntry; +use dashcore::transaction::special_transaction::coinbase::CoinbasePayload; use dashcore::{BlockHash, Network, ProTxHash, QuorumHash}; use tokio::sync::RwLock; @@ -24,7 +25,7 @@ use super::MasternodeListEngine; use crate::storage::BlockHeaderStorage; const MAGIC: [u8; 4] = *b"DMLE"; -const VERSION: u8 = 1; +const VERSION: u8 = 2; /// Bounds what a corrupt length prefix can make the decoder allocate up front: /// bincode claims `len * size_of::()` against it before preallocating. @@ -264,7 +265,8 @@ impl Encode for MasternodeListEngine { masternodes.values.encode(encoder)?; quorums.values.encode(encoder)?; records.encode(encoder)?; - self.rotated_quorums_per_cycle.encode(encoder) + self.rotated_quorums_per_cycle.encode(encoder)?; + self.latest_coinbase.encode(encoder) } } @@ -273,6 +275,7 @@ type Decoded = ( Vec, Vec, BTreeMap>, + Option, ); fn entry(entries: &[Arc], index: u32) -> Result, DecodeError> { @@ -337,8 +340,13 @@ impl Decode<(Network, Arc>)> for MasternodeList if Network::decode(decoder)? != network { return Err(DecodeError::Other("snapshot of another network")); } - let (masternode_entries, quorum_entries, records, rotated_quorums_per_cycle) = - Decoded::decode(decoder)?; + let ( + masternode_entries, + quorum_entries, + records, + rotated_quorums_per_cycle, + latest_coinbase, + ) = Decoded::decode(decoder)?; let masternode_entries: Vec<_> = masternode_entries.into_iter().map(Arc::new).collect(); let quorum_entries: Vec<_> = quorum_entries.into_iter().map(Arc::new).collect(); @@ -377,6 +385,7 @@ impl Decode<(Network, Arc>)> for MasternodeList Ok(Self { masternode_lists, rotated_quorums_per_cycle, + latest_coinbase, ..Self::new(network, headers) }) } 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, + } + } +}