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_utils::tracing::grpc::OtelInterceptor;
35use miden_protocol::batch::ProposedBatch;
36use miden_protocol::utils::serde::Serializable;
37use tonic::metadata::AsciiMetadataValue;
38use tonic::service::interceptor::InterceptedService;
39use tonic::transport::{Channel, ClientTlsConfig, Endpoint, Error as TransportError};
40use tonic::{Request, Status};
41use url::Url;
42
43use crate::generated;
44
45#[derive(Clone)]
46pub struct Interceptor {
47    otel: Option<OtelInterceptor>,
48    accept: AsciiMetadataValue,
49    auth_header_value: Option<AsciiMetadataValue>,
50}
51
52impl Default for Interceptor {
53    fn default() -> Self {
54        Self {
55            otel: None,
56            accept: AsciiMetadataValue::from_static(Self::MEDIA_TYPE),
57            auth_header_value: None,
58        }
59    }
60}
61
62impl Interceptor {
63    const MEDIA_TYPE: &str = "application/vnd.miden";
64    const VERSION: &str = "version";
65    const GENESIS: &str = "genesis";
66    const NETWORK_TX_AUTH_HEADER_NAME: &str = "x-miden-network-tx-auth";
67
68    fn new(
69        enable_otel: bool,
70        version: Option<&str>,
71        genesis: Option<&str>,
72        auth_header: Option<AsciiMetadataValue>,
73    ) -> Self {
74        if let Some(version) = version
75            && !version.is_ascii()
76        {
77            panic!("version contains non-ascii values: {version}");
78        }
79
80        if let Some(genesis) = genesis
81            && !genesis.is_ascii()
82        {
83            panic!("genesis contains non-ascii values: {genesis}");
84        }
85
86        let accept = match (version, genesis) {
87            (None, None) => Self::MEDIA_TYPE.to_string(),
88            (None, Some(genesis)) => format!("{}; {}={genesis}", Self::MEDIA_TYPE, Self::GENESIS),
89            (Some(version), None) => format!("{}; {}={version}", Self::MEDIA_TYPE, Self::VERSION),
90            (Some(version), Some(genesis)) => format!(
91                "{}; {}={version}, {}={genesis}",
92                Self::MEDIA_TYPE,
93                Self::VERSION,
94                Self::GENESIS
95            ),
96        };
97        Self {
98            otel: enable_otel.then_some(OtelInterceptor),
99            // SAFETY: we checked that all values are ascii at the top of the function.
100            accept: AsciiMetadataValue::from_str(&accept).unwrap(),
101            auth_header_value: auth_header,
102        }
103    }
104}
105
106impl tonic::service::Interceptor for Interceptor {
107    fn call(&mut self, mut request: tonic::Request<()>) -> Result<Request<()>, Status> {
108        if let Some(mut otel) = self.otel {
109            request = otel.call(request)?;
110        }
111
112        if request.metadata().get(ACCEPT.as_str()).is_none() {
113            request.metadata_mut().insert(ACCEPT.as_str(), self.accept.clone());
114        }
115
116        if let Some(value) = &self.auth_header_value {
117            request.metadata_mut().insert(Self::NETWORK_TX_AUTH_HEADER_NAME, value.clone());
118        }
119
120        Ok(request)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn interceptor_preserves_existing_accept_metadata() {
130        let original_accept =
131            AsciiMetadataValue::from_static("application/vnd.miden; version=1.2; genesis=0x1234");
132        let mut request = Request::new(());
133        request.metadata_mut().insert(ACCEPT.as_str(), original_accept.clone());
134
135        let mut interceptor = Interceptor::new(false, Some("9.9"), Some("0xabcd"), None);
136        let request = tonic::service::Interceptor::call(&mut interceptor, request)
137            .expect("interceptor should succeed");
138
139        assert_eq!(request.metadata().get(ACCEPT.as_str()), Some(&original_accept));
140    }
141
142    #[test]
143    fn interceptor_inserts_accept_metadata_when_missing() {
144        let mut interceptor = Interceptor::new(false, Some("9.9"), Some("0xabcd"), None);
145
146        let request = tonic::service::Interceptor::call(&mut interceptor, Request::new(()))
147            .expect("interceptor should succeed");
148
149        assert_eq!(
150            request.metadata().get(ACCEPT.as_str()).and_then(|value| value.to_str().ok()),
151            Some("application/vnd.miden; version=9.9, genesis=0xabcd"),
152        );
153    }
154}
155
156// TYPE ALIASES TO AID LEGIBILITY
157// ================================================================================================
158
159type InterceptedChannel = InterceptedService<Channel, Interceptor>;
160type GeneratedRpcClient = generated::rpc::api_client::ApiClient<InterceptedChannel>;
161type GeneratedProxyStatusClient =
162    generated::remote_prover::proxy_status_api_client::ProxyStatusApiClient<InterceptedChannel>;
163type GeneratedProverClient = generated::remote_prover::api_client::ApiClient<InterceptedChannel>;
164type GeneratedValidatorClient = generated::validator::api_client::ApiClient<InterceptedChannel>;
165type GeneratedNtxBuilderClient = generated::ntx_builder::api_client::ApiClient<InterceptedChannel>;
166type GeneratedSequencerClient = generated::sequencer::api_client::ApiClient<InterceptedChannel>;
167
168// gRPC CLIENTS
169// ================================================================================================
170
171#[derive(Debug, Clone)]
172pub struct RpcClient(GeneratedRpcClient);
173#[derive(Debug, Clone)]
174pub struct RemoteProverProxyStatusClient(GeneratedProxyStatusClient);
175#[derive(Debug, Clone)]
176pub struct RemoteProverClient(GeneratedProverClient);
177#[derive(Debug, Clone)]
178pub struct ValidatorClient(GeneratedValidatorClient);
179#[derive(Debug, Clone)]
180pub struct NtxBuilderClient(GeneratedNtxBuilderClient);
181#[derive(Debug, Clone)]
182pub struct SequencerClient(GeneratedSequencerClient);
183
184impl DerefMut for RpcClient {
185    fn deref_mut(&mut self) -> &mut Self::Target {
186        &mut self.0
187    }
188}
189
190impl Deref for RpcClient {
191    type Target = GeneratedRpcClient;
192
193    fn deref(&self) -> &Self::Target {
194        &self.0
195    }
196}
197
198impl DerefMut for RemoteProverProxyStatusClient {
199    fn deref_mut(&mut self) -> &mut Self::Target {
200        &mut self.0
201    }
202}
203
204impl Deref for RemoteProverProxyStatusClient {
205    type Target = GeneratedProxyStatusClient;
206
207    fn deref(&self) -> &Self::Target {
208        &self.0
209    }
210}
211
212impl DerefMut for RemoteProverClient {
213    fn deref_mut(&mut self) -> &mut Self::Target {
214        &mut self.0
215    }
216}
217
218impl Deref for RemoteProverClient {
219    type Target = GeneratedProverClient;
220
221    fn deref(&self) -> &Self::Target {
222        &self.0
223    }
224}
225
226impl DerefMut for ValidatorClient {
227    fn deref_mut(&mut self) -> &mut Self::Target {
228        &mut self.0
229    }
230}
231
232impl Deref for ValidatorClient {
233    type Target = GeneratedValidatorClient;
234
235    fn deref(&self) -> &Self::Target {
236        &self.0
237    }
238}
239
240impl DerefMut for NtxBuilderClient {
241    fn deref_mut(&mut self) -> &mut Self::Target {
242        &mut self.0
243    }
244}
245
246impl Deref for NtxBuilderClient {
247    type Target = GeneratedNtxBuilderClient;
248
249    fn deref(&self) -> &Self::Target {
250        &self.0
251    }
252}
253
254impl DerefMut for SequencerClient {
255    fn deref_mut(&mut self) -> &mut Self::Target {
256        &mut self.0
257    }
258}
259
260impl Deref for SequencerClient {
261    type Target = GeneratedSequencerClient;
262
263    fn deref(&self) -> &Self::Target {
264        &self.0
265    }
266}
267
268// GRPC CLIENT BUILDER TRAIT
269// ================================================================================================
270
271/// Trait for building gRPC clients from a common [`Builder`] configuration.
272pub trait GrpcClient {
273    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self;
274}
275
276impl GrpcClient for RpcClient {
277    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
278        Self(GeneratedRpcClient::new(InterceptedService::new(channel, interceptor)))
279    }
280}
281
282impl GrpcClient for RemoteProverProxyStatusClient {
283    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
284        Self(GeneratedProxyStatusClient::new(InterceptedService::new(channel, interceptor)))
285    }
286}
287
288impl GrpcClient for RemoteProverClient {
289    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
290        Self(GeneratedProverClient::new(InterceptedService::new(channel, interceptor)))
291    }
292}
293
294impl GrpcClient for ValidatorClient {
295    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
296        Self(GeneratedValidatorClient::new(InterceptedService::new(channel, interceptor)))
297    }
298}
299
300impl GrpcClient for NtxBuilderClient {
301    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
302        Self(GeneratedNtxBuilderClient::new(InterceptedService::new(channel, interceptor)))
303    }
304}
305
306impl GrpcClient for SequencerClient {
307    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
308        Self(GeneratedSequencerClient::new(InterceptedService::new(channel, interceptor)))
309    }
310}
311
312// STRICT TYPE-SAFE BUILDER (NO DEFAULTS)
313// ================================================================================================
314
315/// A type-safe builder that forces the caller to make an explicit decision for each
316/// configuration item (TLS, timeout, metadata version, metadata genesis) before connecting.
317///
318/// This builder replaces the previous defaulted builder. Callers must explicitly choose TLS,
319/// timeout, and metadata options before connecting.
320///
321/// Usage example:
322///
323/// ```rust
324/// # use miden_node_proto::clients::{Builder, WantsTls, RpcClient};
325/// # use url::Url;
326/// # use std::time::Duration;
327///
328/// # async fn example() -> anyhow::Result<()> {
329/// let url = Url::parse("https://rpc.example.com:8080")?;
330/// let client: RpcClient = Builder::new(url)
331///     .with_tls()?                          // or `.without_tls()`
332///     .with_timeout(Duration::from_secs(5)) // or `.without_timeout()`
333///     .with_metadata_version("1.0".into())  // or `.without_metadata_version()`
334///     .without_metadata_genesis()           // or `.with_metadata_genesis(genesis)`
335///     .without_auth_header()                // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))`
336///     .with_otel_context_injection()        // or `.without_otel_context_injection()`
337///     .connect::<RpcClient>()
338///     .await?;
339/// # Ok(())
340/// # }
341/// ```
342#[derive(Clone, Debug)]
343pub struct Builder<State> {
344    endpoint: Endpoint,
345    metadata_version: Option<String>,
346    metadata_genesis: Option<String>,
347    metadata_auth_header_value: Option<AsciiMetadataValue>,
348    enable_otel: bool,
349    _state: PhantomData<State>,
350}
351
352#[derive(Copy, Clone, Debug)]
353pub struct WantsTls;
354#[derive(Copy, Clone, Debug)]
355pub struct WantsTimeout;
356#[derive(Copy, Clone, Debug)]
357pub struct WantsVersion;
358#[derive(Copy, Clone, Debug)]
359pub struct WantsGenesis;
360#[derive(Copy, Clone, Debug)]
361pub struct WantsOTel;
362#[derive(Copy, Clone, Debug)]
363pub struct WantsConnection;
364
365impl<State> Builder<State> {
366    /// Convenience function to cast the state type and carry internal configuration forward.
367    fn next_state<Next>(self) -> Builder<Next> {
368        Builder {
369            endpoint: self.endpoint,
370            metadata_version: self.metadata_version,
371            metadata_genesis: self.metadata_genesis,
372            metadata_auth_header_value: self.metadata_auth_header_value,
373            enable_otel: self.enable_otel,
374            _state: PhantomData::<Next>,
375        }
376    }
377}
378
379/// Client HTTP/2 keepalive interval.
380const HTTP2_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(20);
381/// Client HTTP/2 keepalive: how long to wait for a PING ack before considering the connection dead.
382const HTTP2_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
383/// OS-level TCP keepalive backstop for direct (non-proxied) connections.
384const TCP_KEEPALIVE: Duration = Duration::from_secs(30);
385
386impl Builder<WantsTls> {
387    /// Create a new strict builder from a gRPC endpoint URL such as `http://localhost:8080` or
388    /// `https://api.example.com:443`.
389    pub fn new(url: Url) -> Builder<WantsTls> {
390        let endpoint = Endpoint::from_shared(String::from(url))
391            .expect("Url type always results in valid endpoint")
392            // Detect silently dropped connections so long-lived streams can't hang forever; see the
393            // keepalive constants above.
394            .http2_keep_alive_interval(HTTP2_KEEPALIVE_INTERVAL)
395            .keep_alive_timeout(HTTP2_KEEPALIVE_TIMEOUT)
396            .keep_alive_while_idle(true)
397            .tcp_keepalive(Some(TCP_KEEPALIVE));
398
399        Builder {
400            endpoint,
401            metadata_version: None,
402            metadata_genesis: None,
403            metadata_auth_header_value: None,
404            enable_otel: false,
405            _state: PhantomData,
406        }
407    }
408
409    /// Explicitly disable TLS.
410    pub fn without_tls(self) -> Builder<WantsTimeout> {
411        self.next_state()
412    }
413
414    /// Explicitly enable TLS.
415    pub fn with_tls(mut self) -> Result<Builder<WantsTimeout>, TransportError> {
416        self.endpoint = self.endpoint.tls_config(ClientTlsConfig::new().with_native_roots())?;
417
418        Ok(self.next_state())
419    }
420}
421
422impl Builder<WantsTimeout> {
423    /// Explicitly disable request timeout.
424    pub fn without_timeout(self) -> Builder<WantsVersion> {
425        self.next_state()
426    }
427
428    /// Explicitly configure a request timeout.
429    pub fn with_timeout(mut self, duration: Duration) -> Builder<WantsVersion> {
430        self.endpoint = self.endpoint.timeout(duration);
431        self.next_state()
432    }
433}
434
435impl Builder<WantsVersion> {
436    /// Do not include version in request metadata.
437    pub fn without_metadata_version(mut self) -> Builder<WantsGenesis> {
438        self.metadata_version = None;
439        self.next_state()
440    }
441
442    /// Include a specific version string in request metadata.
443    pub fn with_metadata_version(mut self, version: String) -> Builder<WantsGenesis> {
444        self.metadata_version = Some(version);
445        self.next_state()
446    }
447}
448
449impl Builder<WantsGenesis> {
450    /// Do not include genesis commitment in request metadata.
451    pub fn without_metadata_genesis(mut self) -> Builder<WantsOTel> {
452        self.metadata_genesis = None;
453        self.next_state()
454    }
455
456    /// Include a specific genesis commitment string in request metadata.
457    pub fn with_metadata_genesis(mut self, genesis: String) -> Builder<WantsOTel> {
458        self.metadata_genesis = Some(genesis);
459        self.next_state()
460    }
461}
462
463impl Builder<WantsOTel> {
464    /// Do not include any additional metadata header in request metadata.
465    #[must_use]
466    pub fn without_auth_header(mut self) -> Self {
467        self.metadata_auth_header_value = None;
468        self
469    }
470
471    /// Include an additional ASCII metadata header in request metadata.
472    #[must_use]
473    pub fn with_auth_header_value(mut self, value: AsciiMetadataValue) -> Self {
474        self.metadata_auth_header_value = Some(value);
475        self
476    }
477
478    /// Enables OpenTelemetry context propagation via gRPC.
479    ///
480    /// This is used to by OpenTelemetry to connect traces across network boundaries. The server on
481    /// the other end must be configured to receive and use the injected trace context.
482    pub fn with_otel_context_injection(mut self) -> Builder<WantsConnection> {
483        self.enable_otel = true;
484        self.next_state()
485    }
486
487    /// Disables OpenTelemetry context propagation. This should be disabled when interfacing with
488    /// external third party gRPC servers.
489    pub fn without_otel_context_injection(mut self) -> Builder<WantsConnection> {
490        self.enable_otel = false;
491        self.next_state()
492    }
493}
494
495impl Builder<WantsConnection> {
496    /// Establish an eager connection and return a fully configured client.
497    pub async fn connect<T>(self) -> Result<T, TransportError>
498    where
499        T: GrpcClient,
500    {
501        let channel = self.endpoint.connect().await?;
502        Ok(self.connect_with_channel::<T>(channel))
503    }
504
505    /// Establish a lazy connection and return a client that will connect on first use.
506    pub fn connect_lazy<T>(self) -> T
507    where
508        T: GrpcClient,
509    {
510        let channel = self.endpoint.connect_lazy();
511        self.connect_with_channel::<T>(channel)
512    }
513
514    fn connect_with_channel<T>(self, channel: Channel) -> T
515    where
516        T: GrpcClient,
517    {
518        let interceptor = Interceptor::new(
519            self.enable_otel,
520            self.metadata_version.as_deref(),
521            self.metadata_genesis.as_deref(),
522            self.metadata_auth_header_value,
523        );
524        T::with_interceptor(channel, interceptor)
525    }
526}
527
528impl ValidatorClient {
529    /// Submits each transaction in the batch to the validator for re-execution.
530    ///
531    /// # Errors
532    ///
533    /// - If `transaction_inputs` does not match the batch's transactions in length
534    pub async fn submit_batch(
535        &mut self,
536        proposed_batch: &ProposedBatch,
537        transaction_inputs: &[Vec<u8>],
538    ) -> Result<(), Status> {
539        if proposed_batch.transactions().len() != transaction_inputs.len() {
540            return Err(Status::invalid_argument(
541                "transaction inputs do not match the batch's transactions",
542            ));
543        }
544        for (tx, inputs) in proposed_batch.transactions().iter().zip(transaction_inputs) {
545            let proven_tx = generated::transaction::ProvenTransaction {
546                transaction: tx.to_bytes(),
547                transaction_inputs: Some(inputs.clone()),
548            };
549            self.submit_proven_transaction(proven_tx).await?;
550        }
551        Ok(())
552    }
553}