Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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,
SERDE_2026_COMPRESSION_LEVEL, max_canonical_blob_size, node_from_bytes_2026,
};
use clvm_fuzzing::make_tree;
use clvmr::Allocator;
Expand Down Expand Up @@ -62,10 +62,10 @@ fuzz_target!(|data: &[u8]| {
// 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.
// Passing `bound` as the size cap doubles as a check that the consensus
// gate (node_from_bytes_2026) 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 parsed = node_from_bytes_2026(&mut b, &blob, bound).expect("node_from_bytes_2026");
let blob2 = serialize_2026(&b, parsed, SERDE_2026_COMPRESSION_LEVEL).expect("serialize_2026");
assert_eq!(blob, blob2, "round-trip mismatch");
});
4 changes: 2 additions & 2 deletions crates/chia-consensus/src/additions_and_removals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use chia_protocol::Coin;
use crate::allocator::make_allocator;
use crate::consensus_constants::ConsensusConstants;
use crate::flags::ConsensusFlags;
use crate::serde_2026::node_from_bytes_auto;
use crate::validation_error::{ErrorCode, ValidationErr, atom, first, next, rest};
use chia_protocol::{Bytes, Bytes32};
use clvm_traits::FromClvm;
Expand All @@ -14,7 +15,6 @@ use clvmr::allocator::{NodePtr, SExp};
use clvmr::chia_dialect::ChiaDialect;
use clvmr::reduction::Reduction;
use clvmr::run_program::run_program;
use clvmr::serde::node_from_bytes_backrefs;

/// Run a *trusted* block generator and return its additions and removals. This
/// function does not validate the block, it is assumed to be valid.
Expand All @@ -36,7 +36,7 @@ where

let mut cost_left = constants.max_block_cost_clvm;

let program = node_from_bytes_backrefs(&mut a, program)?;
let program = node_from_bytes_auto(&mut a, program)?;

