1use 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
50pub 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#[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 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
118fn 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#[cfg(not(target_arch = "wasm32"))]
131type Service = Channel;
132#[cfg(target_arch = "wasm32")]
133type Service = tonic_web_wasm_client::Client;
134
135#[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#[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#[derive(Clone)]
175struct ConnectedClient {
176 client: NoteTransportServiceClient<Service>,
177 health_client: HealthClient<Service>,
178}
179
180pub struct GrpcNoteTransportClient {
185 inner: RwLock<Option<ConnectedClient>>,
186 endpoint: String,
187 timeout_ms: u64,
188 max_retries: u32,
190 retry_interval_ms: u64,
192}
193
194impl GrpcNoteTransportClient {
195 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 #[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 #[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 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 async fn api(&self) -> Result<NoteTransportServiceClient<Service>, NoteTransportError> {
237 Ok(self.ensure_connected().await?.client)
238 }
239
240 async fn health_api(&self) -> Result<HealthClient<Service>, NoteTransportError> {
242 Ok(self.ensure_connected().await?.health_client)
243 }
244
245 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((¬e_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 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 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 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 pub async fn health_check(&mut self) -> Result<(), NoteTransportError> {
344 let request = tonic::Request::new(HealthCheckRequest {
345 service: String::new(), });
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
394const DEFAULT_SEND_MAX_RETRIES: u32 = 3;
399
400const DEFAULT_SEND_RETRY_INTERVAL_MS: u64 = 250;
402
403#[derive(Debug)]
405enum SendFailure {
406 Connect(NoteTransportError),
408 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
423async 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
461fn 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#[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 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 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 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}