Skip to main content

miden_node_proto/clients/
mod.rs

1//! gRPC client builder utilities for Miden node.
2//!
3//! This module provides a unified type-safe [`Builder`] for creating various gRPC clients with
4//! explicit configuration decisions for TLS, timeout, and metadata.
5//!
6//! # Examples
7//!
8//! ```rust
9//! # use miden_node_proto::clients::{Builder, WantsTls, RpcClient};
10//! # use url::Url;
11//!
12//! # async fn example() -> anyhow::Result<()> {
13//! // Create an RPC client with OTEL and TLS
14//! let url = Url::parse("https://example.com:8080")?;
15//! let client: RpcClient = Builder::new(url)
16//!     .with_tls()?                   // or `.without_tls()`
17//!     .without_timeout()             // or `.with_timeout(Duration::from_secs(10))`
18//!     .without_metadata_version()    // or `.with_metadata_version("1.0".into())`
19//!     .without_metadata_genesis()    // or `.with_metadata_genesis(genesis)`
20//!     .without_auth_header()         // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))`
21//!     .with_otel_context_injection() // or `.without_otel_context_injection()`
22//!     .connect::<RpcClient>()
23//!     .await?;
24//! # Ok(())
25//! # }
26//! ```
27
28use std::marker::PhantomData;
29use std::ops::{Deref, DerefMut};
30use std::str::FromStr;
31use std::time::Duration;
32
33use http::header::ACCEPT;
34use miden_node_tracing::grpc::OtelInterceptor;
35use miden_node_tracing::{debug, info, warn};
36use miden_protocol::Word;
37use miden_protocol::batch::ProposedBatch;
38use tonic::metadata::AsciiMetadataValue;
39use tonic::service::interceptor::InterceptedService;
40use tonic::transport::{Channel, ClientTlsConfig, Endpoint, Error as TransportError};
41use tonic::{Request, Status};
42use url::Url;
43
44use crate::generated;
45
46#[derive(Clone)]
47pub struct Interceptor {
48    otel: Option<OtelInterceptor>,
49    accept: AsciiMetadataValue,
50    auth_header_value: Option<AsciiMetadataValue>,
51}
52
53impl Default for Interceptor {
54    fn default() -> Self {
55        Self {
56            otel: None,
57            accept: AsciiMetadataValue::from_static(Self::MEDIA_TYPE),
58            auth_header_value: None,
59        }
60    }
61}
62
63impl Interceptor {
64    const MEDIA_TYPE: &str = "application/vnd.miden";
65    const VERSION: &str = "version";
66    const GENESIS: &str = "genesis";
67    const NETWORK_TX_AUTH_HEADER_NAME: &str = "x-miden-network-tx-auth";
68
69    fn new(
70        enable_otel: bool,
71        version: Option<&str>,
72        genesis: Option<&str>,
73        auth_header: Option<AsciiMetadataValue>,
74    ) -> Self {
75        if let Some(version) = version
76            && !version.is_ascii()
77        {
78            panic!("version contains non-ascii values: {version}");
79        }
80
81        if let Some(genesis) = genesis
82            && !genesis.is_ascii()
83        {
84            panic!("genesis contains non-ascii values: {genesis}");
85        }
86
87        let accept = match (version, genesis) {
88            (None, None) => Self::MEDIA_TYPE.to_string(),
89            (None, Some(genesis)) => format!("{}; {}={genesis}", Self::MEDIA_TYPE, Self::GENESIS),
90            (Some(version), None) => format!("{}; {}={version}", Self::MEDIA_TYPE, Self::VERSION),
91            (Some(version), Some(genesis)) => format!(
92                "{}; {}={version}; {}={genesis}",
93                Self::MEDIA_TYPE,
94                Self::VERSION,
95                Self::GENESIS
96            ),
97        };
98        Self {
99            otel: enable_otel.then_some(OtelInterceptor),
100            // SAFETY: we checked that all values are ascii at the top of the function.
101            accept: AsciiMetadataValue::from_str(&accept).unwrap(),
102            auth_header_value: auth_header,
103        }
104    }
105}
106
107impl tonic::service::Interceptor for Interceptor {
108    fn call(&mut self, mut request: tonic::Request<()>) -> Result<Request<()>, Status> {
109        if let Some(mut otel) = self.otel {
110            request = otel.call(request)?;
111        }
112
113        if request.metadata().get(ACCEPT.as_str()).is_none() {
114            request.metadata_mut().insert(ACCEPT.as_str(), self.accept.clone());
115        }
116
117        if let Some(value) = &self.auth_header_value {
118            request.metadata_mut().insert(Self::NETWORK_TX_AUTH_HEADER_NAME, value.clone());
119        }
120
121        Ok(request)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn interceptor_preserves_existing_accept_metadata() {
131        let original_accept =
132            AsciiMetadataValue::from_static("application/vnd.miden; version=1.2; genesis=0x1234");
133        let mut request = Request::new(());
134        request.metadata_mut().insert(ACCEPT.as_str(), original_accept.clone());
135
136        let mut interceptor = Interceptor::new(false, Some("9.9"), Some("0xabcd"), None);
137        let request = tonic::service::Interceptor::call(&mut interceptor, request)
138            .expect("interceptor should succeed");
139
140        assert_eq!(request.metadata().get(ACCEPT.as_str()), Some(&original_accept));
141    }
142
143    #[test]
144    fn interceptor_inserts_accept_metadata_when_missing() {
145        let mut interceptor = Interceptor::new(false, Some("9.9"), Some("0xabcd"), None);
146
147        let request = tonic::service::Interceptor::call(&mut interceptor, Request::new(()))
148            .expect("interceptor should succeed");
149
150        assert_eq!(
151            request.metadata().get(ACCEPT.as_str()).and_then(|value| value.to_str().ok()),
152            Some("application/vnd.miden; version=9.9; genesis=0xabcd"),
153        );
154    }
155
156    #[tokio::test]
157    async fn connection_monitor_stops_when_cancelled() {
158        let builder = Builder::new(Url::parse("http://127.0.0.1:1").unwrap())
159            .without_tls()
160            .without_timeout()
161            .without_metadata_version()
162            .without_metadata_genesis()
163            .without_auth_header()
164            .without_otel_context_injection();
165        let shutdown = miden_node_utils::shutdown::CancellationToken::new();
166        shutdown.cancel();
167
168        tokio::time::timeout(
169            Duration::from_millis(100),
170            builder.monitor::<RpcClient>("test-dependency", shutdown),
171        )
172        .await
173        .expect("cancelled monitor should return promptly");
174    }
175}
176
177// TYPE ALIASES TO AID LEGIBILITY
178// ================================================================================================
179
180type InterceptedChannel = InterceptedService<Channel, Interceptor>;
181type GeneratedRpcClient =
182    generated::miden::node::v1::node_service_client::NodeServiceClient<InterceptedChannel>;
183type GeneratedNoteTransportClient =
184    generated::miden::note_transport::v1::note_transport_service_client::NoteTransportServiceClient<
185        InterceptedChannel,
186    >;
187type GeneratedProxyStatusClient =
188    generated::miden::remote_prover::v1::proxy_status_service_client::ProxyStatusServiceClient<
189        InterceptedChannel,
190    >;
191type GeneratedProverClient =
192    generated::miden::remote_prover::v1::prover_service_client::ProverServiceClient<
193        InterceptedChannel,
194    >;
195type GeneratedValidatorClient =
196    generated::miden::validator::v1::validator_service_client::ValidatorServiceClient<
197        InterceptedChannel,
198    >;
199type GeneratedNtxBuilderClient =
200    generated::miden::ntx_builder::v1::network_transaction_builder_service_client::NetworkTransactionBuilderServiceClient<InterceptedChannel>;
201type GeneratedSequencerClient =
202    generated::miden::sequencer::v1::sequencer_service_client::SequencerServiceClient<
203        InterceptedChannel,
204    >;
205type GeneratedProvenTransaction = generated::submission::ProvenTransactionSubmission;
206type SealedTransactionInputs = generated::submission::SealedTransactionInputs;
207
208// gRPC CLIENTS
209// ================================================================================================
210
211#[derive(Debug, Clone)]
212pub struct RpcClient(GeneratedRpcClient);
213#[derive(Debug, Clone)]
214pub struct NoteTransportClient(GeneratedNoteTransportClient);
215#[derive(Debug, Clone)]
216pub struct RemoteProverProxyStatusClient(GeneratedProxyStatusClient);
217#[derive(Debug, Clone)]
218pub struct RemoteProverClient(GeneratedProverClient);
219#[derive(Debug, Clone)]
220pub struct ValidatorClient(GeneratedValidatorClient);
221#[derive(Debug, Clone)]
222pub struct NtxBuilderClient(GeneratedNtxBuilderClient);
223#[derive(Debug, Clone)]
224pub struct SequencerClient(GeneratedSequencerClient);
225
226impl DerefMut for RpcClient {
227    fn deref_mut(&mut self) -> &mut Self::Target {
228        &mut self.0
229    }
230}
231
232impl Deref for RpcClient {
233    type Target = GeneratedRpcClient;
234
235    fn deref(&self) -> &Self::Target {
236        &self.0
237    }
238}
239
240impl DerefMut for RemoteProverProxyStatusClient {
241    fn deref_mut(&mut self) -> &mut Self::Target {
242        &mut self.0
243    }
244}
245
246impl Deref for RemoteProverProxyStatusClient {
247    type Target = GeneratedProxyStatusClient;
248
249    fn deref(&self) -> &Self::Target {
250        &self.0
251    }
252}
253
254impl DerefMut for RemoteProverClient {
255    fn deref_mut(&mut self) -> &mut Self::Target {
256        &mut self.0
257    }
258}
259
260impl Deref for RemoteProverClient {
261    type Target = GeneratedProverClient;
262
263    fn deref(&self) -> &Self::Target {
264        &self.0
265    }
266}
267
268impl DerefMut for ValidatorClient {
269    fn deref_mut(&mut self) -> &mut Self::Target {
270        &mut self.0
271    }
272}
273
274impl Deref for ValidatorClient {
275    type Target = GeneratedValidatorClient;
276
277    fn deref(&self) -> &Self::Target {
278        &self.0
279    }
280}
281
282impl DerefMut for NtxBuilderClient {
283    fn deref_mut(&mut self) -> &mut Self::Target {
284        &mut self.0
285    }
286}
287
288impl Deref for NtxBuilderClient {
289    type Target = GeneratedNtxBuilderClient;
290
291    fn deref(&self) -> &Self::Target {
292        &self.0
293    }
294}
295
296impl DerefMut for SequencerClient {
297    fn deref_mut(&mut self) -> &mut Self::Target {
298        &mut self.0
299    }
300}
301
302impl Deref for SequencerClient {
303    type Target = GeneratedSequencerClient;
304
305    fn deref(&self) -> &Self::Target {
306        &self.0
307    }
308}
309
310// GRPC CLIENT BUILDER TRAIT
311// ================================================================================================
312
313/// Trait for building gRPC clients from a common [`Builder`] configuration.
314pub trait GrpcClient {
315    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self;
316}
317
318impl GrpcClient for RpcClient {
319    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
320        Self(GeneratedRpcClient::new(InterceptedService::new(channel, interceptor)))
321    }
322}
323
324impl Deref for NoteTransportClient {
325    type Target = GeneratedNoteTransportClient;
326
327    fn deref(&self) -> &Self::Target {
328        &self.0
329    }
330}
331
332impl DerefMut for NoteTransportClient {
333    fn deref_mut(&mut self) -> &mut Self::Target {
334        &mut self.0
335    }
336}
337
338impl GrpcClient for NoteTransportClient {
339    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
340        Self(GeneratedNoteTransportClient::new(InterceptedService::new(channel, interceptor)))
341    }
342}
343
344impl GrpcClient for RemoteProverProxyStatusClient {
345    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
346        Self(GeneratedProxyStatusClient::new(InterceptedService::new(channel, interceptor)))
347    }
348}
349
350impl GrpcClient for RemoteProverClient {
351    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
352        Self(GeneratedProverClient::new(InterceptedService::new(channel, interceptor)))
353    }
354}
355
356impl GrpcClient for ValidatorClient {
357    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
358        Self(GeneratedValidatorClient::new(InterceptedService::new(channel, interceptor)))
359    }
360}
361
362impl GrpcClient for NtxBuilderClient {
363    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
364        Self(GeneratedNtxBuilderClient::new(InterceptedService::new(channel, interceptor)))
365    }
366}
367
368impl GrpcClient for SequencerClient {
369    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
370        Self(GeneratedSequencerClient::new(InterceptedService::new(channel, interceptor)))
371    }
372}
373
374// STRICT TYPE-SAFE BUILDER (NO DEFAULTS)
375// ================================================================================================
376
377/// A type-safe builder that forces the caller to make an explicit decision for each
378/// configuration item (TLS, timeout, metadata version, metadata genesis) before connecting.
379///
380/// This builder replaces the previous defaulted builder. Callers must explicitly choose TLS,
381/// timeout, and metadata options before connecting.
382///
383/// Usage example:
384///
385/// ```rust
386/// # use miden_node_proto::clients::{Builder, WantsTls, RpcClient};
387/// # use url::Url;
388/// # use std::time::Duration;
389///
390/// # async fn example() -> anyhow::Result<()> {
391/// let url = Url::parse("https://rpc.example.com:8080")?;
392/// let client: RpcClient = Builder::new(url)
393///     .with_tls()?                          // or `.without_tls()`
394///     .with_timeout(Duration::from_secs(5)) // or `.without_timeout()`
395///     .with_metadata_version("1.0".into())  // or `.without_metadata_version()`
396///     .without_metadata_genesis()           // or `.with_metadata_genesis(genesis)`
397///     .without_auth_header()                // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))`
398///     .with_otel_context_injection()        // or `.without_otel_context_injection()`
399///     .connect::<RpcClient>()
400///     .await?;
401/// # Ok(())
402/// # }
403/// ```
404#[derive(Clone, Debug)]
405pub struct Builder<State> {
406    endpoint: Endpoint,
407    endpoint_url: Url,
408    metadata_version: Option<String>,
409    metadata_genesis: Option<Word>,
410    metadata_auth_header_value: Option<AsciiMetadataValue>,
411    enable_otel: bool,
412    _state: PhantomData<State>,
413}
414
415#[derive(Copy, Clone, Debug)]
416pub struct WantsTls;
417#[derive(Copy, Clone, Debug)]
418pub struct WantsTimeout;
419#[derive(Copy, Clone, Debug)]
420pub struct WantsVersion;
421#[derive(Copy, Clone, Debug)]
422pub struct WantsGenesis;
423#[derive(Copy, Clone, Debug)]
424pub struct WantsOTel;
425#[derive(Copy, Clone, Debug)]
426pub struct WantsConnection;
427
428impl<State> Builder<State> {
429    /// Convenience function to cast the state type and carry internal configuration forward.
430    fn next_state<Next>(self) -> Builder<Next> {
431        Builder {
432            endpoint: self.endpoint,
433            endpoint_url: self.endpoint_url,
434            metadata_version: self.metadata_version,
435            metadata_genesis: self.metadata_genesis,
436            metadata_auth_header_value: self.metadata_auth_header_value,
437            enable_otel: self.enable_otel,
438            _state: PhantomData::<Next>,
439        }
440    }
441}
442
443/// Client HTTP/2 keepalive interval.
444const HTTP2_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(20);
445/// Client HTTP/2 keepalive: how long to wait for a PING ack before considering the connection dead.
446const HTTP2_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
447/// OS-level TCP keepalive backstop for direct (non-proxied) connections.
448const TCP_KEEPALIVE: Duration = Duration::from_secs(30);
449
450impl Builder<WantsTls> {
451    /// Create a new strict builder from a gRPC endpoint URL such as `http://localhost:8080` or
452    /// `https://api.example.com:443`.
453    pub fn new(url: Url) -> Builder<WantsTls> {
454        let endpoint = Endpoint::from_shared(String::from(url.clone()))
455            .expect("Url type always results in valid endpoint")
456            // Detect silently dropped connections so long-lived streams can't hang forever; see the
457            // keepalive constants above.
458            .http2_keep_alive_interval(HTTP2_KEEPALIVE_INTERVAL)
459            .keep_alive_timeout(HTTP2_KEEPALIVE_TIMEOUT)
460            .keep_alive_while_idle(true)
461            .tcp_keepalive(Some(TCP_KEEPALIVE));
462
463        Builder {
464            endpoint,
465            endpoint_url: url,
466            metadata_version: None,
467            metadata_genesis: None,
468            metadata_auth_header_value: None,
469            enable_otel: false,
470            _state: PhantomData,
471        }
472    }
473
474    /// Explicitly disable TLS.
475    pub fn without_tls(self) -> Builder<WantsTimeout> {
476        self.next_state()
477    }
478
479    /// Explicitly enable TLS.
480    pub fn with_tls(mut self) -> Result<Builder<WantsTimeout>, TransportError> {
481        self.endpoint = self.endpoint.tls_config(ClientTlsConfig::new().with_native_roots())?;
482
483        Ok(self.next_state())
484    }
485}
486
487impl Builder<WantsTimeout> {
488    /// Explicitly disable request timeout.
489    pub fn without_timeout(self) -> Builder<WantsVersion> {
490        self.next_state()
491    }
492
493    /// Explicitly configure a request timeout.
494    pub fn with_timeout(mut self, duration: Duration) -> Builder<WantsVersion> {
495        self.endpoint = self.endpoint.timeout(duration);
496        self.next_state()
497    }
498}
499
500impl Builder<WantsVersion> {
501    /// Do not include version in request metadata.
502    pub fn without_metadata_version(mut self) -> Builder<WantsGenesis> {
503        self.metadata_version = None;
504        self.next_state()
505    }
506
507    /// Include a specific version string in request metadata.
508    pub fn with_metadata_version(mut self, version: String) -> Builder<WantsGenesis> {
509        self.metadata_version = Some(version);
510        self.next_state()
511    }
512}
513
514impl Builder<WantsGenesis> {
515    /// Do not include genesis commitment in request metadata.
516    pub fn without_metadata_genesis(mut self) -> Builder<WantsOTel> {
517        self.metadata_genesis = None;
518        self.next_state()
519    }
520
521    /// Include a specific genesis commitment in request metadata.
522    pub fn with_metadata_genesis(mut self, genesis: Word) -> Builder<WantsOTel> {
523        self.metadata_genesis = Some(genesis);
524        self.next_state()
525    }
526}
527
528impl Builder<WantsOTel> {
529    /// Do not include any additional metadata header in request metadata.
530    #[must_use]
531    pub fn without_auth_header(mut self) -> Self {
532        self.metadata_auth_header_value = None;
533        self
534    }
535
536    /// Include an additional ASCII metadata header in request metadata.
537    #[must_use]
538    pub fn with_auth_header_value(mut self, value: AsciiMetadataValue) -> Self {
539        self.metadata_auth_header_value = Some(value);
540        self
541    }
542
543    /// Enables OpenTelemetry context propagation via gRPC.
544    ///
545    /// This is used to by OpenTelemetry to connect traces across network boundaries. The server on
546    /// the other end must be configured to receive and use the injected trace context.
547    pub fn with_otel_context_injection(mut self) -> Builder<WantsConnection> {
548        self.enable_otel = true;
549        self.next_state()
550    }
551
552    /// Disables OpenTelemetry context propagation. This should be disabled when interfacing with
553    /// external third party gRPC servers.
554    pub fn without_otel_context_injection(mut self) -> Builder<WantsConnection> {
555        self.enable_otel = false;
556        self.next_state()
557    }
558}
559
560impl Builder<WantsConnection> {
561    /// Establish an eager connection and return a fully configured client.
562    pub async fn connect<T>(self) -> Result<T, TransportError>
563    where
564        T: GrpcClient,
565    {
566        let channel = self.endpoint.connect().await?;
567        Ok(self.connect_with_channel::<T>(channel))
568    }
569
570    /// Establish a lazy connection and return a client that will connect on first use.
571    pub fn connect_lazy<T>(self) -> T
572    where
573        T: GrpcClient,
574    {
575        let channel = self.endpoint.connect_lazy();
576        self.connect_with_channel::<T>(channel)
577    }
578
579    /// Monitors whether the configured endpoint can establish a transport connection.
580    ///
581    /// The monitor is non-blocking with respect to service startup. It warns on the first failed
582    /// attempt, retries with capped exponential backoff, and reports when the dependency becomes
583    /// reachable. Once connected it waits for shutdown instead of creating duplicate connections.
584    pub async fn monitor<T>(
585        self,
586        dependency_name: &'static str,
587        shutdown: miden_node_utils::shutdown::CancellationToken,
588    ) where
589        T: GrpcClient + Send + 'static,
590    {
591        const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
592        const RETRY_MIN: Duration = Duration::from_secs(1);
593        const RETRY_MAX: Duration = Duration::from_secs(30);
594
595        use miden_node_utils::retry::BackoffBuilder;
596
597        let endpoint = miden_node_utils::formatting::format_endpoint(&self.endpoint_url);
598        let mut backoff = miden_node_utils::retry::exponential(RETRY_MIN, RETRY_MAX).build();
599        let mut first_failure = true;
600
601        loop {
602            let attempt = tokio::time::timeout(CONNECT_TIMEOUT, self.clone().connect::<T>());
603            let result = tokio::select! {
604                () = shutdown.cancelled() => return,
605                result = attempt => result,
606            };
607
608            match result {
609                Ok(Ok(_client)) => {
610                    info!(
611                        "Configured service reachable",
612                        dependency.name = dependency_name,
613                        dependency.endpoint = endpoint.as_str()
614                    );
615                    shutdown.cancelled().await;
616                    return;
617                },
618                Ok(Err(err)) if first_failure => {
619                    warn!(
620                        &err,
621                        "Configured service unreachable",
622                        dependency.name = dependency_name,
623                        dependency.endpoint = endpoint.as_str()
624                    );
625                },
626                Err(_elapsed) if first_failure => {
627                    warn!(
628                        "Configured service connection timed out",
629                        dependency.name = dependency_name,
630                        dependency.endpoint = endpoint.as_str(),
631                        timeout.ms = CONNECT_TIMEOUT.as_millis() as u64
632                    );
633                },
634                Ok(Err(err)) => {
635                    debug!(
636                        &err,
637                        "Configured service still unreachable",
638                        dependency.name = dependency_name,
639                        dependency.endpoint = endpoint.as_str()
640                    );
641                },
642                Err(_elapsed) => {
643                    debug!(
644                        "Configured service connection still timing out",
645                        dependency.name = dependency_name,
646                        dependency.endpoint = endpoint.as_str(),
647                        timeout.ms = CONNECT_TIMEOUT.as_millis() as u64
648                    );
649                },
650            }
651            first_failure = false;
652
653            let retry_delay = backoff.next().unwrap_or(RETRY_MAX);
654            tokio::select! {
655                () = shutdown.cancelled() => return,
656                () = tokio::time::sleep(retry_delay) => {},
657            }
658        }
659    }
660
661    fn connect_with_channel<T>(self, channel: Channel) -> T
662    where
663        T: GrpcClient,
664    {
665        let metadata_genesis = self.metadata_genesis.map(|genesis| genesis.to_hex());
666        let interceptor = Interceptor::new(
667            self.enable_otel,
668            self.metadata_version.as_deref(),
669            metadata_genesis.as_deref(),
670            self.metadata_auth_header_value,
671        );
672        T::with_interceptor(channel, interceptor)
673    }
674}
675
676impl ValidatorClient {
677    /// Submits each transaction in the batch to the validator for re-execution.
678    ///
679    /// # Errors
680    ///
681    /// - If `sealed_transaction_inputs` does not match the batch's transactions in length
682    pub async fn submit_batch(
683        &mut self,
684        proposed_batch: &ProposedBatch,
685        sealed_transaction_inputs: &[SealedTransactionInputs],
686    ) -> Result<(), Status> {
687        if proposed_batch.transactions().len() != sealed_transaction_inputs.len() {
688            return Err(Status::invalid_argument(
689                "transaction inputs do not match the batch's transactions",
690            ));
691        }
692        for (tx, inputs) in proposed_batch.transactions().iter().zip(sealed_transaction_inputs) {
693            let proven_tx = GeneratedProvenTransaction {
694                transaction: Some(tx.as_ref().into()),
695                sealed_transaction_inputs: Some(inputs.clone()),
696            };
697            self.submit_proven_transaction(
698                crate::generated::miden::validator::v1::SubmitProvenTransactionRequest {
699                    submission: Some(proven_tx),
700                },
701            )
702            .await?;
703        }
704        Ok(())
705    }
706}