From c17bc3f628f28f48e738831b90bd8d48106281a8 Mon Sep 17 00:00:00 2001 From: shamardy Date: Tue, 5 Aug 2025 02:38:28 +0300 Subject: [PATCH 1/2] fix(utxo): Correct PIVX block header deserialization Updates the BlockHeader deserializer to correctly read the `hash_final_sapling_root` field for PIVX. --- mm2src/coins/utxo/utxo_tests.rs | 8 ++--- mm2src/mm2_bitcoin/chain/src/block_header.rs | 33 ++++++++++++++++--- .../mm2_bitcoin/serialization/src/reader.rs | 7 ++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/mm2src/coins/utxo/utxo_tests.rs b/mm2src/coins/utxo/utxo_tests.rs index 161401b8e3..152e73acf3 100644 --- a/mm2src/coins/utxo/utxo_tests.rs +++ b/mm2src/coins/utxo/utxo_tests.rs @@ -5729,13 +5729,13 @@ fn test_electrum_v14_block_hash() { fn test_scan_and_deserialize_block_headers() { // ========================== CONFIGURATION ========================== /// The ticker of the coin to test (e.g., "NMC", "CHTA", "RVN"). - const COIN_TICKER: &str = "NMC"; + const COIN_TICKER: &str = "PIVX"; /// A list of active Electrum servers for the specified coin. - const ELECTRUM_URLS: &[&str] = &["nmc2.bitcoins.sk:57001", "nmc2.bitcoins.sk:57002"]; + const ELECTRUM_URLS: &[&str] = &["electrum01.chainster.org:50001", "electrum02.chainster.org:50001"]; /// The block height to start scanning from. - const START_HEIGHT: u64 = 701614; + const START_HEIGHT: u64 = 4903982; /// The block height to stop scanning at. Set to `None` to scan to the tip of the chain. - const END_HEIGHT: Option = Some(701616); + const END_HEIGHT: Option = Some(4913982); /// The number of headers to fetch in a single RPC call. const CHUNK_SIZE: u64 = 100; // =================================================================== diff --git a/mm2src/mm2_bitcoin/chain/src/block_header.rs b/mm2src/mm2_bitcoin/chain/src/block_header.rs index 0730fd125d..0708c82355 100644 --- a/mm2src/mm2_bitcoin/chain/src/block_header.rs +++ b/mm2src/mm2_bitcoin/chain/src/block_header.rs @@ -230,16 +230,21 @@ impl Deserializable for BlockHeader { None }; - let is_zcash = (version == 4 && !reader.coin_variant().is_btc() && !reader.coin_variant().is_ppc()) + let is_zcash_style = (version == 4 && !reader.coin_variant().is_btc() && !reader.coin_variant().is_ppc()) || reader.coin_variant().is_kmd_assetchain(); - let hash_final_sapling_root = if is_zcash { Some(reader.read()?) } else { None }; + // A PIVX header is a standard header with an added `hash_final_sapling_root`. + let hash_final_sapling_root = if is_zcash_style || reader.coin_variant().is_pivx() { + Some(reader.read()?) + } else { + None + }; let time = reader.read()?; - let bits = if is_zcash { + let bits = if is_zcash_style { BlockHeaderBits::U32(reader.read()?) } else { BlockHeaderBits::Compact(reader.read()?) }; - let nonce = if is_zcash { + let nonce = if is_zcash_style { BlockHeaderNonce::H256(reader.read()?) } else if (version == KAWPOW_VERSION && reader.coin_variant().is_rvn()) || (version == MTP_POW_VERSION && time >= PROG_POW_SWITCH_TIME) @@ -248,7 +253,11 @@ impl Deserializable for BlockHeader { } else { BlockHeaderNonce::U32(reader.read()?) }; - let solution = if is_zcash { Some(reader.read_list()?) } else { None }; + let solution = if is_zcash_style { + Some(reader.read_list()?) + } else { + None + }; // https://en.bitcoin.it/wiki/Merged_mining_specification#Merged_mining_coinbase let aux_pow = if (version & AUXPOW_VERSION_FLAG) != 0 { @@ -2896,4 +2905,18 @@ mod tests { let serialized = serialize(&header); assert_eq!(serialized.take(), header_bytes); } + + #[test] + fn test_pivx_sapling_header() { + let header_hex = "0b000000097d36aeeb2585e6c08226f8f48cb91213708fcad603cb67be76efa5b3b31c0baf86a77624fd298be0f5a7b908d17d3d83edf8f681de2913b2584fb92380e152594229684411051b00000000c801eff496c2720766cdbf2ec20b5436b37350e2945f85a7feb8a4b4a12d4323"; + let header_bytes = &header_hex.from_hex::>().unwrap() as &[u8]; + let mut reader = Reader::new_with_coin_variant(header_bytes, CoinVariant::PIVX); + let header: BlockHeader = reader.read().unwrap(); + + // Sapling root must be present + assert!(header.hash_final_sapling_root.is_some()); + + let serialized = serialize(&header); + assert_eq!(serialized.take(), header_bytes); + } } diff --git a/mm2src/mm2_bitcoin/serialization/src/reader.rs b/mm2src/mm2_bitcoin/serialization/src/reader.rs index 9c9d3feaff..8d01d54a9a 100644 --- a/mm2src/mm2_bitcoin/serialization/src/reader.rs +++ b/mm2src/mm2_bitcoin/serialization/src/reader.rs @@ -65,6 +65,7 @@ pub enum CoinVariant { /// Same reason as RICK. MORTY, RVN, + PIVX, } impl CoinVariant { @@ -86,6 +87,10 @@ impl CoinVariant { pub fn is_rvn(&self) -> bool { matches!(self, CoinVariant::RVN) } + + pub fn is_pivx(&self) -> bool { + matches!(self, CoinVariant::PIVX) + } } fn ticker_matches(ticker: &str, with: &str) -> bool { @@ -111,6 +116,8 @@ impl From<&str> for CoinVariant { t if ticker_matches(t, "MORTY") => CoinVariant::MORTY, // "RVN" t if ticker_matches(t, "RVN") => CoinVariant::RVN, + // "PIVX" + t if ticker_matches(t, "PIVX") => CoinVariant::PIVX, _ => CoinVariant::Standard, } } From 4701e9ab33275d5345dc652056ab18c7dfdcc92d Mon Sep 17 00:00:00 2001 From: shamardy Date: Tue, 5 Aug 2025 04:53:02 +0300 Subject: [PATCH 2/2] fix(deserialization): correct PIVX block header serde for Sapling fields PIVX block headers with `nVersion` >= 8 include a `hash_final_sapling_root`, but its position in the byte stream is different from other Zcash-style chains. It appears after the `nNonce`, not before `nTime`. The previous deserialization logic incorrectly assumed the Zcash-style order, leading to data corruption for all subsequent fields (`nTime`, `nBits`, etc.) and causing failures in functions that rely on block timestamps, such as swap refunds. This commit corrects both the serialization and deserialization implementations for `BlockHeader` to handle the PIVX-specific field order. It also introduces a `pivx_mtp` test to validate the fix and prevent regressions. --- mm2src/coins/utxo/utxo_tests.rs | 8 ++++++ mm2src/mm2_bitcoin/chain/src/block_header.rs | 28 +++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/mm2src/coins/utxo/utxo_tests.rs b/mm2src/coins/utxo/utxo_tests.rs index 152e73acf3..4071e28cf8 100644 --- a/mm2src/coins/utxo/utxo_tests.rs +++ b/mm2src/coins/utxo/utxo_tests.rs @@ -3282,6 +3282,14 @@ fn rvn_mtp() { assert_eq!(mtp, 1633946264); } +#[test] +fn pivx_mtp() { + let electrum = electrum_client_for_test(&["electrum01.chainster.org:50001", "electrum02.chainster.org:50001"]); + let mtp = + block_on_f01(electrum.get_median_time_past(5014894, NonZeroU64::new(11).unwrap(), CoinVariant::PIVX)).unwrap(); + assert_eq!(mtp, 1754356500); +} + #[test] fn qtum_mtp() { let electrum = electrum_client_for_test(&[ diff --git a/mm2src/mm2_bitcoin/chain/src/block_header.rs b/mm2src/mm2_bitcoin/chain/src/block_header.rs index 0708c82355..5446d5d351 100644 --- a/mm2src/mm2_bitcoin/chain/src/block_header.rs +++ b/mm2src/mm2_bitcoin/chain/src/block_header.rs @@ -156,15 +156,24 @@ impl Serializable for BlockHeader { if let Some(claim) = &self.claim_trie_root { s.append(claim); } - if let Some(h) = &self.hash_final_sapling_root { - s.append(h); - }; + // For Zcash-style headers, the sapling root is serialized before the time. + if self.solution.is_some() { + if let Some(h) = &self.hash_final_sapling_root { + s.append(h); + } + } s.append(&self.time); s.append(&self.bits); // If a BTC header uses KAWPOW_VERSION, the nonce can't be zero if !self.is_prog_pow() && (self.version != KAWPOW_VERSION || self.nonce != BlockHeaderNonce::U32(0)) { s.append(&self.nonce); } + // For PIVX-style headers, the sapling root is serialized after the nonce. + if self.solution.is_none() { + if let Some(h) = &self.hash_final_sapling_root { + s.append(h); + } + } if let Some(sol) = &self.solution { s.append_list(sol); } @@ -232,12 +241,7 @@ impl Deserializable for BlockHeader { let is_zcash_style = (version == 4 && !reader.coin_variant().is_btc() && !reader.coin_variant().is_ppc()) || reader.coin_variant().is_kmd_assetchain(); - // A PIVX header is a standard header with an added `hash_final_sapling_root`. - let hash_final_sapling_root = if is_zcash_style || reader.coin_variant().is_pivx() { - Some(reader.read()?) - } else { - None - }; + let mut hash_final_sapling_root = if is_zcash_style { Some(reader.read()?) } else { None }; let time = reader.read()?; let bits = if is_zcash_style { BlockHeaderBits::U32(reader.read()?) @@ -253,6 +257,12 @@ impl Deserializable for BlockHeader { } else { BlockHeaderNonce::U32(reader.read()?) }; + // A PIVX header is a standard header with a `hash_final_sapling_root` added after the nonce. + hash_final_sapling_root = if reader.coin_variant().is_pivx() { + Some(reader.read()?) + } else { + hash_final_sapling_root + }; let solution = if is_zcash_style { Some(reader.read_list()?) } else {