From a9ba5bac4eb485e21b54afcda85ccbf6c617bf3b Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Wed, 17 Jun 2026 12:48:03 +0200 Subject: [PATCH 1/2] fix: reject non-soft-forkable constant type codes in sized ErgoTree (rule 1009) A size-flagged ErgoTree whose segregated constant has a non-serializable type code was degraded to an Unparsed tree (and re-serialized) on ANY body-parse error. sigma-state degrades only soft-forkable ValidationExceptions: ErgoTreeSerializer.deserializeErgoTree wraps the body parse in a catch that yields UnparsedErgoTree only for a ValidationException, and rule 1009 (CheckSerializableTypeCode) throws one only when the type code == OptionTypeCode (36) or > LastDataType (111). For any other non-serializable data type code (SHeader=104, SPreHeader, SGlobal, SContext, SAny, STypeVar) a hard SerializerException escapes the fallback and the tree is rejected even with the size bit set. sigma-rust degraded these too, over-accepting trees the JVM rejects. Add SigmaParsingError::NonSerializableTypeCode for the non-soft-forkable codes and propagate it out of the sized-parse degrade arm instead of wrapping Unparsed. SOption (36) and SFunc (112) stay soft-forkable and keep degrading. Matches sigma-state 6.0.3 (SANTA vectors ErgoTree.unparsed_soft_fork_{header,option}_constant). --- ergotree-ir/src/ergo_tree.rs | 38 +++++++++++++++ ergotree-ir/src/serialization/data.rs | 46 ++++++++++++++++--- ergotree-ir/src/serialization/serializable.rs | 9 ++++ 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/ergotree-ir/src/ergo_tree.rs b/ergotree-ir/src/ergo_tree.rs index a1d52708d..03f2df539 100644 --- a/ergotree-ir/src/ergo_tree.rs +++ b/ergotree-ir/src/ergo_tree.rs @@ -391,6 +391,18 @@ impl SigmaSerializable for ErgoTree { }) { Ok(parsed_tree) => Ok(parsed_tree.into()), Err(error) => { + // Mirror sigma-state `ErgoTreeSerializer.deserializeErgoTree`: + // the size-flagged `UnparsedErgoTree` fallback wraps ONLY + // soft-forkable `ValidationException`s. A non-soft-forkable + // data type code (rule 1009 `CheckSerializableTypeCode` does + // not fire) throws a hard `SerializerException` that escapes + // the fallback — reject instead of degrading to `Unparsed`. + if let ErgoTreeError::SigmaParsingError( + e @ SigmaParsingError::NonSerializableTypeCode(_), + ) = &error + { + return Err(e.clone()); + } let num_bytes = (body_pos - start_pos) + tree_size_bytes as u64; r.seek(io::SeekFrom::Start(start_pos))?; let mut bytes = vec![0; num_bytes as usize]; @@ -713,6 +725,32 @@ mod tests { assert_eq!(tree.sigma_serialize_bytes().unwrap(), valid_ergo_tree_bytes); } + #[test] + fn sized_tree_rejects_non_soft_forkable_data_type_code() { + // SANTA wire reject vector `ErgoTree.unparsed_soft_fork_header_constant`: a + // v2 + size + const-seg tree with one segregated `SHeader` constant (typeCode + // 0x68 = 104). sigma-state rule 1009 (`CheckSerializableTypeCode`) does NOT + // special-case `SHeader` (neither `OptionTypeCode` 36 nor `> LastDataType` + // 111), so the JVM throws a hard `SerializerException` that escapes + // `deserializeErgoTree`'s `UnparsedErgoTree` fallback and REJECTS — even with + // the size flag set. We must reject, not degrade to `Unparsed`. + let bytes = base16::decode("1adb01016802000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0843d0000000000000000000000000000000000000000000000000000000000000000070239b8010000000000000000000000000000000000000000000000000000000000000000000000000000000001000000017300").unwrap(); + assert!(ErgoTree::sigma_parse_bytes(&bytes).is_err()); + } + + #[test] + fn sized_tree_degrades_soft_forkable_option_constant() { + // Twin accept vector `ErgoTree.unparsed_soft_fork_option_constant`: a v2 + + // size + const-seg tree with one segregated `SOption[SInt]` constant + // (`Some(5)`). The Option typecode (36) IS rule-1009 soft-forkable, so the + // size flag degrades the whole tree to `Unparsed` and it re-serializes + // byte-identical (identity round-trip) — must stay accepted, not regress. + let bytes = base16::decode("1a060128010a7300").unwrap(); + let tree = ErgoTree::sigma_parse_bytes(&bytes).unwrap(); + assert!(matches!(tree, ErgoTree::Unparsed { .. })); + assert_eq!(tree.sigma_serialize_bytes().unwrap(), bytes); + } + #[test] fn parse_p2pk_672() { // see https://github.com/ergoplatform/sigma-rust/issues/672 diff --git a/ergotree-ir/src/serialization/data.rs b/ergotree-ir/src/serialization/data.rs index ff1d4b10b..f4ac31552 100644 --- a/ergotree-ir/src/serialization/data.rs +++ b/ergotree-ir/src/serialization/data.rs @@ -29,6 +29,7 @@ use crate::unsignedbigint256::UnsignedBigInt; use ergo_chain_types::EcPoint; use super::sigma_byte_writer::SigmaByteWrite; +use super::types::TypeCode; use alloc::sync::Arc; use core::convert::TryInto; @@ -196,14 +197,47 @@ impl DataSerializer { SHeader if r.tree_version() >= ErgoTreeVersion::V3 => { Literal::Header(Box::new(Header::scorex_parse(r)?)) } - STypeVar(_) => return Err(SigmaParsingError::NotSupported("TypeVar data")), - SAny => return Err(SigmaParsingError::NotSupported("SAny data")), + // Non-serializable constant data types: mirror sigma-state's + // `CoreDataSerializer` fallback + rule 1009 (`CheckSerializableTypeCode`). + // A type code that is neither `OptionTypeCode` (36) nor `> LastDataType` + // (111) is NOT soft-forkable — the JVM throws a hard `SerializerException` + // that escapes `deserializeErgoTree`'s `UnparsedErgoTree` fallback, so a + // size-flagged tree carrying one is rejected (`NonSerializableTypeCode`). + // `SOption` (36) and `SFunc` (112 > 111) ARE soft-forkable and keep the + // degradable `NotSupported` (a size-flagged tree carrying one degrades to + // `Unparsed`, matching the JVM soft-fork). + STypeVar(_) => { + return Err(SigmaParsingError::NonSerializableTypeCode( + TypeCode::STYPE_VAR.value(), + )) + } + SAny => { + return Err(SigmaParsingError::NonSerializableTypeCode( + TypeCode::SANY.value(), + )) + } SOption(_) => return Err(SigmaParsingError::NotSupported("SOption data")), SFunc(_) => return Err(SigmaParsingError::NotSupported("SFunc data")), - SContext => return Err(SigmaParsingError::NotSupported("SContext data")), - SHeader => return Err(SigmaParsingError::NotSupported("SHeader data")), - SPreHeader => return Err(SigmaParsingError::NotSupported("SPreHeader data")), - SGlobal => return Err(SigmaParsingError::NotSupported("SGlobal data")), + SContext => { + return Err(SigmaParsingError::NonSerializableTypeCode( + TypeCode::SCONTEXT.value(), + )) + } + SHeader => { + return Err(SigmaParsingError::NonSerializableTypeCode( + TypeCode::SHEADER.value(), + )) + } + SPreHeader => { + return Err(SigmaParsingError::NonSerializableTypeCode( + TypeCode::SPRE_HEADER.value(), + )) + } + SGlobal => { + return Err(SigmaParsingError::NonSerializableTypeCode( + TypeCode::SGLOBAL.value(), + )) + } }) } } diff --git a/ergotree-ir/src/serialization/serializable.rs b/ergotree-ir/src/serialization/serializable.rs index 3745aaf01..b4aaf5faa 100644 --- a/ergotree-ir/src/serialization/serializable.rs +++ b/ergotree-ir/src/serialization/serializable.rs @@ -112,6 +112,15 @@ pub enum SigmaParsingError { /// Invalid register value #[error("Invalid register value: {0}")] InvalidRegisterValue(#[from] RegisterValueError), + /// Data value of a type whose type code has no `DataSerializer` and is NOT + /// soft-forkable per sigma-state rule 1009 (`CheckSerializableTypeCode`): the + /// code is neither `OptionTypeCode` (36) nor `> LastDataType` (111). Mirrors the + /// JVM's hard `SerializerException` ("Not defined DataSerializer for type ..."), + /// which escapes `ErgoTreeSerializer.deserializeErgoTree`'s `UnparsedErgoTree` + /// soft-fork fallback — so a size-flagged tree carrying such a constant is + /// rejected, not degraded to `Unparsed`. + #[error("data value of type code {0} cannot be deserialized (rule 1009: not soft-forkable)")] + NonSerializableTypeCode(u8), } impl From for SigmaParsingError { From fa366f78f1ec86281ae1818d41f9d85b44384166 Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Thu, 18 Jun 2026 00:21:44 +0200 Subject: [PATCH 2/2] Generalize the size-flagged ErgoTree degrade gate to reject all hard wire errors The previous commit rejected only a non-soft-forkable constant type code (rule 1009). sigma-state's ErgoTreeSerializer.deserializeErgoTree degrades a size-flagged body-parse failure to UnparsedErgoTree ONLY for a ValidationException; every other (hard) exception escapes and rejects the tree -- an IllegalArgumentException from an invalid AutolykosSolution / group element point, an IOException from a truncated constant body, or a VLQ overflow. The gate degraded all of these, so a crafted size-flagged tree carrying such a constant was accepted (degraded to Unparsed and echoed) where the JVM rejects. Generalize SigmaParsingError::escapes_sized_tree_degrade to escape all hard wire-structure errors (NonSerializableTypeCode, ScorexParsingError, Io, VlqEncode), keeping position-limit degrading -- rule 1014 CheckPositionLimit is the one soft-forkable wire error (ReaderPositionLimitExceeded -> ValidationException -> degrade). Position-limit gets a dedicated PositionLimitExceeded variant (routed from the InvalidData io::Error at the From boundary across VlqEncodingError / ScorexParsingError / SigmaParsingError, with a recursive detector) so it is distinguished from a hard EOF. The position-limit window itself (the MaxBoxSize span during box parse) is not on this branch, so the carve-out is forward-compatible. --- ergotree-ir/src/ergo_tree.rs | 78 ++++++++++++++++--- ergotree-ir/src/serialization/serializable.rs | 60 +++++++++++++- sigma-ser/src/scorex_serialize.rs | 35 ++++++++- sigma-ser/src/vlq_encode.rs | 18 ++++- 4 files changed, 176 insertions(+), 15 deletions(-) diff --git a/ergotree-ir/src/ergo_tree.rs b/ergotree-ir/src/ergo_tree.rs index 03f2df539..2d6efe4a7 100644 --- a/ergotree-ir/src/ergo_tree.rs +++ b/ergotree-ir/src/ergo_tree.rs @@ -392,16 +392,20 @@ impl SigmaSerializable for ErgoTree { Ok(parsed_tree) => Ok(parsed_tree.into()), Err(error) => { // Mirror sigma-state `ErgoTreeSerializer.deserializeErgoTree`: - // the size-flagged `UnparsedErgoTree` fallback wraps ONLY - // soft-forkable `ValidationException`s. A non-soft-forkable - // data type code (rule 1009 `CheckSerializableTypeCode` does - // not fire) throws a hard `SerializerException` that escapes - // the fallback — reject instead of degrading to `Unparsed`. - if let ErgoTreeError::SigmaParsingError( - e @ SigmaParsingError::NonSerializableTypeCode(_), - ) = &error - { - return Err(e.clone()); + // the size-flagged `UnparsedErgoTree` fallback wraps ONLY a + // soft-forkable `ValidationException`. A HARD wire-structure + // failure escapes the fallback and REJECTS instead of + // degrading to `Unparsed`: a non-soft-forkable data type code + // (rule 1009 `CheckSerializableTypeCode` does not fire), an + // invalid EC point, EOF/truncation, or a VLQ overflow — the + // JVM's `SerializerException` / `IllegalArgumentException` / + // `IOException`. Position-limit (rule 1014) is the one + // soft-forkable wire error and still degrades. See + // `SigmaParsingError::escapes_sized_tree_degrade`. + if let ErgoTreeError::SigmaParsingError(e) = &error { + if e.escapes_sized_tree_degrade() { + return Err(e.clone()); + } } let num_bytes = (body_pos - start_pos) + tree_size_bytes as u64; r.seek(io::SeekFrom::Start(start_pos))?; @@ -751,6 +755,60 @@ mod tests { assert_eq!(tree.sigma_serialize_bytes().unwrap(), bytes); } + #[test] + fn sized_tree_rejects_malformed_ec_point_pk() { + // SANTA wire reject vector `ErgoTree.sheader_constant_v3_malformed_pk_reject`: + // a v3 + size + const-seg tree with one segregated `SHeader` constant whose + // AutolykosSolution pk is an INVALID compressed EC point (prefix 0x05). The JVM + // rejects — `GroupElementSerializer.parse` throws `IllegalArgumentException`, a + // HARD error that escapes `deserializeErgoTree`'s `UnparsedErgoTree` soft-fork + // fallback. We must reject (the strict production parse), not degrade to + // `Unparsed`. (The integration branch additionally exercises the same gate via + // the lenient/conformance entry against this exact SANTA vector.) + let bytes = base16::decode("1bdb01016802000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0843d0000000000000000000000000000000000000000000000000000000000000000070239b8010000000005000000000000000000000000000000000000000000000000000000000000000000000001000000017300").unwrap(); + assert!(ErgoTree::sigma_parse_bytes(&bytes).is_err()); + } + + #[test] + fn sized_tree_degrade_gate_escapes_hard_errors_but_degrades_position_limit() { + // The gate policy (`SigmaParsingError::escapes_sized_tree_degrade`): hard + // wire-structure failures REJECT (escape == true), matching the JVM's + // non-`ValidationException` exceptions; position-limit (rule 1014) is the one + // soft-forkable wire error and DEGRADES (escape == false), in every channel it + // can arrive through (top-level, nested in `VlqEncode` / `ScorexParsingError`). + use sigma_ser::vlq_encode::VlqEncodingError; + use sigma_ser::ScorexParsingError; + let degrades = [ + SigmaParsingError::PositionLimitExceeded, + SigmaParsingError::VlqEncode(VlqEncodingError::PositionLimitExceeded), + SigmaParsingError::ScorexParsingError(ScorexParsingError::PositionLimitExceeded), + SigmaParsingError::ScorexParsingError(ScorexParsingError::VlqEncode( + VlqEncodingError::PositionLimitExceeded, + )), + // soft-forkable type/opcode errors keep degrading (behavior unchanged) + SigmaParsingError::NotSupported("SOption data"), + SigmaParsingError::InvalidOpCode(0xff), + ]; + for e in °rades { + assert!(!e.escapes_sized_tree_degrade(), "should degrade: {e:?}"); + } + let rejects = [ + // invalid EC point (this finding) + SigmaParsingError::ScorexParsingError(ScorexParsingError::Misc( + "failed to parse PK from bytes".to_string(), + )), + // EOF / truncation (the sibling over-accept this fix also closes) + SigmaParsingError::Io("unexpected end of file".to_string()), + // VLQ overflow + SigmaParsingError::VlqEncode(VlqEncodingError::VlqDecodingFailed), + // non-soft-forkable data type code (rule 1009, prior round) + SigmaParsingError::NonSerializableTypeCode(104), + ]; + for e in &rejects { + assert!(e.escapes_sized_tree_degrade(), "should reject: {e:?}"); + } + } + #[test] fn parse_p2pk_672() { // see https://github.com/ergoplatform/sigma-rust/issues/672 diff --git a/ergotree-ir/src/serialization/serializable.rs b/ergotree-ir/src/serialization/serializable.rs index b4aaf5faa..13379671d 100644 --- a/ergotree-ir/src/serialization/serializable.rs +++ b/ergotree-ir/src/serialization/serializable.rs @@ -121,17 +121,73 @@ pub enum SigmaParsingError { /// rejected, not degraded to `Unparsed`. #[error("data value of type code {0} cannot be deserialized (rule 1009: not soft-forkable)")] NonSerializableTypeCode(u8), + /// A read started past the position limit (the reference impl's rule 1014 + /// `CheckPositionLimit`, set as the `MaxBoxSize` window during box parse). + /// SOFT-FORKABLE: a size-flagged tree whose body overruns its position + /// window degrades to `Unparsed` (matching the JVM's `ValidationException`), + /// unlike a hard EOF/structural failure which rejects. + #[error("read position exceeds the position limit")] + PositionLimitExceeded, } impl From for SigmaParsingError { fn from(error: io::Error) -> Self { - SigmaParsingError::Io(error.to_string()) + // `InvalidData` is the position-limit signal (rule 1014); see + // `VlqEncodingError`'s `From`. Keep it apart from `Io` (EOF). + if error.kind() == io::ErrorKind::InvalidData { + SigmaParsingError::PositionLimitExceeded + } else { + SigmaParsingError::Io(error.to_string()) + } } } impl From<&io::Error> for SigmaParsingError { fn from(error: &io::Error) -> Self { - SigmaParsingError::Io(error.to_string()) + if error.kind() == io::ErrorKind::InvalidData { + SigmaParsingError::PositionLimitExceeded + } else { + SigmaParsingError::Io(error.to_string()) + } + } +} + +impl SigmaParsingError { + /// True if this is the soft-forkable position-limit error (rule 1014), + /// whether at the top level or nested in `VlqEncode` / `ScorexParsingError` + /// (a windowed read can trip the limit through either channel). + pub fn is_position_limit_exceeded(&self) -> bool { + match self { + SigmaParsingError::PositionLimitExceeded => true, + SigmaParsingError::VlqEncode(vlq_encode::VlqEncodingError::PositionLimitExceeded) => { + true + } + SigmaParsingError::ScorexParsingError(e) => e.is_position_limit_exceeded(), + _ => false, + } + } + + /// True if a size-flagged `ErgoTree` carrying a constant whose body fails + /// with this error must REJECT (escape the soft-fork degrade) instead of + /// degrading to `Unparsed`. Mirrors sigma-state + /// `ErgoTreeSerializer.deserializeErgoTree`, which wraps as `UnparsedErgoTree` + /// ONLY a soft-forkable `ValidationException`: a hard wire-structure failure + /// (invalid EC point, EOF/truncation, VLQ overflow → the JVM's + /// `IllegalArgumentException` / `IOException`) escapes and rejects. The one + /// soft-forkable case in these wire channels is position-limit (rule 1014), + /// which is excluded so it still degrades. Soft-forkable type/opcode/method + /// errors live in other variants and keep degrading (not listed here). + pub fn escapes_sized_tree_degrade(&self) -> bool { + if self.is_position_limit_exceeded() { + return false; + } + matches!( + self, + SigmaParsingError::NonSerializableTypeCode(_) + | SigmaParsingError::ScorexParsingError(_) + | SigmaParsingError::Io(_) + | SigmaParsingError::VlqEncode(_) + ) } } diff --git a/sigma-ser/src/scorex_serialize.rs b/sigma-ser/src/scorex_serialize.rs index f1c76ddbf..61b6329a4 100644 --- a/sigma-ser/src/scorex_serialize.rs +++ b/sigma-ser/src/scorex_serialize.rs @@ -96,17 +96,48 @@ pub enum ScorexParsingError { /// Failed to convert integer type #[error("Bounds check error: {0}")] TryFrom(#[from] core::num::TryFromIntError), + /// A read started past the position limit (rule 1014). DISTINCT from + /// [`ScorexParsingError::Io`] (EOF): soft-forkable (degrade a size-flagged + /// tree) vs hard reject. See [`ReadSigmaVlqExt::check_position_limit`]. + #[error("read position exceeds the position limit")] + PositionLimitExceeded, } impl From for ScorexParsingError { fn from(error: io::Error) -> Self { - ScorexParsingError::Io(error.to_string()) + // See `VlqEncodingError`'s `From`: `InvalidData` is the + // position-limit signal (rule 1014), kept apart from `Io` (EOF). + if error.kind() == io::ErrorKind::InvalidData { + ScorexParsingError::PositionLimitExceeded + } else { + ScorexParsingError::Io(error.to_string()) + } } } impl From<&io::Error> for ScorexParsingError { fn from(error: &io::Error) -> Self { - ScorexParsingError::Io(error.to_string()) + if error.kind() == io::ErrorKind::InvalidData { + ScorexParsingError::PositionLimitExceeded + } else { + ScorexParsingError::Io(error.to_string()) + } + } +} + +impl ScorexParsingError { + /// True if this is the soft-forkable position-limit error (rule 1014), + /// at the top level or nested in [`ScorexParsingError::VlqEncode`] (a VLQ + /// read can trip the limit and arrive wrapped). Used by the sized-`ErgoTree` + /// degrade gate to DEGRADE position-limit while REJECTING hard wire errors. + pub fn is_position_limit_exceeded(&self) -> bool { + matches!( + self, + ScorexParsingError::PositionLimitExceeded + | ScorexParsingError::VlqEncode( + vlq_encode::VlqEncodingError::PositionLimitExceeded + ) + ) } } diff --git a/sigma-ser/src/vlq_encode.rs b/sigma-ser/src/vlq_encode.rs index 880e7dc50..02d0541e1 100644 --- a/sigma-ser/src/vlq_encode.rs +++ b/sigma-ser/src/vlq_encode.rs @@ -25,11 +25,27 @@ pub enum VlqEncodingError { /// Fail to decode a value from bytes #[error("VLQ decoding failed")] VlqDecodingFailed, + /// A read started past the position limit (the reference impl's rule 1014 + /// `CheckPositionLimit`). Kept DISTINCT from [`VlqEncodingError::Io`] (EOF): + /// the JVM treats this as a soft-forkable `ValidationException` (a + /// size-flagged tree degrades to `Unparsed`), whereas EOF/truncation is a + /// hard reject. See [`ReadSigmaVlqExt::check_position_limit`]. + #[error("read position exceeds the position limit")] + PositionLimitExceeded, } impl From for VlqEncodingError { fn from(error: io::Error) -> Self { - VlqEncodingError::Io(error.to_string()) + // `check_position_limit` is the sole producer of `InvalidData` on this + // (core2) reader path, so route it to the distinct soft-forkable variant + // rather than collapsing it into `Io` (EOF). Keeping them apart is + // consensus-critical: the sized-`ErgoTree` degrade gate must DEGRADE + // position-limit (rule 1014) but REJECT a hard EOF. + if error.kind() == io::ErrorKind::InvalidData { + VlqEncodingError::PositionLimitExceeded + } else { + VlqEncodingError::Io(error.to_string()) + } } }