Skip to main content

server_rpc/
client.rs

1//! Client-side Ark server connector.
2//!
3//! This module provides a managed, version-aware gRPC connection between a
4//! Bark client and a paired Ark server. Its responsibilities are:
5//! - Negotiating and enforcing a compatible wire protocol version via a
6//!   handshake.
7//! - Establishing a gRPC channel (optionally with TLS) with sensible timeouts
8//!   and keepalives.
9//! - Injecting the negotiated protocol version into every RPC call so the
10//!   server can route/validate requests correctly.
11//! - Fetching and exposing the server's runtime configuration ([ArkInfo]) so
12//!   the client can adapt its behavior (e.g., network, round cadence, limits).
13//!
14//! Overview
15//! - Version negotiation: The client first calls the server's handshake RPC,
16//!   which returns the supported protocol version range. The client checks its
17//!   own supported range ([MIN_PROTOCOL_VERSION]..=[MAX_PROTOCOL_VERSION]) and
18//!   picks the highest mutually supported version.
19//! - Metadata propagation: After negotiation, all subsequent RPCs carry the
20//!   selected protocol version in the request metadata using a gRPC
21//!   interceptor.
22//! - TLS: If the server URI is HTTPS, a TLS configuration with the configured
23//!   crate roots is set up; otherwise the connection proceeds in cleartext.
24//! - Server info: Once connected, the client retrieves [ArkInfo] to validate
25//!   that the selected Bitcoin [Network] matches the wallet and to learn
26//!   server-side parameters that drive client behavior.
27//!
28
29use std::cmp;
30use std::convert::TryFrom;
31use std::ops::Deref;
32use std::sync::Arc;
33use std::time::Duration;
34
35use bitcoin::{FeeRate, Network};
36use log::warn;
37use tokio::sync::RwLock;
38use tonic::codec::CompressionEncoding;
39use tonic::metadata::AsciiMetadataValue;
40use tonic::metadata::errors::InvalidMetadataValue;
41use tonic::service::interceptor::{InterceptedService, Interceptor};
42
43use ark::ArkInfo;
44
45use crate::{
46	mailbox, protos, ArkServiceClient, ConvertError, RequestExt,
47	MAX_PROTOCOL_VERSION, MIN_PROTOCOL_VERSION,
48};
49
50
51#[cfg(all(feature = "tonic-native", feature = "tonic-web"))]
52compile_error!("features `tonic-native` and `tonic-web` are mutually exclusive");
53
54#[cfg(all(feature = "socks5-proxy", not(feature = "tonic-native")))]
55compile_error!("the `socks5-proxy` feature is only usable in conjunction with `tonic-native`");
56
57
58/// The HTTP header used for private server access tokens
59#[deprecated(
60	since = "0.2.4",
61	note = "access tokens are not enforced by the server; this header will be removed",
62)]
63pub const ACCESS_TOKEN_HEADER: &str = "ark-access-token";
64/// The HTTP header used to identify the client implementation.
65///
66/// We use `x-user-agent` rather than `user-agent` because browsers control the
67/// latter for `fetch`-based transports (gRPC-web from WASM), and `x-user-agent`
68/// is the established gRPC-web convention for client-set identifiers.
69///
70/// Expected value: `<name>/<version>` where `name` is 1-32 chars of lowercase
71/// ASCII alphanumeric / `-` / `_`. Anything else (uppercase, missing slash,
72/// invalid chars, too long) is rejected server-side with `invalid_argument`.
73pub const USER_AGENT_HEADER: &str = "x-user-agent";
74/// Error text used when no Ark RPC transport backend was compiled into the binary.
75pub const NO_TRANSPORT_BACKEND_MESSAGE: &str =
76	"no Ark RPC transport backend compiled in this build; enable `bark-server-rpc/tonic-native` or `bark-server-rpc/tonic-web`";
77
78/// Default timeout to add on requests to the server
79pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10 * 60);
80
81
82#[cfg(feature = "tonic-native")]
83mod transport {
84	use std::str::FromStr;
85	use std::time::Duration;
86
87	use http::Uri;
88	use log::info;
89	use tonic::transport::{Channel, Endpoint};
90
91	use super::CreateEndpointError;
92
93	pub type Transport = Channel;
94
95	/// Build a tonic endpoint from a server address, configuring timeouts and TLS if required.
96	///
97	/// - Supports `http` and `https` URIs. Any other scheme results in an error.
98	/// - Uses a 10-minute keep-alive and overall request timeout to accommodate long-running RPCs.
99	/// - When `https` is used, the crate-configured root CAs are enabled and the SNI domain is set.
100	pub async fn connect(address: &str) -> Result<Transport, CreateEndpointError> {
101		Ok(create_endpoint(address)?.connect().await?)
102	}
103
104	/// Similar to [connect] but the HTTP/HTTPS connection is wrapped with a SOCKS5 proxy.
105	#[cfg(feature = "socks5-proxy")]
106	pub async fn connect_with_proxy(
107		address: &str,
108		proxy: &str,
109	) -> Result<Transport, CreateEndpointError> {
110		use hyper_socks2::SocksConnector;
111		use hyper_util::client::legacy::connect::HttpConnector;
112
113		let endpoint = create_endpoint(address)?;
114		let proxy_uri = proxy.parse::<Uri>().map_err(CreateEndpointError::InvalidProxyUri)?;
115		let connector = {
116			// TLS is handled by tonic's `tls_config()` on the endpoint, so this connector only
117			// needs to establish the SOCKS5 tunnel.
118			let mut http = HttpConnector::new();
119			http.enforce_http(false);
120			SocksConnector {
121				proxy_addr: proxy_uri,
122				auth: None,
123				connector: http,
124			}
125		};
126		info!("Connecting to Ark server via SOCKS5 proxy {}...", proxy);
127		Ok(endpoint.connect_with_connector(connector).await?)
128	}
129
130	/// Creates an endpoint for the given server address which the application can use to create a
131	/// connection. Any required TLS configuration will be added so both HTTP and HTTPS are
132	/// supported.
133	fn create_endpoint(address: &str) -> Result<Endpoint, CreateEndpointError> {
134		let uri = Uri::from_str(address)?;
135
136		let scheme = uri.scheme_str().unwrap_or("");
137		if scheme != "http" && scheme != "https" {
138			return Err(CreateEndpointError::InvalidScheme(scheme.to_string()));
139		}
140
141		#[cfg_attr(not(any(feature = "tls-native-roots", feature = "tls-webpki-roots")), allow(unused_mut))]
142		let mut endpoint = Channel::builder(uri.clone())
143			// nb how often we check if server is still there
144			.http2_keep_alive_interval(Duration::from_secs(20))
145			// nb time we allow server to respond to ping before we consider dead
146			.keep_alive_timeout(Duration::from_secs(60)) // 1 min
147			.keep_alive_while_idle(true);
148
149		#[cfg(any(feature = "tls-native-roots", feature = "tls-webpki-roots"))]
150		if scheme == "https" {
151			use tonic::transport::ClientTlsConfig;
152
153			info!("Connecting to Ark server at {} using TLS...", address);
154			let uri_auth = uri.clone().into_parts().authority
155				.ok_or(CreateEndpointError::MissingAuthority)?;
156			let domain = uri_auth.host();
157
158			let tls_config = ClientTlsConfig::new()
159					.with_enabled_roots()
160					.domain_name(domain);
161			endpoint = endpoint.tls_config(tls_config).map_err(CreateEndpointError::Transport)?;
162			return Ok(endpoint);
163		}
164		#[cfg(not(any(feature = "tls-native-roots", feature = "tls-webpki-roots")))]
165		if scheme == "https" {
166			return Err(CreateEndpointError::InvalidScheme(
167				"Missing TLS roots, https is unsupported".to_owned(),
168			));
169		}
170		info!("Connecting to Ark server at {} without TLS...", address);
171		Ok(endpoint)
172	}
173}
174
175#[cfg(feature = "tonic-web")]
176mod transport {
177	use super::CreateEndpointError;
178	use tonic_web_wasm_client::Client as WasmClient;
179
180	pub type Transport = WasmClient;
181
182	pub async fn connect(address: &str) -> Result<Transport, CreateEndpointError> {
183		Ok(tonic_web_wasm_client::Client::new(address.to_string()))
184	}
185}
186
187/// Dummy transport used so the generated tonic clients still have a concrete transport type in
188/// transportless builds. `connect()` rejects these builds before any RPC is attempted, but
189/// if a client somehow does call into this transport we still return a clean gRPC error.
190#[cfg(not(any(feature = "tonic-native", feature = "tonic-web")))]
191mod transport {
192	use std::convert::Infallible;
193	use std::future::{ready, Ready};
194	use std::task::{Context, Poll};
195
196	use http::{Request, Response};
197	use tonic::Status;
198	use tonic::body::Body;
199	use tonic::codegen::Service;
200
201	use super::NO_TRANSPORT_BACKEND_MESSAGE;
202
203	pub async fn connect(_address: &str) -> Result<Transport, crate::client::CreateEndpointError> {
204		Err(crate::client::CreateEndpointError::NoTransportBackend)
205	}
206
207	#[derive(Debug, Clone, Default)]
208	pub struct Transport;
209
210	impl Service<Request<Body>> for Transport {
211		type Response = Response<Body>;
212		type Error = Infallible;
213		type Future = Ready<Result<Self::Response, Self::Error>>;
214
215		fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
216			Poll::Ready(Ok(()))
217		}
218
219		fn call(&mut self, _req: Request<Body>) -> Self::Future {
220			let status = Status::failed_precondition(NO_TRANSPORT_BACKEND_MESSAGE);
221			ready(Ok(status.into_http::<Body>()))
222		}
223	}
224}
225
226
227#[derive(Debug, thiserror::Error)]
228#[error("failed to create gRPC endpoint: {msg}")]
229pub enum CreateEndpointError {
230	#[error("{NO_TRANSPORT_BACKEND_MESSAGE}")]
231	NoTransportBackend,
232	#[error("failed to parse Ark server as a URI")]
233	InvalidUri(#[from] http::uri::InvalidUri),
234	#[error("Ark server scheme must be either http or https. Found: {0}")]
235	InvalidScheme(String),
236	#[error("Ark server URI is missing an authority part")]
237	MissingAuthority,
238	#[cfg(feature = "tonic-native")]
239	#[error(transparent)]
240	Transport(#[from] tonic::transport::Error),
241	#[cfg(feature = "socks5-proxy")]
242	#[error("invalid SOCKS5 proxy URI: {0:#}")]
243	InvalidProxyUri(http::uri::InvalidUri),
244}
245
246#[derive(Debug, thiserror::Error)]
247#[error("failed to connect to Ark server: {msg}")]
248pub enum ConnectError {
249	#[error("missing info '{0}' to connect")]
250	MissingInfo(&'static str),
251	#[deprecated(
252		since = "0.2.4",
253		note = "access tokens are not enforced by the server; this variant will be removed",
254	)]
255	#[error("invalid access token: {0}")]
256	InvalidAccessToken(#[source] InvalidMetadataValue),
257	#[error("invalid user agent: {0}")]
258	InvalidUserAgent(#[source] InvalidMetadataValue),
259	#[error(transparent)]
260	CreateEndpoint(#[from] CreateEndpointError),
261	#[error("handshake request failed: {0}")]
262	Handshake(tonic::Status),
263	#[error("version mismatch. Client max is: {client_max}, server min is: {server_min}")]
264	ProtocolVersionMismatchClientTooOld { client_max: u64, server_min: u64 },
265	#[error("version mismatch. Client min is: {client_min}, server max is: {server_max}")]
266	ProtocolVersionMismatchServerTooOld { client_min: u64, server_max: u64 },
267	#[error("error getting ark info: {0}")]
268	GetArkInfo(tonic::Status),
269	#[error("invalid ark info from ark server: {0}")]
270	InvalidArkInfo(#[from] ConvertError),
271	#[error("network mismatch. Expected: {expected}, Got: {got}")]
272	NetworkMismatch { expected: Network, got: Network },
273	#[error("error getting offboard fee rate: {0}")]
274	GetOffboardFeeRate(tonic::Status),
275	#[error("tokio channel error: {0}")]
276	Tokio(#[from] tokio::sync::oneshot::error::RecvError),
277}
278
279/// A gRPC interceptor that attaches the negotiated protocol version to each request.
280///
281/// After the handshake determines the mutually supported protocol version, this
282/// interceptor injects it into the outgoing request metadata so the server can
283/// process calls according to the agreed wire format and semantics.
284#[derive(Clone)]
285#[deprecated(since = "0.1.3", note = "should not be used directly")]
286pub struct ProtocolVersionInterceptor {
287	pver: u64,
288}
289
290#[allow(deprecated)]
291impl tonic::service::Interceptor for ProtocolVersionInterceptor {
292	fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
293		#[allow(deprecated)]
294		req.set_pver(self.pver);
295		Ok(req)
296	}
297}
298
299/// A gRPC interceptor that attaches ark-specific headers to each request
300///
301/// - pver: the negotiated protocol version
302/// - if no timeout is set yet, it sets [DEFAULT_REQUEST_TIMEOUT]
303/// - access_token: the access token to use for private servers
304/// - user_agent: client identifier sent on every RPC so the server can
305///   attribute traffic per implementation (see [USER_AGENT_HEADER]).
306#[derive(Clone)]
307pub struct ArkServiceInterceptor {
308	pver: Option<u64>,
309	access_token: Option<AsciiMetadataValue>,
310	user_agent: AsciiMetadataValue,
311}
312
313impl tonic::service::Interceptor for ArkServiceInterceptor {
314	fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
315		req.set_default_timeout(DEFAULT_REQUEST_TIMEOUT);
316		if let Some(pver) = self.pver {
317			req.set_pver(pver);
318		}
319		if let Some(ref access_token) = self.access_token {
320			#[allow(deprecated)]
321			req.metadata_mut().insert(ACCESS_TOKEN_HEADER, access_token.clone());
322		}
323		req.metadata_mut().insert(USER_AGENT_HEADER, self.user_agent.clone());
324		Ok(req)
325	}
326}
327
328/// A handle to the Ark info.
329///
330/// This handle is used to wait for the Ark info to be updated, if needed.
331pub struct ArkInfoHandle {
332	pub info: ArkInfo,
333	pub waiter: Option<tokio::sync::oneshot::Receiver<Result<ArkInfo, ConnectError>>>,
334}
335
336impl Deref for ArkInfoHandle {
337	type Target = ArkInfo;
338
339	fn deref(&self) -> &Self::Target {
340		&self.info
341	}
342}
343
344pub struct ServerInfo {
345	/// Protocol version used for rpc protocol.
346	///
347	/// For info on protocol versions, see [server_rpc](crate) module documentation.
348	pub pver: u64,
349	/// Server-side configuration and network parameters returned after connection.
350	pub info: ArkInfo,
351}
352
353impl ServerInfo {
354	pub fn new(pver: u64, info: ArkInfo) -> Self {
355		Self { pver, info }
356	}
357}
358
359#[derive(Default)]
360pub struct ServerConnectionBuilder {
361	address: Option<String>,
362	network: Option<Network>,
363	#[cfg(feature = "socks5-proxy")]
364	proxy: Option<String>,
365	access_token: Option<String>,
366	user_agent: Option<String>,
367}
368
369impl ServerConnectionBuilder {
370	pub fn address(mut self, address: impl Into<String>) -> Self {
371		self.address = Some(address.into());
372		self
373	}
374
375	pub fn network(mut self, network: Network) -> Self {
376		self.network = Some(network);
377		self
378	}
379
380	#[cfg(feature = "socks5-proxy")]
381	pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
382		self.proxy = Some(proxy.into());
383		self
384	}
385
386	#[deprecated(
387		since = "0.2.4",
388		note = "access tokens are not enforced by the server; this method will be removed",
389	)]
390	pub fn access_token(mut self, access_token: impl Into<String>) -> Self {
391		self.access_token = Some(access_token.into());
392		self
393	}
394
395	/// Override the client identifier sent on every RPC.
396	///
397	/// Defaults to `bark/<bark-server-rpc version>` when not set. Integrators
398	/// (FFI bindings, WASM wallets, custom apps) should pass their own ident
399	/// (e.g. `"aqua/1.4.2"`) so server-side telemetry can attribute traffic.
400	pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
401		self.user_agent = Some(user_agent.into());
402		self
403	}
404
405	pub async fn connect(self) -> Result<ServerConnection, ConnectError> {
406		ServerConnection::inner_connect(self).await
407	}
408}
409
410/// A managed connection to the Ark server.
411///
412/// This type encapsulates:
413/// - `pver`: The negotiated protocol version for the current session.
414/// - `info`: The server's [ArkInfo] configuration snapshot retrieved at connection time.
415/// - `client`: A ready-to-use gRPC client bound to the same channel used for the handshake.
416#[derive(Clone)]
417pub struct ServerConnection {
418	info: Arc<RwLock<ServerInfo>>,
419	/// The gRPC client to call Ark RPCs.
420	pub client: ArkServiceClient<InterceptedService<transport::Transport, ArkServiceInterceptor>>,
421	/// The mailbox gRPC client to call mailbox RPCs.
422	pub mailbox_client: mailbox::MailboxServiceClient<InterceptedService<transport::Transport, ArkServiceInterceptor>>,
423}
424
425impl ServerConnection {
426	fn handshake_req() -> protos::HandshakeRequest {
427		protos::HandshakeRequest {
428			bark_version: Some(crate::BARK_CRATE_VERSION.into()),
429		}
430	}
431
432	/// Establish a connection to an Ark server and perform protocol negotiation.
433	///
434	/// Steps performed:
435	/// 1. Build and connect a gRPC channel to `address` (with TLS for https).
436	/// 2. Perform the handshake RPC, sending the Bark client version.
437	/// 3. Validate the server's supported protocol range against
438	///    [MIN_PROTOCOL_VERSION]..=[MAX_PROTOCOL_VERSION] and select a version.
439	/// 4. Create a client with a protocol-version interceptor to tag future calls.
440	/// 5. Fetch [ArkInfo] and verify it matches the provided Bitcoin [Network].
441	///
442	/// Returns a [ServerConnection] with:
443	/// - the negotiated protocol version,
444	/// - the server's configuration snapshot,
445	/// - and a gRPC client bound to the established channel.
446	///
447	/// Errors if the server cannot be reached, handshake fails, protocol versions
448	/// are incompatible, or the server's network does not match `network`.
449	pub fn builder() -> ServerConnectionBuilder {
450		ServerConnectionBuilder::default()
451	}
452
453	//TODO(stevenroose) can rename to connect once original removed
454	async fn inner_connect(builder: ServerConnectionBuilder) -> Result<ServerConnection, ConnectError> {
455		let address = builder.address.ok_or(ConnectError::MissingInfo("address"))?;
456		let network = builder.network.ok_or(ConnectError::MissingInfo("network"))?;
457
458		#[cfg(feature = "socks5-proxy")]
459		let transport = if let Some(proxy) = builder.proxy {
460			transport::connect_with_proxy(&address, &proxy).await?
461		} else {
462			transport::connect(&address).await?
463		};
464		#[cfg(not(feature = "socks5-proxy"))]
465		let transport = transport::connect(&address).await?;
466
467		let user_agent = builder.user_agent
468			.unwrap_or_else(|| format!("bark/{}", env!("CARGO_PKG_VERSION")));
469		let user_agent: AsciiMetadataValue = user_agent.try_into()
470			.map_err(ConnectError::InvalidUserAgent)?;
471
472		let mut interceptor = ArkServiceInterceptor {
473			pver: None,
474			#[allow(deprecated)]
475			access_token: builder.access_token
476				.map(AsciiMetadataValue::try_from)
477				.transpose()
478				.map_err(ConnectError::InvalidAccessToken)?,
479			user_agent,
480		};
481
482		let mut handshake_client = ArkServiceClient::with_interceptor(transport.clone(), interceptor.clone());
483		let handshake = handshake_client.handshake(Self::handshake_req()).await
484			.map_err(ConnectError::Handshake)?.into_inner();
485
486		let pver = check_handshake(handshake)?;
487		interceptor.pver = Some(pver);
488
489		// Advertise zstd so capable servers compress their responses; the
490		// savings are mostly on the response path. We only accept_compressed,
491		// never send_compressed: gRPC can't negotiate request-body compression,
492		// so the client leaves its own requests uncompressed.
493		let mut client = ArkServiceClient::with_interceptor(transport.clone(), interceptor.clone())
494			.accept_compressed(CompressionEncoding::Zstd)
495			.max_decoding_message_size(64 * 1024 * 1024); // 64MB limit
496
497		let info = client.ark_info(network).await?;
498
499		let mailbox_client = mailbox::MailboxServiceClient::with_interceptor(transport, interceptor)
500			.accept_compressed(CompressionEncoding::Zstd)
501			.max_decoding_message_size(64 * 1024 * 1024); // 64MB limit
502
503		let info = Arc::new(RwLock::new(ServerInfo::new(pver, info)));
504		Ok(ServerConnection {
505			info,
506			client,
507			mailbox_client,
508		})
509	}
510
511	#[deprecated(since = "0.1.3", note = "use builder() instead")]
512	pub async fn connect(
513		address: &str,
514		network: Network,
515	) -> Result<ServerConnection, ConnectError> {
516		Self::builder().address(address).network(network).connect().await
517	}
518
519	#[cfg(feature = "socks5-proxy")]
520	#[deprecated(since = "0.1.3", note = "use builder() instead")]
521	pub async fn connect_via_proxy(
522		address: &str,
523		network: Network,
524		proxy: &str,
525	) -> Result<ServerConnection, ConnectError> {
526		Self::builder().address(address).network(network).proxy(proxy).connect().await
527	}
528
529	/// Checks the connection to the Ark server by performing an handshake request.
530	pub async fn check_connection(&self) -> Result<(), ConnectError> {
531		let mut client = self.client.clone();
532		let handshake = client.handshake(Self::handshake_req()).await
533			.map_err(ConnectError::Handshake)?.into_inner();
534		check_handshake(handshake)?;
535		Ok(())
536	}
537
538	/// Returns the cached [ArkInfo].
539	pub async fn ark_info(&self) -> ArkInfo {
540		self.info.read().await.info.clone()
541	}
542
543	/// Fetches the current offboard fee rate from the server.
544	pub async fn offboard_feerate(&self) -> Result<FeeRate, ConnectError> {
545		let resp = self.client.clone()
546			.get_offboard_fee_rate(protos::Empty {}).await
547			.map_err(ConnectError::GetOffboardFeeRate)?
548			.into_inner();
549		Ok(FeeRate::from_sat_per_kwu(resp.sat_vkb / 4))
550	}
551}
552trait ArkServiceClientExt {
553	async fn ark_info(&mut self, network: Network) -> Result<ArkInfo, ConnectError>;
554}
555
556impl<I: Interceptor> ArkServiceClientExt for ArkServiceClient<InterceptedService<transport::Transport, I>> {
557	async fn ark_info(&mut self, network: Network) -> Result<ArkInfo, ConnectError> {
558		let res = self.get_ark_info(protos::Empty {}).await
559			.map_err(ConnectError::GetArkInfo)?;
560		let info = ArkInfo::try_from(res.into_inner())
561			.map_err(ConnectError::InvalidArkInfo)?;
562		if network != info.network {
563			return Err(ConnectError::NetworkMismatch { expected: network, got: info.network });
564		}
565
566		Ok(info)
567	}
568}
569
570fn check_handshake(handshake: protos::HandshakeResponse) -> Result<u64, ConnectError> {
571	if let Some(ref msg) = handshake.psa {
572		warn!("Message from Ark server: \"{}\"", msg);
573	}
574
575	if MAX_PROTOCOL_VERSION < handshake.min_protocol_version {
576		return Err(ConnectError::ProtocolVersionMismatchClientTooOld {
577			client_max: MAX_PROTOCOL_VERSION, server_min: handshake.min_protocol_version
578		});
579	}
580	if MIN_PROTOCOL_VERSION > handshake.max_protocol_version {
581		return Err(ConnectError::ProtocolVersionMismatchServerTooOld {
582			client_min: MIN_PROTOCOL_VERSION, server_max: handshake.max_protocol_version
583		});
584	}
585
586	let pver = cmp::min(MAX_PROTOCOL_VERSION, handshake.max_protocol_version);
587	assert!((MIN_PROTOCOL_VERSION..=MAX_PROTOCOL_VERSION).contains(&pver));
588	assert!((handshake.min_protocol_version..=handshake.max_protocol_version).contains(&pver));
589
590	Ok(pver)
591}
592
593#[cfg(test)]
594mod tests {
595	use super::{CreateEndpointError, NO_TRANSPORT_BACKEND_MESSAGE};
596
597	#[test]
598	fn no_transport_backend_error_mentions_feature_selection() {
599		let err = CreateEndpointError::NoTransportBackend;
600		assert_eq!(err.to_string(), NO_TRANSPORT_BACKEND_MESSAGE);
601		assert!(err.to_string().contains("bark-server-rpc/tonic-native"));
602		assert!(err.to_string().contains("bark-server-rpc/tonic-web"));
603	}
604}