diff --git a/Cargo.lock b/Cargo.lock index c7e110135..745195eb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,6 +384,7 @@ dependencies = [ name = "chia-consensus" version = "0.46.0" dependencies = [ + "arbitrary", "bitflags", "blocking-threadpool", "chia-bls 0.46.0", @@ -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", diff --git a/crates/chia-consensus/Cargo.toml b/crates/chia-consensus/Cargo.toml index c278047ea..e8e82b4c1 100644 --- a/crates/chia-consensus/Cargo.toml +++ b/crates/chia-consensus/Cargo.toml @@ -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 diff --git a/crates/chia-consensus/examples/gen_serde_2026_fuzz_seeds.rs b/crates/chia-consensus/examples/gen_serde_2026_fuzz_seeds.rs new file mode 100644 index 000000000..9b6a8e5c2 --- /dev/null +++ b/crates/chia-consensus/examples/gen_serde_2026_fuzz_seeds.rs @@ -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)> = 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::(); + 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; + } + } +} diff --git a/crates/chia-consensus/fuzz/Cargo.toml b/crates/chia-consensus/fuzz/Cargo.toml index d07fb5787..ed68eb60c 100644 --- a/crates/chia-consensus/fuzz/Cargo.toml +++ b/crates/chia-consensus/fuzz/Cargo.toml @@ -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 diff --git a/crates/chia-consensus/fuzz/fuzz_targets/serde-2026-size-bound.rs b/crates/chia-consensus/fuzz/fuzz_targets/serde-2026-size-bound.rs new file mode 100644 index 000000000..e2d7c4230 --- /dev/null +++ b/crates/chia-consensus/fuzz/fuzz_targets/serde-2026-size-bound.rs @@ -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"); +}); diff --git a/crates/chia-consensus/src/lib.rs b/crates/chia-consensus/src/lib.rs index 044234211..9a87bd40f 100644 --- a/crates/chia-consensus/src/lib.rs +++ b/crates/chia-consensus/src/lib.rs @@ -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; diff --git a/crates/chia-consensus/src/serde_2026.rs b/crates/chia-consensus/src/serde_2026.rs new file mode 100644 index 000000000..fc547cb79 --- /dev/null +++ b/crates/chia-consensus/src/serde_2026.rs @@ -0,0 +1,315 @@ +//! Consensus-tuned wrappers around the `clvm_rs::serde_2026` deserializer. +//! +//! `clvm_rs` deliberately makes the caller pick `max_atom_len` and `strict`, +//! since those are policy and clvm_rs has no consensus opinion. This module +//! supplies the values chia consensus expects and exposes the +//! "sniff the magic prefix and dispatch" convenience that callers used to get +//! from `clvm_rs::serde::node_from_bytes_auto`. + +use clvmr::allocator::{Allocator, NodePtr}; +use clvmr::error::{EvalErr, Result}; +use clvmr::serde::{SERDE_2026_MAGIC_PREFIX, deserialize_2026, node_from_bytes_backrefs}; + +/// Compression level passed to [`clvmr::serde::serialize_2026`] when chia +/// produces serde_2026 blobs. +/// +/// The level only affects the serializer's effort/output size; every level +/// produces blobs that the one deserializer accepts (like zlib levels). +/// clvmr keeps its `Compression` enum private and takes a bare `u32`, +/// saturating values above the highest implemented level. Level 0 is the +/// fast/left-first encoding (currently the only one implemented). +pub const SERDE_2026_COMPRESSION_LEVEL: u32 = 0; + +/// Maximum serde_2026 wire size, in bytes, of any generator whose cost fits +/// within `max_cost` at `cost_per_byte` — i.e. every generator that could +/// possibly be valid under those constants has a canonical encoding no +/// larger than this. A blob above this size is either over-cost or +/// non-minimally encoded (and its sender could re-encode it smaller). +/// +/// # Why such a bound matters +/// +/// Under the interned cost model, cost is charged on the *deduplicated* +/// tree, but the decoder must process every *wire* byte before any cost is +/// charged. Cost alone therefore does not bound pre-charge decoding work; a +/// size cap derived from this function does. +/// +/// # Derivation +/// +/// Let a tree have `atom_bytes` total atom payload, `U_a` unique atoms and +/// `U_p` unique pairs, so its interned weight (see +/// [`generator_cost::interned_vbytes`](crate::generator_cost::interned_vbytes)) +/// is `vbytes = atom_bytes + 2*U_a + 3*U_p`. +/// +/// **Step 1 — encoding bound:** the canonical serde_2026 *body* is at most +/// `vbytes + 5` bytes. Sketch: the atom table costs at most 2 bytes of +/// overhead per atom (length varint, amortized group headers) plus a group +/// count header; the instruction stream is exactly `2*U_p + 1` instructions +/// (each push adds one stack entry, each cons nets -1, one root remains), +/// costing 1 byte per cons and at most 2 bytes per push, plus a count +/// header; the headers and per-item slack together never exceed the `+5` +/// because `U_a <= U_p + 1` forces cheap 1-byte pushes to exist whenever the +/// headers grow. The bound is tight (slack reaches 0 at atom length 2^20) +/// and requires atom lengths < 2^27 — enforced by the per-atom cap in +/// [`node_from_bytes_auto`] whenever the derived blob cap is below 2^27 +/// (at mainnet constants it is ~0.9 MB). The wire blob adds the +/// [`SERDE_2026_MAGIC_PREFIX`] on top of the body. +/// +/// **Step 2 — cost bound:** consensus charges `vbytes * cost_per_byte`, and +/// rejects anything over `max_cost`, so any potentially-valid generator has +/// `vbytes <= max_cost / cost_per_byte`. +/// +/// Combining: `wire_size <= max_cost / cost_per_byte + 5 + prefix_len`. +/// +/// If `cost_per_byte` is 0, bytes are free and no size is over-cost, so the +/// bound degenerates to `usize::MAX` (unbounded). +/// +/// At mainnet constants (`max_cost` = 11e9, `cost_per_byte` = 12_000) this +/// is 916_677 bytes, a little under 1 MiB. +pub fn max_canonical_blob_size(max_cost: u64, cost_per_byte: u64) -> usize { + if cost_per_byte == 0 { + // Free bytes: no blob size exhausts the budget, so the least upper + // bound is "unbounded". + return usize::MAX; + } + // Saturating: a clamped result is still a correct upper bound, and this + // keeps the function total for extreme (non-mainnet) constants. + ((max_cost / cost_per_byte) as usize).saturating_add(5 + SERDE_2026_MAGIC_PREFIX.len()) +} + +/// Deserialize CLVM bytes, auto-detecting classic / backrefs / serde_2026. +/// +/// Sniffs `SERDE_2026_MAGIC_PREFIX` at the head of `bytes`; if present, +/// dispatches to [`deserialize_2026`]. Otherwise falls back to +/// [`node_from_bytes_backrefs`] (which also accepts plain classic). +/// +/// `max_blob_size` bounds the total wire size accepted; blobs above it are +/// rejected before any parsing. Callers should derive it from the network's +/// cost constants via [`max_canonical_blob_size`] (any headroom multiplier +/// on top — e.g. to tolerate non-minimal encodings, which `strict = false` +/// otherwise admits — is caller policy). +/// +/// The same value doubles as the per-atom cap: atoms appear as literals in +/// the canonical serialization, so an atom of length `L` forces a canonical +/// blob of at least `L` bytes — no atom of a cost-valid generator can ever +/// exceed the blob bound. There is deliberately no separate atom-length +/// constant. +pub fn node_from_bytes_auto( + allocator: &mut Allocator, + bytes: &[u8], + max_blob_size: usize, +) -> Result { + if bytes.len() > max_blob_size { + return Err(EvalErr::SerializationError); + } + if bytes.starts_with(&SERDE_2026_MAGIC_PREFIX) { + // strict = false is deliberate. Post-HF2 the generator's identity and + // cost come from the interned tree, not its byte encoding, so overlong + // (non-minimal) varints don't affect consensus — they only bloat the + // blob of whoever produced it. We accept such blobs rather than + // rejecting valid transactions over a self-inflicted encoding choice; + // a node is free to re-encode strictly before relaying, and to + // disconnect a peer that habitually sends non-minimal encodings. + deserialize_2026(allocator, bytes, max_blob_size, false) + } else { + node_from_bytes_backrefs(allocator, bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::generator_cost::interned_vbytes; + use clvmr::serde::{intern_tree, node_to_bytes, node_to_bytes_backrefs, serialize_2026}; + use rstest::rstest; + + /// Build a small tree with a repeated subtree (so the backrefs and + /// serde_2026 encodings are both exercised meaningfully). + fn sample_tree(a: &mut Allocator) -> NodePtr { + let atom = a.new_atom(b"hello world, this is a test atom").unwrap(); + let pair = a.new_pair(atom, atom).unwrap(); + a.new_pair(pair, pair).unwrap() + } + + /// The provable per-tree wire-size bound: interned_vbytes + 5 + prefix. + fn encoding_bound(a: &Allocator, node: NodePtr) -> usize { + let tree = intern_tree(a, node).unwrap(); + interned_vbytes(&tree) as usize + 5 + SERDE_2026_MAGIC_PREFIX.len() + } + + /// Adversarial tree shapes: the configurations where the encoding + /// bound's slack is smallest (each stresses a different header/varint + /// growth case in the proof on [`max_canonical_blob_size`]). + fn tight_trees() -> Vec<(Allocator, NodePtr)> { + let mut trees = Vec::new(); + + // Single atom at 2^20 bytes, where the encoding bound's slack + // reaches exactly zero (see the proof's tightness note). + let mut a = Allocator::new(); + let node = a.new_atom(&vec![0xa5; 1 << 20]).unwrap(); + trees.push((a, node)); + + // >63 distinct atom lengths: forces a 2-byte atom-group-count header. + let mut a = Allocator::new(); + let mut node = a.nil(); + for len in 0..=63usize { + let atom = a.new_atom(&vec![0x5a; len]).unwrap(); + node = a.new_pair(atom, node).unwrap(); + } + trees.push((a, node)); + + // >4096 unique pairs: forces a 3-byte instruction-count header, and + // pushes/back-references beyond the 1-byte varint range. + let mut a = Allocator::new(); + let mut node = a.nil(); + for i in 1..=5000u32 { + let atom = a.new_number(i.into()).unwrap(); + node = a.new_pair(atom, node).unwrap(); + } + trees.push((a, node)); + + // Doubling DAG: maximal sharing, so wire bytes come almost entirely + // from back-references rather than atom payload. + let mut a = Allocator::new(); + let mut node = a.one(); + for _ in 0..20 { + node = a.new_pair(node, node).unwrap(); + } + trees.push((a, node)); + + // Small mixed tree. + let mut a = Allocator::new(); + let node = sample_tree(&mut a); + trees.push((a, node)); + + trees + } + + #[test] + fn test_encoding_bound_holds_on_tight_shapes() { + for (a, node) in tight_trees() { + let blob = serialize_2026(&a, node, SERDE_2026_COMPRESSION_LEVEL).unwrap(); + assert!( + blob.len() <= encoding_bound(&a, node), + "encoding bound violated: blob {} > bound {}", + blob.len(), + encoding_bound(&a, node) + ); + } + } + + #[test] + fn test_encoding_bound_tight_at_max_atom_len() { + // A single atom of exactly 2^20 bytes is the known worst case: the + // encoding uses every byte the bound allows. 2^20 is a property of + // the encoding's varint/header boundaries, NOT of any consensus cap + // (mainnet's derived cap is ~917 KB, below this), which is why it is + // hardcoded rather than computed via max_canonical_blob_size. + // If equality stops holding, the "+5" analysis has changed — revisit + // the proof on max_canonical_blob_size. + let mut a = Allocator::new(); + let node = a.new_atom(&vec![0xa5; 1 << 20]).unwrap(); + let blob = serialize_2026(&a, node, SERDE_2026_COMPRESSION_LEVEL).unwrap(); + assert_eq!(blob.len(), encoding_bound(&a, node)); + } + + #[rstest] + // mainnet constants + #[case(11_000_000_000, 12_000)] + // tiny budget: only trivial trees are affordable + #[case(100, 1)] + // zero budget: nothing is affordable, cap is just the fixed overhead + #[case(0, 7)] + // free bytes / huge budget extremes + #[case(u64::MAX, 1)] + #[case(u64::MAX, u64::MAX)] + // zero cost per byte: everything is affordable, cap must be unbounded + #[case(11_000_000_000, 0)] + #[case(0, 0)] + // awkward non-divisible pair + #[case(1_000_003, 17)] + fn test_max_canonical_blob_size_general(#[case] max_cost: u64, #[case] cost_per_byte: u64) { + // The theorem is generic over the cost constants: for ANY + // (max_cost, cost_per_byte), every tree affordable under them + // encodes within the derived cap. + let cap = max_canonical_blob_size(max_cost, cost_per_byte); + for (a, node) in tight_trees() { + let tree = intern_tree(&a, node).unwrap(); + let affordable = interned_vbytes(&tree) + .checked_mul(cost_per_byte) + .is_some_and(|cost| cost <= max_cost); + if affordable { + let blob = serialize_2026(&a, node, SERDE_2026_COMPRESSION_LEVEL).unwrap(); + assert!( + blob.len() <= cap, + "cap violated at ({max_cost}, {cost_per_byte}): blob {} > cap {cap}", + blob.len(), + ); + } + } + } + + #[test] + fn test_auto_dispatch_all_formats() { + let mut a = Allocator::new(); + let node = sample_tree(&mut a); + let expected = node_to_bytes(&a, node).unwrap(); + + let classic = expected.clone(); + let backrefs = node_to_bytes_backrefs(&a, node).unwrap(); + let serde2026 = serialize_2026(&a, node, 0).unwrap(); + assert!(serde2026.starts_with(&SERDE_2026_MAGIC_PREFIX)); + + for blob in [classic, backrefs, serde2026] { + let mut b = Allocator::new(); + let parsed = + node_from_bytes_auto(&mut b, &blob, mainnet_cap()).expect("node_from_bytes_auto"); + assert_eq!(node_to_bytes(&b, parsed).unwrap(), expected); + } + } + + /// The derived cap at the real consensus constants. + fn mainnet_cap() -> usize { + use crate::consensus_constants::TEST_CONSTANTS; + max_canonical_blob_size( + TEST_CONSTANTS.max_block_cost_clvm, + TEST_CONSTANTS.cost_per_byte, + ) + } + + #[test] + fn test_max_canonical_blob_size_at_real_constants() { + // Ties the doc-comment number to the real consensus constants so + // drift gets caught here instead of silently invalidating the bound. + assert_eq!(mainnet_cap(), 916_677); + } + + #[test] + fn test_blob_size_cap() { + let mut a = Allocator::new(); + let node = sample_tree(&mut a); + let blob = serialize_2026(&a, node, SERDE_2026_COMPRESSION_LEVEL).unwrap(); + + // One byte over the cap: rejected before any parsing. + let mut b = Allocator::new(); + assert!(matches!( + node_from_bytes_auto(&mut b, &blob, blob.len() - 1), + Err(EvalErr::SerializationError) + )); + + // At exactly the cap: parses. + let mut b = Allocator::new(); + let parsed = node_from_bytes_auto(&mut b, &blob, blob.len()).unwrap(); + assert_eq!( + node_to_bytes(&b, parsed).unwrap(), + node_to_bytes(&a, node).unwrap() + ); + + // The size gate applies to non-serde_2026 formats too. + let classic = node_to_bytes(&a, node).unwrap(); + let mut b = Allocator::new(); + assert!(matches!( + node_from_bytes_auto(&mut b, &classic, classic.len() - 1), + Err(EvalErr::SerializationError) + )); + } +} diff --git a/crates/chia-consensus/src/solution_generator.rs b/crates/chia-consensus/src/solution_generator.rs index f13c933a4..4f8dc664c 100644 --- a/crates/chia-consensus/src/solution_generator.rs +++ b/crates/chia-consensus/src/solution_generator.rs @@ -1,8 +1,11 @@ use crate::error::Result; +use crate::serde_2026::SERDE_2026_COMPRESSION_LEVEL; use chia_protocol::Coin; use chia_protocol::CoinSpend; use clvmr::allocator::{Allocator, NodePtr}; -use clvmr::serde::{node_from_bytes_backrefs, node_to_bytes, node_to_bytes_backrefs}; +use clvmr::serde::{ + node_from_bytes_backrefs, node_to_bytes, node_to_bytes_backrefs, serialize_2026, +}; /// the tuple has the Coin, puzzle-reveal and solution pub(crate) fn build_generator(a: &mut Allocator, spends: I) -> Result @@ -106,6 +109,16 @@ where Ok(node_to_bytes_backrefs(&a, generator)?) } +pub fn solution_generator_2026(spends: I) -> Result> +where + BufRef: AsRef<[u8]>, + I: IntoIterator, +{ + let mut a = Allocator::new(); + let generator = build_generator(&mut a, spends)?; + Ok(serialize_2026(&a, generator, SERDE_2026_COMPRESSION_LEVEL)?) +} + #[cfg(test)] mod tests { use super::*; @@ -444,6 +457,42 @@ mod tests { assert_eq!(generator_output, EXPECTED_GENERATOR_OUTPUT); } + #[test] + fn test_solution_generator_2026() { + use crate::consensus_constants::TEST_CONSTANTS; + use crate::serde_2026::{max_canonical_blob_size, node_from_bytes_auto}; + use clvmr::serde::SERDE_2026_MAGIC_PREFIX; + + let coin1: Coin = Coin::new( + hex!("ccd5bb71183532bff220ba46c268991a00000000000000000000000000036840").into(), + hex!("fcc78a9e396df6ceebc217d2446bc016e0b3d5922fb32e5783ec5a85d490cfb6").into(), + 1_750_000_000_000, + ); + let coin2: Coin = Coin::new( + hex!("ccd5bb71183532bff220ba46c268991a00000000000000000000000000000000").into(), + hex!("d23da14695a188ae5708dd152263c4db883eb27edeb936178d4d988b8f3ce5fc").into(), + 18_375_000_000_000_000_000, + ); + let spends = [ + (coin1, PUZZLE1.as_ref(), SOLUTION1.as_ref()), + (coin2, PUZZLE2.as_ref(), SOLUTION2.as_ref()), + ]; + + let result = solution_generator_2026(spends).expect("solution_generator_2026"); + assert!(result.starts_with(&SERDE_2026_MAGIC_PREFIX)); + + // Round-trip through the consensus auto-deserializer and confirm the + // tree is identical to the one behind the classic encoding. + let cap = max_canonical_blob_size( + TEST_CONSTANTS.max_block_cost_clvm, + TEST_CONSTANTS.cost_per_byte, + ); + let mut a = Allocator::new(); + let node = node_from_bytes_auto(&mut a, &result, cap).expect("node_from_bytes_auto"); + let classic = solution_generator(spends).expect("solution_generator"); + assert_eq!(node_to_bytes(&a, node).expect("node_to_bytes"), classic); + } + #[rstest] #[case(0)] #[case(1)] diff --git a/wheel/generate_type_stubs.py b/wheel/generate_type_stubs.py index a2f27d2f9..4e7ad10c7 100644 --- a/wheel/generate_type_stubs.py +++ b/wheel/generate_type_stubs.py @@ -325,6 +325,7 @@ class _Unspec: def solution_generator(spends: Sequence[tuple[Coin, bytes, bytes]]) -> bytes: ... def solution_generator_backrefs(spends: Sequence[tuple[Coin, bytes, bytes]]) -> bytes: ... +def solution_generator_2026(spends: Sequence[tuple[Coin, bytes, bytes]]) -> bytes: ... def is_canonical_serialization(buf: bytes) -> bool: ... @@ -443,6 +444,7 @@ def compute_plot_group_id_v2(strength: uint8, plot_pk: G1Element, pool_pk: G1Ele COST_CONDITIONS: int = ... SIMPLE_GENERATOR: int = ... LIMIT_SPENDS: int = ... +SERDE_2026_MAGIC_PREFIX: bytes = ... DISABLE_OP: int = ... CANONICAL_INTS: int = ... ENABLE_SHA256_TREE: int = ... diff --git a/wheel/python/chia_rs/chia_rs.pyi b/wheel/python/chia_rs/chia_rs.pyi index 90b6c9bb6..cea472d90 100644 --- a/wheel/python/chia_rs/chia_rs.pyi +++ b/wheel/python/chia_rs/chia_rs.pyi @@ -15,6 +15,7 @@ class _Unspec: def solution_generator(spends: Sequence[tuple[Coin, bytes, bytes]]) -> bytes: ... def solution_generator_backrefs(spends: Sequence[tuple[Coin, bytes, bytes]]) -> bytes: ... +def solution_generator_2026(spends: Sequence[tuple[Coin, bytes, bytes]]) -> bytes: ... def is_canonical_serialization(buf: bytes) -> bool: ... @@ -133,6 +134,7 @@ COMPUTE_FINGERPRINT: int = ... COST_CONDITIONS: int = ... SIMPLE_GENERATOR: int = ... LIMIT_SPENDS: int = ... +SERDE_2026_MAGIC_PREFIX: bytes = ... DISABLE_OP: int = ... CANONICAL_INTS: int = ... ENABLE_SHA256_TREE: int = ... diff --git a/wheel/src/api.rs b/wheel/src/api.rs index 9393a18f6..f8f82fea8 100644 --- a/wheel/src/api.rs +++ b/wheel/src/api.rs @@ -17,6 +17,7 @@ use chia_consensus::run_block_generator::{ get_coinspends_for_trusted_block, get_coinspends_with_conditions_for_trusted_block, }; use chia_consensus::solution_generator::solution_generator as native_solution_generator; +use chia_consensus::solution_generator::solution_generator_2026 as native_solution_generator_2026; use chia_consensus::solution_generator::solution_generator_backrefs as native_solution_generator_backrefs; use chia_consensus::spendbundle_conditions::get_conditions_from_spendbundle; use chia_consensus::spendbundle_validation::{ @@ -82,7 +83,9 @@ use clvmr::error::EvalErr; use clvmr::reduction::Reduction; use clvmr::run_program; use clvmr::serde::is_canonical_serialization; -use clvmr::serde::{node_from_bytes, node_from_bytes_backrefs, node_to_bytes}; +use clvmr::serde::{ + SERDE_2026_MAGIC_PREFIX, node_from_bytes, node_from_bytes_backrefs, node_to_bytes, +}; use chia_bls::{ BlsCache, DerivableKey, G1Element, GTElement, PublicKey, SecretKey, Signature, @@ -314,6 +317,15 @@ fn solution_generator_backrefs<'p>( )) } +#[pyfunction] +fn solution_generator_2026<'p>( + py: Python<'p>, + spends: &Bound<'_, PyAny>, +) -> PyResult> { + let spends = convert_list_of_tuples(spends)?; + Ok(PyBytes::new(py, &native_solution_generator_2026(spends)?)) +} + #[pyclass] struct AugSchemeMPL {} @@ -789,6 +801,7 @@ pub fn chia_rs(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(additions_and_removals, m)?)?; m.add_function(wrap_pyfunction!(solution_generator, m)?)?; m.add_function(wrap_pyfunction!(solution_generator_backrefs, m)?)?; + m.add_function(wrap_pyfunction!(solution_generator_2026, m)?)?; m.add_function(wrap_pyfunction!(supports_fast_forward, m)?)?; m.add_function(wrap_pyfunction!(fast_forward_singleton, m)?)?; m.add_class::()?; @@ -867,6 +880,10 @@ pub fn chia_rs(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("COST_CONDITIONS", ConsensusFlags::COST_CONDITIONS.bits())?; m.add("SIMPLE_GENERATOR", ConsensusFlags::SIMPLE_GENERATOR.bits())?; m.add("LIMIT_SPENDS", ConsensusFlags::LIMIT_SPENDS.bits())?; + m.add( + "SERDE_2026_MAGIC_PREFIX", + PyBytes::new(py, &SERDE_2026_MAGIC_PREFIX), + )?; // flags from clvm_rs, affecting execution m.add_function(wrap_pyfunction!(run_chia_program, m)?)?;