Skip to main content

chainrpc_core/
chain_client.rs

1//! Unified `ChainClient` trait — a high-level typed abstraction over any
2//! blockchain's RPC interface.
3//!
4//! While [`RpcTransport`] operates at the raw JSON-RPC level (any method, any
5//! params), `ChainClient` provides a minimal, chain-agnostic API for common
6//! operations like fetching the current block height or retrieving a block by
7//! number.
8//!
9//! Each chain family (EVM, Solana, Cosmos, Substrate, Bitcoin, Aptos, Sui)
10//! provides its own implementation wrapping the appropriate transport.
11
12use async_trait::async_trait;
13use serde::de::Error as _;
14use serde::{Deserialize, Serialize};
15
16use crate::error::TransportError;
17
18// ─── ChainBlock ──────────────────────────────────────────────────────────────
19
20/// A chain-agnostic block summary.
21///
22/// Contains only the fields that every blockchain provides. Individual chain
23/// clients may expose richer block types through chain-specific methods.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ChainBlock {
26    /// Block height / slot / checkpoint number.
27    pub height: u64,
28    /// Block hash (hex string, chain-specific format).
29    pub hash: String,
30    /// Parent block hash.
31    pub parent_hash: String,
32    /// Block timestamp (Unix seconds).
33    pub timestamp: i64,
34    /// Number of transactions in the block.
35    pub tx_count: u32,
36}
37
38// ─── ChainClient trait ──────────────────────────────────────────────────────
39
40/// A high-level, chain-agnostic blockchain client.
41///
42/// Provides a minimal set of operations that any blockchain supports.
43/// Use this trait when writing chain-agnostic infrastructure (indexers,
44/// monitors, dashboards) that needs to work across multiple blockchain
45/// families.
46///
47/// For chain-specific operations (e.g. EVM `eth_getLogs`, Solana
48/// `getSignaturesForAddress`), use the concrete client types directly.
49#[async_trait]
50pub trait ChainClient: Send + Sync {
51    /// Get the current head height (block number / slot / checkpoint).
52    async fn get_head_height(&self) -> Result<u64, TransportError>;
53
54    /// Get a block by its height.
55    ///
56    /// Returns `None` if the block does not exist (e.g. future block number).
57    async fn get_block_by_height(
58        &self,
59        height: u64,
60    ) -> Result<Option<ChainBlock>, TransportError>;
61
62    /// Return the chain identifier (e.g. `"1"` for Ethereum mainnet,
63    /// `"mainnet-beta"` for Solana).
64    fn chain_id(&self) -> &str;
65
66    /// Return the chain family name (e.g. `"evm"`, `"solana"`, `"cosmos"`).
67    fn chain_family(&self) -> &str;
68
69    /// Perform a health check against the underlying transport.
70    async fn health_check(&self) -> Result<bool, TransportError>;
71}
72
73// ─── EvmChainClient ─────────────────────────────────────────────────────────
74
75use std::sync::Arc;
76use crate::transport::RpcTransport;
77
78/// EVM implementation of [`ChainClient`].
79///
80/// Wraps any `Arc<dyn RpcTransport>` and translates `ChainClient` methods
81/// into the appropriate `eth_*` JSON-RPC calls.
82pub struct EvmChainClient {
83    transport: Arc<dyn RpcTransport>,
84    chain_id: String,
85}
86
87impl EvmChainClient {
88    /// Create an EVM chain client with the given transport and chain ID.
89    pub fn new(transport: Arc<dyn RpcTransport>, chain_id: impl Into<String>) -> Self {
90        Self {
91            transport,
92            chain_id: chain_id.into(),
93        }
94    }
95}
96
97#[async_trait]
98impl ChainClient for EvmChainClient {
99    async fn get_head_height(&self) -> Result<u64, TransportError> {
100        let req = crate::request::JsonRpcRequest::new(
101            1,
102            "eth_blockNumber",
103            vec![],
104        );
105        let resp = self.transport.send(req).await?;
106        let result = resp.into_result().map_err(TransportError::Rpc)?;
107        let hex_str = result
108            .as_str()
109            .ok_or_else(|| TransportError::Deserialization(
110                serde_json::Error::custom("expected hex string for block number"),
111            ))?;
112        let stripped = hex_str.strip_prefix("0x").unwrap_or(hex_str);
113        u64::from_str_radix(stripped, 16).map_err(|e| {
114            TransportError::Deserialization(serde_json::Error::custom(format!(
115                "invalid block number hex: {e}"
116            )))
117        })
118    }
119
120    async fn get_block_by_height(
121        &self,
122        height: u64,
123    ) -> Result<Option<ChainBlock>, TransportError> {
124        let hex_height = format!("0x{height:x}");
125        let req = crate::request::JsonRpcRequest::new(
126            1,
127            "eth_getBlockByNumber",
128            vec![
129                serde_json::Value::String(hex_height),
130                serde_json::Value::Bool(false), // don't include full txs
131            ],
132        );
133        let resp = self.transport.send(req).await?;
134        let result = resp.into_result().map_err(TransportError::Rpc)?;
135
136        if result.is_null() {
137            return Ok(None);
138        }
139
140        let hash = result["hash"]
141            .as_str()
142            .unwrap_or_default()
143            .to_string();
144        let parent_hash = result["parentHash"]
145            .as_str()
146            .unwrap_or_default()
147            .to_string();
148        let timestamp = parse_hex_u64(result["timestamp"].as_str().unwrap_or("0x0"))
149            as i64;
150        let tx_count = result["transactions"]
151            .as_array()
152            .map(|a| a.len() as u32)
153            .unwrap_or(0);
154
155        Ok(Some(ChainBlock {
156            height,
157            hash,
158            parent_hash,
159            timestamp,
160            tx_count,
161        }))
162    }
163
164    fn chain_id(&self) -> &str {
165        &self.chain_id
166    }
167
168    fn chain_family(&self) -> &str {
169        "evm"
170    }
171
172    async fn health_check(&self) -> Result<bool, TransportError> {
173        // Simple: if eth_blockNumber succeeds, we're healthy
174        self.get_head_height().await.map(|_| true)
175    }
176}
177
178// ─── SolanaChainClient ──────────────────────────────────────────────────────
179
180/// Solana implementation of [`ChainClient`].
181///
182/// Wraps any `Arc<dyn RpcTransport>` and translates `ChainClient` methods
183/// into the appropriate Solana JSON-RPC calls.
184pub struct SolanaChainClient {
185    transport: Arc<dyn RpcTransport>,
186    chain_id: String,
187}
188
189impl SolanaChainClient {
190    /// Create a Solana chain client.
191    pub fn new(transport: Arc<dyn RpcTransport>, chain_id: impl Into<String>) -> Self {
192        Self {
193            transport,
194            chain_id: chain_id.into(),
195        }
196    }
197}
198
199#[async_trait]
200impl ChainClient for SolanaChainClient {
201    async fn get_head_height(&self) -> Result<u64, TransportError> {
202        let req = crate::request::JsonRpcRequest::new(
203            1,
204            "getSlot",
205            vec![],
206        );
207        let resp = self.transport.send(req).await?;
208        let result = resp.into_result().map_err(TransportError::Rpc)?;
209        // Solana returns slot as a JSON number, not hex
210        result.as_u64().ok_or_else(|| {
211            TransportError::Deserialization(serde_json::Error::custom(
212                "expected u64 for slot number",
213            ))
214        })
215    }
216
217    async fn get_block_by_height(
218        &self,
219        height: u64,
220    ) -> Result<Option<ChainBlock>, TransportError> {
221        let req = crate::request::JsonRpcRequest::new(
222            1,
223            "getBlock",
224            vec![
225                serde_json::Value::Number(serde_json::Number::from(height)),
226                serde_json::json!({
227                    "encoding": "json",
228                    "transactionDetails": "none",
229                    "rewards": false,
230                }),
231            ],
232        );
233        let resp = self.transport.send(req).await?;
234        let result = resp.into_result().map_err(TransportError::Rpc)?;
235
236        if result.is_null() {
237            return Ok(None);
238        }
239
240        let hash = result["blockhash"]
241            .as_str()
242            .unwrap_or_default()
243            .to_string();
244        let parent_hash = result["previousBlockhash"]
245            .as_str()
246            .unwrap_or_default()
247            .to_string();
248        let timestamp = result["blockTime"].as_i64().unwrap_or(0);
249        let tx_count = result["transactions"]
250            .as_array()
251            .map(|a| a.len() as u32)
252            .unwrap_or(0);
253
254        Ok(Some(ChainBlock {
255            height,
256            hash,
257            parent_hash,
258            timestamp,
259            tx_count,
260        }))
261    }
262
263    fn chain_id(&self) -> &str {
264        &self.chain_id
265    }
266
267    fn chain_family(&self) -> &str {
268        "solana"
269    }
270
271    async fn health_check(&self) -> Result<bool, TransportError> {
272        let req = crate::request::JsonRpcRequest::new(
273            1,
274            "getHealth",
275            vec![],
276        );
277        let resp = self.transport.send(req).await?;
278        let result = resp.into_result().map_err(TransportError::Rpc)?;
279        Ok(result.as_str() == Some("ok"))
280    }
281}
282
283// ─── Helpers ────────────────────────────────────────────────────────────────
284
285fn parse_hex_u64(hex_str: &str) -> u64 {
286    let stripped = hex_str.strip_prefix("0x").unwrap_or(hex_str);
287    u64::from_str_radix(stripped, 16).unwrap_or(0)
288}
289
290// ─── Tests ──────────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::request::{JsonRpcRequest, JsonRpcResponse, RpcId};
296    use std::sync::Mutex;
297
298    /// A mock transport that records requests and returns pre-configured responses.
299    struct MockTransport {
300        url: String,
301        responses: Mutex<Vec<JsonRpcResponse>>,
302        recorded_requests: Mutex<Vec<(String, Vec<serde_json::Value>)>>,
303    }
304
305    impl MockTransport {
306        fn new(responses: Vec<JsonRpcResponse>) -> Self {
307            Self {
308                url: "mock://test".to_string(),
309                responses: Mutex::new(responses),
310                recorded_requests: Mutex::new(Vec::new()),
311            }
312        }
313
314        fn recorded(&self) -> Vec<(String, Vec<serde_json::Value>)> {
315            self.recorded_requests.lock().unwrap().clone()
316        }
317    }
318
319    #[async_trait]
320    impl RpcTransport for MockTransport {
321        async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
322            self.recorded_requests.lock().unwrap().push((
323                req.method.clone(),
324                req.params.clone(),
325            ));
326            let mut responses = self.responses.lock().unwrap();
327            if responses.is_empty() {
328                Err(TransportError::Other("no more mock responses".into()))
329            } else {
330                Ok(responses.remove(0))
331            }
332        }
333
334        fn url(&self) -> &str {
335            &self.url
336        }
337    }
338
339    fn ok_response(result: serde_json::Value) -> JsonRpcResponse {
340        JsonRpcResponse {
341            jsonrpc: "2.0".to_string(),
342            id: RpcId::Number(1),
343            result: Some(result),
344            error: None,
345        }
346    }
347
348    // ── EVM tests ───────────────────────────────────────────────────────
349
350    #[tokio::test]
351    async fn evm_get_head_height() {
352        let transport = Arc::new(MockTransport::new(vec![
353            ok_response(serde_json::Value::String("0x10".to_string())),
354        ]));
355        let client = EvmChainClient::new(transport.clone(), "1");
356
357        let height = client.get_head_height().await.unwrap();
358        assert_eq!(height, 16);
359
360        let reqs = transport.recorded();
361        assert_eq!(reqs[0].0, "eth_blockNumber");
362    }
363
364    #[tokio::test]
365    async fn evm_get_block_by_height() {
366        let block_json = serde_json::json!({
367            "hash": "0xabc123",
368            "parentHash": "0xdef456",
369            "timestamp": "0x60000000",
370            "transactions": ["0xtx1", "0xtx2", "0xtx3"]
371        });
372        let transport = Arc::new(MockTransport::new(vec![
373            ok_response(block_json),
374        ]));
375        let client = EvmChainClient::new(transport.clone(), "1");
376
377        let block = client.get_block_by_height(100).await.unwrap().unwrap();
378        assert_eq!(block.height, 100);
379        assert_eq!(block.hash, "0xabc123");
380        assert_eq!(block.parent_hash, "0xdef456");
381        assert_eq!(block.tx_count, 3);
382
383        let reqs = transport.recorded();
384        assert_eq!(reqs[0].0, "eth_getBlockByNumber");
385        assert_eq!(reqs[0].1[0], serde_json::Value::String("0x64".to_string()));
386    }
387
388    #[tokio::test]
389    async fn evm_get_block_null() {
390        let transport = Arc::new(MockTransport::new(vec![
391            ok_response(serde_json::Value::Null),
392        ]));
393        let client = EvmChainClient::new(transport, "1");
394
395        let block = client.get_block_by_height(99999999).await.unwrap();
396        assert!(block.is_none());
397    }
398
399    #[tokio::test]
400    async fn evm_chain_metadata() {
401        let transport = Arc::new(MockTransport::new(vec![]));
402        let client = EvmChainClient::new(transport, "137");
403        assert_eq!(client.chain_id(), "137");
404        assert_eq!(client.chain_family(), "evm");
405    }
406
407    #[tokio::test]
408    async fn evm_health_check() {
409        let transport = Arc::new(MockTransport::new(vec![
410            ok_response(serde_json::Value::String("0x1".to_string())),
411        ]));
412        let client = EvmChainClient::new(transport, "1");
413        assert!(client.health_check().await.unwrap());
414    }
415
416    // ── Solana tests ────────────────────────────────────────────────────
417
418    #[tokio::test]
419    async fn solana_get_head_height() {
420        let transport = Arc::new(MockTransport::new(vec![
421            ok_response(serde_json::Value::Number(200_000_000u64.into())),
422        ]));
423        let client = SolanaChainClient::new(transport.clone(), "mainnet-beta");
424
425        let slot = client.get_head_height().await.unwrap();
426        assert_eq!(slot, 200_000_000);
427
428        let reqs = transport.recorded();
429        assert_eq!(reqs[0].0, "getSlot");
430    }
431
432    #[tokio::test]
433    async fn solana_get_block_by_height() {
434        let block_json = serde_json::json!({
435            "blockhash": "5abc123def",
436            "previousBlockhash": "4abc123def",
437            "blockTime": 1700000000i64,
438            "transactions": [{"tx": 1}, {"tx": 2}]
439        });
440        let transport = Arc::new(MockTransport::new(vec![
441            ok_response(block_json),
442        ]));
443        let client = SolanaChainClient::new(transport.clone(), "mainnet-beta");
444
445        let block = client.get_block_by_height(100).await.unwrap().unwrap();
446        assert_eq!(block.height, 100);
447        assert_eq!(block.hash, "5abc123def");
448        assert_eq!(block.parent_hash, "4abc123def");
449        assert_eq!(block.timestamp, 1700000000);
450        assert_eq!(block.tx_count, 2);
451
452        let reqs = transport.recorded();
453        assert_eq!(reqs[0].0, "getBlock");
454    }
455
456    #[tokio::test]
457    async fn solana_health_check_ok() {
458        let transport = Arc::new(MockTransport::new(vec![
459            ok_response(serde_json::Value::String("ok".to_string())),
460        ]));
461        let client = SolanaChainClient::new(transport, "mainnet-beta");
462        assert!(client.health_check().await.unwrap());
463    }
464
465    #[tokio::test]
466    async fn solana_health_check_behind() {
467        let transport = Arc::new(MockTransport::new(vec![
468            ok_response(serde_json::Value::String("behind".to_string())),
469        ]));
470        let client = SolanaChainClient::new(transport, "mainnet-beta");
471        assert!(!client.health_check().await.unwrap());
472    }
473
474    #[tokio::test]
475    async fn solana_chain_metadata() {
476        let transport = Arc::new(MockTransport::new(vec![]));
477        let client = SolanaChainClient::new(transport, "devnet");
478        assert_eq!(client.chain_id(), "devnet");
479        assert_eq!(client.chain_family(), "solana");
480    }
481
482    // ── ChainBlock tests ────────────────────────────────────────────────
483
484    #[test]
485    fn chain_block_serde_roundtrip() {
486        let block = ChainBlock {
487            height: 100,
488            hash: "0xabc".to_string(),
489            parent_hash: "0xdef".to_string(),
490            timestamp: 1700000000,
491            tx_count: 42,
492        };
493        let json = serde_json::to_string(&block).unwrap();
494        let back: ChainBlock = serde_json::from_str(&json).unwrap();
495        assert_eq!(back.height, 100);
496        assert_eq!(back.tx_count, 42);
497    }
498
499    #[test]
500    fn parse_hex_u64_works() {
501        assert_eq!(parse_hex_u64("0x10"), 16);
502        assert_eq!(parse_hex_u64("0xff"), 255);
503        assert_eq!(parse_hex_u64("10"), 16);
504        assert_eq!(parse_hex_u64("0x0"), 0);
505        assert_eq!(parse_hex_u64("invalid"), 0);
506    }
507}