/ fedimint-cli / src / lib.rs
lib.rs
   1  mod client;
   2  mod db_locked;
   3  pub mod envs;
   4  mod utils;
   5  
   6  use core::fmt;
   7  use std::collections::BTreeMap;
   8  use std::fmt::Debug;
   9  use std::io::{Read, Write};
  10  use std::path::{Path, PathBuf};
  11  use std::process::exit;
  12  use std::str::FromStr;
  13  use std::sync::Arc;
  14  use std::time::Duration;
  15  use std::{fs, result};
  16  
  17  use anyhow::format_err;
  18  use bip39::Mnemonic;
  19  use clap::{Args, CommandFactory, Parser, Subcommand};
  20  use db_locked::LockedBuilder;
  21  use fedimint_aead::{encrypted_read, encrypted_write, get_encryption_key};
  22  use fedimint_api_client::api::{
  23      DynGlobalApi, FederationApiExt, FederationError, IRawFederationApi, WsFederationApi,
  24  };
  25  use fedimint_bip39::Bip39RootSecretStrategy;
  26  use fedimint_client::module::init::{ClientModuleInit, ClientModuleInitRegistry};
  27  use fedimint_client::module::ClientModule as _;
  28  use fedimint_client::secret::{get_default_client_secret, RootSecretStrategy};
  29  use fedimint_client::{AdminCreds, Client, ClientBuilder, ClientHandleArc};
  30  use fedimint_core::admin_client::{ConfigGenConnectionsRequest, ConfigGenParamsRequest};
  31  use fedimint_core::config::{
  32      ClientConfig, FederationId, FederationIdPrefix, ServerModuleConfigGenParamsRegistry,
  33  };
  34  use fedimint_core::db::{Database, DatabaseValue};
  35  use fedimint_core::invite_code::InviteCode;
  36  use fedimint_core::module::{ApiAuth, ApiRequestErased};
  37  use fedimint_core::util::{handle_version_hash_command, retry, ConstantBackoff, SafeUrl};
  38  use fedimint_core::{fedimint_build_code_version_env, runtime, PeerId, TieredMulti};
  39  use fedimint_ln_client::LightningClientInit;
  40  use fedimint_logging::{TracingSetup, LOG_CLIENT};
  41  use fedimint_meta_client::MetaClientInit;
  42  use fedimint_mint_client::{MintClientInit, MintClientModule, OOBNotes, SpendableNote};
  43  use fedimint_server::config::io::SALT_FILE;
  44  use fedimint_wallet_client::api::WalletFederationApi;
  45  use fedimint_wallet_client::{WalletClientInit, WalletClientModule};
  46  use futures::future::pending;
  47  use rand::thread_rng;
  48  use serde::{Deserialize, Serialize};
  49  use serde_json::Value;
  50  use thiserror::Error;
  51  use tracing::{debug, info};
  52  use utils::parse_peer_id;
  53  
  54  use crate::client::ClientCmd;
  55  use crate::envs::{FM_CLIENT_DIR_ENV, FM_OUR_ID_ENV, FM_PASSWORD_ENV};
  56  
  57  /// Type of output the cli produces
  58  #[derive(Serialize)]
  59  #[serde(rename_all = "snake_case")]
  60  #[serde(untagged)]
  61  enum CliOutput {
  62      VersionHash {
  63          hash: String,
  64      },
  65  
  66      UntypedApiOutput {
  67          value: Value,
  68      },
  69  
  70      WaitBlockCount {
  71          reached: u64,
  72      },
  73  
  74      InviteCode {
  75          invite_code: InviteCode,
  76      },
  77  
  78      DecodeInviteCode {
  79          url: SafeUrl,
  80          federation_id: FederationId,
  81      },
  82  
  83      JoinFederation {
  84          joined: String,
  85      },
  86  
  87      DecodeTransaction {
  88          transaction: String,
  89      },
  90  
  91      EpochCount {
  92          count: u64,
  93      },
  94  
  95      ConfigDecrypt,
  96  
  97      ConfigEncrypt,
  98  
  99      Raw(serde_json::Value),
 100  }
 101  
 102  impl fmt::Display for CliOutput {
 103      fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 104          write!(f, "{}", serde_json::to_string_pretty(self).unwrap())
 105      }
 106  }
 107  
 108  /// `Result` with `CliError` as `Error`
 109  type CliResult<E> = Result<E, CliError>;
 110  
 111  /// `Result` with `CliError` as `Error` and `CliOutput` as `Ok`
 112  type CliOutputResult = Result<CliOutput, CliError>;
 113  
 114  /// Cli error
 115  #[derive(Serialize, Error)]
 116  #[serde(tag = "error", rename_all(serialize = "snake_case"))]
 117  struct CliError {
 118      error: String,
 119  }
 120  
 121  /// Extension trait making turning Results/Errors into
 122  /// [`CliError`]/[`CliOutputResult`] easier
 123  trait CliResultExt<O, E> {
 124      /// Map error into `CliError` wrapping the original error message
 125      fn map_err_cli(self) -> Result<O, CliError>;
 126      /// Map error into `CliError` using custom error message `msg`
 127      fn map_err_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError>;
 128  }
 129  
 130  impl<O, E> CliResultExt<O, E> for result::Result<O, E>
 131  where
 132      E: Into<anyhow::Error>,
 133  {
 134      fn map_err_cli(self) -> Result<O, CliError> {
 135          self.map_err(|e| {
 136              let e = e.into();
 137              CliError {
 138                  error: e.to_string(),
 139              }
 140          })
 141      }
 142  
 143      fn map_err_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError> {
 144          self.map_err(|_| CliError { error: msg.into() })
 145      }
 146  }
 147  
 148  /// Extension trait to make turning `Option`s into
 149  /// [`CliError`]/[`CliOutputResult`] easier
 150  trait CliOptionExt<O> {
 151      fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError>;
 152  }
 153  
 154  impl<O> CliOptionExt<O> for Option<O> {
 155      fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError> {
 156          self.ok_or_else(|| CliError { error: msg.into() })
 157      }
 158  }
 159  
 160  // TODO: Refactor federation API errors to just delegate to this
 161  impl From<FederationError> for CliError {
 162      fn from(e: FederationError) -> Self {
 163          CliError {
 164              error: e.to_string(),
 165          }
 166      }
 167  }
 168  
 169  impl Debug for CliError {
 170      fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 171          f.debug_struct("CliError")
 172              .field("error", &self.error)
 173              .finish()
 174      }
 175  }
 176  
 177  impl fmt::Display for CliError {
 178      fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
 179          let json = serde_json::to_value(self).expect("CliError is valid json");
 180          let json_as_string =
 181              serde_json::to_string_pretty(&json).expect("valid json is serializable");
 182          write!(f, "{}", json_as_string)
 183      }
 184  }
 185  
 186  #[derive(Parser, Clone)]
 187  #[command(version)]
 188  struct Opts {
 189      /// The working directory of the client containing the config and db
 190      #[arg(long = "data-dir", env = FM_CLIENT_DIR_ENV)]
 191      data_dir: Option<PathBuf>,
 192  
 193      /// Peer id of the guardian
 194      #[arg(env = FM_OUR_ID_ENV, long, value_parser = parse_peer_id)]
 195      our_id: Option<PeerId>,
 196  
 197      /// Guardian password for authentication
 198      #[arg(long, env = FM_PASSWORD_ENV)]
 199      password: Option<String>,
 200  
 201      /// Activate more verbose logging, for full control use the RUST_LOG env
 202      /// variable
 203      #[arg(short = 'v', long)]
 204      verbose: bool,
 205  
 206      #[clap(subcommand)]
 207      command: Command,
 208  }
 209  
 210  impl Opts {
 211      fn data_dir(&self) -> CliResult<&PathBuf> {
 212          self.data_dir
 213              .as_ref()
 214              .ok_or_cli_msg("`--data-dir=` argument not set.")
 215      }
 216  
 217      /// Get and create if doesn't exist the data dir
 218      async fn data_dir_create(&self) -> CliResult<&PathBuf> {
 219          let dir = self.data_dir()?;
 220  
 221          tokio::fs::create_dir_all(&dir).await.map_err_cli()?;
 222  
 223          Ok(dir)
 224      }
 225  
 226      fn admin_client(&self, cfg: &ClientConfig) -> CliResult<DynGlobalApi> {
 227          let our_id = self.our_id.ok_or_cli_msg("Admin client needs our-id set")?;
 228          Ok(DynGlobalApi::from_config_admin(cfg, our_id))
 229      }
 230  
 231      fn auth(&self) -> CliResult<ApiAuth> {
 232          let password = self
 233              .password
 234              .clone()
 235              .ok_or_cli_msg("CLI needs password set")?;
 236          Ok(ApiAuth(password))
 237      }
 238  
 239      async fn load_rocks_db(&self) -> CliResult<Database> {
 240          debug!(target: LOG_CLIENT, "Loading client database");
 241          let db_path = self.data_dir_create().await?.join("client.db");
 242          let lock_path = db_path.with_extension("db.lock");
 243          Ok(LockedBuilder::new(&lock_path)
 244              .await
 245              .map_err_cli_msg("could not lock database")?
 246              .with_db(
 247                  fedimint_rocksdb::RocksDb::open(db_path)
 248                      .map_err_cli_msg("could not open database")?,
 249              )
 250              .into())
 251      }
 252  }
 253  
 254  async fn load_or_generate_mnemonic(db: &Database) -> Result<Mnemonic, CliError> {
 255      Ok(
 256          match Client::load_decodable_client_secret::<Vec<u8>>(db).await {
 257              Ok(entropy) => Mnemonic::from_entropy(&entropy).map_err_cli()?,
 258              Err(_) => {
 259                  info!("Generating mnemonic and writing entropy to client storage");
 260                  let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut thread_rng());
 261                  Client::store_encodable_client_secret(db, mnemonic.to_entropy())
 262                      .await
 263                      .map_err_cli()?;
 264                  mnemonic
 265              }
 266          },
 267      )
 268  }
 269  
 270  #[derive(Subcommand, Clone)]
 271  enum Command {
 272      /// Print the latest Git commit hash this bin. was built with.
 273      VersionHash,
 274  
 275      #[clap(flatten)]
 276      Client(client::ClientCmd),
 277  
 278      #[clap(subcommand)]
 279      Admin(AdminCmd),
 280  
 281      #[clap(subcommand)]
 282      Dev(DevCmd),
 283  
 284      /// Config enabling client to establish websocket connection to federation
 285      InviteCode {
 286          peer: PeerId,
 287      },
 288  
 289      /// Join a federation using it's InviteCode
 290      JoinFederation {
 291          invite_code: String,
 292      },
 293  
 294      Completion {
 295          shell: clap_complete::Shell,
 296      },
 297  }
 298  
 299  #[derive(Debug, Clone, Subcommand)]
 300  enum AdminCmd {
 301      /// Show the status according to the `status` endpoint
 302      Status,
 303  
 304      /// Show an audit across all modules
 305      Audit,
 306  
 307      /// Download guardian config to back it up
 308      GuardianConfigBackup,
 309  
 310      Dkg(DkgAdminArgs),
 311  }
 312  
 313  #[derive(Debug, Clone, Args)]
 314  struct DkgAdminArgs {
 315      #[arg(long, env = "FM_WS_URL")]
 316      ws: SafeUrl,
 317  
 318      #[clap(subcommand)]
 319      subcommand: DkgAdminCmd,
 320  }
 321  
 322  impl DkgAdminArgs {
 323      fn ws_admin_client(&self) -> CliResult<DynGlobalApi> {
 324          let ws = self.ws.clone();
 325          Ok(DynGlobalApi::from_pre_peer_id_admin_endpoint(ws))
 326      }
 327  }
 328  
 329  #[derive(Debug, Clone, Subcommand)]
 330  enum DkgAdminCmd {
 331      // These commands are roughly in the order they should be called
 332      /// Allow to access the `status` endpoint in a pre-dkg phase
 333      WsStatus,
 334      SetPassword,
 335      GetDefaultConfigGenParams,
 336      SetConfigGenParams {
 337          /// Guardian-defined key-value pairs that will be passed to the client
 338          /// Must be a valid JSON object (Map<String, String>)
 339          #[clap(long)]
 340          meta_json: String,
 341          /// Set the params (if leader) or just the local params (if follower)
 342          #[clap(long)]
 343          modules_json: String,
 344      },
 345      SetConfigGenConnections {
 346          /// Our guardian name
 347          #[clap(long)]
 348          our_name: String,
 349          /// URL of "leader" guardian to send our connection info to
 350          /// Will be `None` if we are the leader
 351          #[clap(long)]
 352          leader_api_url: Option<SafeUrl>,
 353      },
 354      GetConfigGenPeers,
 355      ConsensusConfigGenParams,
 356      RunDkg,
 357      GetVerifyConfigHash,
 358      StartConsensus,
 359  }
 360  
 361  #[derive(Debug, Clone, Subcommand)]
 362  enum DecodeType {
 363      /// Decode an invite code string into a JSON representation
 364      InviteCode { invite_code: InviteCode },
 365      /// Decode a string of ecash notes into a JSON representation
 366      Notes { notes: OOBNotes },
 367  }
 368  
 369  #[derive(Debug, Clone, Deserialize, Serialize)]
 370  struct OOBNotesJson {
 371      federation_id_prefix: String,
 372      notes: TieredMulti<SpendableNote>,
 373  }
 374  
 375  #[derive(Debug, Clone, Subcommand)]
 376  enum EncodeType {
 377      /// Encode connection info from its constituent parts
 378      InviteCode {
 379          #[clap(long)]
 380          url: SafeUrl,
 381          #[clap(long = "federation_id")]
 382          federation_id: FederationId,
 383          #[clap(long = "peer")]
 384          peer: PeerId,
 385      },
 386  
 387      /// Encode a JSON string of notes to an ecash string
 388      Notes { notes_json: String },
 389  }
 390  
 391  #[derive(Debug, Clone, Subcommand)]
 392  enum DevCmd {
 393      /// Send direct method call to the API. If you specify --peer-id, it will
 394      /// just ask one server, otherwise it will try to get consensus from all
 395      /// servers.
 396      #[command(after_long_help = r#"
 397  Examples:
 398  
 399    fedimint-cli dev api --peer-id 0 config '"fed114znk7uk7ppugdjuytr8venqf2tkywd65cqvg3u93um64tu5cw4yr0n3fvn7qmwvm4g48cpndgnm4gqq4waen5te0xyerwt3s9cczuvf6xyurzde597s7crdvsk2vmyarjw9gwyqjdzj"'
 400      "#)]
 401      Api {
 402          /// JSON-RPC method to call
 403          method: String,
 404          /// JSON-RPC parameters for the request
 405          ///
 406          /// Note: single jsonrpc argument params string, which might require
 407          /// double-quotes (see example above).
 408          #[clap(default_value = "null")]
 409          params: String,
 410          /// Which server to send request to
 411          #[clap(long = "peer-id")]
 412          peer_id: Option<u16>,
 413          /// Guardian password in case authenticated API endpoints are being
 414          /// called. Only use together with --peer-id.
 415          #[clap(long, requires = "peer_id")]
 416          password: Option<String>,
 417      },
 418  
 419      /// Wait for the fed to reach a consensus block count
 420      WaitBlockCount { count: u64 },
 421  
 422      /// Just start the `Client` and wait
 423      Wait {
 424          /// Limit the wait time
 425          seconds: Option<f32>,
 426      },
 427  
 428      /// Wait for all state machines to complete
 429      WaitComplete,
 430  
 431      /// Decode invite code or ecash notes string into a JSON representation
 432      Decode {
 433          #[clap(subcommand)]
 434          decode_type: DecodeType,
 435      },
 436  
 437      /// Encode an invite code or ecash notes into binary
 438      Encode {
 439          #[clap(subcommand)]
 440          encode_type: EncodeType,
 441      },
 442  
 443      /// Gets the current fedimint AlephBFT block count
 444      SessionCount,
 445  
 446      ConfigDecrypt {
 447          /// Encrypted config file
 448          #[arg(long = "in-file")]
 449          in_file: PathBuf,
 450          /// Plaintext config file output
 451          #[arg(long = "out-file")]
 452          out_file: PathBuf,
 453          /// Encryption salt file, otherwise defaults to the salt file from the
 454          /// in_file directory
 455          #[arg(long = "salt-file")]
 456          salt_file: Option<PathBuf>,
 457          /// The password that encrypts the configs
 458          #[arg(env = FM_PASSWORD_ENV)]
 459          password: String,
 460      },
 461  
 462      ConfigEncrypt {
 463          /// Plaintext config file
 464          #[arg(long = "in-file")]
 465          in_file: PathBuf,
 466          /// Encrypted config file output
 467          #[arg(long = "out-file")]
 468          out_file: PathBuf,
 469          /// Encryption salt file, otherwise defaults to the salt file from the
 470          /// out_file directory
 471          #[arg(long = "salt-file")]
 472          salt_file: Option<PathBuf>,
 473          /// The password that encrypts the configs
 474          #[arg(env = FM_PASSWORD_ENV)]
 475          password: String,
 476      },
 477  
 478      /// Decode a transaction hex string and print it to stdout
 479      DecodeTransaction { hex_string: String },
 480  }
 481  
 482  #[derive(Debug, Serialize, Deserialize)]
 483  #[serde(rename_all = "snake_case")]
 484  struct PayRequest {
 485      notes: TieredMulti<SpendableNote>,
 486      invoice: lightning_invoice::Bolt11Invoice,
 487  }
 488  
 489  pub struct FedimintCli {
 490      module_inits: ClientModuleInitRegistry,
 491      cli_args: Opts,
 492  }
 493  
 494  impl FedimintCli {
 495      /// Build a new `fedimintd` with a custom version hash
 496      pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
 497          assert_eq!(
 498              fedimint_build_code_version_env!().len(),
 499              version_hash.len(),
 500              "version_hash must have an expected length"
 501          );
 502  
 503          handle_version_hash_command(version_hash);
 504  
 505          let cli_args = Opts::parse();
 506          let base_level = if cli_args.verbose { "info" } else { "warn" };
 507          TracingSetup::default()
 508              .with_base_level(base_level)
 509              .init()
 510              .expect("tracing initializes");
 511  
 512          let version = env!("CARGO_PKG_VERSION");
 513          debug!("Starting fedimint-cli (version: {version} version_hash: {version_hash})");
 514  
 515          Ok(Self {
 516              module_inits: ClientModuleInitRegistry::new(),
 517              cli_args,
 518          })
 519      }
 520  
 521      pub fn with_module<T>(mut self, gen: T) -> Self
 522      where
 523          T: ClientModuleInit + 'static + Send + Sync,
 524      {
 525          self.module_inits.attach(gen);
 526          self
 527      }
 528  
 529      pub fn with_default_modules(self) -> Self {
 530          self.with_module(LightningClientInit::default())
 531              .with_module(MintClientInit)
 532              .with_module(WalletClientInit::default())
 533              .with_module(MetaClientInit)
 534      }
 535  
 536      pub async fn run(&mut self) {
 537          match self.handle_command(self.cli_args.clone()).await {
 538              Ok(output) => {
 539                  // ignore if there's anyone reading the stuff we're writing out
 540                  let _ = writeln!(std::io::stdout(), "{output}");
 541              }
 542              Err(err) => {
 543                  debug!(err = %err.error, "Command failed");
 544                  let _ = writeln!(std::io::stdout(), "{err}");
 545                  exit(1);
 546              }
 547          }
 548      }
 549  
 550      async fn make_client_builder(&self, cli: &Opts) -> CliResult<ClientBuilder> {
 551          let db = cli.load_rocks_db().await?;
 552          let mut client_builder = Client::builder(db);
 553          client_builder.with_module_inits(self.module_inits.clone());
 554          client_builder.with_primary_module(1);
 555  
 556          Ok(client_builder)
 557      }
 558  
 559      async fn client_join(
 560          &mut self,
 561          cli: &Opts,
 562          invite_code: InviteCode,
 563      ) -> CliResult<ClientHandleArc> {
 564          let client_config = fedimint_api_client::download_from_invite_code(&invite_code)
 565              .await
 566              .map_err_cli()?;
 567  
 568          let client_builder = self.make_client_builder(cli).await?;
 569  
 570          let mnemonic = load_or_generate_mnemonic(client_builder.db_no_decoders()).await?;
 571  
 572          client_builder
 573              .join(
 574                  get_default_client_secret(
 575                      &Bip39RootSecretStrategy::<12>::to_root_secret(&mnemonic),
 576                      &client_config.global.calculate_federation_id(),
 577                  ),
 578                  client_config.clone(),
 579              )
 580              .await
 581              .map(Arc::new)
 582              .map_err_cli()
 583      }
 584  
 585      async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
 586          let mut client_builder = self.make_client_builder(cli).await?;
 587  
 588          if let Some(our_id) = cli.our_id {
 589              client_builder.set_admin_creds(AdminCreds {
 590                  peer_id: our_id,
 591                  auth: cli.auth()?,
 592              });
 593          }
 594  
 595          let mnemonic = Mnemonic::from_entropy(
 596              &Client::load_decodable_client_secret::<Vec<u8>>(client_builder.db_no_decoders())
 597                  .await
 598                  .map_err_cli()?,
 599          )
 600          .map_err_cli()?;
 601  
 602          let config = client_builder.load_existing_config().await.map_err_cli()?;
 603  
 604          let federation_id = config.calculate_federation_id();
 605  
 606          client_builder
 607              .open(get_default_client_secret(
 608                  &Bip39RootSecretStrategy::<12>::to_root_secret(&mnemonic),
 609                  &federation_id,
 610              ))
 611              .await
 612              .map(Arc::new)
 613              .map_err_cli()
 614      }
 615  
 616      async fn client_recover(
 617          &mut self,
 618          cli: &Opts,
 619          mnemonic: Mnemonic,
 620          invite_code: InviteCode,
 621      ) -> CliResult<ClientHandleArc> {
 622          let builder = self.make_client_builder(cli).await?;
 623  
 624          let client_config = fedimint_api_client::download_from_invite_code(&invite_code)
 625              .await
 626              .map_err_cli()?;
 627  
 628          match Client::load_decodable_client_secret_opt::<Vec<u8>>(builder.db_no_decoders())
 629              .await
 630              .map_err_cli()?
 631          {
 632              Some(existing) => {
 633                  if existing != mnemonic.to_entropy() {
 634                      Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
 635                  }
 636              }
 637              None => {
 638                  Client::store_encodable_client_secret(
 639                      builder.db_no_decoders(),
 640                      mnemonic.to_entropy(),
 641                  )
 642                  .await
 643                  .map_err_cli()?;
 644              }
 645          }
 646  
 647          let root_secret = get_default_client_secret(
 648              &Bip39RootSecretStrategy::<12>::to_root_secret(&mnemonic),
 649              &client_config.calculate_federation_id(),
 650          );
 651          let backup = builder
 652              .download_backup_from_federation(&root_secret, &client_config)
 653              .await
 654              .map_err_cli()?;
 655          builder
 656              .recover(root_secret, client_config.to_owned(), backup)
 657              .await
 658              .map(Arc::new)
 659              .map_err_cli()
 660      }
 661  
 662      async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
 663          match cli.command.clone() {
 664              Command::InviteCode { peer } => {
 665                  let db = cli.load_rocks_db().await?;
 666                  let client_config = Client::get_config_from_db(&db)
 667                      .await
 668                      .ok_or_cli_msg("client config code not found")?;
 669  
 670                  let invite_code = client_config
 671                      .invite_code(&peer)
 672                      .ok_or_cli_msg("peer not found")?;
 673  
 674                  Ok(CliOutput::InviteCode { invite_code })
 675              }
 676              Command::JoinFederation { invite_code } => {
 677                  {
 678                      let invite_code: InviteCode = InviteCode::from_str(&invite_code)
 679                          .map_err_cli_msg("invalid invite code")?;
 680  
 681                      // Build client and store config in DB
 682                      let _client = self.client_join(&cli, invite_code).await?;
 683                  }
 684  
 685                  Ok(CliOutput::JoinFederation {
 686                      joined: invite_code,
 687                  })
 688              }
 689              Command::VersionHash => Ok(CliOutput::VersionHash {
 690                  hash: fedimint_build_code_version_env!().to_string(),
 691              }),
 692              Command::Client(ClientCmd::Restore {
 693                  mnemonic,
 694                  invite_code,
 695              }) => {
 696                  let invite_code: InviteCode =
 697                      InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
 698                  let mnemonic = Mnemonic::from_str(&mnemonic).map_err_cli()?;
 699                  let client = self.client_recover(&cli, mnemonic, invite_code).await?;
 700  
 701                  // TODO: until we implement recovery for other modules we can't really wait
 702                  // for more than this one
 703                  debug!("Waiting for mint module recovery to finish");
 704                  client
 705                      .wait_for_module_kind_recovery(MintClientModule::kind())
 706                      .await
 707                      .map_err_cli()?;
 708  
 709                  debug!("Recovery complete");
 710  
 711                  Ok(CliOutput::Raw(serde_json::to_value(()).unwrap()))
 712              }
 713              Command::Client(command) => {
 714                  let client = self.client_open(&cli).await?;
 715                  Ok(CliOutput::Raw(
 716                      client::handle_command(command, client)
 717                          .await
 718                          .map_err_cli()?,
 719                  ))
 720              }
 721              Command::Admin(AdminCmd::Audit) => {
 722                  let client = self.client_open(&cli).await?;
 723  
 724                  let audit = cli
 725                      .admin_client(client.get_config())?
 726                      .audit(cli.auth()?)
 727                      .await?;
 728                  Ok(CliOutput::Raw(
 729                      serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
 730                  ))
 731              }
 732              Command::Admin(AdminCmd::Status) => {
 733                  let client = self.client_open(&cli).await?;
 734  
 735                  let status = cli.admin_client(client.get_config())?.status().await?;
 736                  Ok(CliOutput::Raw(
 737                      serde_json::to_value(status).map_err_cli_msg("invalid response")?,
 738                  ))
 739              }
 740              Command::Admin(AdminCmd::GuardianConfigBackup) => {
 741                  let client = self.client_open(&cli).await?;
 742  
 743                  let guardian_config_backup = cli
 744                      .admin_client(client.get_config())?
 745                      .guardian_config_backup(cli.auth()?)
 746                      .await?;
 747                  Ok(CliOutput::Raw(
 748                      serde_json::to_value(guardian_config_backup)
 749                          .map_err_cli_msg("invalid response")?,
 750                  ))
 751              }
 752              Command::Admin(AdminCmd::Dkg(dkg_args)) => {
 753                  self.handle_admin_dkg_command(cli, dkg_args).await
 754              }
 755              Command::Dev(DevCmd::Api {
 756                  method,
 757                  params,
 758                  peer_id,
 759                  password: auth,
 760              }) => {
 761                  //Parse params to JSON.
 762                  //If fails, convert to JSON string.
 763                  let params = serde_json::from_str::<Value>(&params).unwrap_or_else(|err| {
 764                      debug!(
 765                          "Failed to serialize params:{}. Converting it to JSON string",
 766                          err
 767                      );
 768  
 769                      serde_json::Value::String(params)
 770                  });
 771  
 772                  let mut params = ApiRequestErased::new(params);
 773                  if let Some(auth) = auth {
 774                      params = params.with_auth(ApiAuth(auth))
 775                  }
 776                  let client = self.client_open(&cli).await?;
 777  
 778                  let ws_api: Arc<_> = WsFederationApi::from_config(client.get_config()).into();
 779                  let response: Value = match peer_id {
 780                      Some(peer_id) => ws_api
 781                          .request_raw(peer_id.into(), &method, &[params.to_json()])
 782                          .await
 783                          .map_err_cli()?,
 784                      None => ws_api
 785                          .request_current_consensus(method, params)
 786                          .await
 787                          .map_err_cli()?,
 788                  };
 789  
 790                  Ok(CliOutput::UntypedApiOutput { value: response })
 791              }
 792              Command::Dev(DevCmd::WaitBlockCount { count: target }) => retry(
 793                  "wait_block_count",
 794                  ConstantBackoff::default()
 795                      .with_delay(Duration::from_millis(100))
 796                      .with_max_times(usize::MAX),
 797                  || async {
 798                      let client = self.client_open(&cli).await?;
 799                      let wallet = client.get_first_module::<WalletClientModule>();
 800                      let count = client
 801                          .api()
 802                          .with_module(wallet.id)
 803                          .fetch_consensus_block_count()
 804                          .await?;
 805                      if count >= target {
 806                          Ok(CliOutput::WaitBlockCount { reached: count })
 807                      } else {
 808                          info!(target: LOG_CLIENT, current=count, target, "Block count not reached");
 809                          Err(format_err!("target not reached"))
 810                      }
 811                  },
 812              )
 813              .await
 814              .map_err_cli(),
 815  
 816              Command::Dev(DevCmd::WaitComplete) => {
 817                  let client = self.client_open(&cli).await?;
 818                  client
 819                      .wait_for_all_active_state_machines()
 820                      .await
 821                      .map_err_cli_msg("failed to wait for all active state machines")?;
 822                  Ok(CliOutput::Raw(serde_json::Value::Null))
 823              }
 824              Command::Dev(DevCmd::Wait { seconds }) => {
 825                  let _client = self.client_open(&cli).await?;
 826                  if let Some(secs) = seconds {
 827                      runtime::sleep(Duration::from_secs_f32(secs)).await
 828                  } else {
 829                      pending().await
 830                  }
 831                  Ok(CliOutput::Raw(serde_json::Value::Null))
 832              }
 833              Command::Dev(DevCmd::Decode { decode_type }) => match decode_type {
 834                  DecodeType::InviteCode { invite_code } => Ok(CliOutput::DecodeInviteCode {
 835                      url: invite_code.url(),
 836                      federation_id: invite_code.federation_id(),
 837                  }),
 838                  DecodeType::Notes { notes } => {
 839                      let notes_json = notes
 840                          .notes_json()
 841                          .map_err_cli_msg("failed to decode notes")?;
 842                      Ok(CliOutput::Raw(notes_json))
 843                  }
 844              },
 845              Command::Dev(DevCmd::Encode { encode_type }) => match encode_type {
 846                  EncodeType::InviteCode {
 847                      url,
 848                      federation_id,
 849                      peer,
 850                  } => Ok(CliOutput::InviteCode {
 851                      invite_code: InviteCode::new(url, peer, federation_id),
 852                  }),
 853                  EncodeType::Notes { notes_json } => {
 854                      let notes = serde_json::from_str::<OOBNotesJson>(&notes_json)
 855                          .map_err_cli_msg("invalid JSON for notes")?;
 856                      let prefix =
 857                          FederationIdPrefix::from_str(&notes.federation_id_prefix).map_err_cli()?;
 858                      let notes = OOBNotes::new(prefix, notes.notes);
 859                      Ok(CliOutput::Raw(notes.to_string().into()))
 860                  }
 861              },
 862              Command::Dev(DevCmd::SessionCount) => {
 863                  let client = self.client_open(&cli).await?;
 864                  let count = client.api().session_count().await?;
 865                  Ok(CliOutput::EpochCount { count })
 866              }
 867              Command::Dev(DevCmd::ConfigDecrypt {
 868                  in_file,
 869                  out_file,
 870                  salt_file,
 871                  password,
 872              }) => {
 873                  let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
 874                  let salt = fs::read_to_string(salt_file).map_err_cli()?;
 875                  let key = get_encryption_key(&password, &salt).map_err_cli()?;
 876                  let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
 877  
 878                  let mut out_file_handle = fs::File::options()
 879                      .create_new(true)
 880                      .write(true)
 881                      .open(out_file)
 882                      .expect("Could not create output cfg file");
 883                  out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
 884                  Ok(CliOutput::ConfigDecrypt)
 885              }
 886              Command::Dev(DevCmd::ConfigEncrypt {
 887                  in_file,
 888                  out_file,
 889                  salt_file,
 890                  password,
 891              }) => {
 892                  let mut in_file_handle =
 893                      fs::File::open(in_file).expect("Could not create output cfg file");
 894                  let mut plaintext_bytes = vec![];
 895                  in_file_handle.read_to_end(&mut plaintext_bytes).unwrap();
 896  
 897                  let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
 898                  let salt = fs::read_to_string(salt_file).map_err_cli()?;
 899                  let key = get_encryption_key(&password, &salt).map_err_cli()?;
 900                  encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
 901                  Ok(CliOutput::ConfigEncrypt)
 902              }
 903              Command::Dev(DevCmd::DecodeTransaction { hex_string }) => {
 904                  let bytes: Vec<u8> = hex::FromHex::from_hex(&hex_string)
 905                      .map_err_cli_msg("failed to decode transaction")?;
 906  
 907                  let client = self.client_open(&cli).await?;
 908                  let tx =
 909                      fedimint_core::transaction::Transaction::from_bytes(&bytes, client.decoders())
 910                          .map_err_cli_msg("failed to decode transaction")?;
 911  
 912                  Ok(CliOutput::DecodeTransaction {
 913                      transaction: (format!("{tx:?}")),
 914                  })
 915              }
 916              Command::Completion { shell } => {
 917                  clap_complete::generate(
 918                      shell,
 919                      &mut Opts::command(),
 920                      "fedimint-cli",
 921                      &mut std::io::stdout(),
 922                  );
 923                  // HACK: prints true to stdout which is fine for shells
 924                  Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
 925              }
 926          }
 927      }
 928  
 929      async fn handle_admin_dkg_command(
 930          &self,
 931          cli: Opts,
 932          dkg_args: DkgAdminArgs,
 933      ) -> Result<CliOutput, CliError> {
 934          let client = dkg_args.ws_admin_client()?;
 935          match &dkg_args.subcommand {
 936              DkgAdminCmd::WsStatus => {
 937                  let status = client.status().await?;
 938                  Ok(CliOutput::Raw(
 939                      serde_json::to_value(status).map_err_cli_msg("invalid response")?,
 940                  ))
 941              }
 942              DkgAdminCmd::SetPassword => {
 943                  client.set_password(cli.auth()?).await?;
 944                  Ok(CliOutput::Raw(Value::Null))
 945              }
 946              DkgAdminCmd::GetDefaultConfigGenParams => {
 947                  let default_params = client.get_default_config_gen_params(cli.auth()?).await?;
 948                  Ok(CliOutput::Raw(
 949                      serde_json::to_value(default_params).map_err_cli_msg("invalid response")?,
 950                  ))
 951              }
 952              DkgAdminCmd::SetConfigGenParams {
 953                  meta_json,
 954                  modules_json,
 955              } => {
 956                  let meta: BTreeMap<String, String> =
 957                      serde_json::from_str(meta_json).map_err_cli_msg("Invalid JSON")?;
 958                  let modules: ServerModuleConfigGenParamsRegistry =
 959                      serde_json::from_str(modules_json).map_err_cli_msg("Invalid JSON")?;
 960                  let params = ConfigGenParamsRequest { meta, modules };
 961                  client.set_config_gen_params(params, cli.auth()?).await?;
 962                  Ok(CliOutput::Raw(Value::Null))
 963              }
 964              DkgAdminCmd::SetConfigGenConnections {
 965                  our_name,
 966                  leader_api_url,
 967              } => {
 968                  let req = ConfigGenConnectionsRequest {
 969                      our_name: our_name.to_owned(),
 970                      leader_api_url: leader_api_url.to_owned(),
 971                  };
 972                  client.set_config_gen_connections(req, cli.auth()?).await?;
 973                  Ok(CliOutput::Raw(Value::Null))
 974              }
 975              DkgAdminCmd::GetConfigGenPeers => {
 976                  let peer_server_params = client.get_config_gen_peers().await?;
 977                  Ok(CliOutput::Raw(
 978                      serde_json::to_value(peer_server_params).map_err_cli_msg("invalid response")?,
 979                  ))
 980              }
 981              DkgAdminCmd::ConsensusConfigGenParams => {
 982                  let config_gen_params_response = client.consensus_config_gen_params().await?;
 983                  Ok(CliOutput::Raw(
 984                      serde_json::to_value(config_gen_params_response)
 985                          .map_err_cli_msg("invalid response")?,
 986                  ))
 987              }
 988              DkgAdminCmd::RunDkg => {
 989                  client.run_dkg(cli.auth()?).await?;
 990                  Ok(CliOutput::Raw(Value::Null))
 991              }
 992              DkgAdminCmd::GetVerifyConfigHash => {
 993                  let hashes_by_peer = client.get_verify_config_hash(cli.auth()?).await?;
 994                  Ok(CliOutput::Raw(
 995                      serde_json::to_value(hashes_by_peer).map_err_cli_msg("invalid response")?,
 996                  ))
 997              }
 998              DkgAdminCmd::StartConsensus => {
 999                  client.start_consensus(cli.auth()?).await?;
1000                  Ok(CliOutput::Raw(Value::Null))
1001              }
1002          }
1003      }
1004  }
1005  
1006  fn salt_from_file_path(file_path: &Path) -> PathBuf {
1007      file_path
1008          .parent()
1009          .expect("File has no parent?!")
1010          .join(SALT_FILE)
1011  }
1012  
1013  /// Convert clap arguments to backup metadata
1014  fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1015      let metadata: BTreeMap<String, String> = metadata
1016          .into_iter()
1017          .map(|item| {
1018              match &item
1019                  .splitn(2, '=')
1020                  .map(ToString::to_string)
1021                  .collect::<Vec<String>>()[..]
1022              {
1023                  [] => Err(format_err!("Empty metadata argument not allowed")),
1024                  [key] => Err(format_err!("Metadata {key} is missing a value")),
1025                  [key, val] => Ok((key.clone(), val.clone())),
1026                  [..] => unreachable!(),
1027              }
1028          })
1029          .collect::<anyhow::Result<_>>()
1030          .map_err_cli_msg("invalid metadata")?;
1031      Ok(metadata)
1032  }
1033  
1034  #[test]
1035  fn metadata_from_clap_cli_test() {
1036      for (args, expected) in [
1037          (
1038              vec!["a=b".to_string()],
1039              BTreeMap::from([("a".into(), "b".into())]),
1040          ),
1041          (
1042              vec!["a=b".to_string(), "c=d".to_string()],
1043              BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1044          ),
1045      ] {
1046          assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1047      }
1048  }