Skip to main content

chainrpc_core/
substrate.rs

1//! Substrate (Polkadot/Kusama) RPC support — method safety, CU costs, and transport.
2//!
3//! Substrate chains use JSON-RPC on port 9944. Methods follow the `module_method`
4//! naming convention (e.g., `chain_getBlock`, `state_getStorage`).
5
6use std::collections::{HashMap, HashSet};
7use std::sync::{Arc, OnceLock};
8
9use async_trait::async_trait;
10use crate::chain_client::{ChainBlock, ChainClient};
11use crate::error::TransportError;
12use crate::method_safety::MethodSafety;
13use crate::request::{JsonRpcRequest, JsonRpcResponse};
14use crate::transport::{HealthStatus, RpcTransport};
15
16// ---------------------------------------------------------------------------
17// Method classification
18// ---------------------------------------------------------------------------
19
20/// Classify a Substrate JSON-RPC method by its safety level.
21pub fn classify_substrate_method(method: &str) -> MethodSafety {
22    if substrate_unsafe_methods().contains(method) {
23        MethodSafety::Unsafe
24    } else if substrate_idempotent_methods().contains(method) {
25        MethodSafety::Idempotent
26    } else {
27        MethodSafety::Safe
28    }
29}
30
31pub fn is_substrate_safe_to_retry(method: &str) -> bool {
32    classify_substrate_method(method) == MethodSafety::Safe
33}
34
35pub fn is_substrate_safe_to_dedup(method: &str) -> bool {
36    classify_substrate_method(method) == MethodSafety::Safe
37}
38
39pub fn is_substrate_cacheable(method: &str) -> bool {
40    classify_substrate_method(method) == MethodSafety::Safe
41}
42
43fn substrate_unsafe_methods() -> &'static HashSet<&'static str> {
44    static UNSAFE: OnceLock<HashSet<&'static str>> = OnceLock::new();
45    UNSAFE.get_or_init(HashSet::new) // no fire-and-forget methods in Substrate
46}
47
48fn substrate_idempotent_methods() -> &'static HashSet<&'static str> {
49    static IDEMPOTENT: OnceLock<HashSet<&'static str>> = OnceLock::new();
50    IDEMPOTENT.get_or_init(|| {
51        ["author_submitExtrinsic", "author_submitAndWatchExtrinsic"]
52            .into_iter()
53            .collect()
54    })
55}
56
57// ---------------------------------------------------------------------------
58// CU cost table
59// ---------------------------------------------------------------------------
60
61/// Per-method compute-unit cost table for Substrate RPC.
62#[derive(Debug, Clone)]
63pub struct SubstrateCuCostTable {
64    costs: HashMap<String, u32>,
65    default_cost: u32,
66}
67
68impl SubstrateCuCostTable {
69    pub fn defaults() -> Self {
70        let mut table = Self::new(15);
71        let entries: &[(&str, u32)] = &[
72            ("chain_getBlock", 20),
73            ("chain_getBlockHash", 5),
74            ("chain_getHeader", 10),
75            ("chain_getFinalizedHead", 5),
76            ("state_getStorage", 15),
77            ("state_getMetadata", 50),
78            ("state_getRuntimeVersion", 10),
79            ("state_queryStorageAt", 30),
80            ("system_chain", 5),
81            ("system_health", 5),
82            ("system_peers", 10),
83            ("system_properties", 5),
84            ("author_submitExtrinsic", 10),
85        ];
86        for &(method, cost) in entries {
87            table.costs.insert(method.to_string(), cost);
88        }
89        table
90    }
91
92    pub fn new(default_cost: u32) -> Self {
93        Self {
94            costs: HashMap::new(),
95            default_cost,
96        }
97    }
98
99    pub fn set_cost(&mut self, method: &str, cost: u32) {
100        self.costs.insert(method.to_string(), cost);
101    }
102
103    pub fn cost_for(&self, method: &str) -> u32 {
104        self.costs.get(method).copied().unwrap_or(self.default_cost)
105    }
106}
107
108impl Default for SubstrateCuCostTable {
109    fn default() -> Self {
110        Self::defaults()
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Known endpoints
116// ---------------------------------------------------------------------------
117
118pub fn polkadot_mainnet_endpoints() -> &'static [&'static str] {
119    &[
120        "wss://rpc.polkadot.io",
121        "wss://polkadot.api.onfinality.io/public-ws",
122    ]
123}
124
125pub fn kusama_mainnet_endpoints() -> &'static [&'static str] {
126    &[
127        "wss://kusama-rpc.polkadot.io",
128        "wss://kusama.api.onfinality.io/public-ws",
129    ]
130}
131
132// ---------------------------------------------------------------------------
133// SubstrateTransport
134// ---------------------------------------------------------------------------
135
136/// Substrate RPC transport wrapper.
137pub struct SubstrateTransport {
138    inner: Arc<dyn RpcTransport>,
139}
140
141impl SubstrateTransport {
142    pub fn new(inner: Arc<dyn RpcTransport>) -> Self {
143        Self { inner }
144    }
145
146    pub fn inner(&self) -> &Arc<dyn RpcTransport> {
147        &self.inner
148    }
149}
150
151#[async_trait]
152impl RpcTransport for SubstrateTransport {
153    async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
154        self.inner.send(req).await
155    }
156
157    async fn send_batch(&self, reqs: Vec<JsonRpcRequest>) -> Result<Vec<JsonRpcResponse>, TransportError> {
158        self.inner.send_batch(reqs).await
159    }
160
161    fn health(&self) -> HealthStatus {
162        self.inner.health()
163    }
164
165    fn url(&self) -> &str {
166        self.inner.url()
167    }
168}
169
170// ---------------------------------------------------------------------------
171// SubstrateChainClient
172// ---------------------------------------------------------------------------
173
174/// Substrate implementation of [`ChainClient`].
175pub struct SubstrateChainClient {
176    transport: Arc<dyn RpcTransport>,
177    chain_id: String,
178}
179
180impl SubstrateChainClient {
181    pub fn new(transport: Arc<dyn RpcTransport>, chain_id: impl Into<String>) -> Self {
182        Self {
183            transport,
184            chain_id: chain_id.into(),
185        }
186    }
187}
188
189#[async_trait]
190impl ChainClient for SubstrateChainClient {
191    async fn get_head_height(&self) -> Result<u64, TransportError> {
192        let req = JsonRpcRequest::new(1, "chain_getHeader", vec![]);
193        let resp = self.transport.send(req).await?;
194        let result = resp.into_result().map_err(TransportError::Rpc)?;
195
196        let number_hex = result["number"]
197            .as_str()
198            .unwrap_or("0x0");
199        let stripped = number_hex.strip_prefix("0x").unwrap_or(number_hex);
200        u64::from_str_radix(stripped, 16).map_err(|e| {
201            TransportError::Other(format!("invalid substrate block number: {e}"))
202        })
203    }
204
205    async fn get_block_by_height(
206        &self,
207        height: u64,
208    ) -> Result<Option<ChainBlock>, TransportError> {
209        // First get hash for height
210        let hash_req = JsonRpcRequest::new(
211            1,
212            "chain_getBlockHash",
213            vec![serde_json::json!(height)],
214        );
215        let hash_resp = self.transport.send(hash_req).await?;
216        let hash_result = hash_resp.into_result().map_err(TransportError::Rpc)?;
217
218        let block_hash = match hash_result.as_str() {
219            Some(h) if !h.is_empty() => h.to_string(),
220            _ => return Ok(None),
221        };
222
223        // Then get block by hash
224        let block_req = JsonRpcRequest::new(
225            1,
226            "chain_getBlock",
227            vec![serde_json::Value::String(block_hash.clone())],
228        );
229        let block_resp = self.transport.send(block_req).await?;
230        let block_result = block_resp.into_result().map_err(TransportError::Rpc)?;
231
232        if block_result.is_null() {
233            return Ok(None);
234        }
235
236        let header = &block_result["block"]["header"];
237        let parent_hash = header["parentHash"]
238            .as_str()
239            .unwrap_or_default()
240            .to_string();
241        let tx_count = block_result["block"]["extrinsics"]
242            .as_array()
243            .map(|a| a.len() as u32)
244            .unwrap_or(0);
245
246        Ok(Some(ChainBlock {
247            height,
248            hash: block_hash,
249            parent_hash,
250            timestamp: 0, // Substrate doesn't include timestamp in block header directly
251            tx_count,
252        }))
253    }
254
255    fn chain_id(&self) -> &str {
256        &self.chain_id
257    }
258
259    fn chain_family(&self) -> &str {
260        "substrate"
261    }
262
263    async fn health_check(&self) -> Result<bool, TransportError> {
264        let req = JsonRpcRequest::new(1, "system_health", vec![]);
265        let resp = self.transport.send(req).await?;
266        let result = resp.into_result().map_err(TransportError::Rpc)?;
267        // system_health returns { peers, isSyncing, shouldHavePeers }
268        Ok(!result["isSyncing"].as_bool().unwrap_or(true))
269    }
270}
271
272// ---------------------------------------------------------------------------
273// Tests
274// ---------------------------------------------------------------------------
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::request::RpcId;
280    use serde_json::Value;
281    use std::sync::Mutex;
282
283    struct MockTransport {
284        url: String,
285        responses: Mutex<Vec<JsonRpcResponse>>,
286        recorded: Mutex<Vec<String>>,
287    }
288
289    impl MockTransport {
290        fn new(responses: Vec<JsonRpcResponse>) -> Self {
291            Self {
292                url: "mock://substrate".to_string(),
293                responses: Mutex::new(responses),
294                recorded: Mutex::new(Vec::new()),
295            }
296        }
297    }
298
299    #[async_trait]
300    impl RpcTransport for MockTransport {
301        async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
302            self.recorded.lock().unwrap().push(req.method.clone());
303            let mut responses = self.responses.lock().unwrap();
304            if responses.is_empty() {
305                Err(TransportError::Other("no mock responses".into()))
306            } else {
307                Ok(responses.remove(0))
308            }
309        }
310        fn url(&self) -> &str { &self.url }
311    }
312
313    fn ok_response(result: Value) -> JsonRpcResponse {
314        JsonRpcResponse {
315            jsonrpc: "2.0".to_string(),
316            id: RpcId::Number(1),
317            result: Some(result),
318            error: None,
319        }
320    }
321
322    #[test]
323    fn classify_methods() {
324        assert_eq!(classify_substrate_method("chain_getBlock"), MethodSafety::Safe);
325        assert_eq!(classify_substrate_method("state_getStorage"), MethodSafety::Safe);
326        assert_eq!(classify_substrate_method("author_submitExtrinsic"), MethodSafety::Idempotent);
327        assert_eq!(classify_substrate_method("unknown"), MethodSafety::Safe);
328    }
329
330    #[test]
331    fn cu_costs() {
332        let table = SubstrateCuCostTable::defaults();
333        assert_eq!(table.cost_for("chain_getBlock"), 20);
334        assert_eq!(table.cost_for("system_health"), 5);
335        assert_eq!(table.cost_for("unknown"), 15);
336    }
337
338    #[test]
339    fn endpoints() {
340        assert!(!polkadot_mainnet_endpoints().is_empty());
341        assert!(!kusama_mainnet_endpoints().is_empty());
342    }
343
344    #[tokio::test]
345    async fn substrate_get_head_height() {
346        let transport = Arc::new(MockTransport::new(vec![ok_response(serde_json::json!({
347            "number": "0x1234",
348            "parentHash": "0xabc"
349        }))]));
350        let client = SubstrateChainClient::new(transport, "polkadot");
351        let height = client.get_head_height().await.unwrap();
352        assert_eq!(height, 0x1234);
353    }
354
355    #[tokio::test]
356    async fn substrate_get_block() {
357        let transport = Arc::new(MockTransport::new(vec![
358            ok_response(serde_json::Value::String("0xblock_hash".to_string())),
359            ok_response(serde_json::json!({
360                "block": {
361                    "header": {
362                        "number": "0x64",
363                        "parentHash": "0xparent"
364                    },
365                    "extrinsics": ["ext1", "ext2", "ext3"]
366                }
367            })),
368        ]));
369        let client = SubstrateChainClient::new(transport, "polkadot");
370        let block = client.get_block_by_height(100).await.unwrap().unwrap();
371        assert_eq!(block.height, 100);
372        assert_eq!(block.hash, "0xblock_hash");
373        assert_eq!(block.parent_hash, "0xparent");
374        assert_eq!(block.tx_count, 3);
375    }
376
377    #[tokio::test]
378    async fn substrate_health_check() {
379        let transport = Arc::new(MockTransport::new(vec![ok_response(serde_json::json!({
380            "peers": 10,
381            "isSyncing": false,
382            "shouldHavePeers": true
383        }))]));
384        let client = SubstrateChainClient::new(transport, "polkadot");
385        assert!(client.health_check().await.unwrap());
386    }
387
388    #[tokio::test]
389    async fn substrate_metadata() {
390        let transport = Arc::new(MockTransport::new(vec![]));
391        let client = SubstrateChainClient::new(transport, "kusama");
392        assert_eq!(client.chain_id(), "kusama");
393        assert_eq!(client.chain_family(), "substrate");
394    }
395}