From 77f0ffce1dd337deb4035971f4e9ce65ec849f08 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 12 Aug 2026 10:53:26 +0200 Subject: [PATCH 1/8] feat(proto): add felt and word wire conversions Add canonical protobuf wrappers for Miden field elements and words. Implement owned and borrowed domain conversions with strict encoded-length and canonical-value validation, plus focused round-trip and malformed-input tests. --- crates/proto/src/domain/mod.rs | 1 + crates/proto/src/domain/primitives.rs | 191 ++++++++++++++++++++++++++ proto/proto/types/primitives.proto | 18 +++ 3 files changed, 210 insertions(+) create mode 100644 crates/proto/src/domain/primitives.rs diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index d19046cb28..6763b66195 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -6,6 +6,7 @@ pub mod encryption; pub mod merkle; pub mod note; pub mod nullifier; +pub mod primitives; pub mod proof_request; pub mod transaction; diff --git a/crates/proto/src/domain/primitives.rs b/crates/proto/src/domain/primitives.rs new file mode 100644 index 0000000000..b7eb4d44fc --- /dev/null +++ b/crates/proto/src/domain/primitives.rs @@ -0,0 +1,191 @@ +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_protocol::{Felt, Word}; + +use crate::errors::ConversionError; +use crate::generated as proto; + +// CONSTANTS +// ================================================================================================ + +const FELT_SERIALIZED_SIZE: usize = size_of::(); +const WORD_SERIALIZED_SIZE: usize = Word::SERIALIZED_SIZE; + +// HELPERS +// ================================================================================================ + +fn ensure_exact_length( + encoded: &[u8], + expected: usize, + field: &'static str, +) -> Result<(), ConversionError> { + if encoded.len() != expected { + return Err(ConversionError::message(format!( + "expected exactly {expected} bytes, got {}", + encoded.len() + )) + .context(field)); + } + + Ok(()) +} + +// FELT +// ================================================================================================ + +impl From for proto::primitives::Felt { + fn from(value: Felt) -> Self { + Self { encoded: value.to_bytes() } + } +} + +impl From<&Felt> for proto::primitives::Felt { + fn from(value: &Felt) -> Self { + Self { encoded: value.to_bytes() } + } +} + +impl TryFrom for Felt { + type Error = ConversionError; + + fn try_from(value: proto::primitives::Felt) -> Result { + Self::try_from(&value) + } +} + +impl TryFrom<&proto::primitives::Felt> for Felt { + type Error = ConversionError; + + fn try_from(value: &proto::primitives::Felt) -> Result { + ensure_exact_length(&value.encoded, FELT_SERIALIZED_SIZE, "felt.encoded")?; + + Self::read_from_bytes(&value.encoded) + .map_err(|err| ConversionError::deserialization("felt.encoded", err)) + } +} + +// WORD +// ================================================================================================ + +impl From for proto::primitives::Word { + fn from(value: Word) -> Self { + Self { encoded: value.to_bytes() } + } +} + +impl From<&Word> for proto::primitives::Word { + fn from(value: &Word) -> Self { + Self { encoded: value.to_bytes() } + } +} + +impl TryFrom for Word { + type Error = ConversionError; + + fn try_from(value: proto::primitives::Word) -> Result { + Self::try_from(&value) + } +} + +impl TryFrom<&proto::primitives::Word> for Word { + type Error = ConversionError; + + fn try_from(value: &proto::primitives::Word) -> Result { + ensure_exact_length(&value.encoded, WORD_SERIALIZED_SIZE, "word.encoded")?; + + Self::read_from_bytes(&value.encoded) + .map_err(|err| ConversionError::deserialization("word.encoded", err)) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn felt_roundtrip() { + for felt in [Felt::ZERO, Felt::new_unchecked(42), Felt::new_unchecked(Felt::ORDER - 1)] { + let encoded = proto::primitives::Felt::from(felt); + + assert_eq!(encoded.encoded.len(), FELT_SERIALIZED_SIZE); + assert_eq!(Felt::try_from(encoded.clone()).unwrap(), felt); + assert_eq!(Felt::try_from(&encoded).unwrap(), felt); + assert_eq!(proto::primitives::Felt::from(&felt), encoded); + } + } + + #[test] + fn felt_rejects_invalid_lengths() { + for length in [0, 7, 9, 1024] { + let value = proto::primitives::Felt { encoded: vec![0; length] }; + let err = Felt::try_from(value).unwrap_err(); + + assert_eq!( + err.to_string(), + format!("felt.encoded: expected exactly 8 bytes, got {length}") + ); + } + } + + #[test] + fn felt_rejects_non_canonical_value() { + let value = proto::primitives::Felt { + encoded: Felt::ORDER.to_le_bytes().to_vec(), + }; + let err = Felt::try_from(value).unwrap_err(); + + assert!(err.to_string().starts_with("failed to deserialize felt.encoded:")); + } + + #[test] + fn word_roundtrip() { + let words = [ + Word::default(), + Word::new([ + Felt::new_unchecked(1), + Felt::new_unchecked(2), + Felt::new_unchecked(3), + Felt::new_unchecked(4), + ]), + ]; + + for word in words { + let encoded = proto::primitives::Word::from(word); + + assert_eq!(encoded.encoded.len(), WORD_SERIALIZED_SIZE); + assert_eq!(Word::try_from(encoded.clone()).unwrap(), word); + assert_eq!(Word::try_from(&encoded).unwrap(), word); + assert_eq!(proto::primitives::Word::from(&word), encoded); + } + } + + #[test] + fn word_rejects_invalid_lengths() { + for length in [0, 31, 33, 1024] { + let value = proto::primitives::Word { encoded: vec![0; length] }; + let err = Word::try_from(value).unwrap_err(); + + assert_eq!( + err.to_string(), + format!("word.encoded: expected exactly 32 bytes, got {length}") + ); + } + } + + #[test] + fn word_rejects_non_canonical_element() { + for element_index in 0..Word::NUM_ELEMENTS { + let mut encoded = vec![0; WORD_SERIALIZED_SIZE]; + let offset = element_index * FELT_SERIALIZED_SIZE; + encoded[offset..offset + FELT_SERIALIZED_SIZE] + .copy_from_slice(&Felt::ORDER.to_le_bytes()); + + let value = proto::primitives::Word { encoded }; + let err = Word::try_from(value).unwrap_err(); + + assert!(err.to_string().starts_with("failed to deserialize word.encoded:")); + } + } +} diff --git a/proto/proto/types/primitives.proto b/proto/proto/types/primitives.proto index a0c30b812a..68088726af 100644 --- a/proto/proto/types/primitives.proto +++ b/proto/proto/types/primitives.proto @@ -1,6 +1,24 @@ syntax = "proto3"; package primitives; +// FIELD ELEMENT +// ================================================================================================ + +// A field element encoded by miden_protocol::utils::serde::Serializable. +message Felt { + // Exactly eight bytes containing a canonical field element. + bytes encoded = 1; +} + +// WORD +// ================================================================================================ + +// A word encoded by miden_protocol::utils::serde::Serializable. +message Word { + // Exactly 32 bytes containing four canonical field elements. + bytes encoded = 1; +} + // ASSET // ================================================================================================ From 374c3715490e9d70d59b01e2c05ebfbc86ea259e Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 12 Aug 2026 11:14:30 +0200 Subject: [PATCH 2/8] feat(proto): structure note attachments Replace serialized note attachment payloads with validated protobuf messages backed by canonical Word wrappers. Update note and RPC conversions, reserve the removed wire fields, add attachment boundary and consistency tests, and lower the GetNotesById limit to keep worst-case responses under 4 MiB. --- crates/proto/src/domain/note.rs | 309 ++++++++++++++++++- crates/rpc/src/server/api/get_notes_by_id.rs | 86 +++++- crates/utils/src/limiter.rs | 8 +- proto/proto/types/note.proto | 31 +- 4 files changed, 407 insertions(+), 27 deletions(-) diff --git a/crates/proto/src/domain/note.rs b/crates/proto/src/domain/note.rs index 0a52e02775..b3e7a4fdf0 100644 --- a/crates/proto/src/domain/note.rs +++ b/crates/proto/src/domain/note.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use miden_protocol::crypto::merkle::SparseMerklePath; use miden_protocol::note::{ Note, + NoteAttachment, NoteAttachmentHeader, NoteAttachmentScheme, NoteAttachments, @@ -21,7 +22,7 @@ use miden_protocol::utils::serde::Serializable; use miden_protocol::{MastForest, MastNodeId, Word}; use miden_standards::note::AccountTargetNetworkNote; -use crate::decode::{ConversionResultExt, DecodeBytesExt, GrpcDecodeExt}; +use crate::decode::{ConversionResultExt, DecodeBytesExt, GrpcDecodeExt, GrpcStructDecoder}; use crate::errors::ConversionError; use crate::{decode, generated as proto}; @@ -111,21 +112,82 @@ impl TryFrom for NoteMetadata { // NOTE // ================================================================================================ +impl From<&NoteAttachment> for proto::note::NoteAttachment { + fn from(attachment: &NoteAttachment) -> Self { + Self { + scheme: u32::from(attachment.attachment_scheme().as_u16()), + words: attachment.content().as_words().iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for NoteAttachment { + type Error = ConversionError; + + fn try_from(attachment: proto::note::NoteAttachment) -> Result { + let scheme = u16::try_from(attachment.scheme).context("scheme")?; + let scheme = NoteAttachmentScheme::new(scheme) + .map_err(ConversionError::from) + .context("scheme")?; + let words = attachment + .words + .into_iter() + .map(Word::try_from) + .collect::, _>>() + .context("words")?; + + NoteAttachment::with_words(scheme, words) + .map_err(ConversionError::from) + .context("words") + } +} + +impl From for proto::note::NoteAttachments { + fn from(attachments: NoteAttachments) -> Self { + Self::from(&attachments) + } +} + +impl From<&NoteAttachments> for proto::note::NoteAttachments { + fn from(attachments: &NoteAttachments) -> Self { + Self { + attachments: attachments.iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for NoteAttachments { + type Error = ConversionError; + + fn try_from(attachments: proto::note::NoteAttachments) -> Result { + let attachments = attachments + .attachments + .into_iter() + .map(NoteAttachment::try_from) + .collect::, _>>() + .context("attachments")?; + + NoteAttachments::new(attachments) + .map_err(ConversionError::from) + .context("attachments") + } +} + impl From for proto::note::NetworkNote { fn from(note: Note) -> Self { let metadata = Some(proto::note::NoteMetadata::from(*note.metadata())); - let attachments = note.attachments().to_bytes(); + let note_attachments = Some(note.attachments().into()); let details = NoteDetails::from(note).to_bytes(); - Self { metadata, details, attachments } + Self { metadata, details, note_attachments } } } impl From for proto::note::Note { fn from(note: Note) -> Self { let metadata = Some(proto::note::NoteMetadata::from(*note.metadata())); - let attachments = note.attachments().to_bytes(); + let note_attachments = Some(note.attachments().into()); let details = Some(NoteDetails::from(note).to_bytes()); - Self { metadata, details, attachments } + Self { metadata, details, note_attachments } } } @@ -140,14 +202,14 @@ impl TryFrom for AccountTargetNetworkNote { fn try_from(value: proto::note::NetworkNote) -> Result { let decoder = value.decoder(); - let proto::note::NetworkNote { metadata, details, attachments } = value; + let proto::note::NetworkNote { metadata, details, note_attachments } = value; let metadata = decode!(decoder, metadata)?; let partial_metadata = partial_note_metadata_from_proto(metadata)?; let note_details = NoteDetails::decode_bytes(&details, "NoteDetails")?; let (assets, recipient) = note_details.into_parts(); - let attachments = decode_attachments(&attachments)?; + let attachments = decode_note_attachments::(note_attachments)?; let note = Note::with_attachments(assets, partial_metadata, recipient, attachments); AccountTargetNetworkNote::new(note).map_err(ConversionError::from) @@ -159,7 +221,7 @@ impl TryFrom for Note { fn try_from(proto_note: proto::note::Note) -> Result { let decoder = proto_note.decoder(); - let proto::note::Note { metadata, details, attachments } = proto_note; + let proto::note::Note { metadata, details, note_attachments } = proto_note; let metadata = decode!(decoder, metadata)?; let partial_metadata = partial_note_metadata_from_proto(metadata)?; @@ -167,7 +229,7 @@ impl TryFrom for Note { let details: Vec = decode!(decoder, details)?; let note_details = NoteDetails::decode_bytes(&details, "NoteDetails")?; let (assets, recipient) = note_details.into_parts(); - let attachments = decode_attachments(&attachments)?; + let attachments = decode_note_attachments::(note_attachments)?; Ok(Note::with_attachments(assets, partial_metadata, recipient, attachments)) } @@ -303,14 +365,11 @@ fn partial_note_metadata_from_proto( Ok(PartialNoteMetadata::new(sender, note_type).with_tag(tag)) } -/// Decodes a serialized [`NoteAttachments`] payload. Empty bytes are treated as an empty collection -/// so that proto3's default value round-trips cleanly. -fn decode_attachments(bytes: &[u8]) -> Result { - if bytes.is_empty() { - Ok(NoteAttachments::empty()) - } else { - NoteAttachments::decode_bytes(bytes, "NoteAttachments") - } +/// Requires and decodes the structured attachments carried by a note message. +fn decode_note_attachments( + attachments: Option, +) -> Result { + GrpcStructDecoder::::default().decode_field("note_attachments", attachments) } #[cfg(test)] @@ -319,6 +378,33 @@ mod tests { use super::*; + fn word(value: u32) -> Word { + Word::from([value, value + 1, value + 2, value + 3]) + } + + fn attachment(scheme: u16, num_words: usize, first_word: u32) -> NoteAttachment { + let words = (0..num_words) + .map(|index| word(first_word + u32::try_from(index).unwrap() * 4)) + .collect(); + NoteAttachment::with_words(NoteAttachmentScheme::new(scheme).unwrap(), words).unwrap() + } + + fn proto_attachment(scheme: u32, num_words: usize) -> proto::note::NoteAttachment { + proto::note::NoteAttachment { + scheme, + words: vec![ + proto::primitives::Word { encoded: vec![0; Word::SERIALIZED_SIZE] }; + num_words + ], + } + } + + fn note_with_attachments(attachments: NoteAttachments) -> Note { + let base = Note::mock_noop(word(100)); + let (assets, metadata, recipient, _) = base.into_parts(); + Note::with_attachments(assets, metadata.into_partial_metadata(), recipient, attachments) + } + #[test] fn note_header_roundtrip_preserves_id() { // Build a NoteHeader with a known details_commitment and metadata. @@ -348,4 +434,193 @@ mod tests { assert_eq!(decoded.details_commitment(), original.details_commitment()); assert_eq!(decoded.metadata(), original.metadata()); } + + #[test] + fn empty_attachments_roundtrip() { + let original = NoteAttachments::empty(); + let encoded = proto::note::NoteAttachments::from(original.clone()); + + assert!(encoded.attachments.is_empty()); + assert_eq!(NoteAttachments::try_from(encoded).unwrap(), original); + } + + #[test] + fn one_attachment_with_none_scheme_roundtrips() { + let original = + NoteAttachments::from(NoteAttachment::with_word(NoteAttachmentScheme::none(), word(1))); + let encoded = proto::note::NoteAttachments::from(&original); + + assert_eq!(encoded.attachments[0].scheme, 1); + assert_eq!(NoteAttachments::try_from(encoded).unwrap(), original); + } + + #[test] + fn attachment_and_word_order_and_duplicate_schemes_are_preserved() { + let original = NoteAttachments::new(vec![ + attachment(42, 3, 1), + attachment(42, 2, 101), + attachment(7, 1, 201), + ]) + .unwrap(); + + let encoded = proto::note::NoteAttachments::from(&original); + assert_eq!( + encoded.attachments.iter().map(|item| item.scheme).collect::>(), + [42, 42, 7] + ); + assert_eq!( + encoded.attachments[0] + .words + .iter() + .map(|item| Word::try_from(item).unwrap()) + .collect::>(), + original.get(0).unwrap().content().as_words() + ); + assert_eq!(NoteAttachments::try_from(encoded).unwrap(), original); + } + + #[test] + fn attachment_boundaries_are_accepted() { + let four = NoteAttachments::new(vec![ + attachment(1, 1, 1), + attachment(2, 1, 10), + attachment(3, 1, 20), + attachment(4, 1, 30), + ]) + .unwrap(); + assert_eq!( + NoteAttachments::try_from(proto::note::NoteAttachments::from(four.clone())).unwrap(), + four + ); + + let max_single = NoteAttachments::from(attachment(1, 256, 1)); + assert_eq!( + NoteAttachments::try_from(proto::note::NoteAttachments::from(max_single.clone())) + .unwrap(), + max_single + ); + + let max_total = NoteAttachments::new(vec![ + attachment(1, 128, 1), + attachment(2, 128, 1001), + attachment(3, 128, 2001), + attachment(4, 128, 3001), + ]) + .unwrap(); + assert_eq!( + NoteAttachments::try_from(proto::note::NoteAttachments::from(max_total.clone())) + .unwrap(), + max_total + ); + } + + #[test] + fn invalid_attachment_schemes_are_rejected() { + for scheme in [0, 65_535, u32::from(u16::MAX) + 1] { + let err = NoteAttachment::try_from(proto_attachment(scheme, 1)).unwrap_err(); + assert!(err.to_string().starts_with("scheme:"), "unexpected error: {err}"); + } + } + + #[test] + fn invalid_attachment_sizes_are_rejected() { + let empty = NoteAttachment::try_from(proto_attachment(1, 0)).unwrap_err(); + assert!(empty.to_string().starts_with("words:"), "unexpected error: {empty}"); + + let too_large = NoteAttachment::try_from(proto_attachment(1, 257)).unwrap_err(); + assert!(too_large.to_string().starts_with("words:"), "unexpected error: {too_large}"); + } + + #[test] + fn invalid_attachment_collections_are_rejected() { + let five = proto::note::NoteAttachments { + attachments: (1..=5).map(|scheme| proto_attachment(scheme, 1)).collect(), + }; + let err = NoteAttachments::try_from(five).unwrap_err(); + assert!(err.to_string().starts_with("attachments:"), "unexpected error: {err}"); + + let over_total = proto::note::NoteAttachments { + attachments: vec![ + proto_attachment(1, 171), + proto_attachment(2, 171), + proto_attachment(3, 171), + ], + }; + let err = NoteAttachments::try_from(over_total).unwrap_err(); + assert!(err.to_string().starts_with("attachments:"), "unexpected error: {err}"); + } + + #[test] + fn malformed_primitive_word_is_rejected_with_context() { + let value = proto::note::NoteAttachment { + scheme: 1, + words: vec![proto::primitives::Word { encoded: vec![0; 31] }], + }; + let err = NoteAttachment::try_from(value).unwrap_err(); + + assert!(err.to_string().starts_with("words.word.encoded:"), "unexpected error: {err}"); + } + + #[test] + fn attachment_commitment_roundtrips() { + let original = + NoteAttachments::new(vec![attachment(11, 4, 1), attachment(12, 3, 100)]).unwrap(); + let expected_commitment = original.to_commitment(); + let decoded = + NoteAttachments::try_from(proto::note::NoteAttachments::from(original)).unwrap(); + + assert_eq!(decoded.to_commitment(), expected_commitment); + } + + #[test] + fn note_encoding_keeps_attachments_and_metadata_consistent() { + let attachments = + NoteAttachments::new(vec![attachment(11, 2, 1), attachment(11, 3, 100)]).unwrap(); + let note = note_with_attachments(attachments.clone()); + let encoded = proto::note::Note::from(note.clone()); + let metadata = encoded.metadata.as_ref().unwrap(); + let encoded_attachments = encoded.note_attachments.as_ref().unwrap(); + + assert_eq!( + metadata.attachment_schemes, + attachments + .to_headers() + .iter() + .map(|header| u32::from(header.scheme().map_or(0, |scheme| scheme.as_u16()))) + .collect::>() + ); + assert_eq!( + Word::try_from(metadata.attachments_commitment.as_ref().unwrap()).unwrap(), + attachments.to_commitment() + ); + assert_eq!(NoteAttachments::try_from(encoded_attachments.clone()).unwrap(), attachments); + + let decoded = Note::try_from(encoded).unwrap(); + assert_eq!(decoded.attachments(), note.attachments()); + assert_eq!( + decoded.metadata().attachments_commitment(), + note.metadata().attachments_commitment() + ); + } + + #[test] + fn missing_structured_attachments_are_rejected() { + let mut encoded = proto::note::Note::from(note_with_attachments(NoteAttachments::empty())); + encoded.note_attachments = None; + + let err = Note::try_from(encoded).unwrap_err(); + assert!(err.to_string().contains("note_attachments"), "unexpected error: {err}"); + + let default_note = proto::note::Note::default(); + let err = decode_note_attachments::(default_note.note_attachments) + .unwrap_err(); + assert!(err.to_string().contains("note_attachments"), "unexpected error: {err}"); + + let default_network_note = proto::note::NetworkNote::default(); + let err = decode_note_attachments::( + default_network_note.note_attachments, + ) + .unwrap_err(); + assert!(err.to_string().contains("note_attachments"), "unexpected error: {err}"); + } } diff --git a/crates/rpc/src/server/api/get_notes_by_id.rs b/crates/rpc/src/server/api/get_notes_by_id.rs index bcbc6bcb90..c18101a76f 100644 --- a/crates/rpc/src/server/api/get_notes_by_id.rs +++ b/crates/rpc/src/server/api/get_notes_by_id.rs @@ -70,7 +70,91 @@ fn note_record_to_proto(note: NoteRecord) -> proto::note::CommittedNote { let note = Some(proto::note::Note { metadata: Some(note.metadata.into()), details: note.details.map(|details| details.to_bytes()), - attachments: note.attachments.to_bytes(), + note_attachments: Some(note.attachments.into()), }); proto::note::CommittedNote { inclusion_proof, note } } + +#[cfg(test)] +mod tests { + use miden_node_proto::prost::Message; + use miden_node_utils::limiter::{ + MAX_RESPONSE_PAYLOAD_BYTES, + QueryParamLimiter, + QueryParamNoteIdLimit, + }; + use miden_protocol::NOTE_MAX_SIZE; + + use super::*; + + fn maximum_representative_note() -> proto::note::CommittedNote { + let digest = proto::primitives::Digest { + d0: u64::MAX, + d1: u64::MAX, + d2: u64::MAX, + d3: u64::MAX, + }; + let attachment = proto::note::NoteAttachment { + scheme: u32::from(u16::MAX - 1), + words: vec![ + proto::primitives::Word { + encoded: vec![u8::MAX; Word::SERIALIZED_SIZE] + }; + 256 + ], + }; + let metadata = proto::note::NoteMetadata { + sender: Some(proto::account::AccountId { id: vec![u8::MAX; 15] }), + note_type: proto::note::NoteType::Public as i32, + tag: u32::MAX, + attachment_schemes: vec![u32::from(u16::MAX - 1); 4], + attachments_commitment: Some(digest), + }; + let note = proto::note::Note { + metadata: Some(metadata), + // Deliberately conservative: a valid note's complete encoding, rather than only its + // details, is bounded by NOTE_MAX_SIZE. + details: Some(vec![u8::MAX; NOTE_MAX_SIZE as usize]), + note_attachments: Some(proto::note::NoteAttachments { + attachments: vec![attachment; 2], + }), + }; + let inclusion_proof = proto::note::NoteInclusionInBlockProof { + note_id: Some(proto::note::NoteId { id: Some(digest) }), + block_num: u32::MAX, + note_index_in_block: u32::MAX, + inclusion_path: Some(proto::primitives::SparseMerklePath { + empty_nodes_mask: u64::MAX, + siblings: vec![digest; 64], + }), + }; + + proto::note::CommittedNote { + note: Some(note), + inclusion_proof: Some(inclusion_proof), + } + } + + #[test] + fn maximum_get_notes_response_fits_payload_limit() { + let note = maximum_representative_note(); + let response = proto::note::CommittedNoteList { + notes: vec![note.clone(); QueryParamNoteIdLimit::LIMIT], + }; + assert!( + response.encoded_len() <= MAX_RESPONSE_PAYLOAD_BYTES, + "{} notes encode to {} bytes, exceeding the {} byte response limit", + QueryParamNoteIdLimit::LIMIT, + response.encoded_len(), + MAX_RESPONSE_PAYLOAD_BYTES, + ); + + let response = proto::note::CommittedNoteList { + notes: vec![note; QueryParamNoteIdLimit::LIMIT + 1], + }; + assert!( + response.encoded_len() > MAX_RESPONSE_PAYLOAD_BYTES, + "the query limit can be raised without exceeding the response payload bound" + ); + } +} diff --git a/crates/utils/src/limiter.rs b/crates/utils/src/limiter.rs index 4f5f6b670f..a75d47d56a 100644 --- a/crates/utils/src/limiter.rs +++ b/crates/utils/src/limiter.rs @@ -78,14 +78,14 @@ impl QueryParamLimiter for QueryParamNoteTagLimit { } /// Used for the following RPC endpoints -/// `select_notes_by_id` +/// * `get_notes_by_id` /// -/// The limit is set to 100 notes to keep responses within the 4 MiB payload cap because individual -/// notes are bounded to roughly 32 KiB. +/// The limit is set to 14 notes to keep responses within the 4 MiB payload cap. Protocol notes may +/// approach 256 KiB, and structured attachment words add Protobuf framing overhead. pub struct QueryParamNoteIdLimit; impl QueryParamLimiter for QueryParamNoteIdLimit { const PARAM_NAME: &str = "note_id"; - const LIMIT: usize = 100; + const LIMIT: usize = 14; } /// Used for internal queries retrieving note inclusion proofs by commitment. diff --git a/proto/proto/types/note.proto b/proto/proto/types/note.proto index 4481c018b5..fdb9049b56 100644 --- a/proto/proto/types/note.proto +++ b/proto/proto/types/note.proto @@ -62,18 +62,36 @@ message NoteMetadata { primitives.Digest attachments_commitment = 5; } +// Represents one public note attachment. +message NoteAttachment { + // Valid range: 1..=65534. + uint32 scheme = 1; + + // Ordered attachment content. Must contain 1..=256 words. + repeated primitives.Word words = 2; +} + +// Represents the ordered public attachments of a note. +message NoteAttachments { + // At most four attachments and 512 words in total. + repeated NoteAttachment attachments = 1; +} + // Represents a note. // -// The note is composed of the note metadata, its serialized details, and serialized attachments. +// The note is composed of its metadata, serialized details, and structured public attachments. message Note { + reserved 3; + reserved "attachments"; + // The note's metadata. NoteMetadata metadata = 1; // Serialized note details (empty for private notes). optional bytes details = 2; - // Serialized `miden_protocol::note::NoteAttachments`. Empty bytes encode an empty collection. - bytes attachments = 3; + // The note's public attachments. Required even when the collection is empty. + NoteAttachments note_attachments = 5; } // Represents a network note. @@ -81,14 +99,17 @@ message Note { // Network notes are a subtype of public notes, and as such, their details are always publicly // known. message NetworkNote { + reserved 3; + reserved "attachments"; + // The note's metadata. NoteMetadata metadata = 1; // Serialized note details (i.e., assets and recipient). bytes details = 2; - // Serialized `miden_protocol::note::NoteAttachments`. Empty bytes encode an empty collection. - bytes attachments = 3; + // The note's public attachments. Required even when the collection is empty. + NoteAttachments note_attachments = 5; } // Represents a committed note. From 4a69e5acb0ef38922ffd2436655bf4f8ff00d99f Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 12 Aug 2026 11:23:37 +0200 Subject: [PATCH 3/8] feat(proto): structure note details --- crates/proto/src/domain/note.rs | 358 ++++++++++++++++++- crates/rpc/src/server/api/get_notes_by_id.rs | 75 +++- proto/proto/types/note.proto | 38 +- 3 files changed, 443 insertions(+), 28 deletions(-) diff --git a/crates/proto/src/domain/note.rs b/crates/proto/src/domain/note.rs index b3e7a4fdf0..55fdd0f868 100644 --- a/crates/proto/src/domain/note.rs +++ b/crates/proto/src/domain/note.rs @@ -1,8 +1,10 @@ use std::sync::Arc; +use miden_protocol::asset::Asset; use miden_protocol::crypto::merkle::SparseMerklePath; use miden_protocol::note::{ Note, + NoteAssets, NoteAttachment, NoteAttachmentHeader, NoteAttachmentScheme, @@ -13,13 +15,15 @@ use miden_protocol::note::{ NoteId, NoteInclusionProof, NoteMetadata, + NoteRecipient, NoteScript, + NoteStorage, NoteTag, NoteType, PartialNoteMetadata, }; use miden_protocol::utils::serde::Serializable; -use miden_protocol::{MastForest, MastNodeId, Word}; +use miden_protocol::{Felt, MastForest, MastNodeId, Word}; use miden_standards::note::AccountTargetNetworkNote; use crate::decode::{ConversionResultExt, DecodeBytesExt, GrpcDecodeExt, GrpcStructDecoder}; @@ -173,21 +177,119 @@ impl TryFrom for NoteAttachments { } } +// NOTE DETAILS +// ================================================================================================ + +impl From for proto::note::NoteStorage { + fn from(storage: NoteStorage) -> Self { + Self::from(&storage) + } +} + +impl From<&NoteStorage> for proto::note::NoteStorage { + fn from(storage: &NoteStorage) -> Self { + Self { + items: storage.items().iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for NoteStorage { + type Error = ConversionError; + + fn try_from(storage: proto::note::NoteStorage) -> Result { + let items = storage + .items + .into_iter() + .map(Felt::try_from) + .collect::, _>>() + .context("items")?; + + NoteStorage::new(items).map_err(ConversionError::from).context("items") + } +} + +impl From for proto::note::NoteRecipient { + fn from(recipient: NoteRecipient) -> Self { + Self::from(&recipient) + } +} + +impl From<&NoteRecipient> for proto::note::NoteRecipient { + fn from(recipient: &NoteRecipient) -> Self { + Self { + serial_num: Some(recipient.serial_num().into()), + script: Some(recipient.script().into()), + storage: Some(recipient.storage().into()), + } + } +} + +impl TryFrom for NoteRecipient { + type Error = ConversionError; + + fn try_from(recipient: proto::note::NoteRecipient) -> Result { + let decoder = recipient.decoder(); + let serial_num = decode!(decoder, recipient.serial_num)?; + let script = decode!(decoder, recipient.script)?; + let storage = decode!(decoder, recipient.storage)?; + + Ok(NoteRecipient::new(serial_num, script, storage)) + } +} + +impl From for proto::note::NoteDetails { + fn from(details: NoteDetails) -> Self { + Self::from(&details) + } +} + +impl From<&NoteDetails> for proto::note::NoteDetails { + fn from(details: &NoteDetails) -> Self { + Self { + assets: details.assets().iter().copied().map(Into::into).collect(), + recipient: Some(details.recipient().into()), + } + } +} + +impl TryFrom for NoteDetails { + type Error = ConversionError; + + fn try_from(details: proto::note::NoteDetails) -> Result { + let decoder = details.decoder(); + let assets = details + .assets + .into_iter() + .map(Asset::try_from) + .collect::, _>>() + .context("assets")?; + let assets = NoteAssets::new(assets).map_err(ConversionError::from).context("assets")?; + let recipient = decode!(decoder, details.recipient)?; + + Ok(NoteDetails::new(assets, recipient)) + } +} + impl From for proto::note::NetworkNote { fn from(note: Note) -> Self { - let metadata = Some(proto::note::NoteMetadata::from(*note.metadata())); - let note_attachments = Some(note.attachments().into()); - let details = NoteDetails::from(note).to_bytes(); - Self { metadata, details, note_attachments } + let (assets, metadata, recipient, attachments) = note.into_parts(); + Self { + metadata: Some(metadata.into()), + note_details: Some(NoteDetails::new(assets, recipient).into()), + note_attachments: Some(attachments.into()), + } } } impl From for proto::note::Note { fn from(note: Note) -> Self { - let metadata = Some(proto::note::NoteMetadata::from(*note.metadata())); - let note_attachments = Some(note.attachments().into()); - let details = Some(NoteDetails::from(note).to_bytes()); - Self { metadata, details, note_attachments } + let (assets, metadata, recipient, attachments) = note.into_parts(); + Self { + metadata: Some(metadata.into()), + note_details: Some(NoteDetails::new(assets, recipient).into()), + note_attachments: Some(attachments.into()), + } } } @@ -202,12 +304,13 @@ impl TryFrom for AccountTargetNetworkNote { fn try_from(value: proto::note::NetworkNote) -> Result { let decoder = value.decoder(); - let proto::note::NetworkNote { metadata, details, note_attachments } = value; + let proto::note::NetworkNote { metadata, note_details, note_attachments } = value; let metadata = decode!(decoder, metadata)?; let partial_metadata = partial_note_metadata_from_proto(metadata)?; - let note_details = NoteDetails::decode_bytes(&details, "NoteDetails")?; + let note_details = decode_note_details::(note_details, true)? + .expect("required note details decoder must return a value"); let (assets, recipient) = note_details.into_parts(); let attachments = decode_note_attachments::(note_attachments)?; @@ -221,13 +324,13 @@ impl TryFrom for Note { fn try_from(proto_note: proto::note::Note) -> Result { let decoder = proto_note.decoder(); - let proto::note::Note { metadata, details, note_attachments } = proto_note; + let proto::note::Note { metadata, note_details, note_attachments } = proto_note; let metadata = decode!(decoder, metadata)?; let partial_metadata = partial_note_metadata_from_proto(metadata)?; - let details: Vec = decode!(decoder, details)?; - let note_details = NoteDetails::decode_bytes(&details, "NoteDetails")?; + let note_details = decode_note_details::(note_details, true)? + .expect("required note details decoder must return a value"); let (assets, recipient) = note_details.into_parts(); let attachments = decode_note_attachments::(note_attachments)?; @@ -324,6 +427,12 @@ impl TryFrom for NoteHeader { impl From for proto::note::NoteScript { fn from(script: NoteScript) -> Self { + Self::from(&script) + } +} + +impl From<&NoteScript> for proto::note::NoteScript { + fn from(script: &NoteScript) -> Self { Self { entrypoint: script.entrypoint().into(), mast: script.mast().to_bytes(), @@ -372,9 +481,24 @@ fn decode_note_attachments( GrpcStructDecoder::::default().decode_field("note_attachments", attachments) } +/// Decodes structured note details, optionally allowing the field to be absent. +fn decode_note_details( + details: Option, + required: bool, +) -> Result, ConversionError> { + match details { + Some(details) => details.try_into().map(Some).context("note_details"), + None if required => Err(ConversionError::missing_field::("note_details")), + None => Ok(None), + } +} + #[cfg(test)] mod tests { use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, AssetCallbackFlag}; + use miden_protocol::asset::{FungibleAsset, NonFungibleAsset}; + use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE; + use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; use super::*; @@ -405,6 +529,20 @@ mod tests { Note::with_attachments(assets, metadata.into_partial_metadata(), recipient, attachments) } + fn public_note_with_attachments(attachments: NoteAttachments) -> Note { + let base = Note::mock_noop(word(100)); + let (assets, metadata, recipient, _) = base.into_parts(); + let partial_metadata = + PartialNoteMetadata::new(metadata.sender(), NoteType::Public).with_tag(metadata.tag()); + Note::with_attachments(assets, partial_metadata, recipient, attachments) + } + + fn distinct_assets(count: usize) -> Vec { + (0..count) + .map(|index| NonFungibleAsset::mock(&u64::try_from(index).unwrap().to_le_bytes())) + .collect() + } + #[test] fn note_header_roundtrip_preserves_id() { // Build a NoteHeader with a known details_commitment and metadata. @@ -623,4 +761,196 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("note_attachments"), "unexpected error: {err}"); } + + #[test] + fn note_storage_boundaries_roundtrip() { + for items in [Vec::new(), vec![Felt::ONE; miden_protocol::MAX_NOTE_STORAGE_ITEMS]] { + let original = NoteStorage::new(items).unwrap(); + let encoded = proto::note::NoteStorage::from(&original); + + assert_eq!(NoteStorage::try_from(encoded).unwrap(), original); + } + } + + #[test] + fn note_storage_rejects_too_many_or_malformed_items() { + let too_many = proto::note::NoteStorage { + items: vec![ + proto::primitives::Felt { encoded: Felt::ZERO.to_bytes() }; + miden_protocol::MAX_NOTE_STORAGE_ITEMS + 1 + ], + }; + let err = NoteStorage::try_from(too_many).unwrap_err(); + assert!(err.to_string().starts_with("items:"), "unexpected error: {err}"); + + let malformed = proto::note::NoteStorage { + items: vec![proto::primitives::Felt { encoded: vec![0; 7] }], + }; + let err = NoteStorage::try_from(malformed).unwrap_err(); + assert!(err.to_string().starts_with("items.felt.encoded:"), "unexpected error: {err}"); + + let non_canonical = proto::note::NoteStorage { + items: vec![proto::primitives::Felt { + encoded: Felt::ORDER.to_le_bytes().to_vec(), + }], + }; + let err = NoteStorage::try_from(non_canonical).unwrap_err(); + assert!(err.to_string().starts_with("items:"), "unexpected error: {err}"); + } + + #[test] + fn note_recipient_roundtrip_preserves_all_components() { + let original = Note::mock_noop(word(200)).recipient().clone(); + let encoded = proto::note::NoteRecipient::from(&original); + let decoded = NoteRecipient::try_from(encoded).unwrap(); + + assert_eq!(decoded.serial_num(), original.serial_num()); + assert_eq!(decoded.script().root(), original.script().root()); + assert_eq!(decoded.script().entrypoint(), original.script().entrypoint()); + assert_eq!(decoded.storage(), original.storage()); + assert_eq!(decoded.digest(), original.digest()); + } + + #[test] + fn note_recipient_requires_every_component() { + let mut encoded = proto::note::NoteRecipient::default(); + let err = NoteRecipient::try_from(encoded.clone()).unwrap_err(); + assert!(err.to_string().contains("serial_num"), "unexpected error: {err}"); + + encoded.serial_num = Some(word(1).into()); + let err = NoteRecipient::try_from(encoded.clone()).unwrap_err(); + assert!(err.to_string().contains("script"), "unexpected error: {err}"); + + encoded.script = Some(NoteScript::mock().into()); + let err = NoteRecipient::try_from(encoded).unwrap_err(); + assert!(err.to_string().contains("storage"), "unexpected error: {err}"); + } + + #[test] + fn note_details_roundtrip_empty_mixed_and_max_assets() { + let recipient = Note::mock_noop(word(300)).recipient().clone(); + let asset_sets = [ + Vec::new(), + vec![FungibleAsset::mock(10), NonFungibleAsset::mock(b"mixed")], + distinct_assets(NoteAssets::MAX_NUM_ASSETS), + ]; + + for assets in asset_sets { + let original = NoteDetails::new(NoteAssets::new(assets).unwrap(), recipient.clone()); + let encoded = proto::note::NoteDetails::from(&original); + let decoded = NoteDetails::try_from(encoded).unwrap(); + + assert_eq!(decoded, original); + } + } + + #[test] + fn note_details_preserves_asset_order() { + let assets = vec![ + NonFungibleAsset::mock(b"first"), + FungibleAsset::mock(10), + NonFungibleAsset::mock(b"third"), + ]; + let recipient = Note::mock_noop(word(400)).recipient().clone(); + let original = NoteDetails::new(NoteAssets::new(assets.clone()).unwrap(), recipient); + let decoded = NoteDetails::try_from(proto::note::NoteDetails::from(original)).unwrap(); + + assert_eq!(decoded.assets().as_slice(), assets); + } + + #[test] + fn note_details_rejects_asset_limit_and_duplicates() { + let recipient = Some(Note::mock_noop(word(500)).recipient().into()); + + let too_many = proto::note::NoteDetails { + assets: distinct_assets(NoteAssets::MAX_NUM_ASSETS + 1) + .into_iter() + .map(Into::into) + .collect(), + recipient: recipient.clone(), + }; + let err = NoteDetails::try_from(too_many).unwrap_err(); + assert!(err.to_string().starts_with("assets:"), "unexpected error: {err}"); + + let duplicate_fungible = proto::note::NoteDetails { + assets: vec![FungibleAsset::mock(1).into(), FungibleAsset::mock(2).into()], + recipient: recipient.clone(), + }; + let err = NoteDetails::try_from(duplicate_fungible).unwrap_err(); + assert!(err.to_string().starts_with("assets:"), "unexpected error: {err}"); + + let duplicate = NonFungibleAsset::mock(b"duplicate"); + let duplicate_non_fungible = proto::note::NoteDetails { + assets: vec![duplicate.into(), duplicate.into()], + recipient, + }; + let err = NoteDetails::try_from(duplicate_non_fungible).unwrap_err(); + assert!(err.to_string().starts_with("assets:"), "unexpected error: {err}"); + } + + #[test] + fn note_details_requires_recipient_with_nested_context() { + let err = NoteDetails::try_from(proto::note::NoteDetails::default()).unwrap_err(); + assert!(err.to_string().contains("recipient"), "unexpected error: {err}"); + + let value = proto::note::NoteDetails { + assets: Vec::new(), + recipient: Some(proto::note::NoteRecipient::default()), + }; + let err = NoteDetails::try_from(value).unwrap_err(); + assert!( + err.to_string().starts_with("recipient:") && err.to_string().contains("serial_num"), + "unexpected error: {err}" + ); + } + + #[test] + fn public_note_roundtrip_preserves_commitments_and_identity() { + let attachments = NoteAttachments::new(vec![attachment(11, 2, 1)]).unwrap(); + let original = public_note_with_attachments(attachments); + let encoded = proto::note::Note::from(original.clone()); + let decoded = Note::try_from(encoded).unwrap(); + + assert_eq!(decoded.id(), original.id()); + assert_eq!(decoded.nullifier(), original.nullifier()); + assert_eq!(decoded.details_commitment(), original.details_commitment()); + assert_eq!( + decoded.metadata().attachments_commitment(), + original.metadata().attachments_commitment() + ); + assert_eq!(decoded.metadata(), original.metadata()); + } + + #[test] + fn network_note_roundtrip_preserves_target_validation() { + let target_id: AccountId = + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(); + let target = NetworkAccountTarget::new(target_id, NoteExecutionHint::Always).unwrap(); + let note = + public_note_with_attachments(NoteAttachments::from(NoteAttachment::from(target))); + let original = AccountTargetNetworkNote::new(note).unwrap(); + + let encoded = proto::note::NetworkNote::from(original.clone()); + let decoded = AccountTargetNetworkNote::try_from(encoded).unwrap(); + + assert_eq!(decoded.target_account_id(), original.target_account_id()); + assert_eq!(decoded.as_note(), original.as_note()); + } + + #[test] + fn required_and_optional_note_details_presence_is_enforced() { + let mut encoded = + proto::note::Note::from(public_note_with_attachments(NoteAttachments::empty())); + encoded.note_details = None; + let err = Note::try_from(encoded).unwrap_err(); + assert!(err.to_string().contains("note_details"), "unexpected error: {err}"); + + assert!( + decode_note_details::(None, false).unwrap().is_none(), + "optional details should preserve absence" + ); + + let err = decode_note_details::(None, true).unwrap_err(); + assert!(err.to_string().contains("note_details"), "unexpected error: {err}"); + } } diff --git a/crates/rpc/src/server/api/get_notes_by_id.rs b/crates/rpc/src/server/api/get_notes_by_id.rs index c18101a76f..632827a1eb 100644 --- a/crates/rpc/src/server/api/get_notes_by_id.rs +++ b/crates/rpc/src/server/api/get_notes_by_id.rs @@ -6,7 +6,6 @@ use miden_node_utils::limiter::QueryParamNoteIdLimit; use miden_node_utils::tracing::miden_instrument; use miden_protocol::Word; use miden_protocol::note::NoteId; -use miden_protocol::utils::serde::Serializable; use tonic::Status; use super::{RpcService, check, database_error_to_status}; @@ -69,7 +68,7 @@ fn note_record_to_proto(note: NoteRecord) -> proto::note::CommittedNote { }); let note = Some(proto::note::Note { metadata: Some(note.metadata.into()), - details: note.details.map(|details| details.to_bytes()), + note_details: note.details.map(Into::into), note_attachments: Some(note.attachments.into()), }); proto::note::CommittedNote { inclusion_proof, note } @@ -84,9 +83,34 @@ mod tests { QueryParamNoteIdLimit, }; use miden_protocol::NOTE_MAX_SIZE; + use miden_protocol::block::{BlockNoteIndex, BlockNumber}; + use miden_protocol::crypto::merkle::SparseMerklePath; + use miden_protocol::note::{Note, NoteDetails, NoteType, PartialNoteMetadata}; use super::*; + fn note_record(note: Note, include_details: bool) -> NoteRecord { + let note_id = Word::new(*note.id().as_word()); + let (assets, metadata, recipient, attachments) = note.into_parts(); + NoteRecord { + block_num: BlockNumber::from(1), + note_index: BlockNoteIndex::new(0, 0).unwrap(), + note_id, + metadata, + details: include_details.then(|| NoteDetails::new(assets, recipient)), + attachments, + inclusion_path: SparseMerklePath::default(), + } + } + + fn public_note() -> Note { + let note = Note::mock_noop(Word::from([1, 2, 3, 4u32])); + let (assets, metadata, recipient, attachments) = note.into_parts(); + let partial_metadata = + PartialNoteMetadata::new(metadata.sender(), NoteType::Public).with_tag(metadata.tag()); + Note::with_attachments(assets, partial_metadata, recipient, attachments) + } + fn maximum_representative_note() -> proto::note::CommittedNote { let digest = proto::primitives::Digest { d0: u64::MAX, @@ -112,9 +136,28 @@ mod tests { }; let note = proto::note::Note { metadata: Some(metadata), - // Deliberately conservative: a valid note's complete encoding, rather than only its - // details, is bounded by NOTE_MAX_SIZE. - details: Some(vec![u8::MAX; NOTE_MAX_SIZE as usize]), + note_details: Some(proto::note::NoteDetails { + assets: Vec::new(), + recipient: Some(proto::note::NoteRecipient { + serial_num: Some(proto::primitives::Word { + encoded: vec![u8::MAX; Word::SERIALIZED_SIZE], + }), + // Deliberately conservative: allow the opaque MAST leaf alone to approach the + // protocol's complete-note size bound. + script: Some(proto::note::NoteScript { + entrypoint: u32::MAX, + mast: vec![u8::MAX; NOTE_MAX_SIZE as usize], + }), + storage: Some(proto::note::NoteStorage { + items: vec![ + proto::primitives::Felt { + encoded: vec![u8::MAX; size_of::()], + }; + miden_protocol::MAX_NOTE_STORAGE_ITEMS + ], + }), + }), + }), note_attachments: Some(proto::note::NoteAttachments { attachments: vec![attachment; 2], }), @@ -157,4 +200,26 @@ mod tests { "the query limit can be raised without exceeding the response payload bound" ); } + + #[test] + fn private_note_response_omits_details_and_keeps_attachments() { + let encoded = + note_record_to_proto(note_record(Note::mock_noop(Word::from([5, 6, 7, 8u32])), false)); + let note = encoded.note.unwrap(); + + assert!(note.note_details.is_none()); + assert!(note.note_attachments.is_some()); + } + + #[test] + fn public_note_response_contains_structured_details() { + let original = public_note(); + let expected_commitment = original.details_commitment(); + let encoded = note_record_to_proto(note_record(original, true)); + let note = encoded.note.unwrap(); + + let details = NoteDetails::try_from(note.note_details.unwrap()).unwrap(); + assert_eq!(details.commitment(), expected_commitment); + assert!(note.note_attachments.is_some()); + } } diff --git a/proto/proto/types/note.proto b/proto/proto/types/note.proto index fdb9049b56..2059b2798a 100644 --- a/proto/proto/types/note.proto +++ b/proto/proto/types/note.proto @@ -77,18 +77,38 @@ message NoteAttachments { repeated NoteAttachment attachments = 1; } +// Represents the ordered field elements in a note's storage. +message NoteStorage { + // Ordered storage values. At most 1024 field elements. + repeated primitives.Felt items = 1; +} + +// Represents the recipient data committed to by a note. +message NoteRecipient { + primitives.Word serial_num = 1; + NoteScript script = 2; + NoteStorage storage = 3; +} + +// Represents a note's assets and recipient. +message NoteDetails { + // Ordered assets. At most 16, with protocol-level duplicate checks. + repeated primitives.Asset assets = 1; + NoteRecipient recipient = 2; +} + // Represents a note. // -// The note is composed of its metadata, serialized details, and structured public attachments. +// The note is composed of its metadata, structured details, and structured public attachments. message Note { - reserved 3; - reserved "attachments"; + reserved 2, 3; + reserved "details", "attachments"; // The note's metadata. NoteMetadata metadata = 1; - // Serialized note details (empty for private notes). - optional bytes details = 2; + // Absent for a private note response and required when reconstructing a full domain Note. + optional NoteDetails note_details = 4; // The note's public attachments. Required even when the collection is empty. NoteAttachments note_attachments = 5; @@ -99,14 +119,14 @@ message Note { // Network notes are a subtype of public notes, and as such, their details are always publicly // known. message NetworkNote { - reserved 3; - reserved "attachments"; + reserved 2, 3; + reserved "details", "attachments"; // The note's metadata. NoteMetadata metadata = 1; - // Serialized note details (i.e., assets and recipient). - bytes details = 2; + // Required because network notes are public. + NoteDetails note_details = 4; // The note's public attachments. Required even when the collection is empty. NoteAttachments note_attachments = 5; From 33645e18f309cb4ca67e3a110678ab02762148a5 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 12 Aug 2026 11:52:31 +0200 Subject: [PATCH 4/8] test(proto): verify structured note API cutover Assert the public descriptor exposes structured note messages and reserves legacy fields. Document the breaking client regeneration requirement and record the completed migration in grpc.md. --- crates/rpc/src/tests.rs | 63 +++++++++++++++++++++++++++++++++++++++++ proto/README.md | 6 ++++ 2 files changed, 69 insertions(+) diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 9b5bfc4b70..b78f03ebb6 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -121,6 +121,69 @@ impl TestStore { const DELTA_COMMITMENT_BYTE_OFFSET: usize = 15 + 32 + 32; const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +#[test] +fn rpc_descriptor_exposes_structured_note_schema() { + let descriptor = miden_node_proto_build::rpc_api_descriptor(); + let note_file = descriptor + .file + .iter() + .find(|file| file.name() == "types/note.proto") + .expect("the public RPC descriptor should include types/note.proto"); + + for name in [ + "NoteAttachment", + "NoteAttachments", + "NoteStorage", + "NoteRecipient", + "NoteDetails", + ] { + assert!( + note_file.message_type.iter().any(|message| message.name() == name), + "the public RPC descriptor should expose note.{name}" + ); + } + + for message_name in ["Note", "NetworkNote"] { + let message = note_file + .message_type + .iter() + .find(|message| message.name() == message_name) + .unwrap_or_else(|| { + panic!("the public RPC descriptor should expose note.{message_name}") + }); + + for field_number in [2, 3] { + assert!( + message + .reserved_range + .iter() + .any(|range| range.start() <= field_number && range.end() > field_number), + "note.{message_name} should reserve field number {field_number}" + ); + } + assert!(message.reserved_name.iter().any(|name| name == "details")); + assert!(message.reserved_name.iter().any(|name| name == "attachments")); + assert!(!message.field.iter().any(|field| field.name() == "details")); + assert!(!message.field.iter().any(|field| field.name() == "attachments")); + + let details = message + .field + .iter() + .find(|field| field.name() == "note_details") + .expect("the structured note_details field should be present"); + assert_eq!(details.number(), 4); + assert_eq!(details.type_name(), ".note.NoteDetails"); + + let attachments = message + .field + .iter() + .find(|field| field.name() == "note_attachments") + .expect("the structured note_attachments field should be present"); + assert_eq!(attachments.number(), 5); + assert_eq!(attachments.type_name(), ".note.NoteAttachments"); + } +} + /// Creates a minimal account and its patch for testing proven transaction building. fn build_test_account(seed: [u8; 32]) -> (Account, AccountPatch) { let account = AccountBuilder::new(seed) diff --git a/proto/README.md b/proto/README.md index 007ba374a7..e0f441a891 100644 --- a/proto/README.md +++ b/proto/README.md @@ -12,6 +12,12 @@ component APIs used by the Miden node workspace. Raw protobuf files are included in this repository for projects that generate bindings in other languages. For project navigation and documentation links, see the [primary README](https://github.com/0xMiden/node#readme). +## Wire compatibility + +Generated clients must use the protobuf definitions from the same Miden node release. The note API now represents +`NoteDetails` and `NoteAttachments` as structured protobuf messages; clients generated from the earlier opaque `bytes` +fields are wire-incompatible and must regenerate their bindings before connecting to this release. + ## Crate Features - `internal`: exposes file descriptors for internal node component APIs. These APIs are not intended for general client From 891f83069247a13a5e1d5339282e626b731c403f Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 12 Aug 2026 12:27:45 +0200 Subject: [PATCH 5/8] feat(proto): migrate block RPCs to structured messages Replace opaque BlockBody, SignedBlock, and BlockProof gRPC payloads with validated protobuf structures. Add shared account patch, output note, and transaction header conversions, migrate all in-repository consumers, retain serialization at persistence boundaries, and add descriptor and round-trip coverage. --- bin/benchmark/src/inclusion.rs | 7 +- bin/node/src/commands/recover.rs | 8 +- bin/ntx-builder/src/clients/rpc.rs | 9 +- .../validator_service/block_subscription.rs | 17 +- .../src/server/validator_service/tests.rs | 6 +- crates/block-producer/src/proof_scheduler.rs | 20 +- crates/block-producer/src/rpc_sync.rs | 17 +- crates/proto/src/decode/mod.rs | 6 +- crates/proto/src/domain/account_patch.rs | 496 ++++++++++++++++++ crates/proto/src/domain/block.rs | 461 +++++++++++++++- crates/proto/src/domain/mod.rs | 1 + crates/proto/src/domain/transaction.rs | 246 ++++++++- crates/proto/src/errors/mod.rs | 10 +- .../rpc/src/server/api/get_block_by_number.rs | 29 +- .../rpc/src/server/api/subscription/block.rs | 31 +- .../rpc/src/server/api/subscription/proof.rs | 37 +- .../rpc/src/server/api/sync_transactions.rs | 9 +- crates/rpc/src/tests.rs | 147 ++++++ crates/store/src/state/writer/apply_proof.rs | 23 +- proto/README.md | 7 +- proto/proto/internal/validator.proto | 8 +- proto/proto/rpc.proto | 16 +- proto/proto/types/account.proto | 79 +++ proto/proto/types/blockchain.proto | 44 +- proto/proto/types/transaction.proto | 21 + 25 files changed, 1669 insertions(+), 86 deletions(-) create mode 100644 crates/proto/src/domain/account_patch.rs diff --git a/bin/benchmark/src/inclusion.rs b/bin/benchmark/src/inclusion.rs index 07ebc44351..0e6f6fbf0c 100644 --- a/bin/benchmark/src/inclusion.rs +++ b/bin/benchmark/src/inclusion.rs @@ -15,7 +15,6 @@ use miden_node_proto::generated as proto; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; use miden_protocol::block::{BlockHeader, SignedBlock}; use miden_protocol::transaction::TransactionId; -use miden_protocol::utils::serde::Deserializable; /// One scanned block that contained at least one of our txs. Empty blocks in the scan range are not /// represented here. @@ -126,15 +125,15 @@ pub(crate) async fn scan_with_drain( continue; }, }; - let Some(bytes) = response.block else { + let Some(block) = response.signed_block else { next_block += 1; continue; }; - let signed_block = match SignedBlock::read_from_bytes(&bytes) { + let signed_block = match SignedBlock::try_from(block) { Ok(sb) => sb, Err(err) => { eprintln!( - " warning: failed to deserialize SignedBlock for block {next_block}: {err}" + " warning: failed to convert SignedBlock for block {next_block}: {err}" ); next_block += 1; continue; diff --git a/bin/node/src/commands/recover.rs b/bin/node/src/commands/recover.rs index 211f4a2db9..d00f005b52 100644 --- a/bin/node/src/commands/recover.rs +++ b/bin/node/src/commands/recover.rs @@ -17,7 +17,6 @@ use miden_protocol::block::{ ValidatorKeys, }; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::Signature; -use miden_protocol::utils::serde::Deserializable; use tokio::sync::mpsc; use tokio_stream::StreamExt; use tonic::codec::Streaming; @@ -218,8 +217,11 @@ async fn read_blocks( result.with_context(|| format!("block stream of validator {url} returned an error")) }) .and_then(|event| { - SignedBlock::read_from_bytes(&event.block) - .with_context(|| format!("failed to deserialize block from validator {url}")) + let signed_block = event.signed_block.ok_or_else(|| { + anyhow::anyhow!("validator {url} returned a block without signed_block") + })?; + SignedBlock::try_from(signed_block) + .with_context(|| format!("failed to convert block from validator {url}")) }); let is_err = block.is_err(); diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index 1b1ee2140c..c52f72eec0 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -210,7 +210,7 @@ impl RpcClient { // `&self`, so callers like `block_subscription_reconnecting` can store it freely. Ok(stream .map_err(RpcError::GrpcClientError) - .and_then(|response| async move { decode_block_subscription_response(&response) }) + .and_then(|response| async move { decode_block_subscription_response(response) }) .boxed()) }) .retry(self.backoff) @@ -374,9 +374,12 @@ impl RpcClient { } fn decode_block_subscription_response( - response: &BlockSubscriptionResponse, + response: BlockSubscriptionResponse, ) -> Result<(SignedBlock, BlockNumber), RpcError> { - let block = SignedBlock::read_from_bytes(&response.block).map_err(RpcError::Deserialize)?; + let signed_block = response.signed_block.ok_or_else(|| { + RpcError::InvalidResponse("block subscription response is missing signed_block".into()) + })?; + let block = SignedBlock::try_from(signed_block).map_err(RpcError::Conversion)?; let committed_tip = BlockNumber::from(response.committed_chain_tip); Ok((block, committed_tip)) } diff --git a/bin/validator/src/server/validator_service/block_subscription.rs b/bin/validator/src/server/validator_service/block_subscription.rs index 2c1157726e..b39d28c3b3 100644 --- a/bin/validator/src/server/validator_service/block_subscription.rs +++ b/bin/validator/src/server/validator_service/block_subscription.rs @@ -6,7 +6,8 @@ use miden_node_proto::generated as grpc; use miden_node_proto::generated::validator::BlockSubscriptionResponse; use miden_node_utils::ErrorReport; use miden_node_utils::tracing::{miden_instrument, miden_span_record}; -use miden_protocol::block::BlockNumber; +use miden_protocol::block::{BlockNumber, SignedBlock}; +use miden_protocol::utils::serde::Deserializable; use tokio::sync::OwnedRwLockWriteGuard; use tokio_stream::wrappers::ReceiverStream; use tonic::Status; @@ -83,10 +84,16 @@ impl grpc::server::validator_api::BlockSubscription for ValidatorService { async move { for block in from.as_u32()..=committed_tip.as_u32() { let response = match store.load_block(block.into()).await { - Ok(Some(block)) => Ok(BlockSubscriptionResponse { - block, - committed_chain_tip: committed_tip.as_u32(), - }), + Ok(Some(block_bytes)) => SignedBlock::read_from_bytes(&block_bytes) + .map(|signed_block| BlockSubscriptionResponse { + committed_chain_tip: committed_tip.as_u32(), + signed_block: Some(signed_block.into()), + }) + .map_err(|err| { + tonic::Status::data_loss(format!( + "stored block {block} could not be decoded: {err}" + )) + }), Ok(None) => { Err(tonic::Status::not_found(format!("block {block} not found"))) } diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 6b600c0461..19f0bcc720 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -772,7 +772,6 @@ async fn block_subscription_replays_then_freezes_signing() { use std::time::Duration; use miden_protocol::block::SignedBlock; - use miden_tx::utils::serde::Deserializable; use tokio_stream::StreamExt; let mut tv = TestValidator::new().await; @@ -789,7 +788,10 @@ async fn block_subscription_replays_then_freezes_signing() { .expect("replayed block should arrive promptly") .expect("stream should not end") .expect("stream item should not be an error"); - let block = SignedBlock::read_from_bytes(&response.block).expect("valid signed block"); + let block = SignedBlock::try_from( + response.signed_block.expect("response should contain a signed block"), + ) + .expect("valid signed block"); assert_eq!(block.header().block_num().as_u32(), expected); assert_eq!(response.committed_chain_tip, 2); } diff --git a/crates/block-producer/src/proof_scheduler.rs b/crates/block-producer/src/proof_scheduler.rs index a6c9d4c29b..238cb48a60 100644 --- a/crates/block-producer/src/proof_scheduler.rs +++ b/crates/block-producer/src/proof_scheduler.rs @@ -16,7 +16,7 @@ use std::num::NonZeroUsize; use std::sync::Arc; use std::time::Duration; -use anyhow::Context; +use anyhow::{Context, ensure}; use miden_node_proto::BlockProofRequest; use miden_node_store::state::{ProofWriter, State}; use miden_node_utils::retry::{self, Retryable}; @@ -123,7 +123,11 @@ pub(crate) async fn run( let mut next_to_prove = state.proven_tip().child(); // Completed proofs waiting to be committed in order. - let mut pending: BTreeMap> = BTreeMap::new(); + #[expect( + clippy::zero_sized_map_values, + reason = "BlockProof is a placeholder today but the scheduler must retain each future proof" + )] + let mut pending: BTreeMap = BTreeMap::new(); loop { // Schedule blocks up to chain_tip that haven't been scheduled yet. @@ -142,13 +146,19 @@ pub(crate) async fn run( // Proving a block has completed - cache and commit the proof. proving_result = proving_tasks.join_next() => { let (block_num, proof_bytes) = proving_result?; - pending.insert(block_num, proof_bytes); + ensure!( + proof_bytes.is_empty(), + "block prover returned an unsupported non-empty placeholder proof for block {block_num}", + ); + let proof = BlockProof::read_from_bytes(&proof_bytes) + .context("failed to deserialize block proof returned by prover")?; + pending.insert(block_num, proof); // Drain completed proofs in ascending order so the proven tip advances without // gaps. let mut next = state.proven_tip().child(); - while let Some(proof_bytes) = pending.remove(&next) { - proof_writer.apply_proof(next, proof_bytes).await?; + while let Some(proof) = pending.remove(&next) { + proof_writer.apply_proof(next, proof).await?; next = next.child(); } }, diff --git a/crates/block-producer/src/rpc_sync.rs b/crates/block-producer/src/rpc_sync.rs index f193b4aa82..35a2be5854 100644 --- a/crates/block-producer/src/rpc_sync.rs +++ b/crates/block-producer/src/rpc_sync.rs @@ -10,8 +10,7 @@ use miden_node_utils::retry::{self, RetryableWithContext}; use miden_node_utils::shutdown::CancellationToken; use miden_node_utils::tasks::Tasks; use miden_node_utils::tracing::miden_instrument; -use miden_protocol::block::{BlockNumber, SignedBlock}; -use miden_protocol::utils::serde::Deserializable; +use miden_protocol::block::{BlockNumber, BlockProof, SignedBlock}; use tokio_stream::StreamExt; use tonic_health::ServingStatus; use tonic_health::server::HealthReporter; @@ -234,8 +233,11 @@ impl BlockSync { }; let event = result?; let upstream_tip = BlockNumber::from(event.committed_chain_tip); - let block = SignedBlock::read_from_bytes(&event.block) - .context("failed to deserialize block from upstream")?; + let signed_block = event + .signed_block + .context("upstream block subscription response is missing signed_block")?; + let block = SignedBlock::try_from(signed_block) + .context("failed to convert structured block from upstream")?; self.writer.apply_block(block).await?; let local_tip = self.state.committed_tip(); @@ -329,7 +331,12 @@ impl ProofSync { }, } - self.writer.apply_proof(block_num, event.proof).await?; + let block_proof = event + .block_proof + .context("upstream proof subscription response is missing block_proof")?; + let proof = BlockProof::try_from(block_proof) + .context("failed to convert structured block proof from upstream")?; + self.writer.apply_proof(block_num, proof).await?; expected = expected.child(); } diff --git a/crates/proto/src/decode/mod.rs b/crates/proto/src/decode/mod.rs index acd0c32335..d9e34e633b 100644 --- a/crates/proto/src/decode/mod.rs +++ b/crates/proto/src/decode/mod.rs @@ -114,11 +114,11 @@ macro_rules! decode { /// /// ```rust,ignore /// // Before: -/// BlockBody::read_from_bytes(&value.block_body) -/// .map_err(|source| ConversionError::deserialization("BlockBody", source)) +/// MastForest::read_from_bytes(&value.mast) +/// .map_err(|source| ConversionError::deserialization("MastForest", source)) /// /// // After: -/// BlockBody::decode_bytes(&value.block_body, "BlockBody") +/// MastForest::decode_bytes(&value.mast, "MastForest") /// ``` pub trait DecodeBytesExt: Deserializable { /// Deserialize from bytes, wrapping any error as a [`ConversionError`]. diff --git a/crates/proto/src/domain/account_patch.rs b/crates/proto/src/domain/account_patch.rs new file mode 100644 index 0000000000..451fc10c8d --- /dev/null +++ b/crates/proto/src/domain/account_patch.rs @@ -0,0 +1,496 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use miden_protocol::account::{ + AccountCode, + AccountPatch, + AccountProcedureRoot, + AccountStoragePatch, + AccountUpdateDetails, + AccountVaultPatch, + StorageMapKey, + StorageMapPatch, + StorageMapPatchEntries, + StoragePatchOperation, + StorageSlotName, + StorageSlotPatch, + StorageValuePatch, +}; +use miden_protocol::asset::AssetId; +use miden_protocol::utils::serde::Serializable; +use miden_protocol::{MastForest, Word}; + +use crate::decode::{ConversionResultExt, DecodeBytesExt, GrpcDecodeExt}; +use crate::errors::ConversionError; +use crate::{decode, generated as proto}; + +// ACCOUNT CODE +// ================================================================================================ + +impl From<&AccountCode> for proto::account::AccountCode { + fn from(code: &AccountCode) -> Self { + Self { + mast: code.mast().to_bytes(), + procedure_roots: code.procedure_roots().map(Into::into).collect(), + } + } +} + +impl From for proto::account::AccountCode { + fn from(code: AccountCode) -> Self { + Self::from(&code) + } +} + +impl TryFrom for AccountCode { + type Error = ConversionError; + + fn try_from(code: proto::account::AccountCode) -> Result { + let mast = MastForest::decode_bytes(&code.mast, "MastForest").context("mast")?; + let procedure_roots = code + .procedure_roots + .into_iter() + .enumerate() + .map(|(index, root)| { + Word::try_from(root) + .map(AccountProcedureRoot::from_raw) + .context(format!("procedure_roots[{index}]")) + }) + .collect::, _>>()?; + + AccountCode::from_parts(Arc::new(mast), procedure_roots).map_err(ConversionError::new) + } +} + +// STORAGE PATCHES +// ================================================================================================ + +const fn encode_storage_operation(operation: StoragePatchOperation) -> i32 { + match operation { + StoragePatchOperation::Create => proto::account::StoragePatchOperation::Create as i32, + StoragePatchOperation::Update => proto::account::StoragePatchOperation::Update as i32, + StoragePatchOperation::Remove => proto::account::StoragePatchOperation::Remove as i32, + } +} + +fn decode_storage_operation(operation: i32) -> Result { + match proto::account::StoragePatchOperation::try_from(operation) { + Ok(proto::account::StoragePatchOperation::Create) => Ok(StoragePatchOperation::Create), + Ok(proto::account::StoragePatchOperation::Update) => Ok(StoragePatchOperation::Update), + Ok(proto::account::StoragePatchOperation::Remove) => Ok(StoragePatchOperation::Remove), + Ok(proto::account::StoragePatchOperation::Unspecified) => { + Err(ConversionError::message("storage patch operation is unspecified")) + }, + Err(_) => { + Err(ConversionError::message(format!("unknown storage patch operation {operation}"))) + }, + } +} + +impl From<&StorageValuePatch> for proto::account::StorageValuePatch { + fn from(patch: &StorageValuePatch) -> Self { + Self { + operation: encode_storage_operation(patch.patch_op()), + value: patch.value().map(Into::into), + } + } +} + +impl TryFrom for StorageValuePatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::StorageValuePatch) -> Result { + let operation = decode_storage_operation(patch.operation).context("operation")?; + match operation { + StoragePatchOperation::Create | StoragePatchOperation::Update => { + let decoder = patch.decoder(); + let value = decode!(decoder, patch.value)?; + Ok(if operation.is_create() { + StorageValuePatch::Create { value } + } else { + StorageValuePatch::Update { value } + }) + }, + StoragePatchOperation::Remove => { + if patch.value.is_some() { + return Err(ConversionError::message( + "value must be absent for a remove operation", + ) + .context("value")); + } + Ok(StorageValuePatch::Remove) + }, + } + } +} + +impl From<&StorageMapPatch> for proto::account::StorageMapPatch { + fn from(patch: &StorageMapPatch) -> Self { + let entries = patch + .entries() + .into_iter() + .flat_map(StorageMapPatchEntries::as_map) + .map(|(key, value)| proto::account::StorageMapEntry { + key: Some(Word::from(*key).into()), + value: Some((*value).into()), + }) + .collect(); + + Self { + operation: encode_storage_operation(patch.patch_op()), + entries, + } + } +} + +impl TryFrom for StorageMapPatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::StorageMapPatch) -> Result { + let operation = decode_storage_operation(patch.operation).context("operation")?; + if operation.is_remove() { + if !patch.entries.is_empty() { + return Err(ConversionError::message( + "entries must be empty for a remove operation", + ) + .context("entries")); + } + return Ok(StorageMapPatch::Remove); + } + + let mut entries = BTreeMap::new(); + for (index, entry) in patch.entries.into_iter().enumerate() { + let decoder = entry.decoder(); + let key: Word = decode!(decoder, entry.key).context(format!("entries[{index}]"))?; + let value = decode!(decoder, entry.value).context(format!("entries[{index}]"))?; + let key = StorageMapKey::from_raw(key); + if entries.insert(key, value).is_some() { + return Err(ConversionError::message("duplicate storage map key") + .context(format!("entries[{index}].key"))); + } + } + + let entries = StorageMapPatchEntries::from_raw(entries); + match operation { + StoragePatchOperation::Create => Ok(StorageMapPatch::Create { entries }), + StoragePatchOperation::Update if entries.is_empty() => { + Err(ConversionError::message("entries must be non-empty for an update operation") + .context("entries")) + }, + StoragePatchOperation::Update => Ok(StorageMapPatch::Update { entries }), + StoragePatchOperation::Remove => unreachable!("remove handled above"), + } + } +} + +enum StorageSlotPatchRef<'a> { + Value(&'a StorageValuePatch), + Map(&'a StorageMapPatch), +} + +impl From<(&StorageSlotName, StorageSlotPatchRef<'_>)> for proto::account::StorageSlotPatch { + fn from((slot_name, patch): (&StorageSlotName, StorageSlotPatchRef<'_>)) -> Self { + use proto::account::storage_slot_patch::Patch; + + let patch = match patch { + StorageSlotPatchRef::Value(value) => Patch::Value(value.into()), + StorageSlotPatchRef::Map(map) => Patch::Map(map.into()), + }; + Self { + slot_name: slot_name.as_str().to_owned(), + patch: Some(patch), + } + } +} + +impl From<&AccountStoragePatch> for proto::account::AccountStoragePatch { + fn from(patch: &AccountStoragePatch) -> Self { + let mut slots = patch + .values() + .map(|(name, patch)| (name, StorageSlotPatchRef::Value(patch))) + .chain(patch.maps().map(|(name, patch)| (name, StorageSlotPatchRef::Map(patch)))) + .collect::>(); + slots.sort_by_key(|(name, _)| *name); + + Self { + slots: slots.into_iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for AccountStoragePatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::AccountStoragePatch) -> Result { + use proto::account::storage_slot_patch::Patch; + + let slots = patch + .slots + .into_iter() + .enumerate() + .map(|(index, slot)| { + let slot_path = format!("slots[{index}]"); + let slot_name = StorageSlotName::new(slot.slot_name) + .map_err(ConversionError::from) + .context("slot_name") + .context(slot_path.clone())?; + let patch = match slot.patch { + Some(Patch::Value(value)) => StorageSlotPatch::Value( + value.try_into().context("patch").context(slot_path.clone())?, + ), + Some(Patch::Map(map)) => StorageSlotPatch::Map( + map.try_into().context("patch").context(slot_path.clone())?, + ), + None => { + return Err(ConversionError::missing_field::< + proto::account::StorageSlotPatch, + >("patch") + .context(slot_path)); + }, + }; + Ok((slot_name, patch)) + }) + .collect::, ConversionError>>()?; + + AccountStoragePatch::from_entries(slots) + .map_err(ConversionError::new) + .context("slots") + } +} + +// VAULT AND ACCOUNT PATCHES +// ================================================================================================ + +impl From<&AccountVaultPatch> for proto::account::AccountVaultPatch { + fn from(patch: &AccountVaultPatch) -> Self { + Self { + entries: patch + .iter() + .map(|(asset_id, value)| proto::account::AccountVaultPatchEntry { + asset_id: Some(asset_id.to_word().into()), + value: Some((*value).into()), + }) + .collect(), + } + } +} + +impl TryFrom for AccountVaultPatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::AccountVaultPatch) -> Result { + let mut entries = BTreeMap::new(); + for (index, entry) in patch.entries.into_iter().enumerate() { + let decoder = entry.decoder(); + let asset_id: Word = + decode!(decoder, entry.asset_id).context(format!("entries[{index}]"))?; + let asset_id = AssetId::try_from(asset_id) + .map_err(ConversionError::from) + .context("asset_id") + .context(format!("entries[{index}]"))?; + let value = decode!(decoder, entry.value).context(format!("entries[{index}]"))?; + if entries.insert(asset_id, value).is_some() { + return Err(ConversionError::message("duplicate vault asset ID") + .context(format!("entries[{index}].asset_id"))); + } + } + + AccountVaultPatch::new(entries) + .map_err(ConversionError::from) + .context("entries") + } +} + +impl From<&AccountPatch> for proto::account::AccountPatch { + fn from(patch: &AccountPatch) -> Self { + Self { + account_id: Some(patch.id().into()), + storage: Some(patch.storage().into()), + vault: Some(patch.vault().into()), + code: patch.code().map(Into::into), + final_nonce: patch.final_nonce().map(Into::into), + } + } +} + +impl From for proto::account::AccountPatch { + fn from(patch: AccountPatch) -> Self { + Self::from(&patch) + } +} + +impl TryFrom for AccountPatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::AccountPatch) -> Result { + let decoder = patch.decoder(); + let account_id = decode!(decoder, patch.account_id)?; + let storage = decode!(decoder, patch.storage)?; + let vault = decode!(decoder, patch.vault)?; + let code = patch.code.map(TryInto::try_into).transpose().context("code")?; + let final_nonce = + patch.final_nonce.map(TryInto::try_into).transpose().context("final_nonce")?; + + AccountPatch::new(account_id, storage, vault, code, final_nonce) + .map_err(ConversionError::new) + } +} + +impl From<&AccountUpdateDetails> for proto::account::AccountUpdateDetails { + fn from(details: &AccountUpdateDetails) -> Self { + use proto::account::account_update_details::Update; + + let update = match details { + AccountUpdateDetails::Private => { + Update::Private(proto::account::PrivateAccountUpdate {}) + }, + AccountUpdateDetails::Public(patch) => Update::Public(patch.into()), + }; + Self { update: Some(update) } + } +} + +impl From for proto::account::AccountUpdateDetails { + fn from(details: AccountUpdateDetails) -> Self { + Self::from(&details) + } +} + +impl TryFrom for AccountUpdateDetails { + type Error = ConversionError; + + fn try_from(details: proto::account::AccountUpdateDetails) -> Result { + use proto::account::account_update_details::Update; + + match details.update { + Some(Update::Private(_)) => Ok(AccountUpdateDetails::Private), + Some(Update::Public(patch)) => { + patch.try_into().map(AccountUpdateDetails::Public).context("public") + }, + None => Err(ConversionError::missing_field::( + "update", + )), + } + } +} + +#[cfg(test)] +mod tests { + use miden_protocol::Word; + use miden_protocol::account::{ + AccountCode, + AccountId, + AccountIdVersion, + AccountPatch, + AccountType, + AccountUpdateDetails, + AssetCallbackFlag, + StorageMapPatch, + StorageValuePatch, + }; + + use crate::generated as proto; + + fn public_account_id() -> AccountId { + AccountId::dummy( + [3; 15], + AccountIdVersion::Version1, + AccountType::Public, + AssetCallbackFlag::Disabled, + ) + } + + #[test] + fn account_code_roundtrips_with_structured_procedure_roots() { + let code = AccountCode::mock(); + let encoded = proto::account::AccountCode::from(&code); + + assert_eq!(encoded.procedure_roots.len(), code.procedure_roots().count()); + assert_eq!(AccountCode::try_from(encoded).unwrap(), code); + } + + #[test] + fn empty_public_patch_and_private_update_roundtrip() { + let public = AccountUpdateDetails::Public(AccountPatch::empty(public_account_id())); + assert_eq!( + AccountUpdateDetails::try_from(proto::account::AccountUpdateDetails::from(&public)) + .unwrap(), + public + ); + + let private = AccountUpdateDetails::Private; + assert_eq!( + AccountUpdateDetails::try_from(proto::account::AccountUpdateDetails::from(&private)) + .unwrap(), + private + ); + } + + #[test] + fn storage_patch_operation_presence_rules_are_enforced() { + let missing_value = proto::account::StorageValuePatch { + operation: proto::account::StoragePatchOperation::Create as i32, + value: None, + }; + assert!( + StorageValuePatch::try_from(missing_value) + .unwrap_err() + .to_string() + .contains("value") + ); + + let remove_with_value = proto::account::StorageValuePatch { + operation: proto::account::StoragePatchOperation::Remove as i32, + value: Some(Word::default().into()), + }; + assert!( + StorageValuePatch::try_from(remove_with_value) + .unwrap_err() + .to_string() + .contains("value") + ); + + let empty_update = proto::account::StorageMapPatch { + operation: proto::account::StoragePatchOperation::Update as i32, + entries: Vec::new(), + }; + assert!( + StorageMapPatch::try_from(empty_update) + .unwrap_err() + .to_string() + .contains("entries") + ); + + let empty_create = proto::account::StorageMapPatch { + operation: proto::account::StoragePatchOperation::Create as i32, + entries: Vec::new(), + }; + assert!(matches!( + StorageMapPatch::try_from(empty_create).unwrap(), + StorageMapPatch::Create { .. } + )); + } + + #[test] + fn storage_map_patch_rejects_duplicate_keys_with_index_context() { + let entry = proto::account::StorageMapEntry { + key: Some(Word::from([1_u32, 2, 3, 4]).into()), + value: Some(Word::from([5_u32, 6, 7, 8]).into()), + }; + let patch = proto::account::StorageMapPatch { + operation: proto::account::StoragePatchOperation::Update as i32, + entries: vec![entry.clone(), entry], + }; + + let error = StorageMapPatch::try_from(patch).unwrap_err().to_string(); + assert!(error.contains("entries[1].key")); + } + + #[test] + fn account_update_details_requires_a_variant() { + let error = AccountUpdateDetails::try_from(proto::account::AccountUpdateDetails::default()) + .unwrap_err() + .to_string(); + assert!(error.contains("update")); + } +} diff --git a/crates/proto/src/domain/block.rs b/crates/proto/src/domain/block.rs index 992f267ba4..1ed1db9af4 100644 --- a/crates/proto/src/domain/block.rs +++ b/crates/proto/src/domain/block.rs @@ -1,16 +1,25 @@ +use std::collections::BTreeSet; use std::ops::RangeInclusive; +use miden_protocol::account::{AccountId, AccountUpdateDetails}; use miden_protocol::block::{ + BlockAccountUpdate, BlockBody, BlockHeader, + BlockNoteIndex, BlockNumber, + BlockProof, BlockSignatures, FeeParameters, + OutputNoteBatch, SignedBlock, ValidatorKeys, }; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; -use miden_protocol::utils::serde::Serializable; +use miden_protocol::note::Nullifier; +use miden_protocol::transaction::{OrderedTransactionHeaders, OutputNote, TransactionHeader}; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_protocol::{MAX_BATCHES_PER_BLOCK, MAX_OUTPUT_NOTES_PER_BATCH, Word}; use thiserror::Error; use crate::decode::{ConversionResultExt, DecodeBytesExt, GrpcDecodeExt}; @@ -113,7 +122,18 @@ impl TryFrom for BlockHeader { impl From<&BlockBody> for proto::blockchain::BlockBody { fn from(body: &BlockBody) -> Self { - Self { block_body: body.to_bytes() } + Self { + contents: Some(proto::blockchain::BlockBodyContents { + updated_accounts: body.updated_accounts().iter().map(Into::into).collect(), + output_note_batches: body.output_note_batches().iter().map(Into::into).collect(), + created_nullifiers: body + .created_nullifiers() + .iter() + .map(|nullifier| nullifier.as_word().into()) + .collect(), + transactions: body.transactions().as_slice().iter().map(Into::into).collect(), + }), + } } } @@ -134,7 +154,199 @@ impl TryFrom<&proto::blockchain::BlockBody> for BlockBody { impl TryFrom for BlockBody { type Error = ConversionError; fn try_from(value: proto::blockchain::BlockBody) -> Result { - BlockBody::decode_bytes(&value.block_body, "BlockBody") + let decoder = value.decoder(); + let contents: proto::blockchain::BlockBodyContents = decode!(decoder, value.contents)?; + + let updated_accounts = contents + .updated_accounts + .into_iter() + .enumerate() + .map(|(index, update)| { + BlockAccountUpdate::try_from(update).context(format!("updated_accounts[{index}]")) + }) + .collect::, _>>()?; + + if contents.output_note_batches.len() > MAX_BATCHES_PER_BLOCK { + return Err(ConversionError::message(format!( + "block has {} output note batches, maximum is {MAX_BATCHES_PER_BLOCK}", + contents.output_note_batches.len() + )) + .context("output_note_batches")); + } + let output_note_batches = contents + .output_note_batches + .into_iter() + .enumerate() + .map(|(batch_index, batch)| { + OutputNoteBatch::try_from(batch) + .and_then(|batch| { + for (note_index, _) in &batch { + if BlockNoteIndex::new(batch_index, *note_index).is_none() { + return Err(ConversionError::message(format!( + "invalid block note index ({batch_index}, {note_index})" + ))); + } + } + Ok(batch) + }) + .context(format!("output_note_batches[{batch_index}]")) + }) + .collect::, _>>()?; + + let created_nullifiers = contents + .created_nullifiers + .into_iter() + .enumerate() + .map(|(index, nullifier)| { + Word::try_from(nullifier) + .map(Nullifier::from_raw) + .context(format!("created_nullifiers[{index}]")) + }) + .collect::, _>>()?; + + let transactions = contents + .transactions + .into_iter() + .enumerate() + .map(|(index, transaction)| { + TransactionHeader::try_from(transaction).context(format!("transactions[{index}]")) + }) + .collect::, _>>()?; + let transactions = OrderedTransactionHeaders::new_unchecked(transactions); + + Ok(BlockBody::new_unchecked( + updated_accounts, + output_note_batches, + created_nullifiers, + transactions, + )) + } +} + +// BLOCK BODY COMPONENTS +// ================================================================================================ + +impl From<&BlockAccountUpdate> for proto::blockchain::BlockAccountUpdate { + fn from(update: &BlockAccountUpdate) -> Self { + Self { + account_id: Some(update.account_id().into()), + final_state_commitment: Some(update.final_state_commitment().into()), + details: Some(update.details().into()), + } + } +} + +impl TryFrom for BlockAccountUpdate { + type Error = ConversionError; + + fn try_from(update: proto::blockchain::BlockAccountUpdate) -> Result { + let decoder = update.decoder(); + let account_id: AccountId = decode!(decoder, update.account_id)?; + let final_state_commitment = decode!(decoder, update.final_state_commitment)?; + let details: AccountUpdateDetails = decode!(decoder, update.details)?; + if let AccountUpdateDetails::Public(patch) = &details + && patch.id() != account_id + { + return Err(ConversionError::message(format!( + "public patch account ID {} does not match enclosing account ID {account_id}", + patch.id() + )) + .context("details.public.account_id")); + } + + Ok(BlockAccountUpdate::new(account_id, final_state_commitment, details)) + } +} + +impl From<&(usize, OutputNote)> for proto::blockchain::IndexedOutputNote { + fn from((index, note): &(usize, OutputNote)) -> Self { + Self { + note_index_in_batch: u32::try_from(*index) + .expect("valid output note indices fit into u32"), + note: Some(note.into()), + } + } +} + +impl TryFrom for (usize, OutputNote) { + type Error = ConversionError; + + fn try_from(note: proto::blockchain::IndexedOutputNote) -> Result { + let decoder = note.decoder(); + let index = usize::try_from(note.note_index_in_batch).context("note_index_in_batch")?; + if index >= MAX_OUTPUT_NOTES_PER_BATCH { + return Err(ConversionError::message(format!( + "note index {index} exceeds maximum {}", + MAX_OUTPUT_NOTES_PER_BATCH - 1 + )) + .context("note_index_in_batch")); + } + let output_note = decode!(decoder, note.note)?; + Ok((index, output_note)) + } +} + +impl From<&OutputNoteBatch> for proto::blockchain::OutputNoteBatch { + fn from(batch: &OutputNoteBatch) -> Self { + Self { + notes: batch.iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for OutputNoteBatch { + type Error = ConversionError; + + fn try_from(batch: proto::blockchain::OutputNoteBatch) -> Result { + if batch.notes.len() > MAX_OUTPUT_NOTES_PER_BATCH { + return Err(ConversionError::message(format!( + "batch has {} notes, maximum is {MAX_OUTPUT_NOTES_PER_BATCH}", + batch.notes.len() + )) + .context("notes")); + } + + let mut indices = BTreeSet::new(); + batch + .notes + .into_iter() + .enumerate() + .map(|(position, note)| { + let (index, note) = + <(usize, OutputNote)>::try_from(note).context(format!("notes[{position}]"))?; + if !indices.insert(index) { + return Err(ConversionError::message(format!("duplicate note index {index}")) + .context(format!("notes[{position}].note_index_in_batch"))); + } + Ok((index, note)) + }) + .collect() + } +} + +// BLOCK PROOF +// ================================================================================================ + +impl From<&BlockProof> for proto::blockchain::BlockProof { + fn from(_proof: &BlockProof) -> Self { + Self {} + } +} + +impl From for proto::blockchain::BlockProof { + fn from(proof: BlockProof) -> Self { + Self::from(&proof) + } +} + +impl TryFrom for BlockProof { + type Error = ConversionError; + + fn try_from(_proof: proto::blockchain::BlockProof) -> Result { + // BlockProof is currently an empty placeholder without a public production constructor. + // Replace this isolated workaround with field-based construction when it gains fields. + BlockProof::read_from_bytes(&[]) + .map_err(|source| ConversionError::deserialization("BlockProof", source)) } } @@ -169,8 +381,8 @@ impl TryFrom for SignedBlock { type Error = ConversionError; fn try_from(value: proto::blockchain::SignedBlock) -> Result { let decoder = value.decoder(); - let header = decode!(decoder, value.header)?; - let body = decode!(decoder, value.body)?; + let header: BlockHeader = decode!(decoder, value.header)?; + let body: BlockBody = decode!(decoder, value.body)?; let signatures = value .signatures .into_iter() @@ -181,7 +393,31 @@ impl TryFrom for SignedBlock { .map_err(ConversionError::new) .context("signatures")?; - Ok(SignedBlock::new_unchecked(header, body, signatures)) + if header.tx_commitment() != body.transaction_commitment() { + return Err(ConversionError::message(format!( + "header transaction commitment {} does not match body transaction commitment {}", + header.tx_commitment(), + body.transaction_commitment(), + )) + .context("tx_commitment") + .context("header")); + } + + let body_note_root = body.compute_block_note_tree().root(); + if header.note_root() != body_note_root { + return Err(ConversionError::message(format!( + "header note root {} does not match body note root {body_note_root}", + header.note_root(), + )) + .context("note_root") + .context("header")); + } + + // This establishes header/body self-consistency. Parent/signature trust is validated by the + // state application path against an already trusted parent header. + SignedBlock::new(header, body, signatures) + .map_err(ConversionError::new) + .context("body") } } @@ -302,3 +538,216 @@ impl From> for proto::rpc::BlockRange { } } } + +#[cfg(test)] +mod tests { + use miden_protocol::account::{ + AccountId, + AccountIdVersion, + AccountType, + AccountUpdateDetails, + AssetCallbackFlag, + }; + use miden_protocol::block::{ + BlockAccountUpdate, + BlockBody, + BlockHeader, + BlockProof, + BlockSignatures, + SignedBlock, + }; + use miden_protocol::note::{Note, NoteAttachments, Nullifier}; + use miden_protocol::transaction::{OrderedTransactionHeaders, OutputNote, PrivateOutputNote}; + use miden_protocol::utils::serde::Serializable; + use miden_protocol::{MAX_BATCHES_PER_BLOCK, MAX_OUTPUT_NOTES_PER_BATCH, Word}; + use prost::Message; + + use crate::generated as proto; + + fn empty_body() -> BlockBody { + BlockBody::new_unchecked( + Vec::new(), + Vec::new(), + Vec::new(), + OrderedTransactionHeaders::new_unchecked(Vec::new()), + ) + } + + fn header_for(body: &BlockBody) -> BlockHeader { + let template = BlockHeader::mock( + 1, + None, + Some(body.compute_block_note_tree().root()), + &[], + Word::default(), + ); + BlockHeader::new( + template.version(), + template.prev_block_commitment(), + template.block_num(), + template.chain_commitment(), + template.account_root(), + template.nullifier_root(), + body.compute_block_note_tree().root(), + body.transaction_commitment(), + template.tx_kernel_commitment(), + template.validator_keys().clone(), + template.fee_parameters().clone(), + template.timestamp(), + ) + } + + fn private_output_note(serial_num: Word) -> OutputNote { + let note = Note::mock_noop(serial_num); + OutputNote::Private( + PrivateOutputNote::new(*note.header(), NoteAttachments::default()) + .expect("mock note is private"), + ) + } + + #[test] + fn empty_block_body_roundtrips_structurally() { + let body = empty_body(); + let encoded = proto::blockchain::BlockBody::from(&body); + + assert!(encoded.contents.is_some()); + assert_eq!(BlockBody::try_from(encoded).unwrap(), body); + } + + #[test] + fn representative_block_body_preserves_order_and_sparse_note_indices() { + let account_id = AccountId::dummy( + [7; 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ); + let account_update = BlockAccountUpdate::new( + account_id, + Word::from([1_u32, 2, 3, 4]), + AccountUpdateDetails::Private, + ); + let body = BlockBody::new_unchecked( + vec![account_update], + vec![vec![(3, private_output_note(Word::from([5_u32, 6, 7, 8])))], vec![]], + vec![ + Nullifier::from_raw(Word::from([9_u32, 10, 11, 12])), + Nullifier::from_raw(Word::from([13_u32, 14, 15, 16])), + ], + OrderedTransactionHeaders::new_unchecked(Vec::new()), + ); + let expected_indices = body.output_notes().map(|(index, _)| index).collect::>(); + let expected_tx_commitment = body.transaction_commitment(); + let expected_note_root = body.compute_block_note_tree().root(); + + let proto_body = proto::blockchain::BlockBody::from(&body); + let decoded = BlockBody::try_from(proto_body.clone()).unwrap(); + + assert_eq!(decoded, body); + assert_eq!(decoded.transaction_commitment(), expected_tx_commitment); + assert_eq!(decoded.compute_block_note_tree().root(), expected_note_root); + assert_eq!( + decoded.output_notes().map(|(index, _)| index).collect::>(), + expected_indices + ); + assert!(proto_body.encoded_len() > 0, "structured body should have a wire payload"); + } + + #[test] + fn block_body_requires_contents() { + let error = BlockBody::try_from(proto::blockchain::BlockBody { contents: None }) + .unwrap_err() + .to_string(); + assert!(error.contains("contents")); + } + + #[test] + fn output_note_batches_reject_duplicate_and_out_of_range_indices() { + let note = proto::transaction::OutputNote::from(&private_output_note(Word::default())); + let duplicate = proto::blockchain::OutputNoteBatch { + notes: vec![ + proto::blockchain::IndexedOutputNote { + note_index_in_batch: 2, + note: Some(note.clone()), + }, + proto::blockchain::IndexedOutputNote { + note_index_in_batch: 2, + note: Some(note.clone()), + }, + ], + }; + let error = miden_protocol::block::OutputNoteBatch::try_from(duplicate) + .unwrap_err() + .to_string(); + assert!(error.contains("notes[1].note_index_in_batch")); + + let out_of_range = proto::blockchain::OutputNoteBatch { + notes: vec![proto::blockchain::IndexedOutputNote { + note_index_in_batch: u32::try_from(MAX_OUTPUT_NOTES_PER_BATCH).unwrap(), + note: Some(note), + }], + }; + let error = miden_protocol::block::OutputNoteBatch::try_from(out_of_range) + .unwrap_err() + .to_string(); + assert!(error.contains("note_index_in_batch")); + } + + #[test] + fn block_body_rejects_too_many_batches() { + let contents = proto::blockchain::BlockBodyContents { + output_note_batches: vec![ + proto::blockchain::OutputNoteBatch::default(); + MAX_BATCHES_PER_BLOCK + 1 + ], + ..Default::default() + }; + let error = BlockBody::try_from(proto::blockchain::BlockBody { contents: Some(contents) }) + .unwrap_err() + .to_string(); + assert!(error.contains("output_note_batches")); + } + + #[test] + fn signed_block_roundtrips_and_rejects_commitment_mismatches() { + let body = empty_body(); + let header = header_for(&body); + let block = + SignedBlock::new(header, body, BlockSignatures::new(Vec::new()).unwrap()).unwrap(); + let encoded = proto::blockchain::SignedBlock::from(&block); + + assert_eq!(SignedBlock::try_from(encoded.clone()).unwrap(), block); + + let mut bad_tx_commitment = encoded.clone(); + bad_tx_commitment.header.as_mut().unwrap().tx_commitment = + Some(Word::from([1_u32, 0, 0, 0]).into()); + let error = SignedBlock::try_from(bad_tx_commitment).unwrap_err().to_string(); + assert!(error.contains("header.tx_commitment")); + + let mut bad_note_root = encoded; + bad_note_root.header.as_mut().unwrap().note_root = + Some(Word::from([1_u32, 0, 0, 0]).into()); + let error = SignedBlock::try_from(bad_note_root).unwrap_err().to_string(); + assert!(error.contains("header.note_root")); + } + + #[test] + fn block_proof_roundtrips_and_keeps_presence_distinct() { + let proof = BlockProof::new_dummy(); + assert!( + proof.to_bytes().is_empty(), + "update the protobuf proof envelope when BlockProof changes" + ); + + let encoded = proto::blockchain::BlockProof::from(&proof); + assert_eq!(BlockProof::try_from(encoded).unwrap(), proof); + + let absent = proto::blockchain::MaybeBlock::default(); + let present = proto::blockchain::MaybeBlock { + block_proof: Some(encoded), + ..Default::default() + }; + assert!(absent.block_proof.is_none()); + assert!(present.block_proof.is_some()); + } +} diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index 6763b66195..aa5c696f45 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -1,4 +1,5 @@ pub mod account; +mod account_patch; pub mod batch; pub mod block; pub mod digest; diff --git a/crates/proto/src/domain/transaction.rs b/crates/proto/src/domain/transaction.rs index 13fcb98a9c..76331152c2 100644 --- a/crates/proto/src/domain/transaction.rs +++ b/crates/proto/src/domain/transaction.rs @@ -1,6 +1,15 @@ use miden_protocol::Word; -use miden_protocol::note::Nullifier; -use miden_protocol::transaction::{InputNoteCommitment, TransactionId}; +use miden_protocol::account::AccountId; +use miden_protocol::note::{Note, NoteHeader, Nullifier}; +use miden_protocol::transaction::{ + InputNoteCommitment, + InputNotes, + OutputNote, + PrivateOutputNote, + PublicOutputNote, + TransactionHeader, + TransactionId, +}; use crate::decode::{ConversionResultExt, GrpcDecodeExt}; use crate::errors::ConversionError; @@ -59,6 +68,12 @@ impl TryFrom for TransactionId { impl From for proto::transaction::InputNoteCommitment { fn from(value: InputNoteCommitment) -> Self { + Self::from(&value) + } +} + +impl From<&InputNoteCommitment> for proto::transaction::InputNoteCommitment { + fn from(value: &InputNoteCommitment) -> Self { Self { nullifier: Some(value.nullifier().into()), header: value.header().copied().map(Into::into), @@ -79,3 +94,230 @@ impl TryFrom for InputNoteCommitment { Ok(InputNoteCommitment::from_parts_unchecked(nullifier, header)) } } + +// TRANSACTION HEADER +// ================================================================================================ + +impl From<&TransactionHeader> for proto::transaction::TransactionHeader { + fn from(header: &TransactionHeader) -> Self { + Self { + transaction_id: Some(header.id().into()), + account_id: Some(header.account_id().into()), + initial_state_commitment: Some(header.initial_state_commitment().into()), + final_state_commitment: Some(header.final_state_commitment().into()), + input_notes: header.input_notes().iter().map(Into::into).collect(), + output_notes: header.output_notes().iter().copied().map(Into::into).collect(), + } + } +} + +impl From for proto::transaction::TransactionHeader { + fn from(header: TransactionHeader) -> Self { + Self::from(&header) + } +} + +impl TryFrom for TransactionHeader { + type Error = ConversionError; + + fn try_from(header: proto::transaction::TransactionHeader) -> Result { + let decoder = header.decoder(); + let transmitted_id: TransactionId = decode!(decoder, header.transaction_id)?; + let account_id: AccountId = decode!(decoder, header.account_id)?; + let initial_state_commitment = decode!(decoder, header.initial_state_commitment)?; + let final_state_commitment = decode!(decoder, header.final_state_commitment)?; + let input_notes = header + .input_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + InputNoteCommitment::try_from(note).context(format!("input_notes[{index}]")) + }) + .collect::, _>>()?; + let input_notes = InputNotes::new(input_notes) + .map_err(ConversionError::new) + .context("input_notes")?; + let output_notes = header + .output_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + NoteHeader::try_from(note).context(format!("output_notes[{index}]")) + }) + .collect::, _>>()?; + + let header = TransactionHeader::new( + account_id, + initial_state_commitment, + final_state_commitment, + input_notes, + output_notes, + ); + if header.id() != transmitted_id { + return Err(ConversionError::message(format!( + "transaction ID mismatch: transmitted {transmitted_id}, recomputed {}", + header.id() + )) + .context("transaction_id")); + } + + Ok(header) + } +} + +// OUTPUT NOTES +// ================================================================================================ + +impl From<&PublicOutputNote> for proto::transaction::PublicOutputNote { + fn from(note: &PublicOutputNote) -> Self { + let details = proto::note::NoteDetails { + assets: note.assets().iter().copied().map(Into::into).collect(), + recipient: Some(note.recipient().into()), + }; + Self { + metadata: Some((*note.metadata()).into()), + details: Some(details), + attachments: Some(note.as_note().attachments().into()), + } + } +} + +impl From for proto::transaction::PublicOutputNote { + fn from(note: PublicOutputNote) -> Self { + Self::from(¬e) + } +} + +impl TryFrom for PublicOutputNote { + type Error = ConversionError; + + fn try_from(note: proto::transaction::PublicOutputNote) -> Result { + let domain_note = Note::try_from(proto::note::Note { + metadata: note.metadata, + note_details: note.details, + note_attachments: note.attachments, + })?; + PublicOutputNote::new(domain_note).map_err(ConversionError::new) + } +} + +impl From<&PrivateOutputNote> for proto::transaction::PrivateOutputNote { + fn from(note: &PrivateOutputNote) -> Self { + Self { + header: Some((*note.header()).into()), + attachments: Some(note.attachments().into()), + } + } +} + +impl From for proto::transaction::PrivateOutputNote { + fn from(note: PrivateOutputNote) -> Self { + Self::from(¬e) + } +} + +impl TryFrom for PrivateOutputNote { + type Error = ConversionError; + + fn try_from(note: proto::transaction::PrivateOutputNote) -> Result { + let decoder = note.decoder(); + let header = decode!(decoder, note.header)?; + let attachments = decode!(decoder, note.attachments)?; + PrivateOutputNote::new(header, attachments).map_err(ConversionError::new) + } +} + +impl From<&OutputNote> for proto::transaction::OutputNote { + fn from(note: &OutputNote) -> Self { + use proto::transaction::output_note::Note; + + let note = match note { + OutputNote::Public(note) => Note::Public(note.into()), + OutputNote::Private(note) => Note::Private(note.into()), + }; + Self { note: Some(note) } + } +} + +impl From for proto::transaction::OutputNote { + fn from(note: OutputNote) -> Self { + Self::from(¬e) + } +} + +impl TryFrom for OutputNote { + type Error = ConversionError; + + fn try_from(note: proto::transaction::OutputNote) -> Result { + use proto::transaction::output_note::Note; + + match note.note { + Some(Note::Public(note)) => note.try_into().map(OutputNote::Public).context("public"), + Some(Note::Private(note)) => { + note.try_into().map(OutputNote::Private).context("private") + }, + None => Err(ConversionError::missing_field::("note")), + } + } +} + +#[cfg(test)] +mod tests { + use miden_protocol::Word; + use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, AssetCallbackFlag}; + use miden_protocol::note::{Note, NoteAttachments}; + use miden_protocol::transaction::{ + InputNoteCommitment, + InputNotes, + OutputNote, + PrivateOutputNote, + TransactionHeader, + }; + + use crate::generated as proto; + + fn account_id() -> AccountId { + AccountId::dummy( + [9; 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ) + } + + #[test] + fn transaction_header_roundtrips_and_rejects_a_mismatched_id() { + let header = TransactionHeader::new( + account_id(), + Word::from([1_u32, 2, 3, 4]), + Word::from([5_u32, 6, 7, 8]), + InputNotes::::new(Vec::new()).unwrap(), + Vec::new(), + ); + let encoded = proto::transaction::TransactionHeader::from(&header); + assert_eq!(TransactionHeader::try_from(encoded.clone()).unwrap(), header); + + let mut mismatched = encoded; + mismatched.transaction_id = + Some(miden_protocol::transaction::TransactionId::from_raw(Word::default()).into()); + let error = TransactionHeader::try_from(mismatched).unwrap_err().to_string(); + assert!(error.contains("transaction_id")); + } + + #[test] + fn private_output_note_roundtrips_and_oneof_is_required() { + let note = Note::mock_noop(Word::from([4_u32, 3, 2, 1])); + let output = OutputNote::Private( + PrivateOutputNote::new(*note.header(), NoteAttachments::default()).unwrap(), + ); + assert_eq!( + OutputNote::try_from(proto::transaction::OutputNote::from(&output)).unwrap(), + output + ); + + let error = OutputNote::try_from(proto::transaction::OutputNote::default()) + .unwrap_err() + .to_string(); + assert!(error.contains("note")); + } +} diff --git a/crates/proto/src/errors/mod.rs b/crates/proto/src/errors/mod.rs index 46db6d2142..aecd0bc887 100644 --- a/crates/proto/src/errors/mod.rs +++ b/crates/proto/src/errors/mod.rs @@ -19,7 +19,7 @@ mod test_macro; /// Always maps to [`tonic::Status::invalid_argument()`]. #[derive(Debug)] pub struct ConversionError { - path: Vec<&'static str>, + path: Vec, source: Box, } @@ -42,8 +42,8 @@ impl ConversionError { /// [`missing_field`](Self::missing_field) which already embeds the field name in its /// message. #[must_use] - pub fn context(mut self, field: &'static str) -> Self { - self.path.push(field); + pub fn context(mut self, field: impl Into) -> Self { + self.path.push(field.into()); self } @@ -164,11 +164,11 @@ impl std::error::Error for StringError {} /// `"header.account_root: value is not in range 0..MODULUS"`. pub trait ConversionResultExt { /// Add field context to the error, wrapping it in a [`ConversionError`] if needed. - fn context(self, field: &'static str) -> Result; + fn context(self, field: impl Into) -> Result; } impl> ConversionResultExt for Result { - fn context(self, field: &'static str) -> Result { + fn context(self, field: impl Into) -> Result { self.map_err(|e| e.into().context(field)) } } diff --git a/crates/rpc/src/server/api/get_block_by_number.rs b/crates/rpc/src/server/api/get_block_by_number.rs index 49af795256..755d4de973 100644 --- a/crates/rpc/src/server/api/get_block_by_number.rs +++ b/crates/rpc/src/server/api/get_block_by_number.rs @@ -1,6 +1,7 @@ use miden_node_proto::generated as proto; use miden_node_utils::tracing::miden_instrument; -use miden_protocol::block::BlockNumber; +use miden_protocol::block::{BlockNumber, BlockProof, SignedBlock}; +use miden_protocol::utils::serde::Deserializable; use tracing::debug; use super::{RpcService, database_error_to_status}; @@ -50,6 +51,30 @@ impl proto::server::rpc_api::GetBlockByNumber for RpcService { None }; - Ok(proto::blockchain::MaybeBlock { block, proof }) + let signed_block = block + .map(|bytes| { + SignedBlock::read_from_bytes(&bytes).map(Into::into).map_err(|err| { + tonic::Status::data_loss(format!( + "stored block {block_num} could not be decoded: {err}" + )) + }) + }) + .transpose()?; + let block_proof = proof + .map(|bytes| { + if !bytes.is_empty() { + return Err(tonic::Status::data_loss(format!( + "stored proof for block {block_num} uses an unsupported non-empty placeholder encoding" + ))); + } + BlockProof::read_from_bytes(&bytes).map(Into::into).map_err(|err| { + tonic::Status::data_loss(format!( + "stored proof for block {block_num} could not be decoded: {err}" + )) + }) + }) + .transpose()?; + + Ok(proto::blockchain::MaybeBlock { signed_block, block_proof }) } } diff --git a/crates/rpc/src/server/api/subscription/block.rs b/crates/rpc/src/server/api/subscription/block.rs index ca2fc0978a..d5797b1c24 100644 --- a/crates/rpc/src/server/api/subscription/block.rs +++ b/crates/rpc/src/server/api/subscription/block.rs @@ -1,7 +1,8 @@ use miden_node_proto::generated as proto; use miden_node_utils::grpc::ClientIp; use miden_node_utils::tracing::miden_instrument; -use miden_protocol::block::BlockNumber; +use miden_protocol::block::{BlockNumber, SignedBlock}; +use miden_protocol::utils::serde::Deserializable; use tracing::debug; use super::super::{COMPONENT, RpcService}; @@ -19,9 +20,15 @@ impl proto::server::rpc_api::BlockSubscription for RpcService { } fn encode(event: Self::Item) -> tonic::Result { + let signed_block = SignedBlock::read_from_bytes(&event.data).map_err(|err| { + tonic::Status::data_loss(format!( + "stored block {} could not be decoded: {err}", + event.block + )) + })?; Ok(proto::rpc::BlockSubscriptionResponse { - block: event.data, committed_chain_tip: event.tip.as_u32(), + signed_block: Some(signed_block.into()), }) } @@ -47,3 +54,23 @@ impl proto::server::rpc_api::BlockSubscription for RpcService { SubscriptionStream::blocks(self, from, client_ip) } } + +#[cfg(test)] +mod tests { + use miden_protocol::block::BlockNumber; + use tonic::Code; + + use super::{RpcService, StreamItem}; + use crate::server::rpc_api::BlockSubscription; + + #[test] + fn corrupt_stored_block_is_reported_as_data_loss() { + let result = ::encode(StreamItem { + data: vec![0xff], + block: BlockNumber::from(4_u32), + tip: BlockNumber::from(7_u32), + }); + + assert_eq!(result.unwrap_err().code(), Code::DataLoss); + } +} diff --git a/crates/rpc/src/server/api/subscription/proof.rs b/crates/rpc/src/server/api/subscription/proof.rs index 300cd976c7..908bb98b65 100644 --- a/crates/rpc/src/server/api/subscription/proof.rs +++ b/crates/rpc/src/server/api/subscription/proof.rs @@ -1,7 +1,8 @@ use miden_node_proto::generated as proto; use miden_node_utils::grpc::ClientIp; use miden_node_utils::tracing::miden_instrument; -use miden_protocol::block::BlockNumber; +use miden_protocol::block::{BlockNumber, BlockProof}; +use miden_protocol::utils::serde::Deserializable; use tracing::debug; use super::super::{COMPONENT, RpcService}; @@ -19,10 +20,22 @@ impl proto::server::rpc_api::ProofSubscription for RpcService { } fn encode(event: Self::Item) -> tonic::Result { + if !event.data.is_empty() { + return Err(tonic::Status::data_loss(format!( + "stored proof for block {} uses an unsupported non-empty placeholder encoding", + event.block + ))); + } + let block_proof = BlockProof::read_from_bytes(&event.data).map_err(|err| { + tonic::Status::data_loss(format!( + "stored proof for block {} could not be decoded: {err}", + event.block + )) + })?; Ok(proto::rpc::ProofSubscriptionResponse { block_num: event.block.as_u32(), - proof: event.data, proven_chain_tip: event.tip.as_u32(), + block_proof: Some(block_proof.into()), }) } @@ -48,3 +61,23 @@ impl proto::server::rpc_api::ProofSubscription for RpcService { SubscriptionStream::proofs(self, from, client_ip) } } + +#[cfg(test)] +mod tests { + use miden_protocol::block::BlockNumber; + use tonic::Code; + + use super::{RpcService, StreamItem}; + use crate::server::rpc_api::ProofSubscription; + + #[test] + fn corrupt_stored_proof_is_reported_as_data_loss() { + let result = ::encode(StreamItem { + data: vec![0xff], + block: BlockNumber::from(4_u32), + tip: BlockNumber::from(7_u32), + }); + + assert_eq!(result.unwrap_err().code(), Code::DataLoss); + } +} diff --git a/crates/rpc/src/server/api/sync_transactions.rs b/crates/rpc/src/server/api/sync_transactions.rs index 984e9d7c84..df0ccfc357 100644 --- a/crates/rpc/src/server/api/sync_transactions.rs +++ b/crates/rpc/src/server/api/sync_transactions.rs @@ -104,14 +104,7 @@ fn transaction_record_to_proto(record: TransactionRecord) -> proto::rpc::Transac .collect(); proto::rpc::TransactionRecord { - header: Some(proto::transaction::TransactionHeader { - transaction_id: Some(record.header.id().into()), - account_id: Some(record.header.account_id().into()), - initial_state_commitment: Some(record.header.initial_state_commitment().into()), - final_state_commitment: Some(record.header.final_state_commitment().into()), - input_notes: record.header.input_notes().iter().cloned().map(Into::into).collect(), - output_notes: record.header.output_notes().iter().copied().map(Into::into).collect(), - }), + header: Some(record.header.into()), block_num: record.block_num.as_u32(), output_note_proofs, consumed_note_refs, diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index b78f03ebb6..bfd4dee0be 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -43,6 +43,7 @@ use miden_protocol::account::{ AccountUpdateDetails, AssetCallbackFlag, }; +use miden_protocol::block::SignedBlock; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; use miden_protocol::transaction::{ProvenTransaction, TxAccountUpdate}; use miden_protocol::utils::serde::Serializable; @@ -51,6 +52,7 @@ use miden_standards::account::wallets::BasicWallet; use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task; +use tokio_stream::StreamExt; use tokio_stream::wrappers::TcpListenerStream; use tonic::Request; use tonic::metadata::MetadataMap; @@ -184,6 +186,122 @@ fn rpc_descriptor_exposes_structured_note_schema() { } } +#[test] +fn rpc_descriptor_exposes_structured_blockchain_schema_and_reserves_legacy_fields() { + let descriptor = miden_node_proto_build::rpc_api_descriptor(); + let blockchain_file = descriptor + .file + .iter() + .find(|file| file.name() == "types/blockchain.proto") + .expect("the public RPC descriptor should include types/blockchain.proto"); + + for name in [ + "BlockAccountUpdate", + "IndexedOutputNote", + "OutputNoteBatch", + "BlockBodyContents", + "BlockProof", + ] { + assert!( + blockchain_file.message_type.iter().any(|message| message.name() == name), + "the public RPC descriptor should expose blockchain.{name}" + ); + } + + let block_body = blockchain_file + .message_type + .iter() + .find(|message| message.name() == "BlockBody") + .expect("blockchain.BlockBody should be present"); + assert!(block_body.reserved_name.iter().any(|name| name == "block_body")); + assert!( + block_body + .reserved_range + .iter() + .any(|range| range.start() <= 1 && range.end() > 1) + ); + let contents = block_body + .field + .iter() + .find(|field| field.name() == "contents") + .expect("structured BlockBody.contents should be present"); + assert_eq!(contents.number(), 2); + assert_eq!(contents.type_name(), ".blockchain.BlockBodyContents"); + + let maybe_block = blockchain_file + .message_type + .iter() + .find(|message| message.name() == "MaybeBlock") + .expect("blockchain.MaybeBlock should be present"); + for field_number in [1, 2] { + assert!( + maybe_block + .reserved_range + .iter() + .any(|range| range.start() <= field_number && range.end() > field_number) + ); + } + for field_name in ["block", "proof"] { + assert!(maybe_block.reserved_name.iter().any(|name| name == field_name)); + assert!(!maybe_block.field.iter().any(|field| field.name() == field_name)); + } + assert_eq!( + maybe_block + .field + .iter() + .find(|field| field.name() == "signed_block") + .expect("MaybeBlock.signed_block should be present") + .number(), + 3 + ); + assert_eq!( + maybe_block + .field + .iter() + .find(|field| field.name() == "block_proof") + .expect("MaybeBlock.block_proof should be present") + .number(), + 4 + ); +} + +#[test] +fn rpc_descriptor_exposes_structured_block_subscription_schema() { + let descriptor = miden_node_proto_build::rpc_api_descriptor(); + let rpc_file = descriptor + .file + .iter() + .find(|file| file.name().ends_with("/rpc.proto")) + .expect("the public RPC descriptor should include rpc.proto"); + for (message_name, legacy_name, legacy_number, structured_name, structured_number) in [ + ("BlockSubscriptionResponse", "block", 1, "signed_block", 3), + ("ProofSubscriptionResponse", "proof", 2, "block_proof", 4), + ] { + let message = rpc_file + .message_type + .iter() + .find(|message| message.name() == message_name) + .unwrap_or_else(|| panic!("rpc.{message_name} should be present")); + assert!(message.reserved_name.iter().any(|name| name == legacy_name)); + assert!( + message + .reserved_range + .iter() + .any(|range| range.start() <= legacy_number && range.end() > legacy_number) + ); + assert!(!message.field.iter().any(|field| field.name() == legacy_name)); + assert_eq!( + message + .field + .iter() + .find(|field| field.name() == structured_name) + .unwrap_or_else(|| panic!("rpc.{message_name}.{structured_name} should be present")) + .number(), + structured_number + ); + } +} + /// Creates a minimal account and its patch for testing proven transaction building. fn build_test_account(seed: [u8; 32]) -> (Account, AccountPatch) { let account = AccountBuilder::new(seed) @@ -423,6 +541,35 @@ async fn rpc_uses_in_process_store_state() { assert!(response.unwrap().into_inner().block_header.is_some()); } +#[tokio::test] +async fn unary_and_streaming_block_responses_use_the_same_structured_value() { + let (mut rpc_client, _, store) = start_rpc().await; + let unary = rpc_client + .get_block_by_number(proto::blockchain::BlockRequest { + block_num: 0, + include_proof: Some(false), + }) + .await + .unwrap() + .into_inner(); + assert!(unary.block_proof.is_none()); + let unary_block = SignedBlock::try_from(unary.signed_block.unwrap()).unwrap(); + + let stored_bytes = store.state.load_block(0_u32.into()).await.unwrap().unwrap(); + assert_eq!(unary_block.to_bytes(), stored_bytes); + + let mut stream = rpc_client + .block_subscription(proto::rpc::BlockSubscriptionRequest { block_from: 0 }) + .await + .unwrap() + .into_inner(); + let event = stream.next().await.unwrap().unwrap(); + assert_eq!(event.committed_chain_tip, 0); + let streamed_block = SignedBlock::try_from(event.signed_block.unwrap()).unwrap(); + + assert_eq!(streamed_block, unary_block); +} + #[tokio::test] async fn rpc_server_has_web_support() { // Start server diff --git a/crates/store/src/state/writer/apply_proof.rs b/crates/store/src/state/writer/apply_proof.rs index 029557415d..4b06d7ca50 100644 --- a/crates/store/src/state/writer/apply_proof.rs +++ b/crates/store/src/state/writer/apply_proof.rs @@ -1,7 +1,7 @@ -use anyhow::{Context, ensure}; +use anyhow::ensure; use miden_node_utils::tracing::miden_instrument; use miden_protocol::block::{BlockNumber, BlockProof}; -use miden_protocol::utils::serde::Deserializable; +use miden_protocol::utils::serde::Serializable; use crate::COMPONENT; use crate::state::{ProofNotification, ProofWriter}; @@ -23,7 +23,7 @@ impl ProofWriter { pub async fn apply_proof( &mut self, block_num: BlockNumber, - proof_bytes: Vec, + proof: BlockProof, ) -> anyhow::Result<()> { let expected = self.state.proven_tip().child(); ensure!( @@ -37,7 +37,11 @@ impl ProofWriter { "proof for uncommitted block {block_num} exceeds committed tip {committed_tip}", ); - verify_block_proof(block_num, &proof_bytes)?; + verify_block_proof(block_num, &proof)?; + + // Persistence remains in the canonical Miden protocol encoding. The gRPC boundary uses a + // structured proof message and passes the domain value to this writer. + let proof_bytes = proof.to_bytes(); self.state.block_store.commit_proof(block_num, &proof_bytes).await?; self.state @@ -49,11 +53,12 @@ impl ProofWriter { } } -/// Verifies that `proof_bytes` is a valid [`BlockProof`] for the block at `block_num`. -fn verify_block_proof(_block_num: BlockNumber, proof_bytes: &[u8]) -> anyhow::Result<()> { - let _proof = - BlockProof::read_from_bytes(proof_bytes).context("failed to deserialize block proof")?; - +/// Verifies that `proof` is a valid [`BlockProof`] for the block at `block_num`. +fn verify_block_proof(_block_num: BlockNumber, proof: &BlockProof) -> anyhow::Result<()> { // TODO: perform verification. + ensure!( + proof.to_bytes().is_empty(), + "unsupported non-empty placeholder block proof encoding" + ); Ok(()) } diff --git a/proto/README.md b/proto/README.md index e0f441a891..f4b03737f1 100644 --- a/proto/README.md +++ b/proto/README.md @@ -15,8 +15,11 @@ navigation and documentation links, see the [primary README](https://github.com/ ## Wire compatibility Generated clients must use the protobuf definitions from the same Miden node release. The note API now represents -`NoteDetails` and `NoteAttachments` as structured protobuf messages; clients generated from the earlier opaque `bytes` -fields are wire-incompatible and must regenerate their bindings before connecting to this release. +`NoteDetails` and `NoteAttachments` as structured protobuf messages. Block APIs likewise return a structured +`SignedBlock`, whose `BlockBody` contains structured account updates, output-note batches, nullifiers, and transaction +headers, plus a presence-bearing `BlockProof` message. Clients generated from the earlier opaque `bytes` fields are +wire-incompatible and must regenerate their bindings before connecting to this release. Stored block and proof files +retain their existing Miden serialization; only the gRPC representation changed. ## Crate Features diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index c7d7d09b18..69c522cb9d 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -48,12 +48,14 @@ message BlockSubscriptionRequest { // A signed block streamed to a subscriber. message BlockSubscriptionResponse { - // The block encoded using [miden_serde_utils::Serializable] implementation for - // [miden_protocol::block::SignedBlock]. - bytes block = 1; + reserved 1; + reserved "block"; // The signed chain tip when this item was emitted. fixed32 committed_chain_tip = 2; + + // The signed block. + blockchain.SignedBlock signed_block = 3; } // VALIDATOR STATUS diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 66f9468f45..37ed9556ab 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -131,12 +131,14 @@ message BlockSubscriptionRequest { // A committed block streamed to a replica. message BlockSubscriptionResponse { - // The block encoded using [miden_serde_utils::Serializable] implementation for - // [miden_protocol::block::SignedBlock]. - bytes block = 1; + reserved 1; + reserved "block"; // The committed chain tip when this item was emitted. fixed32 committed_chain_tip = 2; + + // The committed signed block. + blockchain.SignedBlock signed_block = 3; } // PROOF SUBSCRIPTION @@ -153,12 +155,14 @@ message ProofSubscriptionResponse { // The block number this proof corresponds to. fixed32 block_num = 1; - // The block proof encoded using [miden_serde_utils::Serializable] implementation for - // [miden_protocol::block::BlockProof]. - bytes proof = 2; + reserved 2; + reserved "proof"; // The proven chain tip when this item was emitted. fixed32 proven_chain_tip = 3; + + // The proof for block_num. Presence distinguishes an available empty placeholder proof. + blockchain.BlockProof block_proof = 4; } // RPC STATUS diff --git a/proto/proto/types/account.proto b/proto/proto/types/account.proto index 6df8725658..044a7f1cf0 100644 --- a/proto/proto/types/account.proto +++ b/proto/proto/types/account.proto @@ -98,3 +98,82 @@ message AccountWitness { // The merkle path of the state commitment in the account tree. primitives.SparseMerklePath path = 4; } + +// ACCOUNT PATCH +// ================================================================================================ + +// Public interface and implementation of an account. +message AccountCode { + // Deliberately opaque MastForest encoded with Miden serialization. + bytes mast = 1; + + // Ordered public interface procedures. + repeated primitives.Word procedure_roots = 2; +} + +enum StoragePatchOperation { + STORAGE_PATCH_OPERATION_UNSPECIFIED = 0; + STORAGE_PATCH_OPERATION_CREATE = 1; + STORAGE_PATCH_OPERATION_UPDATE = 2; + STORAGE_PATCH_OPERATION_REMOVE = 3; +} + +message StorageValuePatch { + StoragePatchOperation operation = 1; + + // Required for CREATE/UPDATE and forbidden for REMOVE. + primitives.Word value = 2; +} + +message StorageMapEntry { + primitives.Word key = 1; + primitives.Word value = 2; +} + +message StorageMapPatch { + StoragePatchOperation operation = 1; + + // CREATE may be empty; UPDATE must be non-empty; REMOVE must be empty. + repeated StorageMapEntry entries = 2; +} + +message StorageSlotPatch { + string slot_name = 1; + + oneof patch { + StorageValuePatch value = 2; + StorageMapPatch map = 3; + } +} + +message AccountStoragePatch { + repeated StorageSlotPatch slots = 1; +} + +message AccountVaultPatchEntry { + primitives.Word asset_id = 1; + + // Word::empty means removal; any other value must form a valid asset with asset_id. + primitives.Word value = 2; +} + +message AccountVaultPatch { + repeated AccountVaultPatchEntry entries = 1; +} + +message AccountPatch { + AccountId account_id = 1; + AccountStoragePatch storage = 2; + AccountVaultPatch vault = 3; + optional AccountCode code = 4; + optional primitives.Felt final_nonce = 5; +} + +message PrivateAccountUpdate {} + +message AccountUpdateDetails { + oneof update { + PrivateAccountUpdate private = 1; + AccountPatch public = 2; + } +} diff --git a/proto/proto/types/blockchain.proto b/proto/proto/types/blockchain.proto index f083b1c4c1..75fa592fa8 100644 --- a/proto/proto/types/blockchain.proto +++ b/proto/proto/types/blockchain.proto @@ -3,6 +3,7 @@ package blockchain; import "types/account.proto"; import "types/primitives.proto"; +import "types/transaction.proto"; // BLOCK // ================================================================================================ @@ -36,12 +37,11 @@ message BlockRequest { // Contains empty values for both blocks and proofs that are not found. Some blocks may not yet be // proven so it is possible to retrieve a block without a proof even if the proof has been requested. message MaybeBlock { - // The requested block data encoded using [miden_serde_utils::Serializable] implementation for - // [miden_protocol::block::SignedBlock]. - optional bytes block = 1; - // The block proof encoded using [miden_serde_utils::Serializable] implementation for - // [miden_protocol::block::BlockProof], if requested and available. - optional bytes proof = 2; + reserved 1, 2; + reserved "block", "proof"; + + optional SignedBlock signed_block = 3; + optional BlockProof block_proof = 4; } // Represents a block number. @@ -143,7 +143,33 @@ message FeeParameters { // Represents a block body. message BlockBody { - // Block body data encoded using [miden_serde_utils::Serializable] implementation for - // [miden_protocol::block::BlockBody]. - bytes block_body = 1; + reserved 1; + reserved "block_body"; + + BlockBodyContents contents = 2; +} + +message BlockAccountUpdate { + account.AccountId account_id = 1; + primitives.Word final_state_commitment = 2; + account.AccountUpdateDetails details = 3; +} + +message IndexedOutputNote { + uint32 note_index_in_batch = 1; + transaction.OutputNote note = 2; } + +message OutputNoteBatch { + repeated IndexedOutputNote notes = 1; +} + +message BlockBodyContents { + repeated BlockAccountUpdate updated_accounts = 1; + repeated OutputNoteBatch output_note_batches = 2; + repeated primitives.Word created_nullifiers = 3; + repeated transaction.TransactionHeader transactions = 4; +} + +// Placeholder block proof. Extend this message when the protocol proof gains fields. +message BlockProof {} diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index c95d2c41a6..5885b504f0 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -213,3 +213,24 @@ message TransactionHeader { // Output notes of the transaction. repeated note.NoteHeader output_notes = 6; } + +// OUTPUT NOTES +// ================================================================================================ + +message PublicOutputNote { + note.NoteMetadata metadata = 1; + note.NoteDetails details = 2; + note.NoteAttachments attachments = 3; +} + +message PrivateOutputNote { + note.NoteHeader header = 1; + note.NoteAttachments attachments = 2; +} + +message OutputNote { + oneof note { + PublicOutputNote public = 1; + PrivateOutputNote private = 2; + } +} From 8ae63e80d44dbdbe06278186b3a0c7edbbcfa40d Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 12 Aug 2026 13:05:07 +0200 Subject: [PATCH 6/8] feat(proto): structure proven transactions and batches Replace opaque serialized transaction and batch payloads with fine-grained protobuf messages across RPC, sequencer, validator, and remote prover APIs. Add structured execution-proof and partial-blockchain envelopes, strict domain conversions and validation, and update clients, services, tests, and migration documentation. --- Cargo.lock | 2 + Cargo.toml | 1 + bin/benchmark/src/prover.rs | 25 +- bin/benchmark/src/submit.rs | 6 +- bin/network-monitor/Cargo.toml | 1 + bin/network-monitor/src/deploy/mod.rs | 5 +- bin/network-monitor/src/remote_prover.rs | 8 +- bin/ntx-builder/src/clients/prover.rs | 25 +- bin/ntx-builder/src/clients/rpc.rs | 6 +- bin/remote-prover/src/server/prove.rs | 14 +- bin/remote-prover/src/server/prover.rs | 147 +++---- bin/remote-prover/src/server/tests.rs | 29 +- .../submit_proven_transaction.rs | 8 +- .../src/server/validator_service/tests.rs | 4 +- .../src/batch_builder/remote_prover.rs | 150 ++----- crates/block-producer/src/block_prover.rs | 22 +- .../block-producer/src/domain/transaction.rs | 9 +- crates/proto/Cargo.toml | 1 + crates/proto/build.rs | 6 + crates/proto/src/clients/mod.rs | 3 +- crates/proto/src/domain/batch.rs | 367 +++++++++++++++++- crates/proto/src/domain/block.rs | 140 ++++++- crates/proto/src/domain/mod.rs | 1 + crates/proto/src/domain/transaction.rs | 119 +++++- crates/proto/src/domain/vm.rs | 316 +++++++++++++++ .../src/server/api/submit_auth_tx_batch.rs | 44 ++- crates/rpc/src/server/api/submit_proven_tx.rs | 12 +- .../src/server/api/submit_proven_tx_batch.rs | 42 +- crates/rpc/src/tests.rs | 90 ++++- proto/proto/internal/sequencer.proto | 16 +- proto/proto/remote_prover.proto | 34 +- proto/proto/rpc.proto | 1 + proto/proto/types/block_header.proto | 33 ++ proto/proto/types/blockchain.proto | 61 +-- proto/proto/types/partial_blockchain.proto | 18 + proto/proto/types/transaction.proto | 70 +++- proto/proto/types/vm.proto | 75 ++++ 37 files changed, 1474 insertions(+), 437 deletions(-) create mode 100644 crates/proto/src/domain/vm.rs create mode 100644 proto/proto/types/block_header.proto create mode 100644 proto/proto/types/partial_blockchain.proto create mode 100644 proto/proto/types/vm.proto diff --git a/Cargo.lock b/Cargo.lock index e94dcca4fd..3dad8d4871 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4136,6 +4136,7 @@ dependencies = [ "miden-standards", "miden-testing", "miden-tx", + "prost", "rand 0.10.2", "rand_chacha 0.10.0", "reqwest", @@ -4240,6 +4241,7 @@ dependencies = [ "fs-err", "hex", "http 1.5.0", + "miden-core", "miden-node-grpc-error-macro", "miden-node-proto-build", "miden-node-utils", diff --git a/Cargo.toml b/Cargo.toml index 84139473d5..538b79a7d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ miden-node-utils = { path = "crates/utils", version = "0.16.0-rc.1" } # miden-protocol dependencies. These should be updated in sync. miden-block-prover = { version = "=0.16.0-rc.3" } +miden-core = { default-features = false, version = "=0.29.0" } miden-protocol = { default-features = false, version = "=0.16.0-rc.3" } miden-standards = { version = "=0.16.0-rc.3" } miden-testing = { version = "=0.16.0-rc.3" } diff --git a/bin/benchmark/src/prover.rs b/bin/benchmark/src/prover.rs index bfcd1c87f6..b5aeb02f55 100644 --- a/bin/benchmark/src/prover.rs +++ b/bin/benchmark/src/prover.rs @@ -15,10 +15,10 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use miden_node_proto::clients::{Builder, RemoteProverClient}; -use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; +use miden_node_proto::generated::remote_prover::{ProofRequest, proof, proof_request}; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction, TransactionInputs}; -use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_protocol::utils::serde::Serializable; use miden_tx::{LocalTransactionProver, TransactionProverError}; use tokio::sync::{Mutex, Semaphore}; use url::Url; @@ -199,19 +199,26 @@ impl RemoteTransactionProver { tx_inputs: &TransactionInputs, ) -> Result { let request = tonic::Request::new(ProofRequest { - proof_type: ProofType::Transaction.into(), - payload: tx_inputs.to_bytes(), + request: Some(proof_request::Request::TransactionInputs(tx_inputs.to_bytes())), }); let response = self.client.clone().prove(request).await.map_err(|err| { TransactionProverError::other_with_source("failed to prove transaction", err) })?; - ProvenTransaction::read_from_bytes(&response.into_inner().payload).map_err(|_| { - TransactionProverError::other( - "failed to deserialize received response from remote transaction prover", - ) - }) + match response.into_inner().result { + Some(proof::Result::ProvenTransaction(transaction)) => { + ProvenTransaction::try_from(transaction).map_err(|err| { + TransactionProverError::other_with_source( + "failed to decode received response from remote transaction prover", + err, + ) + }) + }, + _ => Err(TransactionProverError::other( + "remote transaction prover returned the wrong proof kind", + )), + } } } diff --git a/bin/benchmark/src/submit.rs b/bin/benchmark/src/submit.rs index 6b3744e8f1..5c28054a2f 100644 --- a/bin/benchmark/src/submit.rs +++ b/bin/benchmark/src/submit.rs @@ -22,7 +22,7 @@ use miden_node_proto::domain::encryption::TransactionInputsSealer; use miden_node_proto::generated as proto; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::transaction::{ProvenTransaction, TransactionId}; -use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_protocol::utils::serde::Deserializable; use tokio::sync::Semaphore; use url::Url; @@ -188,8 +188,8 @@ async fn submit_all( let sealed_inputs = sealer.seal(tx.id(), &inputs).expect("failed to seal transaction inputs"); let request = proto::transaction::ProvenTransaction { - transaction: tx.to_bytes(), sealed_transaction_inputs: Some(sealed_inputs), + transaction_data: Some(tx.into()), }; let t0 = Instant::now(); let outcome = match client.submit_proven_tx(request).await { @@ -247,8 +247,8 @@ async fn submit_sequential( let sealed_inputs = sealer.seal(tx.id(), &inputs).expect("failed to seal transaction inputs"); let request = proto::transaction::ProvenTransaction { - transaction: tx.to_bytes(), sealed_transaction_inputs: Some(sealed_inputs), + transaction_data: Some(tx.into()), }; let t0 = Instant::now(); diff --git a/bin/network-monitor/Cargo.toml b/bin/network-monitor/Cargo.toml index 88a41507f4..cb7e4b8e71 100644 --- a/bin/network-monitor/Cargo.toml +++ b/bin/network-monitor/Cargo.toml @@ -28,6 +28,7 @@ miden-node-utils = { workspace = true } miden-protocol = { features = ["std"], workspace = true } miden-standards = { workspace = true } miden-tx = { features = ["concurrent", "std"], workspace = true } +prost = { workspace = true } rand = { workspace = true } rand_chacha = { workspace = true } reqwest = { features = ["json", "query"], workspace = true } diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index 89881e35cd..70f8b05efe 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -150,12 +150,11 @@ impl TransactionSubmissionClient { proven_tx: &ProvenTransaction, transaction_inputs: &[u8], ) -> Result { - let transaction = proven_tx.to_bytes(); let tx_id = proven_tx.id(); let stale_key = AtomicBool::new(false); let result = (|| { - let transaction = transaction.clone(); + let transaction_data = proven_tx.into(); async { if stale_key.swap(false, Ordering::Relaxed) { *self.sealer.lock().await = None; @@ -169,8 +168,8 @@ impl TransactionSubmissionClient { self.rpc_client .clone() .submit_proven_tx(ProtoProvenTransaction { - transaction, sealed_transaction_inputs: Some(sealed), + transaction_data: Some(transaction_data), }) .await .context("Failed to submit proven transaction to RPC") diff --git a/bin/network-monitor/src/remote_prover.rs b/bin/network-monitor/src/remote_prover.rs index 8b2a4d216b..b2d498b0a8 100644 --- a/bin/network-monitor/src/remote_prover.rs +++ b/bin/network-monitor/src/remote_prover.rs @@ -15,6 +15,7 @@ use miden_node_proto::clients::{RemoteProverClient, RemoteProverProxyStatusClien use miden_node_proto::generated as proto; use miden_node_utils::tracing::miden_instrument; use miden_protocol::utils::serde::Serializable; +use prost::Message; use serde::{Deserialize, Serialize}; use tokio::sync::watch; use tokio::task::JoinHandle; @@ -415,7 +416,7 @@ async fn run_prover_test( state.latest = Some(ProverTestOutcome { details: ProverTestDetails { test_duration_ms: start.elapsed().as_millis() as u64, - proof_size_bytes: response.into_inner().payload.len(), + proof_size_bytes: response.into_inner().encoded_len(), success_count: state.success_count, failure_count: state.failure_count, proof_type: ProofType::Transaction, @@ -499,8 +500,9 @@ async fn generate_prover_test_payload( ) -> anyhow::Result { let tx_inputs = crate::deploy::build_probe_transaction_inputs(rpc_url).await?; Ok(proto::remote_prover::ProofRequest { - proof_type: proto::remote_prover::ProofType::Transaction.into(), - payload: tx_inputs.to_bytes(), + request: Some(proto::remote_prover::proof_request::Request::TransactionInputs( + tx_inputs.to_bytes(), + )), }) } diff --git a/bin/ntx-builder/src/clients/prover.rs b/bin/ntx-builder/src/clients/prover.rs index 83bc65d2a3..6d1dd6068c 100644 --- a/bin/ntx-builder/src/clients/prover.rs +++ b/bin/ntx-builder/src/clients/prover.rs @@ -1,9 +1,9 @@ use std::time::Duration; use miden_node_proto::clients::{Builder, RemoteProverClient}; -use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; +use miden_node_proto::generated::remote_prover::{ProofRequest, proof, proof_request}; use miden_protocol::transaction::{ProvenTransaction, TransactionInputs}; -use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_protocol::utils::serde::Serializable; use miden_tx::TransactionProverError; use url::Url; @@ -36,18 +36,25 @@ impl RemoteTransactionProver { tx_inputs: &TransactionInputs, ) -> Result { let request = tonic::Request::new(ProofRequest { - proof_type: ProofType::Transaction.into(), - payload: tx_inputs.to_bytes(), + request: Some(proof_request::Request::TransactionInputs(tx_inputs.to_bytes())), }); let response = self.client.clone().prove(request).await.map_err(|err| { TransactionProverError::other_with_source("failed to prove transaction", err) })?; - ProvenTransaction::read_from_bytes(&response.into_inner().payload).map_err(|_| { - TransactionProverError::other( - "failed to deserialize received response from remote transaction prover", - ) - }) + match response.into_inner().result { + Some(proof::Result::ProvenTransaction(transaction)) => { + ProvenTransaction::try_from(transaction).map_err(|err| { + TransactionProverError::other_with_source( + "failed to decode received response from remote transaction prover", + err, + ) + }) + }, + _ => Err(TransactionProverError::other( + "remote transaction prover returned the wrong proof kind", + )), + } } } diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index c52f72eec0..7f7954e8fa 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -328,14 +328,14 @@ impl RpcClient { proven_tx: &ProvenTransaction, tx_inputs: &TransactionInputs, ) -> Result<(), Status> { - let transaction = proven_tx.to_bytes(); + let transaction_data: proto::transaction::ProvenTransactionData = proven_tx.into(); let transaction_inputs = tx_inputs.to_bytes(); let tx_id = proven_tx.id(); let stale_key = AtomicBool::new(false); (|| { let mut client = self.inner.clone(); - let transaction = transaction.clone(); + let transaction_data = transaction_data.clone(); let transaction_inputs = transaction_inputs.clone(); let stale_key = &stale_key; async move { @@ -351,8 +351,8 @@ impl RpcClient { })?; client .submit_proven_tx(proto::transaction::ProvenTransaction { - transaction, sealed_transaction_inputs: Some(sealed), + transaction_data: Some(transaction_data), }) .await } diff --git a/bin/remote-prover/src/server/prove.rs b/bin/remote-prover/src/server/prove.rs index d23d7957f2..e7f2967796 100644 --- a/bin/remote-prover/src/server/prove.rs +++ b/bin/remote-prover/src/server/prove.rs @@ -51,13 +51,15 @@ impl grpc::server::remote_prover_api::Prove for ProverService { } fn decode(request: grpc::remote_prover::ProofRequest) -> tonic::Result { - // Check that the proof type is supported. Protobuf enums return a default value if the enum - // is set to an unknown value. This round trip checks that the value is valid. - if request.proof_type() as i32 != request.proof_type { - return Err(tonic::Status::invalid_argument("unknown proof_type value")); - } + use grpc::remote_prover::proof_request::Request; - Ok((ProofKind::from(request.proof_type()), request)) + let proof_kind = match request.request.as_ref() { + Some(Request::TransactionInputs(_)) => ProofKind::Transaction, + Some(Request::ProposedBatch(_)) => ProofKind::Batch, + Some(Request::BlockProofRequest(_)) => ProofKind::Block, + None => return Err(tonic::Status::invalid_argument("missing proof request")), + }; + Ok((proof_kind, request)) } fn encode(output: Self::Output) -> tonic::Result { diff --git a/bin/remote-prover/src/server/prover.rs b/bin/remote-prover/src/server/prover.rs index f05b6120b4..80a8457b70 100644 --- a/bin/remote-prover/src/server/prover.rs +++ b/bin/remote-prover/src/server/prover.rs @@ -1,16 +1,14 @@ use miden_block_prover::LocalBlockProver; use miden_node_proto::BlockProofRequest; +use miden_node_proto::domain::batch::decode_proposed_batch; use miden_node_proto::generated::remote_prover as proto; use miden_node_utils::ErrorReport; -use miden_node_utils::tracing::miden_instrument; use miden_protocol::MIN_PROOF_SECURITY_LEVEL; -use miden_protocol::batch::{ProposedBatch, ProvenBatch}; -use miden_protocol::block::BlockProof; -use miden_protocol::transaction::{ProvenTransaction, TransactionInputs}; +use miden_protocol::transaction::TransactionInputs; +use miden_protocol::utils::serde::Deserializable; use miden_tx::LocalTransactionProver; use miden_tx_batch::{BatchExecutor, LocalBatchProver}; -use crate::COMPONENT; use crate::server::proof_kind::ProofKind; /// An enum representing the different types of provers available. @@ -33,11 +31,54 @@ impl Prover { /// Proves a [`proto::ProofRequest`] using the appropriate prover implementation as specified /// during construction. pub fn prove(&self, request: proto::ProofRequest) -> Result { - match self { - Prover::Transaction(prover) => prover.prove_request(request), - Prover::Batch(prover) => prover.prove_request(request), - Prover::Block(prover) => prover.prove_request(request), - } + use proto::proof::Result as ProofResult; + use proto::proof_request::Request; + + let result = match (self, request.request) { + (Self::Transaction(prover), Some(Request::TransactionInputs(bytes))) => { + let inputs = TransactionInputs::read_from_bytes(&bytes).map_err(|err| { + tonic::Status::invalid_argument( + err.as_report_context("failed to decode transaction inputs"), + ) + })?; + let transaction = prover.prove(inputs).map_err(|err| { + tonic::Status::internal(err.as_report_context("failed to prove transaction")) + })?; + ProofResult::ProvenTransaction(transaction.into()) + }, + (Self::Batch(prover), Some(Request::ProposedBatch(batch))) => { + let batch = decode_proposed_batch(batch, MIN_PROOF_SECURITY_LEVEL) + .map_err(tonic::Status::from)?; + let executed_batch = BatchExecutor::new().execute(batch).map_err(|err| { + tonic::Status::internal(err.as_report_context("failed to execute batch")) + })?; + let batch = prover.prove(executed_batch).map_err(|err| { + tonic::Status::internal(err.as_report_context("failed to prove batch")) + })?; + ProofResult::ProvenBatch(batch.into()) + }, + (Self::Block(prover), Some(Request::BlockProofRequest(bytes))) => { + let request = BlockProofRequest::read_from_bytes(&bytes).map_err(|err| { + tonic::Status::invalid_argument( + err.as_report_context("failed to decode block proof request"), + ) + })?; + let BlockProofRequest { tx_batches, block_header, block_inputs } = request; + let proof = + prover.prove(tx_batches, &block_header, block_inputs).map_err(|err| { + tonic::Status::internal(err.as_report_context("failed to prove block")) + })?; + ProofResult::BlockProof(proof.into()) + }, + (_, None) => return Err(tonic::Status::invalid_argument("missing proof request")), + _ => { + return Err(tonic::Status::invalid_argument( + "request kind does not match the configured prover", + )); + }, + }; + + Ok(proto::Proof { result: Some(result) }) } /// Returns the context attached to failures of the blocking task running this prover. @@ -49,89 +90,3 @@ impl Prover { } } } - -/// This trait abstracts over proof request handling by providing a common interface for our -/// different provers. -/// -/// It standardizes the proving process by providing default implementations for the decoding of -/// requests, and encoding of response. Notably it also standardizes the instrumentation, though -/// implementations should still add attributes that can only be known post-decoding of the request. -/// -/// Implementations of this trait only need to provide the input and outputs types, as well as the -/// proof implementation. -trait ProveRequest: Send + Sync { - type Input: miden_protocol::utils::serde::Deserializable + Send; - type Output: miden_protocol::utils::serde::Serializable + Send; - - fn prove(&self, input: Self::Input) -> Result; - - /// Entry-point to the proof request handling. - /// - /// Decodes the request, proves it, and encodes the response. - #[miden_instrument( - target=COMPONENT, - name="prove", - err, - )] - fn prove_request(&self, request: proto::ProofRequest) -> Result { - let input = Self::decode_request(request)?; - self.prove(input).map(|output| Self::encode_response(output)) - } - - #[miden_instrument( - target=COMPONENT, - err, - )] - fn decode_request(request: proto::ProofRequest) -> Result { - use miden_protocol::utils::serde::Deserializable; - - Self::Input::read_from_bytes(&request.payload).map_err(|e| { - tonic::Status::invalid_argument(e.as_report_context("failed to decode request")) - }) - } - - #[miden_instrument( - target=COMPONENT, - )] - fn encode_response(output: Self::Output) -> proto::Proof { - use miden_protocol::utils::serde::Serializable; - - proto::Proof { payload: output.to_bytes() } - } -} - -impl ProveRequest for LocalTransactionProver { - type Input = TransactionInputs; - type Output = ProvenTransaction; - - fn prove(&self, input: Self::Input) -> Result { - self.prove(input).map_err(|e| { - tonic::Status::internal(e.as_report_context("failed to prove transaction")) - }) - } -} - -impl ProveRequest for LocalBatchProver { - type Input = ProposedBatch; - type Output = ProvenBatch; - - fn prove(&self, input: Self::Input) -> Result { - let executed_batch = BatchExecutor::new() - .execute(input) - .map_err(|e| tonic::Status::internal(e.as_report_context("failed to execute batch")))?; - self.prove(executed_batch) - .map_err(|e| tonic::Status::internal(e.as_report_context("failed to prove batch"))) - } -} - -impl ProveRequest for LocalBlockProver { - type Input = BlockProofRequest; - type Output = BlockProof; - - fn prove(&self, input: Self::Input) -> Result { - let BlockProofRequest { tx_batches, block_header, block_inputs } = input; - - self.prove(tx_batches, &block_header, block_inputs) - .map_err(|e| tonic::Status::internal(e.as_report_context("failed to prove block"))) - } -} diff --git a/bin/remote-prover/src/server/tests.rs b/bin/remote-prover/src/server/tests.rs index 7b154ee198..568f50a12a 100644 --- a/bin/remote-prover/src/server/tests.rs +++ b/bin/remote-prover/src/server/tests.rs @@ -4,17 +4,18 @@ use std::sync::Arc; use std::time::Duration; use assert_matches::assert_matches; +use miden_node_proto::domain::batch::decode_proven_batch; use miden_node_proto::generated::remote_prover::api_client::ApiClient; -use miden_node_proto::generated::remote_prover::{Proof, ProofRequest, ProofType}; +use miden_node_proto::generated::remote_prover::{Proof, ProofRequest, proof, proof_request}; use miden_node_utils::shutdown::CancellationToken; use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::account::auth::AuthScheme; use miden_protocol::asset::{Asset, FungibleAsset}; -use miden_protocol::batch::{ProposedBatch, ProvenBatch}; +use miden_protocol::batch::ProposedBatch; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::{ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_SENDER}; use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction, TransactionVerifier}; -use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_protocol::utils::serde::Serializable; use miden_testing::{Auth, MockChainBuilder}; use miden_tx::LocalTransactionProver; use miden_tx_batch::BatchVerifier; @@ -56,15 +57,13 @@ impl ProofRequestExt for ProofRequest { let tx_inputs = tx.tx_inputs().clone(); ProofRequest { - proof_type: ProofType::Transaction as i32, - payload: tx_inputs.to_bytes(), + request: Some(proof_request::Request::TransactionInputs(tx_inputs.to_bytes())), } } fn from_batch(batch: &ProposedBatch) -> ProofRequest { ProofRequest { - proof_type: ProofType::Batch as i32, - payload: batch.to_bytes(), + request: Some(proof_request::Request::ProposedBatch(batch.into())), } } @@ -303,14 +302,14 @@ async fn invalid_proof_kind_is_rejected() { .expect("server should spawn"); let mut request = ProofRequest::from_tx(&ProofRequest::mock_tx().await); - request.proof_type = i32::MAX; + request.request = Some(proof_request::Request::BlockProofRequest(Vec::new())); let mut client = Client::connect(port).await; let response = client.submit_request(request).await; let err = response.unwrap_err(); assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert!(err.message().contains("unknown proof_type value")); + assert!(err.message().contains("unsupported proof type")); server.abort(); } @@ -357,7 +356,12 @@ async fn transaction_proof_is_correct() { let mut client = Client::connect(port).await; let response = client.submit_request(request).await.unwrap(); - let response = ProvenTransaction::read_from_bytes(&response.payload).unwrap(); + let response = match response.result { + Some(proof::Result::ProvenTransaction(transaction)) => { + ProvenTransaction::try_from(transaction).unwrap() + }, + _ => panic!("transaction prover returned the wrong response kind"), + }; assert_eq!(response.id(), tx.id()); TransactionVerifier::new(MIN_PROOF_SECURITY_LEVEL).verify(&response).unwrap(); @@ -382,7 +386,10 @@ async fn batch_proof_is_correct() { let mut client = Client::connect(port).await; let response = client.submit_request(request).await.unwrap(); - let response = ProvenBatch::read_from_bytes(&response.payload).unwrap(); + let response = match response.result { + Some(proof::Result::ProvenBatch(proven)) => decode_proven_batch(proven, &batch).unwrap(), + _ => panic!("batch prover returned the wrong response kind"), + }; assert_eq!(response.id(), batch.id()); BatchVerifier::new(MIN_PROOF_SECURITY_LEVEL).verify(&response).unwrap(); diff --git a/bin/validator/src/server/validator_service/submit_proven_transaction.rs b/bin/validator/src/server/validator_service/submit_proven_transaction.rs index 750aabcbf3..be52034136 100644 --- a/bin/validator/src/server/validator_service/submit_proven_transaction.rs +++ b/bin/validator/src/server/validator_service/submit_proven_transaction.rs @@ -86,9 +86,11 @@ impl grpc::server::validator_api::SubmitProvenTransaction for ValidatorService { } fn decode(request: grpc::transaction::ProvenTransaction) -> tonic::Result { - let tx = ProvenTransaction::read_from_bytes(&request.transaction).map_err(|err| { - Status::invalid_argument(err.as_report_context("Invalid proven transaction")) - })?; + let tx = request + .transaction_data + .ok_or_else(|| Status::invalid_argument("Missing transaction_data"))? + .try_into() + .map_err(Status::from)?; let sealed = request.sealed_transaction_inputs.ok_or_else(|| { Status::invalid_argument( "Missing sealed transaction inputs: fetch the encryption key with \ diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 19f0bcc720..87b85c2169 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -108,8 +108,8 @@ impl TestValidator { sealed: proto::transaction::SealedTransactionInputs, ) -> Result<(), tonic::Status> { let request = tonic::Request::new(proto::transaction::ProvenTransaction { - transaction: tx.to_bytes(), sealed_transaction_inputs: Some(sealed), + transaction_data: Some(tx.into()), }); validator_api::SubmitProvenTransaction::full(&self.server, request).await } @@ -1085,8 +1085,8 @@ async fn submit_rejects_missing_encrypted_inputs() { let tv = TestValidator::new().await; let tx = dummy_proven_tx(2); let request = tonic::Request::new(proto::transaction::ProvenTransaction { - transaction: tx.to_bytes(), sealed_transaction_inputs: None, + transaction_data: Some((&tx).into()), }); let status = validator_api::SubmitProvenTransaction::full(&tv.server, request) diff --git a/crates/block-producer/src/batch_builder/remote_prover.rs b/crates/block-producer/src/batch_builder/remote_prover.rs index 50cc694648..3177304243 100644 --- a/crates/block-producer/src/batch_builder/remote_prover.rs +++ b/crates/block-producer/src/batch_builder/remote_prover.rs @@ -1,11 +1,11 @@ -use std::sync::Arc; - use miden_node_proto::clients::{Builder, RemoteProverClient}; -use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; +use miden_node_proto::domain::batch::decode_proven_batch; +use miden_node_proto::errors::ConversionError; +use miden_node_proto::generated::remote_prover::{ProofRequest, proof, proof_request}; +use miden_node_utils::spawn::spawn_blocking_in_current_span; +use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::batch::{ProposedBatch, ProvenBatch}; -use miden_protocol::transaction::{OutputNote, ProvenTransaction}; -use miden_protocol::utils::serde::{Deserializable, DeserializationError, Serializable}; -use miden_tx_batch::LocalBatchProver; +use miden_tx_batch::{BatchVerifier, LocalBatchProver}; use url::Url; /// Errors returned by [`RemoteBatchProver`]. @@ -13,8 +13,8 @@ use url::Url; pub enum RemoteProverError { #[error("remote prover request failed")] Grpc(#[source] tonic::Status), - #[error("failed to deserialize proven batch from remote prover")] - Deserialize(#[source] DeserializationError), + #[error("failed to decode proven batch from remote prover")] + Decode(#[source] ConversionError), #[error("{0}")] Validation(String), } @@ -77,122 +77,32 @@ impl RemoteBatchProver { &self, proposed_batch: ProposedBatch, ) -> Result { - // Keep the set of transactions we passed in for later validation. - let proposed_txs: Vec<_> = proposed_batch.transactions().iter().map(Arc::clone).collect(); - let request = tonic::Request::new(ProofRequest { - proof_type: ProofType::Batch.into(), - payload: proposed_batch.to_bytes(), + request: Some(proof_request::Request::ProposedBatch((&proposed_batch).into())), }); let response = self.client.clone().prove(request).await.map_err(RemoteProverError::Grpc)?; - let proven_batch = ProvenBatch::read_from_bytes(&response.into_inner().payload) - .map_err(RemoteProverError::Deserialize)?; - - Self::validate_tx_headers(&proven_batch, proposed_txs)?; - - Ok(proven_batch) - } - - /// Validates that the proven batch's transaction headers are consistent with the transactions - /// passed in the proposed batch. - /// - /// Note that we expect all input and output notes from a proposed transaction to be present - /// in the corresponding header as well, because note erasure doesn't matter for the transaction - /// itself and we want the original transaction data to be preserved. - /// - /// This expects that proposed transactions and batch transactions are in the same order, as - /// define by `OrderedTransactionHeaders`. - fn validate_tx_headers( - proven_batch: &ProvenBatch, - proposed_txs: Vec>, - ) -> Result<(), RemoteProverError> { - if proposed_txs.len() != proven_batch.transactions().as_slice().len() { - return Err(RemoteProverError::Validation(format!( - "remote prover returned {} transaction headers but {} transactions were passed as part of the proposed batch", - proven_batch.transactions().as_slice().len(), - proposed_txs.len() - ))); - } - - // Because we checked the length matches we can zip the iterators up. We expect the - // transactions to be in the same order. - for (proposed_header, proven_header) in - proposed_txs.into_iter().zip(proven_batch.transactions().as_slice()) - { - if proven_header.account_id() != proposed_header.account_id() { - return Err(RemoteProverError::Validation(format!( - "transaction header of {} has a different account ID than the proposed transaction", - proposed_header.id() - ))); - } - - if proven_header.initial_state_commitment() - != proposed_header.account_update().initial_state_commitment() - { - return Err(RemoteProverError::Validation(format!( - "transaction header of {} has a different initial state commitment than the proposed transaction", - proposed_header.id() - ))); - } - - if proven_header.final_state_commitment() - != proposed_header.account_update().final_state_commitment() - { - return Err(RemoteProverError::Validation(format!( - "transaction header of {} has a different final state commitment than the proposed transaction", - proposed_header.id() - ))); - } - - // Check input notes - let num_notes = proposed_header.input_notes().num_notes(); - if num_notes != proven_header.input_notes().num_notes() { - return Err(RemoteProverError::Validation(format!( - "transaction header of {} has a different number of input notes than the proposed transaction", - proposed_header.id() - ))); - } - - // Because we checked the length matches we can zip the iterators up. We expect the - // nullifiers to be in the same order. - for (proposed_nullifier, input_note_commitment) in - proposed_header.nullifiers().zip(proven_header.input_notes().iter()) - { - if proposed_nullifier != input_note_commitment.nullifier() { - return Err(RemoteProverError::Validation(format!( - "transaction header of {} has a different set of input notes than the proposed transaction", - proposed_header.id() - ))); - } - } - - // Check output notes - if proposed_header.output_notes().num_notes() != proven_header.output_notes().len() { - return Err(RemoteProverError::Validation(format!( - "transaction header of {} has a different number of output notes than the proposed transaction", - proposed_header.id() - ))); - } - - // Because we checked the length matches we can zip the iterators up. We expect the note - // IDs to be in the same order. - for (proposed_note_id, header_note) in proposed_header - .output_notes() - .iter() - .map(OutputNote::id) - .zip(proven_header.output_notes().iter()) - { - if proposed_note_id != header_note.id() { - return Err(RemoteProverError::Validation(format!( - "transaction header of {} has a different set of input notes than the proposed transaction", - proposed_header.id() - ))); - } - } - } - - Ok(()) + let batch = match response.into_inner().result { + Some(proof::Result::ProvenBatch(batch)) => { + decode_proven_batch(batch, &proposed_batch).map_err(RemoteProverError::Decode) + }, + _ => Err(RemoteProverError::Validation( + "remote batch prover returned the wrong proof kind".to_string(), + )), + }?; + + let batch_to_verify = batch.clone(); + spawn_blocking_in_current_span(move || { + BatchVerifier::new(MIN_PROOF_SECURITY_LEVEL) + .verify(&batch_to_verify) + .map_err(|err| RemoteProverError::Validation(err.to_string())) + }) + .await + .map_err(|err| { + RemoteProverError::Validation(format!("batch proof verification task failed: {err}")) + })??; + + Ok(batch) } } diff --git a/crates/block-producer/src/block_prover.rs b/crates/block-producer/src/block_prover.rs index 97a7a2f2a0..d7b26b6510 100644 --- a/crates/block-producer/src/block_prover.rs +++ b/crates/block-producer/src/block_prover.rs @@ -1,12 +1,13 @@ use miden_block_prover::{BlockProverError as LocalBlockProverError, LocalBlockProver}; use miden_node_proto::clients::{Builder, RemoteProverClient}; -use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; +use miden_node_proto::errors::ConversionError; +use miden_node_proto::generated::remote_prover::{ProofRequest, proof, proof_request}; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_node_utils::tracing::miden_instrument; use miden_protocol::batch::OrderedBatches; use miden_protocol::block::{BlockHeader, BlockInputs, BlockProof, ProposedBlock}; use miden_protocol::errors::ProposedBlockError; -use miden_protocol::utils::serde::{Deserializable, DeserializationError, Serializable}; +use miden_protocol::utils::serde::Serializable; use url::Url; use crate::COMPONENT; @@ -28,8 +29,8 @@ pub enum RemoteProverError { ProposeBlock(#[source] ProposedBlockError), #[error("remote prover request failed")] Grpc(#[source] tonic::Status), - #[error("failed to deserialize block proof from remote prover")] - Deserialize(#[source] DeserializationError), + #[error("failed to decode block proof from remote prover")] + Decode(#[source] ConversionError), } // BLOCK PROVER @@ -122,13 +123,18 @@ impl RemoteBlockProver { .map_err(RemoteProverError::ProposeBlock)?; let request = tonic::Request::new(ProofRequest { - proof_type: ProofType::Block.into(), - payload: proposed_block.to_bytes(), + request: Some(proof_request::Request::BlockProofRequest(proposed_block.to_bytes())), }); let response = self.client.clone().prove(request).await.map_err(RemoteProverError::Grpc)?; - BlockProof::read_from_bytes(&response.into_inner().payload) - .map_err(RemoteProverError::Deserialize) + match response.into_inner().result { + Some(proof::Result::BlockProof(proof)) => { + BlockProof::try_from(proof).map_err(RemoteProverError::Decode) + }, + _ => Err(RemoteProverError::Grpc(tonic::Status::internal( + "remote block prover returned the wrong proof kind", + ))), + } } } diff --git a/crates/block-producer/src/domain/transaction.rs b/crates/block-producer/src/domain/transaction.rs index a91a7194f5..4743c693f7 100644 --- a/crates/block-producer/src/domain/transaction.rs +++ b/crates/block-producer/src/domain/transaction.rs @@ -8,7 +8,6 @@ use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; use miden_protocol::note::Nullifier; use miden_protocol::transaction::{ProvenTransaction, TransactionId, TxAccountUpdate}; -use miden_protocol::utils::serde::{Deserializable, Serializable}; use crate::errors::StateConflict; use crate::store::TransactionInputs; @@ -141,7 +140,6 @@ impl AuthenticatedTransaction { impl From for sequencer::AuthenticatedTransaction { fn from(value: AuthenticatedTransaction) -> Self { Self { - transaction: value.inner.to_bytes(), store_account_state: value.store_account_state.map(Into::into), notes_authenticated_by_store: value .notes_authenticated_by_store @@ -149,6 +147,7 @@ impl From for sequencer::AuthenticatedTransaction { .map(Into::into) .collect(), authentication_height: value.authentication_height.as_u32(), + proven_transaction: Some(value.inner.as_ref().into()), } } } @@ -157,8 +156,10 @@ impl TryFrom for AuthenticatedTransaction { type Error = ConversionError; fn try_from(value: sequencer::AuthenticatedTransaction) -> Result { - let inner = ProvenTransaction::read_from_bytes(&value.transaction) - .map_err(|err| ConversionError::deserialization("ProvenTransaction", err))?; + let inner = value + .proven_transaction + .ok_or_else(|| ConversionError::message("missing proven_transaction"))? + .try_into()?; let store_account_state = value.store_account_state.map(Word::try_from).transpose()?; diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index e00ac65657..5c704f5bd0 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -18,6 +18,7 @@ workspace = true anyhow = { workspace = true } hex = { workspace = true } http = { workspace = true } +miden-core = { workspace = true } miden-node-grpc-error-macro = { workspace = true } miden-node-utils = { workspace = true } miden-protocol = { workspace = true } diff --git a/crates/proto/build.rs b/crates/proto/build.rs index ba8d71fe22..f5fff3e428 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -59,6 +59,12 @@ fn main() -> miette::Result<()> { fn generate_bindings(file_descriptors: &FileDescriptorSet, dst_dir: &Path) -> miette::Result<()> { let mut prost_config = tonic_prost_build::Config::new(); prost_config.skip_debug(["AccountId", "Digest"]); + prost_config.type_attribute( + ".remote_prover.ProofRequest.request", + "#[allow(clippy::large_enum_variant)]", + ); + prost_config + .type_attribute(".remote_prover.Proof.result", "#[allow(clippy::large_enum_variant)]"); // Generate the stub of the user facing server from its proto file tonic_prost_build::configure() diff --git a/crates/proto/src/clients/mod.rs b/crates/proto/src/clients/mod.rs index 8f1a0b4f2a..11091a518f 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -34,7 +34,6 @@ use http::header::ACCEPT; use miden_node_utils::tracing::grpc::OtelInterceptor; use miden_protocol::Word; use miden_protocol::batch::ProposedBatch; -use miden_protocol::utils::serde::Serializable; use tonic::metadata::AsciiMetadataValue; use tonic::service::interceptor::InterceptedService; use tonic::transport::{Channel, ClientTlsConfig, Endpoint, Error as TransportError}; @@ -652,8 +651,8 @@ impl ValidatorClient { } for (tx, inputs) in proposed_batch.transactions().iter().zip(sealed_transaction_inputs) { let proven_tx = GeneratedProvenTransaction { - transaction: tx.to_bytes(), sealed_transaction_inputs: Some(inputs.clone()), + transaction_data: Some(tx.as_ref().into()), }; self.submit_proven_transaction(proven_tx).await?; } diff --git a/crates/proto/src/domain/batch.rs b/crates/proto/src/domain/batch.rs index d4f1418b66..9abed811c8 100644 --- a/crates/proto/src/domain/batch.rs +++ b/crates/proto/src/domain/batch.rs @@ -1,8 +1,23 @@ use std::collections::BTreeMap; +use std::sync::Arc; -use miden_protocol::block::BlockHeader; +use miden_protocol::Word; +use miden_protocol::account::{AccountId, AccountUpdateDetails}; +use miden_protocol::batch::{BatchAccountUpdate, ProposedBatch, ProvenBatch}; +use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::note::{NoteId, NoteInclusionProof}; -use miden_protocol::transaction::PartialBlockchain; +use miden_protocol::transaction::{ + InputNoteCommitment, + OrderedTransactionHeaders, + OutputNote, + PartialBlockchain, + ProvenTransaction, + TransactionHeader, +}; + +use crate::decode::{ConversionResultExt, GrpcDecodeExt}; +use crate::errors::ConversionError; +use crate::{decode, generated as proto}; /// Data required for a transaction batch. #[derive(Clone, Debug)] @@ -11,3 +26,351 @@ pub struct BatchInputs { pub note_proofs: BTreeMap, pub partial_block_chain: PartialBlockchain, } + +impl From<&BatchAccountUpdate> for proto::transaction::BatchAccountUpdate { + fn from(value: &BatchAccountUpdate) -> Self { + Self { + account_id: Some(value.account_id().into()), + initial_state_commitment: Some(value.initial_state_commitment().into()), + final_state_commitment: Some(value.final_state_commitment().into()), + details: Some(value.details().into()), + } + } +} + +impl From<&ProposedBatch> for proto::transaction::ProposedBatch { + fn from(value: &ProposedBatch) -> Self { + let ( + transactions, + reference_block_header, + partial_blockchain, + unauthenticated_note_proofs, + .., + ) = value.clone().into_parts(); + + Self { + transactions: transactions.iter().map(|tx| tx.as_ref().into()).collect(), + reference_block_header: Some(reference_block_header.into()), + partial_blockchain: Some((&partial_blockchain).into()), + unauthenticated_note_proofs: unauthenticated_note_proofs + .iter() + .map(Into::into) + .collect(), + } + } +} + +impl From for proto::transaction::ProposedBatch { + fn from(value: ProposedBatch) -> Self { + Self::from(&value) + } +} + +/// Decodes and structurally validates a proposed batch, including transaction proof verification. +/// +/// Callers handling untrusted requests should invoke this inside a blocking task. +pub fn decode_proposed_batch( + value: proto::transaction::ProposedBatch, + proof_security_level: u32, +) -> Result { + let decoder = value.decoder(); + let transactions = value + .transactions + .into_iter() + .enumerate() + .map(|(index, tx)| { + ProvenTransaction::try_from(tx) + .map(Arc::new) + .context(format!("transactions[{index}]")) + }) + .collect::, _>>()?; + let reference_block_header = decode!(decoder, value.reference_block_header)?; + let partial_blockchain = decode!(decoder, value.partial_blockchain)?; + + let mut unauthenticated_note_proofs = BTreeMap::new(); + let mut previous_note_id = None; + for (index, proof) in value.unauthenticated_note_proofs.iter().enumerate() { + let (note_id, proof) = <(NoteId, NoteInclusionProof)>::try_from(proof) + .context(format!("unauthenticated_note_proofs[{index}]"))?; + if previous_note_id.is_some_and(|previous| note_id <= previous) { + return Err(ConversionError::message( + "unauthenticated note proofs must have unique, ascending note IDs", + ) + .context(format!("unauthenticated_note_proofs[{index}].note_id"))); + } + previous_note_id = Some(note_id); + unauthenticated_note_proofs.insert(note_id, proof); + } + + ProposedBatch::new( + transactions, + reference_block_header, + partial_blockchain, + unauthenticated_note_proofs, + proof_security_level, + ) + .map_err(ConversionError::new) +} + +impl From<&ProvenBatch> for proto::transaction::ProvenBatch { + fn from(value: &ProvenBatch) -> Self { + Self { + reference_block_commitment: Some(value.reference_block_commitment().into()), + reference_block_num: value.reference_block_num().as_u32(), + account_updates: value.account_updates().values().map(Into::into).collect(), + input_notes: value.input_notes().iter().map(Into::into).collect(), + output_notes: value.output_notes().iter().map(Into::into).collect(), + expiration_block_num: value.batch_expiration_block_num().as_u32(), + transactions: value.transactions().as_slice().iter().map(Into::into).collect(), + proof: Some(value.proof().into()), + } + } +} + +impl From for proto::transaction::ProvenBatch { + fn from(value: ProvenBatch) -> Self { + Self::from(&value) + } +} + +#[derive(PartialEq, Eq)] +struct BatchAccountUpdateProjection { + account_id: AccountId, + initial_state_commitment: Word, + final_state_commitment: Word, + details: AccountUpdateDetails, +} + +impl TryFrom for BatchAccountUpdateProjection { + type Error = ConversionError; + + fn try_from(value: proto::transaction::BatchAccountUpdate) -> Result { + let decoder = value.decoder(); + Ok(Self { + account_id: decode!(decoder, value.account_id)?, + initial_state_commitment: decode!(decoder, value.initial_state_commitment)?, + final_state_commitment: decode!(decoder, value.final_state_commitment)?, + details: decode!(decoder, value.details)?, + }) + } +} + +/// Decodes a proven batch and checks every duplicated public field against its proposal. +pub fn decode_proven_batch( + value: proto::transaction::ProvenBatch, + proposed: &ProposedBatch, +) -> Result { + let decoder = value.decoder(); + let reference_block_commitment: Word = decode!(decoder, value.reference_block_commitment)?; + let reference_block_num = BlockNumber::from(value.reference_block_num); + let expected_header = proposed.reference_block_header(); + if reference_block_num != expected_header.block_num() { + return Err(ConversionError::message("reference block number does not match proposal") + .context("reference_block_num")); + } + if reference_block_commitment != expected_header.commitment() { + return Err(ConversionError::message("reference block commitment does not match proposal") + .context("reference_block_commitment")); + } + + if value.account_updates.len() != proposed.account_updates().len() { + return Err(ConversionError::message("account updates do not match proposal") + .context("account_updates")); + } + let mut previous_account_id = None; + for (index, update) in value.account_updates.into_iter().enumerate() { + let update = BatchAccountUpdateProjection::try_from(update) + .context(format!("account_updates[{index}]"))?; + if previous_account_id.is_some_and(|previous| update.account_id <= previous) { + return Err(ConversionError::message( + "account updates must have unique, ascending account IDs", + ) + .context(format!("account_updates[{index}].account_id"))); + } + previous_account_id = Some(update.account_id); + let expected = proposed.account_updates().get(&update.account_id).ok_or_else(|| { + ConversionError::message("account update is absent from proposal") + .context(format!("account_updates[{index}].account_id")) + })?; + let expected = BatchAccountUpdateProjection { + account_id: expected.account_id(), + initial_state_commitment: expected.initial_state_commitment(), + final_state_commitment: expected.final_state_commitment(), + details: expected.details().clone(), + }; + if update != expected { + return Err(ConversionError::message("account update does not match proposal") + .context(format!("account_updates[{index}]"))); + } + } + + let input_notes = value + .input_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + InputNoteCommitment::try_from(note).context(format!("input_notes[{index}]")) + }) + .collect::, _>>()?; + if !input_notes.iter().eq(proposed.input_notes().iter()) { + return Err( + ConversionError::message("input notes do not match proposal").context("input_notes") + ); + } + + let output_notes = value + .output_notes + .into_iter() + .enumerate() + .map(|(index, note)| OutputNote::try_from(note).context(format!("output_notes[{index}]"))) + .collect::, _>>()?; + if output_notes != proposed.output_notes() { + return Err( + ConversionError::message("output notes do not match proposal").context("output_notes") + ); + } + + let expiration = BlockNumber::from(value.expiration_block_num); + if expiration != proposed.batch_expiration_block_num() { + return Err(ConversionError::message("expiration block does not match proposal") + .context("expiration_block_num")); + } + + let transactions = value + .transactions + .into_iter() + .enumerate() + .map(|(index, tx)| { + TransactionHeader::try_from(tx).context(format!("transactions[{index}]")) + }) + .collect::, _>>()?; + let expected_transactions = proposed.transaction_headers(); + if transactions.as_slice() != expected_transactions.as_slice() { + return Err(ConversionError::message("transaction headers do not match proposal") + .context("transactions")); + } + + let proof = decode!(decoder, value.proof)?; + ProvenBatch::new_unchecked( + proposed.id(), + expected_header.commitment(), + expected_header.block_num(), + proposed.account_updates().clone(), + proposed.input_notes().clone(), + proposed.output_notes().to_vec(), + proposed.batch_expiration_block_num(), + OrderedTransactionHeaders::new_unchecked(transactions), + proof, + ) + .map_err(ConversionError::new) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::sync::Arc; + + use miden_protocol::Word; + use miden_protocol::account::{ + AccountId, + AccountIdVersion, + AccountType, + AccountUpdateDetails, + AssetCallbackFlag, + }; + use miden_protocol::batch::{ProposedBatch, ProvenBatch}; + use miden_protocol::block::BlockHeader; + use miden_protocol::transaction::{ + InputNoteCommitment, + OutputNote, + PartialBlockchain, + ProvenTransaction, + TxAccountUpdate, + }; + use miden_protocol::vm::ExecutionProof; + + use super::decode_proven_batch; + use crate::generated as proto; + + fn proposal_and_proof() -> (ProposedBatch, ProvenBatch) { + let partial_blockchain = PartialBlockchain::default(); + let reference_block_header = BlockHeader::mock( + 0, + Some(partial_blockchain.peaks().hash_peaks()), + None, + &[], + Word::default(), + ); + let account_id = AccountId::dummy( + [8; 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ); + let account_update = TxAccountUpdate::new( + account_id, + Word::from([1_u32, 2, 3, 4]), + Word::from([5_u32, 6, 7, 8]), + Word::from([9_u32, 10, 11, 12]), + AccountUpdateDetails::Private, + ) + .unwrap(); + let transaction = ProvenTransaction::new( + account_update, + Vec::::new(), + Vec::::new(), + reference_block_header.block_num(), + reference_block_header.commitment(), + reference_block_header.block_num() + 1, + ExecutionProof::new_dummy(), + ) + .unwrap(); + let proposed = ProposedBatch::new_unverified( + vec![Arc::new(transaction)], + reference_block_header, + partial_blockchain, + BTreeMap::new(), + ) + .unwrap(); + let proven = ProvenBatch::new_unchecked( + proposed.id(), + proposed.reference_block_header().commitment(), + proposed.reference_block_header().block_num(), + proposed.account_updates().clone(), + proposed.input_notes().clone(), + proposed.output_notes().to_vec(), + proposed.batch_expiration_block_num(), + proposed.transaction_headers(), + ExecutionProof::new_dummy(), + ) + .unwrap(); + (proposed, proven) + } + + #[test] + fn proven_batch_roundtrips_only_with_its_proposal() { + let (proposed, proven) = proposal_and_proof(); + let encoded = proto::transaction::ProvenBatch::from(&proven); + assert_eq!(decode_proven_batch(encoded.clone(), &proposed).unwrap(), proven); + + let mut wrong_reference = encoded.clone(); + wrong_reference.reference_block_num += 1; + assert!( + decode_proven_batch(wrong_reference, &proposed) + .unwrap_err() + .to_string() + .contains("reference_block_num") + ); + + let mut duplicate_account = encoded; + duplicate_account + .account_updates + .push(duplicate_account.account_updates[0].clone()); + assert!( + decode_proven_batch(duplicate_account, &proposed) + .unwrap_err() + .to_string() + .contains("account_updates") + ); + } +} diff --git a/crates/proto/src/domain/block.rs b/crates/proto/src/domain/block.rs index 1ed1db9af4..f9dd03df9a 100644 --- a/crates/proto/src/domain/block.rs +++ b/crates/proto/src/domain/block.rs @@ -16,8 +16,15 @@ use miden_protocol::block::{ ValidatorKeys, }; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; +use miden_protocol::crypto::merkle::MerklePath; +use miden_protocol::crypto::merkle::mmr::{Forest, MmrPeaks, PartialMmr}; use miden_protocol::note::Nullifier; -use miden_protocol::transaction::{OrderedTransactionHeaders, OutputNote, TransactionHeader}; +use miden_protocol::transaction::{ + OrderedTransactionHeaders, + OutputNote, + PartialBlockchain, + TransactionHeader, +}; use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::{MAX_BATCHES_PER_BLOCK, MAX_OUTPUT_NOTES_PER_BATCH, Word}; use thiserror::Error; @@ -41,6 +48,110 @@ impl From for BlockNumber { } } +// PARTIAL BLOCKCHAIN +// ================================================================================================ + +impl From<&PartialBlockchain> for proto::blockchain::PartialBlockchain { + fn from(value: &PartialBlockchain) -> Self { + let mmr = value.mmr(); + let tracked_leaves = mmr + .leaves() + .map(|(position, leaf)| { + let proof = mmr + .open(position) + .expect("tracked MMR position must be in bounds") + .expect("tracked MMR leaf must have an opening"); + proto::blockchain::TrackedMmrLeaf { + position: position as u64, + leaf: Some(leaf.into()), + path: proof.merkle_path().nodes().iter().map(Into::into).collect(), + } + }) + .collect(); + let peaks = mmr.peaks(); + Self { + forest: mmr.forest().num_leaves() as u64, + peaks: peaks.peaks().iter().map(Into::into).collect(), + tracked_leaves, + block_headers: value.block_headers().map(Into::into).collect(), + } + } +} + +impl TryFrom for PartialBlockchain { + type Error = ConversionError; + + fn try_from(value: proto::blockchain::PartialBlockchain) -> Result { + let forest_size = + usize::try_from(value.forest).map_err(ConversionError::new).context("forest")?; + let forest = Forest::new(forest_size).map_err(ConversionError::new).context("forest")?; + let peaks = value + .peaks + .into_iter() + .enumerate() + .map(|(index, peak)| Word::try_from(peak).context(format!("peaks[{index}]"))) + .collect::, _>>()?; + let peaks = MmrPeaks::new(forest, peaks).map_err(ConversionError::new).context("peaks")?; + let mut mmr = PartialMmr::from_peaks(peaks); + + let mut previous_position = None; + for (index, tracked) in value.tracked_leaves.into_iter().enumerate() { + let position = usize::try_from(tracked.position) + .map_err(ConversionError::new) + .context(format!("tracked_leaves[{index}].position"))?; + if position >= forest_size { + return Err(ConversionError::message(format!( + "tracked leaf position {position} is outside forest of size {forest_size}" + )) + .context(format!("tracked_leaves[{index}].position"))); + } + if previous_position.is_some_and(|previous| position <= previous) { + return Err(ConversionError::message( + "tracked leaf positions must be unique and strictly increasing", + ) + .context(format!("tracked_leaves[{index}].position"))); + } + previous_position = Some(position); + + let decoder = tracked.decoder(); + let leaf = decode!(decoder, tracked.leaf)?; + let path = tracked + .path + .into_iter() + .enumerate() + .map(|(path_index, node)| { + Word::try_from(node) + .context(format!("tracked_leaves[{index}].path[{path_index}]")) + }) + .collect::, _>>()?; + mmr.track(position, leaf, &MerklePath::new(path)) + .map_err(ConversionError::new) + .context(format!("tracked_leaves[{index}]"))?; + } + + let mut previous_block_num = None; + let block_headers = value + .block_headers + .into_iter() + .enumerate() + .map(|(index, header)| { + let header = + BlockHeader::try_from(header).context(format!("block_headers[{index}]"))?; + if previous_block_num.is_some_and(|previous| header.block_num() <= previous) { + return Err(ConversionError::message( + "block headers must be unique and ordered by ascending block number", + ) + .context(format!("block_headers[{index}].block_num"))); + } + previous_block_num = Some(header.block_num()); + Ok(header) + }) + .collect::, ConversionError>>()?; + + Self::new(mmr, block_headers).map_err(ConversionError::new) + } +} + // BLOCK HEADER // ================================================================================================ @@ -556,8 +667,14 @@ mod tests { BlockSignatures, SignedBlock, }; + use miden_protocol::crypto::merkle::mmr::{Mmr, PartialMmr}; use miden_protocol::note::{Note, NoteAttachments, Nullifier}; - use miden_protocol::transaction::{OrderedTransactionHeaders, OutputNote, PrivateOutputNote}; + use miden_protocol::transaction::{ + OrderedTransactionHeaders, + OutputNote, + PartialBlockchain, + PrivateOutputNote, + }; use miden_protocol::utils::serde::Serializable; use miden_protocol::{MAX_BATCHES_PER_BLOCK, MAX_OUTPUT_NOTES_PER_BATCH, Word}; use prost::Message; @@ -614,6 +731,25 @@ mod tests { assert_eq!(BlockBody::try_from(encoded).unwrap(), body); } + #[test] + fn partial_blockchain_roundtrip_preserves_tracked_leaves_without_headers() { + let leaves = [ + Word::from([1_u32, 2, 3, 4]), + Word::from([5_u32, 6, 7, 8]), + Word::from([9_u32, 10, 11, 12]), + ]; + let mmr = Mmr::try_from_iter(leaves).unwrap(); + let mut partial = PartialMmr::from_peaks(mmr.peaks()); + let proof = mmr.open(0).unwrap(); + partial.track(0, proof.leaf(), proof.merkle_path()).unwrap(); + let chain = PartialBlockchain::new(partial, Vec::new()).unwrap(); + + let encoded = proto::blockchain::PartialBlockchain::from(&chain); + assert_eq!(encoded.tracked_leaves.len(), 1); + assert!(encoded.block_headers.is_empty()); + assert_eq!(PartialBlockchain::try_from(encoded).unwrap(), chain); + } + #[test] fn representative_block_body_preserves_order_and_sparse_note_indices() { let account_id = AccountId::dummy( diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index aa5c696f45..1db113d539 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -10,6 +10,7 @@ pub mod nullifier; pub mod primitives; pub mod proof_request; pub mod transaction; +pub mod vm; // UTILITIES // ================================================================================================ diff --git a/crates/proto/src/domain/transaction.rs b/crates/proto/src/domain/transaction.rs index 76331152c2..654068c10f 100644 --- a/crates/proto/src/domain/transaction.rs +++ b/crates/proto/src/domain/transaction.rs @@ -1,20 +1,137 @@ use miden_protocol::Word; -use miden_protocol::account::AccountId; +use miden_protocol::account::{AccountId, AccountUpdateDetails}; +use miden_protocol::block::BlockNumber; use miden_protocol::note::{Note, NoteHeader, Nullifier}; use miden_protocol::transaction::{ InputNoteCommitment, InputNotes, OutputNote, PrivateOutputNote, + ProvenTransaction, PublicOutputNote, TransactionHeader, TransactionId, + TxAccountUpdate, }; use crate::decode::{ConversionResultExt, GrpcDecodeExt}; use crate::errors::ConversionError; use crate::{decode, generated as proto}; +/// A decoded public transaction submission, keeping the sealed validator payload separate from the +/// protocol transaction. +pub struct DecodedProvenTransaction { + pub transaction: ProvenTransaction, + pub sealed_transaction_inputs: Option, +} + +// PROVEN TRANSACTION +// ================================================================================================ + +impl From<&TxAccountUpdate> for proto::transaction::TxAccountUpdate { + fn from(value: &TxAccountUpdate) -> Self { + Self { + account_id: Some(value.account_id().into()), + initial_state_commitment: Some(value.initial_state_commitment().into()), + final_state_commitment: Some(value.final_state_commitment().into()), + account_patch_commitment: Some(value.account_patch_commitment().into()), + details: Some(value.details().into()), + } + } +} + +impl TryFrom for TxAccountUpdate { + type Error = ConversionError; + + fn try_from(value: proto::transaction::TxAccountUpdate) -> Result { + let decoder = value.decoder(); + let account_id: AccountId = decode!(decoder, value.account_id)?; + let initial_state_commitment = decode!(decoder, value.initial_state_commitment)?; + let final_state_commitment = decode!(decoder, value.final_state_commitment)?; + let account_patch_commitment = decode!(decoder, value.account_patch_commitment)?; + let details: AccountUpdateDetails = decode!(decoder, value.details)?; + Self::new( + account_id, + initial_state_commitment, + final_state_commitment, + account_patch_commitment, + details, + ) + .map_err(ConversionError::new) + } +} + +impl From<&ProvenTransaction> for proto::transaction::ProvenTransactionData { + fn from(value: &ProvenTransaction) -> Self { + Self { + account_update: Some(value.account_update().into()), + input_notes: value.input_notes().iter().map(Into::into).collect(), + output_notes: value.output_notes().iter().map(Into::into).collect(), + reference_block_num: value.ref_block_num().as_u32(), + reference_block_commitment: Some(value.ref_block_commitment().into()), + expiration_block_num: value.expiration_block_num().as_u32(), + proof: Some(value.proof().into()), + } + } +} + +impl From for proto::transaction::ProvenTransactionData { + fn from(value: ProvenTransaction) -> Self { + Self::from(&value) + } +} + +impl TryFrom for ProvenTransaction { + type Error = ConversionError; + + fn try_from(value: proto::transaction::ProvenTransactionData) -> Result { + let decoder = value.decoder(); + let account_update = decode!(decoder, value.account_update)?; + let input_notes = value + .input_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + InputNoteCommitment::try_from(note).context(format!("input_notes[{index}]")) + }) + .collect::, _>>()?; + let output_notes = value + .output_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + OutputNote::try_from(note).context(format!("output_notes[{index}]")) + }) + .collect::, _>>()?; + let reference_block_commitment = decode!(decoder, value.reference_block_commitment)?; + let proof = decode!(decoder, value.proof)?; + + Self::new( + account_update, + input_notes, + output_notes, + BlockNumber::from(value.reference_block_num), + reference_block_commitment, + BlockNumber::from(value.expiration_block_num), + proof, + ) + .map_err(ConversionError::new) + } +} + +impl TryFrom for DecodedProvenTransaction { + type Error = ConversionError; + + fn try_from(value: proto::transaction::ProvenTransaction) -> Result { + let decoder = value.decoder(); + let transaction = decode!(decoder, value.transaction_data)?; + Ok(Self { + transaction, + sealed_transaction_inputs: value.sealed_transaction_inputs, + }) + } +} + // FROM TRANSACTION ID // ================================================================================================ diff --git a/crates/proto/src/domain/vm.rs b/crates/proto/src/domain/vm.rs new file mode 100644 index 0000000000..68c547ee06 --- /dev/null +++ b/crates/proto/src/domain/vm.rs @@ -0,0 +1,316 @@ +use miden_core::deferred::{DeferredStateWire, Tag, WireEntry}; +use miden_core::proof::{DeferredProof, ExecutionProof, HashFunction, StarkProof}; +use miden_protocol::{Felt, Word}; + +use crate::decode::{ConversionResultExt, GrpcDecodeExt}; +use crate::errors::ConversionError; +use crate::{decode, generated as proto}; + +impl From for proto::vm::ExecutionProofHashFunction { + fn from(value: HashFunction) -> Self { + match value { + HashFunction::Blake3_256 => Self::Blake3256, + HashFunction::Rpo256 => Self::Rpo256, + HashFunction::Rpx256 => Self::Rpx256, + HashFunction::Poseidon2 => Self::Poseidon2, + HashFunction::Keccak => Self::Keccak, + } + } +} + +fn decode_hash_function(value: i32) -> Result { + match proto::vm::ExecutionProofHashFunction::try_from(value) { + Ok(proto::vm::ExecutionProofHashFunction::Blake3256) => Ok(HashFunction::Blake3_256), + Ok(proto::vm::ExecutionProofHashFunction::Rpo256) => Ok(HashFunction::Rpo256), + Ok(proto::vm::ExecutionProofHashFunction::Rpx256) => Ok(HashFunction::Rpx256), + Ok(proto::vm::ExecutionProofHashFunction::Poseidon2) => Ok(HashFunction::Poseidon2), + Ok(proto::vm::ExecutionProofHashFunction::Keccak) => Ok(HashFunction::Keccak), + Ok(proto::vm::ExecutionProofHashFunction::Unspecified) => { + Err(ConversionError::message("execution proof hash function is unspecified")) + }, + Err(_) => Err(ConversionError::message(format!( + "unknown execution proof hash function value {value}" + ))), + } +} + +impl From<&StarkProof> for proto::vm::StarkProof { + fn from(value: &StarkProof) -> Self { + Self { + proof: value.bytes().to_vec(), + hash_function: proto::vm::ExecutionProofHashFunction::from(value.hash_fn()) as i32, + } + } +} + +impl From for proto::vm::StarkProof { + fn from(value: StarkProof) -> Self { + let (proof, hash_function) = value.into_parts(); + Self { + proof, + hash_function: proto::vm::ExecutionProofHashFunction::from(hash_function) as i32, + } + } +} + +impl TryFrom for StarkProof { + type Error = ConversionError; + + fn try_from(value: proto::vm::StarkProof) -> Result { + let hash_function = decode_hash_function(value.hash_function).context("hash_function")?; + Ok(Self::new(value.proof, hash_function)) + } +} + +fn encode_wire_entry(entry: &WireEntry) -> proto::vm::DeferredWireEntry { + use proto::vm::deferred_wire_entry::Entry; + + let (tag, entry) = match entry { + WireEntry::Data { tag, chunks } => { + let chunks = chunks + .iter() + .map(|chunk| proto::vm::DeferredDataChunk { + elements: chunk.iter().map(Into::into).collect(), + }) + .collect(); + ( + Word::new((*tag).as_word()).into(), + Entry::Data(proto::vm::DeferredData { chunks }), + ) + }, + WireEntry::Join { tag, lhs, rhs } => ( + Word::new((*tag).as_word()).into(), + Entry::Join(proto::vm::DeferredJoin { lhs: *lhs, rhs: *rhs }), + ), + WireEntry::PairList { tag, pairs } => { + let pairs = pairs + .iter() + .map(|(lhs, rhs)| proto::vm::DeferredIndexPair { lhs: *lhs, rhs: *rhs }) + .collect(); + ( + Word::new((*tag).as_word()).into(), + Entry::PairList(proto::vm::DeferredPairList { pairs }), + ) + }, + }; + + proto::vm::DeferredWireEntry { tag: Some(tag), entry: Some(entry) } +} + +fn decode_wire_entry( + value: proto::vm::DeferredWireEntry, + index: usize, +) -> Result { + use proto::vm::deferred_wire_entry::Entry; + + let decoder = value.decoder(); + let tag: Word = decode!(decoder, value.tag)?; + let tag = Tag::from_word(tag.into_elements()); + let entry = value + .entry + .ok_or_else(|| ConversionError::missing_field::("entry"))?; + + let max_child = u32::try_from(index) + .map_err(|_| ConversionError::message("too many deferred wire entries"))?; + let validate_child = |child: u32, field: &str| { + if child > max_child { + Err(ConversionError::message(format!( + "child index {child} must refer to TRUE or an earlier entry" + )) + .context(field)) + } else { + Ok(()) + } + }; + + match entry { + Entry::Data(data) => { + if data.chunks.is_empty() { + return Err(ConversionError::message("data entry must contain at least one chunk") + .context("data.chunks")); + } + let chunks = data + .chunks + .into_iter() + .enumerate() + .map(|(chunk_index, chunk)| { + if chunk.elements.len() != 8 { + return Err(ConversionError::message(format!( + "deferred data chunk must contain exactly 8 elements, got {}", + chunk.elements.len() + )) + .context(format!("data.chunks[{chunk_index}].elements"))); + } + let elements = chunk + .elements + .into_iter() + .enumerate() + .map(|(element_index, element)| { + Felt::try_from(element).context(format!("elements[{element_index}]")) + }) + .collect::, _>>()?; + elements.try_into().map_err(|_| { + ConversionError::message("deferred data chunk has invalid length") + }) + }) + .collect::, _>>()?; + Ok(WireEntry::Data { tag, chunks }) + }, + Entry::Join(join) => { + validate_child(join.lhs, "join.lhs")?; + validate_child(join.rhs, "join.rhs")?; + Ok(WireEntry::Join { tag, lhs: join.lhs, rhs: join.rhs }) + }, + Entry::PairList(pair_list) => { + if pair_list.pairs.is_empty() { + return Err(ConversionError::message( + "pair-list entry must contain at least one pair", + ) + .context("pair_list.pairs")); + } + let pairs = pair_list + .pairs + .into_iter() + .enumerate() + .map(|(pair_index, pair)| { + validate_child(pair.lhs, &format!("pair_list.pairs[{pair_index}].lhs"))?; + validate_child(pair.rhs, &format!("pair_list.pairs[{pair_index}].rhs"))?; + Ok((pair.lhs, pair.rhs)) + }) + .collect::, ConversionError>>()?; + Ok(WireEntry::PairList { tag, pairs }) + }, + } +} + +impl From<&DeferredProof> for proto::vm::DeferredProof { + fn from(value: &DeferredProof) -> Self { + use proto::vm::deferred_proof::Proof; + + let proof = match value { + DeferredProof::Empty => Proof::Empty(proto::vm::EmptyDeferredProof {}), + DeferredProof::Wire(wire) => Proof::Wire(proto::vm::DeferredStateWire { + entries: wire.entries.iter().map(encode_wire_entry).collect(), + }), + DeferredProof::Stark { proof, public_root } => { + Proof::Stark(proto::vm::DeferredStarkProof { + proof: Some(proof.into()), + public_root: Some(public_root.into()), + }) + }, + }; + Self { proof: Some(proof) } + } +} + +impl TryFrom for DeferredProof { + type Error = ConversionError; + + fn try_from(value: proto::vm::DeferredProof) -> Result { + use proto::vm::deferred_proof::Proof; + + match value.proof { + Some(Proof::Empty(_)) => Ok(Self::Empty), + Some(Proof::Wire(wire)) => { + let entries = wire + .entries + .into_iter() + .enumerate() + .map(|(index, entry)| { + decode_wire_entry(entry, index).context(format!("wire.entries[{index}]")) + }) + .collect::, _>>()?; + Ok(Self::Wire(DeferredStateWire { entries })) + }, + Some(Proof::Stark(stark)) => { + let decoder = stark.decoder(); + let proof = decode!(decoder, stark.proof)?; + let public_root = decode!(decoder, stark.public_root)?; + Ok(Self::Stark { proof, public_root }) + }, + None => Err(ConversionError::missing_field::("proof")), + } + } +} + +impl From<&ExecutionProof> for proto::vm::ExecutionProof { + fn from(value: &ExecutionProof) -> Self { + Self { + miden: Some(value.miden_proof().into()), + deferred: Some(value.deferred_proof().into()), + } + } +} + +impl From for proto::vm::ExecutionProof { + fn from(value: ExecutionProof) -> Self { + Self::from(&value) + } +} + +impl TryFrom for ExecutionProof { + type Error = ConversionError; + + fn try_from(value: proto::vm::ExecutionProof) -> Result { + let decoder = value.decoder(); + let miden = decode!(decoder, value.miden)?; + let deferred = decode!(decoder, value.deferred)?; + Ok(Self::new(miden, deferred)) + } +} + +#[cfg(test)] +mod tests { + use miden_core::deferred::{DeferredStateWire, Tag, WireEntry}; + use miden_core::proof::{DeferredProof, ExecutionProof, HashFunction, StarkProof}; + use miden_protocol::{Felt, Word}; + + use crate::generated as proto; + + #[test] + fn execution_proof_roundtrips_all_variants() { + let hashes = [ + HashFunction::Blake3_256, + HashFunction::Rpo256, + HashFunction::Rpx256, + HashFunction::Poseidon2, + HashFunction::Keccak, + ]; + for hash in hashes { + let proofs = [ + ExecutionProof::new(StarkProof::new(vec![1, 2, 3], hash), DeferredProof::Empty), + ExecutionProof::new( + StarkProof::new(vec![4], hash), + DeferredProof::Wire(DeferredStateWire { + entries: vec![WireEntry::Data { + tag: Tag::from_word(Word::from([3_u32, 4, 5, 6]).into_elements()), + chunks: vec![[Felt::new_unchecked(7); 8]], + }], + }), + ), + ExecutionProof::new( + StarkProof::new(vec![], hash), + DeferredProof::Stark { + proof: StarkProof::new(vec![8, 9], HashFunction::Poseidon2), + public_root: Word::from([10_u32, 11, 12, 13]), + }, + ), + ]; + for proof in proofs { + let encoded = proto::vm::ExecutionProof::from(&proof); + assert_eq!(ExecutionProof::try_from(encoded).unwrap(), proof); + } + } + } + + #[test] + fn rejects_unspecified_and_unknown_hash_functions() { + for hash_function in [0, 99] { + let error = + StarkProof::try_from(proto::vm::StarkProof { proof: Vec::new(), hash_function }) + .unwrap_err() + .to_string(); + assert!(error.contains("hash function")); + } + } +} diff --git a/crates/rpc/src/server/api/submit_auth_tx_batch.rs b/crates/rpc/src/server/api/submit_auth_tx_batch.rs index 2d082be899..8d2b36d744 100644 --- a/crates/rpc/src/server/api/submit_auth_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_auth_tx_batch.rs @@ -1,24 +1,45 @@ use miden_node_block_producer::store::TransactionInputs; +use miden_node_proto::domain::batch::decode_proposed_batch; use miden_node_proto::generated as proto; use miden_node_proto::generated::server::sequencer_api; use miden_node_utils::ErrorReport; +use miden_node_utils::spawn::spawn_blocking_in_current_span; +use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::batch::ProposedBatch; -use miden_protocol::utils::serde::Deserializable; use tonic::Status; use super::SequencerInternalService; #[tonic::async_trait] impl sequencer_api::SubmitAuthenticatedTxBatch for SequencerInternalService { - type Input = (ProposedBatch, Vec); + type Input = proto::sequencer::AuthenticatedTransactionBatch; type Output = proto::blockchain::BlockNumber; fn decode( request: proto::sequencer::AuthenticatedTransactionBatch, ) -> tonic::Result { - let batch = ProposedBatch::read_from_bytes(&request.proposed_batch).map_err(|err| { - Status::invalid_argument(err.as_report_context("invalid proposed_batch")) - })?; + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + mut request: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + let proposed = request + .proposed + .take() + .ok_or_else(|| Status::invalid_argument("missing `proposed` field"))?; + let batch: ProposedBatch = spawn_blocking_in_current_span(move || { + decode_proposed_batch(proposed, MIN_PROOF_SECURITY_LEVEL).map_err(Status::from) + }) + .await + .map_err(|err| Status::internal(format!("batch validation task failed: {err}")))??; if batch.transactions().len() != request.auth_inputs.len() { return Err(Status::invalid_argument(format!( @@ -37,19 +58,6 @@ impl sequencer_api::SubmitAuthenticatedTxBatch for SequencerInternalService { Status::invalid_argument(err.as_report_context("invalid auth_inputs")) })?; - Ok((batch, inputs)) - } - - fn encode(output: Self::Output) -> tonic::Result { - Ok(output) - } - - async fn handle( - &self, - (batch, inputs): Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { self.block_producer .submit_authenticated_tx_batch(batch, inputs) .await diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index 65ae7a1932..22d0249617 100644 --- a/crates/rpc/src/server/api/submit_proven_tx.rs +++ b/crates/rpc/src/server/api/submit_proven_tx.rs @@ -13,7 +13,6 @@ use miden_protocol::transaction::{ TransactionVerifier, TxAccountUpdate, }; -use miden_protocol::utils::serde::{Deserializable, Serializable}; use tonic::{Request, Status}; use tracing::debug; @@ -50,9 +49,12 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { tracing::trace!(target: LOG_TARGET, "Received transaction submission"); - let tx = ProvenTransaction::read_from_bytes(&request.transaction).map_err(|err| { - Status::invalid_argument(err.as_report_context("invalid transaction")) - })?; + let tx: ProvenTransaction = request + .transaction_data + .take() + .ok_or_else(|| Status::invalid_argument("missing `transaction_data` field"))? + .try_into() + .map_err(Status::from)?; miden_span_record!( transaction.id = %tx.id(), @@ -89,7 +91,7 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { tx.proof().clone(), ) .map_err(|e| Status::invalid_argument(e.to_string()))?; - request.transaction = rebuilt_tx.to_bytes(); + request.transaction_data = Some((&rebuilt_tx).into()); // Block post-deployment network-account transactions from user RPC. First-deployment txs // are exempt because the protocol-level allowlist only kicks in once the account exists, diff --git a/crates/rpc/src/server/api/submit_proven_tx_batch.rs b/crates/rpc/src/server/api/submit_proven_tx_batch.rs index c8ea58ca3e..60b0c4f558 100644 --- a/crates/rpc/src/server/api/submit_proven_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_proven_tx_batch.rs @@ -1,12 +1,12 @@ use miden_node_block_producer::store::get_tx_inputs; use miden_node_proto::clients::{SequencerClient, ValidatorClient}; +use miden_node_proto::domain::batch::{decode_proposed_batch, decode_proven_batch}; use miden_node_proto::generated as proto; use miden_node_utils::ErrorReport; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_node_utils::tracing::{miden_instrument, miden_span_record}; use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::batch::{ProposedBatch, ProvenBatch}; -use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_tx_batch::BatchVerifier; use tonic::{Request, Status}; @@ -37,7 +37,7 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { metadata: &tonic::metadata::MetadataMap, _extensions: &tonic::codegen::http::Extensions, ) -> tonic::Result { - let request = input; + let mut request = input; let is_authorized_network_tx = self.is_authorized_network_tx(metadata); let original_accept_header = metadata.get(http::header::ACCEPT.as_str()).cloned(); @@ -47,9 +47,23 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { "Received transaction batch", ); - let proven_batch = ProvenBatch::read_from_bytes(&request.batch_proof).map_err(|err| { - Status::invalid_argument(err.as_report_context("invalid proven_batch")) - })?; + let proposed = request + .proposed + .take() + .ok_or_else(|| Status::invalid_argument("missing `proposed` field"))?; + let proposed_batch = spawn_blocking_in_current_span(move || { + decode_proposed_batch(proposed, MIN_PROOF_SECURITY_LEVEL).map_err(Status::from) + }) + .await + .map_err(|err| { + Status::internal(format!("proposed batch validation task failed: {err}")) + })??; + + let proven_batch = request + .proven_batch + .take() + .ok_or_else(|| Status::invalid_argument("missing `proven_batch` field")) + .and_then(|batch| decode_proven_batch(batch, &proposed_batch).map_err(Status::from))?; miden_span_record!( batch.id = %proven_batch.id(), @@ -58,16 +72,6 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { batch.reference_block.commitment = %proven_batch.reference_block_commitment(), ); - let proposed_batch = request - .proposed_batch - .as_deref() - .map(ProposedBatch::read_from_bytes) - .transpose() - .map_err(|err| { - Status::invalid_argument(err.as_report_context("invalid proposed_batch")) - })? - .ok_or(Status::invalid_argument("missing `proposed_batch` field"))?; - tracing::debug!(target: LOG_TARGET, "Submitting transaction batch"); // Verify the reference block is actually part of the chain. @@ -105,7 +109,9 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { } // Verify batch transaction proofs. - verify_batch_proof(proven_batch, &proposed_batch).await?; + verify_batch_proof(&proven_batch, &proposed_batch).await?; + request.proven_batch = Some((&proven_batch).into()); + request.proposed = Some((&proposed_batch).into()); match &self.backend { RpcBackend::Sequencer { block_producer, validators } => { @@ -173,8 +179,8 @@ impl RpcService { } let authenticated_batch = proto::sequencer::AuthenticatedTransactionBatch { - proposed_batch: proposed_batch.to_bytes(), auth_inputs, + proposed: Some((&proposed_batch).into()), }; sequencer .submit_authenticated_tx_batch(authenticated_batch) @@ -187,7 +193,7 @@ impl RpcService { /// /// Errors on id mismatch, or the proof cannot be verified [`MIN_PROOF_SECURITY_LEVEL`] async fn verify_batch_proof( - proven_batch: ProvenBatch, + proven_batch: &ProvenBatch, proposed_batch: &ProposedBatch, ) -> tonic::Result<()> { if proven_batch.id() != proposed_batch.id() { diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index bfd4dee0be..e14879d17b 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -118,9 +118,6 @@ impl TestStore { } } -/// Byte offset of the account delta commitment in serialized `ProvenTransaction`. Layout: -/// `AccountId` (15) + `initial_commitment` (32) + `final_commitment` (32) = 79 -const DELTA_COMMITMENT_BYTE_OFFSET: usize = 15 + 32 + 32; const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); #[test] @@ -265,6 +262,76 @@ fn rpc_descriptor_exposes_structured_blockchain_schema_and_reserves_legacy_field ); } +#[test] +fn rpc_descriptor_exposes_structured_transaction_and_proof_schema() { + let descriptor = miden_node_proto_build::rpc_api_descriptor(); + let transaction_file = descriptor + .file + .iter() + .find(|file| file.name() == "types/transaction.proto") + .expect("the public RPC descriptor should include types/transaction.proto"); + + for name in [ + "TxAccountUpdate", + "ProvenTransactionData", + "ProposedBatch", + "BatchAccountUpdate", + "ProvenBatch", + ] { + assert!( + transaction_file.message_type.iter().any(|message| message.name() == name), + "the public RPC descriptor should expose transaction.{name}" + ); + } + + for (message_name, reserved_names, structured_fields) in [ + ("ProvenTransaction", &["transaction"][..], &[("transaction_data", 3)][..]), + ( + "TransactionBatch", + &["batch_proof", "proposed_batch"][..], + &[("proven_batch", 4), ("proposed", 5)][..], + ), + ] { + let message = transaction_file + .message_type + .iter() + .find(|message| message.name() == message_name) + .unwrap_or_else(|| panic!("transaction.{message_name} should be present")); + for reserved_name in reserved_names { + assert!(message.reserved_name.iter().any(|name| name == reserved_name)); + } + for (field_name, field_number) in structured_fields { + assert_eq!( + message + .field + .iter() + .find(|field| field.name() == *field_name) + .unwrap_or_else(|| panic!("{message_name}.{field_name} should be present")) + .number(), + *field_number + ); + } + } + + for (file_name, messages) in [ + ( + "types/vm.proto", + &["StarkProof", "DeferredProof", "DeferredStateWire", "ExecutionProof"][..], + ), + ("types/partial_blockchain.proto", &["TrackedMmrLeaf", "PartialBlockchain"][..]), + ("types/block_header.proto", &["BlockHeader"][..]), + ] { + let file = descriptor + .file + .iter() + .find(|file| file.name() == file_name) + .unwrap_or_else(|| panic!("the public RPC descriptor should include {file_name}")); + for message_name in messages { + assert!(file.message_type.iter().any(|message| message.name() == *message_name)); + } + } +} + #[test] fn rpc_descriptor_exposes_structured_block_subscription_schema() { let descriptor = miden_node_proto_build::rpc_api_descriptor(); @@ -634,16 +701,13 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_commitment() { // Create an incorrect patch commitment from a different account let (other_account, _) = build_test_account([1; 32]); let incorrect_patch: AccountPatch = AccountPatch::try_from(other_account).unwrap(); - let incorrect_commitment_bytes = incorrect_patch.to_commitment().as_bytes(); - - // Corrupt the transaction bytes with the incorrect patch commitment - let mut tx_bytes = tx.to_bytes(); - tx_bytes[DELTA_COMMITMENT_BYTE_OFFSET..DELTA_COMMITMENT_BYTE_OFFSET + 32] - .copy_from_slice(&incorrect_commitment_bytes); + let mut transaction_data: proto::transaction::ProvenTransactionData = (&tx).into(); + transaction_data.account_update.as_mut().unwrap().account_patch_commitment = + Some(incorrect_patch.to_commitment().into()); let request = proto::transaction::ProvenTransaction { - transaction: tx_bytes, sealed_transaction_inputs: None, + transaction_data: Some(transaction_data), }; let response = rpc_client.submit_proven_tx(request).await; @@ -681,8 +745,8 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_reference_block() { let tx = build_test_proven_tx(&account, &account_patch, invalid); let request = proto::transaction::ProvenTransaction { - transaction: tx.to_bytes(), sealed_transaction_inputs: None, + transaction_data: Some((&tx).into()), }; let response = rpc_client.submit_proven_tx(request).await; @@ -720,8 +784,8 @@ async fn rpc_rejects_post_deployment_network_account_tx() { let (account, _) = build_test_account([0; 32]); let tx = build_test_proven_tx_with_id(network_account_id, &account, genesis); let request = proto::transaction::ProvenTransaction { - transaction: tx.to_bytes(), sealed_transaction_inputs: None, + transaction_data: Some((&tx).into()), }; let service = RpcService::new( @@ -1259,8 +1323,8 @@ async fn rpc_server_rejects_tx_submissions_without_genesis() { let tx = build_test_proven_tx(&account, &account_patch, genesis); let request = proto::transaction::ProvenTransaction { - transaction: tx.to_bytes(), sealed_transaction_inputs: None, + transaction_data: Some((&tx).into()), }; let response = rpc_client.submit_proven_tx(request).await; diff --git a/proto/proto/internal/sequencer.proto b/proto/proto/internal/sequencer.proto index db26af4278..05f48386f3 100644 --- a/proto/proto/internal/sequencer.proto +++ b/proto/proto/internal/sequencer.proto @@ -27,10 +27,8 @@ service Api { // An authenticated transaction. message AuthenticatedTransaction { - // The proven transaction. - // - // Encoded using [miden_protocol::transaction::ProvenTransaction::to_bytes]. - bytes transaction = 1; + reserved 1; + reserved "transaction"; // The account state provided by the store inputs, if any. optional primitives.Digest store_account_state = 2; @@ -40,19 +38,21 @@ message AuthenticatedTransaction { // The chain height at which authentication took place. fixed32 authentication_height = 4; + + transaction.ProvenTransactionData proven_transaction = 5; } // A proposed batch together with the inputs each of its transactions was authenticated against. message AuthenticatedTransactionBatch { - // The proposed batch. - // - // Encoded using [miden_protocol::batch::ProposedBatch::to_bytes]. - bytes proposed_batch = 1; + reserved 1; + reserved "proposed_batch"; // The store inputs for each transaction in the batch. // // Must match the transaction ordering in the batch. repeated AuthInputs auth_inputs = 2; + + transaction.ProposedBatch proposed = 3; } // The store-derived inputs a transaction was authenticated against. diff --git a/proto/proto/remote_prover.proto b/proto/proto/remote_prover.proto index 28a0ad485a..e5605f9fc8 100644 --- a/proto/proto/remote_prover.proto +++ b/proto/proto/remote_prover.proto @@ -3,6 +3,8 @@ syntax = "proto3"; package remote_prover; import "google/protobuf/empty.proto"; +import "types/blockchain.proto"; +import "types/transaction.proto"; // PROVER SERVICE // ================================================================================================ @@ -26,24 +28,28 @@ enum ProofType { // Request message for proof generation containing payload and proof type metadata. message ProofRequest { - // Type of proof being requested, determines payload interpretation - ProofType proof_type = 1; - - // Serialized payload requiring proof generation. The encoding format is - // type-specific: - // - TRANSACTION: TransactionInputs encoded. - // - BATCH: ProposedBatch encoded. - // - BLOCK: BlockProofRequest encoded. - bytes payload = 2; + reserved 1, 2; + reserved "proof_type", "payload"; + + oneof request { + // Canonically serialized TransactionInputs, deferred to a later migration. + bytes transaction_inputs = 3; + transaction.ProposedBatch proposed_batch = 4; + // Canonically serialized BlockProofRequest, deferred to block-prover API work. + bytes block_proof_request = 5; + } } // Response message containing the generated proof. message Proof { - // Serialized proof bytes. - // - TRANSACTION: Returns an encoded ProvenTransaction. - // - BATCH: Returns an encoded ProvenBatch. - // - BLOCK: Returns an encoded BlockProof. - bytes payload = 1; + reserved 1; + reserved "payload"; + + oneof result { + transaction.ProvenTransactionData proven_transaction = 2; + transaction.ProvenBatch proven_batch = 3; + blockchain.BlockProof block_proof = 4; + } } // PROXY STATUS SERVICE diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 37ed9556ab..2da87d1929 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -5,6 +5,7 @@ package rpc; import "google/protobuf/empty.proto"; import "types/account.proto"; import "types/blockchain.proto"; +import "types/block_header.proto"; import "types/note.proto"; import "types/primitives.proto"; import "types/transaction.proto"; diff --git a/proto/proto/types/block_header.proto b/proto/proto/types/block_header.proto new file mode 100644 index 0000000000..e0f89a6a78 --- /dev/null +++ b/proto/proto/types/block_header.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; +package blockchain; + +import "types/account.proto"; +import "types/primitives.proto"; + +// Represents a block header. +message BlockHeader { + uint32 version = 1; + primitives.Digest prev_block_commitment = 2; + fixed32 block_num = 3; + primitives.Digest chain_commitment = 4; + primitives.Digest account_root = 5; + primitives.Digest nullifier_root = 6; + primitives.Digest note_root = 7; + primitives.Digest tx_commitment = 8; + repeated ValidatorPublicKey validator_keys = 9; + primitives.Digest tx_kernel_commitment = 10; + FeeParameters fee_parameters = 11; + fixed32 timestamp = 12; +} + +// Validator ECDSA public key. +message ValidatorPublicKey { + // Encoded using the protocol public key's canonical serialization. + bytes validator_key = 1; +} + +// Fee parameters for block processing. +message FeeParameters { + account.AccountId native_asset_id = 1; + fixed32 verification_base_fee = 2; +} diff --git a/proto/proto/types/blockchain.proto b/proto/proto/types/blockchain.proto index 75fa592fa8..158b675cf1 100644 --- a/proto/proto/types/blockchain.proto +++ b/proto/proto/types/blockchain.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package blockchain; import "types/account.proto"; +import "types/block_header.proto"; import "types/primitives.proto"; import "types/transaction.proto"; @@ -56,55 +57,6 @@ message MaybeBlockNumber { optional fixed32 block_num = 1; } -// Represents a block header. -message BlockHeader { - // Specifies the version of the protocol. - uint32 version = 1; - - // The commitment of the previous blocks header. - primitives.Digest prev_block_commitment = 2; - - // A unique sequential number of the current block. - fixed32 block_num = 3; - - // A commitment to an MMR of the entire chain where each block is a leaf. - primitives.Digest chain_commitment = 4; - - // A commitment to account database. - primitives.Digest account_root = 5; - - // A commitment to the nullifier database. - primitives.Digest nullifier_root = 6; - - // A commitment to all notes created in the current block. - primitives.Digest note_root = 7; - - // A commitment to a set of IDs of transactions which affected accounts in this block. - primitives.Digest tx_commitment = 8; - - // The set of validator ECDSA public keys authorized to sign the next block. - repeated ValidatorPublicKey validator_keys = 9; - - // A commitment to all transaction kernels supported by this block. - primitives.Digest tx_kernel_commitment = 10; - - // Fee parameters for block processing. - FeeParameters fee_parameters = 11; - - // The time when the block was created. - fixed32 timestamp = 12; -} - -// PUBLIC KEY -// ================================================================================================ - -// Validator ECDSA public key. -message ValidatorPublicKey { - // Signature encoded using [miden_serde_utils::Serializable] implementation for - // [crypto::dsa::ecdsa_k256_keccak::PublicKey]. - bytes validator_key = 1; -} - // BLOCK SIGNATURE // ================================================================================================ @@ -127,17 +79,6 @@ message SignBlockResponse { } -// FEE PARAMETERS -// ================================================================================================ - -// Definition of the fee parameters. -message FeeParameters { - // The faucet account ID which is used for native fee assets. - account.AccountId native_asset_id = 1; - // The base fee (in base units) capturing the cost for the verification of a transaction. - fixed32 verification_base_fee = 2; -} - // BLOCK BODY // ================================================================================================ diff --git a/proto/proto/types/partial_blockchain.proto b/proto/proto/types/partial_blockchain.proto new file mode 100644 index 0000000000..5439b5098c --- /dev/null +++ b/proto/proto/types/partial_blockchain.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; +package blockchain; + +import "types/block_header.proto"; +import "types/primitives.proto"; + +message TrackedMmrLeaf { + fixed64 position = 1; + primitives.Word leaf = 2; + repeated primitives.Word path = 3; +} + +message PartialBlockchain { + fixed64 forest = 1; + repeated primitives.Word peaks = 2; + repeated TrackedMmrLeaf tracked_leaves = 3; + repeated BlockHeader block_headers = 4; +} diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index 5885b504f0..d08e7dabf4 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -2,8 +2,11 @@ syntax = "proto3"; package transaction; import "types/account.proto"; +import "types/block_header.proto"; import "types/note.proto"; +import "types/partial_blockchain.proto"; import "types/primitives.proto"; +import "types/vm.proto"; // TRANSACTION // ================================================================================================ @@ -19,15 +22,16 @@ import "types/primitives.proto"; // nodes that relay a submission cannot read them: only a validator can. Submissions carrying // unsealed inputs are rejected. This requirement will be lifted as Miden matures. message ProvenTransaction { - // The transaction proof. - // - // Encoded using [miden_protocol::transaction::ProvenTransaction::to_bytes]. - bytes transaction = 1; + reserved 1; + reserved "transaction"; // The sealed private inputs used for the transaction proof. // // Transactions missing this field will be rejected as per the message description. SealedTransactionInputs sealed_transaction_inputs = 2; + + // The structured proven transaction. Required. + ProvenTransactionData transaction_data = 3; } // Transaction inputs sealed against the validator set's shared transaction encryption key. @@ -57,16 +61,8 @@ message SealedTransactionInputs { // // In addition, in order to verify the batch itself, we also require the proposed batch. message TransactionBatch { - // The batch proof. - // - // Encoded using [miden_protocol::batch::ProvenBatch::to_bytes]. - bytes batch_proof = 1; - // The batch contents of the given proof. - // - // Encoded using [miden_protocol::batch::ProposedBatch::to_bytes]. - // - // Batches missing this field will be rejected as per the message description. - optional bytes proposed_batch = 2; + reserved 1, 2; + reserved "batch_proof", "proposed_batch"; // The sealed transaction inputs for each transaction in the batch. // @@ -76,6 +72,52 @@ message TransactionBatch { // // Batch will be rejected if any transaction's input is missing as per the method description. repeated SealedTransactionInputs sealed_transaction_inputs = 3; + + ProvenBatch proven_batch = 4; + ProposedBatch proposed = 5; +} + +message TxAccountUpdate { + account.AccountId account_id = 1; + primitives.Word initial_state_commitment = 2; + primitives.Word final_state_commitment = 3; + primitives.Word account_patch_commitment = 4; + account.AccountUpdateDetails details = 5; +} + +message ProvenTransactionData { + TxAccountUpdate account_update = 1; + repeated InputNoteCommitment input_notes = 2; + repeated OutputNote output_notes = 3; + fixed32 reference_block_num = 4; + primitives.Word reference_block_commitment = 5; + fixed32 expiration_block_num = 6; + vm.ExecutionProof proof = 7; +} + +message ProposedBatch { + repeated ProvenTransactionData transactions = 1; + blockchain.BlockHeader reference_block_header = 2; + blockchain.PartialBlockchain partial_blockchain = 3; + repeated note.NoteInclusionInBlockProof unauthenticated_note_proofs = 4; +} + +message BatchAccountUpdate { + account.AccountId account_id = 1; + primitives.Word initial_state_commitment = 2; + primitives.Word final_state_commitment = 3; + account.AccountUpdateDetails details = 4; +} + +message ProvenBatch { + primitives.Word reference_block_commitment = 1; + fixed32 reference_block_num = 2; + repeated BatchAccountUpdate account_updates = 3; + repeated InputNoteCommitment input_notes = 4; + repeated OutputNote output_notes = 5; + fixed32 expiration_block_num = 6; + repeated TransactionHeader transactions = 7; + vm.ExecutionProof proof = 8; } // IES scheme used for transaction input encryption. diff --git a/proto/proto/types/vm.proto b/proto/proto/types/vm.proto new file mode 100644 index 0000000000..74cf248029 --- /dev/null +++ b/proto/proto/types/vm.proto @@ -0,0 +1,75 @@ +syntax = "proto3"; +package vm; + +import "types/primitives.proto"; + +enum ExecutionProofHashFunction { + EXECUTION_PROOF_HASH_FUNCTION_UNSPECIFIED = 0; + EXECUTION_PROOF_HASH_FUNCTION_BLAKE3_256 = 1; + EXECUTION_PROOF_HASH_FUNCTION_RPO_256 = 2; + EXECUTION_PROOF_HASH_FUNCTION_RPX_256 = 3; + EXECUTION_PROOF_HASH_FUNCTION_POSEIDON2 = 4; + EXECUTION_PROOF_HASH_FUNCTION_KECCAK = 5; +} + +message StarkProof { + bytes proof = 1; + ExecutionProofHashFunction hash_function = 2; +} + +message DeferredDataChunk { + // Exactly eight canonical field elements. + repeated primitives.Felt elements = 1; +} + +message DeferredData { + repeated DeferredDataChunk chunks = 1; +} + +message DeferredJoin { + fixed32 lhs = 1; + fixed32 rhs = 2; +} + +message DeferredIndexPair { + fixed32 lhs = 1; + fixed32 rhs = 2; +} + +message DeferredPairList { + repeated DeferredIndexPair pairs = 1; +} + +message DeferredWireEntry { + primitives.Word tag = 1; + + oneof entry { + DeferredData data = 2; + DeferredJoin join = 3; + DeferredPairList pair_list = 4; + } +} + +message DeferredStateWire { + repeated DeferredWireEntry entries = 1; +} + +message EmptyDeferredProof {} + +message DeferredStarkProof { + StarkProof proof = 1; + primitives.Word public_root = 2; +} + +message DeferredProof { + oneof proof { + EmptyDeferredProof empty = 1; + DeferredStateWire wire = 2; + DeferredStarkProof stark = 3; + } +} + +message ExecutionProof { + StarkProof miden = 1; + DeferredProof deferred = 2; +} From e97c41344b2cb3de383c7c9048b06f81d2d99676 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 12 Aug 2026 18:54:56 +0200 Subject: [PATCH 7/8] refactor(proto): keep proofs as serialized bytes Replace the structured execution-proof envelope in transaction and batch messages with canonical Miden-serialized byte fields. Restore the remote prover's generic proof-type and byte-payload API across its server and clients, remove the VM proof schema and conversions, and update tests and migration documentation. --- Cargo.lock | 2 - Cargo.toml | 1 - bin/benchmark/src/prover.rs | 25 +- bin/network-monitor/Cargo.toml | 1 - bin/network-monitor/src/remote_prover.rs | 8 +- bin/ntx-builder/src/clients/prover.rs | 25 +- bin/remote-prover/src/server/prove.rs | 14 +- bin/remote-prover/src/server/prover.rs | 147 +++++--- bin/remote-prover/src/server/tests.rs | 29 +- .../src/batch_builder/remote_prover.rs | 150 +++++++-- crates/block-producer/src/block_prover.rs | 22 +- crates/proto/Cargo.toml | 1 - crates/proto/build.rs | 6 - crates/proto/src/domain/batch.rs | 27 +- crates/proto/src/domain/mod.rs | 1 - crates/proto/src/domain/transaction.rs | 8 +- crates/proto/src/domain/vm.rs | 316 ------------------ crates/rpc/src/tests.rs | 6 +- proto/proto/remote_prover.proto | 34 +- proto/proto/types/transaction.proto | 7 +- proto/proto/types/vm.proto | 75 ----- 21 files changed, 312 insertions(+), 593 deletions(-) delete mode 100644 crates/proto/src/domain/vm.rs delete mode 100644 proto/proto/types/vm.proto diff --git a/Cargo.lock b/Cargo.lock index 3dad8d4871..e94dcca4fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4136,7 +4136,6 @@ dependencies = [ "miden-standards", "miden-testing", "miden-tx", - "prost", "rand 0.10.2", "rand_chacha 0.10.0", "reqwest", @@ -4241,7 +4240,6 @@ dependencies = [ "fs-err", "hex", "http 1.5.0", - "miden-core", "miden-node-grpc-error-macro", "miden-node-proto-build", "miden-node-utils", diff --git a/Cargo.toml b/Cargo.toml index 538b79a7d4..84139473d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,6 @@ miden-node-utils = { path = "crates/utils", version = "0.16.0-rc.1" } # miden-protocol dependencies. These should be updated in sync. miden-block-prover = { version = "=0.16.0-rc.3" } -miden-core = { default-features = false, version = "=0.29.0" } miden-protocol = { default-features = false, version = "=0.16.0-rc.3" } miden-standards = { version = "=0.16.0-rc.3" } miden-testing = { version = "=0.16.0-rc.3" } diff --git a/bin/benchmark/src/prover.rs b/bin/benchmark/src/prover.rs index b5aeb02f55..bfcd1c87f6 100644 --- a/bin/benchmark/src/prover.rs +++ b/bin/benchmark/src/prover.rs @@ -15,10 +15,10 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use miden_node_proto::clients::{Builder, RemoteProverClient}; -use miden_node_proto::generated::remote_prover::{ProofRequest, proof, proof_request}; +use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction, TransactionInputs}; -use miden_protocol::utils::serde::Serializable; +use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_tx::{LocalTransactionProver, TransactionProverError}; use tokio::sync::{Mutex, Semaphore}; use url::Url; @@ -199,26 +199,19 @@ impl RemoteTransactionProver { tx_inputs: &TransactionInputs, ) -> Result { let request = tonic::Request::new(ProofRequest { - request: Some(proof_request::Request::TransactionInputs(tx_inputs.to_bytes())), + proof_type: ProofType::Transaction.into(), + payload: tx_inputs.to_bytes(), }); let response = self.client.clone().prove(request).await.map_err(|err| { TransactionProverError::other_with_source("failed to prove transaction", err) })?; - match response.into_inner().result { - Some(proof::Result::ProvenTransaction(transaction)) => { - ProvenTransaction::try_from(transaction).map_err(|err| { - TransactionProverError::other_with_source( - "failed to decode received response from remote transaction prover", - err, - ) - }) - }, - _ => Err(TransactionProverError::other( - "remote transaction prover returned the wrong proof kind", - )), - } + ProvenTransaction::read_from_bytes(&response.into_inner().payload).map_err(|_| { + TransactionProverError::other( + "failed to deserialize received response from remote transaction prover", + ) + }) } } diff --git a/bin/network-monitor/Cargo.toml b/bin/network-monitor/Cargo.toml index cb7e4b8e71..88a41507f4 100644 --- a/bin/network-monitor/Cargo.toml +++ b/bin/network-monitor/Cargo.toml @@ -28,7 +28,6 @@ miden-node-utils = { workspace = true } miden-protocol = { features = ["std"], workspace = true } miden-standards = { workspace = true } miden-tx = { features = ["concurrent", "std"], workspace = true } -prost = { workspace = true } rand = { workspace = true } rand_chacha = { workspace = true } reqwest = { features = ["json", "query"], workspace = true } diff --git a/bin/network-monitor/src/remote_prover.rs b/bin/network-monitor/src/remote_prover.rs index b2d498b0a8..8b2a4d216b 100644 --- a/bin/network-monitor/src/remote_prover.rs +++ b/bin/network-monitor/src/remote_prover.rs @@ -15,7 +15,6 @@ use miden_node_proto::clients::{RemoteProverClient, RemoteProverProxyStatusClien use miden_node_proto::generated as proto; use miden_node_utils::tracing::miden_instrument; use miden_protocol::utils::serde::Serializable; -use prost::Message; use serde::{Deserialize, Serialize}; use tokio::sync::watch; use tokio::task::JoinHandle; @@ -416,7 +415,7 @@ async fn run_prover_test( state.latest = Some(ProverTestOutcome { details: ProverTestDetails { test_duration_ms: start.elapsed().as_millis() as u64, - proof_size_bytes: response.into_inner().encoded_len(), + proof_size_bytes: response.into_inner().payload.len(), success_count: state.success_count, failure_count: state.failure_count, proof_type: ProofType::Transaction, @@ -500,9 +499,8 @@ async fn generate_prover_test_payload( ) -> anyhow::Result { let tx_inputs = crate::deploy::build_probe_transaction_inputs(rpc_url).await?; Ok(proto::remote_prover::ProofRequest { - request: Some(proto::remote_prover::proof_request::Request::TransactionInputs( - tx_inputs.to_bytes(), - )), + proof_type: proto::remote_prover::ProofType::Transaction.into(), + payload: tx_inputs.to_bytes(), }) } diff --git a/bin/ntx-builder/src/clients/prover.rs b/bin/ntx-builder/src/clients/prover.rs index 6d1dd6068c..83bc65d2a3 100644 --- a/bin/ntx-builder/src/clients/prover.rs +++ b/bin/ntx-builder/src/clients/prover.rs @@ -1,9 +1,9 @@ use std::time::Duration; use miden_node_proto::clients::{Builder, RemoteProverClient}; -use miden_node_proto::generated::remote_prover::{ProofRequest, proof, proof_request}; +use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; use miden_protocol::transaction::{ProvenTransaction, TransactionInputs}; -use miden_protocol::utils::serde::Serializable; +use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_tx::TransactionProverError; use url::Url; @@ -36,25 +36,18 @@ impl RemoteTransactionProver { tx_inputs: &TransactionInputs, ) -> Result { let request = tonic::Request::new(ProofRequest { - request: Some(proof_request::Request::TransactionInputs(tx_inputs.to_bytes())), + proof_type: ProofType::Transaction.into(), + payload: tx_inputs.to_bytes(), }); let response = self.client.clone().prove(request).await.map_err(|err| { TransactionProverError::other_with_source("failed to prove transaction", err) })?; - match response.into_inner().result { - Some(proof::Result::ProvenTransaction(transaction)) => { - ProvenTransaction::try_from(transaction).map_err(|err| { - TransactionProverError::other_with_source( - "failed to decode received response from remote transaction prover", - err, - ) - }) - }, - _ => Err(TransactionProverError::other( - "remote transaction prover returned the wrong proof kind", - )), - } + ProvenTransaction::read_from_bytes(&response.into_inner().payload).map_err(|_| { + TransactionProverError::other( + "failed to deserialize received response from remote transaction prover", + ) + }) } } diff --git a/bin/remote-prover/src/server/prove.rs b/bin/remote-prover/src/server/prove.rs index e7f2967796..d23d7957f2 100644 --- a/bin/remote-prover/src/server/prove.rs +++ b/bin/remote-prover/src/server/prove.rs @@ -51,15 +51,13 @@ impl grpc::server::remote_prover_api::Prove for ProverService { } fn decode(request: grpc::remote_prover::ProofRequest) -> tonic::Result { - use grpc::remote_prover::proof_request::Request; + // Check that the proof type is supported. Protobuf enums return a default value if the enum + // is set to an unknown value. This round trip checks that the value is valid. + if request.proof_type() as i32 != request.proof_type { + return Err(tonic::Status::invalid_argument("unknown proof_type value")); + } - let proof_kind = match request.request.as_ref() { - Some(Request::TransactionInputs(_)) => ProofKind::Transaction, - Some(Request::ProposedBatch(_)) => ProofKind::Batch, - Some(Request::BlockProofRequest(_)) => ProofKind::Block, - None => return Err(tonic::Status::invalid_argument("missing proof request")), - }; - Ok((proof_kind, request)) + Ok((ProofKind::from(request.proof_type()), request)) } fn encode(output: Self::Output) -> tonic::Result { diff --git a/bin/remote-prover/src/server/prover.rs b/bin/remote-prover/src/server/prover.rs index 80a8457b70..f05b6120b4 100644 --- a/bin/remote-prover/src/server/prover.rs +++ b/bin/remote-prover/src/server/prover.rs @@ -1,14 +1,16 @@ use miden_block_prover::LocalBlockProver; use miden_node_proto::BlockProofRequest; -use miden_node_proto::domain::batch::decode_proposed_batch; use miden_node_proto::generated::remote_prover as proto; use miden_node_utils::ErrorReport; +use miden_node_utils::tracing::miden_instrument; use miden_protocol::MIN_PROOF_SECURITY_LEVEL; -use miden_protocol::transaction::TransactionInputs; -use miden_protocol::utils::serde::Deserializable; +use miden_protocol::batch::{ProposedBatch, ProvenBatch}; +use miden_protocol::block::BlockProof; +use miden_protocol::transaction::{ProvenTransaction, TransactionInputs}; use miden_tx::LocalTransactionProver; use miden_tx_batch::{BatchExecutor, LocalBatchProver}; +use crate::COMPONENT; use crate::server::proof_kind::ProofKind; /// An enum representing the different types of provers available. @@ -31,54 +33,11 @@ impl Prover { /// Proves a [`proto::ProofRequest`] using the appropriate prover implementation as specified /// during construction. pub fn prove(&self, request: proto::ProofRequest) -> Result { - use proto::proof::Result as ProofResult; - use proto::proof_request::Request; - - let result = match (self, request.request) { - (Self::Transaction(prover), Some(Request::TransactionInputs(bytes))) => { - let inputs = TransactionInputs::read_from_bytes(&bytes).map_err(|err| { - tonic::Status::invalid_argument( - err.as_report_context("failed to decode transaction inputs"), - ) - })?; - let transaction = prover.prove(inputs).map_err(|err| { - tonic::Status::internal(err.as_report_context("failed to prove transaction")) - })?; - ProofResult::ProvenTransaction(transaction.into()) - }, - (Self::Batch(prover), Some(Request::ProposedBatch(batch))) => { - let batch = decode_proposed_batch(batch, MIN_PROOF_SECURITY_LEVEL) - .map_err(tonic::Status::from)?; - let executed_batch = BatchExecutor::new().execute(batch).map_err(|err| { - tonic::Status::internal(err.as_report_context("failed to execute batch")) - })?; - let batch = prover.prove(executed_batch).map_err(|err| { - tonic::Status::internal(err.as_report_context("failed to prove batch")) - })?; - ProofResult::ProvenBatch(batch.into()) - }, - (Self::Block(prover), Some(Request::BlockProofRequest(bytes))) => { - let request = BlockProofRequest::read_from_bytes(&bytes).map_err(|err| { - tonic::Status::invalid_argument( - err.as_report_context("failed to decode block proof request"), - ) - })?; - let BlockProofRequest { tx_batches, block_header, block_inputs } = request; - let proof = - prover.prove(tx_batches, &block_header, block_inputs).map_err(|err| { - tonic::Status::internal(err.as_report_context("failed to prove block")) - })?; - ProofResult::BlockProof(proof.into()) - }, - (_, None) => return Err(tonic::Status::invalid_argument("missing proof request")), - _ => { - return Err(tonic::Status::invalid_argument( - "request kind does not match the configured prover", - )); - }, - }; - - Ok(proto::Proof { result: Some(result) }) + match self { + Prover::Transaction(prover) => prover.prove_request(request), + Prover::Batch(prover) => prover.prove_request(request), + Prover::Block(prover) => prover.prove_request(request), + } } /// Returns the context attached to failures of the blocking task running this prover. @@ -90,3 +49,89 @@ impl Prover { } } } + +/// This trait abstracts over proof request handling by providing a common interface for our +/// different provers. +/// +/// It standardizes the proving process by providing default implementations for the decoding of +/// requests, and encoding of response. Notably it also standardizes the instrumentation, though +/// implementations should still add attributes that can only be known post-decoding of the request. +/// +/// Implementations of this trait only need to provide the input and outputs types, as well as the +/// proof implementation. +trait ProveRequest: Send + Sync { + type Input: miden_protocol::utils::serde::Deserializable + Send; + type Output: miden_protocol::utils::serde::Serializable + Send; + + fn prove(&self, input: Self::Input) -> Result; + + /// Entry-point to the proof request handling. + /// + /// Decodes the request, proves it, and encodes the response. + #[miden_instrument( + target=COMPONENT, + name="prove", + err, + )] + fn prove_request(&self, request: proto::ProofRequest) -> Result { + let input = Self::decode_request(request)?; + self.prove(input).map(|output| Self::encode_response(output)) + } + + #[miden_instrument( + target=COMPONENT, + err, + )] + fn decode_request(request: proto::ProofRequest) -> Result { + use miden_protocol::utils::serde::Deserializable; + + Self::Input::read_from_bytes(&request.payload).map_err(|e| { + tonic::Status::invalid_argument(e.as_report_context("failed to decode request")) + }) + } + + #[miden_instrument( + target=COMPONENT, + )] + fn encode_response(output: Self::Output) -> proto::Proof { + use miden_protocol::utils::serde::Serializable; + + proto::Proof { payload: output.to_bytes() } + } +} + +impl ProveRequest for LocalTransactionProver { + type Input = TransactionInputs; + type Output = ProvenTransaction; + + fn prove(&self, input: Self::Input) -> Result { + self.prove(input).map_err(|e| { + tonic::Status::internal(e.as_report_context("failed to prove transaction")) + }) + } +} + +impl ProveRequest for LocalBatchProver { + type Input = ProposedBatch; + type Output = ProvenBatch; + + fn prove(&self, input: Self::Input) -> Result { + let executed_batch = BatchExecutor::new() + .execute(input) + .map_err(|e| tonic::Status::internal(e.as_report_context("failed to execute batch")))?; + self.prove(executed_batch) + .map_err(|e| tonic::Status::internal(e.as_report_context("failed to prove batch"))) + } +} + +impl ProveRequest for LocalBlockProver { + type Input = BlockProofRequest; + type Output = BlockProof; + + fn prove(&self, input: Self::Input) -> Result { + let BlockProofRequest { tx_batches, block_header, block_inputs } = input; + + self.prove(tx_batches, &block_header, block_inputs) + .map_err(|e| tonic::Status::internal(e.as_report_context("failed to prove block"))) + } +} diff --git a/bin/remote-prover/src/server/tests.rs b/bin/remote-prover/src/server/tests.rs index 568f50a12a..7b154ee198 100644 --- a/bin/remote-prover/src/server/tests.rs +++ b/bin/remote-prover/src/server/tests.rs @@ -4,18 +4,17 @@ use std::sync::Arc; use std::time::Duration; use assert_matches::assert_matches; -use miden_node_proto::domain::batch::decode_proven_batch; use miden_node_proto::generated::remote_prover::api_client::ApiClient; -use miden_node_proto::generated::remote_prover::{Proof, ProofRequest, proof, proof_request}; +use miden_node_proto::generated::remote_prover::{Proof, ProofRequest, ProofType}; use miden_node_utils::shutdown::CancellationToken; use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::account::auth::AuthScheme; use miden_protocol::asset::{Asset, FungibleAsset}; -use miden_protocol::batch::ProposedBatch; +use miden_protocol::batch::{ProposedBatch, ProvenBatch}; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::{ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_SENDER}; use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction, TransactionVerifier}; -use miden_protocol::utils::serde::Serializable; +use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_testing::{Auth, MockChainBuilder}; use miden_tx::LocalTransactionProver; use miden_tx_batch::BatchVerifier; @@ -57,13 +56,15 @@ impl ProofRequestExt for ProofRequest { let tx_inputs = tx.tx_inputs().clone(); ProofRequest { - request: Some(proof_request::Request::TransactionInputs(tx_inputs.to_bytes())), + proof_type: ProofType::Transaction as i32, + payload: tx_inputs.to_bytes(), } } fn from_batch(batch: &ProposedBatch) -> ProofRequest { ProofRequest { - request: Some(proof_request::Request::ProposedBatch(batch.into())), + proof_type: ProofType::Batch as i32, + payload: batch.to_bytes(), } } @@ -302,14 +303,14 @@ async fn invalid_proof_kind_is_rejected() { .expect("server should spawn"); let mut request = ProofRequest::from_tx(&ProofRequest::mock_tx().await); - request.request = Some(proof_request::Request::BlockProofRequest(Vec::new())); + request.proof_type = i32::MAX; let mut client = Client::connect(port).await; let response = client.submit_request(request).await; let err = response.unwrap_err(); assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert!(err.message().contains("unsupported proof type")); + assert!(err.message().contains("unknown proof_type value")); server.abort(); } @@ -356,12 +357,7 @@ async fn transaction_proof_is_correct() { let mut client = Client::connect(port).await; let response = client.submit_request(request).await.unwrap(); - let response = match response.result { - Some(proof::Result::ProvenTransaction(transaction)) => { - ProvenTransaction::try_from(transaction).unwrap() - }, - _ => panic!("transaction prover returned the wrong response kind"), - }; + let response = ProvenTransaction::read_from_bytes(&response.payload).unwrap(); assert_eq!(response.id(), tx.id()); TransactionVerifier::new(MIN_PROOF_SECURITY_LEVEL).verify(&response).unwrap(); @@ -386,10 +382,7 @@ async fn batch_proof_is_correct() { let mut client = Client::connect(port).await; let response = client.submit_request(request).await.unwrap(); - let response = match response.result { - Some(proof::Result::ProvenBatch(proven)) => decode_proven_batch(proven, &batch).unwrap(), - _ => panic!("batch prover returned the wrong response kind"), - }; + let response = ProvenBatch::read_from_bytes(&response.payload).unwrap(); assert_eq!(response.id(), batch.id()); BatchVerifier::new(MIN_PROOF_SECURITY_LEVEL).verify(&response).unwrap(); diff --git a/crates/block-producer/src/batch_builder/remote_prover.rs b/crates/block-producer/src/batch_builder/remote_prover.rs index 3177304243..50cc694648 100644 --- a/crates/block-producer/src/batch_builder/remote_prover.rs +++ b/crates/block-producer/src/batch_builder/remote_prover.rs @@ -1,11 +1,11 @@ +use std::sync::Arc; + use miden_node_proto::clients::{Builder, RemoteProverClient}; -use miden_node_proto::domain::batch::decode_proven_batch; -use miden_node_proto::errors::ConversionError; -use miden_node_proto::generated::remote_prover::{ProofRequest, proof, proof_request}; -use miden_node_utils::spawn::spawn_blocking_in_current_span; -use miden_protocol::MIN_PROOF_SECURITY_LEVEL; +use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; use miden_protocol::batch::{ProposedBatch, ProvenBatch}; -use miden_tx_batch::{BatchVerifier, LocalBatchProver}; +use miden_protocol::transaction::{OutputNote, ProvenTransaction}; +use miden_protocol::utils::serde::{Deserializable, DeserializationError, Serializable}; +use miden_tx_batch::LocalBatchProver; use url::Url; /// Errors returned by [`RemoteBatchProver`]. @@ -13,8 +13,8 @@ use url::Url; pub enum RemoteProverError { #[error("remote prover request failed")] Grpc(#[source] tonic::Status), - #[error("failed to decode proven batch from remote prover")] - Decode(#[source] ConversionError), + #[error("failed to deserialize proven batch from remote prover")] + Deserialize(#[source] DeserializationError), #[error("{0}")] Validation(String), } @@ -77,32 +77,122 @@ impl RemoteBatchProver { &self, proposed_batch: ProposedBatch, ) -> Result { + // Keep the set of transactions we passed in for later validation. + let proposed_txs: Vec<_> = proposed_batch.transactions().iter().map(Arc::clone).collect(); + let request = tonic::Request::new(ProofRequest { - request: Some(proof_request::Request::ProposedBatch((&proposed_batch).into())), + proof_type: ProofType::Batch.into(), + payload: proposed_batch.to_bytes(), }); let response = self.client.clone().prove(request).await.map_err(RemoteProverError::Grpc)?; - let batch = match response.into_inner().result { - Some(proof::Result::ProvenBatch(batch)) => { - decode_proven_batch(batch, &proposed_batch).map_err(RemoteProverError::Decode) - }, - _ => Err(RemoteProverError::Validation( - "remote batch prover returned the wrong proof kind".to_string(), - )), - }?; - - let batch_to_verify = batch.clone(); - spawn_blocking_in_current_span(move || { - BatchVerifier::new(MIN_PROOF_SECURITY_LEVEL) - .verify(&batch_to_verify) - .map_err(|err| RemoteProverError::Validation(err.to_string())) - }) - .await - .map_err(|err| { - RemoteProverError::Validation(format!("batch proof verification task failed: {err}")) - })??; - - Ok(batch) + let proven_batch = ProvenBatch::read_from_bytes(&response.into_inner().payload) + .map_err(RemoteProverError::Deserialize)?; + + Self::validate_tx_headers(&proven_batch, proposed_txs)?; + + Ok(proven_batch) + } + + /// Validates that the proven batch's transaction headers are consistent with the transactions + /// passed in the proposed batch. + /// + /// Note that we expect all input and output notes from a proposed transaction to be present + /// in the corresponding header as well, because note erasure doesn't matter for the transaction + /// itself and we want the original transaction data to be preserved. + /// + /// This expects that proposed transactions and batch transactions are in the same order, as + /// define by `OrderedTransactionHeaders`. + fn validate_tx_headers( + proven_batch: &ProvenBatch, + proposed_txs: Vec>, + ) -> Result<(), RemoteProverError> { + if proposed_txs.len() != proven_batch.transactions().as_slice().len() { + return Err(RemoteProverError::Validation(format!( + "remote prover returned {} transaction headers but {} transactions were passed as part of the proposed batch", + proven_batch.transactions().as_slice().len(), + proposed_txs.len() + ))); + } + + // Because we checked the length matches we can zip the iterators up. We expect the + // transactions to be in the same order. + for (proposed_header, proven_header) in + proposed_txs.into_iter().zip(proven_batch.transactions().as_slice()) + { + if proven_header.account_id() != proposed_header.account_id() { + return Err(RemoteProverError::Validation(format!( + "transaction header of {} has a different account ID than the proposed transaction", + proposed_header.id() + ))); + } + + if proven_header.initial_state_commitment() + != proposed_header.account_update().initial_state_commitment() + { + return Err(RemoteProverError::Validation(format!( + "transaction header of {} has a different initial state commitment than the proposed transaction", + proposed_header.id() + ))); + } + + if proven_header.final_state_commitment() + != proposed_header.account_update().final_state_commitment() + { + return Err(RemoteProverError::Validation(format!( + "transaction header of {} has a different final state commitment than the proposed transaction", + proposed_header.id() + ))); + } + + // Check input notes + let num_notes = proposed_header.input_notes().num_notes(); + if num_notes != proven_header.input_notes().num_notes() { + return Err(RemoteProverError::Validation(format!( + "transaction header of {} has a different number of input notes than the proposed transaction", + proposed_header.id() + ))); + } + + // Because we checked the length matches we can zip the iterators up. We expect the + // nullifiers to be in the same order. + for (proposed_nullifier, input_note_commitment) in + proposed_header.nullifiers().zip(proven_header.input_notes().iter()) + { + if proposed_nullifier != input_note_commitment.nullifier() { + return Err(RemoteProverError::Validation(format!( + "transaction header of {} has a different set of input notes than the proposed transaction", + proposed_header.id() + ))); + } + } + + // Check output notes + if proposed_header.output_notes().num_notes() != proven_header.output_notes().len() { + return Err(RemoteProverError::Validation(format!( + "transaction header of {} has a different number of output notes than the proposed transaction", + proposed_header.id() + ))); + } + + // Because we checked the length matches we can zip the iterators up. We expect the note + // IDs to be in the same order. + for (proposed_note_id, header_note) in proposed_header + .output_notes() + .iter() + .map(OutputNote::id) + .zip(proven_header.output_notes().iter()) + { + if proposed_note_id != header_note.id() { + return Err(RemoteProverError::Validation(format!( + "transaction header of {} has a different set of input notes than the proposed transaction", + proposed_header.id() + ))); + } + } + } + + Ok(()) } } diff --git a/crates/block-producer/src/block_prover.rs b/crates/block-producer/src/block_prover.rs index d7b26b6510..97a7a2f2a0 100644 --- a/crates/block-producer/src/block_prover.rs +++ b/crates/block-producer/src/block_prover.rs @@ -1,13 +1,12 @@ use miden_block_prover::{BlockProverError as LocalBlockProverError, LocalBlockProver}; use miden_node_proto::clients::{Builder, RemoteProverClient}; -use miden_node_proto::errors::ConversionError; -use miden_node_proto::generated::remote_prover::{ProofRequest, proof, proof_request}; +use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_node_utils::tracing::miden_instrument; use miden_protocol::batch::OrderedBatches; use miden_protocol::block::{BlockHeader, BlockInputs, BlockProof, ProposedBlock}; use miden_protocol::errors::ProposedBlockError; -use miden_protocol::utils::serde::Serializable; +use miden_protocol::utils::serde::{Deserializable, DeserializationError, Serializable}; use url::Url; use crate::COMPONENT; @@ -29,8 +28,8 @@ pub enum RemoteProverError { ProposeBlock(#[source] ProposedBlockError), #[error("remote prover request failed")] Grpc(#[source] tonic::Status), - #[error("failed to decode block proof from remote prover")] - Decode(#[source] ConversionError), + #[error("failed to deserialize block proof from remote prover")] + Deserialize(#[source] DeserializationError), } // BLOCK PROVER @@ -123,18 +122,13 @@ impl RemoteBlockProver { .map_err(RemoteProverError::ProposeBlock)?; let request = tonic::Request::new(ProofRequest { - request: Some(proof_request::Request::BlockProofRequest(proposed_block.to_bytes())), + proof_type: ProofType::Block.into(), + payload: proposed_block.to_bytes(), }); let response = self.client.clone().prove(request).await.map_err(RemoteProverError::Grpc)?; - match response.into_inner().result { - Some(proof::Result::BlockProof(proof)) => { - BlockProof::try_from(proof).map_err(RemoteProverError::Decode) - }, - _ => Err(RemoteProverError::Grpc(tonic::Status::internal( - "remote block prover returned the wrong proof kind", - ))), - } + BlockProof::read_from_bytes(&response.into_inner().payload) + .map_err(RemoteProverError::Deserialize) } } diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index 5c704f5bd0..e00ac65657 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -18,7 +18,6 @@ workspace = true anyhow = { workspace = true } hex = { workspace = true } http = { workspace = true } -miden-core = { workspace = true } miden-node-grpc-error-macro = { workspace = true } miden-node-utils = { workspace = true } miden-protocol = { workspace = true } diff --git a/crates/proto/build.rs b/crates/proto/build.rs index f5fff3e428..ba8d71fe22 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -59,12 +59,6 @@ fn main() -> miette::Result<()> { fn generate_bindings(file_descriptors: &FileDescriptorSet, dst_dir: &Path) -> miette::Result<()> { let mut prost_config = tonic_prost_build::Config::new(); prost_config.skip_debug(["AccountId", "Digest"]); - prost_config.type_attribute( - ".remote_prover.ProofRequest.request", - "#[allow(clippy::large_enum_variant)]", - ); - prost_config - .type_attribute(".remote_prover.Proof.result", "#[allow(clippy::large_enum_variant)]"); // Generate the stub of the user facing server from its proto file tonic_prost_build::configure() diff --git a/crates/proto/src/domain/batch.rs b/crates/proto/src/domain/batch.rs index 9abed811c8..7633707bba 100644 --- a/crates/proto/src/domain/batch.rs +++ b/crates/proto/src/domain/batch.rs @@ -14,6 +14,8 @@ use miden_protocol::transaction::{ ProvenTransaction, TransactionHeader, }; +use miden_protocol::utils::serde::Deserializable; +use miden_protocol::vm::ExecutionProof; use crate::decode::{ConversionResultExt, GrpcDecodeExt}; use crate::errors::ConversionError; @@ -122,7 +124,7 @@ impl From<&ProvenBatch> for proto::transaction::ProvenBatch { output_notes: value.output_notes().iter().map(Into::into).collect(), expiration_block_num: value.batch_expiration_block_num().as_u32(), transactions: value.transactions().as_slice().iter().map(Into::into).collect(), - proof: Some(value.proof().into()), + proof: value.proof().to_bytes(), } } } @@ -250,7 +252,9 @@ pub fn decode_proven_batch( .context("transactions")); } - let proof = decode!(decoder, value.proof)?; + let proof = ExecutionProof::read_from_bytes(&value.proof) + .map_err(|source| ConversionError::deserialization("ExecutionProof", source)) + .context("proof")?; ProvenBatch::new_unchecked( proposed.id(), expected_header.commitment(), @@ -353,6 +357,25 @@ mod tests { let encoded = proto::transaction::ProvenBatch::from(&proven); assert_eq!(decode_proven_batch(encoded.clone(), &proposed).unwrap(), proven); + let mut malformed_proof = encoded.clone(); + malformed_proof.proof = vec![0xff]; + assert!( + decode_proven_batch(malformed_proof, &proposed) + .unwrap_err() + .to_string() + .contains("proof") + ); + + let mut malformed_transaction = + proto::transaction::ProvenTransactionData::from(proposed.transactions()[0].as_ref()); + malformed_transaction.proof = vec![0xff]; + assert!( + ProvenTransaction::try_from(malformed_transaction) + .unwrap_err() + .to_string() + .contains("proof") + ); + let mut wrong_reference = encoded.clone(); wrong_reference.reference_block_num += 1; assert!( diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index 1db113d539..aa5c696f45 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -10,7 +10,6 @@ pub mod nullifier; pub mod primitives; pub mod proof_request; pub mod transaction; -pub mod vm; // UTILITIES // ================================================================================================ diff --git a/crates/proto/src/domain/transaction.rs b/crates/proto/src/domain/transaction.rs index 654068c10f..dc6f76d16e 100644 --- a/crates/proto/src/domain/transaction.rs +++ b/crates/proto/src/domain/transaction.rs @@ -13,6 +13,8 @@ use miden_protocol::transaction::{ TransactionId, TxAccountUpdate, }; +use miden_protocol::utils::serde::Deserializable; +use miden_protocol::vm::ExecutionProof; use crate::decode::{ConversionResultExt, GrpcDecodeExt}; use crate::errors::ConversionError; @@ -70,7 +72,7 @@ impl From<&ProvenTransaction> for proto::transaction::ProvenTransactionData { reference_block_num: value.ref_block_num().as_u32(), reference_block_commitment: Some(value.ref_block_commitment().into()), expiration_block_num: value.expiration_block_num().as_u32(), - proof: Some(value.proof().into()), + proof: value.proof().to_bytes(), } } } @@ -104,7 +106,9 @@ impl TryFrom for ProvenTransaction { }) .collect::, _>>()?; let reference_block_commitment = decode!(decoder, value.reference_block_commitment)?; - let proof = decode!(decoder, value.proof)?; + let proof = ExecutionProof::read_from_bytes(&value.proof) + .map_err(|source| ConversionError::deserialization("ExecutionProof", source)) + .context("proof")?; Self::new( account_update, diff --git a/crates/proto/src/domain/vm.rs b/crates/proto/src/domain/vm.rs deleted file mode 100644 index 68c547ee06..0000000000 --- a/crates/proto/src/domain/vm.rs +++ /dev/null @@ -1,316 +0,0 @@ -use miden_core::deferred::{DeferredStateWire, Tag, WireEntry}; -use miden_core::proof::{DeferredProof, ExecutionProof, HashFunction, StarkProof}; -use miden_protocol::{Felt, Word}; - -use crate::decode::{ConversionResultExt, GrpcDecodeExt}; -use crate::errors::ConversionError; -use crate::{decode, generated as proto}; - -impl From for proto::vm::ExecutionProofHashFunction { - fn from(value: HashFunction) -> Self { - match value { - HashFunction::Blake3_256 => Self::Blake3256, - HashFunction::Rpo256 => Self::Rpo256, - HashFunction::Rpx256 => Self::Rpx256, - HashFunction::Poseidon2 => Self::Poseidon2, - HashFunction::Keccak => Self::Keccak, - } - } -} - -fn decode_hash_function(value: i32) -> Result { - match proto::vm::ExecutionProofHashFunction::try_from(value) { - Ok(proto::vm::ExecutionProofHashFunction::Blake3256) => Ok(HashFunction::Blake3_256), - Ok(proto::vm::ExecutionProofHashFunction::Rpo256) => Ok(HashFunction::Rpo256), - Ok(proto::vm::ExecutionProofHashFunction::Rpx256) => Ok(HashFunction::Rpx256), - Ok(proto::vm::ExecutionProofHashFunction::Poseidon2) => Ok(HashFunction::Poseidon2), - Ok(proto::vm::ExecutionProofHashFunction::Keccak) => Ok(HashFunction::Keccak), - Ok(proto::vm::ExecutionProofHashFunction::Unspecified) => { - Err(ConversionError::message("execution proof hash function is unspecified")) - }, - Err(_) => Err(ConversionError::message(format!( - "unknown execution proof hash function value {value}" - ))), - } -} - -impl From<&StarkProof> for proto::vm::StarkProof { - fn from(value: &StarkProof) -> Self { - Self { - proof: value.bytes().to_vec(), - hash_function: proto::vm::ExecutionProofHashFunction::from(value.hash_fn()) as i32, - } - } -} - -impl From for proto::vm::StarkProof { - fn from(value: StarkProof) -> Self { - let (proof, hash_function) = value.into_parts(); - Self { - proof, - hash_function: proto::vm::ExecutionProofHashFunction::from(hash_function) as i32, - } - } -} - -impl TryFrom for StarkProof { - type Error = ConversionError; - - fn try_from(value: proto::vm::StarkProof) -> Result { - let hash_function = decode_hash_function(value.hash_function).context("hash_function")?; - Ok(Self::new(value.proof, hash_function)) - } -} - -fn encode_wire_entry(entry: &WireEntry) -> proto::vm::DeferredWireEntry { - use proto::vm::deferred_wire_entry::Entry; - - let (tag, entry) = match entry { - WireEntry::Data { tag, chunks } => { - let chunks = chunks - .iter() - .map(|chunk| proto::vm::DeferredDataChunk { - elements: chunk.iter().map(Into::into).collect(), - }) - .collect(); - ( - Word::new((*tag).as_word()).into(), - Entry::Data(proto::vm::DeferredData { chunks }), - ) - }, - WireEntry::Join { tag, lhs, rhs } => ( - Word::new((*tag).as_word()).into(), - Entry::Join(proto::vm::DeferredJoin { lhs: *lhs, rhs: *rhs }), - ), - WireEntry::PairList { tag, pairs } => { - let pairs = pairs - .iter() - .map(|(lhs, rhs)| proto::vm::DeferredIndexPair { lhs: *lhs, rhs: *rhs }) - .collect(); - ( - Word::new((*tag).as_word()).into(), - Entry::PairList(proto::vm::DeferredPairList { pairs }), - ) - }, - }; - - proto::vm::DeferredWireEntry { tag: Some(tag), entry: Some(entry) } -} - -fn decode_wire_entry( - value: proto::vm::DeferredWireEntry, - index: usize, -) -> Result { - use proto::vm::deferred_wire_entry::Entry; - - let decoder = value.decoder(); - let tag: Word = decode!(decoder, value.tag)?; - let tag = Tag::from_word(tag.into_elements()); - let entry = value - .entry - .ok_or_else(|| ConversionError::missing_field::("entry"))?; - - let max_child = u32::try_from(index) - .map_err(|_| ConversionError::message("too many deferred wire entries"))?; - let validate_child = |child: u32, field: &str| { - if child > max_child { - Err(ConversionError::message(format!( - "child index {child} must refer to TRUE or an earlier entry" - )) - .context(field)) - } else { - Ok(()) - } - }; - - match entry { - Entry::Data(data) => { - if data.chunks.is_empty() { - return Err(ConversionError::message("data entry must contain at least one chunk") - .context("data.chunks")); - } - let chunks = data - .chunks - .into_iter() - .enumerate() - .map(|(chunk_index, chunk)| { - if chunk.elements.len() != 8 { - return Err(ConversionError::message(format!( - "deferred data chunk must contain exactly 8 elements, got {}", - chunk.elements.len() - )) - .context(format!("data.chunks[{chunk_index}].elements"))); - } - let elements = chunk - .elements - .into_iter() - .enumerate() - .map(|(element_index, element)| { - Felt::try_from(element).context(format!("elements[{element_index}]")) - }) - .collect::, _>>()?; - elements.try_into().map_err(|_| { - ConversionError::message("deferred data chunk has invalid length") - }) - }) - .collect::, _>>()?; - Ok(WireEntry::Data { tag, chunks }) - }, - Entry::Join(join) => { - validate_child(join.lhs, "join.lhs")?; - validate_child(join.rhs, "join.rhs")?; - Ok(WireEntry::Join { tag, lhs: join.lhs, rhs: join.rhs }) - }, - Entry::PairList(pair_list) => { - if pair_list.pairs.is_empty() { - return Err(ConversionError::message( - "pair-list entry must contain at least one pair", - ) - .context("pair_list.pairs")); - } - let pairs = pair_list - .pairs - .into_iter() - .enumerate() - .map(|(pair_index, pair)| { - validate_child(pair.lhs, &format!("pair_list.pairs[{pair_index}].lhs"))?; - validate_child(pair.rhs, &format!("pair_list.pairs[{pair_index}].rhs"))?; - Ok((pair.lhs, pair.rhs)) - }) - .collect::, ConversionError>>()?; - Ok(WireEntry::PairList { tag, pairs }) - }, - } -} - -impl From<&DeferredProof> for proto::vm::DeferredProof { - fn from(value: &DeferredProof) -> Self { - use proto::vm::deferred_proof::Proof; - - let proof = match value { - DeferredProof::Empty => Proof::Empty(proto::vm::EmptyDeferredProof {}), - DeferredProof::Wire(wire) => Proof::Wire(proto::vm::DeferredStateWire { - entries: wire.entries.iter().map(encode_wire_entry).collect(), - }), - DeferredProof::Stark { proof, public_root } => { - Proof::Stark(proto::vm::DeferredStarkProof { - proof: Some(proof.into()), - public_root: Some(public_root.into()), - }) - }, - }; - Self { proof: Some(proof) } - } -} - -impl TryFrom for DeferredProof { - type Error = ConversionError; - - fn try_from(value: proto::vm::DeferredProof) -> Result { - use proto::vm::deferred_proof::Proof; - - match value.proof { - Some(Proof::Empty(_)) => Ok(Self::Empty), - Some(Proof::Wire(wire)) => { - let entries = wire - .entries - .into_iter() - .enumerate() - .map(|(index, entry)| { - decode_wire_entry(entry, index).context(format!("wire.entries[{index}]")) - }) - .collect::, _>>()?; - Ok(Self::Wire(DeferredStateWire { entries })) - }, - Some(Proof::Stark(stark)) => { - let decoder = stark.decoder(); - let proof = decode!(decoder, stark.proof)?; - let public_root = decode!(decoder, stark.public_root)?; - Ok(Self::Stark { proof, public_root }) - }, - None => Err(ConversionError::missing_field::("proof")), - } - } -} - -impl From<&ExecutionProof> for proto::vm::ExecutionProof { - fn from(value: &ExecutionProof) -> Self { - Self { - miden: Some(value.miden_proof().into()), - deferred: Some(value.deferred_proof().into()), - } - } -} - -impl From for proto::vm::ExecutionProof { - fn from(value: ExecutionProof) -> Self { - Self::from(&value) - } -} - -impl TryFrom for ExecutionProof { - type Error = ConversionError; - - fn try_from(value: proto::vm::ExecutionProof) -> Result { - let decoder = value.decoder(); - let miden = decode!(decoder, value.miden)?; - let deferred = decode!(decoder, value.deferred)?; - Ok(Self::new(miden, deferred)) - } -} - -#[cfg(test)] -mod tests { - use miden_core::deferred::{DeferredStateWire, Tag, WireEntry}; - use miden_core::proof::{DeferredProof, ExecutionProof, HashFunction, StarkProof}; - use miden_protocol::{Felt, Word}; - - use crate::generated as proto; - - #[test] - fn execution_proof_roundtrips_all_variants() { - let hashes = [ - HashFunction::Blake3_256, - HashFunction::Rpo256, - HashFunction::Rpx256, - HashFunction::Poseidon2, - HashFunction::Keccak, - ]; - for hash in hashes { - let proofs = [ - ExecutionProof::new(StarkProof::new(vec![1, 2, 3], hash), DeferredProof::Empty), - ExecutionProof::new( - StarkProof::new(vec![4], hash), - DeferredProof::Wire(DeferredStateWire { - entries: vec![WireEntry::Data { - tag: Tag::from_word(Word::from([3_u32, 4, 5, 6]).into_elements()), - chunks: vec![[Felt::new_unchecked(7); 8]], - }], - }), - ), - ExecutionProof::new( - StarkProof::new(vec![], hash), - DeferredProof::Stark { - proof: StarkProof::new(vec![8, 9], HashFunction::Poseidon2), - public_root: Word::from([10_u32, 11, 12, 13]), - }, - ), - ]; - for proof in proofs { - let encoded = proto::vm::ExecutionProof::from(&proof); - assert_eq!(ExecutionProof::try_from(encoded).unwrap(), proof); - } - } - } - - #[test] - fn rejects_unspecified_and_unknown_hash_functions() { - for hash_function in [0, 99] { - let error = - StarkProof::try_from(proto::vm::StarkProof { proof: Vec::new(), hash_function }) - .unwrap_err() - .to_string(); - assert!(error.contains("hash function")); - } - } -} diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index e14879d17b..10a9a92d6f 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -263,7 +263,7 @@ fn rpc_descriptor_exposes_structured_blockchain_schema_and_reserves_legacy_field } #[test] -fn rpc_descriptor_exposes_structured_transaction_and_proof_schema() { +fn rpc_descriptor_exposes_structured_transaction_and_batch_schema() { let descriptor = miden_node_proto_build::rpc_api_descriptor(); let transaction_file = descriptor .file @@ -314,10 +314,6 @@ fn rpc_descriptor_exposes_structured_transaction_and_proof_schema() { } for (file_name, messages) in [ - ( - "types/vm.proto", - &["StarkProof", "DeferredProof", "DeferredStateWire", "ExecutionProof"][..], - ), ("types/partial_blockchain.proto", &["TrackedMmrLeaf", "PartialBlockchain"][..]), ("types/block_header.proto", &["BlockHeader"][..]), ] { diff --git a/proto/proto/remote_prover.proto b/proto/proto/remote_prover.proto index e5605f9fc8..28a0ad485a 100644 --- a/proto/proto/remote_prover.proto +++ b/proto/proto/remote_prover.proto @@ -3,8 +3,6 @@ syntax = "proto3"; package remote_prover; import "google/protobuf/empty.proto"; -import "types/blockchain.proto"; -import "types/transaction.proto"; // PROVER SERVICE // ================================================================================================ @@ -28,28 +26,24 @@ enum ProofType { // Request message for proof generation containing payload and proof type metadata. message ProofRequest { - reserved 1, 2; - reserved "proof_type", "payload"; - - oneof request { - // Canonically serialized TransactionInputs, deferred to a later migration. - bytes transaction_inputs = 3; - transaction.ProposedBatch proposed_batch = 4; - // Canonically serialized BlockProofRequest, deferred to block-prover API work. - bytes block_proof_request = 5; - } + // Type of proof being requested, determines payload interpretation + ProofType proof_type = 1; + + // Serialized payload requiring proof generation. The encoding format is + // type-specific: + // - TRANSACTION: TransactionInputs encoded. + // - BATCH: ProposedBatch encoded. + // - BLOCK: BlockProofRequest encoded. + bytes payload = 2; } // Response message containing the generated proof. message Proof { - reserved 1; - reserved "payload"; - - oneof result { - transaction.ProvenTransactionData proven_transaction = 2; - transaction.ProvenBatch proven_batch = 3; - blockchain.BlockProof block_proof = 4; - } + // Serialized proof bytes. + // - TRANSACTION: Returns an encoded ProvenTransaction. + // - BATCH: Returns an encoded ProvenBatch. + // - BLOCK: Returns an encoded BlockProof. + bytes payload = 1; } // PROXY STATUS SERVICE diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index d08e7dabf4..0db5c06ff5 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -6,7 +6,6 @@ import "types/block_header.proto"; import "types/note.proto"; import "types/partial_blockchain.proto"; import "types/primitives.proto"; -import "types/vm.proto"; // TRANSACTION // ================================================================================================ @@ -92,7 +91,8 @@ message ProvenTransactionData { fixed32 reference_block_num = 4; primitives.Word reference_block_commitment = 5; fixed32 expiration_block_num = 6; - vm.ExecutionProof proof = 7; + // Canonically serialized miden_protocol::vm::ExecutionProof. + bytes proof = 7; } message ProposedBatch { @@ -117,7 +117,8 @@ message ProvenBatch { repeated OutputNote output_notes = 5; fixed32 expiration_block_num = 6; repeated TransactionHeader transactions = 7; - vm.ExecutionProof proof = 8; + // Canonically serialized miden_protocol::vm::ExecutionProof. + bytes proof = 8; } // IES scheme used for transaction input encryption. diff --git a/proto/proto/types/vm.proto b/proto/proto/types/vm.proto deleted file mode 100644 index 74cf248029..0000000000 --- a/proto/proto/types/vm.proto +++ /dev/null @@ -1,75 +0,0 @@ -syntax = "proto3"; -package vm; - -import "types/primitives.proto"; - -enum ExecutionProofHashFunction { - EXECUTION_PROOF_HASH_FUNCTION_UNSPECIFIED = 0; - EXECUTION_PROOF_HASH_FUNCTION_BLAKE3_256 = 1; - EXECUTION_PROOF_HASH_FUNCTION_RPO_256 = 2; - EXECUTION_PROOF_HASH_FUNCTION_RPX_256 = 3; - EXECUTION_PROOF_HASH_FUNCTION_POSEIDON2 = 4; - EXECUTION_PROOF_HASH_FUNCTION_KECCAK = 5; -} - -message StarkProof { - bytes proof = 1; - ExecutionProofHashFunction hash_function = 2; -} - -message DeferredDataChunk { - // Exactly eight canonical field elements. - repeated primitives.Felt elements = 1; -} - -message DeferredData { - repeated DeferredDataChunk chunks = 1; -} - -message DeferredJoin { - fixed32 lhs = 1; - fixed32 rhs = 2; -} - -message DeferredIndexPair { - fixed32 lhs = 1; - fixed32 rhs = 2; -} - -message DeferredPairList { - repeated DeferredIndexPair pairs = 1; -} - -message DeferredWireEntry { - primitives.Word tag = 1; - - oneof entry { - DeferredData data = 2; - DeferredJoin join = 3; - DeferredPairList pair_list = 4; - } -} - -message DeferredStateWire { - repeated DeferredWireEntry entries = 1; -} - -message EmptyDeferredProof {} - -message DeferredStarkProof { - StarkProof proof = 1; - primitives.Word public_root = 2; -} - -message DeferredProof { - oneof proof { - EmptyDeferredProof empty = 1; - DeferredStateWire wire = 2; - DeferredStarkProof stark = 3; - } -} - -message ExecutionProof { - StarkProof miden = 1; - DeferredProof deferred = 2; -} From 7e1447086d71d4c26477f74179f3fce47dc1bb94 Mon Sep 17 00:00:00 2001 From: KOVACS Krisztian Date: Wed, 12 Aug 2026 22:53:07 +0200 Subject: [PATCH 8/8] refactor(proto): refactor validator proposed blocks to structured protobuf --- .../server/validator_service/sign_block.rs | 10 +- .../src/server/validator_service/tests.rs | 72 ++++- .../block-producer/src/block_builder/mod.rs | 2 +- crates/block-producer/src/validator/mod.rs | 10 +- crates/proto/Cargo.toml | 2 +- crates/proto/src/domain/batch.rs | 184 ++++++++++++- crates/proto/src/domain/mod.rs | 1 + crates/proto/src/domain/validator.rs | 254 ++++++++++++++++++ crates/rpc/src/tests.rs | 2 +- proto/proto/internal/validator.proto | 40 ++- proto/proto/types/blockchain.proto | 7 - 11 files changed, 552 insertions(+), 32 deletions(-) create mode 100644 crates/proto/src/domain/validator.rs diff --git a/bin/validator/src/server/validator_service/sign_block.rs b/bin/validator/src/server/validator_service/sign_block.rs index 75356659e7..1a203db6a2 100644 --- a/bin/validator/src/server/validator_service/sign_block.rs +++ b/bin/validator/src/server/validator_service/sign_block.rs @@ -5,7 +5,7 @@ use miden_node_utils::ErrorReport; use miden_protocol::Word; use miden_protocol::block::{BlockNumber, ProposedBlock}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; -use miden_tx::utils::serde::{Deserializable, Serializable}; +use miden_tx::utils::serde::Serializable; use super::ValidatorService; @@ -14,11 +14,9 @@ impl grpc::server::validator_api::SignBlock for ValidatorService { type Input = ProposedBlock; type Output = (Signature, Word, PublicKey); - fn decode(request: grpc::blockchain::ProposedBlock) -> tonic::Result { - ProposedBlock::read_from_bytes(&request.proposed_block).map_err(|err| { - tonic::Status::invalid_argument( - err.as_report_context("Failed to deserialize proposed block"), - ) + fn decode(request: grpc::validator::ProposedBlock) -> tonic::Result { + ProposedBlock::try_from(request).map_err(|err| { + tonic::Status::invalid_argument(err.as_report_context("Invalid proposed block")) }) } diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 87b85c2169..a361e7857f 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -144,9 +144,20 @@ impl TestValidator { &self, proposed_block: &ProposedBlock, ) -> Result { - let request = tonic::Request::new(proto::blockchain::ProposedBlock { - proposed_block: proposed_block.to_bytes(), - }); + // All proposals submitted through this helper are empty, so their original inputs contain + // only the parent header and partial blockchain. Tests exercising non-empty proposals call + // `validate_block` directly. + let block_inputs = BlockInputs::new( + proposed_block.prev_block_header().clone(), + proposed_block.partial_blockchain().clone(), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + ); + let request = tonic::Request::new(proto::validator::ProposedBlock::from(( + proposed_block, + &block_inputs, + ))); validator_api::SignBlock::full(&self.server, request).await } @@ -379,6 +390,61 @@ async fn proven_transaction_fixture() -> &'static ProvenTransactionFixture { // TESTS // ================================================================================================ +#[test] +fn validator_descriptor_exposes_structured_proposed_block_schema() { + let descriptor = miden_node_proto_build::validator_api_descriptor(); + let validator_file = descriptor + .file + .iter() + .find(|file| file.name().ends_with("validator.proto")) + .expect("the validator descriptor should include validator.proto"); + + for name in ["ProposedBlock", "BlockInputs", "NullifierWitness"] { + assert!( + validator_file.message_type.iter().any(|message| message.name() == name), + "the validator descriptor should expose validator.{name}" + ); + } + + let proposed_block = validator_file + .message_type + .iter() + .find(|message| message.name() == "ProposedBlock") + .expect("validator.ProposedBlock should be present"); + assert!(proposed_block.reserved_name.iter().any(|name| name == "proposed_block")); + assert!( + proposed_block + .reserved_range + .iter() + .any(|range| range.start() <= 1 && range.end() > 1) + ); + assert!(!proposed_block.field.iter().any(|field| field.name() == "proposed_block")); + + let api = validator_file + .service + .iter() + .find(|service| service.name() == "Api") + .expect("validator.Api should be present"); + let sign_block = api + .method + .iter() + .find(|method| method.name() == "SignBlock") + .expect("validator.Api.SignBlock should be present"); + assert_eq!(sign_block.input_type(), ".validator.ProposedBlock"); + + let blockchain_file = descriptor + .file + .iter() + .find(|file| file.name() == "types/blockchain.proto") + .expect("the validator descriptor should include types/blockchain.proto"); + assert!( + !blockchain_file + .message_type + .iter() + .any(|message| message.name() == "ProposedBlock") + ); +} + /// A validator whose signing key does not match the `validator_key` designated by the chain /// (carried forward from genesis) must fail to start, rather than coming up and silently producing /// signatures that the block producer cannot verify. diff --git a/crates/block-producer/src/block_builder/mod.rs b/crates/block-producer/src/block_builder/mod.rs index b95c5d9295..8742c26797 100644 --- a/crates/block-producer/src/block_builder/mod.rs +++ b/crates/block-producer/src/block_builder/mod.rs @@ -296,7 +296,7 @@ impl BlockBuilder { }); let responses = self .validator - .sign_block(proposed_block.clone()) + .sign_block(&proposed_block, &block_inputs) .await .map_err(|err| BuildBlockError::ValidateBlockFailed(err.into()))?; let (header, body) = build_result diff --git a/crates/block-producer/src/validator/mod.rs b/crates/block-producer/src/validator/mod.rs index 1fdf49498e..3be28b3200 100644 --- a/crates/block-producer/src/validator/mod.rs +++ b/crates/block-producer/src/validator/mod.rs @@ -6,9 +6,8 @@ use miden_node_proto::errors::ConversionError; use miden_node_proto::{decode, generated as proto}; use miden_node_utils::tracing::miden_instrument; use miden_protocol::Word; -use miden_protocol::block::ProposedBlock; +use miden_protocol::block::{BlockInputs, ProposedBlock}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; -use miden_protocol::utils::serde::Serializable; use thiserror::Error; use tracing::info; use url::Url; @@ -87,11 +86,10 @@ impl BlockProducerValidatorClient { )] pub async fn sign_block( &self, - proposed_block: ProposedBlock, + proposed_block: &ProposedBlock, + block_inputs: &BlockInputs, ) -> Result, ValidatorError> { - let message = proto::blockchain::ProposedBlock { - proposed_block: proposed_block.to_bytes(), - }; + let message = proto::validator::ProposedBlock::from((proposed_block, block_inputs)); let responses = futures::future::try_join_all(self.clients.iter().map(|client| { let mut client = client.clone(); diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index e00ac65657..22cad1cebe 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -20,7 +20,7 @@ hex = { workspace = true } http = { workspace = true } miden-node-grpc-error-macro = { workspace = true } miden-node-utils = { workspace = true } -miden-protocol = { workspace = true } +miden-protocol = { features = ["testing"], workspace = true } miden-standards = { workspace = true } prost = { workspace = true } rand = { workspace = true } diff --git a/crates/proto/src/domain/batch.rs b/crates/proto/src/domain/batch.rs index 7633707bba..dee6753a0f 100644 --- a/crates/proto/src/domain/batch.rs +++ b/crates/proto/src/domain/batch.rs @@ -1,21 +1,28 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; -use miden_protocol::Word; -use miden_protocol::account::{AccountId, AccountUpdateDetails}; -use miden_protocol::batch::{BatchAccountUpdate, ProposedBatch, ProvenBatch}; +use miden_protocol::account::{Account, AccountId, AccountUpdateDetails}; +use miden_protocol::batch::{BatchAccountUpdate, BatchId, ProposedBatch, ProvenBatch}; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::note::{NoteId, NoteInclusionProof}; use miden_protocol::transaction::{ InputNoteCommitment, + InputNotes, OrderedTransactionHeaders, OutputNote, PartialBlockchain, ProvenTransaction, TransactionHeader, }; -use miden_protocol::utils::serde::Deserializable; +use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::vm::ExecutionProof; +use miden_protocol::{ + ACCOUNT_UPDATE_MAX_SIZE, + MAX_ACCOUNTS_PER_BATCH, + MAX_INPUT_NOTES_PER_BATCH, + MAX_OUTPUT_NOTES_PER_BATCH, + Word, +}; use crate::decode::{ConversionResultExt, GrpcDecodeExt}; use crate::errors::ConversionError; @@ -157,6 +164,144 @@ impl TryFrom for BatchAccountUpdateProje } } +impl BatchAccountUpdateProjection { + fn into_domain(self) -> Result { + if self.details.get_size_hint() > ACCOUNT_UPDATE_MAX_SIZE as usize { + return Err(ConversionError::message("account update exceeds the size limit")); + } + + match (&self.details, self.account_id.is_private()) { + (AccountUpdateDetails::Private, true) => {}, + (AccountUpdateDetails::Public(_), true) => { + return Err(ConversionError::message( + "private account update must not reveal public details", + )); + }, + (AccountUpdateDetails::Private, false) => { + return Err(ConversionError::message( + "public account update must include public details", + )); + }, + (AccountUpdateDetails::Public(patch), false) => { + if patch.id() != self.account_id { + return Err(ConversionError::message( + "public account patch ID does not match account ID", + )); + } + if self.initial_state_commitment.is_empty() { + let account = Account::try_from(patch).map_err(ConversionError::new)?; + if account.to_commitment() != self.final_state_commitment { + return Err(ConversionError::message( + "new public account commitment does not match its full-state patch", + )); + } + } + }, + } + + Ok(BatchAccountUpdate::new_unchecked( + self.account_id, + self.initial_state_commitment, + self.final_state_commitment, + self.details, + )) + } +} + +/// Decodes a proven batch without a proposal, for the internal proposed-block API. +/// +/// This validates every invariant available from the transmitted batch. Cryptographic proof +/// verification remains the responsibility of the service boundary. +pub fn decode_standalone_proven_batch( + value: proto::transaction::ProvenBatch, +) -> Result { + let decoder = value.decoder(); + let reference_block_commitment = decode!(decoder, value.reference_block_commitment)?; + + if value.account_updates.len() > MAX_ACCOUNTS_PER_BATCH { + return Err(ConversionError::message("too many account updates").context("account_updates")); + } + let mut account_updates = BTreeMap::new(); + let mut previous_account_id = None; + for (index, update) in value.account_updates.into_iter().enumerate() { + let projection = BatchAccountUpdateProjection::try_from(update) + .context(format!("account_updates[{index}]"))?; + if previous_account_id.is_some_and(|previous| projection.account_id <= previous) { + return Err(ConversionError::message( + "account updates must have unique, ascending account IDs", + ) + .context(format!("account_updates[{index}].account_id"))); + } + previous_account_id = Some(projection.account_id); + let update = projection.into_domain().context(format!("account_updates[{index}]"))?; + account_updates.insert(update.account_id(), update); + } + + if value.input_notes.len() > MAX_INPUT_NOTES_PER_BATCH { + return Err(ConversionError::message("too many input notes").context("input_notes")); + } + let input_notes = value + .input_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + InputNoteCommitment::try_from(note).context(format!("input_notes[{index}]")) + }) + .collect::, _>>()?; + let mut nullifiers = BTreeSet::new(); + for (index, note) in input_notes.iter().enumerate() { + if !nullifiers.insert(note.nullifier()) { + return Err(ConversionError::message("duplicate input note nullifier") + .context(format!("input_notes[{index}]"))); + } + } + let input_notes = InputNotes::new_unchecked(input_notes); + + if value.output_notes.len() > MAX_OUTPUT_NOTES_PER_BATCH { + return Err(ConversionError::message("too many output notes").context("output_notes")); + } + let output_notes = value + .output_notes + .into_iter() + .enumerate() + .map(|(index, note)| OutputNote::try_from(note).context(format!("output_notes[{index}]"))) + .collect::, _>>()?; + let mut output_note_ids = BTreeSet::new(); + for (index, note) in output_notes.iter().enumerate() { + if !output_note_ids.insert(note.id()) { + return Err(ConversionError::message("duplicate output note ID") + .context(format!("output_notes[{index}]"))); + } + } + + let transactions = value + .transactions + .into_iter() + .enumerate() + .map(|(index, tx)| { + TransactionHeader::try_from(tx).context(format!("transactions[{index}]")) + }) + .collect::, _>>()?; + let id = BatchId::from_ids(transactions.iter().map(|tx| (tx.id(), tx.account_id()))); + let transactions = OrderedTransactionHeaders::new_unchecked(transactions); + let proof = ExecutionProof::read_from_bytes(&value.proof) + .map_err(|source| ConversionError::deserialization("ExecutionProof", source)) + .context("proof")?; + + ProvenBatch::new_unchecked( + id, + reference_block_commitment, + BlockNumber::from(value.reference_block_num), + account_updates, + input_notes, + output_notes, + BlockNumber::from(value.expiration_block_num), + transactions, + proof, + ) + .map_err(ConversionError::new) +} + /// Decodes a proven batch and checks every duplicated public field against its proposal. pub fn decode_proven_batch( value: proto::transaction::ProvenBatch, @@ -293,7 +438,7 @@ mod tests { }; use miden_protocol::vm::ExecutionProof; - use super::decode_proven_batch; + use super::{decode_proven_batch, decode_standalone_proven_batch}; use crate::generated as proto; fn proposal_and_proof() -> (ProposedBatch, ProvenBatch) { @@ -396,4 +541,31 @@ mod tests { .contains("account_updates") ); } + + #[test] + fn standalone_proven_batch_roundtrips_and_rejects_malformed_fields() { + let (_, proven) = proposal_and_proof(); + let encoded = proto::transaction::ProvenBatch::from(&proven); + assert_eq!(decode_standalone_proven_batch(encoded.clone()).unwrap(), proven); + + let mut malformed_proof = encoded.clone(); + malformed_proof.proof = vec![0xff]; + assert!( + decode_standalone_proven_batch(malformed_proof) + .unwrap_err() + .to_string() + .contains("proof") + ); + + let mut duplicate_account = encoded; + duplicate_account + .account_updates + .push(duplicate_account.account_updates[0].clone()); + assert!( + decode_standalone_proven_batch(duplicate_account) + .unwrap_err() + .to_string() + .contains("account_updates") + ); + } } diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index aa5c696f45..099c567e9f 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -10,6 +10,7 @@ pub mod nullifier; pub mod primitives; pub mod proof_request; pub mod transaction; +pub mod validator; // UTILITIES // ================================================================================================ diff --git a/crates/proto/src/domain/validator.rs b/crates/proto/src/domain/validator.rs new file mode 100644 index 0000000000..301fc5847b --- /dev/null +++ b/crates/proto/src/domain/validator.rs @@ -0,0 +1,254 @@ +use std::collections::BTreeMap; + +use miden_protocol::account::AccountId; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::block::nullifier_tree::NullifierWitness; +use miden_protocol::block::{BlockInputs, ProposedBlock, ValidatorKeys}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey; +use miden_protocol::crypto::merkle::smt::SmtProof; +use miden_protocol::note::{NoteId, NoteInclusionProof, Nullifier}; +use miden_protocol::{MAX_BATCHES_PER_BLOCK, Word}; + +use crate::decode::{ConversionResultExt, GrpcDecodeExt}; +use crate::domain::account::AccountWitnessRecord; +use crate::domain::batch::decode_standalone_proven_batch; +use crate::errors::ConversionError; +use crate::{decode, generated as proto}; + +impl From<(&ProposedBlock, &BlockInputs)> for proto::validator::ProposedBlock { + fn from((block, inputs): (&ProposedBlock, &BlockInputs)) -> Self { + Self { + block_inputs: Some(inputs.into()), + batches: block.batches().as_slice().iter().map(Into::into).collect(), + timestamp: block.timestamp(), + next_validator_keys: block + .next_validator_keys() + .as_keys() + .iter() + .map(Into::into) + .collect(), + } + } +} + +impl From<&BlockInputs> for proto::validator::BlockInputs { + fn from(value: &BlockInputs) -> Self { + Self { + prev_block_header: Some(value.prev_block_header().into()), + partial_blockchain: Some(value.partial_blockchain().into()), + account_witnesses: value + .account_witnesses() + .iter() + .map(|(account_id, witness)| { + AccountWitnessRecord { + account_id: *account_id, + witness: witness.clone(), + } + .into() + }) + .collect(), + nullifier_witnesses: value + .nullifier_witnesses() + .iter() + .map(|(nullifier, witness)| proto::validator::NullifierWitness { + nullifier: Some(nullifier.as_word().into()), + proof: Some(witness.proof().clone().into()), + }) + .collect(), + unauthenticated_note_proofs: value + .unauthenticated_note_proofs() + .iter() + .map(Into::into) + .collect(), + } + } +} + +impl TryFrom for BlockInputs { + type Error = ConversionError; + + fn try_from(value: proto::validator::BlockInputs) -> Result { + let decoder = value.decoder(); + let prev_block_header = decode!(decoder, value.prev_block_header)?; + let partial_blockchain = decode!(decoder, value.partial_blockchain)?; + + let mut account_witnesses = BTreeMap::::new(); + let mut previous_account_id = None; + for (index, witness) in value.account_witnesses.into_iter().enumerate() { + let record = AccountWitnessRecord::try_from(witness) + .context(format!("account_witnesses[{index}]"))?; + if previous_account_id.is_some_and(|previous| record.account_id <= previous) { + return Err(ConversionError::message( + "account witnesses must have unique, ascending requested account IDs", + ) + .context(format!("account_witnesses[{index}].account_id"))); + } + previous_account_id = Some(record.account_id); + account_witnesses.insert(record.account_id, record.witness); + } + + let mut nullifier_witnesses = BTreeMap::new(); + let mut previous_nullifier = None; + for (index, witness) in value.nullifier_witnesses.into_iter().enumerate() { + let decoder = witness.decoder(); + let word: Word = decode!(decoder, witness.nullifier)?; + let nullifier = Nullifier::from_raw(word); + if previous_nullifier.is_some_and(|previous| nullifier <= previous) { + return Err(ConversionError::message( + "nullifier witnesses must have unique, ascending nullifiers", + ) + .context(format!("nullifier_witnesses[{index}].nullifier"))); + } + previous_nullifier = Some(nullifier); + let proof: SmtProof = decode!(decoder, witness.proof)?; + if proof.get(&nullifier.as_word()).is_none() { + return Err(ConversionError::message("SMT opening does not contain the nullifier") + .context(format!("nullifier_witnesses[{index}].proof"))); + } + nullifier_witnesses.insert(nullifier, NullifierWitness::new(proof)); + } + + let mut unauthenticated_note_proofs = BTreeMap::::new(); + let mut previous_note_id = None; + for (index, proof) in value.unauthenticated_note_proofs.iter().enumerate() { + let (note_id, proof) = <(NoteId, NoteInclusionProof)>::try_from(proof) + .context(format!("unauthenticated_note_proofs[{index}]"))?; + if previous_note_id.is_some_and(|previous| note_id <= previous) { + return Err(ConversionError::message( + "unauthenticated note proofs must have unique, ascending note IDs", + ) + .context(format!("unauthenticated_note_proofs[{index}].note_id"))); + } + previous_note_id = Some(note_id); + unauthenticated_note_proofs.insert(note_id, proof); + } + + Ok(BlockInputs::new( + prev_block_header, + partial_blockchain, + account_witnesses, + nullifier_witnesses, + unauthenticated_note_proofs, + )) + } +} + +impl TryFrom for ProposedBlock { + type Error = ConversionError; + + fn try_from(value: proto::validator::ProposedBlock) -> Result { + if value.batches.len() > MAX_BATCHES_PER_BLOCK { + return Err(ConversionError::message("too many batches").context("batches")); + } + let decoder = value.decoder(); + let block_inputs = decode!(decoder, value.block_inputs)?; + let batches = value + .batches + .into_iter() + .enumerate() + .map(|(index, batch)| { + decode_standalone_proven_batch(batch).context(format!("batches[{index}]")) + }) + .collect::, _>>()?; + let next_validator_keys = value + .next_validator_keys + .into_iter() + .enumerate() + .map(|(index, key)| { + PublicKey::try_from(key).context(format!("next_validator_keys[{index}]")) + }) + .collect::, _>>()?; + let next_validator_keys = ValidatorKeys::new(next_validator_keys) + .map_err(ConversionError::new) + .context("next_validator_keys")?; + + ProposedBlock::new_at(block_inputs, batches, value.timestamp) + .map(|block| block.with_next_validator_keys(next_validator_keys)) + .map_err(ConversionError::new) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use miden_protocol::Word; + use miden_protocol::block::{BlockHeader, BlockInputs, ProposedBlock, ValidatorKeys}; + use miden_protocol::testing::random_secret_key::random_secret_key; + use miden_protocol::transaction::PartialBlockchain; + + use crate::generated as proto; + + #[test] + fn proposed_block_roundtrips_with_explicit_timestamp_and_validator_rotation() { + let partial_blockchain = PartialBlockchain::default(); + let parent = BlockHeader::mock( + 0, + Some(partial_blockchain.peaks().hash_peaks()), + None, + &[], + Word::empty(), + ); + let inputs = BlockInputs::new( + parent.clone(), + partial_blockchain, + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + ); + let timestamp = parent.timestamp().saturating_add(1); + let next_validator_keys = + ValidatorKeys::new(vec![random_secret_key().public_key()]).unwrap(); + let block = ProposedBlock::new_at(inputs.clone(), vec![], timestamp) + .unwrap() + .with_next_validator_keys(next_validator_keys.clone()); + + let encoded = proto::validator::ProposedBlock::from((&block, &inputs)); + let decoded = ProposedBlock::try_from(encoded).unwrap(); + + assert_eq!(decoded.timestamp(), timestamp); + assert_eq!(decoded.next_validator_keys(), &next_validator_keys); + let expected = block.into_header_and_body().unwrap(); + let actual = decoded.into_header_and_body().unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn proposed_block_rejects_missing_inputs_and_duplicate_validator_keys() { + let missing_inputs = proto::validator::ProposedBlock::default(); + assert!( + ProposedBlock::try_from(missing_inputs) + .unwrap_err() + .to_string() + .contains("block_inputs") + ); + + let partial_blockchain = PartialBlockchain::default(); + let parent = BlockHeader::mock( + 0, + Some(partial_blockchain.peaks().hash_peaks()), + None, + &[], + Word::empty(), + ); + let inputs = BlockInputs::new( + parent.clone(), + partial_blockchain, + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + ); + let block = + ProposedBlock::new_at(inputs.clone(), vec![], parent.timestamp().saturating_add(1)) + .unwrap(); + let mut encoded = proto::validator::ProposedBlock::from((&block, &inputs)); + encoded.next_validator_keys.push(encoded.next_validator_keys[0].clone()); + + assert!( + ProposedBlock::try_from(encoded) + .unwrap_err() + .to_string() + .contains("next_validator_keys") + ); + } +} diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 10a9a92d6f..f963e1e5a6 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -1025,7 +1025,7 @@ impl validator_api::SignBlock for FixedValidator { type Input = (); type Output = proto::blockchain::SignBlockResponse; - fn decode(_request: proto::blockchain::ProposedBlock) -> tonic::Result { + fn decode(_request: proto::validator::ProposedBlock) -> tonic::Result { Ok(()) } diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index 69c522cb9d..1855c2ea38 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -4,6 +4,10 @@ package validator; import "types/transaction.proto"; import "types/blockchain.proto"; +import "types/block_header.proto"; +import "types/partial_blockchain.proto"; +import "types/account.proto"; +import "types/note.proto"; import "types/primitives.proto"; import "google/protobuf/empty.proto"; @@ -19,7 +23,7 @@ service Api { rpc SubmitProvenTransaction(transaction.ProvenTransaction) returns (google.protobuf.Empty) {} // Validates and signs a proposed block, returning the signature and the signed block commitment. - rpc SignBlock(blockchain.ProposedBlock) returns (blockchain.SignBlockResponse) {} + rpc SignBlock(ProposedBlock) returns (blockchain.SignBlockResponse) {} // Streams signed blocks starting from the given block number (inclusive). // @@ -37,6 +41,40 @@ service Api { rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKey) {} } +// PROPOSED BLOCK +// ================================================================================================ + +// The inputs from which every validator independently reconstructs and validates a proposed block. +message ProposedBlock { + reserved 1; + reserved "proposed_block"; + + BlockInputs block_inputs = 2; + repeated transaction.ProvenBatch batches = 3; + fixed32 timestamp = 4; + repeated blockchain.ValidatorPublicKey next_validator_keys = 5; +} + +// State witnesses required to construct a proposed block. +message BlockInputs { + blockchain.BlockHeader prev_block_header = 1; + blockchain.PartialBlockchain partial_blockchain = 2; + + // Canonically ordered by requested account ID. + repeated account.AccountWitness account_witnesses = 3; + + // Canonically ordered by nullifier. + repeated NullifierWitness nullifier_witnesses = 4; + + // Canonically ordered by note ID. + repeated note.NoteInclusionInBlockProof unauthenticated_note_proofs = 5; +} + +message NullifierWitness { + primitives.Word nullifier = 1; + primitives.SmtOpening proof = 2; +} + // BLOCK SUBSCRIPTION // ================================================================================================ diff --git a/proto/proto/types/blockchain.proto b/proto/proto/types/blockchain.proto index 158b675cf1..38252fa227 100644 --- a/proto/proto/types/blockchain.proto +++ b/proto/proto/types/blockchain.proto @@ -18,13 +18,6 @@ message SignedBlock { repeated BlockSignature signatures = 3; } -// Represents a proposed block. -message ProposedBlock { - // Block data encoded using [miden_serde_utils::Serializable] implementation for - // [miden_protocol::block::ProposedBlock]. - bytes proposed_block = 1; -} - // Request for retrieving a block by its number, optionally including the block proof. message BlockRequest { // The block number of the target block.