Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ dependencies = [
name = "chia-consensus"
version = "0.46.0"
dependencies = [
"arbitrary",
"bitflags",
"blocking-threadpool",
"chia-bls 0.46.0",
Expand All @@ -394,6 +395,7 @@ dependencies = [
"chia-traits 0.46.0",
"chia_py_streamable_macro",
"chia_streamable_macro 0.46.0",
"clvm-fuzzing",
"clvm-traits",
"clvm-utils",
"clvmr",
Expand Down
2 changes: 2 additions & 0 deletions crates/chia-consensus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ text-diff = { workspace = true }
criterion = { workspace = true }
rand = { workspace = true, features = [ "small_rng" ] }
blocking-threadpool = { workspace = true }
clvm-fuzzing = { workspace = true }
arbitrary = { workspace = true }

[lib]
bench = false
Expand Down
84 changes: 84 additions & 0 deletions crates/chia-consensus/examples/gen_serde_2026_fuzz_seeds.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//! Generate seed inputs for the `serde-2026-size-bound` fuzz target.
//!
//! The target checks that serde_2026 encodings stay within the proven size
//! bound (see `chia_consensus::serde_2026::max_canonical_blob_size`). The
//! bound is only *tight* for specific tree shapes, which coverage-guided
//! fuzzing rarely reaches on its own. This example searches random inputs
//! (through the exact decode path the fuzz target uses) for trees whose
//! encoding comes closest to the bound, and writes the best as seeds, so
//! fuzz runs mutate around the boundary instead of wandering toward it.
//!
//! Deterministic per rand version: fixed RNG seed, so regenerated seeds are
//! reproducible (seed corpora are regenerable scratch, so drift across rand
//! releases is fine).
//!
//! ```sh
//! cargo run --release --example gen_serde_2026_fuzz_seeds -- \
//! fuzz/corpus/serde-2026-size-bound
//! cargo fuzz run serde-2026-size-bound
//! ```

use chia_consensus::generator_cost::interned_vbytes;
use chia_consensus::serde_2026::SERDE_2026_COMPRESSION_LEVEL;
use clvm_fuzzing::make_tree;
use clvmr::Allocator;
use clvmr::serde::{SERDE_2026_MAGIC_PREFIX, intern_tree, serialize_2026};
use rand::rngs::SmallRng;
use rand::{Rng, RngCore, SeedableRng};
use std::fs;

/// Interpret `data` exactly as the fuzz target does and return
/// (blob_size / bound, blob_size).
fn fullness(data: &[u8]) -> Option<(f64, usize)> {
let mut u = arbitrary::Unstructured::new(data);
let _max_cost: u64 = u.arbitrary().ok()?;
let _cost_per_byte: u64 = u.arbitrary().ok()?;
let mut a = Allocator::new();
let (node, _) = make_tree(&mut a, &mut u);
let blob = serialize_2026(&a, node, SERDE_2026_COMPRESSION_LEVEL).ok()?;
let tree = intern_tree(&a, node).ok()?;
let bound = interned_vbytes(&tree) as usize + 5 + SERDE_2026_MAGIC_PREFIX.len();
Some((blob.len() as f64 / bound as f64, blob.len()))
}

fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "fuzz/corpus/serde-2026-size-bound".to_string());
fs::create_dir_all(&out).unwrap();

let mut rng = SmallRng::seed_from_u64(0x9e37_79b9_7f4a_7c15);
let mut best: Vec<(f64, usize, Vec<u8>)> = Vec::new();

for round in 0..400_000u64 {
let len = 16 + (rng.next_u64() % 3000) as usize;
let mut data = vec![0u8; len];
rng.fill_bytes(&mut data);
// Bias some inputs toward long constant runs, which favors large
// atoms and deep spines over noise.
if round % 3 == 0 {
let run_byte = rng.random::<u8>();
let start = 16 + (rng.next_u64() as usize % (len - 16).max(1)).min(len - 16);
for b in &mut data[start..] {
*b = run_byte;
}
}
if let Some((score, size)) = fullness(&data) {
best.push((score, size, data));
best.sort_by(|x, y| y.0.total_cmp(&x.0));
best.truncate(200);
}
}

// Keep the fullest inputs across several blob-size buckets, so seeds
// aren't all tiny.
let mut count = 0;
for (lo, hi) in [(0, 100), (100, 1000), (1000, 10_000), (10_000, usize::MAX)] {
for (score, size, data) in best.iter().filter(|e| e.1 >= lo && e.1 < hi).take(3) {
let path = format!("{out}/near-bound-{count:02}");
fs::write(&path, data).unwrap();
println!("{path}: fullness {score:.4}, blob {size} bytes");
count += 1;
}
}
}
7 changes: 7 additions & 0 deletions crates/chia-consensus/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,10 @@ path = "fuzz_targets/puzzle-fingerprint.rs"
test = false
doc = false
bench = false

[[bin]]
name = "serde-2026-size-bound"
path = "fuzz_targets/serde-2026-size-bound.rs"
test = false
doc = false
bench = false
71 changes: 71 additions & 0 deletions crates/chia-consensus/fuzz/fuzz_targets/serde-2026-size-bound.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#![no_main]
use libfuzzer_sys::{arbitrary, fuzz_target};

use chia_consensus::generator_cost::interned_vbytes;
use chia_consensus::serde_2026::{
SERDE_2026_COMPRESSION_LEVEL, max_canonical_blob_size, node_from_bytes_auto,
};
use clvm_fuzzing::make_tree;
use clvmr::Allocator;
use clvmr::serde::{SERDE_2026_MAGIC_PREFIX, intern_tree, serialize_2026};

// Empirically checks the theorem behind `max_canonical_blob_size` (see the
// proof on that function; this target hunts for counterexamples):
//
// 1. Encoding bound: for ANY CLVM tree, the canonical serde_2026 wire
// encoding is at most interned_vbytes(tree) + 5 bytes plus the magic
// prefix.
// 2. Corollary, at fuzzed cost constants: if the tree is affordable under
// (max_cost, cost_per_byte), its blob fits in
// max_canonical_blob_size(max_cost, cost_per_byte).
//
// Also verifies the blob round-trips to the same tree.
//
// The bound is only tight for specific tree shapes; seed the corpus with
// near-bound inputs first (see the gen_serde_2026_fuzz_seeds example in
// chia-consensus) so mutation starts at the boundary.
fuzz_target!(|data: &[u8]| {
let mut unstructured = arbitrary::Unstructured::new(data);
let max_cost: u64 = unstructured.arbitrary().unwrap_or(11_000_000_000);
let cost_per_byte: u64 = unstructured.arbitrary().unwrap_or(12_000);

let mut a = Allocator::new();
let (node, _) = make_tree(&mut a, &mut unstructured);

let blob = serialize_2026(&a, node, SERDE_2026_COMPRESSION_LEVEL).expect("serialize_2026");

let tree = intern_tree(&a, node).expect("intern_tree");
let vbytes = interned_vbytes(&tree);
let bound = vbytes as usize + 5 + SERDE_2026_MAGIC_PREFIX.len();
assert!(
blob.len() <= bound,
"size bound violated: blob {} > interned_vbytes-derived bound {}",
blob.len(),
bound
);

// Corollary at arbitrary cost constants: any tree affordable under
// (max_cost, cost_per_byte) must encode within the derived cap.
if vbytes
.checked_mul(cost_per_byte)
.is_some_and(|c| c <= max_cost)
{
let cap = max_canonical_blob_size(max_cost, cost_per_byte);
assert!(
blob.len() <= cap,
"cap violated: blob {} > max_canonical_blob_size({max_cost}, {cost_per_byte}) = {cap}",
blob.len(),
);
}

// Round-trip check via canonical re-serialization: serialize_2026 is
// deterministic and DAG-aware, so equal trees produce equal blobs.
// (Comparing classic encodings instead would blow up on trees whose
// classic expansion is huge — compressing those is the format's point.)
// Passing `bound` as the size cap doubles as a check that the gate
// admits every canonical blob.
let mut b = Allocator::new();
let parsed = node_from_bytes_auto(&mut b, &blob, bound).expect("node_from_bytes_auto");
let blob2 = serialize_2026(&b, parsed, SERDE_2026_COMPRESSION_LEVEL).expect("serialize_2026");
assert_eq!(blob, blob2, "round-trip mismatch");
});
1 change: 1 addition & 0 deletions crates/chia-consensus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod owned_conditions;
pub mod puzzle_fingerprint;
pub mod run_block_generator;
pub mod sanitize_int;
pub mod serde_2026;
pub mod solution_generator;
pub mod spend_visitor;
pub mod spendbundle_conditions;
Expand Down
Loading
Loading