Skip to main content

arch_kit/
client.rs

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/// The smallest reusable Arch client, currently limited to node health.
14#[derive(Clone, Debug)]
15pub struct ArchKitClient {
16    rpc_url: String,
17}
18
19impl ArchKitClient {
20    /// Create a client for an Arch JSON-RPC endpoint.
21    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    /// Check that the node is ready and produces a new block during the
30    /// observation window.
31    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            // Health checks do not create or sign transactions, so the
71            // Bitcoin network has no effect on this operation.
72            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/// Successful node-health measurements.
89#[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/// Errors returned by the reusable Arch client.
106#[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}