From ec0ccd27b588d056463d4285e6c6a5a4fd763acb Mon Sep 17 00:00:00 2001 From: Alexandru Vasile Date: Tue, 3 Mar 2026 09:41:35 +0000 Subject: [PATCH 1/8] config: Add custom chain config Signed-off-by: Alexandru Vasile --- src/config.rs | 316 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 287 insertions(+), 29 deletions(-) diff --git a/src/config.rs b/src/config.rs index d43534f..328d00e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -282,6 +282,14 @@ pub enum Parachain { maybe_bite_at: MaybeByteAt, maybe_rpc_endpoint: MaybeSyncUrl, }, + Custom { + para_id: u32, + name: String, + chain_spec: String, + maybe_override: MaybeWasmOverridePath, + maybe_bite_at: MaybeByteAt, + maybe_rpc_endpoint: MaybeSyncUrl, + }, } impl Parachain { @@ -317,6 +325,7 @@ impl Parachain { Parachain::People { .. } => "people", Parachain::BridgeHub { .. } => "bridge-hub", Parachain::Collectives { .. } => "collectives", + Parachain::Custom { name, .. } => return format!("{name}-{relay_part}-local"), }; format!("{para_part}-{relay_part}-local") @@ -329,6 +338,7 @@ impl Parachain { Parachain::People { .. } => "people", Parachain::BridgeHub { .. } => "bridge-hub", Parachain::Collectives { .. } => "collectives", + Parachain::Custom { name, .. } => return format!("{name}-{relay_part}"), }; format!("{para_part}-{relay_part}") @@ -345,6 +355,7 @@ impl Parachain { Parachain::People { .. } => 1004, Parachain::BridgeHub { .. } => 1002, Parachain::Collectives { .. } => 1001, + Parachain::Custom { para_id, .. } => *para_id, } } @@ -354,7 +365,8 @@ impl Parachain { | Parachain::Coretime { maybe_override, .. } | Parachain::People { maybe_override, .. } | Parachain::BridgeHub { maybe_override, .. } - | Parachain::Collectives { maybe_override, .. } => maybe_override.as_deref(), + | Parachain::Collectives { maybe_override, .. } + | Parachain::Custom { maybe_override, .. } => maybe_override.as_deref(), } } @@ -364,7 +376,8 @@ impl Parachain { | Parachain::Coretime { maybe_bite_at, .. } | Parachain::People { maybe_bite_at, .. } | Parachain::BridgeHub { maybe_bite_at, .. } - | Parachain::Collectives { maybe_bite_at, .. } => *maybe_bite_at, + | Parachain::Collectives { maybe_bite_at, .. } + | Parachain::Custom { maybe_bite_at, .. } => *maybe_bite_at, } } @@ -384,9 +397,23 @@ impl Parachain { } | Parachain::Collectives { maybe_rpc_endpoint, .. + } + | Parachain::Custom { + maybe_rpc_endpoint, .. } => maybe_rpc_endpoint.as_deref(), } } + + pub fn chain_spec_path(&self) -> Option<&str> { + match self { + Parachain::Custom { chain_spec, .. } => Some(chain_spec.as_str()), + _ => None, + } + } + + pub fn is_custom(&self) -> bool { + matches!(self, Parachain::Custom { .. }) + } } // Chain generator command template @@ -461,34 +488,44 @@ pub fn generate_network_config( let network_builder = paras.iter().fold(network_builder, |builder, para| { println!("para: {:?}", para); - let (chain_part, id) = match para { - Parachain::AssetHub { .. } => ("asset-hub", para.id()), - Parachain::Coretime{ .. } => ("coretime", para.id()), - Parachain::People { .. } => ("people", para.id()), - Parachain::BridgeHub { .. } => ("bridge-hub", para.id()), - Parachain::Collectives { .. } => ("collectives", para.id()), - }; - let chain = format!("{}-{}",chain_part, relay_chain); - + let id = para.id(); let collator_name = format!("Collator-{}", id); + builder.with_parachain(|p| { - p.with_id(id) - .with_default_command(para_context.cmd().as_str()) - .with_chain(chain.as_str()) - .with_chain_spec_command(chain_spec_cmd) - .with_collator(|c| { - let col_builder = c.with_name(&collator_name) - .with_args(vec![ - ("-l", "aura=debug,runtime=trace,cumulus-consensus=trace,consensus::common=trace,parachain::collation-generation=trace,parachain::collator-protocol=trace,parachain=debug,basic-authorship=trace").into(), - "--force-authoring".into() - ]); - if let Ok(port) = env::var("ZOMBIE_BITE_AH_PORT") { - let rpc_port = port.parse().expect("env var ZOMBIE_BITE_AH_PORT must be a valid u16"); - col_builder.with_rpc_port(rpc_port) - } else { - col_builder - } - }) + let p = p + .with_id(id) + .with_default_command(para_context.cmd().as_str()); + + // Custom paras use chain_spec_path directly; system paras use chain name + spec command + let p = if let Some(spec_path) = para.chain_spec_path() { + p.with_chain_spec_path(spec_path) + } else { + let chain_part = match para { + Parachain::AssetHub { .. } => "asset-hub", + Parachain::Coretime { .. } => "coretime", + Parachain::People { .. } => "people", + Parachain::BridgeHub { .. } => "bridge-hub", + Parachain::Collectives { .. } => "collectives", + Parachain::Custom { .. } => unreachable!(), + }; + let chain = format!("{}-{}", chain_part, relay_chain); + p.with_chain(chain.as_str()) + .with_chain_spec_command(chain_spec_cmd) + }; + + p.with_collator(|c| { + let col_builder = c.with_name(&collator_name) + .with_args(vec![ + ("-l", "aura=debug,runtime=trace,cumulus-consensus=trace,consensus::common=trace,parachain::collation-generation=trace,parachain::collator-protocol=trace,parachain=debug,basic-authorship=trace").into(), + "--force-authoring".into() + ]); + if let Ok(port) = env::var("ZOMBIE_BITE_AH_PORT") { + let rpc_port = port.parse().expect("env var ZOMBIE_BITE_AH_PORT must be a valid u16"); + col_builder.with_rpc_port(rpc_port) + } else { + col_builder + } + }) }) }); @@ -523,11 +560,14 @@ pub struct RelaychainConfig { #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct ParachainConfig { #[serde(rename = "type")] - pub parachain_type: String, // asset-hub, coretime, people, bridge-hub + pub parachain_type: String, // asset-hub, coretime, people, bridge-hub, collectives, custom pub runtime_override: Option, pub enabled: Option, // default true pub bite_at: Option, pub rpc_endpoint: Option, + pub para_id: Option, + pub chain_spec: Option, + pub name: Option, } impl ParachainConfig { @@ -559,6 +599,31 @@ impl ParachainConfig { maybe_bite_at: self.bite_at, maybe_rpc_endpoint: self.rpc_endpoint.clone(), }), + "custom" => { + let para_id = self + .para_id + .expect("Custom parachain requires 'para_id' field"); + let chain_spec = self + .chain_spec + .clone() + .expect("Custom parachain requires 'chain_spec' field"); + let rpc_endpoint = self + .rpc_endpoint + .clone() + .expect("Custom parachain requires 'rpc_endpoint' field"); + let name = self + .name + .clone() + .unwrap_or_else(|| format!("custom-{}", para_id)); + Some(Parachain::Custom { + para_id, + name, + chain_spec, + maybe_override: self.runtime_override.clone(), + maybe_bite_at: self.bite_at, + maybe_rpc_endpoint: Some(rpc_endpoint), + }) + } _ => None, } } else { @@ -635,6 +700,9 @@ mod test { enabled: None, // Not specified bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }; assert!(config.to_parachain().is_some()); @@ -652,6 +720,9 @@ mod test { enabled: Some(true), bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }; assert!(config.to_parachain().is_some()); @@ -669,6 +740,9 @@ mod test { enabled: Some(false), bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }; assert!(config.to_parachain().is_none()); @@ -683,6 +757,9 @@ mod test { enabled: Some(true), bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }; let parachain = config.to_parachain().unwrap(); @@ -703,6 +780,9 @@ mod test { enabled: Some(true), bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }; assert!(config.to_parachain().is_none()); @@ -719,6 +799,9 @@ mod test { enabled: Some(true), bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }; assert!( @@ -973,6 +1056,9 @@ mod test { enabled: Some(true), bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }, ParachainConfig { parachain_type: "coretime".to_string(), @@ -980,6 +1066,9 @@ mod test { enabled: Some(false), // Disabled bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }, ParachainConfig { parachain_type: "people".to_string(), @@ -987,6 +1076,9 @@ mod test { enabled: None, // Defaults to true bite_at: None, rpc_endpoint: None, + para_id: None, + chain_spec: None, + name: None, }, ]), base_path: None, @@ -1094,4 +1186,170 @@ network = "polkadot" let parachains = config.get_parachains(); assert_eq!(parachains.len(), 0); // No parachains specified } + + #[test] + fn custom_parachain_id() { + let para = Parachain::Custom { + para_id: 3392, + name: "yap-3392".to_string(), + chain_spec: "/path/to/spec.json".to_string(), + maybe_override: None, + maybe_bite_at: None, + maybe_rpc_endpoint: Some("wss://example.com".to_string()), + }; + assert_eq!(para.id(), 3392); + } + + #[test] + fn custom_parachain_chain_strings() { + let para = Parachain::Custom { + para_id: 3392, + name: "yap-3392".to_string(), + chain_spec: "/path/to/spec.json".to_string(), + maybe_override: None, + maybe_bite_at: None, + maybe_rpc_endpoint: Some("wss://example.com".to_string()), + }; + assert_eq!(para.as_chain_string("kusama"), "yap-3392-kusama"); + assert_eq!(para.as_local_chain_string("kusama"), "yap-3392-kusama-local"); + } + + #[test] + fn custom_parachain_chain_spec_path() { + let para = Parachain::Custom { + para_id: 3392, + name: "yap-3392".to_string(), + chain_spec: "/path/to/spec.json".to_string(), + maybe_override: None, + maybe_bite_at: None, + maybe_rpc_endpoint: Some("wss://example.com".to_string()), + }; + assert_eq!(para.chain_spec_path(), Some("/path/to/spec.json")); + assert!(para.is_custom()); + + // Non-custom paras return None for chain_spec_path + let ah = Parachain::new("asset-hub"); + assert_eq!(ah.chain_spec_path(), None); + assert!(!ah.is_custom()); + } + + #[test] + fn custom_parachain_config_from_toml() { + let toml_content = r#" +[relaychain] +network = "kusama" + +[[parachains]] +type = "custom" +para_id = 3392 +name = "yap-3392" +rpc_endpoint = "wss://kusama-yap-3392.parity-chains.parity.io" +chain_spec = "/path/to/yap-3392-raw-chain-spec.json" + "#; + + let config: ZombieBiteConfig = toml::from_str(toml_content).unwrap(); + let parachains = config.get_parachains(); + assert_eq!(parachains.len(), 1); + + let para = ¶chains[0]; + assert_eq!(para.id(), 3392); + assert!(para.is_custom()); + assert_eq!( + para.chain_spec_path(), + Some("/path/to/yap-3392-raw-chain-spec.json") + ); + assert_eq!( + para.rpc_endpoint(), + Some("wss://kusama-yap-3392.parity-chains.parity.io") + ); + assert_eq!(para.as_chain_string("kusama"), "yap-3392-kusama"); + } + + #[test] + fn custom_parachain_config_default_name() { + let config = ParachainConfig { + parachain_type: "custom".to_string(), + runtime_override: None, + enabled: Some(true), + bite_at: None, + rpc_endpoint: Some("wss://example.com".to_string()), + para_id: Some(3392), + chain_spec: Some("/path/to/spec.json".to_string()), + name: None, // Should default to "custom-3392" + }; + + let para = config.to_parachain().unwrap(); + assert_eq!(para.as_chain_string("kusama"), "custom-3392-kusama"); + } + + #[test] + #[should_panic(expected = "Custom parachain requires 'para_id' field")] + fn custom_parachain_config_missing_para_id() { + let config = ParachainConfig { + parachain_type: "custom".to_string(), + runtime_override: None, + enabled: Some(true), + bite_at: None, + rpc_endpoint: Some("wss://example.com".to_string()), + para_id: None, + chain_spec: Some("/path/to/spec.json".to_string()), + name: None, + }; + config.to_parachain(); + } + + #[test] + #[should_panic(expected = "Custom parachain requires 'chain_spec' field")] + fn custom_parachain_config_missing_chain_spec() { + let config = ParachainConfig { + parachain_type: "custom".to_string(), + runtime_override: None, + enabled: Some(true), + bite_at: None, + rpc_endpoint: Some("wss://example.com".to_string()), + para_id: Some(3392), + chain_spec: None, + name: None, + }; + config.to_parachain(); + } + + #[test] + #[should_panic(expected = "Custom parachain requires 'rpc_endpoint' field")] + fn custom_parachain_config_missing_rpc_endpoint() { + let config = ParachainConfig { + parachain_type: "custom".to_string(), + runtime_override: None, + enabled: Some(true), + bite_at: None, + rpc_endpoint: None, + para_id: Some(3392), + chain_spec: Some("/path/to/spec.json".to_string()), + name: None, + }; + config.to_parachain(); + } + + #[test] + fn generate_config_with_custom_parachain() { + let relaychain = Relaychain::new("kusama"); + // Create a temp chain spec file for the test + let spec_path = "/tmp/test-custom-para-spec.json"; + std::fs::write(spec_path, r#"{"bootNodes": []}"#).unwrap(); + + let parachains = vec![Parachain::Custom { + para_id: 3392, + name: "yap-3392".to_string(), + chain_spec: spec_path.to_string(), + maybe_override: None, + maybe_bite_at: None, + maybe_rpc_endpoint: Some("wss://example.com".to_string()), + }]; + + let config = generate_network_config(&relaychain, parachains).unwrap(); + let parachains = config.parachains(); + assert_eq!(parachains.len(), 1); + let para_config = parachains.first().unwrap(); + assert_eq!(para_config.id(), 3392); + } } From 55d9c45e038f4db1d7044070ecf379c88947c87b Mon Sep 17 00:00:00 2001 From: Alexandru Vasile Date: Tue, 3 Mar 2026 09:42:31 +0000 Subject: [PATCH 2/8] doppelganger: Do not rebuild chainspec for custom ones Signed-off-by: Alexandru Vasile --- src/doppelganger.rs | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/doppelganger.rs b/src/doppelganger.rs index 39d4c37..f717d09 100644 --- a/src/doppelganger.rs +++ b/src/doppelganger.rs @@ -132,24 +132,40 @@ pub async fn doppelganger_inner( for (para_index, (_sync_node, sync_db_path, sync_chain, sync_head_path)) in res.into_iter().enumerate() { + let para = paras_to + .get(para_index) + .expect("para_index should be valid. qed"); + let sync_chain_name = if sync_chain.contains('/') { - let parts: Vec<&str> = sync_chain.split('/').collect(); - let name_parts: Vec<&str> = parts.last().unwrap().split('.').collect(); - name_parts.first().unwrap().to_string() + // File path (custom para or paseo) — use the canonical chain string for naming + para.as_chain_string(&relay_chain.as_chain_string()) } else { - // is not a file sync_chain.clone() }; let chain_spec_path = format!("{}/{}-spec.json", &base_dir_str, &sync_chain_name); - generate_chain_spec( - ns.clone(), - &chain_spec_path, - &context_para.doppelganger_cmd(), - &sync_chain, - ) - .await - .unwrap(); + + if para.is_custom() { + // For custom paras, copy the user's chain spec and clear bootNodes + // instead of running `doppelganger-parachain build-spec` which may not + // understand arbitrary runtimes. + let spec_content = std::fs::read_to_string(&sync_chain) + .unwrap_or_else(|_| panic!("Failed to read custom chain spec: {}", &sync_chain)); + let mut spec_json: serde_json::Value = serde_json::from_str(&spec_content) + .unwrap_or_else(|_| panic!("Failed to parse custom chain spec JSON: {}", &sync_chain)); + spec_json["bootNodes"] = serde_json::Value::Array(vec![]); + let contents = serde_json::to_string_pretty(&spec_json).unwrap(); + tokio::fs::write(&chain_spec_path, contents).await.unwrap(); + } else { + generate_chain_spec( + ns.clone(), + &chain_spec_path, + &context_para.doppelganger_cmd(), + &sync_chain, + ) + .await + .unwrap(); + } // generate the data.tgz to use as snapshot let snap_path = format!("{}/{}-snap.tgz", &base_dir_str, &sync_chain_name); @@ -170,9 +186,6 @@ pub async fn doppelganger_inner( .encode(), ); - let para = paras_to - .get(para_index) - .expect("para_index should be valid. qed"); para_heads_env.push(( format!("ZOMBIE_{}", ¶_head_key(para.id())[2..]), para_head[2..].to_string(), From 4e618bedc380f511c9f346274c058a31674dfd3d Mon Sep 17 00:00:00 2001 From: Alexandru Vasile Date: Tue, 3 Mar 2026 09:42:52 +0000 Subject: [PATCH 3/8] cli: Support custom chains as input Signed-off-by: Alexandru Vasile --- src/cli.rs | 27 ++++++++++++++++++++++++++- src/sync.rs | 7 ++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index a73c873..58aba34 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,6 +36,8 @@ pub enum Commands { #[arg(long = "rc-bite-at", verbatim_doc_comment)] relay_bite_at: Option, /// Parachains to include: asset-hub, coretime, people, bridge-hub, collectives (comma-separated) + /// For custom parachains use: custom::: + /// Example: custom:3392:wss://kusama-yap-3392.example.com:/path/to/chain-spec.json #[arg(long, short = 'p', value_delimiter = ',', verbatim_doc_comment)] parachains: Option>, /// Base path to use. if not provided we will check the env 'ZOMBIE_BITE_BASE_PATH' and if not present we will use `_timestamp` @@ -215,10 +217,33 @@ pub fn resolve_bite_config( maybe_bite_at: None, maybe_rpc_endpoint: None, }), + s if s.starts_with("custom:") => { + let parts: Vec<&str> = s.splitn(4, ':').collect(); + if parts.len() != 4 { + panic!( + "Custom parachain format must be custom:::, got: {}", + s + ); + } + let para_id: u32 = parts[1] + .parse() + .unwrap_or_else(|_| panic!("Invalid para_id '{}' in custom parachain", parts[1])); + let rpc_endpoint = parts[2].to_string(); + let chain_spec = parts[3].to_string(); + let name = format!("custom-{}", para_id); + Some(Parachain::Custom { + para_id, + name, + chain_spec, + maybe_override: None, + maybe_bite_at: None, + maybe_rpc_endpoint: Some(rpc_endpoint), + }) + } unknown => { warn!( "⚠️ Warning: Unknown parachain '{}' will be ignored. - Valid options are: asset-hub, coretime, people, bridge-hub, collectives", + Valid options are: asset-hub, coretime, people, bridge-hub, collectives, custom:::", unknown ); None diff --git a/src/sync.rs b/src/sync.rs index 8779839..7e75366 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -139,8 +139,13 @@ pub async fn sync_para( trace!("env: {env:?}"); + // Custom parachains: use the provided chain spec file path directly + let chain_arg_for_custom; let dest_for_paseo = format!("{}/asset-hub-paseo.json", ns.base_dir().to_string_lossy(),); - let chain_arg = if chain == "asset-hub-paseo" { + let chain_arg = if let Some(spec_path) = parachain.chain_spec_path() { + chain_arg_for_custom = spec_path.to_string(); + chain_arg_for_custom.as_str() + } else if chain == "asset-hub-paseo" { // get chain spec from https://paseo-r2.zondax.ch/chain-specs/paseo-asset-hub.json let response = reqwest::get(PASEO_ASSET_HUB_SPEC_URL) .await From 0e285a0fe4a956ef81a63e6c227f9763dfef983e Mon Sep 17 00:00:00 2001 From: Alexandru Vasile Date: Tue, 3 Mar 2026 09:43:12 +0000 Subject: [PATCH 4/8] examples: Add custom parachain example on kusama Signed-off-by: Alexandru Vasile --- examples/custom-parachain.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 examples/custom-parachain.toml diff --git a/examples/custom-parachain.toml b/examples/custom-parachain.toml new file mode 100644 index 0000000..83ec976 --- /dev/null +++ b/examples/custom-parachain.toml @@ -0,0 +1,10 @@ +[relaychain] +network = "kusama" + +[[parachains]] +type = "custom" +para_id = 3392 +name = "yap-3392" +rpc_endpoint = "wss://kusama-yap-3392.parity-chains.parity.io" +chain_spec = "/path/to/yap-3392-raw-chain-spec.json" +# runtime_override = "/path/to/new_runtime.wasm" From f1b50dbec95a7a4badf1ed211822839159ef30cc Mon Sep 17 00:00:00 2001 From: Javier Viola Date: Fri, 19 Jun 2026 09:38:01 +0200 Subject: [PATCH 5/8] fmt --- src/config.rs | 26 ++++++++++++++++---------- src/doppelganger.rs | 9 ++++++--- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 727eed1..58926ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -402,7 +402,6 @@ impl Parachain { Parachain::BridgeHub { .. } => "bridge-hub", Parachain::Collectives { .. } => "collectives", Parachain::Custom { id, .. } => &id.to_string(), - }; format!("{para_part}-{relay_part}-local") @@ -704,8 +703,11 @@ impl ParachainConfig { }), "custom" => { // validate chain / id - let (Some(id), Some(chain_spec), Some(rpc_endpoint)) = (self.id.clone(), self.chain_spec.clone(), self.rpc_endpoint.clone()) - else { + let (Some(id), Some(chain_spec), Some(rpc_endpoint)) = ( + self.id.clone(), + self.chain_spec.clone(), + self.rpc_endpoint.clone(), + ) else { panic!("Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required"); }; @@ -1324,8 +1326,7 @@ network = "polkadot" maybe_override: None, maybe_bite_at: None, maybe_rpc_endpoint: Some("wss://example.com".to_string()), - cores: 1 - + cores: 1, }; assert_eq!(para.chain_spec_path(), Some("/path/to/spec.json")); assert!(para.is_custom()); @@ -1379,7 +1380,6 @@ chain_spec = "/path/to/yap-3392-raw-chain-spec.json" id: Some(3392), chain_spec: Some("/path/to/spec.json".to_string()), cores: None, - }; let para = config.to_parachain().unwrap(); @@ -1387,7 +1387,9 @@ chain_spec = "/path/to/yap-3392-raw-chain-spec.json" } #[test] - #[should_panic(expected = "Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required")] + #[should_panic( + expected = "Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required" + )] fn custom_parachain_config_missing_para_id() { let config = ParachainConfig { parachain_type: "custom".to_string(), @@ -1403,7 +1405,9 @@ chain_spec = "/path/to/yap-3392-raw-chain-spec.json" } #[test] - #[should_panic(expected = "Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required")] + #[should_panic( + expected = "Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required" + )] fn custom_parachain_config_missing_chain_spec() { let config = ParachainConfig { parachain_type: "custom".to_string(), @@ -1413,13 +1417,15 @@ chain_spec = "/path/to/yap-3392-raw-chain-spec.json" rpc_endpoint: Some("wss://example.com".to_string()), id: Some(3392), chain_spec: None, - cores: None + cores: None, }; config.to_parachain(); } #[test] - #[should_panic(expected = "Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required")] + #[should_panic( + expected = "Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required" + )] fn custom_parachain_config_missing_rpc_endpoint() { let config = ParachainConfig { parachain_type: "custom".to_string(), diff --git a/src/doppelganger.rs b/src/doppelganger.rs index b1458e8..4a30eab 100644 --- a/src/doppelganger.rs +++ b/src/doppelganger.rs @@ -151,14 +151,17 @@ pub async fn doppelganger_inner( let sync_chain_name = para.as_chain_string(&relay_chain.as_chain_string()); let chain_spec_path = format!("{}/{}-spec.json", base_dir_str, sync_chain_name); - if para.is_custom() { + if para.is_custom() { // For custom paras, copy the user's chain spec and clear bootNodes // instead of running `doppelganger-parachain build-spec` which may not // understand arbitrary runtimes. - let spec_content = tokio::fs::read_to_string(&sync_chain).await + let spec_content = tokio::fs::read_to_string(&sync_chain) + .await .unwrap_or_else(|_| panic!("Failed to read custom chain spec: {}", &sync_chain)); let mut spec_json: serde_json::Value = serde_json::from_str(&spec_content) - .unwrap_or_else(|_| panic!("Failed to parse custom chain spec JSON: {}", &sync_chain)); + .unwrap_or_else(|_| { + panic!("Failed to parse custom chain spec JSON: {}", &sync_chain) + }); spec_json["bootNodes"] = serde_json::Value::Array(vec![]); let contents = serde_json::to_string_pretty(&spec_json).unwrap(); tokio::fs::write(&chain_spec_path, contents).await.unwrap(); From 2b554e2a7bcc005e44fc39d238edf5ef39c2274b Mon Sep 17 00:00:00 2001 From: Javier Viola Date: Fri, 19 Jun 2026 09:47:23 +0200 Subject: [PATCH 6/8] clippy --- src/config.rs | 6 +----- src/doppelganger.rs | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/config.rs b/src/config.rs index 58926ca..8eb2e1a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -704,17 +704,13 @@ impl ParachainConfig { "custom" => { // validate chain / id let (Some(id), Some(chain_spec), Some(rpc_endpoint)) = ( - self.id.clone(), + self.id, self.chain_spec.clone(), self.rpc_endpoint.clone(), ) else { panic!("Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required"); }; - // let id: u32 = id - // .parse() - // .unwrap_or_else(|_| panic!("Invalid custom para id: {}", id)); - Some(Parachain::Custom { maybe_override: self.runtime_override.clone(), maybe_bite_at: self.bite_at, diff --git a/src/doppelganger.rs b/src/doppelganger.rs index 4a30eab..77a3130 100644 --- a/src/doppelganger.rs +++ b/src/doppelganger.rs @@ -157,10 +157,10 @@ pub async fn doppelganger_inner( // understand arbitrary runtimes. let spec_content = tokio::fs::read_to_string(&sync_chain) .await - .unwrap_or_else(|_| panic!("Failed to read custom chain spec: {}", &sync_chain)); + .unwrap_or_else(|_| panic!("Failed to read custom chain spec: {}", sync_chain)); let mut spec_json: serde_json::Value = serde_json::from_str(&spec_content) .unwrap_or_else(|_| { - panic!("Failed to parse custom chain spec JSON: {}", &sync_chain) + panic!("Failed to parse custom chain spec JSON: {}", sync_chain) }); spec_json["bootNodes"] = serde_json::Value::Array(vec![]); let contents = serde_json::to_string_pretty(&spec_json).unwrap(); From cf2b19597c4a2f3a541dc1409b402ffaaebd08ae Mon Sep 17 00:00:00 2001 From: Javier Viola Date: Fri, 19 Jun 2026 09:49:26 +0200 Subject: [PATCH 7/8] fmt --- src/config.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index 8eb2e1a..ecb3a2b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -703,11 +703,9 @@ impl ParachainConfig { }), "custom" => { // validate chain / id - let (Some(id), Some(chain_spec), Some(rpc_endpoint)) = ( - self.id, - self.chain_spec.clone(), - self.rpc_endpoint.clone(), - ) else { + let (Some(id), Some(chain_spec), Some(rpc_endpoint)) = + (self.id, self.chain_spec.clone(), self.rpc_endpoint.clone()) + else { panic!("Invalid custom parachain config, 'id', 'chain_spec' and 'rpc_endpoint' are required"); }; From a14c827d0d6e01a4bf35c7ed647baeb57332ca54 Mon Sep 17 00:00:00 2001 From: Javier Viola Date: Fri, 19 Jun 2026 11:33:38 +0200 Subject: [PATCH 8/8] allow to set req_cores in cli --- src/cli.rs | 125 +++++++++++++++++++++++++++++++++++++++----------- src/config.rs | 7 +++ 2 files changed, 106 insertions(+), 26 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 25df5dd..0f4421a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use std::{ str::FromStr, time::{SystemTime, UNIX_EPOCH}, }; -use tracing::warn; +use tracing::{trace, warn}; use crate::config::{Parachain, Relaychain, ZombieBiteConfig}; @@ -36,7 +36,7 @@ pub enum Commands { #[arg(long = "rc-bite-at", verbatim_doc_comment)] relay_bite_at: Option, /// Parachains to include: asset-hub, coretime, people, bridge-hub, collectives (comma-separated) - /// For custom parachains use: custom::: + /// For custom parachains use: custom%%%%[req_cores] /// Example: custom:3392:wss://kusama-yap-3392.example.com:/path/to/chain-spec.json #[arg(long, short = 'p', value_delimiter = ',', verbatim_doc_comment)] parachains: Option>, @@ -217,34 +217,13 @@ pub fn resolve_bite_config( maybe_bite_at: None, maybe_rpc_endpoint: None, }), - s if s.starts_with("custom:") => { - let parts: Vec<&str> = s.splitn(4, ':').collect(); - if parts.len() != 4 { - panic!( - "Custom parachain format must be custom:::, got: {}", - s - ); - } - let para_id: u32 = parts[1] - .parse() - .unwrap_or_else(|_| panic!("Invalid para_id '{}' in custom parachain", parts[1])); - let rpc_endpoint = parts[2].to_string(); - let chain_spec = parts[3].to_string(); - let name = format!("custom-{}", para_id); - Some(Parachain::Custom { - id: para_id, - name, - chain_spec, - maybe_override: None, - maybe_bite_at: None, - maybe_rpc_endpoint: Some(rpc_endpoint), - cores: 1, // needs to have a value from cli/toml - }) + s if s.starts_with("custom%") => { + Some(resolve_custom_parachain(s)) } unknown => { warn!( "⚠️ Warning: Unknown parachain '{}' will be ignored. - Valid options are: asset-hub, coretime, people, bridge-hub, collectives, custom:::", + Valid options are: asset-hub, coretime, people, bridge-hub, collectives, custom%%%%[req_cores]", unknown ); None @@ -317,3 +296,97 @@ pub fn resolve_spawn_config( with_monitor: resolved_with_monitor, }) } + +fn resolve_custom_parachain(s: &str) -> Parachain { + let parts: Vec<&str> = s.splitn(5, '%').collect(); + trace!("custom parts: {parts:?}"); + if parts.len() < 4 || parts.len() > 5 { + panic!( + "Custom parachain format must be custom%%%%[req_cores], got: {}", + s + ); + } + let para_id: u32 = parts[1] + .parse() + .unwrap_or_else(|_| panic!("Invalid para_id '{}' in custom parachain", parts[1])); + let rpc_endpoint = parts[2].to_string(); + let chain_spec = parts[3].to_string(); + let name = format!("custom-{}", para_id); + let req_cores = if parts.len() == 5 { + parts[4] + .parse() + .unwrap_or_else(|_| panic!("Invalid req_cores '{}' in custom parachain", parts[4])) + } else { + // default to 1 core + 1 + }; + Parachain::Custom { + id: para_id, + name, + chain_spec, + maybe_override: None, + maybe_bite_at: None, + maybe_rpc_endpoint: Some(rpc_endpoint), + cores: req_cores, + } +} + +#[cfg(test)] +mod test { + use super::*; + #[test] + fn custom_para_works() { + let s = "custom%3392%wss://kusama-yap-3392.example.com%/path/to/chain-spec.json"; + let para = resolve_custom_parachain(s); + assert_eq!(para.id(), 3392, "para id should be valid"); + assert_eq!( + para.rpc_endpoint(), + Some("wss://kusama-yap-3392.example.com"), + "rpc should match" + ); + assert_eq!( + para.chain_spec(), + Some("/path/to/chain-spec.json"), + "chain-spec should match" + ); + } + + #[test] + fn custom_para_works_with_port_number() { + let s = "custom%3392%wss://kusama-yap-3392.example.com:1234%/path/to/chain-spec.json"; + let para = resolve_custom_parachain(s); + assert_eq!( + para.rpc_endpoint(), + Some("wss://kusama-yap-3392.example.com:1234"), + "rpc should match" + ); + } + + #[test] + fn custom_para_works_with_cores() { + let s = "custom%3392%wss://kusama-yap-3392.example.com:1234%/path/to/chain-spec.json%3"; + let para = resolve_custom_parachain(s); + assert_eq!(para.req_cores(), Some(3), "cores should match"); + } + + #[test] + fn custom_para_works_with_default_cores() { + let s = "custom%3392%wss://kusama-yap-3392.example.com:1234%/path/to/chain-spec.json"; + let para = resolve_custom_parachain(s); + assert_eq!(para.req_cores(), Some(1), "cores should match"); + } + + #[test] + #[should_panic(expected = "Invalid para_id 'abc3392' in custom parachain")] + fn custom_para_id_parse_err() { + let s = "custom%abc3392%wss://kusama-yap-3392.example.com:1234%/path/to/chain-spec.json%3"; + let _para = resolve_custom_parachain(s); + } + + #[test] + #[should_panic(expected = "Invalid req_cores 'abc' in custom parachain")] + fn custom_para_cores_parse_err() { + let s = "custom%3392%wss://kusama-yap-3392.example.com:1234%/path/to/chain-spec.json%abc"; + let _para = resolve_custom_parachain(s); + } +} diff --git a/src/config.rs b/src/config.rs index ecb3a2b..6a728cf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -464,6 +464,13 @@ impl Parachain { } } + pub fn req_cores(&self) -> Option { + match self { + Parachain::Custom { cores, .. } => Some(*cores), + _ => None, + } + } + pub fn rpc_endpoint(&self) -> Option<&str> { match self { Parachain::AssetHub {