diff --git a/Cargo.toml b/Cargo.toml index d9ab5ea..f838a44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "ghost-eye" authors = ["str3tch "] description = "Application for interacting with Casper/Ghost nodes that are exposing RPC only to the localhost" -version = "0.3.50" +version = "0.3.51" edition = "2021" homepage = "https://git.ghostchain.io/ghostchain" repository = "https://git.ghostchain.io/ghostchain/ghost-eye" diff --git a/src/components/validator/mod.rs b/src/components/validator/mod.rs index 3490120..745f3cd 100644 --- a/src/components/validator/mod.rs +++ b/src/components/validator/mod.rs @@ -29,6 +29,7 @@ mod chill_popup; mod unbond_popup; mod rebond_popup; mod withdraw_popup; +mod payee_popup; use stash_details::StashDetails; use staking_details::StakingDetails; @@ -48,6 +49,7 @@ use chill_popup::ChillPopup; use unbond_popup::UnbondPopup; use rebond_popup::RebondPopup; use withdraw_popup::WithdrawPopup; +use payee_popup::PayeePopup; #[derive(Debug, Copy, Clone, PartialEq)] pub enum CurrentTab { @@ -66,6 +68,7 @@ pub enum CurrentTab { UnbondPopup, RebondPopup, WithdrawPopup, + PayeePopup, } pub trait PartialComponent: Component { @@ -104,6 +107,7 @@ impl Default for Validator { Box::new(UnbondPopup::default()), Box::new(RebondPopup::default()), Box::new(WithdrawPopup::default()), + Box::new(PayeePopup::default()), ], } } @@ -257,6 +261,13 @@ impl Component for Validator { component.set_active(self.current_tab.clone()); } }, + KeyCode::Char('I') => { + self.previous_tab = self.current_tab; + self.current_tab = CurrentTab::PayeePopup; + for component in self.components.iter_mut() { + component.set_active(self.current_tab.clone()); + } + }, _ => { for component in self.components.iter_mut() { component.handle_key_event(key)?; diff --git a/src/components/validator/payee_popup.rs b/src/components/validator/payee_popup.rs new file mode 100644 index 0000000..154fe80 --- /dev/null +++ b/src/components/validator/payee_popup.rs @@ -0,0 +1,365 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use color_eyre::Result; +use ratatui::{ + layout::{Alignment, Constraint, Flex, Layout, Position, Rect}, + text::Text, + widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState}, + Frame, +}; +use subxt::ext::sp_core::crypto::{ + ByteArray, Ss58Codec, Ss58AddressFormat, AccountId32, +}; +use tokio::sync::mpsc::UnboundedSender; +use std::sync::mpsc::Sender; + +use super::{Component, PartialComponent, CurrentTab}; +use crate::{ + action::Action, + config::Config, + palette::StylePalette, + types::{ActionLevel, ActionTarget, RewardDestination}, + widgets::{Input, InputRequest}, +}; + +#[derive(Debug)] +pub struct PayeePopup { + is_active: bool, + action_tx: Option>, + network_tx: Option>, + table_state: TableState, + stash_secret_seed: [u8; 32], + stash_account_id: [u8; 32], + proposed_account_id: Option<[u8; 32]>, + is_bonded: bool, + is_account_chosen: bool, + is_input_active: bool, + address: Input, + possible_payee_options: &'static [(&'static str, &'static str)], + current_reward_destination: RewardDestination, + palette: StylePalette +} + +impl Default for PayeePopup { + fn default() -> Self { + Self::new() + } +} + +impl PayeePopup { + pub fn new() -> Self { + Self { + is_active: false, + stash_secret_seed: [0u8; 32], + stash_account_id: [0u8; 32], + proposed_account_id: None, + action_tx: None, + network_tx: None, + table_state: TableState::new(), + is_bonded: false, + is_account_chosen: false, + is_input_active: false, + address: Input::new(String::new()), + current_reward_destination: Default::default(), + possible_payee_options: &[ + ("Re-stake", "(pay into the stash account, increasing the amount at stake accordingly)"), + ("Stake", "(pay into the stash account, not increasing the amount at stake)"), + ("Account", "(pay into a specified account different from stash)"), + ("None", "(refuse to receive all rewards from staking)"), + ], + palette: StylePalette::default(), + } + } + + fn close_popup(&mut self) { + self.is_active = false; + if let Some(action_tx) = &self.action_tx { + let _ = action_tx.send(Action::ClosePopup); + } + } + + fn parse_index_from_destination(&mut self) -> usize { + let (index, address) = match self.current_reward_destination { + RewardDestination::Staked => (0, Default::default()), + RewardDestination::Stash => (1, Default::default()), + RewardDestination::Account(account_id) => { + let address = AccountId32::from(account_id) + .to_ss58check_with_version(Ss58AddressFormat::custom(1996)); + (2, address) + }, + RewardDestination::None => (3, Default::default()), + _ => (0, Default::default()), + }; + self.address = Input::new(address); + index + } + + fn move_to_row(&mut self, index: usize) { + self.table_state.select(Some(index)); + self.is_account_chosen = index == 2; + } + + fn next_row(&mut self) { + let i = match self.table_state.selected() { + Some(i) => { + if i >= self.possible_payee_options.len() - 1 { + i + } else { + i + 1 + } + }, + None => 0, + }; + self.move_to_row(i); + } + + + fn previous_row(&mut self) { + let i = match self.table_state.selected() { + Some(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + }, + None => 0 + }; + self.move_to_row(i); + } + + fn log_event(&mut self, message: String, level: ActionLevel) { + if let Some(action_tx) = &self.action_tx { + let _ = action_tx.send(Action::EventLog( + message, + level, + ActionTarget::ValidatorLog)); + } + } + + fn trigger_address_input(&mut self) { + self.is_input_active = !self.is_input_active; + } + + fn submit_new_input(&mut self) { + match AccountId32::from_ss58check_with_version(self.address.value()) { + Ok((account_id, format)) => { + if format != Ss58AddressFormat::custom(1996) { + self.log_event( + format!("provided public address for {} is not part of Casper/Ghost ecosystem", self.address.value()), + ActionLevel::Error); + } + let seed_vec = account_id.to_raw_vec(); + let mut account_id = [0u8; 32]; + account_id.copy_from_slice(&seed_vec); + + self.proposed_account_id = Some(account_id); + self.submit_new_payee(); + }, + _ => { + self.log_event( + format!("could not create valid account id from {}", self.address.value()), + ActionLevel::Error); + self.proposed_account_id = None; + } + }; + + } + + fn submit_new_payee(&mut self) { + if let Some(index) = self.table_state.selected() { + let new_destination = match index { + 0 => RewardDestination::Staked, + 1 => RewardDestination::Stash, + 2 => { + let account_id = self.proposed_account_id + .expect("checked before in submit_new_input; qed"); + RewardDestination::Account(account_id) + } + 3 => RewardDestination::None, + _ => RewardDestination::Staked, + }; + + if !self.is_bonded { + self.log_event( + "no bond detected, stake minimum bond amount first".to_string(), + ActionLevel::Warn); + } else if new_destination == self.current_reward_destination { + self.log_event( + "same destination choosen, no need for transaction".to_string(), + ActionLevel::Warn); + } else { + if let Some(network_tx) = &self.network_tx { + let _ = network_tx.send(Action::SetPayee( + self.stash_secret_seed, + new_destination, + ActionTarget::ValidatorLog)); + } + } + if let Some(action_tx) = &self.action_tx { + let _ = action_tx.send(Action::ClosePopup); + } + } + } + + fn enter_char(&mut self, new_char: char) { + let _ = self.address.handle(InputRequest::InsertChar(new_char)); + } + + fn delete_char(&mut self) { + let _ = self.address.handle(InputRequest::DeletePrevChar); + } + + fn move_cursor_right(&mut self) { + let _ = self.address.handle(InputRequest::GoToNextChar); + } + + fn move_cursor_left(&mut self) { + let _ = self.address.handle(InputRequest::GoToPrevChar); + } +} + +impl PartialComponent for PayeePopup { + fn set_active(&mut self, current_tab: CurrentTab) { + match current_tab { + CurrentTab::PayeePopup => self.is_active = true, + _ => { + self.is_active = false; + self.is_account_chosen = false; + self.is_input_active = false; + + let index = self.parse_index_from_destination(); + self.move_to_row(index); + } + }; + } +} + +impl Component for PayeePopup { + fn register_network_handler(&mut self, tx: Sender) -> Result<()> { + self.network_tx = Some(tx); + Ok(()) + } + + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.action_tx = Some(tx); + Ok(()) + } + + fn register_config_handler(&mut self, config: Config) -> Result<()> { + if let Some(style) = config.styles.get(&crate::app::Mode::Wallet) { + self.palette.with_normal_style(style.get("normal_style").copied()); + self.palette.with_normal_border_style(style.get("normal_border_style").copied()); + self.palette.with_normal_title_style(style.get("normal_title_style").copied()); + self.palette.with_popup_style(style.get("popup_style").copied()); + self.palette.with_popup_title_style(style.get("popup_title_style").copied()); + self.palette.with_highlight_style(style.get("highlight_style").copied()); + } + Ok(()) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + if self.is_active && key.kind == KeyEventKind::Press { + if self.is_input_active { + match key.code { + KeyCode::Enter => self.submit_new_input(), + KeyCode::Char(to_insert) => self.enter_char(to_insert), + KeyCode::Backspace => self.delete_char(), + KeyCode::Left => self.move_cursor_left(), + KeyCode::Right => self.move_cursor_right(), + KeyCode::Esc => self.trigger_address_input(), + _ => {}, + }; + } else { + match key.code { + KeyCode::Enter if !self.is_account_chosen => self.submit_new_payee(), + KeyCode::Enter if self.is_account_chosen => self.trigger_address_input(), + KeyCode::Up | KeyCode::Char('k') => self.previous_row(), + KeyCode::Down | KeyCode::Char('j') => self.next_row(), + KeyCode::Esc => self.close_popup(), + _ => {}, + }; + } + } + Ok(None) + } + + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetStashAccount(account_id) => self.stash_account_id = account_id, + Action::SetStashSecret(secret_seed) => self.stash_secret_seed = secret_seed, + Action::SetIsBonded(is_bonded, account_id) if self.stash_account_id == account_id => + self.is_bonded = is_bonded, + Action::SetStakingPayee(reward_destination, account_id) if self.stash_account_id == account_id => { + let destination_changed = self.current_reward_destination != reward_destination; + self.current_reward_destination = reward_destination; + if destination_changed || self.table_state.selected().is_none() { + let index = self.parse_index_from_destination(); + self.move_to_row(index); + } + } + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + if self.is_active { + let (border_style, border_type) = self.palette.create_popup_style(); + let size = area.as_size(); + let input_area = Rect::new(size.width / 2, size.height / 2, 51, 3); + + let table = Table::new( + self.possible_payee_options + .iter() + .map(|data| { + Row::new(vec![ + Cell::from(Text::from(data.0.to_string()).alignment(Alignment::Left)), + Cell::from(Text::from(data.1.to_string()).alignment(Alignment::Left)), + ]) + }) + .collect::>(), + [Constraint::Length(8), Constraint::Min(0)] + ) + .highlight_style(self.palette.create_highlight_style()) + .column_spacing(1) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_style(self.palette.create_popup_title_style()) + .title_alignment(Alignment::Right) + .title("Select reward destination")); + + let v = Layout::vertical([Constraint::Max(6)]).flex(Flex::Center); + let h = Layout::horizontal([Constraint::Max(83)]).flex(Flex::Center); + let [area] = v.areas(area); + let [area] = h.areas(area); + + frame.render_widget(Clear, area); + frame.render_stateful_widget(table, area, &mut self.table_state); + + if self.is_input_active { + let input_amount = Paragraph::new(self.address.value()) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_style(self.palette.create_popup_title_style()) + .title_alignment(Alignment::Right) + .title("Destination account")); + + let v = Layout::vertical([Constraint::Max(8)]).flex(Flex::Center); + let h = Layout::horizontal([Constraint::Max(51)]).flex(Flex::Center); + let [input_area] = v.areas(input_area); + let [input_area] = h.areas(input_area); + + frame.render_widget(Clear, input_area); + frame.render_widget(input_amount, input_area); + + frame.set_cursor_position(Position::new( + input_area.x + self.address.cursor() as u16 + 1, + input_area.y + 1 + )); + } + } + Ok(()) + } +}