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