let args = setup_generator_args(&mut a, block_refs, flags)?;
let dialect = ChiaDialect::new(flags.to_clvm_flags());
Expand Down
7 changes: 7 additions & 0 deletions crates/chia-consensus/src/build_interned_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ pub enum BuildBlockResult {
/// By the triangle inequality (vbytes(A ∪ B) ≤ vbytes(A) + vbytes(B)), the
/// running sum is an upper bound on the true interned cost of all spends
/// combined. finalize() computes the exact cost.
///
/// finalize() always emits the generator in serde_2026 format (interned
/// serialization, magic-prefixed). There is no classic-emission mode: this
/// builder's cost accounting charges by interned vbytes, which is only
/// correct once INTERNED_GENERATOR is active, and serde_2026 acceptance
/// activates at that same height (single activation) — so there is no valid
/// height at which this builder's output could be classic-serialized.
#[cfg_attr(feature = "py-bindings", pyclass)]
pub struct InternedBlockBuilder {
allocator: Allocator,
Expand Down
136 changes: 136 additions & 0 deletions crates/chia-consensus/src/build_interned_block/additional_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::run_block_generator::run_block_generator2;
use crate::solution_generator::calculate_generator_length;
use crate::spendbundle_conditions::run_spendbundle;
use chia_traits::Streamable;
use clvmr::serde::node_to_bytes_backrefs;
use std::fs;
use std::path::Path;

Expand Down Expand Up @@ -329,3 +330,138 @@ fn test_byte_cost_tracking() {
"upper bound ({upper_bound}) should be >= exact cost ({exact_cost})"
);
}

/// Deterministic set of bundles with shared puzzle bytes, used by the
/// serde_2026 emission tests below.
fn serde_2026_test_bundles() -> Vec<SpendBundle> {
(0..5)
.map(|i| {
SpendBundle::new(
vec![make_test_coin_spend([i + 1; 32], 1000 + i as u64)],
Signature::default(),
)
})
.collect()
}

fn build_block(bundles: &[SpendBundle]) -> (Vec<u8>, Signature, u64) {
let mut builder = InternedBlockBuilder::new(&TEST_CONSTANTS);
for bundle in bundles {
let exec_cost = clvm_execution_cost(bundle);
let (added, _) = builder
.add_spend_bundles([bundle], exec_cost)
.expect("add_spend_bundles");
assert!(added, "bundle should fit");
}
builder.finalize().expect("finalize")
}

/// Independently constructs the classic-serialization reference generator
/// for the same spend list the builder would build, without going through
/// `InternedBlockBuilder` (which only ever emits serde_2026). Used to compare
/// serde_2026 output against a classic-format generator for the same
/// bundles, run under classic (pre-HF2) consensus rules.
fn build_classic_reference(bundles: &[SpendBundle]) -> (Vec<u8>, Signature) {
let mut a = Allocator::new();
let mut spend_list = a.nil();
let mut signature = Signature::default();
for bundle in bundles {
for spend in &bundle.coin_spends {
let solution = node_from_bytes_backrefs(&mut a, spend.solution.as_ref()).unwrap();
let item = a.new_pair(solution, NodePtr::NIL).unwrap();
let amount = a.new_number(spend.coin.amount.into()).unwrap();
let item = a.new_pair(amount, item).unwrap();
let puzzle = node_from_bytes_backrefs(&mut a, spend.puzzle_reveal.as_ref()).unwrap();
let item = a.new_pair(puzzle, item).unwrap();
let parent_id = a.new_atom(&spend.coin.parent_coin_info).unwrap();
let item = a.new_pair(parent_id, item).unwrap();
spend_list = a.new_pair(item, spend_list).unwrap();
}
signature.aggregate(&bundle.aggregated_signature);
}
let inner = a.new_pair(spend_list, a.nil()).unwrap();
let root = a.new_pair(a.one(), inner).unwrap();
(node_to_bytes_backrefs(&a, root).unwrap(), signature)
}

fn normalized_spends(
generator: &[u8],
signature: &Signature,
flags: ConsensusFlags,
) -> (Vec<crate::owned_conditions::OwnedSpendConditions>, u64) {
let (a, conds) = run_block_generator2::<&[u8], _>(
generator,
[],
TEST_CONSTANTS.max_block_cost_clvm,
MEMPOOL_MODE | flags,
signature,
None,
&TEST_CONSTANTS,
)
.expect("run_block_generator2");
let cost = conds.cost;
let mut conds = crate::owned_conditions::OwnedSpendBundleConditions::from(&a, conds);
conds.spends.sort_by_key(|s| s.coin_id);
for s in &mut conds.spends {
s.create_coin.sort();
s.flags = 0;
s.fingerprint = chia_protocol::Bytes::default();
}
(conds.spends, cost)
}

/// The builder's serde_2026 output round-trips through the
/// INTERNED_GENERATOR consensus path and yields the same spends/conditions
/// as an independently-built classic generator for the same bundles, run
/// under classic rules.
#[test]
fn test_serde_2026_round_trip() {
use clvmr::serde::SERDE_2026_MAGIC_PREFIX;

let bundles = serde_2026_test_bundles();

let (generator_2026, sig_2026, cost_2026) = build_block(&bundles);
assert!(
generator_2026.starts_with(&SERDE_2026_MAGIC_PREFIX),
"builder output must always carry the serde_2026 magic prefix"
);

let (generator_classic, sig_classic) = build_classic_reference(&bundles);
assert!(!generator_classic.starts_with(&SERDE_2026_MAGIC_PREFIX));

let (spends_2026, run_cost_2026) = normalized_spends(
&generator_2026,
&sig_2026,
ConsensusFlags::INTERNED_GENERATOR,
);
assert_eq!(
run_cost_2026, cost_2026,
"finalize() cost must match the INTERNED_GENERATOR consensus path"
);

// classic reference generator, run under classic (pre-HF2) rules
let (spends_classic, _) =
normalized_spends(&generator_classic, &sig_classic, ConsensusFlags::empty());

assert_eq!(spends_2026, spends_classic);
}

/// tree_hash_auto semantics: hashing the serde_2026 generator agrees with the
/// tree hash of the classic serialization of the same tree.
#[test]
fn test_serde_2026_tree_hash_auto_agrees() {
use crate::serde_2026::node_from_bytes_auto;
use clvm_utils::{tree_hash, tree_hash_from_bytes};

let bundles = serde_2026_test_bundles();
let (generator_2026, _, _) = build_block(&bundles);
let (generator_classic, _) = build_classic_reference(&bundles);

// same dispatch as the wheel's tree_hash_auto()
let mut a = Allocator::new();
let node = node_from_bytes_auto(&mut a, &generator_2026).expect("node_from_bytes_auto");
let hash_2026 = tree_hash(&a, node);

let hash_classic = tree_hash_from_bytes(&generator_classic).expect("tree_hash_from_bytes");
assert_eq!(hash_2026, hash_classic);
}
82 changes: 50 additions & 32 deletions crates/chia-consensus/src/serde_2026.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ use clvmr::serde::{SERDE_2026_MAGIC_PREFIX, deserialize_2026, node_from_bytes_ba
/// Deserialize a generator on the consensus path: the blob must be a
/// magic-prefixed serde_2026 encoding, with no fallback to classic/backrefs
/// parsing — with `INTERNED_GENERATOR` active, serde_2026 is the only legal
/// generator encoding. `max_blob_size` and `strict = false` have the same
/// meaning (and rationale) as in [`node_from_bytes_auto`].
/// generator encoding. `max_blob_size` bounds the wire size accepted (derive
/// it via [`max_canonical_blob_size`]); it doubles as the per-atom cap, since
/// atoms appear as literals in the canonical serialization, so an atom of
/// length `L` forces a canonical blob of at least `L` bytes. `strict = false`
/// has the same meaning (and rationale) as in [`node_from_bytes_auto`].
pub fn node_from_bytes_2026(
allocator: &mut Allocator,
bytes: &[u8],
Expand Down Expand Up @@ -66,7 +69,7 @@ pub const SERDE_2026_COMPRESSION_LEVEL: u32 = 0;
/// 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
/// [`node_from_bytes_2026`] 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.
///
Expand Down Expand Up @@ -98,25 +101,26 @@ pub fn max_canonical_blob_size(max_cost: u64, cost_per_byte: u64) -> usize {
/// 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).
/// This is a *policy-free reader*: it enforces no consensus rules and must
/// never be a consensus entry point — consensus validation uses
/// [`node_from_bytes_2026`], which enforces the cost-derived size cap from
/// [`max_canonical_blob_size`]. Being policy-free is what lets readers of
/// historical blocks accept every blob the chain ever accepted, whatever
/// the rules were when it was created. The classic branch is byte-for-byte
/// [`node_from_bytes_backrefs`], same as before this dispatcher existed.
///
/// 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<NodePtr> {
if bytes.len() > max_blob_size {
return Err(EvalErr::SerializationError);
}
/// Parsing itself is safe on *untrusted* input: time and memory are linear
/// in the blob length for all three formats (every node costs at least one
/// input byte; shared subtrees are shared, not copied). clvmr's
/// deserializer still wants a per-atom bound; `bytes.len()` is the natural
/// policy-free choice, since an atom of length `L` appears as a literal in
/// the blob and therefore forces `bytes.len() >= L`.
///
/// The caveat is downstream: backrefs and serde_2026 can encode trees whose
/// *expansion* is exponential in the blob size, so anything traversing the
/// result of an untrusted parse must be DAG-aware (e.g.
/// [`clvm_utils::tree_hash_cached`] rather than the naive `tree_hash`).
pub fn node_from_bytes_auto(allocator: &mut Allocator, bytes: &[u8]) -> Result<NodePtr> {
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
Expand All @@ -125,7 +129,7 @@ pub fn node_from_bytes_auto(
// 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)
deserialize_2026(allocator, bytes, bytes.len(), false)
} else {
node_from_bytes_backrefs(allocator, bytes)
}
Expand Down Expand Up @@ -277,8 +281,7 @@ mod tests {

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");
let parsed = node_from_bytes_auto(&mut b, &blob).expect("node_from_bytes_auto");
assert_eq!(node_to_bytes(&b, parsed).unwrap(), expected);
}
}
Expand All @@ -301,31 +304,46 @@ mod tests {

#[test]
fn test_blob_size_cap() {
// The cost-derived size cap is a consensus concern, enforced only by
// node_from_bytes_2026. The trusted-reader node_from_bytes_auto has
// no cap in either branch.
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.
// Consensus entry point, 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),
node_from_bytes_2026(&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();
let parsed = node_from_bytes_2026(&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.
// The trusted reader parses the same serde_2026 blob with no cap to
// trip over...
let mut b = Allocator::new();
let parsed = node_from_bytes_auto(&mut b, &blob).unwrap();
assert_eq!(
node_to_bytes(&b, parsed).unwrap(),
node_to_bytes(&a, node).unwrap()
);

// ...and classic blobs likewise load uncapped: historical blocks
// must load regardless of current constants.
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)
));
let parsed = node_from_bytes_auto(&mut b, &classic).unwrap();
assert_eq!(
node_to_bytes(&b, parsed).unwrap(),
node_to_bytes(&a, node).unwrap()
);
}
}
13 changes: 4 additions & 9 deletions crates/chia-consensus/src/solution_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,8 +459,7 @@ mod tests {

#[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 crate::serde_2026::node_from_bytes_auto;
use clvmr::serde::SERDE_2026_MAGIC_PREFIX;

let coin1: Coin = Coin::new(
Expand All @@ -481,14 +480,10 @@ mod tests {
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,
);
// Round-trip through the auto-deserializer and confirm the tree is
// identical to the one behind the classic encoding.
let mut a = Allocator::new();
let node = node_from_bytes_auto(&mut a, &result, cap).expect("node_from_bytes_auto");
let node = node_from_bytes_auto(&mut a, &result).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);
}
Expand Down
Loading
Loading