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