From 2752fcb9d4183d540fa763a56b4b1d11506af6ae Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 3 Nov 2026 19:21:10 +0700 Subject: [PATCH 1/5] test(dash): pin the roots of a coinbase payload of a newer version The transaction decoder keeps a coinbase payload raw when its layout is newer than this crate decodes. That is the case for a later CbTx version that appends fields. `verify_coinbase_roots` refuses every coinbase whose payload is not typed, so the first block with such a layout would get every masternode list refused, on every network that activates it. `Transaction::with_newer_coinbase_payload` writes a coinbase's payload the way such a version would: the version 4 fields under version 5, plus a field of its own. - The roots of the result must be checked like those of a typed payload, and a payload off either root must be refused. - A raw payload of a version this crate decodes is refused. So is a raw payload of another transaction type. - A raw payload cut short inside its quorum root is refused, even against a list without quorums, whose root is all zeros. `verify_coinbase_roots_of_a_raw_payload` "of a newer version" fails on dev with `CoinbaseCommitmentMismatch`. The next commit fixes it. Co-Authored-By: Claude Opus 5.5 --- dash/src/sml/masternode_list/merkle_roots.rs | 92 +++++++++++++++++++- dash/src/test_utils/transaction.rs | 32 +++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/dash/src/sml/masternode_list/merkle_roots.rs b/dash/src/sml/masternode_list/merkle_roots.rs index 4ba79c53b..917e6580c 100644 --- a/dash/src/sml/masternode_list/merkle_roots.rs +++ b/dash/src/sml/masternode_list/merkle_roots.rs @@ -159,14 +159,15 @@ mod tests { use hashes::Hash; use test_case::test_case; - use crate::consensus::deserialize; + use crate::consensus::{deserialize, serialize}; use crate::hash_types::{MerkleRootMasternodeList, MerkleRootQuorums}; use crate::network::message_sml::MnListDiff; use crate::sml::error::SmlError; use crate::sml::masternode_list::MasternodeList; + use crate::test_utils::UNASSIGNED_SPECIAL_TX_TYPE; use crate::transaction::special_transaction::TransactionPayload; use crate::transaction::special_transaction::coinbase::CoinbasePayload; - use crate::{BlockHash, Network}; + use crate::{BlockHash, Network, Transaction}; #[test_case(|_| {}, None; "matching")] #[test_case( @@ -210,4 +211,91 @@ mod tests { assert_eq!(result, error.map_or(Ok(()), |error| Err(error(list.block_hash)))); } + + /// The list of the 2227096 capture, built after `edit`, and the capture's + /// coinbase committing to its masternodes. + fn fixture_list(edit: fn(&mut MnListDiff)) -> (MasternodeList, Transaction) { + let mut diff: MnListDiff = deserialize(include_bytes!( + "../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin" + )) + .expect("expected to deserialize"); + edit(&mut diff); + let mut coinbase = diff.coinbase_tx.clone(); + let list = MasternodeList::from_diff(diff, 2_227_096, Network::Mainnet).expect("builds"); + let Some(TransactionPayload::CoinbasePayloadType(payload)) = + &mut coinbase.special_transaction_payload + else { + panic!("coinbase payload"); + }; + // The capture's masternodes do not hash to its coinbase's root, its quorums do. + payload.merkle_root_masternode_list = list.masternode_merkle_root.expect("masternodes"); + (list, coinbase) + } + + /// `coinbase` with its payload written as a version this crate does not + /// decode, its type and bytes edited, and decoded back from the wire. + fn newer_coinbase( + coinbase: &Transaction, + edit: impl FnOnce(&mut u16, &mut Vec), + ) -> Transaction { + let mut newer = coinbase.with_newer_coinbase_payload(); + let Some(TransactionPayload::RawPayloadType(tx_type, bytes)) = + &mut newer.special_transaction_payload + else { + panic!("a raw payload"); + }; + edit(tx_type, bytes); + let decoded: Transaction = deserialize(&serialize(&newer)).expect("decodes"); + assert_eq!(decoded, newer, "the decoder keeps the payload raw"); + decoded + } + + /// A coinbase payload of a version this crate does not decode stays raw. + /// Dash Core only appends fields in a new version, so the roots are read + /// from the fields this crate knows, at bytes 6..38 and 38..70, and still + /// have to match. A raw payload of a version this crate decodes, which + /// Dash Core never produces, or of another transaction type commits to + /// nothing. + #[test_case(|_, _| {}, true; "of a newer version")] + #[test_case(|_, bytes| bytes[6] ^= 1, false; "of a newer version off the masternode list root")] + #[test_case(|_, bytes| bytes[38] ^= 1, false; "of a newer version off the quorum list root")] + #[test_case( + |_, bytes| bytes[..2].copy_from_slice(&4u16.to_le_bytes()), + false; + "of a known version with bytes left over" + )] + #[test_case( + |tx_type, _| *tx_type = UNASSIGNED_SPECIAL_TX_TYPE, + false; + "of another transaction type" + )] + fn verify_coinbase_roots_of_a_raw_payload(edit: fn(&mut u16, &mut Vec), matches: bool) { + let (list, coinbase) = fixture_list(|_| {}); + let coinbase = newer_coinbase(&coinbase, edit); + + let result = list.verify_coinbase_roots(&coinbase); + + let expected = if matches { + Ok(()) + } else { + Err(SmlError::CoinbaseCommitmentMismatch(list.block_hash)) + }; + assert_eq!(result, expected); + } + + /// A newer coinbase payload cut short inside its quorum list root commits + /// to nothing, even for a list without quorums, whose root is all zeros. + #[test] + fn a_newer_coinbase_payload_cut_short_commits_to_nothing() { + let (list, coinbase) = fixture_list(|diff| { + diff.new_quorums.clear(); + diff.quorums_chainlock_signatures.clear(); + }); + assert!(list.quorums.is_empty()); + let coinbase = newer_coinbase(&coinbase, |_, bytes| bytes.truncate(38 + 16)); + + let result = list.verify_coinbase_roots(&coinbase); + + assert_eq!(result, Err(SmlError::CoinbaseCommitmentMismatch(list.block_hash))); + } } diff --git a/dash/src/test_utils/transaction.rs b/dash/src/test_utils/transaction.rs index 53acd33f5..66540ea6d 100644 --- a/dash/src/test_utils/transaction.rs +++ b/dash/src/test_utils/transaction.rs @@ -1,5 +1,11 @@ use std::ops::Range; +use hashes::Hash; + +use crate::consensus::serialize; +use crate::hash_types::MerkleRootAssetUnlocks; +use crate::transaction::special_transaction::coinbase::CoinbasePayload; +use crate::transaction::special_transaction::{TransactionPayload, TransactionType}; use crate::{Address, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; /// A special transaction type no Dash Core version assigns @@ -75,4 +81,30 @@ impl Transaction { special_transaction_payload: None, } } + + /// This coinbase with its version 3 payload written as a version this crate does not decode, + /// the way a later Dash Core release that appends fields would write it: the version 4 + /// fields under version 5, then 8 bytes of a field of its own. The transaction decoder keeps + /// such a payload raw. + pub fn with_newer_coinbase_payload(&self) -> Transaction { + let Some(TransactionPayload::CoinbasePayloadType(payload)) = + &self.special_transaction_payload + else { + panic!("a coinbase payload"); + }; + let mut bytes = serialize(&CoinbasePayload { + version: 4, + merkle_root_asset_unlocks: Some(MerkleRootAssetUnlocks::all_zeros()), + ..payload.clone() + }); + bytes[..2].copy_from_slice(&5u16.to_le_bytes()); + bytes.extend([0xA5; 8]); + Transaction { + special_transaction_payload: Some(TransactionPayload::RawPayloadType( + TransactionType::Coinbase.to_u16(), + bytes, + )), + ..self.clone() + } + } } From bac5e32cb01066db263fa36ffe4044cf486ee29e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 3 Nov 2026 19:21:45 +0700 Subject: [PATCH 2/5] fix(dash): check the roots of a coinbase payload of a newer version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transaction decoder keeps a coinbase payload raw when its typed decode leaves bytes over or does not re-encode to the same bytes. A later CbTx version that appends fields does exactly that. `MasternodeList::verify_coinbase_roots` refused every coinbase whose payload was not typed. So the first block with a newer layout would have got every masternode list refused, and masternode sync would have stopped until a release of this library learned the layout. `CoinbasePayload::from_coinbase_tx` returns the payload of a coinbase as far as this crate knows its layout: - A typed payload is returned as it is. - A raw coinbase payload of a version newer than 4, the newest this crate decodes, is decoded from the front as version 4, and its newer fields are left out. Dash Core only appends fields in a new version (`CCbTx::SERIALIZE_METHODS`), and it refuses versions it does not know (`bad-cbtx-version`). So a new layout arrives with a Core release that this assumption can be checked against. - A raw payload of a known version gives `None`. Dash Core never produces one (`bad-cbtx-payload`), and reading it could skip the quorum root of a raw version 1. `verify_coinbase_roots` now reads its roots through it. The coinbase bytes are authenticated by the diff's merkle proof against the stored header before the roots are checked, and the roots still have to equal the ones recomputed from the list. Test would have caught this in CI: `verify_coinbase_roots_of_a_raw_payload` "of a newer version". ✖ before the fix (`CoinbaseCommitmentMismatch`), ✔ after. Co-Authored-By: Claude Opus 5.5 --- .../special_transaction/coinbase.rs | 29 +++++++++++++++++++ dash/src/sml/masternode_list/merkle_roots.rs | 10 +++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/dash/src/blockdata/transaction/special_transaction/coinbase.rs b/dash/src/blockdata/transaction/special_transaction/coinbase.rs index 44e4ba87c..d12331ed8 100644 --- a/dash/src/blockdata/transaction/special_transaction/coinbase.rs +++ b/dash/src/blockdata/transaction/special_transaction/coinbase.rs @@ -20,6 +20,8 @@ use hashes::Hash; +use super::{TransactionPayload, TransactionType}; +use crate::Transaction; use crate::bls_sig_utils::BLSSignature; use crate::consensus::encode::{compact_size_len, read_compact_size, write_compact_size}; use crate::consensus::{Decodable, Encodable, encode}; @@ -27,6 +29,9 @@ use crate::hash_types::{MerkleRootAssetUnlocks, MerkleRootMasternodeList, Merkle use crate::io; use crate::io::{Error, ErrorKind}; +/// Newest payload version whose layout this crate decodes. +const NEWEST_DECODED_VERSION: u16 = 4; + /// A Coinbase payload. This is contained as the payload of a coinbase special transaction. /// The Coinbase payload is described in DIP4. /// @@ -204,6 +209,30 @@ impl CoinbasePayload { } } + /// The payload of `coinbase`, read as far as this crate knows its layout. + /// + /// The transaction decoder keeps a payload of a version newer than this crate decodes raw. + /// Dash Core only appends fields in a new version, so such a payload is read as the newest + /// version this crate decodes, leaving the newer fields out. The result keeps the payload's + /// `version` but encodes only the fields it read. + /// + /// `None` for a transaction without a coinbase payload, a raw payload of a version this + /// crate decodes, which Dash Core never produces, or one too short for the fields this crate + /// knows. + pub fn from_coinbase_tx(coinbase: &Transaction) -> Option { + match &coinbase.special_transaction_payload { + Some(TransactionPayload::CoinbasePayloadType(payload)) => Some(payload.clone()), + Some(TransactionPayload::RawPayloadType(tx_type, bytes)) + if *tx_type == TransactionType::Coinbase.to_u16() => + { + CoinbasePayload::consensus_decode(&mut &bytes[..]) + .ok() + .filter(|payload| payload.version > NEWEST_DECODED_VERSION) + } + _ => None, + } + } + /// The ChainLock signature this coinbase carries for member selection at /// `block_height`, as Dash Core's `GetNonNullCoinbaseChainlock` reads it /// (`src/evo/cbtx.cpp:200-211`) short of `IsValid()`, which the caller diff --git a/dash/src/sml/masternode_list/merkle_roots.rs b/dash/src/sml/masternode_list/merkle_roots.rs index 917e6580c..38872ff69 100644 --- a/dash/src/sml/masternode_list/merkle_roots.rs +++ b/dash/src/sml/masternode_list/merkle_roots.rs @@ -4,7 +4,7 @@ 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; +use crate::transaction::special_transaction::coinbase::CoinbasePayload; /// Computes the Merkle root from a list of hashes. /// @@ -46,14 +46,14 @@ pub fn merkle_root_from_hashes(hashes: Vec) -> Option Result<(), SmlError> { - let Some(TransactionPayload::CoinbasePayloadType(coinbase_payload)) = - &coinbase_transaction.special_transaction_payload - else { + let Some(coinbase_payload) = CoinbasePayload::from_coinbase_tx(coinbase_transaction) else { return Err(SmlError::CoinbaseCommitmentMismatch(self.block_hash)); }; From 79b7288163f5d46b833502958a3621e56365e65a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 3 Nov 2026 19:22:03 +0700 Subject: [PATCH 3/5] test(dash-spv): pin the ChainLock of a coinbase payload of a newer version The engine records the ChainLock a list's coinbase carries for member selection, through `valid_coinbase_chain_lock`, which reads only a typed payload. A coinbase payload of a version newer than this library decodes stays raw and yields no ChainLock. The non-rotating quorum check then derives its modifier from the block hash and selects the wrong members. So from the first block with such a layout, every new non-rotating quorum, the ChainLock quorums among them, would be marked invalid, even once its list is accepted. Dash Core only appends fields in a new version, so the version 3 ChainLock fields of such a payload are where they were. This test fails on top of the previous commit: it gets `None` where the typed coinbase gives the signature. The next commit fixes it. Co-Authored-By: Claude Opus 5.5 --- dash-spv/src/sml_engine.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/dash-spv/src/sml_engine.rs b/dash-spv/src/sml_engine.rs index 4983356f8..709fa68a8 100644 --- a/dash-spv/src/sml_engine.rs +++ b/dash-spv/src/sml_engine.rs @@ -1483,7 +1483,7 @@ pub(crate) mod test_support { #[cfg(test)] mod tests { use super::test_support::{fixture_heights, quorum_entry, TestEngine}; - use super::{build_cycle_quorum_map, qr_info_diffs}; + use super::{build_cycle_quorum_map, qr_info_diffs, valid_coinbase_chain_lock}; use super::{FeedError, MasternodeListEngine, QRInfoFeedResult, WORK_DIFF_DEPTH}; use crate::error::{StorageError, StorageResult}; use crate::storage::{BlockHeaderStorage, BlockHeaderTip}; @@ -3306,6 +3306,25 @@ mod tests { ); } + /// A coinbase payload of a version this library does not decode still + /// carries the ChainLock member selection reads: Dash Core only appends + /// fields in a new version, so it holds the version 3 fields where they + /// were. + #[test] + fn a_newer_coinbase_payload_carries_its_chain_lock() { + const HEIGHT: CoreBlockHeight = 2_227_096; + let diff: MnListDiff = deserialize(include_bytes!( + "../../dash/tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin" + )) + .expect("diff"); + let chain_lock = valid_coinbase_chain_lock(&diff.coinbase_tx, HEIGHT); + assert!(chain_lock.is_some(), "the capture's coinbase carries a ChainLock"); + + let newer = diff.coinbase_tx.with_newer_coinbase_payload(); + + assert_eq!(valid_coinbase_chain_lock(&newer, HEIGHT), chain_lock); + } + /// On an engine that checks coinbases, a restarted engine records the /// newest list's coinbase ChainLock from the diff from that block to /// itself, and records nothing from one whose coinbase proof or roots fail. From e2837d92fad8383ee132b966d2faa083137a96c8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 3 Nov 2026 19:22:44 +0700 Subject: [PATCH 4/5] fix(dash-spv): read the ChainLock of a coinbase payload of a newer version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `valid_coinbase_chain_lock` read only a typed coinbase payload. A payload of a version newer than this library decodes stays raw, so no ChainLock was recorded for its list. The non-rotating quorum check then derived the modifier from the block hash, selected the wrong members, and marked every new non-rotating quorum invalid, the ChainLock quorums among them. That would have happened from the first block with such a layout, even though the previous commit lets its list in. The ChainLock is now read through `CoinbasePayload::from_coinbase_tx`. Dash Core only appends fields in a new version, so the version 3 fields of a newer payload are read where they were. A misread signature would only select wrong members, so the quorum would fail its check, as it does today. It cannot add a quorum the coinbase does not commit to. Test would have caught this in CI: `a_newer_coinbase_payload_carries_its_chain_lock`. ✖ before the fix (`None`), ✔ after. Co-Authored-By: Claude Opus 5.5 --- dash-spv/src/sml_engine.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/dash-spv/src/sml_engine.rs b/dash-spv/src/sml_engine.rs index 709fa68a8..b5183fe4b 100644 --- a/dash-spv/src/sml_engine.rs +++ b/dash-spv/src/sml_engine.rs @@ -25,8 +25,8 @@ 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::transaction::special_transaction::TransactionPayload; use dashcore::{BlockHash, QuorumHash}; use dashcore::{Network, Transaction}; use tokio::sync::RwLock; @@ -173,16 +173,14 @@ fn build_cycle_quorum_map( /// The ChainLock `coinbase_tx`, the coinbase of the block at `height`, carries /// for member selection, as Dash Core's `GetNonNullCoinbaseChainlock` reads it -/// (`src/evo/cbtx.cpp:200-211`). +/// (`src/evo/cbtx.cpp:200-211`). A payload of a version newer than this +/// library decodes is read as far as it knows the layout, see +/// [`CoinbasePayload::from_coinbase_tx`]. fn valid_coinbase_chain_lock( coinbase_tx: &Transaction, height: CoreBlockHeight, ) -> Option { - let Some(TransactionPayload::CoinbasePayloadType(payload)) = - &coinbase_tx.special_transaction_payload - else { - return None; - }; + let payload = CoinbasePayload::from_coinbase_tx(coinbase_tx)?; let signature = payload.chain_lock_candidate(height)?; signature.as_scheme(BlsScheme::Modern).is_valid().then_some(signature) } From 46602c7b77ba80ffd1a20023c37c38fb3d1b410c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 3 Nov 2026 19:31:00 +0700 Subject: [PATCH 5/5] test(dash): pin what `from_coinbase_tx` reads of a newer coinbase payload - A raw coinbase payload of version 5 or 0xffff gives the fields up to version 4, under its own version. - A raw payload of versions 0 to 4 gives nothing, and so does every cut of it short of those fields. - The roots test of a newer payload cut short now cuts before the quorum root, and checks that nothing is read, so a reader that filled missing bytes with zeros would fail it. - `with_newer_coinbase_payload` asserts the version 3 payload it needs. - The typed roots test shares the fixture setup. - The docs say when the decoder keeps a payload raw. They also say that the result of `from_coinbase_tx` does not encode to the payload's bytes, and that a later version changing a known field's meaning would need this reader to change. Co-Authored-By: Claude Opus 5.5 --- .../special_transaction/coinbase.rs | 80 +++++++++++++++++-- dash/src/sml/masternode_list/merkle_roots.rs | 14 +--- dash/src/test_utils/transaction.rs | 1 + 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/dash/src/blockdata/transaction/special_transaction/coinbase.rs b/dash/src/blockdata/transaction/special_transaction/coinbase.rs index d12331ed8..a7862c0f5 100644 --- a/dash/src/blockdata/transaction/special_transaction/coinbase.rs +++ b/dash/src/blockdata/transaction/special_transaction/coinbase.rs @@ -29,7 +29,8 @@ use crate::hash_types::{MerkleRootAssetUnlocks, MerkleRootMasternodeList, Merkle use crate::io; use crate::io::{Error, ErrorKind}; -/// Newest payload version whose layout this crate decodes. +/// Newest payload version whose layout this crate decodes: the last version +/// `CoinbasePayload::consensus_decode` reads fields for. const NEWEST_DECODED_VERSION: u16 = 4; /// A Coinbase payload. This is contained as the payload of a coinbase special transaction. @@ -211,10 +212,12 @@ impl CoinbasePayload { /// The payload of `coinbase`, read as far as this crate knows its layout. /// - /// The transaction decoder keeps a payload of a version newer than this crate decodes raw. - /// Dash Core only appends fields in a new version, so such a payload is read as the newest - /// version this crate decodes, leaving the newer fields out. The result keeps the payload's - /// `version` but encodes only the fields it read. + /// The transaction decoder keeps a payload raw when bytes follow the fields this crate + /// knows, as they do in a newer version that appends fields. Dash Core only appends fields in + /// a new version, so a raw payload of a version newer than this crate decodes is read as the + /// newest version it decodes, leaving the newer fields out. Should a later version change + /// the meaning of a field this crate knows, this has to change with it. The result keeps the + /// payload's `version`, so it does not encode to the payload's bytes. /// /// `None` for a transaction without a coinbase payload, a raw payload of a version this /// crate decodes, which Dash Core never produces, or one too short for the fields this crate @@ -412,9 +415,11 @@ impl Decodable for CoinbasePayload { mod tests { use hashes::Hash; + use crate::Transaction; use crate::bls_sig_utils::BLSSignature; use crate::consensus::{Decodable, Encodable}; use crate::hash_types::{MerkleRootAssetUnlocks, MerkleRootMasternodeList, MerkleRootQuorums}; + use crate::transaction::special_transaction::TransactionPayload; use crate::transaction::special_transaction::coinbase::CoinbasePayload; use test_case::test_case; @@ -709,4 +714,69 @@ mod tests { json["version"] = 3.into(); assert!(serde_json::from_value::(json).is_err()); } + + /// A raw coinbase payload of a version newer than this crate decodes is + /// read as far as it knows the layout: the fields up to version 4, under + /// the payload's own version. A raw payload of a version it decodes, or + /// one cut short anywhere inside those fields, gives nothing. + #[test] + fn from_coinbase_tx_reads_the_known_fields_of_a_newer_payload() { + let typed = mainnet_v20_payload(); + let coinbase = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::CoinbasePayloadType( + typed.clone(), + )), + }; + assert_eq!(CoinbasePayload::from_coinbase_tx(&coinbase), Some(typed.clone())); + let newer = coinbase.with_newer_coinbase_payload(); + let Some(TransactionPayload::RawPayloadType(tx_type, bytes)) = + &newer.special_transaction_payload + else { + panic!("a raw payload"); + }; + let raw = |bytes: Vec| Transaction { + special_transaction_payload: Some(TransactionPayload::RawPayloadType(*tx_type, bytes)), + ..newer.clone() + }; + let with_version = |version: u16| { + let mut bytes = bytes.clone(); + bytes[..2].copy_from_slice(&version.to_le_bytes()); + raw(bytes) + }; + + let known_fields = CoinbasePayload { + version: 4, + merkle_root_asset_unlocks: Some(MerkleRootAssetUnlocks::all_zeros()), + ..typed + }; + for version in [5, u16::MAX] { + assert_eq!( + CoinbasePayload::from_coinbase_tx(&with_version(version)), + Some(CoinbasePayload { + version, + ..known_fields.clone() + }), + "version {version}" + ); + } + for version in 0..=4 { + assert_eq!( + CoinbasePayload::from_coinbase_tx(&with_version(version)), + None, + "version {version}" + ); + } + for len in 0..known_fields.size() { + assert_eq!( + CoinbasePayload::from_coinbase_tx(&raw(bytes[..len].to_vec())), + None, + "cut to {len} bytes" + ); + } + assert_eq!(CoinbasePayload::from_coinbase_tx(&Transaction::dummy_empty()), None); + } } diff --git a/dash/src/sml/masternode_list/merkle_roots.rs b/dash/src/sml/masternode_list/merkle_roots.rs index 38872ff69..d8a1ba9b2 100644 --- a/dash/src/sml/masternode_list/merkle_roots.rs +++ b/dash/src/sml/masternode_list/merkle_roots.rs @@ -192,19 +192,12 @@ mod tests { tamper: fn(&mut CoinbasePayload), error: Option SmlError>, ) { - let diff: MnListDiff = deserialize(include_bytes!( - "../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin" - )) - .expect("expected to deserialize"); - let mut coinbase = diff.coinbase_tx.clone(); - let list = MasternodeList::from_diff(diff, 2_227_096, Network::Mainnet).expect("builds"); + let (list, mut coinbase) = fixture_list(|_| {}); let Some(TransactionPayload::CoinbasePayloadType(payload)) = &mut coinbase.special_transaction_payload else { panic!("coinbase payload"); }; - // The capture's masternodes do not hash to its coinbase's root, its quorums do. - payload.merkle_root_masternode_list = list.masternode_merkle_root.expect("masternodes"); tamper(payload); let result = list.verify_coinbase_roots(&coinbase); @@ -283,7 +276,7 @@ mod tests { assert_eq!(result, expected); } - /// A newer coinbase payload cut short inside its quorum list root commits + /// A newer coinbase payload cut short before its quorum list root commits /// to nothing, even for a list without quorums, whose root is all zeros. #[test] fn a_newer_coinbase_payload_cut_short_commits_to_nothing() { @@ -292,7 +285,8 @@ mod tests { diff.quorums_chainlock_signatures.clear(); }); assert!(list.quorums.is_empty()); - let coinbase = newer_coinbase(&coinbase, |_, bytes| bytes.truncate(38 + 16)); + let coinbase = newer_coinbase(&coinbase, |_, bytes| bytes.truncate(38)); + assert_eq!(CoinbasePayload::from_coinbase_tx(&coinbase), None); let result = list.verify_coinbase_roots(&coinbase); diff --git a/dash/src/test_utils/transaction.rs b/dash/src/test_utils/transaction.rs index 66540ea6d..e538f9f68 100644 --- a/dash/src/test_utils/transaction.rs +++ b/dash/src/test_utils/transaction.rs @@ -92,6 +92,7 @@ impl Transaction { else { panic!("a coinbase payload"); }; + assert_eq!(payload.version, 3, "a version 3 coinbase payload"); let mut bytes = serialize(&CoinbasePayload { version: 4, merkle_root_asset_unlocks: Some(MerkleRootAssetUnlocks::all_zeros()),