partner_chains_cli/register/
register1.rs

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