forest/wallet/subcommands/
wallet_cmd.rs

1// Copyright 2019-2025 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use std::{
5    cell::RefCell,
6    path::PathBuf,
7    str::{self, FromStr},
8};
9
10use crate::cli::humantoken::TokenAmountPretty as _;
11use crate::key_management::{Key, KeyInfo};
12use crate::{
13    ENCRYPTED_KEYSTORE_NAME,
14    cli::humantoken,
15    message::SignedMessage,
16    rpc::{
17        mpool::{MpoolGetNonce, MpoolPush, MpoolPushMessage},
18        types::ApiTipsetKey,
19    },
20    shim::address::Address,
21};
22use crate::{KeyStore, lotus_json::LotusJson};
23use crate::{
24    KeyStoreConfig,
25    shim::{
26        address::StrictAddress,
27        crypto::{Signature, SignatureType},
28        econ::TokenAmount,
29        message::{METHOD_SEND, Message},
30    },
31};
32use crate::{
33    lotus_json::HasLotusJson as _,
34    rpc::{self, prelude::*},
35};
36use anyhow::{Context as _, bail};
37use base64::{Engine, prelude::BASE64_STANDARD};
38use clap::{Subcommand, arg};
39use dialoguer::{Password, console::Term, theme::ColorfulTheme};
40use directories::ProjectDirs;
41use num::Zero as _;
42
43// Abstraction over local and remote wallets. A connection to a running Filecoin
44// node is always required for balance queries and for sending messages. When a
45// local wallet is available, no sensitive information will be sent to the
46// remote Filecoin node.
47struct WalletBackend {
48    pub remote: rpc::Client,
49    pub local: Option<KeyStore>,
50}
51
52impl WalletBackend {
53    fn new_remote(client: rpc::Client) -> Self {
54        WalletBackend {
55            remote: client,
56            local: None,
57        }
58    }
59
60    fn new_local(client: rpc::Client, want_encryption: bool) -> anyhow::Result<Self> {
61        let Some(dir) = ProjectDirs::from("com", "ChainSafe", "Forest-Wallet") else {
62            bail!("Failed to find wallet directory");
63        };
64
65        let wallet_dir = dir.data_dir().to_path_buf();
66
67        let is_encrypted = wallet_dir.join(ENCRYPTED_KEYSTORE_NAME).exists();
68
69        // Always use the encrypted keystore if it exists. It it does not exist,
70        // only use encryption when explicitly asked for it.
71        let keystore = if is_encrypted || want_encryption {
72            input_password_to_load_encrypted_keystore(wallet_dir)?
73        } else {
74            KeyStore::new(KeyStoreConfig::Persistent(wallet_dir.to_path_buf()))?
75        };
76
77        Ok(WalletBackend {
78            remote: client,
79            local: Some(keystore),
80        })
81    }
82
83    async fn list_addrs(&self) -> anyhow::Result<Vec<Address>> {
84        if let Some(keystore) = &self.local {
85            Ok(crate::key_management::list_addrs(keystore)?)
86        } else {
87            Ok(WalletList::call(&self.remote, ()).await?)
88        }
89    }
90
91    async fn wallet_export(&self, address: Address) -> anyhow::Result<KeyInfo> {
92        if let Some(keystore) = &self.local {
93            Ok(crate::key_management::export_key_info(&address, keystore)?)
94        } else {
95            Ok(WalletExport::call(&self.remote, (address,)).await?)
96        }
97    }
98
99    async fn wallet_import(&mut self, key_info: KeyInfo) -> anyhow::Result<String> {
100        if let Some(keystore) = &mut self.local {
101            let key = Key::try_from(key_info)?;
102            let addr = format!("wallet-{}", key.address);
103
104            keystore.put(&addr, key.key_info)?;
105            Ok(key.address.to_string())
106        } else {
107            Ok(WalletImport::call(&self.remote, (key_info,))
108                .await?
109                .to_string())
110        }
111    }
112
113    async fn wallet_has(&self, address: Address) -> anyhow::Result<bool> {
114        if let Some(keystore) = &self.local {
115            Ok(crate::key_management::find_key(&address, keystore).is_ok())
116        } else {
117            Ok(WalletHas::call(&self.remote, (address,)).await?)
118        }
119    }
120
121    async fn wallet_delete(&mut self, address: Address) -> anyhow::Result<()> {
122        if let Some(keystore) = &mut self.local {
123            Ok(crate::key_management::remove_key(&address, keystore)?)
124        } else {
125            Ok(WalletDelete::call(&self.remote, (address,)).await?)
126        }
127    }
128
129    async fn wallet_new(&mut self, signature_type: SignatureType) -> anyhow::Result<String> {
130        if let Some(keystore) = &mut self.local {
131            let key = crate::key_management::generate_key(signature_type)?;
132
133            let addr = format!("wallet-{}", key.address);
134            keystore.put(&addr, key.key_info.clone())?;
135            let value = keystore.get("default");
136            if value.is_err() {
137                keystore.put("default", key.key_info)?
138            }
139
140            Ok(key.address.to_string())
141        } else {
142            Ok(WalletNew::call(&self.remote, (signature_type,))
143                .await?
144                .to_string())
145        }
146    }
147
148    async fn wallet_default_address(&self) -> anyhow::Result<Option<String>> {
149        if let Some(keystore) = &self.local {
150            Ok(crate::key_management::get_default(keystore)?.map(|s| s.to_string()))
151        } else {
152            Ok(WalletDefaultAddress::call(&self.remote, ())
153                .await?
154                .map(|it| it.to_string()))
155        }
156    }
157
158    async fn wallet_set_default(&mut self, address: Address) -> anyhow::Result<()> {
159        if let Some(keystore) = &mut self.local {
160            let addr_string = format!("wallet-{address}");
161            let key_info = keystore.get(&addr_string)?;
162            keystore.remove("default")?; // This line should unregister current default key then continue
163            keystore.put("default", key_info)?;
164            Ok(())
165        } else {
166            Ok(WalletSetDefault::call(&self.remote, (address,)).await?)
167        }
168    }
169
170    async fn wallet_sign(&self, address: Address, message: String) -> anyhow::Result<Signature> {
171        if let Some(keystore) = &self.local {
172            let key = crate::key_management::find_key(&address, keystore)?;
173
174            Ok(crate::key_management::sign(
175                *key.key_info.key_type(),
176                key.key_info.private_key(),
177                &BASE64_STANDARD.decode(message)?,
178            )?)
179        } else {
180            Ok(WalletSign::call(&self.remote, (address, message.into_bytes())).await?)
181        }
182    }
183
184    async fn wallet_verify(
185        &self,
186        address: Address,
187        msg: Vec<u8>,
188        signature: Signature,
189    ) -> anyhow::Result<bool> {
190        if self.local.is_some() {
191            Ok(signature.verify(&msg, &address).is_ok())
192        } else {
193            // Relying on a remote server to validate signatures is not secure but it's useful for testing.
194            Ok(WalletVerify::call(&self.remote, (address, msg, signature)).await?)
195        }
196    }
197}
198
199#[derive(Debug, Subcommand)]
200pub enum WalletCommands {
201    /// Create a new wallet
202    New {
203        /// The signature type to use. One of `secp256k1`, `bls` or `delegated`
204        #[arg(default_value = "secp256k1")]
205        signature_type: SignatureType,
206    },
207    /// Get account balance
208    Balance {
209        /// The address of the account to check
210        address: String,
211        /// Output is rounded to 4 significant figures by default.
212        /// Do not round
213        // ENHANCE(aatifsyed): add a --round/--no-round argument pair
214        #[arg(long, alias = "exact-balance")]
215        no_round: bool,
216        /// Output may be given an SI prefix like `atto` by default.
217        /// Do not do this, showing whole FIL at all times.
218        #[arg(long, alias = "fixed-unit")]
219        no_abbrev: bool,
220    },
221    /// Get the default address of the wallet
222    Default,
223    /// Export the wallet's keys
224    Export {
225        /// The address that contains the keys to export
226        address: String,
227    },
228    /// Check if the wallet has a key
229    Has {
230        /// The key to check
231        key: String,
232    },
233    /// Import keys from existing wallet
234    Import {
235        /// The path to the private key
236        path: Option<String>,
237    },
238    /// List addresses of the wallet
239    List {
240        /// Output is rounded to 4 significant figures by default.
241        /// Do not round
242        // ENHANCE(aatifsyed): add a --round/--no-round argument pair
243        #[arg(long, alias = "exact-balance")]
244        no_round: bool,
245        /// Output may be given an SI prefix like `atto` by default.
246        /// Do not do this, showing whole FIL at all times.
247        #[arg(long, alias = "fixed-unit")]
248        no_abbrev: bool,
249    },
250    /// Set the default wallet address
251    SetDefault {
252        /// The given key to set to the default address
253        key: String,
254    },
255    /// Sign a message
256    Sign {
257        /// The hex encoded message to sign
258        #[arg(short)]
259        message: String,
260        /// The address to be used to sign the message
261        #[arg(short)]
262        address: String,
263    },
264    /// Validates whether a given string can be decoded as a well-formed address
265    ValidateAddress {
266        /// The address to be validated
267        address: String,
268    },
269    /// Verify the signature of a message. Returns true if the signature matches
270    /// the message and address
271    Verify {
272        /// The address used to sign the message
273        #[arg(short)]
274        address: String,
275        /// The message to verify
276        #[arg(short)]
277        message: String,
278        /// The signature of the message to verify
279        #[arg(short)]
280        signature: String,
281    },
282    /// Deletes the wallet associated with the given address.
283    Delete {
284        /// The address of the wallet to delete
285        address: String,
286    },
287    /// Send funds between accounts
288    Send {
289        /// optionally specify the account to send funds from (otherwise the default
290        /// one will be used)
291        #[arg(long)]
292        from: Option<String>,
293        target_address: String,
294        #[arg(value_parser = humantoken::parse)]
295        amount: TokenAmount,
296        #[arg(long, value_parser = humantoken::parse, default_value_t = TokenAmount::zero())]
297        gas_feecap: TokenAmount,
298        /// In milliGas
299        #[arg(long, default_value_t = 0)]
300        gas_limit: i64,
301        #[arg(long, value_parser = humantoken::parse, default_value_t = TokenAmount::zero())]
302        gas_premium: TokenAmount,
303    },
304}
305impl WalletCommands {
306    pub async fn run(
307        self,
308        client: rpc::Client,
309        remote_wallet: bool,
310        encrypt: bool,
311    ) -> anyhow::Result<()> {
312        let mut backend = if remote_wallet {
313            WalletBackend::new_remote(client)
314        } else {
315            WalletBackend::new_local(client, encrypt)?
316        };
317        match self {
318            Self::New { signature_type } => {
319                let addr: String = backend.wallet_new(signature_type).await?;
320                println!("{addr}");
321                Ok(())
322            }
323            Self::Balance {
324                address,
325                no_round,
326                no_abbrev,
327            } => {
328                let StrictAddress(address) = StrictAddress::from_str(&address)
329                    .with_context(|| format!("Invalid address: {address}"))?;
330                let balance = WalletBalance::call(&backend.remote, (address,)).await?;
331                println!("{}", format_balance(&balance, no_round, no_abbrev));
332                Ok(())
333            }
334            Self::Default => {
335                let default_addr = backend
336                    .wallet_default_address()
337                    .await?
338                    .context("No default wallet address set")?;
339                println!("{default_addr}");
340                Ok(())
341            }
342            Self::Export {
343                address: address_string,
344            } => {
345                let StrictAddress(address) = StrictAddress::from_str(&address_string)
346                    .with_context(|| format!("Invalid address: {address_string}"))?;
347                let key_info = backend.wallet_export(address).await?;
348                let encoded_key = key_info.into_lotus_json_string()?;
349                println!("{}", hex::encode(encoded_key));
350                Ok(())
351            }
352            Self::Has { key } => {
353                let StrictAddress(address) = StrictAddress::from_str(&key)
354                    .with_context(|| format!("Invalid address: {key}"))?;
355
356                println!("{response}", response = backend.wallet_has(address).await?);
357                Ok(())
358            }
359            Self::Delete { address } => {
360                let StrictAddress(address) = StrictAddress::from_str(&address)
361                    .with_context(|| format!("Invalid address: {address}"))?;
362
363                backend.wallet_delete(address).await?;
364                println!("deleted {address}.");
365                Ok(())
366            }
367            Self::Import { path } => {
368                let key = match path {
369                    Some(path) => std::fs::read_to_string(path)?,
370                    _ => {
371                        let term = Term::stderr();
372                        if term.is_term() {
373                            tokio::task::spawn_blocking(|| {
374                                Password::with_theme(&ColorfulTheme::default())
375                                    .allow_empty_password(true)
376                                    .with_prompt("Enter the private key")
377                                    .interact()
378                            })
379                            .await??
380                        } else {
381                            let mut buffer = String::new();
382                            std::io::stdin().read_line(&mut buffer)?;
383                            buffer
384                        }
385                    }
386                };
387
388                let key = key.trim();
389
390                let decoded_key = hex::decode(key).context("Key must be hex encoded")?;
391
392                let key_str = str::from_utf8(&decoded_key)?;
393
394                let LotusJson(key_info) = serde_json::from_str::<LotusJson<KeyInfo>>(key_str)
395                    .context("invalid key format")?;
396
397                let key = backend.wallet_import(key_info).await?;
398
399                println!("{key}");
400                Ok(())
401            }
402            Self::List {
403                no_round,
404                no_abbrev,
405            } => {
406                let key_pairs = backend.list_addrs().await?;
407                let default = backend.wallet_default_address().await?;
408
409                let max_addr_len = key_pairs
410                    .iter()
411                    .map(|addr| addr.to_string().len())
412                    .max()
413                    .unwrap_or(42);
414
415                println!(
416                    "{:<width_addr$} {:<width_default$} Balance",
417                    "Address",
418                    "Default",
419                    width_addr = max_addr_len,
420                    width_default = 7,
421                );
422
423                for address in key_pairs {
424                    let default_address_mark = if default.as_ref() == Some(&address.to_string()) {
425                        "X"
426                    } else {
427                        ""
428                    };
429
430                    let balance_token_amount =
431                        WalletBalance::call(&backend.remote, (address,)).await?;
432
433                    let balance_string = format_balance(&balance_token_amount, no_round, no_abbrev);
434
435                    println!(
436                        "{:<width_addr$} {:<width_default$} {}",
437                        address.to_string(),
438                        default_address_mark,
439                        balance_string,
440                        width_addr = max_addr_len,
441                        width_default = 7,
442                    );
443                }
444                Ok(())
445            }
446            Self::SetDefault { key } => {
447                let StrictAddress(key) = StrictAddress::from_str(&key)
448                    .with_context(|| format!("Invalid address: {key}"))?;
449
450                backend.wallet_set_default(key).await
451            }
452            Self::Sign { address, message } => {
453                let StrictAddress(address) = StrictAddress::from_str(&address)
454                    .with_context(|| format!("Invalid address: {address}"))?;
455
456                let message = hex::decode(message).context("Message has to be a hex string")?;
457                let message = BASE64_STANDARD.encode(message);
458
459                let signature = backend.wallet_sign(address, message).await?;
460                println!("{}", hex::encode(signature.to_bytes()));
461                Ok(())
462            }
463            Self::ValidateAddress { address } => {
464                let response = WalletValidateAddress::call(&backend.remote, (address,)).await?;
465                println!("{response}");
466                Ok(())
467            }
468            Self::Verify {
469                message,
470                address,
471                signature,
472            } => {
473                let sig_bytes =
474                    hex::decode(signature).context("Signature has to be a hex string")?;
475                let StrictAddress(address) = StrictAddress::from_str(&address)
476                    .with_context(|| format!("Invalid address: {address}"))?;
477                let msg = hex::decode(message).context("Message has to be a hex string")?;
478
479                let signature = Signature::from_bytes(sig_bytes)?;
480                let is_valid = backend.wallet_verify(address, msg, signature).await?;
481
482                println!("{is_valid}");
483                Ok(())
484            }
485            Self::Send {
486                from,
487                target_address,
488                amount,
489                gas_feecap,
490                gas_limit,
491                gas_premium,
492            } => {
493                let from: Address = if let Some(from) = from {
494                    StrictAddress::from_str(&from)?.into()
495                } else {
496                    StrictAddress::from_str(&backend.wallet_default_address().await?.context(
497                        "No default wallet address selected. Please set a default address.",
498                    )?)?
499                    .into()
500                };
501
502                let message = Message {
503                    from,
504                    to: StrictAddress::from_str(&target_address)?.into(),
505                    value: amount,
506                    method_num: METHOD_SEND,
507                    gas_limit: gas_limit as u64,
508                    gas_fee_cap: gas_feecap,
509                    gas_premium,
510                    ..Default::default()
511                };
512
513                let signed_msg = if let Some(keystore) = &backend.local {
514                    let spec = None;
515                    let mut message = GasEstimateMessageGas::call(
516                        &backend.remote,
517                        (message, spec, ApiTipsetKey(None)),
518                    )
519                    .await?;
520
521                    if message.gas_premium > message.gas_fee_cap {
522                        anyhow::bail!("After estimation, gas premium is greater than gas fee cap")
523                    }
524
525                    message.sequence = MpoolGetNonce::call(&backend.remote, (from,)).await?;
526
527                    let key = crate::key_management::find_key(&from, keystore)?;
528                    let sig = crate::key_management::sign(
529                        *key.key_info.key_type(),
530                        key.key_info.private_key(),
531                        message.cid().to_bytes().as_slice(),
532                    )?;
533
534                    let smsg = SignedMessage::new_from_parts(message, sig)?;
535
536                    MpoolPush::call(&backend.remote, (smsg.clone(),)).await?;
537                    smsg
538                } else {
539                    MpoolPushMessage::call(&backend.remote, (message, None)).await?
540                };
541
542                println!("{}", signed_msg.cid());
543
544                Ok(())
545            }
546        }
547    }
548}
549
550/// Prompts for password, looping until the [`KeyStore`] is successfully loaded.
551///
552/// This code makes blocking syscalls.
553fn input_password_to_load_encrypted_keystore(data_dir: PathBuf) -> dialoguer::Result<KeyStore> {
554    let keystore = RefCell::new(None);
555    let term = Term::stderr();
556
557    // Unlike `dialoguer::Confirm`, `dialoguer::Password` doesn't fail if the terminal is not a tty
558    // so do that check ourselves.
559    // This means users can't pipe their password from stdin.
560    if !term.is_term() {
561        return Err(std::io::Error::new(
562            std::io::ErrorKind::NotConnected,
563            "cannot read password from non-terminal",
564        )
565        .into());
566    }
567
568    dialoguer::Password::new()
569        .with_prompt("Enter the password for the wallet keystore")
570        .allow_empty_password(true) // let validator do validation
571        .validate_with(|input: &String| {
572            KeyStore::new(KeyStoreConfig::Encrypted(data_dir.clone(), input.clone()))
573                .map(|created| *keystore.borrow_mut() = Some(created))
574                .context(
575                    "Error: couldn't load keystore with this password. Try again or press Ctrl+C to abort.",
576                )
577        })
578        .interact_on(&term)?;
579
580    Ok(keystore
581        .into_inner()
582        .expect("validation succeeded, so keystore must be emplaced"))
583}
584
585fn format_balance(balance: &TokenAmount, no_round: bool, no_abbrev: bool) -> String {
586    match (no_round, no_abbrev) {
587        // no_round, absolute
588        (true, true) => format!("{:#}", balance.pretty()),
589        // no_round, relative
590        (true, false) => format!("{}", balance.pretty()),
591        // round, absolute
592        (false, true) => format!("{:#.4}", balance.pretty()),
593        // round, relative
594        (false, false) => format!("{:.4}", balance.pretty()),
595    }
596}