diff --git a/Cargo.toml b/Cargo.toml index 9bd417b16..61895e32b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,15 +29,15 @@ edition = "2021" [workspace.dependencies] sigma-util = { version = "^0.18.0", path = "./sigma-util" } sigma-ser = { version = "^0.19.0", path = "./sigma-ser", default-features = false } -ergotree-ir = { version = "^0.28.0", path = "./ergotree-ir", default-features = false } +ergotree-ir = { version = "^0.30.0", path = "./ergotree-ir", default-features = false } ergo-chain-types = { version = "^0.15.0", path = "./ergo-chain-types", default-features = false } sigma-test-util = { version = "^0.3.0", path = "./sigma-test-util" } ergoscript-compiler = { version = "^0.24.0", path = "./ergoscript-compiler" } -ergotree-interpreter = { version = "^0.28.0", path = "./ergotree-interpreter", default-features = false } +ergotree-interpreter = { version = "^0.30.0", path = "./ergotree-interpreter", default-features = false } ergo-nipopow = { version = "^0.15", path = "./ergo-nipopow" } ergo-merkle-tree = { version = "^0.15.0", path = "./ergo-merkle-tree", default-features = false } ergo-rest = { version = "^0.13.0", path = "./ergo-rest" } -ergo-lib = { version = "^0.28.0", path = "./ergo-lib" } +ergo-lib = { version = "^0.30.0", path = "./ergo-lib" } k256 = { version = "0.13.1", default-features = false, features = [ "arithmetic", "ecdsa", diff --git a/bindings/ergo-lib-c-core/Cargo.toml b/bindings/ergo-lib-c-core/Cargo.toml index d507d0867..0a99f33ab 100644 --- a/bindings/ergo-lib-c-core/Cargo.toml +++ b/bindings/ergo-lib-c-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-lib-c-core" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Denys Zadorozhnyi "] repository.workspace = true diff --git a/bindings/ergo-lib-c/Cargo.toml b/bindings/ergo-lib-c/Cargo.toml index bb0453811..2421a2821 100644 --- a/bindings/ergo-lib-c/Cargo.toml +++ b/bindings/ergo-lib-c/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-lib-c" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Denys Zadorozhnyi "] repository.workspace = true @@ -14,7 +14,7 @@ name = "ergo" crate-type = ["staticlib"] [dependencies] -ergo-lib-c-core = { version = "^0.28.0", path = "../ergo-lib-c-core" } +ergo-lib-c-core = { version = "^0.30.0", path = "../ergo-lib-c-core" } paste = "^1.0" [features] diff --git a/bindings/ergo-lib-jni/Cargo.toml b/bindings/ergo-lib-jni/Cargo.toml index 0b8b8af47..8cc6040af 100644 --- a/bindings/ergo-lib-jni/Cargo.toml +++ b/bindings/ergo-lib-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-lib-jni" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Denys Zadorozhnyi "] repository.workspace = true @@ -16,4 +16,4 @@ path = "src/main/rust/lib.rs" jni = "0.19.0" log = "0.4.16" ergo-lib = { workspace = true } -ergo-lib-c-core = { version = "^0.28.0", path = "../ergo-lib-c-core" } +ergo-lib-c-core = { version = "^0.30.0", path = "../ergo-lib-c-core" } diff --git a/bindings/ergo-lib-python/Cargo.toml b/bindings/ergo-lib-python/Cargo.toml index a5688af88..113eccf13 100644 --- a/bindings/ergo-lib-python/Cargo.toml +++ b/bindings/ergo-lib-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-lib-python" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Kamal Ahmad "] edition.workspace = true diff --git a/bindings/ergo-lib-wasm/Cargo.toml b/bindings/ergo-lib-wasm/Cargo.toml index a01f74045..23e41809f 100644 --- a/bindings/ergo-lib-wasm/Cargo.toml +++ b/bindings/ergo-lib-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-lib-wasm" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Denys Zadorozhnyi "] repository = "https://github.com/ergoplatform/sigma-rust" diff --git a/ergo-chain-generation/src/fake_pow_scheme.rs b/ergo-chain-generation/src/fake_pow_scheme.rs index 7baa98fb2..dec9d907d 100644 --- a/ergo-chain-generation/src/fake_pow_scheme.rs +++ b/ergo-chain-generation/src/fake_pow_scheme.rs @@ -8,7 +8,7 @@ #[cfg(test)] mod tests { use ergo_lib::ergo_chain_types::{blake2b256_hash, ADDigest, BlockId, Digest32}; - use ergo_nipopow::{NipopowAlgos, NipopowProof}; + use ergo_nipopow::{NipopowAlgos, NipopowProof, NipopowVerifier, INTERLINK_VECTOR_PREFIX}; use ergo_chain_types::{autolykos_pow_scheme::order_bigint, AutolykosSolution, Header, Votes}; use ergo_lib::ergotree_interpreter::sigma_protocol::private_input::DlogProverInput; @@ -179,6 +179,191 @@ mod tests { } } + fn generated_nipopow_proof() -> (BlockId, NipopowProof) { + let m = 5; + let k = 5; + let chain = generate_popowheader_chain(100, None); + let genesis_id = chain[0].header.id; + let proof = NipopowAlgos::default().prove(&chain, k, m).unwrap(); + assert_eq!(proof.prefix[0].header.id, genesis_id); + (genesis_id, proof) + } + + fn assert_initial_proof_ignored(genesis_id: BlockId, proof: NipopowProof) { + let mut verifier = NipopowVerifier::new(genesis_id); + assert!(verifier.process(proof).is_ok()); + assert!(verifier.best_proof().is_none()); + assert!(verifier.best_chain().is_empty()); + } + + fn indexed_block_id(index: usize) -> BlockId { + let mut bytes = [0u8; 32]; + bytes[..2].copy_from_slice(&u16::try_from(index).unwrap().to_be_bytes()); + BlockId(Digest32::from(bytes)) + } + + fn set_first_prefix_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; + } + + #[test] + fn test_nipopow_verifier_accepts_valid_initial_proof() { + let (genesis_id, proof) = generated_nipopow_proof(); + let mut verifier = NipopowVerifier::new(genesis_id); + assert!(verifier.process(proof.clone()).is_ok()); + assert_eq!(verifier.best_proof(), Some(proof)); + } + + #[test] + fn test_nipopow_verifier_ignores_wrong_genesis() { + let (_, proof) = generated_nipopow_proof(); + let wrong_genesis = BlockId(Digest32::zero()); + assert_ne!(proof.prefix[0].header.id, wrong_genesis); + assert_initial_proof_ignored(wrong_genesis, proof); + } + + #[test] + fn test_nipopow_verifier_rejects_disconnected_initial_proof() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + assert!(!proof.suffix_tail.is_empty()); + proof.suffix_tail[0].parent_id = BlockId(Digest32::zero()); + assert!(!proof.has_valid_connections()); + assert_initial_proof_ignored(genesis_id, proof); + } + + #[test] + fn test_nipopow_verifier_rejects_non_increasing_initial_proof() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + assert!(!proof.suffix_tail.is_empty()); + proof.suffix_tail[0].height = proof.suffix_head.header.height; + assert!(proof.has_valid_connections()); + assert_initial_proof_ignored(genesis_id, proof); + } + + #[test] + fn test_nipopow_verifier_rejects_invalid_interlinks_initial_proof() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + assert!(proof.suffix_head.check_interlinks_proof()); + proof.suffix_head.interlinks.push(BlockId(Digest32::zero())); + assert!(!proof.suffix_head.check_interlinks_proof()); + assert!(proof.has_valid_connections()); + assert_initial_proof_ignored(genesis_id, proof); + } + + #[test] + fn test_nipopow_verifier_rejects_empty_interlinks_with_nonempty_proof() { + let (genesis_id, mut proof) = generated_nipopow_proof(); + let extension = + ExtensionCandidate::new(NipopowAlgos::pack_interlinks(vec![genesis_id])).unwrap(); + let nonempty_proof = NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); + assert!(!nonempty_proof.get_indices().is_empty()); + proof.prefix[0].interlinks.clear(); + proof.prefix[0].interlinks_proof = nonempty_proof; + assert!(proof.has_valid_connections()); + 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.has_valid_connections()); + 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)) + .map(indexed_block_id) + .collect(); + assert!(proof.has_valid_connections()); + 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(); + let second_run_id = indexed_block_id(1); + let third_run_id = indexed_block_id(2); + assert_ne!(genesis_id, second_run_id); + assert_ne!(genesis_id, third_run_id); + 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), + ), + ( + [INTERLINK_VECTOR_PREFIX, 1], + interlink_field_value(1, second_run_id), + ), + ( + [INTERLINK_VECTOR_PREFIX, 2], + interlink_field_value(1, third_run_id), + ), + ]) + .unwrap(); + proof.prefix[0].interlinks = interlinks; + proof.prefix[0].interlinks_proof = + NipopowAlgos::proof_for_interlink_vector(&extension).unwrap(); + assert!(proof.has_valid_connections()); + 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()); + let mut verifier = NipopowVerifier::new(genesis_id); + assert!(verifier.process(proof.clone()).is_ok()); + assert_eq!(verifier.best_proof(), Some(proof)); + } + + #[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()); + let mut verifier = NipopowVerifier::new(genesis_id); + assert!(verifier.process(proof.clone()).is_ok()); + assert_eq!(verifier.best_proof(), Some(proof)); + } + + #[test] + fn test_nipopow_verifier_accepts_valid_proof_after_invalid_initial_proof() { + let (genesis_id, valid_proof) = generated_nipopow_proof(); + let mut invalid_proof = valid_proof.clone(); + invalid_proof.suffix_tail[0].parent_id = BlockId(Digest32::zero()); + assert!(!invalid_proof.has_valid_connections()); + + let mut verifier = NipopowVerifier::new(genesis_id); + assert!(verifier.process(invalid_proof).is_ok()); + assert!(verifier.best_proof().is_none()); + assert!(verifier.process(valid_proof.clone()).is_ok()); + assert_eq!(verifier.best_proof(), Some(valid_proof)); + } + #[test] fn test_nipopow_lowest_common_ancestor_diverging() { let popow_algos = NipopowAlgos::default(); diff --git a/ergo-chain-types/src/header.rs b/ergo-chain-types/src/header.rs index 44d40b3f4..bc5c22356 100644 --- a/ergo-chain-types/src/header.rs +++ b/ergo-chain-types/src/header.rs @@ -91,8 +91,9 @@ impl Header { w.write_all(&self.votes.0)?; // For block version >= 2, this new byte encodes length of possible new fields. - // Set to 0 for now, so no new fields. - if self.version > 1 { + // Set to 0 for now, so no new fields. `version` is a signed Byte in the JVM, + // so a version >= 0x80 is negative and does not gate these fields -- compare signed. + if (self.version as i8) > 1 { w.put_u8(self.unparsed_bytes.len().try_into()?)?; w.write_all(&self.unparsed_bytes)?; } @@ -139,7 +140,9 @@ impl ScorexSerializable for Header { // For block version >= 2, a new byte encodes length of possible new fields. If this byte > // 0, we read new fields but do nothing, as semantics of the fields is not known. - let unparsed_bytes: Box<[u8]> = if version > 1 { + // `version` is a signed Byte in the JVM, so a version >= 0x80 is negative and skips + // this region (the AutolykosSolution parse then shifts accordingly) -- compare signed. + let unparsed_bytes: Box<[u8]> = if (version as i8) > 1 { let new_field_size = r.get_u8()?; if new_field_size > 0 { let mut field_bytes: Vec = vec![0; new_field_size as usize]; @@ -508,3 +511,45 @@ mod tests { ); } } + +#[allow(clippy::unwrap_used, clippy::panic)] +#[cfg(test)] +mod version_signedness_tests { + use crate::header::Header; + use sigma_ser::ScorexSerializable; + + // SANTA ask 21 / V6-ARITY: the JVM (`HeaderWithoutPow`) reads `version` as a + // signed `Byte`, so a version byte 0x80 = -128 <= 1 SKIPS `unparsedBytes`, + // shifting the AutolykosSolution parse so `minerPk` reads as the infinity point. + // sigma-rust read `version` unsigned (128 > 1), consumed `unparsedBytes`, and + // parsed a different `minerPk` -- a deserialization fork vs sigma-state 6.0.3. + // The two witnesses are identical except the leading version byte. + const HEADER_V80: &str = "80ac2101807f0000ca01ff0119db227f202201007f62000177a080005d440896d05d3f80dcff7f5e7f59007294c180808d0158d1ff6ba10000f901c7f0ef87dcfff17fffacb6ff7f7f1180d2ff7f1e24ffffe1ff937f807f0797b9ff6ebdae007e5c8c00b8403d3701557181c8df800001b6d5009e2201c6ff807d71808c00019780f087adb3fcdbc0b3441480887f80007f4b01cf7f013ff1ffff564a0000b9a54f00770e807f41ff88c00240000080c0250000000003bedaee069ff4829500b3c07c4d5fe6b3ea3d3bf76c5c28c1d4dcdb1bed0ade0c0000000000003105"; + + fn miner_pk_hex(header_hex: &str) -> String { + let bytes = base16::decode(header_hex).unwrap(); + let h = Header::scorex_parse_bytes(&bytes).unwrap(); + base16::encode_lower( + &h.autolykos_solution + .miner_pk + .scorex_serialize_bytes() + .unwrap(), + ) + } + + #[test] + fn version_0x80_skips_unparsed_bytes_miner_pk_is_infinity() { + // signed -128 <= 1 -> skip unparsedBytes -> minerPk = point at infinity (33 zero bytes) + assert_eq!(miner_pk_hex(HEADER_V80), "00".repeat(33)); + } + + #[test] + fn version_0x7f_control_reads_unparsed_bytes_miner_pk_is_real() { + // 127 > 1 in both signed and unsigned readings -> reads unparsedBytes (fix does not change it) + let header_v7f = format!("7f{}", &HEADER_V80[2..]); + assert_eq!( + miner_pk_hex(&header_v7f), + "03bedaee069ff4829500b3c07c4d5fe6b3ea3d3bf76c5c28c1d4dcdb1bed0ade0c" + ); + } +} diff --git a/ergo-lib/Cargo.toml b/ergo-lib/Cargo.toml index e8c8a869b..22fbda943 100644 --- a/ergo-lib/Cargo.toml +++ b/ergo-lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-lib" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Denys Zadorozhnyi "] repository.workspace = true diff --git a/ergo-lib/src/wallet/tx_context.rs b/ergo-lib/src/wallet/tx_context.rs index 5339e397b..6c76939d6 100644 --- a/ergo-lib/src/wallet/tx_context.rs +++ b/ergo-lib/src/wallet/tx_context.rs @@ -10,7 +10,6 @@ use crate::chain::transaction::{verify_tx_input_proof, Transaction, TransactionE use crate::ergotree_ir::chain::ergo_box::BoxId; use ergotree_interpreter::sigma_protocol::verifier::VerificationResult; use ergotree_ir::chain::context::TxIoVec; -use ergotree_ir::chain::ergo_box::box_value::BoxValue; use ergotree_ir::chain::ergo_box::{BoxTokens, ErgoBox}; use ergotree_ir::chain::token::{TokenAmount, TokenId}; use ergotree_ir::serialization::SigmaSerializable; @@ -104,25 +103,26 @@ impl TransactionContext { // TODO: costing // This is based on validateStateful() in Ergo: https://github.com/ergoplatform/ergo/blob/48239ef98ced06617dc21a0eee5670235e362933/ergo-core/src/main/scala/org/ergoplatform/modifiers/mempool/ErgoTransaction.scala#L357 pub fn validate(&self, state_context: &ErgoStateContext) -> Result<(), TxValidationError> { - // Check that input sum does not overflow - let input_sum = BoxValue::new( - self.boxes_to_spend - .iter() - .map(|b| b.value.as_u64()) - .sum::(), - ) - .map_err(|_| TxValidationError::InputSumOverflow)?; + // Check that input sum does not overflow. The reference implementation + // sums with Math.addExact over longs (validateStateful), so every + // addition is checked — a plain sum would panic in debug builds and + // wrap in release builds before any bound check could fire. + let input_sum = self + .boxes_to_spend + .iter() + .try_fold(0i64, |a, b| a.checked_add(b.value.as_i64())) + .ok_or(TxValidationError::InputSumOverflow)?; // Check that output sum does not overflow and is equal to ERG amount in inputs let output_sum = self .spending_tx .outputs .iter() - .map(|b| b.value.as_u64()) - .sum(); - if *input_sum.as_u64() != output_sum { + .try_fold(0i64, |a, b| a.checked_add(b.value.as_i64())) + .ok_or(TxValidationError::OutputSumOverflow)?; + if input_sum != output_sum { return Err(TxValidationError::ErgPreservationError( - *input_sum.as_u64(), - output_sum, + input_sum as u64, + output_sum as u64, )); } @@ -313,7 +313,7 @@ mod test { use crate::chain::transaction::ergo_transaction::{ErgoTransaction, TxValidationError}; use crate::chain::transaction::prover_result::ProverResult; use crate::chain::transaction::unsigned::UnsignedTransaction; - use crate::chain::transaction::{Input, Transaction, UnsignedInput}; + use crate::chain::transaction::{Input, Transaction, TxId, UnsignedInput}; use crate::wallet::Wallet; use super::TransactionContext; @@ -569,6 +569,85 @@ mod test { } } + fn huge_value_box(index: u16) -> ErgoBox { + ErgoBox::new( + BoxValue::try_from(i64::MAX as u64).unwrap(), + force_any_val::(), + None, + NonMandatoryRegisters::empty(), + 0, + force_any_val::(), + index, + ) + .unwrap() + } + + fn input_for(b: &ErgoBox) -> Input { + Input { + box_id: b.box_id(), + spending_proof: ProverResult { + proof: ProofBytes::Empty, + extension: ContextExtension::empty(), + }, + } + } + + // Aggregate ERG overflow must surface as a validation error (issue #881): + // the reference implementation sums with Math.addExact, so an overflowing + // aggregate is trapped at the addition — not a panic (debug) or a wrapped + // total flowing into the preservation check (release). Three i64::MAX + // boxes are each individually valid but their sum exceeds the i64 domain. + #[test] + fn validate_input_sum_overflow() { + let boxes: Vec = (0..3).map(huge_value_box).collect(); + let inputs: Vec = boxes.iter().map(input_for).collect(); + let output = ErgoBoxCandidate { + value: BoxValue::SAFE_USER_MIN, + ergo_tree: force_any_val::(), + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: 0, + }; + let tx = Transaction::new_from_vec(inputs, vec![], vec![output]).unwrap(); + let tx_context = TransactionContext::new(tx, boxes, vec![]).unwrap(); + let state_context: ErgoStateContext = force_any_val(); + assert!(matches!( + tx_context.validate(&state_context), + Err(TxValidationError::InputSumOverflow) + )); + } + + #[test] + fn validate_output_sum_overflow() { + let input_box = ErgoBox::new( + BoxValue::SAFE_USER_MIN, + force_any_val::(), + None, + NonMandatoryRegisters::empty(), + 0, + force_any_val::(), + 0, + ) + .unwrap(); + let inputs = vec![input_for(&input_box)]; + let outputs: Vec = (0..3) + .map(|_| ErgoBoxCandidate { + value: BoxValue::try_from(i64::MAX as u64).unwrap(), + ergo_tree: force_any_val::(), + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: 0, + }) + .collect(); + let tx = Transaction::new_from_vec(inputs, vec![], outputs).unwrap(); + let tx_context = TransactionContext::new(tx, vec![input_box], vec![]).unwrap(); + let state_context: ErgoStateContext = force_any_val(); + assert!(matches!( + tx_context.validate(&state_context), + Err(TxValidationError::OutputSumOverflow) + )); + } + proptest! { #![proptest_config(ProptestConfig::with_cases(32))] diff --git a/ergo-nipopow/src/nipopow_algos.rs b/ergo-nipopow/src/nipopow_algos.rs index f9686abd8..7be94c2de 100644 --- a/ergo-nipopow/src/nipopow_algos.rs +++ b/ergo-nipopow/src/nipopow_algos.rs @@ -12,6 +12,13 @@ use ergo_chain_types::{BlockId, Digest32, ExtensionCandidate}; /// Prefix for Block Interlinks pub const INTERLINK_VECTOR_PREFIX: u8 = 0x01; + +/// Default value for [`NipopowAlgos::use_last_epochs`] — the number of epochs +/// the difficulty adjustment looks back over. Matches the value used by Ergo +/// mainnet AND testnet (`useLastEpochs = 8` in +/// `ergo-core/src/main/resources/application.conf`). +pub const DEFAULT_USE_LAST_EPOCHS: u32 = 8; + /// A set of utilities for working with NiPoPoW protocol. /// /// Based on papers: @@ -22,10 +29,33 @@ pub const INTERLINK_VECTOR_PREFIX: u8 = 0x01; /// /// Please note that for KMZ17 we're using the version published @ Financial Cryptography 2020, /// which is different from previously published versions on IACR eprint. -#[derive(Default, Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct NipopowAlgos { /// The proof-of-work scheme pub pow_scheme: AutolykosPowScheme, + /// Number of last epochs the difficulty adjustment looks back over. + /// + /// This is the Rust analog of the JVM `chainSettings.useLastEpochs` + /// field. It is consumed by [`NipopowProof::has_valid_connections`] to + /// compute the maximum allowed gap between adjacent prefix entries — + /// JVM-built proofs include continuous-mode difficulty-recalculation + /// headers and naturally-skipped entries from sparse-superlevel walks, + /// so the verifier needs a tolerant lookback window to accept them. + /// + /// Default is [`DEFAULT_USE_LAST_EPOCHS`] (= 8), matching Ergo mainnet + /// and testnet `application.conf`. A future port of `ChainSettings` can + /// replace this single field with the full struct without changing the + /// connection-check semantics. + pub use_last_epochs: u32, +} + +impl Default for NipopowAlgos { + fn default() -> Self { + Self { + pow_scheme: AutolykosPowScheme::default(), + use_last_epochs: DEFAULT_USE_LAST_EPOCHS, + } + } } impl NipopowAlgos { @@ -196,36 +226,50 @@ impl NipopowAlgos { NipopowProof::new(m, k, prefix, suffix_head, suffix_tail) } /// Packs interlinks into key-value format of the block extension. + /// + /// Each duplicate-run of `BlockId`s in the input is packed into a single + /// `([INTERLINK_VECTOR_PREFIX, first_pos], [qty, blockid_bytes...])` entry, + /// where `first_pos` is the input-vector index of the run's first element. + /// This matches JVM Ergo's `org.ergoplatform.modifiers.history.popow.NipopowAlgos` + /// encoding — the Merkle-leaf hash of each entry depends on `first_pos`, so a + /// sequential distinct-group counter (used here pre-fix) produces leaves that + /// hash differently and break `check_interlinks_proof` against JVM-generated + /// proofs. pub fn pack_interlinks(interlinks: Vec) -> Vec<([u8; 2], Vec)> { + if interlinks.is_empty() { + return vec![]; + } let mut res = vec![]; - let mut ix_distinct_block_ids = 0; - let mut curr_block_id_count = 1; + let mut curr_block_id_count: u8 = 1; let mut curr_block_id = interlinks[0]; - for id in interlinks.into_iter().skip(1) { - if id == curr_block_id { + let mut curr_first_pos: usize = 0; + for (i, id) in interlinks.iter().enumerate().skip(1) { + if *id == curr_block_id { curr_block_id_count += 1; } else { let block_id_bytes: Vec = curr_block_id.0.into(); let packed_value = std::iter::once(curr_block_id_count) .chain(block_id_bytes) .collect(); - res.push(( - [INTERLINK_VECTOR_PREFIX, ix_distinct_block_ids], - packed_value, - )); - curr_block_id = id; + #[allow(clippy::expect_used)] + let ix_byte: u8 = curr_first_pos + .try_into() + .expect("interlinks first-position byte index > 255"); + res.push(([INTERLINK_VECTOR_PREFIX, ix_byte], packed_value)); + curr_block_id = *id; curr_block_id_count = 1; - ix_distinct_block_ids += 1; + curr_first_pos = i; } } let block_id_bytes: Vec = curr_block_id.0.into(); let packed_value = std::iter::once(curr_block_id_count) .chain(block_id_bytes) .collect(); - res.push(( - [INTERLINK_VECTOR_PREFIX, ix_distinct_block_ids], - packed_value, - )); + #[allow(clippy::expect_used)] + let ix_byte: u8 = curr_first_pos + .try_into() + .expect("interlinks first-position byte index > 255"); + res.push(([INTERLINK_VECTOR_PREFIX, ix_byte], packed_value)); res } /// Unpacks interlinks from key-value format of block extension. @@ -348,3 +392,45 @@ fn extension_merkletree(kv: &[([u8; 2], Vec)]) -> ergo_merkle_tree::MerkleTr .collect::>(); ergo_merkle_tree::MerkleTree::new(leafs) } + +#[cfg(test)] +mod tests { + use super::*; + + fn blockid(byte: u8) -> BlockId { + BlockId(Digest32::from([byte; 32])) + } + + #[test] + fn pack_interlinks_keys_are_first_occurrence_positions() { + // JVM Ergo encodes ExtensionKV keys as [INTERLINK_VECTOR_PREFIX, pos_of_first_occurrence] + // where pos is the index of the first interlink in each duplicate-run within the input + // vector. Verified against mainnet block 1784124 (11/11 leaf hashes match with this + // encoding; 2/11 with the prior sequential distinct_ix counter). + let a = blockid(0xa0); + let b = blockid(0xb0); + let c = blockid(0xc0); + let d = blockid(0xd0); + // [A, B, B, B, B, C, D] — positions 0, 1..4 (run), 5, 6 + let interlinks = vec![a, b, b, b, b, c, d]; + + let packed = NipopowAlgos::pack_interlinks(interlinks); + + assert_eq!(packed.len(), 4); + assert_eq!(packed[0].0, [INTERLINK_VECTOR_PREFIX, 0]); + assert_eq!(packed[1].0, [INTERLINK_VECTOR_PREFIX, 1]); + assert_eq!(packed[2].0, [INTERLINK_VECTOR_PREFIX, 5]); + assert_eq!(packed[3].0, [INTERLINK_VECTOR_PREFIX, 6]); + + assert_eq!(packed[0].1[0], 1); + assert_eq!(packed[1].1[0], 4); + assert_eq!(packed[2].1[0], 1); + assert_eq!(packed[3].1[0], 1); + } + + #[test] + fn pack_interlinks_empty_returns_empty() { + let packed = NipopowAlgos::pack_interlinks(vec![]); + assert!(packed.is_empty()); + } +} diff --git a/ergo-nipopow/src/nipopow_proof.rs b/ergo-nipopow/src/nipopow_proof.rs index d10fbac11..a0e68c5df 100644 --- a/ergo-nipopow/src/nipopow_proof.rs +++ b/ergo-nipopow/src/nipopow_proof.rs @@ -82,31 +82,87 @@ impl NipopowProof { } } - fn is_valid(&self) -> bool { + /// 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() } - /// Checks the connections of the blocks in the proof. Adjacent blocks should be linked either - /// via interlink or parent block id. Returns true if all adjacent blocks are correctly - /// connected. + /// Checks the connections of the blocks in the proof. + /// + /// Adjacent blocks in the suffix must be linked via parent id (the + /// suffix is a strict header chain). + /// + /// Prefix entries are linked via interlink or parent block id, but the + /// check is *tolerant*: each entry need not connect to its immediate + /// predecessor — it may connect to any of the up to + /// `use_last_epochs + 3` immediately preceding entries. The tolerance + /// exists because JVM-built proofs include continuous-mode + /// difficulty-recalculation headers and naturally-skipped entries from + /// sparse-superlevel walks; these show up as prefix entries that don't + /// connect to their immediate sorted-by-height neighbour but do connect + /// to a nearby earlier neighbour. + /// + /// Direct port of the JVM + /// `org.ergoplatform.modifiers.history.popow.NipopowProof.hasValidConnections` + /// (see `ergo-core/.../popow/NipopowProof.scala`, lines ~128-148): + /// + /// ```scala + /// val maxDiffHeaders = popowAlgos.chainSettings.useLastEpochs + 1 + /// val prefixToCheck = prefix :+ suffixHead + /// val prefixConnections = (1 until prefixToCheck.length).forall { checkIdx => + /// val next = prefixToCheck(checkIdx) + /// (checkIdx - 1).to(Math.max(0, checkIdx - maxDiffHeaders - 1 - 1), -1).exists { prevIdx => + /// val prev = prefixToCheck(prevIdx) + /// next.interlinks.contains(prev.id) || next.header.parentId == prev.id + /// } + /// } + /// ``` + /// + /// The Scala `(checkIdx - 1).to(max(0, checkIdx - maxDiffHeaders - 2), -1)` + /// range is **inclusive on both ends**, so the lookback covers indices + /// `[max(0, checkIdx - maxDiffHeaders - 2), checkIdx - 1]` — a window of + /// up to `maxDiffHeaders + 2 = use_last_epochs + 3` predecessors. pub fn has_valid_connections(&self) -> bool { - self.prefix - .iter() - .zip( - self.prefix - .iter() - .skip(1) - .chain(std::iter::once(&self.suffix_head)), - ) - .all(|(prev, next)| { - // Note that blocks with level 0 do not appear at all within interlinks, which is - // why we need to check the parent block id as well. + let use_last_epochs = self.popow_algos.use_last_epochs as usize; + // `maxDiffHeaders = useLastEpochs + 1` in JVM. The full lookback span + // is `maxDiffHeaders + 2 = use_last_epochs + 3` predecessors. + let lookback_span = use_last_epochs + 3; + + let prefix_len = self.prefix.len(); + // `prefixToCheck = prefix :+ suffixHead` — virtual concatenation, + // accessed via the closure below to avoid an unnecessary clone. + let prefix_to_check_len = prefix_len + 1; + let get = |idx: usize| -> &PoPowHeader { + if idx == prefix_len { + &self.suffix_head + } else { + &self.prefix[idx] + } + }; + + let prefix_connections = (1..prefix_to_check_len).all(|check_idx| { + let next = get(check_idx); + // JVM walks `(checkIdx - 1).to(max(0, checkIdx - maxDiffHeaders - 2), -1)`, + // i.e. indices `[lookback_start, check_idx - 1]` inclusive, + // descending. The descending order is observable only as a + // micro-optimization (closer predecessors are likeliest to match); + // any iteration order is semantically equivalent for `exists`. + let lookback_start = check_idx.saturating_sub(lookback_span); + (lookback_start..check_idx).rev().any(|prev_idx| { + let prev = get(prev_idx); + // Note that blocks with level 0 do not appear at all within + // interlinks, which is why we need to check the parent + // block id as well. next.interlinks.contains(&prev.header.id) || next.header.parent_id == prev.header.id }) - && std::iter::once(&self.suffix_head.header) - .chain(self.suffix_tail.iter()) - .zip(self.suffix_tail.iter()) - .all(|(prev, next)| next.parent_id == prev.id) + }); + + let suffix_connections = std::iter::once(&self.suffix_head.header) + .chain(self.suffix_tail.iter()) + .zip(self.suffix_tail.iter()) + .all(|(prev, next)| next.parent_id == prev.id); + + prefix_connections && suffix_connections } /// Checks if the heights of the header-chain provided are consistent, meaning that for any two @@ -215,28 +271,55 @@ pub struct PoPowHeader { } impl PoPowHeader { + fn has_packable_interlinks(&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; + + for (position, interlink) in self.interlinks.iter().enumerate().skip(1) { + if interlink == current { + if run_length == max_run_length { + return false; + } + run_length += 1; + } else { + if position > max_key_position { + return false; + } + current = interlink; + run_length = 1; + } + } + true + } + /// Validates interlinks merkle root against provided proof pub fn check_interlinks_proof(&self) -> bool { - if self.interlinks.is_empty() - && self.interlinks_proof.get_indices().is_empty() - && self.interlinks_proof.get_proofs().is_empty() - { - true - } else { - 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 proof_is_empty = self.interlinks_proof.get_indices().is_empty() + && self.interlinks_proof.get_proofs().is_empty(); + if self.interlinks.is_empty() { + return proof_is_empty; } + if !self.has_packable_interlinks() { + 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()) } } @@ -288,8 +371,6 @@ impl ScorexSerializable for PoPowHeader { #[cfg(feature = "arbitrary")] #[allow(clippy::unwrap_used)] mod arbitrary { - use ergo_chain_types::autolykos_pow_scheme::AutolykosPowScheme; - use super::*; use ergo_chain_types::Digest32; use ergo_chain_types::ExtensionCandidate; @@ -330,9 +411,7 @@ mod arbitrary { vec(any::
(), 1..10), ) .prop_map(|(m, k, prefix, suffix_head, suffix_tail)| NipopowProof { - popow_algos: NipopowAlgos { - pow_scheme: AutolykosPowScheme::default(), - }, + popow_algos: NipopowAlgos::default(), m, k, prefix, @@ -349,7 +428,11 @@ mod 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! { @@ -362,4 +445,166 @@ pub mod tests { } + + /// Build a `BlockId` filled with the given byte. Used by the + /// `has_valid_connections` regression tests below to mint distinct, + /// human-traceable block ids without paying the cost of real hashing. + fn id_from_byte(byte: u8) -> BlockId { + 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`. + fn header_factory() -> impl Fn(BlockId, BlockId, u32) -> Header { + let mut runner = TestRunner::default(); + let base = any::>() + .new_tree(&mut runner) + .unwrap() + .current(); + move |id, parent_id, height| { + let mut h = (*base).clone(); + h.id = id; + h.parent_id = parent_id; + h.height = height; + h + } + } + + /// Build a `PoPowHeader` from a header plus an interlink vector. The + /// `interlinks_proof` is a no-op empty proof — `has_valid_connections` + /// never invokes `check_interlinks_proof`, so this is sufficient for + /// connection-check tests. + fn pop_header(header: Header, interlinks: Vec) -> PoPowHeader { + PoPowHeader { + header, + interlinks, + interlinks_proof: BatchMerkleProof::new(vec![], vec![]), + } + } + + /// Constructs a deliberately-skipped prefix and asserts the JVM-tolerant + /// `has_valid_connections` accepts it. + /// + /// Layout: `h0 -> h1 -> h2 -> h3 -> suffix_head`. `h2.parent_id` is set + /// to an unrelated id (NOT `h1.id`) and `h1.id` is **not** in + /// `h2.interlinks`, so the strict (pre-fix) verifier would have rejected + /// at index 2. The tolerant verifier MUST accept because `h0.id` is in + /// `h2.interlinks` and `h0` is within the lookback window + /// (`use_last_epochs + 3 = 11` predecessors by default). + #[test] + fn has_valid_connections_accepts_skipped_prefix_entry() { + let mk_header = header_factory(); + + let h0_id = id_from_byte(1); + let h1_id = id_from_byte(2); + let h2_id = id_from_byte(3); + let h3_id = id_from_byte(4); + let suffix_head_id = id_from_byte(5); + let suffix_tail_id = id_from_byte(6); + let unrelated_parent = id_from_byte(0xff); + + // h0: genesis. parent_id is irrelevant for index 0. + let h0 = pop_header(mk_header(h0_id, id_from_byte(0), 1), vec![h0_id]); + // h1: connects via parent_id == h0.id. + let h1 = pop_header(mk_header(h1_id, h0_id, 10), vec![h0_id]); + // h2: parent_id is UNRELATED (not h1) and h1.id is NOT in interlinks, + // but h0.id IS in interlinks → tolerant verifier connects via h0 + // through the lookback window. + let h2 = pop_header(mk_header(h2_id, unrelated_parent, 20), vec![h0_id]); + // h3: connects via parent_id == h2.id. + let h3 = pop_header(mk_header(h3_id, h2_id, 30), vec![h0_id, h2_id]); + // suffix_head: connects via parent_id == h3.id. + let suffix_head = pop_header( + mk_header(suffix_head_id, h3_id, 40), + vec![h0_id, h2_id, h3_id], + ); + // suffix_tail must be a strict parent_id chain. + let suffix_tail = vec![mk_header(suffix_tail_id, suffix_head_id, 41)]; + + let proof = + NipopowProof::new(6, 2, vec![h0, h1, h2, h3], suffix_head, suffix_tail).unwrap(); + + assert!( + proof.has_valid_connections(), + "tolerant verifier must accept a prefix where an entry skips its \ + immediate predecessor but still connects to a header within the \ + lookback window" + ); + } + + /// Constructs a prefix with a gap LARGER than the lookback window and + /// asserts the verifier still rejects. This proves the fix is not a + /// blanket accept-all. + /// + /// We squeeze the lookback by setting `use_last_epochs = 0`, which gives + /// `lookback_span = 3`. The chain is then designed so that the bad entry + /// (suffix_head, at index 4) only connects backward to index 0, which is + /// outside the `[1, 3]` lookback range. + #[test] + fn has_valid_connections_rejects_too_far_skip() { + let mk_header = header_factory(); + + let h0_id = id_from_byte(1); + let h1_id = id_from_byte(2); + let h2_id = id_from_byte(3); + let h3_id = id_from_byte(4); + let suffix_head_id = id_from_byte(5); + let suffix_tail_id = id_from_byte(6); + let unrelated_parent = id_from_byte(0xff); + + let h0 = pop_header(mk_header(h0_id, id_from_byte(0), 1), vec![h0_id]); + let h1 = pop_header(mk_header(h1_id, h0_id, 10), vec![h0_id]); + let h2 = pop_header(mk_header(h2_id, h1_id, 20), vec![h0_id, h1_id]); + let h3 = pop_header(mk_header(h3_id, h2_id, 30), vec![h0_id, h2_id]); + // suffix_head's parent is unrelated, h0 is its only interlink, and + // h1/h2/h3 ids are NOT among its interlinks. With lookback span 3, + // the lookback window for index 4 covers indices [1, 3] only — + // h0 (index 0) is excluded → no valid predecessor → REJECT. + let suffix_head = pop_header(mk_header(suffix_head_id, unrelated_parent, 40), vec![h0_id]); + let suffix_tail = vec![mk_header(suffix_tail_id, suffix_head_id, 41)]; + + let mut proof = + NipopowProof::new(6, 2, vec![h0, h1, h2, h3], suffix_head, suffix_tail).unwrap(); + + // Squeeze the lookback window to size 3 (= use_last_epochs + 3) + // so a small synthetic chain can demonstrate the boundary. + proof.popow_algos.use_last_epochs = 0; + + assert!( + !proof.has_valid_connections(), + "verifier must reject when the only valid backward connection is \ + outside the lookback window — the fix is tolerant, not blanket" + ); + } + + /// Sanity check: a proof whose suffix tail is broken (parent_id chain + /// violated) must still be rejected. Ensures we didn't accidentally + /// loosen the suffix-side check while loosening the prefix-side check. + #[test] + fn has_valid_connections_rejects_broken_suffix_tail() { + let mk_header = header_factory(); + + let h0_id = id_from_byte(1); + let suffix_head_id = id_from_byte(2); + let bad_parent = id_from_byte(0xee); + let suffix_tail_id = id_from_byte(3); + + let h0 = pop_header(mk_header(h0_id, id_from_byte(0), 1), vec![h0_id]); + let suffix_head = pop_header(mk_header(suffix_head_id, h0_id, 10), vec![h0_id]); + // suffix_tail header's parent_id is unrelated to suffix_head.id + // → suffix-side check must fail. + let suffix_tail = vec![mk_header(suffix_tail_id, bad_parent, 11)]; + + let proof = NipopowProof::new(6, 2, vec![h0], suffix_head, suffix_tail).unwrap(); + + assert!( + !proof.has_valid_connections(), + "broken suffix tail (parent_id chain violation) must still be \ + rejected after the prefix-tolerance fix" + ); + } } diff --git a/ergo-nipopow/src/nipopow_verifier.rs b/ergo-nipopow/src/nipopow_verifier.rs index 57b55a5b6..3b775f9c6 100644 --- a/ergo-nipopow/src/nipopow_verifier.rs +++ b/ergo-nipopow/src/nipopow_verifier.rs @@ -42,7 +42,7 @@ impl NipopowVerifier { if new_proof.is_better_than(p)? { self.best_proof = Some(new_proof); } - } else { + } else if new_proof.is_valid() { self.best_proof = Some(new_proof); } } diff --git a/ergotree-interpreter/Cargo.toml b/ergotree-interpreter/Cargo.toml index af1df292e..ed0c83334 100644 --- a/ergotree-interpreter/Cargo.toml +++ b/ergotree-interpreter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergotree-interpreter" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Denys Zadorozhnyi "] repository.workspace = true @@ -35,7 +35,7 @@ serde_json = { workspace = true, optional = true } serde_with = { workspace = true, optional = true } proptest = { workspace = true, optional = true } ergo_avltree_rust = { version = "0.1.1" } -gf2_192 = { version = "^0.28.0", path = "../gf2_192" } +gf2_192 = { version = "^0.30.0", path = "../gf2_192" } miette = { workspace = true, optional = true } hashbrown = { workspace = true } core2 = { workspace = true } diff --git a/ergotree-interpreter/src/eval.rs b/ergotree-interpreter/src/eval.rs index 356a48261..72e484c49 100644 --- a/ergotree-interpreter/src/eval.rs +++ b/ergotree-interpreter/src/eval.rs @@ -12,6 +12,7 @@ use ergotree_ir::mir::value::Value; use ergotree_ir::sigma_protocol::sigma_boolean::SigmaBoolean; use ergotree_ir::types::smethod::SMethod; +use ergotree_ir::types::stype::SType; use self::env::Env; use ergotree_ir::chain::context::Context; @@ -126,8 +127,56 @@ pub struct ReductionResult { pub diag: ReductionDiagnosticInfo, } +/// Reject a self-box `ContextExtension` whose key set leaves the signed-`Byte` +/// range (any key `>= 0x80`), mirroring the JVM's `ErgoLikeContext.toSigmaContext` +/// (`ErgoLikeContext.scala:140-146`): it builds `contextVars` as +/// `new Array(maxKey + 1)` indexed by the SIGNED key, so a key `>= 0x80` +/// (signed-negative) crashes context construction — `NegativeArraySizeException` +/// (all keys negative ⇒ negative array size) or `ArrayIndexOutOfBoundsException` +/// (a negative index) — BEFORE any bytecode runs, and the spend never validates. +/// sigma-rust stores keys as unsigned `u8` and resolves them lazily at `GetVar`, +/// so without this guard it would ACCEPT a `>= 0x80` key where the JVM rejects — +/// a consensus fork on the attacker-supplied spending extension. The rejection is +/// on key PRESENCE (a `GetVar`-free tree still rejects), not on the read. This is +/// the top-level self extension only; the per-input `getVarFromInput` path masks +/// the id with `& 0xff` by design and is intentionally not affected. +fn validate_self_extension_key_domain(ctx: &Context) -> Result<(), EvalError> { + if let Some(&bad_key) = ctx.extension.values.keys().find(|&&k| k > 0x7f) { + return Err(EvalError::Misc(format!( + "ContextExtension key {bad_key} is outside the signed-Byte range 0..=127 \ + that context construction accepts (>= 0x80 crashes the JVM vars array)" + ))); + } + Ok(()) +} + +/// Mirror of sigma-state `SType.isValueOfType`'s reachable rejections +/// (`SType.scala:200-205`), invoked by the JVM via `Value.checkType` at the +/// Tuple-eval items (`values.scala:801/804`) and `ConstantPlaceholder.eval` +/// (`values.scala:412`): a tuple type must be a pair (arity 2) and a function +/// type must be unary, otherwise the JVM throws "Unsupported tuple/function +/// type". sigma-rust models flat N-ary tuples (`TupleItems` = `BoundedVec<2,255>`) +/// so an arity≠2 tuple constant/value is representable and would otherwise +/// evaluate where the JVM rejects (a consensus accept/reject divergence). The +/// remaining `isValueOfType` arms only assert a value matches its type, which is +/// an invariant of sigma-rust's typed `Value`s, so they need no run-time check. +pub(crate) fn check_value_type(tpe: &SType) -> Result<(), EvalError> { + match tpe { + SType::STuple(t) if t.items.len() != 2 => { + Err(EvalError::Misc(format!("Unsupported tuple type {tpe:?}"))) + } + SType::SFunc(f) if f.t_dom.len() != 1 => Err(EvalError::Misc(format!( + "Unsupported function type {tpe:?}" + ))), + _ => Ok(()), + } +} + /// Evaluate the given expression by reducing it to SigmaBoolean value. pub fn reduce_to_crypto(tree: &ErgoTree, ctx: &Context) -> Result { + // The JVM rejects a `>= 0x80` self-extension key at context construction, + // before any bytecode; mirror that here at the reduction boundary. + validate_self_extension_key_domain(ctx)?; fn inner<'ctx>(expr: &'ctx Expr, ctx: &Context<'ctx>) -> Result { let mut env_mut = Env::empty(); expr.eval(&mut env_mut, ctx) @@ -442,6 +491,12 @@ pub mod test_util { expr: &Expr, ctx: &'ctx Context<'ctx>, ) -> Result { + // The eval-tier mirror of `reduce_to_crypto`'s context-construction guard: + // a self extension key >= 0x80 crashes JVM context construction before any + // bytecode, so reject it here too. This entry is the one the conformance + // runner drives, so it must surface the same key-domain verdict the node + // path does (without it the runner would never exercise the rejection). + super::validate_self_extension_key_domain(ctx)?; let expr = expr.clone().substitute_deserialize(ctx)?; try_eval_out(&expr, ctx) } @@ -537,4 +592,66 @@ mod test { "#]] .assert_eq(&res.diag.to_string()); } + + // The JVM rejects a self-box ContextExtension with any key >= 0x80 at context + // construction (the signed-key vars array crashes), before any bytecode. Mirror: + // both the node entry (reduce_to_crypto) and the eval-tier entry + // (try_eval_with_deserialize) reject on key PRESENCE — a GetVar-free `true` tree + // still rejects — with 0x7f the accepted boundary and 0x80/0xff rejected. + #[test] + fn self_extension_key_above_0x7f_rejected_at_construction() { + use crate::eval::test_util::{try_eval_out, try_eval_with_deserialize}; + use ergotree_ir::chain::context_extension::ContextExtension; + use ergotree_ir::mir::constant::Constant; + use ergotree_ir::mir::get_var::GetVar; + use ergotree_ir::mir::value::Value; + + // GetVar-free trivial tree: reduces to TrivialProp(true) when the context is + // accepted, so any rejection is purely the key-domain guard. + let true_expr: Expr = Expr::Const(true.into()); + let tree: ErgoTree = true_expr.clone().try_into().unwrap(); + + let ctx_with_key = |key: u8| -> Context<'static> { + let mut ext = ContextExtension::empty(); + ext.values.insert(key, Constant::from(1i32)); + let mut ctx = force_any_val::(); + ctx.extension = Box::leak(Box::new(ext)); + ctx + }; + + // 0x7f (127) — the boundary — accepts on both the node and eval-tier entries. + assert_eq!( + reduce_to_crypto(&tree, &ctx_with_key(0x7f)) + .unwrap() + .sigma_prop, + SigmaBoolean::TrivialProp(true) + ); + assert_eq!( + try_eval_with_deserialize::(&true_expr, &ctx_with_key(0x7f)).unwrap(), + Value::Boolean(true) + ); + // 0x80 (128) and 0xff (255) — out of the signed-Byte range — reject on both. + assert!(reduce_to_crypto(&tree, &ctx_with_key(0x80)).is_err()); + assert!(reduce_to_crypto(&tree, &ctx_with_key(0xff)).is_err()); + assert!(try_eval_with_deserialize::(&true_expr, &ctx_with_key(0x80)).is_err()); + assert!(try_eval_with_deserialize::(&true_expr, &ctx_with_key(0xff)).is_err()); + // An empty self extension is unaffected. + let mut ctx_empty = force_any_val::(); + ctx_empty.extension = Box::leak(Box::new(ContextExtension::empty())); + assert!(reduce_to_crypto(&tree, &ctx_empty).is_ok()); + + // Guard-rail: the READ is not over-rejected — GetVar of a >= 0x80 id with the + // key ABSENT (so it never reaches the construction check) stays None. + let get_var_0x80: Expr = GetVar { + var_id: 0x80, + var_tpe: SType::SInt, + } + .into(); + let mut ctx_no_var = force_any_val::(); + ctx_no_var.extension = Box::leak(Box::new(ContextExtension::empty())); + assert_eq!( + try_eval_out::(&get_var_0x80, &ctx_no_var).unwrap(), + Value::Opt(None) + ); + } } diff --git a/ergotree-interpreter/src/eval/tuple.rs b/ergotree-interpreter/src/eval/tuple.rs index ae0be2ddd..9add3c3ca 100644 --- a/ergotree-interpreter/src/eval/tuple.rs +++ b/ergotree-interpreter/src/eval/tuple.rs @@ -12,7 +12,33 @@ impl Evaluable for Tuple { env: &mut Env<'ctx>, ctx: &Context<'ctx>, ) -> Result, EvalError> { - let items_v = self.items.try_mapped_ref(|i| i.eval(env, ctx)); + // sigma-state restricts tuples to exactly 2 elements: `Tuple.eval` + // rejects `items.length != 2` with "Invalid tuple" before doing any work + // (sigma `values.scala`: "in v5.0 version we support only tuples of 2 + // elements to be equivalent with v4.x"). The check is unconditional — + // no version gate, and sigma-state 6.0.3 evaluates every height through + // it. sigma-rust models tuples as flat N-ary (`TupleItems = + // BoundedVec<2,255>`), so without this guard it accepts arity>=3 tuples + // the JVM rejects — a consensus accept/reject divergence (sigma-rust + // would accept a spend the JVM rejects). Mirror the JVM: reject non-pairs. + if self.items.len() != 2 { + return Err(EvalError::Misc(format!( + "Tuple: sigma-state allows only 2-element tuples, got {}", + self.items.len() + ))); + } + let items_v = self + .items + .try_mapped_ref(|i| -> Result, EvalError> { + // Mirror the JVM Tuple eval, which `checkType`s each item BEFORE + // evaluating it (values.scala:801/804): an item whose type is an + // unsupported tuple (arity != 2) is rejected ("Unsupported tuple + // type", SType.scala:200). Catches an arity-3 tuple constant carried + // as a pair item, which the JVM refuses but sigma-rust would + // otherwise evaluate. + crate::eval::check_value_type(&i.tpe())?; + i.eval(env, ctx) + }); Ok(Value::Tup(items_v?)) } } @@ -21,9 +47,11 @@ impl Evaluable for Tuple { #[cfg(test)] mod tests { use super::*; - use crate::eval::test_util::eval_out_wo_ctx; + use crate::eval::test_util::{eval_out_wo_ctx, try_eval_out}; + use ergotree_ir::chain::context::Context; use ergotree_ir::mir::expr::Expr; use ergotree_ir::mir::global_vars::GlobalVars; + use sigma_test_util::force_any_val; #[test] fn eval() { @@ -34,4 +62,58 @@ mod tests { let res = eval_out_wo_ctx::(&tuple); assert!(matches!(res, Value::Tup(_))); } + + #[test] + fn eval_rejects_non_pair_tuple() { + // sigma-state rejects tuples with length != 2 ("Invalid tuple", JVM + // consensus). A flat arity-3 tuple is constructible here (TupleItems + // allows 2..=255), but must be rejected at eval to match the JVM — else + // sigma-rust would accept a spend the JVM rejects. + let e1: Expr = 1i64.into(); + let e2: Expr = 2i64.into(); + let e3: Expr = 3i64.into(); + let tuple: Expr = Tuple::new(vec![e1, e2, e3]).unwrap().into(); + let ctx = force_any_val::(); + let res = try_eval_out::(&tuple, &ctx); + assert!( + res.is_err(), + "arity-3 Tuple must be rejected at eval, got {res:?}" + ); + } + + #[test] + fn eval_rejects_arity3_tuple_item_constant() { + use crate::eval::test_util::try_eval_out_wo_ctx; + use ergotree_ir::mir::constant::{Constant, Literal}; + use ergotree_ir::types::stuple::{STuple, TupleItems}; + use ergotree_ir::types::stype::SType; + + // SANTA Tuple.checkType_unsupported (inline): a pair Tuple whose item0 is + // an arity-3 (Bool,Bool,Bool) tuple CONSTANT. The JVM `checkType`s each + // Tuple item (values.scala:801/804) → arity != 2 → "Unsupported tuple + // type" (SType.scala:200-202). sigma-rust models flat N-ary tuples, so the + // value is representable; it must be rejected at eval to match the JVM. + let triple = Constant { + tpe: SType::STuple(STuple::triple( + SType::SBoolean, + SType::SBoolean, + SType::SBoolean, + )), + v: Literal::Tup( + TupleItems::try_from(vec![ + Literal::Boolean(true), + Literal::Boolean(true), + Literal::Boolean(true), + ]) + .unwrap(), + ), + }; + let tuple: Expr = Tuple::new(vec![Expr::Const(triple), 1i32.into()]) + .unwrap() + .into(); + assert!( + try_eval_out_wo_ctx::(&tuple).is_err(), + "arity-3 tuple item constant must be rejected at eval (Unsupported tuple type)" + ); + } } diff --git a/ergotree-ir/Cargo.toml b/ergotree-ir/Cargo.toml index 4c2443d48..822c0edd8 100644 --- a/ergotree-ir/Cargo.toml +++ b/ergotree-ir/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergotree-ir" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Denys Zadorozhnyi "] repository.workspace = true diff --git a/ergotree-ir/src/serialization/constant.rs b/ergotree-ir/src/serialization/constant.rs index 56d370451..58ba94562 100644 --- a/ergotree-ir/src/serialization/constant.rs +++ b/ergotree-ir/src/serialization/constant.rs @@ -88,4 +88,26 @@ mod tests { assert!(c_res.is_err()); assert!(matches!(c_res, Err(SigmaParsingError::ValueOutOfBounds(_)))); } + + #[test] + fn parse_option_nonzero_data_tag_is_some() { + // The JVM reads the Option DATA tag via `getOption`, which treats ANY + // nonzero tag byte as Some (sigma `Extensions.getOption`: + // `if (tag != 0) Some(getValue) else None`) — tag 0x02 parses as + // Some(5) and must not be rejected. + let constant_bytes = [0x28, 0x02, 0x0a]; // SOption(SInt): tag 0x02, Int 5 + let expected = Constant { + tpe: SType::SOption(Arc::new(SType::SInt)), + v: Literal::Opt(Some(Literal::Int(5).into())), + }; + let c = Constant::sigma_parse_bytes(&constant_bytes).unwrap(); + assert_eq!(c, expected); + + // The same constant segregated in a v3 tree (the conformance vector + // `SOption.nonzero_data_tag`, header size bit cleared) hydrates as + // Some(5) through the versioned tree-parse path. + let tree_bytes = base16::decode("130128020a7300").unwrap(); + let tree = crate::ergo_tree::ErgoTree::sigma_parse_bytes(&tree_bytes).unwrap(); + assert_eq!(tree.get_constants().unwrap().as_slice(), &[expected]); + } } diff --git a/ergotree-ir/src/types/sglobal.rs b/ergotree-ir/src/types/sglobal.rs index 59ee6f73d..e1edb1a18 100644 --- a/ergotree-ir/src/types/sglobal.rs +++ b/ergotree-ir/src/types/sglobal.rs @@ -178,7 +178,13 @@ lazy_static! { SType::SColl(SType::SByte.into()), SType::SInt, ], - t_range: SType::SBoolean.into(), + // powHit computes the Autolykos-2 PoW hit value (a big integer), not a + // boolean. Scala `SGlobalMethods.powHit` returns `SUnsignedBigInt` + // (methods.scala) and `powHit_eval` yields an `UnsignedBigInt`; the + // interpreter `POW_HIT_EVAL_FN` already produces `Value::UnsignedBigInt`. + // Mis-typing this as `SBoolean` made `Coll[..].map(powHit).exists(UnsignedBigInt => Boolean)` + // fail parse-time type-checking, wedging testnet block 28,474. + t_range: SType::SUnsignedBigInt.into(), tpe_params: vec![], }, explicit_type_args: vec![], @@ -235,6 +241,8 @@ mod test { }; use super::{DECODE_NBITS_METHOD, DESERIALIZE_METHOD, ENCODE_NBITS_METHOD}; + use crate::ergo_tree::ErgoTree; + use crate::serialization::SigmaSerializable; proptest! { #[test] fn test_deserialize_method_roundtrip(v in any::()) { @@ -271,4 +279,32 @@ mod test { MethodCall::new(Expr::Global, DECODE_NBITS_METHOD.clone(), vec![1i64.into()]).unwrap(); roundtrip_new_feature(&mc, ErgoTreeVersion::V3); } + + /// Regression: `Global.powHit` returns `SUnsignedBigInt` (the Autolykos-2 hit + /// value), not `SBoolean`. Mirrors Scala `SGlobalMethods.powHit` and the + /// interpreter's `POW_HIT_EVAL_FN` (which yields `Value::UnsignedBigInt`). + #[test] + fn powhit_returns_unsigned_bigint() { + assert_eq!( + POW_HIT_METHOD.tpe().t_range.as_ref(), + &SType::SUnsignedBigInt + ); + } + + /// Real-block regression: testnet block 28,474 tx[4] input[1], spending box + /// `1d746ebe5da0a0df46de9c34c60c5ed642b07fbff7c4cbf46dc93b1cd4a95166`. An + /// Autolykos-2 verify of the form + /// `coll.map(x => Global.powHit(..)).exists((u: UnsignedBigInt) => u > ..)`. + /// With `powHit` mis-typed `SBoolean`, the mapped collection resolved to + /// `Coll[Boolean]` and `Exists::new` rejected the `UnsignedBigInt => Boolean` + /// predicate ("Invalid condition tpe"), wedging the node off testnet here. + /// The JVM (6.0.3) accepts this canonical block. The header carries the size + /// flag, so the body type error surfaces at `proposition()`, not parse. + #[test] + fn powhit_coll_hof_tree_parses() { + let tree_hex = "1bb8042204000200040004100400041004000402040404060408040a040c040e041004120400040804b803040204400442040804c003040004080440040804ee020400040806207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed04a09c010402d807d601b2a4730000d602e4c672010464d6038301027301d604e5e3000e7203d605e4dc640a7202027204e5e3010e7203d606d901060e7cb4720673027303d607b2a5730400d1edaeaddad901080ed801d60ab472087305b172088cb0830a047306730773087309730a730b730c730d730e730f860283000e7310d9010b4c4c1ad804d60d8c720b01d60e8c720d02d60f9a9a720e73117312d6109a9a9a720f731373149c7eb2720a720f000473158602b38c720d0183010eb4720a720e7210721001017205d901080ed801d60adad9010a0ed801d60cdad9010c0edc6a04dd01b4720c731673176801720a8602db680d720cb47a7edb6809720c0573187319017208dc6a08dd05731adad9010b0ecbb4720b731b731c0172088c720a018c720a02dad9010b0edc6a05dd01b4720b731d731e04017208d90108099172089ddad9010a0edad9010c099ddb060e731f720c01db060e7eda720601720a060172057e7320099683060193c17207c1720193db6401e4dc640e72020283010e7204e5e3020e7203db6401e4c67207046493e4c67207050499e4c672010504732193e4c67207060699e4c6720106067eda72060172050693e4c672070705e4c67201070593e4c672070805e4c672010805"; + let bytes = base16::decode(tree_hex).unwrap(); + let tree = ErgoTree::sigma_parse_bytes(&bytes).unwrap(); + tree.proposition().unwrap(); + } } diff --git a/gf2_192/Cargo.toml b/gf2_192/Cargo.toml index 17bd7ebcf..2a23628d5 100644 --- a/gf2_192/Cargo.toml +++ b/gf2_192/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gf2_192" -version = "0.28.0" +version = "0.30.0" license = "CC0-1.0" authors = ["Timothy Ling (@kettlebell)"] description = "Arithmetic operations and polynomial interpolation over Galois fields GF(2^192)" diff --git a/sigma-ser/src/vlq_encode.rs b/sigma-ser/src/vlq_encode.rs index 880e7dc50..50ce8d12c 100644 --- a/sigma-ser/src/vlq_encode.rs +++ b/sigma-ser/src/vlq_encode.rs @@ -255,11 +255,13 @@ pub trait ReadSigmaVlqExt: io::Read + io::Seek { &mut self, get_value: impl Fn(&mut Self) -> Result, ) -> Result, E> { - let is_opt = self.get_u8()?; - Ok(match is_opt { - 1 => Some(get_value(self)?), - // Should only ever be 0 or 1 - _ => None, + let tag = self.get_u8()?; + // Any nonzero tag means Some: scorex/sigma `getOption` reads + // `if (tag != 0) Some(getValue) else None` (sigma `Extensions.getOption`). + Ok(if tag != 0 { + Some(get_value(self)?) + } else { + None }) } } @@ -1013,4 +1015,18 @@ mod tests { prop_assert_eq!(&bytes_i32(i as i32), &expected_bytes); } } + + #[test] + fn option_any_nonzero_tag_is_some() { + // scorex/sigma `getOption` reads `if (tag != 0) Some(..) else None` + // (sigma `Extensions.getOption`) — any nonzero tag byte means Some. + let get = |bytes: &[u8]| -> Result, VlqEncodingError> { + let mut r = Cursor::new(bytes.to_vec()); + r.get_option(|r| r.get_u8().map_err(VlqEncodingError::from)) + }; + assert_eq!(get(&[0, 5]).unwrap(), None); + assert_eq!(get(&[1, 5]).unwrap(), Some(5)); + assert_eq!(get(&[2, 5]).unwrap(), Some(5)); + assert_eq!(get(&[255, 5]).unwrap(), Some(5)); + } }