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