From 60649c0b1e1b21d4b18dcddb286381253deaf048 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 30 Oct 2026 15:26:47 +0200 Subject: [PATCH 1/5] pruning: move pruning into its own module Moves out the Converter interfaces and pruning logic into their own file in preparation for adding another converter and logic to pruning. No change in behaviour. Assisted-by: Claude --- src/node/mod.rs | 1 + src/node/prune.rs | 178 +++++++++++++++++++++++++++++++++++++++++++++ src/node/redeem.rs | 159 +--------------------------------------- 3 files changed, 183 insertions(+), 155 deletions(-) create mode 100644 src/node/prune.rs diff --git a/src/node/mod.rs b/src/node/mod.rs index 4f7dba76..99afe1e1 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -78,6 +78,7 @@ mod disconnect; mod display; mod hiding; mod inner; +mod prune; mod redeem; use crate::value::Word; diff --git a/src/node/prune.rs b/src/node/prune.rs new file mode 100644 index 00000000..bd5b9ac9 --- /dev/null +++ b/src/node/prune.rs @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! Pruning of redeem programs +//! +//! See [`RedeemNode::prune`] for how a program is pruned. + +use std::sync::Arc; + +use crate::bit_machine::{ExecutionError, PruneTracker}; +use crate::dag::{InternalSharing, MaxSharing, PostOrderIterItem}; +use crate::jet::JetEnvironment; +use crate::types; +use crate::{BitMachine, Value}; + +use super::{ + Construct, ConstructData, ConstructNode, Constructible, Converter, Hide, Inner, Redeem, + RedeemData, RedeemNode, +}; + +/// Prunes `program`, as described in [`RedeemNode::prune`]. +pub(super) fn prune( + program: &RedeemNode, + env: &JE, + tracker: &mut T, +) -> Result, ExecutionError> { + // 1) Run the Bit Machine and mark (un)used branches. + // This is the only fallible step in the pruning process. + let mut mac = BitMachine::for_program(program)?; + mac.exec_with_tracker(program, env, tracker)?; + + // 2) Prune out unused case branches. + // Because the types of the pruned program may change, + // we construct a temporary witness program with unfinalized types. + // Convert with `MaxSharing`: the tracker keys branch usage by IHR, so + // converting IHR-equal nodes separately makes each copy keep the branches + // that only its twins executed. + types::Context::with_context(|inference_context| { + let pruned_witness_program = program + .convert::, _, _>(&mut Pruner { + inference_context, + tracker, + }) + .expect("pruning unused branches is infallible"); + + // 3) Finalize the types of the witness program. + // We obtain the pruned redeem program. + // Once the pruned type is finalized, we can proceed to prune witness values. + Ok(pruned_witness_program + .convert::(&mut Finalizer) + .expect("finalization is infallible")) + }) +} + +struct Pruner<'brand, 't, T> { + inference_context: types::Context<'brand>, + tracker: &'t mut T, +} + +impl<'brand, 't, T> Converter> for Pruner<'brand, 't, T> +where + T: PruneTracker, +{ + type Error = std::convert::Infallible; + + fn convert_witness( + &mut self, + _: &PostOrderIterItem<&RedeemNode>, + witness: &Value, + ) -> Result, Self::Error> { + // The pruned type is not finalized at this point, + // so we cannot prune the witness value. + Ok(Some(witness.shallow_clone())) + } + + fn convert_disconnect( + &mut self, + _: &PostOrderIterItem<&RedeemNode>, + right: Option<&Arc>>, + _: &Arc, + ) -> Result>>, Self::Error> { + debug_assert!( + right.is_some(), + "disconnected branch should exist in unpruned redeem program" + ); + Ok(right.map(Arc::clone)) + } + + fn prune_case( + &mut self, + data: &PostOrderIterItem<&RedeemNode>, + _left: &Arc, + _right: &Arc, + ) -> Result { + // The IHR of the pruned program may change, + // but the Converter trait gives us access to the unpruned node (`data`). + // The Bit Machine tracked (un)used case branches based on the unpruned IHR. + match ( + self.tracker.contains_left(data.node.ihr()), + self.tracker.contains_right(data.node.ihr()), + ) { + (true, true) => Ok(Hide::Neither), + (false, true) => Ok(Hide::Left), + (true, false) => Ok(Hide::Right), + (false, false) => Ok(Hide::Neither), // case nodes that were never executed will be pruned out by their ancestors + } + } + + fn convert_data( + &mut self, + _: &PostOrderIterItem<&RedeemNode>, + inner: Inner< + &Arc>, + &Option>>, + &Option, + >, + ) -> Result, Self::Error> { + let converted_inner = inner + .map(|node| node.cached_data()) + .map_witness(Option::::clone); + let retyped = ConstructData::from_inner(&self.inference_context, converted_inner) + .expect("pruned types should check out if unpruned types check out"); + Ok(retyped) + } +} + +struct Finalizer; + +impl<'brand> Converter, Redeem> for Finalizer { + type Error = std::convert::Infallible; + + fn convert_witness( + &mut self, + data: &PostOrderIterItem<&ConstructNode>, + witness: &Option, + ) -> Result { + let pruned_target_ty = data + .node + .arrow() + .target + .finalize() + .expect("pruned types should check out if unpruned types check out"); + let pruned_witness = witness + .as_ref() + .expect("witness node that originally stems from redeem program should be populated") + .prune(&pruned_target_ty) + .expect("pruned type should be shrunken version of unpruned type"); + Ok(pruned_witness) + } + + fn convert_disconnect( + &mut self, + _: &PostOrderIterItem<&ConstructNode>, + right: Option<&Arc>, + _: &Option>, + ) -> Result, Self::Error> { + Ok(right.map(Arc::clone).expect( + "disconnect node that originally stems from redeem program should have all branches", + )) + } + + fn convert_data( + &mut self, + data: &PostOrderIterItem<&ConstructNode>, + inner: Inner<&Arc, &Arc, &Value>, + ) -> Result, Self::Error> { + // Finalize target types of witness nodes in advance so we can prune their values. + let final_arrow = data + .node + .arrow() + .finalize() + .expect("pruned types should check out if unpruned types check out"); + let converted_inner = inner + .map(|node| node.cached_data()) + .map_disconnect(|node| node.cached_data()) + .map_witness(Value::shallow_clone); + Ok(Arc::new(RedeemData::new(final_arrow, converted_inner))) + } +} diff --git a/src/node/redeem.rs b/src/node/redeem.rs index 89202b5d..ef7c168f 100644 --- a/src/node/redeem.rs +++ b/src/node/redeem.rs @@ -3,14 +3,14 @@ use crate::analysis::NodeBounds; use crate::bit_machine::{ExecutionError, PruneTracker, SetTracker}; use crate::dag::{DagLike, InternalSharing, MaxSharing, PostOrderIterItem}; +use crate::encode; use crate::jet::{Jet, JetEnvironment}; use crate::types::{self, FinalArrow}; -use crate::{encode, BitMachine}; use crate::{Amr, BitIter, BitWriter, Cmr, DecodeError, Ihr, Imr, Value}; use super::{ Commit, CommitData, CommitNode, Construct, ConstructData, ConstructNode, Constructible, - Converter, Hide, Inner, Marker, NoDisconnect, NoWitness, Node, + Converter, Inner, Marker, NoDisconnect, NoWitness, Node, }; use std::collections::HashSet; @@ -294,158 +294,7 @@ impl RedeemNode { env: &JE, tracker: &mut T, ) -> Result, ExecutionError> { - struct Pruner<'brand, 't, T> { - inference_context: types::Context<'brand>, - tracker: &'t mut T, - } - - impl<'brand, 't, T> Converter> for Pruner<'brand, 't, T> - where - T: PruneTracker, - { - type Error = std::convert::Infallible; - - fn convert_witness( - &mut self, - _: &PostOrderIterItem<&RedeemNode>, - witness: &Value, - ) -> Result, Self::Error> { - // The pruned type is not finalized at this point, - // so we cannot prune the witness value. - Ok(Some(witness.shallow_clone())) - } - - fn convert_disconnect( - &mut self, - _: &PostOrderIterItem<&RedeemNode>, - right: Option<&Arc>>, - _: &Arc, - ) -> Result>>, Self::Error> { - debug_assert!( - right.is_some(), - "disconnected branch should exist in unpruned redeem program" - ); - Ok(right.map(Arc::clone)) - } - - fn prune_case( - &mut self, - data: &PostOrderIterItem<&RedeemNode>, - _left: &Arc, - _right: &Arc, - ) -> Result { - // The IHR of the pruned program may change, - // but the Converter trait gives us access to the unpruned node (`data`). - // The Bit Machine tracked (un)used case branches based on the unpruned IHR. - match ( - self.tracker.contains_left(data.node.ihr()), - self.tracker.contains_right(data.node.ihr()), - ) { - (true, true) => Ok(Hide::Neither), - (false, true) => Ok(Hide::Left), - (true, false) => Ok(Hide::Right), - (false, false) => Ok(Hide::Neither), // case nodes that were never executed will be pruned out by their ancestors - } - } - - fn convert_data( - &mut self, - _: &PostOrderIterItem<&RedeemNode>, - inner: Inner< - &Arc>, - &Option>>, - &Option, - >, - ) -> Result, Self::Error> { - let converted_inner = inner - .map(|node| node.cached_data()) - .map_witness(Option::::clone); - let retyped = ConstructData::from_inner(&self.inference_context, converted_inner) - .expect("pruned types should check out if unpruned types check out"); - Ok(retyped) - } - } - - struct Finalizer; - - impl<'brand> Converter, Redeem> for Finalizer { - type Error = std::convert::Infallible; - - fn convert_witness( - &mut self, - data: &PostOrderIterItem<&ConstructNode>, - witness: &Option, - ) -> Result { - let pruned_target_ty = data - .node - .arrow() - .target - .finalize() - .expect("pruned types should check out if unpruned types check out"); - let pruned_witness = witness - .as_ref() - .expect("witness node that originally stems from redeem program should be populated") - .prune(&pruned_target_ty) - .expect("pruned type should be shrunken version of unpruned type"); - Ok(pruned_witness) - } - - fn convert_disconnect( - &mut self, - _: &PostOrderIterItem<&ConstructNode>, - right: Option<&Arc>, - _: &Option>, - ) -> Result, Self::Error> { - Ok(right - .map(Arc::clone) - .expect("disconnect node that originally stems from redeem program should have all branches")) - } - - fn convert_data( - &mut self, - data: &PostOrderIterItem<&ConstructNode>, - inner: Inner<&Arc, &Arc, &Value>, - ) -> Result, Self::Error> { - // Finalize target types of witness nodes in advance so we can prune their values. - let final_arrow = data - .node - .arrow() - .finalize() - .expect("pruned types should check out if unpruned types check out"); - let converted_inner = inner - .map(|node| node.cached_data()) - .map_disconnect(|node| node.cached_data()) - .map_witness(Value::shallow_clone); - Ok(Arc::new(RedeemData::new(final_arrow, converted_inner))) - } - } - - // 1) Run the Bit Machine and mark (un)used branches. - // This is the only fallible step in the pruning process. - let mut mac = BitMachine::for_program(self)?; - mac.exec_with_tracker(self, env, tracker)?; - - // 2) Prune out unused case branches. - // Because the types of the pruned program may change, - // we construct a temporary witness program with unfinalized types. - // Convert with `MaxSharing`: the tracker keys branch usage by IHR, so - // converting IHR-equal nodes separately makes each copy keep the branches - // that only its twins executed. - types::Context::with_context(|inference_context| { - let pruned_witness_program = self - .convert::, _, _>(&mut Pruner { - inference_context, - tracker, - }) - .expect("pruning unused branches is infallible"); - - // 3) Finalize the types of the witness program. - // We obtain the pruned redeem program. - // Once the pruned type is finalized, we can proceed to prune witness values. - Ok(pruned_witness_program - .convert::(&mut Finalizer) - .expect("finalization is infallible")) - }) + super::prune::prune(self, env, tracker) } /// Decode a Simplicity program from bits, including the witness data. @@ -595,7 +444,7 @@ mod tests { use hex::DisplayHex; use std::fmt; #[cfg(all(feature = "elements", feature = "human_encoding"))] - use {crate::types::Final, std::collections::HashMap}; + use {crate::types::Final, crate::BitMachine, std::collections::HashMap}; #[cfg_attr(not(feature = "base64"), allow(unused_variables))] #[track_caller] From 6df0de187229e2d26408c5126ad4b0d2db158c0e Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 30 Oct 2026 11:34:44 +0200 Subject: [PATCH 2/5] node: merge nodes with equal IHRs before pruning Pruning assumes that a program has a single node per IHR, as a decoded program does. A program built in memory can have several nodes with the same IHR, and as the IHR does not cover the types inside a node, those nodes can contain case nodes with different IHRs. The tracker records the branches of each of those case nodes separately, so pruning can hide a different branch in each, while the encoding has a single node for all of them, which then reaches a pruned branch. Reduce the program to maximal sharing before running it on the Bit Machine, so that the tracker records branches for the nodes which the encoding will have. Assisted-by: Claude --- src/node/prune.rs | 64 +++++++++++++++++++++++++--- src/node/redeem.rs | 102 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/node/prune.rs b/src/node/prune.rs index bd5b9ac9..2cfa7c4e 100644 --- a/src/node/prune.rs +++ b/src/node/prune.rs @@ -23,12 +23,18 @@ pub(super) fn prune( env: &JE, tracker: &mut T, ) -> Result, ExecutionError> { - // 1) Run the Bit Machine and mark (un)used branches. + // 1) Merge nodes with equal IHRs. The IHR covers the source and target types of a + // node but not the types inside it, so nodes with equal IHRs can contain case + // nodes with different IHRs, which the tracker would mark separately, while the + // encoding of the pruned program has a single node for all of them. + let program = reduce_to_max_sharing(program); + + // 2) Run the Bit Machine and mark (un)used branches. // This is the only fallible step in the pruning process. - let mut mac = BitMachine::for_program(program)?; - mac.exec_with_tracker(program, env, tracker)?; + let mut mac = BitMachine::for_program(&program)?; + mac.exec_with_tracker(&program, env, tracker)?; - // 2) Prune out unused case branches. + // 3) Prune out unused case branches. // Because the types of the pruned program may change, // we construct a temporary witness program with unfinalized types. // Convert with `MaxSharing`: the tracker keys branch usage by IHR, so @@ -42,7 +48,7 @@ pub(super) fn prune( }) .expect("pruning unused branches is infallible"); - // 3) Finalize the types of the witness program. + // 4) Finalize the types of the witness program. // We obtain the pruned redeem program. // Once the pruned type is finalized, we can proceed to prune witness values. Ok(pruned_witness_program @@ -51,6 +57,54 @@ pub(super) fn prune( }) } +fn reduce_to_max_sharing(program: &RedeemNode) -> Arc { + program + .convert::, _, _>(&mut Identity) + .expect("copying a program is infallible") +} + +/// Converts a redeem program into a copy of itself. Converting with [`MaxSharing`] +/// gives a copy with a single node for each IHR, as in the program's encoding. +struct Identity; + +impl Converter for Identity { + type Error = std::convert::Infallible; + + fn convert_witness( + &mut self, + _: &PostOrderIterItem<&RedeemNode>, + witness: &Value, + ) -> Result { + Ok(witness.shallow_clone()) + } + + fn convert_disconnect( + &mut self, + _: &PostOrderIterItem<&RedeemNode>, + right: Option<&Arc>, + _: &Arc, + ) -> Result, Self::Error> { + Ok(Arc::clone(right.expect( + "disconnect node in redeem program has a right child", + ))) + } + + fn convert_data( + &mut self, + data: &PostOrderIterItem<&RedeemNode>, + inner: Inner<&Arc, &Arc, &Value>, + ) -> Result, Self::Error> { + let converted_inner = inner + .map(|node| node.cached_data()) + .map_disconnect(|node| node.cached_data()) + .map_witness(Value::shallow_clone); + Ok(Arc::new(RedeemData::new( + data.node.arrow().shallow_clone(), + converted_inner, + ))) + } +} + struct Pruner<'brand, 't, T> { inference_context: types::Context<'brand>, tracker: &'t mut T, diff --git a/src/node/redeem.rs b/src/node/redeem.rs index ef7c168f..ba24f3a5 100644 --- a/src/node/redeem.rs +++ b/src/node/redeem.rs @@ -267,9 +267,10 @@ impl RedeemNode { /// Prune the redeem program for the given transaction environment. /// /// Pruning works as follows: - /// 1) Run the redeem program on the Bit Machine. - /// 2) Mark all (un)used case branches using the IHR of the case node. - /// 3) Rebuild the program and omit unused branches. + /// 1) Merge nodes with equal IHRs, as the encoding of the program does. + /// 2) Run the redeem program on the Bit Machine. + /// 3) Mark all (un)used case branches using the IHR of the case node. + /// 4) Rebuild the program and omit unused branches. /// /// The pruning result depends on the witness data (which is already part of the redeem program) /// and on the transaction environment. These two inputs determine which case branches are @@ -277,9 +278,11 @@ impl RedeemNode { /// starting from the same original, unpruned program. Pruning is a lossy process, so pruning /// an already pruned program is not sound. /// - /// Pruning fails if the original, unpruned program fails to run on the Bit Machine (step 1). - /// In this case, the witness data needs to be revised. - /// The other pruning steps (2 & 3) never fail. + /// Pruning fails if the program, with its nodes merged in step 1, fails to run on the Bit + /// Machine (step 2). In this case, the witness data needs to be revised, unless merging + /// changed what the program does: nodes with equal IHRs may hide different branches, and + /// no encoding can express such a program. + /// The other pruning steps never fail. pub fn prune(&self, env: &JE) -> Result, ExecutionError> { self.prune_with_tracker(env, &mut SetTracker::default()) } @@ -441,10 +444,11 @@ mod tests { use crate::human_encoding::Forest; use crate::jet::Core; use crate::node::SimpleFinalizer; + use crate::BitMachine; use hex::DisplayHex; use std::fmt; #[cfg(all(feature = "elements", feature = "human_encoding"))] - use {crate::types::Final, crate::BitMachine, std::collections::HashMap}; + use {crate::types::Final, std::collections::HashMap}; #[cfg_attr(not(feature = "base64"), allow(unused_variables))] #[track_caller] @@ -1014,6 +1018,90 @@ main := comp input comp process jet_verify : 1 -> 1"#; &env, ); } + + /// Prunes `unpruned` and checks the encoding of the result as consensus does. + fn prune_and_check_encoding(unpruned: &RedeemNode) { + use crate::ffi::tests::{ffi::SimplicityErr, run_program, TestUpTo}; + use crate::jet::CoreEnv; + + let env = CoreEnv::new(); + let pruned = unpruned.prune(&env).expect("pruning should succeed"); + assert_eq!(pruned.cmr(), unpruned.cmr()); + let (prog_bytes, witness_bytes) = pruned.to_vec_with_witness(); + let decoded = RedeemNode::decode::<_, _, Core>( + BitIter::from(&prog_bytes[..]), + BitIter::from(&witness_bytes[..]), + ) + .expect("pruned program should decode"); + BitMachine::for_program(&decoded) + .expect("pruned program has reasonable bounds") + .exec(&decoded, &env) + .expect("decoded program should not reach a pruned branch"); + let c_result = run_program( + &prog_bytes, + &witness_bytes, + TestUpTo::Everything, + None, + None, + ) + .expect("libsimplicity should decode the pruned program"); + assert_eq!(c_result.eval_result, SimplicityErr::NoError); + } + + #[test] + fn prune_merges_nodes_with_equal_ihrs() { + // `p1` and `p2` have one IHR, which does not cover the types inside them. Their + // case nodes have different types, and so different IHRs, and one only takes its + // left branch and the other only its right. An encoding has a single node for + // `p1` and `p2`, so its case node must keep both branches. + use crate::node::CoreConstructible; + + type ArcNode<'brand> = Arc>; + + let unpruned = types::Context::with_context(|ctx| { + let unit = || ArcNode::unit(&ctx); + let iden = || ArcNode::iden(&ctx); + let g1 = ArcNode::injl(&unit()); + let g2 = ArcNode::injl(&unit()); + let p1 = ArcNode::comp( + &ArcNode::pair(&iden(), &g1).unwrap(), + &ArcNode::case(&unit(), &unit()).unwrap(), + ) + .unwrap(); + let p2 = ArcNode::comp( + &ArcNode::pair(&iden(), &g2).unwrap(), + &ArcNode::case(&unit(), &unit()).unwrap(), + ) + .unwrap(); + // A branch which never runs makes the type of `g1` 2 -> 1 + 2. + let verify = ArcNode::jet(&ctx, &Core::Verify); + let pins_g1 = ArcNode::comp( + &g1, + &ArcNode::comp( + &ArcNode::pair(&iden(), &iden()).unwrap(), + &ArcNode::case(&unit(), &ArcNode::take(&verify)).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + ArcNode::comp( + &ArcNode::pair( + &ArcNode::pair( + &ArcNode::comp(&ArcNode::injl(&unit()), &p1).unwrap(), + &ArcNode::comp(&ArcNode::injr(&unit()), &p2).unwrap(), + ) + .unwrap(), + &ArcNode::comp(&ArcNode::injl(&unit()), &pins_g1).unwrap(), + ) + .unwrap(), + &unit(), + ) + .unwrap() + .finalize_unpruned() + .expect("program should finalize") + }); + prune_and_check_encoding(&unpruned); + } } #[cfg(bench)] From f9f2f27b8d822ebcb5990f701e2ea5183016edf9 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Wed, 28 Oct 2026 17:10:20 +0200 Subject: [PATCH 3/5] pruning: mark kept nodes and rebuild the dag Refactors pruning to use a different approach. First, walk top down from the root, and mark kept nodes and what needs to be hidden. Then, rebuild the DAG from bottom up (the usual way), only adding back kept nodes, and replacing cases with hidden, if necessary. Only finalize on the rebuilt program, which should be more reliable with determining the correct types. Because we use IHRs, we need to make sure that there are no different nodes in the tree, with the same IHR, but potentially different types, which is why we reduced the tree to maximal sharing in a previous commit. Fixes #391. Assisted-by: Claude --- src/node/prune.rs | 202 +++++++++++++++++++++++++-------------------- src/node/redeem.rs | 169 ++++++++++++++++++++++++++++++++++++- 2 files changed, 282 insertions(+), 89 deletions(-) diff --git a/src/node/prune.rs b/src/node/prune.rs index 2cfa7c4e..cd48a234 100644 --- a/src/node/prune.rs +++ b/src/node/prune.rs @@ -4,16 +4,17 @@ //! //! See [`RedeemNode::prune`] for how a program is pruned. +use std::collections::HashMap; use std::sync::Arc; use crate::bit_machine::{ExecutionError, PruneTracker}; -use crate::dag::{InternalSharing, MaxSharing, PostOrderIterItem}; +use crate::dag::{DagLike, InternalSharing, MaxSharing, PostOrderIterItem}; use crate::jet::JetEnvironment; use crate::types; -use crate::{BitMachine, Value}; +use crate::{BitMachine, Ihr, Value}; use super::{ - Construct, ConstructData, ConstructNode, Constructible, Converter, Hide, Inner, Redeem, + Construct, ConstructData, ConstructNode, Constructible, Converter, Hide, Inner, Node, Redeem, RedeemData, RedeemNode, }; @@ -34,27 +35,14 @@ pub(super) fn prune( let mut mac = BitMachine::for_program(&program)?; mac.exec_with_tracker(&program, env, tracker)?; - // 3) Prune out unused case branches. - // Because the types of the pruned program may change, - // we construct a temporary witness program with unfinalized types. - // Convert with `MaxSharing`: the tracker keys branch usage by IHR, so - // converting IHR-equal nodes separately makes each copy keep the branches - // that only its twins executed. - types::Context::with_context(|inference_context| { - let pruned_witness_program = program - .convert::, _, _>(&mut Pruner { - inference_context, - tracker, - }) - .expect("pruning unused branches is infallible"); - - // 4) Finalize the types of the witness program. - // We obtain the pruned redeem program. - // Once the pruned type is finalized, we can proceed to prune witness values. - Ok(pruned_witness_program - .convert::(&mut Finalizer) - .expect("finalization is infallible")) - }) + // 3) Find the nodes to keep: those reachable from the root through used branches. + let tracker = &*tracker; + let kept = kept_nodes(&program, |ihr| { + (tracker.contains_left(ihr), tracker.contains_right(ihr)) + }); + + // 4) Rebuild the program from the kept nodes and prune out unused case branches. + Ok(rebuild(&program, &kept)) } fn reduce_to_max_sharing(program: &RedeemNode) -> Arc { @@ -105,78 +93,116 @@ impl Converter for Identity { } } -struct Pruner<'brand, 't, T> { - inference_context: types::Context<'brand>, - tracker: &'t mut T, -} - -impl<'brand, 't, T> Converter> for Pruner<'brand, 't, T> -where - T: PruneTracker, -{ - type Error = std::convert::Infallible; - - fn convert_witness( - &mut self, - _: &PostOrderIterItem<&RedeemNode>, - witness: &Value, - ) -> Result, Self::Error> { - // The pruned type is not finalized at this point, - // so we cannot prune the witness value. - Ok(Some(witness.shallow_clone())) - } - - fn convert_disconnect( - &mut self, - _: &PostOrderIterItem<&RedeemNode>, - right: Option<&Arc>>, - _: &Arc, - ) -> Result>>, Self::Error> { - debug_assert!( - right.is_some(), - "disconnected branch should exist in unpruned redeem program" - ); - Ok(right.map(Arc::clone)) +/// Returns which branch of a case node to hide, given whether its left and +/// right branches were used. +fn branch_to_hide(used_left: bool, used_right: bool) -> Hide { + match (used_left, used_right) { + (true, true) => Hide::Neither, + (false, true) => Hide::Left, + (true, false) => Hide::Right, + // This is not possible with the default tracker, but if a custom + // tracker is used with `prune_with_tracker`, we might end up here. It's + // difficult to know the reason, but it is most likely unintended. + (false, false) => panic!("both branches of a case node were unused, which is not possible with the default tracker"), } +} - fn prune_case( - &mut self, - data: &PostOrderIterItem<&RedeemNode>, - _left: &Arc, - _right: &Arc, - ) -> Result { - // The IHR of the pruned program may change, - // but the Converter trait gives us access to the unpruned node (`data`). - // The Bit Machine tracked (un)used case branches based on the unpruned IHR. - match ( - self.tracker.contains_left(data.node.ihr()), - self.tracker.contains_right(data.node.ihr()), - ) { - (true, true) => Ok(Hide::Neither), - (false, true) => Ok(Hide::Left), - (true, false) => Ok(Hide::Right), - (false, false) => Ok(Hide::Neither), // case nodes that were never executed will be pruned out by their ancestors +/// Walks the program from the root, descending only into used branches, +/// and returns the IHRs of the nodes to keep together with which branches +/// to hide for each of them. `used` returns whether the left and right +/// branches of the case node with the given IHR were used. +/// +/// `root` must have a single node for each IHR, as `reduce_to_max_sharing` +/// gives: nodes are identified by IHR, so a node whose IHR is already kept is +/// the same node, with the same children. +fn kept_nodes(root: &RedeemNode, mut used: impl FnMut(Ihr) -> (bool, bool)) -> HashMap { + let mut kept = HashMap::new(); + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if kept.contains_key(&node.ihr()) { + continue; + } + let hide = match node.inner() { + Inner::Case(..) => { + let (used_left, used_right) = used(node.ihr()); + branch_to_hide(used_left, used_right) + } + _ => Hide::Neither, + }; + kept.insert(node.ihr(), hide); + match (node.inner(), hide) { + (Inner::Case(_, right), Hide::Left) => stack.push(right), + (Inner::Case(left, _), Hide::Right) => stack.push(left), + _ => { + stack.extend(node.left_child()); + stack.extend(node.right_child()); + } } } + kept +} - fn convert_data( - &mut self, - _: &PostOrderIterItem<&RedeemNode>, - inner: Inner< - &Arc>, - &Option>>, - &Option, - >, - ) -> Result, Self::Error> { - let converted_inner = inner - .map(|node| node.cached_data()) - .map_witness(Option::::clone); - let retyped = ConstructData::from_inner(&self.inference_context, converted_inner) +/// Rebuilds the program bottom-up from the kept nodes alone, and turns case +/// nodes with an unused branch into assertions. +/// +/// `program` must be the program which `kept` came from, with a single node +/// for each IHR. +fn rebuild(program: &RedeemNode, kept: &HashMap) -> Arc { + // Nodes in hidden branches are never built, so they cannot constrain the + // types of the pruned program. + // Because the types of the pruned program may change, + // we construct a temporary witness program with unfinalized types. + types::Context::with_context(|inference_context| { + let mut built: HashMap> = HashMap::new(); + for data in program.post_order_iter::>() { + let node = data.node; + let hide = match kept.get(&node.ihr()) { + Some(&hide) => hide, + // Inside a hidden branch. + None => continue, + }; + // The program has one node for each IHR, so the walk kept the same + // children, and children are visited before their parents, so they + // are built. + let child = |child: &Arc| { + Arc::clone( + built + .get(&child.ihr()) + .expect("children of kept nodes are kept"), + ) + }; + let inner = match (node.inner(), hide) { + (Inner::Case(left, right), Hide::Left) => Inner::AssertR(left.cmr(), child(right)), + (Inner::Case(left, right), Hide::Right) => Inner::AssertL(child(left), right.cmr()), + (inner, _) => inner + .as_ref() + .map(&child) + .map_disconnect(|right| Some(child(right))) + // The pruned type is not finalized at this point, + // so we cannot prune the witness value. + .map_witness(|witness| Some(witness.shallow_clone())), + }; + let data = ConstructData::from_inner( + &inference_context, + inner + .as_ref() + .map(|node| node.cached_data()) + .map_witness(Option::::clone), + ) .expect("pruned types should check out if unpruned types check out"); - Ok(retyped) - } + built.insert(node.ihr(), Arc::new(Node::from_parts(inner, data))); + } + let pruned_witness_program = built.remove(&program.ihr()).expect("root is kept"); + // Finalize the types of the witness program. + // We obtain the pruned redeem program. + // Once the pruned type is finalized, we can proceed to prune witness values. + pruned_witness_program + .convert::(&mut Finalizer) + .expect("finalization is infallible") + }) } +/// Finalizes the types of a pruned program, and prunes its witness values to them. struct Finalizer; impl<'brand> Converter, Redeem> for Finalizer { diff --git a/src/node/redeem.rs b/src/node/redeem.rs index ba24f3a5..35708d8e 100644 --- a/src/node/redeem.rs +++ b/src/node/redeem.rs @@ -270,7 +270,8 @@ impl RedeemNode { /// 1) Merge nodes with equal IHRs, as the encoding of the program does. /// 2) Run the redeem program on the Bit Machine. /// 3) Mark all (un)used case branches using the IHR of the case node. - /// 4) Rebuild the program and omit unused branches. + /// 4) Rebuild the program from the nodes reachable through used branches, + /// and omit unused branches. /// /// The pruning result depends on the witness data (which is already part of the redeem program) /// and on the transaction environment. These two inputs determine which case branches are @@ -900,6 +901,84 @@ mod tests { ); } + #[test] + #[cfg(feature = "human_encoding")] + fn prune_drops_witness_read_only_by_hidden_branch() { + // Only the right branch of the case reads `wit2`. With `wit1` selecting the + // left branch, pruning hides the right branch, nothing else reads `wit2`, and + // a decoder expects no bits for it. Both branches contain `drop iden`, so the + // hidden branch must not constrain the types of the pruned program through it. + use crate::ffi::tests::{ffi::SimplicityErr, run_program, TestUpTo}; + use crate::human_encoding::Forest; + use crate::jet::CoreEnv; + use std::collections::HashMap; + + // `drop iden` is defined once, so both branches point at the same node. + let pointer_shared = r#"wit1 := witness : 1 -> 2 +wit2 := witness : 1 -> 2^8 +input := pair wit1 wit2 : 1 -> 2 * 2^8 +proj := drop iden +skip_wit2 := comp proj unit +read_wit2 := comp proj (comp (pair iden iden) (comp jet_eq_8 jet_verify)) +main := comp input (case skip_wit2 read_wit2) : 1 -> 1"#; + // `drop iden` is written out twice: two pointer-distinct nodes with one IHR. + let ihr_shared = r#"wit1 := witness : 1 -> 2 +wit2 := witness : 1 -> 2^8 +input := pair wit1 wit2 : 1 -> 2 * 2^8 +skip_wit2 := comp (drop iden) unit +read_wit2 := comp (drop iden) (comp (pair iden iden) (comp jet_eq_8 jet_verify)) +main := comp input (case skip_wit2 read_wit2) : 1 -> 1"#; + let wit = HashMap::from([ + (Arc::from("wit1"), Value::u1(0)), + (Arc::from("wit2"), Value::u8(7)), + ]); + + for s in [pointer_shared, ihr_shared] { + let unpruned = types::Context::with_context(|ctx| { + Forest::parse::(s) + .expect("program should parse") + .to_witness_node(&ctx, &wit) + .expect("program should have main") + .finalize_unpruned() + .expect("program should finalize") + }); + + let pruned = unpruned + .prune(&CoreEnv::new()) + .expect("pruning should succeed"); + assert_eq!(pruned.cmr(), unpruned.cmr()); + let (_, witness_bits) = pruned + .encode_with_witness(&mut Vec::::new(), &mut Vec::::new()) + .expect("Vec::write is infallible"); + assert_eq!(witness_bits, 1, "only `wit1` should keep witness bits"); + + let (prog_bytes, witness_bytes) = pruned.to_vec_with_witness(); + let decoded = RedeemNode::decode::<_, _, Core>( + BitIter::from(&prog_bytes[..]), + BitIter::from(&witness_bytes[..]), + ) + .expect("pruned program should decode, so its witness holds no unread bits"); + assert_eq!( + decoded.ihr(), + pruned.ihr(), + "a decoder should infer the same types as pruning did" + ); + + // libsimplicity is the consensus implementation; check that it accepts the + // pruned program. The jets are in the hidden branch, so it can decode the + // program although it expects Elements jet codes. + let c_result = run_program( + &prog_bytes, + &witness_bytes, + TestUpTo::Everything, + None, + None, + ) + .expect("libsimplicity should decode the pruned program"); + assert_eq!(c_result.eval_result, SimplicityErr::NoError); + } + } + #[test] #[cfg(all(feature = "elements", feature = "human_encoding"))] fn prune() { @@ -1102,6 +1181,94 @@ main := comp input comp process jet_verify : 1 -> 1"#; }); prune_and_check_encoding(&unpruned); } + + #[test] + fn prune_nodes_with_equal_ihrs_and_different_children() { + // `p1` and `p2` have one IHR, but their children differ in type, because `a1` + // is also used where its target type must be 1 + 2. Pruning must keep and + // build the same one of them. + use crate::node::CoreConstructible; + + type ArcNode<'brand> = Arc>; + + let unpruned = types::Context::with_context(|ctx| { + let unit = || ArcNode::unit(&ctx); + let iden = || ArcNode::iden(&ctx); + let a1 = ArcNode::injl(&unit()); + let p1 = ArcNode::comp(&a1, &unit()).unwrap(); + let a2 = ArcNode::injl(&unit()); + let p2 = ArcNode::comp(&a2, &unit()).unwrap(); + // A branch which never runs makes the target type of `a1` 1 + 2. + let verify = ArcNode::jet(&ctx, &Core::Verify); + let pins_a1 = ArcNode::comp( + &a1, + &ArcNode::comp( + &ArcNode::pair(&iden(), &iden()).unwrap(), + &ArcNode::case(&unit(), &ArcNode::take(&verify)).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + ArcNode::comp( + &ArcNode::pair(&ArcNode::pair(&p1, &p2).unwrap(), &pins_a1).unwrap(), + &unit(), + ) + .unwrap() + .finalize_unpruned() + .expect("program should finalize") + }); + prune_and_check_encoding(&unpruned); + } + + #[test] + fn prune_case_and_assertion_with_equal_ihrs() { + // A case node and an assertion with one IHR, in either order. Pruning must + // keep and build the same one of them. + use crate::node::CoreConstructible; + + type ArcNode<'brand> = Arc>; + + for case_first in [true, false] { + let unpruned = types::Context::with_context(|ctx| { + let unit = || ArcNode::unit(&ctx); + let input = ArcNode::pair(&ArcNode::injl(&unit()), &unit()).unwrap(); + let take_unit = ArcNode::take(&unit()); + let case = + ArcNode::comp(&input, &ArcNode::case(&unit(), &take_unit).unwrap()).unwrap(); + let assertl = + ArcNode::comp(&input, &ArcNode::assertl(&unit(), take_unit.cmr()).unwrap()) + .unwrap(); + let (first, second) = if case_first { + (case, assertl) + } else { + (assertl, case) + }; + ArcNode::comp(&ArcNode::pair(&first, &second).unwrap(), &unit()) + .unwrap() + .finalize_unpruned() + .expect("program should finalize") + }); + prune_and_check_encoding(&unpruned); + } + } + + #[test] + fn prune_expression_which_is_not_a_program() { + // Pruning keeps the type of an expression which is not 1 -> 1. + use crate::jet::CoreEnv; + use crate::node::CoreConstructible; + + let unpruned = types::Context::with_context(|ctx| { + Arc::::injl(&Arc::::unit(&ctx)) + .finalize_unpruned() + .expect("expression should finalize") + }); + let pruned = unpruned + .prune(&CoreEnv::new()) + .expect("pruning should succeed"); + assert_eq!(pruned.arrow().source, unpruned.arrow().source); + assert_eq!(pruned.arrow().target, unpruned.arrow().target); + } } #[cfg(bench)] From 9275561efa702c0edcae13a28181622b13dc5847 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 30 Oct 2026 11:38:23 +0200 Subject: [PATCH 4/5] node: check pruned programs as consensus does Pruning can produce a program which consensus rejects. Case nodes and assertions whose IHRs only become equal once pruned are a single node in the encoding, which then hides a branch that one of them uses, or keeps a branch that never runs. After pruning a program, encode it, decode it, run it, and check that every node and both branches of every case node ran, as libsimplicity does. If not, fail with the new ExecutionError::PruningFailed rather than return the program. This runs the program a second time. Assisted-by: Claude --- src/bit_machine/mod.rs | 8 ++++- src/node/prune.rs | 70 +++++++++++++++++++++++++++++++++++++++--- src/node/redeem.rs | 51 ++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 5 deletions(-) diff --git a/src/bit_machine/mod.rs b/src/bit_machine/mod.rs index d2f8751c..c0ae42e1 100644 --- a/src/bit_machine/mod.rs +++ b/src/bit_machine/mod.rs @@ -560,6 +560,8 @@ pub enum ExecutionError { JetFailed(JetFailed), /// Jet type mismatch between program and execution environment JetTypeMismatch, + /// Pruning could not produce a program which consensus accepts + PruningFailed, } impl fmt::Display for ExecutionError { @@ -579,6 +581,9 @@ impl fmt::Display for ExecutionError { ExecutionError::JetTypeMismatch => { f.write_str("Jet type mismatch between program and execution environment") } + ExecutionError::PruningFailed => { + f.write_str("Pruning could not produce a program which consensus accepts") + } } } } @@ -589,7 +594,8 @@ impl error::Error for ExecutionError { Self::InputWrongType(..) | Self::ReachedFailNode(..) | Self::ReachedPrunedBranch(..) - | Self::JetTypeMismatch => None, + | Self::JetTypeMismatch + | Self::PruningFailed => None, Self::LimitExceeded(ref e) => Some(e), Self::JetFailed(ref e) => Some(e), } diff --git a/src/node/prune.rs b/src/node/prune.rs index cd48a234..302bdfcb 100644 --- a/src/node/prune.rs +++ b/src/node/prune.rs @@ -4,14 +4,14 @@ //! //! See [`RedeemNode::prune`] for how a program is pruned. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crate::bit_machine::{ExecutionError, PruneTracker}; +use crate::bit_machine::{ExecTracker, ExecutionError, FrameIter, NodeOutput, PruneTracker}; use crate::dag::{DagLike, InternalSharing, MaxSharing, PostOrderIterItem}; use crate::jet::JetEnvironment; use crate::types; -use crate::{BitMachine, Ihr, Value}; +use crate::{BitIter, BitMachine, Ihr, Value}; use super::{ Construct, ConstructData, ConstructNode, Constructible, Converter, Hide, Inner, Node, Redeem, @@ -42,7 +42,17 @@ pub(super) fn prune( }); // 4) Rebuild the program from the kept nodes and prune out unused case branches. - Ok(rebuild(&program, &kept)) + let pruned = rebuild(&program, &kept); + + // 5) Check the encoding of the pruned program as consensus does (Consensus does + // not allow any node to be unused). + let arrow = pruned.arrow(); + // If the pruned program is not unit to unit, it is not a valid redeem program. + // This can happen through fuzzing or calling pruning on a non-valid program. + if arrow.source.is_unit() && arrow.target.is_unit() { + check_encoding(&pruned, env)?; + } + Ok(pruned) } fn reduce_to_max_sharing(program: &RedeemNode) -> Arc { @@ -256,3 +266,55 @@ impl<'brand> Converter, Redeem> for Finalizer { Ok(Arc::new(RedeemData::new(final_arrow, converted_inner))) } } + +/// Records which nodes ran, and which branches of each case node ran. +#[derive(Default)] +struct AntiDosTracker { + ran: HashSet, + left: HashSet, + right: HashSet, +} + +impl ExecTracker for AntiDosTracker { + fn visit_node(&mut self, node: &RedeemNode, mut input: FrameIter, _: NodeOutput) { + self.ran.insert(node.ihr()); + if let Inner::Case(..) = node.inner() { + match input.next() { + Some(false) => self.left.insert(node.ihr()), + Some(true) => self.right.insert(node.ihr()), + None => false, + }; + } + } +} + +/// Checks the encoding of a pruned program as consensus does: that it decodes, +/// runs, and runs every node and both branches of every case node. +fn check_encoding(pruned: &RedeemNode, env: &JE) -> Result<(), ExecutionError> { + let (program, witness) = pruned.to_vec_with_witness(); + let decoded = RedeemNode::decode::<_, _, JE::Jet>( + BitIter::from(&program[..]), + BitIter::from(&witness[..]), + ) + .map_err(|_| ExecutionError::PruningFailed)?; + // A decoder infers the types from the encoding, so different types + // would mean that the witness data is read differently. + if decoded.ihr() != pruned.ihr() { + return Err(ExecutionError::PruningFailed); + } + let mut tracker = AntiDosTracker::default(); + BitMachine::for_program(&decoded)? + .exec_with_tracker(&decoded, env, &mut tracker) + .map_err(|_| ExecutionError::PruningFailed)?; + for data in decoded.as_ref().post_order_iter::() { + let ihr = data.node.ihr(); + let ran = match data.node.inner() { + Inner::Case(..) => tracker.left.contains(&ihr) && tracker.right.contains(&ihr), + _ => tracker.ran.contains(&ihr), + }; + if !ran { + return Err(ExecutionError::PruningFailed); + } + } + Ok(()) +} diff --git a/src/node/redeem.rs b/src/node/redeem.rs index 35708d8e..1607ad1a 100644 --- a/src/node/redeem.rs +++ b/src/node/redeem.rs @@ -272,6 +272,9 @@ impl RedeemNode { /// 3) Mark all (un)used case branches using the IHR of the case node. /// 4) Rebuild the program from the nodes reachable through used branches, /// and omit unused branches. + /// 5) Check the encoding of the pruned program as consensus does: that it decodes, runs, + /// and runs every node and both branches of every case node. This runs the program + /// again. Expressions which are not programs (1 → 1) are not checked. /// /// The pruning result depends on the witness data (which is already part of the redeem program) /// and on the transaction environment. These two inputs determine which case branches are @@ -283,6 +286,8 @@ impl RedeemNode { /// Machine (step 2). In this case, the witness data needs to be revised, unless merging /// changed what the program does: nodes with equal IHRs may hide different branches, and /// no encoding can express such a program. + /// Pruning fails with [`ExecutionError::PruningFailed`] if the pruned program does not pass + /// the check in step 5. /// The other pruning steps never fail. pub fn prune(&self, env: &JE) -> Result, ExecutionError> { self.prune_with_tracker(env, &mut SetTracker::default()) @@ -1252,6 +1257,52 @@ main := comp input comp process jet_verify : 1 -> 1"#; } } + #[test] + fn prune_fails_rather_than_return_a_rejected_program() { + // A tracker which says that no right branch was taken makes pruning hide the + // right branch of a case node which takes it. Pruning must fail rather than + // return a program which consensus rejects. + use crate::bit_machine::{ExecTracker, FrameIter, NodeOutput}; + use crate::jet::CoreEnv; + use crate::node::CoreConstructible; + + struct NoRightBranches(SetTracker); + + impl ExecTracker for NoRightBranches { + fn visit_node(&mut self, node: &RedeemNode, input: FrameIter, output: NodeOutput) { + self.0.visit_node(node, input, output) + } + } + + impl PruneTracker for NoRightBranches { + fn contains_left(&self, _: Ihr) -> bool { + true + } + + fn contains_right(&self, _: Ihr) -> bool { + false + } + } + + type ArcNode<'brand> = Arc>; + + let unpruned = types::Context::with_context(|ctx| { + let unit = || ArcNode::unit(&ctx); + ArcNode::comp( + &ArcNode::pair(&ArcNode::injr(&unit()), &unit()).unwrap(), + &ArcNode::case(&unit(), &unit()).unwrap(), + ) + .unwrap() + .finalize_unpruned() + .expect("program should finalize") + }); + let mut tracker = NoRightBranches(SetTracker::default()); + assert!(matches!( + unpruned.prune_with_tracker(&CoreEnv::new(), &mut tracker), + Err(ExecutionError::PruningFailed) + )); + } + #[test] fn prune_expression_which_is_not_a_program() { // Pruning keeps the type of an expression which is not 1 -> 1. From 21878a590db87d0449286fb418b4f1b309e25ed4 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 30 Oct 2026 15:46:48 +0200 Subject: [PATCH 5/5] changelog: add the pruning fixes Assisted-by: Claude --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 197b6fb2..94e69c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +* Fix `RedeemNode::prune` producing programs that consensus rejects, and add `ExecutionError::PruningFailed` [#391](https://github.com/BlockstreamResearch/rust-simplicity/issues/391). + # 0.9.0 - 2026-09-16 * Update `elements` to `0.27.0`, `bitcoin_hashes` to `1.2.0`, and `hex-conservative` to `1.2.0` [#368](https://github.com/BlockstreamResearch/rust-simplicity/pull/368), [#373](https://github.com/BlockstreamResearch/rust-simplicity/pull/373), [#376](https://github.com/BlockstreamResearch/rust-simplicity/pull/376).