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