Skip to main content

miden_client/rpc/tonic_client/
mod.rs

1use alloc::borrow::ToOwned;
2use alloc::boxed::Box;
3use alloc::collections::{BTreeMap, BTreeSet};
4use alloc::string::{String, ToString};
5use alloc::vec::Vec;
6use core::error::Error;
7use core::pin::Pin;
8
9use miden_protocol::vm::FutureMaybeSend;
10
11type RpcFuture<T> = Pin<Box<dyn FutureMaybeSend<T>>>;
12
13use miden_protocol::account::{
14    AccountCode,
15    AccountId,
16    AccountVaultPatch,
17    StorageMapPatchEntries,
18    StorageSlotName,
19};
20use miden_protocol::address::NetworkId;
21use miden_protocol::batch::{ProposedBatch, ProvenBatch};
22use miden_protocol::block::account_tree::AccountWitness;
23use miden_protocol::block::{BlockHeader, BlockNumber, ProvenBlock};
24use miden_protocol::crypto::merkle::MerklePath;
25use miden_protocol::crypto::merkle::mmr::{Forest, MmrPath, MmrProof};
26use miden_protocol::note::{NoteId, NoteScript, NoteTag};
27use miden_protocol::transaction::{ProvenTransaction, TransactionInputs};
28use miden_protocol::utils::serde::Deserializable;
29use miden_protocol::{EMPTY_WORD, Word};
30use miden_tx::utils::serde::Serializable;
31use miden_tx::utils::sync::RwLock;
32use tonic::Status;
33use tracing::info;
34
35use super::domain::account::{
36    AccountProof,
37    AccountStorageRequirements,
38    GetAccountRequest,
39    StorageMapFetch,
40};
41use super::domain::note::{CommittedNote, FetchedNote, NoteSyncBlock};
42use super::domain::nullifier::NullifierUpdate;
43use super::generated::rpc::AccountRequest;
44use super::generated::rpc::account_request::AccountDetailRequest;
45use super::{Endpoint, NodeRpcClient, RpcEndpoint, RpcError, RpcStatusInfo};
46use crate::rpc::domain::account_vault::AccountVaultInfo;
47use crate::rpc::domain::limits::RpcLimits;
48use crate::rpc::domain::status::NetworkNoteStatusInfo;
49use crate::rpc::domain::storage_map::StorageMapInfo;
50use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
51use crate::rpc::domain::transaction::TransactionRecord;
52use crate::rpc::errors::node::parse_node_error;
53use crate::rpc::errors::{AcceptHeaderContext, AcceptHeaderError, GrpcError, RpcConversionError};
54use crate::rpc::generated::rpc::BlockRange;
55use crate::rpc::{AccountStateAt, generated as proto};
56
57mod api_client;
58mod retry;
59
60use api_client::api_client_wrapper::ApiClient;
61
62/// Tracks the pagination state for block-driven endpoints.
63struct BlockPagination {
64    current_block_from: BlockNumber,
65    block_to: BlockNumber,
66    iterations: u32,
67}
68
69enum PaginationResult {
70    Continue,
71    Done {
72        chain_tip: BlockNumber,
73        block_num: BlockNumber,
74    },
75}
76
77impl BlockPagination {
78    /// Maximum number of pagination iterations for a single request.
79    ///
80    /// Protects against nodes returning inconsistent pagination data that could otherwise
81    /// trigger an infinite loop.
82    const MAX_ITERATIONS: u32 = 1000;
83
84    fn new(block_from: BlockNumber, block_to: BlockNumber) -> Self {
85        Self {
86            current_block_from: block_from,
87            block_to,
88            iterations: 0,
89        }
90    }
91
92    fn current_block_from(&self) -> BlockNumber {
93        self.current_block_from
94    }
95
96    fn block_to(&self) -> BlockNumber {
97        self.block_to
98    }
99
100    fn advance(
101        &mut self,
102        block_num: BlockNumber,
103        chain_tip: BlockNumber,
104    ) -> Result<PaginationResult, RpcError> {
105        if self.iterations >= Self::MAX_ITERATIONS {
106            return Err(RpcError::PaginationError(
107                "too many pagination iterations, possible infinite loop".to_owned(),
108            ));
109        }
110        self.iterations += 1;
111
112        if block_num < self.current_block_from {
113            return Err(RpcError::PaginationError(
114                "invalid pagination: block_num went backwards".to_owned(),
115            ));
116        }
117
118        let target_block = self.block_to.min(chain_tip);
119
120        if block_num >= target_block {
121            return Ok(PaginationResult::Done { chain_tip, block_num });
122        }
123
124        self.current_block_from = BlockNumber::from(block_num.as_u32().saturating_add(1));
125
126        Ok(PaginationResult::Continue)
127    }
128}
129
130/// Returns [`RpcError::InvalidResponse`] if any update in the `sync_nullifiers` batch carries a
131/// nullifier whose prefix was not requested.
132fn ensure_requested_nullifiers(
133    requested_prefixes: &BTreeSet<u16>,
134    batch: &[NullifierUpdate],
135) -> Result<(), RpcError> {
136    for update in batch {
137        let prefix = update.nullifier.prefix();
138        if !requested_prefixes.contains(&prefix) {
139            let requested = requested_prefixes
140                .iter()
141                .map(ToString::to_string)
142                .collect::<Vec<_>>()
143                .join(", ");
144            return Err(RpcError::InvalidResponse(format!(
145                "node returned nullifier with prefix {prefix} but [{requested}] were requested"
146            )));
147        }
148    }
149    Ok(())
150}
151
152/// Returns an error if any note in a `sync_notes` response carries a tag that was not requested.
153fn ensure_requested_tags(
154    requested: &BTreeSet<NoteTag>,
155    returned: impl IntoIterator<Item = NoteTag>,
156) -> Result<(), RpcError> {
157    for tag in returned {
158        if !requested.contains(&tag) {
159            let list = requested.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
160            return Err(RpcError::InvalidResponse(format!(
161                "node returned note with tag {tag} but [{list}] were requested"
162            )));
163        }
164    }
165    Ok(())
166}
167
168/// Returns an error if any note in a `GetNotesById` response has an ID that was not requested.
169fn ensure_requested_note_ids(
170    requested: &BTreeSet<NoteId>,
171    returned: impl IntoIterator<Item = NoteId>,
172) -> Result<(), RpcError> {
173    for id in returned {
174        if !requested.contains(&id) {
175            let list = requested.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
176            return Err(RpcError::InvalidResponse(format!(
177                "node returned note {id} but [{list}] were requested"
178            )));
179        }
180    }
181    Ok(())
182}
183
184// GRPC CLIENT
185// ================================================================================================
186
187/// Default maximum size (in bytes) of a decoded gRPC response the client will accept: 15% above
188/// tonic's built-in 4 MiB receive limit. See [`GrpcClient::with_max_decoding_message_size`].
189const DEFAULT_MAX_RESPONSE_SIZE_BYTES: usize = 4 * 1024 * 1024 * 115 / 100;
190
191/// Client for the Node RPC API using gRPC.
192///
193/// If the `tonic` feature is enabled, this client will use a `tonic::transport::Channel` to
194/// communicate with the node. In this case the connection will be established lazily when the
195/// first request is made.
196/// If the `web-tonic` feature is enabled, this client will use a `tonic_web_wasm_client::Client`
197/// to communicate with the node.
198///
199/// In both cases, the [`GrpcClient`] depends on the types inside the `generated` module, which
200/// are generated by the build script and also depend on the target architecture.
201pub struct GrpcClient {
202    /// The underlying gRPC client, lazily initialized on first request.
203    client: RwLock<Option<ApiClient>>,
204    /// The node endpoint URL to connect to.
205    endpoint: String,
206    /// Request timeout in milliseconds.
207    timeout_ms: u64,
208    /// The genesis block commitment, used for request validation by the node.
209    genesis_commitment: RwLock<Option<Word>>,
210    /// Cached RPC limits fetched from the node.
211    limits: RwLock<Option<RpcLimits>>,
212    /// Maximum number of retry attempts for rate-limited or transiently unavailable requests.
213    max_retries: u32,
214    /// Fallback retry interval in milliseconds when no `retry-after` header is present.
215    retry_interval_ms: u64,
216    /// Optional bearer token injected as `authorization: Bearer <token>` on every outbound
217    /// gRPC call, alongside the standard `accept` header. Used when talking to an
218    /// authenticating gateway in front of the node.
219    bearer_token: Option<String>,
220    /// Maximum size (in bytes) of a decoded gRPC response the client will accept. Defaults to
221    /// [`DEFAULT_MAX_RESPONSE_SIZE_BYTES`].
222    max_decoding_message_size: usize,
223}
224
225impl GrpcClient {
226    /// Returns a new instance of [`GrpcClient`] that'll do calls to the provided [`Endpoint`]
227    /// with the given timeout in milliseconds.
228    pub fn new(endpoint: &Endpoint, timeout_ms: u64) -> GrpcClient {
229        GrpcClient {
230            client: RwLock::new(None),
231            endpoint: endpoint.to_string(),
232            timeout_ms,
233            genesis_commitment: RwLock::new(None),
234            limits: RwLock::new(None),
235            max_retries: retry::DEFAULT_MAX_RETRIES,
236            retry_interval_ms: retry::DEFAULT_RETRY_INTERVAL_MS,
237            bearer_token: None,
238            max_decoding_message_size: DEFAULT_MAX_RESPONSE_SIZE_BYTES,
239        }
240    }
241
242    /// Sets the maximum number of retry attempts for rate-limited or transiently unavailable
243    /// requests. Defaults to `4`.
244    #[must_use]
245    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
246        self.max_retries = max_retries;
247        self
248    }
249
250    /// Sets the fallback retry interval in milliseconds, used when the server does not provide
251    /// a `retry-after` header. Defaults to `100` ms.
252    #[must_use]
253    pub fn with_retry_interval_ms(mut self, retry_interval_ms: u64) -> Self {
254        self.retry_interval_ms = retry_interval_ms;
255        self
256    }
257
258    /// Sets the maximum size (in bytes) of a decoded gRPC response the client will accept.
259    ///
260    /// Defaults to 15% above [tonic's built-in 4 MiB receive limit][tonic-decode], leaving headroom
261    /// for responses that land slightly over 4 MiB.
262    ///
263    /// [tonic-decode]: https://github.com/hyperium/tonic/blob/6cb6056b5a748bc5a29bd48f4602dbc4e552bb7d/tonic/src/codec/decode.rs#L192-L218
264    #[must_use]
265    pub fn with_max_decoding_message_size(mut self, max_decoding_message_size: usize) -> Self {
266        self.max_decoding_message_size = max_decoding_message_size;
267        self
268    }
269
270    /// Attaches a `authorization: Bearer <token>` header to every outbound gRPC call made
271    /// by this client, alongside the standard `accept` header.
272    ///
273    /// Intended for connecting to a Miden node through an authenticating gateway
274    /// (e.g. `miden-testnet.eu-central-8.gateway.fm`) that rate-limits unauthenticated
275    /// traffic. Without an auth mechanism on the client side, callers would have no way
276    /// to supply the token the gateway requires.
277    ///
278    /// Calling this method twice overwrites the earlier token.
279    ///
280    /// Validation of the token against
281    /// [`AsciiMetadataValue`](tonic::metadata::AsciiMetadataValue) is deferred to
282    /// connection time (printable ASCII plus tab only — `HeaderValue::from_str` semantics):
283    /// invalid tokens surface as
284    /// [`RpcError::ConnectionError`](crate::rpc::RpcError::ConnectionError) on the first
285    /// request, so CR/LF header-injection attempts are rejected.
286    ///
287    /// # Example
288    ///
289    /// ```no_run
290    /// # use miden_client::rpc::{Endpoint, GrpcClient};
291    /// let endpoint = Endpoint::new("https".into(), "node.example".into(), Some(443));
292    /// let client = GrpcClient::new(&endpoint, 10_000).with_bearer_auth("<api-key>".into());
293    /// ```
294    #[must_use]
295    pub fn with_bearer_auth(mut self, token: String) -> Self {
296        self.bearer_token = Some(token);
297        self
298    }
299
300    /// Takes care of establishing the RPC connection if not connected yet. It ensures that the
301    /// `rpc_api` field is initialized and returns a write guard to it.
302    async fn ensure_connected(&self) -> Result<ApiClient, RpcError> {
303        if self.client.read().is_none() {
304            self.connect().await?;
305        }
306
307        Ok(self.client.read().as_ref().expect("rpc_api should be initialized").clone())
308    }
309
310    /// Connects to the Miden node, setting the client API with the provided URL, timeout and
311    /// genesis commitment.
312    async fn connect(&self) -> Result<(), RpcError> {
313        let genesis_commitment = *self.genesis_commitment.read();
314        let new_client = ApiClient::new_client(
315            self.endpoint.clone(),
316            self.timeout_ms,
317            genesis_commitment,
318            self.bearer_token.clone(),
319            self.max_decoding_message_size,
320        )
321        .await?;
322        let mut client = self.client.write();
323        client.replace(new_client);
324
325        Ok(())
326    }
327
328    fn rpc_error_from_status(&self, endpoint: RpcEndpoint, status: Status) -> RpcError {
329        let genesis_commitment = self
330            .genesis_commitment
331            .read()
332            .as_ref()
333            .map_or_else(|| "none".to_string(), Word::to_hex);
334        let context = AcceptHeaderContext {
335            client_version: env!("CARGO_PKG_VERSION").to_string(),
336            genesis_commitment,
337        };
338        RpcError::from_grpc_error_with_context(endpoint, status, context)
339    }
340
341    /// Executes an RPC call and automatically retries transient failures.
342    ///
343    /// The provided closure is invoked with a freshly connected [`ApiClient`] on each attempt.
344    /// Retries are delegated to [`retry::RetryState`], which currently handles gRPC
345    /// [`tonic::Code::ResourceExhausted`] and [`tonic::Code::Unavailable`] responses, including
346    /// honoring cooldown delays when the node provides them.
347    ///
348    /// Returns the first successful gRPC response. If the call keeps failing after retries are
349    /// exhausted, or if the error is not retryable, this returns the corresponding [`RpcError`]
350    /// for the provided [`RpcEndpoint`].
351    async fn call_with_retry<T: Send + 'static>(
352        &self,
353        endpoint: RpcEndpoint,
354        mut call: impl FnMut(ApiClient) -> RpcFuture<Result<tonic::Response<T>, Status>>,
355    ) -> Result<tonic::Response<T>, RpcError> {
356        let mut retry_state = retry::RetryState::new(self.max_retries, self.retry_interval_ms);
357
358        loop {
359            let rpc_api = self.ensure_connected().await?;
360
361            match call(rpc_api).await {
362                Ok(response) => return Ok(response),
363                Err(status) if retry_state.should_retry(&status).await => {},
364                Err(status) => return Err(self.rpc_error_from_status(endpoint, status)),
365            }
366        }
367    }
368
369    /// Fetches RPC status without injecting an Accept header.
370    ///
371    /// This instantiates a separate API client without the Accept interceptor, so it does not
372    /// reuse the primary gRPC client. Any caller-supplied
373    /// [`with_bearer_auth`](Self::with_bearer_auth) token is still forwarded so gateway
374    /// authentication keeps working.
375    pub async fn get_status_unversioned(&self) -> Result<RpcStatusInfo, RpcError> {
376        let mut rpc_api = ApiClient::new_client_without_accept_header(
377            self.endpoint.clone(),
378            self.timeout_ms,
379            self.bearer_token.clone(),
380            self.max_decoding_message_size,
381        )
382        .await?;
383        rpc_api
384            .status(())
385            .await
386            .map_err(|status| self.rpc_error_from_status(RpcEndpoint::Status, status))
387            .map(tonic::Response::into_inner)
388            .and_then(RpcStatusInfo::try_from)
389    }
390}
391
392#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
393#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
394impl NodeRpcClient for GrpcClient {
395    /// Sets the genesis commitment for the client. If the client is already connected, it will be
396    /// updated to use the new commitment on subsequent requests. If the client is not connected,
397    /// the commitment will be stored and used when the client connects. If the genesis commitment
398    /// is already set, this method does nothing.
399    fn has_genesis_commitment(&self) -> Option<Word> {
400        *self.genesis_commitment.read()
401    }
402
403    async fn set_genesis_commitment(&self, commitment: Word) -> Result<(), RpcError> {
404        // Check if already set before doing anything else
405        if self.genesis_commitment.read().is_some() {
406            // Genesis commitment is already set, ignoring the new value.
407            return Ok(());
408        }
409
410        // Store the commitment for future connections
411        self.genesis_commitment.write().replace(commitment);
412
413        // If a client is already connected, update it to use the new genesis commitment.
414        // If not connected, the commitment will be used when connect() is called.
415        let mut client_guard = self.client.write();
416        if let Some(client) = client_guard.as_mut() {
417            client.set_genesis_commitment(commitment);
418        }
419
420        Ok(())
421    }
422
423    async fn submit_proven_transaction(
424        &self,
425        proven_transaction: ProvenTransaction,
426        transaction_inputs: TransactionInputs,
427    ) -> Result<BlockNumber, RpcError> {
428        let request = proto::transaction::ProvenTransaction {
429            transaction: proven_transaction.to_bytes(),
430            transaction_inputs: Some(transaction_inputs.to_bytes()),
431        };
432
433        let api_response = self
434            .call_with_retry(RpcEndpoint::SubmitProvenTx, |mut rpc_api| {
435                let request = request.clone();
436                Box::pin(async move { rpc_api.submit_proven_tx(request).await })
437            })
438            .await?;
439
440        Ok(BlockNumber::from(api_response.into_inner().block_num))
441    }
442
443    async fn submit_proven_batch(
444        &self,
445        proven_batch: ProvenBatch,
446        proposed_batch: ProposedBatch,
447        transaction_inputs: Vec<TransactionInputs>,
448    ) -> Result<BlockNumber, RpcError> {
449        let request = proto::transaction::TransactionBatch {
450            batch_proof: proven_batch.to_bytes(),
451            proposed_batch: Some(proposed_batch.to_bytes()),
452            transaction_inputs: transaction_inputs.iter().map(Serializable::to_bytes).collect(),
453        };
454
455        let api_response = self
456            .call_with_retry(RpcEndpoint::SubmitProvenBatch, |mut rpc_api| {
457                let request = request.clone();
458                Box::pin(async move { rpc_api.submit_proven_tx_batch(request).await })
459            })
460            .await?;
461
462        Ok(BlockNumber::from(api_response.into_inner().block_num))
463    }
464
465    async fn get_block_header_by_number(
466        &self,
467        block_num: Option<BlockNumber>,
468        include_mmr_proof: bool,
469    ) -> Result<(BlockHeader, Option<MmrProof>), RpcError> {
470        let request = proto::rpc::BlockHeaderByNumberRequest {
471            block_num: block_num.as_ref().map(BlockNumber::as_u32),
472            include_mmr_proof: Some(include_mmr_proof),
473        };
474
475        info!("Calling GetBlockHeaderByNumber: {:?}", request);
476
477        let api_response = self
478            .call_with_retry(RpcEndpoint::GetBlockHeaderByNumber, |mut rpc_api| {
479                Box::pin(async move { rpc_api.get_block_header_by_number(request).await })
480            })
481            .await?;
482
483        let response = api_response.into_inner();
484
485        let block_header: BlockHeader = response
486            .block_header
487            .ok_or(RpcError::ExpectedDataMissing("BlockHeader".into()))?
488            .try_into()?;
489
490        if let Some(requested) = block_num
491            && block_header.block_num() != requested
492        {
493            return Err(RpcError::InvalidResponse(format!(
494                "node returned header for block {} but block {requested} was requested",
495                block_header.block_num(),
496            )));
497        }
498
499        let mmr_proof = if include_mmr_proof {
500            let forest = response
501                .chain_length
502                .ok_or(RpcError::ExpectedDataMissing("ChainLength".into()))?;
503            let merkle_path: MerklePath = response
504                .mmr_path
505                .ok_or(RpcError::ExpectedDataMissing("MmrPath".into()))?
506                .try_into()?;
507
508            let forest_size = usize::try_from(forest).expect("u64 should fit in usize");
509            let forest = Forest::new(forest_size).map_err(|_| {
510                RpcError::InvalidResponse(format!("invalid forest size: {forest_size}"))
511            })?;
512            Some(MmrProof::new(
513                MmrPath::new(forest, block_header.block_num().as_usize(), merkle_path),
514                block_header.commitment(),
515            ))
516        } else {
517            None
518        };
519
520        Ok((block_header, mmr_proof))
521    }
522
523    async fn get_notes_by_id(&self, note_ids: &[NoteId]) -> Result<Vec<FetchedNote>, RpcError> {
524        let limits = self.get_rpc_limits().await?;
525        let requested_ids: BTreeSet<NoteId> = note_ids.iter().copied().collect();
526        let mut notes = Vec::with_capacity(note_ids.len());
527        for chunk in note_ids.chunks(limits.note_ids_limit as usize) {
528            let request = proto::note::NoteIdList {
529                ids: chunk.iter().map(|id| (*id).into()).collect(),
530            };
531
532            let api_response = self
533                .call_with_retry(RpcEndpoint::GetNotesById, |mut rpc_api| {
534                    let request = request.clone();
535                    Box::pin(async move { rpc_api.get_notes_by_id(request).await })
536                })
537                .await?;
538
539            let response_notes = api_response
540                .into_inner()
541                .notes
542                .into_iter()
543                .map(FetchedNote::try_from)
544                .collect::<Result<Vec<FetchedNote>, RpcConversionError>>()?;
545
546            ensure_requested_note_ids(&requested_ids, response_notes.iter().map(FetchedNote::id))?;
547
548            notes.extend(response_notes);
549        }
550        Ok(notes)
551    }
552
553    async fn sync_chain_mmr(
554        &self,
555        current_block_height: BlockNumber,
556        upper_bound: SyncTarget,
557    ) -> Result<ChainMmrInfo, RpcError> {
558        let finality_level: proto::rpc::FinalityLevel = upper_bound.into();
559
560        let request = proto::rpc::SyncChainMmrRequest {
561            current_client_block_height: current_block_height.as_u32(),
562            finality_level: finality_level.into(),
563        };
564
565        let response = self
566            .call_with_retry(RpcEndpoint::SyncChainMmr, |mut rpc_api| {
567                Box::pin(async move { rpc_api.sync_chain_mmr(request).await })
568            })
569            .await?;
570
571        response.into_inner().try_into()
572    }
573
574    /// Sends a `GetAccount` request to the Miden node, and extracts the [`AccountProof`]
575    /// from the response, as well as the block number that it was retrieved for.
576    ///
577    /// # Errors
578    ///
579    /// This function will return an error if:
580    ///
581    /// - The requested Account isn't returned by the node.
582    /// - The block number of the requested Account doesn't match the response block number.
583    /// - There was an error sending the request to the node.
584    /// - The answer had a `None` for one of the expected fields.
585    /// - There is an error during storage deserialization.
586    async fn get_account(
587        &self,
588        account_id: AccountId,
589        request: GetAccountRequest,
590    ) -> Result<(BlockNumber, AccountProof), RpcError> {
591        let GetAccountRequest { storage, at, known_code, vault } = request;
592
593        let known_code_commitment = known_code.as_ref().map_or(EMPTY_WORD, AccountCode::commitment);
594        let mut known_codes_by_commitment: BTreeMap<Word, AccountCode> = BTreeMap::new();
595        if let Some(account_code) = known_code {
596            known_codes_by_commitment.insert(account_code.commitment(), account_code);
597        }
598
599        // We need the requested slots to interpret the node response.
600        let requirements = match storage.clone() {
601            StorageMapFetch::Slots(reqs) => reqs,
602            StorageMapFetch::Skip | StorageMapFetch::All => AccountStorageRequirements::default(),
603        };
604
605        // Only request details for accounts with public state (Public or Network), passing the
606        // known code commitment so the node can skip re-sending code we already hold.
607        let account_details = if account_id.is_public() {
608            Some(AccountDetailRequest {
609                code_commitment: Some(known_code_commitment.into()),
610                asset_vault_commitment: vault.into(),
611                storage_request: storage.into(),
612            })
613        } else {
614            None
615        };
616
617        let block_num = match at {
618            AccountStateAt::Block(number) => Some(number.into()),
619            AccountStateAt::ChainTip => None,
620        };
621
622        let proto_request = AccountRequest {
623            account_id: Some(account_id.into()),
624            block_num,
625            details: account_details,
626        };
627
628        let response = self
629            .call_with_retry(RpcEndpoint::GetAccount, |mut rpc_api| {
630                let request = proto_request.clone();
631                Box::pin(async move { rpc_api.get_account(request).await })
632            })
633            .await?
634            .into_inner();
635
636        let account_witness: AccountWitness = response
637            .witness
638            .ok_or(RpcError::ExpectedDataMissing("AccountWitness".to_string()))?
639            .try_into()?;
640
641        let response_block_num: BlockNumber = response
642            .block_num
643            .ok_or(RpcError::ExpectedDataMissing("response block num".to_string()))?
644            .block_num
645            .into();
646
647        if let Some(requested) = block_num
648            && requested.block_num != response_block_num.as_u32()
649        {
650            return Err(RpcError::InvalidResponse(format!(
651                "node returned header for block {} but block {} was requested",
652                response_block_num.as_u32(),
653                requested.block_num
654            )));
655        }
656
657        // For accounts with public state, details should be present when requested
658        let headers = if account_witness.id().is_public() {
659            let details = response
660                .details
661                .ok_or(RpcError::ExpectedDataMissing("Account.Details".to_string()))?
662                .into_domain(&known_codes_by_commitment, &requirements)?;
663
664            Some(details)
665        } else {
666            None
667        };
668
669        let proof = AccountProof::new(account_witness, headers)
670            .map_err(|err| RpcError::InvalidResponse(err.to_string()))?;
671
672        Ok((response_block_num, proof))
673    }
674
675    /// Sends one or more `SyncNoteRequest`s to the node and merges the responses into a list of
676    /// [`NoteSyncBlock`]s.
677    ///
678    /// Chunks `note_tags` by [`RpcLimits::note_tags_limit`] and paginates each chunk across the
679    /// requested block range.
680    async fn sync_notes(
681        &self,
682        block_from: BlockNumber,
683        block_to: BlockNumber,
684        note_tags: &BTreeSet<NoteTag>,
685    ) -> Result<Vec<NoteSyncBlock>, RpcError> {
686        if note_tags.is_empty() {
687            return Ok(Vec::new());
688        }
689
690        let limits = self.get_rpc_limits().await?;
691        let tags: Vec<NoteTag> = note_tags.iter().copied().collect();
692
693        // Merge blocks across tag-chunks: a single block can hold notes whose tags fall into
694        // different chunks, so the same block can appear in multiple chunks' responses.
695        let mut merged_blocks: BTreeMap<BlockNumber, NoteSyncBlock> = BTreeMap::new();
696
697        for chunk in tags.chunks(limits.note_tags_limit as usize) {
698            let proto_tags: Vec<u32> = chunk.iter().map(|&t| t.into()).collect();
699            let requested_tags: BTreeSet<NoteTag> = chunk.iter().copied().collect();
700            let mut pagination = BlockPagination::new(block_from, block_to);
701
702            loop {
703                let request = proto::rpc::SyncNotesRequest {
704                    block_range: Some(BlockRange {
705                        block_from: pagination.current_block_from().as_u32(),
706                        block_to: block_to.as_u32(),
707                    }),
708                    note_tags: proto_tags.clone(),
709                };
710
711                let response = self
712                    .call_with_retry(RpcEndpoint::SyncNotes, |mut rpc_api| {
713                        let request = request.clone();
714                        Box::pin(async move { rpc_api.sync_notes(request).await })
715                    })
716                    .await?
717                    .into_inner();
718
719                let page = response.pagination_info.ok_or(RpcError::ExpectedDataMissing(
720                    "SyncNotesResponse.pagination_info".to_owned(),
721                ))?;
722                let page_chain_tip = BlockNumber::from(page.chain_tip);
723                let page_block_to = BlockNumber::from(page.block_num);
724
725                for proto_block in response.blocks {
726                    let block: NoteSyncBlock = proto_block.try_into()?;
727                    ensure_requested_tags(
728                        &requested_tags,
729                        block.notes.values().map(CommittedNote::tag),
730                    )?;
731                    let bn = block.block_header.block_num();
732                    if let Some(existing) = merged_blocks.get_mut(&bn) {
733                        for (id, note) in block.notes {
734                            existing.notes.entry(id).or_insert(note);
735                        }
736                    } else {
737                        merged_blocks.insert(bn, block);
738                    }
739                }
740
741                match pagination.advance(page_block_to, page_chain_tip)? {
742                    PaginationResult::Continue => {},
743                    PaginationResult::Done { .. } => break,
744                }
745            }
746        }
747
748        Ok(merged_blocks.into_values().collect())
749    }
750
751    async fn sync_nullifiers(
752        &self,
753        prefixes: &[u16],
754        block_from: BlockNumber,
755        block_to: BlockNumber,
756    ) -> Result<Vec<NullifierUpdate>, RpcError> {
757        let limits = self.get_rpc_limits().await?;
758        let mut all_nullifiers = BTreeSet::new();
759
760        // If the prefixes are too many, we need to chunk them into smaller groups to avoid
761        // violating the RPC limit.
762        for chunk in prefixes.chunks(limits.nullifiers_limit as usize) {
763            let proto_prefixes: Vec<u32> = chunk.iter().map(|&x| u32::from(x)).collect();
764            let requested_prefixes: BTreeSet<u16> = chunk.iter().copied().collect();
765            let mut pagination = BlockPagination::new(block_from, block_to);
766
767            loop {
768                let request = proto::rpc::SyncNullifiersRequest {
769                    nullifiers: proto_prefixes.clone(),
770                    prefix_len: 16,
771                    block_range: Some(BlockRange {
772                        block_from: pagination.current_block_from().as_u32(),
773                        block_to: pagination.block_to().as_u32(),
774                    }),
775                };
776
777                let response = self
778                    .call_with_retry(RpcEndpoint::SyncNullifiers, |mut rpc_api| {
779                        let request = request.clone();
780                        Box::pin(async move { rpc_api.sync_nullifiers(request).await })
781                    })
782                    .await?
783                    .into_inner();
784
785                let batch_nullifiers = response
786                    .nullifiers
787                    .iter()
788                    .map(TryFrom::try_from)
789                    .collect::<Result<Vec<NullifierUpdate>, _>>()
790                    .map_err(|err| RpcError::InvalidResponse(err.to_string()))?;
791
792                ensure_requested_nullifiers(&requested_prefixes, &batch_nullifiers)?;
793                all_nullifiers.extend(batch_nullifiers);
794
795                let page = response.pagination_info.ok_or(RpcError::ExpectedDataMissing(
796                    "SyncNullifiersResponse.pagination_info".to_owned(),
797                ))?;
798
799                match pagination.advance(page.block_num.into(), page.chain_tip.into())? {
800                    PaginationResult::Continue => {},
801                    PaginationResult::Done { .. } => break,
802                }
803            }
804        }
805        Ok(all_nullifiers.into_iter().collect::<Vec<_>>())
806    }
807
808    async fn get_block_by_number(
809        &self,
810        block_num: BlockNumber,
811        include_proof: bool,
812    ) -> Result<ProvenBlock, RpcError> {
813        let request = proto::blockchain::BlockRequest {
814            block_num: block_num.as_u32(),
815            include_proof: Some(include_proof),
816        };
817
818        let response = self
819            .call_with_retry(RpcEndpoint::GetBlockByNumber, |mut rpc_api| {
820                Box::pin(async move { rpc_api.get_block_by_number(request).await })
821            })
822            .await?;
823
824        let response = response.into_inner();
825        let block =
826            ProvenBlock::read_from_bytes(&response.block.ok_or(RpcError::ExpectedDataMissing(
827                "GetBlockByNumberResponse.block".to_string(),
828            ))?)?;
829
830        if block.header().block_num() != block_num {
831            return Err(RpcError::InvalidResponse(format!(
832                "node returned header for block {} but block {block_num} was requested",
833                block.header().block_num(),
834            )));
835        }
836
837        Ok(block)
838    }
839
840    async fn get_note_script_by_root(&self, root: Word) -> Result<Option<NoteScript>, RpcError> {
841        let request = proto::note::NoteScriptRoot { root: Some(root.into()) };
842
843        let response = self
844            .call_with_retry(RpcEndpoint::GetNoteScriptByRoot, |mut rpc_api| {
845                Box::pin(async move { rpc_api.get_note_script_by_root(request).await })
846            })
847            .await?;
848
849        // The node returns an empty payload when it has no script registered for the root.
850        let Some(script) = response.into_inner().script else {
851            return Ok(None);
852        };
853        let note_script = NoteScript::try_from(script)?;
854
855        let fetched_root = note_script.root();
856        if Word::from(fetched_root) != root {
857            return Err(RpcError::InvalidResponse(format!(
858                "node returned note script with root {fetched_root} for requested root {root}",
859            )));
860        }
861
862        Ok(Some(note_script))
863    }
864
865    async fn sync_storage_maps(
866        &self,
867        block_from: BlockNumber,
868        block_to: BlockNumber,
869        account_id: AccountId,
870    ) -> Result<StorageMapInfo, RpcError> {
871        let mut pagination = BlockPagination::new(block_from, block_to);
872        let mut map_entries: BTreeMap<StorageSlotName, StorageMapPatchEntries> = BTreeMap::new();
873
874        let (chain_tip, block_number) = loop {
875            let request = proto::rpc::SyncAccountStorageMapsRequest {
876                block_range: Some(BlockRange {
877                    block_from: pagination.current_block_from().as_u32(),
878                    block_to: block_to.as_u32(),
879                }),
880                account_id: Some(account_id.into()),
881            };
882            let response = self
883                .call_with_retry(RpcEndpoint::SyncStorageMaps, |mut rpc_api| {
884                    let request = request.clone();
885                    Box::pin(async move { rpc_api.sync_account_storage_maps(request).await })
886                })
887                .await?;
888            let page = StorageMapInfo::try_from(response.into_inner())?;
889
890            for (slot_name, entries) in page.map_entries {
891                map_entries
892                    .entry(slot_name)
893                    .or_default()
894                    .as_map_mut()
895                    .extend(entries.into_map());
896            }
897
898            match pagination.advance(page.block_number, page.chain_tip)? {
899                PaginationResult::Continue => {},
900                PaginationResult::Done {
901                    chain_tip: final_chain_tip,
902                    block_num: final_block_num,
903                } => break (final_chain_tip, final_block_num),
904            }
905        };
906
907        Ok(StorageMapInfo { chain_tip, block_number, map_entries })
908    }
909
910    async fn sync_account_vault(
911        &self,
912        block_from: BlockNumber,
913        block_to: BlockNumber,
914        account_id: AccountId,
915    ) -> Result<AccountVaultInfo, RpcError> {
916        let mut pagination = BlockPagination::new(block_from, block_to);
917        let mut vault_patch = AccountVaultPatch::default();
918
919        let (chain_tip, block_number) = loop {
920            let request = proto::rpc::SyncAccountVaultRequest {
921                block_range: Some(BlockRange {
922                    block_from: pagination.current_block_from().as_u32(),
923                    block_to: block_to.as_u32(),
924                }),
925                account_id: Some(account_id.into()),
926            };
927            let response = self
928                .call_with_retry(RpcEndpoint::SyncAccountVault, |mut rpc_api| {
929                    let request = request.clone();
930                    Box::pin(async move { rpc_api.sync_account_vault(request).await })
931                })
932                .await?;
933            let page = AccountVaultInfo::try_from(response.into_inner())?;
934
935            vault_patch.merge(page.vault_patch);
936
937            match pagination.advance(page.block_number, page.chain_tip)? {
938                PaginationResult::Continue => {},
939                PaginationResult::Done {
940                    chain_tip: final_chain_tip,
941                    block_num: final_block_num,
942                } => break (final_chain_tip, final_block_num),
943            }
944        };
945
946        Ok(AccountVaultInfo { chain_tip, block_number, vault_patch })
947    }
948
949    /// Sends one or more `SyncTransactions` requests to the node and concatenates the responses
950    /// into a flat list of [`TransactionRecord`]s.
951    ///
952    /// Chunks `account_ids` by [`RpcLimits::account_ids_limit`] and paginates each chunk across the
953    /// requested block range.
954    async fn sync_transactions(
955        &self,
956        block_from: BlockNumber,
957        block_to: BlockNumber,
958        account_ids: Vec<AccountId>,
959    ) -> Result<Vec<TransactionRecord>, RpcError> {
960        if account_ids.is_empty() {
961            return Ok(Vec::new());
962        }
963
964        let limits = self.get_rpc_limits().await?;
965        let mut transactions: Vec<TransactionRecord> = Vec::new();
966
967        for chunk in account_ids.chunks(limits.account_ids_limit as usize) {
968            let proto_account_ids: Vec<_> = chunk.iter().map(|acc_id| (*acc_id).into()).collect();
969            let mut pagination = BlockPagination::new(block_from, block_to);
970
971            loop {
972                let request = proto::rpc::SyncTransactionsRequest {
973                    block_range: Some(BlockRange {
974                        block_from: pagination.current_block_from().as_u32(),
975                        block_to: block_to.as_u32(),
976                    }),
977                    account_ids: proto_account_ids.clone(),
978                };
979
980                let response = self
981                    .call_with_retry(RpcEndpoint::SyncTransactions, |mut rpc_api| {
982                        let request = request.clone();
983                        Box::pin(async move { rpc_api.sync_transactions(request).await })
984                    })
985                    .await?
986                    .into_inner();
987
988                let page = response.pagination_info.ok_or(RpcError::ExpectedDataMissing(
989                    "SyncTransactionsResponse.pagination_info".to_owned(),
990                ))?;
991                let page_chain_tip = BlockNumber::from(page.chain_tip);
992                let page_block_to = BlockNumber::from(page.block_num);
993
994                for proto_tx in response.transactions {
995                    transactions.push(TransactionRecord::try_from(proto_tx)?);
996                }
997
998                match pagination.advance(page_block_to, page_chain_tip)? {
999                    PaginationResult::Continue => {},
1000                    PaginationResult::Done { .. } => break,
1001                }
1002            }
1003        }
1004
1005        Ok(transactions)
1006    }
1007
1008    async fn get_network_id(&self) -> Result<NetworkId, RpcError> {
1009        let endpoint: Endpoint =
1010            Endpoint::try_from(self.endpoint.as_str()).map_err(RpcError::InvalidNodeEndpoint)?;
1011        Ok(endpoint.to_network_id())
1012    }
1013
1014    async fn get_rpc_limits(&self) -> Result<RpcLimits, RpcError> {
1015        // Return cached limits if available
1016        if let Some(limits) = *self.limits.read() {
1017            return Ok(limits);
1018        }
1019
1020        // Fetch limits from the node
1021        let response = self
1022            .call_with_retry(RpcEndpoint::GetLimits, |mut rpc_api| {
1023                Box::pin(async move { rpc_api.get_limits(()).await })
1024            })
1025            .await?;
1026        let limits = RpcLimits::try_from(response.into_inner()).map_err(RpcError::from)?;
1027
1028        // Cache fetched values
1029        self.limits.write().replace(limits);
1030        Ok(limits)
1031    }
1032
1033    fn has_rpc_limits(&self) -> Option<RpcLimits> {
1034        *self.limits.read()
1035    }
1036
1037    async fn set_rpc_limits(&self, limits: RpcLimits) {
1038        self.limits.write().replace(limits);
1039    }
1040
1041    async fn get_status_unversioned(&self) -> Result<RpcStatusInfo, RpcError> {
1042        GrpcClient::get_status_unversioned(self).await
1043    }
1044
1045    async fn get_network_note_status(
1046        &self,
1047        note_id: NoteId,
1048    ) -> Result<NetworkNoteStatusInfo, RpcError> {
1049        let request = proto::note::NoteId { id: Some(note_id.into()) };
1050
1051        let response = self
1052            .call_with_retry(RpcEndpoint::GetNetworkNoteStatus, |mut rpc_api| {
1053                Box::pin(async move { rpc_api.get_network_note_status(request).await })
1054            })
1055            .await?;
1056
1057        response.into_inner().try_into()
1058    }
1059}
1060
1061// ERRORS
1062// ================================================================================================
1063
1064impl RpcError {
1065    pub fn from_grpc_error_with_context(
1066        endpoint: RpcEndpoint,
1067        status: Status,
1068        context: AcceptHeaderContext,
1069    ) -> Self {
1070        if let Some(accept_error) =
1071            AcceptHeaderError::try_from_message_with_context(status.message(), context)
1072        {
1073            return Self::AcceptHeaderError(accept_error);
1074        }
1075
1076        // Parse application-level error from status details
1077        let endpoint_error = parse_node_error(&endpoint, status.details(), status.message());
1078
1079        let error_kind = GrpcError::from(&status);
1080        let source = Box::new(status) as Box<dyn Error + Send + Sync + 'static>;
1081
1082        Self::RequestError {
1083            endpoint,
1084            error_kind,
1085            endpoint_error,
1086            source: Some(source),
1087        }
1088    }
1089}
1090
1091impl From<&Status> for GrpcError {
1092    fn from(status: &Status) -> Self {
1093        GrpcError::from_code(status.code() as i32, Some(status.message().to_string()))
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use core::slice;
1100    use std::boxed::Box;
1101    use std::collections::BTreeSet;
1102
1103    use miden_protocol::block::BlockNumber;
1104    use miden_protocol::note::{NoteId, NoteTag, Nullifier};
1105    use miden_protocol::{Felt, Word};
1106
1107    use super::{
1108        BlockPagination,
1109        DEFAULT_MAX_RESPONSE_SIZE_BYTES,
1110        GrpcClient,
1111        NullifierUpdate,
1112        PaginationResult,
1113        ensure_requested_note_ids,
1114        ensure_requested_nullifiers,
1115        ensure_requested_tags,
1116    };
1117    use crate::alloc::string::ToString;
1118    use crate::rpc::{Endpoint, NodeRpcClient, RpcError};
1119
1120    fn assert_send_sync<T: Send + Sync>() {}
1121
1122    #[test]
1123    fn is_send_sync() {
1124        assert_send_sync::<GrpcClient>();
1125        assert_send_sync::<Box<dyn NodeRpcClient>>();
1126    }
1127
1128    #[test]
1129    fn block_pagination_errors_when_block_num_goes_backwards() {
1130        let mut pagination = BlockPagination::new(10_u32.into(), 20_u32.into());
1131
1132        let res = pagination.advance(9_u32.into(), 20_u32.into());
1133        assert!(matches!(res, Err(RpcError::PaginationError(_))));
1134    }
1135
1136    #[test]
1137    fn block_pagination_errors_after_max_iterations() {
1138        let mut pagination = BlockPagination::new(0_u32.into(), 10_000_u32.into());
1139        let chain_tip: BlockNumber = 10_000_u32.into();
1140
1141        for _ in 0..BlockPagination::MAX_ITERATIONS {
1142            let current = pagination.current_block_from();
1143            let res = pagination
1144                .advance(current, chain_tip)
1145                .expect("expected pagination to continue within iteration limit");
1146            assert!(matches!(res, PaginationResult::Continue));
1147        }
1148
1149        let res = pagination.advance(pagination.current_block_from(), chain_tip);
1150        assert!(matches!(res, Err(RpcError::PaginationError(_))));
1151    }
1152
1153    #[test]
1154    fn block_pagination_stops_at_min_of_block_to_and_chain_tip() {
1155        // block_to is beyond chain tip, so target should be chain_tip.
1156        let mut pagination = BlockPagination::new(0_u32.into(), 50_u32.into());
1157
1158        let res = pagination
1159            .advance(30_u32.into(), 30_u32.into())
1160            .expect("expected pagination to succeed");
1161
1162        assert!(matches!(
1163            res,
1164            PaginationResult::Done {
1165                chain_tip,
1166                block_num
1167            } if chain_tip.as_u32() == 30 && block_num.as_u32() == 30
1168        ));
1169    }
1170
1171    #[test]
1172    fn block_pagination_advances_cursor_by_one() {
1173        let mut pagination = BlockPagination::new(5_u32.into(), 100_u32.into());
1174
1175        let res = pagination
1176            .advance(5_u32.into(), 100_u32.into())
1177            .expect("expected pagination to succeed");
1178        assert!(matches!(res, PaginationResult::Continue));
1179        assert_eq!(pagination.current_block_from().as_u32(), 6);
1180    }
1181
1182    // Function that returns a `Send` future from a dynamic trait that must be `Sync`.
1183    async fn dyn_trait_send_fut(client: Box<dyn NodeRpcClient>) {
1184        // This won't compile if `get_block_header_by_number` doesn't return a `Send+Sync` future.
1185        let res = client.get_block_header_by_number(None, false).await;
1186        assert!(res.is_ok());
1187    }
1188
1189    #[tokio::test]
1190    async fn future_is_send() {
1191        let endpoint = &Endpoint::devnet();
1192        let client = GrpcClient::new(endpoint, 10000);
1193        let client: Box<GrpcClient> = client.into();
1194        tokio::task::spawn(async move { dyn_trait_send_fut(client).await });
1195    }
1196
1197    #[tokio::test]
1198    async fn set_genesis_commitment_sets_the_commitment_when_its_not_already_set() {
1199        let endpoint = &Endpoint::devnet();
1200        let client = GrpcClient::new(endpoint, 10000);
1201
1202        assert!(client.genesis_commitment.read().is_none());
1203
1204        let commitment = Word::default();
1205        client.set_genesis_commitment(commitment).await.unwrap();
1206
1207        assert_eq!(client.genesis_commitment.read().unwrap(), commitment);
1208    }
1209
1210    #[tokio::test]
1211    async fn set_genesis_commitment_does_nothing_if_the_commitment_is_already_set() {
1212        let endpoint = &Endpoint::devnet();
1213        let client = GrpcClient::new(endpoint, 10000);
1214
1215        let initial_commitment = Word::default();
1216        client.set_genesis_commitment(initial_commitment).await.unwrap();
1217
1218        let new_commitment = Word::from([1u32, 2, 3, 4]);
1219        client.set_genesis_commitment(new_commitment).await.unwrap();
1220
1221        assert_eq!(client.genesis_commitment.read().unwrap(), initial_commitment);
1222    }
1223
1224    #[tokio::test]
1225    async fn set_genesis_commitment_updates_the_client_if_already_connected() {
1226        let endpoint = &Endpoint::devnet();
1227        let client = GrpcClient::new(endpoint, 10000);
1228
1229        // "Connect" the client
1230        client.connect().await.unwrap();
1231
1232        let commitment = Word::default();
1233        client.set_genesis_commitment(commitment).await.unwrap();
1234
1235        assert_eq!(client.genesis_commitment.read().unwrap(), commitment);
1236        assert!(client.client.read().as_ref().is_some());
1237    }
1238
1239    #[test]
1240    fn with_bearer_auth_stores_token() {
1241        let endpoint = &Endpoint::devnet();
1242        let client = GrpcClient::new(endpoint, 10000).with_bearer_auth("token-one".to_string());
1243
1244        assert_eq!(client.bearer_token.as_deref(), Some("token-one"));
1245    }
1246
1247    #[test]
1248    fn with_bearer_auth_overwrites_on_repeat_call() {
1249        let endpoint = &Endpoint::devnet();
1250        let client = GrpcClient::new(endpoint, 10000)
1251            .with_bearer_auth("token-one".to_string())
1252            .with_bearer_auth("token-two".to_string());
1253
1254        // Second call replaces the first.
1255        assert_eq!(client.bearer_token.as_deref(), Some("token-two"));
1256    }
1257
1258    #[tokio::test]
1259    async fn with_bearer_auth_surfaces_invalid_ascii_value_at_connect_time() {
1260        // Tokens containing control characters are rejected by `AsciiMetadataValue`. The
1261        // fluent builder defers the check to connection time, so the error must surface as
1262        // a `ConnectionError` on the first request — preventing CR/LF header-injection.
1263        let endpoint = &Endpoint::devnet();
1264        let client = GrpcClient::new(endpoint, 10000).with_bearer_auth("bad\nvalue".to_string());
1265
1266        let err = client.connect().await.expect_err("expected invalid token to fail connect");
1267        assert!(
1268            matches!(err, RpcError::ConnectionError(_)),
1269            "expected ConnectionError, got {err:?}",
1270        );
1271    }
1272
1273    #[tokio::test]
1274    async fn with_bearer_auth_is_preserved_across_set_genesis_commitment() {
1275        let endpoint = &Endpoint::devnet();
1276        let client = GrpcClient::new(endpoint, 10000).with_bearer_auth("token".to_string());
1277        client.connect().await.unwrap();
1278
1279        client.set_genesis_commitment(Word::default()).await.unwrap();
1280
1281        // Rebuilding the interceptor after a genesis update must not drop the caller token.
1282        assert_eq!(client.bearer_token.as_deref(), Some("token"));
1283        assert!(client.client.read().as_ref().is_some());
1284    }
1285
1286    #[test]
1287    fn with_max_decoding_message_size_overrides_default() {
1288        let endpoint = &Endpoint::devnet();
1289
1290        // A fresh client uses the default decode ceiling.
1291        let default_client = GrpcClient::new(endpoint, 10_000);
1292        assert_eq!(default_client.max_decoding_message_size, DEFAULT_MAX_RESPONSE_SIZE_BYTES);
1293
1294        // The knob overrides it for callers that hit responses above the default.
1295        let custom =
1296            GrpcClient::new(endpoint, 10_000).with_max_decoding_message_size(8 * 1024 * 1024);
1297        assert_eq!(custom.max_decoding_message_size, 8 * 1024 * 1024);
1298    }
1299
1300    /// Real-network smoke test: hitting the public testnet with a caller-supplied bearer
1301    /// token must return a real [`RpcStatusInfo`], proving the header is a valid
1302    /// [`AsciiMetadataValue`](tonic::metadata::AsciiMetadataValue) on the wire and that an
1303    /// unauthenticated node ignores it cleanly.
1304    ///
1305    /// `#[ignore]`d by default so offline CI doesn't fail; run with
1306    /// `cargo test -- --ignored with_bearer_auth_does_not_break_real_rpc_against_testnet`
1307    /// when validating against the real network. The interceptor-level test
1308    /// (`api_client::tests::interceptor_injects_bearer_token_onto_request`)
1309    /// already proves the header reaches outbound request metadata without needing the
1310    /// network.
1311    #[tokio::test]
1312    #[ignore = "requires network access to public testnet"]
1313    async fn with_bearer_auth_does_not_break_real_rpc_against_testnet() {
1314        let endpoint = &Endpoint::testnet();
1315        let client = GrpcClient::new(endpoint, 10_000).with_bearer_auth("smoke-test".to_string());
1316
1317        let status = client
1318            .get_status_unversioned()
1319            .await
1320            .expect("testnet status with caller auth header must succeed");
1321        assert!(!status.version.is_empty(), "status must include a server version");
1322    }
1323
1324    fn nullifier_with_prefix(prefix: u16) -> Nullifier {
1325        Nullifier::from_raw(Word::new([
1326            Felt::ZERO,
1327            Felt::ZERO,
1328            Felt::ZERO,
1329            Felt::new_unchecked(u64::from(prefix) << 48),
1330        ]))
1331    }
1332
1333    #[test]
1334    fn verify_requested_nullifiers_rejects_unrequested_prefix() {
1335        let requested = NullifierUpdate {
1336            nullifier: nullifier_with_prefix(0x1234),
1337            block_num: 1u32.into(),
1338        };
1339        let unrequested = NullifierUpdate {
1340            nullifier: nullifier_with_prefix(0xabcd),
1341            block_num: 2u32.into(),
1342        };
1343
1344        let requested_prefixes: BTreeSet<u16> = BTreeSet::from([0x1234]);
1345
1346        ensure_requested_nullifiers(&requested_prefixes, slice::from_ref(&requested))
1347            .expect("requested prefix must be accepted");
1348
1349        let err = ensure_requested_nullifiers(&requested_prefixes, &[requested, unrequested])
1350            .expect_err("unrequested prefix must be rejected");
1351        assert!(matches!(err, RpcError::InvalidResponse(_)));
1352    }
1353
1354    #[test]
1355    fn ensure_requested_tags_rejects_unrequested() {
1356        let requested = NoteTag::new(1);
1357        let other = NoteTag::new(2);
1358        let requested_set = BTreeSet::from([requested]);
1359
1360        ensure_requested_tags(&requested_set, [requested]).expect("requested tag must be accepted");
1361
1362        let err = ensure_requested_tags(&requested_set, [other])
1363            .expect_err("unrequested tag must be rejected");
1364        assert!(matches!(err, RpcError::InvalidResponse(_)));
1365    }
1366
1367    fn note_id(n: u32) -> NoteId {
1368        NoteId::from_raw(Word::from([n, 0, 0, 0]))
1369    }
1370
1371    #[test]
1372    fn ensure_requested_note_ids_rejects_unrequested() {
1373        let requested = note_id(1);
1374        let other = note_id(2);
1375        let requested_set = BTreeSet::from([requested]);
1376
1377        ensure_requested_note_ids(&requested_set, [requested])
1378            .expect("requested note id must be accepted");
1379
1380        let err = ensure_requested_note_ids(&requested_set, [other])
1381            .expect_err("unrequested note id must be rejected");
1382        assert!(matches!(err, RpcError::InvalidResponse(_)));
1383    }
1384}