Skip to main content

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