1use async_trait::async_trait;
13use serde::de::Error as _;
14use serde::{Deserialize, Serialize};
15
16use crate::error::TransportError;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ChainBlock {
26 pub height: u64,
28 pub hash: String,
30 pub parent_hash: String,
32 pub timestamp: i64,
34 pub tx_count: u32,
36}
37
38#[async_trait]
50pub trait ChainClient: Send + Sync {
51 async fn get_head_height(&self) -> Result<u64, TransportError>;
53
54 async fn get_block_by_height(
58 &self,
59 height: u64,
60 ) -> Result<Option<ChainBlock>, TransportError>;
61
62 fn chain_id(&self) -> &str;
65
66 fn chain_family(&self) -> &str;
68
69 async fn health_check(&self) -> Result<bool, TransportError>;
71}
72
73use std::sync::Arc;
76use crate::transport::RpcTransport;
77
78pub struct EvmChainClient {
83 transport: Arc<dyn RpcTransport>,
84 chain_id: String,
85}
86
87impl EvmChainClient {
88 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), ],
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 self.get_head_height().await.map(|_| true)
175 }
176}
177
178pub struct SolanaChainClient {
185 transport: Arc<dyn RpcTransport>,
186 chain_id: String,
187}
188
189impl SolanaChainClient {
190 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 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
283fn 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#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::request::{JsonRpcRequest, JsonRpcResponse, RpcId};
296 use std::sync::Mutex;
297
298 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 #[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 #[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 #[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}