From 2db0a8400988184779e891d111fcec607710da1b Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Sun, 12 Apr 2026 00:55:03 +0200 Subject: [PATCH 1/2] fix: gate v4.x ErgoScript leniency on script tree version, not block Three pre-JIT compatibility paths in the interpreter were either missing or wrongly gated on the network's activated script version. The JVM sigmastate-interpreter applies these lenient v4.x semantics to ErgoTree scripts whose header version is < V2 (i.e. V0 or V1), regardless of the block they are spent in. A v0 tree spent in a v3+ block must still get the lenient path because the leniency is a property of how the old script was originally written and validated. The previous selfBoxIndex fix (now superseded) gated on `activated_script_version() < V2`, which only triggered the lenient path for v0/v1 *blocks*. This was incomplete: a buggy v0 script spent in a v3+ block (the common case post-mainnet-v5-activation) still needs the lenient -1 return value to match the JVM. Three behaviors fixed: 1. selfBoxIndex (CContext.scala equivalent): pre-V2 trees return -1 instead of the real input index. JVM bug #603 - historical scripts set output R4=-1 to match the buggy value. 2. BoolToSigmaProp (trees.scala): pre-V2 trees pass a SigmaProp input through unchanged instead of strict bool extraction. Mainnet has `sigmaProp(sigmaProp(...))` patterns at e.g. tx 5fe235558... block 680692 (address Fo6oijFP2JM87ac7w) which the JVM has a property test for. 3. xorOf (CSigmaDslBuilder.scala): pre-V2 trees compute `distinct.length == 2` (true iff coll contains BOTH true and false, count and order independent) instead of the proper left-fold XOR. The JVM source comment labels this "buggy version used in v4.x interpreter". All three gates use `ctx.tree_version() < ErgoTreeVersion::V2` so the script's header version determines the path, not the block's activated version. This matches how the JVM dispatches v4.x semantics through its `VersionContext` propagation. Tests added/updated: - selfBoxIndex: V0, V1, V2 cases - BoolToSigmaProp: V0 passthrough, V2 strict-error - xorOf: V0 distinct-length, V2 left-fold; concrete [true,true,false] cases proving the divergence Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/eval/bool_to_sigma.rs | 57 +++++++++++++++- ergotree-interpreter/src/eval/scontext.rs | 42 +++++++++++- ergotree-interpreter/src/eval/xor_of.rs | 67 ++++++++++++++++++- 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/ergotree-interpreter/src/eval/bool_to_sigma.rs b/ergotree-interpreter/src/eval/bool_to_sigma.rs index 2470d5951..8938dde0d 100644 --- a/ergotree-interpreter/src/eval/bool_to_sigma.rs +++ b/ergotree-interpreter/src/eval/bool_to_sigma.rs @@ -1,3 +1,4 @@ +use ergotree_ir::ergo_tree::ErgoTreeVersion; use ergotree_ir::mir::bool_to_sigma::BoolToSigmaProp; use ergotree_ir::mir::constant::TryExtractInto; use ergotree_ir::mir::value::Value; @@ -16,6 +17,20 @@ impl Evaluable for BoolToSigmaProp { ctx: &Context<'ctx>, ) -> Result, EvalError> { let input_v = self.input.eval(env, ctx)?; + // JVM v4.x compatibility: pre-v2 ErgoTree scripts (versions V0/V1) + // accepted a SigmaProp input to BoolToSigmaProp and passed it + // through unchanged. Mainnet contains historical transactions + // with `sigmaProp(sigmaProp(...))` (e.g. tx 5fe235558... at + // block 680,692, address Fo6oijFP2JM87ac7w) that rely on this. + // The gate is on the SCRIPT's ErgoTree header version, not the + // block's activated version: a v0 tree spent in a v3+ block + // still gets the lenient path. + // See: data/shared/src/main/scala/sigma/ast/trees.scala BoolToSigmaProp.eval + if ctx.tree_version() < ErgoTreeVersion::V2 { + if let Value::SigmaProp(sp) = input_v { + return Ok(Value::SigmaProp(sp)); + } + } let input_v_bool = input_v.try_extract_into::()?; Ok((SigmaProp::new(SigmaBoolean::TrivialProp(input_v_bool))).into()) } @@ -25,9 +40,12 @@ impl Evaluable for BoolToSigmaProp { #[allow(clippy::panic)] mod tests { use super::*; - use crate::eval::test_util::eval_out_wo_ctx; + use crate::eval::test_util::{eval_out, eval_out_wo_ctx, try_eval_out}; + use core::cell::Cell; + use ergotree_ir::chain::context::Context; use ergotree_ir::mir::expr::Expr; use proptest::prelude::*; + use sigma_test_util::force_any_val; proptest! { @@ -38,4 +56,41 @@ mod tests { prop_assert_eq!(res, SigmaProp::new(SigmaBoolean::TrivialProp(b))); } } + + /// Pre-v2 ErgoTree scripts: BoolToSigmaProp(SigmaProp) → passthrough. + /// Matches JVM v4.x behavior for historical mainnet scripts. + #[test] + fn eval_v0_tree_passes_sigmaprop_through() { + let inner: Expr = BoolToSigmaProp { + input: Expr::Const(true.into()).into(), + } + .into(); + let outer: Expr = BoolToSigmaProp { input: inner.into() }.into(); + + let ctx = force_any_val::(); + let ctx = Context { + tree_version: Cell::new(ErgoTreeVersion::V0), + ..ctx + }; + let res = eval_out::(&outer, &ctx); + assert_eq!(res, SigmaProp::new(SigmaBoolean::TrivialProp(true))); + } + + /// V2+ ErgoTree scripts: BoolToSigmaProp(SigmaProp) → strict bool + /// extraction error. Matches JVM v5+ JIT behavior. + #[test] + fn eval_v2_tree_rejects_sigmaprop() { + let inner: Expr = BoolToSigmaProp { + input: Expr::Const(true.into()).into(), + } + .into(); + let outer: Expr = BoolToSigmaProp { input: inner.into() }.into(); + + let ctx = force_any_val::(); + let ctx = Context { + tree_version: Cell::new(ErgoTreeVersion::V2), + ..ctx + }; + assert!(try_eval_out::(&outer, &ctx).is_err()); + } } diff --git a/ergotree-interpreter/src/eval/scontext.rs b/ergotree-interpreter/src/eval/scontext.rs index 190e2ceed..4f6fd32cc 100644 --- a/ergotree-interpreter/src/eval/scontext.rs +++ b/ergotree-interpreter/src/eval/scontext.rs @@ -36,6 +36,16 @@ pub(crate) static SELF_BOX_INDEX_EVAL_FN: EvalFn = |_mc, _env, ctx, obj, _args| obj ))); } + // JVM bug compatibility: selfBoxIndex always returned -1 in the v4.x + // semantics that apply to pre-v2 ErgoTree scripts (versions V0/V1). + // The gate is on the SCRIPT's ErgoTree header version, not the block's + // activated version: a v0 tree spent in a v3+ block still gets the + // lenient path, because the leniency is a property of how the old + // script was written and validated. + // See: https://github.com/ScorexFoundation/sigmastate-interpreter/issues/603 + if ctx.tree_version() < ergotree_ir::ergo_tree::ErgoTreeVersion::V2 { + return Ok(Value::Int(-1)); + } let box_index = ctx .inputs .iter() @@ -134,13 +144,15 @@ mod tests { use ergotree_ir::mir::method_call::MethodCall; use ergotree_ir::mir::property_call::PropertyCall; use ergotree_ir::mir::value::Value; + use ergotree_ir::ergo_tree::ErgoTreeVersion; use ergotree_ir::serialization::SigmaSerializable; use ergotree_ir::types::scontext::{self, GET_VAR_FROM_INPUT_METHOD}; use ergotree_ir::types::stype::LiftIntoSType; use ergotree_ir::types::stype_param::STypeVar; use sigma_test_util::force_any_val; + use core::cell::Cell; - fn make_ctx_inputs_includes_self_box() -> Context<'static> { + fn make_ctx_inputs_includes_self_box(tree_version: ErgoTreeVersion) -> Context<'static> { let ctx = force_any_val::(); let self_box = &*Box::leak(Box::new(force_any_val::())); let inputs = vec![&*Box::leak(Box::new(force_any_val::())), self_box] @@ -150,20 +162,44 @@ mod tests { height: 0u32, self_box, inputs, + tree_version: Cell::new(tree_version), ..ctx } } #[test] - fn eval_self_box_index() { + fn eval_self_box_index_v2_tree() { let expr: Expr = PropertyCall::new(Expr::Context, scontext::SELF_BOX_INDEX_PROPERTY.clone()) .unwrap() .into(); - let context = make_ctx_inputs_includes_self_box(); + // Tree version V2 (JIT/post-v5 script): returns real input index. + let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V2); assert_eq!(eval_out::(&expr, &context), 1); } + #[test] + fn eval_self_box_index_v0_tree() { + let expr: Expr = + PropertyCall::new(Expr::Context, scontext::SELF_BOX_INDEX_PROPERTY.clone()) + .unwrap() + .into(); + // Tree version V0 (pre-JIT script): JVM bug #603 always returned -1. + let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V0); + assert_eq!(eval_out::(&expr, &context), -1); + } + + #[test] + fn eval_self_box_index_v1_tree() { + let expr: Expr = + PropertyCall::new(Expr::Context, scontext::SELF_BOX_INDEX_PROPERTY.clone()) + .unwrap() + .into(); + // Tree version V1 (also pre-JIT): same lenient -1 path as V0. + let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V1); + assert_eq!(eval_out::(&expr, &context), -1); + } + #[test] fn eval_headers() { let expr: Expr = PropertyCall::new(Expr::Context, scontext::HEADERS_PROPERTY.clone()) diff --git a/ergotree-interpreter/src/eval/xor_of.rs b/ergotree-interpreter/src/eval/xor_of.rs index 279d6483b..4016778bc 100644 --- a/ergotree-interpreter/src/eval/xor_of.rs +++ b/ergotree-interpreter/src/eval/xor_of.rs @@ -1,4 +1,5 @@ use alloc::vec::Vec; +use ergotree_ir::ergo_tree::ErgoTreeVersion; use ergotree_ir::mir::constant::TryExtractInto; use ergotree_ir::mir::value::Value; @@ -16,6 +17,21 @@ impl Evaluable for XorOf { ) -> Result, EvalError> { let input_v = self.input.eval(env, ctx)?; let input_v_bools = input_v.try_extract_into::>()?; + // JVM v4.x compatibility: pre-v2 ErgoTree scripts (V0/V1) computed + // xorOf as `distinct.length == 2` — true iff the collection + // contains BOTH true and false (count and order independent). + // V2+ uses the correct left-fold XOR (true iff odd number of trues). + // See: data/shared/src/main/scala/sigma/data/CSigmaDslBuilder.scala + // (xorOf method, comment "This is buggy version used in v4.x interpreter"). + if ctx.tree_version() < ErgoTreeVersion::V2 { + let mut has_true = false; + let mut has_false = false; + for b in input_v_bools { + if b { has_true = true; } else { has_false = true; } + if has_true && has_false { break; } + } + return Ok((has_true && has_false).into()); + } Ok(input_v_bools.into_iter().fold(false, |a, b| a ^ b).into()) } } @@ -25,22 +41,67 @@ impl Evaluable for XorOf { mod tests { use super::*; use crate::eval::test_util::eval_out; + use core::cell::Cell; use ergotree_ir::chain::context::Context; use ergotree_ir::mir::expr::Expr; use proptest::collection; use proptest::prelude::*; use sigma_test_util::force_any_val; + fn ctx_with_tree_version(v: ErgoTreeVersion) -> Context<'static> { + let ctx = force_any_val::(); + Context { + tree_version: Cell::new(v), + ..ctx + } + } + proptest! { #[test] - fn eval(bools in collection::vec(any::(), 0..=10)) { + fn eval_v2_tree_left_fold(bools in collection::vec(any::(), 0..=10)) { + // V2+ ErgoTree: proper XOR (true iff odd number of trues). let expr: Expr = XorOf {input: Expr::Const(bools.clone().into()).into()}.into(); - let ctx = force_any_val::(); + let ctx = ctx_with_tree_version(ErgoTreeVersion::V2); let res = eval_out::(&expr, &ctx); - // eval is true when collection has odd number of "true" values let expected = bools.into_iter().filter(|x| *x).count() & 1 == 1; prop_assert_eq!(res, expected); } + + #[test] + fn eval_v0_tree_distinct_length(bools in collection::vec(any::(), 0..=10)) { + // Pre-v2 ErgoTree: JVM v4.x bug — true iff coll contains + // BOTH true and false (count/order independent). + let expr: Expr = XorOf {input: Expr::Const(bools.clone().into()).into()}.into(); + let ctx = ctx_with_tree_version(ErgoTreeVersion::V0); + let res = eval_out::(&expr, &ctx); + let has_true = bools.iter().any(|x| *x); + let has_false = bools.iter().any(|x| !*x); + prop_assert_eq!(res, has_true && has_false); + } + } + + /// Concrete v4.x bug case: [true, true, false] is true under + /// v4.x bug (both present) but false under v5+ XOR (even count of trues). + #[test] + fn eval_v0_tree_two_trues_one_false_is_true() { + let bools = vec![true, true, false]; + let expr: Expr = XorOf { + input: Expr::Const(bools.into()).into(), + } + .into(); + let ctx = ctx_with_tree_version(ErgoTreeVersion::V0); + assert_eq!(eval_out::(&expr, &ctx), true); + } + + #[test] + fn eval_v2_tree_two_trues_one_false_is_false() { + let bools = vec![true, true, false]; + let expr: Expr = XorOf { + input: Expr::Const(bools.into()).into(), + } + .into(); + let ctx = ctx_with_tree_version(ErgoTreeVersion::V2); + assert_eq!(eval_out::(&expr, &ctx), false); } } From 230863c7d4773d840ec56a536c6c04351368e878 Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Mon, 13 Apr 2026 13:47:02 +0200 Subject: [PATCH 2/2] fix: selfBoxIndex gate must use activated_script_version, not tree_version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JVM bug #603 (eq vs == in CostingDataContext.scala) was a global implementation bug fixed in v5.x for ALL scripts, not a per-script semantic difference. Using tree_version caused V0 scripts in v5+ blocks to incorrectly return -1, failing mainnet block 942664. Gate on activated_script_version (block level) so pre-v5 blocks reproduce the bug and v5+ blocks return the correct index regardless of script version. bool_to_sigma and xor_of gates are unchanged — those ARE per-script semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- ergotree-interpreter/src/eval/scontext.rs | 51 ++++++++++++++++------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/ergotree-interpreter/src/eval/scontext.rs b/ergotree-interpreter/src/eval/scontext.rs index 4f6fd32cc..72f49f2ab 100644 --- a/ergotree-interpreter/src/eval/scontext.rs +++ b/ergotree-interpreter/src/eval/scontext.rs @@ -36,14 +36,13 @@ pub(crate) static SELF_BOX_INDEX_EVAL_FN: EvalFn = |_mc, _env, ctx, obj, _args| obj ))); } - // JVM bug compatibility: selfBoxIndex always returned -1 in the v4.x - // semantics that apply to pre-v2 ErgoTree scripts (versions V0/V1). - // The gate is on the SCRIPT's ErgoTree header version, not the block's - // activated version: a v0 tree spent in a v3+ block still gets the - // lenient path, because the leniency is a property of how the old - // script was written and validated. + // JVM bug compatibility: selfBoxIndex always returned -1 before JIT + // activation (v5.0). The bug was `eq` (reference equality) instead of + // `==` (value equality) in CostingDataContext.scala — a global impl + // bug, not per-script semantics. Fixed in v5.x for ALL scripts. + // Gate: activated_script_version (block level), NOT tree_version. // See: https://github.com/ScorexFoundation/sigmastate-interpreter/issues/603 - if ctx.tree_version() < ergotree_ir::ergo_tree::ErgoTreeVersion::V2 { + if ctx.activated_script_version() < ergotree_ir::ergo_tree::ErgoTreeVersion::V2 { return Ok(Value::Int(-1)); } let box_index = ctx @@ -152,16 +151,24 @@ mod tests { use sigma_test_util::force_any_val; use core::cell::Cell; - fn make_ctx_inputs_includes_self_box(tree_version: ErgoTreeVersion) -> Context<'static> { + fn make_ctx_inputs_includes_self_box( + tree_version: ErgoTreeVersion, + pre_header_version: u8, + ) -> Context<'static> { let ctx = force_any_val::(); let self_box = &*Box::leak(Box::new(force_any_val::())); let inputs = vec![&*Box::leak(Box::new(force_any_val::())), self_box] .try_into() .unwrap(); + let pre_header = PreHeader { + version: pre_header_version, + ..ctx.pre_header.clone() + }; Context { height: 0u32, self_box, inputs, + pre_header, tree_version: Cell::new(tree_version), ..ctx } @@ -173,33 +180,45 @@ mod tests { PropertyCall::new(Expr::Context, scontext::SELF_BOX_INDEX_PROPERTY.clone()) .unwrap() .into(); - // Tree version V2 (JIT/post-v5 script): returns real input index. - let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V2); + // V2 tree in v5+ block (pre_header.version=3 → activated=V2): real index. + let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V2, 3); assert_eq!(eval_out::(&expr, &context), 1); } #[test] - fn eval_self_box_index_v0_tree() { + fn eval_self_box_index_v0_tree_pre_v5() { let expr: Expr = PropertyCall::new(Expr::Context, scontext::SELF_BOX_INDEX_PROPERTY.clone()) .unwrap() .into(); - // Tree version V0 (pre-JIT script): JVM bug #603 always returned -1. - let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V0); + // V0 tree in pre-v5 block (pre_header.version=1 → activated=V0): -1. + let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V0, 1); assert_eq!(eval_out::(&expr, &context), -1); } #[test] - fn eval_self_box_index_v1_tree() { + fn eval_self_box_index_v1_tree_pre_v5() { let expr: Expr = PropertyCall::new(Expr::Context, scontext::SELF_BOX_INDEX_PROPERTY.clone()) .unwrap() .into(); - // Tree version V1 (also pre-JIT): same lenient -1 path as V0. - let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V1); + // V1 tree in pre-v5 block (pre_header.version=1 → activated=V0): -1. + let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V1, 1); assert_eq!(eval_out::(&expr, &context), -1); } + #[test] + fn eval_self_box_index_v0_tree_v5_context() { + let expr: Expr = + PropertyCall::new(Expr::Context, scontext::SELF_BOX_INDEX_PROPERTY.clone()) + .unwrap() + .into(); + // V0 tree in v5+ block (pre_header.version=3 → activated=V2): real index. + // JVM bug #603 was a global impl bug fixed in v5.x for ALL scripts. + let context = make_ctx_inputs_includes_self_box(ErgoTreeVersion::V0, 3); + assert_eq!(eval_out::(&expr, &context), 1); + } + #[test] fn eval_headers() { let expr: Expr = PropertyCall::new(Expr::Context, scontext::HEADERS_PROPERTY.clone())