From 9e6d22b4f843f3834f0c0c8c6aef279db5bc7587 Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Sat, 6 Jun 2026 11:57:23 +0200 Subject: [PATCH 1/2] fix(ergotree-ir): Box.getReg is method id 19, getRegV5 keeps id 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v6 dynamic-register-access method Box.getReg was registered under method id 7 — the JVM's getRegMethodV5 slot. The real getRegMethodV6 (sigma-state 6.0.3 methods.scala) is method id 19, gated to v3+ trees. The mismatch broke both wire directions: JVM-serialized getReg MethodCalls (id 19) failed to parse (UnknownMethodId), and Rust-built ones serialized as id 7, which a JVM node resolves to the non-evaluable getRegV5. Mirror the JVM exactly: - getReg moves to method id 19 with min_version V3 (the MethodCall deserializer already rejects pre-V3 occurrences via min_version, matching JVM v5Methods which has no id-19 entry); - method id 7 stays registered as getRegV5 so trees carrying it keep deserializing at any version, with no explicit type args on the wire (JVM getRegMethodV5 has none) and no eval — a live occurrence errors at evaluation, matching the JVM's failed reflective lookup. Co-Authored-By: Claude Opus 4.8 --- ergotree-interpreter/src/eval/sbox.rs | 14 +++--- ergotree-ir/src/types/sbox.rs | 62 +++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/ergotree-interpreter/src/eval/sbox.rs b/ergotree-interpreter/src/eval/sbox.rs index 1e595029a..03064e669 100644 --- a/ergotree-interpreter/src/eval/sbox.rs +++ b/ergotree-interpreter/src/eval/sbox.rs @@ -157,17 +157,13 @@ mod tests { .into(); let ctx = force_any_val::(); (0..ErgoTreeVersion::V3.into()).for_each(|version| { + // Pre-V3 the method id is not in the method table at all (min_version), + // so the versioned roundtrip fails at deserialization — mirroring JVM + // v5Methods, which has no getReg (id 19) entry. let res = try_eval_out_with_version::>(&expr, &ctx, version, version); match res { - Err(EvalError::Spanned(err)) - if matches!( - *err.error, - EvalError::ScriptVersionError { - required_version: ErgoTreeVersion::V3, - activated_version: _ - } - ) => {} - _ => panic!("Expected script version error"), + Err(EvalError::SigmaParsingError(_)) => {} + _ => panic!("Expected method-id parsing rejection, got {:?}", res), } }); (ErgoTreeVersion::V3.into()..=ErgoTreeVersion::MAX_SCRIPT_VERSION.into()).for_each( diff --git a/ergotree-ir/src/types/sbox.rs b/ergotree-ir/src/types/sbox.rs index fc9117aa1..fa86c4cc6 100644 --- a/ergotree-ir/src/types/sbox.rs +++ b/ergotree-ir/src/types/sbox.rs @@ -21,8 +21,12 @@ pub const TYPE_CODE: TypeCode = TypeCode::SBOX; pub static TYPE_NAME: &str = "Box"; /// Box.value property pub const VALUE_METHOD_ID: MethodId = MethodId(1); -/// Box.Rx property -pub const GET_REG_METHOD_ID: MethodId = MethodId(7); +/// Box.getReg[T](index) — the v6 dynamic register access method (JVM `getRegMethodV6`) +pub const GET_REG_METHOD_ID: MethodId = MethodId(19); +/// Box.getRegV5 — the legacy v5-era method id. Mirrors JVM `getRegMethodV5`: trees +/// carrying it deserialize at any version, but there is no eval for it (on the JVM +/// its reflective lookup fails), so a live occurrence errors at evaluation. +pub const GET_REG_V5_METHOD_ID: MethodId = MethodId(7); /// Box.tokens property pub const TOKENS_METHOD_ID: MethodId = MethodId(8); @@ -31,6 +35,7 @@ lazy_static! { pub(crate) static ref METHOD_DESC: Vec = vec![ GET_REG_METHOD_DESC.clone(), + GET_REG_V5_METHOD_DESC.clone(), VALUE_METHOD_DESC.clone(), TOKENS_METHOD_DESC.clone() ] @@ -63,13 +68,31 @@ lazy_static! { tpe_params: vec![], }, explicit_type_args: vec![STypeVar::t()], - min_version: ErgoTreeVersion::V0 + min_version: ErgoTreeVersion::V3 }; /// Box.getReg pub static ref GET_REG_METHOD: SMethod = SMethod::new(STypeCompanion::Box, GET_REG_METHOD_DESC.clone(),); } +lazy_static! { + // Unlike getReg, getRegV5 carries no explicit type args on the wire + // (JVM getRegMethodV5 has no `Seq(tT)`), so its serialized form ends + // after the args. The unresolved T in t_range is fine: the node only + // ever deserializes (dead-branch occurrences) and is rejected at eval. + static ref GET_REG_V5_METHOD_DESC: SMethodDesc = SMethodDesc { + method_id: GET_REG_V5_METHOD_ID, + name: "getRegV5", + tpe: SFunc { + t_dom: vec![SType::SBox, SType::SInt], + t_range: SType::SOption(Arc::new(STypeVar::t().into())).into(), + tpe_params: vec![], + }, + explicit_type_args: vec![], + min_version: ErgoTreeVersion::V0 + }; +} + lazy_static! { static ref TOKENS_METHOD_DESC: SMethodDesc = SMethodDesc { method_id: TOKENS_METHOD_ID, @@ -92,11 +115,12 @@ lazy_static! { } #[allow(clippy::unwrap_used)] +#[allow(clippy::panic)] #[cfg(test)] mod tests { use crate::{ - mir::{constant::Constant, global_vars::GlobalVars, method_call::MethodCall}, - serialization::SigmaSerializable, + mir::{constant::Constant, expr::Expr, global_vars::GlobalVars, method_call::MethodCall}, + serialization::{roundtrip_new_feature, SigmaSerializable}, }; use super::*; @@ -105,9 +129,14 @@ mod tests { fn test_from_ids() { assert!(SMethod::from_ids(TYPE_CODE, VALUE_METHOD_ID).map(|e| e.name()) == Ok("value")); assert!(SMethod::from_ids(TYPE_CODE, GET_REG_METHOD_ID).map(|e| e.name()) == Ok("getReg")); + assert!( + SMethod::from_ids(TYPE_CODE, GET_REG_V5_METHOD_ID).map(|e| e.name()) == Ok("getRegV5") + ); assert!(SMethod::from_ids(TYPE_CODE, TOKENS_METHOD_ID).map(|e| e.name()) == Ok("tokens")); } + // getReg is v6-only (method id 19): rejected when parsing v0-v2 trees, + // round-trips from v3 on — mirroring JVM SBoxMethods.v6Methods gating. #[test] fn test_getreg_serialization_roundtrip() { let type_args = core::iter::once((STypeVar::t(), SType::SInt)).collect(); @@ -118,9 +147,24 @@ mod tests { type_args, ) .unwrap(); - assert_eq!( - MethodCall::sigma_parse_bytes(&mc.sigma_serialize_bytes().unwrap()).unwrap(), - mc - ); + roundtrip_new_feature(&mc, ErgoTreeVersion::V3); + } + + // getRegV5 (method id 7) deserializes at any version and carries NO trailing + // explicit-type-arg byte — the wire shape JVM getRegMethodV5 produces. Byte-exact + // roundtrip guards both directions. Bytes are the root expr of the blessed + // adversarial vector `{ SELF.getRegV5(getVar[Int](1).get) }`: + // dc=MethodCall 63=SBox 07=methodId a7=SELF 01=argc e4=OptionGet e3 01 04=getVar[Int](1) + #[test] + fn test_getregv5_parses_without_type_args() { + let bytes: Vec = vec![0xdc, 0x63, 0x07, 0xa7, 0x01, 0xe4, 0xe3, 0x01, 0x04]; + let expr = Expr::sigma_parse_bytes(&bytes).unwrap(); + let Expr::MethodCall(mc) = &expr else { + panic!("expected MethodCall, got {:?}", expr) + }; + assert_eq!(mc.expr.method.method_id(), GET_REG_V5_METHOD_ID); + assert_eq!(mc.expr.method.name(), "getRegV5"); + assert!(mc.expr.explicit_type_args.is_empty()); + assert_eq!(expr.sigma_serialize_bytes().unwrap(), bytes); } } From ff922510fff81f45c8bdfc8559fbfe73b453e9f3 Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Sat, 6 Jun 2026 11:57:32 +0200 Subject: [PATCH 2/2] fix(ergotree-interpreter): Box.getReg yields None for absent or out-of-range index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JVM CBox.getReg returns None when the dynamic register index is negative or >= maxRegisters, and when the slot is valid but the register is not set; only a present register of the wrong type throws. The Rust eval errored on out-of-range indices instead (RegisterIdOutOfBounds via the i32 -> i8 -> RegisterId conversions), diverging from the JVM on the blessed Box.getReg_dynamic_index vectors. Map an unconvertible index to an absent register (None) and keep the wrong-type error. Regression tests drive the real path — parse the blessed vector tree bytes, then eval: present/absent/out-of-range/ negative/beyond-i8 indices, the wrong-type boundary, and the getRegV5 twins (live occurrence errors, dead-branch occurrence accepts). Co-Authored-By: Claude Opus 4.8 --- ergotree-interpreter/src/eval/sbox.rs | 127 +++++++++++++++++++++----- 1 file changed, 103 insertions(+), 24 deletions(-) diff --git a/ergotree-interpreter/src/eval/sbox.rs b/ergotree-interpreter/src/eval/sbox.rs index 03064e669..d304b4081 100644 --- a/ergotree-interpreter/src/eval/sbox.rs +++ b/ergotree-interpreter/src/eval/sbox.rs @@ -3,6 +3,7 @@ use crate::eval::EvalError; use alloc::boxed::Box; use alloc::string::ToString; use ergotree_ir::chain::ergo_box::ErgoBox; +use ergotree_ir::chain::ergo_box::RegisterId; use ergotree_ir::ergo_tree::ErgoTreeVersion; use ergotree_ir::mir::constant::TryExtractInto; use ergotree_ir::mir::value::Value; @@ -24,31 +25,28 @@ pub(crate) static GET_REG_EVAL_FN: EvalFn = |mc, _env, ctx, obj, args| { activated_version: ctx.tree_version(), }); } - #[allow(clippy::unwrap_used)] - let reg_id: i8 = args + let reg_idx = args .first() .cloned() .ok_or_else(|| EvalError::NotFound("register index is missing".to_string()))? - .try_extract_into::()? - .try_into() - .map_err(|e| { - EvalError::RegisterIdOutOfBounds(format!("register index is out of bounds: {:?} ", e)) - })?; - let reg_id = reg_id.try_into().map_err(|e| { - EvalError::RegisterIdOutOfBounds(format!( - "register index {reg_id} is out of bounds: {:?} ", - e - )) - })?; - - let reg_val_opt = obj - .try_extract_into::>()? - .get_register(reg_id) - .map_err(|e| { - EvalError::NotFound(format!( - "Error getting the register id {reg_id} with error {e:?}" - )) - })?; + .try_extract_into::()?; + // Mirror JVM CBox.getReg: an out-of-range index (negative or >= maxRegisters) + // yields None rather than an error; only a present register of the wrong type + // errors below. + let reg_id = i8::try_from(reg_idx) + .ok() + .and_then(|id| RegisterId::try_from(id).ok()); + let reg_val_opt = match reg_id { + Some(reg_id) => obj + .try_extract_into::>()? + .get_register(reg_id) + .map_err(|e| { + EvalError::NotFound(format!( + "Error getting the register id {reg_id} with error {e:?}" + )) + })?, + None => None, + }; // Return type of getReg[T] is always Option[T] #[allow(clippy::unreachable)] let SType::SOption(expected_type) = &*mc.tpe().t_range @@ -60,7 +58,7 @@ pub(crate) static GET_REG_EVAL_FN: EvalFn = |mc, _env, ctx, obj, args| { Ok(Value::Opt(Some(Box::new(constant.v.into())))) } Some(constant) => Err(EvalError::UnexpectedValue(format!( - "Expected register {reg_id} to be of type {}, got {}", + "Expected register {reg_idx} to be of type {}, got {}", expected_type, constant.tpe ))), None => Ok(Value::Opt(None)), @@ -80,12 +78,16 @@ pub(crate) static TOKENS_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, _args| { #[cfg(test)] #[cfg(feature = "arbitrary")] mod tests { - use ergotree_ir::ergo_tree::ErgoTreeVersion; + use ergotree_ir::chain::context_extension::ContextExtension; + use ergotree_ir::chain::ergo_box::ErgoBox; + use ergotree_ir::ergo_tree::{ErgoTree, ErgoTreeVersion}; use ergotree_ir::mir::constant::Constant; use ergotree_ir::mir::expr::Expr; use ergotree_ir::mir::global_vars::GlobalVars; use ergotree_ir::mir::method_call::MethodCall; use ergotree_ir::mir::property_call::PropertyCall; + use ergotree_ir::mir::value::Value; + use ergotree_ir::serialization::SigmaSerializable; use ergotree_ir::types::sbox; use ergotree_ir::types::stype::SType; use ergotree_ir::types::stype_param::STypeVar; @@ -95,6 +97,36 @@ mod tests { use crate::eval::EvalError; use ergotree_ir::chain::context::Context; + // The vector trees carry arbitrary-typed roots (eval-tier corpus); the sized + // parse path rejects non-SigmaProp roots, so clear the size bit and drop the + // size byte to route through the non-sized path — the same leniency the + // conformance runner applies. + fn parse_tree_lenient(hex: &str) -> Expr { + let bytes: Vec = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) + .collect(); + let mut lenient = Vec::with_capacity(bytes.len() - 1); + lenient.push(bytes[0] & !0x08); + lenient.extend_from_slice(&bytes[2..]); // size VLQ is 1 byte here + let tree = ErgoTree::sigma_parse_bytes(&lenient).unwrap(); + tree.proposition().unwrap() + } + + // SELF with exactly R4 = Long(7) and ContextExtension var 1 = `idx` — the + // setup of the blessed `Box.getReg_dynamic_index` vectors. + fn ctx_with_r4_long7_and_var1(idx: i32) -> Context<'static> { + let b = force_any_val::() + .with_additional_registers(vec![Constant::from(7i64)].try_into().unwrap()); + let mut ext = ContextExtension::empty(); + ext.values.insert(1u8, Constant::from(idx)); + Context { + self_box: Box::leak(Box::new(b)), + extension: Box::leak(Box::new(ext)), + ..force_any_val::() + } + } + #[test] fn eval_box_value() { let expr: Expr = PropertyCall::new(GlobalVars::SelfBox.into(), sbox::VALUE_METHOD.clone()) @@ -175,4 +207,51 @@ mod tests { }, ); } + + // End-to-end over the blessed `Box.getReg_dynamic_index` vector trees + // (sigma-state 6.0.3): `{ SELF.getReg[Long](getVar[Int](1).get) }` with + // SELF carrying only R4 = Long(7). JVM CBox.getReg yields None for an + // absent or out-of-range index; only a present register of the wrong + // type errors. + #[test] + fn eval_reg_dynamic_index_absent_or_out_of_range_is_none() { + const GET_REG_LONG: &str = "1b0b00dc6313a701e4e3010405"; + let run = |idx: i32| -> Option { + let expr = parse_tree_lenient(GET_REG_LONG); + let ctx = ctx_with_r4_long7_and_var1(idx); + try_eval_out_with_version::>(&expr, &ctx, 3, 3).unwrap() + }; + assert_eq!(run(4), Some(7), "present register of matching type"); + assert_eq!(run(5), None, "absent register R5"); + assert_eq!(run(10), None, "index beyond R9"); + assert_eq!(run(-1), None, "negative index"); + assert_eq!(run(1_000_000), None, "index beyond i8 range"); + + // The wrong-type boundary stays an error: getReg[Int] over the Long R4. + const GET_REG_INT: &str = "1b0b00dc6313a701e4e3010404"; + let expr = parse_tree_lenient(GET_REG_INT); + let ctx = ctx_with_r4_long7_and_var1(4); + assert!( + try_eval_out_with_version::>(&expr, &ctx, 3, 3).is_err(), + "present register of the wrong type must error" + ); + } + + // getRegV5 (method id 7) deserializes but has no eval — mirroring the JVM, + // where getRegMethodV5's reflective lookup fails. A live occurrence errors; + // a dead-branch occurrence leaves the script evaluable. Trees are the + // blessed `Box.getReg_adversarial` vectors. + #[test] + fn eval_getregv5_parses_but_does_not_eval() { + // { SELF.getRegV5(getVar[Int](1).get) } — live, must error at eval. + let expr = parse_tree_lenient("1b0a00dc6307a701e4e30104"); + let ctx = ctx_with_r4_long7_and_var1(4); + assert!(try_eval_out_with_version::(&expr, &ctx, 3, 3).is_err()); + + // { if (true) true else SELF.getRegV5(getVar[Int](1).get).isDefined } + // — dead branch, the tree must parse and evaluate to true. + let expr = parse_tree_lenient("1b1402010101019573007301e6dc6307a701e4e30104"); + let ctx = ctx_with_r4_long7_and_var1(4); + assert!(try_eval_out_with_version::(&expr, &ctx, 3, 3).unwrap()); + } }