forc_crypto/keys/
vanity.rs

1use fuel_crypto::{fuel_types::Address, PublicKey, SecretKey};
2use fuels_accounts::signers::{
3    derivation::DEFAULT_DERIVATION_PATH, private_key::generate_mnemonic_phrase,
4};
5use fuels_core::types::{
6    bech32::{Bech32Address, FUEL_BECH32_HRP},
7    checksum_address::checksum_encode,
8};
9use rayon::iter::{self, Either, ParallelIterator};
10use regex::Regex;
11use serde_json::json;
12use std::{
13    path::PathBuf,
14    time::{Duration, Instant},
15};
16use tokio::runtime::Runtime;
17
18forc_util::cli_examples! {
19    crate::Command {
20        [ Generate a checksummed vanity address with a given prefix => "forc crypto vanity --starts-with \"aaa\"" ]
21        [ Generate a checksummed vanity address with a given suffix => "forc crypto vanity --ends-with \"aaa\"" ]
22        [ Generate a checksummed vanity address with a given prefix and suffix => "forc crypto vanity --starts-with \"00\" --ends-with \"ff\"" ]
23        [ Generate a checksummed vanity address with a given regex pattern => "forc crypto vanity --regex \"^00.*ff$\"" ]
24    }
25}
26
27fn validate_hex_string(s: &str) -> Result<String, String> {
28    if !s.chars().all(|c| c.is_ascii_hexdigit()) {
29        return Err("Pattern must contain only hex characters (0-9, a-f)".to_string());
30    }
31    Ok(s.to_string())
32}
33
34fn validate_regex_pattern(s: &str) -> Result<String, String> {
35    if s.len() > 128 {
36        return Err("Regex pattern too long: max 128 characters".to_string());
37    }
38
39    if let Err(e) = Regex::new(&format!("(?i){}", s)) {
40        return Err(format!("Invalid regex pattern: {}", e));
41    }
42
43    Ok(s.to_string())
44}
45
46#[derive(Debug, clap::Parser)]
47#[clap(
48    version,
49    about = "Generate a vanity address",
50    after_help = "Generate vanity addresses for the Fuel blockchain"
51)]
52pub struct Arg {
53    /// Desired hex string prefix for the address
54    #[arg(
55        long,
56        value_name = "HEX_STRING",
57        required_unless_present = "ends_with",
58        required_unless_present = "regex",
59        conflicts_with = "regex",
60        value_parser = validate_hex_string,
61    )]
62    pub starts_with: Option<String>,
63
64    /// Desired hex string suffix for the address
65    #[arg(long, value_name = "HEX_STRING", conflicts_with = "regex", value_parser = validate_hex_string)]
66    pub ends_with: Option<String>,
67
68    /// Desired regex pattern to match the entire address (case-insensitive)
69    #[arg(long, value_name = "PATTERN", conflicts_with = "starts_with", value_parser = validate_regex_pattern)]
70    pub regex: Option<String>,
71
72    /// Timeout in seconds for address generation
73    #[arg(long, value_name = "SECONDS")]
74    pub timeout: Option<u64>,
75
76    /// Return mnemonic with address (default false)
77    #[arg(long)]
78    pub mnemonic: bool,
79
80    /// Path to save the generated vanity address to.
81    #[arg(long, value_hint = clap::ValueHint::FilePath, value_name = "PATH")]
82    pub save_path: Option<PathBuf>,
83}
84
85impl Arg {
86    pub fn validate(&self) -> anyhow::Result<()> {
87        let total_length = self.starts_with.as_ref().map_or(0, |s| s.len())
88            + self.ends_with.as_ref().map_or(0, |s| s.len());
89        if total_length > 64 {
90            return Err(anyhow::anyhow!(
91                "Combined pattern length exceeds 64 characters"
92            ));
93        }
94        Ok(())
95    }
96}
97
98pub fn handler(args: Arg) -> anyhow::Result<serde_json::Value> {
99    args.validate()?;
100
101    let Arg {
102        starts_with,
103        ends_with,
104        regex,
105        mnemonic,
106        timeout,
107        save_path,
108    } = args;
109
110    let matcher = if let Some(pattern) = regex {
111        Either::Left(RegexMatcher::new(&pattern)?)
112    } else {
113        let starts_with = starts_with.as_deref().unwrap_or("");
114        let ends_with = ends_with.as_deref().unwrap_or("");
115        Either::Right(HexMatcher::new(starts_with, ends_with)?)
116    };
117
118    println!("Starting to generate vanity address...");
119    let start_time = Instant::now();
120
121    let result = find_vanity_address_with_timeout(matcher, mnemonic, timeout)?;
122    let (address, secret_key, mnemonic) = result;
123
124    let duration = start_time.elapsed();
125    println!(
126        "Successfully found vanity address in {:.3} seconds.\n",
127        duration.as_secs_f64()
128    );
129
130    let checksum_address = checksum_encode(&address.to_string())?;
131    let result = if let Some(mnemonic) = mnemonic {
132        json!({
133            "Address": checksum_address,
134            "PrivateKey": hex::encode(secret_key.as_ref()),
135            "Mnemonic": mnemonic,
136        })
137    } else {
138        json!({
139            "Address": checksum_address,
140            "PrivateKey": hex::encode(secret_key.as_ref()),
141        })
142    };
143
144    if let Some(path) = save_path {
145        std::fs::write(path, serde_json::to_string_pretty(&result)?)?;
146    }
147
148    Ok(result)
149}
150
151pub trait VanityMatcher: Send + Sync + 'static {
152    fn is_match(&self, addr: &Address) -> bool;
153}
154
155pub struct HexMatcher {
156    prefix: String,
157    suffix: String,
158}
159
160impl HexMatcher {
161    pub fn new(prefix: &str, suffix: &str) -> anyhow::Result<Self> {
162        Ok(Self {
163            prefix: prefix.to_lowercase(),
164            suffix: suffix.to_lowercase(),
165        })
166    }
167}
168
169impl VanityMatcher for HexMatcher {
170    fn is_match(&self, addr: &Address) -> bool {
171        let hex_addr = hex::encode(addr.as_ref()).to_lowercase();
172        hex_addr.starts_with(&self.prefix) && hex_addr.ends_with(&self.suffix)
173    }
174}
175
176pub struct RegexMatcher {
177    re: Regex,
178}
179
180impl RegexMatcher {
181    pub fn new(pattern: &str) -> anyhow::Result<Self> {
182        let re = Regex::new(&format!("(?i){}", pattern))?;
183        Ok(Self { re })
184    }
185}
186
187impl VanityMatcher for RegexMatcher {
188    fn is_match(&self, addr: &Address) -> bool {
189        let addr = hex::encode(addr.as_ref());
190        self.re.is_match(&addr)
191    }
192}
193
194use std::sync::atomic::{AtomicBool, Ordering};
195use std::sync::Arc;
196
197pub fn find_vanity_address_with_timeout(
198    matcher: Either<RegexMatcher, HexMatcher>,
199    use_mnemonic: bool,
200    timeout_secs: Option<u64>,
201) -> anyhow::Result<(Address, SecretKey, Option<String>)> {
202    let should_stop = Arc::new(AtomicBool::new(false));
203    let should_stop_clone = should_stop.clone();
204
205    let generate_wallet = move || {
206        let breakpoint = if use_mnemonic { 1_000 } else { 100_000 };
207        let start = Instant::now();
208        let attempts = std::sync::atomic::AtomicUsize::new(0);
209
210        wallet_generator(use_mnemonic)
211            .find_any(|result| {
212                // Check if we should stop due to timeout
213                if should_stop.load(Ordering::Relaxed) {
214                    return true; // This will cause find_any to return the current result
215                }
216
217                let current = attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
218                if current != 0 && current % breakpoint == 0 {
219                    let elapsed = start.elapsed().as_secs_f64();
220                    let rate = current as f64 / elapsed;
221                    println!(
222                        "└─ tried {} addresses ({:.2} addresses/sec)...",
223                        current, rate
224                    );
225                }
226
227                if let Ok((addr, _, _)) = result {
228                    match &matcher {
229                        Either::Left(regex_matcher) => regex_matcher.is_match(addr),
230                        Either::Right(hex_matcher) => hex_matcher.is_match(addr),
231                    }
232                } else {
233                    false
234                }
235            })
236            .ok_or_else(|| anyhow::anyhow!("No matching address found"))?
237    };
238
239    let Some(secs) = timeout_secs else {
240        return generate_wallet();
241    };
242
243    Runtime::new()?.block_on(async {
244        let generation_task = tokio::task::spawn_blocking(generate_wallet);
245
246        tokio::select! {
247            result = generation_task => {
248                match result {
249                    Ok(wallet_result) => wallet_result,
250                    Err(_) => Err(anyhow::anyhow!("No matching address found")),
251                }
252            }
253            _ = tokio::time::sleep(Duration::from_secs(secs)) => {
254                // Signal all threads to stop
255                should_stop_clone.store(true, Ordering::Relaxed);
256                // Wait a short time for threads to notice the stop signal
257                tokio::time::sleep(Duration::from_millis(100)).await;
258                Err(anyhow::anyhow!("Vanity address generation timed out after {} seconds", secs))
259            }
260        }
261    })
262}
263
264#[inline]
265fn wallet_generator(
266    use_mnemonic: bool,
267) -> impl ParallelIterator<Item = anyhow::Result<(Address, SecretKey, Option<String>)>> {
268    iter::repeat(()).map(move |()| generate_wallet(use_mnemonic))
269}
270
271fn generate_wallet(use_mnemonic: bool) -> anyhow::Result<(Address, SecretKey, Option<String>)> {
272    let mut rng = rand::thread_rng();
273
274    let (private_key, mnemonic) = if use_mnemonic {
275        let mnemonic = generate_mnemonic_phrase(&mut rng, 24)?;
276        let private_key =
277            SecretKey::new_from_mnemonic_phrase_with_path(&mnemonic, DEFAULT_DERIVATION_PATH)?;
278        (private_key, Some(mnemonic))
279    } else {
280        (SecretKey::random(&mut rng), None)
281    };
282
283    let public = PublicKey::from(&private_key);
284    let hashed = public.hash();
285    let address = Bech32Address::new(FUEL_BECH32_HRP, hashed);
286
287    Ok((address.into(), private_key, mnemonic))
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use clap::Parser;
294
295    // Helper function to parse args and get validation errors
296    fn parse_args(args: Vec<&str>) -> Result<Arg, String> {
297        let args =
298            Arg::try_parse_from(std::iter::once("test").chain(args)).map_err(|e| e.to_string())?;
299        args.validate().map_err(|e| e.to_string())?;
300        Ok(args)
301    }
302
303    #[test]
304    fn test_invalid_hex_characters() {
305        let result = parse_args(vec!["--starts-with", "xyz"]);
306        assert!(result.is_err());
307        assert_eq!(result.unwrap_err(), "error: invalid value 'xyz' for '--starts-with <HEX_STRING>': Pattern must contain only hex characters (0-9, a-f)\n\nFor more information, try '--help'.\n");
308    }
309
310    #[test]
311    fn test_pattern_too_long() {
312        let result = parse_args(vec![
313            "--starts-with",
314            &"a".repeat(32),
315            "--ends-with",
316            &"b".repeat(33),
317        ]);
318        assert!(result.is_err());
319        assert_eq!(
320            result.unwrap_err(),
321            "Combined pattern length exceeds 64 characters"
322        );
323    }
324
325    #[test]
326    fn test_invalid_regex_syntax() {
327        let result = parse_args(vec!["--regex", "["]);
328        assert!(result.is_err());
329        assert_eq!(result.unwrap_err(), "error: invalid value '[' for '--regex <PATTERN>': Invalid regex pattern: regex parse error:\n    (?i)[\n        ^\nerror: unclosed character class\n\nFor more information, try '--help'.\n");
330    }
331
332    #[test]
333    fn test_regex_too_long() {
334        let result = parse_args(vec!["--regex", &"a".repeat(129)]);
335        assert!(result.is_err());
336        assert_eq!(result.unwrap_err(), "error: invalid value 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' for '--regex <PATTERN>': Regex pattern too long: max 128 characters\n\nFor more information, try '--help'.\n");
337    }
338
339    #[test]
340    fn test_conflicting_args() {
341        let result = parse_args(vec!["--starts-with", "aa", "--regex", "^aa"]);
342        assert!(result.is_err());
343        assert_eq!(result.unwrap_err(), "error: the argument '--starts-with <HEX_STRING>' cannot be used with '--regex <PATTERN>'\n\nUsage: test --starts-with <HEX_STRING>\n\nFor more information, try '--help'.\n");
344    }
345
346    #[test]
347    fn test_timeout_respected() {
348        // This pattern should take a long time to generate
349        let args = parse_args(vec!["--starts-with", "fffffffffffff", "--timeout", "1"]).unwrap();
350
351        let result = handler(args);
352        assert!(result.is_err());
353        assert_eq!(
354            result.unwrap_err().to_string(),
355            "Vanity address generation timed out after 1 seconds"
356        );
357    }
358
359    // Test actual functionality with minimal patterns
360    #[test]
361    fn test_valid_short_prefix() {
362        let args = parse_args(vec!["--starts-with", "a"]).unwrap();
363        let result = handler(args).unwrap();
364        let address = result["Address"].as_str().unwrap();
365        assert!(
366            address.to_lowercase().starts_with("0xa"),
367            "Address should start with 'a'"
368        );
369    }
370
371    #[test]
372    fn test_valid_short_suffix() {
373        let args = parse_args(vec!["--ends-with", "a"]).unwrap();
374        let result = handler(args).unwrap();
375        let address = result["Address"].as_str().unwrap();
376        assert!(
377            address.to_lowercase().ends_with('a'),
378            "Address should end with 'a'"
379        );
380    }
381
382    #[test]
383    fn test_both_prefix_and_suffix() {
384        let args = parse_args(vec!["--starts-with", "a", "--ends-with", "b"]).unwrap();
385        let result = handler(args).unwrap();
386        let address = result["Address"].as_str().unwrap().to_lowercase();
387        assert!(address.starts_with("0xa"), "Address should start with 'a'");
388        assert!(address.ends_with('b'), "Address should end with 'b'");
389    }
390
391    #[test]
392    fn test_simple_regex() {
393        let args = parse_args(vec!["--regex", "^a.*b$"]).unwrap();
394        let result = handler(args).unwrap();
395        let address = result["Address"].as_str().unwrap().to_lowercase();
396        assert!(address.starts_with("0xa"), "Address should start with 'a'");
397        assert!(address.ends_with('b'), "Address should end with 'b'");
398    }
399
400    #[test]
401    fn test_simple_regex_uppercase() {
402        let args = parse_args(vec!["--regex", "^A.*B$"]).unwrap();
403        let result = handler(args).unwrap();
404        let address = result["Address"].as_str().unwrap().to_lowercase();
405        assert!(address.starts_with("0xa"), "Address should start with 'a'");
406        assert!(address.ends_with('b'), "Address should end with 'b'");
407    }
408
409    #[test]
410    fn test_mnemonic_generation() {
411        let args = parse_args(vec!["--starts-with", "a", "--mnemonic"]).unwrap();
412        let result = handler(args).unwrap();
413
414        assert!(result["Mnemonic"].is_string(), "Mnemonic should be present");
415        assert_eq!(
416            result["Mnemonic"]
417                .as_str()
418                .unwrap()
419                .split_whitespace()
420                .count(),
421            24,
422            "Mnemonic should have 24 words"
423        );
424
425        let address = result["Address"].as_str().unwrap();
426        assert!(
427            address.to_lowercase().starts_with("0xa"),
428            "Address should start with 'a'"
429        );
430    }
431
432    #[test]
433    fn test_save_path() {
434        let tmp = tempfile::NamedTempFile::new().unwrap();
435        let args = parse_args(vec![
436            "--starts-with",
437            "a",
438            "--save-path",
439            tmp.path().to_str().unwrap(),
440        ])
441        .unwrap();
442
443        handler(args).unwrap();
444
445        assert!(tmp.path().exists(), "File should exist");
446        let content = std::fs::read_to_string(tmp.path()).unwrap();
447        let saved_result: serde_json::Value = serde_json::from_str(&content).unwrap();
448        assert!(
449            saved_result["Address"].is_string(),
450            "Saved result should contain an Address"
451        );
452        assert!(
453            saved_result["PrivateKey"].is_string(),
454            "Saved result should contain a PrivateKey"
455        );
456    }
457}