From 30b2845d47737eebd67bfaaf0eaaec175bd878ca Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Wed, 10 Jun 2026 17:26:06 +0200 Subject: [PATCH] fix: reject non-unary lambdas at closure creation (JVM FuncValue.eval) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JVM supports only unary functions at eval: FuncValue.eval (values.scala:1042-1056) builds its closure iff args.length == 1 and otherwise throws "Function must have 1 argument" — at closure CREATION, so a bound-but-never-applied non-unary lambda rejects too (BlockValue evaluates ValDefs eagerly). Apply.eval (values.scala:1236-1244) gates application arity the same way, which sigma-rust already matches. sigma-rust created the Lambda value for any arity and evaluated non-unary lambdas to completion. Both serializers are arity-agnostic (FuncValueSerializer reads a numArgs VLQ), so such trees are reachable from real wire bytes — an over-accept / chain-split divergence: a tree carrying a 2-arg (or 0-arg) lambda validates on sigma-rust and fails on the JVM. Gate Value::Lambda creation (the single construction site) on args.len() == 1. The reject is eval-time, NOT parse-time: a non-unary lambda on the dead branch of a lazy `if` still accepts (pinned by the vector twin). Tests drive the four SANTA FuncValue.non_unary_arity vector byte trees end-to-end (parse → proposition → eval): 2-arg applied, 2-arg bound, 0-arg applied each reject; the lazy-if twin accepts Int 5. Co-Authored-By: Claude Opus 4.8 --- ergotree-interpreter/src/eval/func_value.rs | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/ergotree-interpreter/src/eval/func_value.rs b/ergotree-interpreter/src/eval/func_value.rs index f539db222..8fadd88b6 100644 --- a/ergotree-interpreter/src/eval/func_value.rs +++ b/ergotree-interpreter/src/eval/func_value.rs @@ -9,9 +9,78 @@ use crate::eval::Evaluable; impl Evaluable for FuncValue { fn eval<'ctx>(&self, _env: &mut Env, _ctx: &Context<'ctx>) -> Result, EvalError> { + // The JVM supports only unary functions: FuncValue.eval + // (values.scala:1042-1056) builds the closure iff args.length == 1 and + // otherwise throws "Function must have 1 argument". The reject fires at + // closure CREATION (BlockValue evaluates ValDefs eagerly), so a bound + // non-unary lambda rejects even when never applied — but NOT at parse: + // both serializers are arity-agnostic, and a non-unary lambda on the + // dead branch of a lazy `if` must still accept. + if self.args().len() != 1 { + return Err(EvalError::Misc(format!( + "Function must have 1 argument, but was: {self:?}" + ))); + } Ok(Value::Lambda(Lambda { args: self.args().to_vec(), body: self.body().clone().into(), })) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use crate::eval::test_util::try_eval_out_with_version; + use ergotree_ir::chain::context::Context; + use ergotree_ir::ergo_tree::ErgoTree; + use ergotree_ir::serialization::SigmaSerializable; + use sigma_test_util::force_any_val; + + fn hx(s: &str) -> alloc::vec::Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + // SANTA FuncValue.non_unary_arity (eval/v5/authored, JVM-blessed): the + // closure-creation arity gate, end-to-end from the vector bytes (v0 trees, + // no size bit — the unsized parse path takes the Int roots as-is). + #[test] + fn non_unary_lambda_rejects_at_closure_creation() { + let ctx = force_any_val::(); + let run = |hex: &str| -> Result { + let tree = ErgoTree::sigma_parse_bytes(&hx(hex)) + .map_err(|e| alloc::format!("parse: {e:?}"))?; + let expr = tree + .proposition() + .map_err(|e| alloc::format!("proposition: {e:?}"))?; + try_eval_out_with_version::(&expr, &ctx, 0, 2) + .map_err(|e| alloc::format!("eval: {e:?}")) + }; + // { val add = (x:Int,y:Int) => x+y; add(3,4) } — applied + // { val add = (x:Int,y:Int) => x+y; 5 } — bound, never applied + // (ValDefs eval eagerly) + // { val f = () => 5; f() } — the zero-arg side + for hex in [ + "100204060408d801d601d902020403049a72027203da72010273007301", + "1001040ad801d601d902020403049a720272037300", + "1001040ad801d601d9007300da720100", + ] { + assert!( + run(hex).is_err(), + "{hex}: non-unary lambda must reject at closure creation" + ); + } + // if (true) 5 else { val add = (x:Int,y:Int) => x+y; add(3,4) } — the + // 2-arg lambda sits on the dead branch of a lazy If and never evaluates: + // the tree ACCEPTS (the gate is eval-time, not parse-time). + assert_eq!( + run("10040101040a040604089573007301d801d601d902020403049a72027203da72010273027303") + .unwrap(), + 5, + "lazy-if twin: a never-evaluated non-unary lambda must not reject the tree" + ); + } +}