1use base58::ToBase58; 2use base64::Engine; 3use clap::{CommandFactory, Parser, Subcommand}; 4use rustyline::completion::{Completer, Pair}; 5use rustyline::error::ReadlineError; 6use rustyline::highlight::{CmdKind, Highlighter}; 7use rustyline::hint::Hinter; 8use rustyline::validate::{ValidationContext, ValidationResult, Validator}; 9use rustyline::{Context, Editor, Helper}; 10 11use hidapi::HidApi; 12use ledger_transport_hid::TransportNativeHID; 13 14use client::{ 15 transport::{Transport, TransportHID, TransportTcp, TransportWrapper}, 16 vanadium_client::{NativeAppClient, VanadiumAppClient}, 17 AtprotoAppClient, 18}; 19 20use serde::Deserialize; 21use std::borrow::Cow; 22use std::fs::File; 23use std::io::Read; 24use std::sync::Arc; 25 26#[derive(Parser, Debug)] 27#[command(name = "vnd-bitcoin-cli")] 28struct Cli { 29 #[clap(subcommand)] 30 command: CliCommand, 31} 32 33#[derive(Subcommand, Debug)] 34#[clap(rename_all = "snake_case")] 35enum CliCommand { 36 GetDidKey { 37 #[clap(long)] 38 key_index: Option<u32>, 39 #[clap(long, default_missing_value = "true", num_args = 0..=1)] 40 display: bool, 41 }, 42 SignPlcOperation { 43 #[clap(long)] 44 key_index: Option<u32>, 45 /// Path to operation json file 46 #[clap(long)] 47 operation: String, 48 /// Path to previous operation json file 49 #[clap(long)] 50 previous: Option<String>, 51 }, 52 Exit, 53} 54 55// Command completer 56struct CommandCompleter; 57 58impl CommandCompleter { 59 fn get_current_word<'a>(&self, line: &'a str, pos: usize) -> (usize, &'a str) { 60 let before = &line[..pos]; 61 // Find the last space before the cursor; if none, start at 0 62 let start = before.rfind(' ').map_or(0, |i| i + 1); 63 let word = &line[start..pos]; 64 (start, word) 65 } 66} 67 68fn make_pair(s: &str) -> Pair { 69 Pair { 70 display: s.to_string(), 71 replacement: s.to_string(), 72 } 73} 74 75impl Completer for CommandCompleter { 76 type Candidate = Pair; 77 78 fn complete( 79 &self, 80 line: &str, 81 pos: usize, 82 _ctx: &Context<'_>, 83 ) -> rustyline::Result<(usize, Vec<Pair>)> { 84 let prefix = line[..pos].trim_start(); 85 86 // Case 1: Empty input, suggest all subcommands 87 if prefix.is_empty() || !prefix.contains(' ') { 88 let suggestions = Cli::command() 89 .get_subcommands() 90 .filter(|cmd| cmd.get_name().starts_with(prefix)) 91 .map(|cmd| make_pair(cmd.get_name())) 92 .collect(); 93 return Ok((0, suggestions)); 94 } 95 96 // Case 3: Subcommand present; suggest possible arguments to complete the command 97 let subcmd_name = prefix.split_whitespace().next().unwrap(); 98 if let Some(subcmd) = Cli::command().find_subcommand(subcmd_name) { 99 let (start, _) = self.get_current_word(line, pos); 100 101 // Collect arguments already present in the line before the cursor 102 let Ok(present_args) = shellwords::split(&line[..start].trim_end()) else { 103 return Ok((0, vec![])); // no suggestions if we can't parse the line 104 }; 105 106 // replace `argument=some_value` with just `argument` for each of present_args 107 let present_args: Vec<String> = present_args 108 .into_iter() 109 .map(|arg| arg.split('=').next().unwrap().to_string()) 110 .collect(); 111 112 // Get all argument continuations 113 let suggestions = subcmd 114 .get_arguments() 115 .filter_map(|arg| arg.get_long().map(|l| l.to_string())) 116 .filter(|arg| !present_args.contains(arg)) 117 .map(|arg| Pair { 118 display: arg.clone(), 119 replacement: arg, 120 }) 121 .collect(); 122 return Ok((start, suggestions)); 123 } 124 125 // Default case: no suggestions 126 Ok((0, vec![])) 127 } 128} 129 130impl Validator for CommandCompleter { 131 fn validate( 132 &self, 133 _ctx: &mut ValidationContext<'_>, 134 ) -> Result<ValidationResult, ReadlineError> { 135 Ok(ValidationResult::Valid(None)) 136 } 137} 138 139impl Highlighter for CommandCompleter { 140 fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { 141 Cow::Borrowed(line) 142 } 143 144 fn highlight_char(&self, _line: &str, _pos: usize, _cmd_kind: CmdKind) -> bool { 145 false 146 } 147} 148 149impl Hinter for CommandCompleter { 150 type Hint = String; 151 152 fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> { 153 None 154 } 155} 156 157impl Helper for CommandCompleter {} 158 159#[derive(Parser)] 160#[command(name = "Vanadium", about = "Run a V-App on Vanadium")] 161struct Args { 162 /// Path to the ELF file of the V-App (if not the default one) 163 app: Option<String>, 164 165 /// Use the HID interface for a real device, instead of Speculos 166 #[arg(long, group = "interface")] 167 hid: bool, 168 169 /// Use the native interface 170 #[arg(long, group = "interface")] 171 native: bool, 172} 173 174// a bit of a hack: we convert the prompt in a format that clap can parse 175// (adding a dummy command, and replacing each 'argument' with '--argument') 176fn prepare_prompt_for_clap(line: &str) -> Result<Vec<String>, String> { 177 let args = shellwords::split(line).map_err(|e| format!("Failed to parse input: {}", e))?; 178 if args.is_empty() { 179 return Err("Empty input".to_string()); 180 } 181 182 // dummy command, and first command unchanged 183 let mut clap_args = vec!["dummy".to_string(), args[0].clone()]; 184 185 // prepend `--` to each subsequent argument 186 for arg in &args[1..] { 187 clap_args.push(format!("--{}", arg)); 188 } 189 Ok(clap_args) 190} 191 192async fn handle_cli_command( 193 app_client: &mut AtprotoAppClient, 194 cli: &Cli, 195) -> Result<(), Box<dyn std::error::Error>> { 196 match &cli.command { 197 CliCommand::GetDidKey { key_index, display } => { 198 let key = app_client 199 .get_did_key(key_index.unwrap_or(0), *display) 200 .await?; 201 println!("did:key:z{}", key.to_base58()); 202 } 203 CliCommand::SignPlcOperation { 204 key_index, 205 operation, 206 previous, 207 } => { 208 let operation = read_operation_file(&operation)?; 209 let previous = if let Some(path) = previous { 210 Some(read_operation_file(&path)?) 211 } else { 212 None 213 }; 214 let sig = app_client 215 .sign_plc_operation(key_index.unwrap_or(0), operation, previous) 216 .await?; 217 println!( 218 "sig: {}", 219 base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(sig) 220 ); 221 } 222 CliCommand::Exit => { 223 app_client.exit().await?; 224 return Err("Exiting".into()); 225 } 226 } 227 Ok(()) 228} 229 230fn read_operation_file(path: &str) -> Result<client::PlcOperation, Box<dyn std::error::Error>> { 231 let mut file = File::open(path)?; 232 let mut contents = String::new(); 233 file.read_to_string(&mut contents)?; 234 let operation: client::PlcOperation = serde_json::from_str(&contents)?; 235 Ok(operation) 236} 237 238#[tokio::main(flavor = "multi_thread")] 239async fn main() -> Result<(), Box<dyn std::error::Error>> { 240 let args = Args::parse(); 241 242 let default_app_path = if args.native { 243 "../target/x86_64-unknown-linux-gnu/release/vnd-atproto" 244 } else { 245 "../target/riscv32imc-unknown-none-elf/release/vnd-atproto" 246 }; 247 248 let app_path_str = args.app.unwrap_or(default_app_path.to_string()); 249 250 let mut app_client = if args.native { 251 AtprotoAppClient::new(Box::new( 252 NativeAppClient::new(&app_path_str) 253 .await 254 .map_err(|_| "Failed to create client")?, 255 )) 256 } else { 257 let transport_raw: Arc< 258 dyn Transport<Error = Box<dyn std::error::Error + Send + Sync>> + Send + Sync, 259 > = if args.hid { 260 Arc::new(TransportHID::new( 261 TransportNativeHID::new( 262 &HidApi::new().expect("Unable to get connect to the device"), 263 ) 264 .unwrap(), 265 )) 266 } else { 267 Arc::new( 268 TransportTcp::new() 269 .await 270 .expect("Unable to get TCP transport. Is speculos running?"), 271 ) 272 }; 273 let transport = TransportWrapper::new(transport_raw.clone()); 274 275 let (client, _) = VanadiumAppClient::new(&app_path_str, Arc::new(transport), None) 276 .await 277 .map_err(|e| format!("Failed to create client: {}", e))?; 278 279 AtprotoAppClient::new(Box::new(client)) 280 }; 281 282 let mut rl = Editor::<CommandCompleter, rustyline::history::DefaultHistory>::new()?; 283 rl.set_helper(Some(CommandCompleter)); 284 285 let _ = rl.load_history("history.txt"); 286 287 loop { 288 match rl.readline("> ") { 289 Ok(line) => { 290 if line.trim().is_empty() { 291 continue; 292 } 293 rl.add_history_entry(line.as_str())?; 294 295 let clap_args = match prepare_prompt_for_clap(&line) { 296 Ok(args) => args, 297 Err(e) => { 298 println!("Error: {}", e); 299 continue; 300 } 301 }; 302 303 match Cli::try_parse_from(clap_args) { 304 Ok(cli) => { 305 if let Err(e) = handle_cli_command(&mut app_client, &cli).await { 306 println!("Error: {}", e); 307 } 308 } 309 Err(e) => println!("Invalid command: {}", e), 310 } 311 } 312 Err(ReadlineError::Interrupted) => println!("Interrupted"), 313 Err(ReadlineError::Eof) => { 314 println!("Exiting"); 315 break; 316 } 317 Err(err) => { 318 println!("Error: {:?}", err); 319 break; 320 } 321 } 322 } 323 324 rl.save_history("history.txt")?; 325 326 app_client.exit().await?; 327 328 Ok(()) 329}