From 9d6673be471e8442e4ba7616329f6aa9c5d00b3a Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 6 Nov 2026 13:47:49 +0200 Subject: [PATCH 1/4] analysis: saturate cell bounds of comp and disconnect Type bit widths saturate at usize::MAX, but NodeBounds::comp and NodeBounds::disconnect added them to their children's cell bounds with plain addition. A comp whose middle type is that wide, and whose children need any extra cells, overflows: debug builds panic while computing the bounds, and release builds wrap to a small bound which passes the Bit Machine's limit check. Executing the program then panics with an index out of bounds. Saturate these sums, as libsimplicity does, so such a program fails BitMachine::for_program with LimitError::MaxCellsExceeded. Assisted-by: Claude --- src/analysis.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/analysis.rs b/src/analysis.rs index fe5fed84..92ed614b 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -306,7 +306,9 @@ impl NodeBounds { /// Node bounds for a `comp` node pub fn comp(left: Self, right: Self, mid_ty_bit_width: usize) -> NodeBounds { NodeBounds { - extra_cells: mid_ty_bit_width + cmp::max(left.extra_cells, right.extra_cells), + // Type widths saturate at usize::MAX, so the sums here must too. + extra_cells: mid_ty_bit_width + .saturating_add(cmp::max(left.extra_cells, right.extra_cells)), extra_frames: 1 + cmp::max(left.extra_frames, right.extra_frames), cost: Cost::OVERHEAD + Cost::of_type(mid_ty_bit_width) + left.cost + right.cost, } @@ -351,8 +353,8 @@ impl NodeBounds { ) -> NodeBounds { NodeBounds { extra_cells: left_source_bit_width - + left_target_bit_width - + cmp::max(left.extra_cells, right.extra_cells), + .saturating_add(left_target_bit_width) + .saturating_add(cmp::max(left.extra_cells, right.extra_cells)), extra_frames: 2 + cmp::max(left.extra_frames, right.extra_frames), cost: Cost::OVERHEAD + Cost::of_type(left_source_bit_width) From e8c8a359461bce005df2b5ba3f3b4de66e3b8048 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 6 Nov 2026 13:48:22 +0200 Subject: [PATCH 2/4] simplicity-sys: export CELLS_MAX and DAG_LEN_MAX Mirror libsimplicity's consensus limits on cells and program length next to BUDGET_MAX, so the Rust Bit Machine can enforce them. Export all three from wrapper.c and test that the Rust values match the vendored header, so a libsimplicity update which changes a limit fails the tests. Assisted-by: Claude --- simplicity-sys/depend/wrapper.c | 5 +++++ simplicity-sys/src/ffi.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/simplicity-sys/depend/wrapper.c b/simplicity-sys/depend/wrapper.c index 72122ef5..04875632 100644 --- a/simplicity-sys/depend/wrapper.c +++ b/simplicity-sys/depend/wrapper.c @@ -7,11 +7,16 @@ #include "simplicity/type.h" #include "simplicity/bounded.h" #include "simplicity/frame.h" +#include "simplicity/limitations.h" #include const uint64_t c_overhead = overhead; +const ubounded c_budget_max = BUDGET_MAX; +const ubounded c_cells_max = CELLS_MAX; +const size_t c_dag_len_max = DAG_LEN_MAX; + const size_t c_sizeof_uchar = sizeof(unsigned char); const size_t c_sizeof_int = sizeof(int); const size_t c_sizeof_uint = sizeof(unsigned int); diff --git a/simplicity-sys/src/ffi.rs b/simplicity-sys/src/ffi.rs index 06216c46..a272bdee 100644 --- a/simplicity-sys/src/ffi.rs +++ b/simplicity-sys/src/ffi.rs @@ -79,6 +79,11 @@ extern "C" { pub type ubounded = c_uint_least32_t; /// Used with `evalTCOProgram` to enforce consensus limits. pub const BUDGET_MAX: ubounded = 4000050; +/// The maximum number of cells (bits) that consensus allows the Bit Machine +/// to need when evaluating a program, as bounded by `analyseBounds`. +pub const CELLS_MAX: ubounded = 0x500000; +/// The maximum number of nodes in a program accepted by consensus. +pub const DAG_LEN_MAX: c_size_t = 8000000; /// The max value of UBOUNDED_MAX pub const UBOUNDED_MAX: ubounded = ubounded::MAX; @@ -87,6 +92,12 @@ extern "C" { pub static c_alignof_ubounded: c_size_t; } +extern "C" { + pub static c_budget_max: ubounded; + pub static c_cells_max: ubounded; + pub static c_dag_len_max: c_size_t; +} + pub mod bounded { use super::ubounded; extern "C" { @@ -167,6 +178,15 @@ mod tests { } } + #[test] + fn test_limits() { + unsafe { + assert_eq!(BUDGET_MAX, c_budget_max); + assert_eq!(CELLS_MAX, c_cells_max); + assert_eq!(DAG_LEN_MAX, c_dag_len_max); + } + } + #[test] #[rustfmt::skip] fn test_aligns() { From 594d28e883d18a2dbc64f4616ff2f26a41421235 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 6 Nov 2026 13:49:39 +0200 Subject: [PATCH 3/4] enforce consensus limits on cells, frames and program length The Bit Machine accepted programs needing up to 2^31 - 1 cells, about 400 times libsimplicity's consensus limit of CELLS_MAX (5 Mib), so it would allocate up to 256 MiB for a program that consensus rejects. Limit cells to CELLS_MAX instead. The two bounds are not computed the same way. libsimplicity bounds the cells needed with tail call optimization, which this Bit Machine lacks, so our bound is never smaller and is sometimes much larger: a chain of comps keeps every middle frame alive here, but only two at a time there. So we still reject some programs which consensus accepts, but no longer accept any which it rejects for using too many cells. To check that our bound is never smaller, return libsimplicity's cell and frame bounds from simplicity-sys's run_program, and compare ours with them for the test programs and in the c_rust_merkle fuzz target. libsimplicity rejects a program whose encoded length exceeds DAG_LEN_MAX nodes before decoding any of them, but we decoded programs of any length. Fail such programs with the new decode::Error::TooManyNodes. libsimplicity has no limit on frames. Both Bit Machines need at most two frames per node, and programs now have at most DAG_LEN_MAX nodes, so set the frame limit to twice that. It was 2^20, which rejected long programs that consensus accepts. Also report MAX_FRAMES rather than MAX_CELLS as the maximum in LimitError::MaxFramesExceeded. Assisted-by: Claude --- fuzz/fuzz_targets/c_rust_merkle.rs | 13 +++++++++ simplicity-sys/src/tests/mod.rs | 8 +++++ src/bit_encoding/decode.rs | 47 ++++++++++++++++++++++++++++++ src/bit_machine/limits.rs | 41 +++++++++++++++++++------- src/node/mod.rs | 14 +++++++-- 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/fuzz/fuzz_targets/c_rust_merkle.rs b/fuzz/fuzz_targets/c_rust_merkle.rs index 6283e910..56cc0376 100644 --- a/fuzz/fuzz_targets/c_rust_merkle.rs +++ b/fuzz/fuzz_targets/c_rust_merkle.rs @@ -65,6 +65,19 @@ fn do_test(data: &[u8]) { &Midstate::from(c_data.ihr).to_parts().0, rust_data.ihr().as_ref(), ); + + // Our Bit Machine lacks tail call optimization, so it needs at least as + // many cells and frames as libsimplicity's. Both count two frames for + // the input and output. + let arrow = rust_data.arrow(); + let bounds = rust_data.bounds(); + let cells = arrow + .source + .bit_width() + .saturating_add(arrow.target.bit_width()) + .saturating_add(bounds.extra_cells); + assert!(cells >= c_data.cell_bound as usize); + assert!(bounds.extra_frames + 2 >= c_data.frame_bound as usize); } } } diff --git a/simplicity-sys/src/tests/mod.rs b/simplicity-sys/src/tests/mod.rs index e5526218..241554d9 100644 --- a/simplicity-sys/src/tests/mod.rs +++ b/simplicity-sys/src/tests/mod.rs @@ -31,6 +31,10 @@ pub struct TestOutput { pub ihr: CSha256Midstate, /// The cost bound of the program pub cost_bound: ubounded, + /// The bound on the cells needed to evaluate the program + pub cell_bound: ubounded, + /// The bound on the frames needed to evaluate the program + pub frame_bound: ubounded, /// Whether or not evaluation succeded pub eval_result: SimplicityErr, } @@ -127,6 +131,8 @@ pub fn run_program( cmr: CSha256Midstate::default(), ihr: CSha256Midstate::default(), cost_bound: 0, + cell_bound: 0, + frame_bound: 0, eval_result: SimplicityErr::NoError, }; @@ -211,6 +217,8 @@ pub fn run_program( ) .into_result()?; result.cost_bound = cost_bound; + result.cell_bound = cell_bound; + result.frame_bound = frame_bound; if test_up_to <= TestUpTo::ComputeCostUnbounded { return Ok(result); } diff --git a/src/bit_encoding/decode.rs b/src/bit_encoding/decode.rs index 2d57c085..716d4f53 100644 --- a/src/bit_encoding/decode.rs +++ b/src/bit_encoding/decode.rs @@ -14,6 +14,7 @@ use crate::node::{ use crate::types; use crate::value::Word; use crate::{BitIter, FailEntropy}; +use simplicity_sys::ffi::DAG_LEN_MAX; use std::collections::HashSet; use std::sync::Arc; use std::{cmp, error, fmt}; @@ -42,6 +43,13 @@ pub enum Error { NotInCanonicalOrder, /// Program does not have maximal sharing SharingNotMaximal, + /// Program has more nodes than consensus allows + TooManyNodes { + /// The number of nodes in the program. + got: usize, + /// The maximum allowed number of nodes. + max: usize, + }, /// Type-checking error Type(crate::types::Error), } @@ -81,6 +89,13 @@ impl fmt::Display for Error { Error::Natural(ref e) => e.fmt(f), Error::NotInCanonicalOrder => f.write_str("program not in canonical order"), Error::SharingNotMaximal => f.write_str("Decoded programs must have maximal sharing"), + Error::TooManyNodes { got, max } => { + write!( + f, + "program has {} nodes, more than the maximum {}", + got, max + ) + } Error::Type(ref e) => fmt::Display::fmt(e, f), } } @@ -97,6 +112,7 @@ impl error::Error for Error { Error::Natural(ref e) => Some(e), Error::NotInCanonicalOrder => None, Error::SharingNotMaximal => None, + Error::TooManyNodes { .. } => None, Error::Type(ref e) => Some(e), } } @@ -172,6 +188,12 @@ pub fn decode_expression<'brand, I: Iterator, J: Jet>( let len = bits.read_natural::(None)?; assert_ne!(len, 0, "impossible to encode 0 in Simplicity"); + if len > DAG_LEN_MAX { + return Err(Error::TooManyNodes { + got: len, + max: DAG_LEN_MAX, + }); + } let mut nodes = Vec::with_capacity(cmp::min(len, 10_000)); for _ in 0..len { @@ -326,6 +348,31 @@ mod tests { RedeemNode::decode::<_, _, Core>(iter, BitIter::from(&[][..])).unwrap_err(); } + #[test] + fn too_many_nodes() { + for (len, too_many) in [(DAG_LEN_MAX, false), (DAG_LEN_MAX + 1, true)] { + let mut prog = Vec::::new(); + let mut w = BitWriter::new(&mut prog); + encode::encode_natural(len, &mut w).unwrap(); + w.flush_all().unwrap(); + + types::Context::with_context(|ctx| { + let mut iter = BitIter::from(&prog[..]); + let err = decode_expression::<_, Core>(&ctx, &mut iter) + .expect_err("there are no nodes after the length"); + match err { + Error::TooManyNodes { got, max } => { + assert!(too_many, "{} nodes should be allowed", len); + assert_eq!(got, len); + assert_eq!(max, DAG_LEN_MAX); + } + // An allowed length fails later, when decoding the nodes. + _ => assert!(!too_many, "{} nodes should be too many: {}", len, err), + } + }); + } + } + #[test] fn decode_fixed_natural() { let tries: Vec<(usize, usize, &[u8])> = vec![ diff --git a/src/bit_machine/limits.rs b/src/bit_machine/limits.rs index ed23324d..4adb6c4c 100644 --- a/src/bit_machine/limits.rs +++ b/src/bit_machine/limits.rs @@ -12,24 +12,27 @@ use std::error; /// The maximum number of cells needed by the bit machine. /// /// This roughly corresponds to the maximum size of any type used by a -/// program, in bits. In a blockchain context this should be limited by -/// the transaction's weight budget. +/// program, in bits. /// -/// The limit here is an absolute limit enforced by the library to avoid -/// unbounded allocations. +/// This is libsimplicity's consensus limit. libsimplicity bounds the cells +/// needed by a Bit Machine with tail call optimization, which this one lacks, +/// so this bound on a program is never smaller than libsimplicity's and is +/// sometimes larger. Every program which consensus rejects for needing too +/// many cells is rejected here, and so are some which consensus accepts. // This value must be less than usize::MAX / 2 to avoid panics in this module. -const MAX_CELLS: usize = 2 * 1024 * 1024 * 1024 - 1; +const MAX_CELLS: usize = simplicity_sys::ffi::CELLS_MAX as usize; /// The maximum number of frames needed by the bit machine. /// /// This roughly corresponds to the maximum depth of nested `comp` and -/// `disconnect` combinators. In a blockchain context this should be -/// limited by the transaction's weight budget. +/// `disconnect` combinators. /// -/// The limit here is an absolute limit enforced by the library to avoid -/// unbounded allocations. +/// libsimplicity has no limit on frames of its own. Programs need at most +/// two frames per node, here and in libsimplicity, and consensus limits +/// programs to `DAG_LEN_MAX` nodes, so this only rejects programs which are +/// too long for consensus anyway. // This value must be less than usize::MAX / 2 to avoid panics in this module. -const MAX_FRAMES: usize = 1024 * 1024; +const MAX_FRAMES: usize = 2 * simplicity_sys::ffi::DAG_LEN_MAX; #[non_exhaustive] #[derive(Clone, Debug)] @@ -84,7 +87,7 @@ impl LimitError { if got > MAX_FRAMES { Err(Self::MaxFramesExceeded { got, - max: MAX_CELLS, + max: MAX_FRAMES, bound, }) } else { @@ -118,3 +121,19 @@ impl LimitError { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_error_reports_frame_limit() { + match LimitError::check_max_frames(MAX_FRAMES + 1, "test") { + Err(LimitError::MaxFramesExceeded { got, max, .. }) => { + assert_eq!(got, MAX_FRAMES + 1); + assert_eq!(max, MAX_FRAMES); + } + res => panic!("expected MaxFramesExceeded, got {:?}", res), + } + } +} diff --git a/src/node/mod.rs b/src/node/mod.rs index 99afe1e1..a0251433 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -837,7 +837,7 @@ impl Drop for Node { mod tests { use ffi::tests::TestData; - use crate::analysis::Cost; + use crate::analysis::{Cost, IO_EXTRA_FRAMES}; use crate::ffi; use crate::jet::Elements; use crate::BitIter; @@ -846,7 +846,7 @@ mod tests { fn check_merkle_roots(test: &TestData) { let prog = BitIter::from(test.prog.as_slice()); let witness = BitIter::from(test.witness.as_slice()); - ffi::tests::run_program( + let c_result = ffi::tests::run_program( &test.prog, &test.witness, ffi::tests::TestUpTo::CheckOneOne, @@ -858,7 +858,15 @@ mod tests { assert_eq!(prog.cmr().to_byte_array(), test.cmr); assert_eq!(prog.amr().to_byte_array(), test.amr); assert_eq!(prog.ihr().to_byte_array(), test.ihr); - assert_eq!(prog.bounds().cost, Cost::from_milliweight(test.cost)) + assert_eq!(prog.bounds().cost, Cost::from_milliweight(test.cost)); + + // Our Bit Machine lacks tail call optimization, so it needs at least as + // many cells and frames as libsimplicity's. + let io_cells = prog.arrow().source.bit_width() + prog.arrow().target.bit_width(); + let cells = io_cells + prog.bounds().extra_cells; + let frames = prog.bounds().extra_frames + IO_EXTRA_FRAMES; + assert!(cells >= c_result.cell_bound as usize); + assert!(frames >= c_result.frame_bound as usize); } #[test] From 5637148bb20aa1c772adb9557aa28d657557bccb Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 6 Nov 2026 13:56:43 +0200 Subject: [PATCH 4/4] changelog: add the consensus limits Assisted-by: Claude --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94e69c6b..3d36a031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased * Fix `RedeemNode::prune` producing programs that consensus rejects, and add `ExecutionError::PruningFailed` [#391](https://github.com/BlockstreamResearch/rust-simplicity/issues/391). +* Enforce libsimplicity's limits on cells and program length: `BitMachine::for_program` rejects programs needing more than `CELLS_MAX` cells, and decoding fails with the new `decode::Error::TooManyNodes` beyond `DAG_LEN_MAX` nodes. Export both constants from `simplicity-sys`. # 0.9.0 - 2026-09-16