diff --git a/standards/src/wallet/notes/note.rs b/standards/src/wallet/notes/note.rs index ddee159..f236860 100644 --- a/standards/src/wallet/notes/note.rs +++ b/standards/src/wallet/notes/note.rs @@ -266,7 +266,10 @@ mod tests { assert_eq!(*PublicNote::decode(&encoded).unwrap(), note); assert_eq!(PublicNote::static_length(), None); - assert!(matches!(PublicNote::decode(&[]).unwrap_err(), PublicNoteError::TooShort)); + assert!(matches!( + PublicNote::decode(&[]).unwrap_err(), + PublicNoteError::TooShort + )); assert!(matches!( PublicNote::decode(&[BFieldElement::new(TAG_PRIVATE), BFieldElement::new(1)]).unwrap_err(), PublicNoteError::WrongTag(t) if t == TAG_PRIVATE @@ -274,8 +277,12 @@ mod tests { // 99 isn't a valid NoteContent discriminant, so this exercises the // #[from] NoteContentError -> PublicNoteError::Content conversion. - let err = PublicNote::decode(&[BFieldElement::new(TAG_PUBLIC), BFieldElement::new(1), BFieldElement::new(99)]) - .unwrap_err(); + let err = PublicNote::decode(&[ + BFieldElement::new(TAG_PUBLIC), + BFieldElement::new(1), + BFieldElement::new(99), + ]) + .unwrap_err(); assert!(matches!(err, PublicNoteError::Content(_))); } @@ -287,7 +294,10 @@ mod tests { assert_eq!(*PrivateNote::decode(&encoded).unwrap(), note); assert_eq!(PrivateNote::static_length(), None); - assert!(matches!(PrivateNote::decode(&[BFieldElement::new(TAG_PRIVATE)]).unwrap_err(), PrivateNoteError::TooShort)); + assert!(matches!( + PrivateNote::decode(&[BFieldElement::new(TAG_PRIVATE)]).unwrap_err(), + PrivateNoteError::TooShort + )); assert!(matches!( PrivateNote::decode(&[BFieldElement::new(TAG_PUBLIC), BFieldElement::new(1)]).unwrap_err(), PrivateNoteError::WrongTag(t) if t == TAG_PUBLIC @@ -315,7 +325,10 @@ mod tests { #[test] fn note_decode_errors() { assert!(matches!(Note::decode(&[]).unwrap_err(), NoteError::Empty)); - assert!(matches!(Note::decode(&[BFieldElement::new(2)]).unwrap_err(), NoteError::UnknownTag(2))); + assert!(matches!( + Note::decode(&[BFieldElement::new(2)]).unwrap_err(), + NoteError::UnknownTag(2) + )); // Tag alone, no receiver id: inner decode fails and propagates via #[from]. assert!(matches!( Note::decode(&[BFieldElement::new(TAG_PUBLIC)]).unwrap_err(), @@ -339,11 +352,13 @@ mod tests { let hrp = format!("note{}", network_hrp_char(Network::Main)); // Right HRP, wrong bech32 variant -> fails the variant check. - let wrong_variant = bech32::encode(&hrp, bytes.to_base32(), bech32::Variant::Bech32).unwrap(); + let wrong_variant = + bech32::encode(&hrp, bytes.to_base32(), bech32::Variant::Bech32).unwrap(); assert!(Note::from_bech32m(&wrong_variant, Network::Main).is_err()); // Right variant, wrong HRP -> fails the network check. - let wrong_hrp = bech32::encode("wrong", bytes.to_base32(), bech32::Variant::Bech32m).unwrap(); + let wrong_hrp = + bech32::encode("wrong", bytes.to_base32(), bech32::Variant::Bech32m).unwrap(); assert!(Note::from_bech32m(&wrong_hrp, Network::Main).is_err()); } -} \ No newline at end of file +} diff --git a/wallet/cli/Cargo.toml b/wallet/cli/Cargo.toml index ffee2f9..4c6067a 100644 --- a/wallet/cli/Cargo.toml +++ b/wallet/cli/Cargo.toml @@ -24,6 +24,9 @@ serde_json = "1.0.149" serde = "1.0.228" clap = { version = "4.5.54", features = ["derive"] } itertools = "0.14.0" +ratatui = "0.30" +crossterm = "0.29" +arboard = "3.6.1" [dev-dependencies] diff --git a/wallet/cli/src/core/console.rs b/wallet/cli/src/core/console.rs deleted file mode 100644 index 74b3d76..0000000 --- a/wallet/cli/src/core/console.rs +++ /dev/null @@ -1,182 +0,0 @@ -use num_traits::CheckedSub; -use nyks_consensus::network::Network; -use nyks_consensus::type_scripts::native_currency_amount::NativeCurrencyAmount; -use nyks_standards::wallet::keys::address::Address; -use nyks_standards::wallet::keys::address::Recipient; -use nyks_standards::wallet::keys::key::KeyType; -use nyks_wallet_sdk::wallet::Wallet; - -use rustyline::DefaultEditor; -use rustyline::error::ReadlineError; -use tracing::error; - -use std::str::FromStr; - -use tokio::sync::mpsc; - -use tracing::{info, warn}; - -#[derive(Debug)] -enum Command { - Height, - Balance, - Address(Option), - Send { - recipient: Address, - amount: NativeCurrencyAmount, - fee: NativeCurrencyAmount, - }, - Unknown(String), -} - -impl FromStr for Command { - type Err = String; - - fn from_str(input: &str) -> Result { - let mut parts = input.split_whitespace(); - - // Empty commands are ignored. - let cmd = parts.next().unwrap().to_lowercase(); - - match cmd.as_str() { - "height" => Ok(Command::Height), - "balance" => Ok(Command::Balance), - "address" => { - let key_type = match parts.next() { - Some("symmetric") => Some(KeyType::Symmetric), - Some(_) => Some(KeyType::Generation), - None => None, - }; - - Ok(Command::Address(key_type)) - } - "send" => { - let recipient_str = parts.next().ok_or("Missing address (e.g. nolgam...)")?; - let recipient = Address::from_bech32m(recipient_str, Network::Testnet(0)) - .map_err(|e| format!("Invalid recipient: {e}. Is it on the wrong network?"))?; - - let amount_str = parts.next().ok_or("Missing amount (e.g. 1.6)")?; - let amount = NativeCurrencyAmount::coins_from_str(amount_str).map_err(|_| { - format!("Invalid amount '{amount_str}'. Try using a number (e.g. 1.5).") - })?; - - let fee_str = parts.next().ok_or("Missing fee (e.g. 0.05)")?; - let fee = NativeCurrencyAmount::coins_from_str(fee_str).map_err(|_| { - format!("Invalid fee '{fee_str}'. Try using a number (e.g. 0.001).") - })?; - - Ok(Command::Send { - recipient, - amount, - fee, - }) - } - - other => Ok(Command::Unknown(other.to_string())), - } - } -} - -pub async fn start_console(wallet: Wallet) { - info!( - "Loaded wallet with {} NYKS on height {}.", - wallet.total_balance().await, - wallet.height().await - ); - - let (tx, mut rx) = mpsc::unbounded_channel::(); - - tokio::task::spawn_blocking(move || { - let mut rl = DefaultEditor::new().expect("failed to init rustyline"); - - loop { - match rl.readline("") { - Ok(line) => { - if line.trim().is_empty() { - continue; - } - - match line.parse::() { - Ok(cmd) => { - if tx.send(cmd).is_err() { - break; - } - } - Err(err) => { - warn!("{err}"); - } - } - } - Err(ReadlineError::Interrupted) => { - std::process::exit(0); - } - Err(ReadlineError::Eof) => { - break; - } - Err(err) => { - panic!("unexpected readline error: {:?}", err); - } - } - } - }); - - while let Some(cmd) = rx.recv().await { - match cmd { - Command::Height => { - info!( - "Height: {} (chain height {}).", - wallet.height().await, - wallet.tip_height().await - ); - } - Command::Balance => { - let utxo_count = wallet.utxo_count().await; - let spendable_balance = wallet.spendable_balance().await; - let total_balance = wallet.total_balance().await; - let unconfirmed_balance = wallet.unconfirmed_balance().await; - let outgoing_balance = wallet.outgoing_balance().await; - - println!( - "\n\ - Balance\n\ - ├─ Total: {} NYKS\n\ - ├─ Spendable: {} NYKS\n\ - ├─ Timelocked: {} NYKS\n\ - ├─ Unconfirmed: {} NYKS\n\ - ├─ Outgoing: {} NYKS\n\ - └─ UTXOs: {}\n", - total_balance, - spendable_balance, - total_balance.checked_sub(&spendable_balance).unwrap(), - unconfirmed_balance, - outgoing_balance, - utxo_count, - ); - } - Command::Address(key_type) => { - let key_type = key_type.unwrap_or(KeyType::Generation); - let address = wallet.address(key_type).await; - - println!("\n{}\n", address.to_bech32m(wallet.network)); - } - Command::Send { - recipient, - amount, - fee, - } => { - info!( - "Initiating sending of {} NYKS with fee {} NYKS...", - amount, fee - ); - - match wallet.send(recipient, amount, fee).await { - Ok(id) => info!("Announced {} successfully.", id), - Err(err) => error!("Failed to submit transaction: {}.", err), - }; - } - Command::Unknown(cmd) => { - warn!("Unknown command: {}", cmd); - } - } - } -} diff --git a/wallet/cli/src/core/mod.rs b/wallet/cli/src/core/mod.rs index f977c67..487995b 100644 --- a/wallet/cli/src/core/mod.rs +++ b/wallet/cli/src/core/mod.rs @@ -1,2 +1,2 @@ -pub mod console; pub mod storage; +pub mod sync; diff --git a/wallet/cli/src/core/storage.rs b/wallet/cli/src/core/storage.rs index 8e9b1dd..d9ec07d 100644 --- a/wallet/cli/src/core/storage.rs +++ b/wallet/cli/src/core/storage.rs @@ -22,7 +22,7 @@ use serde::Serialize; /// Write `contents` to `path` atomically via a `.tmp` sibling + `rename`. /// /// Readers always see either the previous complete file or the new complete -/// file — never a partial write. `sync_all` ensures bytes reach the device +/// file, never a partial write. `sync_all` ensures bytes reach the device /// before the rename commits, guarding against crash-after-rename data loss. fn atomic_write(path: &Path, contents: &[u8]) { let tmp = path.with_extension("tmp"); diff --git a/wallet/cli/src/core/sync.rs b/wallet/cli/src/core/sync.rs new file mode 100644 index 0000000..0eae9ba --- /dev/null +++ b/wallet/cli/src/core/sync.rs @@ -0,0 +1,62 @@ +use std::time::Duration; + +use nyks_wallet_sdk::wallet::Wallet; +use nyks_wallet_sdk::wallet::WalletEvent; +use tracing::info; + +use crate::core::storage::Storage; + +/// Runs the periodic sync loop: polls the wallet for new chain state, +/// persists discovered/invalidated UTXOs to storage, and updates the stored +/// chain height. +pub async fn run(wallet: Wallet, storage: Storage) { + let mut interval = tokio::time::interval(Duration::from_secs(10)); + + loop { + interval.tick().await; + + let events = match wallet.sync().await { + Ok(events) => events, + Err(err) => { + tracing::error!("sync failed: {err}"); + continue; + } + }; + + for event in events { + match event { + WalletEvent::UtxoReceived { key, utxo } => { + match utxo.inclusion_block { + Some((_, block_height)) => info!( + "Discovered new UTXO containing {} NYKS on block {} (AOCL leaf {}).", + utxo.get_native_currency_amount(), + block_height, + utxo.membership_proof.aocl_leaf_index, + ), + None => info!( + "Discovered new UTXO containing {} NYKS (AOCL leaf {}).", + utxo.get_native_currency_amount(), + utxo.membership_proof.aocl_leaf_index, + ), + } + + storage.utxos.put(&key, &utxo); + } + WalletEvent::UtxoInvalidated { key, utxo } => { + info!( + "UTXO containing {} NYKS was invalidated and marked as spent in an unknown block (AOCL leaf {}).", + utxo.get_native_currency_amount(), + utxo.membership_proof.aocl_leaf_index, + ); + + storage.utxos.put(&key, &utxo); + } + WalletEvent::UtxosOutgoing { id, utxos } => { + info!("{} UTXOs being spent on transaction {}.", utxos.len(), id); + } + } + } + + storage.chain.set_height(wallet.height().await); + } +} diff --git a/wallet/cli/src/main.rs b/wallet/cli/src/main.rs index e943ef0..46a6796 100644 --- a/wallet/cli/src/main.rs +++ b/wallet/cli/src/main.rs @@ -1,29 +1,23 @@ -pub mod core; +mod core; +mod tui; use std::panic; -use std::time::Duration; use anyhow::Result; -use anyhow::bail; use clap::Parser; use nyks_consensus::network::Network; use nyks_rpc_client::http::HttpClient; use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; use nyks_wallet_sdk::wallet::Wallet; -use nyks_wallet_sdk::wallet::WalletEvent; -use tracing::info; -use tracing_subscriber::EnvFilter; use crate::core::storage::Storage; +use crate::tui::dashboard; +use crate::tui::setup; #[derive(Parser)] #[command(name = "nyks-wallet")] #[command(about = "A nyks daemon wallet")] struct Args { - /// Mnemonic to import. - #[arg(long)] - mnemonic: Option, - /// RPC URL to use (JSON/HTTP). #[arg(long)] rpc_url: String, @@ -40,103 +34,34 @@ struct Args { #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - let filter = EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new("nyks_wallet=info")); - tracing_subscriber::fmt().with_env_filter(filter).init(); let default_hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| { + // Always leave the terminal usable, even if we panic mid-draw. + tui::restore(); default_hook(panic_info); std::process::exit(1); })); let rpc = HttpClient::new(args.rpc_url); let storage = Storage::new(args.wallet_dir); - let entropy = resolve_mnemonic(&storage, args.mnemonic)?; + let entropy = match storage.keys.get_mnemonic() { + Some(mnemonic) => { + let words: Vec = mnemonic.split_whitespace().map(str::to_owned).collect(); + WalletEntropy::from_phrase(&words) + .map_err(|e| anyhow::anyhow!("stored mnemonic is corrupt: {e}"))? + } + None => setup::run(&storage)?, + }; + let wallet = Wallet::new(rpc, entropy, storage.chain.get_height(), args.network); // Import initial state of wallet. wallet.import_utxos(storage.utxos.iter().collect()).await; - // Start console "readline" task. - tokio::spawn(core::console::start_console(wallet.clone())); - - // Start sync loop - let mut interval = tokio::time::interval(Duration::from_secs(10)); + // Sync loop runs in the background; the TUI takes over the main task and + // owns the terminal until the user quits. + tokio::spawn(core::sync::run(wallet.clone(), storage)); - loop { - interval.tick().await; - - let events = wallet.sync().await.unwrap(); - - for event in events { - match event { - WalletEvent::UtxoReceived { key, utxo } => { - match utxo.inclusion_block { - Some((_, block_height)) => info!( - "Discovered new UTXO containing {} NYKS on block {} (AOCL leaf {}).", - utxo.get_native_currency_amount(), - block_height, - utxo.membership_proof.aocl_leaf_index, - ), - None => info!( - "Discovered new UTXO containing {} NYKS (AOCL leaf {}).", - utxo.get_native_currency_amount(), - utxo.membership_proof.aocl_leaf_index, - ), - } - - storage.utxos.put(&key, &utxo); - } - WalletEvent::UtxoInvalidated { key, utxo } => { - info!( - "UTXO containing {} NYKS was invalidated and marked as spent in an unknown block (AOCL leaf {}).", - utxo.get_native_currency_amount(), - utxo.membership_proof.aocl_leaf_index, - ); - - storage.utxos.put(&key, &utxo); - } - WalletEvent::UtxosOutgoing { id, utxos } => { - info!("{} UTXOs being spent on transaction {}.", utxos.len(), id); - } - } - } - - storage.chain.set_height(wallet.height().await); - } -} - -fn phrase_to_words(phrase: &str) -> Vec { - phrase.split_whitespace().map(str::to_owned).collect() -} - -/// Resolves and validates the mnemonic for this session, returning a `WalletEntropy`. -/// -/// - If `--mnemonic` is passed and no wallet exists, validates, imports, and returns it. -/// - If `--mnemonic` is passed and a wallet already exists, returns an error. -/// - If no `--mnemonic` is passed and a wallet exists, parses and returns the stored one. -/// - If no `--mnemonic` is passed and no wallet exists, returns an error with instructions. -fn resolve_mnemonic(storage: &Storage, mnemonic_arg: Option) -> Result { - match (mnemonic_arg, storage.keys.get_mnemonic()) { - (Some(_), Some(_)) => { - bail!("a wallet is already imported; remove ./wallet_data to start fresh"); - } - (Some(mnemonic), None) => { - let words = phrase_to_words(&mnemonic); - let entropy = WalletEntropy::from_phrase(&words) - .map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))?; - storage.keys.set_mnemonic(&mnemonic); - info!("mnemonic imported successfully"); - Ok(entropy) - } - (None, Some(mnemonic)) => { - let words = phrase_to_words(&mnemonic); - let entropy = WalletEntropy::from_phrase(&words) - .map_err(|e| anyhow::anyhow!("stored mnemonic is corrupt: {e}"))?; - Ok(entropy) - } - (None, None) => { - bail!("no wallet found; import one with --mnemonic "); - } - } + dashboard::run(wallet).await } diff --git a/wallet/cli/src/tui/dashboard/address.rs b/wallet/cli/src/tui/dashboard/address.rs new file mode 100644 index 0000000..136f7d6 --- /dev/null +++ b/wallet/cli/src/tui/dashboard/address.rs @@ -0,0 +1,203 @@ +use std::time::Duration; +use std::time::Instant; + +use arboard::Clipboard; +use crossterm::event::KeyCode; +use ratatui::Frame; +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::List; +use ratatui::widgets::ListItem; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +use super::snapshot::Snapshot; + +/// How long a "Copied!" / error message stays visible after pressing `c`. +const FEEDBACK_DURATION: Duration = Duration::from_secs(2); + +/// Which of the wallet's two address types is currently shown. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +enum AddressKind { + #[default] + Generation, + Symmetric, +} + +impl AddressKind { + const ALL: [AddressKind; 2] = [AddressKind::Generation, AddressKind::Symmetric]; + + fn label(self) -> &'static str { + match self { + AddressKind::Generation => "Generation", + AddressKind::Symmetric => "Symmetric", + } + } + + fn index(self) -> usize { + Self::ALL.iter().position(|k| *k == self).unwrap() + } + + fn next(self) -> Self { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + fn prev(self) -> Self { + let len = Self::ALL.len(); + Self::ALL[(self.index() + len - 1) % len] + } + + fn address(self, snapshot: &Snapshot) -> &str { + match self { + AddressKind::Generation => &snapshot.generation_address, + AddressKind::Symmetric => &snapshot.symmetric_address, + } + } + + /// Trade-offs of this address type. + fn explanation(self) -> &'static str { + match self { + AddressKind::Generation => { + "Fully private, but longer than symmetric addresses, making them less convenient to share." + } + AddressKind::Symmetric => { + "Short and easy to share, but anyone you give it to can see and link the UTXOs sent to it." + } + } + } +} + +/// Transient status shown after a copy attempt. +struct CopyFeedback { + message: String, + ok: bool, + at: Instant, +} + +/// State for the Address tab: which address type is selected, plus the +/// most recent copy-to-clipboard result (if any). +#[derive(Default)] +pub struct AddressPage { + selected: AddressKind, + feedback: Option, +} + +impl AddressPage { + /// Handles a key press while the Address tab is active. + pub fn handle_key(&mut self, code: KeyCode, snapshot: &Snapshot) { + match code { + KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.prev(), + KeyCode::Down | KeyCode::Char('j') => self.selected = self.selected.next(), + KeyCode::Char('c') => self.copy_to_clipboard(snapshot), + _ => {} + } + } + + fn copy_to_clipboard(&mut self, snapshot: &Snapshot) { + let address = self.selected.address(snapshot).to_owned(); + let (ok, message) = match Clipboard::new().and_then(|mut cb| cb.set_text(address)) { + Ok(()) => ( + true, + format!("Copied {} address to clipboard.", self.selected.label()), + ), + Err(e) => (false, format!("Couldn't copy to clipboard: {e}")), + }; + self.feedback = Some(CopyFeedback { + message, + ok, + at: Instant::now(), + }); + } + + /// Draws a small two-item selector (Generation / Symmetric) with the + /// chosen address and a plain-language explanation of its trade-offs + /// below. + pub fn draw(&self, frame: &mut Frame, area: Rect, snapshot: &Snapshot) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(4), Constraint::Min(0)]) + .split(area); + + let items: Vec = AddressKind::ALL + .iter() + .map(|kind| { + let style = if *kind == self.selected { + Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled( + format!(" {} ", kind.label()), + style, + ))) + }) + .collect(); + + frame.render_widget( + List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title(" Address type (↑/↓) "), + ), + layout[0], + ); + + let mut lines = vec![ + Line::from(Span::styled( + self.selected.address(snapshot), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(self.selected.explanation()), + Line::from(""), + ]; + lines.push(self.feedback_line()); + + frame.render_widget( + Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" {} address ", self.selected.label())), + ) + .wrap(Wrap { trim: true }), + layout[1], + ); + } + + /// The line shown at the bottom of the address panel: a recent copy + /// result while it's still fresh, otherwise the standing "press c" hint. + fn feedback_line(&self) -> Line<'static> { + match &self.feedback { + Some(feedback) if feedback.at.elapsed() < FEEDBACK_DURATION => { + let color = if feedback.ok { + Color::Green + } else { + Color::Red + }; + Line::from(Span::styled( + feedback.message.clone(), + Style::default().fg(color), + )) + } + _ => Line::from(Span::styled( + "c = copy address to clipboard", + Style::default().fg(Color::DarkGray), + )), + } + } +} diff --git a/wallet/cli/src/tui/dashboard/balance.rs b/wallet/cli/src/tui/dashboard/balance.rs new file mode 100644 index 0000000..db49431 --- /dev/null +++ b/wallet/cli/src/tui/dashboard/balance.rs @@ -0,0 +1,40 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; + +use super::snapshot::Snapshot; + +pub fn draw(frame: &mut Frame, area: Rect, snapshot: &Snapshot) { + let lines = vec![ + Line::from(format!("Height: {}", snapshot.height)), + Line::from(format!("Chain tip: {}", snapshot.tip_height)), + Line::from(""), + Line::from(vec![ + Span::raw("Total: "), + Span::styled( + &snapshot.total_balance, + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" NYKS"), + ]), + Line::from(format!("Spendable: {} NYKS", snapshot.spendable_balance)), + Line::from(format!("Timelocked: {} NYKS", snapshot.timelocked_balance)), + Line::from(format!( + "Unconfirmed: {} NYKS", + snapshot.unconfirmed_balance + )), + Line::from(format!("Outgoing: {} NYKS", snapshot.outgoing_balance)), + Line::from(format!("UTXOs: {}", snapshot.utxo_count)), + ]; + + frame.render_widget( + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Balance ")), + area, + ); +} diff --git a/wallet/cli/src/tui/dashboard/mod.rs b/wallet/cli/src/tui/dashboard/mod.rs new file mode 100644 index 0000000..3c16f95 --- /dev/null +++ b/wallet/cli/src/tui/dashboard/mod.rs @@ -0,0 +1,199 @@ +mod address; +mod balance; +mod send; +mod snapshot; + +use std::time::Duration; + +use anyhow::Result; +use crossterm::event; +use crossterm::event::Event; +use crossterm::event::KeyCode; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use nyks_wallet_sdk::wallet::Wallet; +use ratatui::Frame; +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Tabs; +use tokio::sync::mpsc; + +use address::AddressPage; +use send::SendPage; +use snapshot::Snapshot; + +use crate::tui; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tab { + Balance, + Address, + Send, +} + +impl Tab { + const ALL: [Tab; 3] = [Tab::Balance, Tab::Address, Tab::Send]; + + fn title(self) -> &'static str { + match self { + Tab::Balance => "Balance", + Tab::Address => "Address", + Tab::Send => "Send", + } + } + + fn index(self) -> usize { + Tab::ALL.iter().position(|t| *t == self).unwrap() + } + + fn next(self) -> Tab { + Tab::ALL[(self.index() + 1) % Tab::ALL.len()] + } + + fn prev(self) -> Tab { + let len = Tab::ALL.len(); + Tab::ALL[(self.index() + len - 1) % len] + } +} + +pub async fn run(wallet: Wallet) -> Result<()> { + let mut terminal = tui::init(); + let result = run_loop(&mut terminal, wallet).await; + tui::restore(); + result +} + +async fn run_loop(terminal: &mut tui::Tui, wallet: Wallet) -> Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + std::thread::spawn(move || { + while let Ok(ev) = event::read() { + if tx.send(ev).is_err() { + break; + } + } + }); + + let mut tab = Tab::Balance; + let mut address_page = AddressPage::default(); + let mut send_page = SendPage::default(); + let mut snapshot = Snapshot::fetch(&wallet).await; + let mut refresh = tokio::time::interval(Duration::from_secs(5)); + refresh.tick().await; + + terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; + + loop { + // No-op branch when no send is running. + let send_update = async { + if send_page.is_active() { + send_page.await_result().await + } else { + std::future::pending().await + } + }; + + tokio::select! { + _ = refresh.tick() => { + snapshot = Snapshot::fetch(&wallet).await; + terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; + } + _ = send_update => { + terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; + } + maybe_event = rx.recv() => { + let Some(event) = maybe_event else { break }; + + match event { + Event::Resize(_, _) => { + terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; + } + Event::Key(key) if key.kind == KeyEventKind::Press => { + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + break; + } + + match key.code { + // Block tab switching while a send is proving. + KeyCode::Left if !send_page.is_active() => tab = tab.prev(), + KeyCode::Right if !send_page.is_active() => tab = tab.next(), + // While on Send, everything else belongs to the form. + _ if tab == Tab::Send => send_page.handle_key(key.code, &wallet).await, + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Tab | KeyCode::Char('l') if !send_page.is_active() => tab = tab.next(), + KeyCode::BackTab | KeyCode::Char('h') if !send_page.is_active() => tab = tab.prev(), + KeyCode::Char('r') => snapshot = Snapshot::fetch(&wallet).await, + // Anything else is the active tab's business (e.g. ↑/↓ or c on Address). + code if tab == Tab::Address => address_page.handle_key(code, &snapshot), + _ => continue, + } + + terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; + } + _ => {} + } + } + } + } + + Ok(()) +} + +fn draw( + frame: &mut Frame, + tab: Tab, + address_page: &AddressPage, + send_page: &SendPage, + snapshot: &Snapshot, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(frame.area()); + + let titles: Vec = Tab::ALL.iter().map(|t| Line::from(t.title())).collect(); + let tabs = Tabs::new(titles) + .block( + Block::default() + .borders(Borders::ALL) + .title(" nyks-wallet "), + ) + .select(tab.index()) + .highlight_style( + Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(tabs, chunks[0]); + + match tab { + Tab::Balance => balance::draw(frame, chunks[1], snapshot), + Tab::Address => address_page.draw(frame, chunks[1], snapshot), + Tab::Send => send_page.draw(frame, chunks[1]), + } + + let help = match tab { + Tab::Balance => "Tab/←→ switch tabs · r refresh · q quit", + Tab::Address => { + "Tab/←→ switch tabs · ↑/↓ choose address type · c copy · r refresh · q quit" + } + Tab::Send if send_page.is_active() => "Sending... please wait · Ctrl-C quit", + Tab::Send => { + "←/→ switch tabs · Tab/↓ next field · Enter confirm · Esc clear/back · Ctrl-C quit" + } + }; + frame.render_widget(Paragraph::new(help), chunks[2]); +} diff --git a/wallet/cli/src/tui/dashboard/send.rs b/wallet/cli/src/tui/dashboard/send.rs new file mode 100644 index 0000000..538ba52 --- /dev/null +++ b/wallet/cli/src/tui/dashboard/send.rs @@ -0,0 +1,394 @@ +use crossterm::event::KeyCode; +use nyks_consensus::network::Network; +use nyks_consensus::type_scripts::native_currency_amount::NativeCurrencyAmount; +use nyks_standards::wallet::keys::address::Address; +use nyks_standards::wallet::keys::address::Recipient; +use nyks_wallet_sdk::wallet::Wallet; +use ratatui::Frame; +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use tokio::sync::oneshot; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Field { + Recipient, + Amount, + Fee, +} + +impl Field { + const ALL: [Field; 3] = [Field::Recipient, Field::Amount, Field::Fee]; + + fn label(self) -> &'static str { + match self { + Field::Recipient => "Recipient", + Field::Amount => "Amount", + Field::Fee => "Fee", + } + } + + fn placeholder(self) -> &'static str { + match self { + Field::Recipient => "nolgam1... (recipient's address)", + Field::Amount => "e.g. 1.5", + Field::Fee => "e.g. 0.01", + } + } + + fn index(self) -> usize { + Self::ALL.iter().position(|f| *f == self).unwrap() + } + + fn next(self) -> Self { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + fn prev(self) -> Self { + let len = Self::ALL.len(); + Self::ALL[(self.index() + len - 1) % len] + } +} + +#[derive(Default)] +struct FormInputs { + recipient: String, + amount: String, + fee: String, +} + +fn field_mut(inputs: &mut FormInputs, field: Field) -> &mut String { + match field { + Field::Recipient => &mut inputs.recipient, + Field::Amount => &mut inputs.amount, + Field::Fee => &mut inputs.fee, + } +} + +fn field_value(inputs: &FormInputs, field: Field) -> &str { + match field { + Field::Recipient => &inputs.recipient, + Field::Amount => &inputs.amount, + Field::Fee => &inputs.fee, + } +} + +fn parse_form(inputs: &FormInputs, network: Network) -> Result { + let recipient_str = inputs.recipient.trim(); + if recipient_str.is_empty() { + return Err("Missing address (e.g. nolgam...)".to_owned()); + } + let recipient = Address::from_bech32m(recipient_str, network) + .map_err(|e| format!("Invalid recipient: {e}. Is it on the wrong network?"))?; + + let amount_str = inputs.amount.trim(); + let amount = NativeCurrencyAmount::coins_from_str(amount_str) + .map_err(|_| format!("Invalid amount '{amount_str}'. Try using a number (e.g. 1.5)."))?; + + let fee_str = inputs.fee.trim(); + let fee = NativeCurrencyAmount::coins_from_str(fee_str) + .map_err(|_| format!("Invalid fee '{fee_str}'. Try using a number (e.g. 0.001)."))?; + + Ok(Step::Confirm { + recipient, + recipient_display: recipient_str.to_owned(), + amount, + fee, + }) +} + +enum Step { + Form { + field: Field, + error: Option, + }, + Confirm { + recipient: Address, + recipient_display: String, + amount: NativeCurrencyAmount, + fee: NativeCurrencyAmount, + }, + Proving, + Result { + ok: bool, + message: String, + }, +} + +impl Default for Step { + fn default() -> Self { + Step::Form { + field: Field::Recipient, + error: None, + } + } +} + +#[derive(Default)] +pub struct SendPage { + inputs: FormInputs, + step: Step, + result_rx: Option>, +} + +impl SendPage { + pub fn is_active(&self) -> bool { + self.result_rx.is_some() + } + + pub async fn handle_key(&mut self, code: KeyCode, wallet: &Wallet) { + let step = std::mem::take(&mut self.step); + + self.step = match step { + Step::Form { field, error } => match code { + KeyCode::Char(c) => { + field_mut(&mut self.inputs, field).push(c); + Step::Form { field, error: None } + } + KeyCode::Backspace => { + field_mut(&mut self.inputs, field).pop(); + Step::Form { field, error: None } + } + KeyCode::Down | KeyCode::Tab => Step::Form { + field: field.next(), + error, + }, + KeyCode::Up | KeyCode::BackTab => Step::Form { + field: field.prev(), + error, + }, + KeyCode::Enter if field != Field::Fee => Step::Form { + field: field.next(), + error, + }, + KeyCode::Enter => match parse_form(&self.inputs, wallet.network) { + Ok(confirm) => confirm, + Err(message) => Step::Form { + field, + error: Some(message), + }, + }, + KeyCode::Esc => { + self.inputs = FormInputs::default(); + Step::default() + } + _ => Step::Form { field, error }, + }, + Step::Confirm { + recipient, + recipient_display, + amount, + fee, + } => match code { + KeyCode::Enter => { + let amount_display = amount.to_string(); + let fee_display = fee.to_string(); + let (result_tx, result_rx) = oneshot::channel(); + let wallet = wallet.clone(); + + tokio::spawn(async move { + let outcome = match wallet.send(recipient, amount, fee, None).await { + Ok(id) => ( + true, + format!( + "Sent {amount_display} NYKS (+ {fee_display} fee) to \ + {recipient_display}. Transaction {id} announced." + ), + ), + Err(e) => (false, format!("Failed to submit transaction: {e}.")), + }; + let _ = result_tx.send(outcome); + }); + + self.result_rx = Some(result_rx); + Step::Proving + } + KeyCode::Esc => Step::Form { + field: Field::Fee, + error: None, + }, + _ => Step::Confirm { + recipient, + recipient_display, + amount, + fee, + }, + }, + // Ignore all input while proving. + Step::Proving => Step::Proving, + Step::Result { .. } => Step::Form { + field: Field::Recipient, + error: None, + }, + }; + } + + /// Only call while `is_active()`. + pub async fn await_result(&mut self) { + let Some(rx) = self.result_rx.as_mut() else { + return; + }; + let outcome = rx.await; + self.result_rx = None; + + let (ok, message) = + outcome.unwrap_or_else(|_| (false, "Send task ended unexpectedly.".to_owned())); + + if ok { + self.inputs = FormInputs::default(); + } + self.step = Step::Result { ok, message }; + } + + pub fn draw(&self, frame: &mut Frame, area: Rect) { + match &self.step { + Step::Form { field, error } => { + draw_form(frame, area, &self.inputs, *field, error.as_deref()) + } + Step::Confirm { + recipient_display, + amount, + fee, + .. + } => draw_confirm(frame, area, recipient_display, amount, fee), + Step::Proving => draw_proving(frame, area), + Step::Result { ok, message } => draw_result(frame, area, *ok, message), + } + } +} + +fn draw_form( + frame: &mut Frame, + area: Rect, + inputs: &FormInputs, + active: Field, + error: Option<&str>, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(area); + + for (i, field) in Field::ALL.iter().enumerate() { + let value = field_value(inputs, *field); + let is_active = *field == active; + + let (text, style) = if is_active { + (format!("{value}\u{2588}"), Style::default()) + } else if value.is_empty() { + ( + field.placeholder().to_owned(), + Style::default().fg(Color::DarkGray), + ) + } else { + (value.to_owned(), Style::default()) + }; + + let border_style = if is_active { + Style::default().fg(Color::Magenta) + } else { + Style::default() + }; + + frame.render_widget( + Paragraph::new(Span::styled(text, style)).block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(format!(" {} ", field.label())), + ), + chunks[i], + ); + } + + let footer = match error { + Some(err) => Line::from(Span::styled(err, Style::default().fg(Color::Red))), + None => Line::from("Type to edit · Tab/↓ next field · Enter next/confirm · Esc clear"), + }; + frame.render_widget(Paragraph::new(footer).wrap(Wrap { trim: true }), chunks[3]); +} + +fn draw_confirm( + frame: &mut Frame, + area: Rect, + recipient_display: &str, + amount: &NativeCurrencyAmount, + fee: &NativeCurrencyAmount, +) { + let lines = vec![ + Line::from("Confirm this transaction:"), + Line::from(""), + Line::from(vec![ + Span::raw("To: "), + Span::styled(recipient_display, Style::default().fg(Color::Magenta)), + ]), + Line::from(format!("Amount: {amount} NYKS")), + Line::from(format!("Fee: {fee} NYKS")), + Line::from(""), + Line::from(Span::styled( + "Enter = send it · Esc = back and edit", + Style::default().fg(Color::Yellow), + )), + ]; + + frame.render_widget( + Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Confirm send "), + ) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn draw_proving(frame: &mut Frame, area: Rect) { + let lines = vec![ + Line::from(Span::styled( + "Proving transaction...", + Style::default().fg(Color::Magenta), + )), + Line::from(""), + Line::from("This can take a while. Please wait."), + ]; + + frame.render_widget( + Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Sending... ")) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn draw_result(frame: &mut Frame, area: Rect, ok: bool, message: &str) { + let color = if ok { Color::Green } else { Color::Red }; + let title = if ok { " Sent " } else { " Send failed " }; + + let lines = vec![ + Line::from(Span::styled(message, Style::default().fg(color))), + Line::from(""), + Line::from("Press any key to start another transaction."), + ]; + + frame.render_widget( + Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(title)) + .wrap(Wrap { trim: true }), + area, + ); +} diff --git a/wallet/cli/src/tui/dashboard/snapshot.rs b/wallet/cli/src/tui/dashboard/snapshot.rs new file mode 100644 index 0000000..115a030 --- /dev/null +++ b/wallet/cli/src/tui/dashboard/snapshot.rs @@ -0,0 +1,47 @@ +use num_traits::CheckedSub; +use nyks_standards::wallet::keys::{address::Recipient, key::KeyType}; +use nyks_wallet_sdk::wallet::Wallet; + +/// Everything the dashboard shows, refreshed on a timer so draw code never +/// has to `.await` a wallet call. +pub struct Snapshot { + pub height: String, + pub tip_height: String, + pub total_balance: String, + pub spendable_balance: String, + pub timelocked_balance: String, + pub unconfirmed_balance: String, + pub outgoing_balance: String, + pub utxo_count: usize, + pub generation_address: String, + pub symmetric_address: String, +} + +impl Snapshot { + pub async fn fetch(wallet: &Wallet) -> Self { + let total_balance = wallet.total_balance().await; + let spendable_balance = wallet.spendable_balance().await; + let timelocked_balance = total_balance + .checked_sub(&spendable_balance) + .unwrap_or(total_balance); + + Snapshot { + height: wallet.height().await.to_string(), + tip_height: wallet.tip_height().await.to_string(), + total_balance: total_balance.to_string(), + spendable_balance: spendable_balance.to_string(), + timelocked_balance: timelocked_balance.to_string(), + unconfirmed_balance: wallet.unconfirmed_balance().await.to_string(), + outgoing_balance: wallet.outgoing_balance().await.to_string(), + utxo_count: wallet.utxo_count().await, + generation_address: wallet + .address(KeyType::Generation) + .await + .to_bech32m(wallet.network), + symmetric_address: wallet + .address(KeyType::Symmetric) + .await + .to_bech32m(wallet.network), + } + } +} diff --git a/wallet/cli/src/tui/mod.rs b/wallet/cli/src/tui/mod.rs new file mode 100644 index 0000000..4034404 --- /dev/null +++ b/wallet/cli/src/tui/mod.rs @@ -0,0 +1,18 @@ +pub mod dashboard; +pub mod setup; + +/// Terminal handle shared by every screen in this module. +pub type Tui = ratatui::DefaultTerminal; + +/// Puts the terminal into raw + alternate-screen mode and returns a +/// ready-to-draw-on terminal handle. +pub fn init() -> Tui { + ratatui::init() +} + +/// Restores the terminal to its normal (cooked, main-screen) state. Always +/// call this before returning out of a TUI screen — including on the error +/// path — so a bad exit never leaves the user's shell in raw mode. +pub fn restore() { + ratatui::restore(); +} diff --git a/wallet/cli/src/tui/setup/create.rs b/wallet/cli/src/tui/setup/create.rs new file mode 100644 index 0000000..9d627a0 --- /dev/null +++ b/wallet/cli/src/tui/setup/create.rs @@ -0,0 +1,97 @@ +use anyhow::Result; +use crossterm::event::KeyCode; +use nyks_wallet_core::entropy::secret_key_material::SecretKeyMaterial; +use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; +use ratatui::Frame; +use ratatui::layout::Alignment; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +use super::layout::centered_rect; +use super::layout::footer_rect; +use super::layout::inset; + +/// What the create screen wants the caller to do next. +pub enum Outcome { + Stay, + Back, + Confirmed { + entropy: WalletEntropy, + phrase: String, + }, +} + +/// The "write down your new mnemonic" screen. +pub struct CreatePage { + words: Vec, +} + +impl CreatePage { + /// Generates a fresh mnemonic and starts the screen on it. + pub fn generate() -> Result { + Ok(Self { + words: generate_words(), + }) + } + + pub fn handle_key(&mut self, code: KeyCode) -> Result { + match code { + KeyCode::Enter => { + let phrase = self.words.join(" "); + let entropy = WalletEntropy::from_phrase(self.words.as_slice()).map_err(|e| { + anyhow::anyhow!("failed to derive keys from generated mnemonic: {e}") + })?; + return Ok(Outcome::Confirmed { entropy, phrase }); + } + KeyCode::Char('r') => self.words = generate_words(), + KeyCode::Esc => return Ok(Outcome::Back), + _ => {} + } + Ok(Outcome::Stay) + } + + pub fn draw(&self, frame: &mut Frame) { + let area = frame.area(); + let inner = centered_rect(area, 70, 60); + frame.render_widget( + Block::default() + .title(" Write down your recovery phrase ") + .borders(Borders::ALL), + inner, + ); + + let numbered: Vec = self + .words + .chunks(4) + .enumerate() + .map(|(row, chunk)| { + Line::from( + chunk + .iter() + .enumerate() + .map(|(i, w)| Span::raw(format!("{:>2}. {:<10}", row * 4 + i + 1, w))) + .collect::>(), + ) + }) + .collect(); + + frame.render_widget( + Paragraph::new(numbered).wrap(Wrap { trim: true }), + inset(inner, 1), + ); + frame.render_widget( + Paragraph::new("Enter = I've saved it, continue · r = regenerate · Esc = back") + .alignment(Alignment::Center), + footer_rect(area), + ); + } +} + +fn generate_words() -> Vec { + let secret = SecretKeyMaterial::random(); + secret.to_phrase() +} diff --git a/wallet/cli/src/tui/setup/import.rs b/wallet/cli/src/tui/setup/import.rs new file mode 100644 index 0000000..eb279be --- /dev/null +++ b/wallet/cli/src/tui/setup/import.rs @@ -0,0 +1,97 @@ +use crossterm::event::KeyCode; +use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; +use ratatui::Frame; +use ratatui::layout::Alignment; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +use super::layout::centered_rect; +use super::layout::footer_rect; +use super::layout::inset; + +/// What the import screen wants the caller to do next. +pub enum Outcome { + Stay, + Back, + Confirmed { + entropy: WalletEntropy, + phrase: String, + }, +} + +/// The "paste an existing mnemonic" screen. +#[derive(Default)] +pub struct ImportPage { + input: String, + error: Option, +} + +impl ImportPage { + /// Handles a key press. Unlike the other screens this never bails on + /// its own, a bad phrase just shows an inline error and stays put. + pub fn handle_key(&mut self, code: KeyCode) -> Outcome { + match code { + KeyCode::Enter => { + let words: Vec = self.input.split_whitespace().map(str::to_owned).collect(); + match WalletEntropy::from_phrase(&words) { + Ok(entropy) => { + return Outcome::Confirmed { + entropy, + phrase: self.input.clone(), + }; + } + Err(e) => self.error = Some(format!("invalid mnemonic: {e}")), + } + } + KeyCode::Backspace => { + self.input.pop(); + self.error = None; + } + KeyCode::Char(c) => { + self.input.push(c); + self.error = None; + } + KeyCode::Esc => return Outcome::Back, + _ => {} + } + Outcome::Stay + } + + pub fn draw(&self, frame: &mut Frame) { + let area = frame.area(); + let inner = centered_rect(area, 70, 30); + frame.render_widget( + Block::default() + .title(" Import an existing wallet ") + .borders(Borders::ALL), + inner, + ); + + frame.render_widget( + Paragraph::new(vec![ + Line::from("Paste your recovery phrase:"), + Line::from(""), + Line::from(Span::styled( + self.input.as_str(), + Style::default().fg(Color::Yellow), + )), + ]) + .wrap(Wrap { trim: true }), + inset(inner, 1), + ); + + let footer = match &self.error { + Some(err) => { + Paragraph::new(Span::styled(err.as_str(), Style::default().fg(Color::Red))) + } + None => Paragraph::new("Enter = confirm · Esc = back"), + }; + frame.render_widget(footer.alignment(Alignment::Center), footer_rect(area)); + } +} diff --git a/wallet/cli/src/tui/setup/layout.rs b/wallet/cli/src/tui/setup/layout.rs new file mode 100644 index 0000000..98aacb7 --- /dev/null +++ b/wallet/cli/src/tui/setup/layout.rs @@ -0,0 +1,43 @@ +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::layout::Rect; + +/// Returns a rect of `percent_x` × `percent_y` centered within `area`. +pub fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} + +pub fn footer_rect(area: Rect) -> Rect { + Rect { + x: area.x, + y: area.y + area.height.saturating_sub(2), + width: area.width, + height: 1, + } +} + +pub fn inset(rect: Rect, margin: u16) -> Rect { + Rect { + x: rect.x + margin, + y: rect.y + margin, + width: rect.width.saturating_sub(margin * 2), + height: rect.height.saturating_sub(margin * 2), + } +} diff --git a/wallet/cli/src/tui/setup/menu.rs b/wallet/cli/src/tui/setup/menu.rs new file mode 100644 index 0000000..831b2ec --- /dev/null +++ b/wallet/cli/src/tui/setup/menu.rs @@ -0,0 +1,92 @@ +use anyhow::Result; +use anyhow::bail; +use crossterm::event::KeyCode; +use ratatui::Frame; +use ratatui::layout::Alignment; +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Borders; +use ratatui::widgets::List; +use ratatui::widgets::ListItem; +use ratatui::widgets::Paragraph; + +use super::layout::centered_rect; +use super::layout::footer_rect; + +const ITEMS: [&str; 2] = ["Create a new wallet", "Import an existing wallet"]; + +/// What the menu screen wants the caller to do next. +pub enum Outcome { + Stay, + GoToCreate, + GoToImport, +} + +/// The entry screen: pick "create" or "import". +#[derive(Default)] +pub struct MenuPage { + selected: usize, +} + +impl MenuPage { + pub fn handle_key(&mut self, code: KeyCode) -> Result { + match code { + KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => { + self.selected = (self.selected + 1).min(ITEMS.len() - 1); + } + KeyCode::Enter if self.selected == 0 => return Ok(Outcome::GoToCreate), + KeyCode::Enter => return Ok(Outcome::GoToImport), + KeyCode::Esc | KeyCode::Char('q') => bail!("setup cancelled"), + _ => {} + } + Ok(Outcome::Stay) + } + + pub fn draw(&self, frame: &mut Frame) { + let area = frame.area(); + + let list_items: Vec = ITEMS + .iter() + .enumerate() + .map(|(i, label)| { + let style = if i == self.selected { + Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled(format!(" {label} "), style))) + }) + .collect(); + + let popup = centered_rect(area, 50, 30); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(ITEMS.len() as u16 + 2), + Constraint::Min(0), + ]) + .split(popup); + + let block = Block::default() + .title(" nyks-wallet — no wallet found ") + .borders(Borders::ALL); + frame.render_widget(List::new(list_items).block(block), layout[0]); + + frame.render_widget( + Paragraph::new("↑/↓ or j/k to move · Enter to select · q to quit") + .alignment(Alignment::Center), + footer_rect(area), + ); + } +} diff --git a/wallet/cli/src/tui/setup/mod.rs b/wallet/cli/src/tui/setup/mod.rs new file mode 100644 index 0000000..234f956 --- /dev/null +++ b/wallet/cli/src/tui/setup/mod.rs @@ -0,0 +1,88 @@ +mod create; +mod import; +mod layout; +mod menu; + +use anyhow::Result; +use anyhow::bail; +use crossterm::event; +use crossterm::event::Event; +use crossterm::event::KeyCode; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; +use ratatui::Frame; + +use create::CreatePage; +use import::ImportPage; +use menu::MenuPage; + +use crate::core::storage::Storage; +use crate::tui; + +enum Screen { + Menu(MenuPage), + Create(CreatePage), + Import(ImportPage), +} + +/// Runs the interactive "no wallet yet" onboarding flow. +/// +/// Returns `Err` if the user quits (`q` / `Esc` at the menu, or `Ctrl-C` +/// anywhere) before finishing. +pub fn run(storage: &Storage) -> Result { + let mut terminal = tui::init(); + let result = run_loop(&mut terminal, storage); + tui::restore(); + result +} + +fn run_loop(terminal: &mut tui::Tui, storage: &Storage) -> Result { + let mut screen = Screen::Menu(MenuPage::default()); + + loop { + terminal.draw(|frame| draw(frame, &screen))?; + + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + bail!("setup cancelled"); + } + + match &mut screen { + Screen::Menu(page) => match page.handle_key(key.code)? { + menu::Outcome::Stay => {} + menu::Outcome::GoToCreate => screen = Screen::Create(CreatePage::generate()?), + menu::Outcome::GoToImport => screen = Screen::Import(ImportPage::default()), + }, + Screen::Create(page) => match page.handle_key(key.code)? { + create::Outcome::Stay => {} + create::Outcome::Back => screen = Screen::Menu(MenuPage::default()), + create::Outcome::Confirmed { entropy, phrase } => { + storage.keys.set_mnemonic(&phrase); + return Ok(entropy); + } + }, + Screen::Import(page) => match page.handle_key(key.code) { + import::Outcome::Stay => {} + import::Outcome::Back => screen = Screen::Menu(MenuPage::default()), + import::Outcome::Confirmed { entropy, phrase } => { + storage.keys.set_mnemonic(&phrase); + return Ok(entropy); + } + }, + } + } +} + +fn draw(frame: &mut Frame, screen: &Screen) { + match screen { + Screen::Menu(page) => page.draw(frame), + Screen::Create(page) => page.draw(frame), + Screen::Import(page) => page.draw(frame), + } +} diff --git a/wallet/core/src/entropy/secret_key_material.rs b/wallet/core/src/entropy/secret_key_material.rs index e9b55f7..f89a99b 100644 --- a/wallet/core/src/entropy/secret_key_material.rs +++ b/wallet/core/src/entropy/secret_key_material.rs @@ -8,6 +8,7 @@ use nyks_consensus::twenty_first::prelude::Polynomial; use nyks_consensus::twenty_first::xfe; use rand::Rng; use rand::SeedableRng; +use rand::rng; use rand::rngs::StdRng; use thiserror::Error; use zeroize::Zeroize; @@ -166,6 +167,12 @@ impl SecretKeyMaterial { Ok(SecretKeyMaterial(p0)) } + /// Generate fresh random secret key material using the operating system's + /// cryptographically secure random number generator. + pub fn random() -> Self { + Self(rng().random()) + } + /// Convert a seed phrase into [`SecretKeyMaterial`]. pub fn from_phrase(phrase: &[String]) -> Result { let mnemonic = Mnemonic::from_phrase(&phrase.iter().join(" "), bip39::Language::English) diff --git a/wallet/core/src/transaction/mod.rs b/wallet/core/src/transaction/mod.rs index b265c80..9a5bf00 100644 --- a/wallet/core/src/transaction/mod.rs +++ b/wallet/core/src/transaction/mod.rs @@ -6,6 +6,7 @@ use nyks_consensus::transaction::validity::proof_collection::ProofCollection; use thiserror::Error; use crate::transaction::primitive_witness::PrimitiveWitness; +use crate::transaction::primitive_witness::ProvingStage; pub mod builder; pub mod primitive_witness; @@ -52,13 +53,18 @@ pub struct BuilderTransaction { } impl BuilderTransaction { - /// Upgrades the transaction proof to a level that can be broadcasted. + /// Upgrades the transaction proof to minimum level that can be broadcasted. pub fn upgrade(self) -> Self { + self.upgrade_with_progress(|_| {}) + } + + /// Like [`Self::upgrade`], but calls `on_progress` as each proving stage + /// begins. + pub fn upgrade_with_progress(self, on_progress: impl FnMut(ProvingStage)) -> Self { let new_proof = match self.proof { - BuilderTransactionProof::Witness(witness) => { - BuilderTransactionProof::ProofCollection(witness.prove().unwrap()) - } - // Other types are not supported yet, still investigating DX and use cases... + BuilderTransactionProof::Witness(witness) => BuilderTransactionProof::ProofCollection( + witness.prove_with_progress(on_progress).unwrap(), + ), _ => unimplemented!(), }; diff --git a/wallet/sdk/src/wallet.rs b/wallet/sdk/src/wallet.rs index d2a5c84..aaa95f2 100644 --- a/wallet/sdk/src/wallet.rs +++ b/wallet/sdk/src/wallet.rs @@ -17,9 +17,11 @@ use nyks_standards::wallet::keys::key::Spender; use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; use nyks_wallet_core::transaction::builder::TransactionBuilder; use nyks_wallet_core::transaction::builder::output::TxOutput; +pub use nyks_wallet_core::transaction::primitive_witness::ProvingStage; use nyks_wallet_core::transaction::utxo::spendable::SpendableUtxo; use thiserror::Error; use tokio::sync::RwLock; +use tokio::sync::mpsc::UnboundedSender; use crate::scanners::chain::AdvanceError; use crate::scanners::chain::ChainScanner; @@ -302,20 +304,23 @@ impl Wallet { /// Builds, signs and submits a transaction sending `amount` to `recipient`. /// - /// Returns the submitted transaction's kernel id. Any `UtxoInvalidated` - /// events raised while selecting inputs (UTXOs found spent during - /// proof-syncing) are queued and surfaced on the next call to - /// [`Wallet::sync`], rather than returned here directly. + /// If `progress` is `Some`, a [`ProvingStage`] is sent as each + /// proof-proving stage begins; the channel closes on its own once + /// proving completes. Pass `None` to skip progress reporting. + /// + /// Any `UtxoInvalidated` events raised while selecting inputs (UTXOs + /// found spent during proof-syncing) are queued and surfaced on the + /// next call to [`Wallet::sync`], rather than returned here directly. pub async fn send( &self, recipient: Address, amount: NativeCurrencyAmount, fee: NativeCurrencyAmount, + progress: Option>, ) -> Result { let height = self.tip_height().await; let timestamp = Timestamp::now(); - // Generate "spendable" UTXOs and prepare them for spending. let mut utxos = self.utxos.write().await; let excluded_utxos = self .mempool_scanner @@ -340,7 +345,6 @@ impl Wallet { let inputs = self.unlock_utxos(selection.utxos).await; - // Generate change address and randomnesses for outputs. let change_address = self.address(KeyType::Symmetric).await; // TODO: increment symmetric address count let (sender_randomness, change_sender_randomness) = { let addresses = self.addresses.read().await; @@ -370,7 +374,19 @@ impl Wallet { .mutator_set_accumulator(selection.msa) .build() .unwrap(); - let transaction = transaction.upgrade(); + + // Proving is CPU-heavy; do it off the async executor and stream + // stage updates back through `progress`, if supplied. + let transaction = tokio::task::spawn_blocking(move || { + transaction.upgrade_with_progress(move |stage| { + if let Some(tx) = &progress { + let _ = tx.send(stage); + } + }) + }) + .await + .expect("spawned task for proving transaction should not panic"); + let transaction: Transaction = transaction.try_into().unwrap(); let transaction_kernel_id = transaction.txid();