diff --git a/Cargo.lock b/Cargo.lock index a858d89ffa4..4b067e99ad1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2765,19 +2765,24 @@ dependencies = [ "async-trait", "atty", "clap 4.5.20", + "criterion", "forc-tracing 0.66.2", "forc-util", "fuel-core-types", "fuel-crypto", + "fuels-accounts", "fuels-core", "futures", "hex", "libp2p-identity", "rand", + "rayon", + "regex", "serde", "serde_json", "serde_yaml", "sha3", + "tempfile", "termion 4.0.3", "tokio", "tracing", diff --git a/forc-plugins/forc-crypto/Cargo.toml b/forc-plugins/forc-crypto/Cargo.toml index 1faf2be8a23..bea4711b7d5 100644 --- a/forc-plugins/forc-crypto/Cargo.toml +++ b/forc-plugins/forc-crypto/Cargo.toml @@ -17,11 +17,14 @@ forc-tracing.workspace = true forc-util.workspace = true fuel-core-types.workspace = true fuel-crypto = { workspace = true, features = ["random"] } +fuels-accounts.workspace = true fuels-core.workspace = true futures.workspace = true hex.workspace = true libp2p-identity = { workspace = true, features = ["peerid", "secp256k1"] } rand.workspace = true +rayon.workspace = true +regex.workspace = true serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true @@ -29,3 +32,11 @@ sha3.workspace = true termion.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process"] } tracing.workspace = true + +[dev-dependencies] +criterion = "0.5" +tempfile.workspace = true + +[[bench]] +name = "bench_main" +harness = false diff --git a/forc-plugins/forc-crypto/benches/bench_main.rs b/forc-plugins/forc-crypto/benches/bench_main.rs new file mode 100644 index 00000000000..675923911cb --- /dev/null +++ b/forc-plugins/forc-crypto/benches/bench_main.rs @@ -0,0 +1,73 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use forc_crypto::keys::vanity::{find_vanity_address_with_timeout, HexMatcher, RegexMatcher}; +use rayon::iter::Either; + +fn benchmark_vanity_address(c: &mut Criterion) { + let mut group = c.benchmark_group("Vanity Address Generation"); + + // Benchmark HexMatcher with prefix + group.bench_function("HexMatcher (starts with 'a')", |b| { + b.iter(|| { + let matcher = Either::Right(HexMatcher::new("a", "").unwrap()); + find_vanity_address_with_timeout(black_box(matcher), false, None) + }) + }); + + // Benchmark HexMatcher with suffix + group.bench_function("HexMatcher (ends with 'f')", |b| { + b.iter(|| { + let matcher = Either::Right(HexMatcher::new("", "f").unwrap()); + find_vanity_address_with_timeout(black_box(matcher), false, None) + }) + }); + + // Benchmark HexMatcher with both prefix and suffix + group.bench_function("HexMatcher (starts with 'a' ends with 'f')", |b| { + b.iter(|| { + let matcher = Either::Right(HexMatcher::new("a", "f").unwrap()); + find_vanity_address_with_timeout(black_box(matcher), false, None) + }) + }); + + // Benchmark RegexMatcher with simple pattern + group.bench_function("RegexMatcher (starts with 'a')", |b| { + b.iter(|| { + let matcher = Either::Left(RegexMatcher::new("^a.*").unwrap()); + find_vanity_address_with_timeout(black_box(matcher), false, None) + }) + }); + + // Benchmark RegexMatcher with complex pattern + group.bench_function("RegexMatcher (contains two consecutive digits)", |b| { + b.iter(|| { + let matcher = Either::Left(RegexMatcher::new(r"[0-9]{2}").unwrap()); + find_vanity_address_with_timeout(black_box(matcher), false, None) + }) + }); + + // Benchmark with mnemonic generation + group.bench_function("HexMatcher with Mnemonic (starts with 'a')", |b| { + b.iter(|| { + let matcher = Either::Right(HexMatcher::new("a", "").unwrap()); + find_vanity_address_with_timeout(black_box(matcher), true, None) + }) + }); + + group.bench_function("RegexMatcher with Mnemonic (starts with 'a')", |b| { + b.iter(|| { + let matcher = Either::Left(RegexMatcher::new("^a.*").unwrap()); + find_vanity_address_with_timeout(black_box(matcher), true, None) + }) + }); + + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(10) // Reduced sample size due to potentially long-running benchmarks + .measurement_time(std::time::Duration::from_secs(20)); + targets = benchmark_vanity_address +} +criterion_main!(benches); diff --git a/forc-plugins/forc-crypto/src/args.rs b/forc-plugins/forc-crypto/src/args.rs index 8e4bcee25fd..4201894632c 100644 --- a/forc-plugins/forc-crypto/src/args.rs +++ b/forc-plugins/forc-crypto/src/args.rs @@ -24,17 +24,17 @@ forc_util::cli_examples! { pub struct HashArgs { /// This argument is optional, it can be either: /// - /// 1. A path to a file. If that is the case, the content of the file is - /// loaded. + /// 1. A path to a file. If that is the case, the content of the file is + /// loaded /// - /// 2. A binary string encoded as a hex string. If that is the case, the - /// hex is decoded and passed as a Vec + /// 2. A binary string encoded as a hex string. If that is the case, the + /// hex is decoded and passed as a Vec /// - /// 3. A string. This is the last option, if the string is "-", `stdin` - /// is read instead. Otherwise the raw string is converted to a Vec - /// and passed + /// 3. A string. This is the last option, if the string is "-", "stdin" + /// is read instead. Otherwise the raw string is converted to a Vec + /// and passed /// - /// 4. If it nos not provided, `stdin` is read + /// 4. If it is not provided, "stdin" is read content_or_filepath: Option, } diff --git a/forc-plugins/forc-crypto/src/keys/mod.rs b/forc-plugins/forc-crypto/src/keys/mod.rs index 9741e32623c..8935b5f366e 100644 --- a/forc-plugins/forc-crypto/src/keys/mod.rs +++ b/forc-plugins/forc-crypto/src/keys/mod.rs @@ -3,6 +3,7 @@ use clap::ValueEnum; pub mod get_public_key; pub mod new_key; pub mod parse_secret; +pub mod vanity; #[derive(Clone, Debug, Default, ValueEnum)] pub enum KeyType { diff --git a/forc-plugins/forc-crypto/src/keys/vanity.rs b/forc-plugins/forc-crypto/src/keys/vanity.rs new file mode 100644 index 00000000000..640165dc818 --- /dev/null +++ b/forc-plugins/forc-crypto/src/keys/vanity.rs @@ -0,0 +1,457 @@ +use fuel_crypto::{fuel_types::Address, PublicKey, SecretKey}; +use fuels_accounts::wallet::{generate_mnemonic_phrase, DEFAULT_DERIVATION_PATH_PREFIX}; +use fuels_core::types::{ + bech32::{Bech32Address, FUEL_BECH32_HRP}, + checksum_address::checksum_encode, +}; +use rayon::iter::{self, Either, ParallelIterator}; +use regex::Regex; +use serde_json::json; +use std::{ + path::PathBuf, + time::{Duration, Instant}, +}; +use tokio::runtime::Runtime; + +forc_util::cli_examples! { + crate::Command { + [ Generate a checksummed vanity address with a given prefix => "forc crypto vanity --starts-with \"aaa\"" ] + [ Generate a checksummed vanity address with a given suffix => "forc crypto vanity --ends-with \"aaa\"" ] + [ Generate a checksummed vanity address with a given prefix and suffix => "forc crypto vanity --starts-with \"00\" --ends-with \"ff\"" ] + [ Generate a checksummed vanity address with a given regex pattern => "forc crypto vanity --regex \"^00.*ff$\"" ] + } +} + +fn validate_hex_string(s: &str) -> Result { + if !s.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("Pattern must contain only hex characters (0-9, a-f)".to_string()); + } + Ok(s.to_string()) +} + +fn validate_regex_pattern(s: &str) -> Result { + if s.len() > 128 { + return Err("Regex pattern too long: max 128 characters".to_string()); + } + + if let Err(e) = Regex::new(&format!("(?i){}", s)) { + return Err(format!("Invalid regex pattern: {}", e)); + } + + Ok(s.to_string()) +} + +#[derive(Debug, clap::Parser)] +#[clap( + version, + about = "Generate a vanity address", + after_help = "Generate vanity addresses for the Fuel blockchain" +)] +pub struct Arg { + /// Desired hex string prefix for the address + #[arg( + long, + value_name = "HEX_STRING", + required_unless_present = "ends_with", + required_unless_present = "regex", + conflicts_with = "regex", + value_parser = validate_hex_string, + )] + pub starts_with: Option, + + /// Desired hex string suffix for the address + #[arg(long, value_name = "HEX_STRING", conflicts_with = "regex", value_parser = validate_hex_string)] + pub ends_with: Option, + + /// Desired regex pattern to match the entire address (case-insensitive) + #[arg(long, value_name = "PATTERN", conflicts_with = "starts_with", value_parser = validate_regex_pattern)] + pub regex: Option, + + /// Timeout in seconds for address generation + #[arg(long, value_name = "SECONDS")] + pub timeout: Option, + + /// Return mnemonic with address (default false) + #[arg(long)] + pub mnemonic: bool, + + /// Path to save the generated vanity address to. + #[arg(long, value_hint = clap::ValueHint::FilePath, value_name = "PATH")] + pub save_path: Option, +} + +impl Arg { + pub fn validate(&self) -> anyhow::Result<()> { + let total_length = self.starts_with.as_ref().map_or(0, |s| s.len()) + + self.ends_with.as_ref().map_or(0, |s| s.len()); + if total_length > 64 { + return Err(anyhow::anyhow!( + "Combined pattern length exceeds 64 characters" + )); + } + Ok(()) + } +} + +pub fn handler(args: Arg) -> anyhow::Result { + args.validate()?; + + let Arg { + starts_with, + ends_with, + regex, + mnemonic, + timeout, + save_path, + } = args; + + let matcher = if let Some(pattern) = regex { + Either::Left(RegexMatcher::new(&pattern)?) + } else { + let starts_with = starts_with.as_deref().unwrap_or(""); + let ends_with = ends_with.as_deref().unwrap_or(""); + Either::Right(HexMatcher::new(starts_with, ends_with)?) + }; + + println!("Starting to generate vanity address..."); + let start_time = Instant::now(); + + let result = find_vanity_address_with_timeout(matcher, mnemonic, timeout)?; + let (address, secret_key, mnemonic) = result; + + let duration = start_time.elapsed(); + println!( + "Successfully found vanity address in {:.3} seconds.\n", + duration.as_secs_f64() + ); + + let checksum_address = checksum_encode(&address.to_string())?; + let result = if let Some(mnemonic) = mnemonic { + json!({ + "Address": checksum_address, + "PrivateKey": hex::encode(secret_key.as_ref()), + "Mnemonic": mnemonic, + }) + } else { + json!({ + "Address": checksum_address, + "PrivateKey": hex::encode(secret_key.as_ref()), + }) + }; + + if let Some(path) = save_path { + std::fs::write(path, serde_json::to_string_pretty(&result)?)?; + } + + Ok(result) +} + +pub trait VanityMatcher: Send + Sync + 'static { + fn is_match(&self, addr: &Address) -> bool; +} + +pub struct HexMatcher { + prefix: String, + suffix: String, +} + +impl HexMatcher { + pub fn new(prefix: &str, suffix: &str) -> anyhow::Result { + Ok(Self { + prefix: prefix.to_lowercase(), + suffix: suffix.to_lowercase(), + }) + } +} + +impl VanityMatcher for HexMatcher { + fn is_match(&self, addr: &Address) -> bool { + let hex_addr = hex::encode(addr.as_ref()).to_lowercase(); + hex_addr.starts_with(&self.prefix) && hex_addr.ends_with(&self.suffix) + } +} + +pub struct RegexMatcher { + re: Regex, +} + +impl RegexMatcher { + pub fn new(pattern: &str) -> anyhow::Result { + let re = Regex::new(&format!("(?i){}", pattern))?; + Ok(Self { re }) + } +} + +impl VanityMatcher for RegexMatcher { + fn is_match(&self, addr: &Address) -> bool { + let addr = hex::encode(addr.as_ref()); + self.re.is_match(&addr) + } +} + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +pub fn find_vanity_address_with_timeout( + matcher: Either, + use_mnemonic: bool, + timeout_secs: Option, +) -> anyhow::Result<(Address, SecretKey, Option)> { + let should_stop = Arc::new(AtomicBool::new(false)); + let should_stop_clone = should_stop.clone(); + + let generate_wallet = move || { + let breakpoint = if use_mnemonic { 1_000 } else { 100_000 }; + let start = Instant::now(); + let attempts = std::sync::atomic::AtomicUsize::new(0); + + wallet_generator(use_mnemonic) + .find_any(|result| { + // Check if we should stop due to timeout + if should_stop.load(Ordering::Relaxed) { + return true; // This will cause find_any to return the current result + } + + let current = attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if current != 0 && current % breakpoint == 0 { + let elapsed = start.elapsed().as_secs_f64(); + let rate = current as f64 / elapsed; + println!( + "└─ tried {} addresses ({:.2} addresses/sec)...", + current, rate + ); + } + + if let Ok((addr, _, _)) = result { + match &matcher { + Either::Left(regex_matcher) => regex_matcher.is_match(addr), + Either::Right(hex_matcher) => hex_matcher.is_match(addr), + } + } else { + false + } + }) + .ok_or_else(|| anyhow::anyhow!("No matching address found"))? + }; + + let Some(secs) = timeout_secs else { + return generate_wallet(); + }; + + Runtime::new()?.block_on(async { + let generation_task = tokio::task::spawn_blocking(generate_wallet); + + tokio::select! { + result = generation_task => { + match result { + Ok(wallet_result) => wallet_result, + Err(_) => Err(anyhow::anyhow!("No matching address found")), + } + } + _ = tokio::time::sleep(Duration::from_secs(secs)) => { + // Signal all threads to stop + should_stop_clone.store(true, Ordering::Relaxed); + // Wait a short time for threads to notice the stop signal + tokio::time::sleep(Duration::from_millis(100)).await; + Err(anyhow::anyhow!("Vanity address generation timed out after {} seconds", secs)) + } + } + }) +} + +#[inline] +fn wallet_generator( + use_mnemonic: bool, +) -> impl ParallelIterator)>> { + iter::repeat(()).map(move |()| generate_wallet(use_mnemonic)) +} + +fn generate_wallet(use_mnemonic: bool) -> anyhow::Result<(Address, SecretKey, Option)> { + let mut rng = rand::thread_rng(); + + let (private_key, mnemonic) = if use_mnemonic { + let mnemonic = generate_mnemonic_phrase(&mut rng, 24)?; + let account_ix = 0; + let derivation_path = format!("{DEFAULT_DERIVATION_PATH_PREFIX}/{account_ix}'/0/0"); + let private_key = + SecretKey::new_from_mnemonic_phrase_with_path(&mnemonic, &derivation_path)?; + (private_key, Some(mnemonic)) + } else { + (SecretKey::random(&mut rng), None) + }; + + let public = PublicKey::from(&private_key); + let hashed = public.hash(); + let address = Bech32Address::new(FUEL_BECH32_HRP, hashed); + + Ok((address.into(), private_key, mnemonic)) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + // Helper function to parse args and get validation errors + fn parse_args(args: Vec<&str>) -> Result { + let args = + Arg::try_parse_from(std::iter::once("test").chain(args)).map_err(|e| e.to_string())?; + args.validate().map_err(|e| e.to_string())?; + Ok(args) + } + + #[test] + fn test_invalid_hex_characters() { + let result = parse_args(vec!["--starts-with", "xyz"]); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "error: invalid value 'xyz' for '--starts-with ': Pattern must contain only hex characters (0-9, a-f)\n\nFor more information, try '--help'.\n"); + } + + #[test] + fn test_pattern_too_long() { + let result = parse_args(vec![ + "--starts-with", + &"a".repeat(32), + "--ends-with", + &"b".repeat(33), + ]); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Combined pattern length exceeds 64 characters" + ); + } + + #[test] + fn test_invalid_regex_syntax() { + let result = parse_args(vec!["--regex", "["]); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "error: invalid value '[' for '--regex ': Invalid regex pattern: regex parse error:\n (?i)[\n ^\nerror: unclosed character class\n\nFor more information, try '--help'.\n"); + } + + #[test] + fn test_regex_too_long() { + let result = parse_args(vec!["--regex", &"a".repeat(129)]); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "error: invalid value 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' for '--regex ': Regex pattern too long: max 128 characters\n\nFor more information, try '--help'.\n"); + } + + #[test] + fn test_conflicting_args() { + let result = parse_args(vec!["--starts-with", "aa", "--regex", "^aa"]); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "error: the argument '--starts-with ' cannot be used with '--regex '\n\nUsage: test --starts-with \n\nFor more information, try '--help'.\n"); + } + + #[test] + fn test_timeout_respected() { + // This pattern should take a long time to generate + let args = parse_args(vec!["--starts-with", "fffffffffffff", "--timeout", "1"]).unwrap(); + + let result = handler(args); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Vanity address generation timed out after 1 seconds" + ); + } + + // Test actual functionality with minimal patterns + #[test] + fn test_valid_short_prefix() { + let args = parse_args(vec!["--starts-with", "a"]).unwrap(); + let result = handler(args).unwrap(); + let address = result["Address"].as_str().unwrap(); + assert!( + address.to_lowercase().starts_with("0xa"), + "Address should start with 'a'" + ); + } + + #[test] + fn test_valid_short_suffix() { + let args = parse_args(vec!["--ends-with", "a"]).unwrap(); + let result = handler(args).unwrap(); + let address = result["Address"].as_str().unwrap(); + assert!( + address.to_lowercase().ends_with('a'), + "Address should end with 'a'" + ); + } + + #[test] + fn test_both_prefix_and_suffix() { + let args = parse_args(vec!["--starts-with", "a", "--ends-with", "b"]).unwrap(); + let result = handler(args).unwrap(); + let address = result["Address"].as_str().unwrap().to_lowercase(); + assert!(address.starts_with("0xa"), "Address should start with 'a'"); + assert!(address.ends_with('b'), "Address should end with 'b'"); + } + + #[test] + fn test_simple_regex() { + let args = parse_args(vec!["--regex", "^a.*b$"]).unwrap(); + let result = handler(args).unwrap(); + let address = result["Address"].as_str().unwrap().to_lowercase(); + assert!(address.starts_with("0xa"), "Address should start with 'a'"); + assert!(address.ends_with('b'), "Address should end with 'b'"); + } + + #[test] + fn test_simple_regex_uppercase() { + let args = parse_args(vec!["--regex", "^A.*B$"]).unwrap(); + let result = handler(args).unwrap(); + let address = result["Address"].as_str().unwrap().to_lowercase(); + assert!(address.starts_with("0xa"), "Address should start with 'a'"); + assert!(address.ends_with('b'), "Address should end with 'b'"); + } + + #[test] + fn test_mnemonic_generation() { + let args = parse_args(vec!["--starts-with", "a", "--mnemonic"]).unwrap(); + let result = handler(args).unwrap(); + + assert!(result["Mnemonic"].is_string(), "Mnemonic should be present"); + assert_eq!( + result["Mnemonic"] + .as_str() + .unwrap() + .split_whitespace() + .count(), + 24, + "Mnemonic should have 24 words" + ); + + let address = result["Address"].as_str().unwrap(); + assert!( + address.to_lowercase().starts_with("0xa"), + "Address should start with 'a'" + ); + } + + #[test] + fn test_save_path() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let args = parse_args(vec![ + "--starts-with", + "a", + "--save-path", + tmp.path().to_str().unwrap(), + ]) + .unwrap(); + + handler(args).unwrap(); + + assert!(tmp.path().exists(), "File should exist"); + let content = std::fs::read_to_string(tmp.path()).unwrap(); + let saved_result: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!( + saved_result["Address"].is_string(), + "Saved result should contain an Address" + ); + assert!( + saved_result["PrivateKey"].is_string(), + "Saved result should contain a PrivateKey" + ); + } +} diff --git a/forc-plugins/forc-crypto/src/lib.rs b/forc-plugins/forc-crypto/src/lib.rs new file mode 100644 index 00000000000..7c89d848120 --- /dev/null +++ b/forc-plugins/forc-crypto/src/lib.rs @@ -0,0 +1,37 @@ +pub mod address; +pub mod args; +pub mod keccak256; +pub mod keys; +pub mod sha256; + +pub(crate) fn help() -> &'static str { + Box::leak( + format!( + "EXAMPLES:\n{}{}{}{}{}{}", + args::examples(), + address::examples(), + keys::new_key::examples(), + keys::parse_secret::examples(), + keys::get_public_key::examples(), + keys::vanity::examples(), + ) + .into_boxed_str(), + ) +} + +/// Forc plugin for hashing arbitrary data +#[derive(Debug, clap::Parser)] +#[clap( + name = "forc-crypto", + after_help = help(), + version +)] +pub enum Command { + Keccak256(args::HashArgs), + Sha256(args::HashArgs), + Address(address::Args), + GetPublicKey(keys::get_public_key::Arg), + NewKey(keys::new_key::Arg), + ParseSecret(keys::parse_secret::Arg), + Vanity(keys::vanity::Arg), +} diff --git a/forc-plugins/forc-crypto/src/main.rs b/forc-plugins/forc-crypto/src/main.rs index fba619b2b59..c9ae55b23f0 100644 --- a/forc-plugins/forc-crypto/src/main.rs +++ b/forc-plugins/forc-crypto/src/main.rs @@ -3,6 +3,7 @@ use anyhow::Result; use atty::Stream; use clap::Parser; +use forc_crypto::{address, keccak256, keys, sha256, Command}; use forc_tracing::{init_tracing_subscriber, println_error}; use std::{ default::Default, @@ -10,42 +11,6 @@ use std::{ }; use termion::screen::IntoAlternateScreen; -mod address; -mod args; -mod keccak256; -mod keys; -mod sha256; - -fn help() -> &'static str { - Box::leak( - format!( - "EXAMPLES:\n{}{}{}{}{}", - args::examples(), - address::examples(), - keys::new_key::examples(), - keys::parse_secret::examples(), - keys::get_public_key::examples(), - ) - .into_boxed_str(), - ) -} - -/// Forc plugin for hashing arbitrary data -#[derive(Debug, Parser)] -#[clap( - name = "forc-crypto", - after_help = help(), - version -)] -pub enum Command { - Keccak256(args::HashArgs), - Sha256(args::HashArgs), - Address(address::Args), - GetPublicKey(keys::get_public_key::Arg), - NewKey(keys::new_key::Arg), - ParseSecret(keys::parse_secret::Arg), -} - fn main() { init_tracing_subscriber(Default::default()); if let Err(err) = run() { @@ -59,6 +24,7 @@ fn run() -> Result<()> { let content = match app { Command::Keccak256(arg) => keccak256::hash(arg)?, Command::GetPublicKey(arg) => keys::get_public_key::handler(arg)?, + Command::Vanity(arg) => keys::vanity::handler(arg)?, Command::Sha256(arg) => sha256::hash(arg)?, Command::Address(arg) => address::dump_address(arg.address)?, Command::NewKey(arg) => keys::new_key::handler(arg)?, @@ -83,7 +49,7 @@ where } } -pub(crate) fn display_output(message: T) -> anyhow::Result<()> +pub fn display_output(message: T) -> anyhow::Result<()> where T: serde::Serialize, {