1use std::{
2 thread,
3 time::{Duration, Instant},
4};
5
6use arch_sdk::{Config, blocking::ArchRpcClient};
7use bitcoin::Network;
8use thiserror::Error;
9
10const IS_NODE_READY: &str = "is_node_ready";
11const BLOCK_PROGRESS_WINDOW: Duration = Duration::from_secs(2);
12
13#[derive(Clone, Debug)]
15pub struct ArchKitClient {
16 rpc_url: String,
17}
18
19impl ArchKitClient {
20 pub fn new(rpc_url: impl Into<String>) -> Result<Self, ArchKitError> {
22 let rpc_url = rpc_url.into();
23 if rpc_url.trim().is_empty() {
24 return Err(ArchKitError::InvalidRpcUrl);
25 }
26 Ok(Self { rpc_url })
27 }
28
29 pub fn health(&self) -> Result<HealthStatus, ArchKitError> {
32 let started_at = Instant::now();
33 let config = self.rpc_config();
34 let client = ArchRpcClient::new(&config);
35
36 let latency_started_at = Instant::now();
37 let readiness = client
38 .call_method::<bool>(IS_NODE_READY)
39 .map_err(|source| self.rpc_error(source))?;
40 let rpc_latency = latency_started_at.elapsed();
41 require_ready(readiness, &self.rpc_url)?;
42
43 let initial_block_height = client
44 .get_block_count()
45 .map_err(|source| self.rpc_error(source))?;
46 thread::sleep(BLOCK_PROGRESS_WINDOW);
47 let final_block_height = client
48 .get_block_count()
49 .map_err(|source| self.rpc_error(source))?;
50 require_progress(
51 initial_block_height,
52 final_block_height,
53 BLOCK_PROGRESS_WINDOW.as_secs(),
54 &self.rpc_url,
55 )?;
56
57 Ok(HealthStatus {
58 rpc_url: self.rpc_url.clone(),
59 initial_block_height,
60 final_block_height,
61 rpc_latency,
62 observation_window: BLOCK_PROGRESS_WINDOW,
63 total_elapsed: started_at.elapsed(),
64 })
65 }
66
67 fn rpc_config(&self) -> Config {
68 Config {
69 arch_node_url: self.rpc_url.clone(),
70 network: Network::Bitcoin,
73 node_endpoint: String::new(),
74 node_username: String::new(),
75 node_password: String::new(),
76 titan_url: String::new(),
77 }
78 }
79
80 fn rpc_error(&self, source: arch_sdk::ArchError) -> ArchKitError {
81 ArchKitError::NodeHealthRpc {
82 rpc_url: self.rpc_url.clone(),
83 source,
84 }
85 }
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct HealthStatus {
91 pub rpc_url: String,
92 pub initial_block_height: u64,
93 pub final_block_height: u64,
94 pub rpc_latency: Duration,
95 pub observation_window: Duration,
96 pub total_elapsed: Duration,
97}
98
99impl HealthStatus {
100 pub fn block_height_delta(&self) -> u64 {
101 self.final_block_height - self.initial_block_height
102 }
103}
104
105#[derive(Debug, Error)]
107pub enum ArchKitError {
108 #[error("Arch RPC URL must not be empty")]
109 InvalidRpcUrl,
110
111 #[error("Arch node is not ready: {rpc_url}")]
112 NodeNotReady { rpc_url: String },
113
114 #[error("Arch node returned no readiness result: {rpc_url}")]
115 NodeHealthUnavailable { rpc_url: String },
116
117 #[error("failed to check Arch node {rpc_url}: {source}")]
118 NodeHealthRpc {
119 rpc_url: String,
120 #[source]
121 source: arch_sdk::ArchError,
122 },
123
124 #[error(
125 "Arch node blocks are not progressing at {rpc_url}: height changed from {initial_height} to {final_height} over {observation_seconds}s"
126 )]
127 BlocksNotProgressing {
128 rpc_url: String,
129 initial_height: u64,
130 final_height: u64,
131 observation_seconds: u64,
132 },
133}
134
135fn require_ready(readiness: Option<bool>, rpc_url: &str) -> Result<(), ArchKitError> {
136 match readiness {
137 Some(true) => Ok(()),
138 Some(false) => Err(ArchKitError::NodeNotReady {
139 rpc_url: rpc_url.to_string(),
140 }),
141 None => Err(ArchKitError::NodeHealthUnavailable {
142 rpc_url: rpc_url.to_string(),
143 }),
144 }
145}
146
147fn require_progress(
148 initial_height: u64,
149 final_height: u64,
150 observation_seconds: u64,
151 rpc_url: &str,
152) -> Result<(), ArchKitError> {
153 if final_height > initial_height {
154 Ok(())
155 } else {
156 Err(ArchKitError::BlocksNotProgressing {
157 rpc_url: rpc_url.to_string(),
158 initial_height,
159 final_height,
160 observation_seconds,
161 })
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn rejects_an_empty_rpc_url() {
171 assert!(matches!(
172 ArchKitClient::new(" "),
173 Err(ArchKitError::InvalidRpcUrl)
174 ));
175 }
176
177 #[test]
178 fn accepts_only_an_explicit_ready_response() {
179 assert!(require_ready(Some(true), "http://node").is_ok());
180 assert!(matches!(
181 require_ready(Some(false), "http://node"),
182 Err(ArchKitError::NodeNotReady { .. })
183 ));
184 assert!(matches!(
185 require_ready(None, "http://node"),
186 Err(ArchKitError::NodeHealthUnavailable { .. })
187 ));
188 }
189
190 #[test]
191 fn requires_the_block_height_to_increase() {
192 assert!(require_progress(100, 101, 2, "http://node").is_ok());
193
194 for final_height in [100, 99] {
195 assert!(matches!(
196 require_progress(100, final_height, 2, "http://node"),
197 Err(ArchKitError::BlocksNotProgressing {
198 initial_height: 100,
199 final_height: observed,
200 observation_seconds: 2,
201 ..
202 }) if observed == final_height
203 ));
204 }
205 }
206
207 #[test]
208 fn reports_the_block_height_delta() {
209 let status = HealthStatus {
210 rpc_url: "http://node".to_string(),
211 initial_block_height: 100,
212 final_block_height: 103,
213 rpc_latency: Duration::from_millis(5),
214 observation_window: Duration::from_secs(2),
215 total_elapsed: Duration::from_millis(2_005),
216 };
217
218 assert_eq!(status.block_height_delta(), 3);
219 }
220}