From 54f6aebb873f4f736e464cb2c12736669c71496d Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Sun, 7 Jun 2026 23:19:33 +0200 Subject: [PATCH] fix: AvlTree digest is variable-length; updateDigest stores any-length Coll[Byte] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference `AvlTreeData.digest` is a `Coll[Byte]` (variable-length), and `CAvlTree.updateDigest` stores whatever bytes it is given with no length check. sigma-rust used a fixed `ADDigest` (33 bytes) and `updateDigest` rejected any other length via `ADDigest::try_from` — a consensus split: a script calling `tree.updateDigest(shortColl)` succeeds on the reference interpreter but errored here. Change `AvlTreeData.digest` to `Vec`; `updateDigest` now stores the bytes verbatim, matching the reference. Serialization mirrors the JVM `AvlTreeData.serializer`: the digest is written raw (`putBytes`) and the parser reads exactly 33 bytes (`getBytes(DigestSize)`), so the wire format is byte-identical for normal trees and a non-33 digest exists only in memory (it cannot round-trip, same as the reference impl). CreateAvlTree keeps its 33-byte validation, unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/eval/create_avl_tree.rs | 9 +- ergotree-interpreter/src/eval/savltree.rs | 144 +++++++++++++----- ergotree-interpreter/src/eval/scontext.rs | 4 +- ergotree-interpreter/src/eval/tree_lookup.rs | 4 +- ergotree-ir/src/mir/avl_tree_data.rs | 23 ++- 5 files changed, 139 insertions(+), 45 deletions(-) diff --git a/ergotree-interpreter/src/eval/create_avl_tree.rs b/ergotree-interpreter/src/eval/create_avl_tree.rs index a2ceb43cc..b548ae044 100644 --- a/ergotree-interpreter/src/eval/create_avl_tree.rs +++ b/ergotree-interpreter/src/eval/create_avl_tree.rs @@ -29,7 +29,12 @@ impl Evaluable for CreateAvlTree { }; let tree_flags = AvlTreeFlags::parse(flags_v); - let digest = ADDigest::try_from(digest_v.as_vec_u8()).map_err(map_eval_err)?; + // CreateAvlTree keeps its 33-byte digest validation (behavior unchanged); + // the field is now `Vec` only so `updateDigest` can hold other lengths. + let digest = ADDigest::try_from(digest_v.as_vec_u8()) + .map_err(map_eval_err)? + .0 + .to_vec(); Ok(Value::AvlTree(Box::new(AvlTreeData { tree_flags, @@ -84,7 +89,7 @@ mod tests { .into(); let tree = eval_out_wo_ctx::(&expr); - assert_eq!(tree.digest, initial_digest); + assert_eq!(tree.digest, initial_digest.0.to_vec()); assert_eq!(tree.key_length, 1); assert_eq!(tree.value_length_opt, None); assert_eq!(tree.tree_flags, flags); diff --git a/ergotree-interpreter/src/eval/savltree.rs b/ergotree-interpreter/src/eval/savltree.rs index f7a7b60e4..15fc6c3f3 100644 --- a/ergotree-interpreter/src/eval/savltree.rs +++ b/ergotree-interpreter/src/eval/savltree.rs @@ -2,7 +2,6 @@ use alloc::boxed::Box; use alloc::string::ToString; use alloc::sync::Arc; use alloc::vec::Vec; -use core::convert::TryFrom; use ergotree_ir::ergo_tree::ErgoTreeVersion; use bytes::Bytes; @@ -13,12 +12,10 @@ use ergo_avltree_rust::batch_node::Node; use ergo_avltree_rust::batch_node::NodeHeader; use ergo_avltree_rust::operation::KeyValue; use ergo_avltree_rust::operation::Operation; -use ergo_chain_types::ADDigest; use ergotree_ir::mir::avl_tree_data::AvlTreeData; use ergotree_ir::mir::avl_tree_data::AvlTreeFlags; use ergotree_ir::mir::constant::TryExtractInto; use ergotree_ir::mir::value::{CollKind, NativeColl, Value}; -use sigma_ser::ScorexSerializable; use super::EvalError; use super::EvalFn; @@ -27,7 +24,7 @@ use ergotree_ir::types::stype::SType; pub(crate) static DIGEST_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, _args| { let avl_tree_data = obj.try_extract_into::()?; Ok(Value::Coll(CollKind::NativeColl(NativeColl::CollByte( - avl_tree_data.digest.0.iter().map(|&b| b as i8).collect(), + avl_tree_data.digest.iter().map(|&b| b as i8).collect(), )))) }; @@ -80,12 +77,16 @@ pub(crate) static UPDATE_OPERATIONS_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, arg pub(crate) static UPDATE_DIGEST_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, args| { let mut avl_tree_data = obj.try_extract_into::()?; + // `CAvlTree.updateDigest` stores the new digest with no length check + // (`treeData.copy(digest = newDigest)`); mirror that — accept any-length + // `Coll[Byte]`. A non-33 digest exists only in memory: it cannot round-trip + // through serialization (the parser reads exactly 33 bytes), same as the + // reference impl. let new_digest = { let v = args.first().cloned().ok_or_else(|| { EvalError::AvlTree("eval is missing first arg (new_digest)".to_string()) })?; - let bytes_vec = v.try_extract_into::>()?; - ADDigest::try_from(bytes_vec).map_err(map_eval_err)? + v.try_extract_into::>()? }; avl_tree_data.digest = new_digest; Ok(Value::AvlTree(Box::new(avl_tree_data))) @@ -108,7 +109,7 @@ pub(crate) static GET_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, args| { Bytes::from(v.try_extract_into::>()?) }; - let starting_digest = Bytes::from(avl_tree_data.digest.0.to_vec()); + let starting_digest = Bytes::from(avl_tree_data.digest.clone()); let mut bv = BatchAVLVerifier::new( &starting_digest, &proof, @@ -156,7 +157,7 @@ pub(crate) static GET_MANY_EVAL_FN: EvalFn = Bytes::from(v.try_extract_into::>()?) }; - let starting_digest = Bytes::from(avl_tree_data.digest.0.to_vec()); + let starting_digest = Bytes::from(avl_tree_data.digest.clone()); let mut bv = BatchAVLVerifier::new( &starting_digest, &proof, @@ -223,7 +224,7 @@ pub(crate) static INSERT_EVAL_FN: EvalFn = Bytes::from(v.try_extract_into::>()?) }; - let starting_digest = Bytes::from(avl_tree_data.digest.0.to_vec()); + let starting_digest = Bytes::from(avl_tree_data.digest.clone()); let mut bv = BatchAVLVerifier::new( &starting_digest, &proof, @@ -258,8 +259,7 @@ pub(crate) static INSERT_EVAL_FN: EvalFn = } } Ok(if let Some(new_digest) = bv.digest() { - let digest = ADDigest::scorex_parse_bytes(&new_digest)?; - avl_tree_data.digest = digest; + avl_tree_data.digest = new_digest.to_vec(); Value::Opt(Some(Box::new(Value::AvlTree(avl_tree_data.into())))) } else { Value::Opt(None) @@ -288,7 +288,7 @@ pub(crate) static REMOVE_EVAL_FN: EvalFn = Bytes::from(v.try_extract_into::>()?) }; - let starting_digest = Bytes::from(avl_tree_data.digest.0.to_vec()); + let starting_digest = Bytes::from(avl_tree_data.digest.clone()); let mut bv = BatchAVLVerifier::new( &starting_digest, &proof, @@ -316,8 +316,7 @@ pub(crate) static REMOVE_EVAL_FN: EvalFn = } } if let Some(new_digest) = bv.digest() { - let digest = ADDigest::scorex_parse_bytes(&new_digest)?; - avl_tree_data.digest = digest; + avl_tree_data.digest = new_digest.to_vec(); Ok(Value::Opt(Some(Box::new(Value::AvlTree( avl_tree_data.into(), ))))) @@ -344,7 +343,7 @@ pub(crate) static CONTAINS_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, args| { Bytes::from(v.try_extract_into::>()?) }; - let starting_digest = Bytes::from(avl_tree_data.digest.0.to_vec()); + let starting_digest = Bytes::from(avl_tree_data.digest.clone()); let mut bv = BatchAVLVerifier::new( &starting_digest, &proof, @@ -392,7 +391,7 @@ pub(crate) static UPDATE_EVAL_FN: EvalFn = Bytes::from(v.try_extract_into::>()?) }; - let starting_digest = Bytes::from(avl_tree_data.digest.0.to_vec()); + let starting_digest = Bytes::from(avl_tree_data.digest.clone()); let mut bv = BatchAVLVerifier::new( &starting_digest, &proof, @@ -420,8 +419,7 @@ pub(crate) static UPDATE_EVAL_FN: EvalFn = } } Ok(if let Some(new_digest) = bv.digest() { - let digest = ADDigest::scorex_parse_bytes(&new_digest)?; - avl_tree_data.digest = digest; + avl_tree_data.digest = new_digest.to_vec(); Value::Opt(Some(Value::AvlTree(avl_tree_data.into()).into())) } else { Value::Opt(None) @@ -451,7 +449,7 @@ pub(crate) static INSERT_OR_UPDATE_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, args Bytes::from(v.try_extract_into::>()?) }; - let starting_digest = Bytes::from(avl_tree_data.digest.0.to_vec()); + let starting_digest = Bytes::from(avl_tree_data.digest.clone()); let mut bv = BatchAVLVerifier::new( &starting_digest, &proof, @@ -479,8 +477,7 @@ pub(crate) static INSERT_OR_UPDATE_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, args } } Ok(if let Some(new_digest) = bv.digest() { - let digest = ADDigest::scorex_parse_bytes(&new_digest)?; - avl_tree_data.digest = digest; + avl_tree_data.digest = new_digest.to_vec(); Value::Opt(Some(Box::new(Value::AvlTree(avl_tree_data.into())))) } else { Value::Opt(None) @@ -514,12 +511,17 @@ mod tests { use crate::eval::test_util::{eval_out_wo_ctx, try_eval_out_with_version}; use super::*; + use ergo_chain_types::ADDigest; + use sigma_ser::ScorexSerializable; use sigma_util::{AsVecI8, AsVecU8}; #[test] fn eval_avl_get() { let mut prover = populate_tree(vec![(vec![1u8], 10u64.to_be_bytes().to_vec())]); - let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let key1 = Bytes::from(vec![1u8]); let key2 = Bytes::from(vec![2u8]); @@ -589,7 +591,10 @@ mod tests { (vec![2u8], 20u64.to_be_bytes().to_vec()), ]); - let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let key1 = Bytes::from(vec![1u8]); let key2 = Bytes::from(vec![2u8]); @@ -672,7 +677,10 @@ mod tests { ), true, ); - let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let key1 = Bytes::from(vec![1u8]); let key2 = Bytes::from(vec![2u8; 1]); let key3 = Bytes::from(vec![3u8; 1]); @@ -691,7 +699,10 @@ mod tests { prover.perform_one_operation(&op1).unwrap(); prover.perform_one_operation(&op2).unwrap(); prover.perform_one_operation(&op3).unwrap(); - let final_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let final_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let proof: Constant = prover .generate_proof() .into_iter() @@ -778,7 +789,10 @@ mod tests { ), true, ); - let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let key1 = Bytes::from(vec![1u8]); let op = Operation::InsertOrUpdate(KeyValue { key: key1, @@ -786,7 +800,10 @@ mod tests { }); prover.perform_one_operation(&op).unwrap(); prover.perform_one_operation(&op).unwrap(); - let final_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let final_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let proof: Constant = prover .generate_proof() .into_iter() @@ -839,7 +856,7 @@ mod tests { proptest! { #[test] fn eval_avl_digest(v in any::()) { - let digest: Vec = v.digest.into(); + let digest: Vec = v.digest.as_vec_i8(); let obj = Expr::Const(v.into()); let expr: Expr = MethodCall::new( @@ -1012,7 +1029,51 @@ mod tests { .into(); let res = eval_out_wo_ctx::(&expr); if let Value::AvlTree(a) = res { - assert_eq!(a.digest, new_digest); + assert_eq!(a.digest, new_digest.0.to_vec()); + } else { + unreachable!(); + } + } + } + + #[test] + fn eval_avl_update_digest_any_length() { + // `CAvlTree.updateDigest` stores any-length `Coll[Byte]` with no length + // check (santa-eval `AvlTree.updateDigest_any_length`): the new digest is + // kept verbatim and read back by `.digest`, regardless of length. Such a + // value is in-memory only — it does not round-trip through serialization. + for new_digest in [ + vec![1u8, 2, 3], + Vec::::new(), + (1u8..=40).collect::>(), + ] { + let tree = AvlTreeData { + digest: vec![0u8; 33], + tree_flags: AvlTreeFlags::new(true, true, true), + key_length: 32, + value_length_opt: None, + }; + let updated: Expr = MethodCall::new( + Expr::Const(tree.into()), + savltree::UPDATE_DIGEST_METHOD.clone(), + vec![Constant::from(new_digest.clone()).into()], + ) + .unwrap() + .into(); + // stored verbatim + assert_eq!( + eval_out_wo_ctx::(&updated.clone()).digest, + new_digest + ); + // and read back via `.digest`, whatever the length + let digest_back: Expr = + MethodCall::new(updated, savltree::DIGEST_METHOD.clone(), vec![]) + .unwrap() + .into(); + if let Value::Coll(CollKind::NativeColl(NativeColl::CollByte(b))) = + eval_out_wo_ctx::(&digest_back) + { + assert_eq!(b.as_vec_u8(), new_digest); } else { unreachable!(); } @@ -1026,7 +1087,10 @@ mod tests { (vec![2u8], 20u64.to_be_bytes().to_vec()), (vec![3u8], 30u64.to_be_bytes().to_vec()), ]); - let digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let op = Operation::Lookup(Bytes::from(vec![2u8])); prover.perform_one_operation(&op).unwrap(); @@ -1061,12 +1125,18 @@ mod tests { #[test] fn eval_avl_remove() { let mut prover = populate_tree(vec![(vec![1u8], 10u64.to_be_bytes().to_vec())]); - let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let key1 = Bytes::from(vec![1u8]); let op1 = Operation::Remove(key1); prover.perform_one_operation(&op1).unwrap(); - let final_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let final_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let proof: Constant = prover .generate_proof() .into_iter() @@ -1119,7 +1189,10 @@ mod tests { (vec![2u8], 20u64.to_be_bytes().to_vec()), (vec![3u8], 30u64.to_be_bytes().to_vec()), ]); - let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let initial_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let op1 = Operation::Update(KeyValue { key: Bytes::from(vec![2u8]), @@ -1132,7 +1205,10 @@ mod tests { prover.perform_one_operation(&op1).unwrap(); prover.perform_one_operation(&op2).unwrap(); - let final_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()).unwrap(); + let final_digest = ADDigest::scorex_parse_bytes(&prover.digest().unwrap()) + .unwrap() + .0 + .to_vec(); let proof: Constant = prover .generate_proof() .into_iter() diff --git a/ergotree-interpreter/src/eval/scontext.rs b/ergotree-interpreter/src/eval/scontext.rs index 190e2ceed..14e785ba2 100644 --- a/ergotree-interpreter/src/eval/scontext.rs +++ b/ergotree-interpreter/src/eval/scontext.rs @@ -74,7 +74,7 @@ pub(crate) static LAST_BLOCK_UTXO_ROOT_HASH_EVAL_FN: EvalFn = |_mc, _env, ctx, o obj ))); } - let digest = ctx.headers[0].state_root; + let digest = ctx.headers[0].state_root.0.to_vec(); let tree_flags = AvlTreeFlags::new(true, true, true); Ok(Value::AvlTree(Box::from(AvlTreeData { digest, @@ -203,7 +203,7 @@ mod tests { .unwrap() .into(); let ctx = force_any_val::(); - let digest = ctx.headers[0].state_root; + let digest = ctx.headers[0].state_root.0.to_vec(); let tree_flags = AvlTreeFlags::new(true, true, true); let avl_tree_data = AvlTreeData { digest, diff --git a/ergotree-interpreter/src/eval/tree_lookup.rs b/ergotree-interpreter/src/eval/tree_lookup.rs index a98da823f..17849ebe8 100644 --- a/ergotree-interpreter/src/eval/tree_lookup.rs +++ b/ergotree-interpreter/src/eval/tree_lookup.rs @@ -28,7 +28,7 @@ impl Evaluable for TreeLookup { let normalized_key_val = self.key.eval(env, ctx)?.try_extract_into::>()?; let normalized_proof_val = self.proof.eval(env, ctx)?.try_extract_into::>()?; - let starting_digest = Bytes::from(normalized_tree_val.digest.0.to_vec()); + let starting_digest = Bytes::from(normalized_tree_val.digest.clone()); let proof = Bytes::from(normalized_proof_val.as_vec_u8()); let mut bv = BatchAVLVerifier::new( @@ -103,7 +103,7 @@ mod tests { let tree_flags = AvlTreeFlags::new(false, false, false); let obj = Expr::Const( AvlTreeData { - digest: initial_digest, + digest: initial_digest.0.to_vec(), tree_flags, key_length: 1, value_length_opt: None, diff --git a/ergotree-ir/src/mir/avl_tree_data.rs b/ergotree-ir/src/mir/avl_tree_data.rs index c03c4d664..ecdc74c08 100644 --- a/ergotree-ir/src/mir/avl_tree_data.rs +++ b/ergotree-ir/src/mir/avl_tree_data.rs @@ -1,4 +1,5 @@ use alloc::boxed::Box; +use alloc::vec::Vec; use ergo_chain_types::ADDigest; use sigma_ser::ScorexSerializable; @@ -58,8 +59,13 @@ impl AvlTreeFlags { /// value length, and access flags are stored in an instance of the datatype. #[derive(PartialEq, Eq, Debug, Clone)] pub struct AvlTreeData { - /// Authenticated tree digest: root hash along with tree height - pub digest: ADDigest, + /// Authenticated tree digest: root hash along with tree height. Normally + /// 33 bytes (`ADDigest` = 32-byte root hash + height byte), but + /// `updateDigest` may set an arbitrary-length value — the reference + /// `CAvlTree.updateDigest` stores any `Coll[Byte]` without a length check. + /// Such a value exists only in memory: the parser always reads exactly 33 + /// bytes, matching the JVM `AvlTreeData.serializer` (`getBytes(DigestSize)`). + pub digest: Vec, /// Allowed modifications pub tree_flags: AvlTreeFlags, /// All the elements under the tree have the same length @@ -70,14 +76,19 @@ pub struct AvlTreeData { impl SigmaSerializable for AvlTreeData { fn sigma_serialize(&self, w: &mut W) -> SigmaSerializeResult { - self.digest.scorex_serialize(w)?; + // Mirror the JVM `AvlTreeData.serializer`: write the digest bytes raw + // (`putBytes`), whatever their length. The parser always reads exactly + // 33 bytes, so a non-33 digest (only producible in memory via + // `updateDigest`) does not round-trip — same as the reference impl. + w.write_all(&self.digest)?; w.put_u8(self.tree_flags.0)?; w.put_u32(self.key_length)?; self.value_length_opt.sigma_serialize(w)?; Ok(()) } fn sigma_parse(r: &mut R) -> Result { - let digest = ADDigest::scorex_parse(r)?; + // Always read exactly `DigestSize` (33) bytes, matching the JVM parser. + let digest = ADDigest::scorex_parse(r)?.0.to_vec(); let tree_flags = AvlTreeFlags::parse(r.get_u8()?); let key_length = r.get_u32()?; let value_length_opt = > as SigmaSerializable>::sigma_parse(r)?; @@ -104,7 +115,9 @@ mod arbitrary { fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { ( - any::(), + // 33-byte digests only: a non-33 digest is producible solely in + // memory via `updateDigest` and does not survive serialization. + any::().prop_map(|d| d.0.to_vec()), any::(), any::(), any::(),