diff --git a/CHANGELOG.md b/CHANGELOG.md index 940cb20627..b208dbf790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Added detached public transaction log bundles, Rust batch/block handoff APIs, and bounded protobuf delivery while keeping log payloads out of proven batches and block bodies. - Added the `tx::add_log_from_memory` helper procedure to emit transaction logs from content in memory ([#4092](https://github.com/0xMiden/protocol/pull/4092)). - Added `WordSchema::display_word` to format a `Word` as a string according to the schema ([#4066](https://github.com/0xMiden/protocol/pull/4066)). - Added `HashMap` and `BTreeMap` field decoding in `miden-protobuf`, including message and enum values with map keys preserved in conversion errors ([#3870](https://github.com/0xMiden/protocol/pull/3870)). diff --git a/crates/miden-objects/build.rs b/crates/miden-objects/build.rs index 2e985e9639..9dcf2fc5b5 100644 --- a/crates/miden-objects/build.rs +++ b/crates/miden-objects/build.rs @@ -70,6 +70,8 @@ fn main() -> Result<(), Box> { ".transaction.TransactionLog", ".transaction.TransactionLogs", ".transaction.TransactionLogDetails", + ".transaction.PublicTransactionLogEntry", + ".transaction.PublicTransactionLogBundle", ".blockchain.SignedBlock", ".blockchain.BlockBody", ".blockchain.OutputNoteBatch", diff --git a/crates/miden-objects/proto/transaction.proto b/crates/miden-objects/proto/transaction.proto index ca53d7349b..7745f3aad0 100644 --- a/crates/miden-objects/proto/transaction.proto +++ b/crates/miden-objects/proto/transaction.proto @@ -130,3 +130,16 @@ message TransactionLogDetails { TransactionLogs public = 2; } } + +// Detached public log delivery. These messages are not fields of batches or blocks. +message PublicTransactionLogEntry { + TransactionId transaction_id = 1; + TransactionLogs logs = 2; +} + +message PublicTransactionLogBundle { + // Binds all ordered transaction headers, including private transactions. + primitives.Word transactions_commitment = 1; + // One entry per public native account transaction, including empty log lists. + repeated PublicTransactionLogEntry entries = 2; +} diff --git a/crates/miden-objects/src/conversion/transaction_logs.rs b/crates/miden-objects/src/conversion/transaction_logs.rs index 09e8e710ec..6df13abbcf 100644 --- a/crates/miden-objects/src/conversion/transaction_logs.rs +++ b/crates/miden-objects/src/conversion/transaction_logs.rs @@ -1,5 +1,6 @@ use miden_protocol::transaction::{ LogTopic, + PublicTransactionLogBundle, TransactionLog, TransactionLogDetails, TransactionLogs, @@ -16,6 +17,22 @@ impl From for proto::transaction::LogTopic { } } +impl From<&PublicTransactionLogBundle> for proto::transaction::PublicTransactionLogBundle { + fn from(value: &PublicTransactionLogBundle) -> Self { + Self { + transactions_commitment: Some(value.transactions_commitment().into()), + entries: value + .entries() + .iter() + .map(|(id, logs)| proto::transaction::PublicTransactionLogEntry { + transaction_id: Some((*id).into()), + logs: Some(logs.into()), + }) + .collect(), + } + } +} + impl From<&TransactionLog> for proto::transaction::TransactionLog { fn from(value: &TransactionLog) -> Self { Self { diff --git a/crates/miden-objects/src/decoded/transaction/log_bundle.rs b/crates/miden-objects/src/decoded/transaction/log_bundle.rs new file mode 100644 index 0000000000..459468eec2 --- /dev/null +++ b/crates/miden-objects/src/decoded/transaction/log_bundle.rs @@ -0,0 +1,211 @@ +use miden_protocol::batch::ProvenBatch; +use miden_protocol::block::BlockBody; +use miden_protocol::transaction::{ + OrderedTransactionHeaders, + PublicTransactionLogBundle as VerifiedBundle, + TransactionLogBundleLimits, + TransactionLogError, + TransactionLogs, +}; +use prost::Message; +use prost::encoding::{DecodeContext, WireType, check_wire_type, decode_key, skip_field}; +pub use proto::transaction::DecodedPublicTransactionLogBundle as PublicTransactionLogBundle; + +use crate::decoded::VerificationError; +use crate::{DecodeMessage, Verify, VerifyWith, proto}; + +#[derive(Debug, thiserror::Error)] +enum LogBundleDecodeError { + #[error("duplicate bundle commitment")] + DuplicateCommitment, + #[error("truncated log bundle frame")] + TruncatedFrame, +} + +impl PublicTransactionLogBundle { + fn verify_headers( + self, + headers: &OrderedTransactionHeaders, + limits: TransactionLogBundleLimits, + ) -> Result { + limits.check(headers.as_slice().len().max(self.entries.as_slice().len()), 0, 0)?; + if self.transactions_commitment != headers.commitment() { + return Err(TransactionLogError::BundleCommitmentMismatch.into()); + } + let (mut num_logs, mut num_words) = (0, 0); + let entries = self.entries.try_map(|entry| -> Result<_, VerificationError> { + num_logs += entry.logs.logs.as_slice().len(); + num_words += entry + .logs + .logs + .as_slice() + .iter() + .map(|log| log.content.as_slice().len()) + .sum::(); + limits.check(headers.as_slice().len(), num_logs, num_words)?; + Ok(( + miden_protobuf::unwrap_infallible(entry.transaction_id.verify()), + entry.logs.verify()?, + )) + })?; + Ok(VerifiedBundle::new(entries, headers)?) + } +} + +/// Checks complete public log availability against an already authenticated batch's headers. +/// Does not verify the batch's proof. For untrusted bytes, use the bounded decoder below. +impl VerifyWith<&ProvenBatch> for PublicTransactionLogBundle { + type Verified = VerifiedBundle; + type Error = VerificationError; + + fn verify_with(self, batch: &ProvenBatch) -> Result { + self.verify_headers(batch.transactions(), TransactionLogBundleLimits::BATCH) + } +} + +/// Checks complete public log availability against an already authenticated block's headers. +/// Does not authenticate the block. For untrusted bytes, use the bounded decoder below. +impl VerifyWith<&BlockBody> for PublicTransactionLogBundle { + type Verified = VerifiedBundle; + type Error = VerificationError; + + fn verify_with(self, body: &BlockBody) -> Result { + self.verify_headers(body.transactions(), TransactionLogBundleLimits::BLOCK) + } +} + +impl proto::transaction::PublicTransactionLogBundle { + /// Maximum encoded batch log bundle size, including framing and unknown fields (4 MiB). + pub const MAX_BATCH_BYTES: usize = 4 * 1024 * 1024; + /// Maximum encoded block log bundle size, including framing and unknown fields (32 MiB). + pub const MAX_BLOCK_BYTES: usize = 32 * 1024 * 1024; + + /// Decodes untrusted bytes and checks the complete bundle against a batch. + /// + /// Bounds bytes before parsing, entries before allocation, and aggregate logs/content before + /// retaining each entry. Each entry frame is capped at 16 KiB before Prost allocates its + /// fields. + pub fn decode_for_batch( + bytes: &[u8], + batch: &ProvenBatch, + ) -> Result { + TransactionLogBundleLimits::BATCH.check(batch.transactions().as_slice().len(), 0, 0)?; + Self::decode_bounded(bytes, TransactionLogBundleLimits::BATCH, Self::MAX_BATCH_BYTES)? + .decode_fields()? + .verify_with(batch) + } + + /// Decodes untrusted bytes and checks the complete bundle against a block body. + /// The block itself must be authenticated separately. See [`Self::decode_for_batch`] for + /// bounds. + pub fn decode_for_block( + bytes: &[u8], + body: &BlockBody, + ) -> Result { + TransactionLogBundleLimits::BLOCK.check(body.transactions().as_slice().len(), 0, 0)?; + Self::decode_bounded(bytes, TransactionLogBundleLimits::BLOCK, Self::MAX_BLOCK_BYTES)? + .decode_fields()? + .verify_with(body) + } + + fn decode_bounded( + mut bytes: &[u8], + limits: TransactionLogBundleLimits, + max_bytes: usize, + ) -> Result { + if bytes.len() > max_bytes { + return Err(TransactionLogError::BundleLimitExceeded.into()); + } + let mut bundle = Self::default(); + let mut remaining = limits; + while !bytes.is_empty() { + let (tag, wire_type) = decode_key(&mut bytes).map_err(VerificationError::new)?; + match tag { + 1 => bundle.read_commitment(&mut bytes, wire_type)?, + 2 => bundle.entries.push(read_entry(&mut bytes, wire_type, &mut remaining)?), + _ => skip_field(wire_type, tag, &mut bytes, DecodeContext::default()) + .map_err(VerificationError::new)?, + } + } + Ok(bundle) + } + + fn read_commitment( + &mut self, + bytes: &mut &[u8], + wire_type: WireType, + ) -> Result<(), VerificationError> { + if self.transactions_commitment.is_some() { + return Err(VerificationError::new(LogBundleDecodeError::DuplicateCommitment)); + } + let frame = read_frame(bytes, wire_type, 64)?; + self.transactions_commitment = + Some(proto::primitives::Word::decode(frame).map_err(VerificationError::new)?); + Ok(()) + } +} + +fn read_entry( + bytes: &mut &[u8], + wire_type: WireType, + remaining: &mut TransactionLogBundleLimits, +) -> Result { + // Reserve the entry before reading its frame or allocating its fields. + remaining.transactions = remaining + .transactions + .checked_sub(1) + .ok_or(TransactionLogError::BundleLimitExceeded)?; + // A canonical entry with 64 log records and 64 content words fits in 16 KiB. + let frame = read_frame(bytes, wire_type, 16 * 1024)?; + let entry = proto::transaction::PublicTransactionLogEntry::decode(frame) + .map_err(VerificationError::new)?; + let (logs, words) = entry_log_counts(&entry)?; + remaining.logs = remaining + .logs + .checked_sub(logs) + .ok_or(TransactionLogError::BundleLimitExceeded)?; + remaining.content_words = remaining + .content_words + .checked_sub(words) + .ok_or(TransactionLogError::BundleLimitExceeded)?; + Ok(entry) +} + +fn entry_log_counts( + entry: &proto::transaction::PublicTransactionLogEntry, +) -> Result<(usize, usize), TransactionLogError> { + let Some(logs) = &entry.logs else { + // Missing required fields are rejected by decode_fields after bounded wire decoding. + return Ok((0, 0)); + }; + let count = logs.logs.len(); + if count > TransactionLogs::MAX_COUNT { + return Err(TransactionLogError::TooManyLogs(count)); + } + let words = logs.logs.iter().map(|log| log.content.len()).sum::(); + if words > TransactionLogs::MAX_NUM_WORDS { + return Err(TransactionLogError::TooManyTotalContentWords(words)); + } + Ok((count, words)) +} + +fn read_frame<'a>( + bytes: &mut &'a [u8], + wire_type: WireType, + max_bytes: usize, +) -> Result<&'a [u8], VerificationError> { + check_wire_type(WireType::LengthDelimited, wire_type).map_err(VerificationError::new)?; + let len = prost::decode_length_delimiter(&mut *bytes).map_err(VerificationError::new)?; + if len > max_bytes { + return Err(TransactionLogError::BundleLimitExceeded.into()); + } + if len > bytes.len() { + return Err(VerificationError::new(LogBundleDecodeError::TruncatedFrame)); + } + let (frame, rest) = bytes.split_at(len); + *bytes = rest; + Ok(frame) +} + +#[cfg(test)] +mod tests; diff --git a/crates/miden-objects/src/decoded/transaction/log_bundle/tests.rs b/crates/miden-objects/src/decoded/transaction/log_bundle/tests.rs new file mode 100644 index 0000000000..2bf61449d7 --- /dev/null +++ b/crates/miden-objects/src/decoded/transaction/log_bundle/tests.rs @@ -0,0 +1,48 @@ +use alloc::vec; + +use miden_protocol::Word; +use miden_protocol::transaction::TransactionLog; + +use super::*; + +#[test] +fn bounded_decoder_checks_frames_and_resource_limits() { + type WireBundle = proto::transaction::PublicTransactionLogBundle; + let wire = WireBundle { + transactions_commitment: Some(Word::empty().into()), + entries: vec![proto::transaction::PublicTransactionLogEntry { + transaction_id: Some(proto::transaction::TransactionId { + id: Some(Word::empty().into()), + }), + logs: Some(proto::transaction::TransactionLogs { + logs: vec![(&TransactionLog::mock(1)).into()], + }), + }], + }; + let limits = TransactionLogBundleLimits { + transactions: 1, + logs: 1, + content_words: 1, + }; + let mut bytes = wire.encode_to_vec(); + bytes.extend_from_slice(&[0x1a, 0]); // Unknown field: skipped but charged to the byte limit. + assert_eq!(WireBundle::decode_bounded(&bytes, limits, bytes.len()).unwrap(), wire); + assert!(WireBundle::decode_bounded(&bytes, limits, bytes.len() - 1).is_err()); + for exhausted in [ + TransactionLogBundleLimits { transactions: 0, ..limits }, + TransactionLogBundleLimits { logs: 0, ..limits }, + TransactionLogBundleLimits { content_words: 0, ..limits }, + ] { + assert!(WireBundle::decode_bounded(&bytes, exhausted, bytes.len()).is_err()); + } + for invalid_frame in [ + &[0x12, 0x81, 0x80, 0x01][..], // Entry frame announces more than 16 KiB. + &[0x12, 10], // Truncated entry frame. + ] { + assert!( + WireBundle::decode_bounded(invalid_frame, limits, WireBundle::MAX_BATCH_BYTES).is_err() + ); + } + bytes.extend_from_slice(&wire.encode_to_vec()); // Duplicate commitment. + assert!(WireBundle::decode_bounded(&bytes, limits, WireBundle::MAX_BATCH_BYTES).is_err()); +} diff --git a/crates/miden-objects/src/decoded/transaction/mod.rs b/crates/miden-objects/src/decoded/transaction/mod.rs index 78303a605c..2e0d5e1160 100644 --- a/crates/miden-objects/src/decoded/transaction/mod.rs +++ b/crates/miden-objects/src/decoded/transaction/mod.rs @@ -55,3 +55,6 @@ pub use batch::{ mod logs; pub use logs::{LogTopic, TransactionLog, TransactionLogDetails, TransactionLogs}; + +mod log_bundle; +pub use log_bundle::PublicTransactionLogBundle; diff --git a/crates/miden-protocol/src/block/proposed_block.rs b/crates/miden-protocol/src/block/proposed_block.rs index 6b1f61a258..81752ec9e1 100644 --- a/crates/miden-protocol/src/block/proposed_block.rs +++ b/crates/miden-protocol/src/block/proposed_block.rs @@ -21,7 +21,13 @@ use crate::block::{ use crate::errors::ProposedBlockError; use crate::note::{NoteId, Nullifier}; use crate::protocol_config::NextProtocolConfig; -use crate::transaction::{InputNoteCommitment, OutputNote, PartialBlockchain, TransactionHeader}; +use crate::transaction::{ + InputNoteCommitment, + OutputNote, + PartialBlockchain, + PublicTransactionLogBundle, + TransactionHeader, +}; use crate::{EMPTY_WORD, MAX_BATCHES_PER_BLOCK, Word}; // PROPOSED BLOCK @@ -571,6 +577,20 @@ impl ProposedBlock { Ok((header, body)) } + /// Builds the block while returning its complete public logs as a separate delivery bundle. + /// + /// Supply one bundle per batch, in batch order. Payloads never enter the block body. The + /// caller can retain/index the returned bundle independently of the block, and can still use + /// [`Self::into_header_and_body`] when public log availability is not required. + pub fn into_header_body_and_logs( + self, + batch_logs: Vec, + ) -> Result<(BlockHeader, BlockBody, PublicTransactionLogBundle), ProposedBlockError> { + let logs = PublicTransactionLogBundle::from_batches(&self.batches, batch_logs)?; + let (header, body) = self.into_header_and_body()?; + Ok((header, body, logs)) + } + /// Consumes self and returns the non-[`Copy`] parts of the block. #[allow(clippy::type_complexity)] pub fn into_parts( diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index aeadd20b57..ef79c9a911 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -1453,6 +1453,8 @@ pub enum ProposedBatchError { #[derive(Debug, Error)] pub enum ProvenBatchError { + #[error("invalid public transaction log bundle")] + TransactionLogs(#[from] crate::transaction::TransactionLogError), #[error("transaction batch must contain at least one transaction")] EmptyTransactionBatch, #[error("transaction {0} appears twice in the proven batch")] @@ -1552,6 +1554,8 @@ pub enum BlockOutputError { #[derive(Debug, Error)] pub enum ProposedBlockError { + #[error("invalid public transaction log bundle")] + TransactionLogs(#[from] crate::transaction::TransactionLogError), #[error("block must contain at least one transaction batch")] EmptyBlock, diff --git a/crates/miden-protocol/src/transaction/logs/bundle.rs b/crates/miden-protocol/src/transaction/logs/bundle.rs new file mode 100644 index 0000000000..fbae86dd09 --- /dev/null +++ b/crates/miden-protocol/src/transaction/logs/bundle.rs @@ -0,0 +1,202 @@ +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +use super::{TransactionLogDetails, TransactionLogError, TransactionLogs}; +use crate::Word; +use crate::batch::{OrderedBatches, ProposedBatch}; +use crate::transaction::{OrderedTransactionHeaders, TransactionId}; + +/// Resource limits for detached public log delivery, enforced in Rust, not by the kernels. +#[derive(Debug, Clone, Copy)] +pub struct TransactionLogBundleLimits { + pub transactions: usize, + pub logs: usize, + pub content_words: usize, +} + +impl TransactionLogBundleLimits { + pub const BATCH: Self = Self { + transactions: 1024, + logs: 4096, + content_words: 65_536, + }; + pub const BLOCK: Self = Self { + transactions: 65_536, + logs: 65_536, + content_words: 524_288, + }; + + /// Checks aggregate counts, including private transactions and transactions with no logs. + pub fn check( + self, + transactions: usize, + logs: usize, + content_words: usize, + ) -> Result<(), TransactionLogError> { + if transactions > self.transactions + || logs > self.logs + || content_words > self.content_words + { + return Err(TransactionLogError::BundleLimitExceeded); + } + Ok(()) + } +} + +/// Complete public logs delivered separately from a batch or block. +/// +/// There is exactly one entry per public native account transaction, including empty log lists, +/// in transaction order. Private transactions have no entries, even when they call public foreign +/// accounts. The transaction commitment binds the entire header sequence, including private +/// transactions. Duplicate logs within a transaction are preserved. +/// +/// Validation establishes agreement with the supplied headers, not their authenticity. Consumers +/// must authenticate the enclosing batch/block separately. Discarding this bundle does not change +/// a block or its validity; it only removes the ability to serve its public logs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicTransactionLogBundle { + transactions_commitment: Word, + entries: Vec<(TransactionId, TransactionLogs)>, +} + +impl PublicTransactionLogBundle { + /// Builds a complete bundle matching the supplied headers, within block delivery limits. + pub fn new( + entries: Vec<(TransactionId, TransactionLogs)>, + headers: &OrderedTransactionHeaders, + ) -> Result { + TransactionLogBundleLimits::BLOCK.check(headers.as_slice().len(), 0, 0)?; + let bundle = Self { + transactions_commitment: headers.commitment(), + entries, + }; + bundle.validate_for_block(headers)?; + Ok(bundle) + } + + /// Extracts public logs before proving consumes the proposal's full transactions. + pub fn from_batch(batch: &ProposedBatch) -> Result { + TransactionLogBundleLimits::BATCH.check(batch.transactions().len(), 0, 0)?; + let mut entries = Vec::new(); + let (mut num_logs, mut num_words) = (0, 0); + for tx in batch.transactions() { + let TransactionLogDetails::Public(logs) = tx.log_details() else { + continue; + }; + num_logs += logs.num_logs(); + num_words += logs.num_content_words(); + TransactionLogBundleLimits::BATCH.check( + batch.transactions().len(), + num_logs, + num_words, + )?; + entries.push((tx.id(), logs.clone())); + } + Self::new(entries, &batch.transaction_headers()) + } + + /// Combines one complete bundle per batch, preserving the block's batch/transaction order. + /// + /// Bundles for private-only batches are required too, so omission or reordering is detected. + pub fn from_batches( + batches: &OrderedBatches, + bundles: Vec, + ) -> Result { + if bundles.len() != batches.as_slice().len() { + return Err(TransactionLogError::BundleAssociationMismatch); + } + let mut entries = Vec::new(); + let (mut num_transactions, mut num_logs, mut num_words) = (0, 0, 0); + for (batch, bundle) in batches.as_slice().iter().zip(bundles) { + bundle.validate_for_batch(batch.transactions())?; + num_transactions += batch.transactions().as_slice().len(); + for (_, logs) in &bundle.entries { + num_logs += logs.num_logs(); + num_words += logs.num_content_words(); + } + TransactionLogBundleLimits::BLOCK.check(num_transactions, num_logs, num_words)?; + entries.extend(bundle.entries); + } + Self::new(entries, &batches.to_transactions()) + } + + /// Commitment to all transaction headers, including those with private or empty logs. + pub fn transactions_commitment(&self) -> Word { + self.transactions_commitment + } + + /// Public log lists in transaction order, keyed by transaction ID. + pub fn entries(&self) -> &[(TransactionId, TransactionLogs)] { + &self.entries + } + + /// Checks completeness, associations, visibility, commitments and batch delivery limits. + pub fn validate_for_batch( + &self, + headers: &OrderedTransactionHeaders, + ) -> Result<(), TransactionLogError> { + self.validate(headers, TransactionLogBundleLimits::BATCH) + } + + /// Checks completeness, associations, visibility, commitments and block delivery limits. + pub fn validate_for_block( + &self, + headers: &OrderedTransactionHeaders, + ) -> Result<(), TransactionLogError> { + self.validate(headers, TransactionLogBundleLimits::BLOCK) + } + + fn validate( + &self, + headers: &OrderedTransactionHeaders, + limits: TransactionLogBundleLimits, + ) -> Result<(), TransactionLogError> { + self.validate_limits(headers.as_slice().len(), limits)?; + if self.transactions_commitment != headers.commitment() { + return Err(TransactionLogError::BundleCommitmentMismatch); + } + self.validate_entries(headers) + } + + fn validate_limits( + &self, + num_transactions: usize, + limits: TransactionLogBundleLimits, + ) -> Result<(), TransactionLogError> { + limits.check(num_transactions.max(self.entries.len()), 0, 0)?; + let (mut num_logs, mut num_words) = (0, 0); + for (_, logs) in &self.entries { + num_logs += logs.num_logs(); + num_words += logs.num_content_words(); + limits.check(num_transactions, num_logs, num_words)?; + } + Ok(()) + } + + fn validate_entries( + &self, + headers: &OrderedTransactionHeaders, + ) -> Result<(), TransactionLogError> { + let mut ids = BTreeSet::new(); + if headers.as_slice().iter().any(|header| !ids.insert(header.id())) { + return Err(TransactionLogError::BundleAssociationMismatch); + } + let public_headers = + headers.as_slice().iter().filter(|header| header.account_id().is_public()); + if public_headers.clone().count() != self.entries.len() { + return Err(TransactionLogError::BundleAssociationMismatch); + } + for ((id, logs), header) in self.entries.iter().zip(public_headers) { + if *id != header.id() { + return Err(TransactionLogError::BundleAssociationMismatch); + } + if logs.commitment() != header.logs_commitment() { + return Err(TransactionLogError::BundleCommitmentMismatch); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/miden-protocol/src/transaction/logs/bundle/tests.rs b/crates/miden-protocol/src/transaction/logs/bundle/tests.rs new file mode 100644 index 0000000000..02ee2b189f --- /dev/null +++ b/crates/miden-protocol/src/transaction/logs/bundle/tests.rs @@ -0,0 +1,109 @@ +use super::*; +use crate::batch::ProvenBatch; +use crate::testing::account_id::{ + ACCOUNT_ID_PRIVATE_SENDER, + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, +}; +use crate::testing::dummy_execution_proof; +use crate::transaction::{InputNotes, TransactionHeader, TransactionLog}; + +fn header(seed: u32, public: bool, logs: &TransactionLogs) -> TransactionHeader { + TransactionHeader::new( + if public { + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE + } else { + ACCOUNT_ID_PRIVATE_SENDER + } + .try_into() + .unwrap(), + Word::from([seed; 4]), + Word::from([seed + 1; 4]), + InputNotes::default(), + vec![], + logs.commitment(), + ) + .unwrap() +} + +fn headers(values: Vec) -> OrderedTransactionHeaders { + OrderedTransactionHeaders::new_unchecked(values) +} + +fn batch(headers: OrderedTransactionHeaders) -> ProvenBatch { + ProvenBatch::new_unchecked( + crate::batch::BatchId::from_ids( + headers.as_slice().iter().map(|tx| (tx.id(), tx.account_id())), + ), + Word::empty(), + 0.into(), + Default::default(), + InputNotes::default(), + vec![], + 1.into(), + headers, + dummy_execution_proof(), + ) + .unwrap() +} + +#[test] +fn bundle_validates_public_transaction_logs() { + // An emitter's privacy does not determine the native transaction's visibility (FPI). + let log = TransactionLog::mock(1); + let logs = TransactionLogs::new(vec![log.clone(), log]).unwrap(); + let public = header(1, true, &logs); + let private = header(2, false, &logs); + let empty = header(3, true, &TransactionLogs::default()); + let entries = vec![(public.id(), logs.clone()), (empty.id(), TransactionLogs::default())]; + let all = headers(vec![public.clone(), private.clone(), empty.clone()]); + let bundle = PublicTransactionLogBundle::new(entries.clone(), &all).unwrap(); + assert_eq!(bundle.entries(), entries); + assert_eq!(bundle.transactions_commitment(), all.commitment()); + bundle.validate_for_batch(&all).unwrap(); + + for invalid in [ + vec![], + vec![entries[0].clone()], // Missing the public transaction with no logs. + vec![entries[1].clone(), entries[0].clone()], + vec![entries[0].clone(), entries[0].clone()], + vec![entries[0].clone(), (private.id(), logs.clone()), entries[1].clone()], + ] { + assert!(matches!( + PublicTransactionLogBundle::new(invalid, &all), + Err(TransactionLogError::BundleAssociationMismatch) + )); + } + assert!(matches!( + PublicTransactionLogBundle::new( + vec![(public.id(), TransactionLogs::default()), entries[1].clone()], + &all, + ), + Err(TransactionLogError::BundleCommitmentMismatch) + )); + let changed_private = header(9, false, &logs); + assert!(matches!( + bundle.validate_for_block(&headers(vec![public, changed_private, empty])), + Err(TransactionLogError::BundleCommitmentMismatch) + )); +} + +#[test] +fn aggregation_binds_public_and_private_batches_in_order() { + let logs = TransactionLogs::new(vec![TransactionLog::mock(1)]).unwrap(); + let public = header(1, true, &logs); + let first = headers(vec![public.clone()]); + let second = headers(vec![header(2, false, &logs)]); + let third = headers(vec![header(3, false, &logs)]); + let a = PublicTransactionLogBundle::new(vec![(public.id(), logs.clone())], &first).unwrap(); + let b = PublicTransactionLogBundle::new(vec![], &second).unwrap(); + let c = PublicTransactionLogBundle::new(vec![], &third).unwrap(); + let batches = OrderedBatches::new(vec![batch(first), batch(second), batch(third)]); + let combined = + PublicTransactionLogBundle::from_batches(&batches, vec![a.clone(), b.clone(), c.clone()]) + .unwrap(); + assert_eq!(combined.entries(), &[(public.id(), logs)]); + combined.validate_for_block(&batches.to_transactions()).unwrap(); + // Swapping even private-only bundles must fail, despite both having no public entries. + assert!(PublicTransactionLogBundle::from_batches(&batches, vec![a, c, b]).is_err()); + assert!(PublicTransactionLogBundle::from_batches(&batches, vec![]).is_err()); +} diff --git a/crates/miden-protocol/src/transaction/logs/errors.rs b/crates/miden-protocol/src/transaction/logs/errors.rs index 43846391d6..cb291adc24 100644 --- a/crates/miden-protocol/src/transaction/logs/errors.rs +++ b/crates/miden-protocol/src/transaction/logs/errors.rs @@ -7,6 +7,12 @@ use super::{TransactionLog, TransactionLogs}; #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum TransactionLogError { + #[error("public log bundle exceeds its delivery resource limits")] + BundleLimitExceeded, + #[error("public log bundle entries do not match the ordered public transactions")] + BundleAssociationMismatch, + #[error("public log bundle does not match the transaction commitments")] + BundleCommitmentMismatch, /// An individual transaction log content exceeds its word limit. #[error( "log content has {0} words, exceeding the maximum of {max}", diff --git a/crates/miden-protocol/src/transaction/logs/mod.rs b/crates/miden-protocol/src/transaction/logs/mod.rs index 42d5b071a9..8b6233ebc2 100644 --- a/crates/miden-protocol/src/transaction/logs/mod.rs +++ b/crates/miden-protocol/src/transaction/logs/mod.rs @@ -15,6 +15,9 @@ pub use topic::LogTopic; mod details; pub use details::TransactionLogDetails; +mod bundle; +pub use bundle::{PublicTransactionLogBundle, TransactionLogBundleLimits}; + mod errors; pub use errors::TransactionLogError; diff --git a/crates/miden-protocol/src/transaction/mod.rs b/crates/miden-protocol/src/transaction/mod.rs index 8a90695cc8..be5c9df597 100644 --- a/crates/miden-protocol/src/transaction/mod.rs +++ b/crates/miden-protocol/src/transaction/mod.rs @@ -27,7 +27,9 @@ pub use inputs::{AccountInputs, InputNote, InputNotes, ToInputNoteCommitments, T pub use kernel::{TransactionAdviceInputs, TransactionEventId, TransactionKernel, memory}; pub use logs::{ LogTopic, + PublicTransactionLogBundle, TransactionLog, + TransactionLogBundleLimits, TransactionLogDetails, TransactionLogError, TransactionLogs, diff --git a/crates/miden-tx-batch/src/local_batch_prover.rs b/crates/miden-tx-batch/src/local_batch_prover.rs index 958876efd8..f9af672f01 100644 --- a/crates/miden-tx-batch/src/local_batch_prover.rs +++ b/crates/miden-tx-batch/src/local_batch_prover.rs @@ -3,6 +3,7 @@ use alloc::string::ToString; use miden_processor::ExecutionError; use miden_protocol::batch::{ProposedBatch, ProvenBatch}; use miden_protocol::errors::ProvenBatchError; +use miden_protocol::transaction::PublicTransactionLogBundle; use miden_prover::HashFunction::Poseidon2; use miden_prover::{ExecutionProof, Prover}; @@ -91,6 +92,20 @@ impl LocalBatchProver { Self::build_proven_batch(proposed_batch, proof) } + /// Proves a batch and returns its public logs separately for delivery to the node. + /// + /// The bundle is checked against the proposal before proving. Neither the batch wire format + /// nor the kernel's public inputs/outputs change. The proof has the same limitations as + /// [`Self::prove`]; bundle validation does not add recursive proof verification. + pub fn prove_with_logs( + &self, + executed_batch: ExecutedBatch, + ) -> Result<(ProvenBatch, PublicTransactionLogBundle), ProvenBatchError> { + let logs = PublicTransactionLogBundle::from_batch(executed_batch.proposed_batch())?; + let batch = self.prove(executed_batch)?; + Ok((batch, logs)) + } + /// Returns a [`ProvenBatch`] built from the proposed batch with a dummy [`ExecutionProof`] /// attached, without running the batch kernel. #[cfg(any(feature = "testing", test))] diff --git a/docs/src/transaction.md b/docs/src/transaction.md index 0eea45db20..389be6552a 100644 --- a/docs/src/transaction.md +++ b/docs/src/transaction.md @@ -203,6 +203,44 @@ proving. Empty transaction logs commit to zero, so the presence of transaction l Local execution data and standard signing summaries retain complete transaction logs, including in debug output. Remote provers and validators that decrypt execution witnesses can access this data. +### Batch and block delivery + +Transaction headers retain `logs_commitment`. Transaction IDs bind this commitment, and the existing +batch ID and block transaction commitment bind the ordered transaction IDs. No separate log root is +needed. Proven batches and block bodies contain these headers, without public or private log payloads. + +Public payloads can be delivered independently in a `PublicTransactionLogBundle`. The bundle commits +to the entire transaction sequence and contains one entry per public native account transaction, +including empty lists, in transaction order. Private transactions have no entries, including when +they emit logs through public foreign accounts. Duplicate logs within a transaction are preserved. + +`LocalBatchProver::prove_with_logs` returns a proven batch and its bundle. Alternatively, +`PublicTransactionLogBundle::from_batch` extracts the bundle before proving consumes the proposal. +`ProposedBlock::into_header_body_and_logs` accepts one bundle per batch and returns the header, body, +and combined public logs separately. It rejects missing, reordered, or mismatched bundles. + +Canonical protobuf defines a separate `PublicTransactionLogBundle` message. Untrusted bytes should +use its `decode_for_batch` or `decode_for_block` methods, which bound the encoded size and entry frames +before allocation and check completeness against the supplied headers. These headers must come from +an independently authenticated batch or block. Node ingestion that promises log availability must +use this complete-bundle validation; ordinary block validation does not require the bundle. + +These Rust delivery checks do not add recursive verification to the skeleton batch/block MASM +kernels. Node storage, replication, retention, and query APIs must handle bundles separately. Dropping +a bundle leaves the block valid but prevents reconstructing its public logs from that block alone. + +Delivery limits are separate from block validity and are enforced by the Rust bundle APIs: + +| Resource | Batch bundle | Block bundle | +| --- | ---: | ---: | +| Transactions, including private and empty | 1,024 | 65,536 | +| Public logs | 4,096 | 65,536 | +| Content words | 65,536 | 524,288 | +| Encoded protobuf bytes | 4 MiB | 32 MiB | + +The bounded decoder additionally limits each transaction entry frame to 16 KiB, including metadata +and unknown fields. The existing per-transaction content and log-count limits still apply. + ### Limits A transaction allows up to 64 transaction logs, with at most 64 content words per log and 64 content