Skip to content

chore: Refactor ConversionError#1754

Open
sergerad wants to merge 27 commits intonextfrom
sergerad-conversion-error
Open

chore: Refactor ConversionError#1754
sergerad wants to merge 27 commits intonextfrom
sergerad-conversion-error

Conversation

@sergerad
Copy link
Collaborator

@sergerad sergerad commented Mar 5, 2026

Resolves #1528

Refactors ConversionError from a public enum to an opaque struct, and introduces layered abstractions to progressively eliminate proto-to-domain conversion boilerplate.

ConversionError struct

Converts ConversionError to an opaque struct with a field path stack and a boxed error source. The Display impl renders dotted paths like "header.account_root: value is not in range 0..MODULUS".

Adds ConversionResultExt for ergonomic .context("field_name") chaining on Result<T, ConversionError>.

DecodeBytesExt

Extension trait on Deserializable types to replace the repeated pattern:

T::read_from_bytes(&bytes).map_err(|source| ConversionError::deserialization("T", source))

with:

T::decode_bytes(&bytes, "T")

GrpcStructDecoder + GrpcDecodeExt

Replaces the TryConvertFieldExt trait (which required specifying the parent proto type on every field) with a serde-style struct decoder. The parent type is specified once via .decoder(), then each field is decoded with decoder.decode_field("name", value.field):

let decoder = value.decoder();
let field = decoder.decode_field("field", value.field)?;

decode_field handles Option unwrapping, TryInto conversion, and field path context in a single call.

#[grpc_decode] proc macro

Attribute macro that eliminates manual field name strings by rewriting .decode() shorthand from the AST:

#[grpc_decode]
impl TryFrom<proto::BlockHeader> for BlockHeader {
    type Error = ConversionError;
    fn try_from(value: proto::BlockHeader) -> Result<Self, Self::Error> {
        let prev = value.prev_block_commitment.decode()?;
        let chain = value.chain_commitment.decode()?;
        Ok(BlockHeader::new(value.version, prev, value.block_num.into(), chain))
    }
}

The macro rewrites value.field.decode() into decoder.decode_field("field", value.field) and injects let decoder = value.decoder(); at the top. It also handles nested scopes:

  • Closures: closure parameters become decode roots (e.g., .map(|entry| entry.key.decode()?))
  • For-loops: loop variables become decode roots
  • Match arms: match bindings become decode roots

Other changes

  • Fixed incorrect missing_field type parameters throughout
  • Made read_account_id generic over parent proto message types
  • Replaced stringify! macros with string literals
  • Re-exported prost from miden-node-proto
  • Added unit tests covering nested paths and various error scenarios

@sergerad sergerad added the no changelog This PR does not require an entry in the `CHANGELOG.md` file label Mar 17, 2026
@sergerad sergerad marked this pull request as ready for review March 17, 2026 01:44
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top level overview; attaching to an arbitrary file so we can discuss inline.

I think this gives the correct end result, but I worry about the ergonomics of it. Having to specify the field, type and operation is a problem and will be error prone. I'll leaving some thoughts on possible things to explore.

At a higher level, I want to move away from TryFrom for something this specific. We really should use a dedicated GrpcDecode trait. This would be a rather large diff so I would suggest we do this in tandem with #1742. This allows us to have the current implementation, where we can then slowly move over to the #1742 piece by piece without requiring all things to change at once. So as we implement a method for #1742, we also implement the GrpcDecode parts that we need.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Latest changes use the proc macro approach with the decoder struct you also mentioned. I have kept the TryFrom instead of introducing another trait, however. Just because there are already a lot of changes for now.

Comment on lines +78 to +104
let prev_block_commitment = value
.prev_block_commitment
.try_convert_field::<proto::blockchain::BlockHeader>("prev_block_commitment")?;
let chain_commitment = value
.chain_commitment
.try_convert_field::<proto::blockchain::BlockHeader>("chain_commitment")?;
let account_root = value
.account_root
.try_convert_field::<proto::blockchain::BlockHeader>("account_root")?;
let nullifier_root = value
.nullifier_root
.try_convert_field::<proto::blockchain::BlockHeader>("nullifier_root")?;
let note_root = value
.note_root
.try_convert_field::<proto::blockchain::BlockHeader>("note_root")?;
let tx_commitment = value
.tx_commitment
.try_convert_field::<proto::blockchain::BlockHeader>("tx_commitment")?;
let tx_kernel_commitment = value
.tx_kernel_commitment
.try_convert_field::<proto::blockchain::BlockHeader>("tx_kernel_commitment")?;
let validator_key = value
.validator_key
.try_convert_field::<proto::blockchain::BlockHeader>("validator_key")?;
let fee_parameters = value
.fee_parameters
.try_convert_field::<proto::blockchain::BlockHeader>("fee_parameters")?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serde solves the redundancy of specifying the parent type (proto::blockchain::BlockHeader) by having dedicated sub-decoders. Something like this (I forget the exact details):

/// This gives a single place to inject the parent struct name.
struct GrpcStructDecoder<T: GrpcMessage>;

impl GrpcStructDecoder<T: GrpcMessage> {
    fn decode_field<U: GrpcDecode, V>(name: &'static str, value: U) -> Result<V> {
        value.decode().context(name)?
    }
}

let mut decoder = value.decode_struct();
let account_root = decoder.decode_field("account_root", value.account_root)?;

and they have similar helper structs for arrays (which inject indices), and enums etc.

This still requires manually specifying the field name which is a bummer, but I wonder if we can't write a proc-macro that injects that intelligently for us to give something along

#[GrpcDecode::struct]
impl GrpcDecode<BlockHeader> for proto::blockchain::BlockHeader {
    fn decode(self) -> Result<BlockHeader, GrpcDecodeError> {
        let prev_block_commitment = self.prev_block_commitment.decode()?;
        let chain_commitment = self.chain_commitment.decode()?;
        let account_root = self.account_root.decode()?;
        let nullifier_root = self.nullifier_root.decode()?;

        ...
        Ok(BlockHeader::new(...))
    }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have done the decoder + proc macro. But kept the TryFrom for now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no changelog This PR does not require an entry in the `CHANGELOG.md` file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor ConversionError enum

2 participants