From 59970b09d2100cf66b1204eddd15a2911bb8c493 Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Sun, 7 Jun 2026 12:21:25 +0200 Subject: [PATCH 1/2] fix(ergotree-interpreter): Header.stateRoot evaluates to AvlTree, not Coll[Byte] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method is typed SAvlTree (and our descriptor already declares it so): the reference impl wraps the header digest in an AvlTree with all operations enabled, keyLength 32 and no value length (CHeader.stateRoot via AvlTreeData.avlTreeFromDigest). The eval impl returned the raw 33 digest bytes as Coll[Byte] — same bytes, wrong type surface (downstream AvlTree method calls vs collection ops). Pinned by the JVM-blessed byte vector (santa-eval Header.property_accessors, h.stateRoot#nominal). Co-Authored-By: Claude Opus 4.8 --- ergotree-interpreter/src/eval/sheader.rs | 57 +++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/ergotree-interpreter/src/eval/sheader.rs b/ergotree-interpreter/src/eval/sheader.rs index 11d7f1c05..6c54e38bb 100644 --- a/ergotree-interpreter/src/eval/sheader.rs +++ b/ergotree-interpreter/src/eval/sheader.rs @@ -1,5 +1,6 @@ //! Evaluating predefined `Header` (or SHeader) type properties +use alloc::boxed::Box; use alloc::sync::Arc; use core::convert::TryInto; use num_bigint::ToBigInt; @@ -8,7 +9,11 @@ use alloc::vec::Vec; use ergo_chain_types::Header; use ergotree_ir::{ bigint256::BigInt256, - mir::{constant::TryExtractInto, value::Value}, + mir::{ + avl_tree_data::{AvlTreeData, AvlTreeFlags}, + constant::TryExtractInto, + value::Value, + }, }; use super::{EvalError, EvalFn}; @@ -35,7 +40,16 @@ pub(crate) static AD_PROOFS_ROOT_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, _args| pub(crate) static STATE_ROOT_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, _args| { let header = obj.try_extract_into::
()?; - Ok(Into::>::into(header.state_root).into()) + // `stateRoot` is typed `SAvlTree` (and so declared in our method descriptor): + // the reference impl wraps the digest with all operations enabled and + // keyLength = 32 (`CHeader.stateRoot` → `AvlTreeData.avlTreeFromDigest`), + // rather than returning the raw digest bytes. + Ok(Value::AvlTree(Box::new(AvlTreeData { + digest: header.state_root, + tree_flags: AvlTreeFlags::new(true, true, true), + key_length: 32, + value_length_opt: None, + }))) }; pub(crate) static TRANSACTION_ROOT_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, _args| { @@ -112,14 +126,18 @@ mod tests { use core::convert::{TryFrom, TryInto}; use alloc::{boxed::Box, vec::Vec}; - use ergo_chain_types::{BlockId, Digest, Digest32, EcPoint, Votes}; + use ergo_chain_types::{BlockId, Digest, Digest32, EcPoint, Header, Votes}; use ergotree_ir::{ bigint256::BigInt256, chain::context::Context, mir::{ - coll_by_index::ByIndex, expr::Expr, method_call::MethodCall, + avl_tree_data::{AvlTreeData, AvlTreeFlags}, + coll_by_index::ByIndex, + expr::Expr, + method_call::MethodCall, property_call::PropertyCall, }, + serialization::SigmaSerializable, types::{ scontext::{self, HEADERS_PROPERTY}, sheader, @@ -127,6 +145,7 @@ mod tests { }, }; use num_bigint::ToBigInt; + use sigma_ser::ScorexSerializable; use sigma_test_util::force_any_val; use sigma_util::AsVecU8; @@ -250,11 +269,37 @@ mod tests { fn test_eval_state_root() { let expr = create_get_header_property_expr(sheader::STATE_ROOT_PROPERTY.clone()); let ctx = force_any_val::(); - let expected = ctx.headers[HEADER_INDEX].state_root; - let actual = digest_from_bytes_signed::<33>(eval_out::>(&expr, &ctx)); + // `stateRoot` is an AvlTree wrapping the digest, all operations enabled, + // keyLength 32 (`CHeader.stateRoot` → `AvlTreeData.avlTreeFromDigest`). + let expected = AvlTreeData { + digest: ctx.headers[HEADER_INDEX].state_root, + tree_flags: AvlTreeFlags::new(true, true, true), + key_length: 32, + value_length_opt: None, + }; + let actual = eval_out::(&expr, &ctx); assert_eq!(expected, actual); } + // JVM-blessed byte vector (santa-eval `Header.property_accessors`, entry + // `h.stateRoot#nominal`): the blessed result is the serialized AvlTree — + // digest ‖ flags 0x07 ‖ keyLength 32 ‖ no value length. + #[test] + fn test_eval_state_root_blessed_bytes() { + let header = Header::scorex_parse_bytes(&base16::decode("02ac2101807f0000ca01ff0119db227f202201007f62000177a080005d440896d05d3f80dcff7f5e7f59007294c180808d0158d1ff6ba10000f901c7f0ef87dcfff17fffacb6ff7f7f1180d2ff7f1e24ffffe1ff937f807f0797b9ff6ebdae007e5c8c00b8403d3701557181c8df800001b6d5009e2201c6ff807d71808c00019780f087adb3fcdbc0b3441480887f80007f4b01cf7f013ff1ffff564a0000b9a54f00770e807f41ff88c00240000080c0250000000003bedaee069ff4829500b3c07c4d5fe6b3ea3d3bf76c5c28c1d4dcdb1bed0ade0c0000000000003105").unwrap()).unwrap(); + let mut ctx = force_any_val::(); + ctx.headers[HEADER_INDEX] = header; + let expr = create_get_header_property_expr(sheader::STATE_ROOT_PROPERTY.clone()); + let actual = eval_out::(&expr, &ctx); + assert_eq!( + actual.sigma_serialize_bytes().unwrap(), + base16::decode( + "5c8c00b8403d3701557181c8df800001b6d5009e2201c6ff807d71808c00019780072000" + ) + .unwrap() + ); + } + #[test] fn test_eval_timestamp() { let expr = create_get_header_property_expr(sheader::TIMESTAMP_PROPERTY.clone()); From f4bd8dfd921784ff60e8c1ee4cca6a80b8cabdbd Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Sun, 7 Jun 2026 12:24:53 +0200 Subject: [PATCH 2/2] fix: autolykos v2 powOnetimePk is the group generator, not zero bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference impl has no absent state for the one-time pk: v2 header parsing fills it with the group generator (ErgoHeader.scala: wForV2 = CryptoConstants.dlogGroup.generator), and Header.powOnetimePk surfaces that in scripts. sigma-rust parsed it as None and the eval fn defaulted to EcPoint::default() — the identity, 33 zero bytes. Wire parsing now fills the generator for v2 (the v2 serializer ignores the field, so header ids and roundtrip bytes are unchanged; the arbitrary generator produces the same post-parse shape), and the eval fallback for headers built without it (e.g. JSON with no `w`) is the generator as well. Pinned by the JVM-blessed byte vector (santa-eval Header.property_accessors, h.powOnetimePk#nominal). Co-Authored-By: Claude Opus 4.8 --- ergo-chain-types/src/header.rs | 17 +++++++----- ergotree-interpreter/src/eval/sheader.rs | 34 +++++++++++++++++++++--- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/ergo-chain-types/src/header.rs b/ergo-chain-types/src/header.rs index 44d40b3f4..88dafb6c5 100644 --- a/ergo-chain-types/src/header.rs +++ b/ergo-chain-types/src/header.rs @@ -169,8 +169,11 @@ impl ScorexSerializable for Header { pow_distance, } } else { - // autolykos v2 - let pow_onetime_pk = None; + // autolykos v2: the wire carries no one-time pk; the reference impl + // fills it with the group generator at parse + // (`ErgoHeader.scala`: `wForV2 = CryptoConstants.dlogGroup.generator`), + // and that is what `Header.powOnetimePk` surfaces in scripts. + let pow_onetime_pk = Some(Box::new(crate::ec_point::generator())); let pow_distance = None; let miner_pk = EcPoint::scorex_parse(r)?.into(); let mut nonce: Vec = vec![0; 8]; @@ -366,11 +369,13 @@ mod arbitrary { let id = BlockId(blake2b256_hash(&id_bytes)); header.id = id; - // Manually set the following parameters to `None` for autolykos v2. This is - // allowable since serialization/deserialization of the `Header` ignores - // these fields for version > 1. + // For autolykos v2 the serialized form carries neither field: + // parsing fills the one-time pk with the group generator (as the + // reference impl does) and leaves the distance empty, so generate + // headers in that post-parse shape for roundtrips. if header.version > 1 { - header.autolykos_solution.pow_onetime_pk = None; + header.autolykos_solution.pow_onetime_pk = + Some(Box::new(crate::ec_point::generator())); header.autolykos_solution.pow_distance = None; } diff --git a/ergotree-interpreter/src/eval/sheader.rs b/ergotree-interpreter/src/eval/sheader.rs index 6c54e38bb..d01acc3cd 100644 --- a/ergotree-interpreter/src/eval/sheader.rs +++ b/ergotree-interpreter/src/eval/sheader.rs @@ -84,7 +84,15 @@ pub(crate) static MINER_PK_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, _args| { pub(crate) static POW_ONETIME_PK_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, _args| { let header = obj.try_extract_into::
()?; - Ok((*header.autolykos_solution.pow_onetime_pk.unwrap_or_default()).into()) + // The reference impl has no absent state for the one-time pk: autolykos v2 + // headers carry the group generator (`ErgoHeader.scala`: `wForV2`). Wire + // parsing fills it in; fall back to the generator (not the identity) for + // headers built without it (e.g. from JSON with no `w` field). + Ok((*header + .autolykos_solution + .pow_onetime_pk + .unwrap_or_else(|| Box::new(ergo_chain_types::ec_point::generator()))) + .into()) }; pub(crate) static POW_NONCE_EVAL_FN: EvalFn = |_mc, _env, _ctx, obj, _args| { @@ -126,7 +134,7 @@ mod tests { use core::convert::{TryFrom, TryInto}; use alloc::{boxed::Box, vec::Vec}; - use ergo_chain_types::{BlockId, Digest, Digest32, EcPoint, Header, Votes}; + use ergo_chain_types::{ec_point, BlockId, Digest, Digest32, EcPoint, Header, Votes}; use ergotree_ir::{ bigint256::BigInt256, chain::context::Context, @@ -336,10 +344,12 @@ mod tests { .map(|h| { [ h.autolykos_solution.miner_pk.clone(), + // an absent one-time pk surfaces as the group generator, + // mirroring the reference impl's autolykos v2 default h.autolykos_solution .pow_onetime_pk .clone() - .unwrap_or_default(), + .unwrap_or_else(|| Box::new(ec_point::generator())), ] }) .expect("internal error: empty headers array"); @@ -347,6 +357,24 @@ mod tests { assert_eq!(expected, actual); } + // JVM-blessed byte vector (santa-eval `Header.property_accessors`, entry + // `h.powOnetimePk#nominal`): on an autolykos-v2 header the one-time pk is + // the group generator. + #[test] + fn test_eval_pow_onetime_pk_blessed_bytes() { + let header = Header::scorex_parse_bytes(&base16::decode("02ac2101807f0000ca01ff0119db227f202201007f62000177a080005d440896d05d3f80dcff7f5e7f59007294c180808d0158d1ff6ba10000f901c7f0ef87dcfff17fffacb6ff7f7f1180d2ff7f1e24ffffe1ff937f807f0797b9ff6ebdae007e5c8c00b8403d3701557181c8df800001b6d5009e2201c6ff807d71808c00019780f087adb3fcdbc0b3441480887f80007f4b01cf7f013ff1ffff564a0000b9a54f00770e807f41ff88c00240000080c0250000000003bedaee069ff4829500b3c07c4d5fe6b3ea3d3bf76c5c28c1d4dcdb1bed0ade0c0000000000003105").unwrap()).unwrap(); + let mut ctx = force_any_val::(); + ctx.headers[HEADER_INDEX] = header; + let expr = create_get_header_property_expr(sheader::POW_ONETIME_PK_PROPERTY.clone()); + let actual = eval_out::(&expr, &ctx); + assert_eq!(actual, ec_point::generator()); + assert_eq!( + actual.scorex_serialize_bytes().unwrap(), + base16::decode("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .unwrap() + ); + } + #[test] fn test_eval_pow_distance() { let expr = create_get_header_property_expr(sheader::POW_DISTANCE_PROPERTY.clone());