diff --git a/ergo-chain-generation/src/chain_generation.rs b/ergo-chain-generation/src/chain_generation.rs index 40e543507..f2f8d91bb 100644 --- a/ergo-chain-generation/src/chain_generation.rs +++ b/ergo-chain-generation/src/chain_generation.rs @@ -468,7 +468,14 @@ mod tests { suffix_head: proof.suffix_head.clone(), suffix_tail: proof.suffix_tail.clone(), }; + assert_eq!( + disconnected_proof.validate(), + Err(ergo_nipopow::NipopowValidationError::InvalidProofStructure( + "connections" + )) + ); assert!(proof.is_better_than(&disconnected_proof).unwrap()); + assert!(!disconnected_proof.is_better_than(&proof).unwrap()); } #[test] diff --git a/ergo-chain-generation/src/fake_pow_scheme.rs b/ergo-chain-generation/src/fake_pow_scheme.rs index dec9d907d..76927aa46 100644 --- a/ergo-chain-generation/src/fake_pow_scheme.rs +++ b/ergo-chain-generation/src/fake_pow_scheme.rs @@ -18,7 +18,7 @@ mod tests { use rand::{thread_rng, Rng}; use crate::{default_miner_secret, ErgoFullBlock, ExtensionCandidate}; - use ergo_merkle_tree::{MerkleNode, MerkleTree}; + use ergo_merkle_tree::{BatchMerkleProof, MerkleNode, MerkleTree}; fn generate_popowheader_chain(len: usize, start: Option) -> Vec { block_stream(start.map(|p| ErgoFullBlock { @@ -202,15 +202,37 @@ mod tests { BlockId(Digest32::from(bytes)) } - fn set_first_prefix_interlinks_with_valid_proof( + fn packed_interlink_field_value(count: u8, block_id: BlockId) -> Vec { + let mut value = vec![count]; + let block_id_bytes: Vec = block_id.0.into(); + value.extend(block_id_bytes); + value + } + + fn set_suffix_head_interlinks_with_valid_proof( proof: &mut NipopowProof, interlinks: Vec, ) { let extension = ExtensionCandidate::new(NipopowAlgos::pack_interlinks(interlinks.clone())).unwrap(); let interlinks_proof = NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); - proof.prefix[0].interlinks = interlinks; - proof.prefix[0].interlinks_proof = interlinks_proof; + let extension_root = MerkleTree::new( + extension + .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(); + proof.suffix_head.interlinks = interlinks; + proof.suffix_head.interlinks_proof = interlinks_proof; + proof.suffix_head.header.extension_root = extension_root; } #[test] @@ -221,6 +243,23 @@ mod tests { assert_eq!(verifier.best_proof(), Some(proof)); } + #[test] + fn test_generated_interlinks_have_canonical_run_structure() { + for popow_header in generate_popowheader_chain(100, None) { + assert_eq!( + popow_header.interlinks.is_empty(), + popow_header.header.height == 1 + ); + let mut run_ids = Vec::new(); + for interlink in popow_header.interlinks { + if run_ids.last() != Some(&interlink) { + assert!(!run_ids.contains(&interlink)); + run_ids.push(interlink); + } + } + } + } + #[test] fn test_nipopow_verifier_ignores_wrong_genesis() { let (_, proof) = generated_nipopow_proof(); @@ -270,27 +309,130 @@ mod tests { assert_initial_proof_ignored(genesis_id, proof); } + #[test] + fn test_nipopow_verifier_rejects_empty_non_genesis_interlinks() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + assert!(proof.suffix_head.header.height > 1); + proof.suffix_head.interlinks.clear(); + proof.suffix_head.interlinks_proof = BatchMerkleProof::new(vec![], vec![]); + assert!(!proof.suffix_head.check_interlinks_proof()); + assert_initial_proof_ignored(genesis_id, proof); + } + + #[test] + fn test_nipopow_verifier_rejects_nonempty_genesis_interlinks() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + assert_eq!(proof.prefix[0].header.height, 1); + let interlinks = vec![genesis_id]; + let extension = + ExtensionCandidate::new(NipopowAlgos::pack_interlinks(interlinks.clone())).unwrap(); + proof.prefix[0].interlinks = interlinks; + proof.prefix[0].interlinks_proof = + NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); + assert!(!proof.prefix[0].check_interlinks_proof()); + assert_initial_proof_ignored(genesis_id, proof); + } + + #[test] + fn test_nipopow_verifier_rejects_nonempty_genesis_interlinks_with_empty_proof() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + assert_eq!(proof.prefix[0].header.height, 1); + assert!(proof.prefix[0].interlinks.is_empty()); + assert!(proof.prefix[0].interlinks_proof.get_indices().is_empty()); + assert!(proof.prefix[0].interlinks_proof.get_proofs().is_empty()); + proof.prefix[0].interlinks = vec![genesis_id]; + assert!(proof.has_valid_connections()); + assert!(!proof.prefix[0].check_interlinks_proof()); + assert_initial_proof_ignored(genesis_id, proof); + } + + #[test] + fn test_nipopow_verifier_rejects_reopened_interlink_run() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + assert!(proof.suffix_head.header.height > 1); + let mut interlinks = proof.suffix_head.interlinks.clone(); + let reopened_id = interlinks[0]; + assert_ne!(interlinks.last(), Some(&reopened_id)); + interlinks.push(reopened_id); + set_suffix_head_interlinks_with_valid_proof(&mut proof, interlinks); + assert!(proof.has_valid_connections()); + assert!(proof + .suffix_head + .interlinks_proof + .valid(proof.suffix_head.header.extension_root.as_ref())); + assert!(!proof.suffix_head.check_interlinks_proof()); + assert_initial_proof_ignored(genesis_id, proof); + } + + #[test] + fn test_nipopow_verifier_rejects_reopened_intermediate_interlink_run() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + assert!(proof.suffix_head.header.height > 1); + let mut interlinks = proof.suffix_head.interlinks.clone(); + let reopened_id = indexed_block_id(1); + let separating_id = indexed_block_id(2); + assert!(!interlinks.contains(&reopened_id)); + assert!(!interlinks.contains(&separating_id)); + assert_ne!(reopened_id, separating_id); + interlinks.extend([reopened_id, separating_id, reopened_id]); + set_suffix_head_interlinks_with_valid_proof(&mut proof, interlinks); + assert!(proof.has_valid_connections()); + assert!(proof + .suffix_head + .interlinks_proof + .valid(proof.suffix_head.header.extension_root.as_ref())); + assert!(!proof.suffix_head.check_interlinks_proof()); + assert_initial_proof_ignored(genesis_id, proof); + } + #[test] fn test_nipopow_verifier_rejects_interlink_run_overflow() { let (genesis_id, mut proof) = generated_nipopow_proof(); - proof.prefix[0].interlinks = vec![genesis_id; usize::from(u8::MAX) + 1]; + assert!(proof.suffix_head.header.height > 1); + let repeated_id = indexed_block_id(1); + assert_ne!(genesis_id, repeated_id); + let interlinks = std::iter::once(genesis_id) + .chain(std::iter::repeat_n(repeated_id, usize::from(u8::MAX) + 1)) + .collect(); + // This is the exact extension that unchecked release-mode u8 + // arithmetic would produce for the overflowing run: 256 wraps to 0. + // A proof over these bytes ensures the structural guard, rather than a + // stale Merkle proof, is what makes the verifier fail closed. + let extension = ExtensionCandidate::new(vec![ + ( + [INTERLINK_VECTOR_PREFIX, 0], + packed_interlink_field_value(1, genesis_id), + ), + ( + [INTERLINK_VECTOR_PREFIX, 1], + packed_interlink_field_value(0, repeated_id), + ), + ]) + .unwrap(); + proof.suffix_head.interlinks = interlinks; + proof.suffix_head.interlinks_proof = + NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); assert!(proof.has_valid_connections()); + assert!(!proof.suffix_head.check_interlinks_proof()); assert_initial_proof_ignored(genesis_id, proof); } #[test] fn test_nipopow_verifier_rejects_interlink_key_overflow() { let (genesis_id, mut proof) = generated_nipopow_proof(); - proof.prefix[0].interlinks = (0..(usize::from(u8::MAX) + 2)) + assert!(proof.suffix_head.header.height > 1); + proof.suffix_head.interlinks = (0..(usize::from(u8::MAX) + 2)) .map(indexed_block_id) .collect(); assert!(proof.has_valid_connections()); + assert!(!proof.suffix_head.check_interlinks_proof()); assert_initial_proof_ignored(genesis_id, proof); } #[test] fn test_nipopow_verifier_rejects_interlink_key_position_overflow() { let (genesis_id, mut proof) = generated_nipopow_proof(); + assert!(proof.suffix_head.header.height > 1); let second_run_id = indexed_block_id(1); let third_run_id = indexed_block_id(2); assert_ne!(genesis_id, second_run_id); @@ -298,42 +440,40 @@ mod tests { assert_ne!(second_run_id, third_run_id); let mut interlinks = vec![genesis_id; usize::from(u8::MAX)]; interlinks.extend([second_run_id, third_run_id]); - let interlink_field_value = |count: u8, block_id: BlockId| { - let mut value = vec![count]; - let block_id_bytes: Vec = block_id.0.into(); - value.extend(block_id_bytes); - value - }; let extension = ExtensionCandidate::new(vec![ ( [INTERLINK_VECTOR_PREFIX, 0], - interlink_field_value(u8::MAX, genesis_id), + packed_interlink_field_value(u8::MAX, genesis_id), ), ( [INTERLINK_VECTOR_PREFIX, 1], - interlink_field_value(1, second_run_id), + packed_interlink_field_value(1, second_run_id), ), ( [INTERLINK_VECTOR_PREFIX, 2], - interlink_field_value(1, third_run_id), + packed_interlink_field_value(1, third_run_id), ), ]) .unwrap(); - proof.prefix[0].interlinks = interlinks; - proof.prefix[0].interlinks_proof = + proof.suffix_head.interlinks = interlinks; + proof.suffix_head.interlinks_proof = NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); assert!(proof.has_valid_connections()); + assert!(!proof.suffix_head.check_interlinks_proof()); assert_initial_proof_ignored(genesis_id, proof); } #[test] fn test_nipopow_verifier_accepts_maximum_interlink_run() { let (genesis_id, mut proof) = generated_nipopow_proof(); - set_first_prefix_interlinks_with_valid_proof( - &mut proof, - vec![genesis_id; usize::from(u8::MAX)], - ); - assert!(proof.prefix[0].check_interlinks_proof()); + assert!(proof.suffix_head.header.height > 1); + let repeated_id = indexed_block_id(1); + assert_ne!(genesis_id, repeated_id); + let interlinks = std::iter::once(genesis_id) + .chain(std::iter::repeat_n(repeated_id, usize::from(u8::MAX))) + .collect(); + set_suffix_head_interlinks_with_valid_proof(&mut proof, interlinks); + assert!(proof.suffix_head.check_interlinks_proof()); let mut verifier = NipopowVerifier::new(genesis_id); assert!(verifier.process(proof.clone()).is_ok()); assert_eq!(verifier.best_proof(), Some(proof)); @@ -342,9 +482,14 @@ mod tests { #[test] fn test_nipopow_verifier_accepts_maximum_interlink_key_count() { let (genesis_id, mut proof) = generated_nipopow_proof(); - let interlinks = (0..=usize::from(u8::MAX)).map(indexed_block_id).collect(); - set_first_prefix_interlinks_with_valid_proof(&mut proof, interlinks); - assert!(proof.prefix[0].check_interlinks_proof()); + assert!(proof.suffix_head.header.height > 1); + let interlinks: Vec<_> = std::iter::once(genesis_id) + .chain((1..=usize::from(u8::MAX)).map(indexed_block_id)) + .collect(); + assert_eq!(interlinks.len(), usize::from(u8::MAX) + 1); + assert!(!interlinks[1..].contains(&genesis_id)); + set_suffix_head_interlinks_with_valid_proof(&mut proof, interlinks); + assert!(proof.suffix_head.check_interlinks_proof()); let mut verifier = NipopowVerifier::new(genesis_id); assert!(verifier.process(proof.clone()).is_ok()); assert_eq!(verifier.best_proof(), Some(proof)); @@ -474,7 +619,14 @@ mod tests { suffix_head: proof.suffix_head.clone(), suffix_tail: proof.suffix_tail.clone(), }; + assert_eq!( + disconnected_proof.validate(), + Err(ergo_nipopow::NipopowValidationError::InvalidProofStructure( + "connections" + )) + ); assert!(proof.is_better_than(&disconnected_proof).unwrap()); + assert!(!disconnected_proof.is_better_than(&proof).unwrap()); } #[test] diff --git a/ergo-merkle-tree/src/batchmerkleproof.rs b/ergo-merkle-tree/src/batchmerkleproof.rs index 9087764dd..26b7d9f62 100644 --- a/ergo-merkle-tree/src/batchmerkleproof.rs +++ b/ergo-merkle-tree/src/batchmerkleproof.rs @@ -33,11 +33,20 @@ impl BatchMerkleProof { /// Generates root hash of proof, and compares it against expected root hash pub fn valid(&self, expected_root: &[u8]) -> bool { - fn validate( - a: &[usize], - e: &[BatchMerkleProofIndex], - m: &[crate::LevelNode], - ) -> Option> { + let mut e = self.indices.to_owned(); + e.sort_by_key(|BatchMerkleProofIndex { index, .. }| *index); + let mut a: Vec = e + .iter() + .map(|BatchMerkleProofIndex { index, .. }| *index) + .collect(); + let mut proof_position = 0; + + loop { + if a.is_empty() || e.len() != a.len() { + return false; + } + let previous_width = e.len(); + let previous_proof_position = proof_position; // For each index in a, take the value of its immediate neighbor, and store each index with its neighbor let b: Vec<(usize, usize)> = a .iter() @@ -45,11 +54,6 @@ impl BatchMerkleProof { .collect(); let mut e_new = vec![]; - let mut m_new = m.to_owned(); - // E must always have the same length as B - if e.len() != b.len() { - return None; - } let mut i = 0; // assign generated hashes to a new E that will be used for next iteration while i < b.len() { @@ -63,11 +67,10 @@ impl BatchMerkleProof { i += 2; } else { // Need an additional hash from m - let head = if !m_new.is_empty() { - m_new.remove(0) - } else { - return None; + let Some(head) = self.proofs.get(proof_position) else { + return false; }; + proof_position += 1; if head.side == NodeSide::Left { e_new.push(prefixed_hash2( @@ -88,28 +91,24 @@ impl BatchMerkleProof { let mut a_new: Vec = b.iter().map(|(_, b)| b / 2).collect(); // Generate indices for parents of current b a_new.sort_unstable(); a_new.dedup(); - // Repeat until root of tree is reached - if (!m_new.is_empty() || e_new.len() > 1) && !a_new.is_empty() { - let e: Vec = a_new - .iter() - .copied() - .zip(e_new) - .map(|(index, hash)| BatchMerkleProofIndex { index, hash }) - .collect(); - e_new = validate(&a_new, &e, &m_new)?; + + if proof_position == self.proofs.len() && e_new.len() == 1 { + return e_new[0].as_ref() == expected_root; + } + if a_new.is_empty() { + return false; + } + if proof_position == previous_proof_position && e_new.len() >= previous_width { + return false; } - Some(e_new) - } - let mut e = self.indices.to_owned(); - e.sort_by_key(|BatchMerkleProofIndex { index, .. }| *index); - let a: Vec = e - .iter() - .map(|BatchMerkleProofIndex { index, .. }| *index) - .collect(); - match validate(&a, &e, &self.proofs).as_deref() { - Some([root_hash]) => root_hash.as_ref() == expected_root, - _ => false, + e = a_new + .iter() + .copied() + .zip(e_new) + .map(|(index, hash)| BatchMerkleProofIndex { index, hash }) + .collect(); + a = a_new; } } @@ -162,33 +161,32 @@ impl ScorexSerializable for BatchMerkleProof { } let indices_len = read_u32_be(r)? as usize; let proofs_len = read_u32_be(r)? as usize; - let indices = (0..indices_len) - .map(|_| { - let index = read_u32_be(r)? as usize; - let mut hash = Digest32::zero(); - r.read_exact(&mut hash.0[..])?; - Ok(BatchMerkleProofIndex { index, hash }) - }) - .collect::, sigma_ser::ScorexParsingError>>()?; - let proofs = (0..proofs_len) - .map(|_| { - let mut hash = Digest32::zero(); - r.read_exact(&mut hash.0[..])?; - let empty = hash.as_ref().iter().all(|&b| b == 0); - let side: NodeSide = r.get_u8()?.try_into().map_err(|_| { - sigma_ser::ScorexParsingError::ValueOutOfBounds( - "Side can only be 0 or 1".into(), - ) - })?; - - if empty { - Ok(crate::LevelNode::empty_node(side)) - } else { - Ok(crate::LevelNode::new(hash, side)) - } - }) - .collect::, sigma_ser::ScorexParsingError>>()?; + // Do not reserve from untrusted counters. Grow only after each complete + // element has actually been read from the bounded input. + let mut indices = Vec::new(); + for _ in 0..indices_len { + let index = read_u32_be(r)? as usize; + let mut hash = Digest32::zero(); + r.read_exact(&mut hash.0[..])?; + indices.push(BatchMerkleProofIndex { index, hash }); + } + + let mut proofs = Vec::new(); + for _ in 0..proofs_len { + let mut hash = Digest32::zero(); + r.read_exact(&mut hash.0[..])?; + let empty = hash.as_ref().iter().all(|&b| b == 0); + let side: NodeSide = r.get_u8()?.try_into().map_err(|_| { + sigma_ser::ScorexParsingError::ValueOutOfBounds("Side can only be 0 or 1".into()) + })?; + + if empty { + proofs.push(crate::LevelNode::empty_node(side)); + } else { + proofs.push(crate::LevelNode::new(hash, side)); + } + } Ok(BatchMerkleProof::new(indices, proofs)) } } @@ -218,3 +216,49 @@ mod test { } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod iterative_validation_tests { + use super::*; + + #[test] + fn valid_preserves_deep_proof_verdicts_without_recursion() { + let leaf_hash = Digest32::zero(); + let mut expected_root = leaf_hash; + let proofs = (0..=u32::BITS) + .map(|_| { + expected_root = + prefixed_hash2(INTERNAL_PREFIX, None::<&[u8]>, expected_root.as_ref()); + LevelNode::empty_node(NodeSide::Left) + }) + .collect(); + let proof = BatchMerkleProof::new( + vec![BatchMerkleProofIndex { + index: 0, + hash: leaf_hash, + }], + proofs, + ); + + assert!(proof.valid(expected_root.as_ref())); + } + + #[test] + fn parser_preserves_invalid_shapes_for_serialization_compatibility() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&1_u32.to_be_bytes()); + bytes.extend_from_slice(&(u32::BITS + 1).to_be_bytes()); + bytes.extend_from_slice(&0_u32.to_be_bytes()); + bytes.extend_from_slice(Digest32::zero().as_ref()); + for _ in 0..=u32::BITS { + bytes.extend_from_slice(Digest32::zero().as_ref()); + bytes.push(NodeSide::Left as u8); + } + + let proof = BatchMerkleProof::scorex_parse_bytes(&bytes).unwrap(); + assert_eq!(proof.get_indices().len(), 1); + assert_eq!(proof.get_proofs().len(), (u32::BITS + 1) as usize); + assert!(!proof.valid(Digest32::zero().as_ref())); + } +} diff --git a/ergo-nipopow/Cargo.toml b/ergo-nipopow/Cargo.toml index 8b5d584b1..a3d0a4076 100644 --- a/ergo-nipopow/Cargo.toml +++ b/ergo-nipopow/Cargo.toml @@ -15,7 +15,6 @@ crate-type = ["cdylib", "rlib"] base16 = { workspace = true } bounded-integer = { workspace = true } thiserror = { workspace = true } -derive_more = { workspace = true } ergotree-ir = { workspace = true, features = ["json"] } num-bigint = { workspace = true } num-traits = { workspace = true } diff --git a/ergo-nipopow/src/lib.rs b/ergo-nipopow/src/lib.rs index caa76063a..8ac1ba3dd 100644 --- a/ergo-nipopow/src/lib.rs +++ b/ergo-nipopow/src/lib.rs @@ -23,5 +23,5 @@ mod nipopow_proof; mod nipopow_verifier; pub use nipopow_algos::{NipopowAlgos, INTERLINK_VECTOR_PREFIX}; -pub use nipopow_proof::{NipopowProof, NipopowProofError, PoPowHeader}; +pub use nipopow_proof::{NipopowProof, NipopowProofError, NipopowValidationError, PoPowHeader}; pub use nipopow_verifier::NipopowVerifier; diff --git a/ergo-nipopow/src/nipopow_algos.rs b/ergo-nipopow/src/nipopow_algos.rs index 7be94c2de..17e4e6be4 100644 --- a/ergo-nipopow/src/nipopow_algos.rs +++ b/ergo-nipopow/src/nipopow_algos.rs @@ -4,10 +4,11 @@ use ergo_chain_types::{ }, Header, }; -use num_traits::ToPrimitive; +use num_traits::{ToPrimitive, Zero}; use std::convert::TryInto; -use crate::{nipopow_proof::PoPowHeader, NipopowProof, NipopowProofError}; +use crate::nipopow_proof::{NipopowValidationError, PoPowHeader}; +use crate::{NipopowProof, NipopowProofError}; use ergo_chain_types::{BlockId, Digest32, ExtensionCandidate}; /// Prefix for Block Interlinks @@ -80,6 +81,9 @@ impl NipopowAlgos { /// /// [`KMZ17`]: https://fc20.ifca.ai/preproceedings/74.pdf pub fn best_arg(&self, chain: &[&Header], m: u32) -> Result { + if m == 0 { + return Err(AutolykosPowSchemeError::OutOfBounds); + } // Little helper struct for loop below struct Acc { level: u32, @@ -123,10 +127,12 @@ impl NipopowAlgos { if !genesis_header { // Order of the secp256k1 elliptic curve let order = order_bigint(); + let decoded_target = decode_compact_bits(header.n_bits); + if decoded_target.is_zero() { + return Err(AutolykosPowSchemeError::OutOfBounds); + } #[allow(clippy::unwrap_used)] - let required_target = (order / decode_compact_bits(header.n_bits)) - .to_f64() - .unwrap(); + let required_target = (order / decoded_target).to_f64().unwrap(); #[allow(clippy::unwrap_used)] let real_target = self.pow_scheme.pow_hit(header)?.to_f64().unwrap(); let level = required_target.log2() - real_target.log2(); @@ -170,22 +176,25 @@ impl NipopowAlgos { k: u32, m: u32, ) -> Result { - if k == 0 { - return Err(NipopowProofError::ZeroKParameter); - } - if chain.len() < ((k + m) as usize) { + NipopowProof::validate_parameters(m, k) + .map_err(NipopowValidationError::into_construction_error)?; + let k_usize = k as usize; + let m_usize = m as usize; + // Both values are at most MAX_NIPOPOW_PROOF_ELEMENTS, so this sum is + // bounded before it participates in arithmetic or slicing. + if chain.len() < k_usize + m_usize { return Err(NipopowProofError::ChainTooShort); } if chain[0].header.height != 1 { return Err(NipopowProofError::NonAnchoredChain); } - let suffix = chain[(chain.len() - (k as usize))..].to_vec(); + let suffix = chain[(chain.len() - k_usize)..].to_vec(); let suffix_head = suffix[0].clone(); let suffix_tail: Vec
= suffix[1..].iter().map(|p| p.header.clone()).collect(); #[allow(clippy::unwrap_used)] - let max_level: i32 = if chain.len() > (k as usize) { - (chain[..(chain.len() - (k as usize))] + let max_level: i32 = if chain.len() > k_usize { + (chain[..(chain.len() - k_usize)] .last() .unwrap() .interlinks @@ -203,15 +212,15 @@ impl NipopowAlgos { // C[:−k]{B:}↑µ let mut sub_chain = vec![]; - for p in &chain[..(chain.len() - (k as usize))] { + for p in &chain[..(chain.len() - k_usize)] { let max_level = self.max_level_of(&p.header)?; if max_level >= level && p.header.height >= anchoring_point.header.height { sub_chain.push(p.clone()); } } - if (m as usize) < sub_chain.len() { - stack.push((sub_chain[sub_chain.len() - (m as usize)].clone(), level - 1)); + if m_usize < sub_chain.len() { + stack.push((sub_chain[sub_chain.len() - m_usize].clone(), level - 1)); } else { stack.push((anchoring_point, level - 1)); } @@ -396,6 +405,7 @@ fn extension_merkletree(kv: &[([u8; 2], Vec)]) -> ergo_merkle_tree::MerkleTr #[cfg(test)] mod tests { use super::*; + use ergo_chain_types::{ADDigest, AutolykosSolution, EcPoint, Votes}; fn blockid(byte: u8) -> BlockId { BlockId(Digest32::from([byte; 32])) @@ -433,4 +443,58 @@ mod tests { let packed = NipopowAlgos::pack_interlinks(vec![]); assert!(packed.is_empty()); } + + #[test] + fn best_arg_rejects_zero_m_without_looping() { + use std::sync::mpsc; + use std::time::Duration; + + let (sender, receiver) = mpsc::channel(); + let _worker = + std::thread::spawn(move || sender.send(NipopowAlgos::default().best_arg(&[], 0))); + + assert_eq!( + receiver.recv_timeout(Duration::from_secs(1)), + Ok(Err(AutolykosPowSchemeError::OutOfBounds)) + ); + } + + #[test] + fn max_level_rejects_zero_decoded_target() { + let header = Header { + version: 2, + id: blockid(1), + parent_id: blockid(0), + ad_proofs_root: Digest32::zero(), + state_root: ADDigest::zero(), + transaction_root: Digest32::zero(), + timestamp: 0, + n_bits: 0, + height: 2, + extension_root: Digest32::zero(), + autolykos_solution: AutolykosSolution { + miner_pk: Box::::default(), + pow_onetime_pk: None, + nonce: vec![0; 8], + pow_distance: None, + }, + votes: Votes([0, 0, 0]), + unparsed_bytes: Box::new([]), + }; + + assert_eq!( + NipopowAlgos::default().max_level_of(&header), + Err(AutolykosPowSchemeError::OutOfBounds) + ); + } + + #[test] + fn prove_rejects_parameter_sum_overflow_before_slicing() { + assert_eq!( + NipopowAlgos::default().prove(&[], u32::MAX, 1), + Err(NipopowProofError::AutolykosPowSchemeError( + AutolykosPowSchemeError::OutOfBounds + )) + ); + } } diff --git a/ergo-nipopow/src/nipopow_proof.rs b/ergo-nipopow/src/nipopow_proof.rs index a0e68c5df..34cac3273 100644 --- a/ergo-nipopow/src/nipopow_proof.rs +++ b/ergo-nipopow/src/nipopow_proof.rs @@ -1,14 +1,103 @@ -use derive_more::From; use ergo_chain_types::{autolykos_pow_scheme::AutolykosPowSchemeError, BlockId, Header}; -use ergo_merkle_tree::BatchMerkleProof; +use ergo_merkle_tree::{BatchMerkleProof, MerkleNode}; use serde::{Deserialize, Serialize}; use sigma_ser::{ vlq_encode::{ReadSigmaVlqExt, WriteSigmaVlqExt}, ScorexParsingError, ScorexSerializable, ScorexSerializeResult, }; +use std::collections::HashSet; use crate::nipopow_algos::NipopowAlgos; +/// Upper bound for prefix/suffix element counts and proof parameters. +/// Real proofs never exceed a few hundred entries; 20 000 is generous. +const MAX_NIPOPOW_PROOF_ELEMENTS: usize = 20_000; +/// Upper bound for a serialized header nested inside a `PoPowHeader`. +const MAX_POPOW_HEADER_BYTES: usize = 10_000; +/// Upper bound for the number of interlinks nested inside a `PoPowHeader`. +const MAX_POPOW_INTERLINKS: usize = 10_000; +/// Upper bound for a serialized interlinks proof frame. +const MAX_POPOW_PROOF_BYTES: usize = 1_000_000; +/// A serialized Merkle index is a `u32`, so a representable binary tree has at +/// most 32 parent levels. At each level, each disclosed index can require at +/// most one sibling proof node. +const MAX_MERKLE_PROOF_LEVELS: usize = u32::BITS as usize; +/// Upper bound for one serialized `PoPowHeader` element frame. +const MAX_POPOW_HEADER_ELEMENT_BYTES: usize = + MAX_POPOW_HEADER_BYTES + MAX_POPOW_INTERLINKS * 32 + MAX_POPOW_PROOF_BYTES + 64; + +/// Read one length-declared element frame. +/// +/// The declared size owns the outer stream boundary: exactly that many bytes +/// are consumed before parsing the element. The nested parser may leave +/// trailing bytes inside its private slice, matching JVM `parseBytes` behavior. +fn read_framed( + r: &mut R, + max_bytes: usize, + what: &str, +) -> Result { + let size = r.get_u32()? as usize; + if size > max_bytes { + return Err(ScorexParsingError::Io(format!( + "{what} declared size {size} exceeds sanity limit {max_bytes}" + ))); + } + let mut buf = vec![0; size]; + r.read_exact(&mut buf)?; + T::scorex_parse_bytes(&buf) +} + +fn validate_batch_merkle_proof_frame(bytes: &[u8]) -> Result<(), ScorexParsingError> { + const COUNT_BYTES: usize = 8; + const INDEX_BYTES: usize = 36; + const PROOF_BYTES: usize = 33; + + let counts = bytes.get(..COUNT_BYTES).ok_or_else(|| { + ScorexParsingError::ValueOutOfBounds( + "BatchMerkleProof counts do not fit declared proof frame".into(), + ) + })?; + let indices_len = u32::from_be_bytes([counts[0], counts[1], counts[2], counts[3]]) as usize; + let proofs_len = u32::from_be_bytes([counts[4], counts[5], counts[6], counts[7]]) as usize; + let indices_bytes = indices_len.checked_mul(INDEX_BYTES).ok_or_else(|| { + ScorexParsingError::ValueOutOfBounds( + "BatchMerkleProof index count does not fit declared proof frame".into(), + ) + })?; + let proofs_bytes = proofs_len.checked_mul(PROOF_BYTES).ok_or_else(|| { + ScorexParsingError::ValueOutOfBounds( + "BatchMerkleProof proof count does not fit declared proof frame".into(), + ) + })?; + let required_bytes = COUNT_BYTES + .checked_add(indices_bytes) + .and_then(|size| size.checked_add(proofs_bytes)) + .ok_or_else(|| { + ScorexParsingError::ValueOutOfBounds( + "BatchMerkleProof counts do not fit declared proof frame".into(), + ) + })?; + if required_bytes != bytes.len() { + return Err(ScorexParsingError::ValueOutOfBounds(format!( + "BatchMerkleProof encoded length requires {required_bytes} bytes and does not fit declared proof frame of {} bytes", + bytes.len() + ))); + } + let max_proof_nodes = indices_len + .checked_mul(MAX_MERKLE_PROOF_LEVELS) + .ok_or_else(|| { + ScorexParsingError::ValueOutOfBounds( + "BatchMerkleProof structural proof-node bound overflow".into(), + ) + })?; + if proofs_len > max_proof_nodes { + return Err(ScorexParsingError::ValueOutOfBounds(format!( + "BatchMerkleProof proof count {proofs_len} exceeds structural limit {max_proof_nodes} for {indices_len} disclosed indices" + ))); + } + Ok(()) +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] /// A structure representing NiPoPow proof as a persistent modifier. pub struct NipopowProof { @@ -41,50 +130,106 @@ impl NipopowProof { suffix_head: PoPowHeader, suffix_tail: Vec
, ) -> Result { - if k >= 1 { - Ok(NipopowProof { - popow_algos: NipopowAlgos::default(), - m, - k, - prefix, - suffix_head, - suffix_tail, - }) - } else { - Err(NipopowProofError::ZeroKParameter) + Self::validate_parameters(m, k).map_err(NipopowValidationError::into_construction_error)?; + Self::validate_suffix_length(k, suffix_tail.len()) + .map_err(NipopowValidationError::into_construction_error)?; + Ok(NipopowProof { + popow_algos: NipopowAlgos::default(), + m, + k, + prefix, + suffix_head, + suffix_tail, + }) + } + + /// Validate proof parameters before any parameter-dependent allocation, + /// arithmetic, loop, or indexing. + pub(crate) fn validate_parameters(m: u32, k: u32) -> Result<(), NipopowValidationError> { + if m == 0 || m > MAX_NIPOPOW_PROOF_ELEMENTS as u32 { + return Err(NipopowValidationError::InvalidMParameter(m)); } + if k == 0 { + return Err(NipopowValidationError::ZeroKParameter); + } + if k > MAX_NIPOPOW_PROOF_ELEMENTS as u32 { + return Err(NipopowValidationError::InvalidKParameter(k)); + } + Ok(()) + } + + fn validate_suffix_length( + k: u32, + suffix_tail_len: usize, + ) -> Result<(), NipopowValidationError> { + let actual = u32::try_from(suffix_tail_len) + .ok() + .and_then(|len| len.checked_add(1)) + .ok_or(NipopowValidationError::SuffixLengthMismatch { + expected: k, + actual: suffix_tail_len.saturating_add(1), + })?; + if actual != k { + return Err(NipopowValidationError::SuffixLengthMismatch { + expected: k, + actual: actual as usize, + }); + } + Ok(()) } /// Implementation of the ≥ algorithm from [`KMZ17`], see Algorithm 4 /// /// [`KMZ17`]: https://fc20.ifca.ai/preproceedings/74.pdf pub fn is_better_than(&self, that: &NipopowProof) -> Result { - if self.is_valid() && that.is_valid() { - if let Some(lca) = self.popow_algos.lowest_common_ancestor( - &self.headers_chain().collect::>(), - &that.headers_chain().collect::>(), - ) { - let self_headers = self - .headers_chain() - .filter(|h| h.height > lca.height) - .collect::>(); - let that_headers = that - .headers_chain() - .filter(|h| h.height > lca.height) - .collect::>(); - Ok(self.popow_algos.best_arg(&self_headers, self.m)? - > self.popow_algos.best_arg(&that_headers, self.m)?) - } else { - Ok(false) - } + let self_is_valid = self.validate().is_ok(); + let that_is_valid = that.validate().is_ok(); + if !self_is_valid || !that_is_valid { + return Ok(self_is_valid); + } + if (self.m, self.k) != (that.m, that.k) { + return Ok(false); + } + if let Some(lca) = self.popow_algos.lowest_common_ancestor( + &self.headers_chain().collect::>(), + &that.headers_chain().collect::>(), + ) { + let self_headers = self + .headers_chain() + .filter(|h| h.height > lca.height) + .collect::>(); + let that_headers = that + .headers_chain() + .filter(|h| h.height > lca.height) + .collect::>(); + Ok(self.popow_algos.best_arg(&self_headers, self.m)? + > self.popow_algos.best_arg(&that_headers, that.m)?) } else { - Ok(self.is_valid()) + Ok(false) } } - /// Returns whether this proof satisfies the current structural validity checks. - pub(crate) fn is_valid(&self) -> bool { - self.has_valid_connections() && self.has_valid_heights() && self.has_valid_proofs() + /// Validate parameters, suffix cardinality, chain structure, and interlink proofs. + pub fn validate(&self) -> Result<(), NipopowValidationError> { + Self::validate_parameters(self.m, self.k)?; + Self::validate_suffix_length(self.k, self.suffix_tail.len())?; + if !self.has_valid_connections() { + return Err(NipopowValidationError::InvalidProofStructure("connections")); + } + if !self.has_valid_heights() { + return Err(NipopowValidationError::InvalidProofStructure("heights")); + } + if !self.has_valid_proofs() { + return Err(NipopowValidationError::InvalidProofStructure( + "interlink proofs", + )); + } + Ok(()) + } + + /// Returns whether this proof passes [`NipopowProof::validate`]. + pub fn is_valid(&self) -> bool { + self.validate().is_ok() } /// Checks the connections of the blocks in the proof. @@ -215,37 +360,90 @@ impl ScorexSerializable for NipopowProof { fn scorex_parse(r: &mut R) -> Result { let m = r.get_u32()?; let k = r.get_u32()?; + Self::validate_parameters(m, k) + .map_err(|err| ScorexParsingError::ValueOutOfBounds(err.to_string()))?; let num_prefixes = r.get_u32()? as usize; + if num_prefixes > MAX_NIPOPOW_PROOF_ELEMENTS { + return Err(ScorexParsingError::Io( + "num_prefixes exceeds sanity limit".into(), + )); + } let mut prefix = Vec::with_capacity(num_prefixes); for _ in 0..num_prefixes { - let _size = r.get_u32()?; - prefix.push(PoPowHeader::scorex_parse(r)?); + prefix.push(read_framed( + r, + MAX_POPOW_HEADER_ELEMENT_BYTES, + "prefix element", + )?); } - let _suffix_head_size = r.get_u32()?; - let suffix_head = PoPowHeader::scorex_parse(r)?; + let suffix_head = read_framed(r, MAX_POPOW_HEADER_ELEMENT_BYTES, "suffix head")?; let num_suffix_tail = r.get_u32()? as usize; + if num_suffix_tail > MAX_NIPOPOW_PROOF_ELEMENTS { + return Err(ScorexParsingError::Io( + "num_suffix_tail exceeds sanity limit".into(), + )); + } + Self::validate_suffix_length(k, num_suffix_tail) + .map_err(|err| ScorexParsingError::ValueOutOfBounds(err.to_string()))?; let mut suffix_tail = Vec::with_capacity(num_suffix_tail); for _ in 0..num_suffix_tail { - let _size = r.get_u32(); - suffix_tail.push(Header::scorex_parse(r)?); + suffix_tail.push(read_framed( + r, + MAX_POPOW_HEADER_BYTES, + "suffix-tail header", + )?); } - Ok(NipopowProof { - popow_algos: NipopowAlgos::default(), - m, - k, - prefix, - suffix_head, - suffix_tail, - }) + Self::new(m, k, prefix, suffix_head, suffix_tail) + .map_err(|err| ScorexParsingError::ValueOutOfBounds(err.to_string())) } } -/// `NipopowProof` errors -#[derive(PartialEq, Eq, Debug, Clone, From, thiserror::Error)] +/// Detailed validation failures for a `NipopowProof`. +#[non_exhaustive] +#[derive(PartialEq, Eq, Debug, Clone, thiserror::Error)] +pub enum NipopowValidationError { + /// `m` is outside the supported proof-resource range. + #[error("m parameter {0} must be in 1..=20000")] + InvalidMParameter(u32), + /// `k` parameter == 0. Must be >= 1. + #[error("k parameter == 0. Must be >= 1")] + ZeroKParameter, + /// `k` exceeds the supported proof-resource range. + #[error("k parameter {0} must be in 1..=20000")] + InvalidKParameter(u32), + /// Declared `k` does not match the number of suffix headers. + #[error("suffix length {actual} does not match k parameter {expected}")] + SuffixLengthMismatch { + /// Declared suffix length. + expected: u32, + /// Actual suffix length, including the suffix head. + actual: usize, + }, + /// A structural or cryptographic proof predicate failed. + #[error("invalid NiPoPoW proof structure: {0}")] + InvalidProofStructure(&'static str), +} + +impl NipopowValidationError { + pub(crate) fn into_construction_error(self) -> NipopowProofError { + match self { + Self::ZeroKParameter => NipopowProofError::ZeroKParameter, + Self::InvalidMParameter(_) + | Self::InvalidKParameter(_) + | Self::SuffixLengthMismatch { .. } + | Self::InvalidProofStructure(_) => { + NipopowProofError::AutolykosPowSchemeError(AutolykosPowSchemeError::OutOfBounds) + } + } + } +} + +/// `NipopowProof` construction and comparison errors. +#[derive(PartialEq, Eq, Debug, Clone, thiserror::Error)] pub enum NipopowProofError { /// Errors from `AutolykosPowScheme` #[error("{0:?}")] - AutolykosPowSchemeError(AutolykosPowSchemeError), + AutolykosPowSchemeError(#[from] AutolykosPowSchemeError), /// `k` parameter == 0. Must be >= 1. #[error("k parameter == 0. Must be >= 1")] ZeroKParameter, @@ -271,13 +469,14 @@ pub struct PoPowHeader { } impl PoPowHeader { - fn has_packable_interlinks(&self) -> bool { + fn has_canonical_interlink_runs(&self) -> bool { let Some(mut current) = self.interlinks.first() else { return false; }; let max_run_length = usize::from(u8::MAX); let max_key_position = usize::from(u8::MAX); let mut run_length = 1usize; + let mut closed_runs = HashSet::new(); for (position, interlink) in self.interlinks.iter().enumerate().skip(1) { if interlink == current { @@ -289,6 +488,12 @@ impl PoPowHeader { if position > max_key_position { return false; } + // Canonical update_interlinks output never reopens an id after + // a different run. + closed_runs.insert(*current); + if closed_runs.contains(interlink) { + return false; + } current = interlink; run_length = 1; } @@ -296,30 +501,41 @@ impl PoPowHeader { true } - /// Validates interlinks merkle root against provided proof + /// Validates the exact packed interlink leaves against the full extension root pub fn check_interlinks_proof(&self) -> bool { let proof_is_empty = self.interlinks_proof.get_indices().is_empty() && self.interlinks_proof.get_proofs().is_empty(); + if self.header.height == 1 { + return self.interlinks.is_empty() && proof_is_empty; + } if self.interlinks.is_empty() { - return proof_is_empty; + return false; } - if !self.has_packable_interlinks() { + if !self.has_canonical_interlink_runs() { return false; } - let fields: Vec = - NipopowAlgos::pack_interlinks(self.interlinks.clone()) - .into_iter() - .map(|(k, v)| -> Vec { - std::iter::once(2u8) - .chain(k.iter().copied()) - .chain(v) - .collect() - }) - .map(ergo_merkle_tree::MerkleNode::from_bytes) - .collect(); - let tree = ergo_merkle_tree::MerkleTree::new(fields); - self.interlinks_proof.valid(tree.root_hash().as_ref()) + let expected_leaf_hashes: Vec<_> = NipopowAlgos::pack_interlinks(self.interlinks.clone()) + .into_iter() + .map(|(key, value)| -> Vec { + std::iter::once(2u8) + .chain(key.iter().copied()) + .chain(value) + .collect() + }) + .map(MerkleNode::from_bytes) + .filter_map(|node| node.get_hash().copied()) + .collect(); + let proven_leaves = self.interlinks_proof.get_indices(); + + expected_leaf_hashes.len() == proven_leaves.len() + && expected_leaf_hashes + .iter() + .zip(proven_leaves) + .all(|(expected, proven)| expected == &proven.hash) + && self + .interlinks_proof + .valid(self.header.extension_root.as_ref()) } } @@ -340,12 +556,22 @@ impl ScorexSerializable for PoPowHeader { } fn scorex_parse(r: &mut R) -> Result { - let header_size = r.get_u32()?; - let mut buf = vec![0; header_size as usize]; + let header_size = r.get_u32()? as usize; + if header_size > MAX_POPOW_HEADER_BYTES { + return Err(ScorexParsingError::Io( + "header_size exceeds sanity limit".into(), + )); + } + let mut buf = vec![0; header_size]; r.read_exact(&mut buf)?; let header = Header::scorex_parse(&mut std::io::Cursor::new(buf))?; - let interlinks_size = r.get_u32()?; + let interlinks_size = r.get_u32()? as usize; + if interlinks_size > MAX_POPOW_INTERLINKS { + return Err(ScorexParsingError::Io( + "interlinks_size exceeds sanity limit".into(), + )); + } let interlinks: Result, ScorexParsingError> = (0..interlinks_size) .map(|_| { @@ -356,8 +582,14 @@ impl ScorexSerializable for PoPowHeader { .collect(); let proof_bytes = r.get_u32()? as usize; + if proof_bytes > MAX_POPOW_PROOF_BYTES { + return Err(ScorexParsingError::Io( + "proof_bytes exceeds sanity limit".into(), + )); + } let mut proof_buf = vec![0u8; proof_bytes]; r.read_exact(&mut proof_buf)?; + validate_batch_merkle_proof_frame(&proof_buf)?; let interlinks_proof = BatchMerkleProof::scorex_parse_bytes(&proof_buf); Ok(Self { @@ -404,19 +636,21 @@ mod arbitrary { fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy { ( - any::(), - any::(), + 1_u32..=MAX_NIPOPOW_PROOF_ELEMENTS as u32, vec(any::(), 1..10), any::(), - vec(any::
(), 1..10), + vec(any::
(), 0..10), ) - .prop_map(|(m, k, prefix, suffix_head, suffix_tail)| NipopowProof { - popow_algos: NipopowAlgos::default(), - m, - k, - prefix, - suffix_head, - suffix_tail, + .prop_map(|(m, prefix, suffix_head, suffix_tail)| { + let k = suffix_tail.len() as u32 + 1; + NipopowProof { + popow_algos: NipopowAlgos::default(), + m, + k, + prefix, + suffix_head, + suffix_tail, + } }) .boxed() } @@ -424,26 +658,30 @@ mod arbitrary { } #[cfg(test)] -#[cfg(feature = "arbitrary")] #[allow(clippy::unwrap_used, clippy::panic)] pub mod tests { use super::*; - use ergo_chain_types::Digest32; - use ergo_merkle_tree::BatchMerkleProof; - use proptest::prelude::*; - use proptest::strategy::ValueTree; - use proptest::test_runner::TestRunner; - use sigma_ser::scorex_serialize_roundtrip; - proptest! { + use ergo_chain_types::{ + ADDigest, AutolykosSolution, Digest32, EcPoint, ExtensionCandidate, Votes, + }; + use ergo_merkle_tree::{BatchMerkleProof, MerkleNode, MerkleTree, NodeSide}; - #![proptest_config(ProptestConfig::with_cases(64))] + #[cfg(feature = "arbitrary")] + mod property_tests { + use super::*; + use proptest::prelude::*; + use sigma_ser::scorex_serialize_roundtrip; - #[test] - fn nipopowproof_roundtrip(v in any::()) { - prop_assert_eq![scorex_serialize_roundtrip(&v), v]; - } + proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + #[test] + fn nipopowproof_roundtrip(v in any::()) { + prop_assert_eq![scorex_serialize_roundtrip(&v), v]; + } + + } } /// Build a `BlockId` filled with the given byte. Used by the @@ -453,20 +691,30 @@ pub mod tests { BlockId(Digest32::from([byte; 32])) } - /// Generate one valid base `Header` via the proptest `Arbitrary` impl, - /// then return a customizing closure. The closure rewrites the - /// `id`, `parent_id`, and `height` fields without rebuilding the rest - /// of the (irrelevant-to-this-test) header content. We only need the - /// three fields above because `has_valid_connections` only inspects - /// `header.id`, `header.parent_id`, and `PoPowHeader::interlinks`. + /// Return a deterministic header template that callers can customize. fn header_factory() -> impl Fn(BlockId, BlockId, u32) -> Header { - let mut runner = TestRunner::default(); - let base = any::>() - .new_tree(&mut runner) - .unwrap() - .current(); + let base = Header { + version: 2, + id: id_from_byte(0), + parent_id: id_from_byte(0), + ad_proofs_root: Digest32::zero(), + state_root: ADDigest::zero(), + transaction_root: Digest32::zero(), + timestamp: 0, + n_bits: 0, + height: 1, + extension_root: Digest32::zero(), + autolykos_solution: AutolykosSolution { + miner_pk: Box::::default(), + pow_onetime_pk: None, + nonce: vec![0; 8], + pow_distance: None, + }, + votes: Votes([0, 0, 0]), + unparsed_bytes: Box::new([]), + }; move |id, parent_id, height| { - let mut h = (*base).clone(); + let mut h = base.clone(); h.id = id; h.parent_id = parent_id; h.height = height; @@ -486,6 +734,253 @@ pub mod tests { } } + fn extension_tree(fields: &[([u8; 2], Vec)]) -> MerkleTree { + MerkleTree::new( + fields + .iter() + .map(|(key, value)| { + let leaf: Vec = std::iter::once(2u8) + .chain(key.iter().copied()) + .chain(value.iter().copied()) + .collect(); + MerkleNode::from_bytes(leaf) + }) + .collect::>(), + ) + } + + /// Build a structurally valid proof whose suffix contains exactly `k` + /// headers. A genesis suffix head needs neither interlinks nor a Merkle + /// proof, so this fixture isolates parameter validation from cryptography. + pub(crate) fn valid_proof(m: u32, k: u32) -> NipopowProof { + assert!(k >= 1); + let mk_header = header_factory(); + let suffix_head_id = id_from_byte(0x40); + let suffix_head = pop_header(mk_header(suffix_head_id, id_from_byte(0), 1), vec![]); + let mut suffix_tail = Vec::new(); + let mut parent_id = suffix_head_id; + for index in 1..k { + let id = id_from_byte(0x40_u8.wrapping_add(index as u8)); + suffix_tail.push(mk_header(id, parent_id, index + 1)); + parent_id = id; + } + NipopowProof { + popow_algos: NipopowAlgos::default(), + m, + k, + prefix: vec![], + suffix_head, + suffix_tail, + } + } + + fn legacy_out_of_bounds_error() -> NipopowProofError { + NipopowProofError::AutolykosPowSchemeError(AutolykosPowSchemeError::OutOfBounds) + } + + #[test] + fn new_rejects_zero_m() { + let proof = valid_proof(1, 1); + assert_eq!( + NipopowProof::new( + 0, + proof.k, + proof.prefix, + proof.suffix_head, + proof.suffix_tail, + ) + .unwrap_err(), + legacy_out_of_bounds_error() + ); + } + + #[test] + fn parameter_bounds_accept_sanity_limit() { + let max = MAX_NIPOPOW_PROOF_ELEMENTS as u32; + assert_eq!(NipopowProof::validate_parameters(max, max), Ok(())); + } + + #[test] + fn new_rejects_zero_k() { + let proof = valid_proof(1, 1); + assert_eq!( + NipopowProof::new( + proof.m, + 0, + proof.prefix, + proof.suffix_head, + proof.suffix_tail, + ) + .unwrap_err(), + NipopowProofError::ZeroKParameter + ); + } + + #[test] + fn new_rejects_m_above_sanity_limit() { + let proof = valid_proof(1, 1); + assert_eq!( + NipopowProof::new( + 20_001, + proof.k, + proof.prefix, + proof.suffix_head, + proof.suffix_tail, + ) + .unwrap_err(), + legacy_out_of_bounds_error() + ); + } + + #[test] + fn new_rejects_k_above_sanity_limit() { + let proof = valid_proof(1, 1); + assert_eq!( + NipopowProof::new( + proof.m, + 20_001, + proof.prefix, + proof.suffix_head, + proof.suffix_tail, + ) + .unwrap_err(), + legacy_out_of_bounds_error() + ); + } + + #[test] + fn new_rejects_suffix_length_not_equal_to_k() { + let proof = valid_proof(6, 1); + assert_eq!( + NipopowProof::new( + proof.m, + 2, + proof.prefix, + proof.suffix_head, + proof.suffix_tail, + ) + .unwrap_err(), + legacy_out_of_bounds_error() + ); + } + + #[test] + fn parser_rejects_zero_m() { + let bytes = valid_proof(0, 1).scorex_serialize_bytes().unwrap(); + assert!(NipopowProof::scorex_parse_bytes(&bytes).is_err()); + } + + #[test] + fn parser_rejects_suffix_length_not_equal_to_k() { + let mut proof = valid_proof(6, 2); + proof.k = 1; + let bytes = proof.scorex_serialize_bytes().unwrap(); + let mut cursor = std::io::Cursor::new(bytes.as_slice()); + assert!(NipopowProof::scorex_parse(&mut cursor).is_err()); + assert!( + cursor.position() < bytes.len() as u64, + "suffix mismatch must be rejected before parsing tail elements" + ); + } + + #[test] + fn comparison_rejects_different_m() { + assert!(!valid_proof(6, 1) + .is_better_than(&valid_proof(7, 1)) + .unwrap()); + } + + #[test] + fn comparison_rejects_different_k() { + assert!(!valid_proof(6, 1) + .is_better_than(&valid_proof(6, 2)) + .unwrap()); + } + + #[test] + fn comparison_prefers_valid_proof_to_invalid_proof() { + let valid = valid_proof(6, 2); + let mut invalid = valid.clone(); + invalid.suffix_tail[0].parent_id = id_from_byte(0xff); + + assert!(valid.is_better_than(&invalid).unwrap()); + assert!(!invalid.is_better_than(&valid).unwrap()); + } + + #[test] + fn validate_accepts_consistent_proof() { + let proof = valid_proof(6, 2); + assert_eq!(proof.validate(), Ok(())); + assert!(proof.is_valid()); + } + + #[test] + fn validate_rejects_zero_m() { + let proof = valid_proof(0, 1); + assert_eq!( + proof.validate(), + Err(NipopowValidationError::InvalidMParameter(0)) + ); + } + + #[test] + fn validate_rejects_zero_k() { + let mut proof = valid_proof(6, 1); + proof.k = 0; + assert_eq!( + proof.validate(), + Err(NipopowValidationError::ZeroKParameter) + ); + } + + #[test] + fn validate_rejects_suffix_length_not_equal_to_k() { + let mut proof = valid_proof(6, 1); + proof.k = 2; + assert_eq!( + proof.validate(), + Err(NipopowValidationError::SuffixLengthMismatch { + expected: 2, + actual: 1, + }) + ); + } + + #[test] + fn validate_rejects_connection_failure() { + let mut proof = valid_proof(6, 2); + proof.suffix_tail[0].parent_id = id_from_byte(0xff); + assert_eq!( + proof.validate(), + Err(NipopowValidationError::InvalidProofStructure("connections")) + ); + assert!(!proof.is_valid()); + } + + #[test] + fn validate_rejects_height_failure() { + let mut proof = valid_proof(6, 2); + proof.suffix_tail[0].height = 1; + assert!(proof.has_valid_connections()); + assert_eq!( + proof.validate(), + Err(NipopowValidationError::InvalidProofStructure("heights")) + ); + } + + #[test] + fn validate_rejects_interlink_proof_failure() { + let mut proof = valid_proof(6, 1); + proof.suffix_head.header.height = 2; + assert!(proof.has_valid_connections()); + assert_eq!( + proof.validate(), + Err(NipopowValidationError::InvalidProofStructure( + "interlink proofs" + )) + ); + } + /// Constructs a deliberately-skipped prefix and asserts the JVM-tolerant /// `has_valid_connections` accepts it. /// @@ -607,4 +1102,827 @@ pub mod tests { rejected after the prefix-tolerance fix" ); } + + #[test] + fn full_root_accepts_a_complete_mixed_extension_proof() { + let interlinks = vec![id_from_byte(0x11), id_from_byte(0x22)]; + let mut fields = NipopowAlgos::pack_interlinks(interlinks.clone()); + fields.push(([0x02, 0x00], vec![0x01])); + let extension = ExtensionCandidate::new(fields.clone()).unwrap(); + let proof = NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); + let full_root = extension_tree(&fields).root_hash(); + assert!(proof.valid(full_root.as_ref())); + + let make_header = header_factory(); + let mut header = make_header(id_from_byte(0x31), id_from_byte(0x30), 2); + header.extension_root = full_root; + let popow_header = PoPowHeader { + header, + interlinks, + interlinks_proof: proof, + }; + + assert!(popow_header.check_interlinks_proof()); + } + + #[test] + fn full_root_rejects_a_one_byte_header_root_mutation() { + let interlinks = vec![id_from_byte(0x11), id_from_byte(0x22)]; + let mut fields = NipopowAlgos::pack_interlinks(interlinks.clone()); + fields.push(([0x02, 0x00], vec![0x01])); + let extension = ExtensionCandidate::new(fields.clone()).unwrap(); + let proof = NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); + let full_root = extension_tree(&fields).root_hash(); + let mut wrong_root = full_root; + wrong_root.0[0] ^= 1; + + let make_header = header_factory(); + let mut header = make_header(id_from_byte(0x31), id_from_byte(0x30), 2); + header.extension_root = wrong_root; + let popow_header = PoPowHeader { + header, + interlinks, + interlinks_proof: proof, + }; + + assert!(!popow_header.check_interlinks_proof()); + } + + #[test] + fn full_root_rejects_an_interlink_mutation_with_the_original_proof() { + let interlinks = vec![id_from_byte(0x11), id_from_byte(0x22)]; + let mut fields = NipopowAlgos::pack_interlinks(interlinks.clone()); + fields.push(([0x02, 0x00], vec![0x01])); + let extension = ExtensionCandidate::new(fields.clone()).unwrap(); + let proof = NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); + let full_root = extension_tree(&fields).root_hash(); + + let make_header = header_factory(); + let mut header = make_header(id_from_byte(0x31), id_from_byte(0x30), 2); + header.extension_root = full_root; + let popow_header = PoPowHeader { + header, + interlinks: vec![id_from_byte(0x11), id_from_byte(0x33)], + interlinks_proof: proof, + }; + + assert!(!popow_header.check_interlinks_proof()); + } + + #[test] + fn full_root_rejects_an_incomplete_interlink_disclosure() { + let interlinks = vec![id_from_byte(0x11), id_from_byte(0x22)]; + let mut fields = NipopowAlgos::pack_interlinks(interlinks.clone()); + let first_interlink_key = fields[0].0; + fields.push(([0x02, 0x00], vec![0x01])); + let extension = ExtensionCandidate::new(fields.clone()).unwrap(); + let incomplete_proof = + NipopowAlgos::extension_batch_proof_for(&extension, &[first_interlink_key]).unwrap(); + let full_root = extension_tree(&fields).root_hash(); + assert!(incomplete_proof.valid(full_root.as_ref())); + + let make_header = header_factory(); + let mut header = make_header(id_from_byte(0x31), id_from_byte(0x30), 2); + header.extension_root = full_root; + let popow_header = PoPowHeader { + header, + interlinks, + interlinks_proof: incomplete_proof, + }; + + assert!(!popow_header.check_interlinks_proof()); + } + + #[test] + fn full_root_rejects_an_extra_disclosed_extension_leaf() { + let interlinks = vec![id_from_byte(0x11), id_from_byte(0x22)]; + let mut fields = NipopowAlgos::pack_interlinks(interlinks.clone()); + fields.push(([0x02, 0x00], vec![0x01])); + let extension = ExtensionCandidate::new(fields.clone()).unwrap(); + let all_keys: Vec<_> = fields.iter().map(|(key, _)| *key).collect(); + let overcomplete_proof = + NipopowAlgos::extension_batch_proof_for(&extension, &all_keys).unwrap(); + let full_root = extension_tree(&fields).root_hash(); + assert!(overcomplete_proof.valid(full_root.as_ref())); + + let make_header = header_factory(); + let mut header = make_header(id_from_byte(0x31), id_from_byte(0x30), 2); + header.extension_root = full_root; + let popow_header = PoPowHeader { + header, + interlinks, + interlinks_proof: overcomplete_proof, + }; + + assert!(!popow_header.check_interlinks_proof()); + } + + #[test] + fn full_root_rejects_an_interlinks_only_proof_under_a_mixed_root() { + let interlinks = vec![id_from_byte(0x11), id_from_byte(0x22)]; + let interlink_fields = NipopowAlgos::pack_interlinks(interlinks.clone()); + let interlinks_only = ExtensionCandidate::new(interlink_fields.clone()).unwrap(); + let legacy_proof = NipopowAlgos::proof_for_interlink_vector(&interlinks_only).unwrap(); + let mut mixed_fields = interlink_fields.clone(); + mixed_fields.push(([0x02, 0x00], vec![0x01])); + let legacy_root = extension_tree(&interlink_fields).root_hash(); + let mixed_root = extension_tree(&mixed_fields).root_hash(); + assert!(legacy_proof.valid(legacy_root.as_ref())); + assert!(!legacy_proof.valid(mixed_root.as_ref())); + + let make_header = header_factory(); + let mut header = make_header(id_from_byte(0x31), id_from_byte(0x30), 2); + header.extension_root = mixed_root; + let popow_header = PoPowHeader { + header, + interlinks, + interlinks_proof: legacy_proof, + }; + + assert!(!popow_header.check_interlinks_proof()); + } + + #[test] + fn full_root_keeps_interlinks_only_extensions_compatible() { + let interlinks = vec![id_from_byte(0x11), id_from_byte(0x22)]; + let fields = NipopowAlgos::pack_interlinks(interlinks.clone()); + let extension = ExtensionCandidate::new(fields.clone()).unwrap(); + let proof = NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); + let root = extension_tree(&fields).root_hash(); + assert!(proof.valid(root.as_ref())); + + let make_header = header_factory(); + let mut header = make_header(id_from_byte(0x31), id_from_byte(0x30), 2); + header.extension_root = root; + let popow_header = PoPowHeader { + header, + interlinks, + interlinks_proof: proof, + }; + + assert!(popow_header.check_interlinks_proof()); + } + + fn jvm_nipopow_fixture() -> (serde_json::Value, Vec) { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../tests/fixtures/nipopow-full-root-mixed-nipopow-proof.json" + )) + .unwrap(); + let bytes = base16::decode(fixture["bytes_hex"].as_str().unwrap()).unwrap(); + (fixture, bytes) + } + + fn fixture_usize(fixture: &serde_json::Value, field: &str) -> usize { + usize::try_from(fixture[field].as_u64().unwrap()).unwrap() + } + + fn fixture_u8(fixture: &serde_json::Value, field: &str) -> u8 { + u8::try_from(fixture[field].as_u64().unwrap()).unwrap() + } + + /// Parse the Rust-owned core and validate the one-byte JVM envelope. + /// + /// `continuous` remains an adapter concern: it is checked here, but is + /// intentionally not represented by the Rust `NipopowProof` core type. + fn parse_jvm_nipopow_fixture( + bytes: &[u8], + core_length: usize, + terminal_mode: u8, + ) -> Result { + if terminal_mode > 1 { + return Err(format!("invalid JVM terminal mode {terminal_mode}")); + } + if bytes.len() + != core_length + .checked_add(1) + .ok_or("fixture length overflow")? + { + return Err(format!( + "expected one terminal byte after {core_length} core bytes, got {} total bytes", + bytes.len() + )); + } + + let mut cursor = std::io::Cursor::new(bytes); + let proof = NipopowProof::scorex_parse(&mut cursor).map_err(|err| err.to_string())?; + let parsed_core_length = + usize::try_from(cursor.position()).map_err(|err| err.to_string())?; + if parsed_core_length != core_length { + return Err(format!( + "Rust parser stopped at {parsed_core_length}, expected {core_length}" + )); + } + let terminal = bytes + .get(parsed_core_length) + .copied() + .ok_or("missing JVM terminal byte")?; + if terminal != terminal_mode { + return Err(format!( + "JVM terminal mode {terminal} does not match expected {terminal_mode}" + )); + } + + proof.validate().map_err(|err| err.to_string())?; + Ok(proof) + } + + fn read_fixture_vlq(bytes: &[u8], offset: &mut usize) -> Result { + let mut value = 0_u32; + for shift in (0..35).step_by(7) { + let byte = bytes.get(*offset).copied().ok_or("truncated fixture VLQ")?; + *offset = offset.checked_add(1).ok_or("fixture offset overflow")?; + value |= u32::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err("fixture VLQ exceeds u32".to_owned()) + } + + fn fixture_suffix_head_range(bytes: &[u8]) -> std::ops::Range { + let mut offset = 0; + read_fixture_vlq(bytes, &mut offset).unwrap(); + read_fixture_vlq(bytes, &mut offset).unwrap(); + let prefix_count = read_fixture_vlq(bytes, &mut offset).unwrap(); + for _ in 0..prefix_count { + let frame_length = + usize::try_from(read_fixture_vlq(bytes, &mut offset).unwrap()).unwrap(); + offset = offset.checked_add(frame_length).unwrap(); + } + let suffix_head_length = + usize::try_from(read_fixture_vlq(bytes, &mut offset).unwrap()).unwrap(); + let end = offset.checked_add(suffix_head_length).unwrap(); + assert!(end <= bytes.len()); + offset..end + } + + fn single_subslice_offset(bytes: &[u8], range: std::ops::Range, needle: &[u8]) -> usize { + assert!(!needle.is_empty()); + let matches = bytes[range.clone()] + .windows(needle.len()) + .enumerate() + .filter_map(|(index, window)| (window == needle).then_some(range.start + index)) + .collect::>(); + assert_eq!(matches.len(), 1, "fixture mutation target must be unique"); + matches[0] + } + + #[test] + fn jvm_nipopow_fixture_roundtrips_at_the_explicit_core_boundary() { + let (fixture, bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + + assert_eq!( + fixture["format"].as_str().unwrap(), + "scorex-nipopow-proof-with-jvm-mode-v1" + ); + assert_eq!(bytes.len(), core_length + 1); + assert_eq!( + base16::encode_lower(sigma_util::hash::sha256_hash(&bytes).as_slice()), + fixture["sha256"].as_str().unwrap() + ); + + let mut cursor = std::io::Cursor::new(bytes.as_slice()); + let parsed = NipopowProof::scorex_parse(&mut cursor).unwrap(); + assert_eq!(cursor.position(), core_length as u64); + assert_eq!(&bytes[core_length..], &[terminal_mode]); + assert_eq!(parsed.m, fixture["m"].as_u64().unwrap() as u32); + assert_eq!(parsed.k, fixture["k"].as_u64().unwrap() as u32); + assert_eq!(parsed.prefix.len(), fixture_usize(&fixture, "prefix_count")); + assert_eq!( + parsed.suffix_tail.len() + 1, + fixture_usize(&fixture, "suffix_count") + ); + assert_eq!( + parsed.suffix_tail.len(), + fixture_usize(&fixture, "suffix_tail_count") + ); + assert_eq!( + parsed.suffix_head.header.extension_root.to_string(), + fixture["extension_root"].as_str().unwrap() + ); + assert_eq!( + parsed.suffix_head.interlinks, + vec![id_from_byte(0x11), id_from_byte(0x22)] + ); + assert!(parsed.prefix[0].check_interlinks_proof()); + assert!(parsed.suffix_head.check_interlinks_proof()); + assert_eq!(parsed.validate(), Ok(())); + assert_eq!( + parsed.scorex_serialize_bytes().unwrap(), + bytes[..core_length] + ); + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, terminal_mode).is_ok()); + } + + #[test] + fn jvm_nipopow_fixture_rejects_extension_root_mutation() { + let (fixture, mut bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + let extension_root = base16::decode(fixture["extension_root"].as_str().unwrap()).unwrap(); + let root_offset = + single_subslice_offset(&bytes, fixture_suffix_head_range(&bytes), &extension_root); + bytes[root_offset] ^= 1; + + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, terminal_mode).is_err()); + } + + #[test] + fn jvm_nipopow_fixture_rejects_disclosed_interlink_mutation() { + let (fixture, mut bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + let interlink = [0x22; 32]; + let interlink_offset = + single_subslice_offset(&bytes, fixture_suffix_head_range(&bytes), &interlink); + bytes[interlink_offset] ^= 1; + + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, terminal_mode).is_err()); + } + + #[test] + fn jvm_nipopow_fixture_rejects_m_mutation() { + let (fixture, mut bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + assert_eq!(bytes[0], 1); + bytes[0] = 0; + + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, terminal_mode).is_err()); + } + + #[test] + fn jvm_nipopow_fixture_rejects_k_mutation() { + let (fixture, mut bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + assert_eq!(&bytes[..2], &[1, 2]); + bytes[1] = 1; + + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, terminal_mode).is_err()); + } + + #[test] + fn jvm_nipopow_fixture_rejects_nested_header_frame_mutation() { + let (fixture, mut bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + let suffix_head_range = fixture_suffix_head_range(&bytes); + let nested_size_offset = suffix_head_range.start; + let mut after_size = nested_size_offset; + assert_eq!(read_fixture_vlq(&bytes, &mut after_size).unwrap(), 218); + assert_eq!(bytes[nested_size_offset], 0xda); + bytes[nested_size_offset] = 0xd9; + + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, terminal_mode).is_err()); + } + + #[test] + fn jvm_nipopow_fixture_rejects_missing_terminal_byte() { + let (fixture, bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + + assert!( + parse_jvm_nipopow_fixture(&bytes[..bytes.len() - 1], core_length, terminal_mode) + .is_err() + ); + } + + #[test] + fn jvm_nipopow_fixture_rejects_extra_terminal_byte() { + let (fixture, mut bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + bytes.push(terminal_mode); + + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, terminal_mode).is_err()); + } + + #[test] + fn jvm_nipopow_fixture_rejects_terminal_mode_mutation() { + let (fixture, mut bytes) = jvm_nipopow_fixture(); + let core_length = fixture_usize(&fixture, "rust_core_length"); + let terminal_mode = fixture_u8(&fixture, "terminal_continuous_byte"); + let last = bytes.len() - 1; + bytes[last] = 1; + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, terminal_mode).is_err()); + + bytes[last] = 2; + assert!(parse_jvm_nipopow_fixture(&bytes, core_length, 2).is_err()); + } + + #[test] + fn full_root_cross_runtime_fixture_roundtrips_and_rejects_mutations() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../tests/fixtures/nipopow-full-root-mixed-popow-header.json" + )) + .unwrap(); + let bytes_hex = fixture["bytes_hex"].as_str().unwrap(); + let bytes = base16::decode(bytes_hex).unwrap(); + + assert_eq!(bytes.len(), fixture["length"].as_u64().unwrap() as usize); + assert_eq!( + base16::encode_lower(sigma_util::hash::sha256_hash(&bytes).as_slice()), + fixture["sha256"].as_str().unwrap() + ); + + let parsed = PoPowHeader::scorex_parse_bytes(&bytes).unwrap(); + assert_eq!(parsed.scorex_serialize_bytes().unwrap(), bytes); + assert_eq!( + parsed.header.extension_root.to_string(), + fixture["extension_root"].as_str().unwrap() + ); + assert!(parsed.check_interlinks_proof()); + + let mut wrong_root = parsed.clone(); + wrong_root.header.extension_root.0[0] ^= 1; + assert!(!wrong_root.check_interlinks_proof()); + + let mut wrong_interlink = parsed; + wrong_interlink.interlinks[1] = id_from_byte(0x33); + assert!(!wrong_interlink.check_interlinks_proof()); + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum FrameSite { + Prefix(usize), + SuffixHead, + SuffixTail(usize), + } + + #[derive(Clone, Copy, Debug)] + enum DeclaredSizeMutation { + Delta(i64), + Override(u32), + Raw(&'static [u8]), + } + + #[derive(Clone, Copy, Debug)] + struct FrameMutation { + site: FrameSite, + declared_size: DeclaredSizeMutation, + filler_len: usize, + } + + fn sample_framing_proof() -> NipopowProof { + let mut proof = valid_proof(6, 3); + proof.prefix = vec![proof.suffix_head.clone(), proof.suffix_head.clone()]; + proof + } + + fn frame_sites(proof: &NipopowProof) -> Vec { + let mut sites = (0..proof.prefix.len()) + .map(FrameSite::Prefix) + .collect::>(); + sites.push(FrameSite::SuffixHead); + sites.extend((0..proof.suffix_tail.len()).map(FrameSite::SuffixTail)); + sites + } + + fn write_test_frame( + output: &mut Vec, + value: &T, + site: FrameSite, + mutation: FrameMutation, + ) { + let frame = value.scorex_serialize_bytes().unwrap(); + if site == mutation.site { + match mutation.declared_size { + DeclaredSizeMutation::Delta(delta) => { + let declared = i64::try_from(frame.len()).unwrap() + delta; + output.put_u32(u32::try_from(declared).unwrap()).unwrap(); + } + DeclaredSizeMutation::Override(declared) => { + output.put_u32(declared).unwrap(); + } + DeclaredSizeMutation::Raw(raw) => output.extend_from_slice(raw), + } + } else { + output.put_u32(u32::try_from(frame.len()).unwrap()).unwrap(); + } + output.extend_from_slice(&frame); + if site == mutation.site { + output.resize(output.len() + mutation.filler_len, 0x7f); + } + } + + fn serialize_with_frame_mutation(proof: &NipopowProof, mutation: FrameMutation) -> Vec { + let mut bytes = Vec::new(); + bytes.put_u32(proof.m).unwrap(); + bytes.put_u32(proof.k).unwrap(); + bytes + .put_u32(u32::try_from(proof.prefix.len()).unwrap()) + .unwrap(); + for (index, prefix) in proof.prefix.iter().enumerate() { + write_test_frame(&mut bytes, prefix, FrameSite::Prefix(index), mutation); + } + write_test_frame( + &mut bytes, + &proof.suffix_head, + FrameSite::SuffixHead, + mutation, + ); + bytes + .put_u32(u32::try_from(proof.suffix_tail.len()).unwrap()) + .unwrap(); + for (index, header) in proof.suffix_tail.iter().enumerate() { + write_test_frame(&mut bytes, header, FrameSite::SuffixTail(index), mutation); + } + bytes + } + + #[test] + fn declared_element_frames_reject_overstatement_without_filler() { + let proof = sample_framing_proof(); + for site in frame_sites(&proof) { + let bytes = serialize_with_frame_mutation( + &proof, + FrameMutation { + site, + declared_size: DeclaredSizeMutation::Delta(1), + filler_len: 0, + }, + ); + assert!( + NipopowProof::scorex_parse_bytes(&bytes).is_err(), + "accepted over-declared {site:?} without filler" + ); + } + } + + #[test] + fn declared_element_frames_reject_understatement() { + let proof = sample_framing_proof(); + for site in frame_sites(&proof) { + let bytes = serialize_with_frame_mutation( + &proof, + FrameMutation { + site, + declared_size: DeclaredSizeMutation::Delta(-1), + filler_len: 0, + }, + ); + assert!( + NipopowProof::scorex_parse_bytes(&bytes).is_err(), + "accepted under-declared {site:?}" + ); + } + } + + #[test] + fn declared_element_frames_accept_matching_filler() { + let proof = sample_framing_proof(); + let canonical = + NipopowProof::scorex_parse_bytes(&proof.scorex_serialize_bytes().unwrap()).unwrap(); + for site in frame_sites(&proof) { + let bytes = serialize_with_frame_mutation( + &proof, + FrameMutation { + site, + declared_size: DeclaredSizeMutation::Delta(1), + filler_len: 1, + }, + ); + assert_eq!( + NipopowProof::scorex_parse_bytes(&bytes).unwrap(), + canonical, + "padded {site:?} changed the proof" + ); + } + } + + fn serialize_popow_header_with_nested_frames( + value: &PoPowHeader, + header_frame: &[u8], + proof_frame: &[u8], + ) -> Vec { + let mut bytes = Vec::new(); + bytes + .put_u32(u32::try_from(header_frame.len()).unwrap()) + .unwrap(); + bytes.extend_from_slice(header_frame); + bytes + .put_u32(u32::try_from(value.interlinks.len()).unwrap()) + .unwrap(); + for interlink in &value.interlinks { + bytes.extend_from_slice(&interlink.0 .0); + } + bytes + .put_u32(u32::try_from(proof_frame.len()).unwrap()) + .unwrap(); + bytes.extend_from_slice(proof_frame); + bytes + } + + #[test] + fn nested_header_frame_accepts_trailing_padding() { + let value = sample_framing_proof().suffix_head; + let canonical_header_frame = value.header.scorex_serialize_bytes().unwrap(); + let proof_frame = value.interlinks_proof.scorex_serialize_bytes().unwrap(); + let canonical_bytes = serialize_popow_header_with_nested_frames( + &value, + &canonical_header_frame, + &proof_frame, + ); + let canonical = PoPowHeader::scorex_parse_bytes(&canonical_bytes).unwrap(); + let mut header_frame = canonical_header_frame; + header_frame.push(0x7f); + let bytes = serialize_popow_header_with_nested_frames(&value, &header_frame, &proof_frame); + + assert_eq!(PoPowHeader::scorex_parse_bytes(&bytes).unwrap(), canonical); + } + + #[test] + fn nested_merkle_proof_frame_rejects_trailing_padding() { + let value = sample_framing_proof().suffix_head; + let header_frame = value.header.scorex_serialize_bytes().unwrap(); + let mut proof_frame = value.interlinks_proof.scorex_serialize_bytes().unwrap(); + proof_frame.push(0x7f); + let bytes = serialize_popow_header_with_nested_frames(&value, &header_frame, &proof_frame); + + assert!(PoPowHeader::scorex_parse_bytes(&bytes).is_err()); + } + + fn assert_merkle_count_preflight(indices_len: u32, proofs_len: u32, label: &str) { + let value = sample_framing_proof().suffix_head; + let header_frame = value.header.scorex_serialize_bytes().unwrap(); + let mut proof_frame = BatchMerkleProof::new(vec![], vec![]) + .scorex_serialize_bytes() + .unwrap(); + assert_eq!(proof_frame.len(), 8); + proof_frame[..4].copy_from_slice(&indices_len.to_be_bytes()); + proof_frame[4..8].copy_from_slice(&proofs_len.to_be_bytes()); + let bytes = serialize_popow_header_with_nested_frames(&value, &header_frame, &proof_frame); + + let error = PoPowHeader::scorex_parse_bytes(&bytes).unwrap_err(); + assert!( + error + .to_string() + .contains("does not fit declared proof frame"), + "unexpected {label} preflight message: {error}" + ); + } + + #[test] + fn merkle_index_count_outside_declared_frame_is_rejected_before_parse() { + assert_merkle_count_preflight(1, 0, "index"); + } + + #[test] + fn merkle_proof_count_outside_declared_frame_is_rejected_before_parse() { + assert_merkle_count_preflight(0, 1, "proof"); + } + + #[test] + fn declared_element_frames_reject_sizes_above_caps() { + let proof = sample_framing_proof(); + let cases = [ + (FrameSite::Prefix(0), MAX_POPOW_HEADER_ELEMENT_BYTES + 1), + (FrameSite::SuffixHead, MAX_POPOW_HEADER_ELEMENT_BYTES + 1), + (FrameSite::SuffixTail(0), MAX_POPOW_HEADER_BYTES + 1), + ]; + for (site, declared) in cases { + let bytes = serialize_with_frame_mutation( + &proof, + FrameMutation { + site, + declared_size: DeclaredSizeMutation::Override(u32::try_from(declared).unwrap()), + filler_len: 0, + }, + ); + assert!( + NipopowProof::scorex_parse_bytes(&bytes).is_err(), + "accepted {site:?} above its declared-size cap" + ); + } + } + + #[test] + fn malformed_suffix_tail_frame_size_is_rejected() { + const MALFORMED_VLQ: &[u8] = &[0x80; 10]; + + let proof = sample_framing_proof(); + let bytes = serialize_with_frame_mutation( + &proof, + FrameMutation { + site: FrameSite::SuffixTail(0), + declared_size: DeclaredSizeMutation::Raw(MALFORMED_VLQ), + filler_len: 0, + }, + ); + assert!(NipopowProof::scorex_parse_bytes(&bytes).is_err()); + } + + fn popow_header_bytes_with_merkle_counts(indices_len: u32, proofs_len: u32) -> Vec { + let header = header_factory()(id_from_byte(0x81), id_from_byte(0x80), 2); + let header_bytes = header.scorex_serialize_bytes().unwrap(); + + let mut proof_frame = Vec::new(); + proof_frame.extend_from_slice(&indices_len.to_be_bytes()); + proof_frame.extend_from_slice(&proofs_len.to_be_bytes()); + for index in 0..indices_len { + proof_frame.extend_from_slice(&index.to_be_bytes()); + proof_frame.extend_from_slice(&[0x11; 32]); + } + for _ in 0..proofs_len { + proof_frame.extend_from_slice(&[0x22; 32]); + proof_frame.push(NodeSide::Left as u8); + } + + let mut bytes = Vec::new(); + bytes + .put_u32(u32::try_from(header_bytes.len()).unwrap()) + .unwrap(); + bytes.extend_from_slice(&header_bytes); + bytes.put_u32(1).unwrap(); + bytes.extend_from_slice(id_from_byte(0x11).0.as_ref()); + bytes + .put_u32(u32::try_from(proof_frame.len()).unwrap()) + .unwrap(); + bytes.extend_from_slice(&proof_frame); + bytes + } + + #[test] + fn parser_rejects_prefix_count_above_cap_before_frame_read() { + let mut bytes = Vec::new(); + bytes.put_u32(1).unwrap(); + bytes.put_u32(1).unwrap(); + bytes + .put_u32(u32::try_from(MAX_NIPOPOW_PROOF_ELEMENTS + 1).unwrap()) + .unwrap(); + + let error = NipopowProof::scorex_parse_bytes(&bytes).unwrap_err(); + assert!( + error + .to_string() + .contains("num_prefixes exceeds sanity limit"), + "unexpected error: {error}" + ); + } + + #[test] + fn parser_rejects_nested_header_size_above_cap_before_allocation() { + let mut bytes = Vec::new(); + bytes.put_u32(10_001).unwrap(); + + let error = PoPowHeader::scorex_parse_bytes(&bytes).unwrap_err(); + assert!( + error + .to_string() + .contains("header_size exceeds sanity limit"), + "unexpected error: {error}" + ); + } + + #[test] + fn parser_rejects_interlink_count_above_cap_before_allocation() { + let header = header_factory()(id_from_byte(0x91), id_from_byte(0x90), 2); + let header_bytes = header.scorex_serialize_bytes().unwrap(); + let mut bytes = Vec::new(); + bytes + .put_u32(u32::try_from(header_bytes.len()).unwrap()) + .unwrap(); + bytes.extend_from_slice(&header_bytes); + bytes.put_u32(10_001).unwrap(); + + let error = PoPowHeader::scorex_parse_bytes(&bytes).unwrap_err(); + assert!( + error + .to_string() + .contains("interlinks_size exceeds sanity limit"), + "unexpected error: {error}" + ); + } + + #[test] + fn parser_rejects_merkle_frame_size_above_cap_before_allocation() { + let header = header_factory()(id_from_byte(0xa1), id_from_byte(0xa0), 2); + let header_bytes = header.scorex_serialize_bytes().unwrap(); + let mut bytes = Vec::new(); + bytes + .put_u32(u32::try_from(header_bytes.len()).unwrap()) + .unwrap(); + bytes.extend_from_slice(&header_bytes); + bytes.put_u32(0).unwrap(); + bytes.put_u32(1_000_001).unwrap(); + + let error = PoPowHeader::scorex_parse_bytes(&bytes).unwrap_err(); + assert!( + error + .to_string() + .contains("proof_bytes exceeds sanity limit"), + "unexpected error: {error}" + ); + } + + #[test] + fn parser_rejects_impossible_singleton_merkle_proof_depth() { + let bytes = popow_header_bytes_with_merkle_counts(1, u32::BITS + 1); + + assert!(PoPowHeader::scorex_parse_bytes(&bytes).is_err()); + } } diff --git a/ergo-nipopow/src/nipopow_verifier.rs b/ergo-nipopow/src/nipopow_verifier.rs index 3b775f9c6..1e0efa41c 100644 --- a/ergo-nipopow/src/nipopow_verifier.rs +++ b/ergo-nipopow/src/nipopow_verifier.rs @@ -1,5 +1,4 @@ -use ergo_chain_types::BlockId; -use ergo_chain_types::Header; +use ergo_chain_types::{autolykos_pow_scheme::AutolykosPowSchemeError, BlockId, Header}; use crate::nipopow_proof::{NipopowProof, NipopowProofError}; @@ -39,6 +38,15 @@ impl NipopowVerifier { if let Some(h) = h { if h.id == self.genesis_block_id { if let Some(p) = &self.best_proof { + // Direct comparison intentionally returns `Ok(false)` for + // different proof parameters. At the stateful verifier + // boundary, keep that incompatibility observable instead + // of making it indistinguishable from an ordinary loss. + if new_proof.is_valid() && (new_proof.m, new_proof.k) != (p.m, p.k) { + return Err(NipopowProofError::AutolykosPowSchemeError( + AutolykosPowSchemeError::OutOfBounds, + )); + } if new_proof.is_better_than(p)? { self.best_proof = Some(new_proof); } @@ -50,3 +58,32 @@ impl NipopowVerifier { Ok(()) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::nipopow_proof::tests::valid_proof; + use ergo_chain_types::{autolykos_pow_scheme::AutolykosPowSchemeError, Digest32}; + + #[test] + fn process_reports_valid_parameter_mismatch_but_ignores_invalid_challenger() { + let incumbent = valid_proof(6, 2); + let genesis_id = incumbent.suffix_head.header.id; + let mut verifier = NipopowVerifier::new(genesis_id); + verifier.process(incumbent.clone()).unwrap(); + + assert_eq!( + verifier.process(valid_proof(7, 2)), + Err(NipopowProofError::AutolykosPowSchemeError( + AutolykosPowSchemeError::OutOfBounds + )) + ); + assert_eq!(verifier.best_proof(), Some(incumbent.clone())); + + let mut invalid = valid_proof(7, 2); + invalid.suffix_tail[0].parent_id = BlockId(Digest32::from([0xff; 32])); + assert_eq!(verifier.process(invalid), Ok(())); + assert_eq!(verifier.best_proof(), Some(incumbent)); + } +} diff --git a/ergo-nipopow/tests/fixtures/nipopow-full-root-mixed-nipopow-proof.json b/ergo-nipopow/tests/fixtures/nipopow-full-root-mixed-nipopow-proof.json new file mode 100644 index 000000000..1a2f585ec --- /dev/null +++ b/ergo-nipopow/tests/fixtures/nipopow-full-root-mixed-nipopow-proof.json @@ -0,0 +1,13 @@ +{ + "format": "scorex-nipopow-proof-with-jvm-mode-v1", + "m": 1, + "k": 2, + "prefix_count": 1, + "suffix_count": 2, + "suffix_tail_count": 1, + "bytes_hex": "010201e601da01020000000000000000000000000000000000000000000000000000000000000000d882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c3963eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713f884ebfd8e2f0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8070239b8010400000002b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f6695939ecfee6b0d7f400080000000000000000b103da01028022567408919e0c11029c17f56ee3f1c9567eee06dbf265ab68129d6fa2e6e0d882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c3963eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713f884ebfd8e2f65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc070239b8020400000002b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f6695939ecfee6b0d7f402111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222229201000000020000000200000001fc95d4accfa4598b6151a1f9837fe4f85b028154176351a076b954abd74ae45300000002413bd0b194cedf41d6ad4ca6b0236b59ff2e070fbbfc499fda2b6f55aa27dc87580c42b47e2deed9aa364fadb6192e03715e87108fd3c96b74c21fa889baa5200000000000000000000000000000000000000000000000000000000000000000000101da01029e3967f6e21ebe3558fa02626efe43df348cb5a7b2eacd4c2853f1df9e34c20dd882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c3963eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713f884ebfd8e2f65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc070239b8030400000002b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f6695939ecfee6b0d7f400", + "rust_core_length": 891, + "terminal_continuous_byte": 0, + "sha256": "7dc6238407b20e62ef5188b331ac789836a9f43d35603e5cb21138ae6b57c0fb", + "extension_root": "65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc" +} diff --git a/ergo-nipopow/tests/fixtures/nipopow-full-root-mixed-popow-header.json b/ergo-nipopow/tests/fixtures/nipopow-full-root-mixed-popow-header.json new file mode 100644 index 000000000..5aab2d98b --- /dev/null +++ b/ergo-nipopow/tests/fixtures/nipopow-full-root-mixed-popow-header.json @@ -0,0 +1,26 @@ +{ + "format": "scorex-popow-header-v1", + "label": "synthetic-mixed-full-root", + "bytes_hex": "dc01026481752bace5fa5acba5d5ef7124d48826664742d46c974c98a2d60ace229a34d882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c3963eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713f884ebfd8e2f65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc070239b8c2e51c0400000002b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f6695939ecfee6b0d7f402111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222229201000000020000000200000001fc95d4accfa4598b6151a1f9837fe4f85b028154176351a076b954abd74ae45300000002413bd0b194cedf41d6ad4ca6b0236b59ff2e070fbbfc499fda2b6f55aa27dc87580c42b47e2deed9aa364fadb6192e03715e87108fd3c96b74c21fa889baa52000000000000000000000000000000000000000000000000000000000000000000001", + "length": 435, + "sha256": "832426d3beece84df9707247919b3a62444c188ae63951273f198952e8ceb068", + "extension_root": "65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc", + "extension_fields": [ + { + "key": "0000", + "value": "2a" + }, + { + "key": "0100", + "value": "011111111111111111111111111111111111111111111111111111111111111111" + }, + { + "key": "0101", + "value": "012222222222222222222222222222222222222222222222222222222222222222" + } + ], + "interlinks": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222" + ] +} diff --git a/ergo-nipopow/tests/public_error_compat.rs b/ergo-nipopow/tests/public_error_compat.rs new file mode 100644 index 000000000..ef6ac1d10 --- /dev/null +++ b/ergo-nipopow/tests/public_error_compat.rs @@ -0,0 +1,18 @@ +use ergo_nipopow::NipopowProofError; + +fn legacy_variant_name(error: NipopowProofError) -> &'static str { + match error { + NipopowProofError::AutolykosPowSchemeError(_) => "autolykos", + NipopowProofError::ZeroKParameter => "zero-k", + NipopowProofError::NonAnchoredChain => "non-anchored", + NipopowProofError::ChainTooShort => "chain-too-short", + } +} + +#[test] +fn nipopow_proof_error_keeps_its_legacy_exhaustive_variants() { + assert_eq!( + legacy_variant_name(NipopowProofError::ZeroKParameter), + "zero-k" + ); +}