From 3903edf964b7874e98d817ba33eb1c7cd0890a8e Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 20:18:42 +0200 Subject: [PATCH 01/52] feat(wallet-cli): carry structured command output through the console --- .../src/command_handler/mod.rs | 7 +- wallet/wallet-cli-commands/src/lib.rs | 10 ++ wallet/wallet-cli-commands/src/output.rs | 104 ++++++++++++++++++ wallet/wallet-cli-lib/src/console.rs | 8 ++ .../src/repl/interactive/mod.rs | 7 ++ .../src/repl/non_interactive/mod.rs | 81 ++++++++++++++ 6 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 wallet/wallet-cli-commands/src/output.rs diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 2e1506e690..6584bfaa1e 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -2019,7 +2019,12 @@ where WalletCommand::PeerCount => { let peer_count = self.wallet().await?.peer_count().await?; - Ok(ConsoleCommand::Print(peer_count.to_string())) + Ok(ConsoleCommand::from( + crate::output::CommandOutput::with_payload( + peer_count.to_string(), + serde_json::json!(peer_count), + ), + )) } WalletCommand::ConnectedPeers => { let peers = self.wallet().await?.connected_peers().await?; diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index a4074822b9..e28050735a 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -16,6 +16,7 @@ mod command_handler; mod errors; mod helper_types; +pub mod output; use std::{collections::BTreeMap, fmt::Debug, num::NonZeroUsize, path::PathBuf, time::Duration}; @@ -1421,6 +1422,15 @@ impl ChoiceMenu for OpenWalletDeviceSelectMenu { #[derive(Debug, Clone)] pub enum ConsoleCommand { Print(String), + /// A structured result: optional human-readable text plus the machine-readable form + /// of the same data. Interactive mode renders the text, falling back to a pretty + /// print of the value; scripting modes emit the value only. + Json { + text: Option, + value: serde_json::Value, + }, + /// A non-fatal condition. Scripting modes route it to stderr. + Warn(String), PaginatedPrint { // TODO: add support for more structured data like table pagination header: String, diff --git a/wallet/wallet-cli-commands/src/output.rs b/wallet/wallet-cli-commands/src/output.rs new file mode 100644 index 0000000000..0e74ace403 --- /dev/null +++ b/wallet/wallet-cli-commands/src/output.rs @@ -0,0 +1,104 @@ +// Copyright (c) 2023 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde_json::Value; + +use crate::ConsoleCommand; + +/// The structured result of a wallet command. +/// +/// `text` is what users read; `payload`, when present, carries the same result in a +/// machine-readable form for scripting modes. +#[derive(Debug, Clone)] +pub struct CommandOutput { + text: String, + payload: Option, +} + +impl CommandOutput { + /// A result that only has a human-readable form. + pub fn plain(text: impl Into) -> Self { + Self { + text: text.into(), + payload: None, + } + } + + /// A result that also carries a machine-readable form of the same data. + pub fn with_payload(text: impl Into, payload: Value) -> Self { + Self { + text: text.into(), + payload: Some(payload), + } + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn payload(&self) -> Option<&Value> { + self.payload.as_ref() + } +} + +impl From for ConsoleCommand { + fn from(output: CommandOutput) -> Self { + match output.payload { + Some(value) => ConsoleCommand::Json { + text: Some(output.text), + value, + }, + None => ConsoleCommand::Print(output.text), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn payload_produces_json_output() { + let command: ConsoleCommand = CommandOutput::with_payload("peer count: 3", json!(3)).into(); + + let ConsoleCommand::Json { text, value } = command else { + panic!("expected a Json output"); + }; + assert_eq!(text.as_deref(), Some("peer count: 3")); + assert_eq!(value, json!(3)); + } + + #[test] + fn absent_payload_produces_print_output() { + let command: ConsoleCommand = CommandOutput::plain("done").into(); + + let ConsoleCommand::Print(text) = command else { + panic!("expected a Print output"); + }; + assert_eq!(text, "done"); + } + + #[test] + fn accessors_return_the_stored_data() { + let output = CommandOutput::with_payload("text", json!({"key": 1})); + assert_eq!(output.text(), "text"); + assert_eq!(output.payload(), Some(&json!({"key": 1}))); + + let output = CommandOutput::plain("text"); + assert_eq!(output.payload(), None); + } +} diff --git a/wallet/wallet-cli-lib/src/console.rs b/wallet/wallet-cli-lib/src/console.rs index 9e7d4b92bc..83bee42d56 100644 --- a/wallet/wallet-cli-lib/src/console.rs +++ b/wallet/wallet-cli-lib/src/console.rs @@ -31,6 +31,14 @@ pub trait ConsoleInput: Send + 'static { pub trait ConsoleOutput: Send + 'static { fn print_line(&mut self, line: &str); + /// Render a structured command payload. The default pretty-prints the value. + fn print_json(&mut self, value: &serde_json::Value) { + match serde_json::to_string_pretty(value) { + Ok(text) => println!("{text}"), + Err(error) => println!(""), + } + } + fn print_error(&mut self, error: WalletCliError); } diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 4926efdb38..bf1be3d17d 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -224,6 +224,13 @@ fn handle_response( Ok(Some(ConsoleCommand::Print(text))) => { console.print_line(&text); } + Ok(Some(ConsoleCommand::Json { text, value })) => match text { + Some(text) => console.print_line(&text), + None => console.print_json(&value), + }, + Ok(Some(ConsoleCommand::Warn(message))) => { + console.print_line(&message); + } Ok(Some(ConsoleCommand::PaginatedPrint { header, body })) => { paginate_output(header, body, line_editor, console, logger) .expect("Should not fail normally"); diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index f10973776f..2a4c7c4efb 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -25,6 +25,11 @@ use crate::{ConsoleInput, cli_event_loop::Event, console::ConsoleOutput, errors: #[derive(Debug)] enum LineOutput { Print(String), + Json { + text: Option, + value: serde_json::Value, + }, + Warn(String), None, Exit, } @@ -52,6 +57,8 @@ fn to_line_output( ) -> Result> { match command_output { ConsoleCommand::Print(text) => Ok(LineOutput::Print(text)), + ConsoleCommand::Json { text, value } => Ok(LineOutput::Json { text, value }), + ConsoleCommand::Warn(message) => Ok(LineOutput::Warn(message)), ConsoleCommand::PaginatedPrint { header, body } => Ok(LineOutput::Print(header + &body)), ConsoleCommand::SetStatus { status: _, @@ -106,6 +113,13 @@ fn handle_response( Ok(LineOutput::Print(text)) => { output.print_line(&text); } + Ok(LineOutput::Json { text, value }) => match text { + Some(text) => output.print_line(&text), + None => output.print_json(&value), + }, + Ok(LineOutput::Warn(message)) => { + output.print_line(&message); + } Ok(LineOutput::None) => {} Ok(LineOutput::Exit) => return Some(Ok(())), @@ -119,3 +133,70 @@ fn handle_response( } None } + +#[cfg(test)] +mod rendering_tests { + use serde_json::json; + + use super::*; + + #[derive(Default)] + struct CapturingOutput { + lines: Vec, + json_values: Vec, + } + + impl ConsoleOutput for CapturingOutput { + fn print_line(&mut self, line: &str) { + self.lines.push(line.to_owned()); + } + + fn print_json(&mut self, value: &serde_json::Value) { + self.json_values.push(value.clone()); + } + + fn print_error(&mut self, _error: WalletCliError) { + unreachable!("errors are not exercised by these tests"); + } + } + + fn render(command: ConsoleCommand) -> CapturingOutput { + let line_output = + to_line_output::(command, "test command") + .expect("rendering must not fail"); + let mut output = CapturingOutput::default(); + handle_response::( + Ok(line_output), + &mut output, + false, + ); + output + } + + #[test] + fn json_payload_with_text_prints_the_text() { + let output = render(ConsoleCommand::Json { + text: Some("peer count: 3".to_owned()), + value: json!(3), + }); + assert_eq!(output.lines, ["peer count: 3"]); + assert!(output.json_values.is_empty()); + } + + #[test] + fn json_payload_without_text_prints_the_value() { + let output = render(ConsoleCommand::Json { + text: None, + value: json!({"key": "value"}), + }); + assert!(output.lines.is_empty()); + assert_eq!(output.json_values, [json!({"key": "value"})]); + } + + #[test] + fn warn_is_printed_as_text() { + let output = render(ConsoleCommand::Warn("heads up".to_owned())); + assert_eq!(output.lines, ["heads up"]); + assert!(output.json_values.is_empty()); + } +} From 3dc86f58bf80912b97565f6fe9c16aaf4c260a61 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 20:36:24 +0200 Subject: [PATCH 02/52] fix(wallet-cli): route payloads through the console abstraction --- wallet/wallet-cli-commands/src/lib.rs | 6 ++--- wallet/wallet-cli-commands/src/output.rs | 25 +++++++++++++++++-- wallet/wallet-cli-lib/src/console.rs | 10 ++++---- .../src/repl/non_interactive/mod.rs | 3 ++- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index e28050735a..5286dd9635 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -1423,13 +1423,13 @@ impl ChoiceMenu for OpenWalletDeviceSelectMenu { pub enum ConsoleCommand { Print(String), /// A structured result: optional human-readable text plus the machine-readable form - /// of the same data. Interactive mode renders the text, falling back to a pretty - /// print of the value; scripting modes emit the value only. + /// of the same data. Both the interactive REPL and the non-interactive runner print + /// the text when present, falling back to a pretty print of the value. Json { text: Option, value: serde_json::Value, }, - /// A non-fatal condition. Scripting modes route it to stderr. + /// A non-fatal condition, printed through the regular output channel. Warn(String), PaginatedPrint { // TODO: add support for more structured data like table pagination diff --git a/wallet/wallet-cli-commands/src/output.rs b/wallet/wallet-cli-commands/src/output.rs index 0e74ace403..502a1585f7 100644 --- a/wallet/wallet-cli-commands/src/output.rs +++ b/wallet/wallet-cli-commands/src/output.rs @@ -20,7 +20,8 @@ use crate::ConsoleCommand; /// The structured result of a wallet command. /// /// `text` is what users read; `payload`, when present, carries the same result in a -/// machine-readable form for scripting modes. +/// machine-readable form. Commands are migrated to this type one by one; a command +/// that has not been migrated keeps returning plain text. #[derive(Debug, Clone)] pub struct CommandOutput { text: String, @@ -44,6 +45,15 @@ impl CommandOutput { } } + /// A result that only has a machine-readable form. Renderers fall back to a + /// pretty print of the value when there is no text. + pub fn value_only(payload: Value) -> Self { + Self { + text: String::new(), + payload: Some(payload), + } + } + pub fn text(&self) -> &str { &self.text } @@ -57,7 +67,7 @@ impl From for ConsoleCommand { fn from(output: CommandOutput) -> Self { match output.payload { Some(value) => ConsoleCommand::Json { - text: Some(output.text), + text: (!output.text.is_empty()).then_some(output.text), value, }, None => ConsoleCommand::Print(output.text), @@ -101,4 +111,15 @@ mod tests { let output = CommandOutput::plain("text"); assert_eq!(output.payload(), None); } + + #[test] + fn value_only_produces_json_output_without_text() { + let command: ConsoleCommand = CommandOutput::value_only(json!("data")).into(); + + let ConsoleCommand::Json { text, value } = command else { + panic!("expected a Json output"); + }; + assert_eq!(text, None); + assert_eq!(value, json!("data")); + } } diff --git a/wallet/wallet-cli-lib/src/console.rs b/wallet/wallet-cli-lib/src/console.rs index 83bee42d56..e32ca0b23c 100644 --- a/wallet/wallet-cli-lib/src/console.rs +++ b/wallet/wallet-cli-lib/src/console.rs @@ -31,12 +31,12 @@ pub trait ConsoleInput: Send + 'static { pub trait ConsoleOutput: Send + 'static { fn print_line(&mut self, line: &str); - /// Render a structured command payload. The default pretty-prints the value. + /// Render a structured command payload. The default pretty-prints the value and + /// routes it through `print_line` so every implementor stays on its own channel. fn print_json(&mut self, value: &serde_json::Value) { - match serde_json::to_string_pretty(value) { - Ok(text) => println!("{text}"), - Err(error) => println!(""), - } + let text = serde_json::to_string_pretty(value) + .unwrap_or_else(|_| value.to_string()); + self.print_line(&text); } fn print_error(&mut self, error: WalletCliError); diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index 2a4c7c4efb..1a5f94a653 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -165,11 +165,12 @@ mod rendering_tests { to_line_output::(command, "test command") .expect("rendering must not fail"); let mut output = CapturingOutput::default(); - handle_response::( + let exit = handle_response::( Ok(line_output), &mut output, false, ); + assert!(exit.is_none(), "rendering a command output must not exit"); output } From c35d260ed911bb2d4f5038c65070d075931ebdc6 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 20:49:00 +0200 Subject: [PATCH 03/52] fix(wallet-cli): render structured output in every console consumer --- node-gui/src/main_window/mod.rs | 15 ++++++- .../src/command_handler/mod.rs | 2 +- wallet/wallet-cli-commands/src/output.rs | 43 +++++++++++++++---- wallet/wallet-cli-lib/src/console.rs | 7 ++- .../src/repl/interactive/mod.rs | 8 ++-- .../src/repl/non_interactive/mod.rs | 24 +++++------ 6 files changed, 66 insertions(+), 33 deletions(-) diff --git a/node-gui/src/main_window/mod.rs b/node-gui/src/main_window/mod.rs index b39dcc3c7a..b9f51091e4 100644 --- a/node-gui/src/main_window/mod.rs +++ b/node-gui/src/main_window/mod.rs @@ -644,7 +644,8 @@ impl MainWindow { status: _, print_message: out, } - | ConsoleCommand::Print(out) => self + | ConsoleCommand::Print(out) + | ConsoleCommand::Warn(out) => self .main_widget .update( MainWidgetMessage::TabsMessage(TabsMessage::WalletMessage( @@ -654,6 +655,18 @@ impl MainWindow { backend_sender, ) .map(MainWindowMessage::MainWidgetMessage), + ConsoleCommand::Json { text, value } => self + .main_widget + .update( + MainWidgetMessage::TabsMessage(TabsMessage::WalletMessage( + wallet_id, + WalletMessage::ConsoleOutput( + wallet_cli_commands::output::json_display_text(text, &value), + ), + )), + backend_sender, + ) + .map(MainWindowMessage::MainWidgetMessage), ConsoleCommand::PaginatedPrint { header, body } => self .main_widget .update( diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 6584bfaa1e..3bc190f279 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -2022,7 +2022,7 @@ where Ok(ConsoleCommand::from( crate::output::CommandOutput::with_payload( peer_count.to_string(), - serde_json::json!(peer_count), + serde_json::json!({ "peer_count": peer_count }), ), )) } diff --git a/wallet/wallet-cli-commands/src/output.rs b/wallet/wallet-cli-commands/src/output.rs index 502a1585f7..b145109bbd 100644 --- a/wallet/wallet-cli-commands/src/output.rs +++ b/wallet/wallet-cli-commands/src/output.rs @@ -24,7 +24,7 @@ use crate::ConsoleCommand; /// that has not been migrated keeps returning plain text. #[derive(Debug, Clone)] pub struct CommandOutput { - text: String, + text: Option, payload: Option, } @@ -32,7 +32,7 @@ impl CommandOutput { /// A result that only has a human-readable form. pub fn plain(text: impl Into) -> Self { Self { - text: text.into(), + text: Some(text.into()), payload: None, } } @@ -40,7 +40,7 @@ impl CommandOutput { /// A result that also carries a machine-readable form of the same data. pub fn with_payload(text: impl Into, payload: Value) -> Self { Self { - text: text.into(), + text: Some(text.into()), payload: Some(payload), } } @@ -49,13 +49,13 @@ impl CommandOutput { /// pretty print of the value when there is no text. pub fn value_only(payload: Value) -> Self { Self { - text: String::new(), + text: None, payload: Some(payload), } } - pub fn text(&self) -> &str { - &self.text + pub fn text(&self) -> Option<&str> { + self.text.as_deref() } pub fn payload(&self) -> Option<&Value> { @@ -67,14 +67,23 @@ impl From for ConsoleCommand { fn from(output: CommandOutput) -> Self { match output.payload { Some(value) => ConsoleCommand::Json { - text: (!output.text.is_empty()).then_some(output.text), + text: output.text, value, }, - None => ConsoleCommand::Print(output.text), + None => ConsoleCommand::Print(output.text.unwrap_or_default()), } } } +/// The text a text-oriented renderer (REPLs, GUI console) shows for a structured +/// payload: the human-readable text when present, else a pretty print of the value. +pub fn json_display_text(text: Option, value: &Value) -> String { + match text { + Some(text) => text, + None => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()), + } +} + #[cfg(test)] mod tests { use serde_json::json; @@ -105,11 +114,15 @@ mod tests { #[test] fn accessors_return_the_stored_data() { let output = CommandOutput::with_payload("text", json!({"key": 1})); - assert_eq!(output.text(), "text"); + assert_eq!(output.text(), Some("text")); assert_eq!(output.payload(), Some(&json!({"key": 1}))); let output = CommandOutput::plain("text"); assert_eq!(output.payload(), None); + + let output = CommandOutput::value_only(json!("data")); + assert_eq!(output.text(), None); + assert_eq!(output.payload(), Some(&json!("data"))); } #[test] @@ -122,4 +135,16 @@ mod tests { assert_eq!(text, None); assert_eq!(value, json!("data")); } + + #[test] + fn display_text_prefers_the_text_and_falls_back_to_the_value() { + assert_eq!( + json_display_text(Some("peer count: 3".to_owned()), &json!(3)), + "peer count: 3" + ); + assert_eq!( + json_display_text(None, &json!({"a": 1})), + "{\n \"a\": 1\n}" + ); + } } diff --git a/wallet/wallet-cli-lib/src/console.rs b/wallet/wallet-cli-lib/src/console.rs index e32ca0b23c..a26e121bf4 100644 --- a/wallet/wallet-cli-lib/src/console.rs +++ b/wallet/wallet-cli-lib/src/console.rs @@ -31,11 +31,10 @@ pub trait ConsoleInput: Send + 'static { pub trait ConsoleOutput: Send + 'static { fn print_line(&mut self, line: &str); - /// Render a structured command payload. The default pretty-prints the value and - /// routes it through `print_line` so every implementor stays on its own channel. + /// Render a structured command payload, routing it through `print_line` so every + /// implementor stays on its own channel. fn print_json(&mut self, value: &serde_json::Value) { - let text = serde_json::to_string_pretty(value) - .unwrap_or_else(|_| value.to_string()); + let text = wallet_cli_commands::output::json_display_text(None, value); self.print_line(&text); } diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index bf1be3d17d..cba21b3c3c 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -224,10 +224,10 @@ fn handle_response( Ok(Some(ConsoleCommand::Print(text))) => { console.print_line(&text); } - Ok(Some(ConsoleCommand::Json { text, value })) => match text { - Some(text) => console.print_line(&text), - None => console.print_json(&value), - }, + Ok(Some(ConsoleCommand::Json { text, value })) => { + let text = wallet_cli_commands::output::json_display_text(text, &value); + console.print_line(&text); + } Ok(Some(ConsoleCommand::Warn(message))) => { console.print_line(&message); } diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index 1a5f94a653..63739ed9b9 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -113,10 +113,10 @@ fn handle_response( Ok(LineOutput::Print(text)) => { output.print_line(&text); } - Ok(LineOutput::Json { text, value }) => match text { - Some(text) => output.print_line(&text), - None => output.print_json(&value), - }, + Ok(LineOutput::Json { text, value }) => { + let text = wallet_cli_commands::output::json_display_text(text, &value); + output.print_line(&text); + } Ok(LineOutput::Warn(message)) => { output.print_line(&message); } @@ -143,7 +143,6 @@ mod rendering_tests { #[derive(Default)] struct CapturingOutput { lines: Vec, - json_values: Vec, } impl ConsoleOutput for CapturingOutput { @@ -151,10 +150,6 @@ mod rendering_tests { self.lines.push(line.to_owned()); } - fn print_json(&mut self, value: &serde_json::Value) { - self.json_values.push(value.clone()); - } - fn print_error(&mut self, _error: WalletCliError) { unreachable!("errors are not exercised by these tests"); } @@ -181,23 +176,24 @@ mod rendering_tests { value: json!(3), }); assert_eq!(output.lines, ["peer count: 3"]); - assert!(output.json_values.is_empty()); } #[test] - fn json_payload_without_text_prints_the_value() { + fn json_payload_without_text_prints_the_pretty_value() { let output = render(ConsoleCommand::Json { text: None, value: json!({"key": "value"}), }); - assert!(output.lines.is_empty()); - assert_eq!(output.json_values, [json!({"key": "value"})]); + assert_eq!(output.lines.len(), 1); + assert_eq!( + output.lines[0], + serde_json::to_string_pretty(&json!({"key": "value"})).expect("static value") + ); } #[test] fn warn_is_printed_as_text() { let output = render(ConsoleCommand::Warn("heads up".to_owned())); assert_eq!(output.lines, ["heads up"]); - assert!(output.json_values.is_empty()); } } From 537941f2e2158511014837a53fc69053177b4444 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 21:04:46 +0200 Subject: [PATCH 04/52] refactor(wallet-cli): drop the unused payload rendering hook --- wallet/wallet-cli-commands/src/output.rs | 13 ++++++++++++- wallet/wallet-cli-lib/src/console.rs | 7 ------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/wallet/wallet-cli-commands/src/output.rs b/wallet/wallet-cli-commands/src/output.rs index b145109bbd..5e3d40ac65 100644 --- a/wallet/wallet-cli-commands/src/output.rs +++ b/wallet/wallet-cli-commands/src/output.rs @@ -67,7 +67,7 @@ impl From for ConsoleCommand { fn from(output: CommandOutput) -> Self { match output.payload { Some(value) => ConsoleCommand::Json { - text: output.text, + text: output.text.filter(|text| !text.is_empty()), value, }, None => ConsoleCommand::Print(output.text.unwrap_or_default()), @@ -136,6 +136,17 @@ mod tests { assert_eq!(value, json!("data")); } + #[test] + fn empty_text_is_treated_as_absent() { + let command: ConsoleCommand = CommandOutput::with_payload("", json!(3)).into(); + + let ConsoleCommand::Json { text, value } = command else { + panic!("expected a Json output"); + }; + assert_eq!(text, None); + assert_eq!(value, json!(3)); + } + #[test] fn display_text_prefers_the_text_and_falls_back_to_the_value() { assert_eq!( diff --git a/wallet/wallet-cli-lib/src/console.rs b/wallet/wallet-cli-lib/src/console.rs index a26e121bf4..9e7d4b92bc 100644 --- a/wallet/wallet-cli-lib/src/console.rs +++ b/wallet/wallet-cli-lib/src/console.rs @@ -31,13 +31,6 @@ pub trait ConsoleInput: Send + 'static { pub trait ConsoleOutput: Send + 'static { fn print_line(&mut self, line: &str); - /// Render a structured command payload, routing it through `print_line` so every - /// implementor stays on its own channel. - fn print_json(&mut self, value: &serde_json::Value) { - let text = wallet_cli_commands::output::json_display_text(None, value); - self.print_line(&text); - } - fn print_error(&mut self, error: WalletCliError); } From 06e5bd456f18e69c73f64ce25d6d67fd0b96aec4 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 21:15:50 +0200 Subject: [PATCH 05/52] feat(wallet-cli): render node and chain data in human-readable form --- .../src/command_handler/mod.rs | 109 ++++++++++++++++-- .../src/repl/non_interactive/mod.rs | 9 +- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 3bc190f279..7d481e5a82 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -20,21 +20,23 @@ use std::{collections::BTreeMap, fmt::Write, str::FromStr}; use bigdecimal::BigDecimal; use itertools::Itertools; +use chainstate::ChainInfo; use chainstate::rpc::RpcOutputValueOut; use common::{ address::{Address, RpcAddress}, chain::{ - ChainConfig, Destination, RpcCurrency, SignedTransaction, + ChainConfig, Destination, RpcCurrency, SignedTransaction, block::timestamp::BlockTimestamp, config::checkpoints_data::print_block_heights_ids_as_checkpoints_data, htlc::HtlcSecret, - tokens::TokenId, + tokens::TokenId, transaction::Transaction, }, - primitives::{H256, Idable as _}, + primitives::{BlockHeight, H256, Id, Idable as _}, text_summary::TextSummary, }; use crypto::key::hdkd::u31::U31; use logging::log; use mempool::tx_options::TxOptionsOverrides; use node_comm::node_traits::NodeInterface; +use p2p_types::socket_address::SocketAddress; use rpc::types::RpcHexString; use serialization::{hex::HexEncode, hex_encoded::HexEncoded}; use utils::{ @@ -42,7 +44,7 @@ use utils::{ qrcode::{QrCode, QrCodeError}, sorted::Sorted as _, }; -use wallet_controller::types::WalletExtraInfo; +use wallet_controller::{ConnectedPeer, types::WalletExtraInfo}; use wallet_rpc_client::wallet_rpc_traits::{PartialOrSignedTx, WalletInterface}; #[cfg(any(feature = "trezor", feature = "ledger"))] use wallet_rpc_lib::types::HardwareWalletType; @@ -891,7 +893,7 @@ where WalletCommand::ChainstateInfo => { let info = self.wallet().await?.chainstate_info().await?; - Ok(ConsoleCommand::Print(format!("{info:#?}"))) + Ok(ConsoleCommand::Print(format_chainstate_info(&info))) } WalletCommand::BestBlock => { @@ -959,7 +961,9 @@ where check_all_timestamps_between_blocks.to_bool(), ) .await?; - Ok(ConsoleCommand::Print(format!("{timestamp_map:#?}"))) + Ok(ConsoleCommand::Print(format_staking_timestamps( + ×tamp_map, + ))) } WalletCommand::GetBlockIdsAsCheckpoints { @@ -1435,10 +1439,10 @@ where WalletCommand::ListPendingTransactions => { let (wallet, selected_account) = wallet_and_selected_acc(&mut self.wallet).await?; - let utxos = wallet.list_pending_transactions(selected_account).await?; + let transactions = wallet.list_pending_transactions(selected_account).await?; Ok(ConsoleCommand::PaginatedPrint { - header: "Pending Transactions".to_owned(), - body: format!("{utxos:#?}"), + header: "Pending transactions".to_owned(), + body: format_pending_transaction_ids(&transactions), }) } @@ -2028,7 +2032,7 @@ where } WalletCommand::ConnectedPeers => { let peers = self.wallet().await?.connected_peers().await?; - Ok(ConsoleCommand::Print(format!("{peers:#?}"))) + Ok(ConsoleCommand::Print(format_connected_peers(&peers))) } WalletCommand::ConnectedPeersJson => { let peers = self.wallet().await?.connected_peers().await?; @@ -2037,7 +2041,7 @@ where } WalletCommand::ReservedPeers => { let peers = self.wallet().await?.reserved_peers().await?; - Ok(ConsoleCommand::Print(format!("{peers:#?}"))) + Ok(ConsoleCommand::Print(format_reserved_peers(&peers))) } WalletCommand::AddReservedPeer { address } => { self.wallet().await?.add_reserved_peer(address).await?; @@ -2522,3 +2526,86 @@ fn get_token_ids_from_order_infos_as_str<'a>( .map(|rpc_addr| rpc_addr.as_str().to_owned()) .collect_vec() } + +fn format_chainstate_info(info: &ChainInfo) -> String { + format!( + "Chainstate info:\n\ + \x20 Best block height: {}\n\ + \x20 Best block id: {}\n\ + \x20 Best block timestamp: {} ({})\n\ + \x20 Median time: {} ({})\n\ + \x20 Initial block download: {}", + info.best_block_height, + info.best_block_id, + info.best_block_timestamp, + info.best_block_timestamp.into_time(), + info.median_time, + info.median_time.into_time(), + info.is_initial_block_download, + ) +} + +fn format_staking_timestamps(timestamps: &BTreeMap>) -> String { + if timestamps.is_empty() { + return "No staking timestamps found".to_owned(); + } + + let mut output = String::from("Staking timestamps by block height:"); + for (height, block_timestamps) in timestamps { + let rendered = block_timestamps + .iter() + .map(|timestamp| format!("{} ({})", timestamp, timestamp.into_time())) + .collect::>() + .join(", "); + write!(output, "\n {}: {}", height, rendered).expect("writing to a string cannot fail"); + } + output +} + +fn format_pending_transaction_ids(transactions: &[Id]) -> String { + if transactions.is_empty() { + return "No pending transactions".to_owned(); + } + + transactions + .iter() + .map(|id| format!(" {}", id_to_hex_string(*id.as_hash()))) + .join("\n") +} + +fn format_connected_peers(peers: &[ConnectedPeer]) -> String { + if peers.is_empty() { + return "No connected peers".to_owned(); + } + + let mut table = prettytable::Table::new(); + table.set_titles(prettytable::row![ + "Peer id", + "Address", + "Role", + "Ban score", + "User agent", + ]); + table.extend(peers.iter().map(|peer| { + prettytable::row![ + peer.peer_id, + peer.address, + format!("{:?}", peer.peer_role), + peer.ban_score, + peer.user_agent, + ] + })); + table.to_string() +} + +fn format_reserved_peers(peers: &[SocketAddress]) -> String { + if peers.is_empty() { + return "No reserved peers".to_owned(); + } + + let mut output = String::from("Reserved peers:"); + for (index, address) in peers.iter().enumerate() { + write!(output, "\n {}. {}", index + 1, address).expect("writing to a string cannot fail"); + } + output +} diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index 63739ed9b9..a4674ef15e 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -59,7 +59,14 @@ fn to_line_output( ConsoleCommand::Print(text) => Ok(LineOutput::Print(text)), ConsoleCommand::Json { text, value } => Ok(LineOutput::Json { text, value }), ConsoleCommand::Warn(message) => Ok(LineOutput::Warn(message)), - ConsoleCommand::PaginatedPrint { header, body } => Ok(LineOutput::Print(header + &body)), + ConsoleCommand::PaginatedPrint { header, body } => { + let rendered = match (header.is_empty(), body.is_empty()) { + (true, _) => body, + (false, true) => header, + (false, false) => format!("{header}\n{body}"), + }; + Ok(LineOutput::Print(rendered)) + } ConsoleCommand::SetStatus { status: _, print_message, From 133a31c3c081ae5098eac2668f3f09e77be6d36f Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 21:40:44 +0200 Subject: [PATCH 06/52] fix(wallet-cli): keep the pending transaction list machine parseable --- test/functional/test_framework/wallet_cli_controller.py | 3 +-- wallet/wallet-cli-commands/src/lib.rs | 3 ++- wallet/wallet-cli-commands/src/output.rs | 8 +++----- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index 828fb08225..9c072bf3d4 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -555,8 +555,7 @@ async def get_balance(self, with_locked: str = 'unlocked', utxo_states: List[str async def list_pending_transactions(self) -> List[str]: output = await self._write_command("transaction-list-pending\n") - pattern = r'Id\{([^}]*)\}' - return re.findall(pattern, output) + return re.findall(r'^\s*([0-9a-f]{64})\s*$', output, re.MULTILINE) async def list_transactions_by_address(self, address: Optional[str] = None, limit: int = 100) -> List[str]: address = address if address else '' diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 5286dd9635..7c65eb2ae1 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -1429,7 +1429,8 @@ pub enum ConsoleCommand { text: Option, value: serde_json::Value, }, - /// A non-fatal condition, printed through the regular output channel. + /// A non-fatal condition, printed through the regular output channel for now; + /// consumers that render to a separate warning stream may route it to stderr. Warn(String), PaginatedPrint { // TODO: add support for more structured data like table pagination diff --git a/wallet/wallet-cli-commands/src/output.rs b/wallet/wallet-cli-commands/src/output.rs index 5e3d40ac65..4af6e807ff 100644 --- a/wallet/wallet-cli-commands/src/output.rs +++ b/wallet/wallet-cli-commands/src/output.rs @@ -65,12 +65,10 @@ impl CommandOutput { impl From for ConsoleCommand { fn from(output: CommandOutput) -> Self { + let text = output.text.filter(|text| !text.is_empty()); match output.payload { - Some(value) => ConsoleCommand::Json { - text: output.text.filter(|text| !text.is_empty()), - value, - }, - None => ConsoleCommand::Print(output.text.unwrap_or_default()), + Some(value) => ConsoleCommand::Json { text, value }, + None => ConsoleCommand::Print(text.unwrap_or_default()), } } } From 842d2c6a50b4519250d6a1cdb6209bfd922b1ec0 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 22:17:32 +0200 Subject: [PATCH 07/52] feat(wallet-cli): add nested command groups for wallet data --- .../src/command_handler/mod.rs | 177 +++++++++- wallet/wallet-cli-commands/src/lib.rs | 315 +++++++++++++++++- 2 files changed, 483 insertions(+), 9 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 7d481e5a82..7e62b25980 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -59,14 +59,6 @@ use wallet_types::partially_signed_transaction::PartiallySignedTransaction; #[cfg(feature = "trezor")] use crate::{CreateWalletDeviceSelectMenu, OpenWalletDeviceSelectMenu}; -use crate::{ - ManageableWalletCommand, OpenWalletSubCommand, WalletManagementCommand, - errors::WalletCliCommandError, - helper_types::{ - CliCurrency, active_order_infos_header, format_token_name, token_ticker_from_rpc_token_info, - }, -}; - use self::local_state::WalletWithState; use super::{ @@ -76,6 +68,15 @@ use super::{ format_delegation_info, format_own_order_info, format_pool_info, }, }; +use crate::{ + AccountGroupSubCommand, AddressGroupSubCommand, CliPublicKeyFormat, DataGroupSubCommand, + ManageableWalletCommand, OpenWalletSubCommand, StandaloneGroupSubCommand, + WalletGroupSubCommand, WalletManagementCommand, + errors::WalletCliCommandError, + helper_types::{ + CliCurrency, active_order_infos_header, format_token_name, token_ticker_from_rpc_token_info, + }, +}; pub struct CommandHandler { config: ControllerConfig, @@ -883,6 +884,36 @@ where WalletCommand::ColdCommands(command) => { self.handle_cold_wallet_command(command, chain_config).await } + WalletCommand::Wallet { command } => { + let management = match command { + WalletGroupSubCommand::Create { wallet } => { + WalletManagementCommand::CreateWallet { wallet } + } + WalletGroupSubCommand::Recover { wallet } => { + WalletManagementCommand::RecoverWallet { wallet } + } + WalletGroupSubCommand::Open { wallet } => { + WalletManagementCommand::OpenWallet { wallet } + } + WalletGroupSubCommand::Close => WalletManagementCommand::CloseWallet, + }; + self.handle_wallet_management_command(management).await + } + command @ (WalletCommand::Account { .. } + | WalletCommand::Address { .. } + | WalletCommand::Send { .. } + | WalletCommand::Data { .. }) => { + // Nested groups translate to their flat equivalents. The recursive call + // is boxed to keep the future size finite; translation always produces a + // flat command, so the recursion never goes deeper than one level. + Box::pin( + self.handle_wallet_command( + chain_config, + normalize_nested_wallet_command(command), + ), + ) + .await + } WalletCommand::ConfigBroadcast { broadcast } => { self.config.broadcast_to_mempool = broadcast.to_bool(); @@ -2441,6 +2472,136 @@ where } } +/// Translate the nested command grammar (`account select`, `address qr`, `send`, ...) +/// into the flat commands that drive the implementation, so every command has exactly +/// one implementation regardless of how it was spelled. The `wallet` group is handled +/// at the dispatch site because it maps onto the wallet management commands. +fn normalize_nested_wallet_command(command: WalletCommand) -> WalletCommand { + match command { + WalletCommand::Account { command } => match command { + AccountGroupSubCommand::Create { name } => WalletCommand::CreateNewAccount { name }, + AccountGroupSubCommand::Rename { name } => WalletCommand::RenameAccount { name }, + AccountGroupSubCommand::Select { account_index } => { + WalletCommand::SelectAccount { account_index } + } + AccountGroupSubCommand::Utxos { + utxo_type, + with_locked, + utxo_states, + } => WalletCommand::ListUtxo { + utxo_type, + with_locked, + utxo_states, + }, + AccountGroupSubCommand::Balance { + with_locked, + utxo_states, + } => WalletCommand::GetBalance { + with_locked, + utxo_states, + }, + AccountGroupSubCommand::ShowXpub => { + WalletCommand::ColdCommands(ColdWalletCommand::AccountExtendedPublicKey) + } + }, + WalletCommand::Address { command } => { + match command { + AddressGroupSubCommand::New => { + WalletCommand::ColdCommands(ColdWalletCommand::NewAddress) + } + AddressGroupSubCommand::List { include_change } => { + WalletCommand::ColdCommands(ColdWalletCommand::ShowAddresses { include_change }) + } + AddressGroupSubCommand::Qr { address } => { + WalletCommand::ColdCommands(ColdWalletCommand::AddressQRCode { address }) + } + AddressGroupSubCommand::RevealPublicKey { + public_key_hash, + format, + } => WalletCommand::ColdCommands(match format { + CliPublicKeyFormat::Hex => { + ColdWalletCommand::RevealPublicKeyHex { public_key_hash } + } + CliPublicKeyFormat::Address => { + ColdWalletCommand::RevealPublicKey { public_key_hash } + } + }), + AddressGroupSubCommand::Sweep { + destination_address, + addresses, + all, + } => WalletCommand::SweepFromAddress { + destination_address, + addresses, + all, + }, + AddressGroupSubCommand::Standalone { command } => match command { + StandaloneGroupSubCommand::RenameLabel { address, label } => { + WalletCommand::StandaloneAddressLabelRename { address, label } + } + StandaloneGroupSubCommand::Add { + address, + label, + no_rescan, + } => WalletCommand::AddStandaloneKey { + address, + label, + no_rescan, + }, + StandaloneGroupSubCommand::AddPrivateKey { + hex_private_key, + label, + no_rescan, + } => WalletCommand::AddStandalonePrivateKey { + hex_private_key, + label, + no_rescan, + }, + StandaloneGroupSubCommand::AddMultisig { + min_required_signatures, + public_keys, + label, + no_rescan, + } => WalletCommand::AddStandaloneMultisig { + min_required_signatures, + public_keys, + label, + no_rescan, + }, + StandaloneGroupSubCommand::List => { + WalletCommand::ColdCommands(ColdWalletCommand::ShowStandaloneAddresses) + } + StandaloneGroupSubCommand::Details { address } => WalletCommand::ColdCommands( + ColdWalletCommand::ShowStandaloneAddressDetails { address }, + ), + StandaloneGroupSubCommand::Utxos { + utxo_type, + with_locked, + utxo_states, + } => WalletCommand::ListMultisigUtxo { + utxo_type, + with_locked, + utxo_states, + }, + }, + } + } + WalletCommand::Send { + address, + amount, + utxos, + } => WalletCommand::SendToAddress { + address, + amount, + utxos, + }, + WalletCommand::Data { command } => match command { + DataGroupSubCommand::Deposit { hex_data } => WalletCommand::DepositData { hex_data }, + }, + command => command, + } +} + fn format_tx_to_be_broadcasted( tx: HexEncoded, fees: &Balances, diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 7c65eb2ae1..16e27389f4 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -20,7 +20,7 @@ pub mod output; use std::{collections::BTreeMap, fmt::Debug, num::NonZeroUsize, path::PathBuf, time::Duration}; -use clap::{Command, FromArgMatches, Parser, Subcommand}; +use clap::{Command, FromArgMatches, Parser, Subcommand, ValueEnum}; use dyn_clone::DynClone; use regex::Regex; @@ -282,6 +282,246 @@ pub enum OpenWalletSubCommand { }, } +/// The nested wallet management grammar: `wallet create|recover|open|close`. +/// Each subcommand delegates to the same implementation as its flat legacy +/// counterpart (`wallet-create`, `wallet-recover`, ...). +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum WalletGroupSubCommand { + /// Create a new wallet. This will create a new file without scanning the blockchain. + Create { + #[command(subcommand)] + wallet: CreateWalletSubCommand, + }, + /// Recover a wallet. This will create a new wallet file and scan the blockchain for + /// associated transactions. + Recover { + #[command(subcommand)] + wallet: RecoverWalletSubCommand, + }, + /// Open an existing wallet file. + Open { + #[command(subcommand)] + wallet: OpenWalletSubCommand, + }, + /// Close the wallet. + Close, +} + +/// The nested account grammar: `account create|rename|select|utxos|balance|show-xpub`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum AccountGroupSubCommand { + /// Create a new account. + Create { name: Option }, + /// Rename the selected account. + Rename { name: Option }, + /// Switch to a given wallet account. + Select { account_index: U31 }, + /// List the utxos of the selected account. + Utxos { + /// The type of utxos to be listed. + #[arg(value_enum, default_value_t = CliUtxoTypes::All)] + utxo_type: CliUtxoTypes, + /// Whether to include locked outputs. + #[arg(value_enum, default_value_t = CliWithLocked::Unlocked)] + with_locked: CliWithLocked, + /// The state of the utxos. + #[arg(default_values_t = vec![CliUtxoState::Confirmed])] + utxo_states: Vec, + }, + /// Show the balance of the selected account. + Balance { + /// Whether to include locked outputs (outputs that cannot be spend and need time to + /// mature) + #[arg(value_enum, default_value_t = CliWithLocked::Unlocked)] + with_locked: CliWithLocked, + /// The state of utxos to be included (confirmed, unconfirmed, etc) + #[arg(default_values_t = vec![CliUtxoState::Confirmed])] + utxo_states: Vec, + }, + /// Show the extended public key of the selected account as hex. + ShowXpub, +} + +/// The encoding of a revealed public key. +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq)] +pub enum CliPublicKeyFormat { + Hex, + Address, +} + +/// The nested standalone address grammar: +/// `address standalone rename-label|add|add-private-key|add-multisig|list|details|utxos`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum StandaloneGroupSubCommand { + /// Rename the label of an existing standalone address; omitting --label removes it. + RenameLabel { + /// The existing standalone address + address: String, + /// Optionally specify a new label, not specifying a label will remove the existing one + #[arg(long = "label")] + label: Option, + }, + /// Add a new standalone watch only address to the selected account. + Add { + /// The new standalone watch only address to be added to the selected account + address: String, + /// Optionally specify a label to the new address + #[arg(long = "label")] + label: Option, + /// Skip the rescanning of the blockchain + #[arg(long = "no-rescan")] + no_rescan: Option, + }, + /// Add a new hex-encoded standalone private key to the selected account. + AddPrivateKey { + /// The new hex-encoded standalone private key to be added to the selected account + hex_private_key: HexEncoded, + /// Optionally specify a label to the new address + #[arg(long = "label")] + label: Option, + /// Skip the rescanning of the blockchain + #[arg(long = "no-rescan")] + no_rescan: Option, + }, + /// Create a new standalone multisig address. + AddMultisig { + /// The minimum required signatures out of the specified public keys + min_required_signatures: u8, + /// Public keys from which to create the multisig challenge + public_keys: Vec, + /// Optionally specify a label to the new address + #[arg(long = "label")] + label: Option, + /// Skip the rescanning of the blockchain + #[arg(long = "no-rescan")] + no_rescan: Option, + }, + /// List the standalone addresses of the selected account. + List, + /// Show the details of a standalone address. + Details { + /// The standalone address to inspect + address: String, + }, + /// List the utxos owned by the standalone multisig addresses. + Utxos { + /// The type of utxos to be listed. + #[arg(value_enum, default_value_t = CliUtxoTypes::All)] + utxo_type: CliUtxoTypes, + /// Whether to include locked outputs. + #[arg(value_enum, default_value_t = CliWithLocked::Unlocked)] + with_locked: CliWithLocked, + /// The state of the utxos. + #[arg(default_values_t = vec![CliUtxoState::Confirmed])] + utxo_states: Vec, + }, +} + +/// The nested address grammar: +/// `address new|list|qr|reveal-public-key|sweep|standalone`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum AddressGroupSubCommand { + /// Generate a new address. + New, + /// List the wallet addresses. + List { + /// Include the change addresses along with the receiving addresses + #[arg(long = "include-change")] + include_change: bool, + }, + /// Show a QR code of the provided address. + Qr { address: String }, + /// Reveal the public key behind the specified "public key hash" address. + RevealPublicKey { + public_key_hash: String, + /// The encoding of the revealed key (default: address). + /// Note that the address encoding isn't a normal address to be used in transactions. + /// It's preferred to take the address from `address list`. + #[arg(long = "format", value_enum, default_value_t = CliPublicKeyFormat::Address)] + format: CliPublicKeyFormat, + }, + /// Sweep all spendable coins or tokens from the specified (or all) addresses to the + /// given destination address. + Sweep { + /// The receiving address of the coins or tokens + destination_address: String, + /// The addresses to be swept. Mutually exclusive with --all. + /// + /// If --all is not specified, this has to contain at least one address. + #[arg(required_unless_present("all"))] + addresses: Vec, + /// Sweep all addresses + #[arg(long = "all", default_value_t = false, conflicts_with_all(["addresses"]))] + all: bool, + }, + /// Manage standalone (non-account) addresses. + Standalone { + #[command(subcommand)] + command: StandaloneGroupSubCommand, + }, +} + +/// The nested data carrier grammar: `data deposit`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum DataGroupSubCommand { + /// Deposit arbitrary data on the blockchain. + Deposit { + /// The data to be deposited on the blockchain as hex. DO NOT start the data with 0x. + hex_data: String, + }, +} + +/// Mapping between the legacy flat command names and their nested equivalents. +/// The flat names remain first-class commands; this manifest documents the +/// correspondence and drives the grammar compatibility test. +pub const LEGACY_ALIASES: &[(&str, &str)] = &[ + ("wallet-create", "wallet create"), + ("wallet-recover", "wallet recover"), + ("wallet-open", "wallet open"), + ("wallet-close", "wallet close"), + ("account-create", "account create"), + ("account-rename", "account rename"), + ("account-select", "account select"), + ("account-utxos", "account utxos"), + ("account-balance", "account balance"), + ("account-extended-public-key-as-hex", "account show-xpub"), + ("address-new", "address new"), + ("address-show", "address list"), + ("address-qrcode", "address qr"), + ( + "address-reveal-public-key-as-hex", + "address reveal-public-key --format hex", + ), + ( + "address-reveal-public-key-as-address", + "address reveal-public-key", + ), + ("address-send", "send"), + ("address-sweep-spendable", "address sweep"), + ("address-deposit-data", "data deposit"), + ( + "standalone-address-label-rename", + "address standalone rename-label", + ), + ( + "standalone-add-watch-only-address", + "address standalone add", + ), + ( + "standalone-add-private-key-from-hex", + "address standalone add-private-key", + ), + ("standalone-add-multisig", "address standalone add-multisig"), + ("standalone-address-show", "address standalone list"), + ("standalone-address-details", "address standalone details"), + ("standalone-multisig-utxos", "address standalone utxos"), +]; + #[derive(Debug, Parser)] #[clap(rename_all = "kebab-case")] pub enum WalletManagementCommand { @@ -325,6 +565,7 @@ pub enum WalletManagementCommand { RpcShutdownAndExit, /// Exit the wallet + #[clap(visible_alias = "shutdown")] Exit, } @@ -496,6 +737,51 @@ pub enum WalletCommand { broadcast: CliYesNo, }, + /// Wallet management commands (create, recover, open, close). + /// The flat commands (`wallet-create`, `wallet-recover`, ...) remain available. + #[clap(name = "wallet")] + Wallet { + #[command(subcommand)] + command: WalletGroupSubCommand, + }, + + /// Account commands (create, rename, select, utxos, balance, show-xpub). + /// The flat commands (`account-create`, `account-utxos`, ...) remain available. + #[clap(name = "account")] + Account { + #[command(subcommand)] + command: AccountGroupSubCommand, + }, + + /// Address commands (new, list, qr, reveal-public-key, sweep, standalone). + /// The flat commands (`address-new`, `address-show`, ...) remain available. + #[clap(name = "address")] + Address { + #[command(subcommand)] + command: AddressGroupSubCommand, + }, + + /// Send coins to an address (same as `address-send`). + #[clap(name = "send")] + Send { + /// The receiving address of the coins + address: String, + /// The amount to be sent, in decimal format + amount: DecimalAmount, + /// You can choose what utxos to spend (space separated as additional arguments). + /// A utxo can be from a transaction output or a block reward output: + /// e.g tx(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,1) or + /// block(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,2) + utxos: Vec, + }, + + /// Data carrier commands (deposit). + #[clap(name = "data")] + Data { + #[command(subcommand)] + command: DataGroupSubCommand, + }, + #[clap(name = "account-create")] CreateNewAccount { name: Option }, @@ -1625,6 +1911,33 @@ mod tests { logging::init_logging(); } + #[test] + fn legacy_command_names_and_group_grammar_coexist() { + let mut repl = get_repl_command(false, true); + repl.build(); + + // Every legacy flat command must still exist at the top level. + for (legacy, _nested) in LEGACY_ALIASES { + assert!( + repl.find_subcommand(legacy).is_some(), + "legacy command `{legacy}` is missing from the repl command tree" + ); + } + + // Every nested path must exist under its groups. Options inside a nested path + // are not part of the structural check, so everything from the first option on + // is dropped before the walk. + for (_legacy, nested) in LEGACY_ALIASES { + let structural_path = nested.split(" --").next().unwrap_or(nested); + let mut level = &repl; + for name in structural_path.split_whitespace() { + level = level.find_subcommand(name).unwrap_or_else(|| { + panic!("nested path `{nested}` breaks at `{name}` in the repl command tree") + }); + } + } + } + #[rstest] fn ensure_commands_have_description( #[values(false, true)] cold_wallet: bool, From aede7782d418d8703f3b2b33306847b0d6a98df0 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 22:40:43 +0200 Subject: [PATCH 08/52] test(wallet-cli): pin the nested command translation --- .../src/command_handler/mod.rs | 157 +++++++++++++++++- wallet/wallet-cli-commands/src/lib.rs | 36 ++++ 2 files changed, 187 insertions(+), 6 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 7e62b25980..13c440a47a 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -906,13 +906,19 @@ where // Nested groups translate to their flat equivalents. The recursive call // is boxed to keep the future size finite; translation always produces a // flat command, so the recursion never goes deeper than one level. - Box::pin( - self.handle_wallet_command( - chain_config, - normalize_nested_wallet_command(command), + let normalized = normalize_nested_wallet_command(command); + debug_assert!( + !matches!( + normalized, + WalletCommand::Wallet { .. } + | WalletCommand::Account { .. } + | WalletCommand::Address { .. } + | WalletCommand::Send { .. } + | WalletCommand::Data { .. } ), - ) - .await + "translation must produce a flat command, otherwise this recursion is unbounded" + ); + Box::pin(self.handle_wallet_command(chain_config, normalized)).await } WalletCommand::ConfigBroadcast { broadcast } => { @@ -2770,3 +2776,142 @@ fn format_reserved_peers(peers: &[SocketAddress]) -> String { } output } + +#[cfg(test)] +mod nested_translation_tests { + use super::*; + use clap::Parser; + + fn flat_debug(argv: &[&str]) -> String { + let command = WalletCommand::try_parse_from(argv).expect("argv must parse"); + let flat = normalize_nested_wallet_command(command); + assert!( + !matches!( + flat, + WalletCommand::Wallet { .. } + | WalletCommand::Account { .. } + | WalletCommand::Address { .. } + | WalletCommand::Send { .. } + | WalletCommand::Data { .. } + ), + "translation must produce a flat command" + ); + format!("{flat:?}") + } + + #[test] + fn nested_spellings_translate_to_the_legacy_flat_commands() { + const HEX_PRIVATE_KEY: &str = + "001111111111111111111111111111111111111111111111111111111111111111"; + + let cases: &[(&[&str], &[&str])] = &[ + ( + &["repl", "account", "create", "acc"], + &["repl", "account-create", "acc"], + ), + ( + &["repl", "account", "rename", "acc"], + &["repl", "account-rename", "acc"], + ), + ( + &["repl", "account", "select", "1"], + &["repl", "account-select", "1"], + ), + (&["repl", "account", "utxos"], &["repl", "account-utxos"]), + ( + &["repl", "account", "balance"], + &["repl", "account-balance"], + ), + ( + &["repl", "account", "show-xpub"], + &["repl", "account-extended-public-key-as-hex"], + ), + (&["repl", "address", "new"], &["repl", "address-new"]), + (&["repl", "address", "list"], &["repl", "address-show"]), + ( + &["repl", "address", "qr", "addr"], + &["repl", "address-qrcode", "addr"], + ), + ( + &["repl", "address", "reveal-public-key", "pkh", "--format", "hex"], + &["repl", "address-reveal-public-key-as-hex", "pkh"], + ), + ( + &["repl", "address", "reveal-public-key", "pkh"], + &["repl", "address-reveal-public-key-as-address", "pkh"], + ), + ( + &["repl", "address", "sweep", "dest", "--all"], + &["repl", "address-sweep-spendable", "dest", "--all"], + ), + ( + &["repl", "address", "standalone", "rename-label", "addr", "--label", "l"], + &["repl", "standalone-address-label-rename", "addr", "--label", "l"], + ), + ( + &["repl", "address", "standalone", "add", "addr", "--no-rescan", "true"], + &["repl", "standalone-add-watch-only-address", "addr", "--no-rescan", "true"], + ), + ( + &[ + "repl", + "address", + "standalone", + "add-private-key", + HEX_PRIVATE_KEY, + "--no-rescan", + "true", + ], + &[ + "repl", + "standalone-add-private-key-from-hex", + HEX_PRIVATE_KEY, + "--no-rescan", + "true", + ], + ), + ( + &[ + "repl", + "address", + "standalone", + "add-multisig", + "2", + "pk1", + "pk2", + "--no-rescan", + "true", + ], + &["repl", "standalone-add-multisig", "2", "pk1", "pk2", "--no-rescan", "true"], + ), + ( + &["repl", "address", "standalone", "list"], + &["repl", "standalone-address-show"], + ), + ( + &["repl", "address", "standalone", "details", "addr"], + &["repl", "standalone-address-details", "addr"], + ), + ( + &["repl", "address", "standalone", "utxos"], + &["repl", "standalone-multisig-utxos"], + ), + ( + &["repl", "send", "dest", "1.0"], + &["repl", "address-send", "dest", "1.0"], + ), + ( + &["repl", "data", "deposit", "deadbeef"], + &["repl", "address-deposit-data", "deadbeef"], + ), + ]; + + for (nested, legacy) in cases { + assert_eq!( + flat_debug(nested), + flat_debug(legacy), + "nested {nested:?} must translate to legacy {legacy:?}" + ); + } + } +} diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 16e27389f4..ee10992bcc 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -1936,6 +1936,42 @@ mod tests { }); } } + + // Arguments shared between a legacy spelling and its nested leaf must agree on + // defaults and requiredness, so one spelling cannot silently drift from the + // other. Arguments unique to one spelling (such as the merged `--format` of the + // nested `reveal-public-key`) are skipped. + for (legacy, nested) in LEGACY_ALIASES { + let legacy_cmd = repl + .find_subcommand(legacy) + .unwrap_or_else(|| panic!("legacy command `{legacy}` is missing")); + let structural_path = nested.split(" --").next().unwrap_or(nested); + let mut nested_cmd = &repl; + for name in structural_path.split_whitespace() { + nested_cmd = nested_cmd + .find_subcommand(name) + .unwrap_or_else(|| panic!("nested path `{nested}` breaks at `{name}`")); + } + for legacy_arg in legacy_cmd.get_arguments() { + let Some(nested_arg) = + nested_cmd.get_arguments().find(|arg| arg.get_id() == legacy_arg.get_id()) + else { + continue; + }; + assert_eq!( + legacy_arg.get_default_values(), + nested_arg.get_default_values(), + "default values of `{}` diverge between `{legacy}` and `{nested}`", + legacy_arg.get_id(), + ); + assert_eq!( + legacy_arg.is_required_set(), + nested_arg.is_required_set(), + "requiredness of `{}` diverges between `{legacy}` and `{nested}`", + legacy_arg.get_id(), + ); + } + } } #[rstest] From 028ed6dbbe58ab3168c1de980ce53eae0ba8944e Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 22:48:30 +0200 Subject: [PATCH 09/52] refactor(wallet-cli): share the nested command guard --- .../src/command_handler/mod.rs | 36 ++++++++++--------- wallet/wallet-cli-commands/src/lib.rs | 29 +++++++-------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 13c440a47a..428cefa68c 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -907,15 +907,10 @@ where // is boxed to keep the future size finite; translation always produces a // flat command, so the recursion never goes deeper than one level. let normalized = normalize_nested_wallet_command(command); - debug_assert!( - !matches!( - normalized, - WalletCommand::Wallet { .. } - | WalletCommand::Account { .. } - | WalletCommand::Address { .. } - | WalletCommand::Send { .. } - | WalletCommand::Data { .. } - ), + // Translation must terminate: a nested command fed back into this handler + // would recurse unboundedly through the boxed call. + assert!( + !is_nested_wallet_command(&normalized), "translation must produce a flat command, otherwise this recursion is unbounded" ); Box::pin(self.handle_wallet_command(chain_config, normalized)).await @@ -2478,6 +2473,20 @@ where } } +/// Whether the command still carries a nested group that must be translated before +/// dispatch. Shared by the dispatch guard and the translation tests so both stay in +/// sync when new groups are added. +pub(crate) fn is_nested_wallet_command(command: &WalletCommand) -> bool { + matches!( + command, + WalletCommand::Wallet { .. } + | WalletCommand::Account { .. } + | WalletCommand::Address { .. } + | WalletCommand::Send { .. } + | WalletCommand::Data { .. } + ) +} + /// Translate the nested command grammar (`account select`, `address qr`, `send`, ...) /// into the flat commands that drive the implementation, so every command has exactly /// one implementation regardless of how it was spelled. The `wallet` group is handled @@ -2786,14 +2795,7 @@ mod nested_translation_tests { let command = WalletCommand::try_parse_from(argv).expect("argv must parse"); let flat = normalize_nested_wallet_command(command); assert!( - !matches!( - flat, - WalletCommand::Wallet { .. } - | WalletCommand::Account { .. } - | WalletCommand::Address { .. } - | WalletCommand::Send { .. } - | WalletCommand::Data { .. } - ), + !is_nested_wallet_command(&flat), "translation must produce a flat command" ); format!("{flat:?}") diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index ee10992bcc..a3ca783f4e 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -1911,6 +1911,19 @@ mod tests { logging::init_logging(); } + // Everything before the first `--option` must be a chain of subcommand names; + // options inside a nested path are not part of the structural check. + fn resolve_structural_path<'a>(repl: &'a Command, nested: &str) -> &'a Command { + let (structural_path, _) = nested.split_once(" --").unwrap_or((nested, "")); + let mut level = repl; + for name in structural_path.split_whitespace() { + level = level.find_subcommand(name).unwrap_or_else(|| { + panic!("nested path `{nested}` breaks at `{name}` in the repl command tree") + }); + } + level + } + #[test] fn legacy_command_names_and_group_grammar_coexist() { let mut repl = get_repl_command(false, true); @@ -1928,13 +1941,7 @@ mod tests { // are not part of the structural check, so everything from the first option on // is dropped before the walk. for (_legacy, nested) in LEGACY_ALIASES { - let structural_path = nested.split(" --").next().unwrap_or(nested); - let mut level = &repl; - for name in structural_path.split_whitespace() { - level = level.find_subcommand(name).unwrap_or_else(|| { - panic!("nested path `{nested}` breaks at `{name}` in the repl command tree") - }); - } + resolve_structural_path(&repl, nested); } // Arguments shared between a legacy spelling and its nested leaf must agree on @@ -1945,13 +1952,7 @@ mod tests { let legacy_cmd = repl .find_subcommand(legacy) .unwrap_or_else(|| panic!("legacy command `{legacy}` is missing")); - let structural_path = nested.split(" --").next().unwrap_or(nested); - let mut nested_cmd = &repl; - for name in structural_path.split_whitespace() { - nested_cmd = nested_cmd - .find_subcommand(name) - .unwrap_or_else(|| panic!("nested path `{nested}` breaks at `{name}`")); - } + let nested_cmd = resolve_structural_path(&repl, nested); for legacy_arg in legacy_cmd.get_arguments() { let Some(nested_arg) = nested_cmd.get_arguments().find(|arg| arg.get_id() == legacy_arg.get_id()) From 254238e5aed4a799e65d7a52363ca368e72c1cb8 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 22:51:49 +0200 Subject: [PATCH 10/52] refactor(wallet-cli): keep the nested command guard private --- wallet/wallet-cli-commands/src/command_handler/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 428cefa68c..db1d1fdf6d 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -2476,7 +2476,7 @@ where /// Whether the command still carries a nested group that must be translated before /// dispatch. Shared by the dispatch guard and the translation tests so both stay in /// sync when new groups are added. -pub(crate) fn is_nested_wallet_command(command: &WalletCommand) -> bool { +fn is_nested_wallet_command(command: &WalletCommand) -> bool { matches!( command, WalletCommand::Wallet { .. } From 08edc7f55fc170b289feb96727f7f0cb7bc076ea Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 23:21:04 +0200 Subject: [PATCH 11/52] fix(wallet-cli): gate the wallet group by wallet mutability --- wallet/wallet-cli-commands/src/lib.rs | 40 ++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index a3ca783f4e..4b5cf6c3e7 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -1761,12 +1761,25 @@ pub fn get_repl_command(cold_wallet: bool, mutable_wallet: bool) -> Command { .help_template(MAIN_HELP_TEMPLATE); // Add commands from generated by clap-derive - let repl_command = if cold_wallet { + let mut repl_command = if cold_wallet { ColdWalletCommand::augment_subcommands(repl_command) } else { WalletCommand::augment_subcommands(repl_command) }; + // The nested `wallet` group maps onto the management commands, so it is only + // available for mutable wallets, matching the flat `wallet-create` etc. commands. + // `subcommands` only appends, so the group is replaced with a childless stub. + if !cold_wallet && !mutable_wallet { + repl_command = repl_command.mut_subcommand("wallet", |wallet| { + let mut stub = Command::new(wallet.get_name().to_owned()); + if let Some(about) = wallet.get_about() { + stub = stub.about(about.clone()); + } + stub + }); + } + let mut repl_command = if mutable_wallet { WalletManagementCommand::augment_subcommands(repl_command) } else { @@ -1975,6 +1988,31 @@ mod tests { } } + #[test] + fn non_mutable_grammar_excludes_wallet_management() { + // Hot wallet without management: neither the flat management commands nor the + // nested `wallet` group may be available. + let mut repl = get_repl_command(false, false); + repl.build(); + assert!(repl.find_subcommand("wallet-create").is_none()); + let wallet_group = repl + .find_subcommand("wallet") + .expect("the wallet group is part of the hot wallet grammar"); + assert!(wallet_group.find_subcommand("create").is_none()); + assert!(wallet_group.find_subcommand("close").is_none()); + } + + #[test] + fn mutable_grammar_includes_the_wallet_group() { + let mut repl = get_repl_command(false, true); + repl.build(); + let wallet_group = repl + .find_subcommand("wallet") + .expect("the wallet group is part of the hot wallet grammar"); + assert!(wallet_group.find_subcommand("create").is_some()); + assert!(wallet_group.find_subcommand("close").is_some()); + } + #[rstest] fn ensure_commands_have_description( #[values(false, true)] cold_wallet: bool, From 5245565185821c72898d850560f7276119f51a35 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 23:36:57 +0200 Subject: [PATCH 12/52] feat(wallet-cli): group staking and exchange commands --- .../src/command_handler/mod.rs | 516 +++++++++++++++++- wallet/wallet-cli-commands/src/lib.rs | 330 +++++++++++ 2 files changed, 843 insertions(+), 3 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index db1d1fdf6d..9bfa2d277e 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -70,8 +70,10 @@ use super::{ }; use crate::{ AccountGroupSubCommand, AddressGroupSubCommand, CliPublicKeyFormat, DataGroupSubCommand, - ManageableWalletCommand, OpenWalletSubCommand, StandaloneGroupSubCommand, - WalletGroupSubCommand, WalletManagementCommand, + DelegationGroupSubCommand, HtlcGroupSubCommand, ManageableWalletCommand, NftGroupSubCommand, + OpenWalletSubCommand, OrderGroupSubCommand, StakingGroupSubCommand, StandaloneGroupSubCommand, + TokenGroupSubCommand, TransactionGroupSubCommand, WalletGroupSubCommand, + WalletManagementCommand, errors::WalletCliCommandError, helper_types::{ CliCurrency, active_order_infos_header, format_token_name, token_ticker_from_rpc_token_info, @@ -902,7 +904,13 @@ where command @ (WalletCommand::Account { .. } | WalletCommand::Address { .. } | WalletCommand::Send { .. } - | WalletCommand::Data { .. }) => { + | WalletCommand::Data { .. } + | WalletCommand::Staking { .. } + | WalletCommand::Delegation { .. } + | WalletCommand::Token { .. } + | WalletCommand::Order { .. } + | WalletCommand::Htlc { .. } + | WalletCommand::Transaction { .. }) => { // Nested groups translate to their flat equivalents. The recursive call // is boxed to keep the future size finite; translation always produces a // flat command, so the recursion never goes deeper than one level. @@ -2484,6 +2492,12 @@ fn is_nested_wallet_command(command: &WalletCommand) -> bool { | WalletCommand::Address { .. } | WalletCommand::Send { .. } | WalletCommand::Data { .. } + | WalletCommand::Staking { .. } + | WalletCommand::Delegation { .. } + | WalletCommand::Token { .. } + | WalletCommand::Order { .. } + | WalletCommand::Htlc { .. } + | WalletCommand::Transaction { .. } ) } @@ -2613,6 +2627,264 @@ fn normalize_nested_wallet_command(command: WalletCommand) -> WalletCommand { WalletCommand::Data { command } => match command { DataGroupSubCommand::Deposit { hex_data } => WalletCommand::DepositData { hex_data }, }, + WalletCommand::Staking { command } => match command { + StakingGroupSubCommand::CreatePool { + amount, + cost_per_block, + margin_ratio_per_thousand, + decommission_address, + staker_address, + vrf_public_key, + } => WalletCommand::CreateStakePool { + amount, + cost_per_block, + margin_ratio_per_thousand, + decommission_address, + staker_address, + vrf_public_key, + }, + StakingGroupSubCommand::DecommissionPool { + pool_id, + output_address, + } => WalletCommand::DecommissionStakePool { + pool_id, + output_address, + }, + StakingGroupSubCommand::DecommissionPoolRequest { + pool_id, + output_address, + } => WalletCommand::DecommissionStakePoolRequest { + pool_id, + output_address, + }, + StakingGroupSubCommand::Start => WalletCommand::StartStaking, + StakingGroupSubCommand::Stop => WalletCommand::StopStaking, + StakingGroupSubCommand::Status => WalletCommand::StakingStatus, + StakingGroupSubCommand::ListPools => WalletCommand::ListPools, + StakingGroupSubCommand::ListOwnedPoolsForDecommission => { + WalletCommand::ListOwnedPoolsForDecommission + } + StakingGroupSubCommand::ListCreatedBlocks => WalletCommand::ListCreatedBlocksIds, + StakingGroupSubCommand::PoolBalance { pool_id } => { + WalletCommand::StakePoolBalance { pool_id } + } + }, + WalletCommand::Delegation { command } => match command { + DelegationGroupSubCommand::Create { owner, pool_id } => { + WalletCommand::CreateDelegation { owner, pool_id } + } + DelegationGroupSubCommand::List => WalletCommand::ListDelegationIds, + DelegationGroupSubCommand::Stake { + amount, + delegation_id, + } => WalletCommand::DelegateStaking { + amount, + delegation_id, + }, + DelegationGroupSubCommand::Withdraw { + address, + amount, + delegation_id, + } => WalletCommand::WithdrawFromDelegation { + address, + amount, + delegation_id, + }, + DelegationGroupSubCommand::Sweep { + destination_address, + delegation_id, + } => WalletCommand::SweepFromDelegation { + destination_address, + delegation_id, + }, + }, + WalletCommand::Token { command } => match command { + TokenGroupSubCommand::Issue { + token_ticker, + number_of_decimals, + metadata_uri, + authority_address, + token_supply, + is_freezable, + } => WalletCommand::IssueNewToken { + token_ticker, + number_of_decimals, + metadata_uri, + authority_address, + token_supply, + is_freezable, + }, + TokenGroupSubCommand::Nft { + command: + NftGroupSubCommand::Issue { + destination_address, + media_hash, + name, + description, + ticker, + creator, + icon_uri, + media_uri, + additional_metadata_uri, + }, + } => WalletCommand::IssueNewNft { + destination_address, + media_hash, + name, + description, + ticker, + creator, + icon_uri, + media_uri, + additional_metadata_uri, + }, + TokenGroupSubCommand::ChangeAuthority { token_id, address } => { + WalletCommand::ChangeTokenAuthority { token_id, address } + } + TokenGroupSubCommand::ChangeMetadataUri { + token_id, + metadata_uri, + } => WalletCommand::ChangeTokenMetadataUri { + token_id, + metadata_uri, + }, + TokenGroupSubCommand::Mint { + token_id, + address, + amount, + } => WalletCommand::MintTokens { + token_id, + address, + amount, + }, + TokenGroupSubCommand::Unmint { token_id, amount } => { + WalletCommand::UnmintTokens { token_id, amount } + } + TokenGroupSubCommand::LockSupply { token_id } => { + WalletCommand::LockTokenSupply { token_id } + } + TokenGroupSubCommand::Freeze { + token_id, + is_unfreezable, + } => WalletCommand::FreezeToken { + token_id, + is_unfreezable, + }, + TokenGroupSubCommand::Unfreeze { token_id } => { + WalletCommand::UnfreezeToken { token_id } + } + TokenGroupSubCommand::Send { + token_id, + address, + amount, + } => WalletCommand::SendTokensToAddress { + token_id, + address, + amount, + }, + }, + WalletCommand::Order { command } => match command { + OrderGroupSubCommand::Create { + ask_currency, + ask_amount, + give_currency, + give_amount, + conclude_address, + } => WalletCommand::CreateOrder { + ask_currency, + ask_amount, + give_currency, + give_amount, + conclude_address, + }, + OrderGroupSubCommand::Fill { + order_id, + amount, + output_address, + } => WalletCommand::FillOrder { + order_id, + amount, + output_address, + }, + OrderGroupSubCommand::Freeze { order_id } => WalletCommand::FreezeOrder { order_id }, + OrderGroupSubCommand::Conclude { + order_id, + output_address, + } => WalletCommand::ConcludeOrder { + order_id, + output_address, + }, + OrderGroupSubCommand::ListOwn => WalletCommand::ListOwnOrders, + OrderGroupSubCommand::ListAllActive { + ask_currency, + give_currency, + } => WalletCommand::ListActiveOrders { + ask_currency, + give_currency, + }, + }, + WalletCommand::Htlc { command } => match command { + HtlcGroupSubCommand::Create { + currency, + amount, + secret_hash, + spend_address, + refund_timelock, + refund_address, + } => WalletCommand::CreateHtlcTx { + currency, + amount, + secret_hash, + spend_address, + refund_timelock, + refund_address, + }, + HtlcGroupSubCommand::GenerateSecret => WalletCommand::GenerateHtlcSecret, + HtlcGroupSubCommand::CalcSecretHash { secret } => { + WalletCommand::CalcHtlcSecretHash { secret } + } + }, + WalletCommand::Transaction { command } => match command { + TransactionGroupSubCommand::Compose { + outputs, + utxos, + only_transaction, + } => WalletCommand::TransactionCompose { + outputs, + utxos, + only_transaction, + }, + TransactionGroupSubCommand::CreateFromColdInput { + address, + amount, + utxo, + change_address, + } => WalletCommand::CreateTxFromColdInput { + address, + amount, + utxo, + change_address, + }, + TransactionGroupSubCommand::Inspect { transaction } => { + WalletCommand::InspectTransaction { transaction } + } + TransactionGroupSubCommand::Abandon { transaction_id } => { + WalletCommand::AbandonTransaction { transaction_id } + } + TransactionGroupSubCommand::ListPending => WalletCommand::ListPendingTransactions, + TransactionGroupSubCommand::ListByAddress { address, limit } => { + WalletCommand::ListMainchainTransactions { address, limit } + } + TransactionGroupSubCommand::Get { transaction_id } => { + WalletCommand::GetTransaction { transaction_id } + } + TransactionGroupSubCommand::GetRaw { transaction_id } => { + WalletCommand::GetRawTransaction { transaction_id } + } + TransactionGroupSubCommand::GetSignedRaw { transaction_id } => { + WalletCommand::GetRawSignedTransaction { transaction_id } + } + }, command => command, } } @@ -2805,6 +3077,12 @@ mod nested_translation_tests { fn nested_spellings_translate_to_the_legacy_flat_commands() { const HEX_PRIVATE_KEY: &str = "001111111111111111111111111111111111111111111111111111111111111111"; + const HEX_TXID: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + const HEX_UTXO: &str = concat!( + "tx(", + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + ",1)" + ); let cases: &[(&[&str], &[&str])] = &[ ( @@ -2906,6 +3184,238 @@ mod nested_translation_tests { &["repl", "data", "deposit", "deadbeef"], &["repl", "address-deposit-data", "deadbeef"], ), + ( + &[ + "repl", + "staking", + "create-pool", + "1000", + "0.01", + "0.1", + "decomm_addr", + "staker_addr", + ], + &[ + "repl", + "staking-create-pool", + "1000", + "0.01", + "0.1", + "decomm_addr", + "staker_addr", + ], + ), + ( + &["repl", "staking", "decommission-pool", "pool1", "out_addr"], + &["repl", "staking-decommission-pool", "pool1", "out_addr"], + ), + ( + &["repl", "staking", "decommission-pool-request", "pool1", "out_addr"], + &["repl", "staking-decommission-pool-request", "pool1", "out_addr"], + ), + (&["repl", "staking", "start"], &["repl", "staking-start"]), + (&["repl", "staking", "stop"], &["repl", "staking-stop"]), + (&["repl", "staking", "status"], &["repl", "staking-status"]), + ( + &["repl", "staking", "list-pools"], + &["repl", "staking-list-pools"], + ), + ( + &["repl", "staking", "list-owned-pools-for-decommission"], + &["repl", "staking-list-owned-pools-for-decommission"], + ), + ( + &["repl", "staking", "list-created-blocks"], + &["repl", "staking-list-created-block-ids"], + ), + ( + &["repl", "staking", "pool-balance", "pool1"], + &["repl", "staking-pool-balance", "pool1"], + ), + ( + &["repl", "delegation", "create", "owner_addr", "pool1"], + &["repl", "delegation-create", "owner_addr", "pool1"], + ), + ( + &["repl", "delegation", "list"], + &["repl", "delegation-list-ids"], + ), + ( + &["repl", "delegation", "stake", "1.0", "deleg1"], + &["repl", "delegation-stake", "1.0", "deleg1"], + ), + ( + &["repl", "delegation", "withdraw", "addr", "1.0", "deleg1"], + &["repl", "delegation-withdraw", "addr", "1.0", "deleg1"], + ), + ( + &["repl", "delegation", "sweep", "dest", "deleg1"], + &["repl", "staking-sweep-delegation", "dest", "deleg1"], + ), + ( + &[ + "repl", + "token", + "issue", + "TKN", + "4", + "https://meta", + "authority_addr", + "unlimited", + "freezable", + ], + &[ + "repl", + "token-issue-new", + "TKN", + "4", + "https://meta", + "authority_addr", + "unlimited", + "freezable", + ], + ), + ( + &["repl", "token", "nft", "issue", "dest", "abcd", "name", "descr", "NFT"], + &["repl", "token-nft-issue-new", "dest", "abcd", "name", "descr", "NFT"], + ), + ( + &["repl", "token", "change-authority", "tid", "addr"], + &["repl", "token-change-authority", "tid", "addr"], + ), + ( + &["repl", "token", "change-metadata-uri", "tid", "uri"], + &["repl", "token-change-metadata-uri", "tid", "uri"], + ), + ( + &["repl", "token", "mint", "tid", "addr", "1.0"], + &["repl", "token-mint", "tid", "addr", "1.0"], + ), + ( + &["repl", "token", "unmint", "tid", "1.0"], + &["repl", "token-unmint", "tid", "1.0"], + ), + ( + &["repl", "token", "lock-supply", "tid"], + &["repl", "token-lock-supply", "tid"], + ), + ( + &["repl", "token", "freeze", "tid", "unfreezable"], + &["repl", "token-freeze", "tid", "unfreezable"], + ), + ( + &["repl", "token", "unfreeze", "tid"], + &["repl", "token-unfreeze", "tid"], + ), + ( + &["repl", "token", "send", "tid", "addr", "1.0"], + &["repl", "token-send", "tid", "addr", "1.0"], + ), + ( + &["repl", "order", "create", "coin", "1.0", "coin", "2.0", "concl_addr"], + &["repl", "order-create", "coin", "1.0", "coin", "2.0", "concl_addr"], + ), + ( + &["repl", "order", "fill", "ord1", "1.0", "out_addr"], + &["repl", "order-fill", "ord1", "1.0", "out_addr"], + ), + ( + &["repl", "order", "freeze", "ord1"], + &["repl", "order-freeze", "ord1"], + ), + ( + &["repl", "order", "conclude", "ord1", "out_addr"], + &["repl", "order-conclude", "ord1", "out_addr"], + ), + (&["repl", "order", "list-own"], &["repl", "order-list-own"]), + ( + &["repl", "order", "list-all-active", "--ask-currency", "coin"], + &["repl", "order-list-all-active", "--ask-currency", "coin"], + ), + ( + &[ + "repl", + "htlc", + "create", + "coin", + "1.0", + "secret_hash", + "spend_addr", + "block_count(720)", + "refund_addr", + ], + &[ + "repl", + "htlc-create-transaction", + "coin", + "1.0", + "secret_hash", + "spend_addr", + "block_count(720)", + "refund_addr", + ], + ), + ( + &["repl", "htlc", "generate-secret"], + &["repl", "htlc-generate-secret"], + ), + ( + &["repl", "htlc", "calc-secret-hash", "s3cret"], + &["repl", "htlc-calc-secret-hash", "s3cret"], + ), + ( + &["repl", "transaction", "compose", "transfer(addr,1.0)"], + &["repl", "transaction-compose", "transfer(addr,1.0)"], + ), + ( + &[ + "repl", + "transaction", + "create-from-cold-input", + "addr", + "1.0", + HEX_UTXO, + "--change", + "chg", + ], + &[ + "repl", + "transaction-create-from-cold-input", + "addr", + "1.0", + HEX_UTXO, + "--change", + "chg", + ], + ), + ( + &["repl", "transaction", "inspect", "deadbeef"], + &["repl", "transaction-inspect", "deadbeef"], + ), + ( + &["repl", "transaction", "abandon", HEX_TXID], + &["repl", "transaction-abandon", HEX_TXID], + ), + ( + &["repl", "transaction", "list-pending"], + &["repl", "transaction-list-pending"], + ), + ( + &["repl", "transaction", "list-by-address", "addr", "--limit", "5"], + &["repl", "transaction-list-by-address", "addr", "--limit", "5"], + ), + ( + &["repl", "transaction", "get", HEX_TXID], + &["repl", "transaction-get", HEX_TXID], + ), + ( + &["repl", "transaction", "get-raw", HEX_TXID], + &["repl", "transaction-get-raw", HEX_TXID], + ), + ( + &["repl", "transaction", "get-signed-raw", HEX_TXID], + &["repl", "transaction-get-signed-raw", HEX_TXID], + ), ]; for (nested, legacy) in cases { diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 4b5cf6c3e7..87f6cda983 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -476,6 +476,245 @@ pub enum DataGroupSubCommand { }, } +/// The nested staking grammar: `staking create-pool|decommission-pool|...`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum StakingGroupSubCommand { + /// Create a new staking pool + CreatePool { + amount: DecimalAmount, + cost_per_block: DecimalAmount, + margin_ratio_per_thousand: String, + decommission_address: String, + staker_address: Option, + vrf_public_key: Option, + }, + /// Decommission a staking pool owned by the selected account + DecommissionPool { + pool_id: String, + output_address: String, + }, + /// Create a decommission request transaction without broadcasting it + DecommissionPoolRequest { + pool_id: String, + output_address: String, + }, + /// Start staking with the selected account + Start, + /// Stop staking with the selected account + Stop, + /// Show the staking status of the selected account + Status, + /// List all staking pools + ListPools, + /// List the pools owned by the selected account that can be decommissioned + ListOwnedPoolsForDecommission, + /// List the block ids created by the selected account + ListCreatedBlocks, + /// Show the balance of a staking pool + PoolBalance { pool_id: String }, +} + +/// The nested delegation grammar: `delegation create|list|stake|withdraw|sweep`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum DelegationGroupSubCommand { + /// Create a delegation to a staking pool + Create { owner: String, pool_id: String }, + /// List the delegation ids of the selected account + List, + /// Add coins to a delegation + Stake { + amount: DecimalAmount, + delegation_id: String, + }, + /// Withdraw coins from a delegation + Withdraw { + address: String, + amount: DecimalAmount, + delegation_id: String, + }, + /// Move all spendable delegation coins to an address + Sweep { + destination_address: String, + delegation_id: String, + }, +} + +/// The nested NFT grammar: `token nft issue`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum NftGroupSubCommand { + /// Issue a new NFT + Issue { + destination_address: String, + media_hash: String, + name: String, + description: String, + ticker: String, + creator: Option>, + icon_uri: Option, + media_uri: Option, + additional_metadata_uri: Option, + }, +} + +/// The nested token grammar: `token issue|nft|change-authority|mint|...`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum TokenGroupSubCommand { + /// Issue a new token + Issue { + token_ticker: String, + number_of_decimals: u8, + metadata_uri: String, + authority_address: String, + token_supply: CliTokenTotalSupply, + is_freezable: CliIsFreezable, + }, + /// NFT operations + Nft { + #[command(subcommand)] + command: NftGroupSubCommand, + }, + /// Change the authority of a token + ChangeAuthority { token_id: String, address: String }, + /// Change the metadata uri of a token + ChangeMetadataUri { + token_id: String, + metadata_uri: String, + }, + /// Mint tokens + Mint { + token_id: String, + address: String, + amount: DecimalAmount, + }, + /// Unmint tokens + Unmint { + token_id: String, + amount: DecimalAmount, + }, + /// Lock the supply of a token + LockSupply { token_id: String }, + /// Freeze a token + Freeze { + token_id: String, + is_unfreezable: CliIsUnfreezable, + }, + /// Unfreeze a token + Unfreeze { token_id: String }, + /// Send tokens to an address + Send { + token_id: String, + address: String, + amount: DecimalAmount, + }, +} + +/// The nested order grammar: `order create|fill|freeze|conclude|list-own|list-all-active`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum OrderGroupSubCommand { + /// Create an order for exchanging currencies + Create { + ask_currency: CliCurrency, + ask_amount: DecimalAmount, + give_currency: CliCurrency, + give_amount: DecimalAmount, + conclude_address: String, + }, + /// Fill an order + Fill { + order_id: String, + amount: DecimalAmount, + output_address: Option, + }, + /// Freeze an order + Freeze { order_id: String }, + /// Conclude an order + Conclude { + order_id: String, + output_address: Option, + }, + /// List orders owned by the selected account + ListOwn, + /// List all active orders + ListAllActive { + #[arg(long = "ask-currency")] + ask_currency: Option, + #[arg(long = "give-currency")] + give_currency: Option, + }, +} + +/// The nested htlc grammar: `htlc create|generate-secret|calc-secret-hash`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum HtlcGroupSubCommand { + /// Create a transaction with an HTLC output without broadcasting it + Create { + currency: CliCurrency, + amount: DecimalAmount, + secret_hash: String, + spend_address: String, + refund_timelock: CliOutputTimeLock, + refund_address: String, + }, + /// Generate a new HTLC secret + GenerateSecret, + /// Calculate the hash of an HTLC secret + CalcSecretHash { secret: String }, +} + +/// The nested transaction grammar: `transaction compose|abandon|get|...`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum TransactionGroupSubCommand { + /// Compose a transaction from outputs without broadcasting it + Compose { + outputs: Vec, + #[arg(long = "utxos")] + utxos: Vec, + #[arg(long = "only-transaction", default_value_t = false)] + only_transaction: bool, + }, + /// Create a transaction from a cold wallet input + CreateFromColdInput { + address: String, + amount: DecimalAmount, + utxo: CliUtxoOutPoint, + #[arg(long = "change")] + change_address: Option, + }, + /// Inspect a hex-encoded transaction + Inspect { transaction: String }, + /// Abandon a pending transaction + Abandon { + transaction_id: HexEncoded>, + }, + /// List pending transactions + ListPending, + /// List transactions by address + ListByAddress { + address: Option, + #[arg(long = "limit", default_value_t = 100)] + limit: usize, + }, + /// Get a transaction by id + Get { + transaction_id: HexEncoded>, + }, + /// Get the raw hex of a transaction + GetRaw { + transaction_id: HexEncoded>, + }, + /// Get the raw hex of a signed transaction + GetSignedRaw { + transaction_id: HexEncoded>, + }, +} + /// Mapping between the legacy flat command names and their nested equivalents. /// The flat names remain first-class commands; this manifest documents the /// correspondence and drives the grammar compatibility test. @@ -520,6 +759,61 @@ pub const LEGACY_ALIASES: &[(&str, &str)] = &[ ("standalone-address-show", "address standalone list"), ("standalone-address-details", "address standalone details"), ("standalone-multisig-utxos", "address standalone utxos"), + ("staking-create-pool", "staking create-pool"), + ("staking-decommission-pool", "staking decommission-pool"), + ( + "staking-decommission-pool-request", + "staking decommission-pool-request", + ), + ("staking-start", "staking start"), + ("staking-stop", "staking stop"), + ("staking-status", "staking status"), + ("staking-list-pools", "staking list-pools"), + ( + "staking-list-owned-pools-for-decommission", + "staking list-owned-pools-for-decommission", + ), + ( + "staking-list-created-block-ids", + "staking list-created-blocks", + ), + ("staking-pool-balance", "staking pool-balance"), + ("delegation-create", "delegation create"), + ("delegation-list-ids", "delegation list"), + ("delegation-stake", "delegation stake"), + ("delegation-withdraw", "delegation withdraw"), + ("staking-sweep-delegation", "delegation sweep"), + ("token-issue-new", "token issue"), + ("token-nft-issue-new", "token nft issue"), + ("token-change-authority", "token change-authority"), + ("token-change-metadata-uri", "token change-metadata-uri"), + ("token-mint", "token mint"), + ("token-unmint", "token unmint"), + ("token-lock-supply", "token lock-supply"), + ("token-freeze", "token freeze"), + ("token-unfreeze", "token unfreeze"), + ("token-send", "token send"), + ("order-create", "order create"), + ("order-fill", "order fill"), + ("order-freeze", "order freeze"), + ("order-conclude", "order conclude"), + ("order-list-own", "order list-own"), + ("order-list-all-active", "order list-all-active"), + ("htlc-create-transaction", "htlc create"), + ("htlc-generate-secret", "htlc generate-secret"), + ("htlc-calc-secret-hash", "htlc calc-secret-hash"), + ("transaction-compose", "transaction compose"), + ( + "transaction-create-from-cold-input", + "transaction create-from-cold-input", + ), + ("transaction-inspect", "transaction inspect"), + ("transaction-abandon", "transaction abandon"), + ("transaction-list-pending", "transaction list-pending"), + ("transaction-list-by-address", "transaction list-by-address"), + ("transaction-get", "transaction get"), + ("transaction-get-raw", "transaction get-raw"), + ("transaction-get-signed-raw", "transaction get-signed-raw"), ]; #[derive(Debug, Parser)] @@ -781,6 +1075,42 @@ pub enum WalletCommand { #[command(subcommand)] command: DataGroupSubCommand, }, + /// Staking pool operations + #[clap(name = "staking")] + Staking { + #[command(subcommand)] + command: StakingGroupSubCommand, + }, + /// Delegation operations + #[clap(name = "delegation")] + Delegation { + #[command(subcommand)] + command: DelegationGroupSubCommand, + }, + /// Token operations + #[clap(name = "token")] + Token { + #[command(subcommand)] + command: TokenGroupSubCommand, + }, + /// Order operations + #[clap(name = "order")] + Order { + #[command(subcommand)] + command: OrderGroupSubCommand, + }, + /// HTLC operations + #[clap(name = "htlc")] + Htlc { + #[command(subcommand)] + command: HtlcGroupSubCommand, + }, + /// Transaction operations + #[clap(name = "transaction")] + Transaction { + #[command(subcommand)] + command: TransactionGroupSubCommand, + }, #[clap(name = "account-create")] CreateNewAccount { name: Option }, From 3a3ad5df04fbba539caf98ea20b53ebe159974c0 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Mon, 19 Oct 2026 23:50:36 +0200 Subject: [PATCH 13/52] test(wallet-cli): exercise optional grouped command arguments --- .../src/command_handler/mod.rs | 68 +++++++++++++++ wallet/wallet-cli-commands/src/lib.rs | 83 +++++++++++++++++-- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 9bfa2d277e..eda0770114 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -3416,6 +3416,74 @@ mod nested_translation_tests { &["repl", "transaction", "get-signed-raw", HEX_TXID], &["repl", "transaction-get-signed-raw", HEX_TXID], ), + ( + &[ + "repl", + "token", + "nft", + "issue", + "dest", + "abcd", + "name", + "descr", + "NFT", + "000279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "https://icon", + "https://media", + "https://extra", + ], + &[ + "repl", + "token-nft-issue-new", + "dest", + "abcd", + "name", + "descr", + "NFT", + "000279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "https://icon", + "https://media", + "https://extra", + ], + ), + ( + &[ + "repl", + "order", + "list-all-active", + "--ask-currency", + "coin", + "--give-currency", + "token", + ], + &[ + "repl", + "order-list-all-active", + "--ask-currency", + "coin", + "--give-currency", + "token", + ], + ), + ( + &[ + "repl", + "transaction", + "compose", + "transfer(addr,1.0)", + "--utxos", + HEX_UTXO, + "--only-transaction", + ], + &[ + "repl", + "transaction-compose", + "transfer(addr,1.0)", + "--utxos", + HEX_UTXO, + "--only-transaction", + ], + ), ]; for (nested, legacy) in cases { diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 87f6cda983..3e51eb209e 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -482,21 +482,37 @@ pub enum DataGroupSubCommand { pub enum StakingGroupSubCommand { /// Create a new staking pool CreatePool { + /// The amount to be pledged to the pool. There is a minimum to be accepted. amount: DecimalAmount, + /// An amount in coins to be subtracted from the total rewards in a block and + /// handed to the staker as a constant/fixed cost for running the pool. cost_per_block: DecimalAmount, + /// After subtracting "cost per block" from the reward, this ratio is taken from + /// the rewards and handed to the staker, written as a percentage with per-mill + /// accuracy (0.1% is valid and equivalent to 0.001). margin_ratio_per_thousand: String, + /// The key that can decommission the pool. It's recommended to keep the + /// decommission key in a cold storage. decommission_address: String, + /// The key that will sign new blocks; leave it empty if the current wallet + /// will be the staking wallet. staker_address: Option, + /// The VRF key used to produce PoS hashes during staking; leave it empty if + /// the current wallet will be the staking wallet. vrf_public_key: Option, }, /// Decommission a staking pool owned by the selected account DecommissionPool { + /// The pool id of the pool to be decommissioned. pool_id: String, + /// The address that will receive the staker's balance. output_address: String, }, /// Create a decommission request transaction without broadcasting it DecommissionPoolRequest { + /// The pool id of the pool to be decommissioned. pool_id: String, + /// The address that will receive the staker's balance. output_address: String, }, /// Start staking with the selected account @@ -578,32 +594,52 @@ pub enum TokenGroupSubCommand { command: NftGroupSubCommand, }, /// Change the authority of a token - ChangeAuthority { token_id: String, address: String }, + ChangeAuthority { + /// The token id of the token. + token_id: String, + /// The address of the new authority. + address: String, + }, /// Change the metadata uri of a token ChangeMetadataUri { + /// The token id of the token. token_id: String, + /// The new metadata uri. metadata_uri: String, }, /// Mint tokens Mint { + /// The token id of the tokens to be minted. token_id: String, + /// The receiving address of the minted tokens. address: String, + /// The amount to be minted. amount: DecimalAmount, }, /// Unmint tokens Unmint { + /// The token id of the tokens to be unminted. token_id: String, + /// The amount to be unminted. amount: DecimalAmount, }, /// Lock the supply of a token - LockSupply { token_id: String }, + LockSupply { + /// The token id of the token whose supply will be locked. + token_id: String, + }, /// Freeze a token Freeze { + /// The token id of the token to be frozen. token_id: String, + /// Whether these tokens can be unfrozen again, or permanently frozen. is_unfreezable: CliIsUnfreezable, }, /// Unfreeze a token - Unfreeze { token_id: String }, + Unfreeze { + /// The token id of the token to be unfrozen. + token_id: String, + }, /// Send tokens to an address Send { token_id: String, @@ -618,31 +654,48 @@ pub enum TokenGroupSubCommand { pub enum OrderGroupSubCommand { /// Create an order for exchanging currencies Create { + /// The currency you are asking for - a token id or "coin" for coins. ask_currency: CliCurrency, + /// The asked amount. ask_amount: DecimalAmount, + /// The currency you will be giving - a token id or "coin" for coins. give_currency: CliCurrency, + /// The given amount. give_amount: DecimalAmount, + /// The address that can authorize the conclusion and freezing of the order. conclude_address: String, }, /// Fill an order Fill { + /// The id of the order to fill. order_id: String, + /// The amount of the "asked" currency to fill the order with. amount: DecimalAmount, + /// The address that receives the "given" currency; a new receive address is + /// generated if not specified. output_address: Option, }, /// Freeze an order - Freeze { order_id: String }, + Freeze { + /// The id of the order to freeze. + order_id: String, + }, /// Conclude an order Conclude { + /// The id of the order to conclude. order_id: String, + /// The address that receives the accumulated amounts; a new receive address + /// is generated if not specified. output_address: Option, }, /// List orders owned by the selected account ListOwn, /// List all active orders ListAllActive { + /// Filter orders by the specified "asked" currency. #[arg(long = "ask-currency")] ask_currency: Option, + /// Filter orders by the specified "given" currency. #[arg(long = "give-currency")] give_currency: Option, }, @@ -654,11 +707,17 @@ pub enum OrderGroupSubCommand { pub enum HtlcGroupSubCommand { /// Create a transaction with an HTLC output without broadcasting it Create { + /// The currency to lock inside the HTLC - a token id or "coin" for coins. currency: CliCurrency, + /// The amount to lock inside the HTLC. amount: DecimalAmount, + /// Hex-encoded hash of the HTLC secret. secret_hash: String, + /// The address that can spend the HTLC providing the secret. spend_address: String, + /// The refund timelock, e.g. block_count(720) or until_time(RFC 3339). refund_timelock: CliOutputTimeLock, + /// The address that can refund the HTLC after the refund timelock expires. refund_address: String, }, /// Generate a new HTLC secret @@ -673,31 +732,45 @@ pub enum HtlcGroupSubCommand { pub enum TransactionGroupSubCommand { /// Compose a transaction from outputs without broadcasting it Compose { + /// The transaction outputs, in the format `transfer(address,amount)`. outputs: Vec, + /// Choose what utxos to spend (e.g. tx(,1) or block(,2)). #[arg(long = "utxos")] utxos: Vec, + /// Produce a "simple" transaction hex instead of a PartiallySignedTransaction. #[arg(long = "only-transaction", default_value_t = false)] only_transaction: bool, }, /// Create a transaction from a cold wallet input CreateFromColdInput { + /// The receiving address of the coins. address: String, + /// The amount to be sent, in decimal format. amount: DecimalAmount, + /// The utxo to spend, e.g. tx(,1) or block(,2). utxo: CliUtxoOutPoint, + /// The change address; if not specified, the change returns to the input + /// address. #[arg(long = "change")] change_address: Option, }, /// Inspect a hex-encoded transaction - Inspect { transaction: String }, + Inspect { + /// Hex-encoded transaction or PartiallySignedTransaction. + transaction: String, + }, /// Abandon a pending transaction Abandon { + /// The id of the transaction to abandon, in hex. transaction_id: HexEncoded>, }, /// List pending transactions ListPending, /// List transactions by address ListByAddress { + /// Address to filter by. address: Option, + /// Limit the number of printed transactions. #[arg(long = "limit", default_value_t = 100)] limit: usize, }, From d2e03322906de0a19ae0985eee823166ffb68fdd Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 00:21:29 +0200 Subject: [PATCH 14/52] feat(wallet-cli): group vrf key commands under staking --- .../src/command_handler/mod.rs | 30 ++++++++++++++++ wallet/wallet-cli-commands/src/lib.rs | 35 ++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index eda0770114..0bfae2b731 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -901,6 +901,10 @@ where }; self.handle_wallet_management_command(management).await } + // Keep this or-pattern in sync with `is_nested_wallet_command` and the + // translation arms in `normalize_nested_wallet_command`: a group added here + // needs a variant in the predicate, a translation arm, and a LEGACY_ALIASES + // entry so the coexistence and translation tests pin all three sites. command @ (WalletCommand::Account { .. } | WalletCommand::Address { .. } | WalletCommand::Send { .. } @@ -2485,6 +2489,9 @@ where /// dispatch. Shared by the dispatch guard and the translation tests so both stay in /// sync when new groups are added. fn is_nested_wallet_command(command: &WalletCommand) -> bool { + // Keep in sync with the dispatch or-pattern in `handle_wallet_command` and the + // translation arms in `normalize_nested_wallet_command`; the LEGACY_ALIASES + // coexistence test and the translation test pin the three sites together. matches!( command, WalletCommand::Wallet { .. } @@ -2657,6 +2664,15 @@ fn normalize_nested_wallet_command(command: WalletCommand) -> WalletCommand { pool_id, output_address, }, + StakingGroupSubCommand::NewVrfPublicKey => { + WalletCommand::ColdCommands(ColdWalletCommand::NewVrfPublicKey) + } + StakingGroupSubCommand::ShowVrfPublicKeys => { + WalletCommand::ColdCommands(ColdWalletCommand::GetVrfPublicKey) + } + StakingGroupSubCommand::ShowLegacyVrfKey => { + WalletCommand::ColdCommands(ColdWalletCommand::GetLegacyVrfPublicKey) + } StakingGroupSubCommand::Start => WalletCommand::StartStaking, StakingGroupSubCommand::Stop => WalletCommand::StopStaking, StakingGroupSubCommand::Status => WalletCommand::StakingStatus, @@ -3184,6 +3200,18 @@ mod nested_translation_tests { &["repl", "data", "deposit", "deadbeef"], &["repl", "address-deposit-data", "deadbeef"], ), + ( + &["repl", "staking", "new-vrf-public-key"], + &["repl", "staking-new-vrf-public-key"], + ), + ( + &["repl", "staking", "show-vrf-public-keys"], + &["repl", "staking-show-vrf-public-keys"], + ), + ( + &["repl", "staking", "show-legacy-vrf-key"], + &["repl", "staking-show-legacy-vrf-key"], + ), ( &[ "repl", @@ -3194,6 +3222,7 @@ mod nested_translation_tests { "0.1", "decomm_addr", "staker_addr", + "vrf_key", ], &[ "repl", @@ -3203,6 +3232,7 @@ mod nested_translation_tests { "0.1", "decomm_addr", "staker_addr", + "vrf_key", ], ), ( diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 3e51eb209e..3bfdf149fa 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -480,6 +480,12 @@ pub enum DataGroupSubCommand { #[derive(Debug, Subcommand, Clone)] #[clap(rename_all = "kebab-case")] pub enum StakingGroupSubCommand { + /// Generate a new VRF public key for staking + NewVrfPublicKey, + /// Show the VRF public keys of the selected account + ShowVrfPublicKeys, + /// Show the legacy VRF key of the selected account + ShowLegacyVrfKey, /// Create a new staking pool CreatePool { /// The amount to be pledged to the pool. There is a minimum to be accepted. @@ -536,23 +542,35 @@ pub enum StakingGroupSubCommand { #[clap(rename_all = "kebab-case")] pub enum DelegationGroupSubCommand { /// Create a delegation to a staking pool - Create { owner: String, pool_id: String }, + Create { + /// The address, that will have the authority to sign withdrawals from the delegation. + owner: String, + /// The pool id of the pool that will get the delegation and stake the coins. + pool_id: String, + }, /// List the delegation ids of the selected account List, /// Add coins to a delegation Stake { + /// The amount to be delegated for staking. amount: DecimalAmount, + /// The delegation id. delegation_id: String, }, /// Withdraw coins from a delegation Withdraw { + /// The address that will be receiving the coins address: String, + /// The amount that will be taken away from the delegation amount: DecimalAmount, + /// The delegation id, from which the delegated coins will be taken delegation_id: String, }, /// Move all spendable delegation coins to an address Sweep { + /// The receiving address of the coins destination_address: String, + /// The delegation to be swept delegation_id: String, }, } @@ -563,14 +581,23 @@ pub enum DelegationGroupSubCommand { pub enum NftGroupSubCommand { /// Issue a new NFT Issue { + /// The receiver of the token destination_address: String, + /// The hash of the media, whose ownership is represented by the NFT media_hash: String, + /// Name of the token name: String, + /// Description of the token description: String, + /// Ticker of the token ticker: String, + /// The owner, represented by a public key (hex-encoded) creator: Option>, + /// URI for the icon of the NFT icon_uri: Option, + /// URI of the media media_uri: Option, + /// URI of the additional metadata additional_metadata_uri: Option, }, } @@ -832,6 +859,12 @@ pub const LEGACY_ALIASES: &[(&str, &str)] = &[ ("standalone-address-show", "address standalone list"), ("standalone-address-details", "address standalone details"), ("standalone-multisig-utxos", "address standalone utxos"), + ("staking-new-vrf-public-key", "staking new-vrf-public-key"), + ( + "staking-show-vrf-public-keys", + "staking show-vrf-public-keys", + ), + ("staking-show-legacy-vrf-key", "staking show-legacy-vrf-key"), ("staking-create-pool", "staking create-pool"), ("staking-decommission-pool", "staking decommission-pool"), ( From 84a994d43f063173a732eacdb182852b87994db0 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 00:57:54 +0200 Subject: [PATCH 15/52] docs(wallet-cli): polish grouped command help texts --- wallet/wallet-cli-commands/src/command_handler/mod.rs | 10 +++++----- wallet/wallet-cli-commands/src/lib.rs | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 0bfae2b731..ad16009ab1 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -3094,7 +3094,7 @@ mod nested_translation_tests { const HEX_PRIVATE_KEY: &str = "001111111111111111111111111111111111111111111111111111111111111111"; const HEX_TXID: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; - const HEX_UTXO: &str = concat!( + const UTXO_OUT_POINT_ARG: &str = concat!( "tx(", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", ",1)" @@ -3404,7 +3404,7 @@ mod nested_translation_tests { "create-from-cold-input", "addr", "1.0", - HEX_UTXO, + UTXO_OUT_POINT_ARG, "--change", "chg", ], @@ -3413,7 +3413,7 @@ mod nested_translation_tests { "transaction-create-from-cold-input", "addr", "1.0", - HEX_UTXO, + UTXO_OUT_POINT_ARG, "--change", "chg", ], @@ -3502,7 +3502,7 @@ mod nested_translation_tests { "compose", "transfer(addr,1.0)", "--utxos", - HEX_UTXO, + UTXO_OUT_POINT_ARG, "--only-transaction", ], &[ @@ -3510,7 +3510,7 @@ mod nested_translation_tests { "transaction-compose", "transfer(addr,1.0)", "--utxos", - HEX_UTXO, + UTXO_OUT_POINT_ARG, "--only-transaction", ], ), diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 3bfdf149fa..ce75dd09c5 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -494,11 +494,11 @@ pub enum StakingGroupSubCommand { /// handed to the staker as a constant/fixed cost for running the pool. cost_per_block: DecimalAmount, /// After subtracting "cost per block" from the reward, this ratio is taken from - /// the rewards and handed to the staker, written as a percentage with per-mill + /// the rewards and handed to the staker, written as a percentage with per-mille /// accuracy (0.1% is valid and equivalent to 0.001). margin_ratio_per_thousand: String, /// The key that can decommission the pool. It's recommended to keep the - /// decommission key in a cold storage. + /// decommission key in cold storage. decommission_address: String, /// The key that will sign new blocks; leave it empty if the current wallet /// will be the staking wallet. @@ -1592,11 +1592,11 @@ pub enum WalletCommand { /// After subtracting "cost per block" from the reward, this ratio is taken from the rewards and is handed to the staker. /// What is left is distributed among delegators, pro-rata, based on their delegation amounts. - /// The amount here is written as a percentage with per-mill accuracy. For example, 0.1% is valid, + /// The amount here is written as a percentage with per-mille accuracy. For example, 0.1% is valid, /// and is equivalent to 0.001. Also 5% is valid and is equivalent to 0.05. margin_ratio_per_thousand: String, - /// The key that can decommission the pool. It's recommended to keep the decommission key in a cold storage. + /// The key that can decommission the pool. It's recommended to keep the decommission key in cold storage. decommission_address: String, /// This specifies the key that will sign new blocks. From f210439f7b445bde8b6674b04bc4d1bdcfd64519 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 01:08:37 +0200 Subject: [PATCH 16/52] feat(wallet-cli): group node and peer commands --- .../src/command_handler/mod.rs | 284 +++++++++++++++++- wallet/wallet-cli-commands/src/lib.rs | 185 +++++++++++- 2 files changed, 457 insertions(+), 12 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index ad16009ab1..69b8b16581 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -71,7 +71,8 @@ use super::{ use crate::{ AccountGroupSubCommand, AddressGroupSubCommand, CliPublicKeyFormat, DataGroupSubCommand, DelegationGroupSubCommand, HtlcGroupSubCommand, ManageableWalletCommand, NftGroupSubCommand, - OpenWalletSubCommand, OrderGroupSubCommand, StakingGroupSubCommand, StandaloneGroupSubCommand, + NodeGroupSubCommand, OpenWalletSubCommand, OrderGroupSubCommand, PeerGroupSubCommand, + ReservedPeerGroupSubCommand, StakingGroupSubCommand, StandaloneGroupSubCommand, TokenGroupSubCommand, TransactionGroupSubCommand, WalletGroupSubCommand, WalletManagementCommand, errors::WalletCliCommandError, @@ -914,7 +915,9 @@ where | WalletCommand::Token { .. } | WalletCommand::Order { .. } | WalletCommand::Htlc { .. } - | WalletCommand::Transaction { .. }) => { + | WalletCommand::Transaction { .. } + | WalletCommand::Node { .. } + | WalletCommand::Peer { .. }) => { // Nested groups translate to their flat equivalents. The recursive call // is boxed to keep the future size finite; translation always produces a // flat command, so the recursion never goes deeper than one level. @@ -950,13 +953,45 @@ where Ok(ConsoleCommand::Print(height.to_string())) } + WalletCommand::NodeBestBlock { + id, + height, + timestamp, + } => { + // The legacy RPCs are reused per value so error behavior (including on + // cold wallets) matches the flat commands. Values are fetched back to + // back, so they can straddle a block boundary exactly like issuing the + // flat commands one after another. No flags means all three, mirroring + // the formatter's show-all rule. + let wallet = self.wallet().await?; + let show_all = !id && !height && !timestamp; + let best_block_id = if id || show_all { + Some(wallet.node_best_block_id().await?.hex_encode()) + } else { + None + }; + let best_block_height = if height || show_all { + Some(wallet.node_best_block_height().await?.to_string()) + } else { + None + }; + let best_block_timestamp = if timestamp || show_all { + Some(format_block_timestamp( + wallet.chainstate_info().await?.best_block_timestamp, + )) + } else { + None + }; + Ok(ConsoleCommand::Print(format_best_block( + best_block_id, + best_block_height, + best_block_timestamp, + ))) + } + WalletCommand::BestBlockTimestamp => { let timestamp = self.wallet().await?.chainstate_info().await?.best_block_timestamp; - Ok(ConsoleCommand::Print(format!( - "{} ({})", - timestamp, - timestamp.into_time() - ))) + Ok(ConsoleCommand::Print(format_block_timestamp(timestamp))) } WalletCommand::BlockId { height } => { @@ -2505,6 +2540,8 @@ fn is_nested_wallet_command(command: &WalletCommand) -> bool { | WalletCommand::Order { .. } | WalletCommand::Htlc { .. } | WalletCommand::Transaction { .. } + | WalletCommand::Node { .. } + | WalletCommand::Peer { .. } ) } @@ -2860,6 +2897,58 @@ fn normalize_nested_wallet_command(command: WalletCommand) -> WalletCommand { WalletCommand::CalcHtlcSecretHash { secret } } }, + WalletCommand::Node { command } => match command { + NodeGroupSubCommand::Version => WalletCommand::NodeVersion, + NodeGroupSubCommand::Shutdown => WalletCommand::NodeShutdown, + NodeGroupSubCommand::P2p { enable } => WalletCommand::NodeEnableNetworking { enable }, + NodeGroupSubCommand::SubmitBlock { block } => WalletCommand::SubmitBlock { block }, + NodeGroupSubCommand::SubmitTransaction { + transaction, + do_not_store, + } => WalletCommand::SubmitTransaction { + transaction, + do_not_store, + }, + NodeGroupSubCommand::ChainstateInfo => WalletCommand::ChainstateInfo, + NodeGroupSubCommand::BestBlock { + id, + height, + timestamp, + } => WalletCommand::NodeBestBlock { + id, + height, + timestamp, + }, + NodeGroupSubCommand::BlockId { height } => WalletCommand::BlockId { height }, + NodeGroupSubCommand::Block { id } => WalletCommand::GetBlock { id }, + NodeGroupSubCommand::GenerateBlock { transactions } => { + WalletCommand::GenerateBlock { transactions } + } + }, + WalletCommand::Peer { command } => match command { + PeerGroupSubCommand::Connect { address } => WalletCommand::Connect { address }, + PeerGroupSubCommand::Disconnect { peer_id } => WalletCommand::Disconnect { peer_id }, + PeerGroupSubCommand::Ban { address, duration } => { + WalletCommand::Ban { address, duration } + } + PeerGroupSubCommand::Unban { address } => WalletCommand::Unban { address }, + PeerGroupSubCommand::Banned => WalletCommand::ListBanned, + PeerGroupSubCommand::Discouraged => WalletCommand::ListDiscouraged, + PeerGroupSubCommand::Undiscourage { address } => { + WalletCommand::Undiscourage { address } + } + PeerGroupSubCommand::Count => WalletCommand::PeerCount, + PeerGroupSubCommand::List => WalletCommand::ConnectedPeers, + PeerGroupSubCommand::Reserved { + command: ReservedPeerGroupSubCommand::List, + } => WalletCommand::ReservedPeers, + PeerGroupSubCommand::Reserved { + command: ReservedPeerGroupSubCommand::Add { address }, + } => WalletCommand::AddReservedPeer { address }, + PeerGroupSubCommand::Reserved { + command: ReservedPeerGroupSubCommand::Remove { address }, + } => WalletCommand::RemoveReservedPeer { address }, + }, WalletCommand::Transaction { command } => match command { TransactionGroupSubCommand::Compose { outputs, @@ -2991,6 +3080,60 @@ fn get_token_ids_from_order_infos_as_str<'a>( .collect_vec() } +/// Format best-block values with labels only when more than one value is shown. +/// Values the caller did not fetch are passed as `None`; the caller must always +/// fetch at least one value (its no-flags rule fetches all three), a single fetched +/// value is shown bare, byte-identical to the legacy single-value commands (the +/// unit test pins those shapes), and an all-`None` input renders an empty string. +fn format_best_block( + best_block_id: Option, + best_block_height: Option, + best_block_timestamp: Option, +) -> String { + let selected = [ + best_block_id.as_ref(), + best_block_height.as_ref(), + best_block_timestamp.as_ref(), + ] + .into_iter() + .filter(|value| value.is_some()) + .count(); + debug_assert!( + selected > 0, + "format_best_block requires at least one fetched value; \ + all-None input renders as an empty string" + ); + let labeled = selected != 1; + let mut lines = Vec::new(); + if let Some(value) = best_block_id { + lines.push(if labeled { + format!("Best block id: {value}") + } else { + value + }); + } + if let Some(value) = best_block_height { + lines.push(if labeled { + format!("Best block height: {value}") + } else { + value + }); + } + if let Some(value) = best_block_timestamp { + lines.push(if labeled { + format!("Best block timestamp: {value}") + } else { + value + }); + } + lines.join("\n") +} + +/// Render a block timestamp the way the best-block commands print it. +fn format_block_timestamp(timestamp: BlockTimestamp) -> String { + format!("{} ({})", timestamp, timestamp.into_time()) +} + fn format_chainstate_info(info: &ChainInfo) -> String { format!( "Chainstate info:\n\ @@ -3089,6 +3232,45 @@ mod nested_translation_tests { format!("{flat:?}") } + #[test] + fn best_block_formatter_matches_the_legacy_single_value_shapes() { + // Literal renderings the legacy flat commands produce; the single-flag + // shapes must stay byte-identical to them. + const ZERO_ID_HEX: &str = + "0000000000000000000000000000000000000000000000000000000000000000"; + const GENESIS_TIMESTAMP_RENDERING: &str = "42 (1970-01-01 00:00:42 UTC)"; + + let rendered = ( + ZERO_ID_HEX.to_owned(), + "7".to_owned(), + GENESIS_TIMESTAMP_RENDERING.to_owned(), + ); + + // Exactly one value: bare, byte-identical to the legacy commands. + assert_eq!( + format_best_block(Some(rendered.0.clone()), None, None), + ZERO_ID_HEX + ); + assert_eq!(format_best_block(None, Some(rendered.1.clone()), None), "7"); + assert_eq!( + format_best_block(None, None, Some(rendered.2.clone())), + GENESIS_TIMESTAMP_RENDERING + ); + + // Two values: labeled subset. + assert_eq!( + format_best_block(Some(rendered.0.clone()), Some(rendered.1.clone()), None), + format!("Best block id: {ZERO_ID_HEX}\nBest block height: 7") + ); + + // No values fetched: all three, labeled. + let all = format_best_block(Some(rendered.0), Some(rendered.1), Some(rendered.2)); + assert_eq!(all.lines().count(), 3); + assert!(all.starts_with("Best block id: ")); + assert!(all.contains("Best block height: 7")); + assert!(all.contains("Best block timestamp: ")); + } + #[test] fn nested_spellings_translate_to_the_legacy_flat_commands() { const HEX_PRIVATE_KEY: &str = @@ -3514,6 +3696,94 @@ mod nested_translation_tests { "--only-transaction", ], ), + (&["repl", "node", "version"], &["repl", "node-version"]), + (&["repl", "node", "shutdown"], &["repl", "node-shutdown"]), + ( + &["repl", "node", "p2p", "enable"], + &["repl", "node-enable-p2p-networking", "enable"], + ), + // submit-block / submit-transaction rows are omitted from this table: + // their HexEncoded payloads need valid SCALE encodings to parse from + // argv. Existence and argument agreement are covered by the + // LEGACY_ALIASES walk in lib.rs, and the translation arms destructure + // with field shorthand, so a dropped field is a compile error. + ( + &["repl", "node", "chainstate-info"], + &["repl", "node-chainstate-info"], + ), + ( + &["repl", "node", "best-block", "--id"], + &["repl", "node-best-block", "--id"], + ), + ( + &["repl", "node", "best-block"], + &["repl", "node-best-block"], + ), + ( + &["repl", "node", "best-block", "--height"], + &["repl", "node-best-block", "--height"], + ), + ( + &["repl", "node", "best-block", "--timestamp"], + &["repl", "node-best-block", "--timestamp"], + ), + ( + &["repl", "node", "block-id", "7"], + &["repl", "node-block-id", "7"], + ), + ( + &["repl", "node", "block", "abc"], + &["repl", "node-get-block", "abc"], + ), + ( + &["repl", "node", "generate-block"], + &["repl", "node-generate-block"], + ), + ( + &["repl", "peer", "connect", "10.0.0.1:3000"], + &["repl", "node-connect-to-peer", "10.0.0.1:3000"], + ), + ( + &["repl", "peer", "disconnect", "12345"], + &["repl", "node-disconnect-peer", "12345"], + ), + ( + &["repl", "peer", "ban", "1.2.3.4", "1h"], + &["repl", "node-ban-peer-address", "1.2.3.4", "1h"], + ), + ( + &["repl", "peer", "unban", "1.2.3.4"], + &["repl", "node-unban-peer-address", "1.2.3.4"], + ), + ( + &["repl", "peer", "banned"], + &["repl", "node-list-banned-peers"], + ), + ( + &["repl", "peer", "discouraged"], + &["repl", "node-list-discouraged-peers"], + ), + ( + &["repl", "peer", "undiscourage", "1.2.3.4"], + &["repl", "node-undiscourage-peer-address", "1.2.3.4"], + ), + (&["repl", "peer", "count"], &["repl", "node-peer-count"]), + ( + &["repl", "peer", "list"], + &["repl", "node-list-connected-peers"], + ), + ( + &["repl", "peer", "reserved", "list"], + &["repl", "node-list-reserved-peers"], + ), + ( + &["repl", "peer", "reserved", "add", "10.0.0.2:3000"], + &["repl", "node-add-reserved-peer", "10.0.0.2:3000"], + ), + ( + &["repl", "peer", "reserved", "remove", "10.0.0.2:3000"], + &["repl", "node-remove-reserved-peer", "10.0.0.2:3000"], + ), ]; for (nested, legacy) in cases { diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index ce75dd09c5..926383d41a 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -455,7 +455,7 @@ pub enum AddressGroupSubCommand { #[arg(required_unless_present("all"))] addresses: Vec, /// Sweep all addresses - #[arg(long = "all", default_value_t = false, conflicts_with_all(["addresses"]))] + #[arg(long = "all", conflicts_with_all(["addresses"]))] all: bool, }, /// Manage standalone (non-account) addresses. @@ -765,7 +765,7 @@ pub enum TransactionGroupSubCommand { #[arg(long = "utxos")] utxos: Vec, /// Produce a "simple" transaction hex instead of a PartiallySignedTransaction. - #[arg(long = "only-transaction", default_value_t = false)] + #[arg(long = "only-transaction")] only_transaction: bool, }, /// Create a transaction from a cold wallet input @@ -815,6 +815,129 @@ pub enum TransactionGroupSubCommand { }, } +/// Commands for operating the node +#[derive(Subcommand, Clone, Debug)] +#[clap(rename_all = "kebab-case")] +pub enum NodeGroupSubCommand { + /// Show the version of the running node + Version, + /// Shut down the running node + Shutdown, + /// Enable or disable p2p networking in the node + P2p { + /// Whether to enable or disable p2p networking. + enable: CliEnableOrDisable, + }, + /// Submit a hex-encoded block to the node + SubmitBlock { + /// Hex-encoded block + block: HexEncoded, + }, + /// Submit a hex-encoded transaction to the node + SubmitTransaction { + /// Hex-encoded transaction. + transaction: HexEncoded, + /// Do not store the transaction in the wallet + #[arg(long = "do-not-store")] + do_not_store: bool, + }, + /// Show chainstate information of the running node + ChainstateInfo, + /// Show the best block id, height, and timestamp; pass flags to show a subset + BestBlock { + /// Show the best block id + #[arg(long = "id")] + id: bool, + /// Show the best block height + #[arg(long = "height")] + height: bool, + /// Show the best block timestamp + #[arg(long = "timestamp")] + timestamp: bool, + }, + /// Show the block id at a given height + BlockId { + /// Block height + height: BlockHeight, + }, + /// Show a block by its id + Block { + /// Block id + id: String, + }, + /// Generate a block with the given transactions (regtest) + GenerateBlock { + /// Hex-encoded transactions to include in the block + transactions: Vec>, + }, +} + +/// Commands for managing peers +#[derive(Subcommand, Clone, Debug)] +#[clap(rename_all = "kebab-case")] +pub enum PeerGroupSubCommand { + /// Connect to a peer + Connect { + /// The address of the peer to connect to + address: IpOrSocketAddress, + }, + /// Disconnect from a peer + Disconnect { + /// The id of the peer to disconnect from + peer_id: PeerId, + }, + /// Ban a peer address + Ban { + /// IP address to ban. + address: BannableAddress, + /// Duration of the ban, e.g. 1M (1 month) or "1y 3M 10d 6h 30m 45s" + /// (1 year 3 months 10 days 6 hours 30 minutes 45 seconds). + #[arg(value_parser(humantime::parse_duration))] + duration: Duration, + }, + /// Unban a peer address + Unban { + /// The address to unban + address: BannableAddress, + }, + /// List banned peer addresses + Banned, + /// List discouraged peer addresses + Discouraged, + /// Undiscourage a peer address + Undiscourage { + /// The address to undiscourage + address: BannableAddress, + }, + /// Show the number of connected peers + Count, + /// List connected peers + List, + /// Manage reserved peers + Reserved { + #[command(subcommand)] + command: ReservedPeerGroupSubCommand, + }, +} + +/// Commands for managing the reserved peer list +#[derive(Subcommand, Clone, Debug)] +#[clap(rename_all = "kebab-case")] +pub enum ReservedPeerGroupSubCommand { + /// List reserved peers + List, + /// Add a reserved peer + Add { + /// The address of the peer to reserve + address: IpOrSocketAddress, + }, + /// Remove a reserved peer + Remove { + /// The address of the peer to remove from the reserved list + address: IpOrSocketAddress, + }, +} + /// Mapping between the legacy flat command names and their nested equivalents. /// The flat names remain first-class commands; this manifest documents the /// correspondence and drives the grammar compatibility test. @@ -920,6 +1043,31 @@ pub const LEGACY_ALIASES: &[(&str, &str)] = &[ ("transaction-get", "transaction get"), ("transaction-get-raw", "transaction get-raw"), ("transaction-get-signed-raw", "transaction get-signed-raw"), + ("node-version", "node version"), + ("node-shutdown", "node shutdown"), + ("node-enable-p2p-networking", "node p2p"), + ("node-submit-block", "node submit-block"), + ("node-submit-transaction", "node submit-transaction"), + ("node-chainstate-info", "node chainstate-info"), + ("node-best-block", "node best-block"), + ("node-best-block-id", "node best-block --id"), + ("node-best-block-height", "node best-block --height"), + ("node-best-block-timestamp", "node best-block --timestamp"), + ("node-block-id", "node block-id"), + ("node-get-block", "node block"), + ("node-generate-block", "node generate-block"), + ("node-connect-to-peer", "peer connect"), + ("node-disconnect-peer", "peer disconnect"), + ("node-peer-count", "peer count"), + ("node-list-connected-peers", "peer list"), + ("node-list-banned-peers", "peer banned"), + ("node-list-discouraged-peers", "peer discouraged"), + ("node-ban-peer-address", "peer ban"), + ("node-unban-peer-address", "peer unban"), + ("node-undiscourage-peer-address", "peer undiscourage"), + ("node-list-reserved-peers", "peer reserved list"), + ("node-add-reserved-peer", "peer reserved add"), + ("node-remove-reserved-peer", "peer reserved remove"), ]; #[derive(Debug, Parser)] @@ -1211,6 +1359,18 @@ pub enum WalletCommand { #[command(subcommand)] command: HtlcGroupSubCommand, }, + /// Node management commands + #[clap(name = "node")] + Node { + #[command(subcommand)] + command: NodeGroupSubCommand, + }, + /// Peer management commands + #[clap(name = "peer")] + Peer { + #[command(subcommand)] + command: PeerGroupSubCommand, + }, /// Transaction operations #[clap(name = "transaction")] Transaction { @@ -1487,7 +1647,7 @@ pub enum WalletCommand { #[arg(required_unless_present("all"))] addresses: Vec, /// Sweep all addresses - #[arg(long = "all", default_value_t = false, conflicts_with_all(["addresses"]))] + #[arg(long = "all", conflicts_with_all(["addresses"]))] all: bool, }, @@ -1708,7 +1868,7 @@ pub enum WalletCommand { /// Hex-encoded transaction. transaction: HexEncoded, /// Do not store the transaction in the wallet - #[arg(long = "do-not-store", default_value_t = false)] + #[arg(long = "do-not-store")] do_not_store: bool, }, @@ -1718,6 +1878,21 @@ pub enum WalletCommand { #[clap(name = "node-best-block-id")] BestBlock, + /// Show the best block id, height, and timestamp; pass flags to show a subset + #[clap(name = "node-best-block")] + #[clap(hide = true)] + NodeBestBlock { + /// Show the best block id + #[arg(long = "id")] + id: bool, + /// Show the best block height + #[arg(long = "height")] + height: bool, + /// Show the best block timestamp + #[arg(long = "timestamp")] + timestamp: bool, + }, + #[clap(name = "node-best-block-height")] BestBlockHeight, @@ -1815,7 +1990,7 @@ pub enum WalletCommand { /// /// In general, there is no reason in specifying this option unless you care about the size /// of the resulting hex string. - #[arg(long = "only-transaction", default_value_t = false)] + #[arg(long = "only-transaction")] only_transaction: bool, }, From 6daee141a67af53ef84c4f3609e8ed0b512ec5a8 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 03:11:11 +0200 Subject: [PATCH 17/52] feat(wallet-cli): group cold wallet commands with file based secrets --- node-gui/backend/src/backend_impl.rs | 32 ++- node-gui/backend/src/error.rs | 2 + .../src/command_handler/mod.rs | 248 +++++++++++++++++- wallet/wallet-cli-commands/src/lib.rs | 228 +++++++++++++++- wallet/wallet-cli-lib/src/lib.rs | 67 ++--- .../src/repl/interactive/mod.rs | 6 +- .../src/repl/non_interactive/mod.rs | 6 +- 7 files changed, 525 insertions(+), 64 deletions(-) diff --git a/node-gui/backend/src/backend_impl.rs b/node-gui/backend/src/backend_impl.rs index d069fc4e45..3fec4ee6c0 100644 --- a/node-gui/backend/src/backend_impl.rs +++ b/node-gui/backend/src/backend_impl.rs @@ -34,8 +34,7 @@ use serialization::hex_encoded::HexEncoded; use utils::tokio_spawn; use wallet::{WalletError, account::transaction_list::TransactionList, wallet::Error}; use wallet_cli_commands::{ - CommandHandler, ConsoleCommand, ManageableWalletCommand, WalletCommand, get_repl_command, - parse_input, + CommandHandler, ConsoleCommand, ManageableWalletCommand, WalletCommand, parse_input, }; use wallet_controller::{ ControllerConfig, NodeInterface, UtxoState, WalletHandlesClient, make_cold_wallet_rpc_client, @@ -1205,10 +1204,31 @@ impl Backend { account_id: AccountId, command: String, ) -> Result { - let repl_command = get_repl_command(self.controller.is_cold(), false); - let command = parse_input::(&command, &repl_command) - .map_err(|e| BackendError::InvalidConsoleCommand(e.to_string()))? - .ok_or(BackendError::EmptyConsoleCommand)?; + let cold_wallet = self.controller.is_cold(); + // The grammar build and the input parse both construct large augment + // frames; run them on a sized thread without blocking the backend loop. + let command = tokio::task::spawn_blocking(move || { + wallet_cli_commands::run_with_large_stack(move || { + // Already on a large-stack thread, so build the grammar directly + // instead of nesting another spawn inside `get_repl_command`. + let repl_command = wallet_cli_commands::build_repl_command(cold_wallet, false); + parse_input::(&command, &repl_command) + .map_err(|e| BackendError::InvalidConsoleCommand(e.to_string())) + }) + }) + .await + .map_err(|e| { + if e.is_panic() { + // An internal panic from the grammar build or parse thread is + // not a user input problem; resume unwinding so it is reported + // as such instead of as an invalid console command. + std::panic::resume_unwind(e.into_panic()); + } + // Cancellation (runtime shutdown) is not a user input problem either. + log::warn!("Console command parse task did not complete: {e}"); + BackendError::ConsoleCommandInterrupted(e.to_string()) + })?? + .ok_or(BackendError::EmptyConsoleCommand)?; let wallet = self .wallets diff --git a/node-gui/backend/src/error.rs b/node-gui/backend/src/error.rs index d59afb29fd..0bdacdad89 100644 --- a/node-gui/backend/src/error.rs +++ b/node-gui/backend/src/error.rs @@ -50,6 +50,8 @@ pub enum BackendError { HardwareWalletNotSupportedInColdMode, #[error("Invalid console command: {0}")] InvalidConsoleCommand(String), + #[error("Console command did not complete: {0}")] + ConsoleCommandInterrupted(String), #[error("Empty console command")] EmptyConsoleCommand, diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 69b8b16581..36da90ae48 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -57,15 +57,18 @@ use wallet_rpc_lib::types::{ use wallet_types::partially_signed_transaction::PartiallySignedTransaction; #[cfg(feature = "trezor")] -use crate::{CreateWalletDeviceSelectMenu, OpenWalletDeviceSelectMenu}; +use crate::{ + ChallengeGroupSubCommand, ConfigGroupSubCommand, CreateWalletDeviceSelectMenu, + HistoryGroupSubCommand, OpenWalletDeviceSelectMenu, helper_types::CliForceReduceLookaheadSize, +}; use self::local_state::WalletWithState; use super::{ ColdWalletCommand, ConsoleCommand, WalletCommand, helper_types::{ - CliForceReduceLookaheadSize, CliUtxoState, format_active_order_info, - format_delegation_info, format_own_order_info, format_pool_info, + CliUtxoState, format_active_order_info, format_delegation_info, format_own_order_info, + format_pool_info, }, }; use crate::{ @@ -375,6 +378,76 @@ where } } + /// Read a password from `--password-file`, where `-` means standard input. + /// The trailing line terminator is stripped; any other whitespace is kept. + async fn read_password_from_source( + source: Option<&str>, + ) -> Result> { + // Standard input is read with terminal echo enabled; a hidden interactive + // prompt does not exist yet. + fn read_password_from_stdin() -> std::io::Result { + let mut password = String::new(); + let bytes_read = std::io::stdin().read_line(&mut password)?; + if bytes_read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "no password was provided on standard input", + )); + } + Ok(password) + } + + // One trailing line terminator (`\n` or `\r\n`) is stripped; any other + // whitespace is kept. + fn strip_line_terminator(password: &str) -> &str { + password + .strip_suffix("\r\n") + .or_else(|| password.strip_suffix('\n')) + .unwrap_or(password) + } + + let password = match source { + None => { + return Err(WalletCliCommandError::InvalidInput( + "a password is required: pass --password-file (or \ + --password-file - for standard input); an interactive password \ + prompt is not available yet" + .to_owned(), + )); + } + Some("-") => tokio::task::spawn_blocking(read_password_from_stdin) + .await + .map_err(|err| { + WalletCliCommandError::InvalidInput(format!( + "Failed to read the password from standard input: {err}" + )) + })? + .map_err(|err| { + WalletCliCommandError::InvalidInput(format!( + "Failed to read the password from standard input: {err}" + )) + })?, + Some(path) => { + let path = path.to_owned(); + let read_path = path.clone(); + let read = move || std::fs::read_to_string(read_path); + tokio::task::spawn_blocking(read) + .await + .map_err(|err| { + WalletCliCommandError::InvalidInput(format!( + "Failed to read the password from {path}: {err}" + )) + })? + .map_err(|err| { + WalletCliCommandError::InvalidInput(format!( + "Failed to read the password from {path}: {err}" + )) + })? + } + }; + Ok(strip_line_terminator(&password).to_owned()) + } + async fn handle_cold_wallet_command( &mut self, command: ColdWalletCommand, @@ -384,6 +457,57 @@ where WalletCliCommandError: From, { match command { + // The grouped spellings translate one level down into their flat + // variants; recursing once keeps both spellings on the same behavior. + ColdWalletCommand::Challenge { command } => { + let flat = match command { + ChallengeGroupSubCommand::Sign { + message, + address, + hex, + } => { + if hex { + ColdWalletCommand::SignChallegeHex { message, address } + } else { + ColdWalletCommand::SignChallege { message, address } + } + } + ChallengeGroupSubCommand::Verify { + message, + signed_challenge, + address, + hex, + } => { + if hex { + ColdWalletCommand::VerifyChallengeHex { + message, + signed_challenge, + address, + } + } else { + ColdWalletCommand::VerifyChallenge { + message, + signed_challenge, + address, + } + } + } + }; + Box::pin(self.handle_cold_wallet_command(flat, chain_config)).await + } + ColdWalletCommand::History { command } => { + let flat = match command { + HistoryGroupSubCommand::Print => ColdWalletCommand::PrintHistory, + HistoryGroupSubCommand::Clear => ColdWalletCommand::ClearHistory, + }; + Box::pin(self.handle_cold_wallet_command(flat, chain_config)).await + } + ColdWalletCommand::Clear => { + Box::pin( + self.handle_cold_wallet_command(ColdWalletCommand::ClearScreen, chain_config), + ) + .await + } ColdWalletCommand::WalletInfo => { let info = self.non_empty_wallet().await?.wallet_info().await?; let wallet_description = match info.extra_info { @@ -421,6 +545,10 @@ where } ColdWalletCommand::EncryptPrivateKeys { password } => { + logging::log::warn!( + "The password was passed on the command line, which is insecure; \ + prefer `wallet encrypt --password-file `" + ); self.non_empty_wallet().await?.encrypt_private_keys(password).await?; Ok(ConsoleCommand::Print( @@ -437,6 +565,10 @@ where } ColdWalletCommand::UnlockPrivateKeys { password } => { + logging::log::warn!( + "The password was passed on the command line, which is insecure; \ + prefer `wallet unlock --password-file `" + ); self.non_empty_wallet().await?.unlock_private_keys(password).await?; Ok(ConsoleCommand::Print( @@ -888,16 +1020,94 @@ where self.handle_cold_wallet_command(command, chain_config).await } WalletCommand::Wallet { command } => { - let management = match command { + // The cold wallet subcommands reuse the cold wallet handlers, so + // their behavior is identical to the legacy flat spellings. + let management = match &command { + WalletGroupSubCommand::Info => { + return self + .handle_cold_wallet_command(ColdWalletCommand::WalletInfo, chain_config) + .await; + } + WalletGroupSubCommand::Encrypt { password_file } => { + let password = + Self::read_password_from_source::(password_file.as_deref()).await?; + return self + .handle_cold_wallet_command( + ColdWalletCommand::EncryptPrivateKeys { password }, + chain_config, + ) + .await; + } + WalletGroupSubCommand::Unlock { password_file } => { + let password = + Self::read_password_from_source::(password_file.as_deref()).await?; + return self + .handle_cold_wallet_command( + ColdWalletCommand::UnlockPrivateKeys { password }, + chain_config, + ) + .await; + } + WalletGroupSubCommand::Decrypt => { + return self + .handle_cold_wallet_command( + ColdWalletCommand::RemovePrivateKeysEncryption, + chain_config, + ) + .await; + } + WalletGroupSubCommand::Lock => { + return self + .handle_cold_wallet_command( + ColdWalletCommand::LockPrivateKeys, + chain_config, + ) + .await; + } + WalletGroupSubCommand::ShowSeedPhrase => { + return self + .handle_cold_wallet_command( + ColdWalletCommand::ShowSeedPhrase, + chain_config, + ) + .await; + } + WalletGroupSubCommand::PurgeSeedPhrase => { + return self + .handle_cold_wallet_command( + ColdWalletCommand::PurgeSeedPhrase, + chain_config, + ) + .await; + } + WalletGroupSubCommand::SetLookaheadSize { + lookahead_size, + force, + } => { + return self + .handle_cold_wallet_command( + ColdWalletCommand::SetLookaheadSize { + lookahead_size: *lookahead_size, + i_know_what_i_am_doing: force + .then_some(CliForceReduceLookaheadSize::IKnowWhatIAmDoing), + }, + chain_config, + ) + .await; + } WalletGroupSubCommand::Create { wallet } => { - WalletManagementCommand::CreateWallet { wallet } + WalletManagementCommand::CreateWallet { + wallet: wallet.clone(), + } } WalletGroupSubCommand::Recover { wallet } => { - WalletManagementCommand::RecoverWallet { wallet } - } - WalletGroupSubCommand::Open { wallet } => { - WalletManagementCommand::OpenWallet { wallet } + WalletManagementCommand::RecoverWallet { + wallet: wallet.clone(), + } } + WalletGroupSubCommand::Open { wallet } => WalletManagementCommand::OpenWallet { + wallet: wallet.clone(), + }, WalletGroupSubCommand::Close => WalletManagementCommand::CloseWallet, }; self.handle_wallet_management_command(management).await @@ -917,7 +1127,8 @@ where | WalletCommand::Htlc { .. } | WalletCommand::Transaction { .. } | WalletCommand::Node { .. } - | WalletCommand::Peer { .. }) => { + | WalletCommand::Peer { .. } + | WalletCommand::Config { .. }) => { // Nested groups translate to their flat equivalents. The recursive call // is boxed to keep the future size finite; translation always produces a // flat command, so the recursion never goes deeper than one level. @@ -2973,6 +3184,9 @@ fn normalize_nested_wallet_command(command: WalletCommand) -> WalletCommand { TransactionGroupSubCommand::Inspect { transaction } => { WalletCommand::InspectTransaction { transaction } } + TransactionGroupSubCommand::Sign { transaction } => { + WalletCommand::ColdCommands(ColdWalletCommand::SignRawTransaction { transaction }) + } TransactionGroupSubCommand::Abandon { transaction_id } => { WalletCommand::AbandonTransaction { transaction_id } } @@ -2990,6 +3204,9 @@ fn normalize_nested_wallet_command(command: WalletCommand) -> WalletCommand { WalletCommand::GetRawSignedTransaction { transaction_id } } }, + WalletCommand::Config { + command: ConfigGroupSubCommand::Broadcast { broadcast }, + } => WalletCommand::ConfigBroadcast { broadcast }, command => command, } } @@ -3222,8 +3439,17 @@ mod nested_translation_tests { use super::*; use clap::Parser; + // Parsing builds and augments the whole grammar tree on the calling + // thread, so it runs through `run_with_large_stack`. + fn parse_with_large_stack(argv: &[&str]) -> WalletCommand { + let owned: Vec = argv.iter().map(|arg| arg.to_string()).collect(); + crate::run_with_large_stack(move || { + WalletCommand::try_parse_from(owned).expect("argv must parse") + }) + } + fn flat_debug(argv: &[&str]) -> String { - let command = WalletCommand::try_parse_from(argv).expect("argv must parse"); + let command = parse_with_large_stack(argv); let flat = normalize_nested_wallet_command(command); assert!( !is_nested_wallet_command(&flat), diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 926383d41a..24af3848a9 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -306,6 +306,43 @@ pub enum WalletGroupSubCommand { }, /// Close the wallet. Close, + /// Show info about the currently open wallet. + Info, + /// Encrypt the private keys of the wallet with a password. + /// + /// The password is read from the file given by `--password-file` (`-` for + /// standard input); it is never passed as a command line argument. + Encrypt { + /// File containing the new encryption password, or `-` for standard input. + #[arg(long = "password-file")] + password_file: Option, + }, + /// Unlock the encrypted private keys of the wallet with the encryption password. + /// + /// The password is read from the file given by `--password-file` (`-` for + /// standard input); it is never passed as a command line argument. + Unlock { + /// File containing the current encryption password, or `-` for standard input. + #[arg(long = "password-file")] + password_file: Option, + }, + /// Remove the encryption from the private keys of the wallet. + Decrypt, + /// Lock the encrypted private keys of the wallet again. + Lock, + /// Show the seed phrase of the wallet. + ShowSeedPhrase, + /// Remove the seed phrase from the wallet permanently. + PurgeSeedPhrase, + /// Set the lookahead size of the wallet. + SetLookaheadSize { + /// The new lookahead size + lookahead_size: u32, + /// Force the reduction of the lookahead size even below the last used address; + /// this may cause the wallet to lose track of used addresses and its actual balance. + #[arg(long = "force")] + force: bool, + }, } /// The nested account grammar: `account create|rename|select|utxos|balance|show-xpub`. @@ -781,6 +818,14 @@ pub enum TransactionGroupSubCommand { #[arg(long = "change")] change_address: Option, }, + /// Sign a raw transaction or partially signed transaction with the wallet's keys. + /// + /// Intended for cold wallets; on a hot wallet this maps onto the cold wallet + /// sign command and is rejected by the mode check. + Sign { + /// Hex-encoded transaction or PartiallySignedTransaction. + transaction: String, + }, /// Inspect a hex-encoded transaction Inspect { /// Hex-encoded transaction or PartiallySignedTransaction. @@ -938,6 +983,61 @@ pub enum ReservedPeerGroupSubCommand { }, } +/// The challenge grammar: `challenge sign|verify`, merging the hex and plain legacy +/// spellings behind a `--hex` flag. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum ChallengeGroupSubCommand { + /// Sign a message with the private key of an address. + Sign { + /// The message to be signed (hex-encoded with `--hex`) + message: String, + /// Address with whose private key to sign the challenge + address: String, + /// Treat the message as hex-encoded instead of plain text + #[arg(long = "hex")] + hex: bool, + }, + /// Verify a signed challenge against an address. + Verify { + /// The message that was signed (hex-encoded with `--hex`) + message: String, + /// Hex-encoded signed challenge + signed_challenge: String, + /// Address with whose private key the challenge was signed with + address: String, + /// Treat the message as hex-encoded instead of plain text + #[arg(long = "hex")] + hex: bool, + }, +} + +/// The history grammar: `history print|clear`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum HistoryGroupSubCommand { + /// Print command history for this execution. + Print, + /// Clear command history for this execution. + Clear, +} + +/// The configuration grammar: `config broadcast`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum ConfigGroupSubCommand { + /// Configure broadcasting to Mempool to yes or no. + /// + /// If set to no, any command that creates a transaction will return it to the user + /// and not submit it automatically. The transaction will need to be submitted + /// manually with the command `node-submit-transaction`. The effect of this is not + /// preserved when the CLI wallet is closed. + Broadcast { + #[arg(value_enum)] + broadcast: CliYesNo, + }, +} + /// Mapping between the legacy flat command names and their nested equivalents. /// The flat names remain first-class commands; this manifest documents the /// correspondence and drives the grammar compatibility test. @@ -1053,6 +1153,26 @@ pub const LEGACY_ALIASES: &[(&str, &str)] = &[ ("node-best-block-id", "node best-block --id"), ("node-best-block-height", "node best-block --height"), ("node-best-block-timestamp", "node best-block --timestamp"), + ("wallet-info", "wallet info"), + // wallet encrypt / unlock read the password from --password-file or stdin; the + // legacy spellings keep the positional password (legacy parse shape). + ("wallet-encrypt-private-keys", "wallet encrypt"), + ("wallet-unlock-private-keys", "wallet unlock"), + ("wallet-disable-private-keys-encryption", "wallet decrypt"), + ("wallet-lock-private-keys", "wallet lock"), + ("wallet-show-seed-phrase", "wallet show-seed-phrase"), + ("wallet-purge-seed-phrase", "wallet purge-seed-phrase"), + // The legacy set-lookahead-size consent enum maps to the --force flag. + ("wallet-set-lookahead-size", "wallet set-lookahead-size"), + ("account-sign-raw-transaction", "transaction sign"), + ("challenge-sign-plain", "challenge sign"), + ("challenge-sign-hex", "challenge sign --hex"), + ("challenge-verify-plain", "challenge verify"), + ("challenge-verify-hex", "challenge verify --hex"), + ("history-print", "history print"), + ("history-clear", "history clear"), + ("screen-clear", "clear"), + ("config-broadcast", "config broadcast"), ("node-block-id", "node block-id"), ("node-get-block", "node block"), ("node-generate-block", "node generate-block"), @@ -1126,6 +1246,10 @@ pub enum ColdWalletCommand { #[clap(name = "wallet-encrypt-private-keys")] EncryptPrivateKeys { /// The new encryption password + /// + /// Passing the password on the command line is insecure; the nested + /// `wallet encrypt --password-file ` spelling reads it from a file + /// instead. password: String, }, @@ -1135,6 +1259,10 @@ pub enum ColdWalletCommand { #[clap(name = "wallet-unlock-private-keys")] UnlockPrivateKeys { /// The current encryption password. + /// + /// Passing the password on the command line is insecure; the nested + /// `wallet unlock --password-file ` spelling reads it from a file + /// instead. password: String, }, @@ -1264,6 +1392,24 @@ pub enum ColdWalletCommand { #[clap(name = "screen-clear")] ClearScreen, + /// Challenge signing and verification. + #[clap(name = "challenge")] + Challenge { + #[command(subcommand)] + command: ChallengeGroupSubCommand, + }, + + /// Command history. + #[clap(name = "history")] + History { + #[command(subcommand)] + command: HistoryGroupSubCommand, + }, + + /// Clear screen. + #[clap(name = "clear")] + Clear, + Version, } @@ -1285,6 +1431,13 @@ pub enum WalletCommand { broadcast: CliYesNo, }, + /// Configuration commands (broadcast). + #[clap(name = "config")] + Config { + #[command(subcommand)] + command: ConfigGroupSubCommand, + }, + /// Wallet management commands (create, recover, open, close). /// The flat commands (`wallet-create`, `wallet-recover`, ...) remain available. #[clap(name = "wallet")] @@ -2360,6 +2513,42 @@ const COMMAND_HELP_TEMPLATE: &str = "\ "; pub fn get_repl_command(cold_wallet: bool, mutable_wallet: bool) -> Command { + run_with_large_stack(move || build_repl_command(cold_wallet, mutable_wallet)) +} + +/// Stack size for threads that build or parse against the REPL grammar; the +/// clap-generated `augment_subcommands` frames the nested grammar produces are +/// large and overflow default 2 MiB thread stacks in debug builds. +pub const LARGE_STACK_SIZE: usize = 64 * 1024 * 1024; + +/// Run `task` on a dedicated thread whose stack is large enough for the +/// clap-generated grammar construction. +/// +/// The `augment_subcommands` frames the nested grammar produces are large; on +/// the default 2 MiB thread stacks used by the CLI REPL thread, GUI workers, +/// and test threads, building or parsing against the grammar can overflow the +/// stack in debug builds. +pub fn run_with_large_stack(task: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new() + .stack_size(LARGE_STACK_SIZE) + .spawn(task) + .expect("failed to spawn the grammar thread") + .join() + .unwrap_or_else(|payload| { + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + panic!("the grammar thread panicked: {message}") + }) +} + +/// Build the REPL command tree on the calling thread. +/// +/// Prefer [`get_repl_command`], which runs this on a large-stack thread; call +/// this directly only when already running on one. +pub fn build_repl_command(cold_wallet: bool, mutable_wallet: bool) -> Command { const COLD_WALLET_DESC: &Module = &ColdWalletRpcDescription::DESCRIPTION; const WALLET_DESC: &Module = &WalletRpcDescription::DESCRIPTION; @@ -2378,16 +2567,23 @@ pub fn get_repl_command(cold_wallet: bool, mutable_wallet: bool) -> Command { WalletCommand::augment_subcommands(repl_command) }; - // The nested `wallet` group maps onto the management commands, so it is only - // available for mutable wallets, matching the flat `wallet-create` etc. commands. - // `subcommands` only appends, so the group is replaced with a childless stub. + // The nested `wallet` group carries the management commands, so for + // non-mutable wallets the group is rebuilt without `create`, `recover`, + // `open` and `close`; the remaining spellings stay available and match the + // flat commands, which are gated the same way below. if !cold_wallet && !mutable_wallet { repl_command = repl_command.mut_subcommand("wallet", |wallet| { - let mut stub = Command::new(wallet.get_name().to_owned()); + const MANAGEMENT_COMMANDS: [&str; 4] = ["create", "recover", "open", "close"]; + let mut group = Command::new(wallet.get_name().to_owned()); if let Some(about) = wallet.get_about() { - stub = stub.about(about.clone()); + group = group.about(about.clone()); } - stub + for subcommand in wallet.get_subcommands() { + if !MANAGEMENT_COMMANDS.contains(&subcommand.get_name()) { + group = group.subcommand(subcommand.clone()); + } + } + group }); } @@ -2548,10 +2744,18 @@ mod tests { level } + // The clap-generated `augment_subcommands` frames of the nested grammar are large; + // test threads default to a 2 MiB stack, so grammar construction runs on a dedicated + // thread with a bigger one. + // `get_repl_command` builds the grammar on a large-stack thread internally, + // so the tests can call it directly. + fn grammar_with_large_stack(cold_wallet: bool, mutable_wallet: bool) -> Command { + get_repl_command(cold_wallet, mutable_wallet) + } + #[test] fn legacy_command_names_and_group_grammar_coexist() { - let mut repl = get_repl_command(false, true); - repl.build(); + let repl = grammar_with_large_stack(false, true); // Every legacy flat command must still exist at the top level. for (legacy, _nested) in LEGACY_ALIASES { @@ -2603,8 +2807,7 @@ mod tests { fn non_mutable_grammar_excludes_wallet_management() { // Hot wallet without management: neither the flat management commands nor the // nested `wallet` group may be available. - let mut repl = get_repl_command(false, false); - repl.build(); + let repl = grammar_with_large_stack(false, false); assert!(repl.find_subcommand("wallet-create").is_none()); let wallet_group = repl .find_subcommand("wallet") @@ -2615,8 +2818,7 @@ mod tests { #[test] fn mutable_grammar_includes_the_wallet_group() { - let mut repl = get_repl_command(false, true); - repl.build(); + let repl = grammar_with_large_stack(false, true); let wallet_group = repl .find_subcommand("wallet") .expect("the wallet group is part of the hot wallet grammar"); @@ -2629,7 +2831,7 @@ mod tests { #[values(false, true)] cold_wallet: bool, #[values(false, true)] mutable_wallet: bool, ) { - let repl_command = get_repl_command(cold_wallet, mutable_wallet); + let repl_command = grammar_with_large_stack(cold_wallet, mutable_wallet); let command_names_without_descr = repl_command .get_subcommands() diff --git a/wallet/wallet-cli-lib/src/lib.rs b/wallet/wallet-cli-lib/src/lib.rs index 2490e7b3ff..106874b0c5 100644 --- a/wallet/wallet-cli-lib/src/lib.rs +++ b/wallet/wallet-cli-lib/src/lib.rs @@ -38,7 +38,8 @@ use rpc::RpcAuthData; use tokio::sync::mpsc; use utils::{cookie::COOKIE_FILENAME, default_data_dir::default_data_dir_for_chain, ensure}; use wallet_cli_commands::{ - ManageableWalletCommand, OpenWalletSubCommand, WalletCommand, WalletManagementCommand, + LARGE_STACK_SIZE, ManageableWalletCommand, OpenWalletSubCommand, WalletCommand, + WalletManagementCommand, }; use wallet_rpc_lib::{cmdline::make_wallet_config, config::WalletRpcConfig, types::NodeInterface}; @@ -367,35 +368,41 @@ fn setup_events_and_repl( None }; - // Run a blocking loop in a separate thread - let repl = std::thread::spawn(move || match mode { - Mode::Interactive { logger } => repl::interactive::run( - output, - event_tx, - args.exit_on_error.unwrap_or(false), - logger, - args.history_file, - args.vi_mode, - startup_command_futures, - args.cold_wallet, - ), - Mode::NonInteractive => repl::non_interactive::run( - input, - output, - event_tx, - args.exit_on_error.unwrap_or(false), - args.cold_wallet, - startup_command_futures, - ), - Mode::CommandsList { file_input } => repl::non_interactive::run( - file_input, - output, - event_tx, - args.exit_on_error.unwrap_or(true), - args.cold_wallet, - startup_command_futures, - ), - }); + // Run a blocking loop in a separate thread; the stack is sized for the + // clap-generated grammar construction, whose augment frames are large. + let repl = { + let builder = std::thread::Builder::new().stack_size(LARGE_STACK_SIZE); + builder + .spawn(move || match mode { + Mode::Interactive { logger } => repl::interactive::run( + output, + event_tx, + args.exit_on_error.unwrap_or(false), + logger, + args.history_file, + args.vi_mode, + startup_command_futures, + args.cold_wallet, + ), + Mode::NonInteractive => repl::non_interactive::run( + input, + output, + event_tx, + args.exit_on_error.unwrap_or(false), + args.cold_wallet, + startup_command_futures, + ), + Mode::CommandsList { file_input } => repl::non_interactive::run( + file_input, + output, + event_tx, + args.exit_on_error.unwrap_or(true), + args.cold_wallet, + startup_command_futures, + ), + }) + .map_err(Box::::from)? + }; Ok((repl, wallet_rpc_config)) } diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index cba21b3c3c..09bc316dbf 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -31,7 +31,7 @@ use reedline::{ use tokio::sync::{mpsc, oneshot}; use utils::once_destructor::OnceDestructor; use wallet_cli_commands::{ - ChoiceMenu, ConsoleCommand, ManageableWalletCommand, get_repl_command, parse_input, + ChoiceMenu, ConsoleCommand, ManageableWalletCommand, build_repl_command, parse_input, }; use wallet_rpc_lib::types::NodeInterface; @@ -129,7 +129,9 @@ pub fn run( startup_command_futures: Vec>>>, cold_wallet: bool, ) -> Result<(), WalletCliError> { - let repl_command = get_repl_command(cold_wallet, true); + // The REPL thread is sized for grammar construction, so build the + // grammar directly instead of nesting another large-stack spawn. + let repl_command = build_repl_command(cold_wallet, true); let commands = repl_command .get_subcommands() diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index a4674ef15e..0d952f9540 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -17,7 +17,7 @@ pub mod log; use clap::Command; use tokio::sync::{mpsc, oneshot}; -use wallet_cli_commands::{ConsoleCommand, get_repl_command, parse_input}; +use wallet_cli_commands::{ConsoleCommand, build_repl_command, parse_input}; use wallet_rpc_lib::types::NodeInterface; use crate::{ConsoleInput, cli_event_loop::Event, console::ConsoleOutput, errors::WalletCliError}; @@ -98,7 +98,9 @@ pub fn run( } } - let repl_command = get_repl_command(cold_wallet, true); + // The REPL thread is sized for grammar construction, so build the + // grammar directly instead of nesting another large-stack spawn. + let repl_command = build_repl_command(cold_wallet, true); while let Some(line) = input.read_line() { let res = process_line(&repl_command, &event_tx, &line); From 6ec9e2f72164ea91938f6ad0f96d4094395130f3 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 05:16:12 +0200 Subject: [PATCH 18/52] feat(wallet-cli): complete nested commands with a grammar walker --- .../src/repl/interactive/mod.rs | 32 +- .../repl/interactive/wallet_completions.rs | 436 ++++++++++++++++-- .../src/repl/non_interactive/mod.rs | 4 +- 3 files changed, 430 insertions(+), 42 deletions(-) diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 09bc316dbf..d751aa3e6b 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -47,15 +47,16 @@ const COMPLETION_MENU_NAME: &str = "completion_menu"; fn create_line_editor( printer: reedline::ExternalPrinter, - commands: Vec, + completer: wallet_completions::WalletCompletions, history: Option>, vi_mode: bool, ) -> Reedline { - let completer = Box::new(wallet_completions::WalletCompletions::new(commands)); + let hinter = wallet_completions::WalletHinter::new(completer.clone()); let mut line_editor = Reedline::create() .with_external_printer(printer) - .with_completer(completer) + .with_completer(Box::new(completer)) + .with_hinter(Box::new(hinter)) .with_quick_completions(false) .with_partial_completions(true) .with_validator(Box::new(DefaultValidator)) @@ -130,14 +131,8 @@ pub fn run( cold_wallet: bool, ) -> Result<(), WalletCliError> { // The REPL thread is sized for grammar construction, so build the - // grammar directly instead of nesting another large-stack spawn. - let repl_command = build_repl_command(cold_wallet, true); - - let commands = repl_command - .get_subcommands() - .filter(|command| !command.is_hide_set()) - .map(|command| command.get_name().to_owned()) - .collect::>(); + // grammar directly instead of nesting another large-stack spawn. + let repl_command = build_repl_command(cold_wallet, true); let history = if let Some(file_name) = history_file { let h: Box = Box::new( @@ -149,7 +144,12 @@ pub fn run( None }; - let mut line_editor = create_line_editor(logger.printer().clone(), commands, history, vi_mode); + let mut line_editor = create_line_editor( + logger.printer().clone(), + wallet_completions::WalletCompletions::new(repl_command.clone()), + history, + vi_mode, + ); let mut prompt = wallet_prompt::WalletPrompt::new(); @@ -256,8 +256,12 @@ fn handle_response( Ok(Some(ConsoleCommand::Exit)) => return CommandResponse::Exit, Ok(Some(ConsoleCommand::ChoiceMenu(choice))) => { - let line_editor_helper = - create_line_editor(logger.printer().clone(), vec![], None, vi_mode); + let line_editor_helper = create_line_editor( + logger.printer().clone(), + wallet_completions::WalletCompletions::empty(), + None, + vi_mode, + ); let cmd = handle_choice_menu(choice.as_ref(), line_editor_helper); if let Some(cmd) = cmd { return CommandResponse::Command(Box::new(cmd)); diff --git a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs index 1c45dc678d..868c62ae3d 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs @@ -13,45 +13,429 @@ // See the License for the specific language governing permissions and // limitations under the License. -use reedline::{Completer, DefaultCompleter, Span, Suggestion}; +use std::collections::BTreeSet; -/// A wrapper for [reedline::DefaultCompleter] that also returns -/// all available commands if the input is empty. +use clap::{Arg, ArgAction, Command}; +use reedline::{Completer, Hinter, Span, Suggestion}; + +/// A completer that walks the clap command tree, offering subcommand names, +/// long flags, and enumeration values for the token in front of the cursor. #[derive(Clone)] pub struct WalletCompletions { - external_commands: Vec, - inner: DefaultCompleter, + root: std::sync::Arc, } impl WalletCompletions { - pub fn new(mut external_commands: Vec) -> Self { - external_commands.sort(); - let mut completer = DefaultCompleter::with_inclusions(&['-']); - completer.insert(external_commands.clone()); + pub fn new(mut root: Command) -> Self { + root.build(); + Self { + root: std::sync::Arc::new(root), + } + } + + /// A completer without any candidates, used where completion is not + /// meaningful (for example the choice menu). + pub fn empty() -> Self { + Self::new(Command::new("empty").disable_help_flag(true).disable_help_subcommand(true)) + } +} + +/// One candidate token with its help description. +struct Candidate { + value: String, + description: Option, +} + +fn flag_candidates(cmd: &Command, taken: &BTreeSet) -> Vec { + let mut candidates = Vec::new(); + for arg in cmd.get_arguments() { + let Some(long) = arg.get_long() else { + continue; + }; + if arg.is_hide_set() { + continue; + } + let repeatable = matches!(arg.get_action(), ArgAction::Append); + if !repeatable && taken.contains(long) { + continue; + } + let mut description = + arg.get_long_help().or_else(|| arg.get_help()).map(|help| help.to_string()); + if arg.get_num_args().is_some_and(|range| range.takes_values()) { + let value_name = match arg.get_value_names() { + Some(names) => { + names.iter().map(|name| name.to_string()).collect::>().join(" ") + } + None => "value".to_owned(), + }; + description = Some(match description { + Some(help) => format!("{help} <{value_name}>"), + None => format!("<{value_name}>"), + }); + } + candidates.push(Candidate { + value: format!("--{long}"), + description, + }); + } + candidates +} + +fn subcommand_candidates(cmd: &Command) -> Vec { + let mut candidates = Vec::new(); + for sub in cmd.get_subcommands() { + if sub.is_hide_set() { + continue; + } + let description = sub.get_about().map(|about| about.to_string()); + candidates.push(Candidate { + value: sub.get_name().to_owned(), + description: description.clone(), + }); + for alias in sub.get_all_aliases() { + candidates.push(Candidate { + value: alias.to_owned(), + description: description.clone(), + }); + } + } + candidates +} - WalletCompletions { - external_commands, - inner: completer, +/// Candidates for the positional argument at `index` (zero based) of `cmd`. +fn positional_candidates(cmd: &Command, index: usize) -> Vec { + let positionals: Vec<&Arg> = cmd + .get_arguments() + .filter(|arg| arg.is_positional() && !arg.is_hide_set()) + .collect(); + let arg = positionals.get(index).copied().or_else(|| { + positionals + .last() + .copied() + .filter(|last| matches!(last.get_action(), ArgAction::Append)) + }); + let Some(arg) = arg else { + return Vec::new(); + }; + arg.get_possible_values() + .into_iter() + .map(|value| Candidate { + value: value.get_name().to_owned(), + description: value.get_help().map(|help| help.to_string()), + }) + .collect() +} + +fn value_candidates(pending_value_flag: Option<&Arg>) -> Vec { + let Some(arg) = pending_value_flag else { + return Vec::new(); + }; + arg.get_possible_values() + .into_iter() + .map(|value| Candidate { + value: value.get_name().to_owned(), + description: value.get_help().map(|help| help.to_string()), + }) + .collect() +} + +/// Walk the tokenized prefix of the line and return the command level the +/// cursor sits at, the long flags already given at that level, the number of +/// positional values consumed, and a flag whose value is still missing. +struct Level<'a> { + cmd: &'a Command, + taken_flags: BTreeSet, + positional_index: usize, + pending_value_flag: Option<&'a Arg>, +} + +fn resolve_level<'a>(root: &'a Command, tokens: &[&str]) -> Level<'a> { + let mut cmd = root; + let mut taken_flags = BTreeSet::new(); + let mut positional_index = 0usize; + let mut pending_value_flag: Option<&'a Arg> = None; + for token in tokens { + if let Some(stripped) = token.strip_prefix("--") { + pending_value_flag = None; + let (long, inline_value) = match stripped.split_once('=') { + Some((long, _)) => (long, true), + None => (stripped, false), + }; + if let Some(arg) = cmd.get_arguments().find(|arg| arg.get_long() == Some(long)) { + let repeatable = matches!(arg.get_action(), ArgAction::Append); + if !repeatable { + taken_flags.insert(long.to_owned()); + } + let takes_value = arg.get_num_args().is_some_and(|range| range.takes_values()); + if takes_value && !inline_value { + // The flag still awaits its value; remember it in case the + // next token is being completed. + pending_value_flag = Some(arg); + } + } + continue; + } + if token.len() > 1 && token.starts_with('-') { + // A short flag (such as the automatic -h) is never a positional. + pending_value_flag = None; + let short = token.chars().nth(1); + if let Some(arg) = cmd + .get_arguments() + .find(|arg| arg.get_short().is_some_and(|s| Some(s) == short)) + { + let takes_value = arg.get_num_args().is_some_and(|range| range.takes_values()); + let value_attached = token.chars().count() > 2; + if takes_value && !value_attached { + pending_value_flag = Some(arg); + } + } + continue; + } + // Not a flag: the value of the preceding flag, a subcommand, or a + // positional value, in that order. + if pending_value_flag.take().is_some() { + continue; } + if let Some(sub) = cmd.find_subcommand(token).filter(|sub| !sub.is_hide_set()) { + cmd = sub; + taken_flags.clear(); + positional_index = 0; + pending_value_flag = None; + continue; + } + positional_index += 1; + } + Level { + cmd, + taken_flags, + positional_index, + pending_value_flag, } } impl Completer for WalletCompletions { - fn complete(&mut self, line: &str, pos: usize) -> Vec { - if line.is_empty() { - self.external_commands - .iter() - .map(|cmd| Suggestion { - value: cmd.clone(), - description: None, - extra: None, - span: Span::new(0, 0), - append_whitespace: false, - style: None, - }) - .collect() + fn complete(&mut self, line: &str, pos: usize) -> Vec { + let prefix = line.get(..pos).unwrap_or(line); + let ends_with_space = prefix.ends_with(char::is_whitespace); + let tokens: Vec<&str> = prefix.split_whitespace().collect(); + let (tokens, current): (Vec<&str>, &str) = if ends_with_space || tokens.is_empty() { + (tokens, "") + } else { + ( + tokens[..tokens.len() - 1].to_vec(), + tokens[tokens.len() - 1], + ) + }; + + let level = resolve_level(&self.root, &tokens); + let candidates = if current.starts_with("--") { + flag_candidates(level.cmd, &level.taken_flags) + } else if level.pending_value_flag.is_some() { + value_candidates(level.pending_value_flag) + } else { + let mut candidates = subcommand_candidates(level.cmd); + candidates.extend(positional_candidates(level.cmd, level.positional_index)); + candidates + }; + + let start = pos.saturating_sub(current.len()); + candidates + .into_iter() + .filter(|candidate| candidate.value.starts_with(current)) + .map(|candidate| Suggestion { + value: candidate.value, + description: candidate.description, + extra: None, + span: Span::new(start, pos), + append_whitespace: false, + style: None, + }) + .collect() + } +} + +/// Ghost-text hints built on top of [`WalletCompletions`]: the first matching +/// completion is shown dimmed after the cursor. +pub struct WalletHinter { + completer: WalletCompletions, + current_hint: String, +} + +impl WalletHinter { + pub fn new(completer: WalletCompletions) -> Self { + Self { + completer, + current_hint: String::new(), + } + } +} + +impl Hinter for WalletHinter { + fn handle( + &mut self, + line: &str, + pos: usize, + _history: &dyn reedline::History, + use_ansi_coloring: bool, + _cwd: &str, + ) -> String { + self.current_hint = self + .completer + .complete(line, pos) + .first() + .map(|suggestion| { + let typed = pos.saturating_sub(suggestion.span.start); + suggestion.value.get(typed..).unwrap_or_default().to_owned() + }) + .unwrap_or_default(); + if use_ansi_coloring && !self.current_hint.is_empty() { + format!("\x1b[2m{}\x1b[0m", self.current_hint) } else { - self.inner.complete(line, pos) + self.current_hint.clone() } } + + fn complete_hint(&self) -> String { + self.current_hint.clone() + } + + fn next_hint_token(&self) -> String { + self.current_hint.split_whitespace().next().unwrap_or_default().to_owned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::{CommandFactory, Parser}; + use reedline::History; + + /// A small grammar with the shapes the completer must understand. + #[derive(Parser, Debug)] + #[clap(name = "test")] + enum TestCommand { + /// Send an amount + Send { + /// Destination address + address: String, + }, + /// Set the broadcast behavior + Broadcast { + /// Whether to broadcast + #[clap(long = "broadcast")] + broadcast: YesNo, + }, + /// Group of commands + Group { + #[clap(subcommand)] + command: GroupSubCommand, + }, + } + + #[derive(clap::Subcommand, Debug)] + enum GroupSubCommand { + /// Show the status + Status { + /// Restrict the output + kind: Kind, + }, + } + + #[derive(clap::ValueEnum, Clone, Debug)] + enum YesNo { + Yes, + No, + } + + #[derive(clap::ValueEnum, Clone, Debug)] + enum Kind { + /// Everything + Full, + /// A short summary + Short, + } + + fn completer() -> WalletCompletions { + WalletCompletions::new(TestCommand::command()) + } + + fn values(suggestions: &[Suggestion]) -> Vec<&str> { + suggestions.iter().map(|suggestion| suggestion.value.as_str()).collect() + } + + #[test] + fn empty_line_offers_top_level_commands() { + let mut completer = completer(); + let suggestions = completer.complete("", 0); + let values = values(&suggestions); + assert_eq!(values, vec!["send", "broadcast", "group", "help"]); + } + + #[test] + fn nested_subcommands_are_offered_after_the_group() { + let mut completer = completer(); + let suggestions = completer.complete("group ", 6); + assert_eq!(values(&suggestions), vec!["status", "help"]); + } + + #[test] + fn flags_are_offered_and_taken_ones_skipped() { + let mut completer = completer(); + let suggestions = completer.complete("broadcast --", 12); + let all = values(&suggestions); + assert_eq!(all, vec!["--broadcast", "--help"]); + let suggestions = completer.complete("broadcast --broadcast yes --", 28); + let remaining = values(&suggestions); + assert!(!remaining.contains(&"--broadcast")); + } + + #[test] + fn enum_values_are_offered_for_flags_and_positionals() { + let mut completer = completer(); + let suggestions = completer.complete("broadcast --broadcast ", 22); + let after_flag = values(&suggestions); + assert_eq!(after_flag, vec!["yes", "no"]); + let suggestions = completer.complete("group status ", 13); + let positional = values(&suggestions); + assert_eq!(positional, vec!["full", "short"]); + } + + #[test] + fn short_flags_are_not_counted_as_positionals() { + let mut completer = completer(); + let suggestions = completer.complete("group status -h ", 16); + assert_eq!(values(&suggestions), vec!["full", "short"]); + } + + #[test] + fn inline_flag_values_satisfy_the_flag() { + let mut completer = completer(); + let suggestions = completer.complete("broadcast --broadcast=yes --", 29); + let offered = values(&suggestions); + assert!(!offered.contains(&"--broadcast")); + let suggestions = completer.complete("broadcast --broadcast=yes ", 26); + assert!(values(&suggestions).is_empty()); + } + + #[test] + fn descriptions_carry_the_help_text() { + let mut completer = completer(); + let suggestions = completer.complete("group status ", 13); + assert_eq!(suggestions[0].description.as_deref(), Some("Everything")); + } + + #[test] + fn hinter_shows_the_untyped_remainder() { + let mut hinter = WalletHinter::new(completer()); + let history_file = std::env::temp_dir().join(format!( + "wallet-completions-test-history-{}", + std::process::id() + )); + let history: Box = + Box::new(reedline::FileBackedHistory::with_file(100, history_file).unwrap()); + let hint = hinter.handle("se", 2, history.as_ref(), false, ""); + assert_eq!(hint, "nd"); + assert_eq!(hinter.complete_hint(), "nd"); + assert_eq!(hinter.next_hint_token(), "nd"); + } } diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index 0d952f9540..d1f70babf0 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -99,8 +99,8 @@ pub fn run( } // The REPL thread is sized for grammar construction, so build the - // grammar directly instead of nesting another large-stack spawn. - let repl_command = build_repl_command(cold_wallet, true); + // grammar directly instead of nesting another large-stack spawn. + let repl_command = build_repl_command(cold_wallet, true); while let Some(line) = input.read_line() { let res = process_line(&repl_command, &event_tx, &line); From 349f732b0fb9c8492218d9c2124b8213d70d5316 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 05:55:17 +0200 Subject: [PATCH 19/52] feat(wallet-cli): generate shell completion scripts --- Cargo.lock | 34 ++-- Cargo.toml | 1 + .../src/repl/interactive/mod.rs | 6 +- .../repl/interactive/wallet_completions.rs | 158 ++++++++++++++---- wallet/wallet-cli/Cargo.toml | 1 + wallet/wallet-cli/src/main.rs | 50 +++++- 6 files changed, 198 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca96315935..ba702cb3e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,7 +205,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -216,7 +216,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1638,6 +1638,15 @@ dependencies = [ "strsim 0.11.1", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.55" @@ -2914,7 +2923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3350,7 +3359,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -4424,7 +4433,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5698,7 +5707,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7706,7 +7715,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8914,7 +8923,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10016,6 +10025,7 @@ name = "wallet-cli" version = "1.4.1" dependencies = [ "clap", + "clap_complete", "ctor", "tokio", "utils", @@ -10714,7 +10724,7 @@ dependencies = [ "js-sys", "log", "naga", - "parking_lot 0.11.2", + "parking_lot 0.12.5", "profiling", "raw-window-handle", "smallvec", @@ -10742,7 +10752,7 @@ dependencies = [ "log", "naga", "once_cell", - "parking_lot 0.11.2", + "parking_lot 0.12.5", "profiling", "raw-window-handle", "rustc-hash 1.1.0", @@ -10784,7 +10794,7 @@ dependencies = [ "ndk-sys 0.5.0+25.2.9519653", "objc", "once_cell", - "parking_lot 0.11.2", + "parking_lot 0.12.5", "profiling", "range-alloc", "raw-window-handle", @@ -10848,7 +10858,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7e023cd5ef..8bf49c899a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,6 +152,7 @@ cfg-if = "1.0" chacha20poly1305 = "0.10" chrono = "0.4" clap = "4.5" +clap_complete = "4.5" console-subscriber = "0.5" criterion = "0.5" crossterm = "0.28" diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index d751aa3e6b..ca3b69e3a0 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -22,6 +22,8 @@ use std::path::PathBuf; use clap::Command; use itertools::Itertools; +use std::sync::Arc; + use reedline::{ ColumnarMenu, DefaultPrompt, DefaultValidator, EditMode, Emacs, FileBackedHistory, History, ListMenu, MenuBuilder, Reedline, ReedlineMenu, Signal, Vi, default_emacs_keybindings, @@ -132,7 +134,7 @@ pub fn run( ) -> Result<(), WalletCliError> { // The REPL thread is sized for grammar construction, so build the // grammar directly instead of nesting another large-stack spawn. - let repl_command = build_repl_command(cold_wallet, true); + let repl_command = Arc::new(build_repl_command(cold_wallet, true)); let history = if let Some(file_name) = history_file { let h: Box = Box::new( @@ -146,7 +148,7 @@ pub fn run( let mut line_editor = create_line_editor( logger.printer().clone(), - wallet_completions::WalletCompletions::new(repl_command.clone()), + wallet_completions::WalletCompletions::new(Arc::clone(&repl_command)), history, vi_mode, ); diff --git a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs index 868c62ae3d..b5f22915ce 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs @@ -15,6 +15,8 @@ use std::collections::BTreeSet; +use std::sync::Arc; + use clap::{Arg, ArgAction, Command}; use reedline::{Completer, Hinter, Span, Suggestion}; @@ -22,15 +24,22 @@ use reedline::{Completer, Hinter, Span, Suggestion}; /// long flags, and enumeration values for the token in front of the cursor. #[derive(Clone)] pub struct WalletCompletions { - root: std::sync::Arc, + root: Arc, } impl WalletCompletions { - pub fn new(mut root: Command) -> Self { - root.build(); - Self { - root: std::sync::Arc::new(root), + /// Share the grammar. + /// + /// [`Command::build`] runs only when the caller passes an owned + /// [`Command`] or an unshared [`std::sync::Arc`]; callers sharing an + /// already-wrapped [`std::sync::Arc`] must pass a built tree (as + /// `build_repl_command` does). + pub fn new(root: impl Into>) -> Self { + let mut root = root.into(); + if let Some(root) = Arc::get_mut(&mut root) { + root.build(); } + Self { root } } /// A completer without any candidates, used where completion is not @@ -87,12 +96,15 @@ fn subcommand_candidates(cmd: &Command) -> Vec { if sub.is_hide_set() { continue; } - let description = sub.get_about().map(|about| about.to_string()); + // `build_repl_command` appends a trailing EOL to each top-level + // command's `about` for help formatting; trim it so completion menu + // entries do not carry a stray newline. + let description = sub.get_about().map(|about| about.to_string().trim().to_owned()); candidates.push(Candidate { value: sub.get_name().to_owned(), description: description.clone(), }); - for alias in sub.get_all_aliases() { + for alias in sub.get_visible_aliases() { candidates.push(Candidate { value: alias.to_owned(), description: description.clone(), @@ -117,21 +129,20 @@ fn positional_candidates(cmd: &Command, index: usize) -> Vec { let Some(arg) = arg else { return Vec::new(); }; - arg.get_possible_values() - .into_iter() - .map(|value| Candidate { - value: value.get_name().to_owned(), - description: value.get_help().map(|help| help.to_string()), - }) - .collect() + possible_value_candidates(arg.get_possible_values()) } fn value_candidates(pending_value_flag: Option<&Arg>) -> Vec { let Some(arg) = pending_value_flag else { return Vec::new(); }; - arg.get_possible_values() + possible_value_candidates(arg.get_possible_values()) +} + +fn possible_value_candidates(values: Vec) -> Vec { + values .into_iter() + .filter(|value| !value.is_hide_set()) .map(|value| Candidate { value: value.get_name().to_owned(), description: value.get_help().map(|help| help.to_string()), @@ -142,6 +153,9 @@ fn value_candidates(pending_value_flag: Option<&Arg>) -> Vec { /// Walk the tokenized prefix of the line and return the command level the /// cursor sits at, the long flags already given at that level, the number of /// positional values consumed, and a flag whose value is still missing. +/// +/// The walk assumes the grammar has no lone `--` separator and no values that +/// start with a hyphen; the wallet grammar has neither. struct Level<'a> { cmd: &'a Command, taken_flags: BTreeSet, @@ -183,6 +197,10 @@ fn resolve_level<'a>(root: &'a Command, tokens: &[&str]) -> Level<'a> { .get_arguments() .find(|arg| arg.get_short().is_some_and(|s| Some(s) == short)) { + let repeatable = matches!(arg.get_action(), ArgAction::Append); + if !repeatable && let Some(long) = arg.get_long() { + taken_flags.insert(long.to_owned()); + } let takes_value = arg.get_num_args().is_some_and(|range| range.takes_values()); let value_attached = token.chars().count() > 2; if takes_value && !value_attached { @@ -196,7 +214,9 @@ fn resolve_level<'a>(root: &'a Command, tokens: &[&str]) -> Level<'a> { if pending_value_flag.take().is_some() { continue; } - if let Some(sub) = cmd.find_subcommand(token).filter(|sub| !sub.is_hide_set()) { + // Hidden subcommands are still accepted by clap, so resolve them when + // typed; `subcommand_candidates` keeps them out of the offered menu. + if let Some(sub) = cmd.find_subcommand(token) { cmd = sub; taken_flags.clear(); positional_index = 0; @@ -215,7 +235,9 @@ fn resolve_level<'a>(root: &'a Command, tokens: &[&str]) -> Level<'a> { impl Completer for WalletCompletions { fn complete(&mut self, line: &str, pos: usize) -> Vec { - let prefix = line.get(..pos).unwrap_or(line); + let Some(prefix) = line.get(..pos) else { + return Vec::new(); + }; let ends_with_space = prefix.ends_with(char::is_whitespace); let tokens: Vec<&str> = prefix.split_whitespace().collect(); let (tokens, current): (Vec<&str>, &str) = if ends_with_space || tokens.is_empty() { @@ -228,20 +250,42 @@ impl Completer for WalletCompletions { }; let level = resolve_level(&self.root, &tokens); - let candidates = if current.starts_with("--") { + // A flag whose value is being typed after `=` completes that flag's + // values instead of flag names. + let inline_value_flag = + current.strip_prefix("--").and_then(|rest| rest.split_once('=')).and_then( + |(flag, _)| level.cmd.get_arguments().find(|arg| arg.get_long() == Some(flag)), + ); + let mut candidates = if let Some(arg) = inline_value_flag { + value_candidates(Some(arg)) + } else if let Some(arg) = level.pending_value_flag { + // clap would consume any `--`-prefixed token as this flag's value + // (and reject it), so complete the flag's values instead of + // offering flag names. + value_candidates(Some(arg)) + } else if current.starts_with("--") { flag_candidates(level.cmd, &level.taken_flags) - } else if level.pending_value_flag.is_some() { - value_candidates(level.pending_value_flag) } else { let mut candidates = subcommand_candidates(level.cmd); candidates.extend(positional_candidates(level.cmd, level.positional_index)); candidates }; - let start = pos.saturating_sub(current.len()); + // Keep the menu alphabetical, matching the grammar's help ordering. + candidates.sort_by(|lhs, rhs| lhs.value.cmp(&rhs.value)); + + // For the inline `--flag=` form, match and replace only the + // typed part after `=`; otherwise match and replace the whole token. + let (match_prefix, typed_len): (&str, usize) = if inline_value_flag.is_some() { + let value_part = current.split_once('=').map_or("", |(_, value)| value); + (value_part, value_part.len()) + } else { + (current, current.len()) + }; + let start = pos.saturating_sub(typed_len); candidates .into_iter() - .filter(|candidate| candidate.value.starts_with(current)) + .filter(|candidate| candidate.value.starts_with(match_prefix)) .map(|candidate| Suggestion { value: candidate.value, description: candidate.description, @@ -259,6 +303,7 @@ impl Completer for WalletCompletions { pub struct WalletHinter { completer: WalletCompletions, current_hint: String, + last_input: Option<(String, usize)>, } impl WalletHinter { @@ -266,6 +311,7 @@ impl WalletHinter { Self { completer, current_hint: String::new(), + last_input: None, } } } @@ -279,15 +325,24 @@ impl Hinter for WalletHinter { use_ansi_coloring: bool, _cwd: &str, ) -> String { - self.current_hint = self - .completer - .complete(line, pos) - .first() - .map(|suggestion| { - let typed = pos.saturating_sub(suggestion.span.start); - suggestion.value.get(typed..).unwrap_or_default().to_owned() - }) - .unwrap_or_default(); + // The completion walk is pure, so repeat calls with the same input can + // reuse the previous result instead of re-walking the grammar. + if self + .last_input + .as_ref() + .is_none_or(|(last_line, last_pos)| last_line != line || *last_pos != pos) + { + self.last_input = Some((line.to_owned(), pos)); + self.current_hint = self + .completer + .complete(line, pos) + .first() + .map(|suggestion| { + let typed = pos.saturating_sub(suggestion.span.start); + suggestion.value.get(typed..).unwrap_or_default().to_owned() + }) + .unwrap_or_default(); + } if use_ansi_coloring && !self.current_hint.is_empty() { format!("\x1b[2m{}\x1b[0m", self.current_hint) } else { @@ -330,6 +385,12 @@ mod tests { #[clap(subcommand)] command: GroupSubCommand, }, + /// Internal maintenance command + #[clap(hide = true)] + Sweep { + /// Whether to sweep + broadcast: YesNo, + }, } #[derive(clap::Subcommand, Debug)] @@ -368,14 +429,14 @@ mod tests { let mut completer = completer(); let suggestions = completer.complete("", 0); let values = values(&suggestions); - assert_eq!(values, vec!["send", "broadcast", "group", "help"]); + assert_eq!(values, vec!["broadcast", "group", "help", "send"]); } #[test] fn nested_subcommands_are_offered_after_the_group() { let mut completer = completer(); let suggestions = completer.complete("group ", 6); - assert_eq!(values(&suggestions), vec!["status", "help"]); + assert_eq!(values(&suggestions), vec!["help", "status"]); } #[test] @@ -394,7 +455,7 @@ mod tests { let mut completer = completer(); let suggestions = completer.complete("broadcast --broadcast ", 22); let after_flag = values(&suggestions); - assert_eq!(after_flag, vec!["yes", "no"]); + assert_eq!(after_flag, vec!["no", "yes"]); let suggestions = completer.complete("group status ", 13); let positional = values(&suggestions); assert_eq!(positional, vec!["full", "short"]); @@ -417,6 +478,24 @@ mod tests { assert!(values(&suggestions).is_empty()); } + #[test] + fn partial_inline_values_complete_after_the_equals_sign() { + let mut completer = completer(); + let suggestions = completer.complete("broadcast --broadcast=y", 23); + assert_eq!(values(&suggestions), vec!["yes"]); + assert_eq!(suggestions[0].span, Span::new(22, 23)); + } + + #[test] + fn hidden_subcommands_resolve_when_typed() { + let mut completer = completer(); + // Hidden commands are not offered in the menu... + assert!(!values(&completer.complete("", 0)).contains(&"sweep")); + // ...but resolve once typed. + let suggestions = completer.complete("sweep ", 6); + assert_eq!(values(&suggestions), vec!["no", "yes"]); + } + #[test] fn descriptions_carry_the_help_text() { let mut completer = completer(); @@ -427,13 +506,18 @@ mod tests { #[test] fn hinter_shows_the_untyped_remainder() { let mut hinter = WalletHinter::new(completer()); + // `with_file` opens (and therefore creates) the file eagerly; use a + // process-unique path and remove it afterwards. let history_file = std::env::temp_dir().join(format!( - "wallet-completions-test-history-{}", - std::process::id() + "wallet-completions-test-history-{}-{:?}", + std::process::id(), + std::thread::current().id(), )); let history: Box = - Box::new(reedline::FileBackedHistory::with_file(100, history_file).unwrap()); + Box::new(reedline::FileBackedHistory::with_file(100, history_file.clone()).unwrap()); let hint = hinter.handle("se", 2, history.as_ref(), false, ""); + drop(history); + let _ = std::fs::remove_file(history_file); assert_eq!(hint, "nd"); assert_eq!(hinter.complete_hint(), "nd"); assert_eq!(hinter.next_hint_token(), "nd"); diff --git a/wallet/wallet-cli/Cargo.toml b/wallet/wallet-cli/Cargo.toml index 338b78297c..ba014cb79e 100644 --- a/wallet/wallet-cli/Cargo.toml +++ b/wallet/wallet-cli/Cargo.toml @@ -10,6 +10,7 @@ utils = { path = "../../utils" } wallet-cli-lib = { path = "../wallet-cli-lib", default-features = false } clap = { workspace = true, features = ["derive"] } +clap_complete = { workspace = true } ctor.workspace = true tokio = { workspace = true, default-features = false, features = [ "io-util", diff --git a/wallet/wallet-cli/src/main.rs b/wallet/wallet-cli/src/main.rs index ce8bd4da41..aaf878e120 100644 --- a/wallet/wallet-cli/src/main.rs +++ b/wallet/wallet-cli/src/main.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use clap::Parser; +use clap::{CommandFactory, Parser}; use wallet_cli_lib::{ config::WalletCliArgs, console::{StdioInputConsole, StdioOutputConsole}, @@ -21,8 +21,56 @@ use wallet_cli_lib::{ utils::enable_rust_backtrace!(); +/// Emit a shell completion script for the wallet CLI binary. +/// +/// This verb is handled by the binary before the main argument parser runs, so +/// it does not appear in the command grammar or in the generated completions. +#[derive(Parser, Debug)] +struct CompletionsArgs { + /// Shell to generate the completion script for + #[clap(long)] + shell: clap_complete::Shell, +} + +/// The name completions should bind to: the invoked file name, falling back +/// to the package name when it cannot be determined. +fn binary_name() -> String { + std::env::args_os() + .next() + .as_deref() + .map(std::path::Path::new) + // `file_stem` drops a Windows executable extension; completion script + // identifiers must not contain a dot. + .and_then(|path| path.file_stem()) + .and_then(|name| name.to_str()) + .map_or_else(|| env!("CARGO_PKG_NAME").to_owned(), str::to_owned) +} + +fn generate_completions() { + let bin_name = binary_name(); + // Name the parser after the real binary; the intercepted `completions` + // verb is not part of this parser's arguments. + let mut argv: Vec = vec![bin_name.as_str().into()]; + argv.extend(std::env::args_os().skip(2)); + let args = CompletionsArgs::parse_from(argv); + // Buffer the script so a failed write (closed pipe, full disk) is + // observable: clap_complete discards write errors internally. + let mut script = Vec::new(); + let mut command = WalletCliArgs::command(); + clap_complete::generate(args.shell, &mut command, bin_name, &mut script); + use std::io::Write; + std::io::stdout().write_all(&script).unwrap_or_else(|err| { + eprintln!("failed to write completions: {err}"); + std::process::exit(1); + }); +} + #[tokio::main] async fn main() { + if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("completions")) { + generate_completions(); + return; + } let args = WalletCliArgs::parse(); wallet_cli_lib::run(StdioInputConsole, StdioOutputConsole, args, None) .await From 1da6d1513b987ebc448fd863e3330f09176e97a9 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 08:27:16 +0200 Subject: [PATCH 20/52] feat(wallet-cli): prompt for passwords on a terminal --- Cargo.lock | 22 +++ Cargo.toml | 1 + supply-chain/config.toml | 12 ++ wallet/wallet-cli-commands/Cargo.toml | 1 + .../src/command_handler/mod.rs | 133 +++++++++++++----- wallet/wallet-cli-commands/src/lib.rs | 13 +- wallet/wallet-cli/src/main.rs | 2 +- 7 files changed, 143 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba702cb3e1..f19962e33a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7514,6 +7514,17 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rpc" version = "1.4.1" @@ -7615,6 +7626,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rusb" version = "0.9.4" @@ -10068,6 +10089,7 @@ dependencies = [ "randomness", "reedline", "regex", + "rpassword", "rpc", "rstest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8bf49c899a..24627d3dff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -233,6 +233,7 @@ reqwest = "0.12" rfd = { version = "0.15", default-features = false } ripemd = "0.1" rlimit = "0.10" +rpassword = "7.2" rstest = "0.24" rstest_reuse = "0.7" rusqlite = "0.34" diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 5edc91f9df..2c1eabb1b7 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -215,6 +215,10 @@ criteria = "safe-to-deploy" version = "0.10.2" criteria = "safe-to-deploy" +[[exemptions.clap_complete]] +version = "4.6.9" +criteria = "safe-to-deploy" + [[exemptions.clipboard-win]] version = "5.4.1" criteria = "safe-to-deploy" @@ -1195,6 +1199,10 @@ criteria = "safe-to-deploy" version = "0.20.0" criteria = "safe-to-deploy" +[[exemptions.rpassword]] +version = "7.5.4" +criteria = "safe-to-deploy" + [[exemptions.rstest]] version = "0.24.0" criteria = "safe-to-deploy" @@ -1203,6 +1211,10 @@ criteria = "safe-to-deploy" version = "0.24.0" criteria = "safe-to-deploy" +[[exemptions.rtoolbox]] +version = "0.0.6" +criteria = "safe-to-deploy" + [[exemptions.rusb]] version = "0.9.4" criteria = "safe-to-deploy" diff --git a/wallet/wallet-cli-commands/Cargo.toml b/wallet/wallet-cli-commands/Cargo.toml index f1adf0b2b6..d7bf2cec1c 100644 --- a/wallet/wallet-cli-commands/Cargo.toml +++ b/wallet/wallet-cli-commands/Cargo.toml @@ -42,6 +42,7 @@ lazy_static.workspace = true prettytable-rs = "0.10" reedline = { workspace = true, features = ["external_printer"] } regex.workspace = true +rpassword.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true shlex.workspace = true diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 36da90ae48..18d3752a86 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -15,7 +15,7 @@ mod local_state; -use std::{collections::BTreeMap, fmt::Write, str::FromStr}; +use std::{collections::BTreeMap, fmt::Write, io::IsTerminal, str::FromStr}; use bigdecimal::BigDecimal; use itertools::Itertools; @@ -84,6 +84,14 @@ use crate::{ }, }; +/// How the hidden terminal password prompt behaves: `Create` asks for the +/// password twice (typo guard for wallet encryption), `Enter` asks once. +#[derive(Clone, Copy, PartialEq, Eq)] +enum PromptMode { + Create, + Enter, +} + pub struct CommandHandler { config: ControllerConfig, @@ -379,12 +387,40 @@ where } /// Read a password from `--password-file`, where `-` means standard input. - /// The trailing line terminator is stripped; any other whitespace is kept. + /// When no source is given and standard input is a terminal, the password + /// is read with a hidden prompt on the controlling terminal instead; in + /// [`PromptMode::Create`] mode the prompt asks for the password twice. The trailing line + /// terminator is stripped; any other whitespace is kept. async fn read_password_from_source( source: Option<&str>, + prompt_mode: PromptMode, ) -> Result> { - // Standard input is read with terminal echo enabled; a hidden interactive - // prompt does not exist yet. + // The password can also come from a hidden terminal prompt; see the + // function documentation above. + + // Reads a password from the terminal with echo disabled. In `Create` + // mode (password creation) the password must be repeated; a typo would + // otherwise lock the wallet with an unknown password. + fn prompt_password_hidden(prompt_mode: PromptMode) -> std::io::Result { + // The prompt and the hidden read go to the controlling terminal + // (/dev/tty), so redirected standard error does not matter. + let password = rpassword::prompt_password(if prompt_mode == PromptMode::Create { + "New password: " + } else { + "Password: " + })?; + if prompt_mode == PromptMode::Create { + let confirmed = rpassword::prompt_password("Confirm password: ")?; + if password != confirmed { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the two passwords do not match", + )); + } + } + Ok(password) + } + fn read_password_from_stdin() -> std::io::Result { let mut password = String::new(); let bytes_read = std::io::stdin().read_line(&mut password)?; @@ -397,6 +433,23 @@ where Ok(password) } + // Joins a blocking password read and labels the two failure layers + // (task join vs read) distinctly. + async fn label_password_errors( + fut: impl std::future::Future< + Output = Result, tokio::task::JoinError>, + >, + label: String, + ) -> Result> { + fut.await + .map_err(|err| { + WalletCliCommandError::InvalidInput(format!( + "Internal error - failed to join the password read task: {err}" + )) + })? + .map_err(|err| WalletCliCommandError::InvalidInput(format!("{label}: {err}"))) + } + // One trailing line terminator (`\n` or `\r\n`) is stripped; any other // whitespace is kept. fn strip_line_terminator(password: &str) -> &str { @@ -408,41 +461,39 @@ where let password = match source { None => { - return Err(WalletCliCommandError::InvalidInput( - "a password is required: pass --password-file (or \ - --password-file - for standard input); an interactive password \ - prompt is not available yet" - .to_owned(), - )); + // The hidden prompt goes to the controlling terminal + // (/dev/tty); scripted callers get an error instead. + if std::io::stdin().is_terminal() { + label_password_errors( + tokio::task::spawn_blocking(move || prompt_password_hidden(prompt_mode)), + "Failed to read the password from the terminal".to_owned(), + ) + .await? + } else { + return Err(WalletCliCommandError::InvalidInput( + "a password is required: pass --password-file (or \ + --password-file - for standard input); interactive input \ + requires a terminal on standard input" + .to_owned(), + )); + } + } + Some("-") => { + label_password_errors( + tokio::task::spawn_blocking(read_password_from_stdin), + "Failed to read the password from standard input".to_owned(), + ) + .await? } - Some("-") => tokio::task::spawn_blocking(read_password_from_stdin) - .await - .map_err(|err| { - WalletCliCommandError::InvalidInput(format!( - "Failed to read the password from standard input: {err}" - )) - })? - .map_err(|err| { - WalletCliCommandError::InvalidInput(format!( - "Failed to read the password from standard input: {err}" - )) - })?, Some(path) => { let path = path.to_owned(); let read_path = path.clone(); let read = move || std::fs::read_to_string(read_path); - tokio::task::spawn_blocking(read) - .await - .map_err(|err| { - WalletCliCommandError::InvalidInput(format!( - "Failed to read the password from {path}: {err}" - )) - })? - .map_err(|err| { - WalletCliCommandError::InvalidInput(format!( - "Failed to read the password from {path}: {err}" - )) - })? + label_password_errors( + tokio::task::spawn_blocking(read), + format!("Failed to read the password from {path}"), + ) + .await? } }; Ok(strip_line_terminator(&password).to_owned()) @@ -1029,8 +1080,11 @@ where .await; } WalletGroupSubCommand::Encrypt { password_file } => { - let password = - Self::read_password_from_source::(password_file.as_deref()).await?; + let password = Self::read_password_from_source::( + password_file.as_deref(), + PromptMode::Create, + ) + .await?; return self .handle_cold_wallet_command( ColdWalletCommand::EncryptPrivateKeys { password }, @@ -1039,8 +1093,11 @@ where .await; } WalletGroupSubCommand::Unlock { password_file } => { - let password = - Self::read_password_from_source::(password_file.as_deref()).await?; + let password = Self::read_password_from_source::( + password_file.as_deref(), + PromptMode::Enter, + ) + .await?; return self .handle_cold_wallet_command( ColdWalletCommand::UnlockPrivateKeys { password }, diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 24af3848a9..2fe53a65d7 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -311,7 +311,9 @@ pub enum WalletGroupSubCommand { /// Encrypt the private keys of the wallet with a password. /// /// The password is read from the file given by `--password-file` (`-` for - /// standard input); it is never passed as a command line argument. + /// standard input); it is never passed as a command line argument. When + /// the option is omitted on a terminal, the password is read with a + /// hidden prompt and must be entered twice. Encrypt { /// File containing the new encryption password, or `-` for standard input. #[arg(long = "password-file")] @@ -320,7 +322,9 @@ pub enum WalletGroupSubCommand { /// Unlock the encrypted private keys of the wallet with the encryption password. /// /// The password is read from the file given by `--password-file` (`-` for - /// standard input); it is never passed as a command line argument. + /// standard input); it is never passed as a command line argument. When + /// the option is omitted on a terminal, the password is read with a + /// hidden prompt. Unlock { /// File containing the current encryption password, or `-` for standard input. #[arg(long = "password-file")] @@ -1531,6 +1535,11 @@ pub enum WalletCommand { command: TransactionGroupSubCommand, }, + /// Create a new account in the wallet. + /// + /// When no name is given the account stays unnamed; listings show it as + /// `Account index: , Name: None` and the status line as + /// `(Account No. )` until it is renamed. #[clap(name = "account-create")] CreateNewAccount { name: Option }, diff --git a/wallet/wallet-cli/src/main.rs b/wallet/wallet-cli/src/main.rs index aaf878e120..8d046133bd 100644 --- a/wallet/wallet-cli/src/main.rs +++ b/wallet/wallet-cli/src/main.rs @@ -47,6 +47,7 @@ fn binary_name() -> String { } fn generate_completions() { + use std::io::Write; let bin_name = binary_name(); // Name the parser after the real binary; the intercepted `completions` // verb is not part of this parser's arguments. @@ -58,7 +59,6 @@ fn generate_completions() { let mut script = Vec::new(); let mut command = WalletCliArgs::command(); clap_complete::generate(args.shell, &mut command, bin_name, &mut script); - use std::io::Write; std::io::stdout().write_all(&script).unwrap_or_else(|err| { eprintln!("failed to write completions: {err}"); std::process::exit(1); From 9c67bc8912c4f363e975f5716dab59e1239cdf53 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 11:58:25 +0200 Subject: [PATCH 21/52] feat(wallet-cli): collect complex command arguments interactively --- Cargo.lock | 45 +- Cargo.toml | 1 + wallet/wallet-cli-commands/Cargo.toml | 1 + .../src/command_handler/mod.rs | 5 + wallet/wallet-cli-commands/src/lib.rs | 24 + wallet/wallet-cli-commands/src/wizard/mod.rs | 604 ++++++++++++++++++ .../src/repl/interactive/mod.rs | 33 + 7 files changed, 710 insertions(+), 3 deletions(-) create mode 100644 wallet/wallet-cli-commands/src/wizard/mod.rs diff --git a/Cargo.lock b/Cargo.lock index f19962e33a..2669dacf44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,7 +1717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ "termcolor", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1848,6 +1848,19 @@ dependencies = [ "utxo", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + [[package]] name = "console-api" version = "0.9.0" @@ -2552,6 +2565,19 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810" +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" @@ -6898,7 +6924,7 @@ dependencies = [ "is-terminal", "lazy_static", "term", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -7304,7 +7330,7 @@ dependencies = [ "strum_macros 0.26.4", "thiserror 1.0.69", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -8340,6 +8366,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -9768,6 +9800,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -10071,6 +10109,7 @@ dependencies = [ "crypto", "ctor", "derive_more 1.0.0", + "dialoguer", "directories", "dyn-clone", "futures", diff --git a/Cargo.toml b/Cargo.toml index 24627d3dff..794e50420b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,6 +159,7 @@ crossterm = "0.28" csv = "1.3" ctor = "0.10" derive_more = { version = "1.0", features = ["full"] } +dialoguer = "0.11" directories = "5.0" humantime = "2.1" dyn-clone = "1.0" diff --git a/wallet/wallet-cli-commands/Cargo.toml b/wallet/wallet-cli-commands/Cargo.toml index d7bf2cec1c..4d1e42cb33 100644 --- a/wallet/wallet-cli-commands/Cargo.toml +++ b/wallet/wallet-cli-commands/Cargo.toml @@ -32,6 +32,7 @@ chrono.workspace = true clap = { workspace = true, features = ["derive"] } crossterm.workspace = true derive_more.workspace = true +dialoguer.workspace = true directories.workspace = true dyn-clone.workspace = true futures.workspace = true diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 18d3752a86..9d8e268850 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -1169,6 +1169,11 @@ where }; self.handle_wallet_management_command(management).await } + WalletCommand::Wizard { .. } => Err(WalletCliCommandError::::InvalidInput( + "the wizard is only available on an interactive terminal; type the command \ + directly instead" + .to_owned(), + )), // Keep this or-pattern in sync with `is_nested_wallet_command` and the // translation arms in `normalize_nested_wallet_command`: a group added here // needs a variant in the predicate, a translation arm, and a LEGACY_ALIASES diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index 2fe53a65d7..d535a7b72a 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -17,6 +17,7 @@ mod command_handler; mod errors; mod helper_types; pub mod output; +pub mod wizard; use std::{collections::BTreeMap, fmt::Debug, num::NonZeroUsize, path::PathBuf, time::Duration}; @@ -1042,6 +1043,22 @@ pub enum ConfigGroupSubCommand { }, } +/// The commands that offer interactive argument collection through `wizard`. +#[derive(Debug, Subcommand, Clone)] +#[clap(rename_all = "kebab-case")] +pub enum WizardGroupSubCommand { + /// Interactively create the `token issue` command line. + TokenIssue, + /// Interactively create the `token nft issue` command line. + NftIssue, + /// Interactively create the `staking create-pool` command line. + PoolCreate, + /// Interactively create the `order create` command line. + OrderCreate, + /// Interactively create the `htlc create` command line. + HtlcCreate, +} + /// Mapping between the legacy flat command names and their nested equivalents. /// The flat names remain first-class commands; this manifest documents the /// correspondence and drives the grammar compatibility test. @@ -1442,6 +1459,13 @@ pub enum WalletCommand { command: ConfigGroupSubCommand, }, + /// Collect the arguments of a complex command interactively. + #[clap(name = "wizard")] + Wizard { + #[command(subcommand)] + command: WizardGroupSubCommand, + }, + /// Wallet management commands (create, recover, open, close). /// The flat commands (`wallet-create`, `wallet-recover`, ...) remain available. #[clap(name = "wallet")] diff --git a/wallet/wallet-cli-commands/src/wizard/mod.rs b/wallet/wallet-cli-commands/src/wizard/mod.rs new file mode 100644 index 0000000000..908b8362bb --- /dev/null +++ b/wallet/wallet-cli-commands/src/wizard/mod.rs @@ -0,0 +1,604 @@ +// Copyright (c) 2023 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Interactive collection of command arguments ("wizards"). +//! +//! A wizard asks the same questions the command line would, validates every +//! answer, shows a summary, and produces the equivalent one-liner command. The +//! one-liner is fed back through the regular command parser, so a wizard is +//! only a different way to type the same command, never a second execution +//! path. + +use std::collections::VecDeque; + +/// Where wizard answers come from. The production backend asks on the +/// terminal; tests feed canned answers. +pub trait PromptBackend { + fn text(&mut self, prompt: &str, default: Option<&str>) -> Result; + fn select(&mut self, prompt: &str, options: &[&str]) -> Result; + fn confirm(&mut self, prompt: &str) -> Result; +} + +#[derive(Debug)] +pub enum WizardError { + /// The user declined (summary confirmation or an earlier prompt). + Cancelled, + /// The terminal conversation failed. + Io(std::io::Error), + /// An answer is unusable. + InvalidInput(String), +} + +impl std::fmt::Display for WizardError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Cancelled => write!(f, "cancelled"), + Self::Io(err) => write!(f, "{err}"), + Self::InvalidInput(message) => write!(f, "{message}"), + } + } +} + +impl From for WizardError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for WizardError { + fn from(err: dialoguer::Error) -> Self { + Self::Io(std::io::Error::other(err.to_string())) + } +} + +/// Asks on the terminal with the default `dialoguer` styling. +pub struct DialoguerBackend; + +impl PromptBackend for DialoguerBackend { + fn text(&mut self, prompt: &str, default: Option<&str>) -> Result { + let input = dialoguer::Input::::new() + .with_prompt(prompt.to_owned()) + .with_post_completion_text(""); + let input = if let Some(default) = default { + input.default(default.to_owned()) + } else { + input + }; + Ok(input.interact_text()?) + } + + fn select(&mut self, prompt: &str, options: &[&str]) -> Result { + Ok( + dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(prompt.to_owned()) + .items(options) + .default(0) + .interact()?, + ) + } + + fn confirm(&mut self, prompt: &str) -> Result { + Ok(dialoguer::Confirm::new().with_prompt(prompt).default(true).interact()?) + } +} + +/// Test backend: replays scripted answers in order. +#[derive(Default)] +pub struct CannedBackend { + answers: VecDeque, +} + +pub enum CannedAnswer { + Text(String), + Select(usize), + Confirm(bool), +} + +impl CannedBackend { + pub fn new(answers: impl IntoIterator) -> Self { + Self { + answers: answers.into_iter().collect(), + } + } + + fn next(&mut self) -> Result { + self.answers.pop_front().ok_or_else(|| { + WizardError::InvalidInput("the wizard ran out of scripted answers".to_owned()) + }) + } +} + +impl PromptBackend for CannedBackend { + fn text(&mut self, _prompt: &str, _default: Option<&str>) -> Result { + match self.next()? { + CannedAnswer::Text(answer) => Ok(answer), + _ => Err(WizardError::InvalidInput( + "expected a text answer".to_owned(), + )), + } + } + + fn select(&mut self, _prompt: &str, _options: &[&str]) -> Result { + match self.next()? { + CannedAnswer::Select(index) => Ok(index), + _ => Err(WizardError::InvalidInput( + "expected a selection answer".to_owned(), + )), + } + } + + fn confirm(&mut self, _prompt: &str) -> Result { + match self.next()? { + CannedAnswer::Confirm(answer) => Ok(answer), + _ => Err(WizardError::InvalidInput( + "expected a confirmation answer".to_owned(), + )), + } + } +} + +fn require_non_empty(field: &str, answer: &str) -> Result { + if answer.trim().is_empty() { + Err(WizardError::InvalidInput(format!("{field} is required"))) + } else { + Ok(answer.trim().to_owned()) + } +} + +/// Shell-quotes a value so the produced one-liner round-trips through the +/// same word splitting the REPL uses. +fn quote(value: &str) -> String { + shlex::try_quote(value).map_or_else(|_| value.to_owned(), std::borrow::Cow::into_owned) +} + +fn optional_field(answer: &str) -> Option { + let trimmed = answer.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +/// Summarizes the collected answers and asks for confirmation. Returns an +/// error when the user declines. +fn confirm_summary(backend: &mut impl PromptBackend, lines: &[String]) -> Result<(), WizardError> { + let mut summary = String::from("Summary:\n"); + for line in lines { + summary.push_str(" "); + summary.push_str(line); + summary.push('\n'); + } + println!("{summary}"); + if backend.confirm("Create the command from these values?")? { + Ok(()) + } else { + Err(WizardError::Cancelled) + } +} + +/// `token issue ` +pub fn token_issue(backend: &mut impl PromptBackend) -> Result { + let ticker = require_non_empty("ticker", &backend.text("Token ticker:", None)?)?; + let decimals: u8 = + backend.text("Number of decimals:", Some("8"))?.trim().parse().map_err(|_| { + WizardError::InvalidInput("the number of decimals must be 0-255".to_owned()) + })?; + let metadata_uri = require_non_empty("metadata URI", &backend.text("Metadata URI:", None)?)?; + let authority = require_non_empty( + "authority address", + &backend.text("Authority address:", None)?, + )?; + let supply = match backend.select("Token supply:", &["unlimited", "lockable", "fixed"])? { + 0 => "unlimited".to_owned(), + 1 => "lockable".to_owned(), + _ => { + let amount = require_non_empty("amount", &backend.text("Fixed supply amount:", None)?)?; + format!("fixed({amount})") + } + }; + let freezable = match backend.select( + "Can the token authority freeze accounts?", + &["not-freezable", "freezable"], + )? { + 0 => "not-freezable", + _ => "freezable", + }; + confirm_summary( + backend, + &[ + format!("Ticker: {ticker}"), + format!("Decimals: {decimals}"), + format!("Metadata URI: {metadata_uri}"), + format!("Authority: {authority}"), + format!("Supply: {supply}"), + format!("Freezable: {freezable}"), + ], + )?; + Ok(format!( + "token issue {} {decimals} {} {} {supply} {freezable}", + quote(&ticker), + quote(&metadata_uri), + quote(&authority) + )) +} + +/// `token nft issue [creator] [icon-uri] [media-uri] [additional-metadata-uri]` +/// +/// Optional answers must be filled in order: clap positionals cannot skip a +/// middle value, so once one is left empty the remaining ones are ignored. +pub fn token_nft_issue(backend: &mut impl PromptBackend) -> Result { + let destination = require_non_empty( + "destination address", + &backend.text("Destination address:", None)?, + )?; + let media_hash = require_non_empty("media hash", &backend.text("Media hash (hex):", None)?)?; + let name = require_non_empty("name", &backend.text("NFT name:", None)?)?; + let description = backend.text("Description:", None)?; + let ticker = require_non_empty("ticker", &backend.text("Ticker:", None)?)?; + let creator = backend.text("Creator public key (hex, empty to omit):", None)?; + let icon_uri = backend.text("Icon URI (empty to omit):", None)?; + let media_uri = backend.text("Media URI (empty to omit):", None)?; + let additional_metadata_uri = backend.text("Additional metadata URI (empty to omit):", None)?; + + let optionals = [creator, icon_uri, media_uri, additional_metadata_uri] + .into_iter() + .map(|answer| optional_field(&answer)); + let mut rendered_optionals = Vec::new(); + let mut saw_empty = false; + for optional in optionals { + match (optional, saw_empty) { + (Some(value), false) => rendered_optionals.push(value), + (Some(_), true) => { + return Err(WizardError::InvalidInput( + "optional NFT fields must be filled in order; an empty answer ends the \ + optional fields" + .to_owned(), + )); + } + (None, _) => saw_empty = true, + } + } + + confirm_summary( + backend, + &[ + format!("Destination: {destination}"), + format!("Media hash: {media_hash}"), + format!("Name: {name}"), + format!("Description: {}", description.trim()), + format!("Ticker: {ticker}"), + ], + )?; + let mut line = format!( + "token nft issue {} {} {} {} {}", + quote(&destination), + quote(&media_hash), + quote(&name), + quote(description.trim()), + quote(&ticker) + ); + for optional in rendered_optionals { + line.push(' '); + line.push_str("e(&optional)); + } + Ok(line) +} + +/// `staking create-pool [staker] [vrf-public-key]` +pub fn staking_pool_create(backend: &mut impl PromptBackend) -> Result { + let amount = require_non_empty( + "pledge amount", + &backend.text("Pledge amount (locked until decommission):", None)?, + )?; + let cost_per_block = { + let answer = backend.text("Cost per block:", Some("0"))?; + if answer.trim().is_empty() { + "0".to_owned() + } else { + answer + } + }; + let margin_ratio = require_non_empty( + "margin ratio", + &backend.text("Margin ratio in per-mille (e.g. 100 = 10%):", None)?, + )?; + let decommission = require_non_empty( + "decommission address", + &backend.text("Decommission address (cold storage recommended):", None)?, + )?; + let staker = backend.text("Staker address (empty for this wallet):", None)?; + let vrf = backend.text("VRF public key (empty for this wallet):", None)?; + + confirm_summary( + backend, + &[ + format!("Pledge amount: {amount} (locked until the pool is decommissioned)"), + format!("Cost per block: {}", cost_per_block.trim()), + format!("Margin ratio (per mille): {margin_ratio}"), + format!("Decommission address: {decommission}"), + ], + )?; + let mut line = format!( + "staking create-pool {} {} {} {}", + quote(&amount), + quote(&cost_per_block), + quote(&margin_ratio), + quote(&decommission) + ); + if let Some(staker) = optional_field(&staker) { + line.push(' '); + line.push_str(&staker); + if let Some(vrf) = optional_field(&vrf) { + line.push(' '); + line.push_str(&vrf); + } + } else if optional_field(&vrf).is_some() { + return Err(WizardError::InvalidInput( + "the VRF public key requires a staker address to be set".to_owned(), + )); + } + Ok(line) +} + +/// `order create ` +pub fn order_create(backend: &mut impl PromptBackend) -> Result { + fn currency(backend: &mut impl PromptBackend, side: &str) -> Result { + match backend.select(&format!("{side} currency:"), &["coin", "token id"])? { + 0 => Ok("coin".to_owned()), + _ => require_non_empty("token id", &backend.text("Token id:", None)?), + } + } + let ask_currency = currency(backend, "Asked")?; + let ask_amount = require_non_empty("ask amount", &backend.text("Asked amount:", None)?)?; + let give_currency = currency(backend, "Given")?; + let give_amount = require_non_empty("give amount", &backend.text("Given amount:", None)?)?; + let conclude = require_non_empty( + "conclude address", + &backend.text("Conclude address:", None)?, + )?; + + let implied_price = match (ask_amount.parse::(), give_amount.parse::()) { + (Ok(ask), Ok(give)) if give != 0.0 => (ask / give).to_string(), + _ => "?".to_owned(), + }; + confirm_summary( + backend, + &[ + format!("Ask: {ask_amount} {ask_currency}"), + format!("Give: {give_amount} {give_currency}"), + format!("Conclude address: {conclude}"), + format!("Implied price: 1 {give_currency} = {implied_price} {ask_currency}"), + ], + )?; + Ok(format!( + "order create {ask_currency} {} {give_currency} {} {}", + quote(&ask_amount), + quote(&give_amount), + quote(&conclude) + )) +} + +/// `htlc create ` +/// +/// The resulting transaction is built and shown, but never broadcast. +pub fn htlc_create(backend: &mut impl PromptBackend) -> Result { + let currency_kind = backend.select("Currency to lock:", &["coin", "token id"])?; + let currency = match currency_kind { + 0 => "coin".to_owned(), + _ => require_non_empty("token id", &backend.text("Token id:", None)?)?, + }; + let amount = require_non_empty("amount", &backend.text("Amount to lock:", None)?)?; + let secret_hash = require_non_empty("secret hash", &backend.text("Secret hash (hex):", None)?)?; + let spend = require_non_empty("spend address", &backend.text("Spend address:", None)?)?; + let refund = require_non_empty("refund address", &backend.text("Refund address:", None)?)?; + let timelock_kind = backend.select( + "Refund timelock type:", + &[ + "for a number of blocks", + "for a number of seconds", + "until a height", + "until a time", + ], + )?; + let timelock_name = ["block_count", "seconds", "until_height", "until_time"][timelock_kind]; + let timelock_value = require_non_empty( + "timelock value", + &backend.text( + match timelock_kind { + 3 => "Refund time (RFC 3339):", + _ => "Timelock value:", + }, + None, + )?, + )?; + + confirm_summary( + backend, + &[ + format!("Locks: {amount} {currency}"), + format!("Spend address (with the secret): {spend}"), + format!("Refund address (after the timelock): {refund}"), + format!("Refund timelock: {timelock_name}({timelock_value})"), + "The transaction is NOT broadcast; the output is the raw transaction.".to_owned(), + ], + )?; + Ok(format!( + "htlc create {currency} {} {} {} {timelock_name}({}) {}", + quote(&amount), + quote(&secret_hash), + quote(&spend), + quote(&timelock_value), + quote(&refund) + )) +} + +/// The commands that have an interactive argument collector. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WizardKind { + TokenIssue, + NftIssue, + PoolCreate, + OrderCreate, + HtlcCreate, +} + +impl WizardKind { + /// Recognizes a wizard invocation in a line: either the explicit + /// `wizard ` spelling or one of the eligible command prefixes + /// (`token issue`, `token nft issue`, `staking create-pool`, + /// `order create`, `htlc create`). + pub fn parse_from_tokens(tokens: &[&str]) -> Option { + match tokens { + ["wizard", "token-issue"] => Some(WizardKind::TokenIssue), + ["wizard", "nft-issue"] => Some(WizardKind::NftIssue), + ["wizard", "pool-create"] => Some(WizardKind::PoolCreate), + ["wizard", "order-create"] => Some(WizardKind::OrderCreate), + ["wizard", "htlc-create"] => Some(WizardKind::HtlcCreate), + ["token", "issue"] => Some(WizardKind::TokenIssue), + ["token", "nft", "issue"] => Some(WizardKind::NftIssue), + ["staking", "create-pool"] => Some(WizardKind::PoolCreate), + ["order", "create"] => Some(WizardKind::OrderCreate), + ["htlc", "create"] => Some(WizardKind::HtlcCreate), + _ => None, + } + } + + /// Produces the command line this wizard generates. + pub fn run(self, backend: &mut impl PromptBackend) -> Result { + match self { + WizardKind::TokenIssue => token_issue(backend), + WizardKind::NftIssue => token_nft_issue(backend), + WizardKind::PoolCreate => staking_pool_create(backend), + WizardKind::OrderCreate => order_create(backend), + WizardKind::HtlcCreate => htlc_create(backend), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn backend(answers: impl IntoIterator) -> CannedBackend { + CannedBackend::new(answers) + } + + fn text<'a>(values: &'a [&'a str]) -> impl Iterator + 'a { + values.iter().map(|value| CannedAnswer::Text((*value).to_owned())) + } + + #[test] + fn token_issue_one_liner_round_trips_through_the_parser() { + let mut backend = backend( + text(&["GOLD", "8", "https://example.com", "7Utx1"]) + .chain([CannedAnswer::Select(2)]) + .chain(text(&["100"])) + .chain([CannedAnswer::Select(1), CannedAnswer::Confirm(true)]), + ); + let line = token_issue(&mut backend).unwrap(); + assert_eq!( + line, + "token issue GOLD 8 https://example.com 7Utx1 fixed(100) freezable" + ); + } + + #[test] + fn declining_the_summary_is_reported_as_cancelled() { + let mut backend = backend( + text(&["GOLD", "8", "https://example.com", "addr"]) + .chain([CannedAnswer::Select(0), CannedAnswer::Select(0)]) + .chain([CannedAnswer::Confirm(false)]), + ); + assert!(matches!( + token_issue(&mut backend), + Err(WizardError::Cancelled) + )); + } + + #[test] + fn nft_optionals_stop_at_the_first_empty_answer() { + let mut backend = backend( + text(&["dest", "abcd", "Name", "Desc", "NFT", "cc", "https://icon", "", ""]) + .chain([CannedAnswer::Confirm(true)]), + ); + let line = token_nft_issue(&mut backend).unwrap(); + assert_eq!( + line, + "token nft issue dest abcd Name Desc NFT cc https://icon" + ); + } + + #[test] + fn nft_optionals_must_be_filled_in_order() { + let mut backend = backend(text(&[ + "dest", + "abcd", + "Name", + "Desc", + "NFT", + "", + "https://icon", + ])); + assert!(token_nft_issue(&mut backend).is_err()); + } + + #[test] + fn pool_create_defaults_the_cost_and_requires_ordered_optionals() { + let mut backend = backend( + text(&["40000", "", "100", "cold", "", ""]).chain([CannedAnswer::Confirm(true)]), + ); + let line = staking_pool_create(&mut backend).unwrap(); + assert_eq!(line, "staking create-pool 40000 0 100 cold"); + + let mut with_optional_keys = CannedBackend::new( + text(&["40000", "5", "100", "cold", "staker", "aabb"]) + .chain([CannedAnswer::Confirm(true)]), + ); + let line = staking_pool_create(&mut with_optional_keys).unwrap(); + assert_eq!(line, "staking create-pool 40000 5 100 cold staker aabb"); + } + + #[test] + fn order_create_renders_the_implied_rate() { + let mut backend = backend( + [CannedAnswer::Select(1)] + .into_iter() + .chain(text(&["tok1", "100"])) + .chain([CannedAnswer::Select(0)]) + .chain(text(&["50", "addr"])) + .chain([CannedAnswer::Confirm(true)]), + ); + let line = order_create(&mut backend).unwrap(); + assert_eq!(line, "order create tok1 100 coin 50 addr"); + } + + #[test] + fn htlc_create_pins_the_funclike_timelock() { + let mut backend = backend( + [CannedAnswer::Select(0)] + .into_iter() + .chain(text(&["50", "abcd", "spend", "refund"])) + .chain([CannedAnswer::Select(0)]) + .chain(text(&["720"])) + .chain([CannedAnswer::Confirm(true)]), + ); + let line = htlc_create(&mut backend).unwrap(); + assert_eq!( + line, + "htlc create coin 50 abcd spend block_count(720) refund" + ); + } +} diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index ca3b69e3a0..95e39f572a 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -34,6 +34,7 @@ use tokio::sync::{mpsc, oneshot}; use utils::once_destructor::OnceDestructor; use wallet_cli_commands::{ ChoiceMenu, ConsoleCommand, ManageableWalletCommand, build_repl_command, parse_input, + wizard::{DialoguerBackend, WizardError, WizardKind}, }; use wallet_rpc_lib::types::NodeInterface; @@ -95,6 +96,32 @@ fn create_line_editor( line_editor.with_edit_mode(edit_mode) } +/// Runs the wizard for a recognized command line, echoing the generated +/// one-liner. `None` means the line is not a wizard invocation. +fn run_wizard_for_line(line: &str) -> Result, WalletCliError> { + let Some(tokens) = shlex::split(line) else { + return Ok(None); + }; + let token_refs: Vec<&str> = tokens.iter().map(String::as_str).collect(); + let Some(kind) = WizardKind::parse_from_tokens(&token_refs) else { + return Ok(None); + }; + match kind.run(&mut DialoguerBackend) { + Ok(one_liner) => { + println!("You could also have run: {one_liner}"); + Ok(Some(one_liner)) + } + Err(WizardError::Cancelled) => { + println!("Wizard cancelled."); + Ok(None) + } + Err(WizardError::InvalidInput(message)) => Err(WalletCliError::InvalidInput(message)), + Err(WizardError::Io(err)) => Err(WalletCliError::InvalidInput(format!( + "the wizard could not read the answer: {err}" + ))), + } +} + fn process_line( repl_command: &Command, event_tx: &mpsc::UnboundedSender>, @@ -111,6 +138,12 @@ fn process_line( } }; + if let Some(one_liner) = run_wizard_for_line::(&line)? { + let command_opt = parse_input(&one_liner, repl_command)?; + let command = command_opt.expect("wizard output must re-parse as a command"); + return super::run_command_blocking(event_tx, command).map(Option::Some); + } + let command_opt = parse_input(&line, repl_command)?; let command = match command_opt { From 6de0e88ed43f365b28859dcd04abfdb999cb7e06 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 12:46:18 +0200 Subject: [PATCH 22/52] feat(wallet-cli): run single commands with exec mode --- wallet/wallet-cli-lib/src/config.rs | 24 ++- wallet/wallet-cli-lib/src/console.rs | 21 +++ wallet/wallet-cli-lib/src/lib.rs | 23 ++- wallet/wallet-cli-lib/src/repl/exec.rs | 162 ++++++++++++++++++ wallet/wallet-cli-lib/src/repl/mod.rs | 1 + .../tests/cli_test_framework.rs | 4 + 6 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 wallet/wallet-cli-lib/src/repl/exec.rs diff --git a/wallet/wallet-cli-lib/src/config.rs b/wallet/wallet-cli-lib/src/config.rs index 57074c11c6..95325c96f7 100644 --- a/wallet/wallet-cli-lib/src/config.rs +++ b/wallet/wallet-cli-lib/src/config.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use common::chain::config::{ChainType, regtest_options::ChainConfigOptions}; use crypto::key::hdkd::u31::U31; @@ -100,6 +100,16 @@ impl WalletCliArgs { } } +/// Output format for one-shot (`--exec`) runs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum CliOutputFormat { + /// Human-readable text. + #[default] + Text, + /// A single JSON object on standard output. + Json, +} + #[derive(Args, Clone, Debug)] #[command( group( @@ -154,6 +164,18 @@ pub struct CliArgs { #[clap(long)] pub commands_file: Option, + /// Run a single wallet command and exit. + /// + /// The command is spelled exactly as in the interactive wallet, e.g. + /// `--exec "account create name"`. The exit status is 0 when the command + /// succeeded and 1 when it failed. + #[clap(long)] + pub exec: Option, + + /// Output format for `--exec`: human-readable text or a single JSON object. + #[clap(long, value_enum, default_value_t = CliOutputFormat::Text)] + pub output: CliOutputFormat, + /// Preserve history file between application runs. /// This can be very insecure as it also stores things like the seed-phrase, use at your own risk! #[clap(long)] diff --git a/wallet/wallet-cli-lib/src/console.rs b/wallet/wallet-cli-lib/src/console.rs index 9e7d4b92bc..fbed47e9ab 100644 --- a/wallet/wallet-cli-lib/src/console.rs +++ b/wallet/wallet-cli-lib/src/console.rs @@ -51,6 +51,27 @@ impl ConsoleInput for StdioInputConsole { } } +/// A console input that yields a single fixed line, used by `--exec`. +pub struct OneShotInput { + line: Option, +} + +impl OneShotInput { + pub fn new(line: String) -> Self { + Self { line: Some(line) } + } +} + +impl ConsoleInput for OneShotInput { + fn is_tty(&self) -> bool { + false + } + + fn read_line(&mut self) -> Option { + self.line.take() + } +} + pub struct StdioOutputConsole; impl ConsoleOutput for StdioOutputConsole { diff --git a/wallet/wallet-cli-lib/src/lib.rs b/wallet/wallet-cli-lib/src/lib.rs index 106874b0c5..4f7fd5fcaa 100644 --- a/wallet/wallet-cli-lib/src/lib.rs +++ b/wallet/wallet-cli-lib/src/lib.rs @@ -51,6 +51,10 @@ enum Mode { CommandsList { file_input: console::FileInput, }, + Exec { + line: String, + output_format: config::CliOutputFormat, + }, } pub async fn run( @@ -74,7 +78,13 @@ pub async fn run( let cli_args = args.cli_args(); - let mode = if let Some(file_path) = &cli_args.commands_file { + let mode = if let Some(line) = &cli_args.exec { + repl::non_interactive::log::init(); + Mode::Exec { + line: line.clone(), + output_format: cli_args.output, + } + } else if let Some(file_path) = &cli_args.commands_file { repl::non_interactive::log::init(); let file_input = console::FileInput::new::(file_path.clone())?; Mode::CommandsList { file_input } @@ -400,6 +410,17 @@ fn setup_events_and_repl( args.cold_wallet, startup_command_futures, ), + Mode::Exec { + line, + output_format, + } => repl::exec::run( + line, + output_format, + output, + event_tx, + args.cold_wallet, + startup_command_futures, + ), }) .map_err(Box::::from)? }; diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs new file mode 100644 index 0000000000..80106c8d70 --- /dev/null +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -0,0 +1,162 @@ +// Copyright (c) 2024 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// One-shot mode: run a single wallet command from the command line and exit. + +use clap::Command; +use tokio::sync::{mpsc, oneshot}; +use wallet_cli_commands::{ConsoleCommand, build_repl_command, parse_input}; +use wallet_rpc_lib::types::NodeInterface; + +use crate::{ + cli_event_loop::Event, config::CliOutputFormat, console::ConsoleOutput, errors::WalletCliError, +}; + +enum ExecOutput { + Text(String), + Json(serde_json::Value), + Nothing, +} + +fn process_line( + repl_command: &Command, + event_tx: &mpsc::UnboundedSender>, + line: &str, +) -> Result> { + let command_opt = parse_input(line, repl_command)?; + + let command = match command_opt { + Some(command) => command, + None => return Ok(ExecOutput::Nothing), + }; + + match super::run_command_blocking(event_tx, command)? { + ConsoleCommand::Print(text) => Ok(ExecOutput::Text(text)), + ConsoleCommand::Json { text, value } => Ok(match text { + Some(text) => ExecOutput::Json(serde_json::json!({ "text": text, "value": value })), + None => ExecOutput::Json(value), + }), + ConsoleCommand::Warn(message) => Ok(ExecOutput::Text(message)), + ConsoleCommand::PaginatedPrint { header, body } => { + let rendered = match (header.is_empty(), body.is_empty()) { + (true, _) => body, + (false, true) => header, + (false, false) => format!("{header}\n{body}"), + }; + Ok(ExecOutput::Text(rendered)) + } + ConsoleCommand::SetStatus { + status: _, + print_message, + } => Ok(ExecOutput::Text(print_message)), + ConsoleCommand::ClearScreen + | ConsoleCommand::PrintHistory + | ConsoleCommand::ChoiceMenu(_) + | ConsoleCommand::ClearHistory => Err(WalletCliError::InvalidInput(format!( + "Unsupported command in exec mode: {line}", + ))), + ConsoleCommand::Exit => Ok(ExecOutput::Nothing), + } +} + +/// Runs one command line and renders its result. Errors propagate to the +/// caller, which maps them to a non-zero exit status. +pub fn run( + line: String, + output_format: CliOutputFormat, + mut output: impl ConsoleOutput, + event_tx: mpsc::UnboundedSender>, + cold_wallet: bool, + startup_command_futures: Vec>>>, +) -> Result<(), WalletCliError> { + for res_rx in startup_command_futures { + let res = res_rx.blocking_recv().expect("Channel must be open")?; + // The startup command output is the wallet opening report; render it + // as text regardless of the requested format so the JSON stream on + // standard output carries only the requested command's result. + let line_out = to_startup_text(res); + output.print_line(&line_out); + } + + // The REPL thread is sized for grammar construction, so build the + // grammar directly instead of nesting another large-stack spawn. + let repl_command = build_repl_command(cold_wallet, true); + + let result = process_line(&repl_command, &event_tx, &line)?; + + match (output_format, result) { + (_, ExecOutput::Nothing) => {} + (CliOutputFormat::Text, ExecOutput::Text(text)) => output.print_line(&text), + (CliOutputFormat::Text, ExecOutput::Json(value)) => { + output.print_line(&wallet_cli_commands::output::json_display_text( + None, &value, + )); + } + (CliOutputFormat::Json, ExecOutput::Text(text)) => output.print_line( + &serde_json::to_string(&serde_json::json!({ "output": text })) + .map_err(WalletCliError::SerdeJsonFormatError)?, + ), + (CliOutputFormat::Json, ExecOutput::Json(value)) => output.print_line( + &serde_json::to_string(&value).map_err(WalletCliError::SerdeJsonFormatError)?, + ), + } + + Ok(()) +} + +fn to_startup_text(command_output: ConsoleCommand) -> String { + match command_output { + ConsoleCommand::Print(text) => text, + ConsoleCommand::Json { text, value } => { + wallet_cli_commands::output::json_display_text(text, &value) + } + ConsoleCommand::Warn(message) => message, + ConsoleCommand::PaginatedPrint { header, body } => { + match (header.is_empty(), body.is_empty()) { + (true, _) => body, + (false, true) => header, + (false, false) => format!("{header}\n{body}"), + } + } + ConsoleCommand::SetStatus { + status: _, + print_message, + } => print_message, + ConsoleCommand::ClearScreen + | ConsoleCommand::PrintHistory + | ConsoleCommand::ChoiceMenu(_) + | ConsoleCommand::ClearHistory + | ConsoleCommand::Exit => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_line(line: &str, cold: bool) -> Option { + // Parse-only probe: build the grammar and reuse the shared parse path. + let repl_command = build_repl_command(cold, true); + parse_input::(line, &repl_command) + .expect("grammar parse must not fail") + .map(|_| serde_json::json!("parsed")) + } + + #[test] + fn exec_flag_parses_as_a_cli_arg() { + let args: crate::config::WalletCliArgs = + clap::Parser::parse_from(["wallet-cli", "--exec", "account create name"]); + assert_eq!(args.cli_args().exec.as_deref(), Some("account create name")); + } + + #[test] + fn exec_grammar_accepts_a_cold_wallet_command() { + // The cold-wallet grammar keeps the flat command names and drops the + // hot-wallet groups, so pin the flat spelling here. + assert_eq!(parse_line("wallet-info", true).unwrap(), "parsed"); + } +} diff --git a/wallet/wallet-cli-lib/src/repl/mod.rs b/wallet/wallet-cli-lib/src/repl/mod.rs index cda37594fd..707640cdb0 100644 --- a/wallet/wallet-cli-lib/src/repl/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/mod.rs @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod exec; pub mod interactive; pub mod non_interactive; diff --git a/wallet/wallet-cli-lib/tests/cli_test_framework.rs b/wallet/wallet-cli-lib/tests/cli_test_framework.rs index abfac079db..6a54aaa4c9 100644 --- a/wallet/wallet-cli-lib/tests/cli_test_framework.rs +++ b/wallet/wallet-cli-lib/tests/cli_test_framework.rs @@ -106,6 +106,8 @@ impl CliTestFramework { node_rpc_username: Some(RPC_USERNAME.to_owned()), node_rpc_password: Some(RPC_PASSWORD.to_owned()), commands_file: None, + exec: None, + output: wallet_cli_lib::config::CliOutputFormat::Text, history_file: None, exit_on_error: None, vi_mode: false, @@ -137,6 +139,8 @@ impl CliTestFramework { node_rpc_username: Some(RPC_USERNAME.to_owned()), node_rpc_password: Some(RPC_PASSWORD.to_owned()), commands_file: None, + exec: None, + output: wallet_cli_lib::config::CliOutputFormat::Text, history_file: None, exit_on_error: None, vi_mode: false, From 6c29741ff2fd1a1b25c84b6b2d8bfdf80e970763 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 13:02:41 +0200 Subject: [PATCH 23/52] feat(wallet-cli): confirm state-changing commands before running them --- .../src/command_handler/mod.rs | 20 +- wallet/wallet-cli-commands/src/confirm.rs | 238 ++++++++++++++++++ wallet/wallet-cli-lib/src/cli_event_loop.rs | 6 +- wallet/wallet-cli-lib/src/config.rs | 4 +- wallet/wallet-cli-lib/src/lib.rs | 12 +- .../tests/cli_test_framework.rs | 4 +- 6 files changed, 261 insertions(+), 23 deletions(-) create mode 100644 wallet/wallet-cli-commands/src/confirm.rs diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 9d8e268850..b4f7f3a693 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -97,18 +97,18 @@ pub struct CommandHandler { wallet: WalletWithState, - no_qr: bool, + qr: bool, } impl CommandHandler where W: WalletInterface + Send + Sync + 'static, { - pub async fn new(config: ControllerConfig, wallet: W, no_qr: Option) -> Self { + pub async fn new(config: ControllerConfig, wallet: W, qr: Option) -> Self { CommandHandler { config, wallet: WalletWithState::new(wallet).await, - no_qr: no_qr.unwrap_or(false), + qr: qr.unwrap_or(false), } } @@ -739,7 +739,7 @@ where let hex_chain_code = key.chain_code.to_string(); let extended_public_key = format!("{hex_public_key}{hex_chain_code}"); - let qr_code = if !self.no_qr { + let qr_code = if self.qr { let qr_code = qrcode_or_error_string(&extended_public_key); format!("\nOr contained in the QR code:\n{qr_code}") } else { @@ -925,7 +925,7 @@ where let summary = signed_tx.transaction().text_summary(chain_config); let result_hex: HexEncoded = signed_tx.into(); - let qr_code_string = if self.no_qr { + let qr_code_string = if !self.qr { String::new() } else { let qr_code = qrcode_or_error_string(&result_hex.to_string()); @@ -943,7 +943,7 @@ where let result_hex: HexEncoded = partially_signed_tx.into(); - let qr_code_string = if self.no_qr { + let qr_code_string = if !self.qr { String::new() } else { let qr_code = qrcode_or_error_string(&result_hex.to_string()); @@ -984,7 +984,7 @@ where let result = wallet.sign_challenge_hex(selected_account, challenge, address).await?; - let qr_code_string = if self.no_qr { + let qr_code_string = if !self.qr { String::new() } else { let qr_code = qrcode_or_error_string(&result); @@ -1005,7 +1005,7 @@ where let (wallet, selected_account) = wallet_and_selected_acc(&mut self.wallet).await?; let result = wallet.sign_challenge(selected_account, challenge, address).await?; - let qr_code_string = if self.no_qr { + let qr_code_string = if !self.qr { String::new() } else { let qr_code = qrcode_or_error_string(&result); @@ -1932,7 +1932,7 @@ where let summary = tx.tx().text_summary(chain_config); - let qr_code_string = if self.no_qr { + let qr_code_string = if !self.qr { String::new() } else { let qr_code = qrcode_or_error_string(&hex); @@ -2232,7 +2232,7 @@ where ) .await?; - let qr_code_string = if self.no_qr { + let qr_code_string = if !self.qr { String::new() } else { let qr_code = qrcode_or_error_string(&result_hex.to_string()); diff --git a/wallet/wallet-cli-commands/src/confirm.rs b/wallet/wallet-cli-commands/src/confirm.rs new file mode 100644 index 0000000000..553a3604eb --- /dev/null +++ b/wallet/wallet-cli-commands/src/confirm.rs @@ -0,0 +1,238 @@ +// Copyright (c) 2024 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Confirmation prompts for state-changing wallet commands. +// +// The interactive wallet and `--exec` mode ask for confirmation before a +// command that moves funds, changes on-chain state, or destroys secrets. +// `--yes` skips the prompt. + +use crate::{ + ColdWalletCommand, ManageableWalletCommand, WalletCommand, WalletManagementCommand, +}; + +/// Returns the confirmation question for a state-changing command, or `None` +/// when the command can run without asking. +pub fn confirmation_prompt(command: &ManageableWalletCommand) -> Option { + match command { + ManageableWalletCommand::WalletCommands(command) => wallet_confirmation(command), + ManageableWalletCommand::ManagementCommands(WalletManagementCommand::CloseWallet) => { + None + } + ManageableWalletCommand::ManagementCommands(_) => None, + _ => None, + } +} + +fn wallet_confirmation(command: &WalletCommand) -> Option { + match command { + WalletCommand::ConfigBroadcast { broadcast } => Some(format!( + "Set automatic broadcasting of created transactions to {broadcast}?" + )), + WalletCommand::SendToAddress { + address, + amount, + utxos: _, + } => Some(format!("Send {amount} coins to {address}?")), + WalletCommand::SendTokensToAddress { + token_id, + address, + amount, + } => Some(format!("Send {amount} of token {token_id} to {address}?")), + WalletCommand::SweepFromAddress { + destination_address, + addresses, + all, + } => { + let source = if *all { + "all addresses".to_owned() + } else { + addresses.join(", ") + }; + Some(format!("Sweep {source} to {destination_address}?")) + } + WalletCommand::SweepFromDelegation { + destination_address, + delegation_id, + } => Some(format!( + "Sweep delegation {delegation_id} to {destination_address}?" + )), + WalletCommand::DepositData { data } => Some(format!( + "Store {} bytes of data on the chain?", + data.len() / 2 + )), + WalletCommand::IssueNewNft { + destination: _, + media_hash: _, + name, + .. + } => Some(format!("Issue the NFT \"{name}\"?")), + WalletCommand::IssueNewToken { ticker, .. } => { + Some(format!("Issue the token {ticker}?")) + } + WalletCommand::ChangeTokenAuthority { token_id, address } => Some(format!( + "Change the authority of token {token_id} to {address}?" + )), + WalletCommand::ChangeTokenMetadataUri { + token_id, + metadata_uri, + } => Some(format!( + "Change the metadata URI of token {token_id} to {metadata_uri}?" + )), + WalletCommand::MintTokens { + token_id, + address, + amount, + } => Some(format!("Mint {amount} of token {token_id} to {address}?")), + WalletCommand::UnmintTokens { token_id, amount } => { + Some(format!("Unmint {amount} of token {token_id}?")) + } + WalletCommand::LockTokenSupply { token_id } => { + Some(format!("Permanently lock the supply of token {token_id}?")) + } + WalletCommand::FreezeToken { token_id, .. } => { + Some(format!("Freeze token accounts of {token_id}?")) + } + WalletCommand::UnfreezeToken { token_id } => { + Some(format!("Unfreeze token accounts of {token_id}?")) + } + WalletCommand::DelegateStaking { + delegation_id, + amount, + } => Some(format!("Delegate {amount} to delegation {delegation_id}?")), + WalletCommand::WithdrawFromDelegation { + address, + amount, + delegation_id, + } => Some(format!( + "Withdraw {amount} from delegation {delegation_id} to {address}?" + )), + WalletCommand::StartStaking { .. } => Some("Start staking?".to_owned()), + WalletCommand::StopStaking { .. } => Some("Stop staking?".to_owned()), + WalletCommand::CreateStakePool { amount, .. } => { + Some(format!("Create a stake pool pledging {amount}?")) + } + WalletCommand::DecommissionStakePool { + pool_id, + output_address, + } => Some(format!( + "Decommission stake pool {pool_id}, sending the funds to {output_address}?" + )), + WalletCommand::DecommissionStakePoolRequest { + pool_id, + output_address, + } => Some(format!( + "Produce a decommission request for pool {pool_id} paying to {output_address}?" + )), + WalletCommand::CreateOrder { + ask_currency, + ask_amount, + give_currency, + give_amount, + conclude_address: _, + } => Some(format!( + "Create an order asking {ask_amount} {ask_currency} for {give_amount} {give_currency}?" + )), + WalletCommand::FillOrder { order_id, amount, .. } => { + Some(format!("Fill order {order_id} with {amount}?")) + } + WalletCommand::FreezeOrder { order_id } => { + Some(format!("Freeze order {order_id}?")) + } + WalletCommand::ConcludeOrder { order_id, .. } => { + Some(format!("Conclude order {order_id}?")) + } + WalletCommand::CreateHtlcTx { + currency, + amount, + .. + } => Some(format!("Create an HTLC locking {amount} {currency}?")), + WalletCommand::TransactionCompose { outputs, .. } => Some(format!( + "Compose a transaction with {} output(s)?", + outputs.len() + )), + WalletCommand::CreateTxFromColdInput { + address, + amount, + .. + } => Some(format!( + "Compose a cold-input transaction sending {amount} to {address}? The result is not broadcast." + )), + WalletCommand::AbandonTransaction { transaction_id } => { + Some(format!("Abandon transaction {transaction_id}?")) + } + WalletCommand::SubmitTransaction { transaction: _, do_not_store } => { + let destination = if *do_not_store { + "without storing it" + } else { + "and store it in the local mempool" + }; + Some(format!("Submit the given transaction to the node {destination}?")) + } + WalletCommand::ColdCommands(command) => cold_wallet_confirmation(command), + _ => None, + } +} + +fn cold_wallet_confirmation(command: &ColdWalletCommand) -> Option { + match command { + ColdWalletCommand::PurgeSeedPhrase => Some( + "Permanently delete the stored seed phrase from this wallet file? \ + This cannot be undone." + .to_owned(), + ), + ColdWalletCommand::RemovePrivateKeysEncryption => Some( + "Disable private key encryption? The private keys will be stored in plain text." + .to_owned(), + ), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + type Client = node_comm::node_traits::MockNodeInterface; + + fn prompt(line: &str) -> Option { + let command = crate::parse_input::( + line, + &crate::build_repl_command(false, true), + ) + .expect("grammar parse must not fail"); + confirmation_prompt(&command) + } + + #[test] + fn asset_moving_commands_ask_for_confirmation() { + assert_eq!( + prompt("address-send addr1 100").as_deref(), + Some("Send 100 coins to addr1?") + ); + assert_eq!( + prompt("token-send 0x11 addr1 7").as_deref(), + Some("Send 7 of token 0x11 to addr1?") + ); + assert!(prompt("staking-create-pool 100 0 10 cold").is_some()); + assert!(prompt("delegation-stake 100 0x22").is_some()); + assert!(prompt("order-create coin 1 coin 2 addr").is_some()); + } + + #[test] + fn read_only_commands_do_not_ask() { + assert!(prompt("account-balance").is_none()); + assert!(prompt("address-new").is_none()); + assert!(prompt("staking-status").is_none()); + } + + #[test] + fn destructive_wallet_commands_ask() { + assert!(prompt("wallet-purge-seed-phrase").is_some()); + assert!(prompt("wallet-disable-private-keys-encryption").is_some()); + } +} diff --git a/wallet/wallet-cli-lib/src/cli_event_loop.rs b/wallet/wallet-cli-lib/src/cli_event_loop.rs index 1f43101c6a..02493e42af 100644 --- a/wallet/wallet-cli-lib/src/cli_event_loop.rs +++ b/wallet/wallet-cli-lib/src/cli_event_loop.rs @@ -58,7 +58,7 @@ pub async fn run( in_top_x_mb: usize, wallet_type: WalletType, cold_wallet: bool, - no_qr: bool, + qr: bool, ) -> Result<(), WalletCliError> { let wallet_cli_version = parse_version_without_metadata(app_version_with_git_info!().version); @@ -117,7 +117,7 @@ pub async fn run( broadcast_to_mempool: true, }, wallet, - Some(no_qr), + Some(qr), ) .await; @@ -167,7 +167,7 @@ pub async fn run( broadcast_to_mempool: true, }, wallet, - Some(no_qr), + Some(qr), ) .await; diff --git a/wallet/wallet-cli-lib/src/config.rs b/wallet/wallet-cli-lib/src/config.rs index 95325c96f7..bc2db65654 100644 --- a/wallet/wallet-cli-lib/src/config.rs +++ b/wallet/wallet-cli-lib/src/config.rs @@ -246,9 +246,9 @@ pub struct CliArgs { #[arg(long, conflicts_with_all(["remote_rpc_wallet_password", "remote_rpc_wallet_username", "remote_rpc_wallet_cookie_file"]))] pub remote_rpc_wallet_no_authentication: bool, - /// Disable QR code output for wallet commands + /// Print QR codes for addresses and transaction payloads (off by default). #[arg(long)] - pub no_qr: bool, + pub qr: bool, } impl From<&Network> for ChainType { diff --git a/wallet/wallet-cli-lib/src/lib.rs b/wallet/wallet-cli-lib/src/lib.rs index 4f7fd5fcaa..fa1f08f0c4 100644 --- a/wallet/wallet-cli-lib/src/lib.rs +++ b/wallet/wallet-cli-lib/src/lib.rs @@ -168,7 +168,7 @@ async fn start_hot_wallet( cli_args.node_rpc_address.clone().unwrap_or(default_addr) }; - let no_qr = cli_args.no_qr; + let qr = cli_args.qr; let (repl_handle, wallet_rpc_config) = setup_events_and_repl(cli_args, mode, output, input, event_tx, chain_type)?; @@ -182,7 +182,7 @@ async fn start_hot_wallet( wallet_rpc_config, }, false, - no_qr, + qr, ) .await?; Ok(repl_handle.join().expect("Should not panic")?) @@ -198,7 +198,7 @@ async fn start_cold_wallet( ) -> Result<(), Box> { let (event_tx, event_rx) = mpsc::unbounded_channel(); - let no_qr = cli_args.no_qr; + let qr = cli_args.qr; let (repl_handle, wallet_rpc_config) = setup_events_and_repl( cli_args, mode, @@ -217,7 +217,7 @@ async fn start_cold_wallet( wallet_rpc_config, }, true, - no_qr, + qr, ) .await?; Ok(repl_handle.join().expect("Should not panic")?) @@ -258,7 +258,7 @@ async fn connect_to_rpc_wallet( _ => panic!("Should not happen due to arg constraints"), }; - let no_qr = cli_args.no_qr; + let qr = cli_args.qr; let remote_socket_address = cli_args.remote_rpc_wallet_address.clone().expect("checked"); let (repl_handle, _wallet_rpc_config) = setup_events_and_repl(cli_args, mode, output, input, event_tx, chain_type)?; @@ -272,7 +272,7 @@ async fn connect_to_rpc_wallet( rpc_auth, }, false, - no_qr, + qr, ) .await?; Ok(repl_handle.join().expect("Should not panic")?) diff --git a/wallet/wallet-cli-lib/tests/cli_test_framework.rs b/wallet/wallet-cli-lib/tests/cli_test_framework.rs index 6a54aaa4c9..6390e43f7d 100644 --- a/wallet/wallet-cli-lib/tests/cli_test_framework.rs +++ b/wallet/wallet-cli-lib/tests/cli_test_framework.rs @@ -124,7 +124,7 @@ impl CliTestFramework { remote_rpc_wallet_password: None, remote_rpc_wallet_cookie_file: None, remote_rpc_wallet_no_authentication: true, - no_qr: false, + qr: false, }, }))), run_options: wallet_cli_lib::config::CliArgs { @@ -157,7 +157,7 @@ impl CliTestFramework { remote_rpc_wallet_password: None, remote_rpc_wallet_cookie_file: None, remote_rpc_wallet_no_authentication: true, - no_qr: false, + qr: false, }, }; From 967932b649679cd83fc76576c325aeae603d5e53 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 13:19:46 +0200 Subject: [PATCH 24/52] fix(wallet-cli): wire confirmations into the wallet entry points --- Cargo.lock | 1 + wallet/wallet-cli-commands/src/confirm.rs | 85 ++++++++++--------- wallet/wallet-cli-commands/src/lib.rs | 1 + wallet/wallet-cli-lib/Cargo.toml | 1 + wallet/wallet-cli-lib/src/config.rs | 7 ++ wallet/wallet-cli-lib/src/lib.rs | 4 + wallet/wallet-cli-lib/src/repl/exec.rs | 12 ++- .../src/repl/interactive/mod.rs | 27 ++++++ .../tests/cli_test_framework.rs | 2 + 9 files changed, 101 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2669dacf44..fcfacd50ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10165,6 +10165,7 @@ dependencies = [ "crossterm", "crypto", "derive_more 1.0.0", + "dialoguer", "directories", "futures", "hex", diff --git a/wallet/wallet-cli-commands/src/confirm.rs b/wallet/wallet-cli-commands/src/confirm.rs index 553a3604eb..2d2876f15f 100644 --- a/wallet/wallet-cli-commands/src/confirm.rs +++ b/wallet/wallet-cli-commands/src/confirm.rs @@ -11,28 +11,25 @@ // command that moves funds, changes on-chain state, or destroys secrets. // `--yes` skips the prompt. -use crate::{ - ColdWalletCommand, ManageableWalletCommand, WalletCommand, WalletManagementCommand, -}; +use crate::{ColdWalletCommand, ManageableWalletCommand, WalletCommand, helper_types::CliCurrency}; /// Returns the confirmation question for a state-changing command, or `None` /// when the command can run without asking. pub fn confirmation_prompt(command: &ManageableWalletCommand) -> Option { match command { ManageableWalletCommand::WalletCommands(command) => wallet_confirmation(command), - ManageableWalletCommand::ManagementCommands(WalletManagementCommand::CloseWallet) => { - None - } ManageableWalletCommand::ManagementCommands(_) => None, - _ => None, } } fn wallet_confirmation(command: &WalletCommand) -> Option { match command { - WalletCommand::ConfigBroadcast { broadcast } => Some(format!( - "Set automatic broadcasting of created transactions to {broadcast}?" - )), + WalletCommand::ConfigBroadcast { broadcast } => { + let broadcast = broadcast.to_bool(); + Some(format!( + "Set automatic broadcasting of created transactions to {broadcast}?" + )) + } WalletCommand::SendToAddress { address, amount, @@ -61,18 +58,18 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { } => Some(format!( "Sweep delegation {delegation_id} to {destination_address}?" )), - WalletCommand::DepositData { data } => Some(format!( + WalletCommand::DepositData { hex_data } => Some(format!( "Store {} bytes of data on the chain?", - data.len() / 2 + hex_data.len() / 2 )), WalletCommand::IssueNewNft { - destination: _, + destination_address: _, media_hash: _, name, .. } => Some(format!("Issue the NFT \"{name}\"?")), - WalletCommand::IssueNewToken { ticker, .. } => { - Some(format!("Issue the token {ticker}?")) + WalletCommand::IssueNewToken { token_ticker, .. } => { + Some(format!("Issue the token {token_ticker}?")) } WalletCommand::ChangeTokenAuthority { token_id, address } => Some(format!( "Change the authority of token {token_id} to {address}?" @@ -135,49 +132,60 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { give_amount, conclude_address: _, } => Some(format!( - "Create an order asking {ask_amount} {ask_currency} for {give_amount} {give_currency}?" + "Create an order asking {ask_amount} {} for {give_amount} {}?", + describe_currency(ask_currency), + describe_currency(give_currency) )), - WalletCommand::FillOrder { order_id, amount, .. } => { - Some(format!("Fill order {order_id} with {amount}?")) - } - WalletCommand::FreezeOrder { order_id } => { - Some(format!("Freeze order {order_id}?")) - } + WalletCommand::FillOrder { + order_id, amount, .. + } => Some(format!("Fill order {order_id} with {amount}?")), + WalletCommand::FreezeOrder { order_id } => Some(format!("Freeze order {order_id}?")), WalletCommand::ConcludeOrder { order_id, .. } => { Some(format!("Conclude order {order_id}?")) } WalletCommand::CreateHtlcTx { - currency, - amount, - .. - } => Some(format!("Create an HTLC locking {amount} {currency}?")), + currency, amount, .. + } => Some(format!( + "Create an HTLC locking {amount} {}?", + describe_currency(currency) + )), WalletCommand::TransactionCompose { outputs, .. } => Some(format!( "Compose a transaction with {} output(s)?", outputs.len() )), WalletCommand::CreateTxFromColdInput { - address, - amount, - .. + address, amount, .. } => Some(format!( "Compose a cold-input transaction sending {amount} to {address}? The result is not broadcast." )), WalletCommand::AbandonTransaction { transaction_id } => { Some(format!("Abandon transaction {transaction_id}?")) } - WalletCommand::SubmitTransaction { transaction: _, do_not_store } => { + WalletCommand::SubmitTransaction { + transaction: _, + do_not_store, + } => { let destination = if *do_not_store { "without storing it" } else { "and store it in the local mempool" }; - Some(format!("Submit the given transaction to the node {destination}?")) + Some(format!( + "Submit the given transaction to the node {destination}?" + )) } WalletCommand::ColdCommands(command) => cold_wallet_confirmation(command), _ => None, } } +fn describe_currency(currency: &CliCurrency) -> String { + match currency { + CliCurrency::Coin => "coins".to_owned(), + CliCurrency::Token(token_id) => format!("token {token_id}"), + } +} + fn cold_wallet_confirmation(command: &ColdWalletCommand) -> Option { match command { ColdWalletCommand::PurgeSeedPhrase => Some( @@ -199,13 +207,14 @@ mod tests { type Client = node_comm::node_traits::MockNodeInterface; - fn prompt(line: &str) -> Option { - let command = crate::parse_input::( - line, - &crate::build_repl_command(false, true), - ) - .expect("grammar parse must not fail"); - confirmation_prompt(&command) + fn prompt(line: &'static str) -> Option { + crate::run_with_large_stack(move || { + let command = + crate::parse_input::(line, &crate::build_repl_command(false, true)) + .expect("grammar parse must not fail") + .expect("test lines parse to a command"); + confirmation_prompt(&command) + }) } #[test] diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index d535a7b72a..a498a1d023 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -14,6 +14,7 @@ // limitations under the License. mod command_handler; +pub mod confirm; mod errors; mod helper_types; pub mod output; diff --git a/wallet/wallet-cli-lib/Cargo.toml b/wallet/wallet-cli-lib/Cargo.toml index a0f456158a..9cee6b1298 100644 --- a/wallet/wallet-cli-lib/Cargo.toml +++ b/wallet/wallet-cli-lib/Cargo.toml @@ -35,6 +35,7 @@ humantime.workspace = true hex.workspace = true itertools.workspace = true reedline = { workspace = true, features = ["external_printer"] } +dialoguer.workspace = true semver.workspace = true serde_json.workspace = true shlex.workspace = true diff --git a/wallet/wallet-cli-lib/src/config.rs b/wallet/wallet-cli-lib/src/config.rs index bc2db65654..6fee1f0d38 100644 --- a/wallet/wallet-cli-lib/src/config.rs +++ b/wallet/wallet-cli-lib/src/config.rs @@ -176,6 +176,13 @@ pub struct CliArgs { #[clap(long, value_enum, default_value_t = CliOutputFormat::Text)] pub output: CliOutputFormat, + /// Answer "yes" to every confirmation prompt. + /// + /// State-changing commands ask for confirmation; in non-interactive runs + /// (`--exec`, `--commands-file`) they abort unless this flag is passed. + #[clap(long)] + pub yes: bool, + /// Preserve history file between application runs. /// This can be very insecure as it also stores things like the seed-phrase, use at your own risk! #[clap(long)] diff --git a/wallet/wallet-cli-lib/src/lib.rs b/wallet/wallet-cli-lib/src/lib.rs index fa1f08f0c4..e6a27f4c33 100644 --- a/wallet/wallet-cli-lib/src/lib.rs +++ b/wallet/wallet-cli-lib/src/lib.rs @@ -54,6 +54,7 @@ enum Mode { Exec { line: String, output_format: config::CliOutputFormat, + assume_yes: bool, }, } @@ -83,6 +84,7 @@ pub async fn run( Mode::Exec { line: line.clone(), output_format: cli_args.output, + assume_yes: cli_args.yes, } } else if let Some(file_path) = &cli_args.commands_file { repl::non_interactive::log::init(); @@ -413,9 +415,11 @@ fn setup_events_and_repl( Mode::Exec { line, output_format, + assume_yes, } => repl::exec::run( line, output_format, + assume_yes, output, event_tx, args.cold_wallet, diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index 80106c8d70..b65b1c9eb1 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -26,6 +26,7 @@ fn process_line( repl_command: &Command, event_tx: &mpsc::UnboundedSender>, line: &str, + assume_yes: bool, ) -> Result> { let command_opt = parse_input(line, repl_command)?; @@ -34,6 +35,14 @@ fn process_line( None => return Ok(ExecOutput::Nothing), }; + if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(&command) { + if !assume_yes { + return Err(WalletCliError::InvalidInput(format!( + "{question}\nPass --yes to run state-changing commands in exec mode." + ))); + } + } + match super::run_command_blocking(event_tx, command)? { ConsoleCommand::Print(text) => Ok(ExecOutput::Text(text)), ConsoleCommand::Json { text, value } => Ok(match text { @@ -68,6 +77,7 @@ fn process_line( pub fn run( line: String, output_format: CliOutputFormat, + assume_yes: bool, mut output: impl ConsoleOutput, event_tx: mpsc::UnboundedSender>, cold_wallet: bool, @@ -86,7 +96,7 @@ pub fn run( // grammar directly instead of nesting another large-stack spawn. let repl_command = build_repl_command(cold_wallet, true); - let result = process_line(&repl_command, &event_tx, &line)?; + let result = process_line(&repl_command, &event_tx, &line, assume_yes)?; match (output_format, result) { (_, ExecOutput::Nothing) => {} diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 95e39f572a..d20dc87bbf 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -151,9 +151,36 @@ fn process_line( None => return Ok(None), }; + if !confirm_or_abort(&command)? { + return Ok(None); + } + super::run_command_blocking(event_tx, command).map(Option::Some) } +/// Ask for confirmation before running a state-changing command. Returns +/// `false` when the user declined and the command must not run. +fn confirm_or_abort( + command: &ManageableWalletCommand, +) -> Result> { + if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(command) { + println!(); + println!("{question}"); + let confirmed = dialoguer::Confirm::new() + .with_prompt("Proceed [y/N]?") + .default(false) + .interact() + .map_err(|err| { + WalletCliError::InvalidInput(format!("the confirmation could not be read: {err}")) + })?; + if !confirmed { + println!("Aborted."); + return Ok(false); + } + } + Ok(true) +} + #[allow(clippy::too_many_arguments)] pub fn run( mut console: impl ConsoleOutput, diff --git a/wallet/wallet-cli-lib/tests/cli_test_framework.rs b/wallet/wallet-cli-lib/tests/cli_test_framework.rs index 6390e43f7d..522ef8effb 100644 --- a/wallet/wallet-cli-lib/tests/cli_test_framework.rs +++ b/wallet/wallet-cli-lib/tests/cli_test_framework.rs @@ -108,6 +108,7 @@ impl CliTestFramework { commands_file: None, exec: None, output: wallet_cli_lib::config::CliOutputFormat::Text, + yes: false, history_file: None, exit_on_error: None, vi_mode: false, @@ -141,6 +142,7 @@ impl CliTestFramework { commands_file: None, exec: None, output: wallet_cli_lib::config::CliOutputFormat::Text, + yes: false, history_file: None, exit_on_error: None, vi_mode: false, From bc3889351136b2ba7a3b3e21ea3f8c5a1cd7ba26 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 13:33:04 +0200 Subject: [PATCH 25/52] fix(wallet-cli): gate every non-interactive path behind confirmations --- wallet/wallet-cli-lib/src/errors.rs | 3 +++ wallet/wallet-cli-lib/src/lib.rs | 18 ++++++++++++++---- wallet/wallet-cli-lib/src/repl/exec.rs | 2 +- .../wallet-cli-lib/src/repl/interactive/mod.rs | 10 ++++++++-- .../src/repl/non_interactive/mod.rs | 12 +++++++++++- .../wallet-cli-lib/tests/cli_test_framework.rs | 4 ++-- 6 files changed, 39 insertions(+), 10 deletions(-) diff --git a/wallet/wallet-cli-lib/src/errors.rs b/wallet/wallet-cli-lib/src/errors.rs index a97a0eedf0..c36ee9b23a 100644 --- a/wallet/wallet-cli-lib/src/errors.rs +++ b/wallet/wallet-cli-lib/src/errors.rs @@ -36,6 +36,9 @@ pub enum WalletCliError { #[error("Invalid input: {0}")] InvalidInput(String), + #[error("Confirmation required: {0}")] + ConfirmationRequired(String), + #[error("Error converting to json: {0}")] SerdeJsonFormatError(#[from] serde_json::Error), diff --git a/wallet/wallet-cli-lib/src/lib.rs b/wallet/wallet-cli-lib/src/lib.rs index e6a27f4c33..cbdcf2ad5f 100644 --- a/wallet/wallet-cli-lib/src/lib.rs +++ b/wallet/wallet-cli-lib/src/lib.rs @@ -171,8 +171,10 @@ async fn start_hot_wallet( }; let qr = cli_args.qr; - let (repl_handle, wallet_rpc_config) = - setup_events_and_repl(cli_args, mode, output, input, event_tx, chain_type)?; + let assume_yes = cli_args.yes; + let (repl_handle, wallet_rpc_config) = setup_events_and_repl( + cli_args, mode, assume_yes, output, input, event_tx, chain_type, + )?; let node_rpc = make_rpc_client(chain_config.clone(), rpc_address.to_string(), rpc_auth).await?; cli_event_loop::run( @@ -201,9 +203,11 @@ async fn start_cold_wallet( let (event_tx, event_rx) = mpsc::unbounded_channel(); let qr = cli_args.qr; + let assume_yes = cli_args.yes; let (repl_handle, wallet_rpc_config) = setup_events_and_repl( cli_args, mode, + assume_yes, output, input, event_tx, @@ -262,8 +266,10 @@ async fn connect_to_rpc_wallet( let qr = cli_args.qr; let remote_socket_address = cli_args.remote_rpc_wallet_address.clone().expect("checked"); - let (repl_handle, _wallet_rpc_config) = - setup_events_and_repl(cli_args, mode, output, input, event_tx, chain_type)?; + let assume_yes = cli_args.yes; + let (repl_handle, _wallet_rpc_config) = setup_events_and_repl( + cli_args, mode, assume_yes, output, input, event_tx, chain_type, + )?; cli_event_loop::run( &chain_config.clone(), @@ -284,6 +290,7 @@ async fn connect_to_rpc_wallet( fn setup_events_and_repl( args: CliArgs, mode: Mode, + assume_yes: bool, output: impl ConsoleOutput, input: impl ConsoleInput, event_tx: mpsc::UnboundedSender>, @@ -395,6 +402,7 @@ fn setup_events_and_repl( args.vi_mode, startup_command_futures, args.cold_wallet, + assume_yes, ), Mode::NonInteractive => repl::non_interactive::run( input, @@ -402,6 +410,7 @@ fn setup_events_and_repl( event_tx, args.exit_on_error.unwrap_or(false), args.cold_wallet, + assume_yes, startup_command_futures, ), Mode::CommandsList { file_input } => repl::non_interactive::run( @@ -410,6 +419,7 @@ fn setup_events_and_repl( event_tx, args.exit_on_error.unwrap_or(true), args.cold_wallet, + assume_yes, startup_command_futures, ), Mode::Exec { diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index b65b1c9eb1..1dcfdd762b 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -37,7 +37,7 @@ fn process_line( if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(&command) { if !assume_yes { - return Err(WalletCliError::InvalidInput(format!( + return Err(WalletCliError::ConfirmationRequired(format!( "{question}\nPass --yes to run state-changing commands in exec mode." ))); } diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index d20dc87bbf..2b0bdbeee3 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -126,6 +126,7 @@ fn process_line( repl_command: &Command, event_tx: &mpsc::UnboundedSender>, sig: reedline::Signal, + assume_yes: bool, ) -> Result, WalletCliError> { let line = match sig { Signal::Success(line) => line, @@ -151,7 +152,7 @@ fn process_line( None => return Ok(None), }; - if !confirm_or_abort(&command)? { + if !confirm_or_abort(&command, assume_yes)? { return Ok(None); } @@ -162,8 +163,12 @@ fn process_line( /// `false` when the user declined and the command must not run. fn confirm_or_abort( command: &ManageableWalletCommand, + assume_yes: bool, ) -> Result> { if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(command) { + if assume_yes { + return Ok(true); + } println!(); println!("{question}"); let confirmed = dialoguer::Confirm::new() @@ -191,6 +196,7 @@ pub fn run( vi_mode: bool, startup_command_futures: Vec>>>, cold_wallet: bool, + assume_yes: bool, ) -> Result<(), WalletCliError> { // The REPL thread is sized for grammar construction, so build the // grammar directly instead of nesting another large-stack spawn. @@ -248,7 +254,7 @@ pub fn run( let sig = line_editor.read_line(&prompt).expect("Should not fail normally"); logger.set_print_directly(true); - process_line(&repl_command, &event_tx, sig) + process_line(&repl_command, &event_tx, sig, assume_yes) }; match handle_response( diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index d1f70babf0..2ade7f646d 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -38,6 +38,7 @@ fn process_line( repl_command: &Command, event_tx: &mpsc::UnboundedSender>, line: &str, + assume_yes: bool, ) -> Result> { let command_opt = parse_input(line, repl_command)?; @@ -46,6 +47,14 @@ fn process_line( None => return Ok(LineOutput::None), }; + if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(&command) { + if !assume_yes { + return Err(WalletCliError::ConfirmationRequired(format!( + "{question}\nPass --yes to run state-changing commands in non-interactive mode." + ))); + } + } + let command_output = super::run_command_blocking(event_tx, command)?; to_line_output(command_output, line) @@ -88,6 +97,7 @@ pub fn run( event_tx: mpsc::UnboundedSender>, exit_on_error: bool, cold_wallet: bool, + assume_yes: bool, startup_command_futures: Vec>>>, ) -> Result<(), WalletCliError> { for res_rx in startup_command_futures { @@ -103,7 +113,7 @@ pub fn run( let repl_command = build_repl_command(cold_wallet, true); while let Some(line) = input.read_line() { - let res = process_line(&repl_command, &event_tx, &line); + let res = process_line(&repl_command, &event_tx, &line, assume_yes); if let Some(value) = handle_response(res, &mut output, exit_on_error) { return value; diff --git a/wallet/wallet-cli-lib/tests/cli_test_framework.rs b/wallet/wallet-cli-lib/tests/cli_test_framework.rs index 522ef8effb..299ee00d48 100644 --- a/wallet/wallet-cli-lib/tests/cli_test_framework.rs +++ b/wallet/wallet-cli-lib/tests/cli_test_framework.rs @@ -108,7 +108,7 @@ impl CliTestFramework { commands_file: None, exec: None, output: wallet_cli_lib::config::CliOutputFormat::Text, - yes: false, + yes: true, history_file: None, exit_on_error: None, vi_mode: false, @@ -142,7 +142,7 @@ impl CliTestFramework { commands_file: None, exec: None, output: wallet_cli_lib::config::CliOutputFormat::Text, - yes: false, + yes: true, history_file: None, exit_on_error: None, vi_mode: false, From 25c9e1acd998ff560a9f5348049d08c5432ea68a Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 13:46:23 +0200 Subject: [PATCH 26/52] refactor(wallet-cli): share the confirmation gate across repl modes --- wallet/wallet-cli-lib/src/lib.rs | 26 +++------ wallet/wallet-cli-lib/src/repl/exec.rs | 8 +-- wallet/wallet-cli-lib/src/repl/mod.rs | 56 +++++++++++++++++++ .../src/repl/non_interactive/mod.rs | 8 +-- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/wallet/wallet-cli-lib/src/lib.rs b/wallet/wallet-cli-lib/src/lib.rs index cbdcf2ad5f..fbdc081b25 100644 --- a/wallet/wallet-cli-lib/src/lib.rs +++ b/wallet/wallet-cli-lib/src/lib.rs @@ -54,7 +54,6 @@ enum Mode { Exec { line: String, output_format: config::CliOutputFormat, - assume_yes: bool, }, } @@ -84,7 +83,6 @@ pub async fn run( Mode::Exec { line: line.clone(), output_format: cli_args.output, - assume_yes: cli_args.yes, } } else if let Some(file_path) = &cli_args.commands_file { repl::non_interactive::log::init(); @@ -171,10 +169,8 @@ async fn start_hot_wallet( }; let qr = cli_args.qr; - let assume_yes = cli_args.yes; - let (repl_handle, wallet_rpc_config) = setup_events_and_repl( - cli_args, mode, assume_yes, output, input, event_tx, chain_type, - )?; + let (repl_handle, wallet_rpc_config) = + setup_events_and_repl(cli_args, mode, output, input, event_tx, chain_type)?; let node_rpc = make_rpc_client(chain_config.clone(), rpc_address.to_string(), rpc_auth).await?; cli_event_loop::run( @@ -203,11 +199,9 @@ async fn start_cold_wallet( let (event_tx, event_rx) = mpsc::unbounded_channel(); let qr = cli_args.qr; - let assume_yes = cli_args.yes; let (repl_handle, wallet_rpc_config) = setup_events_and_repl( cli_args, mode, - assume_yes, output, input, event_tx, @@ -266,10 +260,8 @@ async fn connect_to_rpc_wallet( let qr = cli_args.qr; let remote_socket_address = cli_args.remote_rpc_wallet_address.clone().expect("checked"); - let assume_yes = cli_args.yes; - let (repl_handle, _wallet_rpc_config) = setup_events_and_repl( - cli_args, mode, assume_yes, output, input, event_tx, chain_type, - )?; + let (repl_handle, _wallet_rpc_config) = + setup_events_and_repl(cli_args, mode, output, input, event_tx, chain_type)?; cli_event_loop::run( &chain_config.clone(), @@ -290,7 +282,6 @@ async fn connect_to_rpc_wallet( fn setup_events_and_repl( args: CliArgs, mode: Mode, - assume_yes: bool, output: impl ConsoleOutput, input: impl ConsoleInput, event_tx: mpsc::UnboundedSender>, @@ -402,7 +393,7 @@ fn setup_events_and_repl( args.vi_mode, startup_command_futures, args.cold_wallet, - assume_yes, + args.yes, ), Mode::NonInteractive => repl::non_interactive::run( input, @@ -410,7 +401,7 @@ fn setup_events_and_repl( event_tx, args.exit_on_error.unwrap_or(false), args.cold_wallet, - assume_yes, + args.yes, startup_command_futures, ), Mode::CommandsList { file_input } => repl::non_interactive::run( @@ -419,17 +410,16 @@ fn setup_events_and_repl( event_tx, args.exit_on_error.unwrap_or(true), args.cold_wallet, - assume_yes, + args.yes, startup_command_futures, ), Mode::Exec { line, output_format, - assume_yes, } => repl::exec::run( line, output_format, - assume_yes, + args.yes, output, event_tx, args.cold_wallet, diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index 1dcfdd762b..22c3a8381e 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -35,13 +35,7 @@ fn process_line( None => return Ok(ExecOutput::Nothing), }; - if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(&command) { - if !assume_yes { - return Err(WalletCliError::ConfirmationRequired(format!( - "{question}\nPass --yes to run state-changing commands in exec mode." - ))); - } - } + super::confirmation_gate(&command, assume_yes, "exec mode")?; match super::run_command_blocking(event_tx, command)? { ConsoleCommand::Print(text) => Ok(ExecOutput::Text(text)), diff --git a/wallet/wallet-cli-lib/src/repl/mod.rs b/wallet/wallet-cli-lib/src/repl/mod.rs index 707640cdb0..3ec9697a71 100644 --- a/wallet/wallet-cli-lib/src/repl/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/mod.rs @@ -23,6 +23,62 @@ use wallet_rpc_lib::types::NodeInterface; use crate::{cli_event_loop::Event, errors::WalletCliError}; +/// Refuse state-changing commands in non-interactive modes unless the user +/// passed `--yes`. The mode name is embedded in the error message. +pub(crate) fn confirmation_gate( + command: &ManageableWalletCommand, + assume_yes: bool, + mode_name: &str, +) -> Result<(), WalletCliError> { + if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(command) { + if !assume_yes { + return Err(WalletCliError::ConfirmationRequired(format!( + "{question}\nPass --yes to run state-changing commands in {mode_name}." + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod confirmation_gate_tests { + use super::*; + use wallet_cli_commands::build_repl_command; + + type Client = node_comm::node_traits::MockNodeInterface; + + // Grammar construction overflows small test stacks (see + // wallet_cli_commands::run_with_large_stack), so run it on a large one. + fn parse(line: &'static str) -> wallet_cli_commands::ManageableWalletCommand { + wallet_cli_commands::run_with_large_stack(move || { + wallet_cli_commands::parse_input::( + line, + &build_repl_command(false, true), + ) + .expect("grammar parse must not fail") + .expect("test lines parse to a command") + }) + } + + #[test] + fn state_changing_command_is_rejected_without_assume_yes() { + let command = parse("address-send addr1 100"); + assert!(matches!( + confirmation_gate::(&command, false, "exec mode"), + Err(WalletCliError::ConfirmationRequired(_)) + )); + confirmation_gate::(&command, true, "exec mode") + .expect("assume_yes must let the command through"); + } + + #[test] + fn read_only_command_never_needs_confirmation() { + let command = parse("account-balance"); + confirmation_gate::(&command, false, "exec mode") + .expect("read-only commands must not be gated"); + } +} + fn run_command_blocking( event_tx: &mpsc::UnboundedSender>, command: ManageableWalletCommand, diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index 2ade7f646d..c5390c2d22 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -47,13 +47,7 @@ fn process_line( None => return Ok(LineOutput::None), }; - if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(&command) { - if !assume_yes { - return Err(WalletCliError::ConfirmationRequired(format!( - "{question}\nPass --yes to run state-changing commands in non-interactive mode." - ))); - } - } + super::confirmation_gate(&command, assume_yes, "non-interactive mode")?; let command_output = super::run_command_blocking(event_tx, command)?; From 744e60a8327a711419abf9f3d63d09792fe24eaa Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 14:36:09 +0200 Subject: [PATCH 27/52] feat(wallet): add a read-only MCP server for the wallet rpc daemon --- Cargo.lock | 13 + Cargo.toml | 1 + supply-chain/config.toml | 16 + wallet/mcp-server/Cargo.toml | 15 + wallet/mcp-server/src/lib.rs | 454 +++++++++++++++++++ wallet/mcp-server/src/main.rs | 237 ++++++++++ wallet/wallet-cli-commands/src/confirm.rs | 16 +- wallet/wallet-cli-commands/src/wizard/mod.rs | 19 +- wallet/wallet-cli-lib/src/repl/exec.rs | 8 +- wallet/wallet-cli-lib/src/repl/mod.rs | 38 +- 10 files changed, 774 insertions(+), 43 deletions(-) create mode 100644 wallet/mcp-server/Cargo.toml create mode 100644 wallet/mcp-server/src/lib.rs create mode 100644 wallet/mcp-server/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index fcfacd50ae..5d3bd6b96b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10243,6 +10243,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "wallet-mcp-server" +version = "1.4.1" +dependencies = [ + "clap", + "crypto", + "rpc", + "serde_json", + "tokio", + "wallet-rpc-client", + "wallet-types", +] + [[package]] name = "wallet-rpc-client" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index 794e50420b..a3d8363b0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ members = [ "wallet/wallet-address-generator", # Wallet address generator binary. "wallet/wallet-address-generator-lib", # Wallet address generator lib. "wallet/wallet-rpc-client", # Wallet RPC communication. + "wallet/mcp-server", # Read-only MCP server on top of the wallet RPC daemon. "wallet/wallet-rpc-daemon", # Wallet RPC daemon binary. "wallet/wallet-rpc-lib", # Wallet RPC definitions library. "wallet/wallet-test-node", # Node for wallet testing as a library. diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 2c1eabb1b7..2d8eeeb043 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -247,6 +247,10 @@ criteria = "safe-to-deploy" version = "2.5.0" criteria = "safe-to-deploy" +[[exemptions.console]] +version = "0.15.11" +criteria = "safe-to-deploy" + [[exemptions.const-oid]] version = "0.10.2" criteria = "safe-to-deploy" @@ -387,6 +391,10 @@ criteria = "safe-to-deploy" version = "0.2.0" criteria = "safe-to-deploy" +[[exemptions.dialoguer]] +version = "0.11.0" +criteria = "safe-to-deploy" + [[exemptions.difflib]] version = "0.4.0" criteria = "safe-to-deploy" @@ -1303,6 +1311,10 @@ criteria = "safe-to-run" version = "0.11.0" criteria = "safe-to-deploy" +[[exemptions.shell-words]] +version = "1.1.1" +criteria = "safe-to-deploy" + [[exemptions.signal-hook]] version = "0.3.18" criteria = "safe-to-deploy" @@ -1475,6 +1487,10 @@ criteria = "safe-to-deploy" version = "0.2.0" criteria = "safe-to-deploy" +[[exemptions.unicode-width]] +version = "0.2.2" +criteria = "safe-to-deploy" + [[exemptions.untrusted]] version = "0.9.0" criteria = "safe-to-deploy" diff --git a/wallet/mcp-server/Cargo.toml b/wallet/mcp-server/Cargo.toml new file mode 100644 index 0000000000..5949f7f22c --- /dev/null +++ b/wallet/mcp-server/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "wallet-mcp-server" +license.workspace = true +edition.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +clap = { workspace = true, features = ["derive"] } +crypto = { path = "../../crypto" } +rpc = { path = "../../rpc" } +serde_json.workspace = true +tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi-thread"] } +wallet-rpc-client = { path = "../wallet-rpc-client" } +wallet-types = { path = "../types" } diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs new file mode 100644 index 0000000000..8a09fa1430 --- /dev/null +++ b/wallet/mcp-server/src/lib.rs @@ -0,0 +1,454 @@ +// Copyright (c) 2024 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde_json::{Value, json}; + +/// The subset of wallet queries the server exposes. +// Only used through static dispatch; the auto-trait bound restriction of +// native async functions does not matter here. +#[allow(async_fn_in_trait)] +pub trait ReadonlyWallet { + /// The current daemon version string. + async fn version(&self) -> Result; + + /// General wallet state (chains, accounts, sync status). + async fn wallet_info(&self) -> Result; + + /// The best block the wallet has seen. + async fn best_block(&self) -> Result; + + /// Balances for one account. + async fn balance( + &self, + account_index: u32, + utxo_states: Vec, + with_locked: WithLockedSelector, + ) -> Result; + + /// Standalone addresses of one account. + async fn standalone_addresses(&self, account_index: u32) -> Result; +} + +/// UTXO states accepted by the balance tool. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UtxoStateSelector { + Confirmed, + InMempool, + Conflicted, + Inactive, + Abandoned, +} + +impl UtxoStateSelector { + /// Parses a state name; accepts the lowercase tool spelling. + pub fn parse(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "confirmed" => Some(Self::Confirmed), + "in-mempool" => Some(Self::InMempool), + "conflicted" => Some(Self::Conflicted), + "inactive" => Some(Self::Inactive), + "abandoned" => Some(Self::Abandoned), + _ => None, + } + } +} + +/// Locked-UTXO filter accepted by the balance tool. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WithLockedSelector { + Any, + Unlocked, + Locked, +} + +impl WithLockedSelector { + /// Parses a filter name; defaults to unlocked UTXOs only. + pub fn parse(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "any" => Some(Self::Any), + "unlocked" => Some(Self::Unlocked), + "locked" => Some(Self::Locked), + _ => None, + } + } +} + +/// Descriptors of every tool the server offers. Read-only by design. +pub fn tool_definitions() -> Value { + json!({ + "tools": [ + { + "name": "version", + "description": "Return the version of the connected wallet RPC daemon.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "wallet_info", + "description": "Return general wallet state: chain, accounts and synchronization status.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "best_block", + "description": "Return the best block known to the wallet.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "balance", + "description": "Return the balances of one account.", + "inputSchema": { + "type": "object", + "properties": { + "account_index": { "type": "integer", "minimum": 0, "default": 0 }, + "utxo_states": { + "type": "array", + "items": { "enum": ["confirmed", "in-mempool", "conflicted", "inactive", "abandoned"] }, + "default": ["confirmed"] + }, + "with_locked": { "enum": ["unlocked", "locked", "any"], "default": "unlocked" } + } + } + }, + { + "name": "standalone_addresses", + "description": "Return the standalone addresses of one account.", + "inputSchema": { + "type": "object", + "properties": { + "account_index": { "type": "integer", "minimum": 0, "default": 0 } + } + } + } + ] + }) +} + +/// Handles one decoded JSON-RPC message. Returns the response value for +/// requests and `None` for notifications. +pub async fn handle_message(message: &Value, wallet: &W) -> Option { + let id = message.get("id").cloned(); + // Notifications carry no id and never get a response. + id.as_ref()?; + let Some(method) = message.get("method").and_then(Value::as_str) else { + // Requests must always be answered, even when malformed (JSON-RPC 2.0: -32600). + return Some(error_response( + id, + -32600, + "Invalid Request: missing or invalid method".to_owned(), + )); + }; + let params = message.get("params").cloned().unwrap_or_else(|| json!({})); + let response = match method { + "initialize" => result_response( + id, + json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "mintlayer-wallet-mcp", + "version": env!("CARGO_PKG_VERSION") + } + }), + ), + "ping" => result_response(id, json!({})), + "tools/list" => result_response(id, tool_definitions()), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let arguments = params.get("arguments").cloned().unwrap_or_else(|| json!({})); + result_response(id, call_tool(name, arguments, wallet).await) + } + other => error_response(id, -32601, format!("Method not found: {other}")), + }; + Some(response) +} + +async fn call_tool(name: &str, arguments: Value, wallet: &W) -> Value { + let outcome: Result = match name { + "version" => wallet.version().await.map(Value::String), + "wallet_info" => wallet.wallet_info().await, + "best_block" => wallet.best_block().await, + "balance" => { + let account_index = match parse_account_index(&arguments) { + Ok(index) => index, + Err(err) => return tool_result(err, true), + }; + let states = match parse_utxo_states(&arguments) { + Ok(states) => states, + Err(err) => return tool_result(err, true), + }; + let with_locked = match arguments.get("with_locked").and_then(Value::as_str) { + Some(name) => match WithLockedSelector::parse(name) { + Some(selector) => selector, + None => return tool_result(format!("Unknown locked filter: {name}"), true), + }, + None => WithLockedSelector::Unlocked, + }; + wallet.balance(account_index, states, with_locked).await + } + "standalone_addresses" => { + let account_index = match parse_account_index(&arguments) { + Ok(index) => index, + Err(err) => return tool_result(err, true), + }; + wallet.standalone_addresses(account_index).await + } + other => { + return tool_result(format!("Unknown tool: {other}"), true); + } + }; + + match outcome { + Ok(value) => match serde_json::to_string_pretty(&value) { + Ok(text) => tool_result(text, false), + Err(err) => tool_result(format!("Failed to render the result: {err}"), true), + }, + Err(err) => tool_result(err, true), + } +} + +/// Parses the optional `account_index` argument, rejecting invalid values +/// instead of silently falling back to account 0. +fn parse_account_index(arguments: &Value) -> Result { + match arguments.get("account_index") { + None | Some(Value::Null) => Ok(0), + Some(value) => { + let raw = value.as_u64().ok_or_else(|| { + format!("account_index must be a non-negative integer, got: {value}") + })?; + u32::try_from(raw).map_err(|_| format!("account_index out of range: {raw}")) + } + } +} + +fn parse_utxo_states(arguments: &Value) -> Result, String> { + let raw = arguments.get("utxo_states").cloned().unwrap_or(json!(["confirmed"])); + let names = raw + .as_array() + .ok_or_else(|| "utxo_states must be an array of state names".to_owned())?; + if names.is_empty() { + return Err("utxo_states must not be empty".to_owned()); + } + names + .iter() + .map(|name| { + let name = name + .as_str() + .ok_or_else(|| "utxo_states must be an array of state names".to_owned())?; + UtxoStateSelector::parse(name).ok_or_else(|| format!("Unknown UTXO state: {name}")) + }) + .collect() +} + +fn tool_result(text: String, is_error: bool) -> Value { + json!({ + "content": [ { "type": "text", "text": text } ], + "isError": is_error + }) +} + +fn result_response(id: Option, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn error_response(id: Option, code: i64, message: String) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct StubWallet; + + impl ReadonlyWallet for StubWallet { + async fn version(&self) -> Result { + Ok("1.0.0+test".to_owned()) + } + + async fn wallet_info(&self) -> Result { + Ok(json!({ "chain": "mainnet", "synced": true })) + } + + async fn best_block(&self) -> Result { + Ok(json!({ "height": 42 })) + } + + async fn balance( + &self, + account_index: u32, + utxo_states: Vec, + with_locked: WithLockedSelector, + ) -> Result { + if account_index > 3 { + return Err("no such account".to_owned()); + } + Ok(json!({ + "account": account_index, + "states": utxo_states.len(), + "locked": with_locked == WithLockedSelector::Locked, + "coin": "100.5" + })) + } + + async fn standalone_addresses(&self, account_index: u32) -> Result { + Ok(json!({ "account": account_index, "addresses": [] })) + } + } + + fn request(method: &str, params: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": 7, "method": method, "params": params }) + } + + #[test] + fn initialize_reports_the_server_identity() { + let response = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(handle_message( + &request("initialize", json!({})), + &StubWallet, + )) + .expect("requests are answered"); + assert_eq!(response["result"]["protocolVersion"], "2024-11-05"); + assert_eq!( + response["result"]["serverInfo"]["name"], + "mintlayer-wallet-mcp" + ); + assert_eq!(response["result"]["capabilities"]["tools"], json!({})); + } + + #[test] + fn tools_are_listed_and_read_only() { + let response = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(handle_message( + &request("tools/list", json!({})), + &StubWallet, + )) + .expect("requests are answered"); + let tools = response["result"]["tools"].as_array().expect("tool list"); + let names: Vec<_> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert_eq!( + names, + ["version", "wallet_info", "best_block", "balance", "standalone_addresses"] + ); + // No write-shaped tool may appear in the list. + assert!(!names.iter().any(|name| { + name.contains("send") || name.contains("create") || name.contains("sign") + })); + } + + #[test] + fn tool_calls_render_results_as_text_content() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let response = rt + .block_on(handle_message( + &request("tools/call", json!({ "name": "wallet_info" })), + &StubWallet, + )) + .expect("requests are answered"); + assert_eq!(response["result"]["isError"], false); + let text = response["result"]["content"][0]["text"].as_str().unwrap(); + let rendered: Value = serde_json::from_str(text).unwrap(); + assert_eq!(rendered["chain"], "mainnet"); + } + + #[test] + fn tool_call_arguments_are_forwarded() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let response = rt.block_on(handle_message( + &request( + "tools/call", + json!({ "name": "balance", "arguments": { "account_index": 2, "utxo_states": ["confirmed", "in-mempool"], "with_locked": "locked" } }), + ), + &StubWallet, + )) + .expect("requests are answered"); + let text = response["result"]["content"][0]["text"].as_str().unwrap(); + let rendered: Value = serde_json::from_str(text).unwrap(); + assert_eq!(rendered["account"], 2); + assert_eq!(rendered["states"], 2); + assert_eq!(rendered["locked"], true); + } + + #[test] + fn failing_tools_report_errors_without_breaking_the_protocol() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let response = rt + .block_on(handle_message( + &request( + "tools/call", + json!({ "name": "balance", "arguments": { "account_index": 9 } }), + ), + &StubWallet, + )) + .expect("requests are answered"); + assert_eq!(response["result"]["isError"], true); + assert!( + response["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("no such account") + ); + } + + #[test] + fn unknown_tools_and_methods_are_rejected() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let response = rt + .block_on(handle_message( + &request("tools/call", json!({ "name": "purge_seed_phrase" })), + &StubWallet, + )) + .expect("requests are answered"); + assert!( + response["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("Unknown tool") + ); + + let response = rt + .block_on(handle_message( + &request("wallet/create", json!({})), + &StubWallet, + )) + .expect("requests are answered"); + assert_eq!(response["error"]["code"], -32601); + } + + #[test] + fn notifications_and_malformed_payloads_get_no_response() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let notification = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + assert_eq!( + rt.block_on(handle_message(¬ification, &StubWallet)), + None + ); + let bogus = json!({ "jsonrpc": "2.0", "id": 1 }); + let response = + rt.block_on(handle_message(&bogus, &StubWallet)).expect("requests are answered"); + assert_eq!(response["error"]["code"], -32600); + } + + #[test] + fn ping_is_answered_with_an_empty_result() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let response = rt + .block_on(handle_message(&request("ping", json!({})), &StubWallet)) + .expect("requests are answered"); + assert_eq!(response["result"], json!({})); + } +} diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs new file mode 100644 index 0000000000..7d5aef7465 --- /dev/null +++ b/wallet/mcp-server/src/main.rs @@ -0,0 +1,237 @@ +// Copyright (c) 2024 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::path::PathBuf; + +use clap::Parser; +use crypto::key::hdkd::u31::U31; +use rpc::RpcAuthData; +use serde_json::Value; +use wallet_mcp_server::{ReadonlyWallet, UtxoStateSelector, WithLockedSelector, handle_message}; +use wallet_rpc_client::rpc_client::ClientWalletRpc; +use wallet_rpc_client::wallet_rpc_traits::WalletInterface; +use wallet_types::utxo_types::UtxoState; +use wallet_types::with_locked::WithLocked; + +#[derive(Debug, Parser)] +#[clap( + name = "wallet-mcp-server", + about = "Read-only MCP server for a Mintlayer wallet RPC daemon" +)] +#[clap(group( + clap::ArgGroup::new("auth") + .required(true) + .args(["rpc_cookie_file", "rpc_username", "rpc_no_auth"]), +))] +struct CliArgs { + /// Address of the wallet RPC daemon, e.g. 127.0.0.1:8332 + #[clap(long)] + wallet_rpc_address: String, + + /// Cookie file used for authentication against the daemon + #[clap(long, conflicts_with_all = &["rpc_username", "rpc_password", "rpc_no_auth"])] + rpc_cookie_file: Option, + + /// Username for basic authentication + #[clap(long, requires = "rpc_password", conflicts_with_all = &["rpc_cookie_file", "rpc_no_auth"])] + rpc_username: Option, + + /// Password for basic authentication. + /// + /// Prefer `--rpc-cookie-file`; a password passed on the command line is + /// visible to other local users via `ps` or `/proc//cmdline`. + #[clap( + long, + env = "WALLET_MCP_RPC_PASSWORD", + requires = "rpc_username", + conflicts_with_all = &["rpc_cookie_file", "rpc_no_auth"] + )] + rpc_password: Option, + + /// Connect without authentication + #[clap(long, conflicts_with_all = &["rpc_cookie_file", "rpc_username", "rpc_password"])] + rpc_no_auth: bool, +} + +impl CliArgs { + fn auth_data(&self) -> RpcAuthData { + // The auth group is required, so exactly one branch applies. + if let Some(cookie_file_path) = self.rpc_cookie_file.clone() { + RpcAuthData::Cookie { cookie_file_path } + } else if let (Some(username), Some(password)) = + (self.rpc_username.clone(), self.rpc_password.clone()) + { + RpcAuthData::Basic { username, password } + } else { + RpcAuthData::None + } + } +} + +/// Adapter that exposes only the read-only operations of the daemon client. +struct RpcWallet(ClientWalletRpc); + +impl ReadonlyWallet for RpcWallet { + async fn version(&self) -> Result { + WalletInterface::version(&self.0).await.map_err(|err| err.to_string()) + } + + async fn wallet_info(&self) -> Result { + let info = WalletInterface::wallet_info(&self.0).await.map_err(|err| err.to_string())?; + serde_json::to_value(&info).map_err(|err| err.to_string()) + } + + async fn best_block(&self) -> Result { + let block = WalletInterface::best_block(&self.0).await.map_err(|err| err.to_string())?; + serde_json::to_value(&block).map_err(|err| err.to_string()) + } + + async fn balance( + &self, + account_index: u32, + utxo_states: Vec, + with_locked: WithLockedSelector, + ) -> Result { + let account_index = U31::from_u32(account_index) + .ok_or_else(|| format!("Invalid account index: {account_index}"))?; + let utxo_states = utxo_states.into_iter().map(utxo_state).collect(); + let balances = WalletInterface::get_balance( + &self.0, + account_index, + utxo_states, + with_locked_filter(with_locked), + ) + .await + .map_err(|err| err.to_string())?; + serde_json::to_value(&balances).map_err(|err| err.to_string()) + } + + async fn standalone_addresses(&self, account_index: u32) -> Result { + let account_index = U31::from_u32(account_index) + .ok_or_else(|| format!("Invalid account index: {account_index}"))?; + let addresses = WalletInterface::get_standalone_addresses(&self.0, account_index) + .await + .map_err(|err| err.to_string())?; + serde_json::to_value(&addresses).map_err(|err| err.to_string()) + } +} + +fn utxo_state(selector: UtxoStateSelector) -> UtxoState { + match selector { + UtxoStateSelector::Confirmed => UtxoState::Confirmed, + UtxoStateSelector::InMempool => UtxoState::InMempool, + UtxoStateSelector::Conflicted => UtxoState::Conflicted, + UtxoStateSelector::Inactive => UtxoState::Inactive, + UtxoStateSelector::Abandoned => UtxoState::Abandoned, + } +} + +fn with_locked_filter(selector: WithLockedSelector) -> WithLocked { + match selector { + WithLockedSelector::Any => WithLocked::Any, + WithLockedSelector::Unlocked => WithLocked::Unlocked, + WithLockedSelector::Locked => WithLocked::Locked, + } +} + +/// Upper bound for a single protocol message, so a misbehaving peer cannot +/// make the server buffer unbounded input. +const MAX_MESSAGE_BYTES: usize = 1024 * 1024; + +async fn next_message( + reader: &mut R, + buffer: &mut Vec, +) -> Result, std::io::Error> { + use tokio::io::AsyncBufReadExt; + + loop { + let available = reader.fill_buf().await?; + if available.is_empty() { + // End of stream: deliver a trailing message without a newline, if any. + break; + } + if let Some(pos) = available.iter().position(|&byte| byte == b'\n') { + buffer.extend_from_slice(&available[..pos]); + reader.consume(pos + 1); + let line = String::from_utf8_lossy(buffer).into_owned(); + buffer.clear(); + return Ok(Some(line)); + } + if buffer.len() + available.len() > MAX_MESSAGE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "protocol message exceeds the size limit", + )); + } + buffer.extend_from_slice(available); + let len = available.len(); + reader.consume(len); + } + if buffer.is_empty() { + return Ok(None); + } + let line = String::from_utf8_lossy(buffer).into_owned(); + buffer.clear(); + Ok(Some(line)) +} + +async fn serve(wallet: RpcWallet) -> Result<(), Box> { + use tokio::io::AsyncWriteExt; + + let stdin = tokio::io::stdin(); + let mut reader = tokio::io::BufReader::new(stdin); + let mut message_buffer = Vec::with_capacity(1024); + let mut stdout = tokio::io::stdout(); + + while let Some(line) = next_message(&mut reader, &mut message_buffer).await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let response = match serde_json::from_str::(trimmed) { + Ok(message) => handle_message(&message, &wallet).await, + Err(err) => Some(serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": { "code": -32700, "message": format!("Parse error: {err}") } + })), + }; + if let Some(response) = response { + let mut encoded = serde_json::to_string(&response)?; + encoded.push('\n'); + stdout.write_all(encoded.as_bytes()).await?; + stdout.flush().await?; + } + } + Ok(()) +} + +#[tokio::main] +async fn main() { + let args = CliArgs::parse(); + let wallet = match ClientWalletRpc::new(args.wallet_rpc_address.clone(), args.auth_data()).await + { + Ok(client) => RpcWallet(client), + Err(err) => { + eprintln!("Failed to connect to the wallet RPC daemon: {err}"); + std::process::exit(1); + } + }; + // Standard output belongs to the protocol; diagnostics go to stderr. + if let Err(err) = serve(wallet).await { + eprintln!("MCP server stopped: {err}"); + std::process::exit(1); + } +} diff --git a/wallet/wallet-cli-commands/src/confirm.rs b/wallet/wallet-cli-commands/src/confirm.rs index 2d2876f15f..a27fe4148a 100644 --- a/wallet/wallet-cli-commands/src/confirm.rs +++ b/wallet/wallet-cli-commands/src/confirm.rs @@ -3,13 +3,15 @@ // SPDX-License-Identifier: MIT // Licensed under the MIT License; // you may not use this file except in compliance with the License. -// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// You may obtain a copy of the License at // -// Confirmation prompts for state-changing wallet commands. +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE // -// The interactive wallet and `--exec` mode ask for confirmation before a -// command that moves funds, changes on-chain state, or destroys secrets. -// `--yes` skips the prompt. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. use crate::{ColdWalletCommand, ManageableWalletCommand, WalletCommand, helper_types::CliCurrency}; @@ -108,8 +110,8 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { } => Some(format!( "Withdraw {amount} from delegation {delegation_id} to {address}?" )), - WalletCommand::StartStaking { .. } => Some("Start staking?".to_owned()), - WalletCommand::StopStaking { .. } => Some("Stop staking?".to_owned()), + WalletCommand::StartStaking => Some("Start staking?".to_owned()), + WalletCommand::StopStaking => Some("Stop staking?".to_owned()), WalletCommand::CreateStakePool { amount, .. } => { Some(format!("Create a stake pool pledging {amount}?")) } diff --git a/wallet/wallet-cli-commands/src/wizard/mod.rs b/wallet/wallet-cli-commands/src/wizard/mod.rs index 908b8362bb..72963fd331 100644 --- a/wallet/wallet-cli-commands/src/wizard/mod.rs +++ b/wallet/wallet-cli-commands/src/wizard/mod.rs @@ -1,11 +1,11 @@ -// Copyright (c) 2023 RBB S.r.l +// Copyright (c) 2024 RBB S.r.l // opensource@mintlayer.org // SPDX-License-Identifier: MIT // Licensed under the MIT License; // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://opensource.org/licenses/MIT +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, @@ -13,14 +13,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Interactive collection of command arguments ("wizards"). -//! -//! A wizard asks the same questions the command line would, validates every -//! answer, shows a summary, and produces the equivalent one-liner command. The -//! one-liner is fed back through the regular command parser, so a wizard is -//! only a different way to type the same command, never a second execution -//! path. - use std::collections::VecDeque; /// Where wizard answers come from. The production backend asks on the @@ -370,17 +362,12 @@ pub fn order_create(backend: &mut impl PromptBackend) -> Result(), give_amount.parse::()) { - (Ok(ask), Ok(give)) if give != 0.0 => (ask / give).to_string(), - _ => "?".to_owned(), - }; confirm_summary( backend, &[ format!("Ask: {ask_amount} {ask_currency}"), format!("Give: {give_amount} {give_currency}"), format!("Conclude address: {conclude}"), - format!("Implied price: 1 {give_currency} = {implied_price} {ask_currency}"), ], )?; Ok(format!( @@ -572,7 +559,7 @@ mod tests { } #[test] - fn order_create_renders_the_implied_rate() { + fn order_create_builds_the_one_liner() { let mut backend = backend( [CannedAnswer::Select(1)] .into_iter() diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index 22c3a8381e..129923f1e3 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -3,9 +3,15 @@ // SPDX-License-Identifier: MIT // Licensed under the MIT License; // you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// // https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE // -// One-shot mode: run a single wallet command from the command line and exit. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. use clap::Command; use tokio::sync::{mpsc, oneshot}; diff --git a/wallet/wallet-cli-lib/src/repl/mod.rs b/wallet/wallet-cli-lib/src/repl/mod.rs index 3ec9697a71..448100be5c 100644 --- a/wallet/wallet-cli-lib/src/repl/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/mod.rs @@ -30,16 +30,29 @@ pub(crate) fn confirmation_gate( assume_yes: bool, mode_name: &str, ) -> Result<(), WalletCliError> { - if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(command) { - if !assume_yes { - return Err(WalletCliError::ConfirmationRequired(format!( - "{question}\nPass --yes to run state-changing commands in {mode_name}." - ))); - } + if let Some(question) = wallet_cli_commands::confirm::confirmation_prompt(command) + && !assume_yes + { + return Err(WalletCliError::ConfirmationRequired(format!( + "{question}\nPass --yes to run state-changing commands in {mode_name}." + ))); } Ok(()) } +fn run_command_blocking( + event_tx: &mpsc::UnboundedSender>, + command: ManageableWalletCommand, +) -> Result> { + let (res_tx, res_rx) = tokio::sync::oneshot::channel(); + // channel is closed so exit + if event_tx.send(Event::HandleCommand { command, res_tx }).is_err() { + return Ok(ConsoleCommand::Exit); + } + + res_rx.blocking_recv().expect("Channel must be open") +} + #[cfg(test)] mod confirmation_gate_tests { use super::*; @@ -78,16 +91,3 @@ mod confirmation_gate_tests { .expect("read-only commands must not be gated"); } } - -fn run_command_blocking( - event_tx: &mpsc::UnboundedSender>, - command: ManageableWalletCommand, -) -> Result> { - let (res_tx, res_rx) = tokio::sync::oneshot::channel(); - // channel is closed so exit - if event_tx.send(Event::HandleCommand { command, res_tx }).is_err() { - return Ok(ConsoleCommand::Exit); - } - - res_rx.blocking_recv().expect("Channel must be open") -} From 5fab96f18c5226bf4a2317d010e6623b91542a70 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 15:46:47 +0200 Subject: [PATCH 28/52] fix(wallet-cli): gate wizard commands and validate mcp tool arguments --- node-gui/backend/src/backend_impl.rs | 4 +++- wallet/mcp-server/src/lib.rs | 15 ++++++++++----- wallet/mcp-server/src/main.rs | 10 +++++++++- wallet/wallet-cli-lib/src/config.rs | 4 +++- wallet/wallet-cli-lib/src/repl/exec.rs | 7 +++---- wallet/wallet-cli-lib/src/repl/interactive/mod.rs | 3 +++ wallet/wallet-cli/src/main.rs | 3 ++- 7 files changed, 33 insertions(+), 13 deletions(-) diff --git a/node-gui/backend/src/backend_impl.rs b/node-gui/backend/src/backend_impl.rs index 3fec4ee6c0..3d6ca81a29 100644 --- a/node-gui/backend/src/backend_impl.rs +++ b/node-gui/backend/src/backend_impl.rs @@ -1221,7 +1221,9 @@ impl Backend { if e.is_panic() { // An internal panic from the grammar build or parse thread is // not a user input problem; resume unwinding so it is reported - // as such instead of as an invalid console command. + // as such instead of as an invalid console command. (With the + // workspace default of panic = "abort" this arm is mostly + // relevant for unwind builds and tests.) std::panic::resume_unwind(e.into_panic()); } // Cancellation (runtime shutdown) is not a user input problem either. diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index 8a09fa1430..d21f690736 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -187,12 +187,17 @@ async fn call_tool(name: &str, arguments: Value, wallet: &W) Ok(states) => states, Err(err) => return tool_result(err, true), }; - let with_locked = match arguments.get("with_locked").and_then(Value::as_str) { - Some(name) => match WithLockedSelector::parse(name) { + let with_locked = match arguments.get("with_locked") { + None | Some(Value::Null) => WithLockedSelector::Unlocked, + Some(value) => match value.as_str().and_then(WithLockedSelector::parse) { Some(selector) => selector, - None => return tool_result(format!("Unknown locked filter: {name}"), true), + None => { + return tool_result( + format!("Invalid locked filter: {value}"), + true, + ) + } }, - None => WithLockedSelector::Unlocked, }; wallet.balance(account_index, states, with_locked).await } @@ -430,7 +435,7 @@ mod tests { } #[test] - fn notifications_and_malformed_payloads_get_no_response() { + fn notifications_get_no_response_while_malformed_requests_get_an_error() { let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); let notification = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); assert_eq!( diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index 7d5aef7465..2d81033dd1 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -74,8 +74,10 @@ impl CliArgs { (self.rpc_username.clone(), self.rpc_password.clone()) { RpcAuthData::Basic { username, password } - } else { + } else if self.rpc_no_auth { RpcAuthData::None + } else { + unreachable!("clap's `auth` arg group guarantees exactly one auth source") } } } @@ -163,6 +165,12 @@ async fn next_message( break; } if let Some(pos) = available.iter().position(|&byte| byte == b'\n') { + if buffer.len() + pos > MAX_MESSAGE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "message exceeds the maximum size", + )); + } buffer.extend_from_slice(&available[..pos]); reader.consume(pos + 1); let line = String::from_utf8_lossy(buffer).into_owned(); diff --git a/wallet/wallet-cli-lib/src/config.rs b/wallet/wallet-cli-lib/src/config.rs index 6fee1f0d38..7a0f329416 100644 --- a/wallet/wallet-cli-lib/src/config.rs +++ b/wallet/wallet-cli-lib/src/config.rs @@ -169,10 +169,12 @@ pub struct CliArgs { /// The command is spelled exactly as in the interactive wallet, e.g. /// `--exec "account create name"`. The exit status is 0 when the command /// succeeded and 1 when it failed. - #[clap(long)] + #[clap(long, conflicts_with = "commands_file")] pub exec: Option, /// Output format for `--exec`: human-readable text or a single JSON object. + /// Startup reports go to standard error either way, so JSON output on + /// standard output stays machine-parseable. #[clap(long, value_enum, default_value_t = CliOutputFormat::Text)] pub output: CliOutputFormat, diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index 129923f1e3..2c2c2f8f82 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -85,11 +85,10 @@ pub fn run( ) -> Result<(), WalletCliError> { for res_rx in startup_command_futures { let res = res_rx.blocking_recv().expect("Channel must be open")?; - // The startup command output is the wallet opening report; render it - // as text regardless of the requested format so the JSON stream on - // standard output carries only the requested command's result. + // The startup command output is the wallet opening report; keep it off + // standard output so the requested output format stays machine-parseable. let line_out = to_startup_text(res); - output.print_line(&line_out); + eprintln!("{line_out}"); } // The REPL thread is sized for grammar construction, so build the diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 2b0bdbeee3..8b7616fc75 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -142,6 +142,9 @@ fn process_line( if let Some(one_liner) = run_wizard_for_line::(&line)? { let command_opt = parse_input(&one_liner, repl_command)?; let command = command_opt.expect("wizard output must re-parse as a command"); + if !confirm_or_abort(&command, assume_yes)? { + return Ok(None); + } return super::run_command_blocking(event_tx, command).map(Option::Some); } diff --git a/wallet/wallet-cli/src/main.rs b/wallet/wallet-cli/src/main.rs index 8d046133bd..73f0b63fc3 100644 --- a/wallet/wallet-cli/src/main.rs +++ b/wallet/wallet-cli/src/main.rs @@ -24,7 +24,8 @@ utils::enable_rust_backtrace!(); /// Emit a shell completion script for the wallet CLI binary. /// /// This verb is handled by the binary before the main argument parser runs, so -/// it does not appear in the command grammar or in the generated completions. +/// it does not appear in the command grammar or in the generated completions; +/// run `wallet-cli --help` to discover it. #[derive(Parser, Debug)] struct CompletionsArgs { /// Shell to generate the completion script for From 7cdbb03724780e3120adb1a3ef318ec6487f5cba Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 16:04:59 +0200 Subject: [PATCH 29/52] fix(wallet-cli): normalize grammar before confirmation checks --- wallet/mcp-server/src/lib.rs | 29 ++++- wallet/mcp-server/src/main.rs | 1 + .../src/command_handler/mod.rs | 2 +- wallet/wallet-cli-commands/src/confirm.rs | 106 +++++++++++++++++- wallet/wallet-cli-commands/src/lib.rs | 4 +- wallet/wallet-cli-commands/src/wizard/mod.rs | 68 ++++++----- wallet/wallet-cli-lib/src/repl/exec.rs | 6 +- .../src/repl/interactive/mod.rs | 12 +- wallet/wallet-cli-lib/src/repl/mod.rs | 14 +++ .../src/repl/non_interactive/mod.rs | 17 ++- 10 files changed, 207 insertions(+), 52 deletions(-) diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index d21f690736..330128faa8 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -137,6 +137,17 @@ pub fn tool_definitions() -> Value { /// Handles one decoded JSON-RPC message. Returns the response value for /// requests and `None` for notifications. pub async fn handle_message(message: &Value, wallet: &W) -> Option { + // Anything that is not a single request/notification object (a batched + // array, a bare value) is not supported by this server; the advertised MCP + // protocol version still permits batching, so answer with a single error + // instead of leaving a conforming client waiting forever. + if !message.is_object() { + return Some(error_response( + None, + -32600, + "Invalid Request: only single request objects are supported".to_owned(), + )); + } let id = message.get("id").cloned(); // Notifications carry no id and never get a response. id.as_ref()?; @@ -191,12 +202,7 @@ async fn call_tool(name: &str, arguments: Value, wallet: &W) None | Some(Value::Null) => WithLockedSelector::Unlocked, Some(value) => match value.as_str().and_then(WithLockedSelector::parse) { Some(selector) => selector, - None => { - return tool_result( - format!("Invalid locked filter: {value}"), - true, - ) - } + None => return tool_result(format!("Invalid locked filter: {value}"), true), }, }; wallet.balance(account_index, states, with_locked).await @@ -448,6 +454,17 @@ mod tests { assert_eq!(response["error"]["code"], -32600); } + #[test] + fn batched_and_non_object_requests_are_rejected_with_an_error() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + for message in [json!([request("ping", json!({}))]), json!(42)] { + let response = rt + .block_on(handle_message(&message, &StubWallet)) + .expect("non-object messages are answered"); + assert_eq!(response["error"]["code"], -32600); + } + } + #[test] fn ping_is_answered_with_an_empty_result() { let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index 2d81033dd1..b6458a8ab0 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -55,6 +55,7 @@ struct CliArgs { #[clap( long, env = "WALLET_MCP_RPC_PASSWORD", + hide_env_values = true, requires = "rpc_username", conflicts_with_all = &["rpc_cookie_file", "rpc_no_auth"] )] diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index b4f7f3a693..7eb03b7817 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -2822,7 +2822,7 @@ fn is_nested_wallet_command(command: &WalletCommand) -> bool { /// into the flat commands that drive the implementation, so every command has exactly /// one implementation regardless of how it was spelled. The `wallet` group is handled /// at the dispatch site because it maps onto the wallet management commands. -fn normalize_nested_wallet_command(command: WalletCommand) -> WalletCommand { +pub(crate) fn normalize_nested_wallet_command(command: WalletCommand) -> WalletCommand { match command { WalletCommand::Account { command } => match command { AccountGroupSubCommand::Create { name } => WalletCommand::CreateNewAccount { name }, diff --git a/wallet/wallet-cli-commands/src/confirm.rs b/wallet/wallet-cli-commands/src/confirm.rs index a27fe4148a..2d87c7071f 100644 --- a/wallet/wallet-cli-commands/src/confirm.rs +++ b/wallet/wallet-cli-commands/src/confirm.rs @@ -13,13 +13,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{ColdWalletCommand, ManageableWalletCommand, WalletCommand, helper_types::CliCurrency}; +use crate::{ + ColdWalletCommand, ManageableWalletCommand, WalletCommand, WalletGroupSubCommand, + helper_types::CliCurrency, +}; /// Returns the confirmation question for a state-changing command, or `None` /// when the command can run without asking. pub fn confirmation_prompt(command: &ManageableWalletCommand) -> Option { match command { - ManageableWalletCommand::WalletCommands(command) => wallet_confirmation(command), + ManageableWalletCommand::WalletCommands(command) => { + // The gate must not depend on the spelling: translate the nested + // groups to their legacy forms first, so `send 1 addr` and + // `address-send 1 addr` classify identically. + let normalized = + crate::command_handler::normalize_nested_wallet_command(command.clone()); + wallet_confirmation(&normalized) + } ManageableWalletCommand::ManagementCommands(_) => None, } } @@ -176,6 +186,38 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { "Submit the given transaction to the node {destination}?" )) } + WalletCommand::Wallet { command } => match command { + WalletGroupSubCommand::PurgeSeedPhrase => { + cold_wallet_confirmation(&ColdWalletCommand::PurgeSeedPhrase) + } + WalletGroupSubCommand::Decrypt => { + cold_wallet_confirmation(&ColdWalletCommand::RemovePrivateKeysEncryption) + } + WalletGroupSubCommand::Encrypt { password_file: _ } => { + Some(encrypt_private_keys_question()) + } + WalletGroupSubCommand::Unlock { password_file: _ } => { + Some(unlock_private_keys_question()) + } + WalletGroupSubCommand::SetLookaheadSize { .. } => Some(set_lookahead_size_question()), + WalletGroupSubCommand::Info + | WalletGroupSubCommand::Lock + | WalletGroupSubCommand::ShowSeedPhrase + | WalletGroupSubCommand::Create { .. } + | WalletGroupSubCommand::Recover { .. } + | WalletGroupSubCommand::Open { .. } + | WalletGroupSubCommand::Close => None, + }, + WalletCommand::CreateDelegation { owner: _, pool_id } => { + Some(format!("Delegate to stake pool {pool_id}?")) + } + WalletCommand::SpendUtxo { + utxo, + address, + htlc_secret: _, + } => Some(format!( + "Spend the outpoint {utxo:?} (source and output index) to {address}?" + )), WalletCommand::ColdCommands(command) => cold_wallet_confirmation(command), _ => None, } @@ -188,6 +230,20 @@ fn describe_currency(currency: &CliCurrency) -> String { } } +fn encrypt_private_keys_question() -> String { + "Encrypt the private keys stored in this wallet file?".to_owned() +} + +fn unlock_private_keys_question() -> String { + "Decrypt the private keys in memory so they can sign transactions?".to_owned() +} + +fn set_lookahead_size_question() -> String { + "Change the lookahead size? Reducing it can make addresses beyond the new size \ + invisible to this wallet." + .to_owned() +} + fn cold_wallet_confirmation(command: &ColdWalletCommand) -> Option { match command { ColdWalletCommand::PurgeSeedPhrase => Some( @@ -199,6 +255,16 @@ fn cold_wallet_confirmation(command: &ColdWalletCommand) -> Option { "Disable private key encryption? The private keys will be stored in plain text." .to_owned(), ), + ColdWalletCommand::EncryptPrivateKeys { password: _ } => { + Some(encrypt_private_keys_question()) + } + ColdWalletCommand::UnlockPrivateKeys { password: _ } => { + Some(unlock_private_keys_question()) + } + ColdWalletCommand::SetLookaheadSize { + lookahead_size: _, + i_know_what_i_am_doing: _, + } => Some(set_lookahead_size_question()), _ => None, } } @@ -246,4 +312,40 @@ mod tests { assert!(prompt("wallet-purge-seed-phrase").is_some()); assert!(prompt("wallet-disable-private-keys-encryption").is_some()); } + + #[test] + fn nested_spellings_are_classified_the_same_as_legacy_ones() { + assert_eq!( + prompt("send addr1 100").as_deref(), + prompt("address-send addr1 100").as_deref() + ); + assert_eq!( + prompt("wallet purge-seed-phrase").as_deref(), + prompt("wallet-purge-seed-phrase").as_deref() + ); + assert_eq!( + prompt("wallet decrypt").as_deref(), + prompt("wallet-disable-private-keys-encryption").as_deref() + ); + assert!(prompt("delegation-create owner1 pool1").is_some()); + assert!(prompt( + "utxo-spend tx(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,1) addr1", + ) + .is_some()); + assert!(prompt("data deposit deadbeef").is_some()); + assert!(prompt("config broadcast no").is_some()); + assert!(prompt("transaction abandon 1111111111111111111111111111111111111111111111111111111111111111").is_some()); + assert_eq!( + prompt("wallet encrypt").as_deref(), + prompt("wallet-encrypt-private-keys secret").as_deref() + ); + assert_eq!( + prompt("wallet unlock").as_deref(), + prompt("wallet-unlock-private-keys secret").as_deref() + ); + assert_eq!( + prompt("wallet set-lookahead-size 1000").as_deref(), + prompt("wallet-set-lookahead-size 1000 i-know-what-i-am-doing").as_deref() + ); + } } diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index a498a1d023..a44104fd71 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -1259,7 +1259,7 @@ pub enum WalletManagementCommand { Exit, } -#[derive(Debug, Parser)] +#[derive(Debug, Clone, Parser)] #[clap(rename_all = "kebab-case")] pub enum ColdWalletCommand { #[clap(name = "wallet-info")] @@ -1435,7 +1435,7 @@ pub enum ColdWalletCommand { Version, } -#[derive(Debug, Parser)] +#[derive(Debug, Clone, Parser)] #[clap(rename_all = "kebab-case")] pub enum WalletCommand { #[command(flatten)] diff --git a/wallet/wallet-cli-commands/src/wizard/mod.rs b/wallet/wallet-cli-commands/src/wizard/mod.rs index 72963fd331..c4a8b9d658 100644 --- a/wallet/wallet-cli-commands/src/wizard/mod.rs +++ b/wallet/wallet-cli-commands/src/wizard/mod.rs @@ -82,7 +82,7 @@ impl PromptBackend for DialoguerBackend { } fn confirm(&mut self, prompt: &str) -> Result { - Ok(dialoguer::Confirm::new().with_prompt(prompt).default(true).interact()?) + Ok(dialoguer::Confirm::new().with_prompt(prompt).default(false).interact()?) } } @@ -149,10 +149,19 @@ fn require_non_empty(field: &str, answer: &str) -> Result { } } +use std::str::FromStr; + /// Shell-quotes a value so the produced one-liner round-trips through the -/// same word splitting the REPL uses. -fn quote(value: &str) -> String { - shlex::try_quote(value).map_or_else(|_| value.to_owned(), std::borrow::Cow::into_owned) +/// same word splitting the REPL uses. Fails instead of splicing an unquoted +/// value into the line when the value cannot be quoted at all. +fn quote(value: &str) -> Result { + shlex::try_quote(value) + .map(std::borrow::Cow::into_owned) + .map_err(|_| { + WizardError::InvalidInput(format!( + "{value:?} cannot be quoted because it contains a NUL byte, which command lines cannot carry" + )) + }) } fn optional_field(answer: &str) -> Option { @@ -198,6 +207,11 @@ pub fn token_issue(backend: &mut impl PromptBackend) -> Result "lockable".to_owned(), _ => { let amount = require_non_empty("amount", &backend.text("Fixed supply amount:", None)?)?; + if common::primitives::DecimalAmount::from_str(&amount).is_err() { + return Err(WizardError::InvalidInput(format!( + "the fixed supply amount must be a valid decimal amount, got {amount:?}" + ))); + } format!("fixed({amount})") } }; @@ -221,9 +235,9 @@ pub fn token_issue(backend: &mut impl PromptBackend) -> Result Result Result Result Result( ConsoleCommand::ClearScreen | ConsoleCommand::PrintHistory | ConsoleCommand::ChoiceMenu(_) - | ConsoleCommand::ClearHistory => Err(WalletCliError::InvalidInput(format!( - "Unsupported command in exec mode: {line}", - ))), + | ConsoleCommand::ClearHistory => Err(WalletCliError::InvalidInput( + "Unsupported command in exec mode".to_owned(), + )), ConsoleCommand::Exit => Ok(ExecOutput::Nothing), } } diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 8b7616fc75..f9eca85fc3 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -141,7 +141,17 @@ fn process_line( if let Some(one_liner) = run_wizard_for_line::(&line)? { let command_opt = parse_input(&one_liner, repl_command)?; - let command = command_opt.expect("wizard output must re-parse as a command"); + let command = match command_opt { + Some(command) => command, + // Wizard output is machine-generated and re-checked by tests, but + // an unexpected grammar drift must never crash the wallet. + None => { + return Err(WalletCliError::InvalidInput(format!( + "the wizard produced a command that failed to parse; please report this: \ + {one_liner}" + ))); + } + }; if !confirm_or_abort(&command, assume_yes)? { return Ok(None); } diff --git a/wallet/wallet-cli-lib/src/repl/mod.rs b/wallet/wallet-cli-lib/src/repl/mod.rs index 448100be5c..0c6ee9e363 100644 --- a/wallet/wallet-cli-lib/src/repl/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/mod.rs @@ -84,6 +84,20 @@ mod confirmation_gate_tests { .expect("assume_yes must let the command through"); } + #[test] + fn nested_spellings_hit_the_gate_too() { + let command = parse("send addr1 100"); + assert!(matches!( + confirmation_gate::(&command, false, "exec mode"), + Err(WalletCliError::ConfirmationRequired(_)) + )); + let command = parse("wallet purge-seed-phrase"); + assert!(matches!( + confirmation_gate::(&command, false, "exec mode"), + Err(WalletCliError::ConfirmationRequired(_)) + )); + } + #[test] fn read_only_command_never_needs_confirmation() { let command = parse("account-balance"); diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index c5390c2d22..19ad3b440c 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -51,12 +51,11 @@ fn process_line( let command_output = super::run_command_blocking(event_tx, command)?; - to_line_output(command_output, line) + to_line_output(command_output) } fn to_line_output( command_output: ConsoleCommand, - line: &str, ) -> Result> { match command_output { ConsoleCommand::Print(text) => Ok(LineOutput::Print(text)), @@ -77,10 +76,9 @@ fn to_line_output( ConsoleCommand::ClearScreen | ConsoleCommand::PrintHistory | ConsoleCommand::ChoiceMenu(_) - | ConsoleCommand::ClearHistory => Err(WalletCliError::InvalidInput(format!( - "Unsupported command in non-interactive mode: {}", - line, - ))), + | ConsoleCommand::ClearHistory => Err(WalletCliError::InvalidInput( + "Unsupported command in non-interactive mode".to_owned(), + )), ConsoleCommand::Exit => Ok(LineOutput::Exit), } } @@ -96,7 +94,7 @@ pub fn run( ) -> Result<(), WalletCliError> { for res_rx in startup_command_futures { let res = res_rx.blocking_recv().expect("Channel must be open")?; - let line_out = to_line_output(res, "startup command"); + let line_out = to_line_output(res); if let Some(value) = handle_response(line_out, &mut output, true) { return value; } @@ -169,9 +167,8 @@ mod rendering_tests { } fn render(command: ConsoleCommand) -> CapturingOutput { - let line_output = - to_line_output::(command, "test command") - .expect("rendering must not fail"); + let line_output = to_line_output::(command) + .expect("rendering must not fail"); let mut output = CapturingOutput::default(); let exit = handle_response::( Ok(line_output), From 8bbd4672d11298f9a712ff89fef4377ec8b7fc91 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 17:29:15 +0200 Subject: [PATCH 30/52] fix(wallet-cli): keep exec json output faithful to the command payload --- wallet/mcp-server/src/lib.rs | 78 ++++++++++++++++++++++---- wallet/wallet-cli-lib/src/console.rs | 21 ------- wallet/wallet-cli-lib/src/repl/exec.rs | 56 +++++++++++++----- wallet/wallet-cli/src/main.rs | 8 +-- 4 files changed, 112 insertions(+), 51 deletions(-) diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index 330128faa8..61e2aa8684 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -136,6 +136,11 @@ pub fn tool_definitions() -> Value { /// Handles one decoded JSON-RPC message. Returns the response value for /// requests and `None` for notifications. +/// The MCP protocol revisions this server implements, newest last; the +/// initialize handshake echoes a requested version only when it is listed here +/// and otherwise falls back to the newest one. +const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2024-11-05"]; + pub async fn handle_message(message: &Value, wallet: &W) -> Option { // Anything that is not a single request/notification object (a batched // array, a bare value) is not supported by this server; the advertised MCP @@ -161,17 +166,28 @@ pub async fn handle_message(message: &Value, wallet: &W) -> O }; let params = message.get("params").cloned().unwrap_or_else(|| json!({})); let response = match method { - "initialize" => result_response( - id, - json!({ - "protocolVersion": "2024-11-05", - "capabilities": { "tools": {} }, - "serverInfo": { - "name": "mintlayer-wallet-mcp", - "version": env!("CARGO_PKG_VERSION") - } - }), - ), + "initialize" => { + // Echo the client's requested protocol version only when this + // server actually supports it; unknown or missing versions fall + // back to the newest supported entry, per the MCP initialize + // handshake. + let protocol_version = params + .get("protocolVersion") + .and_then(Value::as_str) + .filter(|requested| SUPPORTED_PROTOCOL_VERSIONS.contains(requested)) + .unwrap_or(SUPPORTED_PROTOCOL_VERSIONS.last().copied().unwrap_or("2024-11-05")); + result_response( + id, + json!({ + "protocolVersion": protocol_version, + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "mintlayer-wallet-mcp", + "version": env!("CARGO_PKG_VERSION") + } + }), + ) + } "ping" => result_response(id, json!({})), "tools/list" => result_response(id, tool_definitions()), "tools/call" => { @@ -465,6 +481,46 @@ mod tests { } } + #[test] + fn initialize_echoes_only_supported_protocol_versions() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let unsupported = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "1999-01-01" } + }); + let response = rt + .block_on(handle_message(&unsupported, &StubWallet)) + .expect("initialize is answered"); + assert_eq!(response["result"]["protocolVersion"], "2024-11-05"); + + let non_string_version = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "initialize", + "params": { "protocolVersion": 2024 } + }); + let response = rt + .block_on(handle_message(&non_string_version, &StubWallet)) + .expect("initialize is answered"); + assert_eq!( + response["result"]["protocolVersion"], + SUPPORTED_PROTOCOL_VERSIONS.last().copied().unwrap() + ); + + let supported = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", + "params": { "protocolVersion": "2024-11-05" } + }); + let response = rt + .block_on(handle_message(&supported, &StubWallet)) + .expect("initialize is answered"); + assert_eq!(response["result"]["protocolVersion"], "2024-11-05"); + } + #[test] fn ping_is_answered_with_an_empty_result() { let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); diff --git a/wallet/wallet-cli-lib/src/console.rs b/wallet/wallet-cli-lib/src/console.rs index fbed47e9ab..9e7d4b92bc 100644 --- a/wallet/wallet-cli-lib/src/console.rs +++ b/wallet/wallet-cli-lib/src/console.rs @@ -51,27 +51,6 @@ impl ConsoleInput for StdioInputConsole { } } -/// A console input that yields a single fixed line, used by `--exec`. -pub struct OneShotInput { - line: Option, -} - -impl OneShotInput { - pub fn new(line: String) -> Self { - Self { line: Some(line) } - } -} - -impl ConsoleInput for OneShotInput { - fn is_tty(&self) -> bool { - false - } - - fn read_line(&mut self) -> Option { - self.line.take() - } -} - pub struct StdioOutputConsole; impl ConsoleOutput for StdioOutputConsole { diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index 4208cac4f5..19f247efde 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -24,7 +24,10 @@ use crate::{ enum ExecOutput { Text(String), - Json(serde_json::Value), + Json { + text: Option, + value: serde_json::Value, + }, Nothing, } @@ -45,10 +48,7 @@ fn process_line( match super::run_command_blocking(event_tx, command)? { ConsoleCommand::Print(text) => Ok(ExecOutput::Text(text)), - ConsoleCommand::Json { text, value } => Ok(match text { - Some(text) => ExecOutput::Json(serde_json::json!({ "text": text, "value": value })), - None => ExecOutput::Json(value), - }), + ConsoleCommand::Json { text, value } => Ok(ExecOutput::Json { text, value }), ConsoleCommand::Warn(message) => Ok(ExecOutput::Text(message)), ConsoleCommand::PaginatedPrint { header, body } => { let rendered = match (header.is_empty(), body.is_empty()) { @@ -100,23 +100,35 @@ pub fn run( match (output_format, result) { (_, ExecOutput::Nothing) => {} (CliOutputFormat::Text, ExecOutput::Text(text)) => output.print_line(&text), - (CliOutputFormat::Text, ExecOutput::Json(value)) => { + (CliOutputFormat::Text, ExecOutput::Json { text, value }) => { output.print_line(&wallet_cli_commands::output::json_display_text( - None, &value, + text, &value, )); } (CliOutputFormat::Json, ExecOutput::Text(text)) => output.print_line( &serde_json::to_string(&serde_json::json!({ "output": text })) .map_err(WalletCliError::SerdeJsonFormatError)?, ), - (CliOutputFormat::Json, ExecOutput::Json(value)) => output.print_line( - &serde_json::to_string(&value).map_err(WalletCliError::SerdeJsonFormatError)?, + (CliOutputFormat::Json, ExecOutput::Json { text, value }) => output.print_line( + &serde_json::to_string(&json_payload(text, value)) + .map_err(WalletCliError::SerdeJsonFormatError)?, ), } Ok(()) } +/// The machine-readable payload for a `ConsoleCommand::Json`. A text-less +/// payload stays the raw value so existing `--output json` consumers that read +/// the command value directly keep working; the `text` key is added only when +/// the command carries human-readable text. +fn json_payload(text: Option, value: serde_json::Value) -> serde_json::Value { + match text { + Some(text) => serde_json::json!({ "text": text, "value": value }), + None => value, + } +} + fn to_startup_text(command_output: ConsoleCommand) -> String { match command_output { ConsoleCommand::Print(text) => text, @@ -147,12 +159,26 @@ fn to_startup_text(command_output: ConsoleCommand) -> String { mod tests { use super::*; - fn parse_line(line: &str, cold: bool) -> Option { - // Parse-only probe: build the grammar and reuse the shared parse path. - let repl_command = build_repl_command(cold, true); - parse_input::(line, &repl_command) - .expect("grammar parse must not fail") - .map(|_| serde_json::json!("parsed")) + fn parse_line(line: &'static str, cold: bool) -> Option { + // Grammar construction overflows the default 2 MiB test-thread stack, + // so build it on a large-stack thread like the sibling test modules do. + wallet_cli_commands::run_with_large_stack(move || { + // Parse-only probe: build the grammar and reuse the shared parse path. + let repl_command = build_repl_command(cold, true); + parse_input::(line, &repl_command) + .expect("grammar parse must not fail") + .map(|_| serde_json::json!("parsed")) + }) + } + + #[test] + fn json_payload_keeps_textless_values_raw() { + let value = serde_json::json!({ "peer_count": 3 }); + assert_eq!(json_payload(None, value.clone()), value); + assert_eq!( + json_payload(Some("peers".to_owned()), value.clone()), + serde_json::json!({ "text": "peers", "value": value }) + ); } #[test] diff --git a/wallet/wallet-cli/src/main.rs b/wallet/wallet-cli/src/main.rs index 73f0b63fc3..43c19b75b6 100644 --- a/wallet/wallet-cli/src/main.rs +++ b/wallet/wallet-cli/src/main.rs @@ -24,8 +24,8 @@ utils::enable_rust_backtrace!(); /// Emit a shell completion script for the wallet CLI binary. /// /// This verb is handled by the binary before the main argument parser runs, so -/// it does not appear in the command grammar or in the generated completions; -/// run `wallet-cli --help` to discover it. +/// it does not appear in the command grammar, in the help output, or in the +/// generated completions. #[derive(Parser, Debug)] struct CompletionsArgs { /// Shell to generate the completion script for @@ -40,8 +40,8 @@ fn binary_name() -> String { .next() .as_deref() .map(std::path::Path::new) - // `file_stem` drops a Windows executable extension; completion script - // identifiers must not contain a dot. + // `file_stem` drops any extension (e.g. `.exe` on Windows); completion + // script identifiers must not contain a dot. .and_then(|path| path.file_stem()) .and_then(|name| name.to_str()) .map_or_else(|| env!("CARGO_PKG_NAME").to_owned(), str::to_owned) From ae4a8106626a14583d2c724ff32031d678dfea6e Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 18:11:26 +0200 Subject: [PATCH 31/52] test(wallet-cli): run the functional harness with confirmations skipped --- test/functional/test_framework/wallet_cli_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index 9c072bf3d4..e055f2bc05 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -82,9 +82,9 @@ async def __aenter__(self): cookie_file = os.path.join(self.node.datadir, ".cookie") # if it is a cold wallet or wallet connecting to an RPC wallet no need to specify node address and cookie if "--remote-rpc-wallet-address" in self.wallet_args or "--cold-wallet" in self.wallet_args: - wallet_args = ["regtest" ] + self.wallet_args + self.chain_config_args + wallet_args = ["regtest", "--yes"] + self.wallet_args + self.chain_config_args else: - wallet_args = ["regtest", "--node-rpc-address", self.node.url.split("@")[1], "--node-rpc-cookie-file", cookie_file] + self.wallet_args + self.chain_config_args + wallet_args = ["regtest", "--node-rpc-address", self.node.url.split("@")[1], "--node-rpc-cookie-file", cookie_file, "--yes"] + self.wallet_args + self.chain_config_args self.wallet_log_file = NamedTemporaryFile(prefix="wallet_stderr_", dir=os.path.dirname(self.node.datadir), delete=False) self.wallet_commands_file = NamedTemporaryFile(prefix="wallet_commands_responses_", dir=os.path.dirname(self.node.datadir), delete=False) From 82e05f54c8dc0bbd9c6c58eddc3fb8b428337969 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 18:12:51 +0200 Subject: [PATCH 32/52] fix(wallet): reject invalid mcp tool calls at the protocol level --- wallet/mcp-server/src/lib.rs | 152 +++++++++++++++++++++++++---------- 1 file changed, 110 insertions(+), 42 deletions(-) diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index 61e2aa8684..bc91a0bea5 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -191,56 +191,92 @@ pub async fn handle_message(message: &Value, wallet: &W) -> O "ping" => result_response(id, json!({})), "tools/list" => result_response(id, tool_definitions()), "tools/call" => { - let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let Some(name) = params.get("name").and_then(Value::as_str) else { + return Some(error_response( + id, + -32602, + "Invalid params: `name` must be a string naming a tool".to_owned(), + )); + }; let arguments = params.get("arguments").cloned().unwrap_or_else(|| json!({})); - result_response(id, call_tool(name, arguments, wallet).await) + if !arguments.is_object() { + return Some(error_response( + id, + -32602, + "Invalid params: `arguments` must be an object".to_owned(), + )); + } + match call_tool(name, arguments, wallet).await { + Ok(value) => match serde_json::to_string_pretty(&value) { + Ok(text) => result_response(id, tool_result(text, false)), + Err(err) => result_response( + id, + tool_result(format!("Failed to render the result: {err}"), true), + ), + }, + // Argument and naming problems are protocol errors; only + // failures of the wallet RPC itself are `isError` results. + Err(ToolCallError::Protocol { code, message }) => error_response(id, code, message), + Err(ToolCallError::Execution(message)) => { + result_response(id, tool_result(message, true)) + } + } } other => error_response(id, -32601, format!("Method not found: {other}")), }; Some(response) } -async fn call_tool(name: &str, arguments: Value, wallet: &W) -> Value { - let outcome: Result = match name { - "version" => wallet.version().await.map(Value::String), - "wallet_info" => wallet.wallet_info().await, - "best_block" => wallet.best_block().await, +/// A failed tool call: either a protocol-level problem (unknown tool, +/// malformed arguments) that must surface as a JSON-RPC error, or a failure of +/// the wallet RPC itself, which the MCP spec reports as an `isError` result. +enum ToolCallError { + Protocol { code: i64, message: String }, + Execution(String), +} + +impl ToolCallError { + fn protocol(message: String) -> Self { + Self::Protocol { + code: -32602, + message, + } + } +} + +async fn call_tool( + name: &str, + arguments: Value, + wallet: &W, +) -> Result { + match name { + "version" => wallet.version().await.map(Value::String).map_err(ToolCallError::Execution), + "wallet_info" => wallet.wallet_info().await.map_err(ToolCallError::Execution), + "best_block" => wallet.best_block().await.map_err(ToolCallError::Execution), "balance" => { - let account_index = match parse_account_index(&arguments) { - Ok(index) => index, - Err(err) => return tool_result(err, true), - }; - let states = match parse_utxo_states(&arguments) { - Ok(states) => states, - Err(err) => return tool_result(err, true), - }; + let account_index = parse_account_index(&arguments).map_err(ToolCallError::protocol)?; + let states = parse_utxo_states(&arguments).map_err(ToolCallError::protocol)?; let with_locked = match arguments.get("with_locked") { None | Some(Value::Null) => WithLockedSelector::Unlocked, - Some(value) => match value.as_str().and_then(WithLockedSelector::parse) { - Some(selector) => selector, - None => return tool_result(format!("Invalid locked filter: {value}"), true), - }, + Some(value) => { + value.as_str().and_then(WithLockedSelector::parse).ok_or_else(|| { + ToolCallError::protocol(format!("Invalid locked filter: {value}")) + })? + } }; - wallet.balance(account_index, states, with_locked).await + wallet + .balance(account_index, states, with_locked) + .await + .map_err(ToolCallError::Execution) } "standalone_addresses" => { - let account_index = match parse_account_index(&arguments) { - Ok(index) => index, - Err(err) => return tool_result(err, true), - }; - wallet.standalone_addresses(account_index).await - } - other => { - return tool_result(format!("Unknown tool: {other}"), true); + let account_index = parse_account_index(&arguments).map_err(ToolCallError::protocol)?; + wallet + .standalone_addresses(account_index) + .await + .map_err(ToolCallError::Execution) } - }; - - match outcome { - Ok(value) => match serde_json::to_string_pretty(&value) { - Ok(text) => tool_result(text, false), - Err(err) => tool_result(format!("Failed to render the result: {err}"), true), - }, - Err(err) => tool_result(err, true), + other => Err(ToolCallError::protocol(format!("Unknown tool: {other}"))), } } @@ -440,12 +476,9 @@ mod tests { &StubWallet, )) .expect("requests are answered"); - assert!( - response["result"]["content"][0]["text"] - .as_str() - .unwrap() - .contains("Unknown tool") - ); + // Unknown tools are protocol errors, not tool results (MCP spec). + assert_eq!(response["error"]["code"], -32602); + assert!(response["error"]["message"].as_str().unwrap().contains("Unknown tool")); let response = rt .block_on(handle_message( @@ -481,6 +514,41 @@ mod tests { } } + #[test] + fn invalid_tool_arguments_are_protocol_errors() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let cases = [ + ( + json!({ "name": "balance", "arguments": { "account_index": "one" } }), + "account_index", + ), + ( + json!({ "name": "balance", "arguments": { "utxo_states": ["bogus"] } }), + "UTXO state", + ), + ( + json!({ "name": "balance", "arguments": { "with_locked": "sometimes" } }), + "locked filter", + ), + ]; + for (params, needle) in cases { + let message = request("tools/call", params); + let response = rt + .block_on(handle_message(&message, &StubWallet)) + .expect("requests are answered"); + assert_eq!(response["error"]["code"], -32602, "for {needle}"); + assert!(response["error"]["message"].as_str().unwrap().contains(needle)); + } + // A missing tool name is also a protocol error. + let response = rt + .block_on(handle_message( + &request("tools/call", json!({})), + &StubWallet, + )) + .expect("requests are answered"); + assert_eq!(response["error"]["code"], -32602); + } + #[test] fn initialize_echoes_only_supported_protocol_versions() { let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); From 0059b567e98ef98d146355367ab2d0844b69d225 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Tue, 20 Oct 2026 23:25:38 +0200 Subject: [PATCH 33/52] ci(wasm): resolve tsc through npx --- .github/workflows/wasm.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 4469518be0..a117b0c3cf 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -63,7 +63,10 @@ jobs: - name: Compile the tests working-directory: ./wasm-wrappers - run: tsc --project js-bindings-test/tsconfig.json + # Invoke the binary installed at the repository root directly: this + # does not depend on how runner images compose PATH and fails with a + # clear "not found" error instead of resolving through the registry. + run: '"$GITHUB_WORKSPACE/node_modules/.bin/tsc" --project js-bindings-test/tsconfig.json' - name: Run the tests working-directory: ./wasm-wrappers @@ -71,7 +74,7 @@ jobs: - name: Run Knip working-directory: ./wasm-wrappers/js-bindings-test - run: npx knip + run: '"$GITHUB_WORKSPACE/node_modules/.bin/knip"' wasm_artifacts: runs-on: ubuntu-latest From c9d4b813ff357e0f0de012eae2a25e3fbcbd662e Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 00:08:50 +0200 Subject: [PATCH 34/52] fix(wallet-cli): classify every command for the confirmation gate --- .../src/command_handler/mod.rs | 13 +- wallet/wallet-cli-commands/src/confirm.rs | 131 +++++++++++++++++- wallet/wallet-cli-commands/src/wizard/mod.rs | 65 +++++++++ 3 files changed, 198 insertions(+), 11 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 7eb03b7817..329d6e349c 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -450,13 +450,14 @@ where .map_err(|err| WalletCliCommandError::InvalidInput(format!("{label}: {err}"))) } - // One trailing line terminator (`\n` or `\r\n`) is stripped; any other - // whitespace is kept. + // Trailing line terminators (`\n` or `\r\n`, however many) are + // stripped; any other whitespace is kept. fn strip_line_terminator(password: &str) -> &str { - password - .strip_suffix("\r\n") - .or_else(|| password.strip_suffix('\n')) - .unwrap_or(password) + // Files edited by hand often end in more than one line terminator + // (a trailing blank line is a common editor artifact); every + // trailing terminator is stripped so the password stays exactly + // what the file's last non-empty line holds. + password.trim_end_matches(['\r', '\n']) } let password = match source { diff --git a/wallet/wallet-cli-commands/src/confirm.rs b/wallet/wallet-cli-commands/src/confirm.rs index 2d87c7071f..a078a4f290 100644 --- a/wallet/wallet-cli-commands/src/confirm.rs +++ b/wallet/wallet-cli-commands/src/confirm.rs @@ -70,10 +70,19 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { } => Some(format!( "Sweep delegation {delegation_id} to {destination_address}?" )), - WalletCommand::DepositData { hex_data } => Some(format!( - "Store {} bytes of data on the chain?", - hex_data.len() / 2 - )), + WalletCommand::DepositData { hex_data } => { + // Report the decoded size so the prompt matches what the handler + // will store; odd-length or prefixed input falls back to a note + // instead of a wrong number. + let rendered = hex_data.clone(); + let payload = rendered.strip_prefix("0x").unwrap_or(&rendered); + let size = if payload.len() % 2 == 0 { + format!("{} bytes", payload.len() / 2) + } else { + "an invalid amount of data (the hex input is malformed)".to_owned() + }; + Some(format!("Store {size} on the chain?")) + } WalletCommand::IssueNewNft { destination_address: _, media_hash: _, @@ -219,7 +228,99 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { "Spend the outpoint {utxo:?} (source and output index) to {address}?" )), WalletCommand::ColdCommands(command) => cold_wallet_confirmation(command), - _ => None, + WalletCommand::SelectAccount { account_index } => Some(format!( + "Select account {account_index} for subsequent commands?" + )), + WalletCommand::AddStandaloneKey { .. } => { + Some("Add a standalone watch-only address to this wallet?".to_owned()) + } + WalletCommand::AddStandalonePrivateKey { .. } => Some( + "Import a private key into this wallet? It will be able to sign \ + for its addresses." + .to_owned(), + ), + WalletCommand::AddStandaloneMultisig { .. } => { + Some("Add a standalone multisig address to this wallet?".to_owned()) + } + WalletCommand::SubmitBlock { .. } => Some("Submit the given block to the node?".to_owned()), + WalletCommand::NodeShutdown => Some("Shut down the connected node?".to_owned()), + WalletCommand::NodeEnableNetworking { enable } => Some(format!( + "Set P2P networking on the node to {}?", + if enable.is_enable() { + "enabled" + } else { + "disabled" + } + )), + // Group spellings and the wizard are translated before this match + // runs; read-only commands never need a confirmation. The list is + // exhaustive so a newly added variant fails to compile until it is + // classified here. + WalletCommand::Config { .. } + | WalletCommand::Wizard { .. } + | WalletCommand::CreateNewAccount { .. } + | WalletCommand::RenameAccount { .. } + | WalletCommand::ListUtxo { .. } + | WalletCommand::GetBalance { .. } + | WalletCommand::StandaloneAddressLabelRename { .. } + | WalletCommand::ListMultisigUtxo { .. } + | WalletCommand::MakeTxToSendTokensToAddressWithIntent { .. } + | WalletCommand::MakeTxToSendTokensFromMultisigAddress { .. } + | WalletCommand::InspectTransaction { .. } + | WalletCommand::ListDelegationIds + | WalletCommand::ListPools + | WalletCommand::ListOwnedPoolsForDecommission + | WalletCommand::StakingStatus + | WalletCommand::StakePoolBalance { .. } + | WalletCommand::ListCreatedBlocksIds + | WalletCommand::Rescan + | WalletCommand::SyncWallet + | WalletCommand::NodeVersion + | WalletCommand::Connect { .. } + | WalletCommand::Disconnect { .. } + | WalletCommand::ListBanned + | WalletCommand::Ban { .. } + | WalletCommand::Unban { .. } + | WalletCommand::ListDiscouraged + | WalletCommand::Undiscourage { .. } + | WalletCommand::PeerCount + | WalletCommand::ConnectedPeers + | WalletCommand::ConnectedPeersJson + | WalletCommand::ReservedPeers + | WalletCommand::AddReservedPeer { .. } + | WalletCommand::RemoveReservedPeer { .. } + | WalletCommand::ChainstateInfo + | WalletCommand::BestBlock + | WalletCommand::NodeBestBlock { .. } + | WalletCommand::BestBlockHeight + | WalletCommand::BestBlockTimestamp + | WalletCommand::BlockId { .. } + | WalletCommand::GetBlock { .. } + | WalletCommand::GenerateBlock { .. } + | WalletCommand::GenerateBlocks { .. } + | WalletCommand::FindTimestampsForStaking { .. } + | WalletCommand::GetBlockIdsAsCheckpoints { .. } + | WalletCommand::ListPendingTransactions + | WalletCommand::ListMainchainTransactions { .. } + | WalletCommand::GetTransaction { .. } + | WalletCommand::GetRawTransaction { .. } + | WalletCommand::GetRawSignedTransaction { .. } + | WalletCommand::ListOwnOrders + | WalletCommand::ListActiveOrders { .. } + | WalletCommand::GenerateHtlcSecret + | WalletCommand::CalcHtlcSecretHash { .. } + | WalletCommand::Account { .. } + | WalletCommand::Address { .. } + | WalletCommand::Send { .. } + | WalletCommand::Data { .. } + | WalletCommand::Staking { .. } + | WalletCommand::Delegation { .. } + | WalletCommand::Token { .. } + | WalletCommand::Order { .. } + | WalletCommand::Htlc { .. } + | WalletCommand::Node { .. } + | WalletCommand::Peer { .. } + | WalletCommand::Transaction { .. } => None, } } @@ -300,6 +401,26 @@ mod tests { assert!(prompt("order-create coin 1 coin 2 addr").is_some()); } + #[test] + fn node_and_wallet_state_changes_ask() { + assert_eq!( + prompt("node-shutdown").as_deref(), + Some("Shut down the connected node?") + ); + assert_eq!( + prompt("node-enable-p2p-networking disable").as_deref(), + Some("Set P2P networking on the node to disabled?") + ); + assert!(prompt("account-select 3").is_some()); + assert!(prompt( + "standalone-add-private-key-from-hex 000000000000000000000000000000000000000000000000000000000000000001" + ) + .is_some()); + // The same commands through the nested grammar classify identically. + assert_eq!(prompt("node shutdown"), prompt("node-shutdown")); + assert_eq!(prompt("account select 3"), prompt("account-select 3")); + } + #[test] fn read_only_commands_do_not_ask() { assert!(prompt("account-balance").is_none()); diff --git a/wallet/wallet-cli-commands/src/wizard/mod.rs b/wallet/wallet-cli-commands/src/wizard/mod.rs index c4a8b9d658..9859680801 100644 --- a/wallet/wallet-cli-commands/src/wizard/mod.rs +++ b/wallet/wallet-cli-commands/src/wizard/mod.rs @@ -492,6 +492,7 @@ impl WizardKind { #[cfg(test)] mod tests { use super::*; + use crate::WalletCommand; fn backend(answers: impl IntoIterator) -> CannedBackend { CannedBackend::new(answers) @@ -602,4 +603,68 @@ mod tests { "htlc create coin 50 abcd spend block_count(720) refund" ); } + + /// The wizard renders `seconds(n)`, `until_height(n)`, `until_time(rfc3339)` + /// and `fixed(amount)`; pin every spelling by round-tripping a generated + /// one-liner through the real grammar. + #[test] + fn htlc_and_fixed_supply_spellings_round_trip_through_the_parser() { + let cases = [ + ( + "htlc create coin 50 abcd spend seconds(86400) refund", + "ForSeconds(86400)", + ), + ( + "htlc create coin 50 abcd spend until_height(500000) refund", + "UntilHeight(", + ), + ( + "htlc create coin 50 abcd spend until_time(2026-02-26T12:30:00Z) refund", + "UntilTime", + ), + ]; + for (line, expected) in cases { + let command = round_trip_flat_variant(line).expect("one-liner must parse"); + let WalletCommand::CreateHtlcTx { + refund_timelock, .. + } = command + else { + panic!("{line} did not normalize to CreateHtlcTx"); + }; + let rendered = format!("{refund_timelock:?}"); + assert!( + rendered.starts_with(expected), + "{line} parsed as {rendered}, expected {expected}" + ); + } + let command = round_trip_flat_variant( + "token issue GOLD 8 https://example.com 7Utx1 fixed(100) freezable", + ) + .expect("one-liner must parse"); + let WalletCommand::IssueNewToken { token_supply, .. } = command else { + panic!("token issue did not normalize to IssueNewToken"); + }; + assert!( + format!("{token_supply:?}").contains("Fixed"), + "supply parsed as {token_supply:?}" + ); + } + + fn round_trip_flat_variant(line: &'static str) -> Option { + crate::run_with_large_stack(move || { + let command = crate::parse_input::( + line, + &crate::build_repl_command(false, true), + ) + .ok() + .flatten()?; + let manageable = match &command { + crate::ManageableWalletCommand::WalletCommands(cmd) => cmd.clone(), + _ => return None, + }; + Some(crate::command_handler::normalize_nested_wallet_command( + manageable, + )) + }) + } } From 883da538309c26f1efe65280e921cb7affeb8990 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 00:08:57 +0200 Subject: [PATCH 35/52] fix(wallet): validate the mcp protocol envelope and input strings --- wallet/mcp-server/src/lib.rs | 11 +++++++++++ wallet/mcp-server/src/main.rs | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index bc91a0bea5..a0855c4538 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -141,6 +141,9 @@ pub fn tool_definitions() -> Value { /// and otherwise falls back to the newest one. const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2024-11-05"]; +/// The JSON-RPC envelope version this server speaks (MCP transport). +const JSONRPC_VERSION: &str = "2.0"; + pub async fn handle_message(message: &Value, wallet: &W) -> Option { // Anything that is not a single request/notification object (a batched // array, a bare value) is not supported by this server; the advertised MCP @@ -156,6 +159,14 @@ pub async fn handle_message(message: &Value, wallet: &W) -> O let id = message.get("id").cloned(); // Notifications carry no id and never get a response. id.as_ref()?; + if message.get("jsonrpc").and_then(Value::as_str) != Some(JSONRPC_VERSION) { + // JSON-RPC 2.0 requires the version member on every request. + return Some(error_response( + id, + -32600, + "Invalid Request: jsonrpc must be \"2.0\"".to_owned(), + )); + } let Some(method) = message.get("method").and_then(Value::as_str) else { // Requests must always be answered, even when malformed (JSON-RPC 2.0: -32600). return Some(error_response( diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index b6458a8ab0..01ec21193e 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -174,8 +174,12 @@ async fn next_message( } buffer.extend_from_slice(&available[..pos]); reader.consume(pos + 1); - let line = String::from_utf8_lossy(buffer).into_owned(); - buffer.clear(); + let bytes: Vec = std::mem::take(buffer); + // A protocol boundary must reject, not repair: lossy decoding could + // silently turn a truncated request into a different valid one. + let line = String::from_utf8(bytes).map_err(|err| { + std::io::Error::new(std::io::ErrorKind::InvalidData, err) + })?; return Ok(Some(line)); } if buffer.len() + available.len() > MAX_MESSAGE_BYTES { @@ -191,8 +195,9 @@ async fn next_message( if buffer.is_empty() { return Ok(None); } - let line = String::from_utf8_lossy(buffer).into_owned(); - buffer.clear(); + let bytes = std::mem::take(buffer); + let line = String::from_utf8(bytes) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; Ok(Some(line)) } @@ -204,7 +209,27 @@ async fn serve(wallet: RpcWallet) -> Result<(), Box> { let mut message_buffer = Vec::with_capacity(1024); let mut stdout = tokio::io::stdout(); - while let Some(line) = next_message(&mut reader, &mut message_buffer).await? { + while let Some(line) = match next_message(&mut reader, &mut message_buffer).await { + Ok(line) => line, + // Malformed bytes and oversized messages are failures of the single + // message, not of the stream: answer with a parse error and keep + // serving instead of tearing the session down. + Err(err) if err.kind() == std::io::ErrorKind::InvalidData => { + let encoded = serde_json::to_string(&serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": -32700, + "message": format!("Parse error: {err}") + } + }))? + + "\n"; + stdout.write_all(encoded.as_bytes()).await?; + stdout.flush().await?; + continue; + } + Err(err) => return Err(err.into()), + } { let trimmed = line.trim(); if trimmed.is_empty() { continue; From d1cc022b2511d76c4375d0115ae12081dc90b5dc Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 00:08:57 +0200 Subject: [PATCH 36/52] fix(wallet-cli): report event loop shutdown instead of panicking --- wallet/wallet-cli-lib/src/repl/exec.rs | 10 +++++++++- .../src/repl/interactive/wallet_completions.rs | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index 19f247efde..ac5abd8972 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -84,7 +84,15 @@ pub fn run( startup_command_futures: Vec>>>, ) -> Result<(), WalletCliError> { for res_rx in startup_command_futures { - let res = res_rx.blocking_recv().expect("Channel must be open")?; + let res = match res_rx.blocking_recv() { + Ok(inner) => inner?, + Err(_recv_error) => { + return Err(WalletCliError::::InvalidInput( + "the wallet event loop shut down before the startup commands finished" + .to_owned(), + )); + } + }; // The startup command output is the wallet opening report; keep it off // standard output so the requested output format stays machine-parseable. let line_out = to_startup_text(res); diff --git a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs index b5f22915ce..b4f6b98c5f 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs @@ -38,6 +38,16 @@ impl WalletCompletions { let mut root = root.into(); if let Some(root) = Arc::get_mut(&mut root) { root.build(); + } else { + // A shared command cannot be built here; callers holding a shared + // Arc must pass an already-built tree (as `build_repl_command` + // does). Walking an unbuilt tree silently yields no candidates. + // `build()` is what injects the help flag, so its presence is the + // cheapest proxy for "this tree was built". + debug_assert!( + root.get_arguments().any(|arg| arg.get_long() == Some("help")), + "a shared clap Command must already be built" + ); } Self { root } } From 07d9c6d03ce2d6559ac28117352544c142a09ff3 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 00:29:53 +0200 Subject: [PATCH 37/52] fix(wallet): resync the mcp stream after an oversized message --- wallet/mcp-server/src/lib.rs | 15 +++++ wallet/mcp-server/src/main.rs | 72 ++++++++++++++++------- wallet/wallet-cli-commands/src/confirm.rs | 13 ++-- wallet/wallet-cli-lib/src/errors.rs | 3 + wallet/wallet-cli-lib/src/repl/exec.rs | 5 +- 5 files changed, 75 insertions(+), 33 deletions(-) diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index a0855c4538..10c38360e6 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -500,6 +500,21 @@ mod tests { assert_eq!(response["error"]["code"], -32601); } + #[test] + fn requests_with_missing_or_wrong_jsonrpc_version_are_rejected() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + for message in [ + serde_json::json!({ "id": 1, "method": "ping" }), + serde_json::json!({ "jsonrpc": "1.0", "id": 2, "method": "ping" }), + ] { + let response = rt + .block_on(handle_message(&message, &StubWallet)) + .expect("requests are answered"); + assert_eq!(response["error"]["code"], -32600); + assert_eq!(response["id"], message["id"]); + } + } + #[test] fn notifications_get_no_response_while_malformed_requests_get_an_error() { let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index 01ec21193e..e918a759cf 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -160,13 +160,17 @@ async fn next_message( use tokio::io::AsyncBufReadExt; loop { - let available = reader.fill_buf().await?; + let mut available = reader.fill_buf().await?; if available.is_empty() { // End of stream: deliver a trailing message without a newline, if any. break; } if let Some(pos) = available.iter().position(|&byte| byte == b'\n') { if buffer.len() + pos > MAX_MESSAGE_BYTES { + // Discard the rejected message so the stream stays usable; + // leaving the bytes in place would trip this error forever. + buffer.clear(); + reader.consume(pos + 1); return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "message exceeds the maximum size", @@ -177,12 +181,33 @@ async fn next_message( let bytes: Vec = std::mem::take(buffer); // A protocol boundary must reject, not repair: lossy decoding could // silently turn a truncated request into a different valid one. - let line = String::from_utf8(bytes).map_err(|err| { - std::io::Error::new(std::io::ErrorKind::InvalidData, err) - })?; + let line = String::from_utf8(bytes) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; return Ok(Some(line)); } if buffer.len() + available.len() > MAX_MESSAGE_BYTES { + // The rejected line has no newline in this chunk; drain chunks + // until its newline so exactly one error is reported per message. + buffer.clear(); + loop { + match available.iter().position(|&byte| byte == b'\n') { + Some(pos) => { + reader.consume(pos + 1); + break; + } + None => { + let drained = available.len(); + reader.consume(drained); + let next = reader.fill_buf().await?; + if next.is_empty() { + break; // EOF mid-line + } + // `available` borrows from the reader and is refreshed + // by fill_buf; rebind it for the next iteration. + available = next; + } + } + } return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "protocol message exceeds the size limit", @@ -209,27 +234,30 @@ async fn serve(wallet: RpcWallet) -> Result<(), Box> { let mut message_buffer = Vec::with_capacity(1024); let mut stdout = tokio::io::stdout(); - while let Some(line) = match next_message(&mut reader, &mut message_buffer).await { - Ok(line) => line, + loop { // Malformed bytes and oversized messages are failures of the single // message, not of the stream: answer with a parse error and keep // serving instead of tearing the session down. - Err(err) if err.kind() == std::io::ErrorKind::InvalidData => { - let encoded = serde_json::to_string(&serde_json::json!({ - "jsonrpc": "2.0", - "id": null, - "error": { - "code": -32700, - "message": format!("Parse error: {err}") - } - }))? - + "\n"; - stdout.write_all(encoded.as_bytes()).await?; - stdout.flush().await?; - continue; - } - Err(err) => return Err(err.into()), - } { + let line = match next_message(&mut reader, &mut message_buffer).await { + Ok(line) => line, + Err(err) if err.kind() == std::io::ErrorKind::InvalidData => { + let encoded = serde_json::to_string(&serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": -32700, + "message": format!("Parse error: {err}") + } + }))? + "\n"; + stdout.write_all(encoded.as_bytes()).await?; + stdout.flush().await?; + continue; + } + Err(err) => return Err(err.into()), + }; + let Some(line) = line else { + break; + }; let trimmed = line.trim(); if trimmed.is_empty() { continue; diff --git a/wallet/wallet-cli-commands/src/confirm.rs b/wallet/wallet-cli-commands/src/confirm.rs index a078a4f290..e1c463a32c 100644 --- a/wallet/wallet-cli-commands/src/confirm.rs +++ b/wallet/wallet-cli-commands/src/confirm.rs @@ -72,14 +72,11 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { )), WalletCommand::DepositData { hex_data } => { // Report the decoded size so the prompt matches what the handler - // will store; odd-length or prefixed input falls back to a note - // instead of a wrong number. - let rendered = hex_data.clone(); - let payload = rendered.strip_prefix("0x").unwrap_or(&rendered); - let size = if payload.len() % 2 == 0 { - format!("{} bytes", payload.len() / 2) - } else { - "an invalid amount of data (the hex input is malformed)".to_owned() + // will store; anything hex::decode rejects (a 0x/0X prefix, odd + // length, invalid characters) falls back to a note. + let size = match hex::decode(hex_data) { + Ok(data) => format!("{} bytes", data.len()), + Err(_) => "an invalid amount of data (the hex input is malformed)".to_owned(), }; Some(format!("Store {size} on the chain?")) } diff --git a/wallet/wallet-cli-lib/src/errors.rs b/wallet/wallet-cli-lib/src/errors.rs index c36ee9b23a..1832b547d1 100644 --- a/wallet/wallet-cli-lib/src/errors.rs +++ b/wallet/wallet-cli-lib/src/errors.rs @@ -39,6 +39,9 @@ pub enum WalletCliError { #[error("Confirmation required: {0}")] ConfirmationRequired(String), + #[error("The wallet event loop shut down before the command finished: {0}")] + EventLoopShutdown(String), + #[error("Error converting to json: {0}")] SerdeJsonFormatError(#[from] serde_json::Error), diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index ac5abd8972..f7d34fe874 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -87,9 +87,8 @@ pub fn run( let res = match res_rx.blocking_recv() { Ok(inner) => inner?, Err(_recv_error) => { - return Err(WalletCliError::::InvalidInput( - "the wallet event loop shut down before the startup commands finished" - .to_owned(), + return Err(WalletCliError::::EventLoopShutdown( + "startup commands did not finish".to_owned(), )); } }; From 29164e910f034fd6ce97639fbab02a16a781c9e2 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 00:41:26 +0200 Subject: [PATCH 38/52] fix(wallet): bound the mcp resync and pin it with tests --- wallet/mcp-server/src/main.rs | 112 +++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index e918a759cf..6d51d2e79a 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -153,6 +153,34 @@ fn with_locked_filter(selector: WithLockedSelector) -> WithLocked { /// make the server buffer unbounded input. const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +/// Discards bytes up to and including the next newline. Gives up on the +/// stream if the line turns out to be endless. +async fn skip_rest_of_line( + reader: &mut R, +) -> std::io::Result<()> { + use tokio::io::AsyncBufReadExt; + + let mut discarded: usize = 0; + loop { + let available = reader.fill_buf().await?; + if available.is_empty() { + return Ok(()); // EOF mid-line + } + if let Some(pos) = available.iter().position(|&byte| byte == b'\n') { + reader.consume(pos + 1); + return Ok(()); + } + discarded += available.len(); + if discarded > MAX_MESSAGE_BYTES * 4 { + return Err(std::io::Error::other( + "peer streamed an oversized message without a newline", + )); + } + let len = available.len(); + reader.consume(len); + } +} + async fn next_message( reader: &mut R, buffer: &mut Vec, @@ -160,7 +188,7 @@ async fn next_message( use tokio::io::AsyncBufReadExt; loop { - let mut available = reader.fill_buf().await?; + let available = reader.fill_buf().await?; if available.is_empty() { // End of stream: deliver a trailing message without a newline, if any. break; @@ -189,28 +217,10 @@ async fn next_message( // The rejected line has no newline in this chunk; drain chunks // until its newline so exactly one error is reported per message. buffer.clear(); - loop { - match available.iter().position(|&byte| byte == b'\n') { - Some(pos) => { - reader.consume(pos + 1); - break; - } - None => { - let drained = available.len(); - reader.consume(drained); - let next = reader.fill_buf().await?; - if next.is_empty() { - break; // EOF mid-line - } - // `available` borrows from the reader and is refreshed - // by fill_buf; rebind it for the next iteration. - available = next; - } - } - } + skip_rest_of_line(reader).await?; return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, - "protocol message exceeds the size limit", + "message exceeds the maximum size", )); } buffer.extend_from_slice(available); @@ -297,3 +307,63 @@ async fn main() { std::process::exit(1); } } + +#[cfg(test)] +mod tests { + use super::*; + + async fn next_n_messages( + input: &str, + count: usize, + capacity: usize, + ) -> Vec, std::io::Error>> { + let mut reader = tokio::io::BufReader::with_capacity(capacity, input.as_bytes()); + let mut buffer = Vec::new(); + let mut results = Vec::new(); + for _ in 0..count { + results.push(next_message(&mut reader, &mut buffer).await); + } + results + } + + #[tokio::test] + async fn oversized_message_is_skipped_and_stream_stays_usable() { + let input = format!( + "{}\n{{\"method\":\"ping\"}}\n", + "x".repeat(MAX_MESSAGE_BYTES + 1) + ); + let results = next_n_messages(&input, 2, 8192).await; + let err = results[0].as_ref().unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert_eq!( + results[1].as_ref().unwrap().as_deref(), + Some("{\"method\":\"ping\"}") + ); + } + + #[tokio::test] + async fn oversized_message_spanning_chunks_resyncs() { + let input = format!( + "{}\n{{\"method\":\"ping\"}}\n", + "y".repeat(MAX_MESSAGE_BYTES + 1) + ); + // A tiny read buffer forces the oversized line through many chunks. + let results = next_n_messages(&input, 2, 64).await; + assert_eq!( + results[0].as_ref().unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + assert!(results[1].as_ref().unwrap().is_some()); + } + + #[tokio::test] + async fn oversized_message_without_newline_reports_once_at_eof() { + let input = "z".repeat(MAX_MESSAGE_BYTES + 1); + let results = next_n_messages(&input, 2, 8192).await; + assert_eq!( + results[0].as_ref().unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + assert_eq!(results[1].as_ref().unwrap(), &None); + } +} From 1cbad73fb02e95994b9d331412ed45c2caf1e5a6 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 00:46:54 +0200 Subject: [PATCH 39/52] chore(deps): refresh advisory exemptions for updated certificate crates --- Cargo.lock | 10 +++++----- deny.toml | 13 +++++-------- supply-chain/config.toml | 4 ---- supply-chain/imports.lock | 27 +++++++++++++++++---------- wallet/mcp-server/src/lib.rs | 2 +- wallet/mcp-server/src/main.rs | 21 +++++++++++++++++---- 6 files changed, 45 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d3bd6b96b..894b6dcdbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7830,9 +7830,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -10775,14 +10775,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "webpki-root-certs 1.0.6", + "webpki-root-certs 1.0.10", ] [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] diff --git a/deny.toml b/deny.toml index 0744d0b92d..c41817824c 100644 --- a/deny.toml +++ b/deny.toml @@ -47,12 +47,9 @@ ignore = [ # "paste" is no longer maintained. "RUSTSEC-2024-0436", - # "bincode" is no longer maintained. - "RUSTSEC-2025-0141", - - # `rand` is unsound. Note that this has been patched in versions 0.10.1, 0.9.3 and 0.8.6, but we still - # have one place where `rand` 0.7.x is used - it's a dependency of `probabilistic-collections` v0.7.0. - # But we don't use any code from `probabilistic-collections` that actually creates an RNG, so our code - # is still sound. - "RUSTSEC-2026-0097" + # Bundled root certificates include distrusted emSign intermediates. The + # 1.x copy of `webpki-root-certs` was updated past the affected versions; + # the 0.26.x line (pulled in via `rustls-platform-verifier`) has no + # release with the root removed yet. + "RUSTSEC-2026-0383", ] diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 2d8eeeb043..6f06d188c0 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -1487,10 +1487,6 @@ criteria = "safe-to-deploy" version = "0.2.0" criteria = "safe-to-deploy" -[[exemptions.unicode-width]] -version = "0.2.2" -criteria = "safe-to-deploy" - [[exemptions.untrusted]] version = "0.9.0" criteria = "safe-to-deploy" diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock index 2e862a430a..fd907d50e1 100644 --- a/supply-chain/imports.lock +++ b/supply-chain/imports.lock @@ -1401,11 +1401,11 @@ user-login = "cpu" user-name = "Daniel McCarney" [[publisher.rustls-webpki]] -version = "0.103.15" -when = "2026-08-21" -user-id = 209038 -user-login = "cpu" -user-name = "Daniel McCarney" +version = "0.103.16" +when = "2026-10-20" +user-id = 2751 +user-login = "ctz" +user-name = "Joe Birr-Pixton" [[publisher.rustversion]] version = "1.0.22" @@ -1855,6 +1855,13 @@ user-id = 1139 user-login = "Manishearth" user-name = "Manish Goregaokar" +[[publisher.unicode-width]] +version = "0.2.2" +when = "2025-10-06" +user-id = 1139 +user-login = "Manishearth" +user-name = "Manish Goregaokar" + [[publisher.unicode-xid]] version = "0.2.6" when = "2024-09-19" @@ -1949,11 +1956,11 @@ user-login = "ctz" user-name = "Joe Birr-Pixton" [[publisher.webpki-root-certs]] -version = "1.0.6" -when = "2026-02-03" -user-id = 2751 -user-login = "ctz" -user-name = "Joe Birr-Pixton" +version = "1.0.10" +when = "2026-10-19" +user-id = 209038 +user-login = "cpu" +user-name = "Daniel McCarney" [[publisher.winapi-util]] version = "0.1.11" diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index 10c38360e6..0cf93aa68f 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -142,7 +142,7 @@ pub fn tool_definitions() -> Value { const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2024-11-05"]; /// The JSON-RPC envelope version this server speaks (MCP transport). -const JSONRPC_VERSION: &str = "2.0"; +pub const JSONRPC_VERSION: &str = "2.0"; pub async fn handle_message(message: &Value, wallet: &W) -> Option { // Anything that is not a single request/notification object (a batched diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index 6d51d2e79a..6cbb6cf6b4 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -19,7 +19,9 @@ use clap::Parser; use crypto::key::hdkd::u31::U31; use rpc::RpcAuthData; use serde_json::Value; -use wallet_mcp_server::{ReadonlyWallet, UtxoStateSelector, WithLockedSelector, handle_message}; +use wallet_mcp_server::{ + JSONRPC_VERSION, ReadonlyWallet, UtxoStateSelector, WithLockedSelector, handle_message, +}; use wallet_rpc_client::rpc_client::ClientWalletRpc; use wallet_rpc_client::wallet_rpc_traits::WalletInterface; use wallet_types::utxo_types::UtxoState; @@ -153,6 +155,10 @@ fn with_locked_filter(selector: WithLockedSelector) -> WithLocked { /// make the server buffer unbounded input. const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +/// Upper bound on bytes discarded while resyncing after an oversized +/// message without a newline; past this the session is torn down. +const RESYNC_DRAIN_BUDGET: usize = MAX_MESSAGE_BYTES * 4; + /// Discards bytes up to and including the next newline. Gives up on the /// stream if the line turns out to be endless. async fn skip_rest_of_line( @@ -171,7 +177,7 @@ async fn skip_rest_of_line( return Ok(()); } discarded += available.len(); - if discarded > MAX_MESSAGE_BYTES * 4 { + if discarded > RESYNC_DRAIN_BUDGET { return Err(std::io::Error::other( "peer streamed an oversized message without a newline", )); @@ -248,11 +254,18 @@ async fn serve(wallet: RpcWallet) -> Result<(), Box> { // Malformed bytes and oversized messages are failures of the single // message, not of the stream: answer with a parse error and keep // serving instead of tearing the session down. + // Recovery contract: every InvalidData from next_message has already + // consumed or discarded the offending line (or hit EOF), so the next + // iteration never re-reads the same bytes. The one error that must + // NOT be recovered from — skip_rest_of_line's endless-line give-up — + // uses io::Error::other on purpose; reclassifying it to InvalidData, + // or adding an InvalidData path that does not resync, would spin + // this loop forever. let line = match next_message(&mut reader, &mut message_buffer).await { Ok(line) => line, Err(err) if err.kind() == std::io::ErrorKind::InvalidData => { let encoded = serde_json::to_string(&serde_json::json!({ - "jsonrpc": "2.0", + "jsonrpc": JSONRPC_VERSION, "id": null, "error": { "code": -32700, @@ -275,7 +288,7 @@ async fn serve(wallet: RpcWallet) -> Result<(), Box> { let response = match serde_json::from_str::(trimmed) { Ok(message) => handle_message(&message, &wallet).await, Err(err) => Some(serde_json::json!({ - "jsonrpc": "2.0", + "jsonrpc": JSONRPC_VERSION, "id": null, "error": { "code": -32700, "message": format!("Parse error: {err}") } })), From 066840f9b97bbeebac2a840ffb5486b498a11b78 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 08:39:26 +0200 Subject: [PATCH 40/52] fix(wallet): pin the null-id behavior and warn on password files --- wallet/mcp-server/src/lib.rs | 18 +++++++++++-- .../src/command_handler/mod.rs | 27 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index 0cf93aa68f..0b7d9fb891 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -157,8 +157,9 @@ pub async fn handle_message(message: &Value, wallet: &W) -> O )); } let id = message.get("id").cloned(); - // Notifications carry no id and never get a response. - id.as_ref()?; + // Notifications carry no id member and never get a response. A request + // with an explicit null id still gets an answer, echoing the null back. + message.get("id")?; if message.get("jsonrpc").and_then(Value::as_str) != Some(JSONRPC_VERSION) { // JSON-RPC 2.0 requires the version member on every request. return Some(error_response( @@ -529,6 +530,19 @@ mod tests { assert_eq!(response["error"]["code"], -32600); } + #[test] + fn null_id_requests_are_answered_with_a_null_id() { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let message = json!({ "jsonrpc": "2.0", "id": null, "method": "ping" }); + let response = rt + .block_on(handle_message(&message, &StubWallet)) + .expect("an explicit null id is a request, not a notification"); + // Indexing with [] maps a missing member to Null, so use get() to + // prove the id member is present and null. + assert_eq!(response.get("id"), Some(&Value::Null)); + assert!(response.get("result").is_some()); + } + #[test] fn batched_and_non_object_requests_are_rejected_with_an_error() { let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 329d6e349c..56cee27f1f 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -489,7 +489,32 @@ where Some(path) => { let path = path.to_owned(); let read_path = path.clone(); - let read = move || std::fs::read_to_string(read_path); + let read = move || -> std::io::Result { + use std::io::Read; + + // Open first, then check the mode of the opened handle: a + // separate metadata() call would leave a window where the + // check and the read see different files. + let mut file = std::fs::File::open(&read_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = file.metadata()?.permissions().mode(); + if mode & 0o077 != 0 { + // Warn instead of failing: the wallet must stay + // usable, but a group/other-accessible password + // file is almost always a mistake. + log::warn!( + "The password file {} is accessible by group or others; \ + restrict it with chmod 600", + read_path + ); + } + } + let mut password = String::new(); + file.read_to_string(&mut password)?; + Ok(password) + }; label_password_errors( tokio::task::spawn_blocking(read), format!("Failed to read the password from {path}"), From e9b103be04ae5dfa019c1472e40c28391dbafac0 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 16:43:20 +0200 Subject: [PATCH 41/52] fix(wallet): fail safe on shutdown and bad password files --- .../src/command_handler/mod.rs | 391 +++++++++++------- .../src/repl/interactive/mod.rs | 4 +- wallet/wallet-cli-lib/src/repl/mod.rs | 4 +- .../src/repl/non_interactive/mod.rs | 9 +- 4 files changed, 264 insertions(+), 144 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 56cee27f1f..705e6b601b 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -92,6 +92,183 @@ enum PromptMode { Enter, } +/// Read a password from `--password-file`, where `-` means standard input. +/// When no source is given and standard input is a terminal, the password +/// is read with a hidden prompt on the controlling terminal instead; in +/// [`PromptMode::Create`] mode the prompt asks for the password twice. The +/// trailing line terminator is stripped; any other whitespace is kept. +async fn read_password_from_source( + source: Option<&str>, + prompt_mode: PromptMode, +) -> Result> { + // The password can also come from a hidden terminal prompt; see the + // function documentation above. + + // Reads a password from the terminal with echo disabled. In `Create` + // mode (password creation) the password must be repeated; a typo would + // otherwise lock the wallet with an unknown password. + // + // The prompt is only offered when standard input is a terminal. The + // hidden prompt itself reads from the controlling terminal, but gating + // on stdin keeps piped automation (commands files, `--exec`) failing + // fast with an explicit error instead of hanging on an unseen prompt. + fn prompt_password_hidden(prompt_mode: PromptMode) -> std::io::Result { + // The prompt and the hidden read go to the controlling terminal + // (/dev/tty), so redirected standard error does not matter. + let password = rpassword::prompt_password(if prompt_mode == PromptMode::Create { + "New password: " + } else { + "Password: " + })?; + if prompt_mode == PromptMode::Create { + let confirmed = rpassword::prompt_password("Confirm password: ")?; + if password != confirmed { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the two passwords do not match", + )); + } + } + Ok(password) + } + + fn read_password_from_stdin() -> std::io::Result { + let mut password = String::new(); + let bytes_read = std::io::stdin().read_line(&mut password)?; + if bytes_read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "no password was provided on standard input", + )); + } + Ok(password) + } + + // Joins a blocking password read and labels the two failure layers + // (task join vs read) distinctly. + async fn label_password_errors( + fut: impl std::future::Future< + Output = Result, tokio::task::JoinError>, + >, + label: String, + ) -> Result> { + fut.await + .map_err(|err| { + WalletCliCommandError::InvalidInput(format!( + "Internal error - failed to join the password read task: {err}" + )) + })? + .map_err(|err| WalletCliCommandError::InvalidInput(format!("{label}: {err}"))) + } + + // Trailing line terminators (`\n` or `\r\n`, however many) are + // stripped; any other whitespace is kept. + fn strip_line_terminator(password: &str) -> &str { + // Files edited by hand often end in more than one line terminator + // (a trailing blank line is a common editor artifact); every + // trailing terminator is stripped so the password stays exactly + // what the file's last non-empty line holds. + password.trim_end_matches(['\r', '\n']) + } + + let password = match source { + None => { + // The hidden prompt goes to the controlling terminal + // (/dev/tty); scripted callers get an error instead. + if std::io::stdin().is_terminal() { + label_password_errors( + tokio::task::spawn_blocking(move || prompt_password_hidden(prompt_mode)), + "Failed to read the password from the terminal".to_owned(), + ) + .await? + } else { + return Err(WalletCliCommandError::InvalidInput( + "a password is required: pass --password-file (or \ + --password-file - for standard input); interactive input \ + requires a terminal on standard input" + .to_owned(), + )); + } + } + Some("-") => { + label_password_errors( + tokio::task::spawn_blocking(read_password_from_stdin), + "Failed to read the password from standard input".to_owned(), + ) + .await? + } + Some(path) => { + let read_path = path.to_owned(); + let read = move || -> std::io::Result { + use std::io::Read; + + // Open first, then check the mode of the opened handle: a + // separate metadata() call would leave a window where the + // check and the read see different files. + let file = std::fs::File::open(&read_path)?; + let metadata = file.metadata()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = metadata.permissions().mode(); + if mode & 0o077 != 0 { + // Warn instead of failing: the wallet must stay + // usable, but a group/other-accessible password + // file is almost always a mistake. + log::warn!( + "The password file {} is accessible by group or others; \ + restrict it with chmod 600", + read_path + ); + } + } + // A password file that is not tiny is almost certainly the + // wrong file; refuse rather than encrypt the wallet with + // an unintended password it could never be unlocked with. + const MAX_PASSWORD_FILE_BYTES: u64 = 4 * 1024; + if metadata.len() > MAX_PASSWORD_FILE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the password file is larger than 4 KiB; this does not \ + look like a password file", + )); + } + let mut password = String::new(); + file.take(MAX_PASSWORD_FILE_BYTES).read_to_string(&mut password)?; + if password.contains('\0') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the password file contains a null byte; this does not \ + look like a password file", + )); + } + Ok(password) + }; + label_password_errors( + tokio::task::spawn_blocking(read), + format!("Failed to read the password from {path}"), + ) + .await? + } + }; + let password = strip_line_terminator(&password); + if password.is_empty() { + return Err(WalletCliCommandError::InvalidInput( + "the password is empty; expected a non-empty password".to_owned(), + )); + } + if password.contains(['\r', '\n']) { + // An interior line terminator means the file holds more than one + // line; using only the first would silently lock the wallet. + return Err(WalletCliCommandError::InvalidInput( + "the password file contains more than one line; expected a \ + single-line password file" + .to_owned(), + )); + } + Ok(password.to_owned()) +} + pub struct CommandHandler { config: ControllerConfig, @@ -386,145 +563,6 @@ where } } - /// Read a password from `--password-file`, where `-` means standard input. - /// When no source is given and standard input is a terminal, the password - /// is read with a hidden prompt on the controlling terminal instead; in - /// [`PromptMode::Create`] mode the prompt asks for the password twice. The trailing line - /// terminator is stripped; any other whitespace is kept. - async fn read_password_from_source( - source: Option<&str>, - prompt_mode: PromptMode, - ) -> Result> { - // The password can also come from a hidden terminal prompt; see the - // function documentation above. - - // Reads a password from the terminal with echo disabled. In `Create` - // mode (password creation) the password must be repeated; a typo would - // otherwise lock the wallet with an unknown password. - fn prompt_password_hidden(prompt_mode: PromptMode) -> std::io::Result { - // The prompt and the hidden read go to the controlling terminal - // (/dev/tty), so redirected standard error does not matter. - let password = rpassword::prompt_password(if prompt_mode == PromptMode::Create { - "New password: " - } else { - "Password: " - })?; - if prompt_mode == PromptMode::Create { - let confirmed = rpassword::prompt_password("Confirm password: ")?; - if password != confirmed { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "the two passwords do not match", - )); - } - } - Ok(password) - } - - fn read_password_from_stdin() -> std::io::Result { - let mut password = String::new(); - let bytes_read = std::io::stdin().read_line(&mut password)?; - if bytes_read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "no password was provided on standard input", - )); - } - Ok(password) - } - - // Joins a blocking password read and labels the two failure layers - // (task join vs read) distinctly. - async fn label_password_errors( - fut: impl std::future::Future< - Output = Result, tokio::task::JoinError>, - >, - label: String, - ) -> Result> { - fut.await - .map_err(|err| { - WalletCliCommandError::InvalidInput(format!( - "Internal error - failed to join the password read task: {err}" - )) - })? - .map_err(|err| WalletCliCommandError::InvalidInput(format!("{label}: {err}"))) - } - - // Trailing line terminators (`\n` or `\r\n`, however many) are - // stripped; any other whitespace is kept. - fn strip_line_terminator(password: &str) -> &str { - // Files edited by hand often end in more than one line terminator - // (a trailing blank line is a common editor artifact); every - // trailing terminator is stripped so the password stays exactly - // what the file's last non-empty line holds. - password.trim_end_matches(['\r', '\n']) - } - - let password = match source { - None => { - // The hidden prompt goes to the controlling terminal - // (/dev/tty); scripted callers get an error instead. - if std::io::stdin().is_terminal() { - label_password_errors( - tokio::task::spawn_blocking(move || prompt_password_hidden(prompt_mode)), - "Failed to read the password from the terminal".to_owned(), - ) - .await? - } else { - return Err(WalletCliCommandError::InvalidInput( - "a password is required: pass --password-file (or \ - --password-file - for standard input); interactive input \ - requires a terminal on standard input" - .to_owned(), - )); - } - } - Some("-") => { - label_password_errors( - tokio::task::spawn_blocking(read_password_from_stdin), - "Failed to read the password from standard input".to_owned(), - ) - .await? - } - Some(path) => { - let path = path.to_owned(); - let read_path = path.clone(); - let read = move || -> std::io::Result { - use std::io::Read; - - // Open first, then check the mode of the opened handle: a - // separate metadata() call would leave a window where the - // check and the read see different files. - let mut file = std::fs::File::open(&read_path)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = file.metadata()?.permissions().mode(); - if mode & 0o077 != 0 { - // Warn instead of failing: the wallet must stay - // usable, but a group/other-accessible password - // file is almost always a mistake. - log::warn!( - "The password file {} is accessible by group or others; \ - restrict it with chmod 600", - read_path - ); - } - } - let mut password = String::new(); - file.read_to_string(&mut password)?; - Ok(password) - }; - label_password_errors( - tokio::task::spawn_blocking(read), - format!("Failed to read the password from {path}"), - ) - .await? - } - }; - Ok(strip_line_terminator(&password).to_owned()) - } - async fn handle_cold_wallet_command( &mut self, command: ColdWalletCommand, @@ -1106,7 +1144,7 @@ where .await; } WalletGroupSubCommand::Encrypt { password_file } => { - let password = Self::read_password_from_source::( + let password = read_password_from_source::( password_file.as_deref(), PromptMode::Create, ) @@ -1119,7 +1157,7 @@ where .await; } WalletGroupSubCommand::Unlock { password_file } => { - let password = Self::read_password_from_source::( + let password = read_password_from_source::( password_file.as_deref(), PromptMode::Enter, ) @@ -4109,3 +4147,74 @@ mod nested_translation_tests { } } } + +#[cfg(test)] +mod password_source_tests { + use super::*; + use node_comm::node_traits::MockNodeInterface; + + async fn read_from_file( + content: &[u8], + ) -> Result> { + let mut path = std::env::temp_dir(); + path.push(format!( + "ml-cli-password-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + { + struct TempFile(std::path::PathBuf); + impl Drop for TempFile { + fn drop(&mut self) { + std::fs::remove_file(&self.0).ok(); + } + } + let temp = TempFile(path.clone()); + std::fs::write(&temp.0, content).expect("temp file must write"); + let result = read_password_from_source::( + Some(path.to_str().expect("utf-8 path")), + PromptMode::Enter, + ) + .await; + drop(temp); + result + } + } + + #[tokio::test] + async fn single_line_passwords_are_read_with_the_terminator_stripped() { + let password = read_from_file(b"hunter2\n").await.expect("must read"); + assert_eq!(password, "hunter2"); + } + + #[tokio::test] + async fn multi_line_password_files_are_rejected() { + let err = read_from_file(b"first\nsecond\n") + .await + .expect_err("multi-line files are rejected"); + assert!( + format!("{err}").contains("more than one line"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn password_files_with_null_bytes_are_rejected() { + let err = read_from_file(b"pass\0word").await.expect_err("null bytes are rejected"); + assert!( + format!("{err}").contains("null byte"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn oversized_password_files_are_rejected() { + let err = read_from_file(&[b'x'; 5 * 1024]) + .await + .expect_err("oversized files are rejected"); + assert!( + format!("{err}").contains("larger than 4 KiB"), + "unexpected error: {err}" + ); + } +} diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index f9eca85fc3..4b4058d04a 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -236,7 +236,9 @@ pub fn run( // first wait for the results of any startup command before processing the rest for res_rx in startup_command_futures { - let res = res_rx.blocking_recv().expect("Channel must be open"); + let res = res_rx.blocking_recv().map_err(|_recv_error| { + WalletCliError::::EventLoopShutdown("startup commands did not finish".to_owned()) + })?; match handle_response( res.map(Some), &mut console, diff --git a/wallet/wallet-cli-lib/src/repl/mod.rs b/wallet/wallet-cli-lib/src/repl/mod.rs index 0c6ee9e363..160ca91b3a 100644 --- a/wallet/wallet-cli-lib/src/repl/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/mod.rs @@ -50,7 +50,9 @@ fn run_command_blocking( return Ok(ConsoleCommand::Exit); } - res_rx.blocking_recv().expect("Channel must be open") + res_rx.blocking_recv().map_err(|_recv_error| { + WalletCliError::::EventLoopShutdown("while waiting for the command result".to_owned()) + })? } #[cfg(test)] diff --git a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs index 19ad3b440c..042506db7f 100644 --- a/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/non_interactive/mod.rs @@ -93,7 +93,14 @@ pub fn run( startup_command_futures: Vec>>>, ) -> Result<(), WalletCliError> { for res_rx in startup_command_futures { - let res = res_rx.blocking_recv().expect("Channel must be open")?; + let res = match res_rx.blocking_recv() { + Ok(inner) => inner?, + Err(_recv_error) => { + return Err(WalletCliError::::EventLoopShutdown( + "startup commands did not finish".to_owned(), + )); + } + }; let line_out = to_line_output(res); if let Some(value) = handle_response(line_out, &mut output, true) { return value; From 00aa45bd85f604e2cedce9883b18dc8134a0533b Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 17:29:50 +0200 Subject: [PATCH 42/52] fix(wallet): read multi-flag best block from one snapshot --- .../src/command_handler/mod.rs | 77 +++++++++++++------ wallet/wallet-cli-lib/src/errors.rs | 3 + .../src/repl/interactive/mod.rs | 13 ++-- .../repl/interactive/wallet_completions.rs | 12 ++- 4 files changed, 72 insertions(+), 33 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 705e6b601b..d93db52c5e 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -1295,30 +1295,61 @@ where height, timestamp, } => { - // The legacy RPCs are reused per value so error behavior (including on - // cold wallets) matches the flat commands. Values are fetched back to - // back, so they can straddle a block boundary exactly like issuing the - // flat commands one after another. No flags means all three, mirroring - // the formatter's show-all rule. + // Single-flag runs reuse the legacy RPCs per value so error behavior + // (including on cold wallets) matches the flat commands. When more + // than one value is requested, everything is read from one + // chainstate snapshot so the printed tuple describes a real block + // instead of values straddling a block boundary. No flags means all + // three, mirroring the formatter's show-all rule. let wallet = self.wallet().await?; - let show_all = !id && !height && !timestamp; - let best_block_id = if id || show_all { - Some(wallet.node_best_block_id().await?.hex_encode()) - } else { - None - }; - let best_block_height = if height || show_all { - Some(wallet.node_best_block_height().await?.to_string()) - } else { - None - }; - let best_block_timestamp = if timestamp || show_all { - Some(format_block_timestamp( - wallet.chainstate_info().await?.best_block_timestamp, - )) - } else { - None - }; + let requested: usize = [id, height, timestamp].iter().filter(|flag| **flag).count(); + let show_all = requested == 0; + let consistent_snapshot = requested > 1 || show_all; + // TODO(test): cover the consistent-snapshot path with a mock node: + // - per-value RPCs error while chainstate_info succeeds (cold wallet) + // => the command must error, never print the fabricated genesis values; + // - multi-flag output must equal the values from one chainstate_info call. + let (best_block_id, best_block_height, best_block_timestamp) = + if consistent_snapshot { + // A cold wallet cannot serve a combined snapshot: + // `chainstate_info()` fabricates genesis values there while + // the per-value RPCs fail. Probe the node first so the error + // behavior matches the flat commands, then read every + // displayed value from one snapshot. + if id || show_all { + wallet.node_best_block_id().await?; + } + if height || show_all { + wallet.node_best_block_height().await?; + } + let info = wallet.chainstate_info().await?; + ( + (id || show_all).then(|| info.best_block_id.hex_encode()), + (height || show_all).then(|| info.best_block_height.to_string()), + (timestamp || show_all) + .then(|| format_block_timestamp(info.best_block_timestamp)), + ) + } else { + ( + if id { + Some(wallet.node_best_block_id().await?.hex_encode()) + } else { + None + }, + if height { + Some(wallet.node_best_block_height().await?.to_string()) + } else { + None + }, + if timestamp { + Some(format_block_timestamp( + wallet.chainstate_info().await?.best_block_timestamp, + )) + } else { + None + }, + ) + }; Ok(ConsoleCommand::Print(format_best_block( best_block_id, best_block_height, diff --git a/wallet/wallet-cli-lib/src/errors.rs b/wallet/wallet-cli-lib/src/errors.rs index 1832b547d1..d7f0946a8a 100644 --- a/wallet/wallet-cli-lib/src/errors.rs +++ b/wallet/wallet-cli-lib/src/errors.rs @@ -39,6 +39,9 @@ pub enum WalletCliError { #[error("Confirmation required: {0}")] ConfirmationRequired(String), + #[error("Terminal I/O error: {0}")] + TerminalIo(#[from] std::io::Error), + #[error("The wallet event loop shut down before the command finished: {0}")] EventLoopShutdown(String), diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 4b4058d04a..7e28a9fdb8 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -116,9 +116,9 @@ fn run_wizard_for_line(line: &str) -> Result, W Ok(None) } Err(WizardError::InvalidInput(message)) => Err(WalletCliError::InvalidInput(message)), - Err(WizardError::Io(err)) => Err(WalletCliError::InvalidInput(format!( - "the wizard could not read the answer: {err}" - ))), + // The original io::Error (e.g. "prompt failed: not a terminal") is kept so + // non-TTY contexts get a precise diagnosis instead of a generic message. + Err(WizardError::Io(err)) => Err(WalletCliError::TerminalIo(err)), } } @@ -188,9 +188,10 @@ fn confirm_or_abort( .with_prompt("Proceed [y/N]?") .default(false) .interact() - .map_err(|err| { - WalletCliError::InvalidInput(format!("the confirmation could not be read: {err}")) - })?; + // dialoguer errors are io errors underneath; keep them typed so + // non-TTY contexts get a precise diagnosis instead of a generic + // "invalid input" message. + .map_err(|err| WalletCliError::TerminalIo(std::io::Error::other(err.to_string())))?; if !confirmed { println!("Aborted."); return Ok(false); diff --git a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs index b4f6b98c5f..f5237b666b 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs @@ -44,10 +44,14 @@ impl WalletCompletions { // does). Walking an unbuilt tree silently yields no candidates. // `build()` is what injects the help flag, so its presence is the // cheapest proxy for "this tree was built". - debug_assert!( - root.get_arguments().any(|arg| arg.get_long() == Some("help")), - "a shared clap Command must already be built" - ); + if !root.get_arguments().any(|arg| arg.get_long() == Some("help")) { + debug_assert!(false, "a shared clap Command must already be built"); + // The assertion above is compiled out in release builds, so also + // leave a trace: the completer will produce no candidates. + logging::log::warn!( + "WalletCompletions received an unbuilt clap command tree; completions will be empty" + ); + } } Self { root } } From f2454aec87b1b346c3f6dbe8f4098c30dc2bde4c Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 18:28:13 +0200 Subject: [PATCH 43/52] fix(wallet): hoist the password file size cap above its use --- wallet/wallet-cli-commands/src/command_handler/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index d93db52c5e..eed266103b 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -101,6 +101,8 @@ async fn read_password_from_source( source: Option<&str>, prompt_mode: PromptMode, ) -> Result> { + // A password file that is not tiny is almost certainly the wrong file. + const MAX_PASSWORD_FILE_BYTES: u64 = 4 * 1024; // The password can also come from a hidden terminal prompt; see the // function documentation above. @@ -225,7 +227,6 @@ async fn read_password_from_source( // A password file that is not tiny is almost certainly the // wrong file; refuse rather than encrypt the wallet with // an unintended password it could never be unlocked with. - const MAX_PASSWORD_FILE_BYTES: u64 = 4 * 1024; if metadata.len() > MAX_PASSWORD_FILE_BYTES { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, From ebe5b983981543f97f60a7c916e315d49f8eda73 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 18:30:27 +0200 Subject: [PATCH 44/52] refactor(wallet): resume the grammar thread panic unchanged --- wallet/wallet-cli-commands/src/lib.rs | 11 +++-------- wallet/wallet-cli-lib/src/repl/exec.rs | 7 +++++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index a44104fd71..e0820aa698 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -2568,14 +2568,9 @@ pub fn run_with_large_stack(task: impl FnOnce() -> T + Send + .spawn(task) .expect("failed to spawn the grammar thread") .join() - .unwrap_or_else(|payload| { - let message = payload - .downcast_ref::<&str>() - .copied() - .or_else(|| payload.downcast_ref::().map(String::as_str)) - .unwrap_or(""); - panic!("the grammar thread panicked: {message}") - }) + // Resume the original panic so its payload, location, and backtrace + // are preserved instead of being flattened into a new panic here. + .unwrap_or_else(|payload| std::panic::resume_unwind(payload)) } /// Build the REPL command tree on the calling thread. diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index f7d34fe874..3fc2b1a373 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -74,6 +74,13 @@ fn process_line( /// Runs one command line and renders its result. Errors propagate to the /// caller, which maps them to a non-zero exit status. +/// +/// `--output json` contract: +/// - migrated commands that emit structured data print their raw JSON value +/// (or `{"text": ..., "value": ...}` when they also carry a text line); +/// - commands that emit plain text print `{"output": ""}`; +/// - failures are reported as plain text on standard error with a non-zero +/// exit status and no JSON envelope. pub fn run( line: String, output_format: CliOutputFormat, From 2758abbbba5bf12dbe641b129701d3f27c7b9d2f Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 19:35:42 +0200 Subject: [PATCH 45/52] fix(wallet): fail closed when a password file races its size check --- .../src/command_handler/mod.rs | 23 +++++++++++++++---- .../src/repl/interactive/mod.rs | 16 +++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index eed266103b..634e6e7d8e 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -230,12 +230,26 @@ async fn read_password_from_source( if metadata.len() > MAX_PASSWORD_FILE_BYTES { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "the password file is larger than 4 KiB; this does not \ - look like a password file", + format!( + "the password file is larger than {MAX_PASSWORD_FILE_BYTES} \ + bytes; this does not look like a password file" + ), )); } + // Read one byte past the cap so a file that grows (or is + // swapped) after the metadata check is detected here instead + // of being silently truncated into a prefix password. let mut password = String::new(); - file.take(MAX_PASSWORD_FILE_BYTES).read_to_string(&mut password)?; + file.take(MAX_PASSWORD_FILE_BYTES + 1).read_to_string(&mut password)?; + if password.len() as u64 > MAX_PASSWORD_FILE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "the password file is larger than {MAX_PASSWORD_FILE_BYTES} \ + bytes; this does not look like a password file" + ), + )); + } if password.contains('\0') { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -2911,6 +2925,7 @@ fn is_nested_wallet_command(command: &WalletCommand) -> bool { | WalletCommand::Transaction { .. } | WalletCommand::Node { .. } | WalletCommand::Peer { .. } + | WalletCommand::Config { .. } ) } @@ -4245,7 +4260,7 @@ mod password_source_tests { .await .expect_err("oversized files are rejected"); assert!( - format!("{err}").contains("larger than 4 KiB"), + format!("{err}").contains("larger than 4096 bytes"), "unexpected error: {err}" ); } diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 7e28a9fdb8..aa76c20bd1 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -184,14 +184,20 @@ fn confirm_or_abort( } println!(); println!("{question}"); - let confirmed = dialoguer::Confirm::new() + let confirmed = match dialoguer::Confirm::new() .with_prompt("Proceed [y/N]?") .default(false) .interact() - // dialoguer errors are io errors underneath; keep them typed so - // non-TTY contexts get a precise diagnosis instead of a generic - // "invalid input" message. - .map_err(|err| WalletCliError::TerminalIo(std::io::Error::other(err.to_string())))?; + { + Ok(confirmed) => confirmed, + // A prompt that cannot be shown (for example piped input driving + // the REPL) cannot receive consent; treat that as a decline for + // this command instead of tearing down the whole session. + Err(dialoguer::Error::IO(err)) => { + println!("The confirmation prompt is unavailable ({err}); treating as declined."); + false + } + }; if !confirmed { println!("Aborted."); return Ok(false); From fe60d67c9bc2223ca73a95e510efe7948591861b Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 19:52:49 +0200 Subject: [PATCH 46/52] refactor(wallet): share the password size error text --- .../src/command_handler/mod.rs | 17 +++++++---------- .../wallet-cli-lib/src/repl/interactive/mod.rs | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 634e6e7d8e..938c597033 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -227,14 +227,17 @@ async fn read_password_from_source( // A password file that is not tiny is almost certainly the // wrong file; refuse rather than encrypt the wallet with // an unintended password it could never be unlocked with. - if metadata.len() > MAX_PASSWORD_FILE_BYTES { - return Err(std::io::Error::new( + let too_large = || { + std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "the password file is larger than {MAX_PASSWORD_FILE_BYTES} \ bytes; this does not look like a password file" ), - )); + ) + }; + if metadata.len() > MAX_PASSWORD_FILE_BYTES { + return Err(too_large()); } // Read one byte past the cap so a file that grows (or is // swapped) after the metadata check is detected here instead @@ -242,13 +245,7 @@ async fn read_password_from_source( let mut password = String::new(); file.take(MAX_PASSWORD_FILE_BYTES + 1).read_to_string(&mut password)?; if password.len() as u64 > MAX_PASSWORD_FILE_BYTES { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "the password file is larger than {MAX_PASSWORD_FILE_BYTES} \ - bytes; this does not look like a password file" - ), - )); + return Err(too_large()); } if password.contains('\0') { return Err(std::io::Error::new( diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index aa76c20bd1..91c1ce24e2 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -193,7 +193,7 @@ fn confirm_or_abort( // A prompt that cannot be shown (for example piped input driving // the REPL) cannot receive consent; treat that as a decline for // this command instead of tearing down the whole session. - Err(dialoguer::Error::IO(err)) => { + Err(err) => { println!("The confirmation prompt is unavailable ({err}); treating as declined."); false } From 5db1d818fb3e6f49064822d5350e34c4d7d0ad74 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 20:36:57 +0200 Subject: [PATCH 47/52] fix(wallet): prompt before generating blocks --- wallet/wallet-cli-commands/src/confirm.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/wallet/wallet-cli-commands/src/confirm.rs b/wallet/wallet-cli-commands/src/confirm.rs index e1c463a32c..54f9f1963a 100644 --- a/wallet/wallet-cli-commands/src/confirm.rs +++ b/wallet/wallet-cli-commands/src/confirm.rs @@ -240,6 +240,16 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { Some("Add a standalone multisig address to this wallet?".to_owned()) } WalletCommand::SubmitBlock { .. } => Some("Submit the given block to the node?".to_owned()), + WalletCommand::GenerateBlock { transactions } => Some(format!( + "Generate and submit a block with {} transaction(s)?", + transactions.len() + )), + WalletCommand::GenerateBlocks { block_count } => { + Some(format!("Generate and submit {block_count} block(s)?")) + } + // Banning and reserving peers are reversible, node-local admin + // actions (their counterparts unban/undiscourage/remove undo them), + // so they stay prompt-free on purpose. WalletCommand::NodeShutdown => Some("Shut down the connected node?".to_owned()), WalletCommand::NodeEnableNetworking { enable } => Some(format!( "Set P2P networking on the node to {}?", @@ -293,8 +303,6 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { | WalletCommand::BestBlockTimestamp | WalletCommand::BlockId { .. } | WalletCommand::GetBlock { .. } - | WalletCommand::GenerateBlock { .. } - | WalletCommand::GenerateBlocks { .. } | WalletCommand::FindTimestampsForStaking { .. } | WalletCommand::GetBlockIdsAsCheckpoints { .. } | WalletCommand::ListPendingTransactions @@ -409,6 +417,14 @@ mod tests { Some("Set P2P networking on the node to disabled?") ); assert!(prompt("account-select 3").is_some()); + // Hidden dev commands that write to the chain ask too. + assert_eq!( + prompt("node-generate-blocks 5").as_deref(), + Some("Generate and submit 5 block(s)?") + ); + // `node-generate-block ` also prompts, but parsing it needs a + // fully encoded signed-transaction fixture, so only the block-count + // variant is pinned here. assert!(prompt( "standalone-add-private-key-from-hex 000000000000000000000000000000000000000000000000000000000000000001" ) From 831e2efa40f67ccde82489f4e159583443c42eb5 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 20:59:05 +0200 Subject: [PATCH 48/52] fix(wallet): close the password file size race and wizard cancel paths --- Cargo.lock | 1 + .../test_framework/wallet_cli_controller.py | 8 +++ wallet/mcp-server/src/main.rs | 55 +++++++++----- wallet/wallet-cli-commands/Cargo.toml | 1 + .../src/command_handler/mod.rs | 71 ++++++++++++------- wallet/wallet-cli-commands/src/confirm.rs | 51 ++++++++++--- .../wallet-cli-commands/src/helper_types.rs | 42 ++++++++++- wallet/wallet-cli-lib/src/repl/exec.rs | 20 +++++- .../src/repl/interactive/mod.rs | 56 +++++++++------ .../tests/cli_test_framework.rs | 15 +++- 10 files changed, 243 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 894b6dcdbd..7d425d6098 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10148,6 +10148,7 @@ dependencies = [ "wallet-storage", "wallet-test-node", "wallet-types", + "zeroize", ] [[package]] diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index e055f2bc05..749daac5a8 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -555,6 +555,14 @@ async def get_balance(self, with_locked: str = 'unlocked', utxo_states: List[str async def list_pending_transactions(self) -> List[str]: output = await self._write_command("transaction-list-pending\n") + # Non-interactive mode prints only command results (never the line + # echo), so scope the ids to the section after the last + # "Pending transactions" header; stray 64-hex lines from earlier + # output are then ignored. + headers = [m.end() for m in re.finditer(r'^Pending transactions\s*$', output, re.MULTILINE)] + if headers: + tail = output[headers[-1]:] + return re.findall(r'^\s*([0-9a-f]{64})\s*$', tail, re.MULTILINE) return re.findall(r'^\s*([0-9a-f]{64})\s*$', output, re.MULTILINE) async def list_transactions_by_address(self, address: Optional[str] = None, limit: int = 100) -> List[str]: diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index 6cbb6cf6b4..942a0f07e6 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -69,38 +69,48 @@ struct CliArgs { } impl CliArgs { - fn auth_data(&self) -> RpcAuthData { - // The auth group is required, so exactly one branch applies. + fn auth_data(&self) -> Result { + // The auth group is required, so exactly one branch applies; a new + // group member missing here fails with a message instead of a panic. if let Some(cookie_file_path) = self.rpc_cookie_file.clone() { - RpcAuthData::Cookie { cookie_file_path } + Ok(RpcAuthData::Cookie { cookie_file_path }) } else if let (Some(username), Some(password)) = (self.rpc_username.clone(), self.rpc_password.clone()) { - RpcAuthData::Basic { username, password } + Ok(RpcAuthData::Basic { username, password }) } else if self.rpc_no_auth { - RpcAuthData::None + Ok(RpcAuthData::None) } else { - unreachable!("clap's `auth` arg group guarantees exactly one auth source") + Err("exactly one of --rpc-cookie-file, --rpc-username with --rpc-password, or --rpc-no-auth must be provided" + .to_owned()) } } } +/// Labels wallet RPC failures at the adapter boundary so tool results name +/// their provenance instead of surfacing a bare daemon message. +fn rpc_error(err: impl std::fmt::Display) -> String { + format!("wallet RPC failed: {err}") +} + /// Adapter that exposes only the read-only operations of the daemon client. struct RpcWallet(ClientWalletRpc); impl ReadonlyWallet for RpcWallet { async fn version(&self) -> Result { - WalletInterface::version(&self.0).await.map_err(|err| err.to_string()) + WalletInterface::version(&self.0).await.map_err(rpc_error) } async fn wallet_info(&self) -> Result { - let info = WalletInterface::wallet_info(&self.0).await.map_err(|err| err.to_string())?; - serde_json::to_value(&info).map_err(|err| err.to_string()) + let info = WalletInterface::wallet_info(&self.0).await.map_err(rpc_error)?; + serde_json::to_value(&info) + .map_err(|err| format!("failed to serialize the wallet info: {err}")) } async fn best_block(&self) -> Result { - let block = WalletInterface::best_block(&self.0).await.map_err(|err| err.to_string())?; - serde_json::to_value(&block).map_err(|err| err.to_string()) + let block = WalletInterface::best_block(&self.0).await.map_err(rpc_error)?; + serde_json::to_value(&block) + .map_err(|err| format!("failed to serialize the best block: {err}")) } async fn balance( @@ -119,8 +129,9 @@ impl ReadonlyWallet for RpcWallet { with_locked_filter(with_locked), ) .await - .map_err(|err| err.to_string())?; - serde_json::to_value(&balances).map_err(|err| err.to_string()) + .map_err(rpc_error)?; + serde_json::to_value(&balances) + .map_err(|err| format!("failed to serialize the balance: {err}")) } async fn standalone_addresses(&self, account_index: u32) -> Result { @@ -128,8 +139,9 @@ impl ReadonlyWallet for RpcWallet { .ok_or_else(|| format!("Invalid account index: {account_index}"))?; let addresses = WalletInterface::get_standalone_addresses(&self.0, account_index) .await - .map_err(|err| err.to_string())?; - serde_json::to_value(&addresses).map_err(|err| err.to_string()) + .map_err(rpc_error)?; + serde_json::to_value(&addresses) + .map_err(|err| format!("failed to serialize the addresses: {err}")) } } @@ -233,6 +245,9 @@ async fn next_message( let len = available.len(); reader.consume(len); } + // The loop's `buffer.len() + available.len() > MAX_MESSAGE_BYTES` guard + // runs before every extend, so a trailing message at EOF is already + // bounded; no separate check is needed here. if buffer.is_empty() { return Ok(None); } @@ -306,8 +321,14 @@ async fn serve(wallet: RpcWallet) -> Result<(), Box> { #[tokio::main] async fn main() { let args = CliArgs::parse(); - let wallet = match ClientWalletRpc::new(args.wallet_rpc_address.clone(), args.auth_data()).await - { + let auth_data = match args.auth_data() { + Ok(auth_data) => auth_data, + Err(err) => { + eprintln!("{err}"); + std::process::exit(2); + } + }; + let wallet = match ClientWalletRpc::new(args.wallet_rpc_address.clone(), auth_data).await { Ok(client) => RpcWallet(client), Err(err) => { eprintln!("Failed to connect to the wallet RPC daemon: {err}"); diff --git a/wallet/wallet-cli-commands/Cargo.toml b/wallet/wallet-cli-commands/Cargo.toml index 4d1e42cb33..3b15d57e20 100644 --- a/wallet/wallet-cli-commands/Cargo.toml +++ b/wallet/wallet-cli-commands/Cargo.toml @@ -39,6 +39,7 @@ futures.workspace = true humantime.workspace = true hex.workspace = true itertools.workspace = true +zeroize.workspace = true lazy_static.workspace = true prettytable-rs = "0.10" reedline = { workspace = true, features = ["external_printer"] } diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 938c597033..bfee0cec19 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -55,6 +55,7 @@ use wallet_rpc_lib::types::{ RpcStandaloneAddressDetails, RpcValidatedSignatures, TokenMetadata, }; use wallet_types::partially_signed_transaction::PartiallySignedTransaction; +use zeroize::Zeroizing; #[cfg(feature = "trezor")] use crate::{ @@ -95,12 +96,14 @@ enum PromptMode { /// Read a password from `--password-file`, where `-` means standard input. /// When no source is given and standard input is a terminal, the password /// is read with a hidden prompt on the controlling terminal instead; in -/// [`PromptMode::Create`] mode the prompt asks for the password twice. The +/// [`PromptMode::Create`] mode the prompt asks for the password twice. +/// The file and standard-input sources are read exactly once, so the +/// double-entry typo guard exists only on the interactive prompt. The /// trailing line terminator is stripped; any other whitespace is kept. async fn read_password_from_source( source: Option<&str>, prompt_mode: PromptMode, -) -> Result> { +) -> Result, WalletCliCommandError> { // A password file that is not tiny is almost certainly the wrong file. const MAX_PASSWORD_FILE_BYTES: u64 = 4 * 1024; // The password can also come from a hidden terminal prompt; see the @@ -114,17 +117,20 @@ async fn read_password_from_source( // hidden prompt itself reads from the controlling terminal, but gating // on stdin keeps piped automation (commands files, `--exec`) failing // fast with an explicit error instead of hanging on an unseen prompt. - fn prompt_password_hidden(prompt_mode: PromptMode) -> std::io::Result { + fn prompt_password_hidden(prompt_mode: PromptMode) -> std::io::Result> { // The prompt and the hidden read go to the controlling terminal - // (/dev/tty), so redirected standard error does not matter. - let password = rpassword::prompt_password(if prompt_mode == PromptMode::Create { - "New password: " - } else { - "Password: " - })?; + // (/dev/tty), so redirected standard error does not matter. Both + // entries are returned as wiping buffers so a mismatch wipes both. + let password = Zeroizing::new(rpassword::prompt_password( + if prompt_mode == PromptMode::Create { + "New password: " + } else { + "Password: " + }, + )?); if prompt_mode == PromptMode::Create { - let confirmed = rpassword::prompt_password("Confirm password: ")?; - if password != confirmed { + let confirmed = Zeroizing::new(rpassword::prompt_password("Confirm password: ")?); + if *password != *confirmed { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "the two passwords do not match", @@ -134,8 +140,8 @@ async fn read_password_from_source( Ok(password) } - fn read_password_from_stdin() -> std::io::Result { - let mut password = String::new(); + fn read_password_from_stdin() -> std::io::Result> { + let mut password = Zeroizing::default(); let bytes_read = std::io::stdin().read_line(&mut password)?; if bytes_read == 0 { return Err(std::io::Error::new( @@ -201,7 +207,7 @@ async fn read_password_from_source( } Some(path) => { let read_path = path.to_owned(); - let read = move || -> std::io::Result { + let read = move || -> std::io::Result> { use std::io::Read; // Open first, then check the mode of the opened handle: a @@ -242,7 +248,7 @@ async fn read_password_from_source( // Read one byte past the cap so a file that grows (or is // swapped) after the metadata check is detected here instead // of being silently truncated into a prefix password. - let mut password = String::new(); + let mut password = Zeroizing::default(); file.take(MAX_PASSWORD_FILE_BYTES + 1).read_to_string(&mut password)?; if password.len() as u64 > MAX_PASSWORD_FILE_BYTES { return Err(too_large()); @@ -263,13 +269,15 @@ async fn read_password_from_source( .await? } }; - let password = strip_line_terminator(&password); - if password.is_empty() { + // `password` is a wiping buffer on every path, so the early + // validation-error returns below wipe it too. + let stripped = strip_line_terminator(&password); + if stripped.is_empty() { return Err(WalletCliCommandError::InvalidInput( "the password is empty; expected a non-empty password".to_owned(), )); } - if password.contains(['\r', '\n']) { + if stripped.contains(['\r', '\n']) { // An interior line terminator means the file holds more than one // line; using only the first would silently lock the wallet. return Err(WalletCliCommandError::InvalidInput( @@ -278,7 +286,12 @@ async fn read_password_from_source( .to_owned(), )); } - Ok(password.to_owned()) + // Every copy this function made wipes itself: the returned value and + // the source buffer are both `Zeroizing`. The value handed to the + // command enum remains a plain copy on purpose (its grammar type is + // `String`), so the dispatch site below makes the one deliberate, + // documented copy. + Ok(Zeroizing::new(stripped.to_owned())) } pub struct CommandHandler { @@ -1163,7 +1176,13 @@ where .await?; return self .handle_cold_wallet_command( - ColdWalletCommand::EncryptPrivateKeys { password }, + // The grammar field is `String`, so this is + // the one deliberate plain copy of the + // password; wiping it would require a + // zeroizing-aware grammar type. + ColdWalletCommand::EncryptPrivateKeys { + password: password.to_string(), + }, chain_config, ) .await; @@ -1176,7 +1195,11 @@ where .await?; return self .handle_cold_wallet_command( - ColdWalletCommand::UnlockPrivateKeys { password }, + // See the note on the encrypt arm: one + // deliberate plain copy for the grammar type. + ColdWalletCommand::UnlockPrivateKeys { + password: password.to_string(), + }, chain_config, ) .await; @@ -2903,7 +2926,7 @@ where /// Whether the command still carries a nested group that must be translated before /// dispatch. Shared by the dispatch guard and the translation tests so both stay in /// sync when new groups are added. -fn is_nested_wallet_command(command: &WalletCommand) -> bool { +pub(crate) fn is_nested_wallet_command(command: &WalletCommand) -> bool { // Keep in sync with the dispatch or-pattern in `handle_wallet_command` and the // translation arms in `normalize_nested_wallet_command`; the LEGACY_ALIASES // coexistence test and the translation test pin the three sites together. @@ -4199,7 +4222,7 @@ mod password_source_tests { async fn read_from_file( content: &[u8], - ) -> Result> { + ) -> Result, WalletCliCommandError> { let mut path = std::env::temp_dir(); path.push(format!( "ml-cli-password-test-{}-{:?}", @@ -4228,7 +4251,7 @@ mod password_source_tests { #[tokio::test] async fn single_line_passwords_are_read_with_the_terminator_stripped() { let password = read_from_file(b"hunter2\n").await.expect("must read"); - assert_eq!(password, "hunter2"); + assert_eq!(password.as_str(), "hunter2"); } #[tokio::test] diff --git a/wallet/wallet-cli-commands/src/confirm.rs b/wallet/wallet-cli-commands/src/confirm.rs index 54f9f1963a..169104ec52 100644 --- a/wallet/wallet-cli-commands/src/confirm.rs +++ b/wallet/wallet-cli-commands/src/confirm.rs @@ -25,10 +25,15 @@ pub fn confirmation_prompt(command: &ManageableWalletCommand) -> Option ManageableWalletCommand::WalletCommands(command) => { // The gate must not depend on the spelling: translate the nested // groups to their legacy forms first, so `send 1 addr` and - // `address-send 1 addr` classify identically. - let normalized = - crate::command_handler::normalize_nested_wallet_command(command.clone()); - wallet_confirmation(&normalized) + // `address-send 1 addr` classify identically. Flat spellings are + // classified by reference; only nested ones pay for a rebuild. + if crate::command_handler::is_nested_wallet_command(command) { + let normalized = + crate::command_handler::normalize_nested_wallet_command(command.clone()); + wallet_confirmation(&normalized) + } else { + wallet_confirmation(command) + } } ManageableWalletCommand::ManagementCommands(_) => None, } @@ -45,8 +50,24 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { WalletCommand::SendToAddress { address, amount, - utxos: _, - } => Some(format!("Send {amount} coins to {address}?")), + utxos, + } => { + let selection = if utxos.is_empty() { + String::new() + } else { + const MAX_LISTED_UTXOS: usize = 10; + let mut list = utxos + .iter() + .take(MAX_LISTED_UTXOS) + .map(|utxo| utxo.to_string()) + .collect::>(); + if utxos.len() > MAX_LISTED_UTXOS { + list.push(format!("and {} more", utxos.len() - MAX_LISTED_UTXOS)); + } + format!(" using {}", list.join(", ")) + }; + Some(format!("Send {amount} coins to {address}{selection}?")) + } WalletCommand::SendTokensToAddress { token_id, address, @@ -221,9 +242,7 @@ fn wallet_confirmation(command: &WalletCommand) -> Option { utxo, address, htlc_secret: _, - } => Some(format!( - "Spend the outpoint {utxo:?} (source and output index) to {address}?" - )), + } => Some(format!("Spend the outpoint {utxo} to {address}?")), WalletCommand::ColdCommands(command) => cold_wallet_confirmation(command), WalletCommand::SelectAccount { account_index } => Some(format!( "Select account {account_index} for subsequent commands?" @@ -434,6 +453,20 @@ mod tests { assert_eq!(prompt("account select 3"), prompt("account-select 3")); } + #[test] + fn send_recap_lists_utxos_with_a_cap() { + let hash = "0000000000000000000000000000000000000000000000000000000000000001"; + let utxos = (1..=12) + .map(|index| format!("tx({hash},{index})")) + .collect::>() + .join(" "); + // `prompt` needs a 'static line; leak the built one in the test process. + let line: &'static str = Box::leak(format!("address-send addr 1 {utxos}").into_boxed_str()); + let recap = prompt(line).expect("send must ask"); + assert!(recap.contains(&format!("tx({hash},1)")), "{recap}"); + assert!(recap.contains("and 2 more"), "{recap}"); + } + #[test] fn read_only_commands_do_not_ask() { assert!(prompt("account-balance").is_none()); diff --git a/wallet/wallet-cli-commands/src/helper_types.rs b/wallet/wallet-cli-commands/src/helper_types.rs index a505633278..902bf6de87 100644 --- a/wallet/wallet-cli-commands/src/helper_types.rs +++ b/wallet/wallet-cli-commands/src/helper_types.rs @@ -384,6 +384,20 @@ impl FromStr for CliUtxoOutPoint { } } +impl std::fmt::Display for CliUtxoOutPoint { + /// Renders the outpoint in the same `tx(,)` notation the CLI + /// accepts, so prompts and errors can quote it back verbatim. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.source_id { + // `Id`'s `Display` elides the hash (`0000…36c`); `LowerHex` + // emits the full 64-hex-digit form `H256::from_str` accepts, + // so the rendered outpoint parses back through `FromStr`. + OutPointSourceId::Transaction(id) => write!(f, "tx({id:x},{})", self.output_index), + OutPointSourceId::BlockReward(id) => write!(f, "block({id:x},{})", self.output_index), + } + } +} + impl From for UtxoOutPoint { fn from(value: CliUtxoOutPoint) -> Self { Self::new(value.source_id, value.output_index) @@ -832,7 +846,7 @@ mod tests { use common::{ address::pubkeyhash::PublicKeyHash, - chain::{self, Destination, tokens::TokenId}, + chain::{self, Destination, GenBlock, Transaction, tokens::TokenId}, }; use randomness::RngExt; use test_utils::{ @@ -843,6 +857,32 @@ mod tests { use super::*; + #[test] + fn outpoint_display_round_trips_through_from_str() { + let mut rng = make_seedable_rng(Seed::from(42u64)); + let tx_id = Id::::new(H256::random_using(&mut rng)); + let outpoint = CliUtxoOutPoint { + source_id: OutPointSourceId::Transaction(tx_id), + output_index: 7, + }; + let rendered = outpoint.to_string(); + let parsed: CliUtxoOutPoint = rendered.parse().expect("display must round-trip"); + assert_eq!(parsed.source_id, outpoint.source_id); + assert_eq!(parsed.output_index, 7); + assert!(rendered.starts_with("tx(")); + + let block_id = Id::::new(H256::random_using(&mut rng)); + let outpoint = CliUtxoOutPoint { + source_id: OutPointSourceId::BlockReward(block_id), + output_index: 3, + }; + let rendered = outpoint.to_string(); + assert!(rendered.starts_with("block("), "{rendered}"); + let parsed: CliUtxoOutPoint = rendered.parse().expect("display must round-trip"); + assert_eq!(parsed.source_id, outpoint.source_id); + assert_eq!(parsed.output_index, 3); + } + #[rstest] #[trace] #[case(Seed::from_entropy())] diff --git a/wallet/wallet-cli-lib/src/repl/exec.rs b/wallet/wallet-cli-lib/src/repl/exec.rs index 3fc2b1a373..a7dfd820df 100644 --- a/wallet/wallet-cli-lib/src/repl/exec.rs +++ b/wallet/wallet-cli-lib/src/repl/exec.rs @@ -101,8 +101,24 @@ pub fn run( }; // The startup command output is the wallet opening report; keep it off // standard output so the requested output format stays machine-parseable. - let line_out = to_startup_text(res); - eprintln!("{line_out}"); + // In json mode the startup result is folded into a single JSON object + // (`{"text": ..., "value": ...}`) so stderr stays one parseable line, + // matching the payload convention of the command output on stdout. + match (output_format, res) { + (CliOutputFormat::Json, ConsoleCommand::Json { text, value }) => { + let envelope = json_payload(text, value); + match serde_json::to_string(&envelope) { + Ok(rendered) => eprintln!("{rendered}"), + Err(err) => { + return Err(WalletCliError::::SerdeJsonFormatError(err)); + } + } + } + (_, res) => { + let line_out = to_startup_text(res); + eprintln!("{line_out}"); + } + } } // The REPL thread is sized for grammar construction, so build the diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 91c1ce24e2..7034955832 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -96,24 +96,32 @@ fn create_line_editor( line_editor.with_edit_mode(edit_mode) } -/// Runs the wizard for a recognized command line, echoing the generated -/// one-liner. `None` means the line is not a wizard invocation. -fn run_wizard_for_line(line: &str) -> Result, WalletCliError> { +/// Outcome of offering a wizard for the entered line. +enum WizardOutcome { + /// The line is not a wizard invocation; normal processing continues. + NotAWizard, + /// The wizard produced an equivalent one-liner to run. + Produced(String), + /// The user declined the wizard's summary; return to the prompt. + Cancelled, +} + +fn run_wizard_for_line(line: &str) -> Result> { let Some(tokens) = shlex::split(line) else { - return Ok(None); + return Ok(WizardOutcome::NotAWizard); }; let token_refs: Vec<&str> = tokens.iter().map(String::as_str).collect(); let Some(kind) = WizardKind::parse_from_tokens(&token_refs) else { - return Ok(None); + return Ok(WizardOutcome::NotAWizard); }; match kind.run(&mut DialoguerBackend) { Ok(one_liner) => { println!("You could also have run: {one_liner}"); - Ok(Some(one_liner)) + Ok(WizardOutcome::Produced(one_liner)) } Err(WizardError::Cancelled) => { println!("Wizard cancelled."); - Ok(None) + Ok(WizardOutcome::Cancelled) } Err(WizardError::InvalidInput(message)) => Err(WalletCliError::InvalidInput(message)), // The original io::Error (e.g. "prompt failed: not a terminal") is kept so @@ -139,23 +147,27 @@ fn process_line( } }; - if let Some(one_liner) = run_wizard_for_line::(&line)? { - let command_opt = parse_input(&one_liner, repl_command)?; - let command = match command_opt { - Some(command) => command, - // Wizard output is machine-generated and re-checked by tests, but - // an unexpected grammar drift must never crash the wallet. - None => { - return Err(WalletCliError::InvalidInput(format!( - "the wizard produced a command that failed to parse; please report this: \ - {one_liner}" - ))); + match run_wizard_for_line::(&line)? { + WizardOutcome::NotAWizard => {} + WizardOutcome::Cancelled => return Ok(None), + WizardOutcome::Produced(one_liner) => { + let command_opt = parse_input(&one_liner, repl_command)?; + let command = match command_opt { + Some(command) => command, + // Wizard output is machine-generated and re-checked by tests, but + // an unexpected grammar drift must never crash the wallet. + None => { + return Err(WalletCliError::InvalidInput(format!( + "the wizard produced a command that failed to parse; please report this: \ + {one_liner}" + ))); + } + }; + if !confirm_or_abort(&command, assume_yes)? { + return Ok(None); } - }; - if !confirm_or_abort(&command, assume_yes)? { - return Ok(None); + return super::run_command_blocking(event_tx, command).map(Option::Some); } - return super::run_command_blocking(event_tx, command).map(Option::Some); } let command_opt = parse_input(&line, repl_command)?; diff --git a/wallet/wallet-cli-lib/tests/cli_test_framework.rs b/wallet/wallet-cli-lib/tests/cli_test_framework.rs index 299ee00d48..757405fac6 100644 --- a/wallet/wallet-cli-lib/tests/cli_test_framework.rs +++ b/wallet/wallet-cli-lib/tests/cli_test_framework.rs @@ -77,7 +77,18 @@ pub struct CliTestFramework { } impl CliTestFramework { + /// Boots the wallet with confirmation prompts skipped (`--yes`), which is + /// what nearly all tests want. Tests that exercise the gate itself pass + /// `assume_yes: false` and expect [`WalletCliError`] rejections. pub async fn setup(rng: &mut impl Rng) -> Self { + Self::setup_with(rng, true).await + } + + /// Same as [`CliTestFramework::setup`], but with explicit control over the + /// confirmation-skip behavior: `assume_yes: true` is equivalent to `setup`, + /// while `assume_yes: false` boots without `--yes`, so confirmation-gated + /// commands are rejected instead of run. + pub async fn setup_with(rng: &mut impl Rng, assume_yes: bool) -> Self { logging::init_logging(); let test_root = test_utils::test_root!("wallet-cli-tests").unwrap(); @@ -108,7 +119,7 @@ impl CliTestFramework { commands_file: None, exec: None, output: wallet_cli_lib::config::CliOutputFormat::Text, - yes: true, + yes: assume_yes, history_file: None, exit_on_error: None, vi_mode: false, @@ -142,7 +153,7 @@ impl CliTestFramework { commands_file: None, exec: None, output: wallet_cli_lib::config::CliOutputFormat::Text, - yes: true, + yes: assume_yes, history_file: None, exit_on_error: None, vi_mode: false, From 69c88fcc88bba40757d4d00d76a8b44bff3550b8 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 22:23:30 +0200 Subject: [PATCH 49/52] fix(wallet): gate wizards on a terminal and soften failure paths --- wallet/mcp-server/src/lib.rs | 5 ++++- wallet/mcp-server/src/main.rs | 16 ++++++++++++-- .../src/command_handler/mod.rs | 21 +++++++++++++------ .../src/repl/interactive/mod.rs | 12 ++++++++++- .../repl/interactive/wallet_completions.rs | 7 +++++++ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index 0b7d9fb891..34dd2d8843 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -187,7 +187,10 @@ pub async fn handle_message(message: &Value, wallet: &W) -> O .get("protocolVersion") .and_then(Value::as_str) .filter(|requested| SUPPORTED_PROTOCOL_VERSIONS.contains(requested)) - .unwrap_or(SUPPORTED_PROTOCOL_VERSIONS.last().copied().unwrap_or("2024-11-05")); + // The list is a non-empty const slice ordered newest last, so + // the newest entry is always present and no literal is + // duplicated here. + .unwrap_or(SUPPORTED_PROTOCOL_VERSIONS.last().expect("non-empty const")); result_response( id, json!({ diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index 942a0f07e6..0d031da52c 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -88,9 +88,21 @@ impl CliArgs { } /// Labels wallet RPC failures at the adapter boundary so tool results name -/// their provenance instead of surfacing a bare daemon message. +/// their provenance instead of surfacing a bare daemon message. The full +/// daemon error is echoed to standard error for server-side diagnostics; +/// the tool result carries the same text since the daemon and this server +/// share a trusted local operator. +// Diagnostics must not kill the protocol loop: a broken stderr (e.g. a +// client that closed its end of the pipe) is not a fatal server error. +fn log_rpc_error(message: &str) { + use std::io::Write as _; + let _ = writeln!(std::io::stderr(), "[wallet-mcp] {message}"); +} + fn rpc_error(err: impl std::fmt::Display) -> String { - format!("wallet RPC failed: {err}") + let message = format!("wallet RPC failed: {err}"); + log_rpc_error(&message); + message } /// Adapter that exposes only the read-only operations of the daemon client. diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index bfee0cec19..dc905328a9 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -1294,12 +1294,16 @@ where // is boxed to keep the future size finite; translation always produces a // flat command, so the recursion never goes deeper than one level. let normalized = normalize_nested_wallet_command(command); - // Translation must terminate: a nested command fed back into this handler - // would recurse unboundedly through the boxed call. - assert!( - !is_nested_wallet_command(&normalized), - "translation must produce a flat command, otherwise this recursion is unbounded" - ); + // Translation must terminate: a nested command fed back into this + // handler would recurse unboundedly through the boxed call. A + // group variant that is missing a translation arm is reported + // instead of asserted, so adding a group cannot turn into a + // runtime panic here. + if is_nested_wallet_command(&normalized) { + return Err(WalletCliCommandError::::InvalidInput(format!( + "the command {normalized:?} has no flat translation" + ))); + } Box::pin(self.handle_wallet_command(chain_config, normalized)).await } @@ -1377,6 +1381,11 @@ where None }, if timestamp { + // Probe the node first so a cold wallet errors + // here exactly like the --id and --height arms, + // instead of chainstate_info quietly reporting + // its genesis stub. + wallet.node_best_block_id().await?; Some(format_block_timestamp( wallet.chainstate_info().await?.best_block_timestamp, )) diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index 7034955832..cd72fe1715 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -114,6 +114,15 @@ fn run_wizard_for_line(line: &str) -> Result::InvalidInput( + "the wizard needs an interactive terminal; type the command directly instead" + .to_owned(), + )); + } match kind.run(&mut DialoguerBackend) { Ok(one_liner) => { println!("You could also have run: {one_liner}"); @@ -246,7 +255,8 @@ pub fn run( let mut line_editor = create_line_editor( logger.printer().clone(), - wallet_completions::WalletCompletions::new(Arc::clone(&repl_command)), + // The grammar was built above; share it through the trusted path. + wallet_completions::WalletCompletions::from_built(Arc::clone(&repl_command)), history, vi_mode, ); diff --git a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs index f5237b666b..208bc4892a 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/wallet_completions.rs @@ -56,6 +56,13 @@ impl WalletCompletions { Self { root } } + /// Takes an already-built grammar and trusts the caller: use this where + /// the [`Command`] is shared behind an [`std::sync::Arc`] and the builder + /// contract is known to hold (as with `build_repl_command` output). + pub fn from_built(root: Arc) -> Self { + Self { root } + } + /// A completer without any candidates, used where completion is not /// meaningful (for example the choice menu). pub fn empty() -> Self { From e1e019adbd8075aaa66d8a7198187b35a8fea93f Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 22:53:36 +0200 Subject: [PATCH 50/52] fix(wallet): bound mcp wallet requests with a timeout --- wallet/mcp-server/Cargo.toml | 2 +- wallet/mcp-server/src/main.rs | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/wallet/mcp-server/Cargo.toml b/wallet/mcp-server/Cargo.toml index 5949f7f22c..3b4d9e48eb 100644 --- a/wallet/mcp-server/Cargo.toml +++ b/wallet/mcp-server/Cargo.toml @@ -10,6 +10,6 @@ clap = { workspace = true, features = ["derive"] } crypto = { path = "../../crypto" } rpc = { path = "../../rpc" } serde_json.workspace = true -tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi-thread", "time"] } wallet-rpc-client = { path = "../wallet-rpc-client" } wallet-types = { path = "../types" } diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index 0d031da52c..832f64503d 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -183,6 +183,10 @@ const MAX_MESSAGE_BYTES: usize = 1024 * 1024; /// message without a newline; past this the session is torn down. const RESYNC_DRAIN_BUDGET: usize = MAX_MESSAGE_BYTES * 4; +/// Upper bound for one wallet RPC round trip; a daemon that hangs must not +/// stall the protocol loop past this. +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + /// Discards bytes up to and including the next newline. Gives up on the /// stream if the line turns out to be endless. async fn skip_rest_of_line( @@ -313,7 +317,27 @@ async fn serve(wallet: RpcWallet) -> Result<(), Box> { continue; } let response = match serde_json::from_str::(trimmed) { - Ok(message) => handle_message(&message, &wallet).await, + Ok(message) => { + // Bound each request so a hung wallet daemon cannot stall the + // protocol loop; the client gets a JSON-RPC error instead of + // silence. + match tokio::time::timeout(REQUEST_TIMEOUT, handle_message(&message, &wallet)).await + { + Ok(response) => response, + Err(_elapsed) => { + log_rpc_error(&format!( + "wallet request timed out after {REQUEST_TIMEOUT:?}" + )); + message.get("id").cloned().map(|id| { + serde_json::json!({ + "jsonrpc": JSONRPC_VERSION, + "id": id, + "error": { "code": -32000, "message": "wallet request timed out" } + }) + }) + } + } + } Err(err) => Some(serde_json::json!({ "jsonrpc": JSONRPC_VERSION, "id": null, From 34668fb94220ae1025989b9dc246084bf80eeabb Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 21 Oct 2026 23:50:39 +0200 Subject: [PATCH 51/52] fix(wallet): fail closed on unverifiable password sources --- wallet/mcp-server/src/lib.rs | 9 +++- .../src/command_handler/mod.rs | 41 +++++++++++++++++++ .../src/repl/interactive/mod.rs | 19 +++++++-- wallet/wallet-cli/src/main.rs | 23 ++++++++++- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/wallet/mcp-server/src/lib.rs b/wallet/mcp-server/src/lib.rs index 34dd2d8843..3e1ffb93d5 100644 --- a/wallet/mcp-server/src/lib.rs +++ b/wallet/mcp-server/src/lib.rs @@ -304,7 +304,14 @@ fn parse_account_index(arguments: &Value) -> Result { let raw = value.as_u64().ok_or_else(|| { format!("account_index must be a non-negative integer, got: {value}") })?; - u32::try_from(raw).map_err(|_| format!("account_index out of range: {raw}")) + let index = + u32::try_from(raw).map_err(|_| format!("account_index out of range: {raw}"))?; + // Mirror the U31 account bound here so an out-of-range index is a + // protocol error, not a tool execution failure. + if index >= crypto::key::hdkd::u31::MSB_BIT { + return Err(format!("account_index out of range: {index}")); + } + Ok(index) } } } diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index dc905328a9..d7d377c7a1 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -210,11 +210,31 @@ async fn read_password_from_source( let read = move || -> std::io::Result> { use std::io::Read; + // Cheap pre-open check: open(2) itself blocks on a writer-less + // FIFO, so refuse non-regular files before opening. The check + // is racy, but the post-open check on the opened handle below + // stays authoritative. + if let Ok(pre) = std::fs::metadata(&read_path) + && !pre.is_file() + { + return Err(not_regular_file()); + } // Open first, then check the mode of the opened handle: a // separate metadata() call would leave a window where the // check and the read see different files. let file = std::fs::File::open(&read_path)?; let metadata = file.metadata()?; + // Shared so the pre-open and post-open checks fail with the + // same message. + fn not_regular_file() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the password file is not a regular file", + ) + } + if !metadata.is_file() { + return Err(not_regular_file()); + } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -4293,4 +4313,25 @@ mod password_source_tests { "unexpected error: {err}" ); } + + #[tokio::test] + async fn non_regular_password_files_are_rejected() { + let dir = std::env::temp_dir().join(format!( + "ml-cli-password-test-dir-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let result = read_password_from_source::( + Some(dir.to_str().unwrap()), + PromptMode::Enter, + ) + .await; + std::fs::remove_dir(&dir).unwrap(); + let err = result.expect_err("a directory must be rejected as a password file"); + assert!( + err.to_string().contains("not a regular file"), + "unexpected error: {err}" + ); + } } diff --git a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs index cd72fe1715..590deca889 100644 --- a/wallet/wallet-cli-lib/src/repl/interactive/mod.rs +++ b/wallet/wallet-cli-lib/src/repl/interactive/mod.rs @@ -211,10 +211,23 @@ fn confirm_or_abort( .interact() { Ok(confirmed) => confirmed, - // A prompt that cannot be shown (for example piped input driving - // the REPL) cannot receive consent; treat that as a decline for - // this command instead of tearing down the whole session. + // A prompt that cannot be shown cannot receive consent. On a real + // terminal treat the failure as a decline for this command instead + // of tearing down the whole session; without a terminal there is + // no interactive session to preserve, so fail the command with a + // distinct error instead of silently skipping the mutation. Err(err) => { + // The prompt renders on standard error (dialoguer's default + // terminal target); without a prompt-capable terminal there is + // no way to obtain consent, so fail the command with a + // distinct error instead of silently skipping the mutation. + if !std::io::IsTerminal::is_terminal(&std::io::stdin()) + || !std::io::IsTerminal::is_terminal(&std::io::stderr()) + { + return Err(WalletCliError::ConfirmationRequired(format!( + "cannot show the confirmation prompt ({err}); pass --yes or run interactively" + ))); + } println!("The confirmation prompt is unavailable ({err}); treating as declined."); false } diff --git a/wallet/wallet-cli/src/main.rs b/wallet/wallet-cli/src/main.rs index 43c19b75b6..a5c3ad34cc 100644 --- a/wallet/wallet-cli/src/main.rs +++ b/wallet/wallet-cli/src/main.rs @@ -25,7 +25,9 @@ utils::enable_rust_backtrace!(); /// /// This verb is handled by the binary before the main argument parser runs, so /// it does not appear in the command grammar, in the help output, or in the -/// generated completions. +/// generated completions. The trade-off is that a future first-position word +/// named `completions` would be claimed by this handler; the top-level grammar +/// has no such positional and the behavior is pinned by a test. #[derive(Parser, Debug)] struct CompletionsArgs { /// Shell to generate the completion script for @@ -80,3 +82,22 @@ async fn main() { std::process::exit(1); }) } + +#[cfg(test)] +mod completions_tests { + use clap::CommandFactory as _; + + /// Pins the interception contract: `completions` is claimed by the binary + /// before the grammar parser runs, so the top-level grammar must NOT + /// accept a `completions` subcommand — otherwise the two would fight over + /// the first positional word. + #[test] + fn grammar_has_no_completions_verb() { + assert!( + wallet_cli_lib::config::WalletCliArgs::command() + .find_subcommand("completions") + .is_none(), + "the top-level grammar claims the `completions` verb" + ); + } +} From 2f0e9fa11e4395caa53f7732291c571ee05c6dec Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 22 Oct 2026 00:40:30 +0200 Subject: [PATCH 52/52] fix(wallet): enforce the mcp resync contract in the type system --- wallet/mcp-server/src/main.rs | 172 ++++++++++++------ .../src/command_handler/mod.rs | 17 +- 2 files changed, 129 insertions(+), 60 deletions(-) diff --git a/wallet/mcp-server/src/main.rs b/wallet/mcp-server/src/main.rs index 832f64503d..81155432a3 100644 --- a/wallet/mcp-server/src/main.rs +++ b/wallet/mcp-server/src/main.rs @@ -187,6 +187,10 @@ const RESYNC_DRAIN_BUDGET: usize = MAX_MESSAGE_BYTES * 4; /// stall the protocol loop past this. const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +/// How many consecutive transient read interrupts are tolerated before the +/// stream is declared unusable; real interrupt storms are bounded. +const MAX_INTERRUPT_RETRIES: usize = 64; + /// Discards bytes up to and including the next newline. Gives up on the /// stream if the line turns out to be endless. async fn skip_rest_of_line( @@ -195,8 +199,23 @@ async fn skip_rest_of_line( use tokio::io::AsyncBufReadExt; let mut discarded: usize = 0; + let mut interrupted_retries = 0usize; loop { - let available = reader.fill_buf().await?; + let available = match reader.fill_buf().await { + Ok(available) => available, + // A transient interrupt is retried, but a reader that keeps + // interrupting without progress makes the stream unusable. + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => { + interrupted_retries += 1; + if interrupted_retries > MAX_INTERRUPT_RETRIES { + return Err(err); + } + tokio::task::yield_now().await; + continue; + } + Err(err) => return Err(err), + }; + interrupted_retries = 0; if available.is_empty() { return Ok(()); // EOF mid-line } @@ -215,14 +234,50 @@ async fn skip_rest_of_line( } } +/// The result of reading one framed message. The recovered/fatal split makes +/// the serve-loop contract compile-enforced: a `Recovered` error guarantees +/// the offending line was consumed or discarded, so the caller may continue; +/// a `Fatal` error leaves the stream in an unknown state and must end the +/// session. +enum MessageOutcome { + Line(String), + Eof, + Recovered(std::io::Error), + Fatal(std::io::Error), +} + async fn next_message( reader: &mut R, buffer: &mut Vec, -) -> Result, std::io::Error> { +) -> MessageOutcome { use tokio::io::AsyncBufReadExt; + let oversize = || { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "message exceeds the maximum size", + ) + }; + let invalid_utf8 = + |err: std::string::FromUtf8Error| std::io::Error::new(std::io::ErrorKind::InvalidData, err); + + let mut interrupted_retries = 0usize; loop { - let available = reader.fill_buf().await?; + let available = match reader.fill_buf().await { + Ok(available) => available, + // A transient interrupt is retried; a reader that keeps + // interrupting without progress ends the session. + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => { + interrupted_retries += 1; + if interrupted_retries > MAX_INTERRUPT_RETRIES { + return MessageOutcome::Fatal(err); + } + tokio::task::yield_now().await; + continue; + } + Err(err) => return MessageOutcome::Fatal(err), + }; + interrupted_retries = 0; if available.is_empty() { // End of stream: deliver a trailing message without a newline, if any. break; @@ -233,29 +288,28 @@ async fn next_message( // leaving the bytes in place would trip this error forever. buffer.clear(); reader.consume(pos + 1); - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "message exceeds the maximum size", - )); + return MessageOutcome::Recovered(oversize()); } buffer.extend_from_slice(&available[..pos]); reader.consume(pos + 1); let bytes: Vec = std::mem::take(buffer); // A protocol boundary must reject, not repair: lossy decoding could // silently turn a truncated request into a different valid one. - let line = String::from_utf8(bytes) - .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; - return Ok(Some(line)); + // The line was fully consumed, so the stream stays usable. + return match String::from_utf8(bytes) { + Ok(line) => MessageOutcome::Line(line), + Err(err) => MessageOutcome::Recovered(invalid_utf8(err)), + }; } if buffer.len() + available.len() > MAX_MESSAGE_BYTES { // The rejected line has no newline in this chunk; drain chunks // until its newline so exactly one error is reported per message. buffer.clear(); - skip_rest_of_line(reader).await?; - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "message exceeds the maximum size", - )); + return match skip_rest_of_line(reader).await { + Ok(()) => MessageOutcome::Recovered(oversize()), + // The endless-line give-up leaves the stream unusable. + Err(err) => MessageOutcome::Fatal(err), + }; } buffer.extend_from_slice(available); let len = available.len(); @@ -265,12 +319,13 @@ async fn next_message( // runs before every extend, so a trailing message at EOF is already // bounded; no separate check is needed here. if buffer.is_empty() { - return Ok(None); + return MessageOutcome::Eof; } let bytes = std::mem::take(buffer); - let line = String::from_utf8(bytes) - .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; - Ok(Some(line)) + match String::from_utf8(bytes) { + Ok(line) => MessageOutcome::Line(line), + Err(err) => MessageOutcome::Recovered(invalid_utf8(err)), + } } async fn serve(wallet: RpcWallet) -> Result<(), Box> { @@ -293,24 +348,23 @@ async fn serve(wallet: RpcWallet) -> Result<(), Box> { // or adding an InvalidData path that does not resync, would spin // this loop forever. let line = match next_message(&mut reader, &mut message_buffer).await { - Ok(line) => line, - Err(err) if err.kind() == std::io::ErrorKind::InvalidData => { - let encoded = serde_json::to_string(&serde_json::json!({ + MessageOutcome::Line(line) => line, + MessageOutcome::Eof => break, + MessageOutcome::Recovered(err) => { + let mut encoded = serde_json::to_string(&serde_json::json!({ "jsonrpc": JSONRPC_VERSION, "id": null, "error": { "code": -32700, "message": format!("Parse error: {err}") } - }))? + "\n"; + }))?; + encoded.push('\n'); stdout.write_all(encoded.as_bytes()).await?; stdout.flush().await?; continue; } - Err(err) => return Err(err.into()), - }; - let Some(line) = line else { - break; + MessageOutcome::Fatal(err) => return Err(err.into()), }; let trimmed = line.trim(); if trimmed.is_empty() { @@ -382,16 +436,19 @@ async fn main() { mod tests { use super::*; - async fn next_n_messages( - input: &str, - count: usize, - capacity: usize, - ) -> Vec, std::io::Error>> { - let mut reader = tokio::io::BufReader::with_capacity(capacity, input.as_bytes()); + /// Reads `count` framed messages and renders each outcome as a terse + /// tag: `line:`, `eof`, `recovered:`, or `fatal:`. + async fn next_n_messages(input: &[u8], count: usize, capacity: usize) -> Vec { + let mut reader = tokio::io::BufReader::with_capacity(capacity, input); let mut buffer = Vec::new(); let mut results = Vec::new(); for _ in 0..count { - results.push(next_message(&mut reader, &mut buffer).await); + results.push(match next_message(&mut reader, &mut buffer).await { + MessageOutcome::Line(line) => format!("line:{line}"), + MessageOutcome::Eof => "eof".to_owned(), + MessageOutcome::Recovered(err) => format!("recovered:{:?}", err.kind()), + MessageOutcome::Fatal(err) => format!("fatal:{:?}", err.kind()), + }); } results } @@ -402,13 +459,9 @@ mod tests { "{}\n{{\"method\":\"ping\"}}\n", "x".repeat(MAX_MESSAGE_BYTES + 1) ); - let results = next_n_messages(&input, 2, 8192).await; - let err = results[0].as_ref().unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - assert_eq!( - results[1].as_ref().unwrap().as_deref(), - Some("{\"method\":\"ping\"}") - ); + let results = next_n_messages(input.as_bytes(), 2, 8192).await; + assert_eq!(results[0], "recovered:InvalidData"); + assert_eq!(results[1], "line:{\"method\":\"ping\"}"); } #[tokio::test] @@ -418,22 +471,37 @@ mod tests { "y".repeat(MAX_MESSAGE_BYTES + 1) ); // A tiny read buffer forces the oversized line through many chunks. - let results = next_n_messages(&input, 2, 64).await; - assert_eq!( - results[0].as_ref().unwrap_err().kind(), - std::io::ErrorKind::InvalidData - ); - assert!(results[1].as_ref().unwrap().is_some()); + let results = next_n_messages(input.as_bytes(), 2, 64).await; + assert_eq!(results[0], "recovered:InvalidData"); + assert!(results[1].starts_with("line:"), "{:?}", results[1]); } #[tokio::test] async fn oversized_message_without_newline_reports_once_at_eof() { let input = "z".repeat(MAX_MESSAGE_BYTES + 1); + let results = next_n_messages(input.as_bytes(), 2, 8192).await; + assert_eq!(results[0], "recovered:InvalidData"); + assert_eq!(results[1], "eof"); + } + + #[tokio::test] + async fn newline_free_line_past_the_drain_budget_ends_the_session() { + // The in-loop guard trips at MAX_MESSAGE_BYTES first; the fatal + // give-up needs the remainder to also exceed the drain budget. + let input = "z".repeat(MAX_MESSAGE_BYTES + RESYNC_DRAIN_BUDGET + 1); + // The give-up path is fatal by contract: the stream is left mid-line, + // so the session must end instead of reading on. + let results = next_n_messages(input.as_bytes(), 1, 8192).await; + assert_eq!(results[0], format!("fatal:{:?}", std::io::ErrorKind::Other)); + } + + #[tokio::test] + async fn invalid_utf8_line_is_skipped_and_stream_stays_usable() { + let mut input = b"{\xff\xfe}".to_vec(); + input.extend_from_slice(b"\n{\"method\":\"ping\"}\n"); let results = next_n_messages(&input, 2, 8192).await; - assert_eq!( - results[0].as_ref().unwrap_err().kind(), - std::io::ErrorKind::InvalidData - ); - assert_eq!(results[1].as_ref().unwrap(), &None); + // The offending line was consumed, so the next message still parses. + assert_eq!(results[0], "recovered:InvalidData"); + assert_eq!(results[1], "line:{\"method\":\"ping\"}"); } } diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index d7d377c7a1..3dc3f32479 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -106,6 +106,15 @@ async fn read_password_from_source( ) -> Result, WalletCliCommandError> { // A password file that is not tiny is almost certainly the wrong file. const MAX_PASSWORD_FILE_BYTES: u64 = 4 * 1024; + // Shared by the pre-open and post-open password-file checks so both fail + // with the same message. Declared before any statement so the lint that + // flags items after statements stays quiet. + fn not_regular_file() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the password file is not a regular file", + ) + } // The password can also come from a hidden terminal prompt; see the // function documentation above. @@ -224,14 +233,6 @@ async fn read_password_from_source( // check and the read see different files. let file = std::fs::File::open(&read_path)?; let metadata = file.metadata()?; - // Shared so the pre-open and post-open checks fail with the - // same message. - fn not_regular_file() -> std::io::Error { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "the password file is not a regular file", - ) - } if !metadata.is_file() { return Err(not_regular_file()); }