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