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