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/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/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/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..7f7954e8fa 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) @@ -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 } @@ -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/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/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 6b600c0461..a361e7857f 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 } @@ -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. @@ -772,7 +838,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 +854,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); } @@ -1083,8 +1151,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/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/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/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/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/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/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/batch.rs b/crates/proto/src/domain/batch.rs index d4f1418b66..dee6753a0f 100644 --- a/crates/proto/src/domain/batch.rs +++ b/crates/proto/src/domain/batch.rs @@ -1,8 +1,32 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; -use miden_protocol::block::BlockHeader; +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::PartialBlockchain; +use miden_protocol::transaction::{ + InputNoteCommitment, + InputNotes, + OrderedTransactionHeaders, + OutputNote, + PartialBlockchain, + ProvenTransaction, + TransactionHeader, +}; +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; +use crate::{decode, generated as proto}; /// Data required for a transaction batch. #[derive(Clone, Debug)] @@ -11,3 +35,537 @@ 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: value.proof().to_bytes(), + } + } +} + +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)?, + }) + } +} + +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, + 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 = ExecutionProof::read_from_bytes(&value.proof) + .map_err(|source| ConversionError::deserialization("ExecutionProof", source)) + .context("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, decode_standalone_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 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!( + 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") + ); + } + + #[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/block.rs b/crates/proto/src/domain/block.rs index 992f267ba4..f9dd03df9a 100644 --- a/crates/proto/src/domain/block.rs +++ b/crates/proto/src/domain/block.rs @@ -1,16 +1,32 @@ +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::crypto::merkle::MerklePath; +use miden_protocol::crypto::merkle::mmr::{Forest, MmrPeaks, PartialMmr}; +use miden_protocol::note::Nullifier; +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; use crate::decode::{ConversionResultExt, DecodeBytesExt, GrpcDecodeExt}; @@ -32,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 // ================================================================================================ @@ -113,7 +233,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 +265,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 +492,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 +504,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 +649,241 @@ 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::crypto::merkle::mmr::{Mmr, PartialMmr}; + use miden_protocol::note::{Note, NoteAttachments, Nullifier}; + 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; + + 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 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( + [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 d19046cb28..099c567e9f 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; @@ -6,8 +7,10 @@ pub mod encryption; pub mod merkle; pub mod note; pub mod nullifier; +pub mod primitives; pub mod proof_request; pub mod transaction; +pub mod validator; // UTILITIES // ================================================================================================ diff --git a/crates/proto/src/domain/note.rs b/crates/proto/src/domain/note.rs index 0a52e02775..55fdd0f868 100644 --- a/crates/proto/src/domain/note.rs +++ b/crates/proto/src/domain/note.rs @@ -1,8 +1,11 @@ use std::sync::Arc; +use miden_protocol::asset::Asset; use miden_protocol::crypto::merkle::SparseMerklePath; use miden_protocol::note::{ Note, + NoteAssets, + NoteAttachment, NoteAttachmentHeader, NoteAttachmentScheme, NoteAttachments, @@ -12,16 +15,18 @@ 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}; +use crate::decode::{ConversionResultExt, DecodeBytesExt, GrpcDecodeExt, GrpcStructDecoder}; use crate::errors::ConversionError; use crate::{decode, generated as proto}; @@ -111,21 +116,180 @@ 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") + } +} + +// 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 attachments = note.attachments().to_bytes(); - let details = NoteDetails::from(note).to_bytes(); - Self { metadata, details, 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 attachments = note.attachments().to_bytes(); - let details = Some(NoteDetails::from(note).to_bytes()); - Self { metadata, details, 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()), + } } } @@ -140,14 +304,15 @@ 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, 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_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,15 +324,15 @@ 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, 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_attachments(&attachments)?; + let attachments = decode_note_attachments::(note_attachments)?; Ok(Note::with_attachments(assets, partial_metadata, recipient, attachments)) } @@ -262,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(), @@ -303,22 +474,75 @@ 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) +} + +/// 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::*; + 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) + } + + 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. @@ -348,4 +572,385 @@ 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}"); + } + + #[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/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/crates/proto/src/domain/transaction.rs b/crates/proto/src/domain/transaction.rs index 13fcb98a9c..dc6f76d16e 100644 --- a/crates/proto/src/domain/transaction.rs +++ b/crates/proto/src/domain/transaction.rs @@ -1,11 +1,141 @@ use miden_protocol::Word; -use miden_protocol::note::Nullifier; -use miden_protocol::transaction::{InputNoteCommitment, TransactionId}; +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 miden_protocol::utils::serde::Deserializable; +use miden_protocol::vm::ExecutionProof; 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: value.proof().to_bytes(), + } + } +} + +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 = ExecutionProof::read_from_bytes(&value.proof) + .map_err(|source| ConversionError::deserialization("ExecutionProof", source)) + .context("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 // ================================================================================================ @@ -59,6 +189,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 +215,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/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/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/get_notes_by_id.rs b/crates/rpc/src/server/api/get_notes_by_id.rs index bcbc6bcb90..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,8 +68,158 @@ 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_details: note.details.map(Into::into), + 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 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, + 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), + 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], + }), + }; + 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" + ); + } + + #[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/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/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 9b5bfc4b70..f963e1e5a6 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; @@ -116,11 +118,253 @@ 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] +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"); + } +} + +#[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_transaction_and_batch_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/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(); + 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) @@ -360,6 +604,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 @@ -424,16 +697,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; @@ -471,8 +741,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; @@ -510,8 +780,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( @@ -755,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(()) } @@ -1049,8 +1319,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/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/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/README.md b/proto/README.md index 007ba374a7..f4b03737f1 100644 --- a/proto/README.md +++ b/proto/README.md @@ -12,6 +12,15 @@ 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. 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 - `internal`: exposes file descriptors for internal node component APIs. These APIs are not intended for general client 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/internal/validator.proto b/proto/proto/internal/validator.proto index c7d7d09b18..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 // ================================================================================================ @@ -48,12 +86,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..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"; @@ -131,12 +132,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 +156,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/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 f083b1c4c1..38252fa227 100644 --- a/proto/proto/types/blockchain.proto +++ b/proto/proto/types/blockchain.proto @@ -2,7 +2,9 @@ syntax = "proto3"; package blockchain; import "types/account.proto"; +import "types/block_header.proto"; import "types/primitives.proto"; +import "types/transaction.proto"; // BLOCK // ================================================================================================ @@ -16,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. @@ -36,12 +31,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. @@ -56,55 +50,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,23 +72,38 @@ 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 // ================================================================================================ // 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/note.proto b/proto/proto/types/note.proto index 4481c018b5..2059b2798a 100644 --- a/proto/proto/types/note.proto +++ b/proto/proto/types/note.proto @@ -62,18 +62,56 @@ 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 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 the note metadata, its serialized details, and serialized attachments. +// The note is composed of its metadata, structured details, and structured public attachments. message Note { + 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; - // 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 +119,17 @@ message Note { // Network notes are a subtype of public notes, and as such, their details are always publicly // known. message NetworkNote { + 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; - // 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. 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/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 // ================================================================================================ diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index c95d2c41a6..0db5c06ff5 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -2,7 +2,9 @@ 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"; // TRANSACTION @@ -19,15 +21,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 +60,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 +71,54 @@ 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; + // Canonically serialized miden_protocol::vm::ExecutionProof. + bytes 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; + // Canonically serialized miden_protocol::vm::ExecutionProof. + bytes proof = 8; } // IES scheme used for transaction input encryption. @@ -213,3 +256,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; + } +}