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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
2 changes: 2 additions & 0 deletions crates/miden-objects/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
".transaction.TransactionLog",
".transaction.TransactionLogs",
".transaction.TransactionLogDetails",
".transaction.PublicTransactionLogEntry",
".transaction.PublicTransactionLogBundle",
".blockchain.SignedBlock",
".blockchain.BlockBody",
".blockchain.OutputNoteBatch",
Expand Down
13 changes: 13 additions & 0 deletions crates/miden-objects/proto/transaction.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
17 changes: 17 additions & 0 deletions crates/miden-objects/src/conversion/transaction_logs.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use miden_protocol::transaction::{
LogTopic,
PublicTransactionLogBundle,
TransactionLog,
TransactionLogDetails,
TransactionLogs,
Expand All @@ -16,6 +17,22 @@ impl From<LogTopic> 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 {
Expand Down
211 changes: 211 additions & 0 deletions crates/miden-objects/src/decoded/transaction/log_bundle.rs
Original file line number Diff line number Diff line change
@@ -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<VerifiedBundle, VerificationError> {
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::<usize>();
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<VerifiedBundle, VerificationError> {
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<VerifiedBundle, VerificationError> {
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<VerifiedBundle, VerificationError> {
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<VerifiedBundle, VerificationError> {
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<Self, VerificationError> {
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<proto::transaction::PublicTransactionLogEntry, VerificationError> {
// 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::<usize>();
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;
48 changes: 48 additions & 0 deletions crates/miden-objects/src/decoded/transaction/log_bundle/tests.rs
Original file line number Diff line number Diff line change
@@ -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());
}
3 changes: 3 additions & 0 deletions crates/miden-objects/src/decoded/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ pub use batch::{

mod logs;
pub use logs::{LogTopic, TransactionLog, TransactionLogDetails, TransactionLogs};

mod log_bundle;
pub use log_bundle::PublicTransactionLogBundle;
22 changes: 21 additions & 1 deletion crates/miden-protocol/src/block/proposed_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<PublicTransactionLogBundle>,
) -> 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(
Expand Down
4 changes: 4 additions & 0 deletions crates/miden-protocol/src/errors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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,

Expand Down
Loading
Loading