diff --git a/ergotree-interpreter/src/eval.rs b/ergotree-interpreter/src/eval.rs index 356a48261..dcd87ea25 100644 --- a/ergotree-interpreter/src/eval.rs +++ b/ergotree-interpreter/src/eval.rs @@ -126,8 +126,34 @@ 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(()) +} + /// 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 +468,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 +569,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) + ); + } }