Skip to main content

miden_client/note_transport/
grpc.rs

1//! gRPC-based note transport client.
2//!
3//! On native targets, the connection is established lazily on the first request using a TLS-enabled
4//! `tonic` channel. On WASM, a `tonic_web_wasm_client` is created on demand.
5
6use alloc::boxed::Box;
7use alloc::string::String;
8use alloc::vec::Vec;
9
10use miden_objects::{
11    ConversionError,
12    ConversionResultExt,
13    DecodeMessage,
14    DecodeMessageExt,
15    Verify,
16};
17use miden_protocol::block::BlockNumber;
18use miden_protocol::note::{
19    NoteDetails,
20    NoteDetailsCommitment,
21    NoteHeader,
22    NoteInclusionProof,
23    NoteTag,
24};
25use miden_protocol::utils::serde::Serializable;
26use miden_tx::utils::sync::RwLock;
27use thiserror::Error;
28use tonic::{Code, Request, Status};
29use tonic_health::pb::HealthCheckRequest;
30use tonic_health::pb::health_client::HealthClient;
31#[cfg(target_arch = "wasm32")]
32use {core::time::Duration, tonic_web_wasm_client::options::FetchOptions};
33#[cfg(not(target_arch = "wasm32"))]
34use {
35    std::time::Duration,
36    tonic::transport::{Channel, ClientTlsConfig},
37};
38
39use super::generated::note_transport::note_transport_service_client::NoteTransportServiceClient;
40use super::generated::note_transport::{
41    FetchNotesCursor,
42    FetchNotesRequest,
43    FetchedNote,
44    SendNoteWithProofRequest,
45    TransportNote as ProtoTransportNote,
46};
47use super::{NoteInfo, NoteTransportCursor, NoteTransportError, NoteTransportPage, TransportNote};
48use crate::grpc_support::{async_sleep, extract_retry_after};
49
50// FETCHED NOTE DECODING
51// ================================================================================================
52
53/// The decoded fields of a [`FetchedNote`], before the details are checked against the header.
54pub struct DecodedFetchedNote {
55    header: NoteHeader,
56    details: NoteDetails,
57    block_hint: Option<BlockNumber>,
58}
59
60impl TryFrom<FetchedNote> for DecodedFetchedNote {
61    type Error = ConversionError;
62
63    fn try_from(note: FetchedNote) -> Result<Self, Self::Error> {
64        let header = note
65            .header
66            .ok_or_else(|| ConversionError::missing_field::<FetchedNote>("header"))?
67            .decode_and_verify()
68            .context("header")?;
69        let details = note
70            .details
71            .ok_or_else(|| ConversionError::missing_field::<FetchedNote>("details"))?
72            .decode_and_verify()
73            .context("details")?;
74        let block_hint =
75            note.committed_in_block.map(|block_num| BlockNumber::from(block_num.block_num));
76
77        Ok(Self { header, details, block_hint })
78    }
79}
80
81impl DecodeMessage for FetchedNote {
82    type Decoded = DecodedFetchedNote;
83}
84
85/// The details of a fetched note do not match the commitment its header carries.
86#[derive(Debug, Error)]
87#[error(
88    "fetched note details (commitment {}) do not match the header's details commitment {}",
89    details.to_hex(),
90    header.to_hex()
91)]
92pub struct FetchedNoteMismatch {
93    header: NoteDetailsCommitment,
94    details: NoteDetailsCommitment,
95}
96
97impl Verify for DecodedFetchedNote {
98    type Verified = NoteInfo;
99    type Error = FetchedNoteMismatch;
100
101    /// Checks that the header commits to the delivered details.
102    fn verify(self) -> Result<NoteInfo, FetchedNoteMismatch> {
103        if self.details.commitment() != self.header.details_commitment() {
104            return Err(FetchedNoteMismatch {
105                header: self.header.details_commitment(),
106                details: self.details.commitment(),
107            });
108        }
109
110        Ok(NoteInfo {
111            header: self.header,
112            details_bytes: self.details.to_bytes(),
113            block_hint: self.block_hint,
114        })
115    }
116}
117
118/// Builds the wire representation of a cursor.
119fn proto_cursor(cursor: NoteTransportCursor) -> FetchNotesCursor {
120    FetchNotesCursor {
121        nonce: cursor.nonce,
122        sequence: cursor.sequence,
123    }
124}
125
126/// Builds the wire representation of a transport note.
127fn proto_transport_note(note: TransportNote) -> ProtoTransportNote {
128    let (header, details) = note.into_parts();
129    ProtoTransportNote {
130        header: Some(header.into()),
131        details: Some(details.into()),
132    }
133}
134
135// GRPC CLIENT
136// ================================================================================================
137
138#[cfg(not(target_arch = "wasm32"))]
139type Service = Channel;
140#[cfg(target_arch = "wasm32")]
141type Service = tonic_web_wasm_client::Client;
142
143/// Establishes a connection to the note transport service with the configured channel timeout.
144#[cfg(not(target_arch = "wasm32"))]
145async fn connect_channel(
146    endpoint: &str,
147    timeout_ms: u64,
148) -> Result<ConnectedClient, NoteTransportError> {
149    let endpoint = tonic::transport::Endpoint::try_from(String::from(endpoint))
150        .map_err(|e| NoteTransportError::Connection(Box::new(e)))?
151        .timeout(Duration::from_millis(timeout_ms));
152    let tls = ClientTlsConfig::new().with_native_roots();
153    let channel = endpoint
154        .tls_config(tls)
155        .map_err(|e| NoteTransportError::Connection(Box::new(e)))?
156        .connect()
157        .await
158        .map_err(|e| NoteTransportError::Connection(Box::new(e)))?;
159    Ok(ConnectedClient {
160        client: NoteTransportServiceClient::new(channel.clone()),
161        health_client: HealthClient::new(channel),
162    })
163}
164
165/// Establishes note transport clients with timed requests.
166#[cfg(target_arch = "wasm32")]
167#[allow(clippy::unused_async)]
168async fn connect_channel(
169    endpoint: &str,
170    timeout_ms: u64,
171) -> Result<ConnectedClient, NoteTransportError> {
172    let fetch_options = FetchOptions::new().timeout(Duration::from_millis(timeout_ms));
173    let wasm_client =
174        tonic_web_wasm_client::Client::new_with_options(String::from(endpoint), fetch_options);
175    Ok(ConnectedClient {
176        client: NoteTransportServiceClient::new(wasm_client.clone()),
177        health_client: HealthClient::new(wasm_client),
178    })
179}
180
181/// Inner state holding the connected gRPC clients.
182#[derive(Clone)]
183struct ConnectedClient {
184    client: NoteTransportServiceClient<Service>,
185    health_client: HealthClient<Service>,
186}
187
188/// gRPC client for the note transport network.
189///
190/// The connection is established lazily on first use. A send that fails with a transient error is
191/// retried a bounded number of times, see [`GrpcNoteTransportClient::send_note_with_proof`].
192pub struct GrpcNoteTransportClient {
193    inner: RwLock<Option<ConnectedClient>>,
194    endpoint: String,
195    timeout_ms: u64,
196    /// Maximum number of retries of a send after a transient failure.
197    max_retries: u32,
198    /// Delay before the first retry of a send, in milliseconds.
199    retry_interval_ms: u64,
200}
201
202impl GrpcNoteTransportClient {
203    /// Creates a new [`GrpcNoteTransportClient`] without establishing a connection. The connection
204    /// will be established lazily on the first request.
205    pub fn new(endpoint: String, timeout_ms: u64) -> Self {
206        Self {
207            inner: RwLock::new(None),
208            endpoint,
209            timeout_ms,
210            max_retries: DEFAULT_SEND_MAX_RETRIES,
211            retry_interval_ms: DEFAULT_SEND_RETRY_INTERVAL_MS,
212        }
213    }
214
215    /// Sets the maximum number of retries of a send after a transient failure. Defaults to `3`. A
216    /// value of `0` disables the retries.
217    #[must_use]
218    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
219        self.max_retries = max_retries;
220        self
221    }
222
223    /// Sets the delay before the first retry of a send, in milliseconds. Each subsequent retry
224    /// waits twice as long as the retry before it. A `retry-after` value from the service replaces
225    /// this delay. Defaults to `250` ms.
226    #[must_use]
227    pub fn with_retry_interval_ms(mut self, retry_interval_ms: u64) -> Self {
228        self.retry_interval_ms = retry_interval_ms;
229        self
230    }
231
232    /// Ensures the client is connected and returns the connected state.
233    async fn ensure_connected(&self) -> Result<ConnectedClient, NoteTransportError> {
234        if let Some(connected) = self.inner.read().as_ref() {
235            return Ok(connected.clone());
236        }
237
238        let connected = connect_channel(&self.endpoint, self.timeout_ms).await?;
239        *self.inner.write() = Some(connected.clone());
240        Ok(connected)
241    }
242
243    /// Get a clone of the main client, connecting if needed.
244    async fn api(&self) -> Result<NoteTransportServiceClient<Service>, NoteTransportError> {
245        Ok(self.ensure_connected().await?.client)
246    }
247
248    /// Get a clone of the health client, connecting if needed.
249    async fn health_api(&self) -> Result<HealthClient<Service>, NoteTransportError> {
250        Ok(self.ensure_connected().await?.health_client)
251    }
252
253    /// Pushes a note to the note transport network together with its inclusion proof.
254    ///
255    /// The service verifies the proof against its node before it stores the note. It relays the
256    /// commitment block to recipients as the exact inclusion block.
257    ///
258    /// The service stores a note only once and returns success for a note it already stores. A
259    /// repeated send is therefore safe, and this method retries a send that fails with a transient
260    /// error. The retries stop after the limit that [`Self::with_max_retries`] sets. The method
261    /// then returns the last error.
262    pub async fn send_note_with_proof(
263        &self,
264        note: TransportNote,
265        inclusion_proof: NoteInclusionProof,
266    ) -> Result<(), NoteTransportError> {
267        let note_id = note.header().id();
268        let request = SendNoteWithProofRequest {
269            inclusion_proof: Some((&note_id, &inclusion_proof).into()),
270            note: Some(proto_transport_note(note)),
271        };
272
273        send_with_retry(self.max_retries, self.retry_interval_ms, || {
274            let request = request.clone();
275            async move {
276                let mut api = self.api().await.map_err(SendFailure::Connect)?;
277                api.send_note_with_proof(Request::new(request))
278                    .await
279                    .map_err(SendFailure::Status)?;
280                Ok(())
281            }
282        })
283        .await
284    }
285
286    /// Fetches one page up to `sync_to` with the service's continuation flag.
287    pub async fn fetch_notes_page(
288        &self,
289        tags: &[NoteTag],
290        cursor: Option<NoteTransportCursor>,
291        sync_to: Option<NoteTransportCursor>,
292    ) -> Result<NoteTransportPage, NoteTransportError> {
293        let tags_int = tags.iter().map(NoteTag::as_u32).collect();
294        let request = FetchNotesRequest {
295            tags: tags_int,
296            cursor: cursor.map(proto_cursor),
297            sync_to: sync_to.map(proto_cursor),
298        };
299
300        let mut api = self.api().await?;
301        let response = match api.fetch_notes(Request::new(request.clone())).await {
302            Ok(response) => response,
303            Err(status)
304                if status.code() == Code::FailedPrecondition && request.cursor.is_some() =>
305            {
306                let retry = FetchNotesRequest { cursor: None, ..request };
307                api.fetch_notes(Request::new(retry)).await.map_err(|error| {
308                    NoteTransportError::Network(format!("Fetch notes failed: {error:?}"))
309                })?
310            },
311            Err(error) => {
312                return Err(NoteTransportError::Network(format!("Fetch notes failed: {error:?}")));
313            },
314        };
315
316        let response = response.into_inner();
317
318        // The service rejects notes that do not decode or whose details do not match their header.
319        // A fetched note that fails these checks shows that the service misbehaves, so the fetch
320        // fails.
321        let notes = response
322            .notes
323            .into_iter()
324            .map(|note| note.decode_and_verify().map_err(NoteTransportError::InvalidFetchedNote))
325            .collect::<Result<Vec<_>, _>>()?;
326
327        let cursor = response
328            .cursor
329            .ok_or_else(|| NoteTransportError::Network("fetch response has no cursor".into()))?;
330        let sync_to = response
331            .sync_to
332            .ok_or_else(|| NoteTransportError::Network("fetch response has no sync_to".into()))?;
333        Ok(NoteTransportPage {
334            notes,
335            cursor: NoteTransportCursor {
336                nonce: cursor.nonce,
337                sequence: cursor.sequence,
338            },
339            has_more: response.has_more,
340            sync_to: NoteTransportCursor {
341                nonce: sync_to.nonce,
342                sequence: sync_to.sequence,
343            },
344        })
345    }
346
347    /// gRPC-standardized server health-check.
348    ///
349    /// Checks if the note transport node and respective gRPC services are serving requests. The
350    /// gRPC server operates the `note_transport.Api` service.
351    pub async fn health_check(&mut self) -> Result<(), NoteTransportError> {
352        let request = tonic::Request::new(HealthCheckRequest {
353            service: String::new(), // empty string -> whole server
354        });
355
356        let response = self
357            .health_api()
358            .await?
359            .check(request)
360            .await
361            .map_err(|e| NoteTransportError::Network(format!("Health check failed: {e}")))?
362            .into_inner();
363
364        let serving = matches!(
365            response.status(),
366            tonic_health::pb::health_check_response::ServingStatus::Serving
367        );
368
369        serving
370            .then_some(())
371            .ok_or_else(|| NoteTransportError::Network("Service is not serving".into()))
372    }
373}
374#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
375#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
376impl super::NoteTransportClient for GrpcNoteTransportClient {
377    async fn send_note_with_proof(
378        &self,
379        note: TransportNote,
380        inclusion_proof: NoteInclusionProof,
381    ) -> Result<(), NoteTransportError> {
382        self.send_note_with_proof(note, inclusion_proof).await
383    }
384
385    async fn fetch_notes_page(
386        &self,
387        tags: &[NoteTag],
388        cursor: Option<NoteTransportCursor>,
389        sync_to: Option<NoteTransportCursor>,
390    ) -> Result<NoteTransportPage, NoteTransportError> {
391        self.fetch_notes_page(tags, cursor, sync_to).await
392    }
393}
394
395// SEND RETRY
396// ================================================================================================
397
398/// Default maximum number of retries of a send after a transient failure.
399const DEFAULT_SEND_MAX_RETRIES: u32 = 3;
400
401/// Default delay before the first retry of a send, in milliseconds.
402const DEFAULT_SEND_RETRY_INTERVAL_MS: u64 = 250;
403
404/// The reason that one send attempt failed.
405#[derive(Debug)]
406enum SendFailure {
407    /// The client could not connect to the service. The request did not reach the service.
408    Connect(NoteTransportError),
409    /// The call returned an error status, from the service or from the local gRPC stack.
410    Status(Status),
411}
412
413impl SendFailure {
414    fn into_error(self) -> NoteTransportError {
415        match self {
416            Self::Connect(err) => err,
417            Self::Status(status) => {
418                NoteTransportError::Network(format!("Send note with proof failed: {status:?}"))
419            },
420        }
421    }
422}
423
424/// Runs `attempt` until it succeeds, until it fails with an error that a retry cannot fix, or until
425/// `max_retries` retries have failed. Returns the error of the last attempt.
426async fn send_with_retry<F, Fut>(
427    max_retries: u32,
428    retry_interval_ms: u64,
429    mut attempt: F,
430) -> Result<(), NoteTransportError>
431where
432    F: FnMut() -> Fut,
433    Fut: Future<Output = Result<(), SendFailure>>,
434{
435    let mut retry = 0;
436    loop {
437        let failure = match attempt().await {
438            Ok(()) => return Ok(()),
439            Err(failure) => failure,
440        };
441
442        let delay = if retry < max_retries {
443            retry_delay(&failure, retry, retry_interval_ms)
444        } else {
445            None
446        };
447        let Some(delay) = delay else {
448            return Err(failure.into_error());
449        };
450
451        tracing::warn!(
452            ?failure,
453            retry = retry + 1,
454            delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
455            "note transport send failed, retrying after delay",
456        );
457        async_sleep(delay).await;
458        retry += 1;
459    }
460}
461
462/// Returns the delay before retry number `retry` (counted from zero) of a send that failed with
463/// `failure`. Returns `None` when a retry cannot get a different result.
464///
465/// The service stores a note only once, so a retry is safe also when an earlier attempt reached the
466/// service. These failures are retried:
467///
468/// - A failed connection. The request did not reach the service.
469/// - `Unavailable`. The local gRPC stack returns it when the connection breaks. The service returns
470///   it when it cannot reach its node.
471/// - `DeadlineExceeded`. The service returns it when its node does not answer in time.
472/// - `ResourceExhausted` with a `retry-after` value, which a rate limiter returns. Without that
473///   value, the service rejects a note that is too large or reports that its storage is full. A
474///   retry gets the same result.
475///
476/// The delay starts at `retry_interval_ms` and doubles with each retry. A non-zero `retry-after`
477/// value replaces it.
478fn retry_delay(failure: &SendFailure, retry: u32, retry_interval_ms: u64) -> Option<Duration> {
479    let backoff =
480        Duration::from_millis(retry_interval_ms.saturating_mul(2u64.saturating_pow(retry)));
481    let status = match failure {
482        SendFailure::Connect(_) => return Some(backoff),
483        SendFailure::Status(status) => status,
484    };
485
486    let retry_after = extract_retry_after(status);
487    let retryable = match status.code() {
488        Code::Unavailable | Code::DeadlineExceeded => true,
489        Code::ResourceExhausted => retry_after.is_some(),
490        _ => false,
491    };
492
493    retryable.then(|| retry_after.filter(|delay| !delay.is_zero()).unwrap_or(backoff))
494}
495
496// TESTS
497// ================================================================================================
498
499#[cfg(test)]
500mod tests {
501    use alloc::string::ToString;
502
503    use miden_protocol::account::AccountId;
504    use miden_protocol::asset::FungibleAsset;
505    use miden_protocol::note::{Note, NoteType};
506    use miden_protocol::testing::account_id::{
507        ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
508        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
509        ACCOUNT_ID_SENDER,
510    };
511    use miden_protocol::utils::serde::Deserializable;
512    use miden_standards::note::P2idNote;
513    use rand::SeedableRng;
514    use rand_chacha::ChaCha20Rng;
515    use tonic::metadata::MetadataMap;
516
517    use super::*;
518    use crate::rng::draw_word;
519
520    /// Builds a private P2ID note whose serial number derives from `seed`.
521    fn private_note(seed: u32) -> Note {
522        let sender = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
523        let target = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
524        let faucet = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
525        let mut rng = ChaCha20Rng::seed_from_u64(u64::from(seed));
526
527        P2idNote::builder()
528            .sender(sender)
529            .target(target)
530            .asset(FungibleAsset::new(faucet, 100).unwrap())
531            .note_type(NoteType::Private)
532            .serial_number(draw_word(&mut rng))
533            .build()
534            .unwrap()
535            .into()
536    }
537
538    fn fetched_note(header: &NoteHeader, details: NoteDetails) -> FetchedNote {
539        FetchedNote {
540            header: Some((*header).into()),
541            details: Some(details.into()),
542            committed_in_block: None,
543        }
544    }
545
546    #[test]
547    fn matching_note_decodes() {
548        let note = private_note(1);
549        let mut fetched = fetched_note(note.header(), NoteDetails::from(note.clone()));
550        fetched.committed_in_block = Some(BlockNumber::from(9).into());
551
552        let info = fetched.decode_and_verify().unwrap();
553
554        assert_eq!(info.header, *note.header());
555        assert_eq!(
556            NoteDetails::read_from_bytes(&info.details_bytes).unwrap().commitment(),
557            note.details_commitment()
558        );
559        assert_eq!(info.block_hint, Some(BlockNumber::from(9)));
560    }
561
562    #[test]
563    fn mismatched_details_are_rejected() {
564        let note_a = private_note(3);
565        let note_b = private_note(4);
566        assert_ne!(note_a.details_commitment(), note_b.details_commitment());
567
568        let fetched = fetched_note(note_b.header(), NoteDetails::from(note_a.clone()));
569
570        assert!(fetched.clone().decode_and_verify().is_err());
571        let error = fetched.decode_fields().unwrap().verify().unwrap_err();
572        assert_eq!(error.header, note_b.details_commitment());
573        assert_eq!(error.details, note_a.details_commitment());
574    }
575
576    // SEND RETRY
577    // --------------------------------------------------------------------------------------------
578
579    fn status_failure(code: Code) -> SendFailure {
580        SendFailure::Status(Status::new(code, "test failure"))
581    }
582
583    fn status_failure_with_retry_after(code: Code, seconds: &str) -> SendFailure {
584        let mut metadata = MetadataMap::new();
585        metadata.insert("retry-after", seconds.parse().unwrap());
586        SendFailure::Status(Status::with_metadata(code, "test failure", metadata))
587    }
588
589    fn connect_failure() -> SendFailure {
590        SendFailure::Connect(NoteTransportError::Network("connection refused".to_string()))
591    }
592
593    #[test]
594    fn retry_delay_retries_only_transient_failures() {
595        for failure in [
596            connect_failure(),
597            status_failure(Code::Unavailable),
598            status_failure(Code::DeadlineExceeded),
599            status_failure_with_retry_after(Code::ResourceExhausted, "2"),
600        ] {
601            assert!(retry_delay(&failure, 0, 250).is_some(), "{failure:?}");
602        }
603
604        // Without `retry-after`, `ResourceExhausted` means a note that is too large or a full
605        // storage. A `retry-after` value does not make a permanent failure retryable.
606        for failure in [
607            status_failure(Code::ResourceExhausted),
608            status_failure(Code::InvalidArgument),
609            status_failure(Code::FailedPrecondition),
610            status_failure(Code::Internal),
611            status_failure(Code::Cancelled),
612            status_failure_with_retry_after(Code::InvalidArgument, "1"),
613        ] {
614            assert_eq!(retry_delay(&failure, 0, 250), None, "{failure:?}");
615        }
616    }
617}