delta_base_sdk/
rpc.rs

1//! # RPC Communication with the delta Base Layer
2//!
3//! This module provides a client for communicating with delta base layer through
4//! RPC (Remote Procedure Call). It enables applications to query network state, submit
5//! transactions, retrieve vault data, and subscribe to network events.
6//!
7//! ## Overview
8//!
9//! The primary interface is the [`BaseRpcClient`] which handles connection
10//! management, request serialization, and response parsing. The client provides
11//! methods for:
12//!
13//! - [Submitting transactions to the network](BaseRpcClient::submit_transaction);
14//! - Retrieving vault data ([`get_vault`](BaseRpcClient::get_vault),
15//!   [`get_base_vault`](BaseRpcClient::get_base_vault),
16//!   [`get_vaults`](BaseRpcClient::get_vaults));
17//! - [Checking transaction status](BaseRpcClient::get_transaction_status); or
18//! - [Subscribing to network events](BaseRpcClient::stream_base_layer_events).
19//!
20//! All RPC operations use the [`tonic`] library for gRPC communication, with automatic
21//! serialization and deserialization between protocol buffer and native Rust types.
22//!
23//! ## Usage
24//!
25//! To use the RPC client, you need a running delta base layer at some known URL.
26//!
27//! ## Example
28//!
29//! ```rust,no_run
30//! use delta_base_sdk::{
31//!     core::Shard,
32//!     crypto::{ed25519, Hash256, IntoSignatureEnum},
33//!     rpc::{BaseRpcClient, RpcError},
34//!     vaults::{Address, ReadableNativeBalance, Vault},
35//! };
36//! # use std::error::Error;
37//! # use std::str::FromStr;
38//! # use tokio_stream::StreamExt;
39//!
40//! async fn rpc_example() -> Result<(), Box<dyn Error>> {
41//!     // Connect to base layer
42//!     let client = BaseRpcClient::new("http://localhost:50051").await?;
43//!
44//!     // Get a vault
45//!     let pub_key = ed25519::PrivKey::generate().pub_key();
46//!     let vault = client.get_vault(Address::new(pub_key.owner(), 1)).await?;
47//!     println!("Vault balance: {}", vault.balance());
48//!
49//!     // Check a transaction status
50//!     // In a real app, this would be a valid signature
51//!     let signature = ed25519::Signature::from_str("<MY SIGNATURE>")?
52//!         .into_signature_enum(pub_key);
53//!     let status = client.get_transaction_status(signature.hash_sha256()).await?;
54//!     println!("Transaction status: {:?}", status);
55//!
56//!     // Query current epoch
57//!     let epoch = client.get_epoch().await?;
58//!     println!("Current epoch: {}", epoch);
59//!
60//!     // Subscribe to network events (requires tokio to run)
61//!     let mut events = client.stream_base_layer_events(0).await?;
62//!
63//!     // Process the first 5 events
64//!     let mut count = 0;
65//!     while let Some(event) = events.next().await {
66//!         if let Ok(event) = event {
67//!             println!("Received event: {:?}", event);
68//!             count += 1;
69//!             if count >= 5 {
70//!                 break;
71//!             }
72//!         }
73//!     }
74//!     Ok(())
75//! }
76//! ```
77
78use crate::{
79    core::Shard,
80    crypto::HashDigest,
81    events::BaseLayerEvent,
82    sdl::StateDiffList,
83    transactions::{
84        types::TransactionType,
85        SignedTransaction,
86        TransactionStatus,
87        TypeData,
88    },
89};
90use http::{
91    uri::{
92        self,
93        InvalidUri,
94        Scheme,
95    },
96    Uri,
97};
98use primitives::{
99    domain_agreement::DomainAgreement,
100    type_aliases::{
101        Epoch,
102        TxId,
103    },
104    vault::{
105        base::Vault as BaseVault,
106        sharded::Vault as ShardedVault,
107        Address,
108        OwnerId,
109    },
110};
111use proto_types::{
112    error::HashSnafu,
113    sdl::SdlStatus,
114    services::{
115        GetBaseVaultRequest,
116        GetDomainAgreementRequest,
117        GetDomainOwnerIdsRequest,
118        GetEpochRequest,
119        GetEpochResponse,
120        GetSdlDataRequest,
121        GetShardedVaultsRequest,
122        GetTransactionStatusRequest,
123        StreamBaseLayerEventRequest,
124        ValidatorServiceClient,
125    },
126};
127use snafu::{
128    ResultExt,
129    Snafu,
130};
131use std::collections::HashMap;
132use tokio_stream::{
133    Stream,
134    StreamExt,
135};
136use tonic::{
137    transport::Channel,
138    Request,
139};
140
141/// Maximal size of gRPC messages that the client can decode, and thus receive (10 MiB).
142const MAX_DECODING_MESSAGE_SIZE: usize = 10 * 1024 * 1024;
143
144/// Errors that can occur when using the [`BaseRpcClient`]
145#[derive(Debug, Snafu)]
146pub enum RpcError {
147    /// Failed to establish a connection to base layer
148    ///
149    /// This error occurs when the client cannot connect to the specified
150    /// base layer URL, typically due to network issues or because
151    /// the node is not running.
152    #[snafu(display("Could not connect: {source}"))]
153    Connection {
154        /// The underlying transport error
155        source: tonic::transport::Error,
156    },
157
158    /// The RPC request was rejected by base layer
159    ///
160    /// This error occurs when the request is received by the base layer
161    /// but cannot be processed, such as when requesting a non-existent
162    /// vault or submitting an invalid transaction.
163    #[snafu(display("Request failed with status: {status}"))]
164    Request {
165        /// The status returned by the base layer
166        #[snafu(source(from(tonic::Status, Box::new)))]
167        status: Box<tonic::Status>,
168    },
169
170    /// Failed to convert between protocol buffer and native format
171    ///
172    /// This error occurs when there's a problem converting between the
173    /// wire format (protocol buffers) and the native Rust types, typically
174    /// due to missing or malformed data.
175    #[snafu(display("Could not convert type: {source}"))]
176    IntoProto {
177        /// The underlying conversion error
178        source: proto_types::error::ProtoError,
179    },
180
181    /// Failed to parse base layer URL
182    ///
183    /// This error occurs when the URL provided to connect to the base layer is
184    /// malformed or cannot be parsed.
185    #[snafu(display("Could not parse URL: {source}"))]
186    InvalidUrl {
187        /// The underlying HTTP error
188        source: http::Error,
189    },
190}
191
192impl RpcError {
193    /// Returns true if the underlying error is a gRPC not found status.
194    pub fn is_not_found(&self) -> bool {
195        matches!(self, Self::Request { status } if status.code() == tonic::Code::NotFound)
196    }
197
198    /// Returns true if the underlying error is a gRPC failed precondition status.
199    pub fn is_failed_precondition(&self) -> bool {
200        matches!(self, Self::Request { status } if status.code() == tonic::Code::FailedPrecondition)
201    }
202}
203
204/// Client for communicating with the delta base layer through RPC
205///
206/// [BaseRpcClient] provides a high-level interface for interacting with
207/// delta base layers. It handles connection management, request formatting,
208/// and response parsing for all base layer operations.
209///
210/// # Example
211///
212/// ```rust,no_run
213/// use delta_base_sdk::rpc::BaseRpcClient;
214///
215/// async fn connect() -> Result<(), Box<dyn std::error::Error>> {
216///     // Connect to local base layer provider
217///     let client = BaseRpcClient::new("http://localhost:50051").await?;
218///
219///     // Connect to a remote node with HTTPS
220///     let secure_client = BaseRpcClient::new("https://baselayer.example.com").await?;
221///
222///     // Connect with just the host (HTTPS will be added automatically)
223///     let auto_scheme_client = BaseRpcClient::new("baselayer.example.com").await?;
224///
225///     Ok(())
226/// }
227/// ```
228#[derive(Debug, Clone)]
229pub struct BaseRpcClient {
230    // The inner client is cloned in all calls but this is cheap and avoids mutable self refs:
231    // https://docs.rs/tonic/latest/tonic/transport/struct.Channel.html#multiplexing-requests
232    inner: ValidatorServiceClient<Channel>,
233}
234
235impl BaseRpcClient {
236    /// Creates a new client by connecting to the specified base layer provider URL
237    ///
238    /// This method establishes a connection to delta base layer at the specified
239    /// URL. If the URL does not include a scheme (http:// or https://), HTTPS will be
240    /// used automatically.
241    ///
242    /// # Parameters
243    ///
244    /// * `url` - URL of base layer provider to connect to, either as a string or a URI
245    ///
246    /// # Errors
247    ///
248    /// Returns [RpcError] if:
249    /// - The URL cannot be parsed; or
250    /// - The connection cannot be established
251    pub async fn new(url: impl TryInto<Uri, Error = InvalidUri> + Send) -> Result<Self, RpcError> {
252        let uri = add_missing_scheme(url)?;
253        let inner = ValidatorServiceClient::connect(uri.to_string())
254            .await
255            .context(ConnectionSnafu)?
256            .max_decoding_message_size(MAX_DECODING_MESSAGE_SIZE);
257        Ok(Self { inner })
258    }
259
260    /// Submits a signed transaction to the base layer network
261    ///
262    /// This method sends a transaction to base layer for processing.
263    /// The transaction must be properly signed with a valid signature from
264    /// the transaction signer's private key.
265    ///
266    /// # Parameters
267    ///
268    /// * `tx` - Signed transaction to submit
269    ///
270    /// # Errors
271    ///
272    /// Returns [RpcError] if:
273    /// - The connection to the base layer provider fails;
274    /// - The base layer provider rejects the transaction; or
275    /// - There's a protocol conversion error.
276    pub async fn submit_transaction<T>(&self, tx: SignedTransaction<T>) -> Result<(), RpcError>
277    where
278        T: TransactionType + Into<TypeData> + Send,
279    {
280        self.inner
281            .clone()
282            .submit_transaction(Request::new(tx.into()))
283            .await
284            .context(RequestSnafu)?;
285        Ok(())
286    }
287
288    /// Gets the base vault for an owner in the base layer shard
289    ///
290    /// This is a convenience method that calls [`get_vault`](BaseRpcClient::get_vault)
291    /// with the base layer shard (0).
292    ///
293    /// # Parameters
294    ///
295    /// * `owner` - The ID of the vault owner
296    ///
297    /// # Errors
298    ///
299    /// Returns [RpcError] if:
300    /// - The vault doesn't exist;
301    /// - The connection to the base layer provider fails; or
302    /// - There's a protocol conversion error.
303    pub async fn get_base_vault(&self, owner: OwnerId) -> Result<BaseVault, RpcError> {
304        let proto_vault = self
305            .inner
306            .clone()
307            .get_base_vault(GetBaseVaultRequest {
308                owner: owner.into(),
309            })
310            .await
311            .context(RequestSnafu)?
312            .into_inner();
313
314        proto_vault.try_into().context(IntoProtoSnafu)
315    }
316
317    /// Retrieves a domain vault data for a specific address
318    ///
319    /// # Parameters
320    ///
321    /// * `address` - of the vault to fetch
322    ///
323    /// # Errors
324    ///
325    /// Returns [RpcError] if:
326    /// - The vault under the given address doesn't exist;
327    /// - The connection to the base layer provider fails; or
328    /// - There's a protocol conversion error.
329    ///
330    /// # Example
331    ///
332    /// ```rust,no_run
333    /// use delta_base_sdk::{
334    ///     crypto::ed25519,
335    ///     rpc::BaseRpcClient,
336    ///     vaults::{Address, ReadableNativeBalance},
337    /// };
338    ///
339    /// async fn get_vault_example(client: &BaseRpcClient) -> Result<(), Box<dyn std::error::Error>> {
340    ///     let address = Address::new(ed25519::PubKey::generate().owner(), 1);
341    ///     let vault = client.get_vault(address).await?;
342    ///     println!("Vault data: {:?}", vault);
343    ///     println!("Vault balance: {}", vault.balance());
344    ///     Ok(())
345    /// }
346    /// ```
347    pub async fn get_vault(&self, address: Address) -> Result<ShardedVault, RpcError> {
348        let mut vaults = self.get_vaults([address]).await?;
349        vaults.remove(&address).ok_or_else(|| RpcError::Request {
350            status: Box::new(tonic::Status::not_found("Vault not found")),
351        })
352    }
353
354    /// Retrieves multiple sharded vaults by address
355    ///
356    /// This method fetches multiple vaults in a single request, up to a maximum of 100.
357    ///
358    /// # Parameters
359    ///
360    /// * `addresses` - An iterator of addresses to retrieve
361    ///
362    /// # Errors
363    ///
364    /// Returns [RpcError] if:
365    /// - Any of the vaults don't exist;
366    /// - The connection to the base layer provider fails; or
367    /// - There's a protocol conversion error.
368    pub async fn get_vaults(
369        &self,
370        addresses: impl IntoIterator<Item = Address>,
371    ) -> Result<HashMap<Address, ShardedVault>, RpcError> {
372        // Collect the iterator first since we'll need to consume it multiple times
373        let addresses: Vec<Address> = addresses.into_iter().collect();
374        if addresses.is_empty() {
375            return Ok(HashMap::new())
376        }
377
378        let addresses_proto: Vec<_> = addresses.iter().map(|address| (*address).into()).collect();
379
380        // Fetch vaults from server
381        let vaults = self
382            .inner
383            .clone()
384            .get_sharded_vaults(GetShardedVaultsRequest {
385                addresses: addresses_proto,
386            })
387            .await
388            .context(RequestSnafu)?
389            .into_inner()
390            .vaults;
391
392        // Reconstruct addresses matching server response order
393        addresses
394            .into_iter()
395            .zip(vaults)
396            .map(|(address, vault_proto)| {
397                vault_proto
398                    .try_into()
399                    .context(IntoProtoSnafu)
400                    .map(|vault| (address, vault))
401            })
402            .collect()
403    }
404
405    /// Returns all the [OwnerId] that have a vault registered for the given shard on the base layer
406    pub async fn get_domain_owner_ids(&self, shard: Shard) -> Result<Vec<OwnerId>, RpcError> {
407        let response = self
408            .inner
409            .clone()
410            .get_domain_owner_ids(GetDomainOwnerIdsRequest { shard })
411            .await
412            .context(RequestSnafu)?
413            .into_inner();
414        response
415            .owner_ids
416            .into_iter()
417            .map(TryInto::try_into)
418            .collect::<Result<Vec<OwnerId>, _>>()
419            .context(HashSnafu)
420            .context(IntoProtoSnafu)
421    }
422
423    /// Gets the Domain Agreement for the provided shard.
424    ///
425    /// # Parameters
426    /// * `shard` - the shard to query the agreement for
427    ///
428    /// # Errors
429    ///
430    /// Returns [RpcError] if:
431    /// - There's no active agreement;
432    /// - The connection to the base layer RPC fails; or
433    /// - There's a protocol conversion error.
434    pub async fn get_domain_agreement(&self, shard: Shard) -> Result<DomainAgreement, RpcError> {
435        self.inner
436            .clone()
437            .get_domain_agreement(GetDomainAgreementRequest { shard })
438            .await
439            .context(RequestSnafu)?
440            .into_inner()
441            .try_into()
442            .context(IntoProtoSnafu)
443    }
444
445    /// Retrieves the current status of a transaction by its signature
446    ///
447    /// This method allows tracking the progress of a transaction through the
448    /// delta network. After submitting a transaction, you can use this method
449    /// to check if it has been processed, confirmed, or rejected.
450    ///
451    /// # Parameters
452    ///
453    /// * `tx_id` - [TxId] of the transaction to query
454    ///
455    /// # Errors
456    ///
457    /// Returns [RpcError] if:
458    /// - The connection to the base layer provider fails; or
459    /// - The signature is invalid or unknown to the network.
460    ///
461    /// # Note
462    ///
463    /// Transaction signatures are unique identifiers generated when signing a
464    /// transaction. Retain the signature after submitting a transaction if you
465    /// want to check its status later.
466    pub async fn get_transaction_status(&self, tx_id: TxId) -> Result<TransactionStatus, RpcError> {
467        let res = self
468            .inner
469            .clone()
470            .get_transaction_status(GetTransactionStatusRequest {
471                tx_id: tx_id.into(),
472            })
473            .await
474            .context(RequestSnafu)?;
475        res.into_inner().try_into().context(IntoProtoSnafu)
476    }
477
478    /// Retrieves the current epoch in delta
479    ///
480    /// This method returns the current epoch number. Epochs are protocol-defined
481    /// periods in the delta protocol during which the base layer provider set
482    /// remains constant.
483    ///
484    /// # Errors
485    ///
486    /// Returns [RpcError] if:
487    /// - The connection to the base layer provider fails
488    /// - The base layer provider cannot provide epoch information
489    ///
490    /// # Example
491    ///
492    /// ```rust,no_run
493    /// use delta_base_sdk::rpc::BaseRpcClient;
494    ///
495    /// async fn get_network_info() -> Result<(), Box<dyn std::error::Error>> {
496    ///     let client = BaseRpcClient::new("http://localhost:50051").await?;
497    ///
498    ///     // Get information about the current epoch
499    ///     let epoch = client.get_epoch().await?;
500    ///
501    ///     println!("Current epoch: {}", epoch);
502    ///
503    ///     Ok(())
504    /// }
505    /// ```
506    pub async fn get_epoch(&self) -> Result<Epoch, RpcError> {
507        let res: GetEpochResponse = self
508            .inner
509            .clone()
510            .get_epoch(GetEpochRequest {})
511            .await
512            .context(RequestSnafu)?
513            .into_inner();
514        Ok(res.epoch)
515    }
516
517    /// Retrieves a [State Diff List (SDL)](StateDiffList) by its hash identifier
518    ///
519    /// A State Diff List (SDL) is a batch of state changes in the delta protocol.
520    /// This method allows retrieving the complete contents of an SDL by its hash,
521    /// enabling applications to inspect state changes or verify transaction results.
522    ///
523    /// # Parameters
524    ///
525    /// * `hash` - [Cryptographic hash](HashDigest) that uniquely identifies the SDL
526    ///
527    /// # Errors
528    ///
529    /// Returns [RpcError] if:
530    /// - The SDL with the specified hash doesn't exist;
531    /// - The connection to the base layer provider fails; or
532    /// - There's a protocol conversion error.
533    pub async fn get_sdl(&self, hash: HashDigest) -> Result<StateDiffList, RpcError> {
534        let res = self
535            .inner
536            .clone()
537            .get_sdl_data(GetSdlDataRequest {
538                sdl_id: hash.into(),
539            })
540            .await
541            .context(RequestSnafu)?;
542        res.into_inner().try_into().context(IntoProtoSnafu)
543    }
544
545    /// Retrieves the [status](SdlStatus) of a [State Diff List (SDL)](StateDiffList).
546    ///
547    /// # Parameters
548    ///
549    /// * `hash` - [Cryptographic hash](HashDigest) that uniquely identifies the SDL
550    ///
551    /// # Errors
552    ///
553    /// Returns [RpcError] if:
554    /// - The SDL with the specified hash doesn't exist;
555    /// - The connection to the base layer provider fails; or
556    /// - There's a protocol conversion error.
557    pub async fn get_sdl_status(&self, hash: HashDigest) -> Result<SdlStatus, RpcError> {
558        let res = self
559            .inner
560            .clone()
561            .get_sdl_status(GetSdlDataRequest {
562                sdl_id: hash.into(),
563            })
564            .await
565            .context(RequestSnafu)?;
566
567        res.into_inner().try_into().context(IntoProtoSnafu)
568    }
569
570    /// Subscribes to a stream of real-time events from the delta network
571    ///
572    /// This method establishes a streaming connection to receive events from the
573    /// base layer as they occur. Events include epoch transitions, vault migrations,
574    /// and other network state changes. The events are filtered by shard.
575    ///
576    /// # Parameters
577    ///
578    /// * `shard` - [Shard] number to receive events for
579    ///
580    /// # Errors
581    ///
582    /// Returns [RpcError] if:
583    /// - The connection to the base layer provider fails;
584    /// - The streaming request is rejected; or
585    /// - Individual stream items may contain errors if there are issues during streaming.
586    ///
587    /// # Example
588    ///
589    /// ```rust,no_run
590    /// use delta_base_sdk::{
591    ///     events::BaseLayerEvent,
592    ///     rpc::BaseRpcClient,
593    /// };
594    /// use tokio_stream::StreamExt;
595    ///
596    /// async fn monitor_events() -> Result<(), Box<dyn std::error::Error>> {
597    ///     let client = BaseRpcClient::new("http://localhost:50051").await?;
598    ///
599    ///     // Subscribe to base layer events for shard 0 (the base layer shard)
600    ///     let mut event_stream = client.stream_base_layer_events(0).await?;
601    ///
602    ///     println!("Listening for events...");
603    ///
604    ///     // Process events as they arrive
605    ///     while let Some(event_result) = event_stream.next().await {
606    ///         match event_result {
607    ///             Ok(event) => {
608    ///                 match event {
609    ///                     BaseLayerEvent::NewEpoch(epoch) => {
610    ///                         println!("New epoch started: {}", epoch);
611    ///                     },
612    ///                     BaseLayerEvent::SdlUpdate(update) => {
613    ///                         println!("SDL update received: {:?}", update.id);
614    ///                     },
615    ///                     BaseLayerEvent::VaultEmigrated(address) => {
616    ///                         println!("Vault emigrated: {address:?}");
617    ///                     },
618    ///                     BaseLayerEvent::VaultImmigrated(address) => {
619    ///                         println!("Vault immigrated: {address:?}");
620    ///                     },
621    ///                 }
622    ///             },
623    ///             Err(e) => {
624    ///                 eprintln!("Error in event stream: {e}");
625    ///                 // Decide whether to break or continue based on the error
626    ///             }
627    ///         }
628    ///     }
629    ///
630    ///     Ok(())
631    /// }
632    /// ```
633    ///
634    /// # Note
635    ///
636    /// The returned stream implements the `Stream` trait from the `tokio_stream` crate.
637    /// You need to bring the `StreamExt` trait into scope to use methods like `next()`.
638    ///
639    /// This is a long-lived connection that will continue to deliver events until
640    /// the stream is dropped or the connection is closed. It's suitable for building
641    /// reactive applications that need to respond to network events in real-time.
642    pub async fn stream_base_layer_events(
643        &self,
644        shard: Shard,
645    ) -> Result<impl Stream<Item = Result<BaseLayerEvent, RpcError>> + Send, RpcError> {
646        let res = self
647            .inner
648            .clone()
649            .stream_base_layer_events(StreamBaseLayerEventRequest { shard })
650            .await
651            .context(RequestSnafu)?;
652
653        let stream = res.into_inner().map(|r| {
654            r.context(RequestSnafu)
655                .and_then(|u| u.try_into().context(IntoProtoSnafu))
656        });
657
658        Ok(stream)
659    }
660}
661
662/// The default URI scheme to use when none is provided
663const DEFAULT_SCHEME: Scheme = Scheme::HTTPS;
664
665/// Adds the HTTPS scheme to a URL if it doesn't already have a scheme
666///
667/// This utility function ensures that all URLs have a scheme, adding HTTPS
668/// as the default if none is specified. This allows users to provide just
669/// the host and port (e.g., "localhost:50051") without having to specify
670/// the scheme.
671///
672/// # Parameters
673///
674/// * `url` - URL to process, which may or may not have a scheme
675///
676/// # Errors
677///
678/// Returns [RpcError] if:
679/// - The URL cannot be parsed as a valid URI
680/// - The modified URI cannot be built
681#[allow(clippy::result_large_err)]
682fn add_missing_scheme(url: impl TryInto<Uri, Error = InvalidUri>) -> Result<Uri, RpcError> {
683    let uri: Uri = url
684        .try_into()
685        .map_err(|e| e.into())
686        .context(InvalidUrlSnafu)?;
687
688    if uri.scheme().is_some() {
689        Ok(uri)
690    } else {
691        let path = uri
692            .path_and_query()
693            .map(|p| p.as_str())
694            .unwrap_or("/")
695            .to_string();
696        Into::<uri::Builder>::into(uri)
697            .scheme(DEFAULT_SCHEME)
698            .path_and_query(path)
699            .build()
700            .context(InvalidUrlSnafu)
701    }
702}
703
704#[cfg(test)]
705mod test {
706    use super::*;
707
708    #[test]
709    fn test_add_missing_scheme() {
710        // leaves existing scheme
711        assert_eq!(
712            "http://localhost:5005/",
713            add_missing_scheme("http://localhost:5005")
714                .unwrap()
715                .to_string()
716        );
717        assert_eq!(
718            "http://localhost:5005/path/yes",
719            add_missing_scheme("http://localhost:5005/path/yes")
720                .unwrap()
721                .to_string()
722        );
723        assert_eq!(
724            "yolo://net.delta.net/",
725            add_missing_scheme("yolo://net.delta.net")
726                .unwrap()
727                .to_string()
728        );
729
730        // adds scheme if missing
731        assert_eq!(
732            "https://net.delta.net/",
733            add_missing_scheme("net.delta.net").unwrap().to_string()
734        );
735        assert_eq!(
736            "https://localhost:12345/",
737            add_missing_scheme("localhost:12345").unwrap().to_string()
738        );
739    }
740}