1use std::collections::BTreeMap;
2
3use super::{CandidateKeyParam, RegisterValidatorMessage};
4use crate::config::KEYS_FILE_PATH;
5use crate::io::IOContext;
6use crate::keystore::{CROSS_CHAIN, keystore_path};
7use crate::{config::config_fields, *};
8use anyhow::anyhow;
9use ogmios::config::prompt_ogmios_configuration;
10use ogmios::get_shelley_config;
11use partner_chains_cardano_offchain::csl::NetworkTypeExt;
12use select_utxo::{query_utxos, select_from_utxos};
13use serde::de::DeserializeOwned;
14use serde::{Deserialize, Serialize};
15use sidechain_domain::byte_string::ByteString;
16use sidechain_domain::crypto::sc_public_key_and_signature_for_datum;
17use sidechain_domain::{NetworkType, SidechainPublicKey, UtxoId};
18use sp_core::{Pair, ecdsa};
19
20#[derive(Clone, Debug, clap::Parser)]
21pub struct Register1Cmd {}
22
23impl CmdRun for Register1Cmd {
24 fn run<C: IOContext>(&self, context: &C) -> anyhow::Result<()> {
25 context.print("⚙️ Registering as a committee candidate (step 1/3)");
26 let genesis_utxo = load_chain_config_field(context, &config_fields::GENESIS_UTXO)?;
27
28 let node_data_base_path =
29 config_fields::SUBSTRATE_NODE_DATA_BASE_PATH.load_or_prompt_and_save(context);
30
31 let GeneratedKeysFileContent { partner_chains_key, keys } =
32 read_generated_keys(context).map_err(|e| {
33 context.eprint(&format!("⚠️ The keys file `{KEYS_FILE_PATH}` is missing or invalid. Please run the `generate-keys` command first"));
34 anyhow!(e)
35 })?;
36
37 context.print("This wizard will query your UTXOs using address derived from the payment verification key and Ogmios service");
38 let ogmios_configuration = prompt_ogmios_configuration(context)?;
39 let shelley_genesis_config = get_shelley_config(&ogmios_configuration, context)?;
40 let address = derive_address(context, shelley_genesis_config.network)?;
41 let utxo_query_result = query_utxos(context, &ogmios_configuration, &address)?;
42
43 if utxo_query_result.is_empty() {
44 context.eprint("⚠️ No UTXOs found for the given address");
45 context.eprint(
46 "The registering transaction requires at least one UTXO to be present at the address.",
47 );
48 return Err(anyhow::anyhow!("No UTXOs found"));
49 };
50
51 let registration_utxo: UtxoId =
52 select_from_utxos(context, "Select UTXO to use for registration", utxo_query_result);
53
54 context.print(
55 "Please do not spend this UTXO, it needs to be consumed by the registration transaction.",
56 );
57 context.print("");
58
59 let pc_pub_key_typed: SidechainPublicKey = SidechainPublicKey(partner_chains_key.0.clone());
60
61 let registration_message = RegisterValidatorMessage {
62 genesis_utxo,
63 sidechain_pub_key: pc_pub_key_typed,
64 registration_utxo,
65 };
66
67 let ecdsa_pair = get_ecdsa_pair_from_file(
68 context,
69 &keystore_path(&node_data_base_path),
70 &partner_chains_key.to_hex_string(),
71 )
72 .map_err(|e| {
73 context.eprint(&format!("⚠️ Failed to read partner chain key from the keystore: {e}"));
74 anyhow!(e)
75 })?;
76
77 let partner_chains_key_str = partner_chains_key.to_hex_string();
78
79 let pc_signature =
80 sign_registration_message_with_sidechain_key(registration_message, ecdsa_pair)?;
81 let executable = context.current_executable()?;
82 context.print("Run the following command to generate signatures on the next step. It has to be executed on the machine with your SPO cold signing key.");
83 context.print("");
84 context.print(&format!(
85 "{executable} wizards register2 \\
86--genesis-utxo {genesis_utxo} \\
87--registration-utxo {registration_utxo} \\
88--partner-chain-pub-key {partner_chains_key_str} \\
89--partner-chain-signature {pc_signature}{}",
90 keys.iter()
91 .map(CandidateKeyParam::to_string)
92 .map(|arg| format!(" \\\n--keys {arg}"))
93 .collect::<Vec<_>>()
94 .join("")
95 ));
96
97 Ok(())
98 }
99}
100
101fn get_ecdsa_pair_from_file<C: IOContext>(
102 context: &C,
103 keystore_path: &str,
104 sidechain_pub_key: &str,
105) -> Result<ecdsa::Pair, anyhow::Error> {
106 let mut seed_phrase_file_name = CROSS_CHAIN.key_type_hex();
107 seed_phrase_file_name.push_str(sidechain_pub_key.replace("0x", "").as_str());
108 let seed_phrase_file_path = format!("{keystore_path}/{seed_phrase_file_name}");
109 let seed = context
110 .read_file(&seed_phrase_file_path)
111 .ok_or_else(|| anyhow::anyhow!("seed phrase file {seed_phrase_file_path} not found"))?;
112 let stripped_quotes = seed.trim_matches('\"');
113 Ok(ecdsa::Pair::from_string(stripped_quotes, None)?)
114}
115
116fn sign_registration_message_with_sidechain_key(
117 message: RegisterValidatorMessage,
118 ecdsa_pair: ecdsa::Pair,
119) -> Result<String, anyhow::Error> {
120 let seed = ecdsa_pair.seed();
121 let secret_key = secp256k1::SecretKey::from_slice(&seed).map_err(|e| anyhow!(e))?;
122 let (_, sig) = sc_public_key_and_signature_for_datum(secret_key, message.clone());
123 Ok(hex::encode(sig.serialize_compact()))
124}
125
126#[derive(Debug)]
127pub struct GeneratedKeysFileContent {
128 pub partner_chains_key: ByteString,
129 pub keys: Vec<CandidateKeyParam>,
130}
131
132pub fn read_generated_keys<C: IOContext>(context: &C) -> anyhow::Result<GeneratedKeysFileContent> {
133 let keys_file_content = context
134 .read_file(KEYS_FILE_PATH)
135 .ok_or_else(|| anyhow::anyhow!("failed to read keys file"))?;
136
137 #[derive(Serialize, Deserialize, Debug)]
138 pub struct GeneratedKeysFileContentRaw {
139 pub partner_chains_key: ByteString,
140 pub keys: BTreeMap<String, ByteString>,
141 }
142
143 let GeneratedKeysFileContentRaw { partner_chains_key, keys: raw_keys } =
144 serde_json::from_str(&keys_file_content)?;
145
146 let mut keys = vec![];
147 for (id, bytes) in raw_keys.into_iter() {
148 keys.push(CandidateKeyParam::try_new_from(&id, bytes.0)?)
149 }
150
151 Ok(GeneratedKeysFileContent { partner_chains_key, keys })
152}
153
154pub fn load_chain_config_field<C: IOContext, T>(
155 context: &C,
156 field: &config::ConfigFieldDefinition<T>,
157) -> Result<T, anyhow::Error>
158where
159 T: DeserializeOwned,
160{
161 field.load_from_file(context).ok_or_else(|| {
162 context.eprint("⚠️ The chain configuration file `pc-chain-config.json` is missing or invalid.\n If you are the governance authority, please make sure you have run the `prepare-configuration` command to generate the chain configuration file.\n If you are a validator, you can obtain the chain configuration file from the governance authority.");
163 anyhow::anyhow!("failed to read {}", field.path.join("."))
164 })
165}
166
167fn derive_address<C: IOContext>(
168 context: &C,
169 cardano_network: NetworkType,
170) -> Result<String, anyhow::Error> {
171 let cardano_payment_verification_key_file =
172 config_fields::CARDANO_PAYMENT_VERIFICATION_KEY_FILE
173 .prompt_with_default_from_file_and_save(context);
174 let key_bytes: [u8; 32] = cardano_key::get_payment_verification_key_bytes_from_file(
175 &cardano_payment_verification_key_file,
176 context,
177 )?;
178 let address =
179 partner_chains_cardano_offchain::csl::payment_address(&key_bytes, cardano_network.to_csl());
180 address.to_bech32(None).map_err(|e| anyhow!(e.to_string()))
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::tests::{CHAIN_CONFIG_FILE_PATH, MockIO, MockIOContext, RESOURCES_CONFIG_FILE_PATH};
187 use ogmios::{
188 OgmiosRequest,
189 config::tests::{
190 default_ogmios_config_json, default_ogmios_service_config,
191 prompt_ogmios_configuration_io,
192 },
193 test_values::preview_shelley_config,
194 };
195 use select_utxo::tests::{mock_7_valid_utxos_rows, mock_result_7_valid};
196 use serde_json::json;
197
198 const PAYMENT_VKEY_PATH: &str = "payment.vkey";
199
200 #[test]
201 fn happy_path() {
202 let resource_config_without_cardano_fields = serde_json::json!({
203 "substrate_node_base_path": "/path/to/data",
204 });
205
206 let mock_context = MockIOContext::new()
207 .with_json_file(CHAIN_CONFIG_FILE_PATH, chain_config_content())
208 .with_json_file(RESOURCES_CONFIG_FILE_PATH, resource_config_without_cardano_fields)
209 .with_json_file(KEYS_FILE_PATH, generated_keys_file_content())
210 .with_file(ECDSA_KEY_PATH, ECDSA_KEY_FILE_CONTENT)
211 .with_file(PAYMENT_VKEY_PATH, PAYMENT_VKEY_CONTENT)
212 .with_expected_io(
213 vec![
214 intro_msg_io(),
215 load_base_path_value(),
216 derive_address_io(),
217 query_utxos_io(),
218 select_utxo_io(),
219 output_io(),
220 ]
221 .into_iter()
222 .flatten()
223 .collect::<Vec<MockIO>>(),
224 );
225
226 let result = Register1Cmd {}.run(&mock_context);
227 result.expect("should succeed");
228 verify_json!(
229 mock_context,
230 RESOURCES_CONFIG_FILE_PATH,
231 json!({
232 "substrate_node_base_path": "/path/to/data",
233 "cardano_payment_verification_key_file": PAYMENT_VKEY_PATH,
234 "ogmios": default_ogmios_config_json()
235 })
236 );
237 }
238
239 #[test]
240 fn report_error_if_chain_config_file_is_missing() {
241 let mock_context = MockIOContext::new().with_expected_io(
242 vec![intro_msg_io(), invalid_chain_config_io()]
243 .into_iter()
244 .flatten()
245 .collect::<Vec<MockIO>>(),
246 );
247
248 let result = Register1Cmd {}.run(&mock_context);
249 result.expect_err("should return error");
250 }
251
252 #[test]
253 fn report_error_if_chain_config_fields_are_missing() {
254 let mock_context = MockIOContext::new()
255 .with_json_file("pc-chain-config.json", serde_json::json!({}))
256 .with_expected_io(
257 vec![intro_msg_io(), invalid_chain_config_io()]
258 .into_iter()
259 .flatten()
260 .collect::<Vec<MockIO>>(),
261 );
262
263 let result = Register1Cmd {}.run(&mock_context);
264 result.expect_err("should return error");
265 }
266
267 #[test]
268 fn saved_prompt_fields_are_loaded_without_prompting() {
269 let mock_context = MockIOContext::new()
270 .with_json_file(CHAIN_CONFIG_FILE_PATH, chain_config_content())
271 .with_json_file(RESOURCES_CONFIG_FILE_PATH, resource_config_content())
272 .with_json_file(KEYS_FILE_PATH, generated_keys_file_content())
273 .with_file(PAYMENT_VKEY_PATH, PAYMENT_VKEY_CONTENT)
274 .with_file(ECDSA_KEY_PATH, ECDSA_KEY_FILE_CONTENT)
275 .with_expected_io(
276 vec![
277 intro_msg_io(),
278 load_base_path_value(),
279 derive_address_io(),
280 query_utxos_io(),
281 select_utxo_io(),
282 output_io(),
283 ]
284 .into_iter()
285 .flatten()
286 .collect::<Vec<MockIO>>(),
287 );
288
289 let result = Register1Cmd {}.run(&mock_context);
290 assert!(result.is_ok());
291 }
292
293 #[test]
294 fn report_error_if_payment_file_is_invalid() {
295 let mock_context = MockIOContext::new()
296 .with_json_file(CHAIN_CONFIG_FILE_PATH, chain_config_content())
297 .with_json_file(RESOURCES_CONFIG_FILE_PATH, resource_config_content())
298 .with_json_file(KEYS_FILE_PATH, generated_keys_file_content())
299 .with_file(PAYMENT_VKEY_PATH, "invalid content")
300 .with_expected_io(
301 vec![intro_msg_io(), load_base_path_value(), derive_address_io()]
302 .into_iter()
303 .flatten()
304 .collect::<Vec<MockIO>>(),
305 );
306
307 let result = Register1Cmd {}.run(&mock_context);
308 assert!(result.is_err());
309 assert!(
310 result
311 .unwrap_err()
312 .to_string()
313 .contains("Failed to parse Cardano key file payment.vkey")
314 );
315 }
316
317 #[test]
318 fn utxo_query_error() {
319 let mock_context = MockIOContext::new()
320 .with_json_file(CHAIN_CONFIG_FILE_PATH, chain_config_content())
321 .with_json_file(RESOURCES_CONFIG_FILE_PATH, resource_config_content())
322 .with_json_file(KEYS_FILE_PATH, generated_keys_file_content())
323 .with_file(PAYMENT_VKEY_PATH, PAYMENT_VKEY_CONTENT)
324 .with_expected_io(
325 vec![
326 intro_msg_io(),
327 load_base_path_value(),
328 derive_address_io(),
329 vec![
330
331 MockIO::print("⚙️ Querying UTXOs of addr_test1vqezxrh24ts0775hulcg3ejcwj7hns8792vnn8met6z9gwsxt87zy from Ogmios at http://localhost:1337..."),
332 MockIO::ogmios_request(
333 "http://localhost:1337",
334 OgmiosRequest::QueryUtxo {
335 address: "addr_test1vqezxrh24ts0775hulcg3ejcwj7hns8792vnn8met6z9gwsxt87zy"
336 .into(),
337 },
338 Err(anyhow!("Ogmios request failed!")),
339 ),
340 ]
341 ]
342 .into_iter()
343 .flatten()
344 .collect::<Vec<MockIO>>(),
345 );
346
347 let result = Register1Cmd {}.run(&mock_context);
348 assert!(result.is_err());
349 assert_eq!(result.unwrap_err().to_string(), "Ogmios request failed!".to_owned());
350 }
351
352 #[test]
353 fn should_error_with_missing_public_keys_file() {
354 let mock_context = MockIOContext::new()
355 .with_json_file(CHAIN_CONFIG_FILE_PATH, chain_config_content())
356 .with_json_file(RESOURCES_CONFIG_FILE_PATH, resource_config_content())
357 .with_expected_io(
358 vec![
359 intro_msg_io(),
360 load_base_path_value(),
361 vec![MockIO::eprint("⚠️ The keys file `partner-chains-public-keys.json` is missing or invalid. Please run the `generate-keys` command first")],
362 ]
363 .into_iter()
364 .flatten()
365 .collect::<Vec<MockIO>>(),
366 );
367
368 let result = Register1Cmd {}.run(&mock_context);
369 assert!(result.is_err());
370 }
371
372 #[test]
373 fn should_error_with_missing_private_keys_in_storage() {
374 let mock_context = MockIOContext::new()
375 .with_json_file(CHAIN_CONFIG_FILE_PATH, chain_config_content())
376 .with_json_file(RESOURCES_CONFIG_FILE_PATH, resource_config_content())
377 .with_file(PAYMENT_VKEY_PATH, PAYMENT_VKEY_CONTENT)
378 .with_json_file(KEYS_FILE_PATH, generated_keys_file_content())
379 .with_expected_io(
380 vec![
381 intro_msg_io(),
382 load_base_path_value(),
383 derive_address_io(),
384 query_utxos_io(),
385 select_utxo_io(),
386 vec![MockIO::eprint(
387 "⚠️ Failed to read partner chain key from the keystore: seed phrase file /path/to/data/keystore/63726368031e75acbf45ef8df98bbe24b19b28fff807be32bf88838c30c0564d7bec5301f6 not found",
388 )],
389 ]
390 .into_iter()
391 .flatten()
392 .collect::<Vec<MockIO>>(),
393 );
394
395 let result = Register1Cmd {}.run(&mock_context);
396 assert!(result.is_err());
397 }
398
399 #[test]
400 fn should_error_on_invalid_seed_phrase() {
401 let mock_context = MockIOContext::new()
402 .with_json_file(CHAIN_CONFIG_FILE_PATH, chain_config_content())
403 .with_json_file(RESOURCES_CONFIG_FILE_PATH, resource_config_content())
404 .with_json_file(KEYS_FILE_PATH, generated_keys_file_content())
405 .with_file(PAYMENT_VKEY_PATH, PAYMENT_VKEY_CONTENT)
406 .with_file(ECDSA_KEY_PATH, "invalid seed phrase")
407 .with_expected_io(
408 vec![
409 intro_msg_io(),
410 load_base_path_value(),
411 derive_address_io(),
412 query_utxos_io(),
413 select_utxo_io(),
414 vec![MockIO::eprint(
415 "⚠️ Failed to read partner chain key from the keystore: Invalid phrase",
416 )],
417 ]
418 .into_iter()
419 .flatten()
420 .collect::<Vec<MockIO>>(),
421 );
422
423 let result = Register1Cmd {}.run(&mock_context);
424 assert!(result.is_err());
425 }
426
427 fn chain_config_content() -> serde_json::Value {
428 serde_json::json!({
429 "chain_parameters": {
430 "genesis_utxo": "0000000000000000000000000000000000000000000000000000000000000001#0",
431 },
432 "cardano": {
433 "network": "testnet"
434 }
435 })
436 }
437
438 fn generated_keys_file_content() -> serde_json::Value {
439 serde_json::json!({
440 "partner_chains_key": "0x031e75acbf45ef8df98bbe24b19b28fff807be32bf88838c30c0564d7bec5301f6",
441 "keys": {
442 "aura": "0xdf883ee0648f33b6103017b61be702017742d501b8fe73b1d69ca0157460b777",
443 "gran": "0x5a091a06abd64f245db11d2987b03218c6bd83d64c262fe10e3a2a1230e90327"
444 }
445 })
446 }
447
448 const PAYMENT_VKEY_CONTENT: &str = r#"
449{
450 "type": "PaymentVerificationKeyShelley_ed25519",
451 "description": "Payment Verification Key",
452 "cborHex": "5820a35ef86f1622172816bb9e916aea86903b2c8d32c728ad5c9b9472be7e3c5e88"
453}
454"#;
455
456 const ECDSA_KEY_FILE_CONTENT: &str =
457 "\"end fury stamp spatial focus tired video tumble good critic tail hood\"";
458
459 fn resource_config_content() -> serde_json::Value {
460 serde_json::json!({
461 "substrate_node_base_path": "/path/to/data",
462 "cardano_payment_verification_key_file": "payment.vkey"
463 })
464 }
465
466 fn intro_msg_io() -> Vec<MockIO> {
467 vec![MockIO::print("⚙️ Registering as a committee candidate (step 1/3)")]
468 }
469
470 fn load_base_path_value() -> Vec<MockIO> {
471 vec![MockIO::eprint(
472 "🛠️ Loaded node base path from config (test-pc-resources-config.json): /path/to/data",
473 )]
474 }
475
476 fn address_and_utxo_msg_io() -> MockIO {
477 MockIO::print(
478 "This wizard will query your UTXOs using address derived from the payment verification key and Ogmios service",
479 )
480 }
481
482 fn ogmios_network_request_io() -> MockIO {
483 MockIO::ogmios_request(
484 "http://localhost:1337",
485 OgmiosRequest::QueryNetworkShelleyGenesis,
486 Ok(ogmios::OgmiosResponse::QueryNetworkShelleyGenesis(preview_shelley_config())),
487 )
488 }
489
490 fn prompt_cardano_payment_verification_key_file_io() -> MockIO {
491 MockIO::prompt(
492 "Enter the path to the payment verification file",
493 Some(PAYMENT_VKEY_PATH),
494 PAYMENT_VKEY_PATH,
495 )
496 }
497
498 fn derive_address_io() -> Vec<MockIO> {
499 vec![
500 address_and_utxo_msg_io(),
501 prompt_ogmios_configuration_io(
502 &default_ogmios_service_config(),
503 &default_ogmios_service_config(),
504 ),
505 ogmios_network_request_io(),
506 prompt_cardano_payment_verification_key_file_io(),
507 ]
508 }
509
510 fn query_utxos_io() -> Vec<MockIO> {
511 vec![crate::select_utxo::tests::query_utxos_io(
512 "addr_test1vqezxrh24ts0775hulcg3ejcwj7hns8792vnn8met6z9gwsxt87zy",
513 "http://localhost:1337",
514 mock_result_7_valid(),
515 )]
516 }
517
518 fn select_utxo_io() -> Vec<MockIO> {
519 vec![
520 MockIO::prompt_multi_option(
521 "Select UTXO to use for registration",
522 mock_7_valid_utxos_rows(),
523 &"4704a903b01514645067d851382efd4a6ed5d2ff07cf30a538acc78fed7c4c02#93 (1100000 lovelace)".to_owned(),
524 ),
525 MockIO::print(
526 "Please do not spend this UTXO, it needs to be consumed by the registration transaction.",
527 ),
528 MockIO::print(""),
529 ]
530 }
531
532 fn output_io() -> Vec<MockIO> {
533 vec![
534 MockIO::print(
535 "Run the following command to generate signatures on the next step. It has to be executed on the machine with your SPO cold signing key.",
536 ),
537 MockIO::print(""),
538 MockIO::print(
539 "<mock executable> wizards register2 \\
540--genesis-utxo 0000000000000000000000000000000000000000000000000000000000000001#0 \\
541--registration-utxo 4704a903b01514645067d851382efd4a6ed5d2ff07cf30a538acc78fed7c4c02#93 \\
542--partner-chain-pub-key 0x031e75acbf45ef8df98bbe24b19b28fff807be32bf88838c30c0564d7bec5301f6 \\
543--partner-chain-signature 6e295e36a6b11d8b1c5ec01ac8a639b466fbfbdda94b39ea82b0992e303d58543341345fc705e09c7838786ba0bc746d9038036f66a36d1127d924c4a0228bec \\
544--keys aura:df883ee0648f33b6103017b61be702017742d501b8fe73b1d69ca0157460b777 \\
545--keys gran:5a091a06abd64f245db11d2987b03218c6bd83d64c262fe10e3a2a1230e90327",
546 ),
547 ]
548 }
549
550 const ECDSA_KEY_PATH: &str = "/path/to/data/keystore/63726368031e75acbf45ef8df98bbe24b19b28fff807be32bf88838c30c0564d7bec5301f6";
551
552 fn invalid_chain_config_io() -> Vec<MockIO> {
553 vec![MockIO::eprint(
554 "⚠️ The chain configuration file `pc-chain-config.json` is missing or invalid.\n If you are the governance authority, please make sure you have run the `prepare-configuration` command to generate the chain configuration file.\n If you are a validator, you can obtain the chain configuration file from the governance authority.",
555 )]
556 }
557}