diff --git a/crates/block-producer/src/store/mod.rs b/crates/block-producer/src/store/mod.rs index fb20bc160e..df9cbcdd51 100644 --- a/crates/block-producer/src/store/mod.rs +++ b/crates/block-producer/src/store/mod.rs @@ -5,7 +5,12 @@ use std::num::NonZeroU32; use itertools::Itertools; use miden_node_proto::clients::{Builder, StoreBlockProducerClient}; use miden_node_proto::domain::batch::BatchInputs; -use miden_node_proto::errors::{ConversionError, MissingFieldHelper}; +use miden_node_proto::errors::{ + ConversionError, + ConversionResultExt, + GrpcDecodeExt as _, + grpc_decode, +}; use miden_node_proto::{AccountState, generated as proto}; use miden_node_utils::formatting::format_opt; use miden_protocol::Word; @@ -66,25 +71,16 @@ impl Display for TransactionInputs { } } +#[grpc_decode] impl TryFrom for TransactionInputs { type Error = ConversionError; fn try_from(response: proto::store::TransactionInputs) -> Result { - let AccountState { account_id, account_commitment } = response - .account_state - .ok_or(proto::store::TransactionInputs::missing_field(stringify!(account_state)))? - .try_into()?; + let AccountState { account_id, account_commitment } = response.account_state.decode()?; let mut nullifiers = HashMap::new(); for nullifier_record in response.nullifiers { - let nullifier = nullifier_record - .nullifier - .ok_or( - proto::store::transaction_inputs::NullifierTransactionInputRecord::missing_field( - stringify!(nullifier), - ), - )? - .try_into()?; + let nullifier = nullifier_record.nullifier.decode()?; // Note that this intentionally maps 0 to None as this is the definition used in // protobuf. @@ -95,7 +91,8 @@ impl TryFrom for TransactionInputs { .found_unauthenticated_notes .into_iter() .map(Word::try_from) - .collect::>()?; + .collect::>() + .context("found_unauthenticated_notes")?; let current_block_height = response.block_height.into(); @@ -148,11 +145,13 @@ impl StoreClient { .await? .into_inner() .block_header - .ok_or(miden_node_proto::generated::blockchain::BlockHeader::missing_field( - "block_header", - ))?; + .ok_or_else(|| { + StoreError::DeserializationError(ConversionError::missing_field::< + miden_node_proto::generated::blockchain::BlockHeader, + >("block_header")) + })?; - BlockHeader::try_from(response).map_err(Into::into) + BlockHeader::try_from(response).map_err(StoreError::DeserializationError) } #[instrument(target = COMPONENT, name = "store.client.get_tx_inputs", skip_all, err)] @@ -219,7 +218,7 @@ impl StoreClient { let store_response = self.client.clone().get_block_inputs(request).await?.into_inner(); - store_response.try_into().map_err(Into::into) + store_response.try_into().map_err(StoreError::DeserializationError) } #[instrument(target = COMPONENT, name = "store.client.get_batch_inputs", skip_all, err)] @@ -235,7 +234,7 @@ impl StoreClient { let store_response = self.client.clone().get_batch_inputs(request).await?.into_inner(); - store_response.try_into().map_err(Into::into) + store_response.try_into().map_err(StoreError::DeserializationError) } #[instrument(target = COMPONENT, name = "store.client.apply_block", skip_all, err)] diff --git a/crates/grpc-error-macro/Cargo.toml b/crates/grpc-error-macro/Cargo.toml index 4fd40b0b2d..555ff61348 100644 --- a/crates/grpc-error-macro/Cargo.toml +++ b/crates/grpc-error-macro/Cargo.toml @@ -19,4 +19,4 @@ proc-macro = true [dependencies] quote = "1.0" -syn = { features = ["full"], version = "2.0" } +syn = { features = ["full", "visit", "visit-mut"], version = "2.0" } diff --git a/crates/grpc-error-macro/src/lib.rs b/crates/grpc-error-macro/src/lib.rs index a898d49dc4..024da3d15a 100644 --- a/crates/grpc-error-macro/src/lib.rs +++ b/crates/grpc-error-macro/src/lib.rs @@ -17,10 +17,10 @@ //! #[error("database error")] //! #[grpc(internal)] //! DatabaseError(#[from] DatabaseError), -//! +//! //! #[error("malformed script root")] //! DeserializationFailed, -//! +//! //! #[error("script with given root doesn't exist")] //! ScriptNotFound, //! } @@ -28,6 +28,7 @@ use proc_macro::TokenStream; use quote::quote; +use syn::visit_mut::VisitMut; use syn::{Data, DeriveInput, Fields, Ident, parse_macro_input}; /// Derives the `GrpcError` trait for an error enum. @@ -178,3 +179,348 @@ pub fn derive_grpc_error(input: TokenStream) -> TokenStream { TokenStream::from(expanded) } + +// GRPC DECODE ATTRIBUTE MACRO +// ================================================================================================ + +/// Attribute macro that rewrites `.decode()` shorthand into +/// `decoder.decode_field("field_name", value.field_name)` calls. +/// +/// Place on an `impl TryFrom for DomainType` block. The macro will: +/// 1. Find all `.field.decode()` calls in each method body +/// 2. Inject `let decoder = .decoder();` at the top of the method +/// 3. Rewrite each `.decode()` call to `decoder.decode_field("field", .field)` +/// +/// The field name string is extracted from the last segment of the field access expression. +/// `.decode()` calls inside closures, for-loops, and match arms are also rewritten +/// when the receiver is rooted at the closure parameter, loop variable, or match binding. +/// +/// # Example +/// +/// ```rust,ignore +/// #[grpc_decode] +/// impl TryFrom for BlockHeader { +/// type Error = ConversionError; +/// fn try_from(value: proto::blockchain::BlockHeader) -> Result { +/// let prev = value.prev_block_commitment.decode()?; +/// let chain = value.chain_commitment.decode()?; +/// // Non-decode code passes through unchanged: +/// Ok(BlockHeader::new(value.version, prev, value.block_num.into(), chain)) +/// } +/// } +/// ``` +/// +/// Expands to: +/// +/// ```rust,ignore +/// impl TryFrom for BlockHeader { +/// type Error = ConversionError; +/// fn try_from(value: proto::blockchain::BlockHeader) -> Result { +/// let decoder = value.decoder(); +/// let prev = decoder.decode_field("prev_block_commitment", value.prev_block_commitment)?; +/// let chain = decoder.decode_field("chain_commitment", value.chain_commitment)?; +/// Ok(BlockHeader::new(value.version, prev, value.block_num.into(), chain)) +/// } +/// } +/// ``` +#[proc_macro_attribute] +pub fn grpc_decode(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut impl_block = parse_macro_input!(item as syn::ItemImpl); + + for item in &mut impl_block.items { + if let syn::ImplItem::Fn(method) = item { + process_method(method); + } + } + + TokenStream::from(quote!(#impl_block)) +} + +/// Processes a single method in the impl block, rewriting `.decode()` calls. +fn process_method(method: &mut syn::ImplItemFn) { + let Some(param_name) = extract_param_name(&method.sig) else { + return; + }; + + let mut rewriter = DecodeRewriter { + roots: vec![param_name.clone()], + found_decode: false, + }; + + syn::visit_mut::visit_block_mut(&mut rewriter, &mut method.block); + + if rewriter.found_decode { + let decoder_stmt: syn::Stmt = syn::parse_quote! { + let decoder = #param_name.decoder(); + }; + method.block.stmts.insert(0, decoder_stmt); + } +} + +/// Extracts the first non-self parameter name from a function signature. +fn extract_param_name(sig: &syn::Signature) -> Option { + for arg in &sig.inputs { + if let syn::FnArg::Typed(pat_type) = arg { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + return Some(pat_ident.ident.clone()); + } + } + } + None +} + +/// Extracts all ident bindings from a pattern (e.g., `Foo::Bar(x)` → `[x]`). +fn extract_pat_idents(pat: &syn::Pat) -> Vec { + let mut idents = Vec::new(); + collect_pat_idents(pat, &mut idents); + idents +} + +fn collect_pat_idents(pat: &syn::Pat, idents: &mut Vec) { + match pat { + syn::Pat::Ident(pat_ident) => { + idents.push(pat_ident.ident.clone()); + }, + syn::Pat::TupleStruct(tuple_struct) => { + for elem in &tuple_struct.elems { + collect_pat_idents(elem, idents); + } + }, + syn::Pat::Tuple(tuple) => { + for elem in &tuple.elems { + collect_pat_idents(elem, idents); + } + }, + syn::Pat::Struct(pat_struct) => { + for field in &pat_struct.fields { + collect_pat_idents(&field.pat, idents); + } + }, + syn::Pat::Reference(pat_ref) => { + collect_pat_idents(&pat_ref.pat, idents); + }, + _ => {}, + } +} + +/// Injects `let decoder = .decoder();` at the start of a block. +fn inject_decoder_into_block(block: &mut syn::Block, root: &Ident) { + let decoder_stmt: syn::Stmt = syn::parse_quote! { + let decoder = #root.decoder(); + }; + block.stmts.insert(0, decoder_stmt); +} + +/// AST visitor that rewrites `.field.decode()` → +/// `decoder.decode_field("field", .field)`. +/// +/// Tracks a stack of "roots" — variable names that are valid decode receivers. +/// The function parameter is the initial root. Closure parameters, for-loop +/// variables, and match bindings are pushed as temporary roots when visiting +/// their respective scopes. +struct DecodeRewriter { + /// Stack of decode root variable names. + roots: Vec, + /// Whether any `.decode()` calls were found and rewritten at the top-level + /// (function parameter) scope. + found_decode: bool, +} + +impl DecodeRewriter { + /// Checks if a field access expression is rooted at any known root, + /// and returns the last field name segment if so. + fn extract_field_info(&self, expr: &syn::Expr) -> Option { + if let syn::Expr::Field(field_expr) = expr { + if let syn::Member::Named(field_ident) = &field_expr.member { + if self.root_matches_any(expr) { + return Some(field_ident.clone()); + } + } + } + None + } + + /// Recursively checks if the root of a field access chain matches any known root. + fn root_matches_any(&self, expr: &syn::Expr) -> bool { + match expr { + syn::Expr::Path(path) => self.roots.iter().any(|root| path.path.is_ident(root)), + syn::Expr::Field(field) => self.root_matches_any(&field.base), + _ => false, + } + } + + /// Finds which root an expression is rooted at. + fn find_root(&self, expr: &syn::Expr) -> Option<&Ident> { + match expr { + syn::Expr::Path(path) => self.roots.iter().find(|root| path.path.is_ident(&**root)), + syn::Expr::Field(field) => self.find_root(&field.base), + _ => None, + } + } +} + +/// Visitor that detects `decoder.decode_field(...)` calls in the immediate scope, +/// without recursing into nested closures or for-loops (which have their own decoders). +struct DecoderCallFinder(bool); + +impl<'ast> syn::visit::Visit<'ast> for DecoderCallFinder { + fn visit_expr_method_call(&mut self, mc: &'ast syn::ExprMethodCall) { + if mc.method == "decode_field" { + if let syn::Expr::Path(path) = &*mc.receiver { + if path.path.is_ident("decoder") { + self.0 = true; + } + } + } + syn::visit::visit_expr_method_call(self, mc); + } + + fn visit_expr_closure(&mut self, _: &'ast syn::ExprClosure) {} + fn visit_expr_for_loop(&mut self, _: &'ast syn::ExprForLoop) {} +} + +/// Checks if an expression contains any `decoder.decode_field(...)` calls. +fn expr_contains_decoder_call(expr: &syn::Expr) -> bool { + let mut finder = DecoderCallFinder(false); + syn::visit::Visit::visit_expr(&mut finder, expr); + finder.0 +} + +/// Checks if a block contains any `decoder.decode_field(...)` calls. +fn block_contains_decoder_call(block: &syn::Block) -> bool { + let mut finder = DecoderCallFinder(false); + syn::visit::Visit::visit_block(&mut finder, block); + finder.0 +} + +impl VisitMut for DecodeRewriter { + fn visit_expr_mut(&mut self, expr: &mut syn::Expr) { + // Recurse into children first (bottom-up). + syn::visit_mut::visit_expr_mut(self, expr); + + // Match: .decode() with no arguments. + if let syn::Expr::MethodCall(mc) = expr { + if mc.method == "decode" && mc.args.is_empty() { + if let Some(field_name) = self.extract_field_info(&mc.receiver) { + let root = self.find_root(&mc.receiver).cloned(); + let receiver = &mc.receiver; + let name_str = field_name.to_string(); + if root.as_ref() == self.roots.first() { + self.found_decode = true; + } + *expr = syn::parse_quote! { + decoder.decode_field(#name_str, #receiver) + }; + } + } + } + } + + fn visit_expr_closure_mut(&mut self, closure: &mut syn::ExprClosure) { + // Extract closure parameter idents as new roots. + let new_roots: Vec = closure + .inputs + .iter() + .filter_map(|pat| { + if let syn::Pat::Ident(pat_ident) = pat { + Some(pat_ident.ident.clone()) + } else { + None + } + }) + .collect(); + + if new_roots.is_empty() { + // No usable closure params — visit normally without adding roots. + syn::visit_mut::visit_expr_closure_mut(self, closure); + return; + } + + // If the closure body is a block, we can inject a decoder statement. + // If it's an expression, wrap it in a block first. + let roots_start = self.roots.len(); + self.roots.extend(new_roots); + + syn::visit_mut::visit_expr_closure_mut(self, closure); + + // Check if we need to inject a decoder. Get the root that was used. + let injected_roots: Vec = self.roots[roots_start..].to_vec(); + self.roots.truncate(roots_start); + + // Find which root needs a decoder by checking the closure body. + // If the body is a block expression, check it directly. + // If the body is a non-block expression (e.g., single-expression closure), + // check if the rewritten expression contains decoder.decode_field calls + // and wrap it in a block with the decoder statement. + for root in &injected_roots { + let needs_decoder = match &*closure.body { + syn::Expr::Block(expr_block) => block_contains_decoder_call(&expr_block.block), + other => expr_contains_decoder_call(other), + }; + + if needs_decoder { + if let syn::Expr::Block(expr_block) = &mut *closure.body { + inject_decoder_into_block(&mut expr_block.block, root); + } else { + // Wrap the expression body in a block with a decoder statement. + let body = &closure.body; + *closure.body = syn::parse_quote! { + { + let decoder = #root.decoder(); + #body + } + }; + } + break; + } + } + } + + fn visit_expr_for_loop_mut(&mut self, for_loop: &mut syn::ExprForLoop) { + // Extract the loop variable as a new root. + let new_roots = extract_pat_idents(&for_loop.pat); + + let roots_start = self.roots.len(); + self.roots.extend(new_roots); + + syn::visit_mut::visit_expr_for_loop_mut(self, for_loop); + + let injected_roots: Vec = self.roots[roots_start..].to_vec(); + self.roots.truncate(roots_start); + + for root in &injected_roots { + if block_contains_decoder_call(&for_loop.body) { + inject_decoder_into_block(&mut for_loop.body, root); + break; // Only one decoder per block + } + } + } + + fn visit_arm_mut(&mut self, arm: &mut syn::Arm) { + // Extract match binding idents as new roots. + let new_roots = extract_pat_idents(&arm.pat); + + let roots_start = self.roots.len(); + self.roots.extend(new_roots); + + syn::visit_mut::visit_arm_mut(self, arm); + + let injected_roots: Vec = self.roots[roots_start..].to_vec(); + self.roots.truncate(roots_start); + + // Inject decoder into the arm body if it's a block expression. + for root in &injected_roots { + let needs_decoder = match &*arm.body { + syn::Expr::Block(expr_block) => block_contains_decoder_call(&expr_block.block), + _ => false, + }; + + if needs_decoder { + if let syn::Expr::Block(expr_block) = &mut *arm.body { + inject_decoder_into_block(&mut expr_block.block, root); + break; + } + } + } + } +} diff --git a/crates/ntx-builder/src/clients/store.rs b/crates/ntx-builder/src/clients/store.rs index 1f8c7b5f72..0d11dc1fc6 100644 --- a/crates/ntx-builder/src/clients/store.rs +++ b/crates/ntx-builder/src/clients/store.rs @@ -4,7 +4,7 @@ use std::time::Duration; use miden_node_proto::clients::{Builder, StoreNtxBuilderClient}; use miden_node_proto::domain::account::{AccountDetails, AccountResponse, NetworkAccountId}; -use miden_node_proto::errors::ConversionError; +use miden_node_proto::errors::{ConversionError, ConversionResultExt}; use miden_node_proto::generated::rpc::BlockRange; use miden_node_proto::generated::{self as proto}; use miden_node_proto::try_convert; @@ -101,7 +101,10 @@ impl StoreClient { match response.current_block_header { // There are new blocks compared to the builder's latest state Some(block) => { - let peaks = try_convert(response.current_peaks).collect::>()?; + let peaks: Vec = try_convert(response.current_peaks) + .collect::>() + .context("current_peaks") + .map_err(StoreError::DeserializationError)?; let header = BlockHeader::try_from(block).map_err(StoreError::DeserializationError)?; @@ -140,9 +143,7 @@ impl StoreClient { // which implies details being public, so OK to error otherwise let account = match store_response.map(|acc| acc.details) { Some(Some(details)) => Some(Account::read_from_bytes(&details).map_err(|err| { - StoreError::DeserializationError(ConversionError::deserialization_error( - "account", err, - )) + StoreError::DeserializationError(ConversionError::from(err).context("details")) })?), _ => None, }; @@ -185,7 +186,8 @@ impl StoreClient { let account_details = account_response .details .ok_or(StoreError::MissingDetails("account details".into()))?; - let partial_account = build_minimal_foreign_account(&account_details)?; + let partial_account = build_minimal_foreign_account(&account_details) + .map_err(StoreError::DeserializationError)?; Ok(AccountInputs::new(partial_account, account_response.witness)) } @@ -216,7 +218,10 @@ impl StoreClient { all_notes.reserve(resp.notes.len()); for note in resp.notes { - all_notes.push(AccountTargetNetworkNote::try_from(note)?); + all_notes.push( + AccountTargetNetworkNote::try_from(note) + .map_err(StoreError::DeserializationError)?, + ); } match resp.next_token { @@ -317,10 +322,9 @@ impl StoreClient { .into_iter() .map(|account_id| { let account_id = AccountId::read_from_bytes(&account_id.id).map_err(|err| { - StoreError::DeserializationError(ConversionError::deserialization_error( - "account_id", - err, - )) + StoreError::DeserializationError( + ConversionError::from(err).context("account_id"), + ) })?; NetworkAccountId::try_from(account_id).map_err(|_| { StoreError::MalformedResponse( @@ -330,12 +334,9 @@ impl StoreClient { }) .collect::, StoreError>>()?; - let pagination_info = response.pagination_info.ok_or( - ConversionError::MissingFieldInProtobufRepresentation { - entity: "NetworkAccountIdList", - field_name: "pagination_info", - }, - )?; + let pagination_info = response.pagination_info.ok_or(ConversionError::missing_field::< + proto::store::NetworkAccountIdList, + >("pagination_info"))?; Ok((accounts, pagination_info)) } @@ -406,8 +407,10 @@ impl StoreClient { let smt_opening = asset_witness.proof.ok_or_else(|| { StoreError::MalformedResponse("missing proof in vault asset witness".to_string()) })?; - let proof: SmtProof = - smt_opening.try_into().map_err(StoreError::DeserializationError)?; + let proof: SmtProof = smt_opening + .try_into() + .context("proof") + .map_err(StoreError::DeserializationError)?; let witness = AssetWitness::new(proof) .map_err(|err| StoreError::DeserializationError(ConversionError::from(err)))?; @@ -445,7 +448,10 @@ impl StoreClient { StoreError::MalformedResponse("missing proof in storage map witness".to_string()) })?; - let proof: SmtProof = smt_opening.try_into().map_err(StoreError::DeserializationError)?; + let proof: SmtProof = smt_opening + .try_into() + .context("proof") + .map_err(StoreError::DeserializationError)?; // Create the storage map witness using the proof and raw map key. let witness = StorageMapWitness::new(proof, [map_key]).map_err(|_err| { @@ -482,10 +488,11 @@ pub fn build_minimal_foreign_account( account_details: &AccountDetails, ) -> Result { // Derive account code. - let account_code_bytes = account_details - .account_code - .as_ref() - .ok_or(ConversionError::AccountCodeMissing)?; + let account_code_bytes = account_details.account_code.as_ref().ok_or_else(|| { + ConversionError::missing_field::( + "account_code", + ) + })?; let account_code = AccountCode::from_bytes(account_code_bytes)?; // Derive partial storage. Storage maps are not required for foreign accounts. diff --git a/crates/proto/src/domain/account.rs b/crates/proto/src/domain/account.rs index aeec888328..7bdf1721b1 100644 --- a/crates/proto/src/domain/account.rs +++ b/crates/proto/src/domain/account.rs @@ -25,7 +25,7 @@ use miden_standards::note::{NetworkAccountTarget, NetworkAccountTargetError}; use thiserror::Error; use super::try_convert; -use crate::errors::{ConversionError, MissingFieldHelper}; +use crate::errors::{ConversionError, ConversionResultExt, GrpcDecodeExt as _, grpc_decode}; use crate::generated::{self as proto}; #[cfg(test)] @@ -57,7 +57,8 @@ impl TryFrom for AccountId { type Error = ConversionError; fn try_from(account_id: proto::account::AccountId) -> Result { - AccountId::read_from_bytes(&account_id.id).map_err(|_| ConversionError::NotAValidFelt) + AccountId::read_from_bytes(&account_id.id) + .map_err(|_| ConversionError::message("value is not in the range 0..MODULUS")) } } @@ -114,6 +115,7 @@ impl From<&AccountInfo> for proto::account::AccountDetails { // ACCOUNT STORAGE HEADER //================================================================================================ +#[grpc_decode] impl TryFrom for AccountStorageHeader { type Error = ConversionError; @@ -125,11 +127,11 @@ impl TryFrom for AccountStorageHeader { .map(|slot| { let slot_name = StorageSlotName::new(slot.slot_name)?; let slot_type = storage_slot_type_from_raw(slot.slot_type)?; - let commitment = - slot.commitment.ok_or(ConversionError::NotAValidFelt)?.try_into()?; + let commitment = slot.commitment.decode()?; Ok(StorageSlotHeader::new(slot_name, slot_type, commitment)) }) - .collect::, ConversionError>>()?; + .collect::, ConversionError>>() + .context("slots")?; Ok(AccountStorageHeader::new(slot_headers)?) } @@ -146,18 +148,14 @@ pub struct AccountRequest { pub details: Option, } +#[grpc_decode] impl TryFrom for AccountRequest { type Error = ConversionError; fn try_from(value: proto::rpc::AccountRequest) -> Result { - let proto::rpc::AccountRequest { account_id, block_num, details } = value; - - let account_id = account_id - .ok_or(proto::rpc::AccountRequest::missing_field(stringify!(account_id)))? - .try_into()?; - let block_num = block_num.map(Into::into); - - let details = details.map(TryFrom::try_from).transpose()?; + let account_id = value.account_id.decode()?; + let block_num = value.block_num.map(Into::into); + let details = value.details.map(TryFrom::try_from).transpose().context("details")?; Ok(AccountRequest { account_id, block_num, details }) } @@ -170,6 +168,7 @@ pub struct AccountDetailRequest { pub storage_requests: Vec, } +#[grpc_decode] impl TryFrom for AccountDetailRequest { type Error = ConversionError; @@ -182,9 +181,14 @@ impl TryFrom for AccountDetai storage_maps, } = value; - let code_commitment = code_commitment.map(TryFrom::try_from).transpose()?; - let asset_vault_commitment = asset_vault_commitment.map(TryFrom::try_from).transpose()?; - let storage_requests = try_convert(storage_maps).collect::>()?; + let code_commitment = + code_commitment.map(TryFrom::try_from).transpose().context("code_commitment")?; + let asset_vault_commitment = asset_vault_commitment + .map(TryFrom::try_from) + .transpose() + .context("asset_vault_commitment")?; + let storage_requests = + try_convert(storage_maps).collect::>().context("storage_maps")?; Ok(AccountDetailRequest { code_commitment, @@ -200,6 +204,7 @@ pub struct StorageMapRequest { pub slot_data: SlotData, } +#[grpc_decode] impl TryFrom for StorageMapRequest { @@ -208,13 +213,8 @@ impl TryFrom Result { - let proto::rpc::account_request::account_detail_request::StorageMapDetailRequest { - slot_name, - slot_data, - } = value; - - let slot_name = StorageSlotName::new(slot_name)?; - let slot_data = slot_data.ok_or(proto::rpc::account_request::account_detail_request::StorageMapDetailRequest::missing_field(stringify!(slot_data)))?.try_into()?; + let slot_name = StorageSlotName::new(value.slot_name).context("slot_name")?; + let slot_data = value.slot_data.decode()?; Ok(StorageMapRequest { slot_name, slot_data }) } @@ -242,7 +242,7 @@ impl Ok(match value { ProtoSlotData::AllEntries(true) => SlotData::All, ProtoSlotData::AllEntries(false) => { - return Err(ConversionError::EnumDiscriminantOutOfRange); + return Err(ConversionError::message("enum variant discriminant out of range")); }, ProtoSlotData::MapKeys(keys) => { let keys = try_convert(keys.map_keys).collect::, _>>()?; @@ -255,31 +255,16 @@ impl // ACCOUNT HEADER CONVERSIONS //================================================================================================ +#[grpc_decode] impl TryFrom for AccountHeader { type Error = ConversionError; fn try_from(value: proto::account::AccountHeader) -> Result { - let proto::account::AccountHeader { - account_id, - vault_root, - storage_commitment, - code_commitment, - nonce, - } = value; - - let account_id = account_id - .ok_or(proto::account::AccountHeader::missing_field(stringify!(account_id)))? - .try_into()?; - let vault_root = vault_root - .ok_or(proto::account::AccountHeader::missing_field(stringify!(vault_root)))? - .try_into()?; - let storage_commitment = storage_commitment - .ok_or(proto::account::AccountHeader::missing_field(stringify!(storage_commitment)))? - .try_into()?; - let code_commitment = code_commitment - .ok_or(proto::account::AccountHeader::missing_field(stringify!(code_commitment)))? - .try_into()?; - let nonce = nonce.try_into().map_err(|_e| ConversionError::NotAValidFelt)?; + let account_id = value.account_id.decode()?; + let vault_root = value.vault_root.decode()?; + let storage_commitment = value.storage_commitment.decode()?; + let code_commitment = value.code_commitment.decode()?; + let nonce = value.nonce.try_into().map_err(ConversionError::message).context("nonce")?; Ok(AccountHeader::new( account_id, @@ -364,6 +349,7 @@ impl AccountVaultDetails { } } +#[grpc_decode] impl TryFrom for AccountVaultDetails { type Error = ConversionError; @@ -375,12 +361,10 @@ impl TryFrom for AccountVaultDetails { } else { let parsed_assets = Result::, ConversionError>::from_iter(assets.into_iter().map(|asset| { - let asset = asset - .asset - .ok_or(proto::primitives::Asset::missing_field(stringify!(asset)))?; - let asset = Word::try_from(asset)?; - Asset::try_from(asset).map_err(ConversionError::AssetError) - }))?; + let word: Word = asset.asset.decode()?; + Asset::try_from(word).map_err(ConversionError::from) + })) + .context("assets")?; Ok(Self::Assets(parsed_assets)) } } @@ -513,6 +497,7 @@ impl AccountStorageMapDetails { } } +#[grpc_decode] impl TryFrom for AccountStorageMapDetails { @@ -522,8 +507,6 @@ impl TryFrom value: proto::rpc::account_storage_details::AccountStorageMapDetails, ) -> Result { use proto::rpc::account_storage_details::account_storage_map_details::{ - all_map_entries::StorageMapEntry, - map_entries_with_proofs::StorageMapEntryWithProof, AllMapEntries, Entries as ProtoEntries, MapEntriesWithProofs, @@ -535,47 +518,35 @@ impl TryFrom entries, } = value; - let slot_name = StorageSlotName::new(slot_name)?; + let slot_name = StorageSlotName::new(slot_name).context("slot_name")?; let entries = if too_many_entries { StorageMapEntries::LimitExceeded } else { match entries { None => { - return Err( - proto::rpc::account_storage_details::AccountStorageMapDetails::missing_field( - stringify!(entries), - ), - ); + return Err(ConversionError::missing_field::< + proto::rpc::account_storage_details::AccountStorageMapDetails, + >("entries")); }, Some(ProtoEntries::AllEntries(AllMapEntries { entries })) => { let entries = entries .into_iter() .map(|entry| { - let key = entry - .key - .ok_or(StorageMapEntry::missing_field(stringify!(key)))? - .try_into() - .map(StorageMapKey::new)?; - let value = entry - .value - .ok_or(StorageMapEntry::missing_field(stringify!(value)))? - .try_into()?; + let key = StorageMapKey::new(entry.key.decode()?); + let value = entry.value.decode()?; Ok((key, value)) }) - .collect::, ConversionError>>()?; + .collect::, ConversionError>>() + .context("entries")?; StorageMapEntries::AllEntries(entries) }, Some(ProtoEntries::EntriesWithProofs(MapEntriesWithProofs { entries })) => { let proofs = entries .into_iter() - .map(|entry| { - let smt_opening = entry.proof.ok_or( - StorageMapEntryWithProof::missing_field(stringify!(proof)), - )?; - SmtProof::try_from(smt_opening) - }) - .collect::, ConversionError>>()?; + .map(|entry| entry.proof.decode()) + .collect::, ConversionError>>() + .context("entries")?; StorageMapEntries::EntriesWithProofs(proofs) }, } @@ -667,17 +638,15 @@ impl AccountStorageDetails { } } +#[grpc_decode] impl TryFrom for AccountStorageDetails { type Error = ConversionError; fn try_from(value: proto::rpc::AccountStorageDetails) -> Result { - let proto::rpc::AccountStorageDetails { header, map_details } = value; - - let header = header - .ok_or(proto::rpc::AccountStorageDetails::missing_field(stringify!(header)))? - .try_into()?; - - let map_details = try_convert(map_details).collect::, _>>()?; + let header = value.header.decode()?; + let map_details = try_convert(value.map_details) + .collect::, _>>() + .context("map_details")?; Ok(Self { header, map_details }) } @@ -694,11 +663,11 @@ impl From for proto::rpc::AccountStorageDetails { } } -const fn storage_slot_type_from_raw(slot_type: u32) -> Result { +fn storage_slot_type_from_raw(slot_type: u32) -> Result { Ok(match slot_type { 0 => StorageSlotType::Value, 1 => StorageSlotType::Map, - _ => return Err(ConversionError::EnumDiscriminantOutOfRange), + _ => return Err(ConversionError::message("enum variant discriminant out of range")), }) } @@ -719,21 +688,17 @@ pub struct AccountResponse { pub details: Option, } +#[grpc_decode] impl TryFrom for AccountResponse { type Error = ConversionError; fn try_from(value: proto::rpc::AccountResponse) -> Result { - let proto::rpc::AccountResponse { block_num, witness, details } = value; - - let block_num = block_num - .ok_or(proto::rpc::AccountResponse::missing_field(stringify!(block_num)))? + let block_num = value + .block_num + .ok_or(ConversionError::missing_field::("block_num"))? .into(); - - let witness = witness - .ok_or(proto::rpc::AccountResponse::missing_field(stringify!(witness)))? - .try_into()?; - - let details = details.map(TryFrom::try_from).transpose()?; + let witness = value.witness.decode()?; + let details = value.details.map(TryFrom::try_from).transpose().context("details")?; Ok(AccountResponse { block_num, witness, details }) } @@ -780,33 +745,15 @@ impl AccountDetails { } } +#[grpc_decode] impl TryFrom for AccountDetails { type Error = ConversionError; fn try_from(value: proto::rpc::account_response::AccountDetails) -> Result { - let proto::rpc::account_response::AccountDetails { - header, - code, - vault_details, - storage_details, - } = value; - - let account_header = header - .ok_or(proto::rpc::account_response::AccountDetails::missing_field(stringify!(header)))? - .try_into()?; - - let storage_details = storage_details - .ok_or(proto::rpc::account_response::AccountDetails::missing_field(stringify!( - storage_details - )))? - .try_into()?; - - let vault_details = vault_details - .ok_or(proto::rpc::account_response::AccountDetails::missing_field(stringify!( - vault_details - )))? - .try_into()?; - let account_code = code; + let account_header = value.header.decode()?; + let storage_details = value.storage_details.decode()?; + let vault_details = value.vault_details.decode()?; + let account_code = value.code; Ok(AccountDetails { account_header, @@ -843,25 +790,17 @@ impl From for proto::rpc::account_response::AccountDetails { // ACCOUNT WITNESS // ================================================================================================ +#[grpc_decode] impl TryFrom for AccountWitness { type Error = ConversionError; fn try_from(account_witness: proto::account::AccountWitness) -> Result { - let witness_id = account_witness - .witness_id - .ok_or(proto::account::AccountWitness::missing_field(stringify!(witness_id)))? - .try_into()?; - let commitment = account_witness - .commitment - .ok_or(proto::account::AccountWitness::missing_field(stringify!(commitment)))? - .try_into()?; - let path = account_witness - .path - .ok_or(proto::account::AccountWitness::missing_field(stringify!(path)))? - .try_into()?; + let witness_id = account_witness.witness_id.decode()?; + let commitment = account_witness.commitment.decode()?; + let path = account_witness.path.decode()?; AccountWitness::new(witness_id, commitment, path).map_err(|err| { - ConversionError::deserialization_error( + ConversionError::deserialization( "AccountWitness", DeserializationError::InvalidValue(err.to_string()), ) @@ -889,41 +828,26 @@ pub struct AccountWitnessRecord { pub witness: AccountWitness, } +#[grpc_decode] impl TryFrom for AccountWitnessRecord { type Error = ConversionError; fn try_from( account_witness_record: proto::account::AccountWitness, ) -> Result { - let witness_id = account_witness_record - .witness_id - .ok_or(proto::account::AccountWitness::missing_field(stringify!(witness_id)))? - .try_into()?; - let commitment = account_witness_record - .commitment - .ok_or(proto::account::AccountWitness::missing_field(stringify!(commitment)))? - .try_into()?; - let path: SparseMerklePath = account_witness_record - .path - .as_ref() - .ok_or(proto::account::AccountWitness::missing_field(stringify!(path)))? - .clone() - .try_into()?; + let witness_id = account_witness_record.witness_id.decode()?; + let commitment = account_witness_record.commitment.decode()?; + let account_id = account_witness_record.account_id.decode()?; + let path: SparseMerklePath = account_witness_record.path.decode()?; let witness = AccountWitness::new(witness_id, commitment, path).map_err(|err| { - ConversionError::deserialization_error( + ConversionError::deserialization( "AccountWitness", DeserializationError::InvalidValue(err.to_string()), ) })?; - Ok(Self { - account_id: account_witness_record - .account_id - .ok_or(proto::account::AccountWitness::missing_field(stringify!(account_id)))? - .try_into()?, - witness, - }) + Ok(Self { account_id, witness }) } } @@ -960,25 +884,16 @@ impl Display for AccountState { } } +#[grpc_decode] impl TryFrom for AccountState { type Error = ConversionError; fn try_from( from: proto::store::transaction_inputs::AccountTransactionInputRecord, ) -> Result { - let account_id = from - .account_id - .ok_or(proto::store::transaction_inputs::AccountTransactionInputRecord::missing_field( - stringify!(account_id), - ))? - .try_into()?; - - let account_commitment = from - .account_commitment - .ok_or(proto::store::transaction_inputs::AccountTransactionInputRecord::missing_field( - stringify!(account_commitment), - ))? - .try_into()?; + let account_id = from.account_id.decode()?; + + let account_commitment = from.account_commitment.decode()?; // If the commitment is equal to `Word::empty()`, it signifies that this is a new // account which is not yet present in the Store. @@ -1004,14 +919,13 @@ impl From for proto::store::transaction_inputs::AccountTransaction // ASSET // ================================================================================================ +#[grpc_decode] impl TryFrom for Asset { type Error = ConversionError; fn try_from(value: proto::primitives::Asset) -> Result { - let inner = value.asset.ok_or(proto::primitives::Asset::missing_field("asset"))?; - let word = Word::try_from(inner)?; - - Asset::try_from(word).map_err(ConversionError::AssetError) + let word: Word = value.asset.decode()?; + Asset::try_from(word).map_err(ConversionError::from) } } diff --git a/crates/proto/src/domain/batch.rs b/crates/proto/src/domain/batch.rs index 1cccf6ab8b..cc1aedceed 100644 --- a/crates/proto/src/domain/batch.rs +++ b/crates/proto/src/domain/batch.rs @@ -3,9 +3,15 @@ use std::collections::BTreeMap; use miden_protocol::block::BlockHeader; use miden_protocol::note::{NoteId, NoteInclusionProof}; use miden_protocol::transaction::PartialBlockchain; -use miden_protocol::utils::{Deserializable, Serializable}; +use miden_protocol::utils::Serializable; -use crate::errors::{ConversionError, MissingFieldHelper}; +use crate::errors::{ + ConversionError, + ConversionResultExt, + DecodeBytesExt, + GrpcDecodeExt as _, + grpc_decode, +}; use crate::generated as proto; /// Data required for a transaction batch. @@ -26,24 +32,23 @@ impl From for proto::store::BatchInputs { } } +#[grpc_decode] impl TryFrom for BatchInputs { type Error = ConversionError; fn try_from(response: proto::store::BatchInputs) -> Result { let result = Self { - batch_reference_block_header: response - .batch_reference_block_header - .ok_or(proto::store::BatchInputs::missing_field("block_header"))? - .try_into()?, + batch_reference_block_header: response.batch_reference_block_header.decode()?, note_proofs: response .note_proofs .iter() .map(<(NoteId, NoteInclusionProof)>::try_from) - .collect::>()?, - partial_block_chain: PartialBlockchain::read_from_bytes(&response.partial_block_chain) - .map_err(|source| { - ConversionError::deserialization_error("PartialBlockchain", source) - })?, + .collect::>() + .context("note_proofs")?, + partial_block_chain: PartialBlockchain::decode_bytes( + &response.partial_block_chain, + "PartialBlockchain", + )?, }; Ok(result) diff --git a/crates/proto/src/domain/block.rs b/crates/proto/src/domain/block.rs index 112f84e50b..7e994613c1 100644 --- a/crates/proto/src/domain/block.rs +++ b/crates/proto/src/domain/block.rs @@ -1,7 +1,6 @@ use std::collections::BTreeMap; use std::ops::RangeInclusive; -use miden_protocol::account::AccountId; use miden_protocol::block::nullifier_tree::NullifierWitness; use miden_protocol::block::{ BlockBody, @@ -14,10 +13,16 @@ use miden_protocol::block::{ use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; use miden_protocol::note::{NoteId, NoteInclusionProof}; use miden_protocol::transaction::PartialBlockchain; -use miden_protocol::utils::{Deserializable, Serializable}; +use miden_protocol::utils::Serializable; use thiserror::Error; -use crate::errors::{ConversionError, MissingFieldHelper}; +use crate::errors::{ + ConversionError, + ConversionResultExt, + DecodeBytesExt, + GrpcDecodeExt as _, + grpc_decode, +}; use crate::{AccountWitnessRecord, NullifierWitnessRecord, generated as proto}; // BLOCK NUMBER @@ -71,52 +76,33 @@ impl TryFrom<&proto::blockchain::BlockHeader> for BlockHeader { } } +#[grpc_decode] impl TryFrom for BlockHeader { type Error = ConversionError; fn try_from(value: proto::blockchain::BlockHeader) -> Result { + let prev_block_commitment = value.prev_block_commitment.decode()?; + let chain_commitment = value.chain_commitment.decode()?; + let account_root = value.account_root.decode()?; + let nullifier_root = value.nullifier_root.decode()?; + let note_root = value.note_root.decode()?; + let tx_commitment = value.tx_commitment.decode()?; + let tx_kernel_commitment = value.tx_kernel_commitment.decode()?; + let validator_key = value.validator_key.decode()?; + let fee_parameters = value.fee_parameters.decode()?; + Ok(BlockHeader::new( value.version, - value - .prev_block_commitment - .ok_or(proto::blockchain::BlockHeader::missing_field(stringify!( - prev_block_commitment - )))? - .try_into()?, + prev_block_commitment, value.block_num.into(), - value - .chain_commitment - .ok_or(proto::blockchain::BlockHeader::missing_field(stringify!(chain_commitment)))? - .try_into()?, - value - .account_root - .ok_or(proto::blockchain::BlockHeader::missing_field(stringify!(account_root)))? - .try_into()?, - value - .nullifier_root - .ok_or(proto::blockchain::BlockHeader::missing_field(stringify!(nullifier_root)))? - .try_into()?, - value - .note_root - .ok_or(proto::blockchain::BlockHeader::missing_field(stringify!(note_root)))? - .try_into()?, - value - .tx_commitment - .ok_or(proto::blockchain::BlockHeader::missing_field(stringify!(tx_commitment)))? - .try_into()?, - value - .tx_kernel_commitment - .ok_or(proto::blockchain::BlockHeader::missing_field(stringify!( - tx_kernel_commitment - )))? - .try_into()?, - value - .validator_key - .ok_or(proto::blockchain::BlockHeader::missing_field(stringify!(validator_key)))? - .try_into()?, - FeeParameters::try_from(value.fee_parameters.ok_or( - proto::blockchain::FeeParameters::missing_field(stringify!(fee_parameters)), - )?)?, + chain_commitment, + account_root, + nullifier_root, + note_root, + tx_commitment, + tx_kernel_commitment, + validator_key, + fee_parameters, value.timestamp, )) } @@ -148,8 +134,7 @@ impl TryFrom<&proto::blockchain::BlockBody> for BlockBody { impl TryFrom for BlockBody { type Error = ConversionError; fn try_from(value: proto::blockchain::BlockBody) -> Result { - BlockBody::read_from_bytes(&value.block_body) - .map_err(|source| ConversionError::deserialization_error("BlockBody", source)) + BlockBody::decode_bytes(&value.block_body, "BlockBody") } } @@ -180,21 +165,13 @@ impl TryFrom<&proto::blockchain::SignedBlock> for SignedBlock { } } +#[grpc_decode] impl TryFrom for SignedBlock { type Error = ConversionError; fn try_from(value: proto::blockchain::SignedBlock) -> Result { - let header = value - .header - .ok_or(proto::blockchain::SignedBlock::missing_field(stringify!(header)))? - .try_into()?; - let body = value - .body - .ok_or(proto::blockchain::SignedBlock::missing_field(stringify!(body)))? - .try_into()?; - let signature = value - .signature - .ok_or(proto::blockchain::SignedBlock::missing_field(stringify!(signature)))? - .try_into()?; + let header = value.header.decode()?; + let body = value.body.decode()?; + let signature = value.signature.decode()?; Ok(SignedBlock::new_unchecked(header, body, signature)) } @@ -235,14 +212,12 @@ impl From for proto::store::BlockInputs { } } +#[grpc_decode] impl TryFrom for BlockInputs { type Error = ConversionError; fn try_from(response: proto::store::BlockInputs) -> Result { - let latest_block_header: BlockHeader = response - .latest_block_header - .ok_or(proto::blockchain::BlockHeader::missing_field("block_header"))? - .try_into()?; + let latest_block_header: BlockHeader = response.latest_block_header.decode()?; let account_witnesses = response .account_witnesses @@ -251,7 +226,8 @@ impl TryFrom for BlockInputs { let witness_record: AccountWitnessRecord = entry.try_into()?; Ok((witness_record.account_id, witness_record.witness)) }) - .collect::, ConversionError>>()?; + .collect::, ConversionError>>() + .context("account_witnesses")?; let nullifier_witnesses = response .nullifier_witnesses @@ -260,18 +236,18 @@ impl TryFrom for BlockInputs { let witness: NullifierWitnessRecord = entry.try_into()?; Ok((witness.nullifier, NullifierWitness::new(witness.proof))) }) - .collect::, ConversionError>>()?; + .collect::, ConversionError>>() + .context("nullifier_witnesses")?; let unauthenticated_note_proofs = response .unauthenticated_note_proofs .iter() .map(<(NoteId, NoteInclusionProof)>::try_from) - .collect::>()?; + .collect::>() + .context("unauthenticated_note_proofs")?; - let partial_block_chain = PartialBlockchain::read_from_bytes(&response.partial_block_chain) - .map_err(|source| { - ConversionError::deserialization_error("PartialBlockchain", source) - })?; + let partial_block_chain = + PartialBlockchain::decode_bytes(&response.partial_block_chain, "PartialBlockchain")?; Ok(BlockInputs::new( latest_block_header, @@ -289,8 +265,7 @@ impl TryFrom for BlockInputs { impl TryFrom for PublicKey { type Error = ConversionError; fn try_from(public_key: proto::blockchain::ValidatorPublicKey) -> Result { - PublicKey::read_from_bytes(&public_key.validator_key) - .map_err(|source| ConversionError::deserialization_error("PublicKey", source)) + PublicKey::decode_bytes(&public_key.validator_key, "PublicKey") } } @@ -312,8 +287,7 @@ impl From<&PublicKey> for proto::blockchain::ValidatorPublicKey { impl TryFrom for Signature { type Error = ConversionError; fn try_from(signature: proto::blockchain::BlockSignature) -> Result { - Signature::read_from_bytes(&signature.signature) - .map_err(|source| ConversionError::deserialization_error("Signature", source)) + Signature::decode_bytes(&signature.signature, "Signature") } } @@ -332,12 +306,11 @@ impl From<&Signature> for proto::blockchain::BlockSignature { // FEE PARAMETERS // ================================================================================================ +#[grpc_decode] impl TryFrom for FeeParameters { type Error = ConversionError; fn try_from(fee_params: proto::blockchain::FeeParameters) -> Result { - let native_asset_id = fee_params.native_asset_id.map(AccountId::try_from).ok_or( - proto::blockchain::FeeParameters::missing_field(stringify!(native_asset_id)), - )??; + let native_asset_id = fee_params.native_asset_id.decode()?; let fee_params = FeeParameters::new(native_asset_id, fee_params.verification_base_fee)?; Ok(fee_params) } diff --git a/crates/proto/src/domain/digest.rs b/crates/proto/src/domain/digest.rs index 08d8c3f9a1..2815cef093 100644 --- a/crates/proto/src/domain/digest.rs +++ b/crates/proto/src/domain/digest.rs @@ -65,12 +65,12 @@ impl FromHex for proto::primitives::Digest { let data = hex::decode(hex)?; match data.len() { - size if size < DIGEST_DATA_SIZE => { - Err(ConversionError::InsufficientData { expected: DIGEST_DATA_SIZE, got: size }) - }, - size if size > DIGEST_DATA_SIZE => { - Err(ConversionError::TooMuchData { expected: DIGEST_DATA_SIZE, got: size }) - }, + size if size < DIGEST_DATA_SIZE => Err(ConversionError::message(format!( + "not enough data, expected {DIGEST_DATA_SIZE}, got {size}" + ))), + size if size > DIGEST_DATA_SIZE => Err(ConversionError::message(format!( + "too much data, expected {DIGEST_DATA_SIZE}, got {size}" + ))), _ => { let d0 = u64::from_be_bytes(data[..8].try_into().unwrap()); let d1 = u64::from_be_bytes(data[8..16].try_into().unwrap()); @@ -178,7 +178,7 @@ impl TryFrom for [Felt; 4] { .iter() .any(|v| *v >= ::MODULUS) { - return Err(ConversionError::NotAValidFelt); + return Err(ConversionError::message("value is not in the range 0..MODULUS")); } Ok([ diff --git a/crates/proto/src/domain/mempool.rs b/crates/proto/src/domain/mempool.rs index c9bf76bfc9..e87336d5b0 100644 --- a/crates/proto/src/domain/mempool.rs +++ b/crates/proto/src/domain/mempool.rs @@ -4,10 +4,16 @@ use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::block::BlockHeader; use miden_protocol::note::Nullifier; use miden_protocol::transaction::TransactionId; -use miden_protocol::utils::{Deserializable, Serializable}; +use miden_protocol::utils::Serializable; use miden_standards::note::AccountTargetNetworkNote; -use crate::errors::{ConversionError, MissingFieldHelper}; +use crate::errors::{ + ConversionError, + ConversionResultExt, + DecodeBytesExt, + GrpcDecodeExt as _, + grpc_decode, +}; use crate::generated as proto; #[derive(Debug, Clone)] @@ -78,34 +84,35 @@ impl From for proto::block_producer::MempoolEvent { } } +#[grpc_decode] impl TryFrom for MempoolEvent { type Error = ConversionError; fn try_from(event: proto::block_producer::MempoolEvent) -> Result { - let event = - event.event.ok_or(proto::block_producer::MempoolEvent::missing_field("event"))?; + let event = event.event.ok_or(ConversionError::missing_field::< + proto::block_producer::MempoolEvent, + >("event"))?; match event { proto::block_producer::mempool_event::Event::TransactionAdded(tx) => { - let id = tx - .id - .ok_or(proto::block_producer::mempool_event::TransactionAdded::missing_field( - "id", - ))? - .try_into()?; - let nullifiers = - tx.nullifiers.into_iter().map(TryInto::try_into).collect::>()?; + let id = tx.id.decode()?; + let nullifiers = tx + .nullifiers + .into_iter() + .map(Nullifier::try_from) + .collect::>() + .context("nullifiers")?; let network_notes = tx .network_notes .into_iter() - .map(TryInto::try_into) - .collect::>()?; + .map(AccountTargetNetworkNote::try_from) + .collect::>() + .context("network_notes")?; let account_delta = tx .network_account_delta .as_deref() - .map(AccountUpdateDetails::read_from_bytes) - .transpose() - .map_err(|err| ConversionError::deserialization_error("account_delta", err))?; + .map(|bytes| AccountUpdateDetails::decode_bytes(bytes, "account_delta")) + .transpose()?; Ok(Self::TransactionAdded { id, @@ -115,18 +122,14 @@ impl TryFrom for MempoolEvent { }) }, proto::block_producer::mempool_event::Event::BlockCommitted(block_committed) => { - let header = block_committed - .block_header - .ok_or(proto::block_producer::mempool_event::BlockCommitted::missing_field( - "block_header", - ))? - .try_into()?; + let header = block_committed.block_header.decode()?; let header = Box::new(header); let txs = block_committed .transactions .into_iter() .map(TransactionId::try_from) - .collect::>()?; + .collect::>() + .context("transactions")?; Ok(Self::BlockCommitted { header, txs }) }, @@ -135,7 +138,8 @@ impl TryFrom for MempoolEvent { .reverted .into_iter() .map(TransactionId::try_from) - .collect::>()?; + .collect::>() + .context("reverted")?; Ok(Self::TransactionsReverted(txs)) }, diff --git a/crates/proto/src/domain/merkle.rs b/crates/proto/src/domain/merkle.rs index ed14d523ba..287fdb6597 100644 --- a/crates/proto/src/domain/merkle.rs +++ b/crates/proto/src/domain/merkle.rs @@ -4,7 +4,7 @@ use miden_protocol::crypto::merkle::smt::{LeafIndex, SmtLeaf, SmtProof}; use miden_protocol::crypto::merkle::{MerklePath, SparseMerklePath}; use crate::domain::{convert, try_convert}; -use crate::errors::{ConversionError, MissingFieldHelper}; +use crate::errors::{ConversionError, ConversionResultExt, GrpcDecodeExt as _, grpc_decode}; use crate::generated as proto; // MERKLE PATH @@ -62,7 +62,8 @@ impl TryFrom for SparseMerklePath { .siblings .into_iter() .map(Word::try_from) - .collect::, _>>()?, + .collect::, _>>() + .context("siblings")?, )?) } } @@ -84,12 +85,16 @@ impl TryFrom for MmrDelta { type Error = ConversionError; fn try_from(value: proto::primitives::MmrDelta) -> Result { - let data: Result, ConversionError> = - value.data.into_iter().map(Word::try_from).collect(); + let data: Vec<_> = value + .data + .into_iter() + .map(Word::try_from) + .collect::>() + .context("data")?; Ok(MmrDelta { forest: Forest::new(value.forest as usize), - data: data?, + data, }) } } @@ -104,20 +109,22 @@ impl TryFrom for SmtLeaf { type Error = ConversionError; fn try_from(value: proto::primitives::SmtLeaf) -> Result { - let leaf = value.leaf.ok_or(proto::primitives::SmtLeaf::missing_field(stringify!(leaf)))?; + let leaf = value + .leaf + .ok_or(ConversionError::missing_field::("leaf"))?; match leaf { proto::primitives::smt_leaf::Leaf::EmptyLeafIndex(leaf_index) => { Ok(Self::new_empty(LeafIndex::new_max_depth(leaf_index))) }, proto::primitives::smt_leaf::Leaf::Single(entry) => { - let (key, value): (Word, Word) = entry.try_into()?; + let (key, value): (Word, Word) = entry.try_into().context("entry")?; Ok(SmtLeaf::new_single(key, value)) }, proto::primitives::smt_leaf::Leaf::Multiple(entries) => { let domain_entries: Vec<(Word, Word)> = - try_convert(entries.entries).collect::>()?; + try_convert(entries.entries).collect::>().context("entries")?; Ok(SmtLeaf::new_multiple(domain_entries)?) }, @@ -144,18 +151,13 @@ impl From for proto::primitives::SmtLeaf { // SMT LEAF ENTRY // ------------------------------------------------------------------------------------------------ +#[grpc_decode] impl TryFrom for (Word, Word) { type Error = ConversionError; fn try_from(entry: proto::primitives::SmtLeafEntry) -> Result { - let key: Word = entry - .key - .ok_or(proto::primitives::SmtLeafEntry::missing_field(stringify!(key)))? - .try_into()?; - let value: Word = entry - .value - .ok_or(proto::primitives::SmtLeafEntry::missing_field(stringify!(value)))? - .try_into()?; + let key: Word = entry.key.decode()?; + let value: Word = entry.value.decode()?; Ok((key, value)) } @@ -173,18 +175,13 @@ impl From<(Word, Word)> for proto::primitives::SmtLeafEntry { // SMT PROOF // ------------------------------------------------------------------------------------------------ +#[grpc_decode] impl TryFrom for SmtProof { type Error = ConversionError; fn try_from(opening: proto::primitives::SmtOpening) -> Result { - let path: SparseMerklePath = opening - .path - .ok_or(proto::primitives::SmtOpening::missing_field(stringify!(path)))? - .try_into()?; - let leaf: SmtLeaf = opening - .leaf - .ok_or(proto::primitives::SmtOpening::missing_field(stringify!(leaf)))? - .try_into()?; + let path: SparseMerklePath = opening.path.decode()?; + let leaf: SmtLeaf = opening.leaf.decode()?; Ok(SmtProof::new(path, leaf)?) } diff --git a/crates/proto/src/domain/note.rs b/crates/proto/src/domain/note.rs index 6a750a6582..3c7dd3cbe2 100644 --- a/crates/proto/src/domain/note.rs +++ b/crates/proto/src/domain/note.rs @@ -13,11 +13,17 @@ use miden_protocol::note::{ NoteTag, NoteType, }; -use miden_protocol::utils::{Deserializable, Serializable}; +use miden_protocol::utils::Serializable; use miden_protocol::{MastForest, MastNodeId, Word}; use miden_standards::note::AccountTargetNetworkNote; -use crate::errors::{ConversionError, MissingFieldHelper}; +use crate::errors::{ + ConversionError, + ConversionResultExt, + DecodeBytesExt, + GrpcDecodeExt as _, + grpc_decode, +}; use crate::generated as proto; // NOTE TYPE @@ -39,7 +45,9 @@ impl TryFrom for NoteType { match note_type { proto::note::NoteType::Public => Ok(NoteType::Public), proto::note::NoteType::Private => Ok(NoteType::Private), - proto::note::NoteType::Unspecified => Err(ConversionError::EnumDiscriminantOutOfRange), + proto::note::NoteType::Unspecified => { + Err(ConversionError::message("enum variant discriminant out of range")) + }, } } } @@ -47,25 +55,23 @@ impl TryFrom for NoteType { // NOTE METADATA // ================================================================================================ +#[grpc_decode] impl TryFrom for NoteMetadata { type Error = ConversionError; fn try_from(value: proto::note::NoteMetadata) -> Result { - let sender = value - .sender - .ok_or_else(|| proto::note::NoteMetadata::missing_field(stringify!(sender)))? - .try_into()?; + let sender = value.sender.decode()?; let note_type = proto::note::NoteType::try_from(value.note_type) - .map_err(|_| ConversionError::EnumDiscriminantOutOfRange)? - .try_into()?; + .map_err(|_| ConversionError::message("enum variant discriminant out of range"))? + .try_into() + .context("note_type")?; let tag = NoteTag::new(value.tag); // Deserialize attachment if present let attachment = if value.attachment.is_empty() { NoteAttachment::default() } else { - NoteAttachment::read_from_bytes(&value.attachment) - .map_err(|err| ConversionError::deserialization_error("NoteAttachment", err))? + NoteAttachment::decode_bytes(&value.attachment, "NoteAttachment")? }; Ok(NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment)) @@ -100,19 +106,16 @@ impl From for proto::note::NetworkNote { } } +#[grpc_decode] impl TryFrom for AccountTargetNetworkNote { type Error = ConversionError; fn try_from(value: proto::note::NetworkNote) -> Result { - let details = NoteDetails::read_from_bytes(&value.details) - .map_err(|err| ConversionError::deserialization_error("NoteDetails", err))?; + let details = NoteDetails::decode_bytes(&value.details, "NoteDetails")?; let (assets, recipient) = details.into_parts(); - let metadata: NoteMetadata = value - .metadata - .ok_or_else(|| proto::note::NetworkNote::missing_field(stringify!(metadata)))? - .try_into()?; + let metadata: NoteMetadata = value.metadata.decode()?; let note = Note::new(assets, metadata, recipient); - AccountTargetNetworkNote::new(note).map_err(ConversionError::NetworkNoteError) + AccountTargetNetworkNote::new(note).map_err(ConversionError::from) } } @@ -140,7 +143,7 @@ impl TryFrom for Word { note_id .id .as_ref() - .ok_or(proto::note::NoteId::missing_field(stringify!(id)))? + .ok_or(ConversionError::missing_field::("id"))? .try_into() } } @@ -172,48 +175,49 @@ impl TryFrom<&proto::note::NoteInclusionInBlockProof> for (NoteId, NoteInclusion proof .inclusion_path .as_ref() - .ok_or(proto::note::NoteInclusionInBlockProof::missing_field(stringify!( - inclusion_path - )))? + .ok_or(ConversionError::missing_field::( + "inclusion_path", + ))? .clone(), - )?; + ) + .context("inclusion_path")?; let note_id = Word::try_from( proof .note_id .as_ref() - .ok_or(proto::note::NoteInclusionInBlockProof::missing_field(stringify!(note_id)))? + .ok_or(ConversionError::missing_field::( + "note_id", + ))? .id .as_ref() - .ok_or(proto::note::NoteId::missing_field(stringify!(id)))?, - )?; + .ok_or(ConversionError::missing_field::("id"))?, + ) + .context("note_id")?; Ok(( NoteId::from_raw(note_id), NoteInclusionProof::new( proof.block_num.into(), - proof.note_index_in_block.try_into()?, + proof.note_index_in_block.try_into().context("note_index_in_block")?, inclusion_path, )?, )) } } +#[grpc_decode] impl TryFrom for Note { type Error = ConversionError; fn try_from(proto_note: proto::note::Note) -> Result { - let metadata: NoteMetadata = proto_note - .metadata - .ok_or(proto::note::Note::missing_field(stringify!(metadata)))? - .try_into()?; + let metadata: NoteMetadata = proto_note.metadata.decode()?; let details = proto_note .details - .ok_or(proto::note::Note::missing_field(stringify!(details)))?; + .ok_or(ConversionError::missing_field::("details"))?; - let note_details = NoteDetails::read_from_bytes(&details) - .map_err(|err| ConversionError::deserialization_error("NoteDetails", err))?; + let note_details = NoteDetails::decode_bytes(&details, "NoteDetails")?; let (assets, recipient) = note_details.into_parts(); Ok(Note::new(assets, metadata, recipient)) @@ -232,18 +236,13 @@ impl From for proto::note::NoteHeader { } } +#[grpc_decode] impl TryFrom for NoteHeader { type Error = ConversionError; fn try_from(value: proto::note::NoteHeader) -> Result { - let note_id_word: Word = value - .note_id - .ok_or_else(|| proto::note::NoteHeader::missing_field(stringify!(note_id)))? - .try_into()?; - let metadata: NoteMetadata = value - .metadata - .ok_or_else(|| proto::note::NoteHeader::missing_field(stringify!(metadata)))? - .try_into()?; + let note_id_word: Word = value.note_id.decode()?; + let metadata: NoteMetadata = value.metadata.decode()?; Ok(NoteHeader::new(NoteId::from_raw(note_id_word), metadata)) } @@ -267,10 +266,9 @@ impl TryFrom for NoteScript { fn try_from(value: proto::note::NoteScript) -> Result { let proto::note::NoteScript { entrypoint, mast } = value; - let mast = MastForest::read_from_bytes(&mast) - .map_err(|err| Self::Error::deserialization_error("note_script.mast", err))?; + let mast = MastForest::decode_bytes(&mast, "note_script.mast")?; let entrypoint = MastNodeId::from_u32_safe(entrypoint, &mast) - .map_err(|err| Self::Error::deserialization_error("note_script.entrypoint", err))?; + .map_err(|err| ConversionError::deserialization("note_script.entrypoint", err))?; Ok(Self::from_parts(Arc::new(mast), entrypoint)) } diff --git a/crates/proto/src/domain/nullifier.rs b/crates/proto/src/domain/nullifier.rs index 3ccdf88bae..675d127ff3 100644 --- a/crates/proto/src/domain/nullifier.rs +++ b/crates/proto/src/domain/nullifier.rs @@ -2,7 +2,7 @@ use miden_protocol::Word; use miden_protocol::crypto::merkle::smt::SmtProof; use miden_protocol::note::Nullifier; -use crate::errors::{ConversionError, MissingFieldHelper}; +use crate::errors::{ConversionError, GrpcDecodeExt as _, grpc_decode}; use crate::generated as proto; // FROM NULLIFIER @@ -41,6 +41,7 @@ pub struct NullifierWitnessRecord { pub proof: SmtProof, } +#[grpc_decode] impl TryFrom for NullifierWitnessRecord { type Error = ConversionError; @@ -48,18 +49,8 @@ impl TryFrom for NullifierWitnessR nullifier_witness_record: proto::store::block_inputs::NullifierWitness, ) -> Result { Ok(Self { - nullifier: nullifier_witness_record - .nullifier - .ok_or(proto::store::block_inputs::NullifierWitness::missing_field(stringify!( - nullifier - )))? - .try_into()?, - proof: nullifier_witness_record - .opening - .ok_or(proto::store::block_inputs::NullifierWitness::missing_field(stringify!( - opening - )))? - .try_into()?, + nullifier: nullifier_witness_record.nullifier.decode()?, + proof: nullifier_witness_record.opening.decode()?, }) } } diff --git a/crates/proto/src/domain/transaction.rs b/crates/proto/src/domain/transaction.rs index 62052eb633..824236417a 100644 --- a/crates/proto/src/domain/transaction.rs +++ b/crates/proto/src/domain/transaction.rs @@ -1,9 +1,15 @@ use miden_protocol::Word; use miden_protocol::note::Nullifier; use miden_protocol::transaction::{InputNoteCommitment, TransactionId}; -use miden_protocol::utils::{Deserializable, Serializable}; - -use crate::errors::{ConversionError, MissingFieldHelper}; +use miden_protocol::utils::Serializable; + +use crate::errors::{ + ConversionError, + ConversionResultExt, + DecodeBytesExt, + GrpcDecodeExt as _, + grpc_decode, +}; use crate::generated as proto; // FROM TRANSACTION ID @@ -45,17 +51,12 @@ impl TryFrom for TransactionId { } } +#[grpc_decode] impl TryFrom for TransactionId { type Error = ConversionError; fn try_from(value: proto::transaction::TransactionId) -> Result { - value - .id - .ok_or(ConversionError::MissingFieldInProtobufRepresentation { - entity: "TransactionId", - field_name: "id", - })? - .try_into() + value.id.decode() } } @@ -71,19 +72,15 @@ impl From for proto::transaction::InputNoteCommitment { } } +#[grpc_decode] impl TryFrom for InputNoteCommitment { type Error = ConversionError; fn try_from(value: proto::transaction::InputNoteCommitment) -> Result { - let nullifier: Nullifier = value - .nullifier - .ok_or_else(|| { - proto::transaction::InputNoteCommitment::missing_field(stringify!(nullifier)) - })? - .try_into()?; + let nullifier: Nullifier = value.nullifier.decode()?; let header: Option = - value.header.map(TryInto::try_into).transpose()?; + value.header.map(TryInto::try_into).transpose().context("header")?; // TODO: https://github.com/0xMiden/node/issues/1783 // InputNoteCommitment has private fields, so we reconstruct it via @@ -91,7 +88,6 @@ impl TryFrom for InputNoteCommitment { let mut bytes = Vec::new(); nullifier.write_into(&mut bytes); header.write_into(&mut bytes); - InputNoteCommitment::read_from_bytes(&bytes) - .map_err(|err| ConversionError::deserialization_error("InputNoteCommitment", err)) + InputNoteCommitment::decode_bytes(&bytes, "InputNoteCommitment") } } diff --git a/crates/proto/src/errors/mod.rs b/crates/proto/src/errors/mod.rs index 04493e6960..c073e2da90 100644 --- a/crates/proto/src/errors/mod.rs +++ b/crates/proto/src/errors/mod.rs @@ -1,79 +1,99 @@ use std::any::type_name; -use std::num::TryFromIntError; +use std::fmt; +use std::marker::PhantomData; // Re-export the GrpcError derive macro for convenience -pub use miden_node_grpc_error_macro::GrpcError; -use miden_protocol::crypto::merkle::smt::{SmtLeafError, SmtProofError}; -use miden_protocol::errors::{AccountError, AssetError, FeeError, NoteError, StorageSlotNameError}; +pub use miden_node_grpc_error_macro::{GrpcError, grpc_decode}; use miden_protocol::utils::DeserializationError; -use miden_standards::note::NetworkAccountTargetError; -use thiserror::Error; +#[cfg(test)] +mod test_grpc_decode; #[cfg(test)] mod test_macro; -#[derive(Debug, Error)] -pub enum ConversionError { - #[error("asset error")] - AssetError(#[from] AssetError), - #[error("account code missing")] - AccountCodeMissing, - #[error("account error")] - AccountError(#[from] AccountError), - #[error("fee parameters error")] - FeeError(#[from] FeeError), - #[error("hex error")] - HexError(#[from] hex::FromHexError), - #[error("note error")] - NoteError(#[from] NoteError), - #[error("network note error")] - NetworkNoteError(#[source] NetworkAccountTargetError), - #[error("SMT leaf error")] - SmtLeafError(#[from] SmtLeafError), - #[error("SMT proof error")] - SmtProofError(#[from] SmtProofError), - #[error("storage slot name error")] - StorageSlotNameError(#[from] StorageSlotNameError), - #[error("integer conversion error: {0}")] - TryFromIntError(#[from] TryFromIntError), - #[error("too much data, expected {expected}, got {got}")] - TooMuchData { expected: usize, got: usize }, - #[error("not enough data, expected {expected}, got {got}")] - InsufficientData { expected: usize, got: usize }, - #[error("value is not in the range 0..MODULUS")] - NotAValidFelt, - #[error("merkle error")] - MerkleError(#[from] miden_protocol::crypto::merkle::MerkleError), - #[error("field `{entity}::{field_name}` is missing")] - MissingFieldInProtobufRepresentation { - entity: &'static str, - field_name: &'static str, - }, - #[error("failed to deserialize {entity}")] - DeserializationError { - entity: &'static str, - source: DeserializationError, - }, - #[error("enum variant discriminant out of range")] - EnumDiscriminantOutOfRange, +// CONVERSION ERROR +// ================================================================================================ + +/// Opaque error for protobuf-to-domain conversions. +/// +/// Captures an underlying error plus an optional field path stack that describes which nested +/// field caused the error (e.g. `"block.header.account_root: value is not in range 0..MODULUS"`). +/// +/// Always maps to [`tonic::Status::invalid_argument()`]. +#[derive(Debug)] +pub struct ConversionError { + path: Vec<&'static str>, + source: Box, } impl ConversionError { - pub fn deserialization_error(entity: &'static str, source: DeserializationError) -> Self { - Self::DeserializationError { entity, source } + /// Create a new `ConversionError` wrapping any error source. + pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self { + path: Vec::new(), + source: Box::new(source), + } + } + + /// Add field context to the error path. + /// + /// Called from inner to outer, so the path accumulates in reverse + /// (outermost field pushed last). + /// + /// Use this to annotate errors from `try_into()` / `try_from()` where the underlying + /// error has no knowledge of which field it originated from. Do not use it with + /// [`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); + self + } + + /// Create a "missing field" error for a protobuf message type `T`. + pub fn missing_field(field_name: &'static str) -> Self { + Self { + path: Vec::new(), + source: Box::new(MissingFieldError { entity: type_name::(), field_name }), + } + } + + /// Create a deserialization error for a named entity. + pub fn deserialization(entity: &'static str, source: DeserializationError) -> Self { + Self { + path: Vec::new(), + source: Box::new(DeserializationErrorWrapper { entity, source }), + } } -} -pub trait MissingFieldHelper { - fn missing_field(field_name: &'static str) -> ConversionError; + /// Create a `ConversionError` from an ad-hoc error message. + pub fn message(msg: impl Into) -> Self { + Self { + path: Vec::new(), + source: Box::new(StringError(msg.into())), + } + } } -impl MissingFieldHelper for T { - fn missing_field(field_name: &'static str) -> ConversionError { - ConversionError::MissingFieldInProtobufRepresentation { - entity: type_name::(), - field_name, +impl fmt::Display for ConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if !self.path.is_empty() { + // Path was pushed inner-to-outer, so reverse for display. + for (i, segment) in self.path.iter().rev().enumerate() { + if i > 0 { + f.write_str(".")?; + } + f.write_str(segment)?; + } + f.write_str(": ")?; } + write!(f, "{}", self.source) + } +} + +impl std::error::Error for ConversionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&*self.source) } } @@ -82,3 +102,274 @@ impl From for tonic::Status { tonic::Status::invalid_argument(value.to_string()) } } + +// INTERNAL HELPER ERROR TYPES +// ================================================================================================ + +#[derive(Debug)] +struct MissingFieldError { + entity: &'static str, + field_name: &'static str, +} + +impl fmt::Display for MissingFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "field `{}::{}` is missing", self.entity, self.field_name) + } +} + +impl std::error::Error for MissingFieldError {} + +#[derive(Debug)] +struct DeserializationErrorWrapper { + entity: &'static str, + source: DeserializationError, +} + +impl fmt::Display for DeserializationErrorWrapper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "failed to deserialize {}: {}", self.entity, self.source) + } +} + +impl std::error::Error for DeserializationErrorWrapper { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +#[derive(Debug)] +struct StringError(String); + +impl fmt::Display for StringError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for StringError {} + +// CONVERSION RESULT EXTENSION TRAIT +// ================================================================================================ + +/// Extension trait to ergonomically add field context to [`ConversionError`] results. +/// +/// This makes it easy to inject field names into the error path at each `?` site: +/// +/// ```rust,ignore +/// let account_root = value.account_root +/// .ok_or(ConversionError::missing_field::("account_root"))? +/// .try_into() +/// .context("account_root")?; +/// ``` +/// +/// The context stacks automatically through nested conversions, producing error paths like +/// `"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; +} + +impl> ConversionResultExt for Result { + fn context(self, field: &'static str) -> Result { + self.map_err(|e| e.into().context(field)) + } +} + +// GRPC STRUCT DECODER +// ================================================================================================ + +/// Zero-cost struct decoder that captures the parent proto message type. +/// +/// Created via [`GrpcDecodeExt::decoder`] which infers the parent type from the value: +/// +/// ```rust,ignore +/// // Before: +/// let body = block.body.try_convert_field::("body")?; +/// let header = block.header.try_convert_field::("header")?; +/// +/// // After: +/// let decoder = block.decoder(); +/// let body = decoder.decode_field("body", block.body)?; +/// let header = decoder.decode_field("header", block.header)?; +/// ``` +pub struct GrpcStructDecoder(PhantomData); + +impl Default for GrpcStructDecoder { + /// Create a decoder for the given parent message type directly. + /// + /// Prefer [`GrpcDecodeExt::decoder`] when a value of type `M` is available, as it infers + /// the type automatically. + fn default() -> Self { + Self(PhantomData) + } +} + +impl GrpcStructDecoder { + /// Decode a required optional field: checks for `None`, converts via `TryInto`, and adds + /// field context on error. + pub fn decode_field( + &self, + name: &'static str, + value: Option, + ) -> Result + where + T: TryInto, + T::Error: Into, + { + value + .ok_or_else(|| ConversionError::missing_field::(name))? + .try_into() + .context(name) + } +} + +/// Extension trait on [`prost::Message`] types to create a [`GrpcStructDecoder`] with the parent +/// type inferred from the value. +pub trait GrpcDecodeExt: prost::Message + Sized { + /// Create a decoder that uses `Self` as the parent message type for error reporting. + fn decoder(&self) -> GrpcStructDecoder { + GrpcStructDecoder(PhantomData) + } +} + +impl GrpcDecodeExt for T {} + +// BYTE DESERIALIZATION EXTENSION TRAIT +// ================================================================================================ + +/// Extension trait on [`Deserializable`](miden_protocol::utils::Deserializable) types to +/// deserialize from bytes and wrap errors as [`ConversionError`]. +/// +/// This removes the boilerplate of calling `T::read_from_bytes(&bytes)` followed by +/// `.map_err(|source| ConversionError::deserialization("T", source))`: +/// +/// ```rust,ignore +/// // Before: +/// BlockBody::read_from_bytes(&value.block_body) +/// .map_err(|source| ConversionError::deserialization("BlockBody", source)) +/// +/// // After: +/// BlockBody::decode_bytes(&value.block_body, "BlockBody") +/// ``` +pub trait DecodeBytesExt: miden_protocol::utils::Deserializable { + /// Deserialize from bytes, wrapping any error as a [`ConversionError`]. + fn decode_bytes(bytes: &[u8], entity: &'static str) -> Result { + Self::read_from_bytes(bytes) + .map_err(|source| ConversionError::deserialization(entity, source)) + } +} + +impl DecodeBytesExt for T {} + +// FROM IMPLS FOR EXTERNAL ERROR TYPES +// ================================================================================================ + +macro_rules! impl_from_for_conversion_error { + ($($ty:ty),* $(,)?) => { + $( + impl From<$ty> for ConversionError { + fn from(e: $ty) -> Self { + Self::new(e) + } + } + )* + }; +} + +impl_from_for_conversion_error!( + hex::FromHexError, + miden_protocol::errors::AccountError, + miden_protocol::errors::AssetError, + miden_protocol::errors::AssetVaultError, + miden_protocol::errors::FeeError, + miden_protocol::errors::NoteError, + miden_protocol::errors::StorageSlotNameError, + miden_protocol::crypto::merkle::MerkleError, + miden_protocol::crypto::merkle::smt::SmtLeafError, + miden_protocol::crypto::merkle::smt::SmtProofError, + miden_standards::note::NetworkAccountTargetError, + std::num::TryFromIntError, + DeserializationError, +); + +#[cfg(test)] +mod tests { + use super::*; + + /// Simulates a deeply nested conversion where each layer adds its field context. + fn inner_conversion() -> Result<(), ConversionError> { + Err(ConversionError::message("value is not in range 0..MODULUS")) + } + + fn outer_conversion() -> Result<(), ConversionError> { + inner_conversion().context("account_root").context("header") + } + + #[test] + fn test_context_builds_dotted_field_path() { + let err = outer_conversion().unwrap_err(); + assert_eq!(err.to_string(), "header.account_root: value is not in range 0..MODULUS"); + } + + #[test] + fn test_context_single_field() { + let err = inner_conversion().context("nullifier").unwrap_err(); + assert_eq!(err.to_string(), "nullifier: value is not in range 0..MODULUS"); + } + + #[test] + fn test_context_deep_nesting() { + let err = outer_conversion().context("block").context("response").unwrap_err(); + assert_eq!( + err.to_string(), + "response.block.header.account_root: value is not in range 0..MODULUS" + ); + } + + #[test] + fn test_no_context_shows_source_only() { + let err = inner_conversion().unwrap_err(); + assert_eq!(err.to_string(), "value is not in range 0..MODULUS"); + } + + #[test] + fn test_context_on_external_error_type() { + let result: Result = u8::try_from(256u16); + let err = result.context("fee_amount").unwrap_err(); + assert!(err.to_string().starts_with("fee_amount: "), "expected field prefix, got: {err}",); + } + + #[test] + fn test_decode_field_missing() { + use miden_protocol::Felt; + + use crate::generated::primitives::Digest; + + let decoder = GrpcStructDecoder::::default(); + let field: Option = None; + let result: Result<[Felt; 4], _> = decoder.decode_field("account_root", field); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("account_root") && err.to_string().contains("missing"), + "expected missing field error, got: {err}", + ); + } + + #[test] + fn test_decode_field_conversion_error() { + use miden_protocol::Felt; + + use crate::generated::primitives::Digest; + + let decoder = GrpcStructDecoder::::default(); + // Create a digest with an out-of-range value. + let bad_digest = Digest { d0: u64::MAX, d1: 0, d2: 0, d3: 0 }; + let result: Result<[Felt; 4], _> = decoder.decode_field("account_root", Some(bad_digest)); + let err = result.unwrap_err(); + assert!( + err.to_string().starts_with("account_root: "), + "expected field prefix, got: {err}", + ); + } +} diff --git a/crates/proto/src/errors/test_grpc_decode.rs b/crates/proto/src/errors/test_grpc_decode.rs new file mode 100644 index 0000000000..cf623d0028 --- /dev/null +++ b/crates/proto/src/errors/test_grpc_decode.rs @@ -0,0 +1,229 @@ +//! Tests for the `#[grpc_decode]` attribute macro. +//! +//! Verifies that the macro correctly rewrites `.decode()` calls in function parameters, +//! closures, for-loops, and match arms. + +use miden_protocol::{Felt, Word}; + +use crate::errors::{ConversionError, GrpcDecodeExt as _, grpc_decode}; +use crate::generated as proto; + +// HELPER +// ================================================================================================ + +/// Creates a valid `proto::primitives::Digest` that converts to `[Felt; 4]` without error. +fn valid_digest() -> proto::primitives::Digest { + proto::primitives::Digest { d0: 1, d1: 2, d2: 3, d3: 4 } +} + +// FUNCTION PARAMETER +// ================================================================================================ + +/// Wrapper to test basic function parameter decoding. +#[derive(Debug)] +struct DecodedEntry { + key: Word, + value: Word, +} + +#[grpc_decode] +impl TryFrom for DecodedEntry { + type Error = ConversionError; + + fn try_from(entry: proto::primitives::SmtLeafEntry) -> Result { + let key: Word = entry.key.decode()?; + let value: Word = entry.value.decode()?; + Ok(Self { key, value }) + } +} + +#[test] +fn test_function_param_decode() { + let entry = proto::primitives::SmtLeafEntry { + key: Some(valid_digest()), + value: Some(valid_digest()), + }; + let decoded = DecodedEntry::try_from(entry).unwrap(); + assert_eq!(decoded.key[0], Felt::new(1)); + assert_eq!(decoded.value[0], Felt::new(1)); +} + +#[test] +fn test_function_param_missing_field() { + let entry = proto::primitives::SmtLeafEntry { key: None, value: Some(valid_digest()) }; + let err = DecodedEntry::try_from(entry).unwrap_err(); + assert!( + err.to_string().contains("key") && err.to_string().contains("missing"), + "expected missing key error, got: {err}", + ); +} + +// CLOSURE +// ================================================================================================ + +/// Wrapper to test closure-based decoding (`.map(|item| item.field.decode())`). +#[derive(Debug)] +struct DecodedEntries { + keys: Vec, +} + +#[grpc_decode] +impl TryFrom for DecodedEntries { + type Error = ConversionError; + + fn try_from(value: proto::primitives::SmtLeafEntryList) -> Result { + let keys: Vec = value + .entries + .into_iter() + .map(|entry| { + let key: Word = entry.key.decode()?; + Ok(key) + }) + .collect::>()?; + Ok(Self { keys }) + } +} + +#[test] +fn test_closure_decode() { + let entries = proto::primitives::SmtLeafEntryList { + entries: vec![ + proto::primitives::SmtLeafEntry { + key: Some(valid_digest()), + value: Some(valid_digest()), + }, + proto::primitives::SmtLeafEntry { + key: Some(proto::primitives::Digest { d0: 10, d1: 20, d2: 30, d3: 40 }), + value: Some(valid_digest()), + }, + ], + }; + let decoded = DecodedEntries::try_from(entries).unwrap(); + assert_eq!(decoded.keys.len(), 2); + assert_eq!(decoded.keys[0][0], Felt::new(1)); + assert_eq!(decoded.keys[1][0], Felt::new(10)); +} + +#[test] +fn test_closure_decode_missing_field() { + let entries = proto::primitives::SmtLeafEntryList { + entries: vec![proto::primitives::SmtLeafEntry { key: None, value: Some(valid_digest()) }], + }; + let err = DecodedEntries::try_from(entries).unwrap_err(); + assert!( + err.to_string().contains("key") && err.to_string().contains("missing"), + "expected missing key error, got: {err}", + ); +} + +// FOR-LOOP +// ================================================================================================ + +/// Wrapper to test for-loop decoding. +#[derive(Debug)] +struct CollectedKeys { + keys: Vec, +} + +#[grpc_decode] +impl TryFrom for CollectedKeys { + type Error = ConversionError; + + fn try_from(value: proto::primitives::SmtLeafEntryList) -> Result { + let mut keys = Vec::new(); + for entry in value.entries { + let key: Word = entry.key.decode()?; + keys.push(key); + } + Ok(Self { keys }) + } +} + +#[test] +fn test_for_loop_decode() { + let entries = proto::primitives::SmtLeafEntryList { + entries: vec![ + proto::primitives::SmtLeafEntry { + key: Some(valid_digest()), + value: Some(valid_digest()), + }, + proto::primitives::SmtLeafEntry { + key: Some(proto::primitives::Digest { d0: 5, d1: 6, d2: 7, d3: 8 }), + value: Some(valid_digest()), + }, + ], + }; + let decoded = CollectedKeys::try_from(entries).unwrap(); + assert_eq!(decoded.keys.len(), 2); + assert_eq!(decoded.keys[0][0], Felt::new(1)); + assert_eq!(decoded.keys[1][0], Felt::new(5)); +} + +#[test] +fn test_for_loop_decode_missing_field() { + let entries = proto::primitives::SmtLeafEntryList { + entries: vec![proto::primitives::SmtLeafEntry { key: None, value: None }], + }; + let err = CollectedKeys::try_from(entries).unwrap_err(); + assert!( + err.to_string().contains("key") && err.to_string().contains("missing"), + "expected missing key error, got: {err}", + ); +} + +// MATCH ARM +// ================================================================================================ + +/// Test enum to exercise match-arm decoding. +enum TestInput { + WithKey(proto::primitives::SmtLeafEntry), + Empty, +} + +/// Wrapper for match-arm decode results. +#[derive(Debug)] +struct MatchResult { + key: Option, +} + +#[grpc_decode] +impl TryFrom for MatchResult { + type Error = ConversionError; + + fn try_from(value: TestInput) -> Result { + match value { + TestInput::WithKey(entry) => { + let key: Word = entry.key.decode()?; + Ok(Self { key: Some(key) }) + }, + TestInput::Empty => Ok(Self { key: None }), + } + } +} + +#[test] +fn test_match_arm_decode() { + let input = TestInput::WithKey(proto::primitives::SmtLeafEntry { + key: Some(valid_digest()), + value: None, + }); + let result = MatchResult::try_from(input).unwrap(); + assert_eq!(result.key.unwrap()[0], Felt::new(1)); +} + +#[test] +fn test_match_arm_empty_variant() { + let input = TestInput::Empty; + let result = MatchResult::try_from(input).unwrap(); + assert!(result.key.is_none()); +} + +#[test] +fn test_match_arm_missing_field() { + let input = TestInput::WithKey(proto::primitives::SmtLeafEntry { key: None, value: None }); + let err = MatchResult::try_from(input).unwrap_err(); + assert!( + err.to_string().contains("key") && err.to_string().contains("missing"), + "expected missing key error, got: {err}", + ); +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 0f5cbb8f51..332044ec51 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -12,3 +12,4 @@ pub use domain::account::{AccountState, AccountWitnessRecord}; pub use domain::nullifier::NullifierWitnessRecord; pub use domain::proof_request::BlockProofRequest; pub use domain::{convert, try_convert}; +pub use prost; diff --git a/crates/store/src/server/api.rs b/crates/store/src/server/api.rs index 56bfcafb49..10b7854fa9 100644 --- a/crates/store/src/server/api.rs +++ b/crates/store/src/server/api.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use std::sync::Arc; -use miden_node_proto::errors::ConversionError; +use miden_node_proto::errors::{ConversionError, ConversionResultExt, GrpcStructDecoder}; use miden_node_proto::generated as proto; use miden_node_utils::ErrorReport; use miden_protocol::Word; @@ -109,8 +109,7 @@ where E: From, { block_range.ok_or_else(|| { - ConversionError::MissingFieldInProtobufRepresentation { entity, field_name: "block_range" } - .into() + ConversionError::message(format!("{entity}: missing field `block_range`")).into() }) } @@ -123,12 +122,10 @@ pub fn read_root( where E: From, { - root.ok_or_else(|| ConversionError::MissingFieldInProtobufRepresentation { - entity, - field_name: "root", - })? - .try_into() - .map_err(Into::into) + root.ok_or_else(|| ConversionError::message(format!("{entity}: missing field `root`")))? + .try_into() + .context("root") + .map_err(|e: ConversionError| e.into()) } /// Converts a collection of proto primitives to Words, returning a specific error type if @@ -143,6 +140,7 @@ where .into_iter() .map(TryInto::try_into) .collect::, ConversionError>>() + .context("digests") .map_err(Into::into) } @@ -156,23 +154,19 @@ where .cloned() .map(AccountId::try_from) .collect::>() + .context("account_ids") .map_err(Into::into) } -pub fn read_account_id(id: Option) -> Result +pub fn read_account_id( + id: Option, +) -> Result where E: From, { - id.ok_or_else(|| { - ConversionError::deserialization_error( - "AccountId", - miden_protocol::crypto::utils::DeserializationError::InvalidValue( - "Missing account ID".to_string(), - ), - ) - })? - .try_into() - .map_err(Into::into) + GrpcStructDecoder::::default() + .decode_field("account_id", id) + .map_err(|e: ConversionError| e.into()) } #[instrument( @@ -189,8 +183,9 @@ where nullifiers .iter() .copied() - .map(TryInto::try_into) + .map(Nullifier::try_from) .collect::>() + .context("nullifiers") .map_err(Into::into) } diff --git a/crates/store/src/server/block_producer.rs b/crates/store/src/server/block_producer.rs index 25f6b05f60..4fd676227e 100644 --- a/crates/store/src/server/block_producer.rs +++ b/crates/store/src/server/block_producer.rs @@ -2,7 +2,7 @@ use std::convert::Infallible; use futures::TryFutureExt; use miden_crypto::dsa::ecdsa_k256_keccak::Signature; -use miden_node_proto::errors::MissingFieldHelper; +use miden_node_proto::errors::{ConversionError, GrpcDecodeExt}; use miden_node_proto::generated::store::block_producer_server; use miden_node_proto::generated::{self as proto}; use miden_node_proto::try_convert; @@ -57,22 +57,12 @@ impl block_producer_server::BlockProducer for StoreApi { // Read block. let block = request .block - .ok_or(proto::store::ApplyBlockRequest::missing_field(stringify!(block)))?; - // Read block header. - let header: BlockHeader = block - .header - .ok_or(proto::blockchain::SignedBlock::missing_field(stringify!(header)))? - .try_into()?; - // Read block body. - let body: BlockBody = block - .body - .ok_or(proto::blockchain::SignedBlock::missing_field(stringify!(body)))? - .try_into()?; - // Read signature. - let signature: Signature = block - .signature - .ok_or(proto::blockchain::SignedBlock::missing_field(stringify!(signature)))? - .try_into()?; + .ok_or(ConversionError::missing_field::("block"))?; + // Decode block fields. + let decoder = block.decoder(); + let header: BlockHeader = decoder.decode_field("header", block.header)?; + let body: BlockBody = decoder.decode_field("body", block.body)?; + let signature: Signature = decoder.decode_field("signature", block.signature)?; // Get block inputs from ordered batches. let block_inputs = @@ -199,7 +189,8 @@ impl block_producer_server::BlockProducer for StoreApi { ) -> Result, Status> { let request = request.into_inner(); - let account_id = read_account_id::(request.account_id)?; + let account_id = + read_account_id::(request.account_id)?; let nullifiers = validate_nullifiers(&request.nullifiers) .map_err(|err| conversion_error_to_status(&err))?; let unauthenticated_note_commitments = diff --git a/crates/store/src/server/ntx_builder.rs b/crates/store/src/server/ntx_builder.rs index f6e8d4a7a5..a55b9c3862 100644 --- a/crates/store/src/server/ntx_builder.rs +++ b/crates/store/src/server/ntx_builder.rs @@ -80,7 +80,8 @@ impl ntx_builder_server::NtxBuilder for StoreApi { &self, request: Request, ) -> Result, Status> { - let account_id = read_account_id::(Some(request.into_inner()))?; + let account_id = + read_account_id::(Some(request.into_inner()))?; let account_info: Option = self.state.get_network_account_details_by_id(account_id).await?; @@ -96,7 +97,9 @@ impl ntx_builder_server::NtxBuilder for StoreApi { ) -> Result, Status> { let request = request.into_inner(); let block_num = BlockNumber::from(request.block_num); - let account_id = read_account_id::(request.account_id)?; + let account_id = read_account_id::( + request.account_id, + )?; let state = self.state.clone(); @@ -206,8 +209,11 @@ impl ntx_builder_server::NtxBuilder for StoreApi { let request = request.into_inner(); // Read account ID. - let account_id = - read_account_id::(request.account_id).map_err(invalid_argument)?; + let account_id = read_account_id::< + proto::store::VaultAssetWitnessesRequest, + GetWitnessesError, + >(request.account_id) + .map_err(invalid_argument)?; // Read vault keys. let vault_keys = request @@ -259,7 +265,10 @@ impl ntx_builder_server::NtxBuilder for StoreApi { // Read the account ID. let account_id = - read_account_id::(request.account_id).map_err(invalid_argument)?; + read_account_id::( + request.account_id, + ) + .map_err(invalid_argument)?; // Read the map key. let map_key = read_root::(request.map_key, "MapKey") diff --git a/crates/store/src/server/rpc_api.rs b/crates/store/src/server/rpc_api.rs index a12fcafae7..ba9ee1f30b 100644 --- a/crates/store/src/server/rpc_api.rs +++ b/crates/store/src/server/rpc_api.rs @@ -1,6 +1,6 @@ use miden_node_proto::convert; use miden_node_proto::domain::block::InvalidBlockRange; -use miden_node_proto::errors::MissingFieldHelper; +use miden_node_proto::errors::ConversionError; use miden_node_proto::generated::store::rpc_server; use miden_node_proto::generated::{self as proto}; use miden_node_utils::limiter::{ @@ -163,7 +163,9 @@ impl rpc_server::Rpc for StoreApi { let block_range = request .block_range - .ok_or_else(|| proto::rpc::SyncChainMmrRequest::missing_field(stringify!(block_range))) + .ok_or_else(|| { + ConversionError::missing_field::("block_range") + }) .map_err(SyncChainMmrError::DeserializationFailed)?; let block_from = BlockNumber::from(block_range.block_from); @@ -260,7 +262,10 @@ impl rpc_server::Rpc for StoreApi { let request = request.into_inner(); let chain_tip = self.state.latest_block_num().await; - let account_id: AccountId = read_account_id::(request.account_id)?; + let account_id: AccountId = read_account_id::< + proto::rpc::SyncAccountVaultRequest, + SyncAccountVaultError, + >(request.account_id)?; if !account_id.has_public_state() { return Err(SyncAccountVaultError::AccountNotPublic(account_id).into()); @@ -308,7 +313,10 @@ impl rpc_server::Rpc for StoreApi { ) -> Result, Status> { let request = request.into_inner(); - let account_id = read_account_id::(request.account_id)?; + let account_id = read_account_id::< + proto::rpc::SyncAccountStorageMapsRequest, + SyncAccountStorageMapsError, + >(request.account_id)?; if !account_id.has_public_state() { Err(SyncAccountStorageMapsError::AccountNotPublic(account_id))?;