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_cursor(cursor: NoteTransportCursor) -> FetchNotesCursor {
120 FetchNotesCursor {
121 nonce: cursor.nonce,
122 sequence: cursor.sequence,
123 }
124}
125
126fn 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#[cfg(not(target_arch = "wasm32"))]
139type Service = Channel;
140#[cfg(target_arch = "wasm32")]
141type Service = tonic_web_wasm_client::Client;
142
143#[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#[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#[derive(Clone)]
183struct ConnectedClient {
184 client: NoteTransportServiceClient<Service>,
185 health_client: HealthClient<Service>,
186}
187
188pub struct GrpcNoteTransportClient {
193 inner: RwLock<Option<ConnectedClient>>,
194 endpoint: String,
195 timeout_ms: u64,
196 max_retries: u32,
198 retry_interval_ms: u64,
200}
201
202impl GrpcNoteTransportClient {
203 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 #[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 #[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 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 async fn api(&self) -> Result<NoteTransportServiceClient<Service>, NoteTransportError> {
245 Ok(self.ensure_connected().await?.client)
246 }
247
248 async fn health_api(&self) -> Result<HealthClient<Service>, NoteTransportError> {
250 Ok(self.ensure_connected().await?.health_client)
251 }
252
253 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((¬e_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 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 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 pub async fn health_check(&mut self) -> Result<(), NoteTransportError> {
352 let request = tonic::Request::new(HealthCheckRequest {
353 service: String::new(), });
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
395const DEFAULT_SEND_MAX_RETRIES: u32 = 3;
400
401const DEFAULT_SEND_RETRY_INTERVAL_MS: u64 = 250;
403
404#[derive(Debug)]
406enum SendFailure {
407 Connect(NoteTransportError),
409 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
424async 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
462fn 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#[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 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 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 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}