From 490b8168db1a38e13b7f5ab2b0cd0b0087d2999d Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:26:48 +0200 Subject: [PATCH 01/13] fix(chain): validate NiPoPoW interlinks proofs --- chain/src/nipopow_proof.rs | 84 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/chain/src/nipopow_proof.rs b/chain/src/nipopow_proof.rs index b5671ec..fb81047 100644 --- a/chain/src/nipopow_proof.rs +++ b/chain/src/nipopow_proof.rs @@ -275,12 +275,14 @@ pub fn compare_nipopow_proof_bytes(a: &[u8], b: &[u8]) -> Result Result Date: Wed, 5 Aug 2026 16:51:34 +0200 Subject: [PATCH 02/13] fix(nipopow): align genesis with strict root checks --- chain/Cargo.toml | 1 + chain/src/nipopow_proof.rs | 133 +++++++++++++++++++++---------------- 2 files changed, 77 insertions(+), 57 deletions(-) diff --git a/chain/Cargo.toml b/chain/Cargo.toml index a7c28c0..0ffc4b4 100644 --- a/chain/Cargo.toml +++ b/chain/Cargo.toml @@ -18,4 +18,5 @@ num-bigint = "0.4" thiserror = "2" [dev-dependencies] +ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } serde_json = "1" diff --git a/chain/src/nipopow_proof.rs b/chain/src/nipopow_proof.rs index fb81047..a4448af 100644 --- a/chain/src/nipopow_proof.rs +++ b/chain/src/nipopow_proof.rs @@ -62,10 +62,9 @@ pub struct NipopowVerificationResult { /// interlink hierarchy on demand and only fetches the popow headers the /// proof actually visits — `O(m + k + m · log₂ N)` per call instead of `O(N)`. /// -/// **Genesis special case**: per `facts/chain.md` Phase 6 invariants, the -/// genesis block's `interlinks = [genesis_id]` is canonical and the extension -/// loader MUST NOT be called for `height == 1` (real testnet/mainnet genesis -/// extensions are empty and would yield wrong interlinks). The +/// **Genesis special case**: the genesis block has empty interlinks and an +/// empty proof. The extension loader MUST NOT be called for `height == 1` +/// because real testnet/mainnet genesis extensions are empty. The /// `popow_header_at_height(1)` and `popow_header_by_id(genesis_id)` paths /// synthesize the popow header in-process; every other height path goes /// through the loader as normal. @@ -74,18 +73,14 @@ struct ChainPopowReader<'a> { } impl<'a> ChainPopowReader<'a> { - /// Synthesize a `PoPowHeader` for `header` with interlinks given by - /// `interlinks`. Builds a synthetic `ExtensionCandidate` carrying the - /// canonical packed-interlinks fields and feeds it through - /// `NipopowAlgos::proof_for_interlink_vector` so the resulting merkle - /// proof is byte-identical with the JVM equivalent. - fn build_popow_header(header: Header, interlinks: Vec) -> Option { - let extension_candidate = - ExtensionCandidate::new(NipopowAlgos::pack_interlinks(interlinks.clone())).ok()?; + /// Synthesize the canonical genesis `PoPowHeader` without consulting the + /// extension loader. + fn build_genesis_popow_header(header: Header) -> Option { + let extension_candidate = ExtensionCandidate::default(); let interlinks_proof = NipopowAlgos::proof_for_interlink_vector(&extension_candidate)?; Some(PoPowHeader { header, - interlinks, + interlinks: Vec::new(), interlinks_proof, }) } @@ -105,11 +100,9 @@ impl<'a> PopowHeaderReader for ChainPopowReader<'a> { let header = self.chain.header_at(height)?; // Genesis: synthesize in-process. NEVER call the loader for h=1 — - // real genesis extensions are empty and would produce empty - // interlinks, which is wrong by convention. + // Real genesis extensions and canonical interlinks/proof are empty. if height == 1 { - let genesis_id = header.id; - return Self::build_popow_header(header, vec![genesis_id]); + return Self::build_genesis_popow_header(header); } // h >= 2: load real extension bytes, unpack canonical interlinks. @@ -370,14 +363,32 @@ mod tests { use crate::voting::pack_extension_bytes; use crate::{ChainConfig, HeaderChain}; use ergo_chain_types::{ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Header, Votes}; + use ergo_merkle_tree::{MerkleNode, MerkleTree}; use sigma_ser::ScorexSerializable; use std::sync::{Arc, Mutex}; - fn make_synthetic_header( + fn extension_root(fields: &[([u8; 2], Vec)]) -> Digest32 { + MerkleTree::new( + fields + .iter() + .map(|(key, value)| { + std::iter::once(2u8) + .chain(key.iter().copied()) + .chain(value.iter().copied()) + .collect::>() + }) + .map(MerkleNode::from_bytes) + .collect::>(), + ) + .root_hash_special() + } + + fn make_synthetic_header_with_extension_root( height: u32, parent_id: BlockId, timestamp: u64, n_bits: u32, + extension_root: Digest32, ) -> Header { let zero32 = Digest32::zero(); let mut header = Header { @@ -390,7 +401,7 @@ mod tests { timestamp, n_bits, height, - extension_root: zero32, + extension_root, autolykos_solution: AutolykosSolution { miner_pk: Box::new(EcPoint::default()), pow_onetime_pk: None, @@ -406,6 +417,21 @@ mod tests { header } + fn make_synthetic_header( + height: u32, + parent_id: BlockId, + timestamp: u64, + n_bits: u32, + ) -> Header { + make_synthetic_header_with_extension_root( + height, + parent_id, + timestamp, + n_bits, + Digest32::zero(), + ) + } + /// Build a synthetic chain of `count` headers + a per-height extension /// store containing interlink fields. Returns the chain (with loader /// already wired) and the synthetic store. @@ -424,56 +450,47 @@ mod tests { let mut chain = HeaderChain::new(config.clone()); let n_bits = config.initial_n_bits; - // Build headers list first so we can compute interlinks for each - // and store the extension bytes per height. + // Build sequentially because each header id commits to its extension + // root and the next block's interlinks commit to prior header ids. let mut headers: Vec
= Vec::with_capacity(count as usize); + let mut interlinks: Vec = Vec::new(); + let mut store: std::collections::HashMap> = + std::collections::HashMap::new(); let mut prev_id = BlockId(Digest32::zero()); - let g = make_synthetic_header(1, prev_id, 1_000_000, n_bits); - prev_id = g.id; - headers.push(g); - for h in 2..=count { + for h in 1..=count { + if let Some(previous_header) = headers.last() { + interlinks = + NipopowAlgos::update_interlinks(previous_header.clone(), interlinks) + .expect("update_interlinks"); + } + + let mut fields = if h == 1 { + Vec::new() + } else { + NipopowAlgos::pack_interlinks(interlinks.clone()) + }; + if h > 1 { + // Exercise full-extension proofs rather than the degenerate + // interlinks-only root used by the legacy verifier. + fields.insert(0, ([0x00, 0x00], h.to_be_bytes().to_vec())); + } // Compute expected difficulty based on currently-built chain // for nBits inheritance — but to avoid bringing in chain state // here, we just use the parent's n_bits within the first epoch. - let header = make_synthetic_header( + let header = make_synthetic_header_with_extension_root( h, prev_id, 1_000_000 + (h as u64 - 1) * 45_000, n_bits, + extension_root(&fields), ); + if h != 1 || include_genesis_in_loader { + store.insert(h, pack_extension_bytes(&header.id, &fields)); + } prev_id = header.id; headers.push(header); } - // Build per-height interlinks and extension bytes. - let mut interlinks: Vec> = Vec::with_capacity(headers.len()); - for (idx, h) in headers.iter().enumerate() { - if idx == 0 { - // Genesis: interlinks = [genesis_id] - interlinks.push(vec![h.id]); - } else { - let prev_header = &headers[idx - 1]; - let prev_interlinks = interlinks[idx - 1].clone(); - let new_interlinks = - NipopowAlgos::update_interlinks(prev_header.clone(), prev_interlinks) - .expect("update_interlinks"); - interlinks.push(new_interlinks); - } - } - - // Pack each into extension bytes keyed by height. - let mut store: std::collections::HashMap> = - std::collections::HashMap::new(); - for (idx, h) in headers.iter().enumerate() { - if h.height == 1 && !include_genesis_in_loader { - continue; - } - let interlinks_for_h = &interlinks[idx]; - let fields = NipopowAlgos::pack_interlinks(interlinks_for_h.clone()); - let bytes = pack_extension_bytes(&h.id, &fields); - store.insert(h.height, bytes); - } - // Append headers to chain (no_pow path). for h in headers { chain.try_append_no_pow(h).expect("append"); @@ -867,8 +884,10 @@ mod tests { .expect("genesis must return Some(bytes)"); let parsed = PoPowHeader::scorex_parse_bytes(&bytes).expect("parses cleanly"); assert_eq!(parsed.header.id, genesis_id); - // Genesis interlinks convention: [genesis_id]. - assert_eq!(parsed.interlinks, vec![genesis_id]); + assert!(parsed.interlinks.is_empty()); + assert!(parsed.interlinks_proof.get_indices().is_empty()); + assert!(parsed.interlinks_proof.get_proofs().is_empty()); + assert!(parsed.check_interlinks_proof()); } #[test] From 9419285c2410efe28916c1dcafd07e9059ec5622 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:29:34 +0200 Subject: [PATCH 03/13] chore(chain): sync NiPoPoW fixture dependency lock --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 03a2d69..e5d8972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,6 +644,7 @@ version = "0.1.0" dependencies = [ "ergo-chain-types", "ergo-lib", + "ergo-merkle-tree", "ergo-nipopow", "hashbrown 0.16.1", "lru", From 53cf4b18130235d000176b0c93f17e1e99eb1b9c Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:35:44 +0200 Subject: [PATCH 04/13] fix(pow): reject zero decoded difficulty target --- chain/src/error.rs | 4 ++++ chain/src/pow.rs | 8 +++++++- chain/src/tests.rs | 19 +++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/chain/src/error.rs b/chain/src/error.rs index 36474fd..2f343ca 100644 --- a/chain/src/error.rs +++ b/chain/src/error.rs @@ -13,6 +13,10 @@ pub enum ChainError { #[error("PoW verification failed: hit {hit} >= target {target}")] PowInvalid { hit: String, target: String }, + /// Header's compact difficulty decodes to an unusable zero target. + #[error("invalid PoW target: nBits {n_bits} decodes to zero")] + InvalidPowTarget { n_bits: u32 }, + /// Error computing proof-of-work hit. #[error("PoW computation error: {0}")] PowCompute(#[from] AutolykosPowSchemeError), diff --git a/chain/src/pow.rs b/chain/src/pow.rs index 4700e20..8e23727 100644 --- a/chain/src/pow.rs +++ b/chain/src/pow.rs @@ -19,12 +19,18 @@ fn pow_scheme() -> &'static AutolykosPowScheme { /// `q / decode_compact_bits(header.n_bits)`, where `q` is the secp256k1 group order. /// /// Returns `Ok(())` if valid, `Err(ChainError::PowInvalid)` if the hit doesn't -/// meet the target, or `Err(ChainError::PowCompute)` if the hit can't be computed. +/// meet the target, `Err(ChainError::InvalidPowTarget)` if the compact target +/// decodes to zero, or `Err(ChainError::PowCompute)` if the hit can't be computed. pub fn verify_pow(header: &Header) -> Result<(), ChainError> { let pow = pow_scheme(); let hit = pow.pow_hit(header)?; let decoded_n_bits = decode_compact_bits(header.n_bits); + if decoded_n_bits == 0.into() { + return Err(ChainError::InvalidPowTarget { + n_bits: header.n_bits, + }); + } let target = order_bigint() / decoded_n_bits; let hit_bigint = hit diff --git a/chain/src/tests.rs b/chain/src/tests.rs index 0f04778..d4e0b33 100644 --- a/chain/src/tests.rs +++ b/chain/src/tests.rs @@ -3,7 +3,7 @@ mod parse_tests { use crate::{parse_header, ChainError}; use sigma_ser::ScorexSerializable; - fn v2_header_json() -> &'static str { + pub(super) fn v2_header_json() -> &'static str { r#"{ "extensionId": "d16f25b14457186df4c5f6355579cc769261ce1aebc8209949ca6feadbac5a3f", "difficulty": "626412390187008", @@ -126,7 +126,7 @@ mod parse_tests { #[cfg(test)] mod pow_tests { - use crate::verify_pow; + use crate::{verify_pow, ChainError}; /// Valid V2 header at height 614400 (first N increase) — known-good PoW from sigma-rust tests. #[test] @@ -159,6 +159,21 @@ mod pow_tests { assert!(result.is_ok(), "valid PoW should pass: {result:?}"); } + #[test] + fn verify_pow_rejects_zero_decoded_target_without_panic() { + let mut header: ergo_chain_types::Header = + serde_json::from_str(super::parse_tests::v2_header_json()).unwrap(); + header.n_bits = 0; + + let result = std::panic::catch_unwind(|| verify_pow(&header)); + + assert!(result.is_ok(), "zero decoded target must not panic"); + assert!(matches!( + result.unwrap(), + Err(ChainError::InvalidPowTarget { n_bits: 0 }) + )); + } + /// Invalid V2 header at height 2870 — PoW doesn't meet difficulty target. #[test] fn verify_pow_invalid_v2_header() { From 3dc2f90ba5f98cfad0600b73ad16b9f5e303628a Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:07:00 +0200 Subject: [PATCH 05/13] fix(nipopow): bind verification to request and genesis --- chain/src/chain.rs | 5 + chain/src/lib.rs | 5 +- chain/src/nipopow_proof.rs | 522 ++++++++++++++++++++++------- facts/chain.md | 71 ++-- src/bridge.rs | 12 +- src/main.rs | 9 +- src/nipopow_serve.rs | 15 +- tests/nipopow_serve_integration.rs | 60 ++-- 8 files changed, 502 insertions(+), 197 deletions(-) diff --git a/chain/src/chain.rs b/chain/src/chain.rs index dead316..816ecfd 100644 --- a/chain/src/chain.rs +++ b/chain/src/chain.rs @@ -410,6 +410,11 @@ impl HeaderChain { &self.config } + /// Configured genesis trust anchor, if this deployment defines one. + pub fn configured_genesis_id(&self) -> Option { + self.config.genesis_id + } + /// Number of headers in the chain. pub fn len(&self) -> usize { self.by_id.len() diff --git a/chain/src/lib.rs b/chain/src/lib.rs index 1dfad1d..fb6ed32 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -36,8 +36,9 @@ pub use sync_info::{build_sync_info, parse_sync_info, SyncInfo}; pub use num_bigint::{BigInt, BigUint}; pub use tracker::HeaderTracker; pub use nipopow_proof::{ - build_nipopow_proof, compare_nipopow_proof_bytes, popow_header_by_id, - verify_nipopow_proof_bytes, NipopowVerificationResult, + build_nipopow_proof, compare_nipopow_proof_bytes, inspect_nipopow_proof_bytes, + popow_header_by_id, verify_nipopow_proof_bytes, NipopowInspection, + NipopowVerificationContext, NipopowVerificationResult, }; pub use voting::{ check_fork_vote, compute_boundary_parameters, encode_validation_settings_update, diff --git a/chain/src/nipopow_proof.rs b/chain/src/nipopow_proof.rs index a4448af..602380d 100644 --- a/chain/src/nipopow_proof.rs +++ b/chain/src/nipopow_proof.rs @@ -1,9 +1,9 @@ //! NiPoPoW proof construction and verification (Phase 6). //! -//! Wraps `ergo-nipopow` for build/verify on the local header chain. -//! Light-client sync mode (applying a proof to chain state) is out of -//! scope — proofs are verified for correctness but not used to skip -//! block download. +//! Wraps `ergo-nipopow` for build/verify on the local header chain. Received +//! proofs are bound to their request parameters and configured genesis before +//! their exact prefix/suffix split is returned to the light-bootstrap layer. +//! Chain-state mutation remains the responsibility of that consumer. //! //! JVM reference: //! - `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala` @@ -22,37 +22,86 @@ use crate::error::ChainError; /// caps the proof size for sanity. pub const MAX_M_K: u32 = 256; -/// Result of a successful NiPoPoW proof verification. -/// -/// Carries the metadata fields used by serve-side log paths AND the full -/// extracted header chain so the light-client install path can pass it to -/// [`HeaderChain::install_from_nipopow_proof`] without re-parsing the bytes. -/// -/// Renamed from `NipopowProofMeta` (which only carried metadata) to reflect -/// the new return shape. Existing serve-side consumers reference fields by -/// name only, so the rename is type-name-only there. +/// Request and trust-anchor values a received proof must match exactly. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct NipopowVerificationContext { + pub expected_m: u32, + pub expected_k: u32, + pub expected_genesis_id: BlockId, +} + +impl NipopowVerificationContext { + /// Build a context from the chain's configured trust anchor. + pub fn from_chain( + chain: &HeaderChain, + expected_m: u32, + expected_k: u32, + ) -> Result { + let expected_genesis_id = chain + .configured_genesis_id() + .ok_or_else(|| ChainError::Nipopow("configured genesis id is absent".into()))?; + Ok(Self { + expected_m, + expected_k, + expected_genesis_id, + }) + } + + fn validate(&self) -> Result<(), ChainError> { + if self.expected_m == 0 + || self.expected_k == 0 + || self.expected_m > MAX_M_K + || self.expected_k > MAX_M_K + { + return Err(ChainError::Nipopow(format!( + "invalid verification context: m={}, k={}, max={MAX_M_K}", + self.expected_m, self.expected_k + ))); + } + Ok(()) + } +} + +/// Lossless result of context-bound NiPoPoW proof verification. #[derive(Debug, Clone, Eq, PartialEq)] pub struct NipopowVerificationResult { - /// Height of the suffix tip (highest header in the proof). + pub m: u32, + pub k: u32, + /// JVM terminal mode, or `None` for a Rust-core-only payload. + pub continuous: Option, + pub prefix: Vec
, + pub suffix_head: Header, + pub suffix_tail: Vec
, +} + +impl NipopowVerificationResult { + pub fn total_headers(&self) -> usize { + self.prefix.len() + 1 + self.suffix_tail.len() + } + + pub fn suffix_tip_height(&self) -> u32 { + self.suffix_tail.last().unwrap_or(&self.suffix_head).height + } + + pub fn headers(&self) -> impl Iterator { + self.prefix + .iter() + .chain(std::iter::once(&self.suffix_head)) + .chain(self.suffix_tail.iter()) + } +} + +/// Structurally and cryptographically checked metadata for diagnostics only. +/// +/// This type intentionally carries no headers and cannot authorize bootstrap +/// installation because it is not bound to a request or configured genesis. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct NipopowInspection { + pub m: u32, + pub k: u32, + pub continuous: Option, pub suffix_tip_height: u32, - /// Total number of headers in the proof (prefix + suffix). pub total_headers: usize, - /// Whether the proof is in continuous mode (carries difficulty headers). - /// - /// Always `false` for first release; we don't currently propagate the - /// continuous-mode flag from `NipopowProof`. Difficulty-recalculation - /// header presence is not separately validated either. - pub continuous: bool, - /// Headers extracted from the verified proof, in strictly-increasing - /// height order: `prefix.iter().map(|p| p.header) - /// .chain(once(suffix_head.header)) - /// .chain(suffix_tail)`. - /// - /// The light-client install path passes `headers.last()` as `suffix_head` - /// and the `k - 1` headers preceding it as `suffix_tail`. Callers that - /// only want metadata (the existing serve-side log path) can ignore the - /// field at zero parsing cost — it's already materialized. - pub headers: Vec
, } /// `PopowHeaderReader` adapter over the local `HeaderChain`. @@ -250,39 +299,113 @@ pub fn popow_header_by_id( /// payload only). Returns `true` if `a` represents a better chain than `b` /// per KMZ17 §4.3. /// -/// Both byte slices must be valid NiPoPoW proof payloads (as produced by -/// `NipopowProof::scorex_serialize_bytes`). Parse failure on either side -/// returns an error. +/// Both byte slices must already have passed [`verify_nipopow_proof_bytes`] +/// against the same [`NipopowVerificationContext`]. This function validates +/// both proofs again, and sigma-rust rejects unequal `m`/`k` parameters during +/// comparison. Parse or validation failure on either side returns an error. pub fn compare_nipopow_proof_bytes(a: &[u8], b: &[u8]) -> Result { - let proof_a = NipopowProof::scorex_parse_bytes(a) - .map_err(|e| ChainError::Nipopow(format!("parse proof A failed: {e:?}")))?; - let proof_b = NipopowProof::scorex_parse_bytes(b) - .map_err(|e| ChainError::Nipopow(format!("parse proof B failed: {e:?}")))?; + let (proof_a, _) = parse_received_nipopow_proof(a) + .map_err(|e| ChainError::Nipopow(format!("parse proof A failed: {e}")))?; + let (proof_b, _) = parse_received_nipopow_proof(b) + .map_err(|e| ChainError::Nipopow(format!("parse proof B failed: {e}")))?; proof_a .is_better_than(&proof_b) .map_err(|e| ChainError::Nipopow(format!("comparison failed: {e:?}"))) } +fn parse_received_nipopow_proof(bytes: &[u8]) -> Result<(NipopowProof, Option), ChainError> { + if bytes.is_empty() { + return Err(ChainError::Nipopow("empty proof bytes".into())); + } + + let mut cursor = std::io::Cursor::new(bytes); + let proof = NipopowProof::scorex_parse(&mut cursor) + .map_err(|e| ChainError::Nipopow(format!("parse failed: {e:?}")))?; + let consumed = usize::try_from(cursor.position()) + .map_err(|_| ChainError::Nipopow("proof cursor position does not fit usize".into()))?; + let remaining = bytes + .get(consumed..) + .ok_or_else(|| ChainError::Nipopow("proof cursor exceeded input".into()))?; + let continuous = match remaining { + [] => None, + [0] => Some(false), + [1] => Some(true), + [mode] => { + return Err(ChainError::Nipopow(format!( + "invalid JVM continuous mode byte {mode}" + ))) + } + _ => { + return Err(ChainError::Nipopow(format!( + "unexpected {} trailing bytes after proof core", + remaining.len() + ))) + } + }; + + Ok((proof, continuous)) +} + +fn validate_received_nipopow_proof(proof: &NipopowProof) -> Result<(), ChainError> { + proof + .validate() + .map_err(|e| ChainError::Nipopow(format!("proof validation failed: {e}")))?; + Ok(()) +} + +fn verify_nipopow_headers_pow(proof: &NipopowProof) -> Result<(), ChainError> { + for header in proof + .prefix + .iter() + .map(|p| &p.header) + .chain(std::iter::once(&proof.suffix_head.header)) + .chain(proof.suffix_tail.iter()) + { + crate::verify_pow(header)?; + } + Ok(()) +} + /// Verify a NiPoPoW proof from raw bytes. /// /// **Precondition**: `bytes` is the inner NiPoPoW proof payload (the main /// crate has stripped any P2P message envelope). /// -/// **Validation checks** (mirrors `NipopowProof.isValid`, plus PoW): -/// 1. The proof parses cleanly via the Scorex serializer. -/// 2. Parent connections in the chain are consistent (via -/// `NipopowProof::has_valid_connections`). -/// 3. Every prefix and suffix-head interlinks proof passes -/// `PoPowHeader::check_interlinks_proof`. -/// 4. Heights strictly increase across the headers chain. -/// 5. Each header's PoW passes [`crate::verify_pow`]. +/// The proof must pass canonical sigma-rust validation, match `context` +/// exactly, bind its first proof-chain header to the configured genesis, and +/// pass [`crate::verify_pow`] for every extracted header. /// /// Does NOT touch chain state. Does NOT apply the proof to local chain. -/// The returned [`NipopowVerificationResult::headers`] field carries the -/// extracted header chain in height order so the caller can install it via -/// [`crate::HeaderChain::install_from_nipopow_proof`] without re-parsing. -pub fn verify_nipopow_proof_bytes(bytes: &[u8]) -> Result { - verify_inner(bytes, true) +/// The returned result preserves the parsed prefix and suffix boundary so an +/// installer never has to reconstruct it from a flattened header vector. +pub fn verify_nipopow_proof_bytes( + bytes: &[u8], + context: &NipopowVerificationContext, +) -> Result { + verify_inner(bytes, context, true) +} + +/// Inspect a proof for diagnostics without binding it to a request or genesis. +/// +/// The returned type intentionally contains no headers and MUST NOT authorize +/// bootstrap selection or installation. +pub fn inspect_nipopow_proof_bytes(bytes: &[u8]) -> Result { + let (proof, continuous) = parse_received_nipopow_proof(bytes)?; + validate_received_nipopow_proof(&proof)?; + verify_nipopow_headers_pow(&proof)?; + let suffix_tip_height = proof + .suffix_tail + .last() + .unwrap_or(&proof.suffix_head.header) + .height; + let total_headers = proof.prefix.len() + 1 + proof.suffix_tail.len(); + Ok(NipopowInspection { + m: proof.m, + k: proof.k, + continuous, + suffix_tip_height, + total_headers, + }) } /// Test-only: verify a NiPoPoW proof without running the per-header PoW @@ -291,69 +414,64 @@ pub fn verify_nipopow_proof_bytes(bytes: &[u8]) -> Result Result { - verify_inner(bytes, false) + verify_inner(bytes, context, false) } -fn verify_inner(bytes: &[u8], check_pow: bool) -> Result { - if bytes.is_empty() { - return Err(ChainError::Nipopow("empty proof bytes".into())); - } - let proof = NipopowProof::scorex_parse_bytes(bytes).map_err(|e| { - ChainError::Nipopow(format!("parse failed: {e:?}")) - })?; +fn verify_inner( + bytes: &[u8], + context: &NipopowVerificationContext, + check_pow: bool, +) -> Result { + context.validate()?; + let (proof, continuous) = parse_received_nipopow_proof(bytes)?; + validate_received_nipopow_proof(&proof)?; - if !proof.has_valid_connections() { - return Err(ChainError::Nipopow("invalid connections".into())); + if proof.m != context.expected_m { + return Err(ChainError::Nipopow(format!( + "expected m {}, got {}", + context.expected_m, proof.m + ))); } - - // `NipopowProof::has_valid_proofs` is private in the pinned dependency, - // so apply its per-PoPowHeader predicate explicitly at this consumer - // boundary before the proof is reduced to bare headers. - if !std::iter::once(&proof.suffix_head) - .chain(proof.prefix.iter()) - .all(PoPowHeader::check_interlinks_proof) - { - return Err(ChainError::Nipopow("invalid interlinks proof".into())); + if proof.k != context.expected_k { + return Err(ChainError::Nipopow(format!( + "expected k {}, got {}", + context.expected_k, proof.k + ))); } - // Walk all headers (prefix + suffix_head + suffix_tail) in order, check - // strictly-increasing heights + (optionally) PoW, and retain ownership - // so the caller can install them without re-parsing the bytes. - let all_headers: Vec
= proof + let proof_genesis_id = proof .prefix - .iter() - .map(|p| &p.header) - .chain(std::iter::once(&proof.suffix_head.header)) - .chain(proof.suffix_tail.iter()) - .cloned() - .collect(); - - if all_headers.is_empty() { - return Err(ChainError::Nipopow("empty proof headers chain".into())); + .first() + .map(|p| p.header.id) + .unwrap_or(proof.suffix_head.header.id); + if proof_genesis_id != context.expected_genesis_id { + return Err(ChainError::Nipopow(format!( + "proof genesis {proof_genesis_id} does not match configured genesis {}", + context.expected_genesis_id + ))); } - let mut last_height: Option = None; - for h in &all_headers { - if let Some(prev) = last_height { - if h.height <= prev { - return Err(ChainError::Nipopow(format!( - "non-increasing heights: {} after {}", - h.height, prev - ))); - } - } - last_height = Some(h.height); - if check_pow { - crate::verify_pow(h)?; - } + if check_pow { + verify_nipopow_headers_pow(&proof)?; } + let NipopowProof { + m, + k, + prefix, + suffix_head, + suffix_tail, + .. + } = proof; Ok(NipopowVerificationResult { - suffix_tip_height: last_height.unwrap_or(0), - total_headers: all_headers.len(), - continuous: false, - headers: all_headers, + m, + k, + continuous, + prefix: prefix.into_iter().map(|p| p.header).collect(), + suffix_head: suffix_head.header, + suffix_tail, }) } @@ -362,7 +480,9 @@ mod tests { use super::*; use crate::voting::pack_extension_bytes; use crate::{ChainConfig, HeaderChain}; - use ergo_chain_types::{ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Header, Votes}; + use ergo_chain_types::{ + ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Header, Votes, + }; use ergo_merkle_tree::{MerkleNode, MerkleTree}; use sigma_ser::ScorexSerializable; use std::sync::{Arc, Mutex}; @@ -505,6 +625,18 @@ mod tests { chain } + fn verification_context( + chain: &HeaderChain, + expected_m: u32, + expected_k: u32, + ) -> NipopowVerificationContext { + NipopowVerificationContext { + expected_m, + expected_k, + expected_genesis_id: chain.header_at(1).expect("genesis header").id, + } + } + #[test] fn build_proof_too_short_chain_errors() { let chain = build_chain_with_interlinks(3); @@ -552,30 +684,157 @@ mod tests { fn build_then_verify_roundtrip_no_pow() { let chain = build_chain_with_interlinks(20); let bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); - let result = verify_nipopow_proof_bytes_no_pow(&bytes).expect("verify"); - assert!(result.total_headers > 0); - assert_eq!(result.suffix_tip_height, 20); - // The headers field carries the same chain that's reflected in - // total_headers + suffix_tip_height — the install path consumes it + let context = verification_context(&chain, 2, 2); + let result = verify_nipopow_proof_bytes_no_pow(&bytes, &context).expect("verify"); + assert!(result.total_headers() > 0); + assert_eq!(result.suffix_tip_height(), 20); + // The lossless segments carry the same chain reflected in + // total_headers + suffix_tip_height; the install path consumes them // directly without re-parsing the bytes. - assert_eq!(result.headers.len(), result.total_headers); - assert_eq!( - result.headers.last().unwrap().height, - result.suffix_tip_height - ); + let headers: Vec<&Header> = result.headers().collect(); + assert_eq!(headers.len(), result.total_headers()); + assert_eq!(headers.last().unwrap().height, result.suffix_tip_height()); // Heights are strictly increasing across the extracted chain. - for pair in result.headers.windows(2) { + for pair in headers.windows(2) { assert!(pair[0].height < pair[1].height); } } + #[test] + fn verification_result_preserves_parsed_suffix_boundary() { + let chain = build_chain_with_interlinks(20); + let bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + let parsed = NipopowProof::scorex_parse_bytes(&bytes).expect("parse control"); + assert!( + !parsed.prefix.is_empty(), + "fixture must exercise the prefix" + ); + + let expected_prefix: Vec = parsed.prefix.iter().map(|p| p.header.id).collect(); + let expected_suffix_head = parsed.suffix_head.header.id; + let expected_suffix_tail: Vec = parsed.suffix_tail.iter().map(|h| h.id).collect(); + let context = verification_context(&chain, 2, 2); + + let result = verify_nipopow_proof_bytes_no_pow(&bytes, &context).expect("verify"); + + assert_eq!(result.m, 2); + assert_eq!(result.k, 2); + assert_eq!(result.continuous, None); + assert_eq!( + result.prefix.iter().map(|h| h.id).collect::>(), + expected_prefix + ); + assert_eq!(result.suffix_head.id, expected_suffix_head); + assert_eq!( + result.suffix_tail.iter().map(|h| h.id).collect::>(), + expected_suffix_tail + ); + assert_eq!( + result.total_headers(), + result.prefix.len() + 1 + result.suffix_tail.len() + ); + assert_eq!(result.suffix_tip_height(), 20); + } + + #[test] + fn verify_rejects_unrequested_m() { + let chain = build_chain_with_interlinks(20); + let bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + let context = verification_context(&chain, 3, 2); + + let err = verify_nipopow_proof_bytes_no_pow(&bytes, &context) + .expect_err("proof m must match the request"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("expected m"))); + } + + #[test] + fn verify_rejects_unrequested_k() { + let chain = build_chain_with_interlinks(20); + let bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + let context = verification_context(&chain, 2, 3); + + let err = verify_nipopow_proof_bytes_no_pow(&bytes, &context) + .expect_err("proof k must match the request"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("expected k"))); + } + + #[test] + fn verify_rejects_suffix_length_mismatch() { + let chain = build_chain_with_interlinks(20); + let bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + let mut proof = NipopowProof::scorex_parse_bytes(&bytes).expect("parse control"); + proof.k = 3; + let malformed = proof + .scorex_serialize_bytes() + .expect("serialize malformed proof"); + let context = verification_context(&chain, 2, 3); + + assert!(verify_nipopow_proof_bytes_no_pow(&malformed, &context).is_err()); + } + + #[test] + fn verify_rejects_wrong_genesis() { + let chain = build_chain_with_interlinks(20); + let bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + let mut context = verification_context(&chain, 2, 2); + context.expected_genesis_id = BlockId(Digest32::from([0xA6; 32])); + + let err = verify_nipopow_proof_bytes_no_pow(&bytes, &context) + .expect_err("proof must bind configured genesis"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("genesis"))); + } + + #[test] + fn verification_context_rejects_absent_configured_genesis() { + let chain = HeaderChain::new(ChainConfig::testnet()); + + assert!(NipopowVerificationContext::from_chain(&chain, 6, 10).is_err()); + } + + #[test] + fn verify_parses_optional_jvm_terminal_mode() { + let chain = build_chain_with_interlinks(20); + let core = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + let context = verification_context(&chain, 2, 2); + + for (terminal, expected) in [(None, None), (Some(0), Some(false)), (Some(1), Some(true))] { + let mut bytes = core.clone(); + if let Some(mode) = terminal { + bytes.push(mode); + } + let result = verify_nipopow_proof_bytes_no_pow(&bytes, &context).expect("verify"); + assert_eq!(result.continuous, expected); + } + } + + #[test] + fn verify_rejects_invalid_jvm_terminal_mode() { + let chain = build_chain_with_interlinks(20); + let mut bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + bytes.push(2); + let context = verification_context(&chain, 2, 2); + + assert!(verify_nipopow_proof_bytes_no_pow(&bytes, &context).is_err()); + } + + #[test] + fn verify_rejects_extra_bytes_after_jvm_terminal_mode() { + let chain = build_chain_with_interlinks(20); + let mut bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + bytes.extend_from_slice(&[0, 0]); + let context = verification_context(&chain, 2, 2); + + assert!(verify_nipopow_proof_bytes_no_pow(&bytes, &context).is_err()); + } + #[test] fn verify_rejects_invalid_interlinks_proof() { let source_chain = build_chain_with_interlinks(20); let bytes = build_nipopow_proof(&source_chain, 6, 10, None).expect("build"); - let control = verify_nipopow_proof_bytes_no_pow(&bytes) + let context = verification_context(&source_chain, 6, 10); + let control = verify_nipopow_proof_bytes_no_pow(&bytes, &context) .expect("control proof must pass verification"); - assert_eq!(control.suffix_tip_height, 20); + assert_eq!(control.suffix_tip_height(), 20); for mutate_prefix in [false, true] { let mut proof = NipopowProof::scorex_parse_bytes(&bytes).expect("parse built proof"); @@ -624,10 +883,10 @@ mod tests { let observed_bytes = proof .scorex_serialize_bytes() .expect("serialize observation"); - let err = verify_nipopow_proof_bytes_no_pow(&observed_bytes) + let err = verify_nipopow_proof_bytes_no_pow(&observed_bytes, &context) .expect_err("verifier must reject an invalid interlinks proof"); assert!( - matches!(err, ChainError::Nipopow(ref msg) if msg == "invalid interlinks proof"), + matches!(err, ChainError::Nipopow(ref msg) if msg.contains("interlink proofs")), "unexpected {location} rejection: {err:?}" ); } @@ -635,13 +894,23 @@ mod tests { #[test] fn verify_empty_bytes_errors() { - let r = verify_nipopow_proof_bytes(&[]); + let context = NipopowVerificationContext { + expected_m: 6, + expected_k: 10, + expected_genesis_id: BlockId(Digest32::zero()), + }; + let r = verify_nipopow_proof_bytes(&[], &context); assert!(r.is_err()); } #[test] fn verify_garbage_bytes_errors() { - let r = verify_nipopow_proof_bytes(&[0xFFu8; 32]); + let context = NipopowVerificationContext { + expected_m: 6, + expected_k: 10, + expected_genesis_id: BlockId(Digest32::zero()), + }; + let r = verify_nipopow_proof_bytes(&[0xFFu8; 32], &context); assert!(r.is_err()); } @@ -664,9 +933,10 @@ mod tests { // build, serialize, and round-trip back through the verifier. let chain = build_chain_with_interlinks_opts(20, false); let bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); - let result = verify_nipopow_proof_bytes_no_pow(&bytes).expect("verify"); - assert!(result.total_headers >= 4); // m + k = 4 - assert_eq!(result.suffix_tip_height, 20); + let context = verification_context(&chain, 2, 2); + let result = verify_nipopow_proof_bytes_no_pow(&bytes, &context).expect("verify"); + assert!(result.total_headers() >= 4); // m + k = 4 + assert_eq!(result.suffix_tip_height(), 20); } #[test] @@ -679,7 +949,8 @@ mod tests { if bytes.len() > 50 { bytes[50] ^= 0xFFu8; } - let r = verify_nipopow_proof_bytes_no_pow(&bytes); + let context = verification_context(&chain, 2, 2); + let r = verify_nipopow_proof_bytes_no_pow(&bytes, &context); assert!(r.is_err(), "mutated proof must fail"); } @@ -758,7 +1029,7 @@ mod tests { // End-to-end regression test for the silent-corruption postcondition: // // `build_nipopow_proof` MUST NEVER return `Ok(bytes)` where - // `verify_nipopow_proof_bytes(bytes)` fails. Either the build + // context-bound verification of `bytes` fails. Either the build // returns an `Err` (clean fail) or it returns bytes that pass // verify (correct construction). There is no third state. // @@ -848,7 +1119,8 @@ mod tests { // Clean fail — acceptable. } Ok(bytes) => { - verify_nipopow_proof_bytes_no_pow(&bytes).unwrap_or_else(|e| panic!( + let context = verification_context(&chain, 2, 2); + verify_nipopow_proof_bytes_no_pow(&bytes, &context).unwrap_or_else(|e| panic!( "build_nipopow_proof returned Ok with bytes that fail verify (silent corruption): {e:?}" )); } diff --git a/facts/chain.md b/facts/chain.md index f902e9a..93f5365 100644 --- a/facts/chain.md +++ b/facts/chain.md @@ -1093,57 +1093,54 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo **Verified by**: integration test `tests/nipopow_serve_integration.rs` in the main crate, which sends `GetNipopowProof(m=6, k=6)` to a running node and verifies the response round-trips through - `verify_nipopow_proof_bytes`. The chain crate's + `verify_nipopow_proof_bytes` against the requested `m`/`k` and canonical + testnet genesis. The chain crate's `build_proof_skips_loader_for_genesis` unit test fixtures a chain whose loader has no entry for `h=1` and asserts the build still succeeds — a black-box check that the reader's genesis synthesis path is wired correctly. -### `verify_nipopow_proof_bytes(bytes: &[u8]) -> Result` +### `verify_nipopow_proof_bytes(bytes, context) -> Result` + - **Precondition**: `bytes` is the inner NiPoPoW proof payload (the main - crate has already stripped the message envelope). -- **Postcondition**: Returns `NipopowVerificationResult` if the proof is - structurally valid AND `is_valid` returns true (heights consistent, - connections valid, PoW valid for each header, difficulty headers present - in continuous mode). The result includes the full extracted header chain - (`prefix` + `suffix_head.header` + `suffix_tail`, in height order) so the - caller can install it via [`HeaderChain::install_from_nipopow_proof`] - without re-parsing the bytes. -- **Validation checks** (mirrors `NipopowProof.isValid`): - 1. Headers parse cleanly via `ergo_nipopow::NipopowProofSerializer`. - 2. Heights are strictly increasing across `headersChain`. - 3. Each header's PoW passes `verify_pow`. - 4. Parent connections in the chain are consistent - (`NipopowProof::has_valid_connections`). - 5. (Continuous mode only) Difficulty-recalculation headers are present. -- **Does NOT** apply the proof to local chain state. Returning the headers - inline is a convenience to avoid double parsing — the chain is mutated - only via the explicit `install_from_nipopow_proof` call. + crate has already stripped the message envelope). `context` carries the + exact requested `m` and `k` plus the deployment's configured genesis ID. +- **Postcondition**: the proof has passed canonical sigma-rust validation, + matches the requested security parameters, starts at the configured + genesis, and every extracted header passes `verify_pow`. The returned + value preserves the parser's exact `prefix` / `suffix_head` / + `suffix_tail` boundary so the installer never reconstructs it from a + flattened vector. +- **Validation checks**: + 1. The Scorex core parses from an explicitly tracked cursor. No remainder + is accepted except one JVM terminal mode byte (`0` or `1`). + 2. `NipopowProof::validate()` enforces the proof's canonical structural, + connection, interlinks-proof, height, and suffix-cardinality invariants. + 3. Proof `m` and `k` equal the values in `context`. + 4. The first proof-chain header equals `context.expected_genesis_id`. + 5. Every prefix, suffix-head, and suffix-tail header passes `verify_pow`. +- **Does NOT** apply the proof to local chain state. Chain mutation remains + an explicit consumer operation. ### `NipopowVerificationResult` ```rust pub struct NipopowVerificationResult { - /// Height of the suffix tip (the highest header in the proof). - pub suffix_tip_height: u32, - /// Total number of headers in the proof (prefix + suffix). - pub total_headers: usize, - /// Whether the proof is in continuous mode (carries difficulty headers). - pub continuous: bool, - /// Headers extracted from the verified proof, in strictly-increasing - /// height order: `prefix.iter().map(|p| p.header).chain(once(suffix_head.header)).chain(suffix_tail)`. - /// The light-client install path passes `headers.last()` as `suffix_head` - /// and the `k - 1` headers preceding it as `suffix_tail`. Callers that - /// only want metadata (the existing serve-side log path) can ignore the - /// field at zero parsing cost — it's already materialized. - pub headers: Vec
, + pub m: u32, + pub k: u32, + /// JVM terminal mode, or `None` for a Rust-core-only payload. + pub continuous: Option, + pub prefix: Vec
, + pub suffix_head: Header, + pub suffix_tail: Vec
, } ``` -Renamed from `NipopowProofMeta` (which only carried metadata) to reflect the -new return shape. The serve-side log path in the main crate is the only -existing call site and just gets the field rename plus an unused-headers -field; no semantic change for that consumer. +`total_headers()` and `suffix_tip_height()` are derived from these exact +segments rather than stored as duplicable metadata. The separate +`NipopowInspection` type contains diagnostic metadata but no headers; the +unsolicited code-91 log path may use it, but bootstrap selection and +installation must not. ### `install_from_nipopow_proof(suffix_head: Header, suffix_tail: Vec
) -> Result>` diff --git a/src/bridge.rs b/src/bridge.rs index 5827d30..bac7b7d 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -154,8 +154,16 @@ impl SyncChain for SharedChain { // sync stays codec-free. let inner = crate::nipopow_serve::parse_nipopow_proof(envelope_body) .map_err(|e| ChainError::Nipopow(format!("envelope parse: {e}")))?; - let result = enr_chain::verify_nipopow_proof_bytes(&inner)?; - Ok(result.headers) + let context = { + let chain = self.chain.lock().await; + enr_chain::NipopowVerificationContext::from_chain(&chain, 6, 10)? + }; + let result = enr_chain::verify_nipopow_proof_bytes(&inner, &context)?; + let mut headers = Vec::with_capacity(result.total_headers()); + headers.extend(result.prefix); + headers.push(result.suffix_head); + headers.extend(result.suffix_tail); + Ok(headers) } async fn is_better_nipopow( diff --git a/src/main.rs b/src/main.rs index abed0ca..1f4869c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -526,15 +526,16 @@ async fn handle_nipopow_event( } }; - // Verify is a pure function over the proof bytes — no chain access needed. - match enr_chain::verify_nipopow_proof_bytes(&proof_bytes) { + // Diagnostic-only inspection: this unsolicited/log-only path has + // no request context and never authorizes bootstrap installation. + match enr_chain::inspect_nipopow_proof_bytes(&proof_bytes) { Ok(meta) => { tracing::info!( peer = %peer_id, suffix_tip_height = meta.suffix_tip_height, total_headers = meta.total_headers, - continuous = meta.continuous, - "received and verified NiPoPoW proof (logged only — light-client mode pending)" + continuous = ?meta.continuous, + "received and inspected NiPoPoW proof (diagnostic only)" ); } Err(e) => { diff --git a/src/nipopow_serve.rs b/src/nipopow_serve.rs index eae3457..457b60e 100644 --- a/src/nipopow_serve.rs +++ b/src/nipopow_serve.rs @@ -6,9 +6,10 @@ //! We build it from the local chain via `enr_chain::build_nipopow_proof` //! and respond with a code 91 message. //! -//! - **91 (`NipopowProof`)**: peer sends us a proof. We verify it via -//! `enr_chain::verify_nipopow_proof_bytes` and log the result. The proof -//! is NOT applied to chain state — light-client mode is a separate session. +//! - **91 (`NipopowProof`)**: peer sends us an unsolicited proof. We inspect it +//! via `enr_chain::inspect_nipopow_proof_bytes` and log only diagnostic +//! metadata. This path has no request context and cannot authorize bootstrap +//! selection or installation. //! //! Both codes use VLQ-encoded integer fields with a `putUShort(0)` pad-length //! footer for forward compatibility (the JVM convention for new message @@ -382,13 +383,13 @@ mod tests { let inner = parse_nipopow_proof(&envelope).unwrap(); assert_eq!(inner, garbage_inner); // Now verify — scorex_parse_bytes should fail, not panic. - let result = enr_chain::verify_nipopow_proof_bytes(&inner); + let result = enr_chain::inspect_nipopow_proof_bytes(&inner); assert!(result.is_err()); } #[test] fn verify_nipopow_empty_inner_bytes_returns_error() { - let result = enr_chain::verify_nipopow_proof_bytes(&[]); + let result = enr_chain::inspect_nipopow_proof_bytes(&[]); assert!(result.is_err()); } @@ -402,7 +403,7 @@ mod tests { inner.put_u32(10).unwrap(); // k inner.put_u32(0x7FFF_FFFF).unwrap(); // num_prefixes = 2 billion - let result = enr_chain::verify_nipopow_proof_bytes(&inner); + let result = enr_chain::inspect_nipopow_proof_bytes(&inner); assert!(result.is_err()); } @@ -419,7 +420,7 @@ mod tests { inner.put_u32(1).unwrap(); // suffix_head_size inner.push(0x00); // bogus header byte (will fail parse) - let result = enr_chain::verify_nipopow_proof_bytes(&inner); + let result = enr_chain::inspect_nipopow_proof_bytes(&inner); assert!(result.is_err()); } diff --git a/tests/nipopow_serve_integration.rs b/tests/nipopow_serve_integration.rs index 6e036e2..02811e6 100644 --- a/tests/nipopow_serve_integration.rs +++ b/tests/nipopow_serve_integration.rs @@ -57,6 +57,18 @@ const FULL_CHAIN_BUILD_TIMEOUT_SECS: u64 = 10; // `chain too short` (must be >= m + k). const ANCHOR_HEIGHT: u32 = 200; const DEFAULT_TARGET: &str = "127.0.0.1:9030"; +/// Canonical testnet height-1 header ID from the reference node config. +const TESTNET_GENESIS_ID: &str = "ab19bb59871e86507defb9a7769841b1130aad4d8c1ea8b0e01e0dee9e97a27e"; + +fn testnet_verification_context(m: u32, k: u32) -> enr_chain::NipopowVerificationContext { + enr_chain::NipopowVerificationContext { + expected_m: m, + expected_k: k, + expected_genesis_id: TESTNET_GENESIS_ID + .parse() + .expect("canonical testnet genesis ID must parse"), + } +} #[tokio::test] #[ignore = "requires a running ergo-node-rust on NIPOPOW_TARGET (default 127.0.0.1:9030)"] @@ -140,23 +152,26 @@ async fn nipopow_serve_round_trip_against_running_node() { eprintln!("got NipopowProof inner bytes: {} bytes", proof_bytes.len()); - let meta = enr_chain::verify_nipopow_proof_bytes(&proof_bytes) + let context = testnet_verification_context(6, 6); + let result = enr_chain::verify_nipopow_proof_bytes(&proof_bytes, &context) .expect("verify_nipopow_proof_bytes failed"); eprintln!( - "verified: suffix_tip_height={} total_headers={} continuous={}", - meta.suffix_tip_height, meta.total_headers, meta.continuous + "verified: suffix_tip_height={} total_headers={} continuous={:?}", + result.suffix_tip_height(), + result.total_headers(), + result.continuous ); // m=6 k=6 means at minimum the proof carries the k-suffix (6 headers). // The prefix can be empty on a short chain, so 6 is the floor we can rely on. assert!( - meta.total_headers >= 6, + result.total_headers() >= 6, "expected at least k=6 headers in the proof, got {}", - meta.total_headers + result.total_headers() ); assert!( - meta.suffix_tip_height > 0, + result.suffix_tip_height() > 0, "suffix tip height should be > 0" ); } @@ -272,23 +287,27 @@ async fn nipopow_serve_full_chain_round_trip() { elapsed.as_secs_f64() ); - let meta = enr_chain::verify_nipopow_proof_bytes(&proof_bytes) + let context = testnet_verification_context(6, 6); + let result = enr_chain::verify_nipopow_proof_bytes(&proof_bytes, &context) .expect("verify_nipopow_proof_bytes failed"); eprintln!( - "[full-chain] verified: suffix_tip_height={} total_headers={} continuous={} wall_time={:.3}s", - meta.suffix_tip_height, meta.total_headers, meta.continuous, elapsed.as_secs_f64() + "[full-chain] verified: suffix_tip_height={} total_headers={} continuous={:?} wall_time={:.3}s", + result.suffix_tip_height(), + result.total_headers(), + result.continuous, + elapsed.as_secs_f64() ); assert!( - meta.total_headers >= 6, + result.total_headers() >= 6, "expected at least k=6 headers in the proof, got {}", - meta.total_headers + result.total_headers() ); assert!( - meta.suffix_tip_height > 200, + result.suffix_tip_height() > 200, "full-chain suffix tip height should be much larger than the 200-anchor test, got {}", - meta.suffix_tip_height + result.suffix_tip_height() ); } @@ -362,24 +381,25 @@ async fn nipopow_serve_no_anchor_repro() { .await .expect("timed out waiting for NipopowProof response"); - let meta = enr_chain::verify_nipopow_proof_bytes(&proof_bytes) + let context = testnet_verification_context(6, 10); + let result = enr_chain::verify_nipopow_proof_bytes(&proof_bytes, &context) .expect("verify_nipopow_proof_bytes failed (regression: tolerant lookback?)"); eprintln!( "[no-anchor] verified OK: proof_bytes={} suffix_tip_height={} total_headers={}", proof_bytes.len(), - meta.suffix_tip_height, - meta.total_headers, + result.suffix_tip_height(), + result.total_headers(), ); assert!( - meta.total_headers >= 10, + result.total_headers() >= 10, "expected at least k=10 headers in the proof, got {}", - meta.total_headers + result.total_headers() ); assert!( - meta.suffix_tip_height > 200, + result.suffix_tip_height() > 200, "no-anchor request should resolve to a deep tip, got {}", - meta.suffix_tip_height + result.suffix_tip_height() ); } From 8dac245f3566f92a2885938200b4aa809998984e Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:35:10 +0200 Subject: [PATCH 06/13] fix(sync): install the verified NiPoPoW suffix --- facts/nipopow.md | 35 +++--- facts/sync.md | 96 +++++++-------- src/bridge.rs | 9 +- sync/src/light_bootstrap.rs | 239 ++++++++++++++++++++++++++++-------- sync/src/state.rs | 8 +- sync/src/traits.rs | 13 +- 6 files changed, 264 insertions(+), 136 deletions(-) diff --git a/facts/nipopow.md b/facts/nipopow.md index fa78f37..bba77f5 100644 --- a/facts/nipopow.md +++ b/facts/nipopow.md @@ -95,8 +95,9 @@ future_pad_length: u16 (VLQ — JVM putUShort) **Validation**: - Total body size MUST be ≤ 2,000,000 bytes (reject before allocating). - `proof_length` MUST be > 0 AND < 2,000,000. -- `proof_bytes` is the inner NiPoPoW proof — passed verbatim to - `chain.verify_nipopow_proof_bytes(proof_bytes)`. +- `proof_bytes` is the inner NiPoPoW proof. The bootstrap path passes it to + context-bound verification with the exact requested `m/k` and configured + genesis; the unsolicited log path uses diagnostic-only inspection. ## Module: `src/nipopow_handler` (or inline in main.rs) @@ -113,17 +114,16 @@ messages. Mirrors the snapshot sync handler structure. calls `p2p.send_to(from, ProtocolMessage::Custom(91, body))`. - On error, log + drop. (Do NOT send an error message — the JVM doesn't expect one and will time out instead.) - 3. On every `ProtocolEvent::Message { from, code, body }` with `code == 91`: + 3. On an unsolicited `ProtocolEvent::Message` with `code == 91`: - Parses `body` to extract the inner proof bytes. - - Acquires the chain lock, calls `chain.verify_nipopow_proof_bytes(...)`. - - In `StateType::Utxo`/`Digest` modes: logs the result and drops it - (the existing serve-side observation path — verifies for visibility, - does not mutate state). - - In `StateType::Light` mode: routes the verified result to the - light-client bootstrap state machine via a one-shot channel - established at bootstrap start. The bootstrap waits for exactly - one such verified result from its requested peer; subsequent code - 91 messages from other peers (if any) are logged and dropped. + - Calls `inspect_nipopow_proof_bytes` and logs header-free metadata. + This diagnostic path has no request context and never authorizes + bootstrap selection or installation. + 4. The light-bootstrap session processes responses to its own code-90 + broadcast separately. It accepts code-91 candidates only from peers that + received that request, calls `SyncChain::verify_nipopow_envelope`, retains + each lossless verification result for comparison, and installs only the + selected result's exact parsed suffix. - **Invariant**: The handler never blocks the event loop — long-running chain operations happen on a `tokio::task::block_in_place` boundary or in a spawned task, mirroring the existing pattern from snapshot sync. @@ -168,13 +168,14 @@ adds no value. ### Sigma-rust integration -The chain submodule's `build_nipopow_proof`, `verify_nipopow_proof_bytes`, -and `install_from_nipopow_proof` wrap `ergo_nipopow::NipopowAlgos` and +The chain submodule's `build_nipopow_proof`, context-bound +`verify_nipopow_proof_bytes`, and `install_from_nipopow_proof` wrap +`ergo_nipopow::NipopowAlgos` and `ergo_nipopow::NipopowProofSerializer`. The main crate does NOT import `ergo-nipopow` directly — that's the chain's job. The main crate only -sees `Vec` (proof bytes), the `NipopowVerificationResult` struct -returned from `verify_nipopow_proof_bytes`, and `Header` (for installing -the suffix into the chain via `install_from_nipopow_proof`). +sees `Vec` proof bytes plus the lossless `NipopowVerificationResult` +returned from verification. The bootstrap carries that typed result through +selection and installs its `suffix_head` / `suffix_tail` fields directly. ## Routing behavior (current limitation) diff --git a/facts/sync.md b/facts/sync.md index 72f6bc6..3bcfa38 100644 --- a/facts/sync.md +++ b/facts/sync.md @@ -678,44 +678,36 @@ returns immediately. State machine: -1. **Wait for at least one outbound peer** with a delivery-eligible status - (handshake complete, not banned). Poll `transport.outbound_peers()` every - 1s up to a 60s deadline. No peers → `LightBootstrapError::NoPeers`. - -2. **Send `GetNipopowProof`** to the first eligible peer with `m=6`, `k=10`, - `header_id = None` (no anchor — request a proof at the peer's current tip). - The wire envelope is built via `src/nipopow_serve::serialize_get_nipopow_proof` - (a new function — currently only the response serializer exists). - -3. **Wait for `NipopowProof` response** (P2P code 91) from that peer with a - 30-second timeout. Other messages from other peers during this window are - processed normally by the rest of the sync machine; only `code == 91` - from the requested peer counts as the response. Timeout or wrong-peer - response → mark peer stalled, rotate to next eligible peer, retry up to - 3 peers total. All 3 stalled → `LightBootstrapError::AllPeersStalled`. - -4. **Verify** the inner proof bytes via - `enr_chain::verify_nipopow_proof_bytes`. Verification failure → mark - peer hostile (NOT just stalled — sending an invalid proof is a protocol - violation), rotate, retry. Three hostile peers in a row → - `LightBootstrapError::AllPeersHostile`. - -5. **Install** the verified suffix into the local `HeaderChain`: - - The result's `headers: Vec
` slice contains, in order: - `prefix`, then `suffix_head.header`, then `suffix_tail`. The light - client only installs the suffix portion (`suffix_head` + `suffix_tail`), - NOT the prefix headers — the prefix exists to prove cumulative work - and is discarded after verification. - - The split point inside `headers` is `headers.len() - k` (the last `k` - entries are the suffix; the rest is the prefix). With `k=10`, the - install passes `headers[headers.len()-10]` as `suffix_head` and the - remaining 9 as `suffix_tail`. - - On success, `chain.height()` returns the suffix tip's height. Set - the `validated_height` watermark to the same value (light mode treats - all installed headers as "validated" — the proof's PoW checks are - the validation). - -6. **Transition to normal tip-following sync** via the existing +1. **Wait for outbound peers** with completed handshakes. Poll + `transport.outbound_peers()` up to the peer-wait deadline. No peers yields + `LightBootstrapError::NoPeers`. + +2. **Broadcast `GetNipopowProof`** with `m=6`, `k=10`, and + `header_id = None` to every currently eligible outbound peer. If every + send fails, return `LightBootstrapError::AllPeersStalled`. + +3. **Collect code-91 responses** from only the peers that received the + request, until they have all responded or the collection window expires. + Unsolicited peers and non-proof messages do not enter the candidate set. + +4. **Verify each response** through `SyncChain::verify_nipopow_envelope`. + The bridge strips the envelope and calls context-bound + `enr_chain::verify_nipopow_proof_bytes` with the exact requested `m/k` and + configured genesis. Failed verification marks that response hostile and + cannot add a candidate or mutate the chain. + +5. **Select among verified candidates only.** Multiple candidates are + compared pairwise through `NipopowProof::is_better_than`; all have already + passed the same request/genesis context. A comparison error skips that + challenger and retains the incumbent. + +6. **Install the selected result's exact parsed suffix** into the local + `HeaderChain`. `NipopowVerificationResult` carries `prefix`, `suffix_head`, + and `suffix_tail` separately. The bootstrap discards the prefix and passes + the two suffix fields directly to `install_from_nipopow_proof`; it never + reconstructs the boundary from `headers.len() - k`. + +7. **Transition to normal tip-following sync** via the existing `sync_from_peer` loop. From here on out, light mode behaves like full mode minus block bodies: the sync machine sends SyncInfo, receives header Inv, requests headers, validates them via `try_append`, and @@ -739,29 +731,27 @@ for first release, terminate. ### Bootstrap invariants -- **Single peer per attempt**: bootstrap requests from ONE peer at a time. - Multi-peer best-arg comparison (KMZ17 §4.3, where the client compares - proofs from multiple peers and picks the one with highest cumulative work - via `bestArg`) is **out of scope for first release** and tracked as a - hardening follow-up. The first-release trust model is "trust the first - peer that returns a verifiable proof." This is documented as a known - limitation in the user-facing release notes. +- **Shared verification context**: every candidate is verified against the + same requested `m=6`, `k=10`, and configured genesis before comparison. + A failed candidate never enters pairwise selection. +- **Lossless install boundary**: selection retains the typed verification + result, and installation consumes its exact `suffix_head` and + `suffix_tail`. No consumer re-parses or re-splits a flattened header list. - **No restart-resume state**: bootstrap is one-shot and re-runs from scratch on every restart where `chain.is_empty()`. Once the chain is installed, subsequent restarts skip bootstrap entirely (chain is loaded from store and is non-empty). There is no partial-bootstrap state that needs persistence — the operation is atomic. -- **Bootstrap NEVER mutates `store/`** beyond what `HeaderChain` itself - writes via its existing persistence path. The proof bytes are not - archived after install. If we want to re-verify the proof after a - reboot, we'd need to re-fetch it; this is not a first-release concern. +- **Proof bytes are not archived** after install. `SharedChain` persists the + installed suffix headers through the normal header-store path. ### Trust model -Standard SPV: single-peer bootstrap trusts that peer's view of the -chain. Failure mode is liveness, not safety — a hostile peer causes a -recoverable DoS, not loss of funds. Multi-peer best-arg comparison -(KMZ17 §4.3) is the standard hardening, tracked as a follow-up. +Bootstrap accepts only proofs that match the configured chain identity and +requested security parameters, then selects the best verified proof among the +responses it observed. Security still depends on the NiPoPoW assumptions and +the available peer view; verification and multi-peer comparison do not by +themselves guarantee peer diversity or network availability. ## Block Section Download diff --git a/src/bridge.rs b/src/bridge.rs index bac7b7d..2335765 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -148,7 +148,7 @@ impl SyncChain for SharedChain { async fn verify_nipopow_envelope( &self, envelope_body: &[u8], - ) -> Result, ChainError> { + ) -> Result { // Strip the P2P code-91 envelope (length-prefixed inner bytes + future // pad). The wire codec lives in the main crate's `nipopow_serve` so // sync stays codec-free. @@ -158,12 +158,7 @@ impl SyncChain for SharedChain { let chain = self.chain.lock().await; enr_chain::NipopowVerificationContext::from_chain(&chain, 6, 10)? }; - let result = enr_chain::verify_nipopow_proof_bytes(&inner, &context)?; - let mut headers = Vec::with_capacity(result.total_headers()); - headers.extend(result.prefix); - headers.push(result.suffix_head); - headers.extend(result.suffix_tail); - Ok(headers) + enr_chain::verify_nipopow_proof_bytes(&inner, &context) } async fn is_better_nipopow( diff --git a/sync/src/light_bootstrap.rs b/sync/src/light_bootstrap.rs index b58fcff..2843839 100644 --- a/sync/src/light_bootstrap.rs +++ b/sync/src/light_bootstrap.rs @@ -9,7 +9,7 @@ //! JVM reference: `ErgoNodeViewSynchronizer.scala:1032` (outbound request), //! `PopowProcessor.applyPopowProof` (install side). -use enr_chain::{BlockId, Header}; +use enr_chain::{BlockId, NipopowVerificationResult}; use enr_p2p::protocol::messages::ProtocolMessage; use enr_p2p::protocol::peer::ProtocolEvent; use enr_p2p::types::PeerId; @@ -91,8 +91,7 @@ fn build_get_nipopow_proof_body(m: i32, k: i32, header_id: Option<&BlockId>) -> /// 3. Collect valid proofs within a 30s window. Verify each on arrival. /// 4. If multiple valid proofs, compare pairwise via `is_better_than` /// and pick the best. If one valid proof, use it. -/// 5. Split the best proof's headers into (suffix_head, suffix_tail) -/// and install. +/// 5. Install the exact parsed suffix carried by the best verified result. /// 6. No valid proofs → return error. pub async fn run_light_bootstrap( transport: &mut T, @@ -184,15 +183,15 @@ pub async fn run_light_bootstrap( responded += 1; match chain.verify_nipopow_envelope(&body).await { - Ok(headers) => { + Ok(verification) => { tracing::info!( peer = ?peer_id, - header_count = headers.len(), + header_count = verification.total_headers(), "light bootstrap: valid proof received" ); valid_proofs.push(ValidProof { peer: peer_id, - headers, + verification, envelope: body, }); } @@ -222,19 +221,17 @@ pub async fn run_light_bootstrap( tracing::info!( peer = ?best.peer, - header_count = best.headers.len(), + header_count = best.verification.total_headers(), "light bootstrap: selected best proof" ); - // Step 5: split into (suffix_head, suffix_tail) and install. - let k = P2P_NIPOPOW_K as usize; - if best.headers.len() < k { - return Err(LightBootstrapError::AllPeersHostile(1)); - } - let split_idx = best.headers.len() - k; - let mut suffix: Vec
= best.headers.into_iter().skip(split_idx).collect(); - let suffix_head = suffix.remove(0); - let suffix_tail = suffix; + // Step 5: install the verifier's exact parsed suffix. Do not reconstruct + // this boundary from a flattened header vector or a local k constant. + let NipopowVerificationResult { + suffix_head, + suffix_tail, + .. + } = best.verification; tracing::info!( suffix_head_height = suffix_head.height, @@ -258,7 +255,7 @@ pub async fn run_light_bootstrap( /// A verified proof from a peer, kept alive for comparison. struct ValidProof { peer: PeerId, - headers: Vec
, + verification: NipopowVerificationResult, /// Raw P2P code-91 envelope body — retained for KMZ17 comparison. envelope: Vec, } @@ -305,6 +302,7 @@ async fn pick_best_proof( mod tests { use super::*; use enr_chain::ChainError; + use enr_chain::Header; use std::collections::VecDeque; use std::sync::Mutex; @@ -344,6 +342,28 @@ mod tests { (1..=count as u32).map(fake_header).collect() } + fn verification_result( + prefix: Vec
, + suffix_head: Header, + suffix_tail: Vec
, + ) -> NipopowVerificationResult { + NipopowVerificationResult { + m: P2P_NIPOPOW_M as u32, + k: P2P_NIPOPOW_K as u32, + continuous: None, + prefix, + suffix_head, + suffix_tail, + } + } + + fn standard_verification_result() -> NipopowVerificationResult { + let mut headers = fake_headers(15); + let suffix_tail = headers.split_off(6); + let suffix_head = headers.pop().expect("fixture has suffix head"); + verification_result(headers, suffix_head, suffix_tail) + } + /// Mock transport that delivers scripted events. struct MockTransport { outbound: Vec, @@ -380,13 +400,19 @@ mod tests { } } - /// Verify outcome: Ok(headers) or Err(reason). + /// Verify outcome: Ok(context-bound result) or Err(reason). #[derive(Clone)] enum VerifyResult { - Ok(Vec
), + Ok(Box), Err(String), } + impl VerifyResult { + fn ok(result: NipopowVerificationResult) -> Self { + Self::Ok(Box::new(result)) + } + } + /// Compare outcome for is_better_nipopow. `Worse` is the matching /// counterpart to `Better` — the mock can script a "this is worse /// than that" comparison even if no test currently does. @@ -407,6 +433,7 @@ mod tests { verify_results: Mutex, VerifyResult)>>, /// Pairwise comparison results: (this, that) → result. compare_results: Mutex>, + comparison_calls: Mutex, Vec)>>, installed: Mutex)>>, } @@ -415,6 +442,7 @@ mod tests { Self { verify_results: Mutex::new(Vec::new()), compare_results: Mutex::new(Vec::new()), + comparison_calls: Mutex::new(Vec::new()), installed: Mutex::new(None), } } @@ -430,6 +458,10 @@ mod tests { fn installed(&self) -> Option<(Header, Vec
)> { self.installed.lock().unwrap().clone() } + + fn comparison_calls(&self) -> Vec<(Vec, Vec)> { + self.comparison_calls.lock().unwrap().clone() + } } impl crate::traits::SyncChain for MockChain { @@ -465,12 +497,12 @@ mod tests { async fn verify_nipopow_envelope( &self, envelope_body: &[u8], - ) -> Result, ChainError> { + ) -> Result { let results = self.verify_results.lock().unwrap(); for (body, result) in results.iter() { if body == envelope_body { return match result { - VerifyResult::Ok(h) => Ok(h.clone()), + VerifyResult::Ok(result) => Ok(result.as_ref().clone()), VerifyResult::Err(e) => Err(ChainError::Nipopow(e.clone())), }; } @@ -483,6 +515,10 @@ mod tests { this_envelope: &[u8], than_envelope: &[u8], ) -> Result { + self.comparison_calls + .lock() + .unwrap() + .push((this_envelope.to_vec(), than_envelope.to_vec())); let results = self.compare_results.lock().unwrap(); for (this, than, result) in results.iter() { if this == this_envelope && than == than_envelope { @@ -554,13 +590,110 @@ mod tests { assert!(chain.installed().is_none()); } + #[tokio::test] + async fn bootstrap_unrequested_k_failure_cannot_select_or_install() { + let peer = PeerId(1); + let body = vec![0xa1]; + let chain = MockChain::new(); + chain.add_verify( + body.clone(), + VerifyResult::Err("expected k 10, got 9".into()), + ); + let mut transport = MockTransport::new(vec![peer], vec![proof_event(peer, body)]); + + let result = run_light_bootstrap(&mut transport, &chain).await; + + assert!(matches!( + result, + Err(LightBootstrapError::AllPeersHostile(1)) + )); + assert!(chain.comparison_calls().is_empty()); + assert!(chain.installed().is_none()); + } + + #[tokio::test] + async fn bootstrap_wrong_genesis_never_enters_selection() { + let hostile_peer = PeerId(1); + let valid_peer = PeerId(2); + let hostile_body = vec![0xa1]; + let valid_body = vec![0xb2]; + let expected = standard_verification_result(); + let expected_head = expected.suffix_head.clone(); + let chain = MockChain::new(); + chain.add_verify( + hostile_body.clone(), + VerifyResult::Err("proof genesis does not match configured genesis".into()), + ); + chain.add_verify(valid_body.clone(), VerifyResult::ok(expected)); + let mut transport = MockTransport::new( + vec![hostile_peer, valid_peer], + vec![ + proof_event(hostile_peer, hostile_body), + proof_event(valid_peer, valid_body), + ], + ); + + let result = run_light_bootstrap(&mut transport, &chain).await; + + assert!(result.is_ok()); + assert!(chain.comparison_calls().is_empty()); + assert_eq!( + chain.installed().expect("valid proof installed").0, + expected_head + ); + } + + #[tokio::test] + async fn bootstrap_compares_only_results_from_the_shared_context() { + let peer_a = PeerId(1); + let hostile_peer = PeerId(2); + let peer_c = PeerId(3); + let body_a = vec![0xa1]; + let hostile_body = vec![0xb2]; + let body_c = vec![0xc3]; + let proof_a = standard_verification_result(); + let proof_c = verification_result( + (101..=105).map(fake_header).collect(), + fake_header(106), + (107..=115).map(fake_header).collect(), + ); + let expected_head = proof_c.suffix_head.clone(); + let chain = MockChain::new(); + chain.add_verify(body_a.clone(), VerifyResult::ok(proof_a)); + chain.add_verify( + hostile_body.clone(), + VerifyResult::Err("verification context mismatch".into()), + ); + chain.add_verify(body_c.clone(), VerifyResult::ok(proof_c)); + chain.add_compare(body_c.clone(), body_a.clone(), CompareResult::Better); + let mut transport = MockTransport::new( + vec![peer_a, hostile_peer, peer_c], + vec![ + proof_event(peer_a, body_a.clone()), + proof_event(hostile_peer, hostile_body), + proof_event(peer_c, body_c.clone()), + ], + ); + + let result = run_light_bootstrap(&mut transport, &chain).await; + + assert!(result.is_ok()); + assert_eq!(chain.comparison_calls(), vec![(body_c, body_a)]); + assert_eq!( + chain.installed().expect("best proof installed").0, + expected_head + ); + } + #[tokio::test] async fn bootstrap_single_valid_proof_installs() { let peer_a = PeerId(1); let body_a = vec![0xaa]; - let headers = fake_headers(15); // 15 > k=10 + let proof = standard_verification_result(); + let expected_head = proof.suffix_head.clone(); + let expected_tail = proof.suffix_tail.clone(); let chain = MockChain::new(); - chain.add_verify(body_a.clone(), VerifyResult::Ok(headers.clone())); + chain.add_verify(body_a.clone(), VerifyResult::ok(proof)); let mut transport = MockTransport::new( vec![peer_a], @@ -570,9 +703,8 @@ mod tests { let result = run_light_bootstrap(&mut transport, &chain).await; assert!(result.is_ok()); let (head, tail) = chain.installed().expect("should have installed"); - // suffix_head is the (len-k)th header, tail is the remaining k-1 - assert_eq!(head.height, 6); // headers[5] (0-indexed), height 6 - assert_eq!(tail.len(), P2P_NIPOPOW_K as usize - 1); + assert_eq!(head, expected_head); + assert_eq!(tail, expected_tail); } #[tokio::test] @@ -581,10 +713,10 @@ mod tests { let peer_b = PeerId(2); let body_a = vec![0xaa]; let body_b = vec![0xbb]; - let headers = fake_headers(15); + let proof = standard_verification_result(); let chain = MockChain::new(); chain.add_verify(body_a.clone(), VerifyResult::Err("invalid".into())); - chain.add_verify(body_b.clone(), VerifyResult::Ok(headers)); + chain.add_verify(body_b.clone(), VerifyResult::ok(proof)); let mut transport = MockTransport::new( vec![peer_a, peer_b], @@ -605,11 +737,11 @@ mod tests { let peer_b = PeerId(2); let body_a = vec![0xaa]; let body_b = vec![0xbb]; - let headers_a = fake_headers(15); - let headers_b = fake_headers(15); + let proof_a = standard_verification_result(); + let proof_b = standard_verification_result(); let chain = MockChain::new(); - chain.add_verify(body_a.clone(), VerifyResult::Ok(headers_a)); - chain.add_verify(body_b.clone(), VerifyResult::Ok(headers_b)); + chain.add_verify(body_a.clone(), VerifyResult::ok(proof_a)); + chain.add_verify(body_b.clone(), VerifyResult::ok(proof_b)); // Peer B's proof is better than A's. chain.add_compare(body_b.clone(), body_a.clone(), CompareResult::Better); @@ -633,11 +765,11 @@ mod tests { let peer_b = PeerId(2); let body_a = vec![0xaa]; let body_b = vec![0xbb]; - let headers_a = fake_headers(15); - let headers_b = fake_headers(15); + let proof_a = standard_verification_result(); + let proof_b = standard_verification_result(); let chain = MockChain::new(); - chain.add_verify(body_a.clone(), VerifyResult::Ok(headers_a)); - chain.add_verify(body_b.clone(), VerifyResult::Ok(headers_b)); + chain.add_verify(body_a.clone(), VerifyResult::ok(proof_a)); + chain.add_verify(body_b.clone(), VerifyResult::ok(proof_b)); // Comparison fails — should fall back to incumbent (peer A, first valid). chain.add_compare(body_b.clone(), body_a.clone(), CompareResult::Err("parse failed".into())); @@ -680,9 +812,9 @@ mod tests { let peer_rogue = PeerId(99); let body_a = vec![0xaa]; let body_rogue = vec![0xff]; - let headers = fake_headers(15); + let proof = standard_verification_result(); let chain = MockChain::new(); - chain.add_verify(body_a.clone(), VerifyResult::Ok(headers)); + chain.add_verify(body_a.clone(), VerifyResult::ok(proof)); // Don't add verify for rogue — it should never be called. let mut transport = MockTransport::new( @@ -704,9 +836,9 @@ mod tests { async fn bootstrap_ignores_non_proof_messages() { let peer_a = PeerId(1); let body_a = vec![0xaa]; - let headers = fake_headers(15); + let proof = standard_verification_result(); let chain = MockChain::new(); - chain.add_verify(body_a.clone(), VerifyResult::Ok(headers)); + chain.add_verify(body_a.clone(), VerifyResult::ok(proof)); let mut transport = MockTransport::new( vec![peer_a], @@ -734,13 +866,18 @@ mod tests { } #[tokio::test] - async fn bootstrap_proof_too_few_headers_for_k() { + async fn bootstrap_installs_verifier_exact_suffix_without_len_k_reconstruction() { let peer_a = PeerId(1); let body_a = vec![0xaa]; - // Only 5 headers — less than k=10. - let headers = fake_headers(5); + // Contract sentinel: a real verifier enforces suffix cardinality, but + // this test double deliberately makes the old `len-k` split choose a + // prefix header. The consumer must treat the typed result's parsed + // boundary as authoritative instead of reinterpreting it. + let exact_head = fake_header(200); + let exact_tail = vec![fake_header(201), fake_header(202)]; + let proof = verification_result(fake_headers(15), exact_head.clone(), exact_tail.clone()); let chain = MockChain::new(); - chain.add_verify(body_a.clone(), VerifyResult::Ok(headers)); + chain.add_verify(body_a.clone(), VerifyResult::ok(proof)); let mut transport = MockTransport::new( vec![peer_a], @@ -748,9 +885,8 @@ mod tests { ); let result = run_light_bootstrap(&mut transport, &chain).await; - // Proof with fewer headers than k is treated as hostile. - assert!(matches!(result, Err(LightBootstrapError::AllPeersHostile(_)))); - assert!(chain.installed().is_none()); + assert!(result.is_ok()); + assert_eq!(chain.installed(), Some((exact_head, exact_tail))); } #[tokio::test] @@ -783,7 +919,10 @@ mod tests { &self, _p: ergo_validation::Parameters, _pu: Vec, ) {} async fn active_proposed_update_bytes(&self) -> Vec { Vec::new() } - async fn verify_nipopow_envelope(&self, _b: &[u8]) -> Result, ChainError> { + async fn verify_nipopow_envelope( + &self, + _b: &[u8], + ) -> Result { unimplemented!() } async fn is_better_nipopow(&self, _a: &[u8], _b: &[u8]) -> Result { @@ -803,9 +942,9 @@ mod tests { async fn bootstrap_broadcasts_to_all_peers() { let peers = vec![PeerId(1), PeerId(2), PeerId(3)]; let body = vec![0xaa]; - let headers = fake_headers(15); + let proof = standard_verification_result(); let chain = MockChain::new(); - chain.add_verify(body.clone(), VerifyResult::Ok(headers)); + chain.add_verify(body.clone(), VerifyResult::ok(proof)); let mut transport = MockTransport::new( peers.clone(), diff --git a/sync/src/state.rs b/sync/src/state.rs index b38fb89..e14c8cb 100644 --- a/sync/src/state.rs +++ b/sync/src/state.rs @@ -2636,7 +2636,7 @@ mod shutdown_flush_tests { async fn verify_nipopow_envelope( &self, _envelope_body: &[u8], - ) -> Result, ChainError> { + ) -> Result { unreachable!("not called when state_type=Utxo") } @@ -3021,7 +3021,7 @@ mod blocks_to_keep_tests { async fn verify_nipopow_envelope( &self, _envelope_body: &[u8], - ) -> Result, ChainError> { + ) -> Result { unreachable!() } @@ -3610,7 +3610,7 @@ mod sweep_resume_tests { async fn verify_nipopow_envelope( &self, _envelope_body: &[u8], - ) -> Result, ChainError> { + ) -> Result { unreachable!() } async fn is_better_nipopow(&self, _this: &[u8], _than: &[u8]) -> Result { @@ -4121,7 +4121,7 @@ mod serve_continuation_tests { async fn verify_nipopow_envelope( &self, _envelope_body: &[u8], - ) -> Result, ChainError> { + ) -> Result { unreachable!("not called in serve tests") } diff --git a/sync/src/traits.rs b/sync/src/traits.rs index cdba2a3..5350a53 100644 --- a/sync/src/traits.rs +++ b/sync/src/traits.rs @@ -192,8 +192,8 @@ pub trait SyncChain { /// Strip a `NipopowProof` (P2P code 91) message envelope and verify the /// inner proof bytes via [`enr_chain::verify_nipopow_proof_bytes`]. - /// Returns the extracted header chain in strictly-increasing height order - /// on success. + /// Returns the context-bound proof with its parsed prefix/suffix boundary + /// preserved on success. /// /// Used by the light-client bootstrap state machine. The bridge wraps /// `nipopow_serve::parse_nipopow_proof` (envelope strip) + @@ -201,14 +201,17 @@ pub trait SyncChain { fn verify_nipopow_envelope( &self, envelope_body: &[u8], - ) -> impl std::future::Future, enr_chain::ChainError>> + Send; + ) -> impl std::future::Future< + Output = Result, + > + Send; /// Compare two NiPoPoW proofs (raw P2P code-91 envelope bodies) and /// return `true` if `this` is better than `that` per KMZ17 §4.3. /// /// Used by the light-client bootstrap to pick the best proof from - /// multiple peers. Both envelopes must be parseable (they should have - /// already passed [`Self::verify_nipopow_envelope`]). + /// multiple peers. Both envelopes must already have passed + /// [`Self::verify_nipopow_envelope`] in the same bootstrap session and + /// therefore against the same request/genesis context. fn is_better_nipopow( &self, this_envelope: &[u8], From f9366e527559eb4ee23218cad93d298cdf450ba5 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:59:04 +0200 Subject: [PATCH 07/13] build: pin NiPoPoW hardening backport --- Cargo.lock | 19 +++++++++---------- Cargo.toml | 8 ++++---- addons/fastsync/Cargo.lock | 19 +++++++++---------- addons/fastsync/Cargo.toml | 6 +++--- addons/indexer/Cargo.lock | 21 ++++++++++----------- addons/indexer/Cargo.toml | 4 ++-- api/Cargo.toml | 10 +++++----- chain/Cargo.toml | 10 +++++----- mempool/Cargo.toml | 4 ++-- mining/Cargo.toml | 10 +++++----- sync/Cargo.toml | 2 +- validation/Cargo.toml | 10 +++++----- 12 files changed, 60 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5d8972..73ed638 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,7 +729,7 @@ dependencies = [ [[package]] name = "ergo-chain-types" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "base64", @@ -752,7 +752,7 @@ dependencies = [ [[package]] name = "ergo-lib" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bounded-vec", @@ -795,7 +795,7 @@ dependencies = [ [[package]] name = "ergo-merkle-tree" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "blake2", @@ -833,11 +833,10 @@ dependencies = [ [[package]] name = "ergo-nipopow" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bounded-integer", - "derive_more", "ergo-chain-types", "ergo-merkle-tree", "ergotree-ir", @@ -948,7 +947,7 @@ dependencies = [ [[package]] name = "ergotree-interpreter" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "blake2", @@ -981,7 +980,7 @@ dependencies = [ [[package]] name = "ergotree-ir" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bnum", @@ -1204,7 +1203,7 @@ dependencies = [ [[package]] name = "gf2_192" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "derive_more", "thiserror", @@ -2313,7 +2312,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "sigma-ser" version = "0.19.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "bitvec", "bounded-vec", @@ -2324,7 +2323,7 @@ dependencies = [ [[package]] name = "sigma-util" version = "0.18.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "blake2", "sha2 0.10.9", diff --git a/Cargo.toml b/Cargo.toml index 7bbe8f2..a8e8c0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,8 @@ enr-store = { path = "store" } ergo-api = { path = "api" } ergo-sync = { path = "sync" } ergo_avltree_rust = "0.1.1" -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } ergo-mempool = { path = "mempool" } ergo-mining = { path = "mining" } ergo-validation = { path = "validation" } @@ -39,7 +39,7 @@ bytes = "1" clap = { version = "4", features = ["derive"] } hex = "0.4" serde = { version = "1", features = ["derive"] } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } toml = "0.8" tracing = "0.1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } @@ -51,7 +51,7 @@ redb = "4" thiserror = "2" [dev-dependencies] -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } serde_json = "1" tempfile = "3" k256 = { version = "0.13.1", features = ["arithmetic"] } diff --git a/addons/fastsync/Cargo.lock b/addons/fastsync/Cargo.lock index b068c9e..3044d34 100644 --- a/addons/fastsync/Cargo.lock +++ b/addons/fastsync/Cargo.lock @@ -535,7 +535,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "ergo-chain-types" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "base64", @@ -577,7 +577,7 @@ dependencies = [ [[package]] name = "ergo-lib" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bounded-vec", @@ -607,7 +607,7 @@ dependencies = [ [[package]] name = "ergo-merkle-tree" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "blake2", @@ -624,11 +624,10 @@ dependencies = [ [[package]] name = "ergo-nipopow" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bounded-integer", - "derive_more", "ergo-chain-types", "ergo-merkle-tree", "ergotree-ir", @@ -659,7 +658,7 @@ dependencies = [ [[package]] name = "ergotree-interpreter" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "blake2", @@ -692,7 +691,7 @@ dependencies = [ [[package]] name = "ergotree-ir" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bnum", @@ -844,7 +843,7 @@ dependencies = [ [[package]] name = "gf2_192" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "derive_more", "thiserror", @@ -1912,7 +1911,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "sigma-ser" version = "0.19.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "bitvec", "bounded-vec", @@ -1923,7 +1922,7 @@ dependencies = [ [[package]] name = "sigma-util" version = "0.18.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "blake2", "sha2 0.10.9", diff --git a/addons/fastsync/Cargo.toml b/addons/fastsync/Cargo.toml index dbd6e40..5762965 100644 --- a/addons/fastsync/Cargo.toml +++ b/addons/fastsync/Cargo.toml @@ -8,9 +8,9 @@ description = "Fast bootstrap for ergo-node-rust via peer REST API — based on [dependencies] # Ergo types — header PoW verification, transaction serialization -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } # HTTP client — rustls so we ship static, no openssl reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/addons/indexer/Cargo.lock b/addons/indexer/Cargo.lock index 3e5d31c..cfe72ab 100644 --- a/addons/indexer/Cargo.lock +++ b/addons/indexer/Cargo.lock @@ -716,7 +716,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "ergo-chain-types" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "base64", @@ -738,7 +738,7 @@ dependencies = [ [[package]] name = "ergo-indexer" -version = "0.2.7" +version = "0.2.8" dependencies = [ "anyhow", "assert_cmd", @@ -769,7 +769,7 @@ dependencies = [ [[package]] name = "ergo-lib" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bounded-vec", @@ -799,7 +799,7 @@ dependencies = [ [[package]] name = "ergo-merkle-tree" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "blake2", @@ -816,11 +816,10 @@ dependencies = [ [[package]] name = "ergo-nipopow" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bounded-integer", - "derive_more", "ergo-chain-types", "ergo-merkle-tree", "ergotree-ir", @@ -851,7 +850,7 @@ dependencies = [ [[package]] name = "ergotree-interpreter" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "blake2", @@ -884,7 +883,7 @@ dependencies = [ [[package]] name = "ergotree-ir" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "base16", "bnum", @@ -1119,7 +1118,7 @@ dependencies = [ [[package]] name = "gf2_192" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "derive_more", "thiserror", @@ -2484,7 +2483,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "sigma-ser" version = "0.19.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "bitvec", "bounded-vec", @@ -2495,7 +2494,7 @@ dependencies = [ [[package]] name = "sigma-util" version = "0.18.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=f76db922#f76db9221fe558320d8d0f509a5532b8917542c6" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" dependencies = [ "blake2", "sha2 0.10.9", diff --git a/addons/indexer/Cargo.toml b/addons/indexer/Cargo.toml index 6244495..fcdf6ad 100644 --- a/addons/indexer/Cargo.toml +++ b/addons/indexer/Cargo.toml @@ -14,8 +14,8 @@ jemalloc = ["dep:tikv-jemallocator", "dep:tikv-jemalloc-ctl"] [dependencies] # Ergo types — address derivation, register decoding -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } # HTTP client — rustls for static binary reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/api/Cargo.toml b/api/Cargo.toml index cb4efc1..99a6395 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -6,10 +6,10 @@ license = "MIT" description = "REST API for ergo-node-rust" [dependencies] -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } ergo-mempool = { path = "../mempool" } ergo-mining = { path = "../mining" } ergo-validation = { path = "../validation" } @@ -23,6 +23,6 @@ tracing = "0.1" blake2 = "0.10" [dev-dependencies] -ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/chain/Cargo.toml b/chain/Cargo.toml index 0ffc4b4..53e7985 100644 --- a/chain/Cargo.toml +++ b/chain/Cargo.toml @@ -8,15 +8,15 @@ description = "Header chain validation for the Ergo Rust node" # Using fork with gen_indexes zero-modulo fix (PR pending: ergoplatform/sigma-rust). # AutolykosPowScheme not exported in published 0.15.0 — need git version. # When upstream merges, switch back to ergoplatform remote + new rev. -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } hashbrown = "0.16" lru = "0.16" num-bigint = "0.4" thiserror = "2" [dev-dependencies] -ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } serde_json = "1" diff --git a/mempool/Cargo.toml b/mempool/Cargo.toml index 1401835..2148d37 100644 --- a/mempool/Cargo.toml +++ b/mempool/Cargo.toml @@ -7,11 +7,11 @@ description = "In-memory transaction pool for the Ergo Rust node" [dependencies] ergo-validation = { path = "../validation" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } serde = { version = "1", features = ["derive"] } tracing = "0.1" hex = "0.4" [dev-dependencies] -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } tempfile = "3" diff --git a/mining/Cargo.toml b/mining/Cargo.toml index 46d842d..642703c 100644 --- a/mining/Cargo.toml +++ b/mining/Cargo.toml @@ -6,12 +6,12 @@ license = "MIT" description = "Block candidate assembly and PoW solution validation for external miners" [dependencies] -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } ergo-validation = { path = "../validation" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } blake2 = "0.10" hex = "0.4" serde = { version = "1", features = ["derive"] } diff --git a/sync/Cargo.toml b/sync/Cargo.toml index 4e28346..14644e9 100644 --- a/sync/Cargo.toml +++ b/sync/Cargo.toml @@ -19,7 +19,7 @@ tracing = "0.1" [dev-dependencies] ergo_avltree_rust = "0.1.1" -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } bytes = "1" hex = "0.4" tempfile = "3" diff --git a/validation/Cargo.toml b/validation/Cargo.toml index 9ca9573..302f0db 100644 --- a/validation/Cargo.toml +++ b/validation/Cargo.toml @@ -14,9 +14,9 @@ description = "Block validation for the Ergo Rust node" json = ["ergo-lib/json", "ergo-chain-types/json"] [dependencies] -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } ergo_avltree_rust = "0.1.1" enr-state = { path = "../state" } rayon = "1" @@ -27,6 +27,6 @@ tracing = "0.1" thiserror = "2" [dev-dependencies] -ergotree-interpreter = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } -ergotree-ir = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +ergotree-interpreter = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergotree-ir = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } tempfile = "3" From d6d200ebcee5e5e04614ee94647a9d5ef69838e1 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:15:52 +0200 Subject: [PATCH 08/13] docs(p2p): satisfy strict Clippy --- p2p/src/transport/frame.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/p2p/src/transport/frame.rs b/p2p/src/transport/frame.rs index 53fd8ff..44e2465 100644 --- a/p2p/src/transport/frame.rs +++ b/p2p/src/transport/frame.rs @@ -26,6 +26,7 @@ const HEADER_SIZE: usize = 13; /// - `Modifiers` messages: up to `maxMsgSizeWithReserve` (~8.4 MB payload) /// - UTXO snapshot manifest / chunk messages: up to ~4 MB /// - Inv / Request / Sync / Peers: kilobytes +/// /// Also bounds per-peer buffering: 30 peers × 16 MB ≈ 480 MB worst-case. const MAX_BODY_SIZE: u32 = 16_388_608; From 0c21839b856a606259303720b248ea7b884fc772 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:48:13 +0200 Subject: [PATCH 09/13] fix(nipopow): enforce continuous difficulty context --- chain/src/chain.rs | 329 +++++++++++--- chain/src/difficulty.rs | 122 +++++- chain/src/lib.rs | 6 +- chain/src/nipopow_proof.rs | 665 ++++++++++++++++++++++++++++- chain/src/tests.rs | 364 +++++++++++++++- src/bridge.rs | 215 +++++++++- src/lib.rs | 4 +- src/main.rs | 7 +- sync/src/light_bootstrap.rs | 41 +- sync/src/state.rs | 4 + sync/src/traits.rs | 1 + tests/nipopow_serve_integration.rs | 2 + 12 files changed, 1647 insertions(+), 113 deletions(-) diff --git a/chain/src/chain.rs b/chain/src/chain.rs index 816ecfd..a9cdc33 100644 --- a/chain/src/chain.rs +++ b/chain/src/chain.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::num::NonZeroUsize; use std::sync::Arc; @@ -100,6 +100,13 @@ pub struct HeaderChain { /// source for [`Self::height`] / [`Self::len`] — chain length is /// `by_id.len()` and the tip height is `base_height + by_id.len() - 1`. by_id: HashMap, + /// Sparse, authenticated epoch-boundary headers carried by a continuous + /// NiPoPoW proof. They are difficulty context only: never best-chain + /// entries, never score contributors, and never reorg anchors. + nipopow_difficulty_headers: BTreeMap, + /// A restored light chain cannot accept successors or reorgs until its + /// suffix-bound sparse context has been validated. + nipopow_difficulty_context_ready: bool, /// Currently active blockchain parameters (Phase 6: Soft-Fork Voting). /// Updated only at epoch-boundary block validation via /// [`Self::apply_epoch_boundary_parameters`]. @@ -134,16 +141,10 @@ pub struct HeaderChain { /// before calling [`Self::recompute_active_parameters_from_storage`] /// or [`crate::nipopow_proof::build_nipopow_proof`]. extension_loader: Option, - /// Set to `true` by [`Self::install_from_nipopow_proof`] and never - /// reset. When `true`, [`Self::validate_child`] and (under `cfg(test)`) - /// `validate_child_no_pow` skip the `expected_difficulty` recalculation - /// and the `header.n_bits` comparison. - /// - /// Standard SPV behavior — light clients can't recompute - /// `expected_difficulty` because the recalc reads - /// `use_last_epochs * epoch_length` headers of pre-install history that - /// they don't have. PoW verification, parent linkage, and timestamp - /// bounds remain in force. See `facts/chain.md` Phase 6 invariants. + /// Set to `true` when the best-chain origin was installed from a NiPoPoW + /// suffix. It controls the reorg floor and restart contract only; it does + /// not relax expected-difficulty validation. Sparse authenticated headers + /// above supply the pre-install recalculation inputs. light_client_mode: bool, /// Lazy header/score store. After v0.5.0 this is the sole source /// of truth for both header and cumulative-score reads at heights @@ -177,6 +178,8 @@ impl HeaderChain { config, base_height: None, by_id: HashMap::new(), + nipopow_difficulty_headers: BTreeMap::new(), + nipopow_difficulty_context_ready: false, active_parameters, active_proposed_update_bytes, extension_loader: None, @@ -197,11 +200,11 @@ impl HeaderChain { /// returned and the partially-built chain is discarded. /// - A duplicate height OR a duplicate header id is reported as /// [`RestoreError::DuplicateHeight`] for the offending height. - /// - `light_client_mode` is derived from the first entry's height: - /// a chain starting above height 1 must have been installed from - /// a NiPoPoW proof, and the SPV difficulty skip is preserved - /// across restart. (A chain whose store index starts at height 1 - /// is treated as a normal full chain.) + /// - `light_client_mode` is derived from the first entry's height. A chain + /// starting above height 1 must have been installed from a NiPoPoW proof. + /// Its sparse difficulty context starts unready; the integrator must call + /// [`Self::restore_nipopow_difficulty_context`] before any mutation. A + /// chain whose store index starts at height 1 is a normal full chain. /// - `active_parameters` and `active_proposed_update_bytes` are /// left at construction defaults. The integrator must call /// [`Self::recompute_active_parameters_from_storage`] after @@ -257,6 +260,8 @@ impl HeaderChain { config, base_height, by_id, + nipopow_difficulty_headers: BTreeMap::new(), + nipopow_difficulty_context_ready: false, active_parameters, active_proposed_update_bytes, extension_loader: None, @@ -369,6 +374,19 @@ impl HeaderChain { Some(header) } + /// Header lookup used only by difficulty recalculation. Continuous + /// NiPoPoW anchors below the install boundary are not part of the best + /// chain, but are authenticated context for this one predicate. + pub(crate) fn difficulty_header_at(&self, height: u32) -> Option
{ + self.header_at(height) + .or_else(|| self.nipopow_difficulty_headers.get(&height).cloned()) + } + + /// Sparse continuous-proof difficulty context, sorted by height. + pub fn nipopow_difficulty_headers(&self) -> Vec
{ + self.nipopow_difficulty_headers.values().cloned().collect() + } + /// Whether this header ID is part of the validated chain. pub fn contains(&self, header_id: &BlockId) -> bool { self.by_id.contains_key(header_id) @@ -978,8 +996,8 @@ impl HeaderChain { /// point for light-client mode. /// /// **Precondition**: chain is empty ([`Self::is_empty`] returns `true`). - /// The headers in `suffix_head` + `suffix_tail` MUST already have been - /// validated by the caller via + /// `difficulty_headers`, `suffix_head`, and `suffix_tail` MUST come from + /// the same typed result already validated by the caller via /// [`crate::nipopow_proof::verify_nipopow_proof_bytes`]. This function /// does NOT re-verify the proof; it assumes the caller has done so and /// is installing the trusted suffix. @@ -995,7 +1013,9 @@ impl HeaderChain { /// - [`ChainError::ChainNotEmpty`] if the chain already contains headers. /// - [`ChainError::ParentNotFound`] if any header in `suffix_tail` does /// not link to its predecessor's id. - /// - PoW failure on any header. + /// - Missing, duplicate, or unexpected difficulty context. + /// - Wrong expected difficulty in the suffix. + /// - PoW failure on any context or suffix header. /// /// The genesis-parent check is bypassed — light clients install at /// arbitrary heights. `scores[0]` is initialized to 0 because cumulative @@ -1009,22 +1029,179 @@ impl HeaderChain { /// "Light-client parameter limitation". pub fn install_from_nipopow_proof( &mut self, + difficulty_headers: Vec
, suffix_head: Header, suffix_tail: Vec
, ) -> Result, ChainError> { - self.install_from_nipopow_proof_impl(suffix_head, suffix_tail, true) + self.install_from_nipopow_proof_impl( + difficulty_headers, + suffix_head, + suffix_tail, + true, + true, + true, + ) + } + + fn validate_nipopow_difficulty_context( + &self, + suffix_head_height: u32, + difficulty_headers: Vec
, + verify_pow: bool, + ) -> Result, ChainError> { + let epoch_length = self + .config + .eip37_epoch_length + .unwrap_or(self.config.epoch_length); + let required_heights = crate::difficulty::heights_for_next_recalculation( + suffix_head_height, + epoch_length, + self.config.use_last_epochs, + )? + .into_iter() + .filter(|height| *height > 0 && *height < suffix_head_height) + .collect::>(); + if required_heights.len() > crate::difficulty::MAX_DIFFICULTY_EPOCHS as usize + 1 { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context requires {} headers, above cap {}", + required_heights.len(), + crate::difficulty::MAX_DIFFICULTY_EPOCHS as usize + 1 + ))); + } + if difficulty_headers.len() != required_heights.len() { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context count mismatch: expected {}, got {}", + required_heights.len(), + difficulty_headers.len() + ))); + } + + let mut difficulty_context = BTreeMap::new(); + for header in difficulty_headers { + if !required_heights.contains(&header.height) { + return Err(ChainError::Nipopow(format!( + "unexpected continuous difficulty header at height {}", + header.height + ))); + } + if difficulty_context + .insert(header.height, header.clone()) + .is_some() + { + return Err(ChainError::Nipopow(format!( + "duplicate continuous difficulty header at height {}", + header.height + ))); + } + if verify_pow { + crate::verify_pow(&header)?; + } + } + for required_height in &required_heights { + if !difficulty_context.contains_key(required_height) { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header at height {required_height} is missing" + ))); + } + } + Ok(difficulty_context) + } + + /// Reattach a persisted continuous-proof difficulty context to a restored + /// light chain. The record must name the exact suffix-head entry in the + /// restored best-chain index and contain the complete configured anchor + /// set. Validation completes before state is mutated. + pub fn restore_nipopow_difficulty_context( + &mut self, + suffix_head_height: u32, + suffix_head_id: BlockId, + difficulty_headers: Vec
, + ) -> Result<(), ChainError> { + self.restore_nipopow_difficulty_context_impl( + suffix_head_height, + suffix_head_id, + difficulty_headers, + true, + ) + } + + fn restore_nipopow_difficulty_context_impl( + &mut self, + suffix_head_height: u32, + suffix_head_id: BlockId, + difficulty_headers: Vec
, + verify_pow: bool, + ) -> Result<(), ChainError> { + let base_height = self.base_height.ok_or_else(|| { + ChainError::Nipopow( + "cannot restore continuous difficulty context on an empty chain".into(), + ) + })?; + if !self.light_client_mode { + return Err(ChainError::Nipopow( + "cannot restore continuous difficulty context on a full chain".into(), + )); + } + if suffix_head_height != base_height { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context suffix-head height {suffix_head_height} does not match restored base {base_height}" + ))); + } + if self.height_of(&suffix_head_id) != Some(base_height) { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context suffix-head id {suffix_head_id} does not match restored base" + ))); + } + + let context = self.validate_nipopow_difficulty_context( + suffix_head_height, + difficulty_headers, + verify_pow, + )?; + self.nipopow_difficulty_headers = context; + self.nipopow_difficulty_context_ready = true; + Ok(()) + } + + /// No-PoW restore seam for synthetic continuous-context fixtures. + #[cfg(test)] + pub(crate) fn restore_nipopow_difficulty_context_no_pow( + &mut self, + suffix_head_height: u32, + suffix_head_id: BlockId, + difficulty_headers: Vec
, + ) -> Result<(), ChainError> { + self.restore_nipopow_difficulty_context_impl( + suffix_head_height, + suffix_head_id, + difficulty_headers, + false, + ) } fn install_from_nipopow_proof_impl( &mut self, + difficulty_headers: Vec
, suffix_head: Header, suffix_tail: Vec
, verify_pow: bool, + require_difficulty_context: bool, + verify_difficulty: bool, ) -> Result, ChainError> { if !self.is_empty() { return Err(ChainError::ChainNotEmpty); } + let difficulty_context = if require_difficulty_context { + self.validate_nipopow_difficulty_context( + suffix_head.height, + difficulty_headers, + verify_pow, + )? + } else { + BTreeMap::new() + }; + // Verify PoW on suffix_head before mutating any state. The validator // contract says the caller already verified the proof, but PoW is // cheap to recheck and gives us a clean rollback path: if the head @@ -1042,6 +1219,8 @@ impl HeaderChain { // — see doc above. let head_id = suffix_head.id; let head_height = suffix_head.height; + self.nipopow_difficulty_headers = difficulty_context; + self.nipopow_difficulty_context_ready = true; self.base_height = Some(head_height); self.by_id.insert(head_id, head_height); self.lazy.put(head_height, suffix_head, BigUint::ZERO); @@ -1071,6 +1250,24 @@ impl HeaderChain { got: header.height, }); } + if verify_difficulty { + let expected_n_bits = match crate::difficulty::expected_difficulty(&tip, self) { + Ok(expected) => expected, + Err(error) => { + self.rollback_install(); + return Err(error); + } + }; + if header.n_bits != expected_n_bits { + let error = ChainError::WrongDifficulty { + height: header.height, + expected: expected_n_bits, + got: header.n_bits, + }; + self.rollback_install(); + return Err(error); + } + } if verify_pow { if let Err(e) = crate::verify_pow(&header) { self.rollback_install(); @@ -1097,22 +1294,48 @@ impl HeaderChain { Ok(installed) } - /// Test variant of [`Self::install_from_nipopow_proof`] that skips the - /// per-header PoW check. Used by unit tests on synthetic chains where - /// headers don't carry real Autolykos solutions. + /// Legacy test seam that skips PoW, sparse-context, and suffix-difficulty + /// checks. Used only to construct unrelated synthetic voting/reorg chains. #[cfg(test)] pub(crate) fn install_from_nipopow_proof_no_pow( &mut self, suffix_head: Header, suffix_tail: Vec
, ) -> Result, ChainError> { - self.install_from_nipopow_proof_impl(suffix_head, suffix_tail, false) + self.install_from_nipopow_proof_impl( + Vec::new(), + suffix_head, + suffix_tail, + false, + false, + false, + ) + } + + /// Context-aware no-PoW install seam for continuous-proof fixtures. + #[cfg(test)] + pub(crate) fn install_from_nipopow_proof_no_pow_with_context( + &mut self, + difficulty_headers: Vec
, + suffix_head: Header, + suffix_tail: Vec
, + ) -> Result, ChainError> { + self.install_from_nipopow_proof_impl( + difficulty_headers, + suffix_head, + suffix_tail, + false, + true, + true, + ) } /// Roll back a partial light-client install. Used internally only. fn rollback_install(&mut self) { self.base_height = None; self.by_id.clear(); + self.nipopow_difficulty_headers.clear(); + self.nipopow_difficulty_context_ready = false; self.light_client_mode = false; self.lazy.clear(); } @@ -1133,13 +1356,22 @@ impl HeaderChain { self.base_height.unwrap_or(1) } - /// Whether this chain has been installed from a NiPoPoW proof and is - /// running in light-client mode (no block bodies, no transaction - /// validation, no expected-difficulty recalculation). + /// Whether this chain originated from a NiPoPoW suffix and is running + /// without block-body or transaction validation. Header difficulty remains + /// fully checked using the authenticated sparse context. pub fn light_client_mode(&self) -> bool { self.light_client_mode } + fn require_nipopow_difficulty_context(&self) -> Result<(), ChainError> { + if self.light_client_mode && !self.nipopow_difficulty_context_ready { + return Err(ChainError::Nipopow( + "continuous difficulty context was not restored for light mode".into(), + )); + } + Ok(()) + } + // --- Best-chain consistency --- /// Walk the best chain and verify the parent-linkage invariant: @@ -1398,6 +1630,7 @@ impl HeaderChain { continuation: Header, verify_pow: bool, ) -> Result { + self.require_nipopow_difficulty_context()?; // Need at least 2 headers — can't reorg genesis. if self.by_id.len() < 2 { return Err(ChainError::Reorg( @@ -1466,6 +1699,7 @@ impl HeaderChain { new_branch: Vec
, verify_pow: bool, ) -> Result, ChainError> { + self.require_nipopow_difficulty_context()?; if new_branch.is_empty() { return Err(ChainError::Reorg("new branch is empty".into())); } @@ -1727,6 +1961,7 @@ impl HeaderChain { #[cfg(test)] fn validate_child_no_pow(&self, header: &Header) -> Result<(), ChainError> { + self.require_nipopow_difficulty_context()?; let tip = self.tip(); if header.parent_id != tip.id { @@ -1745,16 +1980,14 @@ impl HeaderChain { got: header.timestamp, }); } - // Skip difficulty check in light-client mode — see `validate_child`. - if !self.light_client_mode { - let expected_n_bits = crate::difficulty::expected_difficulty(&tip, self)?; - if header.n_bits != expected_n_bits { - return Err(ChainError::WrongDifficulty { - height: header.height, - expected: expected_n_bits, - got: header.n_bits, - }); - } + // Light mode uses authenticated sparse pre-install anchors here. + let expected_n_bits = crate::difficulty::expected_difficulty(&tip, self)?; + if header.n_bits != expected_n_bits { + return Err(ChainError::WrongDifficulty { + height: header.height, + expected: expected_n_bits, + got: header.n_bits, + }); } // JVM validateVotes (rules 212-214): reject malformed vote fields. crate::voting::check_header_votes(header.votes.0)?; @@ -1800,6 +2033,7 @@ impl HeaderChain { } fn validate_child(&self, header: &Header) -> Result<(), ChainError> { + self.require_nipopow_difficulty_context()?; let tip = self.tip(); if header.parent_id != tip.id { @@ -1833,18 +2067,15 @@ impl HeaderChain { }); } - // SPV: light clients cannot recompute expected_difficulty because - // they lack the historical epoch boundaries the recalc depends on. - // See `facts/chain.md` Phase 6 light_client_mode invariant. - if !self.light_client_mode { - let expected_n_bits = crate::difficulty::expected_difficulty(&tip, self)?; - if header.n_bits != expected_n_bits { - return Err(ChainError::WrongDifficulty { - height: header.height, - expected: expected_n_bits, - got: header.n_bits, - }); - } + // Continuous-proof anchors make the pre-install recalculation window + // available even when the best-chain index starts at the suffix head. + let expected_n_bits = crate::difficulty::expected_difficulty(&tip, self)?; + if header.n_bits != expected_n_bits { + return Err(ChainError::WrongDifficulty { + height: header.height, + expected: expected_n_bits, + got: header.n_bits, + }); } crate::verify_pow(header)?; diff --git a/chain/src/difficulty.rs b/chain/src/difficulty.rs index 4377d52..1537899 100644 --- a/chain/src/difficulty.rs +++ b/chain/src/difficulty.rs @@ -9,6 +9,11 @@ use crate::error::ChainError; /// Matches JVM `DifficultyAdjustment.PrecisionConstant`. const PRECISION: i64 = 1_000_000_000; +/// Maximum number of prior epochs accepted for a difficulty recalculation. +/// This bounds all vectors derived from configuration or persisted NiPoPoW +/// context before allocation. +pub(crate) const MAX_DIFFICULTY_EPOCHS: u32 = 256; + /// Returns the expected nBits for the next header after `parent`. /// /// If `parent.height` is at an epoch boundary, recalculates difficulty @@ -27,6 +32,7 @@ pub fn expected_difficulty(parent: &Header, chain: &HeaderChain) -> Result 0 { @@ -38,10 +44,22 @@ pub fn expected_difficulty(parent: &Header, chain: &HeaderChain) -> Result = heights .iter() - .filter_map(|&h| chain.header_at(h)) - .collect(); + .copied() + .filter(|height| *height > 0) + .map(|height| { + chain.difficulty_header_at(height).ok_or_else(|| { + ChainError::DifficultyCalc(format!( + "required difficulty header at height {height} is unavailable" + )) + }) + }) + .collect::>()?; if owned_headers.is_empty() { return Ok(config.initial_n_bits); @@ -60,31 +78,89 @@ pub fn expected_difficulty(parent: &Header, chain: &HeaderChain) -> Result Result, ChainError> { + validate_recalculation_window(epoch_length, use_last_epochs)?; + + let height = u64::from(height); + let epoch_length_u64 = u64::from(epoch_length); + let next = if height % epoch_length_u64 == 0 { + height.checked_add(1) + } else { + height + .checked_div(epoch_length_u64) + .and_then(|epoch| epoch.checked_add(1)) + .and_then(|epoch| epoch.checked_mul(epoch_length_u64)) + .and_then(|boundary| boundary.checked_add(1)) + } + .ok_or_else(|| ChainError::DifficultyCalc("next recalculation height overflow".into()))?; + let next = u32::try_from(next) + .map_err(|_| ChainError::DifficultyCalc("next recalculation height exceeds u32".into()))?; + + Ok(previous_heights_for_recalculation( + next, + epoch_length, + use_last_epochs, + )) +} + +fn validate_recalculation_window( + epoch_length: u32, + use_last_epochs: u32, +) -> Result<(), ChainError> { + if epoch_length == 0 { + return Err(ChainError::DifficultyCalc( + "difficulty epoch length must be positive".into(), + )); + } + if use_last_epochs > MAX_DIFFICULTY_EPOCHS { + return Err(ChainError::DifficultyCalc(format!( + "difficulty use_last_epochs {use_last_epochs} exceeds maximum {MAX_DIFFICULTY_EPOCHS}" + ))); + } + Ok(()) +} + fn previous_heights_for_recalculation( height: u32, epoch_length: u32, use_last_epochs: u32, ) -> Vec { - if (height - 1).is_multiple_of(epoch_length) && epoch_length > 1 { + if height == 0 || epoch_length == 0 { + return Vec::new(); + } + let previous_height = height - 1; + if previous_height.is_multiple_of(epoch_length) && epoch_length > 1 { // Branch 1: epoch boundary with epoch_length > 1. Filter out negative heights // (not enough history to fill all use_last_epochs slots). let mut heights: Vec = (0..=use_last_epochs) - .filter_map(|i| (height - 1).checked_sub(i * epoch_length)) + .filter_map(|i| { + i.checked_mul(epoch_length) + .and_then(|delta| previous_height.checked_sub(delta)) + }) .collect(); heights.reverse(); heights - } else if (height - 1).is_multiple_of(epoch_length) - && height > epoch_length * use_last_epochs + } else if previous_height.is_multiple_of(epoch_length) + && epoch_length + .checked_mul(use_last_epochs) + .is_some_and(|window| height > window) { // Branch 2: epoch boundary with epoch_length <= 1 (i.e. epoch_length == 1) // and enough history. All heights are guaranteed non-negative by the guard. let mut heights: Vec = (0..=use_last_epochs) - .map(|i| (height - 1) - i * epoch_length) + .filter_map(|i| { + i.checked_mul(epoch_length) + .and_then(|delta| previous_height.checked_sub(delta)) + }) .collect(); heights.reverse(); heights } else { - vec![height - 1] + vec![previous_height] } } @@ -279,6 +355,36 @@ pub fn interpolate(data: &[(i64, BigInt)], epoch_length: i64) -> BigInt { mod tests { use super::*; + #[test] + fn test_heights_for_next_recalculation_matches_jvm_vectors() { + assert_eq!( + heights_for_next_recalculation(926_976, 128, 4).unwrap(), + vec![926_464, 926_592, 926_720, 926_848, 926_976] + ); + assert_eq!( + heights_for_next_recalculation(926_977, 128, 4).unwrap(), + vec![926_592, 926_720, 926_848, 926_976, 927_104] + ); + assert_eq!( + heights_for_next_recalculation(926_950, 128, 4).unwrap(), + vec![926_464, 926_592, 926_720, 926_848, 926_976] + ); + assert_eq!( + heights_for_next_recalculation(1, 128, 4).unwrap(), + vec![0, 128] + ); + assert_eq!( + heights_for_next_recalculation(129, 128, 4).unwrap(), + vec![0, 128, 256] + ); + assert!(heights_for_next_recalculation(10, 0, 4).is_err()); + } + + #[test] + fn test_heights_for_next_recalculation_rejects_pathological_epoch_count() { + assert!(heights_for_next_recalculation(10, 128, 257).is_err()); + } + #[test] fn test_previous_heights_basic() { let heights = previous_heights_for_recalculation(129, 128, 4); diff --git a/chain/src/lib.rs b/chain/src/lib.rs index fb6ed32..7b3eb7e 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -37,8 +37,10 @@ pub use num_bigint::{BigInt, BigUint}; pub use tracker::HeaderTracker; pub use nipopow_proof::{ build_nipopow_proof, compare_nipopow_proof_bytes, inspect_nipopow_proof_bytes, - popow_header_by_id, verify_nipopow_proof_bytes, NipopowInspection, - NipopowVerificationContext, NipopowVerificationResult, + parse_nipopow_difficulty_context, popow_header_by_id, serialize_nipopow_difficulty_context, + verify_nipopow_proof_bytes, NipopowInspection, NipopowVerificationContext, + NipopowVerificationResult, PersistedNipopowDifficultyContext, + NIPOPOW_DIFFICULTY_CONTEXT_META_KEY, }; pub use voting::{ check_fork_vote, compute_boundary_parameters, encode_validation_settings_update, diff --git a/chain/src/nipopow_proof.rs b/chain/src/nipopow_proof.rs index 602380d..11fab59 100644 --- a/chain/src/nipopow_proof.rs +++ b/chain/src/nipopow_proof.rs @@ -12,6 +12,8 @@ use ergo_chain_types::{BlockId, ExtensionCandidate, Header}; use ergo_nipopow::{NipopowAlgos, NipopowProof, PoPowHeader, PopowHeaderReader}; use sigma_ser::ScorexSerializable; +use std::collections::BTreeSet; +use std::io::{Cursor, Read}; use crate::chain::HeaderChain; use crate::error::ChainError; @@ -22,12 +24,257 @@ use crate::error::ChainError; /// caps the proof size for sanity. pub const MAX_M_K: u32 = 256; +/// Versioned modifier-store key for the authenticated sparse difficulty +/// context carried by a continuous NiPoPoW proof. +pub const NIPOPOW_DIFFICULTY_CONTEXT_META_KEY: &[u8] = b"nipopow_difficulty_context_v1"; + +const NIPOPOW_DIFFICULTY_CONTEXT_MAGIC: [u8; 4] = *b"NDCX"; +const NIPOPOW_DIFFICULTY_CONTEXT_VERSION: u8 = 1; +const MAX_NIPOPOW_DIFFICULTY_CONTEXT_HEADERS: usize = + crate::difficulty::MAX_DIFFICULTY_EPOCHS as usize + 1; +// Mirrors the pinned ergo-nipopow bare-header frame bound. Keeping the +// persistence codec at the same limit avoids accepting data the proof parser +// would reject or rejecting a header that parser already admitted. +const MAX_NIPOPOW_DIFFICULTY_HEADER_BYTES: usize = 10_000; +const NIPOPOW_DIFFICULTY_CONTEXT_FIXED_BYTES: usize = 4 + 1 + 4 + 32 + 4; +const MAX_NIPOPOW_DIFFICULTY_CONTEXT_BYTES: usize = NIPOPOW_DIFFICULTY_CONTEXT_FIXED_BYTES + + MAX_NIPOPOW_DIFFICULTY_CONTEXT_HEADERS * (4 + MAX_NIPOPOW_DIFFICULTY_HEADER_BYTES); + +/// Decoded, versioned persistence record for continuous-proof difficulty +/// anchors. The suffix-head binding prevents stale context from a previous +/// bootstrap at the same database path being applied to another chain. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct PersistedNipopowDifficultyContext { + pub suffix_head_height: u32, + pub suffix_head_id: BlockId, + pub headers: Vec
, +} + +/// Serialize the small authenticated header set needed for future difficulty +/// recalculations after a continuous NiPoPoW bootstrap. +pub fn serialize_nipopow_difficulty_context( + suffix_head: &Header, + headers: &[Header], +) -> Result, ChainError> { + if suffix_head.height <= 1 { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context suffix head must be above genesis, got {}", + suffix_head.height + ))); + } + let suffix_head_bytes = suffix_head + .scorex_serialize_bytes() + .map_err(|e| ChainError::Nipopow(format!("serialize suffix-head binding: {e}")))?; + let parsed_suffix_head = Header::scorex_parse_bytes(&suffix_head_bytes) + .map_err(|e| ChainError::Nipopow(format!("reparse suffix-head binding: {e}")))?; + if parsed_suffix_head.id != suffix_head.id { + return Err(ChainError::Nipopow( + "continuous difficulty context suffix-head id is not canonical".into(), + )); + } + if headers.len() > MAX_NIPOPOW_DIFFICULTY_CONTEXT_HEADERS { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context count {} exceeds cap {}", + headers.len(), + MAX_NIPOPOW_DIFFICULTY_CONTEXT_HEADERS + ))); + } + + let mut frames = Vec::with_capacity(headers.len()); + let mut previous_height = None; + for header in headers { + if header.height == 0 || header.height >= suffix_head.height { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header height {} is outside 1..{}", + header.height, suffix_head.height + ))); + } + if previous_height.is_some_and(|previous| header.height <= previous) { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header heights must be strictly increasing, got {} after {}", + header.height, + previous_height.expect("checked Some") + ))); + } + let frame = header + .scorex_serialize_bytes() + .map_err(|e| ChainError::Nipopow(format!("serialize difficulty header: {e}")))?; + if frame.len() > MAX_NIPOPOW_DIFFICULTY_HEADER_BYTES { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header frame {} exceeds cap {}", + frame.len(), + MAX_NIPOPOW_DIFFICULTY_HEADER_BYTES + ))); + } + let parsed = Header::scorex_parse_bytes(&frame) + .map_err(|e| ChainError::Nipopow(format!("reparse difficulty header: {e}")))?; + if parsed.id != header.id { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header id at height {} is not canonical", + header.height + ))); + } + previous_height = Some(header.height); + frames.push(frame); + } + + let mut bytes = Vec::with_capacity( + NIPOPOW_DIFFICULTY_CONTEXT_FIXED_BYTES + + frames.iter().map(|frame| 4 + frame.len()).sum::(), + ); + bytes.extend_from_slice(&NIPOPOW_DIFFICULTY_CONTEXT_MAGIC); + bytes.push(NIPOPOW_DIFFICULTY_CONTEXT_VERSION); + bytes.extend_from_slice(&suffix_head.height.to_be_bytes()); + bytes.extend_from_slice(&suffix_head.id.0 .0); + bytes.extend_from_slice(&(frames.len() as u32).to_be_bytes()); + for frame in frames { + bytes.extend_from_slice(&(frame.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&frame); + } + Ok(bytes) +} + +fn read_context_u32(cursor: &mut Cursor<&[u8]>, what: &str) -> Result { + let mut bytes = [0u8; 4]; + cursor.read_exact(&mut bytes).map_err(|e| { + ChainError::Nipopow(format!( + "truncated continuous difficulty context {what}: {e}" + )) + })?; + Ok(u32::from_be_bytes(bytes)) +} + +/// Parse a persisted continuous-proof difficulty context with fixed aggregate, +/// count, and per-header bounds. Every frame must be consumed exactly. +pub fn parse_nipopow_difficulty_context( + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_NIPOPOW_DIFFICULTY_CONTEXT_BYTES { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context length {} exceeds cap {}", + bytes.len(), + MAX_NIPOPOW_DIFFICULTY_CONTEXT_BYTES + ))); + } + + let mut cursor = Cursor::new(bytes); + let mut magic = [0u8; 4]; + cursor.read_exact(&mut magic).map_err(|e| { + ChainError::Nipopow(format!( + "truncated continuous difficulty context magic: {e}" + )) + })?; + if magic != NIPOPOW_DIFFICULTY_CONTEXT_MAGIC { + return Err(ChainError::Nipopow( + "invalid continuous difficulty context magic".into(), + )); + } + let mut version = [0u8; 1]; + cursor.read_exact(&mut version).map_err(|e| { + ChainError::Nipopow(format!( + "truncated continuous difficulty context version: {e}" + )) + })?; + if version[0] != NIPOPOW_DIFFICULTY_CONTEXT_VERSION { + return Err(ChainError::Nipopow(format!( + "unsupported continuous difficulty context version {}", + version[0] + ))); + } + + let suffix_head_height = read_context_u32(&mut cursor, "suffix-head height")?; + if suffix_head_height <= 1 { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context suffix head must be above genesis, got {suffix_head_height}" + ))); + } + let mut suffix_head_id = [0u8; 32]; + cursor.read_exact(&mut suffix_head_id).map_err(|e| { + ChainError::Nipopow(format!( + "truncated continuous difficulty context suffix-head id: {e}" + )) + })?; + let count = read_context_u32(&mut cursor, "header count")? as usize; + if count > MAX_NIPOPOW_DIFFICULTY_CONTEXT_HEADERS { + return Err(ChainError::Nipopow(format!( + "continuous difficulty context count {count} exceeds cap {}", + MAX_NIPOPOW_DIFFICULTY_CONTEXT_HEADERS + ))); + } + + let mut headers = Vec::with_capacity(count); + let mut previous_height = None; + for index in 0..count { + let frame_size = read_context_u32(&mut cursor, "header frame size")? as usize; + if frame_size > MAX_NIPOPOW_DIFFICULTY_HEADER_BYTES { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header frame {frame_size} exceeds cap {}", + MAX_NIPOPOW_DIFFICULTY_HEADER_BYTES + ))); + } + let start = cursor.position() as usize; + let end = start.checked_add(frame_size).ok_or_else(|| { + ChainError::Nipopow("continuous difficulty header frame length overflow".into()) + })?; + if end > bytes.len() { + return Err(ChainError::Nipopow(format!( + "truncated continuous difficulty header frame {index}: declared {frame_size} bytes" + ))); + } + let frame = &bytes[start..end]; + cursor.set_position(end as u64); + let mut frame_reader = Cursor::new(frame); + let header = Header::scorex_parse(&mut frame_reader).map_err(|e| { + ChainError::Nipopow(format!( + "parse continuous difficulty header frame {index}: {e}" + )) + })?; + if frame_reader.position() as usize != frame.len() { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header frame {index} has trailing bytes" + ))); + } + if header.height == 0 || header.height >= suffix_head_height { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header height {} is outside 1..{}", + header.height, suffix_head_height + ))); + } + if previous_height.is_some_and(|previous| header.height <= previous) { + return Err(ChainError::Nipopow(format!( + "continuous difficulty header heights must be strictly increasing, got {} after {}", + header.height, + previous_height.expect("checked Some") + ))); + } + previous_height = Some(header.height); + headers.push(header); + } + + if cursor.position() as usize != bytes.len() { + return Err(ChainError::Nipopow( + "continuous difficulty context has trailing bytes".into(), + )); + } + + Ok(PersistedNipopowDifficultyContext { + suffix_head_height, + suffix_head_id: BlockId(ergo_chain_types::Digest32::from(suffix_head_id)), + headers, + }) +} + /// Request and trust-anchor values a received proof must match exactly. #[derive(Debug, Clone, Eq, PartialEq)] pub struct NipopowVerificationContext { pub expected_m: u32, pub expected_k: u32, pub expected_genesis_id: BlockId, + /// Epoch length used by the JVM continuous-proof producer when choosing + /// headers for the next difficulty recalculation. + pub difficulty_epoch_length: u32, + /// Number of prior epochs consumed by difficulty recalculation. + pub use_last_epochs: u32, } impl NipopowVerificationContext { @@ -40,10 +287,13 @@ impl NipopowVerificationContext { let expected_genesis_id = chain .configured_genesis_id() .ok_or_else(|| ChainError::Nipopow("configured genesis id is absent".into()))?; + let config = chain.config(); Ok(Self { expected_m, expected_k, expected_genesis_id, + difficulty_epoch_length: config.eip37_epoch_length.unwrap_or(config.epoch_length), + use_last_epochs: config.use_last_epochs, }) } @@ -58,6 +308,23 @@ impl NipopowVerificationContext { self.expected_m, self.expected_k ))); } + if self.difficulty_epoch_length == 0 { + return Err(ChainError::Nipopow( + "continuous difficulty epoch length must be positive".into(), + )); + } + if self.use_last_epochs <= 1 { + return Err(ChainError::Nipopow( + "continuous difficulty use_last_epochs must be greater than 1".into(), + )); + } + if self.use_last_epochs > crate::difficulty::MAX_DIFFICULTY_EPOCHS { + return Err(ChainError::Nipopow(format!( + "continuous difficulty use_last_epochs {} exceeds maximum {}", + self.use_last_epochs, + crate::difficulty::MAX_DIFFICULTY_EPOCHS + ))); + } Ok(()) } } @@ -70,6 +337,9 @@ pub struct NipopowVerificationResult { /// JVM terminal mode, or `None` for a Rust-core-only payload. pub continuous: Option, pub prefix: Vec
, + /// Authenticated sparse history required to validate difficulty after + /// installing the suffix. This is a subset of `prefix`. + pub difficulty_headers: Vec
, pub suffix_head: Header, pub suffix_tail: Vec
, } @@ -246,13 +516,46 @@ pub fn build_nipopow_proof( } let reader = ChainPopowReader { chain }; - let proof = NipopowAlgos::default() + let mut proof = NipopowAlgos::default() .prove_with_reader(&reader, header_id.as_ref(), k, m) .map_err(|e| ChainError::Nipopow(format!("prove_with_reader failed: {e:?}")))?; + let config = chain.config(); + let difficulty_epoch_length = config.eip37_epoch_length.unwrap_or(config.epoch_length); + let suffix_head_height = proof.suffix_head.header.height; + let required_heights = crate::difficulty::heights_for_next_recalculation( + suffix_head_height, + difficulty_epoch_length, + config.use_last_epochs, + )?; + let mut stored_heights: BTreeSet = proof + .prefix + .iter() + .map(|popow| popow.header.height) + .collect(); + for height in required_heights + .into_iter() + .filter(|height| *height > 0 && *height < suffix_head_height) + { + if stored_heights.insert(height) { + let popow = reader.popow_header_at_height(height).ok_or_else(|| { + ChainError::Nipopow(format!( + "continuous proof difficulty header at height {height} is unavailable" + )) + })?; + proof.prefix.push(popow); + } + } + proof.prefix.sort_by_key(|popow| popow.header.height); proof + .validate() + .map_err(|e| ChainError::Nipopow(format!("continuous proof validation failed: {e}")))?; + + let mut bytes = proof .scorex_serialize_bytes() - .map_err(|e| ChainError::Nipopow(format!("serialize failed: {e:?}"))) + .map_err(|e| ChainError::Nipopow(format!("serialize failed: {e:?}")))?; + bytes.push(1); + Ok(bytes) } /// Fetch a single popow header by its block id and return the @@ -366,6 +669,37 @@ fn verify_nipopow_headers_pow(proof: &NipopowProof) -> Result<(), ChainError> { Ok(()) } +fn continuous_difficulty_headers( + proof: &NipopowProof, + context: &NipopowVerificationContext, +) -> Result, ChainError> { + let required = crate::difficulty::heights_for_next_recalculation( + proof.suffix_head.header.height, + context.difficulty_epoch_length, + context.use_last_epochs, + )?; + let mut headers = Vec::new(); + let mut next_prefix_index = 0usize; + + for required_height in required + .into_iter() + .filter(|height| *height > 0 && *height < proof.suffix_head.header.height) + { + let relative_index = proof.prefix[next_prefix_index..] + .iter() + .position(|popow| popow.header.height == required_height) + .ok_or_else(|| { + ChainError::Nipopow(format!( + "continuous proof is missing difficulty header at height {required_height}" + )) + })?; + next_prefix_index += relative_index + 1; + headers.push(proof.prefix[next_prefix_index - 1].header.clone()); + } + + Ok(headers) +} + /// Verify a NiPoPoW proof from raw bytes. /// /// **Precondition**: `bytes` is the inner NiPoPoW proof payload (the main @@ -453,6 +787,14 @@ fn verify_inner( ))); } + if continuous != Some(true) { + return Err(ChainError::Nipopow( + "continuous mode byte 1 is required for light bootstrap".into(), + )); + } + + let difficulty_headers = continuous_difficulty_headers(&proof, context)?; + if check_pow { verify_nipopow_headers_pow(&proof)?; } @@ -470,6 +812,7 @@ fn verify_inner( k, continuous, prefix: prefix.into_iter().map(|p| p.header).collect(), + difficulty_headers, suffix_head: suffix_head.header, suffix_tail, }) @@ -634,6 +977,11 @@ mod tests { expected_m, expected_k, expected_genesis_id: chain.header_at(1).expect("genesis header").id, + difficulty_epoch_length: chain + .config() + .eip37_epoch_length + .unwrap_or(chain.config().epoch_length), + use_last_epochs: chain.config().use_last_epochs, } } @@ -719,7 +1067,7 @@ mod tests { assert_eq!(result.m, 2); assert_eq!(result.k, 2); - assert_eq!(result.continuous, None); + assert_eq!(result.continuous, Some(true)); assert_eq!( result.prefix.iter().map(|h| h.id).collect::>(), expected_prefix @@ -791,40 +1139,125 @@ mod tests { assert!(NipopowVerificationContext::from_chain(&chain, 6, 10).is_err()); } + #[test] + fn verification_context_rejects_pathological_difficulty_window_before_parse() { + let context = NipopowVerificationContext { + expected_m: 6, + expected_k: 10, + expected_genesis_id: BlockId(Digest32::zero()), + difficulty_epoch_length: 128, + use_last_epochs: crate::difficulty::MAX_DIFFICULTY_EPOCHS + 1, + }; + + let err = verify_nipopow_proof_bytes(&[], &context) + .expect_err("pathological context must fail before proof parsing"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("exceeds maximum"))); + } + #[test] fn verify_parses_optional_jvm_terminal_mode() { let chain = build_chain_with_interlinks(20); - let core = build_nipopow_proof(&chain, 2, 2, None).expect("build"); - let context = verification_context(&chain, 2, 2); + let mut core = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + assert_eq!(core.pop(), Some(1)); for (terminal, expected) in [(None, None), (Some(0), Some(false)), (Some(1), Some(true))] { let mut bytes = core.clone(); if let Some(mode) = terminal { bytes.push(mode); } - let result = verify_nipopow_proof_bytes_no_pow(&bytes, &context).expect("verify"); - assert_eq!(result.continuous, expected); + let (_, continuous) = parse_received_nipopow_proof(&bytes).expect("parse"); + assert_eq!(continuous, expected); } } + #[test] + fn bootstrap_verifier_rejects_absent_or_false_continuous_mode() { + let chain = build_chain_with_interlinks(20); + let mut core = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + assert_eq!(core.pop(), Some(1)); + let context = verification_context(&chain, 2, 2); + + let absent = verify_nipopow_proof_bytes_no_pow(&core, &context) + .expect_err("bootstrap proof without a mode byte must fail closed"); + assert!(matches!(absent, ChainError::Nipopow(ref msg) if msg.contains("continuous"))); + + let mut one_shot = core; + one_shot.push(0); + let false_mode = verify_nipopow_proof_bytes_no_pow(&one_shot, &context) + .expect_err("one-shot proof must not authorize continuous bootstrap"); + assert!(matches!(false_mode, ChainError::Nipopow(ref msg) if msg.contains("continuous"))); + } + + #[test] + fn continuous_verification_rejects_missing_required_difficulty_headers() { + let chain = build_chain_with_interlinks(300); + let built = build_nipopow_proof(&chain, 6, 10, None).expect("build"); + let (mut proof, continuous) = parse_received_nipopow_proof(&built).expect("parse"); + assert_eq!(continuous, Some(true)); + let original_len = proof.prefix.len(); + proof.prefix.retain(|popow| popow.header.height != 128); + assert_eq!(proof.prefix.len() + 1, original_len); + let mut bytes = proof.scorex_serialize_bytes().expect("serialize mutation"); + bytes.push(1); + let context = verification_context(&chain, 6, 10); + + let err = verify_nipopow_proof_bytes_no_pow(&bytes, &context) + .expect_err("proof without the JVM-required epoch anchors must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) + if msg.contains("difficulty header") && (msg.contains("128") || msg.contains("256")))); + } + + #[test] + fn build_proof_emits_continuous_mode_and_required_difficulty_headers() { + let chain = build_chain_with_interlinks(300); + let bytes = build_nipopow_proof(&chain, 6, 10, None).expect("build"); + let (proof, continuous) = parse_received_nipopow_proof(&bytes).expect("parse"); + + assert_eq!(continuous, Some(true)); + let prefix_heights: Vec = proof.prefix.iter().map(|p| p.header.height).collect(); + assert!(prefix_heights.contains(&128)); + assert!(prefix_heights.contains(&256)); + + let context = verification_context(&chain, 6, 10); + let verified = verify_nipopow_proof_bytes_no_pow(&bytes, &context).expect("verify"); + assert_eq!( + verified + .difficulty_headers + .iter() + .map(|header| header.height) + .collect::>(), + vec![128, 256] + ); + } + #[test] fn verify_rejects_invalid_jvm_terminal_mode() { let chain = build_chain_with_interlinks(20); let mut bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); + assert_eq!(bytes.pop(), Some(1)); bytes.push(2); let context = verification_context(&chain, 2, 2); - assert!(verify_nipopow_proof_bytes_no_pow(&bytes, &context).is_err()); + let err = verify_nipopow_proof_bytes_no_pow(&bytes, &context) + .expect_err("single invalid mode byte must fail"); + assert!( + matches!(err, ChainError::Nipopow(ref msg) if msg.contains("invalid JVM continuous mode byte 2")) + ); } #[test] fn verify_rejects_extra_bytes_after_jvm_terminal_mode() { let chain = build_chain_with_interlinks(20); let mut bytes = build_nipopow_proof(&chain, 2, 2, None).expect("build"); - bytes.extend_from_slice(&[0, 0]); + assert_eq!(bytes.pop(), Some(1)); + bytes.extend_from_slice(&[1, 0]); let context = verification_context(&chain, 2, 2); - assert!(verify_nipopow_proof_bytes_no_pow(&bytes, &context).is_err()); + let err = verify_nipopow_proof_bytes_no_pow(&bytes, &context) + .expect_err("one byte after a valid terminal mode must fail"); + assert!( + matches!(err, ChainError::Nipopow(ref msg) if msg.contains("unexpected 2 trailing bytes")) + ); } #[test] @@ -898,6 +1331,8 @@ mod tests { expected_m: 6, expected_k: 10, expected_genesis_id: BlockId(Digest32::zero()), + difficulty_epoch_length: 128, + use_last_epochs: 8, }; let r = verify_nipopow_proof_bytes(&[], &context); assert!(r.is_err()); @@ -909,6 +1344,8 @@ mod tests { expected_m: 6, expected_k: 10, expected_genesis_id: BlockId(Digest32::zero()), + difficulty_epoch_length: 128, + use_last_epochs: 8, }; let r = verify_nipopow_proof_bytes(&[0xFFu8; 32], &context); assert!(r.is_err()); @@ -1173,4 +1610,212 @@ mod tests { let result = popow_header_by_id(&chain, &unknown).expect("call succeeds"); assert!(result.is_none(), "unknown id must return Ok(None)"); } + + #[test] + fn difficulty_context_codec_roundtrips_and_binds_suffix_head() { + let suffix_head = make_synthetic_header( + 375, + BlockId(Digest32::from([0x75; 32])), + 20_000_000, + ChainConfig::testnet().initial_n_bits, + ); + let headers = vec![ + make_synthetic_header( + 128, + BlockId(Digest32::from([0x80; 32])), + 8_000_000, + suffix_head.n_bits, + ), + make_synthetic_header( + 256, + BlockId(Digest32::from([0x81; 32])), + 14_000_000, + suffix_head.n_bits, + ), + ] + .into_iter() + .map(|header| { + Header::scorex_parse_bytes( + &header + .scorex_serialize_bytes() + .expect("serialize canonical fixture"), + ) + .expect("parse canonical fixture") + }) + .collect::>(); + + let bytes = serialize_nipopow_difficulty_context(&suffix_head, &headers) + .expect("serialize bounded context"); + let parsed = parse_nipopow_difficulty_context(&bytes).expect("parse bounded context"); + + assert_eq!(parsed.suffix_head_height, suffix_head.height); + assert_eq!(parsed.suffix_head_id, suffix_head.id); + assert_eq!(parsed.headers, headers); + assert_eq!( + serialize_nipopow_difficulty_context(&suffix_head, &parsed.headers) + .expect("canonical re-serialize"), + bytes + ); + } + + #[test] + fn difficulty_context_codec_rejects_invalid_envelope_fields() { + let oversized = vec![0; MAX_NIPOPOW_DIFFICULTY_CONTEXT_BYTES + 1]; + let err = parse_nipopow_difficulty_context(&oversized) + .expect_err("aggregate context above the cap must fail before parsing"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("length"))); + + let mut envelope = Vec::new(); + envelope.extend_from_slice(&NIPOPOW_DIFFICULTY_CONTEXT_MAGIC); + envelope.push(NIPOPOW_DIFFICULTY_CONTEXT_VERSION); + envelope.extend_from_slice(&375u32.to_be_bytes()); + envelope.extend_from_slice(&[0x37; 32]); + envelope.extend_from_slice(&0u32.to_be_bytes()); + + let mut bad_magic = envelope.clone(); + bad_magic[0] ^= 0xff; + let err = parse_nipopow_difficulty_context(&bad_magic) + .expect_err("unknown context magic must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("magic"))); + + let mut bad_version = envelope.clone(); + bad_version[4] = NIPOPOW_DIFFICULTY_CONTEXT_VERSION + 1; + let err = parse_nipopow_difficulty_context(&bad_version) + .expect_err("unknown context version must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("version"))); + + let mut bad_height = envelope; + bad_height[5..9].copy_from_slice(&1u32.to_be_bytes()); + let err = parse_nipopow_difficulty_context(&bad_height) + .expect_err("a light context cannot bind to genesis"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("above genesis"))); + } + + #[test] + fn difficulty_context_codec_rejects_count_and_frame_bombs() { + let suffix_head = make_synthetic_header( + 375, + BlockId(Digest32::from([0x75; 32])), + 20_000_000, + ChainConfig::testnet().initial_n_bits, + ); + let mut count_bomb = Vec::new(); + count_bomb.extend_from_slice(&NIPOPOW_DIFFICULTY_CONTEXT_MAGIC); + count_bomb.push(NIPOPOW_DIFFICULTY_CONTEXT_VERSION); + count_bomb.extend_from_slice(&suffix_head.height.to_be_bytes()); + count_bomb.extend_from_slice(&suffix_head.id.0 .0); + count_bomb.extend_from_slice( + &((MAX_NIPOPOW_DIFFICULTY_CONTEXT_HEADERS as u32) + 1).to_be_bytes(), + ); + let err = parse_nipopow_difficulty_context(&count_bomb) + .expect_err("declared count above cap must fail before allocation"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("count"))); + + let mut frame_bomb = Vec::new(); + frame_bomb.extend_from_slice(&NIPOPOW_DIFFICULTY_CONTEXT_MAGIC); + frame_bomb.push(NIPOPOW_DIFFICULTY_CONTEXT_VERSION); + frame_bomb.extend_from_slice(&suffix_head.height.to_be_bytes()); + frame_bomb.extend_from_slice(&suffix_head.id.0 .0); + frame_bomb.extend_from_slice(&1u32.to_be_bytes()); + frame_bomb + .extend_from_slice(&((MAX_NIPOPOW_DIFFICULTY_HEADER_BYTES as u32) + 1).to_be_bytes()); + let err = parse_nipopow_difficulty_context(&frame_bomb) + .expect_err("oversized frame must fail before allocation"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("frame"))); + } + + #[test] + fn difficulty_context_codec_rejects_truncation_trailing_and_duplicate_heights() { + let suffix_head = make_synthetic_header( + 375, + BlockId(Digest32::from([0x75; 32])), + 20_000_000, + ChainConfig::testnet().initial_n_bits, + ); + let header = make_synthetic_header( + 128, + BlockId(Digest32::from([0x80; 32])), + 8_000_000, + suffix_head.n_bits, + ); + let bytes = + serialize_nipopow_difficulty_context(&suffix_head, std::slice::from_ref(&header)) + .expect("serialize control"); + + let err = parse_nipopow_difficulty_context(&bytes[..bytes.len() - 1]) + .expect_err("truncated frame must fail"); + assert!(matches!(err, ChainError::Nipopow(_))); + + let mut trailing = bytes; + trailing.push(0); + let err = parse_nipopow_difficulty_context(&trailing) + .expect_err("trailing metadata bytes must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("trailing"))); + + let err = serialize_nipopow_difficulty_context(&suffix_head, &[header.clone(), header]) + .expect_err("duplicate heights must not have two encodings"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("increasing"))); + } + + #[test] + fn difficulty_context_codec_rejects_trailing_bytes_inside_header_frame() { + let suffix_head = make_synthetic_header( + 375, + BlockId(Digest32::from([0x75; 32])), + 20_000_000, + ChainConfig::testnet().initial_n_bits, + ); + let header = make_synthetic_header( + 128, + BlockId(Digest32::from([0x80; 32])), + 8_000_000, + suffix_head.n_bits, + ); + let mut bytes = + serialize_nipopow_difficulty_context(&suffix_head, std::slice::from_ref(&header)) + .expect("serialize control"); + let frame_size_offset = NIPOPOW_DIFFICULTY_CONTEXT_FIXED_BYTES; + let frame_size = u32::from_be_bytes( + bytes[frame_size_offset..frame_size_offset + 4] + .try_into() + .expect("frame-size bytes"), + ); + bytes[frame_size_offset..frame_size_offset + 4] + .copy_from_slice(&(frame_size + 1).to_be_bytes()); + bytes.push(0); + + let err = parse_nipopow_difficulty_context(&bytes) + .expect_err("a framed header must consume its complete frame"); + assert!( + matches!(err, ChainError::Nipopow(ref msg) if msg.contains("frame 0 has trailing bytes")) + ); + } + + #[test] + fn difficulty_context_codec_rejects_noncanonical_header_id() { + let suffix_head = make_synthetic_header( + 375, + BlockId(Digest32::from([0x75; 32])), + 20_000_000, + ChainConfig::testnet().initial_n_bits, + ); + let mut header = make_synthetic_header( + 128, + BlockId(Digest32::from([0x80; 32])), + 8_000_000, + suffix_head.n_bits, + ); + + let mut noncanonical_suffix_head = suffix_head.clone(); + noncanonical_suffix_head.id = BlockId(Digest32::zero()); + let err = serialize_nipopow_difficulty_context(&noncanonical_suffix_head, &[]) + .expect_err("the suffix-head binding must use its canonical id"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("suffix-head id"))); + + header.id = BlockId(Digest32::zero()); + + let err = serialize_nipopow_difficulty_context(&suffix_head, &[header]) + .expect_err("an in-memory header id must match its canonical bytes"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("not canonical"))); + } } diff --git a/chain/src/tests.rs b/chain/src/tests.rs index d4e0b33..6a84c94 100644 --- a/chain/src/tests.rs +++ b/chain/src/tests.rs @@ -3051,6 +3051,28 @@ mod light_client_install_tests { ChainConfig::testnet() } + fn difficulty_context(config: &ChainConfig, suffix_head_height: u32) -> Vec
{ + let epoch_length = config.eip37_epoch_length.unwrap_or(config.epoch_length); + crate::difficulty::heights_for_next_recalculation( + suffix_head_height, + epoch_length, + config.use_last_epochs, + ) + .expect("valid test configuration") + .into_iter() + .filter(|height| *height > 0 && *height < suffix_head_height) + .map(|height| { + make_chain_header_with_nonce( + height, + BlockId(Digest32::from([0xD1; 32])), + 1_000_000 + u64::from(height) * 50_000, + config.initial_n_bits, + height.to_be_bytes().repeat(2), + ) + }) + .collect() + } + /// Build a synthetic "suffix" — k headers starting at `start_height` /// with the given parent_id, all with the same n_bits. fn build_suffix( @@ -3214,6 +3236,308 @@ mod light_client_install_tests { assert!(!chain.light_client_mode()); } + #[test] + fn continuous_install_rejects_one_missing_difficulty_header_without_mutation() { + let config = testnet_config(); + let mut chain = HeaderChain::new(config.clone()); + let parent = BlockId(Digest32::from([0x77; 32])); + let head = make_chain_header(375, parent, 1_000_000 + 375 * 50_000, config.initial_n_bits); + let mut context = difficulty_context(&config, head.height); + assert_eq!( + context + .iter() + .map(|header| header.height) + .collect::>(), + vec![128, 256] + ); + context.remove(0); + + let err = chain + .install_from_nipopow_proof_no_pow_with_context(context, head, vec![]) + .expect_err("one missing authenticated anchor must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("count mismatch"))); + assert!(chain.is_empty()); + assert!(chain.nipopow_difficulty_headers().is_empty()); + } + + #[test] + fn continuous_install_rejects_duplicate_difficulty_height_without_mutation() { + let config = testnet_config(); + let mut chain = HeaderChain::new(config.clone()); + let parent = BlockId(Digest32::from([0x77; 32])); + let head = make_chain_header(375, parent, 1_000_000 + 375 * 50_000, config.initial_n_bits); + let mut context = difficulty_context(&config, head.height); + context[1] = context[0].clone(); + + let err = chain + .install_from_nipopow_proof_no_pow_with_context(context, head, vec![]) + .expect_err("duplicate authenticated anchor height must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("duplicate"))); + assert!(chain.is_empty()); + assert!(chain.nipopow_difficulty_headers().is_empty()); + } + + #[test] + fn continuous_install_rejects_unexpected_difficulty_height_without_mutation() { + let config = testnet_config(); + let mut chain = HeaderChain::new(config.clone()); + let parent = BlockId(Digest32::from([0x77; 32])); + let head = make_chain_header(375, parent, 1_000_000 + 375 * 50_000, config.initial_n_bits); + let mut context = difficulty_context(&config, head.height); + context[1] = make_chain_header_with_nonce( + 129, + BlockId(Digest32::from([0xD2; 32])), + 1_000_000 + 129 * 50_000, + config.initial_n_bits, + vec![0xD2; 8], + ); + + let err = chain + .install_from_nipopow_proof_no_pow_with_context(context, head, vec![]) + .expect_err("unexpected authenticated anchor height must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("unexpected"))); + assert!(chain.is_empty()); + assert!(chain.nipopow_difficulty_headers().is_empty()); + } + + #[test] + fn continuous_install_rejects_bad_context_pow_without_mutation() { + let config = testnet_config(); + let mut chain = HeaderChain::new(config.clone()); + let parent = BlockId(Digest32::from([0x77; 32])); + let head = make_chain_header(375, parent, 1_000_000 + 375 * 50_000, config.initial_n_bits); + let mut context = difficulty_context(&config, head.height); + context[0] = make_chain_header_with_nonce( + context[0].height, + BlockId(Digest32::from([0xD3; 32])), + context[0].timestamp, + 72_286_528, + vec![0xD3; 8], + ); + + let err = chain + .install_from_nipopow_proof(context, head, vec![]) + .expect_err("invalid authenticated anchor PoW must fail"); + assert!(matches!( + err, + ChainError::PowInvalid { .. } | ChainError::PowCompute(_) + )); + assert!(chain.is_empty()); + assert!(chain.nipopow_difficulty_headers().is_empty()); + } + + #[test] + fn continuous_install_rejects_wrong_suffix_difficulty_and_rolls_back() { + let config = testnet_config(); + let mut chain = HeaderChain::new(config.clone()); + let parent = BlockId(Digest32::from([0x77; 32])); + let head = make_chain_header(375, parent, 1_000_000 + 375 * 50_000, config.initial_n_bits); + let bad_tail = make_chain_header_with_nonce( + 376, + head.id, + head.timestamp + 50_000, + config.initial_n_bits + 1, + vec![0xB4; 8], + ); + let context = difficulty_context(&config, head.height); + + let err = chain + .install_from_nipopow_proof_no_pow_with_context(context, head, vec![bad_tail]) + .expect_err("wrong suffix nBits must fail"); + assert!(matches!( + err, + ChainError::WrongDifficulty { height: 376, .. } + )); + assert!(chain.is_empty()); + assert!(chain.nipopow_difficulty_headers().is_empty()); + } + + #[test] + fn continuous_context_validates_first_post_install_recalculation() { + let config = testnet_config(); + let mut chain = HeaderChain::new(config.clone()); + let parent = BlockId(Digest32::from([0x77; 32])); + let suffix = build_suffix( + 375, + parent, + 1_000_000 + 375 * 50_000, + config.initial_n_bits, + 10, + ); + let head = suffix[0].clone(); + let context = difficulty_context(&config, head.height); + chain + .install_from_nipopow_proof_no_pow_with_context(context, head, suffix[1..].to_vec()) + .expect("continuous suffix install"); + assert_eq!(chain.height(), 384); + + let tip = chain.tip(); + let expected = crate::difficulty::expected_difficulty(&tip, &chain) + .expect("sparse anchors complete the recalculation window"); + let wrong = make_chain_header_with_nonce( + 385, + tip.id, + tip.timestamp + 50_000, + expected.wrapping_add(1), + vec![0xC1; 8], + ); + let err = chain + .try_append_no_pow(wrong) + .expect_err("wrong boundary difficulty must fail"); + assert!(matches!( + err, + ChainError::WrongDifficulty { height: 385, .. } + )); + assert_eq!(chain.height(), 384); + + let correct = make_chain_header_with_nonce( + 385, + tip.id, + tip.timestamp + 50_000, + expected, + vec![0xC2; 8], + ); + assert!(matches!( + chain + .try_append_no_pow(correct) + .expect("correct boundary child"), + AppendResult::Extended + )); + assert_eq!(chain.height(), 385); + } + + #[test] + fn restored_continuous_context_is_bound_to_the_installed_suffix_head() { + let config = testnet_config(); + let parent = BlockId(Digest32::from([0x77; 32])); + let suffix = build_suffix( + 375, + parent, + 1_000_000 + 375 * 50_000, + config.initial_n_bits, + 10, + ); + let entries = suffix.iter().map(|header| (header.height, header.id)); + let mut restored = + HeaderChain::restore(config.clone(), entries).expect("restore suffix index"); + let context = difficulty_context(&config, suffix[0].height); + + let err = restored + .restore_nipopow_difficulty_context_no_pow( + suffix[0].height + 1, + suffix[0].id, + context.clone(), + ) + .expect_err("stale suffix-head height binding must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("suffix-head height"))); + assert!(restored.nipopow_difficulty_headers().is_empty()); + + let err = restored + .restore_nipopow_difficulty_context_no_pow( + suffix[0].height, + BlockId(Digest32::from([0x99; 32])), + context.clone(), + ) + .expect_err("stale suffix-head binding must fail"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("suffix-head id"))); + assert!(restored.nipopow_difficulty_headers().is_empty()); + + restored + .restore_nipopow_difficulty_context_no_pow( + suffix[0].height, + suffix[0].id, + context.clone(), + ) + .expect("matching persisted context"); + assert_eq!(restored.nipopow_difficulty_headers(), context); + } + + #[test] + fn restored_continuous_context_rejects_missing_anchor_without_mutation() { + let config = testnet_config(); + let suffix = build_suffix( + 375, + BlockId(Digest32::from([0x77; 32])), + 1_000_000 + 375 * 50_000, + config.initial_n_bits, + 2, + ); + let entries = suffix.iter().map(|header| (header.height, header.id)); + let mut restored = + HeaderChain::restore(config.clone(), entries).expect("restore suffix index"); + let mut context = difficulty_context(&config, suffix[0].height); + context.remove(0); + + let err = restored + .restore_nipopow_difficulty_context_no_pow(suffix[0].height, suffix[0].id, context) + .expect_err("missing persisted anchor must fail closed"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("count mismatch"))); + assert!(restored.nipopow_difficulty_headers().is_empty()); + } + + #[test] + fn restored_light_chain_rejects_children_until_context_is_ready() { + let config = testnet_config(); + let suffix = build_suffix( + 375, + BlockId(Digest32::from([0x77; 32])), + 1_000_000 + 375 * 50_000, + config.initial_n_bits, + 2, + ); + let entries = suffix.iter().map(|header| (header.height, header.id)); + let mut restored = + HeaderChain::restore(config.clone(), entries).expect("restore suffix index"); + let stored_headers = suffix + .iter() + .cloned() + .map(|header| (header.height, header)) + .collect::>(); + restored.set_header_loader(move |height| stored_headers.get(&height).cloned()); + restored.set_score_loader(|_| Some(crate::BigUint::default())); + let tip = suffix.last().expect("fixture tip"); + let child = make_chain_header_with_nonce( + tip.height + 1, + tip.id, + tip.timestamp + 50_000, + tip.n_bits, + vec![0xC3; 8], + ); + + let alternative = make_chain_header_with_nonce( + suffix[1].height, + suffix[0].id, + suffix[1].timestamp + 1, + suffix[1].n_bits, + vec![0xC4; 8], + ); + let err = restored + .try_reorg_deep_no_pow(suffix[0].height, vec![alternative]) + .expect_err("unrestored light context must block every reorg"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("not restored"))); + assert_eq!(restored.height(), tip.height); + + let err = restored + .try_append_no_pow(child.clone()) + .expect_err("unrestored light context must block every child"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("not restored"))); + assert_eq!(restored.height(), tip.height); + + restored + .restore_nipopow_difficulty_context_no_pow( + suffix[0].height, + suffix[0].id, + difficulty_context(&config, suffix[0].height), + ) + .expect("restore authenticated context"); + assert!(matches!( + restored + .try_append_no_pow(child) + .expect("context-ready child"), + AppendResult::Extended + )); + } + #[test] fn install_rejects_bad_pow_via_real_path() { // Real install_from_nipopow_proof (with PoW) must reject synthetic @@ -3230,12 +3554,12 @@ mod light_client_install_tests { let high_n_bits = 72286528u32; let parent = BlockId(Digest32::from([0x77; 32])); - let suffix = build_suffix(1000, parent, 5_000_000, high_n_bits, 3); + let suffix = build_suffix(100, parent, 5_000_000, high_n_bits, 3); let suffix_head = suffix[0].clone(); let suffix_tail: Vec
= suffix[1..].to_vec(); let err = chain - .install_from_nipopow_proof(suffix_head, suffix_tail) + .install_from_nipopow_proof(vec![], suffix_head, suffix_tail) .unwrap_err(); assert!( matches!(err, ChainError::PowInvalid { .. } | ChainError::PowCompute(_)), @@ -3249,9 +3573,8 @@ mod light_client_install_tests { #[test] fn try_append_after_install_extends_tip() { - // Key test: post-install, a valid child of the suffix tip is - // accepted by try_append_no_pow. Difficulty check is skipped because - // light_client_mode is set. + // Post-install, a valid child of the suffix tip is accepted while the + // same difficulty predicate remains active in light-client mode. let config = testnet_config(); let mut chain = HeaderChain::new(config.clone()); let parent = BlockId(Digest32::from([0x77; 32])); @@ -3280,13 +3603,12 @@ mod light_client_install_tests { assert_eq!(chain.height(), 1003); } - // --- light_client_mode skip tests --- + // --- light-client difficulty tests --- #[test] - fn light_mode_accepts_wrong_n_bits_post_install() { - // In normal (full) mode, a child header with a deliberately wrong - // n_bits is rejected. After install, the same kind of mismatch is - // accepted because light mode skips the difficulty check entirely. + fn light_mode_rejects_wrong_n_bits_post_install() { + // Continuous bootstrap preserves enough history to keep the same + // difficulty predicate active after installation. let config = testnet_config(); // 1) Normal-mode rejection: build a chain at genesis, try a child @@ -3302,9 +3624,8 @@ mod light_client_install_tests { "full mode must reject wrong n_bits" ); - // 2) Light-mode acceptance: install a fresh chain from a synthetic - // 1-header suffix and try the same kind of "wrong n_bits" child. - // The difficulty check is skipped, so it should be accepted. + // 2) Light-mode rejection: install a fresh chain from a synthetic + // 1-header suffix and try the same kind of wrong child. let mut light_chain = HeaderChain::new(config.clone()); let parent = BlockId(Digest32::from([0x42; 32])); let head = make_chain_header_with_nonce( @@ -3319,9 +3640,8 @@ mod light_client_install_tests { light_chain .install_from_nipopow_proof_no_pow(head, vec![]) .expect("install"); - // Append a child with deliberately-wrong n_bits — would be a - // WrongDifficulty error in full mode. Light mode trusts whatever - // n_bits the network sent. + // Append a child with deliberately wrong n_bits. Continuous light + // mode must reject it under the same predicate as full mode. let child = make_chain_header_with_nonce( 5001, head_id, @@ -3329,11 +3649,11 @@ mod light_client_install_tests { config.initial_n_bits + 1, // wrong on purpose vec![0xC1; 8], ); - let result = light_chain + let err = light_chain .try_append_no_pow(child) - .expect("light mode must accept wrong n_bits"); - assert!(matches!(result, AppendResult::Extended)); - assert_eq!(light_chain.height(), 5001); + .expect_err("light mode must reject wrong n_bits"); + assert!(matches!(err, ChainError::WrongDifficulty { .. })); + assert_eq!(light_chain.height(), 5000); } // --- reorg_floor tests --- @@ -3816,8 +4136,8 @@ mod restore_tests { #[test] fn restore_single_entry_above_genesis_implies_light_mode() { // A chain whose store index starts above height 1 must have been - // installed from a NiPoPoW proof — the SPV difficulty skip is - // preserved across restart. + // installed from a NiPoPoW proof. Its sparse difficulty context is + // restored separately before the chain may accept successors. let entries = vec![(5_000_000u32, id_at(5_000_000))]; let chain = HeaderChain::restore(testnet_config(), entries) .expect("single-entry restore at h=5M must succeed"); diff --git a/src/bridge.rs b/src/bridge.rs index 2335765..a2cb3f0 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -12,6 +12,32 @@ use ergo_sync::{SyncChain, SyncStore, SyncTransport}; use sigma_ser::ScorexSerializable; use tokio::sync::{mpsc, Mutex}; +/// Restore the sparse difficulty anchors required by a light chain. A +/// best-chain index starting above genesis is not usable without the matching +/// versioned context record, so absence and corruption are hard errors. +pub fn restore_nipopow_difficulty_context_from_store( + chain: &mut HeaderChain, + store: &RedbModifierStore, +) -> Result<(), ChainError> { + if !chain.light_client_mode() { + return Ok(()); + } + let bytes = store + .chain_meta_get(enr_chain::NIPOPOW_DIFFICULTY_CONTEXT_META_KEY) + .map_err(|e| ChainError::Nipopow(format!("read NiPoPoW difficulty context: {e}")))? + .ok_or_else(|| { + ChainError::Nipopow( + "restored light chain is missing persisted NiPoPoW difficulty context".into(), + ) + })?; + let context = enr_chain::parse_nipopow_difficulty_context(&bytes)?; + chain.restore_nipopow_difficulty_context( + context.suffix_head_height, + context.suffix_head_id, + context.headers, + ) +} + /// Wraps `P2pNode` + event receiver to implement `SyncTransport`. pub struct P2pTransport { node: Arc, @@ -175,29 +201,55 @@ impl SyncChain for SharedChain { async fn install_nipopow_suffix( &self, + difficulty_headers: Vec
, suffix_head: Header, suffix_tail: Vec
, ) -> Result<(), ChainError> { let all_headers: Vec
= std::iter::once(suffix_head.clone()) .chain(suffix_tail.iter().cloned()) .collect(); - let installed = self - .chain - .lock() - .await - .install_from_nipopow_proof(suffix_head, suffix_tail)?; + let raw_headers = all_headers + .iter() + .map(|header| { + header + .scorex_serialize_bytes() + .map_err(|e| ChainError::Nipopow(format!("serialize installed header: {e}"))) + }) + .collect::, _>>()?; + let difficulty_context_bytes = + enr_chain::serialize_nipopow_difficulty_context(&suffix_head, &difficulty_headers)?; + let installed = self.chain.lock().await.install_from_nipopow_proof( + difficulty_headers, + suffix_head, + suffix_tail, + )?; // Persist each installed header with its real cumulative score so // the store's ScoreLoader can serve subsequent chain queries. // install_from_nipopow_proof returns InstalledHeader entries in // the same order as `all_headers`. - for (header, ih) in all_headers.iter().zip(installed.iter()) { - let raw = header.scorex_serialize_bytes() - .map_err(|e| ChainError::Nipopow(format!("re-serialize installed header: {e}")))?; + for ((header, raw), ih) in all_headers + .iter() + .zip(raw_headers.iter()) + .zip(installed.iter()) + { self.store - .put_header(&ih.id.0.0, ih.height, 0, &ih.score_be, &raw) + .put_header(&ih.id.0 .0, ih.height, 0, &ih.score_be, raw) .map_err(|e| ChainError::Nipopow(format!("persist installed header: {e}")))?; + debug_assert_eq!(header.id, ih.id); } + // This record is deliberately committed after every BEST_CHAIN write. + // A crash before it lands makes the next startup refuse light mode + // instead of continuing without authenticated difficulty history. + self.store + .chain_meta_put( + enr_chain::NIPOPOW_DIFFICULTY_CONTEXT_META_KEY, + &difficulty_context_bytes, + ) + .map_err(|e| ChainError::Nipopow(format!("persist NiPoPoW difficulty context: {e}")))?; + self.store + .flush() + .map_err(|e| ChainError::Nipopow(format!("flush NiPoPoW bootstrap state: {e}")))?; Ok(()) } @@ -399,3 +451,148 @@ impl SyncStore for SharedStore { } } } + +#[cfg(test)] +mod tests { + use super::*; + use enr_chain::ChainConfig; + use ergo_chain_types::{ADDigest, AutolykosSolution, Digest32, EcPoint, Votes}; + use sigma_ser::ScorexSerializable; + + fn synthetic_header(height: u32, parent_id: BlockId, n_bits: u32) -> Header { + let zero32 = Digest32::zero(); + let header = Header { + version: 2, + id: BlockId(Digest32::zero()), + parent_id, + ad_proofs_root: zero32, + state_root: ADDigest::zero(), + transaction_root: zero32, + timestamp: 1_000_000 + u64::from(height) * 50_000, + n_bits, + height, + extension_root: zero32, + autolykos_solution: AutolykosSolution { + miner_pk: Box::new(EcPoint::default()), + pow_onetime_pk: None, + nonce: height.to_be_bytes().repeat(2), + pow_distance: None, + }, + votes: Votes([0, 0, 0]), + unparsed_bytes: Box::new([]), + }; + Header::scorex_parse_bytes( + &header + .scorex_serialize_bytes() + .expect("serialize synthetic header"), + ) + .expect("parse canonical synthetic header") + } + + #[test] + fn restored_light_chain_fails_closed_when_difficulty_context_is_absent() { + let dir = tempfile::tempdir().expect("temporary store directory"); + let store = RedbModifierStore::new(&dir.path().join("modifiers.redb")) + .expect("open modifier store"); + let suffix_head_id = BlockId(Digest32::from([0x37; 32])); + let mut chain = HeaderChain::restore(ChainConfig::testnet(), [(375, suffix_head_id)]) + .expect("restore light-chain index"); + + let err = restore_nipopow_difficulty_context_from_store(&mut chain, &store) + .expect_err("light mode without persisted anchors must fail closed"); + assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("missing"))); + assert!(chain.nipopow_difficulty_headers().is_empty()); + } + + #[test] + fn restored_light_chain_fails_closed_when_difficulty_context_is_corrupt() { + let dir = tempfile::tempdir().expect("temporary store directory"); + let store = RedbModifierStore::new(&dir.path().join("modifiers.redb")) + .expect("open modifier store"); + store + .chain_meta_put(enr_chain::NIPOPOW_DIFFICULTY_CONTEXT_META_KEY, b"corrupt") + .expect("write corrupt context fixture"); + let suffix_head_id = BlockId(Digest32::from([0x37; 32])); + let mut chain = HeaderChain::restore(ChainConfig::testnet(), [(375, suffix_head_id)]) + .expect("restore light-chain index"); + + let err = restore_nipopow_difficulty_context_from_store(&mut chain, &store) + .expect_err("corrupt persisted anchors must fail closed"); + assert!(matches!(err, ChainError::Nipopow(_))); + assert!(chain.nipopow_difficulty_headers().is_empty()); + } + + #[test] + fn restored_full_chain_does_not_require_nipopow_context() { + let dir = tempfile::tempdir().expect("temporary store directory"); + let store = RedbModifierStore::new(&dir.path().join("modifiers.redb")) + .expect("open modifier store"); + store + .chain_meta_put( + enr_chain::NIPOPOW_DIFFICULTY_CONTEXT_META_KEY, + b"corrupt stale context", + ) + .expect("write stale context fixture"); + let genesis_id = BlockId(Digest32::from([0x01; 32])); + let mut chain = HeaderChain::restore(ChainConfig::testnet(), [(1, genesis_id)]) + .expect("restore full-chain index"); + + restore_nipopow_difficulty_context_from_store(&mut chain, &store) + .expect("full chain has no sparse-context dependency"); + assert!(!chain.light_client_mode()); + } + + #[tokio::test] + async fn continuous_install_persists_and_restores_bound_difficulty_context() { + let dir = tempfile::tempdir().expect("temporary store directory"); + let store = Arc::new( + RedbModifierStore::new(&dir.path().join("modifiers.redb")) + .expect("open modifier store"), + ); + let config = ChainConfig::testnet(); + let suffix_head = synthetic_header( + 375, + BlockId(Digest32::from([0x75; 32])), + config.initial_n_bits, + ); + let difficulty_headers = vec![ + synthetic_header( + 128, + BlockId(Digest32::from([0x80; 32])), + config.initial_n_bits, + ), + synthetic_header( + 256, + BlockId(Digest32::from([0x81; 32])), + config.initial_n_bits, + ), + ]; + let live_chain = Arc::new(Mutex::new(HeaderChain::new(config.clone()))); + let shared = SharedChain::new(live_chain, store.clone()); + + shared + .install_nipopow_suffix(difficulty_headers.clone(), suffix_head.clone(), Vec::new()) + .await + .expect("install and persist continuous proof state"); + + let raw_context = store + .chain_meta_get(enr_chain::NIPOPOW_DIFFICULTY_CONTEXT_META_KEY) + .expect("read context metadata") + .expect("context metadata exists"); + let persisted = enr_chain::parse_nipopow_difficulty_context(&raw_context) + .expect("parse persisted context"); + assert_eq!(persisted.suffix_head_height, suffix_head.height); + assert_eq!(persisted.suffix_head_id, suffix_head.id); + assert_eq!(persisted.headers, difficulty_headers); + + let entries = store + .best_chain_entries() + .expect("read persisted best chain") + .into_iter() + .map(|(height, id)| (height, BlockId(Digest32::from(id)))); + let mut restored = HeaderChain::restore(config, entries).expect("restore persisted suffix"); + restore_nipopow_difficulty_context_from_store(&mut restored, store.as_ref()) + .expect("restore persisted sparse context"); + assert_eq!(restored.nipopow_difficulty_headers(), difficulty_headers); + } +} diff --git a/src/lib.rs b/src/lib.rs index d06472a..5b79a78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,9 @@ pub mod snapshot_serve; pub mod snapshot_store; pub mod swap_reader; -pub use bridge::{P2pTransport, SharedChain, SharedStore}; +pub use bridge::{ + restore_nipopow_difficulty_context_from_store, P2pTransport, SharedChain, SharedStore, +}; pub use peer_storage_adapter::PeerStorageAdapter; pub use pipeline::ValidationPipeline; pub use swap_reader::SwappableReader; diff --git a/src/main.rs b/src/main.rs index 1f4869c..fc980fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,10 @@ use ergo_lib::ergotree_ir::serialization::SigmaSerializable; use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; use ergo_chain_types::EcPoint; use enr_store::{ModifierStore, RedbModifierStore}; -use ergo_node_rust::{P2pTransport, PeerStorageAdapter, SharedChain, SharedStore, ValidationPipeline}; +use ergo_node_rust::{ + restore_nipopow_difficulty_context_from_store, P2pTransport, PeerStorageAdapter, SharedChain, + SharedStore, ValidationPipeline, +}; use ergo_sync::{HeaderSync, SyncConfig, SyncStore}; use ergo_validation::{ApplyStateOutcome, BlockValidator, DigestValidator, UtxoValidator, ValidationError}; use serde::Deserialize; @@ -1451,6 +1454,8 @@ async fn main() -> Result<(), Box> { }); let mut chain = HeaderChain::restore(chain_config, restore_entries) .map_err(|e| format!("header chain restore failed: {e:?}"))?; + restore_nipopow_difficulty_context_from_store(&mut chain, store.as_ref()) + .map_err(|e| format!("NiPoPoW difficulty context restore failed: {e}"))?; match tip_id { Some(tip) => tracing::info!( headers = entry_count as u64, diff --git a/sync/src/light_bootstrap.rs b/sync/src/light_bootstrap.rs index 2843839..bfb2d44 100644 --- a/sync/src/light_bootstrap.rs +++ b/sync/src/light_bootstrap.rs @@ -228,6 +228,7 @@ pub async fn run_light_bootstrap( // Step 5: install the verifier's exact parsed suffix. Do not reconstruct // this boundary from a flattened header vector or a local k constant. let NipopowVerificationResult { + difficulty_headers, suffix_head, suffix_tail, .. @@ -235,12 +236,13 @@ pub async fn run_light_bootstrap( tracing::info!( suffix_head_height = suffix_head.height, + difficulty_header_count = difficulty_headers.len(), suffix_tail_len = suffix_tail.len(), "light bootstrap: installing suffix" ); chain - .install_nipopow_suffix(suffix_head, suffix_tail) + .install_nipopow_suffix(difficulty_headers, suffix_head, suffix_tail) .await .map_err(LightBootstrapError::InstallFailed)?; @@ -347,11 +349,13 @@ mod tests { suffix_head: Header, suffix_tail: Vec
, ) -> NipopowVerificationResult { + let difficulty_headers = prefix.first().cloned().into_iter().collect(); NipopowVerificationResult { m: P2P_NIPOPOW_M as u32, k: P2P_NIPOPOW_K as u32, - continuous: None, + continuous: Some(true), prefix, + difficulty_headers, suffix_head, suffix_tail, } @@ -427,6 +431,9 @@ mod tests { /// `(this_envelope, than_envelope, scripted_result)` triples. type ScriptedComparison = (Vec, Vec, CompareResult); + /// Exact authenticated context and suffix carried into installation. + type InstalledProof = (Vec
, Header, Vec
); + /// Mock chain that returns scripted verification and comparison results. struct MockChain { /// Map envelope body → verification result. @@ -434,7 +441,7 @@ mod tests { /// Pairwise comparison results: (this, that) → result. compare_results: Mutex>, comparison_calls: Mutex, Vec)>>, - installed: Mutex)>>, + installed: Mutex>, } impl MockChain { @@ -455,7 +462,7 @@ mod tests { self.compare_results.lock().unwrap().push((this, than, result)); } - fn installed(&self) -> Option<(Header, Vec
)> { + fn installed(&self) -> Option { self.installed.lock().unwrap().clone() } @@ -534,10 +541,11 @@ mod tests { async fn install_nipopow_suffix( &self, + difficulty_headers: Vec
, suffix_head: Header, suffix_tail: Vec
, ) -> Result<(), ChainError> { - *self.installed.lock().unwrap() = Some((suffix_head, suffix_tail)); + *self.installed.lock().unwrap() = Some((difficulty_headers, suffix_head, suffix_tail)); Ok(()) } @@ -638,7 +646,7 @@ mod tests { assert!(result.is_ok()); assert!(chain.comparison_calls().is_empty()); assert_eq!( - chain.installed().expect("valid proof installed").0, + chain.installed().expect("valid proof installed").1, expected_head ); } @@ -680,7 +688,7 @@ mod tests { assert!(result.is_ok()); assert_eq!(chain.comparison_calls(), vec![(body_c, body_a)]); assert_eq!( - chain.installed().expect("best proof installed").0, + chain.installed().expect("best proof installed").1, expected_head ); } @@ -690,6 +698,7 @@ mod tests { let peer_a = PeerId(1); let body_a = vec![0xaa]; let proof = standard_verification_result(); + let expected_context = proof.difficulty_headers.clone(); let expected_head = proof.suffix_head.clone(); let expected_tail = proof.suffix_tail.clone(); let chain = MockChain::new(); @@ -702,7 +711,8 @@ mod tests { let result = run_light_bootstrap(&mut transport, &chain).await; assert!(result.is_ok()); - let (head, tail) = chain.installed().expect("should have installed"); + let (context, head, tail) = chain.installed().expect("should have installed"); + assert_eq!(context, expected_context); assert_eq!(head, expected_head); assert_eq!(tail, expected_tail); } @@ -876,6 +886,7 @@ mod tests { let exact_head = fake_header(200); let exact_tail = vec![fake_header(201), fake_header(202)]; let proof = verification_result(fake_headers(15), exact_head.clone(), exact_tail.clone()); + let exact_context = proof.difficulty_headers.clone(); let chain = MockChain::new(); chain.add_verify(body_a.clone(), VerifyResult::ok(proof)); @@ -886,7 +897,10 @@ mod tests { let result = run_light_bootstrap(&mut transport, &chain).await; assert!(result.is_ok()); - assert_eq!(chain.installed(), Some((exact_head, exact_tail))); + assert_eq!( + chain.installed(), + Some((exact_context, exact_head, exact_tail)) + ); } #[tokio::test] @@ -929,8 +943,13 @@ mod tests { unimplemented!() } async fn install_nipopow_suffix( - &self, _h: Header, _t: Vec
, - ) -> Result<(), ChainError> { unimplemented!() } + &self, + _d: Vec
, + _h: Header, + _t: Vec
, + ) -> Result<(), ChainError> { + unimplemented!() + } async fn voting_length(&self) -> u32 { 1024 } } diff --git a/sync/src/state.rs b/sync/src/state.rs index e14c8cb..70f3a38 100644 --- a/sync/src/state.rs +++ b/sync/src/state.rs @@ -2650,6 +2650,7 @@ mod shutdown_flush_tests { async fn install_nipopow_suffix( &self, + _difficulty_headers: Vec
, _suffix_head: Header, _suffix_tail: Vec
, ) -> Result<(), ChainError> { @@ -3035,6 +3036,7 @@ mod blocks_to_keep_tests { async fn install_nipopow_suffix( &self, + _difficulty_headers: Vec
, _suffix_head: Header, _suffix_tail: Vec
, ) -> Result<(), ChainError> { @@ -3618,6 +3620,7 @@ mod sweep_resume_tests { } async fn install_nipopow_suffix( &self, + _difficulty_headers: Vec
, _suffix_head: Header, _suffix_tail: Vec
, ) -> Result<(), ChainError> { @@ -4135,6 +4138,7 @@ mod serve_continuation_tests { async fn install_nipopow_suffix( &self, + _difficulty_headers: Vec
, _suffix_head: Header, _suffix_tail: Vec
, ) -> Result<(), ChainError> { diff --git a/sync/src/traits.rs b/sync/src/traits.rs index 5350a53..881ac13 100644 --- a/sync/src/traits.rs +++ b/sync/src/traits.rs @@ -227,6 +227,7 @@ pub trait SyncChain { /// NOT re-verify. fn install_nipopow_suffix( &self, + difficulty_headers: Vec
, suffix_head: Header, suffix_tail: Vec
, ) -> impl std::future::Future> + Send; diff --git a/tests/nipopow_serve_integration.rs b/tests/nipopow_serve_integration.rs index 02811e6..c163be6 100644 --- a/tests/nipopow_serve_integration.rs +++ b/tests/nipopow_serve_integration.rs @@ -67,6 +67,8 @@ fn testnet_verification_context(m: u32, k: u32) -> enr_chain::NipopowVerificatio expected_genesis_id: TESTNET_GENESIS_ID .parse() .expect("canonical testnet genesis ID must parse"), + difficulty_epoch_length: 128, + use_last_epochs: 8, } } From 9f4bc2bebb828decbcb1c45481289373e547de9f Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:49:09 +0200 Subject: [PATCH 10/13] docs(nipopow): define continuous bootstrap contract --- facts/chain.md | 94 ++++++++++++++++++++++++++------------------------ facts/store.md | 5 +++ facts/sync.md | 62 +++++++++++++++++++++------------ 3 files changed, 93 insertions(+), 68 deletions(-) diff --git a/facts/chain.md b/facts/chain.md index 93f5365..8953a47 100644 --- a/facts/chain.md +++ b/facts/chain.md @@ -180,8 +180,11 @@ Where `I: IntoIterator`. - `base_height = first entry's height` (or `None` if `entries` is empty). - `height() = last entry's height` (or `0` if empty). - `light_client_mode = (base_height.unwrap_or(1) > 1)` — a chain - starting above height 1 must have been installed from a NiPoPoW - proof and the SPV difficulty skip is preserved. + starting above height 1 must have been installed from a NiPoPoW proof. + Its sparse difficulty context is initially unready. The integrator MUST + load the versioned context record and call + `restore_nipopow_difficulty_context` before allowing any successor or + reorg; absence, corruption, or a suffix-head mismatch is fatal. - `active_parameters` / `active_proposed_update_bytes` are at construction defaults. The integrator must call `recompute_active_parameters_from_storage(height())` after @@ -1053,17 +1056,11 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo the JVM's `PoPowAlgosWithDBSpec` takes with `DefaultFakePowScheme`. Use `prove_with_reader` for any production path; the in-memory `prove` is only appropriate for test scenarios with synthetic chains. -- **Non-scope**: JVM's `continuous = true` mode (which interleaves - difficulty-recalculation-boundary headers into the prefix so that - light clients can self-validate difficulty for blocks after the - suffix) is NOT supported. sigma-rust's `NipopowProof` struct has no - `continuous` field — adding it requires a separate change to the - struct, serializer, and on-wire format. `build_nipopow_proof` - produces non-continuous proofs. JVM peers applying non-continuous - proofs still succeed (`applyPopowProof` doesn't strictly require the - flag); they just can't self-validate post-suffix difficulty until - they sync more headers. This is fine for P2P serve. Tracked as - follow-up in the roadmap. +- **Continuous mode**: the producer mirrors the JVM database prover. It adds + every historical height returned by `heightsForNextRecalculation` that is + below the suffix head to the proof prefix, validates the enriched proof, + and appends the JVM terminal byte `1`. The sigma-rust 0.28 core type remains + unchanged; the node adapter owns this terminal byte and enrichment. - **Genesis (height 1) special case**: The genesis block's interlinks vector is canonical and MUST NOT be read from the extension loader. The **reader implementation** (not `build_nipopow_proof` directly) is @@ -1104,13 +1101,16 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo - **Precondition**: `bytes` is the inner NiPoPoW proof payload (the main crate has already stripped the message envelope). `context` carries the - exact requested `m` and `k` plus the deployment's configured genesis ID. + exact requested `m` and `k`, the deployment's configured genesis ID, and + the configured difficulty epoch/use-last-epochs values. - **Postcondition**: the proof has passed canonical sigma-rust validation, matches the requested security parameters, starts at the configured genesis, and every extracted header passes `verify_pow`. The returned - value preserves the parser's exact `prefix` / `suffix_head` / - `suffix_tail` boundary so the installer never reconstructs it from a - flattened vector. + payload is explicitly continuous (`terminal byte == 1`), and every required + historical difficulty header is present in the authenticated prefix. The + returned value preserves the parser's exact `prefix` / `suffix_head` / + `suffix_tail` boundary plus the exact sparse difficulty subset so the + installer never reconstructs either from a flattened vector. - **Validation checks**: 1. The Scorex core parses from an explicitly tracked cursor. No remainder is accepted except one JVM terminal mode byte (`0` or `1`). @@ -1118,7 +1118,10 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo connection, interlinks-proof, height, and suffix-cardinality invariants. 3. Proof `m` and `k` equal the values in `context`. 4. The first proof-chain header equals `context.expected_genesis_id`. - 5. Every prefix, suffix-head, and suffix-tail header passes `verify_pow`. + 5. The terminal mode is exactly `1`; absent and mode `0` remain parseable + for diagnostics but cannot authorize bootstrap. + 6. Every required historical difficulty height is present in the prefix. + 7. Every prefix, suffix-head, and suffix-tail header passes `verify_pow`. - **Does NOT** apply the proof to local chain state. Chain mutation remains an explicit consumer operation. @@ -1128,9 +1131,11 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo pub struct NipopowVerificationResult { pub m: u32, pub k: u32, - /// JVM terminal mode, or `None` for a Rust-core-only payload. + /// JVM terminal mode; authorizing verification returns `Some(true)`. pub continuous: Option, pub prefix: Vec
, + /// Exact authenticated subset required by difficulty recalculation. + pub difficulty_headers: Vec
, pub suffix_head: Header, pub suffix_tail: Vec
, } @@ -1142,7 +1147,7 @@ segments rather than stored as duplicable metadata. The separate unsolicited code-91 log path may use it, but bootstrap selection and installation must not. -### `install_from_nipopow_proof(suffix_head: Header, suffix_tail: Vec
) -> Result>` +### `install_from_nipopow_proof(difficulty_headers: Vec
, suffix_head: Header, suffix_tail: Vec
) -> Result>` Install a verified NiPoPoW proof's suffix as the chain's starting point for light-client mode. @@ -1155,11 +1160,11 @@ pub struct InstalledHeader { } ``` -- **Precondition**: Chain is empty (`is_empty() == true`). The headers in - `suffix_head` + `suffix_tail` MUST already have been validated by the - caller via `verify_nipopow_proof_bytes`. This function does NOT re-verify - the proof; it assumes the caller has done so and is installing the - trusted suffix. +- **Precondition**: Chain is empty (`is_empty() == true`). All three arguments + MUST come directly from the same `NipopowVerificationResult`. The installer + does not re-run proof scoring or interlink validation, but it independently + checks the exact configured anchor-height set, context/suffix PoW, suffix + linkage, and every suffix `n_bits` value before returning success. - **Postcondition on Ok**: Chain now contains `suffix_head` followed by every header in `suffix_tail`, in order. `tip()` returns the last header in `suffix_tail` (or `suffix_head` if `suffix_tail` is empty). `height()` @@ -1172,7 +1177,9 @@ pub struct InstalledHeader { header). The integrator persists these by calling `store.put_header(id, height, fork=0, score=score_be, data=...)` for each — without this write the store's score loader will return `None` - on later queries and the chain's score-dependent paths break. + on later queries and the chain's score-dependent paths break. The sparse + context is retained separately from best-chain state and never contributes + a score or becomes a reorg anchor. - **Postcondition on Err**: Chain is unchanged (rolled back). Possible errors: - Chain not empty. - `suffix_head.parent_id` is anything other than what the caller expects @@ -1180,18 +1187,14 @@ pub struct InstalledHeader { (the suffix head is rarely actually genesis), but it MUST be self- consistent with `suffix_tail` (each header's `parent_id` is the previous header's `id`). - - Any header's PoW fails `verify_pow`. - - Note: the `expected_difficulty` check is NOT performed on suffix - headers, and is permanently disabled for `try_append` after install - via the `light_client_mode` flag. See the "Light-client difficulty - checking" invariant below for the full rationale — light clients - cannot independently recompute difficulty and must trust the - `n_bits` values in incoming headers, validated only by self-contained - PoW verification. + - Required anchor count/height mismatch, duplicate anchor, or missing anchor. + - Any context or suffix header's PoW fails `verify_pow`. + - Any suffix header's `n_bits` differs from `expected_difficulty` computed + with the sparse anchors and already-installed suffix history. - **Behavior**: - - Sets `light_client_mode = true` on the chain — this flag persists for - the chain's lifetime and disables the difficulty-target check on all - subsequent `try_append` calls (see invariant below). + - Sets `light_client_mode = true` and marks the sparse difficulty context + ready. The flag controls the reorg floor and restart contract; it does not + disable difficulty-target checks. - `suffix_head` is pushed via the same internal `push_header` path used by `try_append`'s tip-extension branch, but the genesis-validation check is bypassed. @@ -1204,7 +1207,7 @@ pub struct InstalledHeader { scores are returned to the integrator in the `Vec` result; the chain itself does NOT persist them, only emits them. - For each header in `suffix_tail`, validate parent linkage (`parent_id == - previous.id`), validate PoW, and push. Skip the difficulty-target check. + previous.id`), expected difficulty, and PoW before pushing. - `active_parameters` is left at `default_parameters(network)` — light clients have no source for voted parameters because they don't download block extensions. See "Light-client parameter limitation" below. @@ -1272,14 +1275,13 @@ peer on demand. Tracked as a follow-up. reader); it is consensus-critical because real genesis extensions are empty and cannot produce the canonical `interlinks = [genesis_id]` vector via the loader path. -- **`light_client_mode` flag skips `expected_difficulty`.** `HeaderChain` - gains an internal `light_client_mode: bool` flag, set to `true` during - `install_from_nipopow_proof` and `false` otherwise. When true, - `validate_child` and `validate_child_no_pow` skip the - `expected_difficulty` check. PoW verification (`verify_pow`) and - parent-linkage checks remain in force. Standard SPV behavior — light - clients can't recompute `expected_difficulty` because they don't have - the historical epoch boundaries the recalc depends on. +- **Continuous difficulty context is mandatory.** `light_client_mode` never + relaxes `expected_difficulty`. The exact JVM recalculation heights below the + suffix head are retained in a sparse authenticated map; normal suffix/best- + chain headers satisfy the remaining lookups. A restored light chain starts + with `nipopow_difficulty_context_ready = false` and rejects every successor + and reorg until the versioned, suffix-head-bound record is parsed and + validated. Missing lookup heights are errors, never silently omitted. - `reorg_floor()` is consulted before any reorg execution. Reorgs whose fork point falls below the floor are rejected. diff --git a/facts/store.md b/facts/store.md index fb52d22..5222a02 100644 --- a/facts/store.md +++ b/facts/store.md @@ -396,6 +396,11 @@ Stable keys, opaque to the store, consumed by higher-level crates |---|---|---| | `b"scores_migrated_v1"` | `[1u8]` once migration completes; absent before | Empty-placeholder → real scores migration | | `b"validated_height"` | 4-byte big-endian `u32`: highest height at which state.redb was flushed with `Durability::Immediate`. Absent ⇒ fresh install or pre-handshake upgrade. | sync's flush pair (see `facts/sync.md` "Cross-DB Durability Handshake") | +| `b"nipopow_difficulty_context_v1"` | Versioned, bounded record containing the installed suffix-head height/ID and its authenticated sparse difficulty headers. Written after all suffix `BEST_CHAIN` entries and flushed before bootstrap success. Required on every light-chain restore; absent, malformed, oversized, or stale bindings fail closed. | `SharedChain::install_nipopow_suffix` | + +Pre-metadata light-client databases have no authoritative source for this +record and are not upgraded in place. Initialize a new light-client data +directory and complete a fresh proof bootstrap instead. ## Peer DB key encoding diff --git a/facts/sync.md b/facts/sync.md index 3bcfa38..eff2a5a 100644 --- a/facts/sync.md +++ b/facts/sync.md @@ -692,26 +692,39 @@ State machine: 4. **Verify each response** through `SyncChain::verify_nipopow_envelope`. The bridge strips the envelope and calls context-bound - `enr_chain::verify_nipopow_proof_bytes` with the exact requested `m/k` and - configured genesis. Failed verification marks that response hostile and - cannot add a candidate or mutate the chain. + `enr_chain::verify_nipopow_proof_bytes` with the exact requested `m/k`, + configured genesis, and difficulty epoch context. Only terminal mode `1` + with every required authenticated difficulty header can authorize + bootstrap. Failed verification marks that response hostile and cannot add + a candidate or mutate the chain. 5. **Select among verified candidates only.** Multiple candidates are compared pairwise through `NipopowProof::is_better_than`; all have already passed the same request/genesis context. A comparison error skips that challenger and retains the incumbent. -6. **Install the selected result's exact parsed suffix** into the local - `HeaderChain`. `NipopowVerificationResult` carries `prefix`, `suffix_head`, - and `suffix_tail` separately. The bootstrap discards the prefix and passes - the two suffix fields directly to `install_from_nipopow_proof`; it never - reconstructs the boundary from `headers.len() - k`. - -7. **Transition to normal tip-following sync** via the existing +6. **Install the selected result's exact parsed suffix and difficulty + context** into the local `HeaderChain`. `NipopowVerificationResult` carries + `difficulty_headers`, `suffix_head`, and `suffix_tail` separately. The + bootstrap passes these fields directly to `install_from_nipopow_proof`; it + never reconstructs the boundary or anchor set from a flattened vector. + +7. **Persist and flush the restart dependency.** `SharedChain` writes every + installed suffix header first, then writes the versioned, bounded, + suffix-head-bound `nipopow_difficulty_context_v1` record and flushes the + modifier store. If the final record is absent or corrupt after interruption, + the next startup refuses light mode instead of continuing without anchors. + The in-memory install and these store writes are not one cross-layer + transaction: a write failure is fatal to the current sync run and does not + roll the in-memory chain back. The missing or incomplete metadata then makes + restart fail closed. Supporting retry in the same process would require a + separate atomic-install design. + +8. **Transition to normal tip-following sync** via the existing `sync_from_peer` loop. From here on out, light mode behaves like full mode minus block bodies: the sync machine sends SyncInfo, receives - header Inv, requests headers, validates them via `try_append`, and - advances the tip. + header Inv, requests headers, validates them (including expected + difficulty) via `try_append`, and advances the tip. ### `LightBootstrapError` @@ -732,18 +745,23 @@ for first release, terminate. ### Bootstrap invariants - **Shared verification context**: every candidate is verified against the - same requested `m=6`, `k=10`, and configured genesis before comparison. - A failed candidate never enters pairwise selection. + same requested `m=6`, `k=10`, configured genesis, and difficulty epoch + settings before comparison. A failed candidate never enters pairwise + selection. - **Lossless install boundary**: selection retains the typed verification - result, and installation consumes its exact `suffix_head` and - `suffix_tail`. No consumer re-parses or re-splits a flattened header list. -- **No restart-resume state**: bootstrap is one-shot and re-runs from - scratch on every restart where `chain.is_empty()`. Once the chain is - installed, subsequent restarts skip bootstrap entirely (chain is loaded - from store and is non-empty). There is no partial-bootstrap state that - needs persistence — the operation is atomic. + result, and installation consumes its exact `difficulty_headers`, + `suffix_head`, and `suffix_tail`. No consumer re-parses or re-splits a + flattened header list. +- **Restart is fail-closed**: bootstrap is one-shot when `chain.is_empty()`. + A non-empty restored light chain must also restore the matching versioned + difficulty context before the sync task is constructed. Missing/corrupt + context or a suffix-head height/ID mismatch aborts startup. + Light-client databases created before this metadata existed are intentionally + not migrated in place; recovery is a new light-client data directory followed + by a fresh proof bootstrap. - **Proof bytes are not archived** after install. `SharedChain` persists the - installed suffix headers through the normal header-store path. + installed suffix headers through the normal header-store path and only the + bounded difficulty subset in `chain_meta`. ### Trust model From 3a9d804c35ca6bf0e61dded1f6198d707d96c641 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:27:17 +0200 Subject: [PATCH 11/13] fix(nipopow): disable unauthenticated v1 bootstrap --- chain/src/chain.rs | 38 +++++----- chain/src/error.rs | 8 ++ chain/src/nipopow_proof.rs | 84 ++++++++++++++------- chain/src/state_type.rs | 7 +- facts/chain.md | 115 ++++++++++++++--------------- facts/nipopow.md | 84 ++++++++++----------- facts/store.md | 8 +- facts/sync.md | 95 +++++++++--------------- src/bridge.rs | 3 +- src/main.rs | 14 ++-- sync/src/light_bootstrap.rs | 57 +++++++++++--- sync/src/state.rs | 12 +-- sync/src/traits.rs | 20 ++--- tests/nipopow_serve_integration.rs | 80 +++++++++++--------- 14 files changed, 343 insertions(+), 282 deletions(-) diff --git a/chain/src/chain.rs b/chain/src/chain.rs index a9cdc33..a18c7d5 100644 --- a/chain/src/chain.rs +++ b/chain/src/chain.rs @@ -100,9 +100,9 @@ pub struct HeaderChain { /// source for [`Self::height`] / [`Self::len`] — chain length is /// `by_id.len()` and the tip height is `base_height + by_id.len() - 1`. by_id: HashMap, - /// Sparse, authenticated epoch-boundary headers carried by a continuous - /// NiPoPoW proof. They are difficulty context only: never best-chain - /// entries, never score contributors, and never reorg anchors. + /// Legacy V1 sparse epoch-boundary headers selected by height. They are + /// difficulty-context mechanics only: never best-chain entries, score + /// contributors, reorg anchors, or proof of branch membership. nipopow_difficulty_headers: BTreeMap, /// A restored light chain cannot accept successors or reorgs until its /// suffix-bound sparse context has been validated. @@ -143,8 +143,9 @@ pub struct HeaderChain { extension_loader: Option, /// Set to `true` when the best-chain origin was installed from a NiPoPoW /// suffix. It controls the reorg floor and restart contract only; it does - /// not relax expected-difficulty validation. Sparse authenticated headers - /// above supply the pre-install recalculation inputs. + /// not relax expected-difficulty validation. The legacy V1 bootstrap + /// verifier is disabled because the sparse headers above are not + /// branch-authenticated. light_client_mode: bool, /// Lazy header/score store. After v0.5.0 this is the sole source /// of truth for both header and cumulative-score reads at heights @@ -374,9 +375,9 @@ impl HeaderChain { Some(header) } - /// Header lookup used only by difficulty recalculation. Continuous - /// NiPoPoW anchors below the install boundary are not part of the best - /// chain, but are authenticated context for this one predicate. + /// Header lookup used only by difficulty recalculation. Legacy V1 anchors + /// below the install boundary are not part of the best chain and are not + /// branch-authenticated; the public V1 bootstrap verifier is disabled. pub(crate) fn difficulty_header_at(&self, height: u32) -> Option
{ self.header_at(height) .or_else(|| self.nipopow_difficulty_headers.get(&height).cloned()) @@ -992,15 +993,17 @@ impl HeaderChain { // --- Light-client install --- - /// Install a verified NiPoPoW proof's suffix as the chain's starting + /// Install an externally authorized NiPoPoW suffix as the chain's starting /// point for light-client mode. /// /// **Precondition**: chain is empty ([`Self::is_empty`] returns `true`). /// `difficulty_headers`, `suffix_head`, and `suffix_tail` MUST come from - /// the same typed result already validated by the caller via - /// [`crate::nipopow_proof::verify_nipopow_proof_bytes`]. This function - /// does NOT re-verify the proof; it assumes the caller has done so and - /// is installing the trusted suffix. + /// one branch-authenticated format and authorization result. This function + /// does NOT verify branch membership. Legacy V1 cannot satisfy this + /// precondition and [`crate::nipopow_proof::verify_nipopow_proof_bytes`] + /// always returns [`ChainError::NipopowBootstrapDisabled`]. The method is + /// retained as the install seam for a future authenticated format and for + /// isolated install-mechanics tests. /// /// **Postcondition on Ok**: chain contains `suffix_head` followed by /// every header in `suffix_tail`, in order. `tip()` returns the last @@ -1356,9 +1359,9 @@ impl HeaderChain { self.base_height.unwrap_or(1) } - /// Whether this chain originated from a NiPoPoW suffix and is running - /// without block-body or transaction validation. Header difficulty remains - /// fully checked using the authenticated sparse context. + /// Whether this chain originated from an externally authorized NiPoPoW + /// suffix and is running without block-body or transaction validation. + /// Legacy V1 cannot enter this mode through the public verifier. pub fn light_client_mode(&self) -> bool { self.light_client_mode } @@ -1980,7 +1983,8 @@ impl HeaderChain { got: header.timestamp, }); } - // Light mode uses authenticated sparse pre-install anchors here. + // This validates post-install difficulty mechanics. Legacy V1 cannot + // reach installation because its sparse anchors lack branch proofs. let expected_n_bits = crate::difficulty::expected_difficulty(&tip, self)?; if header.n_bits != expected_n_bits { return Err(ChainError::WrongDifficulty { diff --git a/chain/src/error.rs b/chain/src/error.rs index 2f343ca..8bcb138 100644 --- a/chain/src/error.rs +++ b/chain/src/error.rs @@ -81,6 +81,14 @@ pub enum ChainError { #[error("nipopow error: {0}")] Nipopow(String), + /// Legacy NiPoPoW V1 proofs do not authenticate sparse difficulty + /// headers as members of the proved branch, so they cannot authorize a + /// variable-difficulty bootstrap installation. + #[error( + "NiPoPoW V1 bootstrap disabled: sparse difficulty headers are not branch-authenticated" + )] + NipopowBootstrapDisabled, + /// `install_from_nipopow_proof` was called on a chain that already /// contains headers. The install API only accepts an empty chain. #[error("chain not empty: install_from_nipopow_proof requires is_empty()")] diff --git a/chain/src/nipopow_proof.rs b/chain/src/nipopow_proof.rs index 11fab59..1e49dbb 100644 --- a/chain/src/nipopow_proof.rs +++ b/chain/src/nipopow_proof.rs @@ -1,9 +1,9 @@ -//! NiPoPoW proof construction and verification (Phase 6). +//! NiPoPoW proof construction, inspection, and bootstrap authorization. //! -//! Wraps `ergo-nipopow` for build/verify on the local header chain. Received -//! proofs are bound to their request parameters and configured genesis before -//! their exact prefix/suffix split is returned to the light-bootstrap layer. -//! Chain-state mutation remains the responsibility of that consumer. +//! Wraps `ergo-nipopow` for build and bounded inspection on the local header +//! chain. Legacy V1 proofs cannot authorize bootstrap because their sparse +//! difficulty headers are not branch-authenticated. Chain-state mutation +//! remains the responsibility of a separately authorized consumer. //! //! JVM reference: //! - `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala` @@ -24,8 +24,9 @@ use crate::error::ChainError; /// caps the proof size for sanity. pub const MAX_M_K: u32 = 256; -/// Versioned modifier-store key for the authenticated sparse difficulty -/// context carried by a continuous NiPoPoW proof. +/// Versioned modifier-store key for the legacy V1 sparse difficulty context. +/// The codec is retained for compatibility and future migration work; V1 +/// bootstrap authorization is disabled. pub const NIPOPOW_DIFFICULTY_CONTEXT_META_KEY: &[u8] = b"nipopow_difficulty_context_v1"; const NIPOPOW_DIFFICULTY_CONTEXT_MAGIC: [u8; 4] = *b"NDCX"; @@ -40,9 +41,10 @@ const NIPOPOW_DIFFICULTY_CONTEXT_FIXED_BYTES: usize = 4 + 1 + 4 + 32 + 4; const MAX_NIPOPOW_DIFFICULTY_CONTEXT_BYTES: usize = NIPOPOW_DIFFICULTY_CONTEXT_FIXED_BYTES + MAX_NIPOPOW_DIFFICULTY_CONTEXT_HEADERS * (4 + MAX_NIPOPOW_DIFFICULTY_HEADER_BYTES); -/// Decoded, versioned persistence record for continuous-proof difficulty -/// anchors. The suffix-head binding prevents stale context from a previous -/// bootstrap at the same database path being applied to another chain. +/// Decoded, versioned persistence record for legacy V1 difficulty anchors. +/// The suffix-head binding prevents stale context from a previous bootstrap +/// at the same database path being applied to another chain, but does not +/// prove branch membership. #[derive(Debug, Clone, Eq, PartialEq)] pub struct PersistedNipopowDifficultyContext { pub suffix_head_height: u32, @@ -50,8 +52,9 @@ pub struct PersistedNipopowDifficultyContext { pub headers: Vec
, } -/// Serialize the small authenticated header set needed for future difficulty -/// recalculations after a continuous NiPoPoW bootstrap. +/// Serialize the small legacy V1 header set selected for future difficulty +/// recalculations. This storage binding does not prove branch membership and +/// must not authorize V1 bootstrap installation. pub fn serialize_nipopow_difficulty_context( suffix_head: &Header, headers: &[Header], @@ -337,8 +340,9 @@ pub struct NipopowVerificationResult { /// JVM terminal mode, or `None` for a Rust-core-only payload. pub continuous: Option, pub prefix: Vec
, - /// Authenticated sparse history required to validate difficulty after - /// installing the suffix. This is a subset of `prefix`. + /// Legacy V1 sparse difficulty candidates selected by height from + /// `prefix`. V1 does not authenticate their membership in the proved + /// branch, so this field must not authorize bootstrap installation. pub difficulty_headers: Vec
, pub suffix_head: Header, pub suffix_tail: Vec
, @@ -602,10 +606,12 @@ pub fn popow_header_by_id( /// payload only). Returns `true` if `a` represents a better chain than `b` /// per KMZ17 §4.3. /// -/// Both byte slices must already have passed [`verify_nipopow_proof_bytes`] -/// against the same [`NipopowVerificationContext`]. This function validates -/// both proofs again, and sigma-rust rejects unequal `m`/`k` parameters during -/// comparison. Parse or validation failure on either side returns an error. +/// This is a non-authorizing scoring primitive. Callers must first obtain a +/// branch-authenticated authorization result for both proofs under the same +/// context. Legacy V1 cannot satisfy that precondition because +/// [`verify_nipopow_proof_bytes`] always fails closed. This function validates +/// both structures again, and sigma-rust rejects unequal `m`/`k` parameters +/// during comparison. Parse or validation failure returns an error. pub fn compare_nipopow_proof_bytes(a: &[u8], b: &[u8]) -> Result { let (proof_a, _) = parse_received_nipopow_proof(a) .map_err(|e| ChainError::Nipopow(format!("parse proof A failed: {e}")))?; @@ -669,6 +675,7 @@ fn verify_nipopow_headers_pow(proof: &NipopowProof) -> Result<(), ChainError> { Ok(()) } +#[cfg(test)] fn continuous_difficulty_headers( proof: &NipopowProof, context: &NipopowVerificationContext, @@ -700,23 +707,23 @@ fn continuous_difficulty_headers( Ok(headers) } -/// Verify a NiPoPoW proof from raw bytes. +/// Verify a NiPoPoW proof for bootstrap authorization. /// /// **Precondition**: `bytes` is the inner NiPoPoW proof payload (the main /// crate has stripped any P2P message envelope). /// -/// The proof must pass canonical sigma-rust validation, match `context` -/// exactly, bind its first proof-chain header to the configured genesis, and -/// pass [`crate::verify_pow`] for every extracted header. -/// -/// Does NOT touch chain state. Does NOT apply the proof to local chain. -/// The returned result preserves the parsed prefix and suffix boundary so an -/// installer never has to reconstruct it from a flattened header vector. +/// Legacy V1 proofs cannot authorize bootstrap because their sparse +/// difficulty headers are not authenticated as members of the proved branch. +/// This entry point therefore validates the local context and then fails with +/// [`ChainError::NipopowBootstrapDisabled`]. Use +/// [`inspect_nipopow_proof_bytes`] for bounded parsing and diagnostic PoW +/// inspection; inspection results cannot authorize selection or installation. pub fn verify_nipopow_proof_bytes( - bytes: &[u8], + _bytes: &[u8], context: &NipopowVerificationContext, ) -> Result { - verify_inner(bytes, context, true) + context.validate()?; + Err(ChainError::NipopowBootstrapDisabled) } /// Inspect a proof for diagnostics without binding it to a request or genesis. @@ -753,6 +760,7 @@ pub(crate) fn verify_nipopow_proof_bytes_no_pow( verify_inner(bytes, context, false) } +#[cfg(test)] fn verify_inner( bytes: &[u8], context: &NipopowVerificationContext, @@ -1154,6 +1162,26 @@ mod tests { assert!(matches!(err, ChainError::Nipopow(ref msg) if msg.contains("exceeds maximum"))); } + #[test] + fn bootstrap_verifier_rejects_structurally_valid_v1_as_unauthenticated() { + let chain = build_chain_with_interlinks(300); + let bytes = build_nipopow_proof(&chain, 6, 10, None).expect("build V1 proof"); + let context = verification_context(&chain, 6, 10); + + let err = verify_nipopow_proof_bytes(&bytes, &context) + .expect_err("V1 must not authorize bootstrap installation"); + assert!(matches!(&err, ChainError::NipopowBootstrapDisabled)); + let message = err.to_string(); + assert!( + message.contains("bootstrap disabled"), + "unexpected fail-close error: {message}" + ); + assert!( + message.contains("not branch-authenticated"), + "unexpected fail-close error: {message}" + ); + } + #[test] fn verify_parses_optional_jvm_terminal_mode() { let chain = build_chain_with_interlinks(20); diff --git a/chain/src/state_type.rs b/chain/src/state_type.rs index b88da71..61bdf47 100644 --- a/chain/src/state_type.rs +++ b/chain/src/state_type.rs @@ -18,9 +18,10 @@ pub enum StateType { /// transitions using authenticated dictionary proofs (AD proofs) provided /// in each block. Requires downloading AD proofs from peers. Digest, - /// NiPoPoW light-client mode. Downloads NO block bodies. Bootstraps the - /// header chain from a verified NiPoPoW proof's suffix and follows the - /// tip thereafter. No transaction validation runs in this mode. + /// NiPoPoW light-client mode. Downloads NO block bodies and runs no + /// transaction validation. New legacy V1 bootstrap attempts fail closed; + /// a future authenticated format is required to establish the chain + /// origin before tip following can start. Light, } diff --git a/facts/chain.md b/facts/chain.md index 8953a47..0c48e68 100644 --- a/facts/chain.md +++ b/facts/chain.md @@ -304,9 +304,10 @@ links are consistent. Does not need AD proofs. - `Digest` — maintain only the AVL+ tree root hash, validate state transitions via authenticated dictionary proofs (AD proofs). Requires downloading AD proofs from peers. -- `Light` — NiPoPoW light-client mode. Downloads NO block bodies. Bootstraps the - header chain from a verified NiPoPoW proof's suffix and follows the tip - thereafter. No transaction validation runs in this mode. +- `Light` — NiPoPoW light-client mode. Downloads NO block bodies and runs no + transaction validation. New legacy V1 bootstraps are currently disabled; + the retained tip-following machinery requires a future externally + authorized chain origin. Mirrors JVM's `StateType` enum (`Utxo`/`Digest`). The `Light` variant is a Rust-side addition that has no direct JVM analog — JVM expresses light-client @@ -1003,14 +1004,16 @@ boundary's `compute_expected_parameters` call — which is what JVM BOTH `active_parameters` AND `active_proposed_update_bytes` to the values at the new tip (recompute or store per-height snapshots). -## Phase 6: NiPoPoW Proofs (build + verify + install) +## Phase 6: NiPoPoW Proofs (build + inspect; V1 bootstrap disabled) -Build NiPoPoW proofs from the local chain on request, verify proofs received -from peers, and install a verified proof's suffix as the chain's starting -point for light-client mode. Wraps `ergo-nipopow`. +Build and serve NiPoPoW proofs from the local chain, and inspect received +proofs with bounded structural and PoW checks. Legacy V1 cannot authorize +light-client bootstrap because its sparse difficulty headers are not +authenticated as members of the proved branch. The public bootstrap verifier +therefore fails closed before candidate comparison or installation. -The serve-side (build + verify-only) shipped first; the install path lands -with light-client bootstrap. Both consumers share the same primitives. +The low-level install and persistence mechanics remain isolated and tested for +a future branch-authenticated format. They are not a V1 authorization path. JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala`, `NipopowAlgos.scala`, and `nodeView/history/storage/modifierprocessors/PopowProcessor.scala` @@ -1060,7 +1063,9 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo every historical height returned by `heightsForNextRecalculation` that is below the suffix head to the proof prefix, validates the enriched proof, and appends the JVM terminal byte `1`. The sigma-rust 0.28 core type remains - unchanged; the node adapter owns this terminal byte and enrichment. + unchanged; the node adapter owns this terminal byte and enrichment. These + height-selected headers are useful interoperability data, but V1 does not + bind them to the proved branch and they cannot authorize installation. - **Genesis (height 1) special case**: The genesis block's interlinks vector is canonical and MUST NOT be read from the extension loader. The **reader implementation** (not `build_nipopow_proof` directly) is @@ -1089,9 +1094,9 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo **Verified by**: integration test `tests/nipopow_serve_integration.rs` in the main crate, which sends `GetNipopowProof(m=6, k=6)` to a running - node and verifies the response round-trips through - `verify_nipopow_proof_bytes` against the requested `m`/`k` and canonical - testnet genesis. The chain crate's + node, inspects the response, and separately asserts that + `verify_nipopow_proof_bytes` returns the V1 bootstrap-disabled error. The + chain crate's `build_proof_skips_loader_for_genesis` unit test fixtures a chain whose loader has no entry for `h=1` and asserts the build still succeeds — a black-box check that the reader's genesis synthesis path @@ -1103,27 +1108,17 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo crate has already stripped the message envelope). `context` carries the exact requested `m` and `k`, the deployment's configured genesis ID, and the configured difficulty epoch/use-last-epochs values. -- **Postcondition**: the proof has passed canonical sigma-rust validation, - matches the requested security parameters, starts at the configured - genesis, and every extracted header passes `verify_pow`. The returned - payload is explicitly continuous (`terminal byte == 1`), and every required - historical difficulty header is present in the authenticated prefix. The - returned value preserves the parser's exact `prefix` / `suffix_head` / - `suffix_tail` boundary plus the exact sparse difficulty subset so the - installer never reconstructs either from a flattened vector. -- **Validation checks**: - 1. The Scorex core parses from an explicitly tracked cursor. No remainder - is accepted except one JVM terminal mode byte (`0` or `1`). - 2. `NipopowProof::validate()` enforces the proof's canonical structural, - connection, interlinks-proof, height, and suffix-cardinality invariants. - 3. Proof `m` and `k` equal the values in `context`. - 4. The first proof-chain header equals `context.expected_genesis_id`. - 5. The terminal mode is exactly `1`; absent and mode `0` remain parseable - for diagnostics but cannot authorize bootstrap. - 6. Every required historical difficulty height is present in the prefix. - 7. Every prefix, suffix-head, and suffix-tail header passes `verify_pow`. -- **Does NOT** apply the proof to local chain state. Chain mutation remains - an explicit consumer operation. +- **Postcondition**: after validating the local context bounds, the function + returns `ChainError::NipopowBootstrapDisabled`. It never returns an + authorizing V1 result and never mutates chain state. +- **Reason**: V1 can show that each sparse header has its own PoW and occurs at + a requested height, but it cannot show that the header belongs to the branch + selected by the NiPoPoW proof. Its self-declared `nBits` therefore cannot be + trusted as difficulty-transition evidence. +- **Diagnostics**: `inspect_nipopow_proof_bytes` retains bounded parsing, + canonical structural validation, terminal-mode parsing, and per-header PoW + checks. Its `NipopowInspection` result contains no headers, request binding, + or configured-genesis binding and cannot authorize bootstrap. ### `NipopowVerificationResult` @@ -1131,26 +1126,26 @@ JVM reference: `ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popo pub struct NipopowVerificationResult { pub m: u32, pub k: u32, - /// JVM terminal mode; authorizing verification returns `Some(true)`. + /// JVM terminal mode parsed by the legacy internal verifier. pub continuous: Option, pub prefix: Vec
, - /// Exact authenticated subset required by difficulty recalculation. + /// Legacy V1 height-selected subset; not branch-authenticated. pub difficulty_headers: Vec
, pub suffix_head: Header, pub suffix_tail: Vec
, } ``` -`total_headers()` and `suffix_tip_height()` are derived from these exact -segments rather than stored as duplicable metadata. The separate -`NipopowInspection` type contains diagnostic metadata but no headers; the -unsolicited code-91 log path may use it, but bootstrap selection and -installation must not. +The type and its exact prefix/suffix boundary remain for internal regression +fixtures and a future authenticated format. The public V1 verifier does not +construct it. The separate `NipopowInspection` type contains diagnostic +metadata but no headers; bootstrap selection and installation must not use it. ### `install_from_nipopow_proof(difficulty_headers: Vec
, suffix_head: Header, suffix_tail: Vec
) -> Result>` -Install a verified NiPoPoW proof's suffix as the chain's starting point for -light-client mode. +Install an externally authorized NiPoPoW suffix as the chain's starting point +for light-client mode. This is a low-level future-format/test seam, not a V1 +authorization path. ```rust pub struct InstalledHeader { @@ -1161,10 +1156,9 @@ pub struct InstalledHeader { ``` - **Precondition**: Chain is empty (`is_empty() == true`). All three arguments - MUST come directly from the same `NipopowVerificationResult`. The installer - does not re-run proof scoring or interlink validation, but it independently - checks the exact configured anchor-height set, context/suffix PoW, suffix - linkage, and every suffix `n_bits` value before returning success. + MUST come directly from one branch-authenticated authorization result. The + installer does not prove sparse-header branch membership. Legacy V1 cannot + satisfy this precondition; its public verifier always fails closed. - **Postcondition on Ok**: Chain now contains `suffix_head` followed by every header in `suffix_tail`, in order. `tip()` returns the last header in `suffix_tail` (or `suffix_head` if `suffix_tail` is empty). `height()` @@ -1257,16 +1251,17 @@ peer on demand. Tracked as a follow-up. ### NiPoPoW invariants -- `build_nipopow_proof` and `verify_nipopow_proof_bytes` are pure functions - over chain state (modulo `&self` for chain access in `build`). -- Building and verifying do NOT modify chain state. +- `build_nipopow_proof`, `inspect_nipopow_proof_bytes`, and + `verify_nipopow_proof_bytes` are pure functions over chain state (modulo + `&self` for chain access in `build`). +- Building, inspection, and the fail-closed authorization check do NOT modify + chain state. - `install_from_nipopow_proof` IS a state mutation, but only legal on an empty chain. Calling it on a non-empty chain is an error, not a destructive overwrite. -- Verification rejects any proof whose internal PoW checks fail — - consensus-critical. -- Building never produces a proof that would fail verification on the same - implementation. +- Inspection rejects any proof whose internal PoW checks fail. +- Building produces V1 bytes that round-trip through bounded inspection; this + is an interoperability property, not bootstrap authorization. - The `PopowHeaderReader` implementation used by `build_nipopow_proof` MUST synthesize the genesis `PoPowHeader` in-process — the extension loader MUST NOT be called for `height == 1` or for the genesis block @@ -1275,13 +1270,11 @@ peer on demand. Tracked as a follow-up. reader); it is consensus-critical because real genesis extensions are empty and cannot produce the canonical `interlinks = [genesis_id]` vector via the loader path. -- **Continuous difficulty context is mandatory.** `light_client_mode` never - relaxes `expected_difficulty`. The exact JVM recalculation heights below the - suffix head are retained in a sparse authenticated map; normal suffix/best- - chain headers satisfy the remaining lookups. A restored light chain starts - with `nipopow_difficulty_context_ready = false` and rejects every successor - and reorg until the versioned, suffix-head-bound record is parsed and - validated. Missing lookup heights are errors, never silently omitted. +- **Legacy V1 is non-authorizing.** Its recalculation-height headers are not + branch-authenticated, so the public verifier always returns + `NipopowBootstrapDisabled` before selection or installation. The sparse-map, + suffix validation, and restart codec remain tested mechanics for a future + authenticated format; their own validation does not repair V1. - `reorg_floor()` is consulted before any reorg execution. Reorgs whose fork point falls below the floor are rejected. diff --git a/facts/nipopow.md b/facts/nipopow.md index bba77f5..c9b4003 100644 --- a/facts/nipopow.md +++ b/facts/nipopow.md @@ -1,10 +1,11 @@ -# NiPoPoW Serve + Light-Client Bootstrap Contract (in-repo side) +# NiPoPoW Serve + Fail-Closed V1 Bootstrap Contract (in-repo side) ## Scope This contract covers the **in-repo (main crate) side** of NiPoPoW proof -handling. The chain submodule owns proof construction, verification, and -the install path (see `facts/chain.md` Phase 6: NiPoPoW Proofs). The main +handling. The chain submodule owns proof construction, inspection, +authorization, and the dormant install seam (see `facts/chain.md` Phase 6: +NiPoPoW Proofs). The main crate owns the P2P message envelope, event dispatch, and the light-client bootstrap state machine. @@ -15,20 +16,21 @@ What ships in this contract: 2. A handler in `src/main.rs` that subscribes to `ProtocolEvent`s, filters for codes 90 and 91, parses the envelopes, calls into chain, and sends responses via `p2p.send_to`. -3. Verification of incoming proofs (logged in non-light mode; routed to - the bootstrap state machine in `StateType::Light` mode). +3. Diagnostic inspection of incoming proofs, plus a public V1 bootstrap + authorization entry point that returns a typed disabled error. 4. **Outbound `GetNipopowProof` requests** for light-client bootstrap. The serializer for code 90 (`serialize_get_nipopow_proof`) is added alongside the existing code 91 serializer. -5. **Light-client bootstrap state machine** in `sync/` (see +5. **Fail-closed light-client bootstrap state machine** in `sync/` (see `facts/sync.md` "Light-Client Bootstrap" section). The main crate's role is to wire the bootstrap entry point into startup when `state_type == Light`. What does NOT ship: -- Multi-peer best-arg proof comparison (KMZ17 §4.3) — single-peer - bootstrap only for first release. +- An authorizing V1 bootstrap path. V1 does not authenticate its sparse + difficulty headers as members of the selected branch. +- A V2 proof format or re-enabled multi-peer selection/install path. - In-extension parameter recovery for light clients — `active_parameters` stays at network defaults in light mode (documented limitation in `facts/chain.md`). @@ -95,9 +97,10 @@ future_pad_length: u16 (VLQ — JVM putUShort) **Validation**: - Total body size MUST be ≤ 2,000,000 bytes (reject before allocating). - `proof_length` MUST be > 0 AND < 2,000,000. -- `proof_bytes` is the inner NiPoPoW proof. The bootstrap path passes it to - context-bound verification with the exact requested `m/k` and configured - genesis; the unsolicited log path uses diagnostic-only inspection. +- `proof_bytes` is the inner NiPoPoW proof. The V1 bootstrap path passes it to + the context-bound authorization entry point, which returns the typed + bootstrap-disabled error; the unsolicited log path uses diagnostic-only + inspection. ## Module: `src/nipopow_handler` (or inline in main.rs) @@ -120,10 +123,10 @@ messages. Mirrors the snapshot sync handler structure. This diagnostic path has no request context and never authorizes bootstrap selection or installation. 4. The light-bootstrap session processes responses to its own code-90 - broadcast separately. It accepts code-91 candidates only from peers that - received that request, calls `SyncChain::verify_nipopow_envelope`, retains - each lossless verification result for comparison, and installs only the - selected result's exact parsed suffix. + broadcast separately. It accepts code-91 responses only from peers that + received that request and calls `SyncChain::verify_nipopow_envelope`. + Legacy V1 returns `NipopowBootstrapDisabled`; sync propagates the + capability error before comparison or installation. - **Invariant**: The handler never blocks the event loop — long-running chain operations happen on a `tokio::task::block_in_place` boundary or in a spawned task, mirroring the existing pattern from snapshot sync. @@ -162,20 +165,20 @@ pub enum NipopowError { ``` Note: there is no `NipopowProofResponse` struct — `parse_nipopow_proof` -returns the inner `Vec` directly because every consumer immediately -hands it to `chain.verify_nipopow_proof_bytes`. Wrapping it in a struct -adds no value. +returns the inner `Vec` directly because consumers immediately hand it to +diagnostic inspection or the fail-closed authorization entry point. Wrapping +it in a struct adds no value. ### Sigma-rust integration -The chain submodule's `build_nipopow_proof`, context-bound -`verify_nipopow_proof_bytes`, and `install_from_nipopow_proof` wrap +The chain submodule's `build_nipopow_proof`, diagnostic +`inspect_nipopow_proof_bytes`, fail-closed +`verify_nipopow_proof_bytes`, and dormant `install_from_nipopow_proof` seam wrap `ergo_nipopow::NipopowAlgos` and `ergo_nipopow::NipopowProofSerializer`. The main crate does NOT import -`ergo-nipopow` directly — that's the chain's job. The main crate only -sees `Vec` proof bytes plus the lossless `NipopowVerificationResult` -returned from verification. The bootstrap carries that typed result through -selection and installs its `suffix_head` / `suffix_tail` fields directly. +`ergo-nipopow` directly — that's the chain's job. Legacy V1 produces no +`NipopowVerificationResult` on the public authorization path, so selection and +installation are unreachable. ## Routing behavior (current limitation) @@ -244,33 +247,28 @@ hard limit). Lower values are valid for resource-constrained nodes. ### Integration (light-client bootstrap) -9. **Bootstrap, single peer happy path**: Start a Rust node configured - with `state_type = "light"` against a single peer (the test server's - Rust node, which has full chain). Verify the light client: +9. **Bootstrap, conforming V1 peer fail-close**: Start a Rust node configured + with `state_type = "light"` against a single peer. Verify the light client: - Sends exactly one `GetNipopowProof(m=6, k=10, header_id=None)`. - Receives the response within 30s. - - Verifies the proof. - - Installs the suffix into `HeaderChain` (chain becomes non-empty). - - Transitions to normal sync and starts following the tip. + - Returns `LightBootstrapError::BootstrapDisabled`. + - Performs no candidate comparison or suffix installation. 10. **Bootstrap, peer stall**: Start a light client against a peer that accepts the request but never responds. Verify timeout fires at 30s, no second request to the same peer, and the bootstrap surfaces `LightBootstrapError::AllPeersStalled` after 3 retries (when only one peer is configured, all 3 attempts go to the same peer; verify the error is reached). -11. **Bootstrap, hostile peer**: Inject a peer that returns a malformed - proof (mutated bytes). Verify the verifier rejects it and the - bootstrap surfaces `LightBootstrapError::AllPeersHostile`. -12. **Bootstrap, install failure**: Inject a verified-but-self- - inconsistent proof (parent linkage mismatch within the suffix that - `verify_nipopow_proof_bytes` somehow accepted — this requires - constructing a specially-crafted proof, may need to mock). Verify - `install_from_nipopow_proof` rejects it and bootstrap surfaces - `LightBootstrapError::InstallFailed`. -13. **Bootstrap restart idempotence**: Run a successful bootstrap, kill - the node, restart with the same `state_type = "light"` config and - a non-empty chain on disk. Verify bootstrap is skipped and the node - enters normal sync immediately. +11. **Malformed V1 remains non-authorizing**: The public bootstrap verifier + returns `BootstrapDisabled` without treating malformed V1 bytes as a peer + verdict. Exercise malformed framing and proof bytes separately through + `inspect_nipopow_proof_bytes`, which must reject them. +12. **Dormant install mechanics**: Through an isolated trusted test seam, + inject parent, difficulty, PoW, and sparse-context faults one at a time and + verify `install_from_nipopow_proof` rejects each without partial mutation. +13. **Legacy restart context**: Keep codec and suffix-head-binding regressions + for compatibility evidence, but do not treat a V1 record as proof of + branch membership or as authorization for a new bootstrap. ## Cross-references diff --git a/facts/store.md b/facts/store.md index 5222a02..3d679d9 100644 --- a/facts/store.md +++ b/facts/store.md @@ -396,11 +396,11 @@ Stable keys, opaque to the store, consumed by higher-level crates |---|---|---| | `b"scores_migrated_v1"` | `[1u8]` once migration completes; absent before | Empty-placeholder → real scores migration | | `b"validated_height"` | 4-byte big-endian `u32`: highest height at which state.redb was flushed with `Durability::Immediate`. Absent ⇒ fresh install or pre-handshake upgrade. | sync's flush pair (see `facts/sync.md` "Cross-DB Durability Handshake") | -| `b"nipopow_difficulty_context_v1"` | Versioned, bounded record containing the installed suffix-head height/ID and its authenticated sparse difficulty headers. Written after all suffix `BEST_CHAIN` entries and flushed before bootstrap success. Required on every light-chain restore; absent, malformed, oversized, or stale bindings fail closed. | `SharedChain::install_nipopow_suffix` | +| `b"nipopow_difficulty_context_v1"` | Legacy V1 versioned/bounded codec for a suffix-head binding and height-selected sparse difficulty headers. The binding does not authenticate branch membership. Production V1 bootstrap is disabled and does not write this record; the codec remains for compatibility, tests, and future migration decisions. | Dormant `SharedChain::install_nipopow_suffix` seam | -Pre-metadata light-client databases have no authoritative source for this -record and are not upgraded in place. Initialize a new light-client data -directory and complete a fresh proof bootstrap instead. +Pre-metadata or legacy V1 light-client databases have no branch-authenticated +difficulty source and are not upgraded into an authorized chain. A future +format or explicit migration design must define their recovery path. ## Peer DB key encoding diff --git a/facts/sync.md b/facts/sync.md index eff2a5a..cc36353 100644 --- a/facts/sync.md +++ b/facts/sync.md @@ -665,9 +665,10 @@ Critical findings from debugging sync against JVM 6.0.3 peers: When `config.state_type == StateType::Light` AND the chain is empty at startup, the sync machine runs a one-shot NiPoPoW bootstrap BEFORE entering the normal -sync cycle. The bootstrap installs the proof's suffix as the chain origin via -`HeaderChain::install_from_nipopow_proof`; subsequent tip-following uses the -existing header sync loop without modification. +sync cycle. Legacy V1 is currently non-authorizing: after the first solicited +proof response, the chain verifier returns a typed capability error and sync +terminates before comparison or installation. Build, serve, parser, and +inspection behavior remain available. ### `run_light_bootstrap(transport, chain, config) -> Result<(), LightBootstrapError>` @@ -690,41 +691,24 @@ State machine: request, until they have all responded or the collection window expires. Unsolicited peers and non-proof messages do not enter the candidate set. -4. **Verify each response** through `SyncChain::verify_nipopow_envelope`. +4. **Ask the chain crate to authorize each response** through + `SyncChain::verify_nipopow_envelope`. The bridge strips the envelope and calls context-bound `enr_chain::verify_nipopow_proof_bytes` with the exact requested `m/k`, - configured genesis, and difficulty epoch context. Only terminal mode `1` - with every required authenticated difficulty header can authorize - bootstrap. Failed verification marks that response hostile and cannot add - a candidate or mutate the chain. - -5. **Select among verified candidates only.** Multiple candidates are - compared pairwise through `NipopowProof::is_better_than`; all have already - passed the same request/genesis context. A comparison error skips that - challenger and retains the incumbent. - -6. **Install the selected result's exact parsed suffix and difficulty - context** into the local `HeaderChain`. `NipopowVerificationResult` carries - `difficulty_headers`, `suffix_head`, and `suffix_tail` separately. The - bootstrap passes these fields directly to `install_from_nipopow_proof`; it - never reconstructs the boundary or anchor set from a flattened vector. - -7. **Persist and flush the restart dependency.** `SharedChain` writes every - installed suffix header first, then writes the versioned, bounded, - suffix-head-bound `nipopow_difficulty_context_v1` record and flushes the - modifier store. If the final record is absent or corrupt after interruption, - the next startup refuses light mode instead of continuing without anchors. - The in-memory install and these store writes are not one cross-layer - transaction: a write failure is fatal to the current sync run and does not - roll the in-memory chain back. The missing or incomplete metadata then makes - restart fail closed. Supporting retry in the same process would require a - separate atomic-install design. - -8. **Transition to normal tip-following sync** via the existing - `sync_from_peer` loop. From here on out, light mode behaves like full - mode minus block bodies: the sync machine sends SyncInfo, receives - header Inv, requests headers, validates them (including expected - difficulty) via `try_append`, and advances the tip. + configured genesis, and difficulty epoch context. The V1 entry point + validates the local context and returns + `ChainError::NipopowBootstrapDisabled` because the height-selected sparse + headers are not authenticated as branch members. + +5. **Propagate the capability failure immediately.** Sync returns + `LightBootstrapError::BootstrapDisabled`. It does not classify the peer as + hostile, add a candidate, compare proofs, install headers, or write the + legacy difficulty-context record. + +The retained comparison, exact-suffix install, persistence, and restart +mechanics are exercised by isolated tests but are unreachable from legacy V1 +in production. A future proof format must first supply branch-authenticated +difficulty-transition evidence. ### `LightBootstrapError` @@ -733,6 +717,7 @@ pub enum LightBootstrapError { NoPeers, AllPeersStalled, AllPeersHostile, + BootstrapDisabled, InstallFailed(ChainError), StreamClosed, } @@ -744,32 +729,24 @@ for first release, terminate. ### Bootstrap invariants -- **Shared verification context**: every candidate is verified against the - same requested `m=6`, `k=10`, configured genesis, and difficulty epoch - settings before comparison. A failed candidate never enters pairwise - selection. -- **Lossless install boundary**: selection retains the typed verification - result, and installation consumes its exact `difficulty_headers`, - `suffix_head`, and `suffix_tail`. No consumer re-parses or re-splits a - flattened header list. -- **Restart is fail-closed**: bootstrap is one-shot when `chain.is_empty()`. - A non-empty restored light chain must also restore the matching versioned - difficulty context before the sync task is constructed. Missing/corrupt - context or a suffix-head height/ID mismatch aborts startup. - Light-client databases created before this metadata existed are intentionally - not migrated in place; recovery is a new light-client data directory followed - by a fresh proof bootstrap. -- **Proof bytes are not archived** after install. `SharedChain` persists the - installed suffix headers through the normal header-store path and only the - bounded difficulty subset in `chain_meta`. +- **V1 fails closed**: every V1 authorization attempt returns the typed + capability error before candidate comparison or chain installation. +- **Capability failure is not peer hostility**: a conforming V1 response does + not count as an invalid-proof attack; sync terminates with + `BootstrapDisabled`. +- **Inspection is non-authorizing**: successful bounded parsing, structural + validation, and per-header PoW checks cannot be converted into an install + result. +- **Dormant install mechanics remain isolated**: exact prefix/suffix identity, + sparse-context bounds, suffix difficulty, and restart binding continue to + have regression coverage for a future authenticated format. ### Trust model -Bootstrap accepts only proofs that match the configured chain identity and -requested security parameters, then selects the best verified proof among the -responses it observed. Security still depends on the NiPoPoW assumptions and -the available peer view; verification and multi-peer comparison do not by -themselves guarantee peer diversity or network availability. +Legacy V1 accepts no proof for bootstrap installation. A future V2 design must +authenticate the difficulty-transition evidence as part of the selected +branch before multi-peer comparison or installation can be re-enabled. V2 is +not specified by this implementation. ## Block Section Download diff --git a/src/bridge.rs b/src/bridge.rs index a2cb3f0..ed39913 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -240,7 +240,8 @@ impl SyncChain for SharedChain { } // This record is deliberately committed after every BEST_CHAIN write. // A crash before it lands makes the next startup refuse light mode - // instead of continuing without authenticated difficulty history. + // instead of continuing without the required difficulty-context + // record. This persistence binding is not V1 branch authentication. self.store .chain_meta_put( enr_chain::NIPOPOW_DIFFICULTY_CONTEXT_META_KEY, diff --git a/src/main.rs b/src/main.rs index fc980fc..a019f80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2142,13 +2142,13 @@ async fn main() -> Result<(), Box> { } StateType::Light => { - // Light mode runs no validator. The chain is bootstrapped from a - // verified NiPoPoW proof (see sync's light bootstrap state) and - // tip-following uses HeaderChain::try_append, which the chain - // crate's light_client_mode flag teaches to skip the - // expected_difficulty recalc. Mining and transaction validation - // are not available. - tracing::info!("light-client mode: no block validator constructed"); + // Light mode runs no block validator. Legacy NiPoPoW V1 bootstrap + // is fail-closed before comparison or installation because its + // sparse difficulty headers are not branch-authenticated. Mining + // and transaction validation are not available. + tracing::info!( + "light-client mode: no block validator; NiPoPoW V1 bootstrap is disabled" + ); None } }; diff --git a/sync/src/light_bootstrap.rs b/sync/src/light_bootstrap.rs index bfb2d44..f8cee79 100644 --- a/sync/src/light_bootstrap.rs +++ b/sync/src/light_bootstrap.rs @@ -1,15 +1,16 @@ //! NiPoPoW light-client bootstrap state machine. //! //! Runs once at startup when `state_type == StateType::Light` and the chain -//! is empty. Broadcasts `GetNipopowProof` to all outbound peers, collects -//! valid proofs within a timeout window, compares them via KMZ17 §4.3 -//! (`is_better_than`), and installs the best as the chain's starting point. -//! Subsequent tip-following uses the existing header sync loop. +//! is empty. Legacy V1 responses currently terminate with +//! [`LightBootstrapError::BootstrapDisabled`] before comparison or install, +//! because V1 does not branch-authenticate its sparse difficulty headers. +//! The bounded request/collection and dormant selection/install mechanics are +//! retained for a future proof format that can satisfy that authorization. //! //! JVM reference: `ErgoNodeViewSynchronizer.scala:1032` (outbound request), //! `PopowProcessor.applyPopowProof` (install side). -use enr_chain::{BlockId, NipopowVerificationResult}; +use enr_chain::{BlockId, ChainError, NipopowVerificationResult}; use enr_p2p::protocol::messages::ProtocolMessage; use enr_p2p::protocol::peer::ProtocolEvent; use enr_p2p::types::PeerId; @@ -49,6 +50,11 @@ pub enum LightBootstrapError { #[error("all {0} attempted peers returned invalid proofs")] AllPeersHostile(usize), + #[error( + "NiPoPoW V1 bootstrap is disabled: sparse difficulty headers are not branch-authenticated" + )] + BootstrapDisabled, + #[error("install failed: {0}")] InstallFailed(enr_chain::ChainError), @@ -85,14 +91,13 @@ fn build_get_nipopow_proof_body(m: i32, k: i32, header_id: Option<&BlockId>) -> /// /// Idempotent: returns immediately if `chain.chain_height() > 0`. /// -/// Top-level state machine (KMZ17 §4.3 multi-peer comparison): +/// Top-level state machine: /// 1. Wait for at least one outbound peer (60s deadline). /// 2. Broadcast `GetNipopowProof(m=6, k=10)` to ALL outbound peers. -/// 3. Collect valid proofs within a 30s window. Verify each on arrival. -/// 4. If multiple valid proofs, compare pairwise via `is_better_than` -/// and pick the best. If one valid proof, use it. -/// 5. Install the exact parsed suffix carried by the best verified result. -/// 6. No valid proofs → return error. +/// 3. The V1 authorization verifier returns a typed capability error. +/// 4. Propagate `BootstrapDisabled` before comparison or installation. +/// +/// Candidate comparison and installation remain unreachable for V1. pub async fn run_light_bootstrap( transport: &mut T, chain: &C, @@ -195,6 +200,13 @@ pub async fn run_light_bootstrap( envelope: body, }); } + Err(ChainError::NipopowBootstrapDisabled) => { + tracing::warn!( + peer = ?peer_id, + "light bootstrap: V1 bootstrap capability is disabled" + ); + return Err(LightBootstrapError::BootstrapDisabled); + } Err(e) => { tracing::warn!( peer = ?peer_id, @@ -409,6 +421,7 @@ mod tests { enum VerifyResult { Ok(Box), Err(String), + Disabled, } impl VerifyResult { @@ -511,6 +524,7 @@ mod tests { return match result { VerifyResult::Ok(result) => Ok(result.as_ref().clone()), VerifyResult::Err(e) => Err(ChainError::Nipopow(e.clone())), + VerifyResult::Disabled => Err(ChainError::NipopowBootstrapDisabled), }; } } @@ -598,6 +612,27 @@ mod tests { assert!(chain.installed().is_none()); } + #[tokio::test] + async fn bootstrap_propagates_v1_disabled_without_comparison_or_install() { + let peer = PeerId(1); + let body = vec![0xd1]; + let chain = MockChain::new(); + chain.add_verify(body.clone(), VerifyResult::Disabled); + let mut transport = MockTransport::new(vec![peer], vec![proof_event(peer, body)]); + + let err = run_light_bootstrap(&mut transport, &chain) + .await + .expect_err("V1 capability failure must terminate bootstrap"); + + assert!(matches!(&err, LightBootstrapError::BootstrapDisabled)); + assert!( + err.to_string().contains("bootstrap is disabled"), + "unexpected capability error: {err}" + ); + assert!(chain.comparison_calls().is_empty()); + assert!(chain.installed().is_none()); + } + #[tokio::test] async fn bootstrap_unrequested_k_failure_cannot_select_or_install() { let peer = PeerId(1); diff --git a/sync/src/state.rs b/sync/src/state.rs index 70f3a38..84f68dd 100644 --- a/sync/src/state.rs +++ b/sync/src/state.rs @@ -575,9 +575,9 @@ impl HeaderSync /// through to [`Self::shutdown_flush`] via [`Self::run`]. async fn run_inner(&mut self) { // Light-client bootstrap: if state_type is Light AND chain is empty, - // run a one-shot NiPoPoW bootstrap before entering the normal sync - // cycle. The bootstrap installs the proof's suffix as the chain - // origin; subsequent tip-following uses the existing loop unchanged. + // run a one-shot NiPoPoW bootstrap before entering normal sync. + // Legacy V1 returns BootstrapDisabled before comparison or install; + // the success branch is retained for a future authenticated format. // Idempotent: skipped on restart when the chain is non-empty. if self.config.state_type == StateType::Light && self.chain.chain_height().await == 0 { tracing::info!("light-client mode: running NiPoPoW bootstrap"); @@ -589,9 +589,9 @@ impl HeaderSync { Ok(()) => { let height = self.chain.chain_height().await; - // Light mode treats all installed headers as "validated" - // — the proof's PoW checks ARE the validation. There's - // no validator running and no block sections to download. + // Reachable only after a future authorization path has + // authenticated the installed origin. Legacy V1 cannot + // reach this branch. self.downloaded_height = height; self.state_applied_height = height; tracing::info!(height, "light bootstrap installed, entering tip-following sync"); diff --git a/sync/src/traits.rs b/sync/src/traits.rs index 881ac13..c14ecec 100644 --- a/sync/src/traits.rs +++ b/sync/src/traits.rs @@ -190,14 +190,16 @@ pub trait SyncChain { &self, ) -> impl std::future::Future> + Send; - /// Strip a `NipopowProof` (P2P code 91) message envelope and verify the - /// inner proof bytes via [`enr_chain::verify_nipopow_proof_bytes`]. - /// Returns the context-bound proof with its parsed prefix/suffix boundary - /// preserved on success. + /// Strip a `NipopowProof` (P2P code 91) message envelope and ask the chain + /// crate to authorize its inner bytes for bootstrap. + /// + /// The legacy V1 implementation always returns + /// [`enr_chain::ChainError::NipopowBootstrapDisabled`], because sparse + /// difficulty headers are not branch-authenticated. /// /// Used by the light-client bootstrap state machine. The bridge wraps /// `nipopow_serve::parse_nipopow_proof` (envelope strip) + - /// `enr_chain::verify_nipopow_proof_bytes` (verify). + /// `enr_chain::verify_nipopow_proof_bytes` (authorization gate). fn verify_nipopow_envelope( &self, envelope_body: &[u8], @@ -218,13 +220,13 @@ pub trait SyncChain { than_envelope: &[u8], ) -> impl std::future::Future> + Send; - /// Install a verified NiPoPoW proof's suffix as the chain's starting + /// Install an already authorized NiPoPoW suffix as the chain's starting /// point for light-client mode. Wraps /// [`enr_chain::HeaderChain::install_from_nipopow_proof`]. /// - /// Precondition: chain must be empty. The headers MUST already be - /// verified via [`Self::verify_nipopow_envelope`] — this function does - /// NOT re-verify. + /// Precondition: chain must be empty and the headers must come from a + /// branch-authenticated proof format. Legacy V1 cannot satisfy this + /// precondition and never reaches this method in production. fn install_nipopow_suffix( &self, difficulty_headers: Vec
, diff --git a/tests/nipopow_serve_integration.rs b/tests/nipopow_serve_integration.rs index c163be6..1245c9e 100644 --- a/tests/nipopow_serve_integration.rs +++ b/tests/nipopow_serve_integration.rs @@ -2,7 +2,8 @@ //! //! Connects to a running `ergo-node-rust` as an outbound peer, sends a //! `GetNipopowProof` (code 90), waits for the `NipopowProof` (code 91) -//! response, parses it, and verifies it via `enr_chain::verify_nipopow_proof_bytes`. +//! response, parses and inspects it, then confirms that legacy V1 cannot +//! authorize bootstrap installation. //! //! Exercises the full path: codec → handler dispatch → build → serialize → //! framing → parse on receiver. The unit tests in `src/nipopow_serve.rs` cover @@ -72,6 +73,23 @@ fn testnet_verification_context(m: u32, k: u32) -> enr_chain::NipopowVerificatio } } +fn inspect_v1_and_assert_bootstrap_disabled( + proof_bytes: &[u8], + context: &enr_chain::NipopowVerificationContext, +) -> enr_chain::NipopowInspection { + let inspection = enr_chain::inspect_nipopow_proof_bytes(proof_bytes) + .expect("served V1 proof must remain inspectable"); + let authorization = enr_chain::verify_nipopow_proof_bytes(proof_bytes, context); + assert!( + matches!( + authorization, + Err(enr_chain::ChainError::NipopowBootstrapDisabled) + ), + "legacy V1 must not authorize bootstrap: {authorization:?}" + ); + inspection +} + #[tokio::test] #[ignore = "requires a running ergo-node-rust on NIPOPOW_TARGET (default 127.0.0.1:9030)"] async fn nipopow_serve_round_trip_against_running_node() { @@ -155,25 +173,22 @@ async fn nipopow_serve_round_trip_against_running_node() { eprintln!("got NipopowProof inner bytes: {} bytes", proof_bytes.len()); let context = testnet_verification_context(6, 6); - let result = enr_chain::verify_nipopow_proof_bytes(&proof_bytes, &context) - .expect("verify_nipopow_proof_bytes failed"); + let inspection = inspect_v1_and_assert_bootstrap_disabled(&proof_bytes, &context); eprintln!( - "verified: suffix_tip_height={} total_headers={} continuous={:?}", - result.suffix_tip_height(), - result.total_headers(), - result.continuous + "inspected V1 (bootstrap disabled): suffix_tip_height={} total_headers={} continuous={:?}", + inspection.suffix_tip_height, inspection.total_headers, inspection.continuous ); // m=6 k=6 means at minimum the proof carries the k-suffix (6 headers). // The prefix can be empty on a short chain, so 6 is the floor we can rely on. assert!( - result.total_headers() >= 6, + inspection.total_headers >= 6, "expected at least k=6 headers in the proof, got {}", - result.total_headers() + inspection.total_headers ); assert!( - result.suffix_tip_height() > 0, + inspection.suffix_tip_height > 0, "suffix tip height should be > 0" ); } @@ -290,26 +305,25 @@ async fn nipopow_serve_full_chain_round_trip() { ); let context = testnet_verification_context(6, 6); - let result = enr_chain::verify_nipopow_proof_bytes(&proof_bytes, &context) - .expect("verify_nipopow_proof_bytes failed"); + let inspection = inspect_v1_and_assert_bootstrap_disabled(&proof_bytes, &context); eprintln!( - "[full-chain] verified: suffix_tip_height={} total_headers={} continuous={:?} wall_time={:.3}s", - result.suffix_tip_height(), - result.total_headers(), - result.continuous, + "[full-chain] inspected V1 (bootstrap disabled): suffix_tip_height={} total_headers={} continuous={:?} wall_time={:.3}s", + inspection.suffix_tip_height, + inspection.total_headers, + inspection.continuous, elapsed.as_secs_f64() ); assert!( - result.total_headers() >= 6, + inspection.total_headers >= 6, "expected at least k=6 headers in the proof, got {}", - result.total_headers() + inspection.total_headers ); assert!( - result.suffix_tip_height() > 200, + inspection.suffix_tip_height > 200, "full-chain suffix tip height should be much larger than the 200-anchor test, got {}", - result.suffix_tip_height() + inspection.suffix_tip_height ); } @@ -327,13 +341,14 @@ async fn nipopow_serve_full_chain_round_trip() { /// proofs with `Nipopow("invalid connections")`. The fix /// (`mwaddip/sigma-rust:fix/nipopow-prefix-connection-lookback`, /// integrated as `1e3fe28` on `ergo-node-integration`) ports JVM's -/// `useLastEpochs + 2` lookback window so the verifier accepts proofs +/// `useLastEpochs + 2` lookback window so bounded inspection accepts proofs /// where each entry connects to ANY of the up-to-10 immediately preceding /// entries. /// -/// This test passes against any peer (JVM or Rust) that produces valid -/// JVM-shape proofs. Pre-fix it failed; post-fix it passes. Keeping it -/// as a permanent regression check for the connection-tolerance fix. +/// This test passes against any peer (JVM or Rust) that produces structurally +/// valid JVM-shape proofs, while separately asserting that V1 bootstrap +/// authorization remains disabled. Keeping it as a permanent regression +/// check for both properties. #[tokio::test] #[ignore = "requires a running ergo-node-rust on NIPOPOW_TARGET (default 127.0.0.1:9030)"] async fn nipopow_serve_no_anchor_repro() { @@ -384,24 +399,23 @@ async fn nipopow_serve_no_anchor_repro() { .expect("timed out waiting for NipopowProof response"); let context = testnet_verification_context(6, 10); - let result = enr_chain::verify_nipopow_proof_bytes(&proof_bytes, &context) - .expect("verify_nipopow_proof_bytes failed (regression: tolerant lookback?)"); + let inspection = inspect_v1_and_assert_bootstrap_disabled(&proof_bytes, &context); eprintln!( - "[no-anchor] verified OK: proof_bytes={} suffix_tip_height={} total_headers={}", + "[no-anchor] inspected V1; bootstrap disabled: proof_bytes={} suffix_tip_height={} total_headers={}", proof_bytes.len(), - result.suffix_tip_height(), - result.total_headers(), + inspection.suffix_tip_height, + inspection.total_headers, ); assert!( - result.total_headers() >= 10, + inspection.total_headers >= 10, "expected at least k=10 headers in the proof, got {}", - result.total_headers() + inspection.total_headers ); assert!( - result.suffix_tip_height() > 200, + inspection.suffix_tip_height > 200, "no-anchor request should resolve to a deep tip, got {}", - result.suffix_tip_height() + inspection.suffix_tip_height ); } From 3f099012be9230532180bb78786d166e7d264935 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:06:02 +0200 Subject: [PATCH 12/13] fix(nipopow): disable unsolicited v1 inspection --- src/main.rs | 29 ++++++++++------------ src/nipopow_serve.rs | 58 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index a019f80..659fe9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -424,8 +424,8 @@ async fn penalize( /// Handle an incoming NiPoPoW message (code 90 GetNipopowProof or 91 NipopowProof). /// /// For code 90: parse the request, lock the chain, build the proof, and send the -/// response via the P2P node. For code 91: parse the inner proof bytes and verify -/// against the chain (logged but not applied — light-client mode is a future session). +/// response via the P2P node. For code 91: validate only the bounded envelope; +/// unsolicited V1 inspection is disabled and request-bound handling occurs in sync. /// /// Errors during parsing/building/verification are logged at warn level and dropped. /// We never send error responses — JVM doesn't expect them. @@ -521,29 +521,26 @@ async fn handle_nipopow_event( } nipopow_serve::NIPOPOW_PROOF => { - let proof_bytes = match nipopow_serve::parse_nipopow_proof(body) { - Ok(b) => b, + let disposition = match nipopow_serve::classify_unsolicited_nipopow_proof(body) { + Ok(disposition) => disposition, Err(e) => { penalize(p2p, peer_id, "misbehavior", &format!("NipopowProof parse failed: {e}"), false).await; return; } }; - // Diagnostic-only inspection: this unsolicited/log-only path has - // no request context and never authorizes bootstrap installation. - match enr_chain::inspect_nipopow_proof_bytes(&proof_bytes) { - Ok(meta) => { - tracing::info!( + // This unsolicited path has no request context. Keep only bounded + // envelope validation; request-bound handling continues in sync. + match disposition { + nipopow_serve::UnsolicitedNipopowDisposition::V1InspectionDisabled { + proof_size, + } => { + tracing::debug!( peer = %peer_id, - suffix_tip_height = meta.suffix_tip_height, - total_headers = meta.total_headers, - continuous = ?meta.continuous, - "received and inspected NiPoPoW proof (diagnostic only)" + proof_size, + "received NiPoPoW proof; unsolicited V1 inspection disabled" ); } - Err(e) => { - penalize(p2p, peer_id, "permanent", &format!("NiPoPoW proof verification failed: {e}"), true).await; - } } } diff --git a/src/nipopow_serve.rs b/src/nipopow_serve.rs index 457b60e..5ccd0af 100644 --- a/src/nipopow_serve.rs +++ b/src/nipopow_serve.rs @@ -6,10 +6,10 @@ //! We build it from the local chain via `enr_chain::build_nipopow_proof` //! and respond with a code 91 message. //! -//! - **91 (`NipopowProof`)**: peer sends us an unsolicited proof. We inspect it -//! via `enr_chain::inspect_nipopow_proof_bytes` and log only diagnostic -//! metadata. This path has no request context and cannot authorize bootstrap -//! selection or installation. +//! - **91 (`NipopowProof`)**: peer sends us an unsolicited proof. We validate +//! only its bounded envelope and return a typed V1-disabled disposition. +//! This path has no request context and cannot authorize bootstrap selection, +//! installation, or full proof inspection. //! //! Both codes use VLQ-encoded integer fields with a `putUShort(0)` pad-length //! footer for forward compatibility (the JVM convention for new message @@ -175,6 +175,34 @@ pub fn parse_nipopow_proof(body: &[u8]) -> Result, NipopowError> { Ok(proof_bytes) } +/// Result of classifying an unsolicited code-91 message. +/// +/// Legacy V1 proofs have no request-bound authorization context, so this type +/// deliberately exposes only the bounded envelope size and never parsed proof +/// metadata or headers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnsolicitedNipopowDisposition { + /// The envelope is well formed, but unsolicited V1 inspection is disabled. + V1InspectionDisabled { + /// Size of the inner proof payload declared by the bounded envelope. + proof_size: usize, + }, +} + +/// Validate a code-91 envelope without inspecting its inner V1 proof. +/// +/// The request-bound light-bootstrap state machine may consume the original +/// message separately. This diagnostic path must not duplicate its structural, +/// cryptographic, or proof-of-work validation. +pub fn classify_unsolicited_nipopow_proof( + body: &[u8], +) -> Result { + let proof_bytes = parse_nipopow_proof(body)?; + Ok(UnsolicitedNipopowDisposition::V1InspectionDisabled { + proof_size: proof_bytes.len(), + }) +} + /// Serialize the inner proof bytes into a `NipopowProof` (code 91) message body. /// /// Inverse of [`parse_nipopow_proof`]. @@ -282,6 +310,28 @@ mod tests { assert_eq!(parsed, original); } + #[test] + fn unsolicited_v1_proof_returns_disabled_without_inner_inspection() { + let body = serialize_nipopow_proof(&[0xff]); + + assert_eq!( + classify_unsolicited_nipopow_proof(&body).unwrap(), + UnsolicitedNipopowDisposition::V1InspectionDisabled { proof_size: 1 } + ); + } + + #[test] + fn unsolicited_v1_proof_keeps_bounded_envelope_validation() { + let mut body = Vec::new(); + body.put_u32(2).unwrap(); + body.push(0xff); + + assert!(matches!( + classify_unsolicited_nipopow_proof(&body), + Err(NipopowError::Truncated) + )); + } + #[test] fn serialize_get_nipopow_proof_no_anchor_round_trip() { let req = GetNipopowProofRequest { From 5db724d668a692762c0c3304626036789e5a787e Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:23:11 +0200 Subject: [PATCH 13/13] build(nipopow): pin reviewed backport remediation --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 8 ++++---- addons/fastsync/Cargo.lock | 18 +++++++++--------- addons/fastsync/Cargo.toml | 6 +++--- addons/indexer/Cargo.lock | 18 +++++++++--------- addons/indexer/Cargo.toml | 4 ++-- api/Cargo.toml | 10 +++++----- chain/Cargo.toml | 10 +++++----- mempool/Cargo.toml | 4 ++-- mining/Cargo.toml | 10 +++++----- sync/Cargo.toml | 2 +- validation/Cargo.toml | 10 +++++----- 12 files changed, 59 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73ed638..52a59ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,7 +729,7 @@ dependencies = [ [[package]] name = "ergo-chain-types" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "base64", @@ -752,7 +752,7 @@ dependencies = [ [[package]] name = "ergo-lib" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bounded-vec", @@ -795,7 +795,7 @@ dependencies = [ [[package]] name = "ergo-merkle-tree" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "blake2", @@ -833,7 +833,7 @@ dependencies = [ [[package]] name = "ergo-nipopow" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bounded-integer", @@ -947,7 +947,7 @@ dependencies = [ [[package]] name = "ergotree-interpreter" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "blake2", @@ -980,7 +980,7 @@ dependencies = [ [[package]] name = "ergotree-ir" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bnum", @@ -1203,7 +1203,7 @@ dependencies = [ [[package]] name = "gf2_192" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "derive_more", "thiserror", @@ -2312,7 +2312,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "sigma-ser" version = "0.19.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "bitvec", "bounded-vec", @@ -2323,7 +2323,7 @@ dependencies = [ [[package]] name = "sigma-util" version = "0.18.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "blake2", "sha2 0.10.9", diff --git a/Cargo.toml b/Cargo.toml index a8e8c0d..eb73725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,8 @@ enr-store = { path = "store" } ergo-api = { path = "api" } ergo-sync = { path = "sync" } ergo_avltree_rust = "0.1.1" -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } ergo-mempool = { path = "mempool" } ergo-mining = { path = "mining" } ergo-validation = { path = "validation" } @@ -39,7 +39,7 @@ bytes = "1" clap = { version = "4", features = ["derive"] } hex = "0.4" serde = { version = "1", features = ["derive"] } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } toml = "0.8" tracing = "0.1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } @@ -51,7 +51,7 @@ redb = "4" thiserror = "2" [dev-dependencies] -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } serde_json = "1" tempfile = "3" k256 = { version = "0.13.1", features = ["arithmetic"] } diff --git a/addons/fastsync/Cargo.lock b/addons/fastsync/Cargo.lock index 3044d34..7b5300a 100644 --- a/addons/fastsync/Cargo.lock +++ b/addons/fastsync/Cargo.lock @@ -535,7 +535,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "ergo-chain-types" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "base64", @@ -577,7 +577,7 @@ dependencies = [ [[package]] name = "ergo-lib" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bounded-vec", @@ -607,7 +607,7 @@ dependencies = [ [[package]] name = "ergo-merkle-tree" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "blake2", @@ -624,7 +624,7 @@ dependencies = [ [[package]] name = "ergo-nipopow" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bounded-integer", @@ -658,7 +658,7 @@ dependencies = [ [[package]] name = "ergotree-interpreter" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "blake2", @@ -691,7 +691,7 @@ dependencies = [ [[package]] name = "ergotree-ir" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bnum", @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "gf2_192" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "derive_more", "thiserror", @@ -1911,7 +1911,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "sigma-ser" version = "0.19.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "bitvec", "bounded-vec", @@ -1922,7 +1922,7 @@ dependencies = [ [[package]] name = "sigma-util" version = "0.18.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "blake2", "sha2 0.10.9", diff --git a/addons/fastsync/Cargo.toml b/addons/fastsync/Cargo.toml index 5762965..e22210a 100644 --- a/addons/fastsync/Cargo.toml +++ b/addons/fastsync/Cargo.toml @@ -8,9 +8,9 @@ description = "Fast bootstrap for ergo-node-rust via peer REST API — based on [dependencies] # Ergo types — header PoW verification, transaction serialization -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } # HTTP client — rustls so we ship static, no openssl reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/addons/indexer/Cargo.lock b/addons/indexer/Cargo.lock index cfe72ab..6914f4d 100644 --- a/addons/indexer/Cargo.lock +++ b/addons/indexer/Cargo.lock @@ -716,7 +716,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "ergo-chain-types" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "base64", @@ -769,7 +769,7 @@ dependencies = [ [[package]] name = "ergo-lib" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bounded-vec", @@ -799,7 +799,7 @@ dependencies = [ [[package]] name = "ergo-merkle-tree" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "blake2", @@ -816,7 +816,7 @@ dependencies = [ [[package]] name = "ergo-nipopow" version = "0.15.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bounded-integer", @@ -850,7 +850,7 @@ dependencies = [ [[package]] name = "ergotree-interpreter" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "blake2", @@ -883,7 +883,7 @@ dependencies = [ [[package]] name = "ergotree-ir" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "base16", "bnum", @@ -1118,7 +1118,7 @@ dependencies = [ [[package]] name = "gf2_192" version = "0.28.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "derive_more", "thiserror", @@ -2483,7 +2483,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "sigma-ser" version = "0.19.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "bitvec", "bounded-vec", @@ -2494,7 +2494,7 @@ dependencies = [ [[package]] name = "sigma-util" version = "0.18.0" -source = "git+https://github.com/mwaddip/sigma-rust.git?rev=2a7f8f4132492577e1d63dbb55bc3946e0a31f5d#2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" +source = "git+https://github.com/mwaddip/sigma-rust.git?rev=7d13d0d2fbfb9b65e96fe31c25ebfb022427814d#7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" dependencies = [ "blake2", "sha2 0.10.9", diff --git a/addons/indexer/Cargo.toml b/addons/indexer/Cargo.toml index fcdf6ad..d32eabb 100644 --- a/addons/indexer/Cargo.toml +++ b/addons/indexer/Cargo.toml @@ -14,8 +14,8 @@ jemalloc = ["dep:tikv-jemallocator", "dep:tikv-jemalloc-ctl"] [dependencies] # Ergo types — address derivation, register decoding -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } # HTTP client — rustls for static binary reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/api/Cargo.toml b/api/Cargo.toml index 99a6395..3b3d277 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -6,10 +6,10 @@ license = "MIT" description = "REST API for ergo-node-rust" [dependencies] -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } ergo-mempool = { path = "../mempool" } ergo-mining = { path = "../mining" } ergo-validation = { path = "../validation" } @@ -23,6 +23,6 @@ tracing = "0.1" blake2 = "0.10" [dev-dependencies] -ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/chain/Cargo.toml b/chain/Cargo.toml index 53e7985..b171b36 100644 --- a/chain/Cargo.toml +++ b/chain/Cargo.toml @@ -8,15 +8,15 @@ description = "Header chain validation for the Ergo Rust node" # Using fork with gen_indexes zero-modulo fix (PR pending: ergoplatform/sigma-rust). # AutolykosPowScheme not exported in published 0.15.0 — need git version. # When upstream merges, switch back to ergoplatform remote + new rev. -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } hashbrown = "0.16" lru = "0.16" num-bigint = "0.4" thiserror = "2" [dev-dependencies] -ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } serde_json = "1" diff --git a/mempool/Cargo.toml b/mempool/Cargo.toml index 2148d37..88231f3 100644 --- a/mempool/Cargo.toml +++ b/mempool/Cargo.toml @@ -7,11 +7,11 @@ description = "In-memory transaction pool for the Ergo Rust node" [dependencies] ergo-validation = { path = "../validation" } -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } serde = { version = "1", features = ["derive"] } tracing = "0.1" hex = "0.4" [dev-dependencies] -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } tempfile = "3" diff --git a/mining/Cargo.toml b/mining/Cargo.toml index 642703c..6043cce 100644 --- a/mining/Cargo.toml +++ b/mining/Cargo.toml @@ -6,12 +6,12 @@ license = "MIT" description = "Block candidate assembly and PoW solution validation for external miners" [dependencies] -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } ergo-validation = { path = "../validation" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } blake2 = "0.10" hex = "0.4" serde = { version = "1", features = ["derive"] } diff --git a/sync/Cargo.toml b/sync/Cargo.toml index 14644e9..5bd2145 100644 --- a/sync/Cargo.toml +++ b/sync/Cargo.toml @@ -19,7 +19,7 @@ tracing = "0.1" [dev-dependencies] ergo_avltree_rust = "0.1.1" -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } bytes = "1" hex = "0.4" tempfile = "3" diff --git a/validation/Cargo.toml b/validation/Cargo.toml index 302f0db..69d388a 100644 --- a/validation/Cargo.toml +++ b/validation/Cargo.toml @@ -14,9 +14,9 @@ description = "Block validation for the Ergo Rust node" json = ["ergo-lib/json", "ergo-chain-types/json"] [dependencies] -ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } ergo_avltree_rust = "0.1.1" enr-state = { path = "../state" } rayon = "1" @@ -27,6 +27,6 @@ tracing = "0.1" thiserror = "2" [dev-dependencies] -ergotree-interpreter = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } -ergotree-ir = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "2a7f8f4132492577e1d63dbb55bc3946e0a31f5d" } +ergotree-interpreter = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } +ergotree-ir = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "7d13d0d2fbfb9b65e96fe31c25ebfb022427814d" } tempfile = "3"