From 03e740ed50e4a8ec7a46645d4a2124d292d1cb10 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Thu, 15 Oct 2026 10:50:23 -0700 Subject: [PATCH 1/2] feat(dash-spv)!: validate header difficulty against Dark Gravity Wave Headers were only checked against the target in their own bits, so past the last checkpoint a peer could serve a chain of minimal difficulty. Params::next_work_required computes the bits Dash Core requires, as its GetNextWorkRequired from Dark Gravity Wave on, and the header validator rejects a header whose bits differ, from the stored headers before it. Closes #1116 Co-Authored-By: Claude Opus 5.5 --- dash-spv/src/client/lifecycle.rs | 1 + dash-spv/src/error.rs | 9 +++ dash-spv/src/sync/block_headers/manager.rs | 38 ++++++++-- dash-spv/src/validation/header.rs | 82 +++++++++++++++++--- dash/src/pow.rs | 84 +++++++++++++++++++++ dash/src/test_utils/block.rs | 19 +++++ dash/tests/data/mainnet_headers_1999976.txt | 30 ++++++++ 7 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 dash/tests/data/mainnet_headers_1999976.txt diff --git a/dash-spv/src/client/lifecycle.rs b/dash-spv/src/client/lifecycle.rs index 292ea5875..5d156330b 100644 --- a/dash-spv/src/client/lifecycle.rs +++ b/dash-spv/src/client/lifecycle.rs @@ -128,6 +128,7 @@ impl DashSpvClient { /// Peers we've sent a GetHeaders to after sync, so Dash Core knows our tip /// and can send us header announcements instead of inv. pub(super) announced_peers: HashSet, + /// Network whose difficulty rules new headers are checked against. + network: Network, } impl std::fmt::Debug for BlockHeadersManager { @@ -67,6 +70,7 @@ impl BlockHeadersManager { header_storage: Arc>, metadata_storage: Arc>, checkpoint_manager: Arc, + network: Network, ) -> SyncResult { let tip = header_storage .read() @@ -93,6 +97,7 @@ impl BlockHeadersManager { pipeline: HeadersPipeline::new(checkpoint_manager), pending_announcements: HashMap::new(), announced_peers: HashSet::new(), + network, }) } @@ -109,8 +114,23 @@ impl BlockHeadersManager { async fn store_headers(&mut self, headers: &[HashedBlockHeader]) -> SyncResult { debug_assert!(!headers.is_empty()); - // Validate batch for internal continuity and PoW - BlockHeaderValidator::new().validate(headers)?; + // Validate batch for internal continuity, PoW and difficulty + let tip = self.tip().await?; + let storage = self.header_storage.read().await; + + // A storage started at a checkpoint holds less history than a full window. + let first = tip + .height() + .saturating_sub(DGW_PAST_BLOCKS as u32 - 1) + .max(storage.get_start_height().await.unwrap_or(0)); + + let ancestors = storage.load_headers(first..tip.height() + 1).await?; + + drop(storage); + + let ancestors = ancestors.iter().map(|header| *header.header()).collect(); + + BlockHeaderValidator::new(self.network, ancestors, tip.height()).validate(headers)?; // Store headers self.header_storage.write().await.store_headers(headers).await?; @@ -381,9 +401,14 @@ mod tests { .await .unwrap(); let checkpoint_manager = create_test_checkpoint_manager(); - BlockHeadersManager::new(storage.block_headers(), storage.metadata(), checkpoint_manager) - .await - .expect("Failed to create BlockHeadersManager") + BlockHeadersManager::new( + storage.block_headers(), + storage.metadata(), + checkpoint_manager, + Network::Regtest, + ) + .await + .expect("Failed to create BlockHeadersManager") } #[test_case(None, 5, Some(5); "no ChainLock")] @@ -410,6 +435,7 @@ mod tests { storage.block_headers(), storage.metadata(), create_test_checkpoint_manager(), + Network::Regtest, ) .await .unwrap(); diff --git a/dash-spv/src/validation/header.rs b/dash-spv/src/validation/header.rs index ed6bfc015..d3aab3a9e 100644 --- a/dash-spv/src/validation/header.rs +++ b/dash-spv/src/validation/header.rs @@ -1,16 +1,33 @@ use rayon::prelude::*; use std::time::Instant; +use dashcore::block::Header; +use dashcore::consensus::Params; +use dashcore::Network; + use crate::error::{ValidationError, ValidationResult}; use crate::types::HashedBlockHeader; use crate::validation::Validator; -#[derive(Default)] -pub struct BlockHeaderValidator {} +/// Checks a batch of headers for continuity, proof of work and the difficulty Dash Core +/// requires. Headers without enough history before them are not checked for difficulty. +pub struct BlockHeaderValidator { + params: &'static Params, + /// The stored headers the batch extends, oldest first. + ancestors: Vec
, + /// Height of the last of `ancestors`. + tip_height: u32, +} impl BlockHeaderValidator { - pub fn new() -> Self { - Self {} + /// A validator for a batch on `network` extending `ancestors`, the last of which is at + /// `tip_height`. + pub fn new(network: Network, ancestors: Vec
, tip_height: u32) -> Self { + Self { + params: Params::new(network), + ancestors, + tip_height, + } } } @@ -18,7 +35,15 @@ impl Validator<&[HashedBlockHeader]> for BlockHeaderValidator { fn validate(&self, hashed_headers: &[HashedBlockHeader]) -> ValidationResult<()> { let start = Instant::now(); - // Check PoW of i and continuity of i-1 to i in parallel + // The headers each one's difficulty is computed from: the ancestors, then the batch + let chain: Vec
= self + .ancestors + .iter() + .copied() + .chain(hashed_headers.iter().map(|h| *h.header())) + .collect(); + + // Check continuity of i-1 to i, PoW and difficulty of i in parallel hashed_headers.par_iter().enumerate().try_for_each(|(i, header)| { // For the first header, skip chain continuity check since we don't have i-1 here if i > 0 && header.header().prev_blockhash != *hashed_headers[i - 1].hash() { @@ -32,6 +57,20 @@ impl Validator<&[HashedBlockHeader]> for BlockHeaderValidator { if !header.header().target().is_met_by(*header.hash()) { return Err(ValidationError::InvalidProofOfWork); } + + // Check the bits against the difficulty required at its height + let height = self.tip_height + 1 + i as u32; + let window = &chain[..self.ancestors.len() + i]; + let bits = header.header().bits; + let required = self.params.next_work_required(window, height - 1, header.header().time); + + if let Some(required) = required.filter(|required| *required != bits) { + return Err(ValidationError::InvalidDifficulty { + height, + actual: bits.to_consensus(), + required: required.to_consensus(), + }); + } Ok(()) })?; @@ -58,14 +97,14 @@ mod tests { #[test] fn test_empty_headers() { - let validator = BlockHeaderValidator::new(); + let validator = BlockHeaderValidator::new(Network::Regtest, vec![], 0); assert!(validator.validate(&[]).is_ok()); } #[test] fn test_single_header() { - let validator = BlockHeaderValidator::new(); + let validator = BlockHeaderValidator::new(Network::Regtest, vec![], 0); let headers = hashed(Header::dummy_chain(1, BlockHash::all_zeros())); assert!(validator.validate(&headers).is_ok()); @@ -73,7 +112,7 @@ mod tests { #[test] fn test_valid_chain() { - let validator = BlockHeaderValidator::new(); + let validator = BlockHeaderValidator::new(Network::Regtest, vec![], 0); let headers = hashed(Header::dummy_chain(10, BlockHash::all_zeros())); assert!(validator.validate(&headers).is_ok()); @@ -81,7 +120,7 @@ mod tests { #[test] fn test_broken_chain() { - let validator = BlockHeaderValidator::new(); + let validator = BlockHeaderValidator::new(Network::Regtest, vec![], 0); let mut headers = Header::dummy_chain(3, BlockHash::all_zeros()); // The third header doesn't connect to the second @@ -93,7 +132,7 @@ mod tests { #[test] fn test_invalid_pow() { - let validator = BlockHeaderValidator::new(); + let validator = BlockHeaderValidator::new(Network::Regtest, vec![], 0); // `Header::dummy` has a hard target its hash doesn't meet let result = validator.validate(&hashed(vec![Header::dummy(0)])); @@ -102,7 +141,7 @@ mod tests { #[test] fn test_genesis_blocks() { - let validator = BlockHeaderValidator::new(); + let validator = BlockHeaderValidator::new(Network::Regtest, vec![], 0); for network in [Network::Mainnet, Network::Testnet, Network::Regtest] { let genesis = HashedBlockHeader::from(genesis_block(network).header); @@ -116,7 +155,7 @@ mod tests { #[test] fn test_invalid_pow_mid_chain() { - let validator = BlockHeaderValidator::new(); + let validator = BlockHeaderValidator::new(Network::Regtest, vec![], 0); // The third header has valid continuity but a hard target its hash doesn't meet let mut headers = Header::dummy_chain(2, BlockHash::all_zeros()); @@ -130,4 +169,23 @@ mod tests { let result = validator.validate(&hashed(headers)); assert!(matches!(result, Err(ValidationError::InvalidProofOfWork))); } + + #[test] + fn test_easier_difficulty() { + let headers = Header::mainnet_fixture(); + let mut batch = headers[24..].to_vec(); + // The easy target of the dummy chain, which the header's hash still meets + batch.last_mut().unwrap().bits = Header::dummy_chain(1, BlockHash::all_zeros())[0].bits; + + let validator = + BlockHeaderValidator::new(Network::Mainnet, headers[..24].to_vec(), 1999999); + let result = validator.validate(&hashed(batch)); + assert!(matches!( + result, + Err(ValidationError::InvalidDifficulty { + height: 2000005, + .. + }) + )); + } } diff --git a/dash/src/pow.rs b/dash/src/pow.rs index 652bfe2bf..fa8e31d19 100644 --- a/dash/src/pow.rs +++ b/dash/src/pow.rs @@ -13,6 +13,9 @@ use core::ops::{Add, Sub}; use dash_num::Arith256; use dash_types::Numeric; +use crate::Network; +use crate::block::Header; +use crate::consensus::Params; use crate::consensus::encode::{self, Decodable, Encodable}; use crate::hash_types::BlockHash; use crate::io::{self, Read, Write}; @@ -416,6 +419,65 @@ impl Decodable for CompactTarget { } } +pub const DGW_PAST_BLOCKS: usize = 24; + +impl Params { + pub fn next_work_required( + &self, + window: &[Header], + last_height: u32, + time: u32, + ) -> Option { + if self.network == Network::Devnet || last_height.saturating_add(1) < self.pow_dgw_height { + return None; + } + + if self.no_pow_retargeting { + return Some(self.pow_limit.to_compact_lossy()); + } + + let last = window.last()?; + let elapsed = i64::from(time) - i64::from(last.time); + let spacing = self.pow_target_spacing as i64; + + if self.allow_min_difficulty_blocks && elapsed > 2 * 60 * 60 { + return Some(self.pow_limit.to_compact_lossy()); + } + + if self.allow_min_difficulty_blocks && elapsed > spacing * 4 { + let target = Target(Target::from_compact(last.bits).0 * 10); + return Some(target.min(self.pow_limit).to_compact_lossy()); + } + + let window = &window[window.len().checked_sub(DGW_PAST_BLOCKS)?..]; + + Some(self.dark_gravity_wave(window).to_compact_lossy()) + } + + /// Dash Core's `DarkGravityWave` over exactly [`DGW_PAST_BLOCKS`] headers, oldest first. + fn dark_gravity_wave(&self, window: &[Header]) -> Target { + // Not quite an average, but the one Core computes, newest first. + let mut average = Arith256::ZERO; + for (count, header) in (1u32..).zip(window.iter().rev()) { + let target = Target::from_compact(header.bits).0; + average = if count == 1 { + target + } else { + (average * count + target) / u64::from(count + 1) + }; + } + + // Spans one interval fewer than the target timespan, as in Core. + let target_timespan = DGW_PAST_BLOCKS as i64 * self.pow_target_spacing as i64; + let actual_timespan = (i64::from(window[DGW_PAST_BLOCKS - 1].time) + - i64::from(window[0].time)) + .clamp(target_timespan / 3, target_timespan * 3); + + let target = Target(average * actual_timespan as u32 / target_timespan as u64); + target.min(self.pow_limit) + } +} + // Target::MAX as a float value. Calculated with `Arith256::to_f64`. // This is validated in the unit tests as well. const TARGET_MAX_F64: f64 = 2.695953529101131e67; @@ -462,6 +524,28 @@ mod tests { } } + #[test_case(Network::Mainnet, 1_999_999, 182, Some(0x194c14f3); "mainnet dgw")] + #[test_case(Network::Devnet, 2_000_000, 0, None; "devnet")] + #[test_case(Network::Mainnet, 34_138, 0, None; "before dgw")] + #[test_case(Network::Regtest, 34_139, 0, Some(0x207fffff); "regtest")] + #[test_case(Network::Testnet, 2_000_000, 2 * 60 * 60 + 1, Some(0x1e0fffff); "testnet after two hours")] + #[test_case(Network::Testnet, 2_000_000, 4 * 150 + 1, Some(0x1a03023d); "testnet after four spacings")] + fn next_work_required_rules( + network: Network, + last_height: u32, + elapsed: u32, + required: Option, + ) { + let headers = Header::mainnet_fixture(); + let window = &headers[..DGW_PAST_BLOCKS]; + let time = window[DGW_PAST_BLOCKS - 1].time + elapsed; + + assert_eq!( + Params::new(network).next_work_required(window, last_height, time), + required.map(CompactTarget::from_consensus) + ); + } + #[test] fn compact_target_from_hex_str_happy_path() { let actual = CompactTarget::from_hex_str("0x01003456").unwrap(); diff --git a/dash/src/test_utils/block.rs b/dash/src/test_utils/block.rs index 93a2ae497..a1fe2195b 100644 --- a/dash/src/test_utils/block.rs +++ b/dash/src/test_utils/block.rs @@ -37,6 +37,25 @@ impl Header { } } + /// Mainnet headers 1999976 to 2000005: 24 headers of history for Dark Gravity Wave, + /// then six whose `bits` it reproduces. + pub fn mainnet_fixture() -> Vec { + include_str!("../../tests/data/mainnet_headers_1999976.txt") + .lines() + .map(|line| { + let [_, hash, header] = line.split(' ').collect::>()[..] else { + panic!("height, hash and header"); + }; + let header: Header = crate::consensus::deserialize( + &hex_conservative::decode_to_vec(header).unwrap(), + ) + .unwrap(); + assert_eq!(header.block_hash().to_string(), hash); + header + }) + .collect() + } + pub fn dummy_batch(height_range: Range) -> Vec { height_range.map(Self::dummy).collect() } diff --git a/dash/tests/data/mainnet_headers_1999976.txt b/dash/tests/data/mainnet_headers_1999976.txt new file mode 100644 index 000000000..7d63c2c7b --- /dev/null +++ b/dash/tests/data/mainnet_headers_1999976.txt @@ -0,0 +1,30 @@ +1999976 00000000000000138d8b2dd784e4b8267e721e883ae565c8b67b1a37322be0b3 0000002007ea0913a26961269d12213460d2244a6df44f367fe7f6a01c00000000000000d041a240576e772e2356c5fd8db14d9d021d729154ef2fa06c481b37d5f5bc9642a29865518a3a19481037fe +1999977 00000000000000054eb25a634864e4da659d530380466fe34c34304c70c426ac 00000020b3e02b32371a7bb6c865e53a881e727e26b8e484d72d8b8d13000000000000009ae06edf064838c038ea8ec7b37b5243472bb206b0c4f42b31efd4e45866d96164a2986580314019635010fe +1999978 00000000000000033d5e70755e96eb0261a27eba7388f3d0bf7517ff2c79ae19 00000020ac26c4704c30344ce36f468003539d65dae46448635ab24e050000000000000015342d949823b06b2bcd094c67166bd8cff4efd4b3970f160c7c44eb9af8ba80cca39865c7003f194e4dd864 +1999979 0000000000000027fdecaa95e75340e429d74c6198926d3306e401a63e3113f9 0000002019ae792cff1775bfd0f38873ba7ea26102eb965e75705e3d0300000000000000e4e5adc2e448afbb37495a6e36225849a2fa1d171f3a54c35565d64baa47b8c3f8a39865283b44199bcef4ec +1999980 0000000000000037250fbeb58228dc6b60a1ec8d8dfcd4c7c691382f3a73035d 00000020f913313ea601e406336d9298614cd729e44053e795aaecfd2700000000000000c06d819cc44afab953d8a7d6a6e7e23cbb4625bef4bde5e7f67a723097c2a3f250a49865ef214419ae1e7839 +1999981 0000000000000016ca183790d8c679506bd6b64f95338e1be3e25ef098a45fcb 000000205d03733a2f3891c6c7d4fc8d8deca1606bdc2882b5be0f253700000000000000b549b1c3f2ba8b52028d3cc5f74e58aa2fd90bbe514e84641afa503db2f042f95ba49865eff9441999348293 +1999982 000000000000003a41e9f22fe82dc2a4efa2d592a498495ae94659c93869a7e1 00000020cb5fa498f05ee2e31b8e33954fb6d66b5079c6d8903718ca1600000000000000f659ee3c0c744c1bc6db5e8f8f87589d3044a2ed3d6e4f8623ae5328c73e0b3a3ea698654755411924920d12 +1999983 0000000000000025a8d395bc71439a45450eff029b80f3b53575098f0e206fef 00000020e1a76938c95946e95a4998a492d5a2efa4c22de82ff2e9413a000000000000005810346616027bb93fb1a2762f9d55524f83e96f9e50d622dee9b38a984a59ab54a69865196c4919ab2981db +1999984 0000000000000005b6cf960f846f492be6d215f719459a03dac9e1e802a06952 00000020ef6f200e8f097535b5f3809b02ff0e45459a4371bc95d3a8250000000000000089555f304ec6b1d5d87c33e710b0ef8bc965aa8497eb617ce67421fb421222209da79865cce646197a2ec7fd +1999985 00000000000000245a174be3b93880967b1ac687edccff64fa8714f399e87613 000000205269a002e8e1c9da039a4519f715d2e62b496f840f96cfb60500000000000000301a722c884f6033d0db713b196f93c600326d7af728da140a2e44c0a8bf3ada6da8986594f34b19f222ca55 +1999986 0000000000000037ee083383b48e4b1513a66bd730fb8377a68bdadc3c76ac3e 000000201376e899f31487fa64ffcced87c61a7b968038b9e34b175a2400000000000000145688f308c6e4a931c960f92a44b3e24b58150879a14e76655806ffc4e96d29a3a898656ed24d1945809da0 +1999987 000000000000002f764a41233db5984db6def204efc5b47d8db3575908d3726b 000000203eac763cdcda8ba67783fb30d76ba613154b8eb4833308ee3700000000000000f937c27d3effb2934727198cbca29de9e1ae61f864f495e68ded7776580b942450a9986540aa4e193c00d38e +1999988 000000000000002b73ccd2075eceac84f566a311c96295649e21599ffabcca72 000000206b72d3085957b38d7db4c5ef04f2deb64d98b53d23414a762f0000000000000027b557e6283954f4d5cf2c176c255cc6ecd44fa8e9e74877ac5d27b0a1250ab782a9986502a24e1987092536 +1999989 000000000000003b9ed4f08b75bc493b3415f680c9d6dfe67a1b4d98fdea38f2 0000002072cabcfa9f59219e649562c911a366f584acce5e07d2cc732b0000000000000095182fa56e866b66370d745f6ab25b5d506213accda1c92d4c241cbd69f9d9eb33aa986553e34b190337debb +1999990 000000000000002ba2e26d77120241fb3b7a8cf197177f72dee6e175bcbea9bc 00000020f238eafd984d1b7ae6dfd6c980f615343b49bc758bf0d49e3b000000000000001a853793704336957adc1ff68efe338bc086bc68b7221733b1e0f113bab845f6d3aa986525494c19c3619e26 +1999991 000000000000003e2e567d09d3aa48f496441b1396688e64d31d1c892ead5dfc 00000020bca9bebc75e1e6de727f1797f18c7a3bfb410212776de2a22b000000000000007af6a6907eca5bab8d3146c4b7490ca1b55905c2514c709d362d2397bed4d7a87dab9865e87f4b19d49a14ee +1999992 000000000000001dfb58c78cf03be91dd14474a6010cd67b0980592f42ce14d5 00000020fc5dad2e891c1dd3648e6896131b4496f448aad3097d562e3e00000000000000d023f15dfe1e18a10c12b74d1b7f62f6b740504c745c0ca3f2cef02b6f954ff486ab98658cc948194a236398 +1999993 0000000000000037e81132933b4868edd8b172367aa3005cea5409a23cd1cd13 00000020d514ce422f5980097bd60c01a67444d11de93bf08cc758fb1d000000000000008080291fe782dda78680fcc480cef9edd0efcd73bcb2d9e2ca31f6957de2410e20ac9865549747196a78c12b +1999994 000000000000002c0761c9185e5cbf585f303c101acf48bd071ca1eb73d0e5e4 0000002013cdd13ca20954ea5c00a37a3672b1d8ed68483b933211e83700000000000000ecb082ebdef242f4fdf1e7d3f7ab36849fcace759dfc9e4a965430e6e15004dcb0ac986522ae48195a3d8ed6 +1999995 000000000000003abff7975670c723479dc01f7abeb420c6ec78a9c11c42aa8b 00000020e4e5d073eba11c07bd48cf1a103c305f58bf5c5e18c961072c00000000000000e3903aba0df1a7a57e946ff228841daa0a6bfaee6f043cd47fa348a8797e51b9e6ac9865502445192a4e2aa7 +1999996 0000000000000044d56e2919fc1685f14d8fbaef2566f8f9e0cbf8338379965d 000000208baa421cc1a978ecc620b4be7a1fc09d4723c7705697f7bf3a00000000000000ec040a3bc34f6aea40f2e6e369262d13d2bbe251b06ef93074ba8ad00e7ca83d23ad98655b5e45190261fe92 +1999997 0000000000000023490733fe790c4cc82b843c32c63385f6040481b013b26977 000000205d96798333f8cbe0f9f86625efba8f4df18516fc19296ed54400000000000000209d4509686447a7132c5c09f1c0bdbf8494c1ecd430735b09316df98be3bae3cbaf98658c5241198a223937 +1999998 000000000000003b6e2c2847499023dde8c95d06a1779c3b69b54a16c2891564 000000207769b213b0810404f68533c6323c842bc84c0c79fe33074923000000000000009ee8dde6aa5acf7a01432c1f0d1a5fe0a0cd154bb9be609b82827cd364ec1587e5af98650a364c196f84de8e +1999999 000000000000001dc4e83bba91118506a4db6b9cad0dc2cbb45db216280b0632 00000020641589c2164ab5693b9c77a1065dc9e8dd23904947282c6e3b000000000000009fe7b797f2311707b416d43992ec761c8d5a51f75f1fda940e21312cc93558e332b198652b064d194b037712 +2000000 0000000000000009bd68b5e00976c3f7482d4cc12b6596614fbba5678ef13a59 0000002032060b2816b25db4cbc20dad9c6bdba406851191ba3be8c41d0000000000000029b59c356e22194a12227b79e867a56bc730b3b445b285634a1c763696cb3865e8b19865f3144c19c67780e8 +2000001 00000000000000034a513b4b5098496009d110b9409cd3a3803485e2ade35168 00000020593af18e67a5bb4f6196652bc14c2d48f7c37609e0b568bd0900000000000000242844e799a0fe4f1d458c57861557b473ad507620af2bbbec063064fbf1bc3d82b398654ec24f199425c4fc +2000002 000000000000002b19831eb172a23d3d92a9b866ae790dd45fe1f8c106a2cc9a 000000206851e3ade2853480a3d39c40b910d109604998504b3b514a0300000000000000c6a20e62e2f97579f0fce19f15c11d811a3a14e63aa5b89cca35adfe950f3950ebb798657a9f5119a82f37bb +2000003 0000000000000027650f61f8fd387f9d0152e8bde04af831a668b93b596dd945 000000209acca206c1f8e15fd40d79ae66b8a9923d3da272b11e83192b00000000000000cd44f260e4c0898fceaccc50e0450bc87dc4297a4c34899aa751163a8d524cc214b89865efcd6819c3301910 +2000004 0000000000000003d7efa32f843c30a95368c1af29021f393e0f2edaa515ac53 0000002045d96d593bb968a631f84ae0bde852019d7f38fdf8610f65270000000000000008357ffa407f9f314e0d6f1068adeba05810d397e8ebfb83bca3393effde06b039b898650f336b196610cfb1 +2000005 0000000000000034f98dab191bf077c0e0f036a918cab535c7b50d492a06d321 0000002053ac15a5da2e0f3e391f0229afc16853a9303c842fa3efd70300000000000000875565ddf2a1143108542d1fe3e15336966186146b6a1776d38a22afb2b7cc347bb89865e5176e19664e5a21 From 6383467ac779647bcdea51322e862725040321e9 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Mon, 19 Oct 2026 03:39:08 -0700 Subject: [PATCH 2/2] fix(dash-spv): allow early mainnet bits within 50% of Dark Gravity Wave Early mainnet ran the first versions of Dark Gravity Wave, so Dash Core only requires the exact bits above height 68589. Up to it, the bits only have to be within 50% of the required difficulty. The header validator now applies the same rule, so real mainnet headers from 34140 to 68588 are accepted. Co-Authored-By: Claude Opus 5.5 --- dash-spv/src/validation/header.rs | 4 ++- dash/src/consensus/params.rs | 7 ++++ dash/src/pow.rs | 53 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/dash-spv/src/validation/header.rs b/dash-spv/src/validation/header.rs index d3aab3a9e..02e66738a 100644 --- a/dash-spv/src/validation/header.rs +++ b/dash-spv/src/validation/header.rs @@ -64,7 +64,9 @@ impl Validator<&[HashedBlockHeader]> for BlockHeaderValidator { let bits = header.header().bits; let required = self.params.next_work_required(window, height - 1, header.header().time); - if let Some(required) = required.filter(|required| *required != bits) { + if let Some(required) = + required.filter(|required| !self.params.bits_meet_required(height, bits, *required)) + { return Err(ValidationError::InvalidDifficulty { height, actual: bits.to_consensus(), diff --git a/dash/src/consensus/params.rs b/dash/src/consensus/params.rs index a05499491..9ed599b22 100644 --- a/dash/src/consensus/params.rs +++ b/dash/src/consensus/params.rs @@ -55,6 +55,9 @@ pub struct Params { pub no_pow_retargeting: bool, /// Block height from which the difficulty follows Dark Gravity Wave. pub pow_dgw_height: u32, + /// Last block height whose bits only have to be within 50% of Dark Gravity Wave's, as early + /// mainnet ran its first versions. + pub pow_dgw_tolerant_height: Option, } impl Params { @@ -73,6 +76,7 @@ impl Params { allow_min_difficulty_blocks: false, no_pow_retargeting: false, pow_dgw_height: 34140, + pow_dgw_tolerant_height: Some(68589), }; /// Testnet parameters. @@ -90,6 +94,7 @@ impl Params { allow_min_difficulty_blocks: true, no_pow_retargeting: false, pow_dgw_height: 4002, + pow_dgw_tolerant_height: None, }; /// Devnet parameters, with Dash Core's defaults: each devnet can override its difficulty rules. @@ -107,6 +112,7 @@ impl Params { allow_min_difficulty_blocks: true, no_pow_retargeting: false, pow_dgw_height: 4001, + pow_dgw_tolerant_height: None, }; /// Regtest parameters. @@ -124,6 +130,7 @@ impl Params { allow_min_difficulty_blocks: true, no_pow_retargeting: true, pow_dgw_height: 34140, + pow_dgw_tolerant_height: None, }; /// The parameters of `network`. diff --git a/dash/src/pow.rs b/dash/src/pow.rs index fa8e31d19..d4fc9f674 100644 --- a/dash/src/pow.rs +++ b/dash/src/pow.rs @@ -454,6 +454,22 @@ impl Params { Some(self.dark_gravity_wave(window).to_compact_lossy()) } + /// Whether `bits` meets the `required` difficulty at `height`, as Core's + /// `ContextualCheckBlockHeader` judges it. + pub fn bits_meet_required( + &self, + height: u32, + bits: CompactTarget, + required: CompactTarget, + ) -> bool { + if self.pow_dgw_tolerant_height.is_some_and(|tolerant| height <= tolerant) { + let actual = bits_to_difficulty(bits); + let required = bits_to_difficulty(required); + return (actual - required).abs() <= actual * 0.5; + } + bits == required + } + /// Dash Core's `DarkGravityWave` over exactly [`DGW_PAST_BLOCKS`] headers, oldest first. fn dark_gravity_wave(&self, window: &[Header]) -> Target { // Not quite an average, but the one Core computes, newest first. @@ -478,6 +494,22 @@ impl Params { } } +/// Dash Core's `ConvertBitsToDouble`. +fn bits_to_difficulty(bits: CompactTarget) -> f64 { + let bits = bits.to_consensus(); + let mut shift = (bits >> 24) & 0xff; + let mut difficulty = f64::from(0x0000ffff) / f64::from(bits & 0x00ffffff); + while shift < 29 { + difficulty *= 256.0; + shift += 1; + } + while shift > 29 { + difficulty /= 256.0; + shift -= 1; + } + difficulty +} + // Target::MAX as a float value. Calculated with `Arith256::to_f64`. // This is validated in the unit tests as well. const TARGET_MAX_F64: f64 = 2.695953529101131e67; @@ -546,6 +578,27 @@ mod tests { ); } + // The first DGW header on mainnet, 34140, whose bits differ from DGW's. + #[test_case(Network::Mainnet, 34_140, 0x1c00f8a9, true; "mainnet first dgw header")] + #[test_case(Network::Mainnet, 68_589, 0x1c00f8a9, true; "mainnet last tolerant height")] + #[test_case(Network::Mainnet, 68_590, 0x1c00f8a9, false; "mainnet first exact height")] + #[test_case(Network::Mainnet, 68_590, 0x1c00f987, true; "mainnet exact bits")] + #[test_case(Network::Mainnet, 68_589, 0x1c01f154, false; "mainnet under half the difficulty")] + #[test_case(Network::Mainnet, 68_589, 0x1c007cc0, false; "mainnet over twice the difficulty")] + #[test_case(Network::Testnet, 34_140, 0x1c00f8a9, false; "testnet")] + fn bits_meet_required_rules(network: Network, height: u32, bits: u32, meets: bool) { + let required = CompactTarget::from_consensus(0x1c00f987); + + assert_eq!( + Params::new(network).bits_meet_required( + height, + CompactTarget::from_consensus(bits), + required + ), + meets + ); + } + #[test] fn compact_target_from_hex_str_happy_path() { let actual = CompactTarget::from_hex_str("0x01003456").unwrap();