Skip to main content

host_chain_core/
state_machine.rs

1//! Generic chain state machine — shared between WASM and native.
2//!
3//! Tracks chain connection status, processes JSON-RPC responses, and persists
4//! chain databases. The state machine is generic over [`ChainStore`] so the
5//! same logic works with localStorage (WASM) and the filesystem (native).
6
7use std::collections::HashMap;
8
9use crate::chain::{
10    parse_block_number, ChainExtra, ChainId, ChainState, ChainStatus, REQ_ID_PARA_DB_SAVE,
11    REQ_ID_RELAY_DB_SAVE,
12};
13use crate::store::ChainStore;
14
15/// Starting health subscription ID. Incremented on each health check response.
16const INITIAL_HEALTH_ID: u64 = 1000;
17
18/// Per-chain state tracked by the state machine.
19struct ChainEntry {
20    status: ChainStatus,
21    relay_db_key: String,
22    para_db_key: String,
23    chain_specs: Option<(String, String)>,
24    health_id: u64,
25}
26
27/// Pure state machine for chain connections — no networking, no async.
28///
29/// Processes JSON-RPC responses from smoldot (or smoldot-js on WASM),
30/// tracks chain status, and persists chain databases via a [`ChainStore`].
31pub struct ChainStateMachine<S: ChainStore> {
32    chains: HashMap<ChainId, ChainEntry>,
33    store: S,
34}
35
36impl<S: ChainStore> ChainStateMachine<S> {
37    pub fn new(store: S) -> Self {
38        Self {
39            chains: HashMap::new(),
40            store,
41        }
42    }
43
44    /// Register a chain for tracking. Initial state is `Connecting`.
45    pub fn register_chain(&mut self, chain: ChainId) {
46        self.chains.insert(
47            chain,
48            ChainEntry {
49                status: ChainStatus {
50                    id: chain,
51                    name: chain.display_name(),
52                    state: ChainState::Connecting,
53                    extra: ChainExtra::None,
54                },
55                relay_db_key: chain.relay_db_key().to_string(),
56                para_db_key: chain.para_db_key(),
57                chain_specs: chain
58                    .chain_specs()
59                    .map(|(r, p)| (r.to_string(), p.to_string())),
60                health_id: INITIAL_HEALTH_ID,
61            },
62        );
63    }
64
65    /// Register a chain from a [`ChainRegistryEntry`], using the entry's owned
66    /// strings for db keys and chain specs instead of the `ChainId` enum methods.
67    pub fn register_chain_entry(&mut self, entry: &crate::registry::ChainRegistryEntry) {
68        let chain = entry.id;
69        self.chains.insert(
70            chain,
71            ChainEntry {
72                status: ChainStatus {
73                    id: chain,
74                    name: chain.display_name(),
75                    state: ChainState::Connecting,
76                    extra: ChainExtra::None,
77                },
78                relay_db_key: entry.relay_db_key.clone(),
79                para_db_key: entry.para_db_key.clone(),
80                chain_specs: entry.chain_specs.clone(),
81                health_id: INITIAL_HEALTH_ID,
82            },
83        );
84    }
85
86    /// Unregister a chain (sets state to Disconnected).
87    pub fn unregister_chain(&mut self, chain: ChainId) {
88        if let Some(entry) = self.chains.get_mut(&chain) {
89            entry.status.state = ChainState::Disconnected;
90        }
91    }
92
93    /// Set the chain state, preserving existing `extra`.
94    pub fn set_state(&mut self, chain: ChainId, state: ChainState) {
95        if let Some(entry) = self.chains.get_mut(&chain) {
96            entry.status.state = state;
97        }
98    }
99
100    /// Set both state and extra data.
101    pub fn set_state_with_extra(&mut self, chain: ChainId, state: ChainState, extra: ChainExtra) {
102        if let Some(entry) = self.chains.get_mut(&chain) {
103            entry.status.state = state;
104            entry.status.extra = extra;
105        }
106    }
107
108    /// Access the underlying store.
109    pub fn store(&self) -> &S {
110        &self.store
111    }
112
113    /// Process a JSON-RPC response from smoldot for a given chain.
114    pub fn process_response(&mut self, chain: ChainId, text: &str) {
115        let v: serde_json::Value = match serde_json::from_str(text) {
116            Ok(v) => v,
117            Err(_) => return,
118        };
119
120        let entry = match self.chains.get_mut(&chain) {
121            Some(e) => e,
122            None => return,
123        };
124
125        // Check for para DB save response.
126        if let Some(id) = v.get("id").and_then(|i| i.as_u64()) {
127            if id == REQ_ID_PARA_DB_SAVE {
128                if let Some(db) = v.get("result").and_then(|r| r.as_str()) {
129                    self.store.save(&entry.para_db_key, db);
130                    log::info!("{chain:?}: saved para DB ({} bytes)", db.len());
131                } else if let Some(err) = v.get("error") {
132                    log::warn!("{chain:?}: para DB save returned error: {err}");
133                }
134                return;
135            }
136        }
137
138        // Check for system_health response.
139        if let Some(result) = v.get("result") {
140            if let (Some(peers), Some(is_syncing)) = (
141                result.get("peers").and_then(|p| p.as_u64()),
142                result.get("isSyncing").and_then(|s| s.as_bool()),
143            ) {
144                let current_block = match &entry.status.state {
145                    ChainState::Live { best_block, .. }
146                    | ChainState::Syncing { best_block, .. } => *best_block,
147                    _ => 0,
148                };
149
150                entry.status.state = if is_syncing {
151                    ChainState::Syncing {
152                        best_block: current_block,
153                        peers: peers as u32,
154                    }
155                } else {
156                    ChainState::Live {
157                        best_block: current_block,
158                        peers: peers as u32,
159                    }
160                };
161                return;
162            }
163        }
164
165        // Check for chain_newHead subscription notification.
166        if let Some(block) = parse_block_number(text) {
167            let (current_peers, current_syncing) = match &entry.status.state {
168                ChainState::Live { peers, .. } => (*peers, false),
169                ChainState::Syncing { peers, .. } => (*peers, true),
170                ChainState::Connecting => (0, true),
171                _ => (0, false),
172            };
173
174            entry.status.state = if current_syncing && current_peers > 0 {
175                ChainState::Syncing {
176                    best_block: block,
177                    peers: current_peers,
178                }
179            } else {
180                ChainState::Live {
181                    best_block: block,
182                    peers: current_peers,
183                }
184            };
185        }
186    }
187
188    /// Process a JSON-RPC response for a relay chain DB save.
189    pub fn process_relay_response(&mut self, chain: ChainId, text: &str) {
190        if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
191            if v.get("id").and_then(|i| i.as_u64()) == Some(REQ_ID_RELAY_DB_SAVE) {
192                if let Some(db) = v.get("result").and_then(|r| r.as_str()) {
193                    let key = self
194                        .chains
195                        .get(&chain)
196                        .map(|e| e.relay_db_key.as_str())
197                        .unwrap_or_else(|| chain.relay_db_key());
198                    self.store.save(key, db);
199                    log::info!("{chain:?}: saved relay DB ({} bytes)", db.len());
200                } else if let Some(err) = v.get("error") {
201                    log::warn!("{chain:?}: relay DB save returned error: {err}");
202                }
203            }
204        }
205    }
206
207    /// Set the chain state to Error.
208    pub fn set_error(&mut self, chain: ChainId, msg: String) {
209        if let Some(entry) = self.chains.get_mut(&chain) {
210            entry.status.state = ChainState::Error(msg);
211        }
212    }
213
214    pub fn status(&self, chain: ChainId) -> ChainStatus {
215        self.chains
216            .get(&chain)
217            .map(|e| e.status.clone())
218            .unwrap_or_else(|| ChainStatus::disconnected(chain))
219    }
220
221    pub fn all_statuses(&self) -> Vec<ChainStatus> {
222        ChainId::all().iter().map(|&id| self.status(id)).collect()
223    }
224
225    /// Generate the chain_subscribeNewHeads JSON-RPC request.
226    pub fn subscribe_new_heads_request() -> String {
227        serde_json::json!({
228            "jsonrpc": "2.0",
229            "id": 1,
230            "method": "chain_subscribeNewHeads",
231            "params": []
232        })
233        .to_string()
234    }
235
236    /// Generate a system_health JSON-RPC request with an incrementing ID.
237    /// Returns `None` if the chain is not registered.
238    pub fn health_check_request(&mut self, chain: ChainId) -> Option<String> {
239        let entry = self.chains.get_mut(&chain)?;
240        entry.health_id += 1;
241        Some(
242            serde_json::json!({
243                "jsonrpc": "2.0",
244                "id": entry.health_id,
245                "method": "system_health",
246                "params": []
247            })
248            .to_string(),
249        )
250    }
251
252    /// Generate a parachain DB save request.
253    pub fn para_db_save_request() -> String {
254        serde_json::json!({
255            "jsonrpc": "2.0",
256            "id": REQ_ID_PARA_DB_SAVE,
257            "method": "chainHead_unstable_finalizedDatabase",
258            "params": []
259        })
260        .to_string()
261    }
262
263    /// Generate a relay chain DB save request.
264    pub fn relay_db_save_request() -> String {
265        serde_json::json!({
266            "jsonrpc": "2.0",
267            "id": REQ_ID_RELAY_DB_SAVE,
268            "method": "chainHead_unstable_finalizedDatabase",
269            "params": []
270        })
271        .to_string()
272    }
273
274    /// Load persisted relay chain DB.
275    pub fn load_relay_db(&self, chain: ChainId) -> String {
276        match self.chains.get(&chain) {
277            Some(e) => self.store.load(&e.relay_db_key),
278            None => self.store.load(chain.relay_db_key()),
279        }
280    }
281
282    /// Load persisted parachain DB.
283    pub fn load_para_db(&self, chain: ChainId) -> String {
284        match self.chains.get(&chain) {
285            Some(e) => self.store.load(&e.para_db_key),
286            None => self.store.load(&chain.para_db_key()),
287        }
288    }
289
290    /// Get chain specs for a smoldot chain. Returns (relay_spec, para_spec).
291    pub fn chain_specs(chain: ChainId) -> Option<(&'static str, &'static str)> {
292        chain.chain_specs()
293    }
294
295    /// Get chain specs from the registered entry as owned strings.
296    /// Returns `None` for unregistered chains or chains without specs.
297    pub fn chain_specs_owned(&self, chain: ChainId) -> Option<(String, String)> {
298        self.chains.get(&chain).and_then(|e| e.chain_specs.clone())
299    }
300
301    /// Clear both relay and para chain databases (used after a smoldot panic).
302    pub fn clear_chain_dbs(&self, chain: ChainId) {
303        let relay_key = self
304            .chains
305            .get(&chain)
306            .map(|e| e.relay_db_key.clone())
307            .unwrap_or_else(|| chain.relay_db_key().to_string());
308        let para_key = self
309            .chains
310            .get(&chain)
311            .map(|e| e.para_db_key.clone())
312            .unwrap_or_else(|| chain.para_db_key());
313        self.store.save(&relay_key, "");
314        self.store.save(&para_key, "");
315    }
316
317    // -- Statement store RPC request generators --
318
319    /// Generate a statement_submit JSON-RPC request.
320    pub fn statement_submit_request(encoded_hex: &str, request_id: u64) -> String {
321        serde_json::json!({
322            "jsonrpc": "2.0",
323            "id": request_id,
324            "method": "statement_submit",
325            "params": [encoded_hex]
326        })
327        .to_string()
328    }
329
330    /// Generate a statement_subscribeStatement JSON-RPC request.
331    pub fn statement_subscribe_request(request_id: u64) -> String {
332        serde_json::json!({
333            "jsonrpc": "2.0",
334            "id": request_id,
335            "method": "statement_subscribeStatement",
336            "params": ["any"]
337        })
338        .to_string()
339    }
340
341    /// Generate a statement_unsubscribeStatement JSON-RPC request.
342    pub fn statement_unsubscribe_request(sub_id: &str, request_id: u64) -> String {
343        serde_json::json!({
344            "jsonrpc": "2.0",
345            "id": request_id,
346            "method": "statement_unsubscribeStatement",
347            "params": [sub_id]
348        })
349        .to_string()
350    }
351
352    /// Generate a statement_broadcastsStatement JSON-RPC request.
353    pub fn statement_broadcasts_request(topic_hexes: &[String], request_id: u64) -> String {
354        serde_json::json!({
355            "jsonrpc": "2.0",
356            "id": request_id,
357            "method": "statement_broadcastsStatement",
358            "params": [topic_hexes]
359        })
360        .to_string()
361    }
362
363    /// Parse a statement_subscribeStatement notification.
364    /// Returns hex-encoded statement strings found in the notification,
365    /// or an empty vec if this is not a statement notification.
366    pub fn parse_statement_notification(text: &str) -> Vec<String> {
367        let v: serde_json::Value = match serde_json::from_str(text) {
368            Ok(v) => v,
369            Err(_) => return Vec::new(),
370        };
371
372        if v.get("method").and_then(|m| m.as_str()) != Some("statement_subscribeStatement") {
373            return Vec::new();
374        }
375
376        let result = match v.pointer("/params/result") {
377            Some(r) => r,
378            None => return Vec::new(),
379        };
380
381        let stmts = result
382            .pointer("/data/statements")
383            .or_else(|| result.pointer("/newStatements/statements"))
384            .or_else(|| result.get("statements"));
385
386        match stmts.and_then(|s| s.as_array()) {
387            Some(arr) => arr
388                .iter()
389                .filter_map(|v| v.as_str().map(String::from))
390                .collect(),
391            None => Vec::new(),
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use std::cell::RefCell;
400
401    struct InMemoryStore {
402        data: RefCell<HashMap<String, String>>,
403    }
404
405    impl InMemoryStore {
406        fn new() -> Self {
407            Self {
408                data: RefCell::new(HashMap::new()),
409            }
410        }
411    }
412
413    impl ChainStore for InMemoryStore {
414        fn load(&self, key: &str) -> String {
415            self.data.borrow().get(key).cloned().unwrap_or_default()
416        }
417
418        fn save(&self, key: &str, data: &str) {
419            self.data
420                .borrow_mut()
421                .insert(key.to_string(), data.to_string());
422        }
423    }
424
425    fn make_sm() -> ChainStateMachine<InMemoryStore> {
426        let store = InMemoryStore::new();
427        let mut sm = ChainStateMachine::new(store);
428        sm.register_chain(ChainId::PaseoAssetHub);
429        sm
430    }
431
432    #[test]
433    fn register_sets_connecting() {
434        let sm = make_sm();
435        let status = sm.status(ChainId::PaseoAssetHub);
436        assert!(matches!(status.state, ChainState::Connecting));
437    }
438
439    #[test]
440    fn unregister_sets_disconnected() {
441        let mut sm = make_sm();
442        sm.unregister_chain(ChainId::PaseoAssetHub);
443        let status = sm.status(ChainId::PaseoAssetHub);
444        assert!(matches!(status.state, ChainState::Disconnected));
445    }
446
447    #[test]
448    fn unregistered_chain_returns_disconnected() {
449        let sm = make_sm();
450        let status = sm.status(ChainId::Ethereum);
451        assert!(matches!(status.state, ChainState::Disconnected));
452    }
453
454    #[test]
455    fn health_response_sets_live() {
456        let mut sm = make_sm();
457        let resp = r#"{"jsonrpc":"2.0","id":1001,"result":{"peers":5,"isSyncing":false}}"#;
458        sm.process_response(ChainId::PaseoAssetHub, resp);
459        let status = sm.status(ChainId::PaseoAssetHub);
460        assert!(matches!(status.state, ChainState::Live { peers: 5, .. }));
461    }
462
463    #[test]
464    fn health_response_sets_syncing() {
465        let mut sm = make_sm();
466        let resp = r#"{"jsonrpc":"2.0","id":1001,"result":{"peers":3,"isSyncing":true}}"#;
467        sm.process_response(ChainId::PaseoAssetHub, resp);
468        let status = sm.status(ChainId::PaseoAssetHub);
469        assert!(matches!(status.state, ChainState::Syncing { peers: 3, .. }));
470    }
471
472    #[test]
473    fn new_head_updates_block_number() {
474        let mut sm = make_sm();
475        // First set to Live via health
476        let health = r#"{"jsonrpc":"2.0","id":1001,"result":{"peers":5,"isSyncing":false}}"#;
477        sm.process_response(ChainId::PaseoAssetHub, health);
478        // Then new head notification
479        let head =
480            r#"{"jsonrpc":"2.0","method":"chain_newHead","params":{"result":{"number":"0x1a4"}}}"#;
481        sm.process_response(ChainId::PaseoAssetHub, head);
482        let status = sm.status(ChainId::PaseoAssetHub);
483        match status.state {
484            ChainState::Live {
485                best_block, peers, ..
486            } => {
487                assert_eq!(best_block, 0x1a4);
488                assert_eq!(peers, 5);
489            }
490            other => panic!("expected Live, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn para_db_save_stores_to_store() {
496        let mut sm = make_sm();
497        let resp = format!(
498            r#"{{"jsonrpc":"2.0","id":{},"result":"saved-db-content"}}"#,
499            REQ_ID_PARA_DB_SAVE,
500        );
501        sm.process_response(ChainId::PaseoAssetHub, &resp);
502        assert_eq!(sm.store().load("PaseoAssetHub"), "saved-db-content");
503    }
504
505    #[test]
506    fn relay_db_save_stores_to_store() {
507        let mut sm = make_sm();
508        let resp = format!(
509            r#"{{"jsonrpc":"2.0","id":{},"result":"relay-db-content"}}"#,
510            REQ_ID_RELAY_DB_SAVE,
511        );
512        sm.process_relay_response(ChainId::PaseoAssetHub, &resp);
513        assert_eq!(
514            sm.store().load(ChainId::PaseoAssetHub.relay_db_key()),
515            "relay-db-content"
516        );
517    }
518
519    #[test]
520    fn set_state_preserves_extra() {
521        let mut sm = make_sm();
522        sm.set_state_with_extra(
523            ChainId::PaseoAssetHub,
524            ChainState::Live {
525                best_block: 100,
526                peers: 5,
527            },
528            ChainExtra::Eth {
529                finalized_block: 50,
530                gas_price_gwei: 20,
531            },
532        );
533        // set_state (without extra) should preserve the Eth extra
534        sm.set_state(
535            ChainId::PaseoAssetHub,
536            ChainState::Live {
537                best_block: 200,
538                peers: 3,
539            },
540        );
541        let status = sm.status(ChainId::PaseoAssetHub);
542        assert!(matches!(
543            status.extra,
544            ChainExtra::Eth {
545                finalized_block: 50,
546                gas_price_gwei: 20
547            }
548        ));
549        assert!(matches!(
550            status.state,
551            ChainState::Live {
552                best_block: 200,
553                peers: 3
554            }
555        ));
556    }
557
558    #[test]
559    fn set_state_with_extra_updates_both() {
560        let mut sm = make_sm();
561        sm.set_state_with_extra(
562            ChainId::PaseoAssetHub,
563            ChainState::Live {
564                best_block: 100,
565                peers: 5,
566            },
567            ChainExtra::Btc {
568                tip_height: 800000,
569                fee_rate_sat_vb: 10,
570            },
571        );
572        let status = sm.status(ChainId::PaseoAssetHub);
573        assert!(matches!(
574            status.state,
575            ChainState::Live {
576                best_block: 100,
577                peers: 5
578            }
579        ));
580        assert!(matches!(
581            status.extra,
582            ChainExtra::Btc {
583                tip_height: 800000,
584                fee_rate_sat_vb: 10
585            }
586        ));
587    }
588
589    #[test]
590    fn process_response_unregistered_chain_is_noop() {
591        let mut sm = make_sm();
592        let resp = r#"{"jsonrpc":"2.0","id":1001,"result":{"peers":5,"isSyncing":false}}"#;
593        // Ethereum is not registered — should not panic or change anything
594        sm.process_response(ChainId::Ethereum, resp);
595        let status = sm.status(ChainId::Ethereum);
596        assert!(matches!(status.state, ChainState::Disconnected));
597    }
598
599    #[test]
600    fn health_check_request_unregistered_returns_none() {
601        let mut sm = make_sm();
602        assert!(sm.health_check_request(ChainId::Ethereum).is_none());
603    }
604
605    #[test]
606    fn health_check_request_id_starts_above_1000_and_increments() {
607        let mut sm = make_sm();
608        let req1 = sm.health_check_request(ChainId::PaseoAssetHub).unwrap();
609        let req2 = sm.health_check_request(ChainId::PaseoAssetHub).unwrap();
610
611        let v1: serde_json::Value = serde_json::from_str(&req1).unwrap();
612        let v2: serde_json::Value = serde_json::from_str(&req2).unwrap();
613
614        let id1 = v1["id"].as_u64().unwrap();
615        let id2 = v2["id"].as_u64().unwrap();
616
617        assert!(id1 > 1000);
618        assert_eq!(id2, id1 + 1);
619    }
620
621    #[test]
622    fn all_statuses_includes_registered_chains() {
623        let sm = make_sm();
624        let statuses = sm.all_statuses();
625        let paseo = statuses
626            .iter()
627            .find(|s| s.id == ChainId::PaseoAssetHub)
628            .expect("PaseoAssetHub should be in all_statuses");
629        assert!(matches!(paseo.state, ChainState::Connecting));
630    }
631
632    #[test]
633    fn all_statuses_returns_disconnected_for_unregistered() {
634        let sm = make_sm();
635        let statuses = sm.all_statuses();
636        let eth = statuses
637            .iter()
638            .find(|s| s.id == ChainId::Ethereum)
639            .expect("Ethereum should be in all_statuses (Disconnected)");
640        assert!(matches!(eth.state, ChainState::Disconnected));
641    }
642
643    #[test]
644    fn parse_statement_notification_data_statements_path() {
645        let text = r#"{"jsonrpc":"2.0","method":"statement_subscribeStatement","params":{"result":{"data":{"statements":["0xab","0xcd"]}}}}"#;
646        let stmts = ChainStateMachine::<InMemoryStore>::parse_statement_notification(text);
647        assert_eq!(stmts, vec!["0xab", "0xcd"]);
648    }
649
650    #[test]
651    fn parse_statement_notification_new_statements_path() {
652        let text = r#"{"jsonrpc":"2.0","method":"statement_subscribeStatement","params":{"result":{"newStatements":{"statements":["0xef"]}}}}"#;
653        let stmts = ChainStateMachine::<InMemoryStore>::parse_statement_notification(text);
654        assert_eq!(stmts, vec!["0xef"]);
655    }
656
657    #[test]
658    fn parse_statement_notification_plain_statements_path() {
659        let text = r#"{"jsonrpc":"2.0","method":"statement_subscribeStatement","params":{"result":{"statements":["0x11","0x22","0x33"]}}}"#;
660        let stmts = ChainStateMachine::<InMemoryStore>::parse_statement_notification(text);
661        assert_eq!(stmts, vec!["0x11", "0x22", "0x33"]);
662    }
663
664    #[test]
665    fn parse_statement_notification_wrong_method_returns_empty() {
666        let text = r#"{"jsonrpc":"2.0","method":"chain_newHead","params":{"result":{"statements":["0x11"]}}}"#;
667        let stmts = ChainStateMachine::<InMemoryStore>::parse_statement_notification(text);
668        assert!(stmts.is_empty());
669    }
670
671    #[test]
672    fn parse_statement_notification_no_statements_returns_empty() {
673        let text =
674            r#"{"jsonrpc":"2.0","method":"statement_subscribeStatement","params":{"result":{}}}"#;
675        let stmts = ChainStateMachine::<InMemoryStore>::parse_statement_notification(text);
676        assert!(stmts.is_empty());
677    }
678
679    #[test]
680    fn para_db_save_error_does_not_change_state() {
681        let mut sm = make_sm();
682        // Set to Live first
683        let health = r#"{"jsonrpc":"2.0","id":1001,"result":{"peers":5,"isSyncing":false}}"#;
684        sm.process_response(ChainId::PaseoAssetHub, health);
685
686        // DB save error response — should not trigger health or new-head branches
687        let error_resp = format!(
688            r#"{{"jsonrpc":"2.0","id":{},"error":{{"code":-32000,"message":"db error"}}}}"#,
689            REQ_ID_PARA_DB_SAVE,
690        );
691        sm.process_response(ChainId::PaseoAssetHub, &error_resp);
692
693        // State should still be Live with peers=5
694        let status = sm.status(ChainId::PaseoAssetHub);
695        assert!(matches!(status.state, ChainState::Live { peers: 5, .. }));
696        // And nothing should have been saved to the store
697        assert_eq!(sm.store().load("PaseoAssetHub"), "");
698    }
699
700    #[test]
701    fn register_chain_entry_sets_connecting() {
702        use crate::chain::ConnectionBackend;
703        use crate::registry::ChainRegistryEntry;
704        let store = InMemoryStore::new();
705        let mut sm = ChainStateMachine::new(store);
706        sm.register_chain_entry(&ChainRegistryEntry {
707            id: ChainId::PaseoAssetHub,
708            genesis_hash: [0u8; 32],
709            display_name: "Paseo Asset Hub".to_string(),
710            endpoint: String::new(),
711            backend: ConnectionBackend::Smoldot,
712            relay_db_key: "custom-relay".to_string(),
713            para_db_key: "custom-para".to_string(),
714            chain_specs: None,
715        });
716        let status = sm.status(ChainId::PaseoAssetHub);
717        assert!(matches!(status.state, ChainState::Connecting));
718    }
719
720    #[test]
721    fn register_chain_entry_uses_entry_db_keys() {
722        use crate::chain::ConnectionBackend;
723        use crate::registry::ChainRegistryEntry;
724        let store = InMemoryStore::new();
725        let mut sm = ChainStateMachine::new(store);
726        sm.register_chain_entry(&ChainRegistryEntry {
727            id: ChainId::PaseoAssetHub,
728            genesis_hash: [0u8; 32],
729            display_name: "Paseo Asset Hub".to_string(),
730            endpoint: String::new(),
731            backend: ConnectionBackend::Smoldot,
732            relay_db_key: "custom-relay".to_string(),
733            para_db_key: "custom-para".to_string(),
734            chain_specs: None,
735        });
736
737        // Simulate relay DB save — should use the entry's key
738        let resp = format!(
739            r#"{{"jsonrpc":"2.0","id":{},"result":"relay-data"}}"#,
740            REQ_ID_RELAY_DB_SAVE,
741        );
742        sm.process_relay_response(ChainId::PaseoAssetHub, &resp);
743        assert_eq!(sm.store().load("custom-relay"), "relay-data");
744
745        // Simulate para DB save — should use the entry's key
746        let resp2 = format!(
747            r#"{{"jsonrpc":"2.0","id":{},"result":"para-data"}}"#,
748            REQ_ID_PARA_DB_SAVE,
749        );
750        sm.process_response(ChainId::PaseoAssetHub, &resp2);
751        assert_eq!(sm.store().load("custom-para"), "para-data");
752    }
753
754    #[test]
755    fn chain_specs_owned_returns_entry_specs() {
756        use crate::chain::ConnectionBackend;
757        use crate::registry::ChainRegistryEntry;
758        let store = InMemoryStore::new();
759        let mut sm = ChainStateMachine::new(store);
760        sm.register_chain_entry(&ChainRegistryEntry {
761            id: ChainId::PaseoAssetHub,
762            genesis_hash: [0u8; 32],
763            display_name: "Paseo Asset Hub".to_string(),
764            endpoint: String::new(),
765            backend: ConnectionBackend::Rpc,
766            relay_db_key: "r".to_string(),
767            para_db_key: "p".to_string(),
768            chain_specs: Some(("relay-spec".to_string(), "para-spec".to_string())),
769        });
770        let specs = sm.chain_specs_owned(ChainId::PaseoAssetHub);
771        assert_eq!(
772            specs,
773            Some(("relay-spec".to_string(), "para-spec".to_string()))
774        );
775
776        // Unregistered chain returns None
777        assert!(sm.chain_specs_owned(ChainId::Ethereum).is_none());
778    }
779
780    #[test]
781    fn register_chain_entry_backward_compat_with_register_chain() {
782        // register_chain (enum path) and register_chain_entry (registry path)
783        // should produce identical observable behavior when entry values match.
784        use crate::chain::ConnectionBackend;
785        use crate::registry::ChainRegistryEntry;
786
787        let store1 = InMemoryStore::new();
788        let mut sm1 = ChainStateMachine::new(store1);
789        sm1.register_chain(ChainId::PaseoAssetHub);
790
791        let store2 = InMemoryStore::new();
792        let mut sm2 = ChainStateMachine::new(store2);
793        sm2.register_chain_entry(&ChainRegistryEntry {
794            id: ChainId::PaseoAssetHub,
795            genesis_hash: [0u8; 32],
796            display_name: ChainId::PaseoAssetHub.display_name().to_string(),
797            endpoint: ChainId::PaseoAssetHub.endpoint().to_string(),
798            backend: ConnectionBackend::Smoldot,
799            relay_db_key: ChainId::PaseoAssetHub.relay_db_key().to_string(),
800            para_db_key: ChainId::PaseoAssetHub.para_db_key(),
801            chain_specs: ChainId::PaseoAssetHub
802                .chain_specs()
803                .map(|(r, p)| (r.to_string(), p.to_string())),
804        });
805
806        // Both should produce same status
807        let s1 = sm1.status(ChainId::PaseoAssetHub);
808        let s2 = sm2.status(ChainId::PaseoAssetHub);
809        assert_eq!(s1.name, s2.name);
810        assert!(matches!(s1.state, ChainState::Connecting));
811        assert!(matches!(s2.state, ChainState::Connecting));
812
813        // Both should save para DB to the same key
814        let resp = format!(
815            r#"{{"jsonrpc":"2.0","id":{},"result":"db-content"}}"#,
816            REQ_ID_PARA_DB_SAVE,
817        );
818        sm1.process_response(ChainId::PaseoAssetHub, &resp);
819        sm2.process_response(ChainId::PaseoAssetHub, &resp);
820        assert_eq!(
821            sm1.store().load(&ChainId::PaseoAssetHub.para_db_key()),
822            sm2.store().load(&ChainId::PaseoAssetHub.para_db_key()),
823        );
824    }
825}