From 3163e920504c8a065775e268e54c1b59f290a4bc Mon Sep 17 00:00:00 2001 From: Eren Yegit <115787683+erenyegit@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:14:34 +0300 Subject: [PATCH] refactor(chain): commit block digest over a transaction root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold a block's transactions into a domain-separated transaction-root commitment and commit the block digest over that root instead of inlining every transaction. This lets a compact BlockHeader reproduce and check the block digest without carrying the transactions, then read the authenticated state_root/state_range out of it — the foundation the bridge's certified checkpoint needs to authenticate a foreign state root from a finalization certificate. Add BlockHeader (every non-transaction digest field plus the transaction root), Block::header(), and BlockHeader::digest(), which reproduces the source block's digest exactly. The transaction root is a sequential-hash commitment, not a Merkle tree: it authenticates the exact ordered transaction list (count, order, and bytes) but does not support per-transaction inclusion proofs. BREAKING: this changes every block's digest and is consensus-breaking; it must land before testnet. --- chain/src/block.rs | 236 ++++++++++++++++++++++++++++++++---- chain/src/lib.rs | 2 +- chain/tests/block_header.rs | 125 +++++++++++++++++++ 3 files changed, 339 insertions(+), 24 deletions(-) create mode 100644 chain/tests/block_header.rs diff --git a/chain/src/block.rs b/chain/src/block.rs index 44aadce..ca5beb3 100644 --- a/chain/src/block.rs +++ b/chain/src/block.rs @@ -62,17 +62,61 @@ where digest: Digest, } -struct DigestInput<'a, Tx, Payload> { +struct DigestInput<'a, Payload> { context: &'a Context, parent: &'a Digest, height: Height, timestamp: u64, - transactions: &'a [Tx], + transaction_root: &'a Digest, reshare_log: &'a Option, extension: &'a Payload, state: &'a StateCommitment, } +/// Domain separator for a block's transaction commitment. +const TRANSACTION_ROOT_DOMAIN: &[u8] = b"nunchi/chain/transaction_root/v1"; + +/// A domain-separated commitment over a block's ordered transaction list. +/// +/// This is a sequential-hash *commitment*, not a Merkle tree: it authenticates the exact ordered +/// set of transactions (count, order, and encoded bytes all matter), but it supports only equality +/// checks, not per-transaction inclusion proofs. Committing the block digest over this root instead +/// of inlining every transaction lets a compact [`BlockHeader`] reproduce the digest without +/// carrying the transactions. +fn transaction_root(transactions: &[Tx]) -> Digest +where + Tx: EncodeSize + Write, +{ + let mut hasher = Sha256::new(); + hasher.update(TRANSACTION_ROOT_DOMAIN); + hasher.update(&(transactions.len() as u64).to_be_bytes()); + for transaction in transactions { + hasher.update(&transaction.encode()); + } + hasher.finalize() +} + +/// Compute a block digest from its non-transaction fields and the transaction root. +/// +/// Shared by [`Block`] (which folds its transactions into the root) and [`BlockHeader`] (which +/// carries the root directly), so both produce identical digests for the same block. +fn block_digest(input: DigestInput<'_, Payload>) -> Digest +where + Payload: EncodeSize + Write, +{ + let mut hasher = Sha256::new(); + hasher.update(&input.context.encode()); + hasher.update(input.parent); + hasher.update(&input.height.get().to_be_bytes()); + hasher.update(&input.timestamp.to_be_bytes()); + hasher.update(input.transaction_root); + hasher.update(&input.reshare_log.encode()); + hasher.update(&input.extension.encode()); + hasher.update(&input.state.root); + hasher.update(&input.state.range.encode()); + hasher.finalize() +} + impl Clone for Block where Tx: Clone, @@ -125,23 +169,6 @@ where Tx: EncodeSize + Write, Ext: BlockExtension, { - fn compute_digest(input: DigestInput<'_, Tx, Ext::Payload>) -> Digest { - let mut hasher = Sha256::new(); - hasher.update(&input.context.encode()); - hasher.update(input.parent); - hasher.update(&input.height.get().to_be_bytes()); - hasher.update(&input.timestamp.to_be_bytes()); - hasher.update(&(input.transactions.len() as u64).to_be_bytes()); - for transaction in input.transactions { - hasher.update(&transaction.encode()); - } - hasher.update(&input.reshare_log.encode()); - hasher.update(&input.extension.encode()); - hasher.update(&input.state.root); - hasher.update(&input.state.range.encode()); - hasher.finalize() - } - #[allow(clippy::too_many_arguments)] pub fn new( context: Context, @@ -153,12 +180,12 @@ where extension: Ext::Payload, state: StateCommitment, ) -> Self { - let digest = Self::compute_digest(DigestInput { + let digest = block_digest(DigestInput { context: &context, parent: &parent, height, timestamp, - transactions: &transactions, + transaction_root: &transaction_root(&transactions), reshare_log: &reshare_log, extension: &extension, state: &state, @@ -176,6 +203,23 @@ where digest, } } + + /// The compact, digest-authenticated header of this block: every field that contributes to the + /// block digest except the transactions, which are folded into [`BlockHeader::transaction_root`]. + /// [`BlockHeader::digest`] reproduces this block's digest exactly. + pub fn header(&self) -> BlockHeader { + BlockHeader { + context: self.context.clone(), + parent: self.parent, + height: self.height, + timestamp: self.timestamp, + transaction_root: transaction_root(&self.transactions), + reshare_log: self.reshare_log.clone(), + extension: self.extension.clone(), + state_root: self.state_root, + state_range: self.state_range.clone(), + } + } } impl Write for Block @@ -231,12 +275,12 @@ where range: state_range, }; - let digest = Self::compute_digest(DigestInput { + let digest = block_digest(DigestInput { context: &context, parent: &parent, height, timestamp, - transactions: &transactions, + transaction_root: &transaction_root(&transactions), reshare_log: &reshare_log, extension: &extension, state: &state, @@ -303,6 +347,152 @@ where } } +/// The compact, digest-authenticated header of a [`Block`]. +/// +/// It carries every field that contributes to a block's digest except the transaction list, which +/// is folded into [`BlockHeader::transaction_root`]. This lets a verifier reproduce and check the +/// block digest (for example against a finalization certificate) without the full block, then read +/// the authenticated `state_root`/`state_range` out of it. See [`BlockHeader::digest`] and +/// [`Block::header`]. +#[derive(Debug)] +pub struct BlockHeader { + /// The consensus context when the block was proposed. + pub context: Context, + + /// The parent block's digest. + pub parent: Digest, + + /// The height of the block in the blockchain. + pub height: Height, + + /// The timestamp of the block (in milliseconds since the Unix epoch). + pub timestamp: u64, + + /// Commitment over the block's ordered transaction list (count, order, and encoded bytes all + /// matter). A sequential-hash commitment, not a Merkle tree, so it supports equality checks but + /// not per-transaction inclusion proofs. + pub transaction_root: Digest, + + /// Optional DKG resharing payload included outside ordinary runtime transactions. + pub reshare_log: Option, + + /// Additional consensus-side payload included outside ordinary runtime transactions. + pub extension: Ext::Payload, + + /// Authenticated state root after executing the block's transactions. + pub state_root: Digest, + + /// QMDB operation range that supports state sync to `state_root`. + pub state_range: NonEmptyRange, +} + +impl BlockHeader { + /// The block digest this header authenticates. Equal to the digest of the block it was taken + /// from (see [`Block::header`]). + pub fn digest(&self) -> Digest { + block_digest(DigestInput { + context: &self.context, + parent: &self.parent, + height: self.height, + timestamp: self.timestamp, + transaction_root: &self.transaction_root, + reshare_log: &self.reshare_log, + extension: &self.extension, + state: &StateCommitment { + root: self.state_root, + range: self.state_range.clone(), + }, + }) + } +} + +impl Clone for BlockHeader { + fn clone(&self) -> Self { + Self { + context: self.context.clone(), + parent: self.parent, + height: self.height, + timestamp: self.timestamp, + transaction_root: self.transaction_root, + reshare_log: self.reshare_log.clone(), + extension: self.extension.clone(), + state_root: self.state_root, + state_range: self.state_range.clone(), + } + } +} + +impl PartialEq for BlockHeader { + fn eq(&self, other: &Self) -> bool { + self.context == other.context + && self.parent == other.parent + && self.height == other.height + && self.timestamp == other.timestamp + && self.transaction_root == other.transaction_root + && self.reshare_log.encode() == other.reshare_log.encode() + && self.extension.encode() == other.extension.encode() + && self.state_root == other.state_root + && self.state_range == other.state_range + } +} + +impl Eq for BlockHeader {} + +impl Write for BlockHeader { + fn write(&self, writer: &mut impl BufMut) { + self.context.write(writer); + self.parent.write(writer); + self.height.write(writer); + UInt(self.timestamp).write(writer); + self.transaction_root.write(writer); + self.reshare_log.write(writer); + self.extension.write(writer); + self.state_root.write(writer); + self.state_range.write(writer); + } +} + +impl Read for BlockHeader { + type Cfg = (NonZeroU32, Ext::ReadCfg); + + fn read_cfg(reader: &mut impl Buf, cfg: &Self::Cfg) -> Result { + let context = Context::read(reader)?; + let parent = Digest::read(reader)?; + let height = Height::read(reader)?; + let timestamp = UInt::read(reader)?.0; + let transaction_root = Digest::read(reader)?; + let reshare_log = Option::::read_cfg(reader, &cfg.0)?; + let extension = Ext::Payload::read_cfg(reader, &cfg.1)?; + let state_root = Digest::read(reader)?; + let state_range = NonEmptyRange::read(reader)?; + Ok(Self { + context, + parent, + height, + timestamp, + transaction_root, + reshare_log, + extension, + state_root, + state_range, + }) + } +} + +impl EncodeSize for BlockHeader { + fn encode_size(&self) -> usize { + self.context.encode_size() + + self.parent.encode_size() + + self.height.encode_size() + + UInt(self.timestamp).encode_size() + + self.transaction_root.encode_size() + + self.reshare_log.encode_size() + + self.extension.encode_size() + + self.state_root.encode_size() + + self.state_range.encode_size() + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Notarized where diff --git a/chain/src/lib.rs b/chain/src/lib.rs index a71adb3..b094839 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -12,7 +12,7 @@ mod macros; mod tests; pub use application::{Application, SharedAppliedHeight}; -pub use block::{Block, Finalized, Notarized, StateCommitment, MAX_TRANSACTIONS}; +pub use block::{Block, BlockHeader, Finalized, Notarized, StateCommitment, MAX_TRANSACTIONS}; pub use consensus::{ dkg_reporters, BlockExtension, Composite, ConsensusExtension, DkgActor, DkgMailbox, DkgReporters, NoConsensusExtension, diff --git a/chain/tests/block_header.rs b/chain/tests/block_header.rs new file mode 100644 index 0000000..cfe5b11 --- /dev/null +++ b/chain/tests/block_header.rs @@ -0,0 +1,125 @@ +//! Tests for the compact, digest-authenticated [`BlockHeader`] and the transaction-root +//! commitment the block digest is now committed over (see `Block::header` / `BlockHeader::digest`). + +use commonware_codec::{Decode, Encode}; +use commonware_consensus::types::{Epoch, Height, Round, View}; +use commonware_cryptography::{ed25519, sha256, Digest as _, Digestible as _, Signer}; +use commonware_storage::mmr::Location; +use commonware_utils::{non_empty_range, NZU32}; +use nunchi_chain::{Block, BlockHeader, NoConsensusExtension, StateCommitment}; +use nunchi_dkg::Context; + +fn context() -> Context { + Context { + round: Round::new(Epoch::zero(), View::zero()), + leader: ed25519::PrivateKey::from_seed(0).public_key(), + parent: (View::zero(), sha256::Digest::EMPTY), + } +} + +fn state() -> StateCommitment { + StateCommitment { + root: sha256::Digest::EMPTY, + range: non_empty_range!(Location::new(0), Location::new(1)), + } +} + +/// Read config for `BlockHeader`: a bound for the reshare log plus the unit +/// extension config. +fn header_cfg() -> (std::num::NonZeroU32, ()) { + (NZU32!(1), ()) +} + +fn block(transactions: Vec) -> Block { + Block::new( + context(), + sha256::Digest::EMPTY, + Height::zero(), + 1, + transactions, + None, + (), + state(), + ) +} + +#[test] +fn header_digest_matches_block_digest() { + let block = block(vec![7, 8, 9]); + + // The compact header reproduces the full block's digest without carrying the transactions. + assert_eq!(block.header().digest(), block.digest()); +} + +#[test] +fn changing_a_transaction_changes_root_and_digest() { + let base = block(vec![7, 8]); + let changed = block(vec![7, 9]); + + assert_ne!( + base.header().transaction_root, + changed.header().transaction_root + ); + assert_ne!(base.digest(), changed.digest()); + + // Each header still authenticates its own block. + assert_eq!(base.header().digest(), base.digest()); + assert_eq!(changed.header().digest(), changed.digest()); +} + +#[test] +fn reordering_transactions_changes_root_and_digest() { + let ordered = block(vec![7, 8]); + let reordered = block(vec![8, 7]); + + // The transaction root commits to order, not just the multiset of transactions. + assert_ne!( + ordered.header().transaction_root, + reordered.header().transaction_root + ); + assert_ne!(ordered.digest(), reordered.digest()); +} + +#[test] +fn empty_and_nonempty_transaction_lists_differ() { + let empty = block(vec![]); + let nonempty = block(vec![7]); + + // The count is committed, so an empty list is not the same commitment as any non-empty one. + assert_ne!( + empty.header().transaction_root, + nonempty.header().transaction_root + ); + // The header still authenticates a block with no transactions. + assert_eq!(empty.header().digest(), empty.digest()); +} + +#[test] +fn changing_state_commitment_changes_header_digest() { + let base = block(vec![7]).header(); + + // A different state root changes the authenticated digest. + let mut changed_root = base.clone(); + changed_root.state_root = block(vec![9, 9]).digest(); + assert_ne!(changed_root.state_root, base.state_root); + assert_ne!(changed_root.digest(), base.digest()); + + // A different state range changes the authenticated digest. + let mut changed_range = base.clone(); + changed_range.state_range = non_empty_range!(Location::new(0), Location::new(2)); + assert_ne!(changed_range.digest(), base.digest()); +} + +#[test] +fn header_codec_round_trips() { + let block = block(vec![7, 8]); + let header = block.header(); + + let decoded = + BlockHeader::::decode_cfg(header.encode().as_ref(), &header_cfg()) + .unwrap(); + + assert_eq!(decoded, header); + // A header recovered from the wire still authenticates the block. + assert_eq!(decoded.digest(), block.digest()); +}