From 443bd25ac13d3b6ba739c49decd7d12cde1eb3a0 Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Wed, 23 Sep 2026 01:35:28 +0300 Subject: [PATCH 1/9] feat(wallet-core): Add random SecretKeyMaterial generate fn --- wallet/core/src/entropy/secret_key_material.rs | 7 +++++++ 1 file changed, 7 insertions(+) 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) From e572ab98f8f2afdc9ec654561c0f14b6b20dbb31 Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Wed, 23 Sep 2026 20:48:03 +0300 Subject: [PATCH 2/9] feat(wallet): TUI wallet remake --- .cargo/config.toml | 2 - Cargo.toml | 1 + standards/src/wallet/notes/note.rs | 31 +- wallet/cli-remake/Cargo.toml | 34 ++ wallet/cli-remake/src/core/mod.rs | 3 + wallet/cli-remake/src/core/storage.rs | 230 +++++++++++++ wallet/cli-remake/src/core/sync.rs | 62 ++++ wallet/cli-remake/src/core/tui.rs | 15 + wallet/cli-remake/src/dashboard/address.rs | 203 +++++++++++ wallet/cli-remake/src/dashboard/balance.rs | 40 +++ wallet/cli-remake/src/dashboard/mod.rs | 196 +++++++++++ wallet/cli-remake/src/dashboard/send.rs | 355 ++++++++++++++++++++ wallet/cli-remake/src/dashboard/snapshot.rs | 47 +++ wallet/cli-remake/src/main.rs | 98 ++++++ wallet/cli-remake/src/setup/create.rs | 97 ++++++ wallet/cli-remake/src/setup/import.rs | 97 ++++++ wallet/cli-remake/src/setup/layout.rs | 43 +++ wallet/cli-remake/src/setup/menu.rs | 92 +++++ wallet/cli-remake/src/setup/mod.rs | 88 +++++ 19 files changed, 1724 insertions(+), 10 deletions(-) create mode 100644 wallet/cli-remake/Cargo.toml create mode 100644 wallet/cli-remake/src/core/mod.rs create mode 100644 wallet/cli-remake/src/core/storage.rs create mode 100644 wallet/cli-remake/src/core/sync.rs create mode 100644 wallet/cli-remake/src/core/tui.rs create mode 100644 wallet/cli-remake/src/dashboard/address.rs create mode 100644 wallet/cli-remake/src/dashboard/balance.rs create mode 100644 wallet/cli-remake/src/dashboard/mod.rs create mode 100644 wallet/cli-remake/src/dashboard/send.rs create mode 100644 wallet/cli-remake/src/dashboard/snapshot.rs create mode 100644 wallet/cli-remake/src/main.rs create mode 100644 wallet/cli-remake/src/setup/create.rs create mode 100644 wallet/cli-remake/src/setup/import.rs create mode 100644 wallet/cli-remake/src/setup/layout.rs create mode 100644 wallet/cli-remake/src/setup/menu.rs create mode 100644 wallet/cli-remake/src/setup/mod.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 891fa3c..71e377f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,5 +12,3 @@ RUST_BACKTRACE = "1" # workaround for dependency `leveldb-sys v2.0.9` CMAKE_POLICY_VERSION_MINIMUM = "3.5" -CC = "clang" -CXX = "clang++" \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index f306c44..7c1a054 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "wallet/core", "wallet/sdk", "wallet/cli", + "wallet/cli-remake", "wasm", ] 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-remake/Cargo.toml b/wallet/cli-remake/Cargo.toml new file mode 100644 index 0000000..d2a3a53 --- /dev/null +++ b/wallet/cli-remake/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "nyks-wallet-remake" +description = "wallet of nyks" +authors = ["Nyksdevs"] +version = "0.8.0" +edition = "2024" +license = "Apache-2.0" + +repository = "https://github.com/Nyksnet/nyksnode/" + +[dependencies] +nyks-consensus = { version = "0.8.0", path = "../../consensus" } +nyks-standards = { version = "0.8.0", path = "../../standards" } +nyks-wallet-core = { version = "0.8.0", path = "../core" } +nyks-wallet-sdk = { version = "0.8.0", path = "../sdk" } +nyks-rpc-client = { version = "0.8.0", path = "../../rpc/client" } +anyhow = "1.0.98" +tokio = { version = "1.45.1", features = ["full", "tracing"] } +rustyline = "17.0.2" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +num-traits = "0.2.19" +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] + +[lints] +workspace = true diff --git a/wallet/cli-remake/src/core/mod.rs b/wallet/cli-remake/src/core/mod.rs new file mode 100644 index 0000000..f73ad76 --- /dev/null +++ b/wallet/cli-remake/src/core/mod.rs @@ -0,0 +1,3 @@ +pub mod storage; +pub mod sync; +pub mod tui; diff --git a/wallet/cli-remake/src/core/storage.rs b/wallet/cli-remake/src/core/storage.rs new file mode 100644 index 0000000..8e9b1dd --- /dev/null +++ b/wallet/cli-remake/src/core/storage.rs @@ -0,0 +1,230 @@ +use std::fs; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use nyks_consensus::block::block_height::BlockHeight; +use nyks_standards::wallet::keys::key::KeyType; +use nyks_wallet_sdk::state::utxos::MonitoredUtxo; +use nyks_wallet_sdk::state::utxos::UtxoKey; +use serde::Deserialize; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// Directory layout inside : +// +// keys.json – KeysData { external, internal, mnemonic } +// chain.json – ChainData { height } +// utxos/ +// -.json – LockedUtxo (one file per UTXO) +// --------------------------------------------------------------------------- + +/// 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 +/// before the rename commits, guarding against crash-after-rename data loss. +fn atomic_write(path: &Path, contents: &[u8]) { + let tmp = path.with_extension("tmp"); + { + let mut f = fs::File::create(&tmp) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", tmp.display())); + f.write_all(contents) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", tmp.display())); + f.sync_all() + .unwrap_or_else(|e| panic!("failed to sync {}: {e}", tmp.display())); + } + fs::rename(&tmp, path).unwrap_or_else(|e| { + panic!( + "failed to rename {} → {}: {e}", + tmp.display(), + path.display() + ) + }); +} + +/// Deserialize a JSON file into `T`. Panics if the file is absent or corrupt. +fn read_json Deserialize<'de>>(path: &Path) -> T { + let raw = fs::read_to_string(path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("corrupt {}: {e}", path.display())) +} + +/// Deserialize a JSON file into `T`, returning `T::default()` if the file is absent. +fn read_json_or_default Deserialize<'de> + Default>(path: &Path) -> T { + if path.exists() { + read_json(path) + } else { + T::default() + } +} + +/// Serialize `value` to `path` atomically. +fn write_json(path: &Path, value: &T) { + let raw = serde_json::to_string_pretty(value).expect("serialization failed"); + atomic_write(path, raw.as_bytes()); +} + +pub struct Storage { + pub keys: KeysStore, + pub utxos: UtxosStore, + pub chain: ChainStore, +} + +impl Storage { + pub fn new>(path: P) -> Self { + let root = path.as_ref().to_path_buf(); + fs::create_dir_all(&root).expect("failed to create storage root"); + fs::create_dir_all(root.join("utxos")).expect("failed to create utxos dir"); + + Storage { + keys: KeysStore::new(root.join("keys.json")), + utxos: UtxosStore::new(root.join("utxos")), + chain: ChainStore::new(root.join("chain.json")), + } + } +} + +/// Typed on-disk representation for `keys.json`. +#[derive(Debug, Default, Serialize, Deserialize)] +struct KeysData { + generation_index: u64, + symmetric_index: u64, + mnemonic: Option, +} + +pub struct KeysStore { + path: PathBuf, +} + +impl KeysStore { + fn new(path: PathBuf) -> Self { + Self { path } + } + + fn read(&self) -> KeysData { + read_json_or_default(&self.path) + } + + fn write(&self, data: &KeysData) { + write_json(&self.path, data); + } + + /// Returns the next derivation index (stored index + 1) for display. + pub fn next_index(&self, key: KeyType) -> u64 { + let data = self.read(); + match key { + KeyType::Generation => data.generation_index + 1, + KeyType::Symmetric => data.symmetric_index + 1, + } + } + + pub fn increment(&self, key: KeyType) { + let mut data = self.read(); + match key { + KeyType::Generation => data.generation_index += 1, + KeyType::Symmetric => data.symmetric_index += 1, + } + self.write(&data); + } + + pub fn set_mnemonic(&self, mnemonic: &str) { + let mut data = self.read(); + data.mnemonic = Some(mnemonic.to_owned()); + self.write(&data); + } + + pub fn get_mnemonic(&self) -> Option { + self.read().mnemonic + } +} + +pub struct UtxosStore { + dir: PathBuf, +} + +impl UtxosStore { + pub fn new(dir: PathBuf) -> Self { + Self { dir } + } + + fn file_stem(key: &UtxoKey) -> String { + format!( + "{}-{:x}", + key.aocl_index, key.addition_record.canonical_commitment + ) + } + + fn file_path(&self, key: &UtxoKey) -> PathBuf { + self.dir.join(format!("{}.json", Self::file_stem(key))) + } + + pub fn get(&self, key: &UtxoKey) -> Option { + let path = self.file_path(key); + path.exists().then(|| read_json(&path)) + } + + /// Inserts a UTXO. Returns `true` if newly inserted, `false` if it + /// already existed (mirrors the original `fetch_update` / `is_none` semantics). + pub fn put(&self, key: &UtxoKey, utxo: &MonitoredUtxo) -> bool { + let path = self.file_path(key); + let is_new = !path.exists(); + write_json(&path, utxo); + is_new + } + + pub fn remove(&self, key: &UtxoKey) { + let path = self.file_path(key); + if path.exists() { + fs::remove_file(&path).expect("failed to remove utxo file"); + } + } + + pub fn iter(&self) -> impl Iterator { + fs::read_dir(&self.dir) + .expect("failed to read utxos dir") + .filter_map(|entry| { + let path = entry.ok()?.path(); + if path.extension()? != "json" { + return None; + } + let utxo = read_json(&path); + Some(utxo) + }) + .collect::>() + .into_iter() + } +} + +/// Typed on-disk representation for `chain.json`. +#[derive(Debug, Default, Serialize, Deserialize)] +struct ChainData { + height: u64, +} + +pub struct ChainStore { + path: PathBuf, +} + +impl ChainStore { + fn new(path: PathBuf) -> Self { + Self { path } + } + + pub fn set_height(&self, height: BlockHeight) { + write_json( + &self.path, + &ChainData { + height: height.value(), + }, + ); + } + + pub fn get_height(&self) -> Option { + if self.path.exists() { + Some(read_json::(&self.path).height.into()) + } else { + None + } + } +} diff --git a/wallet/cli-remake/src/core/sync.rs b/wallet/cli-remake/src/core/sync.rs new file mode 100644 index 0000000..0eae9ba --- /dev/null +++ b/wallet/cli-remake/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-remake/src/core/tui.rs b/wallet/cli-remake/src/core/tui.rs new file mode 100644 index 0000000..e6c52c3 --- /dev/null +++ b/wallet/cli-remake/src/core/tui.rs @@ -0,0 +1,15 @@ +/// 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-remake/src/dashboard/address.rs b/wallet/cli-remake/src/dashboard/address.rs new file mode 100644 index 0000000..136f7d6 --- /dev/null +++ b/wallet/cli-remake/src/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-remake/src/dashboard/balance.rs b/wallet/cli-remake/src/dashboard/balance.rs new file mode 100644 index 0000000..db49431 --- /dev/null +++ b/wallet/cli-remake/src/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-remake/src/dashboard/mod.rs b/wallet/cli-remake/src/dashboard/mod.rs new file mode 100644 index 0000000..c9f7e41 --- /dev/null +++ b/wallet/cli-remake/src/dashboard/mod.rs @@ -0,0 +1,196 @@ +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::core::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] + } +} + +/// Runs the main dashboard: tabs for balance, address, and send. Takes over +/// the terminal until the user quits (`q` / `Esc` outside the Send form, or +/// `Ctrl-C` anywhere). +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::(); + + // crossterm's `event::read` is a blocking OS call, so it gets its own + // thread; parsed events are forwarded to the async loop below. + 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; // first tick fires immediately; we already have a snapshot. + + terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; + + loop { + tokio::select! { + _ = refresh.tick() => { + snapshot = Snapshot::fetch(&wallet).await; + terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; + } + maybe_event = rx.recv() => { + let Some(event) = maybe_event else { break }; + + match event { + // `Terminal::draw` re-queries the backend's size and + // adjusts its buffers before painting, so redrawing here + // is all a resize needs — but it only happens if we + // actually redraw on this event instead of dropping it. + 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 { + // Arrow keys always switch tabs, on every tab. + KeyCode::Left => tab = tab.prev(), + KeyCode::Right => 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') => tab = tab.next(), + KeyCode::BackTab | KeyCode::Char('h') => 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))?; + } + // Key-release events (Windows) and mouse/focus/paste events are ignored. + _ => {} + } + } + } + } + + 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 => { + "←/→ 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-remake/src/dashboard/send.rs b/wallet/cli-remake/src/dashboard/send.rs new file mode 100644 index 0000000..fff9059 --- /dev/null +++ b/wallet/cli-remake/src/dashboard/send.rs @@ -0,0 +1,355 @@ +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; + +/// The three fields of the send form, in tab order. +#[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] + } +} + +/// Raw text currently typed into each field. +#[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 { + /// Editing one of the three fields. + Form { field: Field, error: Option }, + /// All three fields parsed; asking the user to confirm before it's sent. + Confirm { + recipient: Address, + recipient_display: String, + amount: NativeCurrencyAmount, + fee: NativeCurrencyAmount, + }, + /// `wallet.send` finished; showing the outcome until the next key. + Result { ok: bool, message: String }, +} + +impl Default for Step { + fn default() -> Self { + Step::Form { + field: Field::Recipient, + error: None, + } + } +} + +/// State for the Send tab. +#[derive(Default)] +pub struct SendPage { + inputs: FormInputs, + step: Step, +} + +impl SendPage { + /// Handles a key press while the Send tab has focus. Takes near-total + /// control of the keyboard (see the dispatch in `dashboard::mod`) since + /// almost any letter is valid inside a recipient address. + pub async fn handle_key(&mut self, code: KeyCode, wallet: &Wallet) { + // Take the current step out (leaving a placeholder), decide the next + // step from an owned value, then write it back. This sidesteps + // borrow-checker issues that come from computing the next `Step` + // while still holding a reference into the old one. + 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(); + match wallet.send(recipient, amount, fee).await { + Ok(id) => { + self.inputs = FormInputs::default(); + Step::Result { + ok: true, + message: format!( + "Sent {amount_display} NYKS (+ {fee_display} fee) to \ + {recipient_display}. Transaction {id} announced." + ), + } + } + // Inputs are deliberately left in place here (unlike + // the success path) so a failed send can just be retried. + Err(e) => Step::Result { + ok: false, + message: format!("Failed to submit transaction: {e}."), + }, + } + } + KeyCode::Esc => Step::Form { + field: Field::Fee, + error: None, + }, + _ => Step::Confirm { + recipient, + recipient_display, + amount, + fee, + }, + }, + Step::Result { .. } => Step::Form { + field: Field::Recipient, + error: None, + }, + }; + } + + 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::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_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-remake/src/dashboard/snapshot.rs b/wallet/cli-remake/src/dashboard/snapshot.rs new file mode 100644 index 0000000..115a030 --- /dev/null +++ b/wallet/cli-remake/src/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-remake/src/main.rs b/wallet/cli-remake/src/main.rs new file mode 100644 index 0000000..3dbfd8b --- /dev/null +++ b/wallet/cli-remake/src/main.rs @@ -0,0 +1,98 @@ +pub mod core; +mod dashboard; +mod setup; + +use std::panic; + +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 tracing::info; + +use crate::core::storage::Storage; + +#[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, + + /// Directory to store wallet data in. + #[arg(long, default_value = "./wallet_data")] + wallet_dir: String, + + /// Network to connect to. + #[arg(long, default_value = "main")] + network: Network, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + 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. + core::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 wallet = Wallet::new(rpc, entropy, storage.chain.get_height(), args.network); + + // Import initial state of wallet. + wallet.import_utxos(storage.utxos.iter().collect()).await; + + // 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)); + + dashboard::run(wallet).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, launches the TUI's +/// create/import screen and returns whatever the user ends up with there. +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) => setup::run(storage), + } +} diff --git a/wallet/cli-remake/src/setup/create.rs b/wallet/cli-remake/src/setup/create.rs new file mode 100644 index 0000000..9d627a0 --- /dev/null +++ b/wallet/cli-remake/src/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-remake/src/setup/import.rs b/wallet/cli-remake/src/setup/import.rs new file mode 100644 index 0000000..eb279be --- /dev/null +++ b/wallet/cli-remake/src/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-remake/src/setup/layout.rs b/wallet/cli-remake/src/setup/layout.rs new file mode 100644 index 0000000..98aacb7 --- /dev/null +++ b/wallet/cli-remake/src/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-remake/src/setup/menu.rs b/wallet/cli-remake/src/setup/menu.rs new file mode 100644 index 0000000..831b2ec --- /dev/null +++ b/wallet/cli-remake/src/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-remake/src/setup/mod.rs b/wallet/cli-remake/src/setup/mod.rs new file mode 100644 index 0000000..a29112a --- /dev/null +++ b/wallet/cli-remake/src/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::core::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), + } +} From 6fc8b82033211357021da68c7a06f990ba5bd550 Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Wed, 23 Sep 2026 21:05:50 +0300 Subject: [PATCH 3/9] feat(wallet): Remove mnemonic importing thru CLI args --- wallet/cli-remake/src/main.rs | 51 +++++++---------------------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/wallet/cli-remake/src/main.rs b/wallet/cli-remake/src/main.rs index 3dbfd8b..eaffdf0 100644 --- a/wallet/cli-remake/src/main.rs +++ b/wallet/cli-remake/src/main.rs @@ -5,13 +5,11 @@ mod setup; use std::panic; 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 tracing::info; use crate::core::storage::Storage; @@ -19,10 +17,6 @@ use crate::core::storage::Storage; #[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, @@ -50,7 +44,16 @@ async fn main() -> Result<()> { 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. @@ -62,37 +65,3 @@ async fn main() -> Result<()> { dashboard::run(wallet).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, launches the TUI's -/// create/import screen and returns whatever the user ends up with there. -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) => setup::run(storage), - } -} From 7f948934131668b171826e9fc970d80404641936 Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Wed, 23 Sep 2026 21:12:23 +0300 Subject: [PATCH 4/9] style(wallet): Move TUI files to its own folder --- wallet/cli-remake/src/core/mod.rs | 1 - wallet/cli-remake/src/main.rs | 10 ++++------ wallet/cli-remake/src/{ => tui}/dashboard/address.rs | 0 wallet/cli-remake/src/{ => tui}/dashboard/balance.rs | 0 wallet/cli-remake/src/{ => tui}/dashboard/mod.rs | 2 +- wallet/cli-remake/src/{ => tui}/dashboard/send.rs | 0 wallet/cli-remake/src/{ => tui}/dashboard/snapshot.rs | 0 wallet/cli-remake/src/{core/tui.rs => tui/mod.rs} | 3 +++ wallet/cli-remake/src/{ => tui}/setup/create.rs | 0 wallet/cli-remake/src/{ => tui}/setup/import.rs | 0 wallet/cli-remake/src/{ => tui}/setup/layout.rs | 0 wallet/cli-remake/src/{ => tui}/setup/menu.rs | 0 wallet/cli-remake/src/{ => tui}/setup/mod.rs | 2 +- 13 files changed, 9 insertions(+), 9 deletions(-) rename wallet/cli-remake/src/{ => tui}/dashboard/address.rs (100%) rename wallet/cli-remake/src/{ => tui}/dashboard/balance.rs (100%) rename wallet/cli-remake/src/{ => tui}/dashboard/mod.rs (99%) rename wallet/cli-remake/src/{ => tui}/dashboard/send.rs (100%) rename wallet/cli-remake/src/{ => tui}/dashboard/snapshot.rs (100%) rename wallet/cli-remake/src/{core/tui.rs => tui/mod.rs} (93%) rename wallet/cli-remake/src/{ => tui}/setup/create.rs (100%) rename wallet/cli-remake/src/{ => tui}/setup/import.rs (100%) rename wallet/cli-remake/src/{ => tui}/setup/layout.rs (100%) rename wallet/cli-remake/src/{ => tui}/setup/menu.rs (100%) rename wallet/cli-remake/src/{ => tui}/setup/mod.rs (99%) diff --git a/wallet/cli-remake/src/core/mod.rs b/wallet/cli-remake/src/core/mod.rs index f73ad76..487995b 100644 --- a/wallet/cli-remake/src/core/mod.rs +++ b/wallet/cli-remake/src/core/mod.rs @@ -1,3 +1,2 @@ pub mod storage; pub mod sync; -pub mod tui; diff --git a/wallet/cli-remake/src/main.rs b/wallet/cli-remake/src/main.rs index eaffdf0..fdadede 100644 --- a/wallet/cli-remake/src/main.rs +++ b/wallet/cli-remake/src/main.rs @@ -1,6 +1,5 @@ -pub mod core; -mod dashboard; -mod setup; +mod core; +mod tui; use std::panic; @@ -11,7 +10,7 @@ use nyks_rpc_client::http::HttpClient; use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; use nyks_wallet_sdk::wallet::Wallet; -use crate::core::storage::Storage; +use crate::{core::storage::Storage, tui::{dashboard, setup}}; #[derive(Parser)] #[command(name = "nyks-wallet")] @@ -37,14 +36,13 @@ async fn main() -> Result<()> { 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. - core::tui::restore(); + 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 = match storage.keys.get_mnemonic() { Some(mnemonic) => { let words: Vec = mnemonic.split_whitespace().map(str::to_owned).collect(); diff --git a/wallet/cli-remake/src/dashboard/address.rs b/wallet/cli-remake/src/tui/dashboard/address.rs similarity index 100% rename from wallet/cli-remake/src/dashboard/address.rs rename to wallet/cli-remake/src/tui/dashboard/address.rs diff --git a/wallet/cli-remake/src/dashboard/balance.rs b/wallet/cli-remake/src/tui/dashboard/balance.rs similarity index 100% rename from wallet/cli-remake/src/dashboard/balance.rs rename to wallet/cli-remake/src/tui/dashboard/balance.rs diff --git a/wallet/cli-remake/src/dashboard/mod.rs b/wallet/cli-remake/src/tui/dashboard/mod.rs similarity index 99% rename from wallet/cli-remake/src/dashboard/mod.rs rename to wallet/cli-remake/src/tui/dashboard/mod.rs index c9f7e41..3a64fff 100644 --- a/wallet/cli-remake/src/dashboard/mod.rs +++ b/wallet/cli-remake/src/tui/dashboard/mod.rs @@ -30,7 +30,7 @@ use address::AddressPage; use send::SendPage; use snapshot::Snapshot; -use crate::core::tui; +use crate::tui; #[derive(Clone, Copy, PartialEq, Eq)] enum Tab { diff --git a/wallet/cli-remake/src/dashboard/send.rs b/wallet/cli-remake/src/tui/dashboard/send.rs similarity index 100% rename from wallet/cli-remake/src/dashboard/send.rs rename to wallet/cli-remake/src/tui/dashboard/send.rs diff --git a/wallet/cli-remake/src/dashboard/snapshot.rs b/wallet/cli-remake/src/tui/dashboard/snapshot.rs similarity index 100% rename from wallet/cli-remake/src/dashboard/snapshot.rs rename to wallet/cli-remake/src/tui/dashboard/snapshot.rs diff --git a/wallet/cli-remake/src/core/tui.rs b/wallet/cli-remake/src/tui/mod.rs similarity index 93% rename from wallet/cli-remake/src/core/tui.rs rename to wallet/cli-remake/src/tui/mod.rs index e6c52c3..4034404 100644 --- a/wallet/cli-remake/src/core/tui.rs +++ b/wallet/cli-remake/src/tui/mod.rs @@ -1,3 +1,6 @@ +pub mod dashboard; +pub mod setup; + /// Terminal handle shared by every screen in this module. pub type Tui = ratatui::DefaultTerminal; diff --git a/wallet/cli-remake/src/setup/create.rs b/wallet/cli-remake/src/tui/setup/create.rs similarity index 100% rename from wallet/cli-remake/src/setup/create.rs rename to wallet/cli-remake/src/tui/setup/create.rs diff --git a/wallet/cli-remake/src/setup/import.rs b/wallet/cli-remake/src/tui/setup/import.rs similarity index 100% rename from wallet/cli-remake/src/setup/import.rs rename to wallet/cli-remake/src/tui/setup/import.rs diff --git a/wallet/cli-remake/src/setup/layout.rs b/wallet/cli-remake/src/tui/setup/layout.rs similarity index 100% rename from wallet/cli-remake/src/setup/layout.rs rename to wallet/cli-remake/src/tui/setup/layout.rs diff --git a/wallet/cli-remake/src/setup/menu.rs b/wallet/cli-remake/src/tui/setup/menu.rs similarity index 100% rename from wallet/cli-remake/src/setup/menu.rs rename to wallet/cli-remake/src/tui/setup/menu.rs diff --git a/wallet/cli-remake/src/setup/mod.rs b/wallet/cli-remake/src/tui/setup/mod.rs similarity index 99% rename from wallet/cli-remake/src/setup/mod.rs rename to wallet/cli-remake/src/tui/setup/mod.rs index a29112a..234f956 100644 --- a/wallet/cli-remake/src/setup/mod.rs +++ b/wallet/cli-remake/src/tui/setup/mod.rs @@ -18,7 +18,7 @@ use import::ImportPage; use menu::MenuPage; use crate::core::storage::Storage; -use crate::core::tui; +use crate::tui; enum Screen { Menu(MenuPage), From a6d3259ca5216661da9e19efbbc67c607260377b Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Thu, 24 Sep 2026 02:37:00 +0300 Subject: [PATCH 5/9] feat(wallet-sdk): Upgrade and send with progress --- wallet/cli-remake/src/core/storage.rs | 2 +- wallet/core/src/transaction/mod.rs | 16 +++++++++----- wallet/sdk/src/wallet.rs | 30 ++++++++++++++++++++------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/wallet/cli-remake/src/core/storage.rs b/wallet/cli-remake/src/core/storage.rs index 8e9b1dd..d9ec07d 100644 --- a/wallet/cli-remake/src/core/storage.rs +++ b/wallet/cli-remake/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/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..bbf7941 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; +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(); From a3ae0c26f3d68581e88c8f0d82b37dc7b074e8f2 Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Thu, 24 Sep 2026 03:12:36 +0300 Subject: [PATCH 6/9] feat(wallet): Prevent tab switching etc. while proving a transaction --- wallet/cli-remake/src/main.rs | 4 +- wallet/cli-remake/src/tui/dashboard/mod.rs | 35 ++++---- wallet/cli-remake/src/tui/dashboard/send.rs | 96 ++++++++++++++------- wallet/cli/src/core/console.rs | 2 +- wallet/sdk/src/wallet.rs | 2 +- 5 files changed, 90 insertions(+), 49 deletions(-) diff --git a/wallet/cli-remake/src/main.rs b/wallet/cli-remake/src/main.rs index fdadede..46a6796 100644 --- a/wallet/cli-remake/src/main.rs +++ b/wallet/cli-remake/src/main.rs @@ -10,7 +10,9 @@ use nyks_rpc_client::http::HttpClient; use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; use nyks_wallet_sdk::wallet::Wallet; -use crate::{core::storage::Storage, tui::{dashboard, setup}}; +use crate::core::storage::Storage; +use crate::tui::dashboard; +use crate::tui::setup; #[derive(Parser)] #[command(name = "nyks-wallet")] diff --git a/wallet/cli-remake/src/tui/dashboard/mod.rs b/wallet/cli-remake/src/tui/dashboard/mod.rs index 3a64fff..18a827a 100644 --- a/wallet/cli-remake/src/tui/dashboard/mod.rs +++ b/wallet/cli-remake/src/tui/dashboard/mod.rs @@ -64,9 +64,6 @@ impl Tab { } } -/// Runs the main dashboard: tabs for balance, address, and send. Takes over -/// the terminal until the user quits (`q` / `Esc` outside the Send form, or -/// `Ctrl-C` anywhere). pub async fn run(wallet: Wallet) -> Result<()> { let mut terminal = tui::init(); let result = run_loop(&mut terminal, wallet).await; @@ -77,8 +74,6 @@ pub async fn run(wallet: Wallet) -> Result<()> { async fn run_loop(terminal: &mut tui::Tui, wallet: Wallet) -> Result<()> { let (tx, mut rx) = mpsc::unbounded_channel::(); - // crossterm's `event::read` is a blocking OS call, so it gets its own - // thread; parsed events are forwarded to the async loop below. std::thread::spawn(move || { while let Ok(ev) = event::read() { if tx.send(ev).is_err() { @@ -92,24 +87,32 @@ async fn run_loop(terminal: &mut tui::Tui, wallet: Wallet) -> Result<()> { 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; // first tick fires immediately; we already have a snapshot. + 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 { - // `Terminal::draw` re-queries the backend's size and - // adjusts its buffers before painting, so redrawing here - // is all a resize needs — but it only happens if we - // actually redraw on this event instead of dropping it. Event::Resize(_, _) => { terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; } @@ -119,14 +122,14 @@ async fn run_loop(terminal: &mut tui::Tui, wallet: Wallet) -> Result<()> { } match key.code { - // Arrow keys always switch tabs, on every tab. - KeyCode::Left => tab = tab.prev(), - KeyCode::Right => tab = tab.next(), + // 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') => tab = tab.next(), - KeyCode::BackTab | KeyCode::Char('h') => tab = tab.prev(), + 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), @@ -135,7 +138,6 @@ async fn run_loop(terminal: &mut tui::Tui, wallet: Wallet) -> Result<()> { terminal.draw(|frame| draw(frame, tab, &address_page, &send_page, &snapshot))?; } - // Key-release events (Windows) and mouse/focus/paste events are ignored. _ => {} } } @@ -188,6 +190,7 @@ fn draw( 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" } diff --git a/wallet/cli-remake/src/tui/dashboard/send.rs b/wallet/cli-remake/src/tui/dashboard/send.rs index fff9059..9e114da 100644 --- a/wallet/cli-remake/src/tui/dashboard/send.rs +++ b/wallet/cli-remake/src/tui/dashboard/send.rs @@ -17,8 +17,8 @@ use ratatui::widgets::Block; use ratatui::widgets::Borders; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; +use tokio::sync::oneshot; -/// The three fields of the send form, in tab order. #[derive(Clone, Copy, PartialEq, Eq)] enum Field { Recipient, @@ -59,7 +59,6 @@ impl Field { } } -/// Raw text currently typed into each field. #[derive(Default)] struct FormInputs { recipient: String, @@ -108,17 +107,21 @@ fn parse_form(inputs: &FormInputs, network: Network) -> Result { } enum Step { - /// Editing one of the three fields. - Form { field: Field, error: Option }, - /// All three fields parsed; asking the user to confirm before it's sent. + Form { + field: Field, + error: Option, + }, Confirm { recipient: Address, recipient_display: String, amount: NativeCurrencyAmount, fee: NativeCurrencyAmount, }, - /// `wallet.send` finished; showing the outcome until the next key. - Result { ok: bool, message: String }, + Proving, + Result { + ok: bool, + message: String, + }, } impl Default for Step { @@ -130,22 +133,19 @@ impl Default for Step { } } -/// State for the Send tab. #[derive(Default)] pub struct SendPage { inputs: FormInputs, step: Step, + result_rx: Option>, } impl SendPage { - /// Handles a key press while the Send tab has focus. Takes near-total - /// control of the keyboard (see the dispatch in `dashboard::mod`) since - /// almost any letter is valid inside a recipient address. + pub fn is_active(&self) -> bool { + self.result_rx.is_some() + } + pub async fn handle_key(&mut self, code: KeyCode, wallet: &Wallet) { - // Take the current step out (leaving a placeholder), decide the next - // step from an owned value, then write it back. This sidesteps - // borrow-checker issues that come from computing the next `Step` - // while still holding a reference into the old one. let step = std::mem::take(&mut self.step); self.step = match step { @@ -192,24 +192,25 @@ impl SendPage { KeyCode::Enter => { let amount_display = amount.to_string(); let fee_display = fee.to_string(); - match wallet.send(recipient, amount, fee).await { - Ok(id) => { - self.inputs = FormInputs::default(); - Step::Result { - ok: true, - message: format!( + 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." ), - } - } - // Inputs are deliberately left in place here (unlike - // the success path) so a failed send can just be retried. - Err(e) => Step::Result { - ok: false, - message: format!("Failed to submit transaction: {e}."), - }, - } + ), + 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, @@ -222,6 +223,8 @@ impl SendPage { fee, }, }, + // Ignore all input while proving. + Step::Proving => Step::Proving, Step::Result { .. } => Step::Form { field: Field::Recipient, error: None, @@ -229,6 +232,23 @@ impl SendPage { }; } + /// 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 } => { @@ -240,6 +260,7 @@ impl SendPage { 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), } } @@ -336,6 +357,21 @@ fn draw_confirm( ); } +fn draw_proving(frame: &mut Frame, area: Rect) { + let lines = vec![ + Line::from(Span::styled("Proving…", Style::default().fg(Color::Yellow))), + 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 " }; diff --git a/wallet/cli/src/core/console.rs b/wallet/cli/src/core/console.rs index 74b3d76..261d209 100644 --- a/wallet/cli/src/core/console.rs +++ b/wallet/cli/src/core/console.rs @@ -169,7 +169,7 @@ pub async fn start_console(wallet: Wallet) { amount, fee ); - match wallet.send(recipient, amount, fee).await { + match wallet.send(recipient, amount, fee, None).await { Ok(id) => info!("Announced {} successfully.", id), Err(err) => error!("Failed to submit transaction: {}.", err), }; diff --git a/wallet/sdk/src/wallet.rs b/wallet/sdk/src/wallet.rs index bbf7941..aaa95f2 100644 --- a/wallet/sdk/src/wallet.rs +++ b/wallet/sdk/src/wallet.rs @@ -17,7 +17,7 @@ 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; -use nyks_wallet_core::transaction::primitive_witness::ProvingStage; +pub use nyks_wallet_core::transaction::primitive_witness::ProvingStage; use nyks_wallet_core::transaction::utxo::spendable::SpendableUtxo; use thiserror::Error; use tokio::sync::RwLock; From 619a8686766e2226fe599bbb07906d2babfd6755 Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Thu, 24 Sep 2026 05:33:31 +0300 Subject: [PATCH 7/9] style(wallet): Cleanup of TUI style --- wallet/cli-remake/src/tui/dashboard/mod.rs | 2 +- wallet/cli-remake/src/tui/dashboard/send.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/wallet/cli-remake/src/tui/dashboard/mod.rs b/wallet/cli-remake/src/tui/dashboard/mod.rs index 18a827a..3c16f95 100644 --- a/wallet/cli-remake/src/tui/dashboard/mod.rs +++ b/wallet/cli-remake/src/tui/dashboard/mod.rs @@ -190,7 +190,7 @@ fn draw( 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 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" } diff --git a/wallet/cli-remake/src/tui/dashboard/send.rs b/wallet/cli-remake/src/tui/dashboard/send.rs index 9e114da..737b742 100644 --- a/wallet/cli-remake/src/tui/dashboard/send.rs +++ b/wallet/cli-remake/src/tui/dashboard/send.rs @@ -359,14 +359,14 @@ fn draw_confirm( fn draw_proving(frame: &mut Frame, area: Rect) { let lines = vec![ - Line::from(Span::styled("Proving…", Style::default().fg(Color::Yellow))), + 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… ")) + .block(Block::default().borders(Borders::ALL).title(" Sending... ")) .wrap(Wrap { trim: true }), area, ); From 3052a877c9a54291986449785af3bb47d44525ad Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Thu, 24 Sep 2026 21:59:44 +0300 Subject: [PATCH 8/9] feat(wallet): Remove old CLI wallet --- Cargo.toml | 1 - wallet/cli-remake/Cargo.toml | 34 --- wallet/cli-remake/src/core/mod.rs | 2 - wallet/cli-remake/src/core/storage.rs | 230 ------------------ wallet/cli-remake/src/main.rs | 67 ----- wallet/cli/Cargo.toml | 3 + wallet/cli/src/core/console.rs | 182 -------------- wallet/cli/src/core/mod.rs | 2 +- wallet/cli/src/core/storage.rs | 2 +- wallet/{cli-remake => cli}/src/core/sync.rs | 0 wallet/cli/src/main.rs | 113 ++------- .../src/tui/dashboard/address.rs | 0 .../src/tui/dashboard/balance.rs | 0 .../src/tui/dashboard/mod.rs | 0 .../src/tui/dashboard/send.rs | 5 +- .../src/tui/dashboard/snapshot.rs | 0 wallet/{cli-remake => cli}/src/tui/mod.rs | 0 .../src/tui/setup/create.rs | 0 .../src/tui/setup/import.rs | 0 .../src/tui/setup/layout.rs | 0 .../{cli-remake => cli}/src/tui/setup/menu.rs | 0 .../{cli-remake => cli}/src/tui/setup/mod.rs | 0 22 files changed, 28 insertions(+), 613 deletions(-) delete mode 100644 wallet/cli-remake/Cargo.toml delete mode 100644 wallet/cli-remake/src/core/mod.rs delete mode 100644 wallet/cli-remake/src/core/storage.rs delete mode 100644 wallet/cli-remake/src/main.rs delete mode 100644 wallet/cli/src/core/console.rs rename wallet/{cli-remake => cli}/src/core/sync.rs (100%) rename wallet/{cli-remake => cli}/src/tui/dashboard/address.rs (100%) rename wallet/{cli-remake => cli}/src/tui/dashboard/balance.rs (100%) rename wallet/{cli-remake => cli}/src/tui/dashboard/mod.rs (100%) rename wallet/{cli-remake => cli}/src/tui/dashboard/send.rs (98%) rename wallet/{cli-remake => cli}/src/tui/dashboard/snapshot.rs (100%) rename wallet/{cli-remake => cli}/src/tui/mod.rs (100%) rename wallet/{cli-remake => cli}/src/tui/setup/create.rs (100%) rename wallet/{cli-remake => cli}/src/tui/setup/import.rs (100%) rename wallet/{cli-remake => cli}/src/tui/setup/layout.rs (100%) rename wallet/{cli-remake => cli}/src/tui/setup/menu.rs (100%) rename wallet/{cli-remake => cli}/src/tui/setup/mod.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 7c1a054..f306c44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ members = [ "wallet/core", "wallet/sdk", "wallet/cli", - "wallet/cli-remake", "wasm", ] diff --git a/wallet/cli-remake/Cargo.toml b/wallet/cli-remake/Cargo.toml deleted file mode 100644 index d2a3a53..0000000 --- a/wallet/cli-remake/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "nyks-wallet-remake" -description = "wallet of nyks" -authors = ["Nyksdevs"] -version = "0.8.0" -edition = "2024" -license = "Apache-2.0" - -repository = "https://github.com/Nyksnet/nyksnode/" - -[dependencies] -nyks-consensus = { version = "0.8.0", path = "../../consensus" } -nyks-standards = { version = "0.8.0", path = "../../standards" } -nyks-wallet-core = { version = "0.8.0", path = "../core" } -nyks-wallet-sdk = { version = "0.8.0", path = "../sdk" } -nyks-rpc-client = { version = "0.8.0", path = "../../rpc/client" } -anyhow = "1.0.98" -tokio = { version = "1.45.1", features = ["full", "tracing"] } -rustyline = "17.0.2" -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -num-traits = "0.2.19" -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] - -[lints] -workspace = true diff --git a/wallet/cli-remake/src/core/mod.rs b/wallet/cli-remake/src/core/mod.rs deleted file mode 100644 index 487995b..0000000 --- a/wallet/cli-remake/src/core/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod storage; -pub mod sync; diff --git a/wallet/cli-remake/src/core/storage.rs b/wallet/cli-remake/src/core/storage.rs deleted file mode 100644 index d9ec07d..0000000 --- a/wallet/cli-remake/src/core/storage.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::fs; -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; - -use nyks_consensus::block::block_height::BlockHeight; -use nyks_standards::wallet::keys::key::KeyType; -use nyks_wallet_sdk::state::utxos::MonitoredUtxo; -use nyks_wallet_sdk::state::utxos::UtxoKey; -use serde::Deserialize; -use serde::Serialize; - -// --------------------------------------------------------------------------- -// Directory layout inside : -// -// keys.json – KeysData { external, internal, mnemonic } -// chain.json – ChainData { height } -// utxos/ -// -.json – LockedUtxo (one file per UTXO) -// --------------------------------------------------------------------------- - -/// 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 -/// before the rename commits, guarding against crash-after-rename data loss. -fn atomic_write(path: &Path, contents: &[u8]) { - let tmp = path.with_extension("tmp"); - { - let mut f = fs::File::create(&tmp) - .unwrap_or_else(|e| panic!("failed to create {}: {e}", tmp.display())); - f.write_all(contents) - .unwrap_or_else(|e| panic!("failed to write {}: {e}", tmp.display())); - f.sync_all() - .unwrap_or_else(|e| panic!("failed to sync {}: {e}", tmp.display())); - } - fs::rename(&tmp, path).unwrap_or_else(|e| { - panic!( - "failed to rename {} → {}: {e}", - tmp.display(), - path.display() - ) - }); -} - -/// Deserialize a JSON file into `T`. Panics if the file is absent or corrupt. -fn read_json Deserialize<'de>>(path: &Path) -> T { - let raw = fs::read_to_string(path) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - serde_json::from_str(&raw).unwrap_or_else(|e| panic!("corrupt {}: {e}", path.display())) -} - -/// Deserialize a JSON file into `T`, returning `T::default()` if the file is absent. -fn read_json_or_default Deserialize<'de> + Default>(path: &Path) -> T { - if path.exists() { - read_json(path) - } else { - T::default() - } -} - -/// Serialize `value` to `path` atomically. -fn write_json(path: &Path, value: &T) { - let raw = serde_json::to_string_pretty(value).expect("serialization failed"); - atomic_write(path, raw.as_bytes()); -} - -pub struct Storage { - pub keys: KeysStore, - pub utxos: UtxosStore, - pub chain: ChainStore, -} - -impl Storage { - pub fn new>(path: P) -> Self { - let root = path.as_ref().to_path_buf(); - fs::create_dir_all(&root).expect("failed to create storage root"); - fs::create_dir_all(root.join("utxos")).expect("failed to create utxos dir"); - - Storage { - keys: KeysStore::new(root.join("keys.json")), - utxos: UtxosStore::new(root.join("utxos")), - chain: ChainStore::new(root.join("chain.json")), - } - } -} - -/// Typed on-disk representation for `keys.json`. -#[derive(Debug, Default, Serialize, Deserialize)] -struct KeysData { - generation_index: u64, - symmetric_index: u64, - mnemonic: Option, -} - -pub struct KeysStore { - path: PathBuf, -} - -impl KeysStore { - fn new(path: PathBuf) -> Self { - Self { path } - } - - fn read(&self) -> KeysData { - read_json_or_default(&self.path) - } - - fn write(&self, data: &KeysData) { - write_json(&self.path, data); - } - - /// Returns the next derivation index (stored index + 1) for display. - pub fn next_index(&self, key: KeyType) -> u64 { - let data = self.read(); - match key { - KeyType::Generation => data.generation_index + 1, - KeyType::Symmetric => data.symmetric_index + 1, - } - } - - pub fn increment(&self, key: KeyType) { - let mut data = self.read(); - match key { - KeyType::Generation => data.generation_index += 1, - KeyType::Symmetric => data.symmetric_index += 1, - } - self.write(&data); - } - - pub fn set_mnemonic(&self, mnemonic: &str) { - let mut data = self.read(); - data.mnemonic = Some(mnemonic.to_owned()); - self.write(&data); - } - - pub fn get_mnemonic(&self) -> Option { - self.read().mnemonic - } -} - -pub struct UtxosStore { - dir: PathBuf, -} - -impl UtxosStore { - pub fn new(dir: PathBuf) -> Self { - Self { dir } - } - - fn file_stem(key: &UtxoKey) -> String { - format!( - "{}-{:x}", - key.aocl_index, key.addition_record.canonical_commitment - ) - } - - fn file_path(&self, key: &UtxoKey) -> PathBuf { - self.dir.join(format!("{}.json", Self::file_stem(key))) - } - - pub fn get(&self, key: &UtxoKey) -> Option { - let path = self.file_path(key); - path.exists().then(|| read_json(&path)) - } - - /// Inserts a UTXO. Returns `true` if newly inserted, `false` if it - /// already existed (mirrors the original `fetch_update` / `is_none` semantics). - pub fn put(&self, key: &UtxoKey, utxo: &MonitoredUtxo) -> bool { - let path = self.file_path(key); - let is_new = !path.exists(); - write_json(&path, utxo); - is_new - } - - pub fn remove(&self, key: &UtxoKey) { - let path = self.file_path(key); - if path.exists() { - fs::remove_file(&path).expect("failed to remove utxo file"); - } - } - - pub fn iter(&self) -> impl Iterator { - fs::read_dir(&self.dir) - .expect("failed to read utxos dir") - .filter_map(|entry| { - let path = entry.ok()?.path(); - if path.extension()? != "json" { - return None; - } - let utxo = read_json(&path); - Some(utxo) - }) - .collect::>() - .into_iter() - } -} - -/// Typed on-disk representation for `chain.json`. -#[derive(Debug, Default, Serialize, Deserialize)] -struct ChainData { - height: u64, -} - -pub struct ChainStore { - path: PathBuf, -} - -impl ChainStore { - fn new(path: PathBuf) -> Self { - Self { path } - } - - pub fn set_height(&self, height: BlockHeight) { - write_json( - &self.path, - &ChainData { - height: height.value(), - }, - ); - } - - pub fn get_height(&self) -> Option { - if self.path.exists() { - Some(read_json::(&self.path).height.into()) - } else { - None - } - } -} diff --git a/wallet/cli-remake/src/main.rs b/wallet/cli-remake/src/main.rs deleted file mode 100644 index 46a6796..0000000 --- a/wallet/cli-remake/src/main.rs +++ /dev/null @@ -1,67 +0,0 @@ -mod core; -mod tui; - -use std::panic; - -use anyhow::Result; -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 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 { - /// RPC URL to use (JSON/HTTP). - #[arg(long)] - rpc_url: String, - - /// Directory to store wallet data in. - #[arg(long, default_value = "./wallet_data")] - wallet_dir: String, - - /// Network to connect to. - #[arg(long, default_value = "main")] - network: Network, -} - -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); - - 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 = 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; - - // 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)); - - dashboard::run(wallet).await -} 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 261d209..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, None).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-remake/src/core/sync.rs b/wallet/cli/src/core/sync.rs similarity index 100% rename from wallet/cli-remake/src/core/sync.rs rename to wallet/cli/src/core/sync.rs 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-remake/src/tui/dashboard/address.rs b/wallet/cli/src/tui/dashboard/address.rs similarity index 100% rename from wallet/cli-remake/src/tui/dashboard/address.rs rename to wallet/cli/src/tui/dashboard/address.rs diff --git a/wallet/cli-remake/src/tui/dashboard/balance.rs b/wallet/cli/src/tui/dashboard/balance.rs similarity index 100% rename from wallet/cli-remake/src/tui/dashboard/balance.rs rename to wallet/cli/src/tui/dashboard/balance.rs diff --git a/wallet/cli-remake/src/tui/dashboard/mod.rs b/wallet/cli/src/tui/dashboard/mod.rs similarity index 100% rename from wallet/cli-remake/src/tui/dashboard/mod.rs rename to wallet/cli/src/tui/dashboard/mod.rs diff --git a/wallet/cli-remake/src/tui/dashboard/send.rs b/wallet/cli/src/tui/dashboard/send.rs similarity index 98% rename from wallet/cli-remake/src/tui/dashboard/send.rs rename to wallet/cli/src/tui/dashboard/send.rs index 737b742..538ba52 100644 --- a/wallet/cli-remake/src/tui/dashboard/send.rs +++ b/wallet/cli/src/tui/dashboard/send.rs @@ -359,7 +359,10 @@ fn draw_confirm( fn draw_proving(frame: &mut Frame, area: Rect) { let lines = vec![ - Line::from(Span::styled("Proving transaction...", Style::default().fg(Color::Magenta))), + Line::from(Span::styled( + "Proving transaction...", + Style::default().fg(Color::Magenta), + )), Line::from(""), Line::from("This can take a while. Please wait."), ]; diff --git a/wallet/cli-remake/src/tui/dashboard/snapshot.rs b/wallet/cli/src/tui/dashboard/snapshot.rs similarity index 100% rename from wallet/cli-remake/src/tui/dashboard/snapshot.rs rename to wallet/cli/src/tui/dashboard/snapshot.rs diff --git a/wallet/cli-remake/src/tui/mod.rs b/wallet/cli/src/tui/mod.rs similarity index 100% rename from wallet/cli-remake/src/tui/mod.rs rename to wallet/cli/src/tui/mod.rs diff --git a/wallet/cli-remake/src/tui/setup/create.rs b/wallet/cli/src/tui/setup/create.rs similarity index 100% rename from wallet/cli-remake/src/tui/setup/create.rs rename to wallet/cli/src/tui/setup/create.rs diff --git a/wallet/cli-remake/src/tui/setup/import.rs b/wallet/cli/src/tui/setup/import.rs similarity index 100% rename from wallet/cli-remake/src/tui/setup/import.rs rename to wallet/cli/src/tui/setup/import.rs diff --git a/wallet/cli-remake/src/tui/setup/layout.rs b/wallet/cli/src/tui/setup/layout.rs similarity index 100% rename from wallet/cli-remake/src/tui/setup/layout.rs rename to wallet/cli/src/tui/setup/layout.rs diff --git a/wallet/cli-remake/src/tui/setup/menu.rs b/wallet/cli/src/tui/setup/menu.rs similarity index 100% rename from wallet/cli-remake/src/tui/setup/menu.rs rename to wallet/cli/src/tui/setup/menu.rs diff --git a/wallet/cli-remake/src/tui/setup/mod.rs b/wallet/cli/src/tui/setup/mod.rs similarity index 100% rename from wallet/cli-remake/src/tui/setup/mod.rs rename to wallet/cli/src/tui/setup/mod.rs From 62db552bbd724785d3222c1380c40fd7cdaee0c1 Mon Sep 17 00:00:00 2001 From: KaffinPX Date: Thu, 24 Sep 2026 22:08:13 +0300 Subject: [PATCH 9/9] fix: Fix clang on linux --- .cargo/config.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index 71e377f..891fa3c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,3 +12,5 @@ RUST_BACKTRACE = "1" # workaround for dependency `leveldb-sys v2.0.9` CMAKE_POLICY_VERSION_MINIMUM = "3.5" +CC = "clang" +CXX = "clang++" \ No newline at end of file