gm-tui 0.0.1

TUI-based Ethereum wallet
Documentation
use std::{
    sync::{atomic::AtomicBool, mpsc, Arc},
    thread::{self, JoinHandle},
    time::{Duration, Instant},
};

use alloy::primitives::{address, Address};
use crossterm::event::KeyCode;
use ratatui::{
    buffer::Buffer,
    layout::{Offset, Rect},
    style::{Modifier, Style, Stylize},
    text::{Line, Span},
    widgets::{Gauge, Widget},
};

use crate::{
    tui::{
        app::SharedState,
        events::Event,
        traits::{Component, HandleResult},
    },
    utils::account::{mine_wallet, AccountManager, AccountUtils},
};

#[derive(PartialEq)]
pub enum HashRateResult {
    None,
    Pending,
    Some(usize),
    Error(String),
}

pub struct AccountCreatePage {
    pub cursor: usize,
    pub mask: [Option<u8>; 40],
    pub error: Option<String>,
    pub hash_rate_thread: Option<JoinHandle<()>>,
    pub hash_rate: HashRateResult,
    pub mining: bool,
    pub started_mining_at: Instant,
    pub vanity_thread: Option<JoinHandle<()>>,
    pub vanity_result: Option<(Address, usize, Duration)>,
    pub mnemonic_result: Option<Address>,
}

impl Default for AccountCreatePage {
    fn default() -> Self {
        Self {
            cursor: 0,
            mask: [None; 40],
            error: None,
            hash_rate_thread: None,
            hash_rate: HashRateResult::None,
            mining: false,
            started_mining_at: Instant::now(),
            vanity_thread: None,
            vanity_result: None,
            mnemonic_result: None,
        }
    }
}

impl AccountCreatePage {
    pub fn is_mask_empty(&self) -> bool {
        self.mask.iter().all(|&x| x.is_none())
    }

    pub fn mask_count(&self) -> usize {
        self.mask.iter().filter(|&&x| x.is_some()).count()
    }

    pub fn mask_a_b(&self) -> (Address, Address) {
        let mut mask_a = [0; 20];
        let mut mask_b = [0; 20];

        for (i, &b) in self.mask.iter().enumerate() {
            if let Some(n) = b {
                mask_a[i / 2] |= n << ((1 - i % 2) * 4);
                mask_b[i / 2] |= (0xf ^ n) << ((1 - i % 2) * 4);
            }
        }

        (Address::from(mask_a), Address::from(mask_b))
    }
}

impl Component for AccountCreatePage {
    async fn exit_threads(&mut self) {
        if let Some(thread) = self.hash_rate_thread.take() {
            thread.join().unwrap();
        }
        if let Some(thread) = self.vanity_thread.take() {
            thread.join().unwrap();
        }
    }

    fn handle_event(
        &mut self,
        event: &Event,
        _area: Rect,
        transmitter: &mpsc::Sender<Event>,
        shutdown_signal: &Arc<AtomicBool>,
        _shared_state: &SharedState,
    ) -> crate::Result<HandleResult> {
        let result = HandleResult::default();

        let cursor_max = self.mask.len();
        match event {
            Event::Input(key_event) => match key_event.code {
                KeyCode::Right => {
                    self.cursor = (self.cursor + 1) % cursor_max;
                }
                KeyCode::Left => {
                    self.cursor = (self.cursor + cursor_max - 1) % cursor_max;
                }
                KeyCode::Char(c) => match c {
                    '0'..='9' => {
                        self.mask[self.cursor] = Some(c as u8 - b'0');
                        if self.cursor < cursor_max - 1 {
                            self.cursor += 1;
                        }
                    }
                    'a'..='f' => {
                        self.mask[self.cursor] = Some(c as u8 - b'a' + 10);
                        if self.cursor < cursor_max - 1 {
                            self.cursor += 1;
                        }
                    }
                    'A'..='F' => {
                        self.mask[self.cursor] = Some(c as u8 - b'A' + 10);
                        if self.cursor < cursor_max - 1 {
                            self.cursor += 1;
                        }
                    }
                    _ => {}
                },
                KeyCode::Backspace => {
                    if self.cursor > 0 {
                        self.mask[self.cursor - 1] = None;
                        self.cursor -= 1;
                    }
                    if self.cursor == 0 || self.cursor == cursor_max {
                        self.mask[self.cursor] = None;
                    }
                }
                KeyCode::Enter => {
                    if self.is_mask_empty() {
                        let addr = AccountManager::create_mnemonic_wallet()?;
                        self.mnemonic_result = Some(addr);
                    }

                    if !self.mining {
                        self.mining = true;
                        self.started_mining_at = Instant::now();
                        let tr = transmitter.clone();
                        let (mask_a, mask_b) = self.mask_a_b();
                        let shutdown_signal = shutdown_signal.clone();
                        let vanity_thread = thread::spawn(move || {
                            let result = mine_wallet(mask_a, mask_b, None, shutdown_signal);
                            if let Ok((Some(key), counter, duration)) = result {
                                tr.send(Event::VanityResult(key, counter, duration))
                                    .unwrap();
                            }
                        });
                        self.vanity_thread = Some(vanity_thread);
                    }
                }
                _ => {}
            },
            Event::HashRateResult(hash_rate) => {
                self.hash_rate = HashRateResult::Some(*hash_rate as usize);
            }
            Event::HashRateError(error) => {
                self.hash_rate = HashRateResult::Error(error.clone());
            }
            Event::VanityResult(key, counter, duration) => {
                let addr = Address::from_private_key(key);
                AccountManager::store_private_key(&key.to_bytes(), addr)?;
                self.vanity_result = Some((addr, *counter, *duration));
                self.hash_rate = HashRateResult::None;
                self.mining = false;
            }
            _ => {}
        }

        if self.hash_rate == HashRateResult::None {
            self.hash_rate = HashRateResult::Pending;

            let tr = transmitter.clone();
            let shutdown_signal = shutdown_signal.clone();
            let hash_rate_thread = thread::spawn(move || {
                let address_one = address!("0xffffffffffffffffffffffffffffffffffffffff");
                let result = mine_wallet(
                    Address::ZERO,
                    address_one,
                    Some(Duration::from_secs(1)),
                    shutdown_signal,
                );
                match result {
                    Ok((_, counter, duration)) => {
                        let hash_rate = counter as f64 / duration.as_secs_f64();
                        tr.send(Event::HashRateResult(hash_rate)).unwrap();
                    }
                    Err(e) => {
                        tr.send(Event::HashRateError(e.to_string())).unwrap();
                    }
                }
            });
            self.hash_rate_thread = Some(hash_rate_thread);
        }

        Ok(result)
    }

