staking_cli/
signature.rs

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/// Node signatures containing pre-signed address signatures for validator operations
26///
27/// This is the native rust type.
28#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
29pub struct NodeSignatures {
30    /// The Ethereum address that was signed
31    pub address: Address,
32    /// BLS verification key
33    pub bls_vk: BLSPubKey,
34    /// BLS signature over the address
35    pub bls_signature: bls_over_bn254::Signature,
36    /// Schnorr verification key
37    pub schnorr_vk: StateVerKey,
38    /// Schnorr signature over the address
39    pub schnorr_signature: StateSignature,
40}
41
42/// Only used for serialization to Solidity.
43#[derive(Clone, Debug)]
44pub struct NodeSignaturesSol {
45    /// The Ethereum address that was signed
46    pub address: Address,
47    /// BLS verification key
48    pub bls_vk: G2PointSol,
49    /// BLS signature over the address
50    pub bls_signature: G1PointSol,
51    /// Schnorr verification key
52    pub schnorr_vk: EdOnBN254PointSol,
53    /// Schnorr signature over the address
54    pub schnorr_signature: StateSignatureSol,
55}
56
57/// Represents either keys for signing or a pre-prepared node signature source
58#[allow(clippy::large_enum_variant)]
59pub enum NodeSignatureInput {
60    /// Sign using private keys
61    Keys {
62        address: Address,
63        bls_key_pair: BLSKeyPair,
64        schnorr_key_pair: StateKeyPair,
65    },
66    /// Load from prepared node signature source
67    PreparedPayload(NodeSignatureSource),
68}
69
70/// Serialization formats supported by the CLI
71#[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/// Source for pre-prepared NodeSignatures
81#[derive(Debug)]
82pub enum NodeSignatureSource {
83    /// Read from stdin with specified format
84    Stdin(SerializationFormat),
85    /// Read from file path with optional format override
86    File {
87        path: PathBuf,
88        format: Option<SerializationFormat>,
89    },
90}
91
92/// Destination for node signature output
93#[derive(Debug)]
94pub enum NodeSignatureDestination {
95    /// Write to stdout with specified format
96    Stdout(SerializationFormat),
97    /// Write to file with specified format
98    File {
99        path: PathBuf,
100        format: SerializationFormat,
101    },
102}
103
104/// Clap arguments for node signature operations
105#[derive(Args, Clone, Debug)]
106pub struct NodeSignatureArgs {
107    /// The consensus signing key. Used to sign a message to prove ownership of the key.
108    #[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    /// The state signing key.
112    #[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    /// Path to file or "-" for stdin (format auto-detected)
116    #[clap(long, required_unless_present_all = ["consensus_private_key", "state_private_key"])]
117    pub node_signatures: Option<PathBuf>,
118
119    /// Input format for stdin (auto-detected for files)
120    #[clap(long, value_enum)]
121    pub format: Option<SerializationFormat>,
122}
123
124/// Clap arguments for output operations
125#[derive(Args, Clone, Debug)]
126pub struct OutputArgs {
127    /// Output file path. If not specified, outputs to stdout
128    #[clap(long)]
129    pub output: Option<PathBuf>,
130
131    /// Output format
132    #[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    /// Create NodeSignatures by signing an Ethereum address with BLS and Schnorr keys
166    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    /// Verify that the BLS and Schnorr signatures are valid for the given address
184    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    /// Handle output of the payload to the specified destination
191    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    /// Parse NodeSignatureSource from a PathBuf and optional format, where "-" means stdin
215    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            // Infer format from extension if not explicitly provided
223            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                // Format selection logic:
244                // 1. If format is explicitly specified, use it (allows overrides)
245                // 2. If no format specified, infer from extension
246                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
336/// Verifies the signatures sign the Ethereum wallet address
337impl 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                // Verify the signatures match the expected keys using the wallet address
353                payload.verify_signatures(wallet_address)?;
354
355                // Should never fail unless serialized payload was tampered with
356                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, // Let it infer from extension
474        };
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}