From bda7d5520629a770e93e4626ee219f015ebd0ac0 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Mon, 29 Jun 2026 12:46:52 -0300 Subject: [PATCH 1/4] Centralize RPC response checks in a verifying wrapper --- CHANGELOG.md | 4 + crates/rust-client/src/builder.rs | 7 +- crates/rust-client/src/rpc/mod.rs | 31 +- .../rust-client/src/rpc/tonic_client/mod.rs | 177 +------- crates/rust-client/src/rpc/verification.rs | 385 ++++++++++++++++++ 5 files changed, 415 insertions(+), 189 deletions(-) create mode 100644 crates/rust-client/src/rpc/verification.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c10ba34f9..3573479625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changes + +* [rust] RPC response verification now lives in a single `VerifyingRpcClient` wrapper that the client builder places around every `NodeRpcClient`. Implementations only provide transport and no longer need to check that a node's response matches the request themselves ([#2278](https://github.com/0xMiden/rust-sdk/pull/2278)). + ### Fixes * [FIX][rust] RPC endpoint parsing now rejects endpoint strings that omit either the protocol or host. ([#2266](https://github.com/0xMiden/miden-client/pull/2266)) diff --git a/crates/rust-client/src/builder.rs b/crates/rust-client/src/builder.rs index 044577d472..3d1882c107 100644 --- a/crates/rust-client/src/builder.rs +++ b/crates/rust-client/src/builder.rs @@ -17,7 +17,7 @@ use crate::keystore::FilesystemKeyStore; use crate::keystore::Keystore; use crate::note_transport::NoteTransportClient; use crate::pswap::PswapTransactionObserver; -use crate::rpc::{Endpoint, NodeRpcClient}; +use crate::rpc::{Endpoint, NodeRpcClient, VerifyingRpcClient}; use crate::store::{Store, StoreError}; use crate::transaction::{TransactionObserver, TransactionProver}; use crate::{Client, ClientError, ClientRng, ClientRngBox, DebugMode, grpc_support}; @@ -459,9 +459,10 @@ where /// - Returns an error if the store cannot be instantiated. #[allow(clippy::unused_async, unused_mut)] pub async fn build(mut self) -> Result, ClientError> { - // Determine the RPC client to use. + // Determine the RPC client to use, wrapping it so every response is verified against its + // request regardless of the underlying implementation. let rpc_api: Arc = if let Some(client) = self.rpc_api { - client + Arc::new(VerifyingRpcClient::new(client)) } else { return Err(ClientError::ClientInitializationError( "RPC client is required. Call `.rpc(...)` or `.grpc_client(...)`.".into(), diff --git a/crates/rust-client/src/rpc/mod.rs b/crates/rust-client/src/rpc/mod.rs index 8070c7a0b4..680efef205 100644 --- a/crates/rust-client/src/rpc/mod.rs +++ b/crates/rust-client/src/rpc/mod.rs @@ -83,6 +83,9 @@ pub use domain::limits::RpcLimits; pub use domain::status::{NetworkNoteStatus, NetworkNoteStatusInfo, RpcStatusInfo}; pub use endpoint::Endpoint; +mod verification; +pub use verification::VerifyingRpcClient; + #[cfg(not(feature = "testing"))] mod generated; #[cfg(feature = "testing")] @@ -166,8 +169,8 @@ pub trait NodeRpcClient: Send + Sync { /// /// When `None` is provided, returns info regarding the latest block. /// - /// When `block_num` is `Some`, implementations must verify that the returned header's block - /// number matches the requested one and return [`RpcError::InvalidResponse`] otherwise. + /// When `block_num` is `Some`, a returned header whose block number does not match it is + /// rejected with [`RpcError::InvalidResponse`]. /// /// Errors: /// - [`RpcError::InvalidResponse`] if the node returns a header whose block number does not @@ -183,8 +186,8 @@ pub trait NodeRpcClient: Send + Sync { /// /// If `include_proof` is set to true, the block proof will be included in the response. /// - /// Implementations must verify that the returned block's number matches the requested - /// `block_num` and return [`RpcError::InvalidResponse`] otherwise. + /// A returned block whose number does not match the requested `block_num` is rejected with + /// [`RpcError::InvalidResponse`]. /// /// # Errors: /// - [`RpcError::InvalidResponse`] if the node returns a block whose number does not match the @@ -207,8 +210,8 @@ pub trait NodeRpcClient: Send + Sync { /// In both cases, a [`miden_protocol::note::NoteInclusionProof`] is returned so the caller can /// verify that each note is part of the block's note tree. /// - /// Implementations must verify that every returned note's ID was present in `note_ids` and - /// return [`RpcError::InvalidResponse`] otherwise. + /// A returned note whose ID was not present in `note_ids` is rejected with + /// [`RpcError::InvalidResponse`]. /// /// Errors: /// - [`RpcError::InvalidResponse`] if the node returns a note whose ID was not requested. @@ -278,8 +281,8 @@ pub trait NodeRpcClient: Send + Sync { /// [`NodeRpcClient::sync_notes_with_details`] to also resolve their full metadata and /// fetch public note bodies in a single follow-up call. /// - /// Implementations must verify that every returned note's tag was present in `note_tags` and - /// return [`RpcError::InvalidResponse`] otherwise. + /// A returned note whose tag was not present in `note_tags` is rejected with + /// [`RpcError::InvalidResponse`]. /// /// # Errors /// - [`RpcError::InvalidResponse`] if the node returns a note whose tag was not requested. @@ -346,8 +349,8 @@ pub trait NodeRpcClient: Send + Sync { /// - `block_from`: The starting block number for the range (inclusive). /// - `block_to`: The ending block number for the range (inclusive). /// - /// Implementations must verify that every returned nullifier's prefix was present in `prefix` - /// and return [`RpcError::InvalidResponse`] otherwise. + /// A returned nullifier whose prefix was not present in `prefix` is rejected with + /// [`RpcError::InvalidResponse`]. /// /// # Errors /// - [`RpcError::InvalidResponse`] if the node returns a nullifier whose prefix was not @@ -368,7 +371,9 @@ pub trait NodeRpcClient: Send + Sync { /// /// For a fully oversize-resolved account, use [`NodeRpcClient::get_account_details`]. /// - /// Errors if the account isn't found or the block number of the requested account doesn't match + /// When the request targets a specific block, a response for a different block is rejected with + /// [`RpcError::InvalidResponse`]. + /// /// # Errors /// /// - If the account isn't found in the network @@ -536,8 +541,8 @@ pub trait NodeRpcClient: Send + Sync { /// Fetches the note script with the specified root, returning `None` if the node has no script /// registered for that root. /// - /// Implementations must verify that a returned script's root matches the requested `root` and - /// return [`RpcError::InvalidResponse`] otherwise; callers may rely on this invariant. + /// A returned script whose root does not match the requested `root` is rejected with + /// [`RpcError::InvalidResponse`]; callers may rely on this invariant. /// /// Errors: /// - [`RpcError::InvalidResponse`] if the node returns a script whose root does not match the diff --git a/crates/rust-client/src/rpc/tonic_client/mod.rs b/crates/rust-client/src/rpc/tonic_client/mod.rs index 905b1922b7..8ad7a8ee81 100644 --- a/crates/rust-client/src/rpc/tonic_client/mod.rs +++ b/crates/rust-client/src/rpc/tonic_client/mod.rs @@ -32,7 +32,7 @@ use super::domain::account::{ GetAccountRequest, StorageMapFetch, }; -use super::domain::note::{CommittedNote, FetchedNote, NoteSyncBlock}; +use super::domain::note::{FetchedNote, NoteSyncBlock}; use super::domain::nullifier::NullifierUpdate; use super::generated::rpc::AccountRequest; use super::generated::rpc::account_request::AccountDetailRequest; @@ -121,60 +121,6 @@ impl BlockPagination { } } -/// Returns [`RpcError::InvalidResponse`] if any update in the `sync_nullifiers` batch carries a -/// nullifier whose prefix was not requested. -fn ensure_requested_nullifiers( - requested_prefixes: &BTreeSet, - batch: &[NullifierUpdate], -) -> Result<(), RpcError> { - for update in batch { - let prefix = update.nullifier.prefix(); - if !requested_prefixes.contains(&prefix) { - let requested = requested_prefixes - .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - return Err(RpcError::InvalidResponse(format!( - "node returned nullifier with prefix {prefix} but [{requested}] were requested" - ))); - } - } - Ok(()) -} - -/// Returns an error if any note in a `sync_notes` response carries a tag that was not requested. -fn ensure_requested_tags( - requested: &BTreeSet, - returned: impl IntoIterator, -) -> Result<(), RpcError> { - for tag in returned { - if !requested.contains(&tag) { - let list = requested.iter().map(ToString::to_string).collect::>().join(", "); - return Err(RpcError::InvalidResponse(format!( - "node returned note with tag {tag} but [{list}] were requested" - ))); - } - } - Ok(()) -} - -/// Returns an error if any note in a `GetNotesById` response has an ID that was not requested. -fn ensure_requested_note_ids( - requested: &BTreeSet, - returned: impl IntoIterator, -) -> Result<(), RpcError> { - for id in returned { - if !requested.contains(&id) { - let list = requested.iter().map(ToString::to_string).collect::>().join(", "); - return Err(RpcError::InvalidResponse(format!( - "node returned note {id} but [{list}] were requested" - ))); - } - } - Ok(()) -} - // GRPC CLIENT // ================================================================================================ @@ -459,15 +405,6 @@ impl NodeRpcClient for GrpcClient { .ok_or(RpcError::ExpectedDataMissing("BlockHeader".into()))? .try_into()?; - if let Some(requested) = block_num - && block_header.block_num() != requested - { - return Err(RpcError::InvalidResponse(format!( - "node returned header for block {} but block {requested} was requested", - block_header.block_num(), - ))); - } - let mmr_proof = if include_mmr_proof { let forest = response .chain_length @@ -494,7 +431,6 @@ impl NodeRpcClient for GrpcClient { async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError> { let limits = self.get_rpc_limits().await?; - let requested_ids: BTreeSet = note_ids.iter().copied().collect(); let mut notes = Vec::with_capacity(note_ids.len()); for chunk in note_ids.chunks(limits.note_ids_limit as usize) { let request = proto::note::NoteIdList { @@ -515,8 +451,6 @@ impl NodeRpcClient for GrpcClient { .map(FetchedNote::try_from) .collect::, RpcConversionError>>()?; - ensure_requested_note_ids(&requested_ids, response_notes.iter().map(FetchedNote::id))?; - notes.extend(response_notes); } Ok(notes) @@ -616,16 +550,6 @@ impl NodeRpcClient for GrpcClient { .block_num .into(); - if let Some(requested) = block_num - && requested.block_num != response_block_num.as_u32() - { - return Err(RpcError::InvalidResponse(format!( - "node returned header for block {} but block {} was requested", - response_block_num.as_u32(), - requested.block_num - ))); - } - // For accounts with public state, details should be present when requested let headers = if account_witness.id().is_public() { let details = response @@ -668,7 +592,6 @@ impl NodeRpcClient for GrpcClient { for chunk in tags.chunks(limits.note_tags_limit as usize) { let proto_tags: Vec = chunk.iter().map(|&t| t.into()).collect(); - let requested_tags: BTreeSet = chunk.iter().copied().collect(); let mut pagination = BlockPagination::new(block_from, block_to); loop { @@ -696,10 +619,6 @@ impl NodeRpcClient for GrpcClient { for proto_block in response.blocks { let block: NoteSyncBlock = proto_block.try_into()?; - ensure_requested_tags( - &requested_tags, - block.notes.values().map(CommittedNote::tag), - )?; let bn = block.block_header.block_num(); if let Some(existing) = merged_blocks.get_mut(&bn) { for (id, note) in block.notes { @@ -733,7 +652,6 @@ impl NodeRpcClient for GrpcClient { // violating the RPC limit. for chunk in prefixes.chunks(limits.nullifiers_limit as usize) { let proto_prefixes: Vec = chunk.iter().map(|&x| u32::from(x)).collect(); - let requested_prefixes: BTreeSet = chunk.iter().copied().collect(); let mut pagination = BlockPagination::new(block_from, block_to); loop { @@ -761,7 +679,6 @@ impl NodeRpcClient for GrpcClient { .collect::, _>>() .map_err(|err| RpcError::InvalidResponse(err.to_string()))?; - ensure_requested_nullifiers(&requested_prefixes, &batch_nullifiers)?; all_nullifiers.extend(batch_nullifiers); let page = response.pagination_info.ok_or(RpcError::ExpectedDataMissing( @@ -799,13 +716,6 @@ impl NodeRpcClient for GrpcClient { "GetBlockByNumberResponse.block".to_string(), ))?)?; - if block.header().block_num() != block_num { - return Err(RpcError::InvalidResponse(format!( - "node returned header for block {} but block {block_num} was requested", - block.header().block_num(), - ))); - } - Ok(block) } @@ -824,13 +734,6 @@ impl NodeRpcClient for GrpcClient { }; let note_script = NoteScript::try_from(script)?; - let fetched_root = note_script.root(); - if Word::from(fetched_root) != root { - return Err(RpcError::InvalidResponse(format!( - "node returned note script with root {fetched_root} for requested root {root}", - ))); - } - Ok(Some(note_script)) } @@ -1080,23 +983,12 @@ impl From<&Status> for GrpcError { #[cfg(test)] mod tests { - use core::slice; use std::boxed::Box; - use std::collections::BTreeSet; + use miden_protocol::Word; use miden_protocol::block::BlockNumber; - use miden_protocol::note::{NoteId, NoteTag, Nullifier}; - use miden_protocol::{Felt, Word}; - - use super::{ - BlockPagination, - GrpcClient, - NullifierUpdate, - PaginationResult, - ensure_requested_note_ids, - ensure_requested_nullifiers, - ensure_requested_tags, - }; + + use super::{BlockPagination, GrpcClient, PaginationResult}; use crate::alloc::string::ToString; use crate::rpc::{Endpoint, NodeRpcClient, RpcError}; @@ -1289,65 +1181,4 @@ mod tests { .expect("testnet status with caller auth header must succeed"); assert!(!status.version.is_empty(), "status must include a server version"); } - - fn nullifier_with_prefix(prefix: u16) -> Nullifier { - Nullifier::from_raw(Word::new([ - Felt::ZERO, - Felt::ZERO, - Felt::ZERO, - Felt::new_unchecked(u64::from(prefix) << 48), - ])) - } - - #[test] - fn verify_requested_nullifiers_rejects_unrequested_prefix() { - let requested = NullifierUpdate { - nullifier: nullifier_with_prefix(0x1234), - block_num: 1u32.into(), - }; - let unrequested = NullifierUpdate { - nullifier: nullifier_with_prefix(0xabcd), - block_num: 2u32.into(), - }; - - let requested_prefixes: BTreeSet = BTreeSet::from([0x1234]); - - ensure_requested_nullifiers(&requested_prefixes, slice::from_ref(&requested)) - .expect("requested prefix must be accepted"); - - let err = ensure_requested_nullifiers(&requested_prefixes, &[requested, unrequested]) - .expect_err("unrequested prefix must be rejected"); - assert!(matches!(err, RpcError::InvalidResponse(_))); - } - - #[test] - fn ensure_requested_tags_rejects_unrequested() { - let requested = NoteTag::new(1); - let other = NoteTag::new(2); - let requested_set = BTreeSet::from([requested]); - - ensure_requested_tags(&requested_set, [requested]).expect("requested tag must be accepted"); - - let err = ensure_requested_tags(&requested_set, [other]) - .expect_err("unrequested tag must be rejected"); - assert!(matches!(err, RpcError::InvalidResponse(_))); - } - - fn note_id(n: u32) -> NoteId { - NoteId::from_raw(Word::from([n, 0, 0, 0])) - } - - #[test] - fn ensure_requested_note_ids_rejects_unrequested() { - let requested = note_id(1); - let other = note_id(2); - let requested_set = BTreeSet::from([requested]); - - ensure_requested_note_ids(&requested_set, [requested]) - .expect("requested note id must be accepted"); - - let err = ensure_requested_note_ids(&requested_set, [other]) - .expect_err("unrequested note id must be rejected"); - assert!(matches!(err, RpcError::InvalidResponse(_))); - } } diff --git a/crates/rust-client/src/rpc/verification.rs b/crates/rust-client/src/rpc/verification.rs new file mode 100644 index 0000000000..fa705ffae3 --- /dev/null +++ b/crates/rust-client/src/rpc/verification.rs @@ -0,0 +1,385 @@ +//! Response verification layer for [`NodeRpcClient`]. +//! +//! A node's reply should match what the client asked for. [`VerifyingRpcClient`] wraps any +//! [`NodeRpcClient`] and checks each response against its request, returning +//! [`RpcError::InvalidResponse`] on a mismatch, so individual implementations only need to provide +//! transport. The client builder wraps every configured RPC client in this type, so these checks +//! always run regardless of the underlying implementation. + +use alloc::boxed::Box; +use alloc::collections::BTreeSet; +use alloc::string::ToString; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::AccountId; +use miden_protocol::address::NetworkId; +use miden_protocol::batch::{ProposedBatch, ProvenBatch}; +use miden_protocol::block::{BlockHeader, BlockNumber, ProvenBlock}; +use miden_protocol::crypto::merkle::mmr::MmrProof; +use miden_protocol::note::{NoteId, NoteScript, NoteTag}; +use miden_protocol::transaction::{ProvenTransaction, TransactionInputs}; + +use super::domain::account::{AccountProof, GetAccountRequest}; +use super::domain::account_vault::AccountVaultInfo; +use super::domain::note::{CommittedNote, FetchedNote, NoteSyncBlock}; +use super::domain::nullifier::NullifierUpdate; +use super::domain::storage_map::StorageMapInfo; +use super::domain::sync::{ChainMmrInfo, SyncTarget}; +use super::domain::transaction::TransactionRecord; +use super::{ + AccountStateAt, + NetworkNoteStatusInfo, + NodeRpcClient, + RpcError, + RpcLimits, + RpcStatusInfo, +}; + +// VERIFYING RPC CLIENT +// ================================================================================================ + +/// A [`NodeRpcClient`] decorator that verifies each response against its request. +/// +/// It delegates every call to the wrapped client and then checks request-scoped invariants (for +/// example, that a returned block matches the requested number, or that returned notes were among +/// those requested). Checks that need cross-call or client-side state are not performed here; they +/// live in the sync layer. +/// +/// The trait's provided methods are intentionally left inherited: their default implementations +/// call back through this type's verified methods, so the composite calls they make are checked +/// too. +pub struct VerifyingRpcClient { + inner: Arc, +} + +impl VerifyingRpcClient { + /// Wraps `inner` so that its responses are verified against their requests. + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +impl NodeRpcClient for VerifyingRpcClient { + async fn set_genesis_commitment(&self, commitment: Word) -> Result<(), RpcError> { + self.inner.set_genesis_commitment(commitment).await + } + + fn has_genesis_commitment(&self) -> Option { + self.inner.has_genesis_commitment() + } + + async fn submit_proven_transaction( + &self, + proven_transaction: ProvenTransaction, + transaction_inputs: TransactionInputs, + ) -> Result { + self.inner + .submit_proven_transaction(proven_transaction, transaction_inputs) + .await + } + + async fn submit_proven_batch( + &self, + proven_batch: ProvenBatch, + proposed_batch: ProposedBatch, + transaction_inputs: Vec, + ) -> Result { + self.inner + .submit_proven_batch(proven_batch, proposed_batch, transaction_inputs) + .await + } + + async fn get_block_header_by_number( + &self, + block_num: Option, + include_mmr_proof: bool, + ) -> Result<(BlockHeader, Option), RpcError> { + let (header, mmr_proof) = + self.inner.get_block_header_by_number(block_num, include_mmr_proof).await?; + verify_block_num(block_num, header.block_num())?; + Ok((header, mmr_proof)) + } + + async fn get_block_by_number( + &self, + block_num: BlockNumber, + include_proof: bool, + ) -> Result { + let block = self.inner.get_block_by_number(block_num, include_proof).await?; + verify_block_num(Some(block_num), block.header().block_num())?; + Ok(block) + } + + async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError> { + let notes = self.inner.get_notes_by_id(note_ids).await?; + let requested: BTreeSet = note_ids.iter().copied().collect(); + verify_note_ids(&requested, notes.iter().map(FetchedNote::id))?; + Ok(notes) + } + + async fn sync_chain_mmr( + &self, + current_block_height: BlockNumber, + upper_bound: SyncTarget, + ) -> Result { + self.inner.sync_chain_mmr(current_block_height, upper_bound).await + } + + async fn sync_notes( + &self, + block_from: BlockNumber, + block_to: BlockNumber, + note_tags: &BTreeSet, + ) -> Result, RpcError> { + let blocks = self.inner.sync_notes(block_from, block_to, note_tags).await?; + verify_note_tags( + note_tags, + blocks.iter().flat_map(|b| b.notes.values().map(CommittedNote::tag)), + )?; + Ok(blocks) + } + + async fn sync_nullifiers( + &self, + prefix: &[u16], + block_from: BlockNumber, + block_to: BlockNumber, + ) -> Result, RpcError> { + let nullifiers = self.inner.sync_nullifiers(prefix, block_from, block_to).await?; + let requested: BTreeSet = prefix.iter().copied().collect(); + verify_nullifier_prefixes(&requested, &nullifiers)?; + Ok(nullifiers) + } + + async fn get_account( + &self, + account_id: AccountId, + request: GetAccountRequest, + ) -> Result<(BlockNumber, AccountProof), RpcError> { + let requested = match request.at { + AccountStateAt::Block(number) => Some(number), + AccountStateAt::ChainTip => None, + }; + let (block_num, proof) = self.inner.get_account(account_id, request).await?; + verify_block_num(requested, block_num)?; + Ok((block_num, proof)) + } + + async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError> { + let script = self.inner.get_note_script_by_root(root).await?; + if let Some(script) = &script { + verify_note_script_root(root, script)?; + } + Ok(script) + } + + async fn sync_storage_maps( + &self, + block_from: BlockNumber, + block_to: BlockNumber, + account_id: AccountId, + ) -> Result { + self.inner.sync_storage_maps(block_from, block_to, account_id).await + } + + async fn sync_account_vault( + &self, + block_from: BlockNumber, + block_to: BlockNumber, + account_id: AccountId, + ) -> Result { + self.inner.sync_account_vault(block_from, block_to, account_id).await + } + + async fn sync_transactions( + &self, + block_from: BlockNumber, + block_to: BlockNumber, + account_ids: Vec, + ) -> Result, RpcError> { + self.inner.sync_transactions(block_from, block_to, account_ids).await + } + + async fn get_network_id(&self) -> Result { + self.inner.get_network_id().await + } + + async fn get_rpc_limits(&self) -> Result { + self.inner.get_rpc_limits().await + } + + fn has_rpc_limits(&self) -> Option { + self.inner.has_rpc_limits() + } + + async fn set_rpc_limits(&self, limits: RpcLimits) { + self.inner.set_rpc_limits(limits).await; + } + + async fn get_status_unversioned(&self) -> Result { + self.inner.get_status_unversioned().await + } + + async fn get_network_note_status( + &self, + note_id: NoteId, + ) -> Result { + self.inner.get_network_note_status(note_id).await + } +} + +// RESPONSE CHECKS +// ================================================================================================ + +/// Returns [`RpcError::InvalidResponse`] if `requested` is `Some` and `returned` does not equal it. +fn verify_block_num(requested: Option, returned: BlockNumber) -> Result<(), RpcError> { + if let Some(requested) = requested + && returned != requested + { + return Err(RpcError::InvalidResponse(format!( + "node returned block {returned} but block {requested} was requested" + ))); + } + Ok(()) +} + +/// Returns [`RpcError::InvalidResponse`] if any returned note ID was not in `requested`. +fn verify_note_ids( + requested: &BTreeSet, + returned: impl IntoIterator, +) -> Result<(), RpcError> { + for id in returned { + if !requested.contains(&id) { + let list = requested.iter().map(ToString::to_string).collect::>().join(", "); + return Err(RpcError::InvalidResponse(format!( + "node returned note {id} but [{list}] were requested" + ))); + } + } + Ok(()) +} + +/// Returns [`RpcError::InvalidResponse`] if any returned note tag was not in `requested`. +fn verify_note_tags( + requested: &BTreeSet, + returned: impl IntoIterator, +) -> Result<(), RpcError> { + for tag in returned { + if !requested.contains(&tag) { + let list = requested.iter().map(ToString::to_string).collect::>().join(", "); + return Err(RpcError::InvalidResponse(format!( + "node returned note with tag {tag} but [{list}] were requested" + ))); + } + } + Ok(()) +} + +/// Returns [`RpcError::InvalidResponse`] if any update carries a nullifier whose prefix was not in +/// `requested_prefixes`. +fn verify_nullifier_prefixes( + requested_prefixes: &BTreeSet, + batch: &[NullifierUpdate], +) -> Result<(), RpcError> { + for update in batch { + let prefix = update.nullifier.prefix(); + if !requested_prefixes.contains(&prefix) { + let requested = requested_prefixes + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + return Err(RpcError::InvalidResponse(format!( + "node returned nullifier with prefix {prefix} but [{requested}] were requested" + ))); + } + } + Ok(()) +} + +/// Returns [`RpcError::InvalidResponse`] if `script`'s root does not equal the `requested` root. +fn verify_note_script_root(requested: Word, script: &NoteScript) -> Result<(), RpcError> { + let fetched_root = script.root(); + if Word::from(fetched_root) != requested { + return Err(RpcError::InvalidResponse(format!( + "node returned note script with root {fetched_root} for requested root {requested}" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use core::slice; + use std::collections::BTreeSet; + + use miden_protocol::note::{NoteId, NoteTag, Nullifier}; + use miden_protocol::{Felt, Word}; + + use super::{NullifierUpdate, verify_note_ids, verify_note_tags, verify_nullifier_prefixes}; + use crate::rpc::RpcError; + + fn nullifier_with_prefix(prefix: u16) -> Nullifier { + Nullifier::from_raw(Word::new([ + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::new_unchecked(u64::from(prefix) << 48), + ])) + } + + #[test] + fn verify_nullifier_prefixes_rejects_unrequested() { + let requested = NullifierUpdate { + nullifier: nullifier_with_prefix(0x1234), + block_num: 1u32.into(), + }; + let unrequested = NullifierUpdate { + nullifier: nullifier_with_prefix(0xabcd), + block_num: 2u32.into(), + }; + + let requested_prefixes: BTreeSet = BTreeSet::from([0x1234]); + + verify_nullifier_prefixes(&requested_prefixes, slice::from_ref(&requested)) + .expect("requested prefix must be accepted"); + + let err = verify_nullifier_prefixes(&requested_prefixes, &[requested, unrequested]) + .expect_err("unrequested prefix must be rejected"); + assert!(matches!(err, RpcError::InvalidResponse(_))); + } + + #[test] + fn verify_note_tags_rejects_unrequested() { + let requested = NoteTag::new(1); + let other = NoteTag::new(2); + let requested_set = BTreeSet::from([requested]); + + verify_note_tags(&requested_set, [requested]).expect("requested tag must be accepted"); + + let err = verify_note_tags(&requested_set, [other]) + .expect_err("unrequested tag must be rejected"); + assert!(matches!(err, RpcError::InvalidResponse(_))); + } + + fn note_id(n: u32) -> NoteId { + NoteId::from_raw(Word::from([n, 0, 0, 0])) + } + + #[test] + fn verify_note_ids_rejects_unrequested() { + let requested = note_id(1); + let other = note_id(2); + let requested_set = BTreeSet::from([requested]); + + verify_note_ids(&requested_set, [requested]).expect("requested note id must be accepted"); + + let err = verify_note_ids(&requested_set, [other]) + .expect_err("unrequested note id must be rejected"); + assert!(matches!(err, RpcError::InvalidResponse(_))); + } +} From a1b50d727437d476215758dbbd99d7b92660af77 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Mon, 29 Jun 2026 13:49:15 -0300 Subject: [PATCH 2/4] Document RPC response checks on the wrapper instead of the trait --- crates/rust-client/src/rpc/mod.rs | 44 ---------------------- crates/rust-client/src/rpc/verification.rs | 21 ++++++----- 2 files changed, 12 insertions(+), 53 deletions(-) diff --git a/crates/rust-client/src/rpc/mod.rs b/crates/rust-client/src/rpc/mod.rs index 680efef205..2ee5dafa01 100644 --- a/crates/rust-client/src/rpc/mod.rs +++ b/crates/rust-client/src/rpc/mod.rs @@ -168,13 +168,6 @@ pub trait NodeRpcClient: Send + Sync { /// of the return tuple should always be Some(MmrProof). /// /// When `None` is provided, returns info regarding the latest block. - /// - /// When `block_num` is `Some`, a returned header whose block number does not match it is - /// rejected with [`RpcError::InvalidResponse`]. - /// - /// Errors: - /// - [`RpcError::InvalidResponse`] if the node returns a header whose block number does not - /// match the requested `block_num`. async fn get_block_header_by_number( &self, block_num: Option, @@ -185,13 +178,6 @@ pub trait NodeRpcClient: Send + Sync { /// the `/GetBlockByNumber` RPC endpoint. /// /// If `include_proof` is set to true, the block proof will be included in the response. - /// - /// A returned block whose number does not match the requested `block_num` is rejected with - /// [`RpcError::InvalidResponse`]. - /// - /// # Errors: - /// - [`RpcError::InvalidResponse`] if the node returns a block whose number does not match the - /// requested `block_num`. async fn get_block_by_number( &self, block_num: BlockNumber, @@ -209,12 +195,6 @@ pub trait NodeRpcClient: Send + Sync { /// /// In both cases, a [`miden_protocol::note::NoteInclusionProof`] is returned so the caller can /// verify that each note is part of the block's note tree. - /// - /// A returned note whose ID was not present in `note_ids` is rejected with - /// [`RpcError::InvalidResponse`]. - /// - /// Errors: - /// - [`RpcError::InvalidResponse`] if the node returns a note whose ID was not requested. async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError>; /// Fetches the MMR delta for a given block range using the `/SyncChainMmr` RPC endpoint. @@ -280,12 +260,6 @@ pub trait NodeRpcClient: Send + Sync { /// Notes with attachments will have header-only metadata after this call; use /// [`NodeRpcClient::sync_notes_with_details`] to also resolve their full metadata and /// fetch public note bodies in a single follow-up call. - /// - /// A returned note whose tag was not present in `note_tags` is rejected with - /// [`RpcError::InvalidResponse`]. - /// - /// # Errors - /// - [`RpcError::InvalidResponse`] if the node returns a note whose tag was not requested. async fn sync_notes( &self, block_from: BlockNumber, @@ -348,13 +322,6 @@ pub trait NodeRpcClient: Send + Sync { /// - `prefix` is a list of nullifiers prefixes to search for. /// - `block_from`: The starting block number for the range (inclusive). /// - `block_to`: The ending block number for the range (inclusive). - /// - /// A returned nullifier whose prefix was not present in `prefix` is rejected with - /// [`RpcError::InvalidResponse`]. - /// - /// # Errors - /// - [`RpcError::InvalidResponse`] if the node returns a nullifier whose prefix was not - /// requested. async fn sync_nullifiers( &self, prefix: &[u16], @@ -371,13 +338,9 @@ pub trait NodeRpcClient: Send + Sync { /// /// For a fully oversize-resolved account, use [`NodeRpcClient::get_account_details`]. /// - /// When the request targets a specific block, a response for a different block is rejected with - /// [`RpcError::InvalidResponse`]. - /// /// # Errors /// /// - If the account isn't found in the network - /// - If the response block number does not match the requested one async fn get_account( &self, account_id: AccountId, @@ -540,13 +503,6 @@ pub trait NodeRpcClient: Send + Sync { /// Fetches the note script with the specified root, returning `None` if the node has no script /// registered for that root. - /// - /// A returned script whose root does not match the requested `root` is rejected with - /// [`RpcError::InvalidResponse`]; callers may rely on this invariant. - /// - /// Errors: - /// - [`RpcError::InvalidResponse`] if the node returns a script whose root does not match the - /// requested `root`. async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError>; /// Fetches storage map updates for specified account and storage slots within a block range, diff --git a/crates/rust-client/src/rpc/verification.rs b/crates/rust-client/src/rpc/verification.rs index fa705ffae3..bd951db0cf 100644 --- a/crates/rust-client/src/rpc/verification.rs +++ b/crates/rust-client/src/rpc/verification.rs @@ -40,16 +40,12 @@ use super::{ // VERIFYING RPC CLIENT // ================================================================================================ -/// A [`NodeRpcClient`] decorator that verifies each response against its request. +/// A [`NodeRpcClient`] decorator that verifies each response against its request, returning +/// [`RpcError::InvalidResponse`] on a mismatch. Each overridden method documents the specific +/// invariant it checks. /// -/// It delegates every call to the wrapped client and then checks request-scoped invariants (for -/// example, that a returned block matches the requested number, or that returned notes were among -/// those requested). Checks that need cross-call or client-side state are not performed here; they -/// live in the sync layer. -/// -/// The trait's provided methods are intentionally left inherited: their default implementations -/// call back through this type's verified methods, so the composite calls they make are checked -/// too. +/// Checks that need cross-call or client-side state are not performed here; they live in the sync +/// layer. pub struct VerifyingRpcClient { inner: Arc, } @@ -93,6 +89,7 @@ impl NodeRpcClient for VerifyingRpcClient { .await } + /// Verifies the returned header is for the requested block, when a block number is given. async fn get_block_header_by_number( &self, block_num: Option, @@ -104,6 +101,7 @@ impl NodeRpcClient for VerifyingRpcClient { Ok((header, mmr_proof)) } + /// Verifies the returned block is for the requested number. async fn get_block_by_number( &self, block_num: BlockNumber, @@ -114,6 +112,7 @@ impl NodeRpcClient for VerifyingRpcClient { Ok(block) } + /// Verifies every returned note's ID was requested. async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError> { let notes = self.inner.get_notes_by_id(note_ids).await?; let requested: BTreeSet = note_ids.iter().copied().collect(); @@ -129,6 +128,7 @@ impl NodeRpcClient for VerifyingRpcClient { self.inner.sync_chain_mmr(current_block_height, upper_bound).await } + /// Verifies every returned note's tag was requested. async fn sync_notes( &self, block_from: BlockNumber, @@ -143,6 +143,7 @@ impl NodeRpcClient for VerifyingRpcClient { Ok(blocks) } + /// Verifies every returned nullifier's prefix was requested. async fn sync_nullifiers( &self, prefix: &[u16], @@ -155,6 +156,7 @@ impl NodeRpcClient for VerifyingRpcClient { Ok(nullifiers) } + /// Verifies the response is for the requested block, when a specific block is requested. async fn get_account( &self, account_id: AccountId, @@ -169,6 +171,7 @@ impl NodeRpcClient for VerifyingRpcClient { Ok((block_num, proof)) } + /// Verifies the returned script's root matches the requested one. async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError> { let script = self.inner.get_note_script_by_root(root).await?; if let Some(script) = &script { From 2749d58ddfab2fee1e3d757398a2af5dd8d033f1 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Mon, 29 Jun 2026 15:47:05 -0300 Subject: [PATCH 3/4] Replace verifying wrapper with _unchecked trait defaults --- CHANGELOG.md | 4 +- crates/rust-client/src/builder.rs | 7 +- crates/rust-client/src/rpc/mod.rs | 173 +++++++++++- .../rust-client/src/rpc/tonic_client/mod.rs | 21 +- crates/rust-client/src/rpc/verification.rs | 251 ++---------------- crates/rust-client/src/test_utils/mock.rs | 20 +- 6 files changed, 208 insertions(+), 268 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3573479625..9766914028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## Unreleased -### Changes +### Breaking Changes -* [rust] RPC response verification now lives in a single `VerifyingRpcClient` wrapper that the client builder places around every `NodeRpcClient`. Implementations only provide transport and no longer need to check that a node's response matches the request themselves ([#2278](https://github.com/0xMiden/rust-sdk/pull/2278)). +* [BREAKING][rust] `NodeRpcClient` response verification moved into the trait's default methods; implementors now provide the raw transport via `get_block_header_by_number_unchecked`, `get_block_by_number_unchecked`, `get_notes_by_id_unchecked`, `sync_notes_unchecked`, `sync_nullifiers_unchecked`, `get_account_unchecked`, and `get_note_script_by_root_unchecked`, and the public methods verify the response ([#2278](https://github.com/0xMiden/rust-sdk/pull/2278)). ### Fixes diff --git a/crates/rust-client/src/builder.rs b/crates/rust-client/src/builder.rs index 3d1882c107..044577d472 100644 --- a/crates/rust-client/src/builder.rs +++ b/crates/rust-client/src/builder.rs @@ -17,7 +17,7 @@ use crate::keystore::FilesystemKeyStore; use crate::keystore::Keystore; use crate::note_transport::NoteTransportClient; use crate::pswap::PswapTransactionObserver; -use crate::rpc::{Endpoint, NodeRpcClient, VerifyingRpcClient}; +use crate::rpc::{Endpoint, NodeRpcClient}; use crate::store::{Store, StoreError}; use crate::transaction::{TransactionObserver, TransactionProver}; use crate::{Client, ClientError, ClientRng, ClientRngBox, DebugMode, grpc_support}; @@ -459,10 +459,9 @@ where /// - Returns an error if the store cannot be instantiated. #[allow(clippy::unused_async, unused_mut)] pub async fn build(mut self) -> Result, ClientError> { - // Determine the RPC client to use, wrapping it so every response is verified against its - // request regardless of the underlying implementation. + // Determine the RPC client to use. let rpc_api: Arc = if let Some(client) = self.rpc_api { - Arc::new(VerifyingRpcClient::new(client)) + client } else { return Err(ClientError::ClientInitializationError( "RPC client is required. Call `.rpc(...)` or `.grpc_client(...)`.".into(), diff --git a/crates/rust-client/src/rpc/mod.rs b/crates/rust-client/src/rpc/mod.rs index 2ee5dafa01..0b4751c1e8 100644 --- a/crates/rust-client/src/rpc/mod.rs +++ b/crates/rust-client/src/rpc/mod.rs @@ -56,7 +56,7 @@ use domain::account::{ StorageMapFetch, VaultFetch, }; -use domain::note::{FetchedNote, NoteSyncBlock, SyncedNoteDetails}; +use domain::note::{CommittedNote, FetchedNote, NoteSyncBlock, SyncedNoteDetails}; use domain::nullifier::NullifierUpdate; use domain::sync::{ChainMmrInfo, SyncTarget}; use miden_protocol::Word; @@ -84,7 +84,6 @@ pub use domain::status::{NetworkNoteStatus, NetworkNoteStatusInfo, RpcStatusInfo pub use endpoint::Endpoint; mod verification; -pub use verification::VerifyingRpcClient; #[cfg(not(feature = "testing"))] mod generated; @@ -163,27 +162,67 @@ pub trait NodeRpcClient: Send + Sync { ) -> Result; /// Given a block number, fetches the block header corresponding to that height from the node - /// using the `/GetBlockHeaderByNumber` endpoint. + /// using the `/GetBlockHeaderByNumber` endpoint, without verifying that the returned header + /// matches the requested block number. + /// /// If `include_mmr_proof` is set to true and the function returns an `Ok`, the second value /// of the return tuple should always be Some(MmrProof). /// /// When `None` is provided, returns info regarding the latest block. - async fn get_block_header_by_number( + async fn get_block_header_by_number_unchecked( &self, block_num: Option, include_mmr_proof: bool, ) -> Result<(BlockHeader, Option), RpcError>; + /// Fetches the block header for `block_num` and, when one is specified, verifies that the node + /// returned the requested block. + /// + /// See [`NodeRpcClient::get_block_header_by_number_unchecked`] for the parameters and the + /// latest-block behavior. + /// + /// # Errors + /// - [`RpcError::InvalidResponse`] if the node returns a header whose block number does not + /// match the requested `block_num`. + async fn get_block_header_by_number( + &self, + block_num: Option, + include_mmr_proof: bool, + ) -> Result<(BlockHeader, Option), RpcError> { + let (header, mmr_proof) = + self.get_block_header_by_number_unchecked(block_num, include_mmr_proof).await?; + verification::verify_block_num(block_num, header.block_num())?; + Ok((header, mmr_proof)) + } + /// Given a block number, fetches the block corresponding to that height from the node using - /// the `/GetBlockByNumber` RPC endpoint. + /// the `/GetBlockByNumber` RPC endpoint, without verifying that the returned block matches the + /// requested block number. /// /// If `include_proof` is set to true, the block proof will be included in the response. - async fn get_block_by_number( + async fn get_block_by_number_unchecked( &self, block_num: BlockNumber, include_proof: bool, ) -> Result; + /// Fetches the block for `block_num` and verifies that the node returned the requested block. + /// + /// See [`NodeRpcClient::get_block_by_number_unchecked`] for the parameters. + /// + /// # Errors + /// - [`RpcError::InvalidResponse`] if the node returns a block whose number does not match the + /// requested `block_num`. + async fn get_block_by_number( + &self, + block_num: BlockNumber, + include_proof: bool, + ) -> Result { + let block = self.get_block_by_number_unchecked(block_num, include_proof).await?; + verification::verify_block_num(Some(block_num), block.header().block_num())?; + Ok(block) + } + /// Fetches note-related data for a list of [`NoteId`] using the `/GetNotesById` /// RPC endpoint. /// @@ -195,7 +234,26 @@ pub trait NodeRpcClient: Send + Sync { /// /// In both cases, a [`miden_protocol::note::NoteInclusionProof`] is returned so the caller can /// verify that each note is part of the block's note tree. - async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError>; + /// + /// This does not verify that every returned note was among `note_ids`. + async fn get_notes_by_id_unchecked( + &self, + note_ids: &[NoteId], + ) -> Result, RpcError>; + + /// Fetches note-related data for `note_ids` and verifies that every returned note was + /// requested. + /// + /// See [`NodeRpcClient::get_notes_by_id_unchecked`] for the response shape. + /// + /// # Errors + /// - [`RpcError::InvalidResponse`] if the node returns a note whose ID was not requested. + async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError> { + let notes = self.get_notes_by_id_unchecked(note_ids).await?; + let requested: BTreeSet = note_ids.iter().copied().collect(); + verification::verify_note_ids(&requested, notes.iter().map(FetchedNote::id))?; + Ok(notes) + } /// Fetches the MMR delta for a given block range using the `/SyncChainMmr` RPC endpoint. /// @@ -260,13 +318,36 @@ pub trait NodeRpcClient: Send + Sync { /// Notes with attachments will have header-only metadata after this call; use /// [`NodeRpcClient::sync_notes_with_details`] to also resolve their full metadata and /// fetch public note bodies in a single follow-up call. - async fn sync_notes( + /// + /// This does not verify that every returned note's tag was among `note_tags`. + async fn sync_notes_unchecked( &self, block_from: BlockNumber, block_to: BlockNumber, note_tags: &BTreeSet, ) -> Result, RpcError>; + /// Fetches notes for `note_tags` over the range and verifies that every returned note's tag + /// was requested. + /// + /// See [`NodeRpcClient::sync_notes_unchecked`] for the parameters and pagination behavior. + /// + /// # Errors + /// - [`RpcError::InvalidResponse`] if the node returns a note whose tag was not requested. + async fn sync_notes( + &self, + block_from: BlockNumber, + block_to: BlockNumber, + note_tags: &BTreeSet, + ) -> Result, RpcError> { + let blocks = self.sync_notes_unchecked(block_from, block_to, note_tags).await?; + verification::verify_note_tags( + note_tags, + blocks.iter().flat_map(|b| b.notes.values().map(CommittedNote::tag)), + )?; + Ok(blocks) + } + /// Calls [`NodeRpcClient::sync_notes`] for the requested range, then makes a single /// [`NodeRpcClient::get_notes_by_id`] call to fetch full note bodies (scripts, assets, /// recipient) for public notes and attachment content for private notes that carry @@ -322,13 +403,35 @@ pub trait NodeRpcClient: Send + Sync { /// - `prefix` is a list of nullifiers prefixes to search for. /// - `block_from`: The starting block number for the range (inclusive). /// - `block_to`: The ending block number for the range (inclusive). - async fn sync_nullifiers( + /// + /// This does not verify that every returned nullifier's prefix was among `prefix`. + async fn sync_nullifiers_unchecked( &self, prefix: &[u16], block_from: BlockNumber, block_to: BlockNumber, ) -> Result, RpcError>; + /// Fetches nullifiers for `prefix` over the range and verifies that every returned nullifier's + /// prefix was requested. + /// + /// See [`NodeRpcClient::sync_nullifiers_unchecked`] for the parameters. + /// + /// # Errors + /// - [`RpcError::InvalidResponse`] if the node returns a nullifier whose prefix was not + /// requested. + async fn sync_nullifiers( + &self, + prefix: &[u16], + block_from: BlockNumber, + block_to: BlockNumber, + ) -> Result, RpcError> { + let nullifiers = self.sync_nullifiers_unchecked(prefix, block_from, block_to).await?; + let requested: BTreeSet = prefix.iter().copied().collect(); + verification::verify_nullifier_prefixes(&requested, &nullifiers)?; + Ok(nullifiers) + } + /// Fetches the account from the node, using the `/GetAccount` endpoint. /// /// The response carries an @@ -338,15 +441,41 @@ pub trait NodeRpcClient: Send + Sync { /// /// For a fully oversize-resolved account, use [`NodeRpcClient::get_account_details`]. /// + /// This does not verify that the response block number matches the requested one. + /// /// # Errors /// /// - If the account isn't found in the network - async fn get_account( + async fn get_account_unchecked( &self, account_id: AccountId, request: GetAccountRequest, ) -> Result<(BlockNumber, AccountProof), RpcError>; + /// Fetches the account from the node and, when a specific block was requested, verifies that + /// the node returned the account at that block. + /// + /// See [`NodeRpcClient::get_account_unchecked`] for details on the response. + /// + /// # Errors + /// + /// - If the account isn't found in the network. + /// - [`RpcError::InvalidResponse`] if the response block number does not match the requested + /// one. + async fn get_account( + &self, + account_id: AccountId, + request: GetAccountRequest, + ) -> Result<(BlockNumber, AccountProof), RpcError> { + let requested = match request.at { + AccountStateAt::Block(number) => Some(number), + AccountStateAt::ChainTip => None, + }; + let (block_num, proof) = self.get_account_unchecked(account_id, request).await?; + verification::verify_block_num(requested, block_num)?; + Ok((block_num, proof)) + } + /// Fills in the asset list when the vault came back flagged `too_many_assets`, by /// querying [`NodeRpcClient::sync_account_vault`] over `[GENESIS, block_to]`. No-op when /// the flag isn't set. @@ -502,8 +631,28 @@ pub trait NodeRpcClient: Send + Sync { } /// Fetches the note script with the specified root, returning `None` if the node has no script - /// registered for that root. - async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError>; + /// registered for that root, without verifying that a returned script's root matches the + /// requested `root`. + async fn get_note_script_by_root_unchecked( + &self, + root: Word, + ) -> Result, RpcError>; + + /// Fetches the note script with the specified root and verifies that a returned script's root + /// matches the requested `root`. + /// + /// Returns `None` if the node has no script registered for that root. + /// + /// # Errors + /// - [`RpcError::InvalidResponse`] if the node returns a script whose root does not match the + /// requested `root`. + async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError> { + let script = self.get_note_script_by_root_unchecked(root).await?; + if let Some(script) = &script { + verification::verify_note_script_root(root, script)?; + } + Ok(script) + } /// Fetches storage map updates for specified account and storage slots within a block range, /// using the `/SyncStorageMaps` RPC endpoint. diff --git a/crates/rust-client/src/rpc/tonic_client/mod.rs b/crates/rust-client/src/rpc/tonic_client/mod.rs index 8ad7a8ee81..1298148cc5 100644 --- a/crates/rust-client/src/rpc/tonic_client/mod.rs +++ b/crates/rust-client/src/rpc/tonic_client/mod.rs @@ -380,7 +380,7 @@ impl NodeRpcClient for GrpcClient { Ok(BlockNumber::from(api_response.into_inner().block_num)) } - async fn get_block_header_by_number( + async fn get_block_header_by_number_unchecked( &self, block_num: Option, include_mmr_proof: bool, @@ -429,7 +429,10 @@ impl NodeRpcClient for GrpcClient { Ok((block_header, mmr_proof)) } - async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError> { + async fn get_notes_by_id_unchecked( + &self, + note_ids: &[NoteId], + ) -> Result, RpcError> { let limits = self.get_rpc_limits().await?; let mut notes = Vec::with_capacity(note_ids.len()); for chunk in note_ids.chunks(limits.note_ids_limit as usize) { @@ -485,11 +488,10 @@ impl NodeRpcClient for GrpcClient { /// This function will return an error if: /// /// - The requested Account isn't returned by the node. - /// - The block number of the requested Account doesn't match the response block number. /// - There was an error sending the request to the node. /// - The answer had a `None` for one of the expected fields. /// - There is an error during storage deserialization. - async fn get_account( + async fn get_account_unchecked( &self, account_id: AccountId, request: GetAccountRequest, @@ -573,7 +575,7 @@ impl NodeRpcClient for GrpcClient { /// /// Chunks `note_tags` by [`RpcLimits::note_tags_limit`] and paginates each chunk across the /// requested block range. - async fn sync_notes( + async fn sync_notes_unchecked( &self, block_from: BlockNumber, block_to: BlockNumber, @@ -639,7 +641,7 @@ impl NodeRpcClient for GrpcClient { Ok(merged_blocks.into_values().collect()) } - async fn sync_nullifiers( + async fn sync_nullifiers_unchecked( &self, prefixes: &[u16], block_from: BlockNumber, @@ -694,7 +696,7 @@ impl NodeRpcClient for GrpcClient { Ok(all_nullifiers.into_iter().collect::>()) } - async fn get_block_by_number( + async fn get_block_by_number_unchecked( &self, block_num: BlockNumber, include_proof: bool, @@ -719,7 +721,10 @@ impl NodeRpcClient for GrpcClient { Ok(block) } - async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError> { + async fn get_note_script_by_root_unchecked( + &self, + root: Word, + ) -> Result, RpcError> { let request = proto::note::NoteScriptRoot { root: Some(root.into()) }; let response = self diff --git a/crates/rust-client/src/rpc/verification.rs b/crates/rust-client/src/rpc/verification.rs index bd951db0cf..9177046ce8 100644 --- a/crates/rust-client/src/rpc/verification.rs +++ b/crates/rust-client/src/rpc/verification.rs @@ -1,245 +1,23 @@ -//! Response verification layer for [`NodeRpcClient`]. -//! -//! A node's reply should match what the client asked for. [`VerifyingRpcClient`] wraps any -//! [`NodeRpcClient`] and checks each response against its request, returning -//! [`RpcError::InvalidResponse`] on a mismatch, so individual implementations only need to provide -//! transport. The client builder wraps every configured RPC client in this type, so these checks -//! always run regardless of the underlying implementation. +//! Response verification helpers shared by the [`NodeRpcClient`](super::NodeRpcClient) trait's +//! default methods, which check that a node's reply matches what was requested and return +//! [`RpcError::InvalidResponse`] on a mismatch. -use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::string::ToString; -use alloc::sync::Arc; use alloc::vec::Vec; use miden_protocol::Word; -use miden_protocol::account::AccountId; -use miden_protocol::address::NetworkId; -use miden_protocol::batch::{ProposedBatch, ProvenBatch}; -use miden_protocol::block::{BlockHeader, BlockNumber, ProvenBlock}; -use miden_protocol::crypto::merkle::mmr::MmrProof; +use miden_protocol::block::BlockNumber; use miden_protocol::note::{NoteId, NoteScript, NoteTag}; -use miden_protocol::transaction::{ProvenTransaction, TransactionInputs}; -use super::domain::account::{AccountProof, GetAccountRequest}; -use super::domain::account_vault::AccountVaultInfo; -use super::domain::note::{CommittedNote, FetchedNote, NoteSyncBlock}; +use super::RpcError; use super::domain::nullifier::NullifierUpdate; -use super::domain::storage_map::StorageMapInfo; -use super::domain::sync::{ChainMmrInfo, SyncTarget}; -use super::domain::transaction::TransactionRecord; -use super::{ - AccountStateAt, - NetworkNoteStatusInfo, - NodeRpcClient, - RpcError, - RpcLimits, - RpcStatusInfo, -}; - -// VERIFYING RPC CLIENT -// ================================================================================================ - -/// A [`NodeRpcClient`] decorator that verifies each response against its request, returning -/// [`RpcError::InvalidResponse`] on a mismatch. Each overridden method documents the specific -/// invariant it checks. -/// -/// Checks that need cross-call or client-side state are not performed here; they live in the sync -/// layer. -pub struct VerifyingRpcClient { - inner: Arc, -} - -impl VerifyingRpcClient { - /// Wraps `inner` so that its responses are verified against their requests. - pub fn new(inner: Arc) -> Self { - Self { inner } - } -} - -#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] -#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] -impl NodeRpcClient for VerifyingRpcClient { - async fn set_genesis_commitment(&self, commitment: Word) -> Result<(), RpcError> { - self.inner.set_genesis_commitment(commitment).await - } - - fn has_genesis_commitment(&self) -> Option { - self.inner.has_genesis_commitment() - } - - async fn submit_proven_transaction( - &self, - proven_transaction: ProvenTransaction, - transaction_inputs: TransactionInputs, - ) -> Result { - self.inner - .submit_proven_transaction(proven_transaction, transaction_inputs) - .await - } - - async fn submit_proven_batch( - &self, - proven_batch: ProvenBatch, - proposed_batch: ProposedBatch, - transaction_inputs: Vec, - ) -> Result { - self.inner - .submit_proven_batch(proven_batch, proposed_batch, transaction_inputs) - .await - } - - /// Verifies the returned header is for the requested block, when a block number is given. - async fn get_block_header_by_number( - &self, - block_num: Option, - include_mmr_proof: bool, - ) -> Result<(BlockHeader, Option), RpcError> { - let (header, mmr_proof) = - self.inner.get_block_header_by_number(block_num, include_mmr_proof).await?; - verify_block_num(block_num, header.block_num())?; - Ok((header, mmr_proof)) - } - - /// Verifies the returned block is for the requested number. - async fn get_block_by_number( - &self, - block_num: BlockNumber, - include_proof: bool, - ) -> Result { - let block = self.inner.get_block_by_number(block_num, include_proof).await?; - verify_block_num(Some(block_num), block.header().block_num())?; - Ok(block) - } - - /// Verifies every returned note's ID was requested. - async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError> { - let notes = self.inner.get_notes_by_id(note_ids).await?; - let requested: BTreeSet = note_ids.iter().copied().collect(); - verify_note_ids(&requested, notes.iter().map(FetchedNote::id))?; - Ok(notes) - } - - async fn sync_chain_mmr( - &self, - current_block_height: BlockNumber, - upper_bound: SyncTarget, - ) -> Result { - self.inner.sync_chain_mmr(current_block_height, upper_bound).await - } - - /// Verifies every returned note's tag was requested. - async fn sync_notes( - &self, - block_from: BlockNumber, - block_to: BlockNumber, - note_tags: &BTreeSet, - ) -> Result, RpcError> { - let blocks = self.inner.sync_notes(block_from, block_to, note_tags).await?; - verify_note_tags( - note_tags, - blocks.iter().flat_map(|b| b.notes.values().map(CommittedNote::tag)), - )?; - Ok(blocks) - } - - /// Verifies every returned nullifier's prefix was requested. - async fn sync_nullifiers( - &self, - prefix: &[u16], - block_from: BlockNumber, - block_to: BlockNumber, - ) -> Result, RpcError> { - let nullifiers = self.inner.sync_nullifiers(prefix, block_from, block_to).await?; - let requested: BTreeSet = prefix.iter().copied().collect(); - verify_nullifier_prefixes(&requested, &nullifiers)?; - Ok(nullifiers) - } - - /// Verifies the response is for the requested block, when a specific block is requested. - async fn get_account( - &self, - account_id: AccountId, - request: GetAccountRequest, - ) -> Result<(BlockNumber, AccountProof), RpcError> { - let requested = match request.at { - AccountStateAt::Block(number) => Some(number), - AccountStateAt::ChainTip => None, - }; - let (block_num, proof) = self.inner.get_account(account_id, request).await?; - verify_block_num(requested, block_num)?; - Ok((block_num, proof)) - } - - /// Verifies the returned script's root matches the requested one. - async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError> { - let script = self.inner.get_note_script_by_root(root).await?; - if let Some(script) = &script { - verify_note_script_root(root, script)?; - } - Ok(script) - } - - async fn sync_storage_maps( - &self, - block_from: BlockNumber, - block_to: BlockNumber, - account_id: AccountId, - ) -> Result { - self.inner.sync_storage_maps(block_from, block_to, account_id).await - } - - async fn sync_account_vault( - &self, - block_from: BlockNumber, - block_to: BlockNumber, - account_id: AccountId, - ) -> Result { - self.inner.sync_account_vault(block_from, block_to, account_id).await - } - - async fn sync_transactions( - &self, - block_from: BlockNumber, - block_to: BlockNumber, - account_ids: Vec, - ) -> Result, RpcError> { - self.inner.sync_transactions(block_from, block_to, account_ids).await - } - - async fn get_network_id(&self) -> Result { - self.inner.get_network_id().await - } - - async fn get_rpc_limits(&self) -> Result { - self.inner.get_rpc_limits().await - } - - fn has_rpc_limits(&self) -> Option { - self.inner.has_rpc_limits() - } - - async fn set_rpc_limits(&self, limits: RpcLimits) { - self.inner.set_rpc_limits(limits).await; - } - - async fn get_status_unversioned(&self) -> Result { - self.inner.get_status_unversioned().await - } - - async fn get_network_note_status( - &self, - note_id: NoteId, - ) -> Result { - self.inner.get_network_note_status(note_id).await - } -} - -// RESPONSE CHECKS -// ================================================================================================ /// Returns [`RpcError::InvalidResponse`] if `requested` is `Some` and `returned` does not equal it. -fn verify_block_num(requested: Option, returned: BlockNumber) -> Result<(), RpcError> { +pub(super) fn verify_block_num( + requested: Option, + returned: BlockNumber, +) -> Result<(), RpcError> { if let Some(requested) = requested && returned != requested { @@ -251,7 +29,7 @@ fn verify_block_num(requested: Option, returned: BlockNumber) -> Re } /// Returns [`RpcError::InvalidResponse`] if any returned note ID was not in `requested`. -fn verify_note_ids( +pub(super) fn verify_note_ids( requested: &BTreeSet, returned: impl IntoIterator, ) -> Result<(), RpcError> { @@ -267,7 +45,7 @@ fn verify_note_ids( } /// Returns [`RpcError::InvalidResponse`] if any returned note tag was not in `requested`. -fn verify_note_tags( +pub(super) fn verify_note_tags( requested: &BTreeSet, returned: impl IntoIterator, ) -> Result<(), RpcError> { @@ -284,7 +62,7 @@ fn verify_note_tags( /// Returns [`RpcError::InvalidResponse`] if any update carries a nullifier whose prefix was not in /// `requested_prefixes`. -fn verify_nullifier_prefixes( +pub(super) fn verify_nullifier_prefixes( requested_prefixes: &BTreeSet, batch: &[NullifierUpdate], ) -> Result<(), RpcError> { @@ -305,7 +83,10 @@ fn verify_nullifier_prefixes( } /// Returns [`RpcError::InvalidResponse`] if `script`'s root does not equal the `requested` root. -fn verify_note_script_root(requested: Word, script: &NoteScript) -> Result<(), RpcError> { +pub(super) fn verify_note_script_root( + requested: Word, + script: &NoteScript, +) -> Result<(), RpcError> { let fetched_root = script.root(); if Word::from(fetched_root) != requested { return Err(RpcError::InvalidResponse(format!( diff --git a/crates/rust-client/src/test_utils/mock.rs b/crates/rust-client/src/test_utils/mock.rs index d568f691fb..7116237400 100644 --- a/crates/rust-client/src/test_utils/mock.rs +++ b/crates/rust-client/src/test_utils/mock.rs @@ -323,7 +323,7 @@ impl NodeRpcClient for MockRpcApi { /// Returns note updates in the inclusive block range `[block_from, block_to]`. /// Only notes that match the provided tags will be returned, grouped by block. - async fn sync_notes( + async fn sync_notes_unchecked( &self, block_from: BlockNumber, block_to: BlockNumber, @@ -390,7 +390,7 @@ impl NodeRpcClient for MockRpcApi { /// Retrieves the block header for the specified block number. If the block number is not /// provided, the chain tip block header will be returned. - async fn get_block_header_by_number( + async fn get_block_header_by_number_unchecked( &self, block_num: Option, include_mmr_proof: bool, @@ -411,7 +411,10 @@ impl NodeRpcClient for MockRpcApi { } /// Returns the node's tracked notes that match the provided note IDs. - async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError> { + async fn get_notes_by_id_unchecked( + &self, + note_ids: &[NoteId], + ) -> Result, RpcError> { // assume all public notes for now let notes = self.mock_chain.read().committed_notes().clone(); @@ -484,7 +487,7 @@ impl NodeRpcClient for MockRpcApi { /// of the request are ignored in the mock implementation: the latest account code and full /// asset list are always returned, and the truncation flags are set when the data exceeds /// `oversize_threshold`. - async fn get_account( + async fn get_account_unchecked( &self, account_id: AccountId, request: GetAccountRequest, @@ -571,7 +574,7 @@ impl NodeRpcClient for MockRpcApi { /// Returns the nullifiers created after the specified block number that match the provided /// prefixes. - async fn sync_nullifiers( + async fn sync_nullifiers_unchecked( &self, prefixes: &[u16], block_from: BlockNumber, @@ -596,7 +599,7 @@ impl NodeRpcClient for MockRpcApi { Ok(nullifiers) } - async fn get_block_by_number( + async fn get_block_by_number_unchecked( &self, block_num: BlockNumber, _include_proof: bool, @@ -613,7 +616,10 @@ impl NodeRpcClient for MockRpcApi { Ok(block) } - async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError> { + async fn get_note_script_by_root_unchecked( + &self, + root: Word, + ) -> Result, RpcError> { let script = self .get_available_notes() .iter() From b962112bb5405caa8009f9565addb67f6580009b Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Mon, 29 Jun 2026 17:14:08 -0300 Subject: [PATCH 4/4] Inline RPC response-check helpers into rpc module --- crates/rust-client/src/rpc/mod.rs | 174 +++++++++++++++++++-- crates/rust-client/src/rpc/verification.rs | 169 -------------------- 2 files changed, 164 insertions(+), 179 deletions(-) delete mode 100644 crates/rust-client/src/rpc/verification.rs diff --git a/crates/rust-client/src/rpc/mod.rs b/crates/rust-client/src/rpc/mod.rs index 0b4751c1e8..90cd0bc8b0 100644 --- a/crates/rust-client/src/rpc/mod.rs +++ b/crates/rust-client/src/rpc/mod.rs @@ -43,7 +43,7 @@ use alloc::boxed::Box; use alloc::collections::{BTreeMap, BTreeSet}; -use alloc::string::String; +use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; @@ -83,8 +83,6 @@ pub use domain::limits::RpcLimits; pub use domain::status::{NetworkNoteStatus, NetworkNoteStatusInfo, RpcStatusInfo}; pub use endpoint::Endpoint; -mod verification; - #[cfg(not(feature = "testing"))] mod generated; #[cfg(feature = "testing")] @@ -118,6 +116,89 @@ fn metadata_has_attachments(metadata: &NoteMetadata) -> bool { metadata.attachment_headers().iter().any(|header| header.scheme().is_some()) } +// RESPONSE VERIFICATION +// ================================================================================================ +// +// Helpers used by the trait's default methods to check that a node's reply matches what was +// requested, returning [`RpcError::InvalidResponse`] on a mismatch. + +/// Returns [`RpcError::InvalidResponse`] if `requested` is `Some` and `returned` does not equal it. +fn verify_block_num(requested: Option, returned: BlockNumber) -> Result<(), RpcError> { + if let Some(requested) = requested + && returned != requested + { + return Err(RpcError::InvalidResponse(format!( + "node returned block {returned} but block {requested} was requested" + ))); + } + Ok(()) +} + +/// Returns [`RpcError::InvalidResponse`] if any returned note ID was not in `requested`. +fn verify_note_ids( + requested: &BTreeSet, + returned: impl IntoIterator, +) -> Result<(), RpcError> { + for id in returned { + if !requested.contains(&id) { + let list = requested.iter().map(ToString::to_string).collect::>().join(", "); + return Err(RpcError::InvalidResponse(format!( + "node returned note {id} but [{list}] were requested" + ))); + } + } + Ok(()) +} + +/// Returns [`RpcError::InvalidResponse`] if any returned note tag was not in `requested`. +fn verify_note_tags( + requested: &BTreeSet, + returned: impl IntoIterator, +) -> Result<(), RpcError> { + for tag in returned { + if !requested.contains(&tag) { + let list = requested.iter().map(ToString::to_string).collect::>().join(", "); + return Err(RpcError::InvalidResponse(format!( + "node returned note with tag {tag} but [{list}] were requested" + ))); + } + } + Ok(()) +} + +/// Returns [`RpcError::InvalidResponse`] if any update carries a nullifier whose prefix was not in +/// `requested_prefixes`. +fn verify_nullifier_prefixes( + requested_prefixes: &BTreeSet, + batch: &[NullifierUpdate], +) -> Result<(), RpcError> { + for update in batch { + let prefix = update.nullifier.prefix(); + if !requested_prefixes.contains(&prefix) { + let requested = requested_prefixes + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + return Err(RpcError::InvalidResponse(format!( + "node returned nullifier with prefix {prefix} but [{requested}] were requested" + ))); + } + } + Ok(()) +} + +/// Returns [`RpcError::InvalidResponse`] if `script`'s root does not equal the `requested` root. +fn verify_note_script_root(requested: Word, script: &NoteScript) -> Result<(), RpcError> { + let fetched_root = script.root(); + if Word::from(fetched_root) != requested { + return Err(RpcError::InvalidResponse(format!( + "node returned note script with root {fetched_root} for requested root {requested}" + ))); + } + Ok(()) +} + // NODE RPC CLIENT TRAIT // ================================================================================================ @@ -191,7 +272,7 @@ pub trait NodeRpcClient: Send + Sync { ) -> Result<(BlockHeader, Option), RpcError> { let (header, mmr_proof) = self.get_block_header_by_number_unchecked(block_num, include_mmr_proof).await?; - verification::verify_block_num(block_num, header.block_num())?; + verify_block_num(block_num, header.block_num())?; Ok((header, mmr_proof)) } @@ -219,7 +300,7 @@ pub trait NodeRpcClient: Send + Sync { include_proof: bool, ) -> Result { let block = self.get_block_by_number_unchecked(block_num, include_proof).await?; - verification::verify_block_num(Some(block_num), block.header().block_num())?; + verify_block_num(Some(block_num), block.header().block_num())?; Ok(block) } @@ -251,7 +332,7 @@ pub trait NodeRpcClient: Send + Sync { async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result, RpcError> { let notes = self.get_notes_by_id_unchecked(note_ids).await?; let requested: BTreeSet = note_ids.iter().copied().collect(); - verification::verify_note_ids(&requested, notes.iter().map(FetchedNote::id))?; + verify_note_ids(&requested, notes.iter().map(FetchedNote::id))?; Ok(notes) } @@ -341,7 +422,7 @@ pub trait NodeRpcClient: Send + Sync { note_tags: &BTreeSet, ) -> Result, RpcError> { let blocks = self.sync_notes_unchecked(block_from, block_to, note_tags).await?; - verification::verify_note_tags( + verify_note_tags( note_tags, blocks.iter().flat_map(|b| b.notes.values().map(CommittedNote::tag)), )?; @@ -428,7 +509,7 @@ pub trait NodeRpcClient: Send + Sync { ) -> Result, RpcError> { let nullifiers = self.sync_nullifiers_unchecked(prefix, block_from, block_to).await?; let requested: BTreeSet = prefix.iter().copied().collect(); - verification::verify_nullifier_prefixes(&requested, &nullifiers)?; + verify_nullifier_prefixes(&requested, &nullifiers)?; Ok(nullifiers) } @@ -472,7 +553,7 @@ pub trait NodeRpcClient: Send + Sync { AccountStateAt::ChainTip => None, }; let (block_num, proof) = self.get_account_unchecked(account_id, request).await?; - verification::verify_block_num(requested, block_num)?; + verify_block_num(requested, block_num)?; Ok((block_num, proof)) } @@ -649,7 +730,7 @@ pub trait NodeRpcClient: Send + Sync { async fn get_note_script_by_root(&self, root: Word) -> Result, RpcError> { let script = self.get_note_script_by_root_unchecked(root).await?; if let Some(script) = &script { - verification::verify_note_script_root(root, script)?; + verify_note_script_root(root, script)?; } Ok(script) } @@ -800,3 +881,76 @@ impl fmt::Display for RpcEndpoint { } } } + +#[cfg(test)] +mod tests { + use core::slice; + use std::collections::BTreeSet; + + use miden_protocol::note::{NoteId, NoteTag, Nullifier}; + use miden_protocol::{Felt, Word}; + + use super::{verify_note_ids, verify_note_tags, verify_nullifier_prefixes}; + use crate::rpc::RpcError; + use crate::rpc::domain::nullifier::NullifierUpdate; + + fn nullifier_with_prefix(prefix: u16) -> Nullifier { + Nullifier::from_raw(Word::new([ + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::new_unchecked(u64::from(prefix) << 48), + ])) + } + + #[test] + fn verify_nullifier_prefixes_rejects_unrequested() { + let requested = NullifierUpdate { + nullifier: nullifier_with_prefix(0x1234), + block_num: 1u32.into(), + }; + let unrequested = NullifierUpdate { + nullifier: nullifier_with_prefix(0xabcd), + block_num: 2u32.into(), + }; + + let requested_prefixes: BTreeSet = BTreeSet::from([0x1234]); + + verify_nullifier_prefixes(&requested_prefixes, slice::from_ref(&requested)) + .expect("requested prefix must be accepted"); + + let err = verify_nullifier_prefixes(&requested_prefixes, &[requested, unrequested]) + .expect_err("unrequested prefix must be rejected"); + assert!(matches!(err, RpcError::InvalidResponse(_))); + } + + #[test] + fn verify_note_tags_rejects_unrequested() { + let requested = NoteTag::new(1); + let other = NoteTag::new(2); + let requested_set = BTreeSet::from([requested]); + + verify_note_tags(&requested_set, [requested]).expect("requested tag must be accepted"); + + let err = verify_note_tags(&requested_set, [other]) + .expect_err("unrequested tag must be rejected"); + assert!(matches!(err, RpcError::InvalidResponse(_))); + } + + fn note_id(n: u32) -> NoteId { + NoteId::from_raw(Word::from([n, 0, 0, 0])) + } + + #[test] + fn verify_note_ids_rejects_unrequested() { + let requested = note_id(1); + let other = note_id(2); + let requested_set = BTreeSet::from([requested]); + + verify_note_ids(&requested_set, [requested]).expect("requested note id must be accepted"); + + let err = verify_note_ids(&requested_set, [other]) + .expect_err("unrequested note id must be rejected"); + assert!(matches!(err, RpcError::InvalidResponse(_))); + } +} diff --git a/crates/rust-client/src/rpc/verification.rs b/crates/rust-client/src/rpc/verification.rs deleted file mode 100644 index 9177046ce8..0000000000 --- a/crates/rust-client/src/rpc/verification.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Response verification helpers shared by the [`NodeRpcClient`](super::NodeRpcClient) trait's -//! default methods, which check that a node's reply matches what was requested and return -//! [`RpcError::InvalidResponse`] on a mismatch. - -use alloc::collections::BTreeSet; -use alloc::string::ToString; -use alloc::vec::Vec; - -use miden_protocol::Word; -use miden_protocol::block::BlockNumber; -use miden_protocol::note::{NoteId, NoteScript, NoteTag}; - -use super::RpcError; -use super::domain::nullifier::NullifierUpdate; - -/// Returns [`RpcError::InvalidResponse`] if `requested` is `Some` and `returned` does not equal it. -pub(super) fn verify_block_num( - requested: Option, - returned: BlockNumber, -) -> Result<(), RpcError> { - if let Some(requested) = requested - && returned != requested - { - return Err(RpcError::InvalidResponse(format!( - "node returned block {returned} but block {requested} was requested" - ))); - } - Ok(()) -} - -/// Returns [`RpcError::InvalidResponse`] if any returned note ID was not in `requested`. -pub(super) fn verify_note_ids( - requested: &BTreeSet, - returned: impl IntoIterator, -) -> Result<(), RpcError> { - for id in returned { - if !requested.contains(&id) { - let list = requested.iter().map(ToString::to_string).collect::>().join(", "); - return Err(RpcError::InvalidResponse(format!( - "node returned note {id} but [{list}] were requested" - ))); - } - } - Ok(()) -} - -/// Returns [`RpcError::InvalidResponse`] if any returned note tag was not in `requested`. -pub(super) fn verify_note_tags( - requested: &BTreeSet, - returned: impl IntoIterator, -) -> Result<(), RpcError> { - for tag in returned { - if !requested.contains(&tag) { - let list = requested.iter().map(ToString::to_string).collect::>().join(", "); - return Err(RpcError::InvalidResponse(format!( - "node returned note with tag {tag} but [{list}] were requested" - ))); - } - } - Ok(()) -} - -/// Returns [`RpcError::InvalidResponse`] if any update carries a nullifier whose prefix was not in -/// `requested_prefixes`. -pub(super) fn verify_nullifier_prefixes( - requested_prefixes: &BTreeSet, - batch: &[NullifierUpdate], -) -> Result<(), RpcError> { - for update in batch { - let prefix = update.nullifier.prefix(); - if !requested_prefixes.contains(&prefix) { - let requested = requested_prefixes - .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - return Err(RpcError::InvalidResponse(format!( - "node returned nullifier with prefix {prefix} but [{requested}] were requested" - ))); - } - } - Ok(()) -} - -/// Returns [`RpcError::InvalidResponse`] if `script`'s root does not equal the `requested` root. -pub(super) fn verify_note_script_root( - requested: Word, - script: &NoteScript, -) -> Result<(), RpcError> { - let fetched_root = script.root(); - if Word::from(fetched_root) != requested { - return Err(RpcError::InvalidResponse(format!( - "node returned note script with root {fetched_root} for requested root {requested}" - ))); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use core::slice; - use std::collections::BTreeSet; - - use miden_protocol::note::{NoteId, NoteTag, Nullifier}; - use miden_protocol::{Felt, Word}; - - use super::{NullifierUpdate, verify_note_ids, verify_note_tags, verify_nullifier_prefixes}; - use crate::rpc::RpcError; - - fn nullifier_with_prefix(prefix: u16) -> Nullifier { - Nullifier::from_raw(Word::new([ - Felt::ZERO, - Felt::ZERO, - Felt::ZERO, - Felt::new_unchecked(u64::from(prefix) << 48), - ])) - } - - #[test] - fn verify_nullifier_prefixes_rejects_unrequested() { - let requested = NullifierUpdate { - nullifier: nullifier_with_prefix(0x1234), - block_num: 1u32.into(), - }; - let unrequested = NullifierUpdate { - nullifier: nullifier_with_prefix(0xabcd), - block_num: 2u32.into(), - }; - - let requested_prefixes: BTreeSet = BTreeSet::from([0x1234]); - - verify_nullifier_prefixes(&requested_prefixes, slice::from_ref(&requested)) - .expect("requested prefix must be accepted"); - - let err = verify_nullifier_prefixes(&requested_prefixes, &[requested, unrequested]) - .expect_err("unrequested prefix must be rejected"); - assert!(matches!(err, RpcError::InvalidResponse(_))); - } - - #[test] - fn verify_note_tags_rejects_unrequested() { - let requested = NoteTag::new(1); - let other = NoteTag::new(2); - let requested_set = BTreeSet::from([requested]); - - verify_note_tags(&requested_set, [requested]).expect("requested tag must be accepted"); - - let err = verify_note_tags(&requested_set, [other]) - .expect_err("unrequested tag must be rejected"); - assert!(matches!(err, RpcError::InvalidResponse(_))); - } - - fn note_id(n: u32) -> NoteId { - NoteId::from_raw(Word::from([n, 0, 0, 0])) - } - - #[test] - fn verify_note_ids_rejects_unrequested() { - let requested = note_id(1); - let other = note_id(2); - let requested_set = BTreeSet::from([requested]); - - verify_note_ids(&requested_set, [requested]).expect("requested note id must be accepted"); - - let err = verify_note_ids(&requested_set, [other]) - .expect_err("unrequested note id must be rejected"); - assert!(matches!(err, RpcError::InvalidResponse(_))); - } -}