diff --git a/dash/src/sml/address.rs b/dash/src/sml/address.rs index 5eafec8e8..8ec1757ce 100644 --- a/dash/src/sml/address.rs +++ b/dash/src/sml/address.rs @@ -40,7 +40,7 @@ impl Decodable for SocketAddr { let ipv6 = Ipv6Addr::from_bits(ip); - if let Some(ipv4) = ipv6.to_ipv4() { + if let Some(ipv4) = ipv6.to_ipv4_mapped() { Ok(SocketAddr::V4(SocketAddrV4::new(ipv4, port))) } else { Ok(SocketAddr::V6(SocketAddrV6::new(ipv6, port, 0, 0))) @@ -86,6 +86,20 @@ mod tests { assert_eq!(writer, decoded_writer); } + #[test] + fn encode_decode_unspecified_preserves_bytes() { + // An all-zero (`::`) address must round-trip to the same 16 zero bytes. Decoding it as + // IPv4 `0.0.0.0` would re-encode with the `::ffff:` mapped prefix and corrupt the bytes, + // which in turn breaks the masternode entry hash for entries with an unset service. + let original = [0u8; 18]; + let mut reader = &original[..]; + let decoded = SocketAddr::consensus_decode(&mut reader).unwrap(); + + let mut writer = Vec::new(); + decoded.consensus_encode(&mut writer).unwrap(); + assert_eq!(writer, original); + } + #[test] fn encode_decode_ipv6() { let address = SocketAddr::V6(SocketAddrV6::new( diff --git a/dash/src/sml/error.rs b/dash/src/sml/error.rs index a8f71bfe0..bc05e66f8 100644 --- a/dash/src/sml/error.rs +++ b/dash/src/sml/error.rs @@ -3,6 +3,7 @@ use bincode::{Decode, Encode}; use thiserror::Error; use crate::BlockHash; +use crate::hash_types::MerkleRootMasternodeList; #[derive(Debug, Error, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] @@ -50,4 +51,16 @@ pub enum SmlError { /// Error indicating the quorum signature set is incomplete (some slots were not filled). #[error("Incomplete quorum signature set; not all slots were filled")] IncompleteSignatureSet, + + /// The recomputed masternode list Merkle root does not match the root committed in the + /// coinbase transaction, so the diff must be rejected and the list must not advance. + #[error( + "Masternode list Merkle root mismatch at height {block_height} (block {block_hash}): coinbase committed {expected}, recomputed {calculated}" + )] + MasternodeListRootMismatch { + block_hash: BlockHash, + block_height: u32, + expected: MerkleRootMasternodeList, + calculated: MerkleRootMasternodeList, + }, } diff --git a/dash/src/sml/masternode_list/apply_diff.rs b/dash/src/sml/masternode_list/apply_diff.rs index 0f0e826a0..4766a6e23 100644 --- a/dash/src/sml/masternode_list/apply_diff.rs +++ b/dash/src/sml/masternode_list/apply_diff.rs @@ -161,29 +161,68 @@ impl MasternodeList { diff_end_height, ); - Ok((builder.build(), rotating_sig)) + let updated_list = builder.build(); + + updated_list.validate_mn_list_root(&diff.coinbase_tx, diff_end_height)?; + + Ok((updated_list, rotating_sig)) } } #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; use crate::consensus::deserialize; use crate::sml::masternode_list::from_diff::TryFromWithBlockHashLookup; - #[test] - fn apply_diff_post_v20_requires_chainlock_signatures() { - // Create base list from first diff + /// Builds a base list from the from-genesis capture fixture, rewriting its coinbase root so it + /// passes production validation (the captured subset does not reproduce the mainnet full-list + /// root the fixture commits to). + fn consistent_base_list(height: u32) -> MasternodeList { let base_diff_bytes: &[u8] = include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); - let base_diff: MnListDiff = deserialize(base_diff_bytes).expect("expected to deserialize"); - - let base_list = MasternodeList::try_from_with_block_hash_lookup( + let mut base_diff: MnListDiff = + deserialize(base_diff_bytes).expect("expected to deserialize"); + let masternodes = base_diff + .new_masternodes + .iter() + .map(|entry| (entry.pro_reg_tx_hash.reverse(), entry.clone().into())) + .collect(); + let assembled = + MasternodeList::build(masternodes, BTreeMap::new(), base_diff.block_hash, height) + .build(); + MasternodeList::rewrite_coinbase_mn_list_root( + &mut base_diff.coinbase_tx, + &assembled, + height, + ); + MasternodeList::try_from_with_block_hash_lookup( base_diff, - |_| Some(2_227_096), + |_| Some(height), Network::Mainnet, ) - .expect("expected to create base list"); + .expect("expected to create base list") + } + + /// Rewrites a diff's coinbase root to the value applying it on top of `base` produces. + fn make_diff_consistent(base: &MasternodeList, diff: &mut MnListDiff, height: u32) { + let mut masternodes = base.masternodes.clone(); + for pro_tx_hash in &diff.deleted_masternodes { + masternodes.remove(&pro_tx_hash.reverse()); + } + for new_mn in &diff.new_masternodes { + masternodes.insert(new_mn.pro_reg_tx_hash.reverse(), new_mn.clone().into()); + } + let assembled = + MasternodeList::build(masternodes, BTreeMap::new(), diff.block_hash, height).build(); + MasternodeList::rewrite_coinbase_mn_list_root(&mut diff.coinbase_tx, &assembled, height); + } + + #[test] + fn apply_diff_post_v20_requires_chainlock_signatures() { + let base_list = consistent_base_list(2_227_096); // Load second diff and clear signatures let diff_bytes: &[u8] = @@ -205,18 +244,8 @@ mod tests { #[test] fn apply_diff_pre_v20_allows_missing_chainlock_signatures() { - // Create base list from first diff at pre-V20 height - let base_diff_bytes: &[u8] = - include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); - let base_diff: MnListDiff = deserialize(base_diff_bytes).expect("expected to deserialize"); - let base_height = 1_800_000u32; - let base_list = MasternodeList::try_from_with_block_hash_lookup( - base_diff, - |_| Some(base_height), - Network::Mainnet, - ) - .expect("expected to create base list"); + let base_list = consistent_base_list(base_height); // Load second diff and clear signatures let diff_bytes: &[u8] = @@ -231,6 +260,8 @@ mod tests { let pre_v20_height = 1_900_000u32; assert!(pre_v20_height < Network::Mainnet.v20_activation_height()); + make_diff_consistent(&base_list, &mut diff, pre_v20_height); + let result = base_list.apply_diff(diff, pre_v20_height, None, Network::Mainnet); assert!( @@ -239,4 +270,24 @@ mod tests { result.err() ); } + + #[test] + fn apply_diff_rejects_coinbase_mn_list_root_mismatch() { + let base_list = consistent_base_list(1_900_000); + + let diff_bytes: &[u8] = + include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_2227096_2241332.bin"); + let mut diff: MnListDiff = deserialize(diff_bytes).expect("expected to deserialize"); + diff.base_block_hash = base_list.block_hash; + diff.quorums_chainlock_signatures.clear(); + + // No coinbase-root rewrite: the fixture's committed root must not match the assembled list. + let result = base_list.apply_diff(diff, 1_900_000, None, Network::Mainnet); + + assert!( + matches!(result, Err(SmlError::MasternodeListRootMismatch { .. })), + "apply_diff must reject a diff whose coinbase root disagrees with the assembled list: {:?}", + result + ); + } } diff --git a/dash/src/sml/masternode_list/from_diff.rs b/dash/src/sml/masternode_list/from_diff.rs index 54c62e450..99c199ca7 100644 --- a/dash/src/sml/masternode_list/from_diff.rs +++ b/dash/src/sml/masternode_list/from_diff.rs @@ -87,6 +87,8 @@ impl TryFromWithBlockHashLookup for MasternodeList { return Err(SmlError::IncompleteMnListDiff); } + let coinbase_tx = diff.coinbase_tx.clone(); + // Populate masternode and quorum maps let masternodes = diff .new_masternodes @@ -143,15 +145,14 @@ impl TryFromWithBlockHashLookup for MasternodeList { }, ); - // Construct `MasternodeList` - Ok(MasternodeList { - block_hash: diff.block_hash, - known_height, - masternode_merkle_root: diff.merkle_hashes.first().cloned(), - llmq_merkle_root: None, // Adjust based on real data availability - masternodes, - quorums, - }) + // Construct `MasternodeList`, recomputing the Merkle roots over the assembled entry set + // instead of trusting any value carried in the diff. + let masternode_list = + MasternodeList::build(masternodes, quorums, diff.block_hash, known_height).build(); + + masternode_list.validate_mn_list_root(&coinbase_tx, known_height)?; + + Ok(masternode_list) } } @@ -160,6 +161,22 @@ mod tests { use super::*; use crate::consensus::deserialize; + /// Rewrites the diff's coinbase masternode list root to match the list its own + /// `new_masternodes` set produces. The from-genesis capture fixture commits a root over the + /// full mainnet list which the captured subset does not reproduce, so without this it would be + /// rejected by root validation even though the unrelated chainlock-signature behaviour under + /// test is correct. + fn make_from_genesis_diff_consistent(diff: &mut MnListDiff, height: u32) { + let masternodes = diff + .new_masternodes + .iter() + .map(|entry| (entry.pro_reg_tx_hash.reverse(), entry.clone().into())) + .collect(); + let assembled = + MasternodeList::build(masternodes, BTreeMap::new(), diff.block_hash, height).build(); + MasternodeList::rewrite_coinbase_mn_list_root(&mut diff.coinbase_tx, &assembled, height); + } + #[test] fn post_v20_requires_chainlock_signatures() { let mn_list_diff_bytes: &[u8] = @@ -200,6 +217,8 @@ mod tests { let pre_v20_height = 1_900_000; assert!(pre_v20_height < Network::Mainnet.v20_activation_height()); + make_from_genesis_diff_consistent(&mut diff, pre_v20_height); + let result = MasternodeList::try_from_with_block_hash_lookup( diff, |_| Some(pre_v20_height), @@ -212,4 +231,25 @@ mod tests { result.err() ); } + + #[test] + fn rejects_coinbase_mn_list_root_mismatch() { + let mn_list_diff_bytes: &[u8] = + include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); + let diff: MnListDiff = deserialize(mn_list_diff_bytes).expect("expected to deserialize"); + + // The capture fixture's coinbase commits a root over the full mainnet list that the + // captured `new_masternodes` subset does not reproduce, so building it must hard-reject. + let result = MasternodeList::try_from_with_block_hash_lookup( + diff, + |_| Some(1_900_000), + Network::Mainnet, + ); + + assert!( + matches!(result, Err(SmlError::MasternodeListRootMismatch { .. })), + "a diff whose coinbase root disagrees with its entry set must be rejected: {:?}", + result + ); + } } diff --git a/dash/src/sml/masternode_list/merkle_roots.rs b/dash/src/sml/masternode_list/merkle_roots.rs index ba12c7680..96ee53cf5 100644 --- a/dash/src/sml/masternode_list/merkle_roots.rs +++ b/dash/src/sml/masternode_list/merkle_roots.rs @@ -2,6 +2,7 @@ use hashes::{Hash, sha256d}; use crate::Transaction; use crate::hash_types::{MerkleRootMasternodeList, MerkleRootQuorums}; +use crate::sml::error::SmlError; use crate::sml::masternode_list::MasternodeList; use crate::transaction::special_transaction::TransactionPayload; @@ -44,31 +45,68 @@ pub fn merkle_root_from_hashes(hashes: Vec) -> Option bool { - let Some(TransactionPayload::CoinbasePayloadType(coinbase_payload)) = - &coinbase_transaction.special_transaction_payload - else { - return false; - }; - // we need to check that the coinbase is in the transaction hashes we got back - // and is in the merkle block - if let Some(mn_merkle_root) = self.masternode_merkle_root { - coinbase_payload.merkle_root_masternode_list == mn_merkle_root + /// Extracts the masternode list Merkle root committed in a coinbase transaction's payload. + fn coinbase_mn_list_root( + coinbase_transaction: &Transaction, + ) -> Option { + match &coinbase_transaction.special_transaction_payload { + Some(TransactionPayload::CoinbasePayloadType(coinbase_payload)) => { + Some(coinbase_payload.merkle_root_masternode_list) + } + _ => None, + } + } + + /// Recomputes the masternode list Merkle root over the fully assembled list and verifies it + /// against the root committed in the coinbase transaction. The list must never be advanced on + /// mismatch. + /// + /// The recomputation is done from scratch over the current entry set rather than trusting any + /// stored root, so this is the authoritative check applied right after a list is built from a + /// diff. + pub fn validate_mn_list_root( + &self, + coinbase_transaction: &Transaction, + block_height: u32, + ) -> Result<(), SmlError> { + let expected = Self::coinbase_mn_list_root(coinbase_transaction) + .ok_or(SmlError::IncompleteMnListDiff)?; + + let calculated = self + .calculate_masternodes_merkle_root(block_height) + .unwrap_or_else(|| MerkleRootMasternodeList::from_raw_hash(sha256d::Hash::all_zeros())); + + if expected == calculated { + Ok(()) } else { - false + Err(SmlError::MasternodeListRootMismatch { + block_hash: self.block_hash, + block_height, + expected, + calculated, + }) + } + } + + /// Overwrites the masternode list Merkle root committed in a diff's coinbase transaction with + /// the value that recomputing over `assembled` produces. The historical capture fixtures in + /// this crate carry coinbase roots that were computed by Dash Core over a full list which the + /// captured `new_masternodes` set does not reproduce, so they would otherwise be rejected by + /// the production root validation. This makes such a fixture self-consistent for tests that + /// exercise unrelated quorum and chainlock logic. + #[cfg(test)] + pub(crate) fn rewrite_coinbase_mn_list_root( + coinbase_transaction: &mut Transaction, + assembled: &MasternodeList, + block_height: u32, + ) { + let root = assembled + .calculate_masternodes_merkle_root(block_height) + .unwrap_or_else(|| MerkleRootMasternodeList::from_raw_hash(sha256d::Hash::all_zeros())); + if let Some(TransactionPayload::CoinbasePayloadType(payload)) = + &mut coinbase_transaction.special_transaction_payload + { + payload.merkle_root_masternode_list = root; } } diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 42f1b35ff..cf5392ade 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -1324,6 +1324,87 @@ impl MasternodeListEngine { Ok(rotation_sig) } + /// Rewrites a diff's coinbase masternode list Merkle root to the value that applying it on top + /// of the engine's current state would produce, so the historical capture fixtures in this + /// crate pass production root validation when used to drive unrelated quorum and chainlock + /// tests. The base list is resolved exactly as [`Self::apply_diff`] would: an empty list for a + /// from-genesis diff, otherwise the stored list at the base block. + #[cfg(test)] + pub(crate) fn make_diff_coinbase_root_consistent( + &self, + diff: &mut MnListDiff, + diff_end_height: CoreBlockHeight, + ) { + let from_genesis = self + .network + .known_genesis_block_hash() + .or_else(|| self.block_container.get_hash(&0).cloned()) + .is_some_and(|genesis| { + diff.base_block_hash == genesis || diff.base_block_hash.as_byte_array() == &[0; 32] + }); + + let mut masternodes = if from_genesis { + BTreeMap::new() + } else { + let base_height = self + .block_container + .get_height(&diff.base_block_hash) + .expect("base height present for non-genesis diff in test fixture"); + self.masternode_lists + .get(&base_height) + .expect("base masternode list present in test fixture") + .masternodes + .clone() + }; + + for pro_tx_hash in &diff.deleted_masternodes { + masternodes.remove(&pro_tx_hash.reverse()); + } + for new_mn in &diff.new_masternodes { + masternodes.insert(new_mn.pro_reg_tx_hash.reverse(), new_mn.clone().into()); + } + + let assembled = + MasternodeList::build(masternodes, BTreeMap::new(), diff.block_hash, diff_end_height) + .build(); + + MasternodeList::rewrite_coinbase_mn_list_root( + &mut diff.coinbase_tx, + &assembled, + diff_end_height, + ); + } + + /// Rewrites the coinbase masternode list roots of every diff carried by a `QRInfo` so the whole + /// response passes production root validation when fed to [`Self::feed_qr_info`]. Each diff is + /// made consistent against the engine state that `feed_qr_info` would have when it applies that + /// diff, by replaying the same apply order on a clone of this engine. + #[cfg(test)] + pub(crate) fn make_qr_info_coinbase_roots_consistent(&self, qr_info: &mut QRInfo) { + let mut replay = self.clone(); + + let patch = |replay: &mut Self, diff: &mut MnListDiff| { + let height = replay + .block_container + .get_height(&diff.block_hash) + .expect("diff block height present in test fixture"); + replay.make_diff_coinbase_root_consistent(diff, height); + replay.apply_diff(diff.clone(), Some(height), false, None).expect("replay apply_diff"); + }; + + for diff in &mut qr_info.mn_list_diff_list { + patch(&mut replay, diff); + } + if let Some((_, diff)) = &mut qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { + patch(&mut replay, diff); + } + patch(&mut replay, &mut qr_info.mn_list_diff_at_h_minus_3c); + patch(&mut replay, &mut qr_info.mn_list_diff_at_h_minus_2c); + patch(&mut replay, &mut qr_info.mn_list_diff_at_h_minus_c); + patch(&mut replay, &mut qr_info.mn_list_diff_h); + patch(&mut replay, &mut qr_info.mn_list_diff_tip); + } + /// Verifies non-rotating quorums in a masternode list at a specific block height. /// /// This function is only available when the `quorum_validation` feature is enabled. @@ -1475,6 +1556,27 @@ mod tests { use crate::sml::quorum_validation_error::QuorumValidationError; use std::collections::BTreeMap; + /// Initializes an engine from the from-genesis capture fixture, rewriting the diff's coinbase + /// masternode list root to the value its own entry set produces. The fixture commits a root + /// over the full mainnet list which the captured subset does not reproduce, so without this the + /// production root validation would reject it before any quorum or chainlock logic runs. + fn engine_from_consistent_genesis_diff(height: CoreBlockHeight) -> MasternodeListEngine { + let mn_list_diff_bytes: &[u8] = + include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); + let mut diff: MnListDiff = + deserialize(mn_list_diff_bytes).expect("expected to deserialize"); + let masternodes = diff + .new_masternodes + .iter() + .map(|entry| (entry.pro_reg_tx_hash.reverse(), entry.clone().into())) + .collect(); + let assembled = + MasternodeList::build(masternodes, BTreeMap::new(), diff.block_hash, height).build(); + MasternodeList::rewrite_coinbase_mn_list_root(&mut diff.coinbase_tx, &assembled, height); + MasternodeListEngine::initialize_with_diff_to_height(diff, height, Network::Mainnet) + .expect("expected to start engine") + } + #[cfg(feature = "quorum_validation")] use { super::build_cycle_quorum_map, @@ -1593,20 +1695,16 @@ mod tests { #[test] fn validate_from_mn_list_diff_chain_locks() { - let mn_list_diff_bytes: &[u8] = - include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); - // This one is serialized not with bincode, but with core consensus - let diff: MnListDiff = deserialize(mn_list_diff_bytes).expect("expected to deserialize"); - let mut masternode_list_engine = - MasternodeListEngine::initialize_with_diff_to_height(diff, 2227096, Network::Mainnet) - .expect("expected to start engine"); + let mut masternode_list_engine = engine_from_consistent_genesis_diff(2227096); let mn_list_diff_bytes_2: &[u8] = include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_2227096_2241332.bin"); // This one is serialized not with bincode, but with core consensus - let diff_2: MnListDiff = + let mut diff_2: MnListDiff = deserialize(mn_list_diff_bytes_2).expect("expected to deserialize"); + masternode_list_engine.make_diff_coinbase_root_consistent(&mut diff_2, 2241332); + masternode_list_engine .apply_diff(diff_2, Some(2241332), false, None) .expect("expected to apply diff"); @@ -1679,12 +1777,7 @@ mod tests { #[cfg(feature = "quorum_validation")] fn load_qrinfo_2240504_fixture() -> (MasternodeListEngine, QRInfo) { - let mn_list_diff_bytes: &[u8] = - include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); - let diff: MnListDiff = deserialize(mn_list_diff_bytes).expect("expected to deserialize"); - let mut engine = - MasternodeListEngine::initialize_with_diff_to_height(diff, 2227096, Network::Mainnet) - .expect("expected to start engine"); + let mut engine = engine_from_consistent_genesis_diff(2227096); let block_container_bytes: &[u8] = include_bytes!("../../../tests/data/test_DML_diffs/block_container_2240504.dat"); @@ -1700,16 +1793,21 @@ mod tests { .0; let qr_info_bytes: &[u8] = include_bytes!("../../../tests/data/test_DML_diffs/qrinfo_2240504.dat"); - let qr_info: QRInfo = + let mut qr_info: QRInfo = bincode::decode_from_slice(qr_info_bytes, bincode::config::standard()) .expect("expected to decode") .0; engine.block_container = block_container; - for ((_start_height, height), diff) in mn_list_diffs.into_iter() { + // The capture fixtures predate root validation; rewrite each diff's coinbase root to the + // value it produces against the running state before applying, in ascending height order. + for ((_start_height, height), mut diff) in mn_list_diffs.into_iter() { + engine.make_diff_coinbase_root_consistent(&mut diff, height); engine.apply_diff(diff, Some(height), false, None).expect("expected to apply diff"); } + engine.make_qr_info_coinbase_roots_consistent(&mut qr_info); + (engine, qr_info) } diff --git a/dash/src/sml/masternode_list_entry/hash.rs b/dash/src/sml/masternode_list_entry/hash.rs index 51f5932cf..dae58fd15 100644 --- a/dash/src/sml/masternode_list_entry/hash.rs +++ b/dash/src/sml/masternode_list_entry/hash.rs @@ -1,13 +1,58 @@ use hashes::{Hash, sha256d}; -use crate::consensus::Encodable; use crate::sml::masternode_list_entry::MasternodeListEntry; impl MasternodeListEntry { pub fn calculate_entry_hash(&self) -> sha256d::Hash { let mut writer = Vec::new(); - - self.consensus_encode(&mut writer).expect("encoding failed"); + self.consensus_encode_body(&mut writer).expect("encoding failed"); sha256d::Hash::hash(&writer) } } + +#[cfg(test)] +mod tests { + use hashes::Hash; + + use crate::consensus::deserialize; + use crate::network::message_sml::MnListDiff; + + // Ground-truth entry hashes produced by Dash Core's `CSimplifiedMNListEntry::CalcHash` + // (`CHashWriter(SER_GETHASH, ...)`) for the matching entries in this fixture. `SER_GETHASH` + // omits the `SER_NETWORK`-gated leading `version`, so the pre-image is the wire body without + // that field. Hashing the full wire (with `version`) yields different values and fails here. + // The first case is a `version` 1 entry, the second a `version` 2 Evo entry, exercising both + // the legacy path and the `nType`/platform fields. + #[test] + fn entry_hash_matches_core_calc_hash() { + let bytes: &[u8] = + include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); + let diff: MnListDiff = deserialize(bytes).expect("expected to deserialize"); + + let cases = [ + ( + "0008858d870b0aa7967c39a551fc953e4e7fa602f19ba1fc805c218f87f41cb6", + "759c929f9d225554a09a8ad817bfaf555847547097495e08d3ba316529b65426", + ), + ( + "000c898c950a9c4a4d1eb3c227ab6d65ab652b44010e25f6dbe7a673e4bb52de", + "045c5f8ae528d32d0e694ddb9d652794d41b89db5f7eaee703beef62b35e4903", + ), + ]; + + for (pro_reg_tx_hash_hex, expected_entry_hash_hex) in cases { + let entry = diff + .new_masternodes + .iter() + .find(|e| hex::encode(e.pro_reg_tx_hash.to_byte_array()) == pro_reg_tx_hash_hex) + .expect("expected entry present in fixture"); + + assert_eq!( + hex::encode(entry.calculate_entry_hash().to_byte_array()), + expected_entry_hash_hex, + "entry hash for {} must match Dash Core's CalcHash", + pro_reg_tx_hash_hex + ); + } + } +} diff --git a/dash/src/sml/masternode_list_entry/mod.rs b/dash/src/sml/masternode_list_entry/mod.rs index 72ca128f0..e4feeaf74 100644 --- a/dash/src/sml/masternode_list_entry/mod.rs +++ b/dash/src/sml/masternode_list_entry/mod.rs @@ -111,10 +111,17 @@ impl PartialOrd for MasternodeListEntry { } } -impl Encodable for MasternodeListEntry { - fn consensus_encode(&self, writer: &mut W) -> Result { +impl MasternodeListEntry { + /// Encodes everything after the leading `version`, shared by the wire format and the hash + /// pre-image. Core's `CSimplifiedMNListEntry::CalcHash` uses `CHashWriter(SER_GETHASH, ...)`, + /// and `SER_GETHASH` does not set `SER_NETWORK`, so the leading `version` (which is + /// `SER_NETWORK`-gated in Core's `SERIALIZE_METHODS`) is excluded from the hash but present on + /// the wire. Every remaining field keys off the `version` member, identical in both contexts. + fn consensus_encode_body( + &self, + writer: &mut W, + ) -> Result { let mut len = 0; - len += self.version.consensus_encode(writer)?; len += self.pro_reg_tx_hash.consensus_encode(writer)?; if let Some(confirmed_hash) = self.confirmed_hash { len += confirmed_hash.consensus_encode(writer)?; @@ -132,6 +139,15 @@ impl Encodable for MasternodeListEntry { } } +impl Encodable for MasternodeListEntry { + fn consensus_encode(&self, writer: &mut W) -> Result { + let mut len = 0; + len += self.version.consensus_encode(writer)?; + len += self.consensus_encode_body(writer)?; + Ok(len) + } +} + impl Decodable for MasternodeListEntry { fn consensus_decode(reader: &mut R) -> Result { let version: u16 = Decodable::consensus_decode(reader)?;