1use std::{
2 io::Read as _,
3 path::{Path, PathBuf},
4};
5
6use alloy::{
7 network::{Ethereum, EthereumWallet, NetworkWallet},
8 primitives::Address,
9};
10use anyhow::bail;
11use clap::Args;
12use hotshot_contract_adapter::{
13 sol_types::{EdOnBN254PointSol, G1PointSol, G2PointSol},
14 stake_table::{self, StateSignatureSol},
15};
16use hotshot_types::{
17 light_client::{StateKeyPair, StateSignature, StateVerKey},
18 signature_key::BLSPubKey,
19};
20use jf_signature::bls_over_bn254;
21use serde::{Deserialize, Serialize};
22
23use crate::{parse, BLSKeyPair, BLSPrivKey, StateSignKey};
24
25#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
29pub struct NodeSignatures {
30 pub address: Address,
32 pub bls_vk: BLSPubKey,
34 pub bls_signature: bls_over_bn254::Signature,
36 pub schnorr_vk: StateVerKey,
38 pub schnorr_signature: StateSignature,
40}
41
42#[derive(Clone, Debug)]
44pub struct NodeSignaturesSol {
45 pub address: Address,
47 pub bls_vk: G2PointSol,
49 pub bls_signature: G1PointSol,
51 pub schnorr_vk: EdOnBN254PointSol,
53 pub schnorr_signature: StateSignatureSol,
55}
56
57#[allow(clippy::large_enum_variant)]
59pub enum NodeSignatureInput {
60 Keys {
62 address: Address,
63 bls_key_pair: BLSKeyPair,
64 schnorr_key_pair: StateKeyPair,
65 },
66 PreparedPayload(NodeSignatureSource),
68}
69
70#[derive(Clone, Debug, Copy, Default, clap::ValueEnum, PartialEq, Eq)]
72pub enum SerializationFormat {
73 #[default]
74 #[value(name = "json")]
75 Json,
76 #[value(name = "toml")]
77 Toml,
78}
79
80#[derive(Debug)]
82pub enum NodeSignatureSource {
83 Stdin(SerializationFormat),
85 File {
87 path: PathBuf,
88 format: Option<SerializationFormat>,
89 },
90}
91
92#[derive(Debug)]
94pub enum NodeSignatureDestination {
95 Stdout(SerializationFormat),
97 File {
99 path: PathBuf,
100 format: SerializationFormat,
101 },
102}
103
104#[derive(Args, Clone, Debug)]
106pub struct NodeSignatureArgs {
107 #[clap(long, value_parser = parse::parse_bls_priv_key, env = "CONSENSUS_PRIVATE_KEY", required_unless_present = "node_signatures")]
109 pub consensus_private_key: Option<BLSPrivKey>,
110
111 #[clap(long, value_parser = parse::parse_state_priv_key, env = "STATE_PRIVATE_KEY", required_unless_present = "node_signatures")]
113 pub state_private_key: Option<StateSignKey>,
114
115 #[clap(long, required_unless_present_all = ["consensus_private_key", "state_private_key"])]
117 pub node_signatures: Option<PathBuf>,
118
119 #[clap(long, value_enum)]
121 pub format: Option<SerializationFormat>,
122}
123
124#[derive(Args, Clone, Debug)]
126pub struct OutputArgs {
127 #[clap(long)]
129 pub output: Option<PathBuf>,
130
131 #[clap(long, value_enum)]
133 pub format: Option<SerializationFormat>,
134}
135
136impl TryFrom<&Path> for SerializationFormat {
137 type Error = anyhow::Error;
138
139 fn try_from(path: &Path) -> anyhow::Result<Self> {
140 let extension = path.extension().and_then(|ext| ext.to_str());
141 match extension {
142 Some("json") => Ok(SerializationFormat::Json),
143 Some("toml") => Ok(SerializationFormat::Toml),
144 _ => anyhow::bail!(
145 "Unsupported extension in path '{}'. Expected .json or .toml",
146 path.display()
147 ),
148 }
149 }
150}
151
152impl From<NodeSignatures> for NodeSignaturesSol {
153 fn from(payload: NodeSignatures) -> Self {
154 Self {
155 address: payload.address,
156 bls_vk: payload.bls_vk.to_affine().into(),
157 bls_signature: payload.bls_signature.into(),
158 schnorr_vk: payload.schnorr_vk.into(),
159 schnorr_signature: payload.schnorr_signature.into(),
160 }
161 }
162}
163
164impl NodeSignatures {
165 pub fn create(
167 address: Address,
168 bls_key_pair: &BLSKeyPair,
169 schnorr_key_pair: &StateKeyPair,
170 ) -> Self {
171 let bls_signature = stake_table::sign_address_bls(bls_key_pair, address);
172 let schnorr_signature = stake_table::sign_address_schnorr(schnorr_key_pair, address);
173
174 Self {
175 address,
176 bls_vk: bls_key_pair.ver_key(),
177 bls_signature,
178 schnorr_vk: schnorr_key_pair.ver_key(),
179 schnorr_signature,
180 }
181 }
182
183 pub fn verify_signatures(&self, address: Address) -> anyhow::Result<()> {
185 stake_table::authenticate_bls_sig(&self.bls_vk, address, &self.bls_signature)?;
186 stake_table::authenticate_schnorr_sig(&self.schnorr_vk, address, &self.schnorr_signature)?;
187 Ok(())
188 }
189
190 pub fn handle_output(&self, destination: NodeSignatureDestination) -> anyhow::Result<()> {
192 match destination {
193 NodeSignatureDestination::Stdout(format) => {
194 let output = match format {
195 SerializationFormat::Json => serde_json::to_string_pretty(self)?,
196 SerializationFormat::Toml => toml::to_string_pretty(self)?,
197 };
198 println!("{output}");
199 },
200 NodeSignatureDestination::File { path, format } => {
201 let output = match format {
202 SerializationFormat::Json => serde_json::to_string_pretty(self)?,
203 SerializationFormat::Toml => toml::to_string_pretty(self)?,
204 };
205 std::fs::write(&path, output)?;
206 tracing::info!("{:?} signatures written to {}", format, path.display());
207 },
208 }
209 Ok(())
210 }
211}
212
213impl NodeSignatureSource {
214 pub(crate) fn parse(
216 path: PathBuf,
217 format: Option<SerializationFormat>,
218 ) -> anyhow::Result<Self> {
219 if path.to_string_lossy() == "-" {
220 Ok(Self::Stdin(format.unwrap_or_default()))
221 } else {
222 let format = if let Some(format) = format {
224 Some(format)
225 } else {
226 Some(SerializationFormat::try_from(path.as_path())?)
227 };
228 Ok(Self::File { path, format })
229 }
230 }
231}
232
233impl TryFrom<OutputArgs> for NodeSignatureDestination {
234 type Error = anyhow::Error;
235
236 fn try_from(args: OutputArgs) -> anyhow::Result<Self> {
237 match args.output {
238 None => {
239 let format = args.format.unwrap_or_default();
240 Ok(Self::Stdout(format))
241 },
242 Some(path) => {
243 let final_format = match args.format {
247 Some(explicit_format) => explicit_format,
248 None => SerializationFormat::try_from(path.as_path())?,
249 };
250 Ok(Self::File {
251 path,
252 format: final_format,
253 })
254 },
255 }
256 }
257}
258
259impl TryFrom<NodeSignatureSource> for NodeSignatures {
260 type Error = anyhow::Error;
261
262 fn try_from(source: NodeSignatureSource) -> anyhow::Result<Self> {
263 match source {
264 NodeSignatureSource::Stdin(format) => {
265 let mut buffer = String::new();
266 std::io::stdin().read_to_string(&mut buffer)?;
267
268 match format {
269 SerializationFormat::Json => serde_json::from_str::<Self>(&buffer)
270 .or_else(|e| bail!("Failed to parse JSON from stdin: {e}")),
271 SerializationFormat::Toml => toml::from_str::<Self>(&buffer)
272 .or_else(|e| bail!("Failed to parse TOML from stdin: {e}")),
273 }
274 },
275 NodeSignatureSource::File { path, format } => {
276 let content = std::fs::read_to_string(&path)?;
277
278 let format = match format {
279 Some(f) => f,
280 None => SerializationFormat::try_from(path.as_path())?,
281 };
282
283 match format {
284 SerializationFormat::Json => serde_json::from_str::<Self>(&content)
285 .or_else(|e| bail!("Failed to parse JSON file {}: {e}", path.display())),
286 SerializationFormat::Toml => toml::from_str::<Self>(&content)
287 .or_else(|e| bail!("Failed to parse TOML file {}: {e}", path.display())),
288 }
289 },
290 }
291 }
292}
293
294impl TryFrom<NodeSignatureInput> for NodeSignatures {
295 type Error = anyhow::Error;
296
297 fn try_from(input: NodeSignatureInput) -> anyhow::Result<Self> {
298 match input {
299 NodeSignatureInput::Keys {
300 address,
301 bls_key_pair,
302 schnorr_key_pair,
303 } => Ok(Self::create(address, &bls_key_pair, &schnorr_key_pair)),
304 NodeSignatureInput::PreparedPayload(source) => Self::try_from(source),
305 }
306 }
307}
308
309impl TryFrom<(NodeSignatureArgs, &EthereumWallet)> for NodeSignatureInput {
310 type Error = anyhow::Error;
311
312 fn try_from((args, wallet): (NodeSignatureArgs, &EthereumWallet)) -> anyhow::Result<Self> {
313 let wallet_address =
314 <EthereumWallet as NetworkWallet<Ethereum>>::default_signer_address(wallet);
315
316 if let Some(sig_path) = args.node_signatures {
317 let source = NodeSignatureSource::parse(sig_path, args.format)?;
318 Ok(Self::PreparedPayload(source))
319 } else {
320 let Some(bls_key) = args.consensus_private_key else {
321 bail!("consensus_private_key is required when not using node_signatures")
322 };
323 let Some(state_key) = args.state_private_key else {
324 bail!("state_private_key is required when not using node_signatures")
325 };
326
327 Ok(Self::Keys {
328 address: wallet_address,
329 bls_key_pair: bls_key.into(),
330 schnorr_key_pair: StateKeyPair::from_sign_key(state_key),
331 })
332 }
333 }
334}
335
336impl TryFrom<(NodeSignatureInput, &EthereumWallet)> for NodeSignatures {
338 type Error = anyhow::Error;
339
340 fn try_from((input, wallet): (NodeSignatureInput, &EthereumWallet)) -> anyhow::Result<Self> {
341 match input {
342 NodeSignatureInput::Keys {
343 address,
344 bls_key_pair,
345 schnorr_key_pair,
346 } => Ok(Self::create(address, &bls_key_pair, &schnorr_key_pair)),
347 NodeSignatureInput::PreparedPayload(source) => {
348 let payload = Self::try_from(source)?;
349 let wallet_address =
350 <EthereumWallet as NetworkWallet<Ethereum>>::default_signer_address(wallet);
351
352 payload.verify_signatures(wallet_address)?;
354
355 if payload.address != wallet_address {
357 bail!(
358 "Address mismatch: payload contains {}, but wallet address is {}",
359 payload.address,
360 wallet_address
361 );
362 }
363
364 Ok(payload)
365 },
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use std::path::PathBuf;
373
374 use alloy::primitives::Address;
375 use rand::{rngs::StdRng, Rng, SeedableRng};
376 use rstest::*;
377 use tempfile::NamedTempFile;
378
379 use super::*;
380 use crate::BLSKeyPair;
381
382 #[fixture]
383 fn sample_address() -> Address {
384 "0x1234567890123456789012345678901234567890"
385 .parse()
386 .unwrap()
387 }
388
389 #[fixture]
390 fn sample_node_signatures(sample_address: Address) -> NodeSignatures {
391 let mut rng = StdRng::from_seed([42u8; 32]);
392 let bls_key = BLSKeyPair::generate(&mut rng);
393 let state_key = StateKeyPair::generate_from_seed(rng.gen());
394
395 NodeSignatures::create(sample_address, &bls_key, &state_key)
396 }
397
398 #[fixture]
399 fn json_content(sample_node_signatures: NodeSignatures) -> String {
400 serde_json::to_string_pretty(&sample_node_signatures).unwrap()
401 }
402
403 #[fixture]
404 fn toml_content(sample_node_signatures: NodeSignatures) -> String {
405 toml::to_string_pretty(&sample_node_signatures).unwrap()
406 }
407
408 #[rstest]
409 fn test_signature_source_parse_stdin() {
410 let result = NodeSignatureSource::parse(PathBuf::from("-"), None).unwrap();
411 matches!(
412 result,
413 NodeSignatureSource::Stdin(SerializationFormat::Json)
414 );
415 }
416
417 #[rstest]
418 fn test_signature_source_parse_stdin_with_format() {
419 let result =
420 NodeSignatureSource::parse(PathBuf::from("-"), Some(SerializationFormat::Toml))
421 .unwrap();
422 matches!(
423 result,
424 NodeSignatureSource::Stdin(SerializationFormat::Toml)
425 );
426 }
427
428 #[rstest]
429 #[case("test.json", true)]
430 #[case("test.toml", true)]
431 #[case("test.txt", false)]
432 #[case("test", false)]
433 #[case("test.yaml", false)]
434 fn test_signature_source_parse_file(#[case] filename: &str, #[case] should_succeed: bool) {
435 let path = PathBuf::from(filename);
436 let result = NodeSignatureSource::parse(path.clone(), None);
437
438 if should_succeed {
439 let source = result.unwrap();
440 matches!(source, NodeSignatureSource::File { path: p, .. } if p == path);
441 } else {
442 assert!(result.is_err());
443 assert!(result
444 .unwrap_err()
445 .to_string()
446 .contains("Unsupported extension"));
447 }
448 }
449
450 #[rstest]
451 fn test_signature_destination_stdout() {
452 let args = OutputArgs {
453 output: None,
454 format: Some(SerializationFormat::Json),
455 };
456 let result = NodeSignatureDestination::try_from(args).unwrap();
457 matches!(
458 result,
459 NodeSignatureDestination::Stdout(SerializationFormat::Json)
460 );
461 }
462
463 #[rstest]
464 #[case("test.json", SerializationFormat::Json)]
465 #[case("test.toml", SerializationFormat::Toml)]
466 fn test_signature_destination_file_valid(
467 #[case] filename: &str,
468 #[case] expected_format: SerializationFormat,
469 ) {
470 let path = PathBuf::from(filename);
471 let args = OutputArgs {
472 output: Some(path.clone()),
473 format: None, };
475 let result = NodeSignatureDestination::try_from(args).unwrap();
476
477 match result {
478 NodeSignatureDestination::File { path: p, format } => {
479 assert_eq!(p, path);
480 assert_eq!(format, expected_format);
481 },
482 _ => panic!("Expected File variant"),
483 }
484 }
485
486 #[rstest]
487 fn test_parse_json_file(
488 json_content: String,
489 sample_node_signatures: NodeSignatures,
490 ) -> anyhow::Result<()> {
491 let temp_file = NamedTempFile::with_suffix(".json")?;
492 std::fs::write(temp_file.path(), &json_content)?;
493
494 let source = NodeSignatureSource::File {
495 path: temp_file.path().to_path_buf(),
496 format: Some(SerializationFormat::Json),
497 };
498 let parsed = NodeSignatures::try_from(source)?;
499
500 assert_eq!(parsed.address, sample_node_signatures.address);
501 assert_eq!(parsed.bls_vk, sample_node_signatures.bls_vk);
502 assert_eq!(parsed.schnorr_vk, sample_node_signatures.schnorr_vk);
503 Ok(())
504 }
505
506 #[rstest]
507 fn test_parse_toml_file(
508 toml_content: String,
509 sample_node_signatures: NodeSignatures,
510 ) -> anyhow::Result<()> {
511 let temp_file = NamedTempFile::with_suffix(".toml")?;
512 std::fs::write(temp_file.path(), &toml_content)?;
513
514 let source = NodeSignatureSource::File {
515 path: temp_file.path().to_path_buf(),
516 format: Some(SerializationFormat::Toml),
517 };
518 let parsed = NodeSignatures::try_from(source)?;
519
520 assert_eq!(parsed.address, sample_node_signatures.address);
521 assert_eq!(parsed.bls_vk, sample_node_signatures.bls_vk);
522 assert_eq!(parsed.schnorr_vk, sample_node_signatures.schnorr_vk);
523 Ok(())
524 }
525
526 #[rstest]
527 fn test_parse_invalid_json_file() -> anyhow::Result<()> {
528 let temp_file = NamedTempFile::with_suffix(".json")?;
529 std::fs::write(temp_file.path(), "invalid json")?;
530
531 let source = NodeSignatureSource::File {
532 path: temp_file.path().to_path_buf(),
533 format: Some(SerializationFormat::Json),
534 };
535 let result = NodeSignatures::try_from(source);
536
537 assert!(result.is_err());
538 assert!(result
539 .unwrap_err()
540 .to_string()
541 .contains("Failed to parse JSON file"));
542 Ok(())
543 }
544
545 #[rstest]
546 fn test_parse_invalid_toml_file() -> anyhow::Result<()> {
547 let temp_file = NamedTempFile::with_suffix(".toml")?;
548 std::fs::write(temp_file.path(), "invalid = toml = syntax")?;
549
550 let source = NodeSignatureSource::File {
551 path: temp_file.path().to_path_buf(),
552 format: Some(SerializationFormat::Toml),
553 };
554 let result = NodeSignatures::try_from(source);
555
556 assert!(result.is_err());
557 assert!(result
558 .unwrap_err()
559 .to_string()
560 .contains("Failed to parse TOML file"));
561 Ok(())
562 }
563
564 #[rstest]
565 #[case(SerializationFormat::Json, ".json")]
566 #[case(SerializationFormat::Toml, ".toml")]
567 #[case(SerializationFormat::Toml, ".not-toml")]
568 fn test_handle_output_to_file(
569 sample_node_signatures: NodeSignatures,
570 #[case] format: SerializationFormat,
571 #[case] suffix: &str,
572 ) -> anyhow::Result<()> {
573 let temp_file = NamedTempFile::with_suffix(suffix)?;
574 let destination = NodeSignatureDestination::File {
575 path: temp_file.path().to_path_buf(),
576 format,
577 };
578
579 sample_node_signatures.handle_output(destination)?;
580
581 let content = std::fs::read_to_string(temp_file.path())?;
582 let parsed: NodeSignatures = match format {
583 SerializationFormat::Json => serde_json::from_str(&content)?,
584 SerializationFormat::Toml => toml::from_str(&content)?,
585 };
586 assert_eq!(parsed.address, sample_node_signatures.address);
587 Ok(())
588 }
589
590 #[rstest]
591 fn test_create_and_verify_signatures(sample_address: Address) {
592 let bls_key = BLSKeyPair::generate(&mut rand::thread_rng());
593 let state_key = hotshot_types::light_client::StateKeyPair::generate();
594
595 let signatures = NodeSignatures::create(sample_address, &bls_key, &state_key);
596
597 assert!(signatures.verify_signatures(sample_address).is_ok());
598
599 let wrong_address: Address = "0x0000000000000000000000000000000000000001"
600 .parse()
601 .unwrap();
602 assert!(signatures.verify_signatures(wrong_address).is_err());
603 }
604
605 #[rstest]
606 #[case("test.txt")]
607 #[case("test")]
608 #[case("test.yaml")]
609 fn test_signature_destination_file_invalid(#[case] filename: &str) {
610 let path = PathBuf::from(filename);
611 let args = OutputArgs {
612 output: Some(path),
613 format: None,
614 };
615 let result = NodeSignatureDestination::try_from(args);
616
617 assert!(result
618 .unwrap_err()
619 .to_string()
620 .contains("Unsupported extension"));
621 }
622
623 #[rstest]
624 fn test_parse_unsupported_extension() -> anyhow::Result<()> {
625 let temp_file = NamedTempFile::with_suffix(".yaml")?;
626 std::fs::write(temp_file.path(), "test: content")?;
627
628 let result = NodeSignatureSource::parse(temp_file.path().to_path_buf(), None);
629
630 assert!(result
631 .unwrap_err()
632 .to_string()
633 .contains("Unsupported extension"));
634 Ok(())
635 }
636
637 #[rstest]
638 #[case("test.json", SerializationFormat::Json)]
639 #[case("test.toml", SerializationFormat::Toml)]
640 fn test_serialization_format_from_path_valid(
641 #[case] filename: &str,
642 #[case] expected_format: SerializationFormat,
643 ) {
644 let path = PathBuf::from(filename);
645 let result = SerializationFormat::try_from(path.as_path()).unwrap();
646 assert_eq!(result, expected_format);
647 }
648
649 #[rstest]
650 #[case("test.txt")]
651 #[case("test")]
652 #[case("test.yaml")]
653 fn test_serialization_format_from_path_invalid(#[case] filename: &str) {
654 let path = PathBuf::from(filename);
655 let result = SerializationFormat::try_from(path.as_path());
656 assert!(result
657 .unwrap_err()
658 .to_string()
659 .contains("Unsupported extension"));
660 }
661}