partner_chains_cli/register/
register1.rs

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}