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