    fn render_component(&self, area: Rect, buf: &mut Buffer, shared_state: &SharedState) -> Rect
    where
        Self: Sized,
    {
        Line::from("Create Wallet").bold().render(area, buf);

        "You can edit mask if you wish to vanity generate special address"
            .render(area.offset(Offset { x: 0, y: 3 }), buf);

        "0x".render(area.offset(Offset { x: 0, y: 5 }), buf);

        for (i, b) in self.mask.iter().enumerate() {
            let content = if let Some(n) = b {
                match n {
                    0..=9 => (b'0' + n) as char,
                    10..=15 => (b'a' + (n - 10)) as char,
                    _ => unreachable!("Only 0..=15 allowed"),
                }
            } else {
                '.'
            };
            let span = Span::from(content.to_string());

            let style = if self.cursor == i {
                Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)
            } else {
                Style::default()
            };

            span.style(style).render(
                area.offset(Offset {
                    x: 2 + i as i32,
                    y: 5,
                }),
                buf,
            );
        }

        let text = if self.is_mask_empty() {
            "Press enter to generate address instantly".to_string()
        } else if let HashRateResult::Some(hash_rate) = self.hash_rate {
            let count = self.mask_count();
            let est_attempts = 16_usize.pow(count as u32);
            let est_time = est_attempts as f64 / hash_rate as f64;
            let est_time = Duration::from_secs(est_time as u64);

            if est_time.as_secs() > 0 {
                format!(
                    "Estimated to take {}, press enter to generate your vanity address",
                    humantime::format_duration(est_time)
                )
            } else {
                "Press enter to generate your vanity address instantly".to_string()
            }
        } else {
            "Press enter to generate address, it may take a while".to_string()
        };

        text.render(area.offset(Offset { x: 0, y: 8 }), buf);

        format!(
            "Hash rate: {}",
            match self.hash_rate {
                HashRateResult::None => "None".to_string(),
                HashRateResult::Pending => "Pending...".to_string(),
                HashRateResult::Some(hash_rate) => format!("{hash_rate} H/s"),
                HashRateResult::Error(ref error) => format!("Error: {error}"),
            }
        )
        .render(area.offset(Offset { x: 0, y: 10 }), buf);

        if self.mining {
            "Mining...".render(area.offset(Offset { x: 0, y: 12 }), buf);
            if let HashRateResult::Some(hash_rate) = self.hash_rate {
                let count = self.mask_count();
                let est_attempts = 16_usize.pow(count as u32);
                let est_time = est_attempts as f64 / hash_rate as f64;
                let elapsed_time = self.started_mining_at.elapsed();
                let remaining_time = est_time - elapsed_time.as_secs_f64();

                if remaining_time.is_sign_negative() {
                    format!(
                        "Searched for: {}.\
                        The expected time has passed, but a match could happen any moment now.",
                        humantime::format_duration(
                            Duration::from_secs(remaining_time.abs() as u64)
                        )
                    )
                    .render(area.offset(Offset { x: 0, y: 16 }), buf);
                } else {
                    format!(
                        "Remaining time: {}",
                        humantime::format_duration(Duration::from_secs(remaining_time as u64))
                    )
                    .render(area.offset(Offset { x: 0, y: 16 }), buf);
                }
                Gauge::default()
                    .gauge_style(&shared_state.theme)
                    .percent(
                        (elapsed_time.as_secs() * 100)
                            .checked_div(est_time as u64)
                            .unwrap_or(100) as u16,
                    )
                    .render(Rect::new(area.x, 16, area.width, 1), buf);
            }
        } else if let Some((addr, counter, duration)) = self.vanity_result {
            format!(
                "Vanity mined the address: {}, took {} to perform {} iters",
                addr,
                humantime::format_duration(Duration::from_secs(duration.as_secs())),
                counter
            )
            .render(area.offset(Offset { x: 0, y: 12 }), buf);
        }
        area
    }
}