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}