diff --git a/gui/Cargo.lock b/gui/Cargo.lock index b5a2133f..3e5014f6 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -1286,6 +1286,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.33" @@ -2609,6 +2618,7 @@ dependencies = [ "bitcoin_hashes 0.12.0", "chrono", "dirs 3.0.2", + "email_address", "flate2", "hex", "iced", diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 2f0f9c92..70e089d3 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -24,6 +24,9 @@ hex = "0.4.3" iced = { version = "0.12.1", default-features = false, features = ["tokio", "svg", "qr_code", "image", "lazy", "wgpu", "advanced"] } iced_runtime = "0.12.1" +# Used to verify RFC-compliance of an email +email_address = "0.2.7" + tokio = {version = "1.21.0", features = ["signal"]} serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/gui/src/app/config.rs b/gui/src/app/config.rs index f39ea1ee..3e834deb 100644 --- a/gui/src/app/config.rs +++ b/gui/src/app/config.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use std::fs::OpenOptions; +use std::io::Write; use std::path::{Path, PathBuf}; use tracing_subscriber::filter; @@ -47,6 +49,26 @@ impl Config { Ok(config) } + pub fn to_file(&self, path: &Path) -> Result<(), ConfigError> { + let content = toml::to_string(&self) + .map_err(|e| ConfigError::WritingFile(format!("Failed to serialize config: {}", e)))?; + + let mut config_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + .map_err(|e| ConfigError::WritingFile(e.to_string()))?; + + config_file.write_all(content.as_bytes()).map_err(|e| { + tracing::warn!("failed to write to file: {:?}", e); + ConfigError::WritingFile(e.to_string()) + })?; + + tracing::info!("Done writing gui configuration file"); + Ok(()) + } + /// TODO: Deserialize directly in the struct. pub fn log_level(&self) -> Result { if let Some(level) = &self.log_level { @@ -72,6 +94,7 @@ pub enum ConfigError { InvalidField(&'static str, String), NotFound, ReadingFile(String), + WritingFile(String), Unexpected(String), } @@ -83,6 +106,7 @@ impl std::fmt::Display for ConfigError { write!(f, "Config field {} is invalid: {}", field, message) } Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e), + Self::WritingFile(e) => write!(f, "Error while writing file: {}", e), Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), } } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index cddfb770..c222211a 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -255,11 +255,12 @@ impl App { Message::Tick => { let daemon = self.daemon.clone(); let datadir_path = self.cache.datadir_path.clone(); + let network = self.cache.network; Command::perform( async move { // we check every 10 second if the daemon poller is alive // or if the access token is not expired. - daemon.is_alive().await?; + daemon.is_alive(&datadir_path, network).await?; let info = daemon.get_info().await?; let coins = daemon diff --git a/gui/src/app/settings.rs b/gui/src/app/settings.rs index a2765dd1..0427cab2 100644 --- a/gui/src/app/settings.rs +++ b/gui/src/app/settings.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use liana::miniscript::bitcoin::{bip32::Fingerprint, Network}; use serde::{Deserialize, Serialize}; -use crate::{app::wallet::Wallet, hw::HardwareWalletConfig}; +use crate::hw::HardwareWalletConfig; pub const DEFAULT_FILE_NAME: &str = "settings.json"; @@ -59,14 +59,26 @@ impl Settings { } } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuthConfig { + pub email: String, + pub wallet_id: String, + pub refresh_token: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct WalletSetting { pub name: String, pub descriptor_checksum: String, + // if wallet is using remote backend, then this information is stored on the remote backend + // wallet metadata #[serde(default)] pub keys: Vec, + // if wallet is using remote backend, then this information is stored on the remote backend + // wallet metadata #[serde(default)] pub hardware_wallets: Vec, + pub remote_backend_auth: Option, } impl WalletSetting { @@ -79,25 +91,6 @@ impl WalletSetting { } } -impl From<&Wallet> for WalletSetting { - fn from(w: &Wallet) -> WalletSetting { - Self { - name: w.name.clone(), - hardware_wallets: w.hardware_wallets.clone(), - keys: w - .keys_aliases - .clone() - .into_iter() - .map(|(master_fingerprint, name)| KeySetting { - name, - master_fingerprint, - }) - .collect(), - descriptor_checksum: w.descriptor_checksum(), - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize)] pub struct KeySetting { pub name: String, diff --git a/gui/src/app/wallet.rs b/gui/src/app/wallet.rs index 2fb83226..bea37a16 100644 --- a/gui/src/app/wallet.rs +++ b/gui/src/app/wallet.rs @@ -105,7 +105,23 @@ impl Wallet { } Err(settings::SettingsError::NotFound) => { let s = settings::Settings { - wallets: vec![settings::WalletSetting::from(&self)], + wallets: vec![settings::WalletSetting { + name: self.name.clone(), + hardware_wallets: self.hardware_wallets.clone(), + keys: self + .keys_aliases + .clone() + .into_iter() + .map(|(master_fingerprint, name)| settings::KeySetting { + name, + master_fingerprint, + }) + .collect(), + descriptor_checksum: self.descriptor_checksum(), + // Only local wallet from previous version of Liana GUI may not have a + // settings.json file + remote_backend_auth: None, + }], }; tracing::info!("Settings file not found, creating one"); diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 8f546f18..23aba553 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::iter::FromIterator; +use std::path::Path; use async_trait::async_trait; use liana::commands::{CoinStatus, CreateRecoveryResult}; @@ -15,7 +16,7 @@ pub mod jsonrpc; use liana::{ commands::LabelItem, config::Config, - miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, + miniscript::bitcoin::{address, psbt::Psbt, Address, Network, OutPoint, Txid}, }; use super::{model::*, Daemon, DaemonBackend, DaemonError}; @@ -63,7 +64,7 @@ impl Daemon for Lianad { None } - async fn is_alive(&self) -> Result<(), DaemonError> { + async fn is_alive(&self, _datadir: &Path, _network: Network) -> Result<(), DaemonError> { Ok(()) } diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index f9ee842c..9ee78d80 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::path::Path; use tokio::sync::Mutex; use super::{model::*, Daemon, DaemonBackend, DaemonError}; @@ -6,7 +7,7 @@ use async_trait::async_trait; use liana::{ commands::{CoinStatus, LabelItem}, config::Config, - miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, + miniscript::bitcoin::{address, psbt::Psbt, Address, Network, OutPoint, Txid}, DaemonControl, DaemonHandle, }; @@ -57,7 +58,7 @@ impl Daemon for EmbeddedDaemon { Some(&self.config) } - async fn is_alive(&self) -> Result<(), DaemonError> { + async fn is_alive(&self, _datadir: &Path, _network: Network) -> Result<(), DaemonError> { let mut handle = self.handle.lock().await; if let Some(h) = handle.as_ref() { if h.is_alive() { diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 43398ce6..6d4e70c5 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -7,6 +7,7 @@ use std::convert::TryInto; use std::fmt::Debug; use std::io::ErrorKind; use std::iter::FromIterator; +use std::path::Path; use async_trait::async_trait; @@ -14,7 +15,7 @@ use liana::{ commands::{CoinStatus, LabelItem, TransactionInfo}, config::Config, miniscript::bitcoin::{ - address, bip32::Fingerprint, psbt::Psbt, secp256k1, Address, OutPoint, Txid, + address, bip32::Fingerprint, psbt::Psbt, secp256k1, Address, Network, OutPoint, Txid, }, StartupError, }; @@ -70,7 +71,7 @@ pub enum DaemonBackend { pub trait Daemon: Debug { fn backend(&self) -> DaemonBackend; fn config(&self) -> Option<&Config>; - async fn is_alive(&self) -> Result<(), DaemonError>; + async fn is_alive(&self, datadir: &Path, network: Network) -> Result<(), DaemonError>; async fn stop(&self) -> Result<(), DaemonError>; async fn get_info(&self) -> Result; async fn get_new_address(&self) -> Result; diff --git a/gui/src/datadir.rs b/gui/src/datadir.rs new file mode 100644 index 00000000..4e906700 --- /dev/null +++ b/gui/src/datadir.rs @@ -0,0 +1,18 @@ +pub fn create_directory(datadir_path: &std::path::Path) -> Result<(), Box> { + #[cfg(unix)] + return { + use std::fs::DirBuilder; + use std::os::unix::fs::DirBuilderExt; + + let mut builder = DirBuilder::new(); + builder.mode(0o700).recursive(true).create(datadir_path)?; + Ok(()) + }; + + // TODO: permissions on Windows.. + #[cfg(not(unix))] + return { + std::fs::create_dir_all(datadir_path)?; + Ok(()) + }; +} diff --git a/gui/src/installer/context.rs b/gui/src/installer/context.rs index 6413626c..98134304 100644 --- a/gui/src/installer/context.rs +++ b/gui/src/installer/context.rs @@ -3,22 +3,35 @@ use std::sync::Arc; use std::time::Duration; use crate::{ - app::{ - settings::{KeySetting, Settings, WalletSetting}, - wallet::wallet_name, - }, + app::settings::KeySetting, bitcoind::{Bitcoind, InternalBitcoindConfig}, - hw::HardwareWalletConfig, + lianalite::client::backend::{BackendClient, BackendWalletClient}, signer::Signer, }; use async_hwi::DeviceKind; use liana::{ - config::Config, config::{BitcoinConfig, BitcoindConfig}, descriptors::LianaDescriptor, miniscript::bitcoin, }; +#[derive(Debug, Clone)] +pub enum RemoteBackend { + // The installer will have to create a wallet from the created descriptor. + WithoutWallet(BackendClient), + // The installer will have to fetch the wallet and only install the missing configuration files. + WithWallet(BackendWalletClient), +} + +impl RemoteBackend { + pub fn user_email(&self) -> &str { + match self { + Self::WithWallet(b) => b.user_email(), + Self::WithoutWallet(b) => b.user_email(), + } + } +} + #[derive(Clone)] pub struct Context { pub bitcoin_config: BitcoinConfig, @@ -27,6 +40,7 @@ pub struct Context { pub keys: Vec, pub hws: Vec<(DeviceKind, bitcoin::bip32::Fingerprint, Option<[u8; 32]>)>, pub data_dir: PathBuf, + pub network: bitcoin::Network, pub hw_is_used: bool, // In case a user entered a mnemonic, // we dont want to override the generated signer with it. @@ -34,10 +48,15 @@ pub struct Context { pub bitcoind_is_external: bool, pub internal_bitcoind_config: Option, pub internal_bitcoind: Option, + pub remote_backend: Option, } impl Context { - pub fn new(network: bitcoin::Network, data_dir: PathBuf) -> Self { + pub fn new( + network: bitcoin::Network, + data_dir: PathBuf, + remote_backend: Option, + ) -> Self { Self { bitcoin_config: BitcoinConfig { network, @@ -48,52 +67,13 @@ impl Context { bitcoind_config: None, descriptor: None, data_dir, + network, hw_is_used: false, recovered_signer: None, bitcoind_is_external: true, internal_bitcoind_config: None, internal_bitcoind: None, - } - } - - pub fn extract_gui_settings(&self) -> Settings { - let hardware_wallets = self - .hws - .iter() - .filter_map(|(kind, fingerprint, token)| { - token - .as_ref() - .map(|token| HardwareWalletConfig::new(kind, *fingerprint, token)) - }) - .collect(); - let descriptor = self - .descriptor - .as_ref() - .expect("Must be a descriptor at this point"); - Settings { - wallets: vec![WalletSetting { - name: wallet_name(descriptor), - descriptor_checksum: descriptor - .to_string() - .split_once('#') - .map(|(_, checksum)| checksum) - .unwrap() - .to_string(), - keys: self.keys.clone(), - hardware_wallets, - }], - } - } - - pub fn extract_daemon_config(&self) -> Config { - Config { - #[cfg(unix)] - daemon: false, - log_level: log::LevelFilter::Info, - main_descriptor: self.descriptor.clone().unwrap(), - data_dir: Some(self.data_dir.clone()), - bitcoin_config: self.bitcoin_config.clone(), - bitcoind_config: self.bitcoind_config.clone(), + remote_backend, } } } diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index 5c2434d7..84169fb9 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -1,11 +1,12 @@ use liana::miniscript::{bitcoin::bip32::Fingerprint, DescriptorPublicKey}; use std::path::PathBuf; -use super::Error; +use super::{context, Error}; use crate::{ bitcoind::{Bitcoind, ConfigField, RpcAuthType}, download::Progress, hw::HardwareWalletMessage, + lianalite::client::{auth::AuthClient, backend::api}, }; use async_hwi::{DeviceKind, Version}; @@ -28,6 +29,7 @@ pub enum Message { UseHotSigner, Installed(Result), CreateTaprootDescriptor(bool), + SelectBackend(SelectBackend), SelectBitcoindType(SelectBitcoindTypeMsg), InternalBitcoind(InternalBitcoindMsg), DefineBitcoind(DefineBitcoind), @@ -39,6 +41,21 @@ pub enum Message { ImportMnemonic(bool), } +#[derive(Debug, Clone)] +pub enum SelectBackend { + // view messages + RequestOTP, + EditEmail, + EmailEdited(String), + OTPEdited(String), + ContinueWithRemoteBackend, + ContinueWithLocalWallet, + // Commands messages + OTPRequested(Result<(AuthClient, String), Error>), + OTPResent(Result<(), Error>), + Connected(Result<(context::RemoteBackend, Option), Error>), +} + #[derive(Debug, Clone)] pub enum DefineBitcoind { ConfigFieldEdited(ConfigField, String), diff --git a/gui/src/installer/mod.rs b/gui/src/installer/mod.rs index 89bc6109..d3ceedb8 100644 --- a/gui/src/installer/mod.rs +++ b/gui/src/installer/mod.rs @@ -5,34 +5,49 @@ mod step; mod view; use iced::{clipboard, Command, Subscription}; -use liana::miniscript::bitcoin::{self, Network}; +use liana::{ + config::Config, + miniscript::bitcoin::{self, Network}, +}; use liana_ui::{ component::network_banner, widget::{Column, Element}, }; use tracing::{error, info, warn}; -use context::Context; +use context::{Context, RemoteBackend}; use std::io::Write; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use crate::{ - app::{config as gui_config, settings as gui_settings}, - hw::HardwareWallets, + app::{ + config as gui_config, settings as gui_settings, + settings::{AuthConfig, Settings, SettingsError, WalletSetting}, + wallet::wallet_name, + }, + daemon::DaemonError, + datadir::create_directory, + hw::{HardwareWalletConfig, HardwareWallets}, + lianalite::client::{ + auth::AuthError, + backend::{BackendClient, BackendWalletClient}, + }, signer::Signer, }; pub use message::Message; use step::{ - BackupDescriptor, BackupMnemonic, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, - InternalBitcoindStep, RecoverMnemonic, RegisterDescriptor, SelectBitcoindTypeStep, ShareXpubs, - Step, Welcome, + BackupDescriptor, BackupMnemonic, ChooseBackend, DefineBitcoind, DefineDescriptor, Final, + ImportDescriptor, InternalBitcoindStep, RecoverMnemonic, RegisterDescriptor, + SelectBitcoindTypeStep, ShareXpubs, Step, Welcome, }; pub struct Installer { - network: bitcoin::Network, + pub network: bitcoin::Network, + pub datadir: PathBuf, + current: usize, steps: Vec>, hws: HardwareWallets, @@ -62,14 +77,20 @@ impl Installer { pub fn new( destination_path: PathBuf, network: bitcoin::Network, + remote_backend: Option, ) -> (Installer, Command) { ( Installer { network, + datadir: destination_path.clone(), current: 0, hws: HardwareWallets::new(destination_path.clone(), network), steps: vec![Welcome::default().into()], - context: Context::new(network, destination_path), + context: Context::new( + network, + destination_path, + remote_backend.map(RemoteBackend::WithoutWallet), + ), signer: Arc::new(Mutex::new(Signer::generate(network).unwrap())), }, Command::none(), @@ -148,6 +169,7 @@ impl Installer { BackupMnemonic::new(self.signer.clone()).into(), BackupDescriptor::default().into(), RegisterDescriptor::new_create_wallet().into(), + ChooseBackend::new(self.network).into(), SelectBitcoindTypeStep::new().into(), InternalBitcoindStep::new(&self.context.data_dir).into(), DefineBitcoind::new().into(), @@ -165,6 +187,7 @@ impl Installer { Message::ImportWallet => { self.steps = vec![ Welcome::default().into(), + ChooseBackend::new(self.network).into(), ImportDescriptor::new(self.network).into(), RecoverMnemonic::default().into(), RegisterDescriptor::new_import_wallet().into(), @@ -173,6 +196,7 @@ impl Installer { DefineBitcoind::new().into(), Final::new().into(), ]; + self.next() } Message::HardwareWallets(msg) => match self.hws.update(msg) { @@ -194,10 +218,24 @@ impl Installer { .get_mut(self.current) .expect("There is always a step") .update(&mut self.hws, message); - Command::perform( - install(self.context.clone(), self.signer.clone()), - Message::Installed, - ) + match &self.context.remote_backend { + Some(RemoteBackend::WithoutWallet(backend)) => Command::perform( + create_remote_wallet( + self.context.clone(), + self.signer.clone(), + backend.clone(), + ), + Message::Installed, + ), + Some(RemoteBackend::WithWallet(backend)) => Command::perform( + import_remote_wallet(self.context.clone(), backend.clone()), + Message::Installed, + ), + None => Command::perform( + install_local_wallet(self.context.clone(), self.signer.clone()), + Message::Installed, + ), + } } Message::Installed(Err(e)) => { let mut data_dir = self.context.data_dir.clone(); @@ -252,7 +290,11 @@ impl Installer { .steps .get(self.current) .expect("There is always a step") - .view(&self.hws, self.progress()); + .view( + &self.hws, + self.progress(), + self.context.remote_backend.as_ref().map(|b| b.user_email()), + ); if self.network != Network::Bitcoin { Column::with_children(vec![network_banner(self.network).into(), content]).into() @@ -275,8 +317,11 @@ pub fn daemon_check(cfg: liana::config::Config) -> Result<(), Error> { } } -pub async fn install(ctx: Context, signer: Arc>) -> Result { - let mut cfg: liana::config::Config = ctx.extract_daemon_config(); +pub async fn install_local_wallet( + ctx: Context, + signer: Arc>, +) -> Result { + let mut cfg: liana::config::Config = extract_daemon_config(&ctx); let data_dir = cfg.data_dir.unwrap(); let data_dir = data_dir @@ -290,6 +335,8 @@ pub async fn install(ctx: Context, signer: Arc>) -> Result>) -> Result>) -> Result>, + remote_backend: BackendClient, +) -> Result { + let data_dir = ctx + .data_dir + .canonicalize() + .map_err(|e| Error::Unexpected(format!("Failed to canonicalize datadir path: {}", e)))?; + + let mut network_datadir_path = data_dir.clone(); + network_datadir_path.push(ctx.network.to_string()); + create_directory(&network_datadir_path) + .map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?; + + let descriptor = ctx + .descriptor + .as_ref() + .expect("There must be a descriptor at this point"); + + if descriptor + .to_string() + .contains(&signer.lock().unwrap().fingerprint().to_string()) + { + signer + .lock() + .unwrap() + .store(&data_dir, ctx.network) + .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; + + info!("Hot signer mnemonic stored"); + } + + if let Some(signer) = &ctx.recovered_signer { + signer + .store(&data_dir, ctx.network) + .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; + + info!("Recovered signer mnemonic stored"); + } + + let mut network_datadir_path = data_dir; + network_datadir_path.push(ctx.network.to_string()); + + // create liana GUI configuration file + let gui_config_path = create_and_write_file( + network_datadir_path.clone(), + gui_config::DEFAULT_FILE_NAME, + toml::to_string(&gui_config::Config { + daemon_config_path: None, + daemon_rpc_path: None, + log_level: Some("info".to_string()), + debug: Some(false), + start_internal_bitcoind: false, + }) + .map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))? + .as_bytes(), + )?; + + info!("Gui configuration file created"); + + let wallet = remote_backend + .create_wallet(&wallet_name(descriptor), descriptor) + .await + .map_err(|e| Error::Unexpected(e.to_string()))?; + + let hws: Vec = ctx + .hws + .iter() + .filter_map(|(kind, fingerprint, token)| { + token + .as_ref() + .map(|token| HardwareWalletConfig::new(kind, *fingerprint, token)) + }) + .collect(); + let descriptor_str = descriptor.to_string(); + let aliases = ctx + .keys + .iter() + .filter_map(|k| { + if descriptor_str.contains(&k.master_fingerprint.to_string()) { + Some((k.master_fingerprint, k.name.to_string())) + } else { + None + } + }) + .collect(); + remote_backend + .update_wallet_metadata(&wallet.id, &aliases, &hws) + .await + .map_err(|e| Error::Unexpected(e.to_string()))?; + + let remote_backend = remote_backend.connect_wallet(wallet).0; + + // create liana GUI settings file + let settings: gui_settings::Settings = extract_remote_gui_settings(&ctx, &remote_backend).await; + create_and_write_file( + network_datadir_path.clone(), + gui_settings::DEFAULT_FILE_NAME, + serde_json::to_string_pretty(&settings) + .map_err(|e| Error::Unexpected(format!("Failed to serialize settings: {}", e)))? + .as_bytes(), + )?; + + info!("Settings file created"); + + Ok(gui_config_path) +} + +pub async fn import_remote_wallet( + ctx: Context, + backend: BackendWalletClient, +) -> Result { + tracing::info!("Importing wallet from remote backend"); + + let data_dir = ctx + .data_dir + .canonicalize() + .map_err(|e| Error::Unexpected(format!("Failed to canonicalize datadir path: {}", e)))?; + + if let Some(signer) = &ctx.recovered_signer { + signer + .store(&data_dir, ctx.network) + .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; + + info!("Recovered signer mnemonic stored"); + } + + let mut network_datadir_path = data_dir; + network_datadir_path.push(ctx.network.to_string()); + create_directory(&network_datadir_path) + .map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?; + + // create liana GUI settings file + let settings: gui_settings::Settings = extract_remote_gui_settings(&ctx, &backend).await; + create_and_write_file( + network_datadir_path.clone(), + gui_settings::DEFAULT_FILE_NAME, + serde_json::to_string_pretty(&settings) + .map_err(|e| Error::Unexpected(format!("Failed to serialize settings: {}", e)))? + .as_bytes(), + )?; + + info!("Settings file created"); + + // create liana GUI configuration file + let gui_config_path = create_and_write_file( + network_datadir_path.clone(), + gui_config::DEFAULT_FILE_NAME, + toml::to_string(&gui_config::Config { + daemon_config_path: None, + daemon_rpc_path: None, + log_level: Some("info".to_string()), + debug: Some(false), + start_internal_bitcoind: false, + }) + .map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))? + .as_bytes(), + )?; + + info!("Gui configuration file created"); + + Ok(gui_config_path) +} + pub fn create_and_write_file( mut network_datadir: PathBuf, file_name: &str, @@ -378,8 +590,93 @@ pub fn create_and_write_file( Ok(path) } +// if the wallet is using the remote backend, then the hardware wallet settings and +// keys will be store on the remote backend side and not in the settings file. +pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletClient) -> Settings { + let descriptor = ctx + .descriptor + .as_ref() + .expect("Context must have a descriptor at this point"); + + let descriptor_checksum = descriptor + .to_string() + .split_once('#') + .map(|(_, checksum)| checksum) + .expect("LianaDescriptor.to_string() always include the checksum") + .to_string(); + + let auth = backend.inner_client().auth.read().await; + + Settings { + wallets: vec![WalletSetting { + name: wallet_name(descriptor), + descriptor_checksum, + keys: Vec::new(), + hardware_wallets: Vec::new(), + remote_backend_auth: Some(AuthConfig { + email: backend.user_email().to_string(), + wallet_id: backend.wallet_id(), + refresh_token: auth.refresh_token.clone(), + }), + }], + } +} + +pub async fn extract_local_gui_settings(ctx: &Context) -> Settings { + let descriptor = ctx + .descriptor + .as_ref() + .expect("Context must have a descriptor at this point"); + + let descriptor_checksum = descriptor + .to_string() + .split_once('#') + .map(|(_, checksum)| checksum) + .expect("LianaDescriptor.to_string() always include the checksum") + .to_string(); + + let hardware_wallets = ctx + .hws + .iter() + .filter_map(|(kind, fingerprint, token)| { + token + .as_ref() + .map(|token| HardwareWalletConfig::new(kind, *fingerprint, token)) + }) + .collect(); + Settings { + wallets: vec![WalletSetting { + name: wallet_name(descriptor), + descriptor_checksum, + keys: ctx.keys.clone(), + hardware_wallets, + remote_backend_auth: None, + }], + } +} + +pub fn extract_daemon_config(ctx: &Context) -> Config { + Config { + #[cfg(unix)] + daemon: false, + log_level: log::LevelFilter::Info, + main_descriptor: ctx + .descriptor + .clone() + .expect("Context must have a descriptor at this point"), + data_dir: Some(ctx.data_dir.clone()), + bitcoin_config: ctx.bitcoin_config.clone(), + bitcoind_config: ctx.bitcoind_config.clone(), + } +} + #[derive(Debug, Clone)] pub enum Error { + Auth(AuthError), + // DaemonError does not implement Clone. + // TODO: maybe Arc is overkill + Backend(Arc), + Settings(SettingsError), Bitcoind(String), CannotCreateDatadir(String), CannotCreateFile(String), @@ -407,9 +704,30 @@ impl From for Error { } } +impl From for Error { + fn from(value: DaemonError) -> Self { + Self::Backend(Arc::new(value)) + } +} + +impl From for Error { + fn from(value: AuthError) -> Self { + Self::Auth(value) + } +} + +impl From for Error { + fn from(value: SettingsError) -> Self { + Self::Settings(value) + } +} + impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { + Self::Auth(e) => write!(f, "Authentification error: {}", e), + Self::Backend(e) => write!(f, "Remote backend error: {}", e), + Self::Settings(e) => write!(f, "Settings file error: {}", e), Self::Bitcoind(e) => write!(f, "Failed to ping bitcoind: {}", e), Self::CannotCreateDatadir(e) => write!(f, "Failed to create datadir: {}", e), Self::CannotGetAvailablePort(e) => write!(f, "Failed to get available port: {}", e), diff --git a/gui/src/installer/step/backend.rs b/gui/src/installer/step/backend.rs new file mode 100644 index 00000000..1bafcc6a --- /dev/null +++ b/gui/src/installer/step/backend.rs @@ -0,0 +1,307 @@ +use iced::Command; + +use liana::miniscript::bitcoin::Network; +use liana_ui::{component::form, widget::Element}; + +use crate::{ + hw::HardwareWallets, + installer::{ + context::{self, Context, RemoteBackend}, + message::{self, Message}, + step::Step, + view, Error, + }, + lianalite::client::{ + self, + auth::{AuthClient, AuthError}, + backend::{api, BackendClient}, + }, +}; + +pub enum ConnectionStep { + EnterEmail { + email: form::Value, + }, + EnterOtp { + client: AuthClient, + backend_api_url: String, + email: String, + otp: form::Value, + }, + Connected { + email: String, + remote_backend: context::RemoteBackend, + wallet: Option, + remote_backend_is_selected: bool, + }, +} + +pub struct ChooseBackend { + network: Network, + processing: bool, + step: ConnectionStep, + connection_error: Option, + auth_error: Option<&'static str>, +} + +impl ChooseBackend { + pub fn new(network: Network) -> Self { + Self { + network, + step: ConnectionStep::EnterEmail { + email: form::Value::default(), + }, + connection_error: None, + auth_error: None, + processing: false, + } + } +} + +impl From for Box { + fn from(s: ChooseBackend) -> Box { + Box::new(s) + } +} + +impl Step for ChooseBackend { + fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command { + if matches!( + message, + Message::SelectBackend(message::SelectBackend::ContinueWithLocalWallet) + ) { + if let ConnectionStep::Connected { + remote_backend_is_selected, + .. + } = &mut self.step + { + *remote_backend_is_selected = false; + } + return Command::perform(async move {}, |_| Message::Next); + } + match &mut self.step { + ConnectionStep::EnterEmail { email } => match message { + Message::SelectBackend(message::SelectBackend::EmailEdited(value)) => { + email.valid = value.is_empty() + || email_address::EmailAddress::parse_with_options( + &value, + email_address::Options::default().with_required_tld(), + ) + .is_ok(); + email.value = value; + } + Message::SelectBackend(message::SelectBackend::RequestOTP) => { + if email.value.is_empty() { + email.valid = false; + } else if email.valid { + let email = email.value.clone(); + let network = self.network; + self.processing = true; + self.connection_error = None; + self.auth_error = None; + return Command::perform( + async move { + let config = + client::get_service_config(network).await.map_err(|e| { + if e.status() == Some(reqwest::StatusCode::NOT_FOUND) { + Error::Unexpected( + "Remote servers are unresponsive".to_string(), + ) + } else { + Error::Unexpected(e.to_string()) + } + })?; + let client = AuthClient::new( + config.auth_api_url, + config.auth_api_public_key, + email, + ); + client.sign_in_otp().await?; + Ok((client, config.backend_api_url)) + }, + |res| Message::SelectBackend(message::SelectBackend::OTPRequested(res)), + ); + } + } + Message::SelectBackend(message::SelectBackend::OTPRequested(res)) => { + self.processing = false; + match res { + Ok((client, backend_api_url)) => { + self.step = ConnectionStep::EnterOtp { + email: email.value.to_owned(), + otp: form::Value::default(), + client, + backend_api_url, + }; + } + Err(e) => { + self.connection_error = Some(e); + } + } + } + _ => {} + }, + ConnectionStep::EnterOtp { + client, + email, + otp, + backend_api_url, + } => match message { + Message::SelectBackend(message::SelectBackend::EditEmail) => { + self.step = ConnectionStep::EnterEmail { + email: form::Value { + value: email.clone(), + valid: true, + }, + }; + } + Message::SelectBackend(message::SelectBackend::RequestOTP) => { + *otp = form::Value::default(); + let client = client.clone(); + self.processing = true; + self.connection_error = None; + self.auth_error = None; + return Command::perform( + async move { + client.resend_otp().await?; + Ok(()) + }, + message::SelectBackend::OTPResent, + ) + .map(Message::SelectBackend); + } + Message::SelectBackend(message::SelectBackend::OTPResent(res)) => { + self.processing = false; + if let Err(e) = res { + self.connection_error = Some(e); + } + } + Message::SelectBackend(message::SelectBackend::OTPEdited(value)) => { + otp.value = value.trim().to_string(); + if otp.value.len() == 6 { + let client = client.clone(); + let otp = otp.value.clone(); + let backend_api_url = backend_api_url.clone(); + self.processing = true; + self.connection_error = None; + self.auth_error = None; + return Command::perform( + async move { connect(client, otp, backend_api_url).await }, + message::SelectBackend::Connected, + ) + .map(Message::SelectBackend); + } + } + + Message::SelectBackend(message::SelectBackend::Connected(res)) => { + self.processing = false; + match res { + Ok((remote_backend, wallet)) => { + self.step = ConnectionStep::Connected { + email: email.clone(), + remote_backend, + wallet, + remote_backend_is_selected: false, + }; + } + Err(e) => { + if let Error::Auth(AuthError { http_status, .. }) = e { + if http_status == Some(403) { + self.auth_error = Some("Token is expired or is invalid") + } else { + self.connection_error = Some(e); + } + } else { + self.connection_error = Some(e); + } + } + } + } + _ => {} + }, + ConnectionStep::Connected { + remote_backend_is_selected, + .. + } => match message { + Message::SelectBackend(message::SelectBackend::EditEmail) => { + self.step = ConnectionStep::EnterEmail { + email: form::Value::default(), + } + } + Message::SelectBackend(message::SelectBackend::ContinueWithRemoteBackend) => { + *remote_backend_is_selected = true; + return Command::perform(async move {}, |_| Message::Next); + } + _ => {} + }, + } + + Command::none() + } + + fn apply(&mut self, ctx: &mut Context) -> bool { + if let ConnectionStep::Connected { + remote_backend, + remote_backend_is_selected, + .. + } = &self.step + { + if *remote_backend_is_selected { + ctx.remote_backend = Some(remote_backend.clone()); + } + } else { + ctx.remote_backend = None; + } + + true + } + + fn view<'a>( + &'a self, + _hws: &'a HardwareWallets, + progress: (usize, usize), + _email: Option<&'a str>, + ) -> Element { + view::choose_backend( + progress, + match &self.step { + ConnectionStep::EnterEmail { email } => view::connection_step_enter_email( + email, + self.processing, + self.connection_error.as_ref(), + self.auth_error, + ), + ConnectionStep::EnterOtp { email, otp, .. } => view::connection_step_enter_otp( + email, + otp, + self.processing, + self.connection_error.as_ref(), + self.auth_error, + ), + ConnectionStep::Connected { email, wallet, .. } => view::connection_step_connected( + email, + self.processing, + wallet.as_ref().map(|w| w.name.as_str()), + self.connection_error.as_ref(), + self.auth_error, + ), + }, + ) + } +} + +pub async fn connect( + auth: AuthClient, + token: String, + backend_api_url: String, +) -> Result<(context::RemoteBackend, Option), Error> { + let access = auth.verify_otp(token.trim_end()).await?; + let client = BackendClient::connect(auth, backend_api_url, access.clone()).await?; + + if !client.list_wallets().await?.is_empty() { + let (wallet_client, wallet) = client.connect_first().await?; + Ok((RemoteBackend::WithWallet(wallet_client), Some(wallet))) + } else { + Ok((RemoteBackend::WithoutWallet(client), None)) + } +} diff --git a/gui/src/installer/step/bitcoind.rs b/gui/src/installer/step/bitcoind.rs index 65990f89..e48637b9 100644 --- a/gui/src/installer/step/bitcoind.rs +++ b/gui/src/installer/step/bitcoind.rs @@ -302,6 +302,9 @@ impl SelectBitcoindTypeStep { } impl Step for SelectBitcoindTypeStep { + fn skip(&self, ctx: &Context) -> bool { + ctx.remote_backend.is_some() + } fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command { if let Message::SelectBitcoindType(msg) = message { match msg { @@ -326,7 +329,12 @@ impl Step for SelectBitcoindTypeStep { true } - fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element { + fn view( + &self, + _hws: &HardwareWallets, + progress: (usize, usize), + _email: Option<&str>, + ) -> Element { view::select_bitcoind_type(progress) } } @@ -479,7 +487,12 @@ impl Step for DefineBitcoind { } } - fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element { + fn view( + &self, + _hws: &HardwareWallets, + progress: (usize, usize), + _email: Option<&str>, + ) -> Element { view::define_bitcoin( progress, &self.address, @@ -494,7 +507,7 @@ impl Step for DefineBitcoind { } fn skip(&self, ctx: &Context) -> bool { - !ctx.bitcoind_is_external + !ctx.bitcoind_is_external || ctx.remote_backend.is_some() } } @@ -787,7 +800,12 @@ impl Step for InternalBitcoindStep { false } - fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element { + fn view( + &self, + _hws: &HardwareWallets, + progress: (usize, usize), + _email: Option<&str>, + ) -> Element { view::start_internal_bitcoind( progress, self.exe_path.as_ref(), @@ -806,7 +824,7 @@ impl Step for InternalBitcoindStep { } fn skip(&self, ctx: &Context) -> bool { - ctx.bitcoind_is_external + ctx.bitcoind_is_external || ctx.remote_backend.is_some() } } diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index 3918d608..029e9036 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -548,10 +548,12 @@ impl Step for DefineDescriptor { &'a self, hws: &'a HardwareWallets, progress: (usize, usize), + email: Option<&'a str>, ) -> Element<'a, Message> { let aliases = self.setup.keys_aliases(); let content = view::define_descriptor( progress, + email, self.use_taproot, self.setup .spending_keys @@ -1166,9 +1168,15 @@ impl Step for ImportDescriptor { } } - fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element { + fn view<'a>( + &'a self, + _hws: &'a HardwareWallets, + progress: (usize, usize), + email: Option<&'a str>, + ) -> Element { view::import_descriptor( progress, + email, &self.imported_descriptor, self.wrong_network, self.error.as_ref(), @@ -1310,10 +1318,12 @@ impl Step for RegisterDescriptor { &'a self, hws: &'a HardwareWallets, progress: (usize, usize), + email: Option<&'a str>, ) -> Element<'a, Message> { let desc = self.descriptor.as_ref().unwrap(); view::register_descriptor( progress, + email, desc.to_string(), &hws.list, &self.registered, @@ -1364,9 +1374,14 @@ impl Step for BackupDescriptor { self.done = false; } } - fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element { + fn view<'a>( + &'a self, + _hws: &'a HardwareWallets, + progress: (usize, usize), + email: Option<&'a str>, + ) -> Element { let desc = self.descriptor.as_ref().unwrap(); - view::backup_descriptor(progress, desc.to_string(), self.done) + view::backup_descriptor(progress, email, desc.to_string(), self.done) } } @@ -1416,7 +1431,7 @@ mod tests { #[tokio::test] async fn test_define_descriptor_use_hotkey() { - let mut ctx = Context::new(Network::Signet, PathBuf::from_str("/").unwrap()); + let mut ctx = Context::new(Network::Signet, PathBuf::from_str("/").unwrap(), None); let sandbox: Sandbox = Sandbox::new(DefineDescriptor::new( Network::Bitcoin, Arc::new(Mutex::new(Signer::generate(Network::Bitcoin).unwrap())), @@ -1498,7 +1513,7 @@ mod tests { #[tokio::test] async fn test_define_descriptor_stores_if_hw_is_used() { - let mut ctx = Context::new(Network::Testnet, PathBuf::from_str("/").unwrap()); + let mut ctx = Context::new(Network::Testnet, PathBuf::from_str("/").unwrap(), None); let sandbox: Sandbox = Sandbox::new(DefineDescriptor::new( Network::Testnet, Arc::new(Mutex::new(Signer::generate(Network::Testnet).unwrap())), diff --git a/gui/src/installer/step/mnemonic.rs b/gui/src/installer/step/mnemonic.rs index 439cb628..8343fb13 100644 --- a/gui/src/installer/step/mnemonic.rs +++ b/gui/src/installer/step/mnemonic.rs @@ -51,8 +51,13 @@ impl Step for BackupMnemonic { false } } - fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element { - view::backup_mnemonic(progress, &self.words, self.done) + fn view<'a>( + &'a self, + _hws: &'a HardwareWallets, + progress: (usize, usize), + email: Option<&'a str>, + ) -> Element { + view::backup_mnemonic(progress, email, &self.words, self.done) } } @@ -163,9 +168,15 @@ impl Step for RecoverMnemonic { ctx.recovered_signer = Some(Arc::new(signer)); true } - fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element { + fn view<'a>( + &'a self, + _hws: &'a HardwareWallets, + progress: (usize, usize), + email: Option<&'a str>, + ) -> Element { view::recover_mnemonic( progress, + email, &self.words, self.current, &self.suggestions, diff --git a/gui/src/installer/step/mod.rs b/gui/src/installer/step/mod.rs index ed92782d..acb15e0d 100644 --- a/gui/src/installer/step/mod.rs +++ b/gui/src/installer/step/mod.rs @@ -1,3 +1,4 @@ +mod backend; mod bitcoind; mod descriptor; mod mnemonic; @@ -9,6 +10,7 @@ pub use bitcoind::{ pub use descriptor::{BackupDescriptor, DefineDescriptor, ImportDescriptor, RegisterDescriptor}; +pub use backend::ChooseBackend; pub use mnemonic::{BackupMnemonic, RecoverMnemonic}; pub use share_xpubs::ShareXpubs; @@ -35,6 +37,7 @@ pub trait Step { &'a self, _hws: &'a HardwareWallets, progress: (usize, usize), + email: Option<&'a str>, ) -> Element<'a, Message>; fn load_context(&mut self, _ctx: &Context) {} @@ -54,7 +57,12 @@ pub trait Step { pub struct Welcome {} impl Step for Welcome { - fn view(&self, _hws: &HardwareWallets, _progress: (usize, usize)) -> Element { + fn view<'a>( + &'a self, + _hws: &'a HardwareWallets, + _progress: (usize, usize), + _email: Option<&'a str>, + ) -> Element { view::welcome() } } @@ -128,9 +136,15 @@ impl Step for Final { Command::none() } - fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element { + fn view<'a>( + &'a self, + _hws: &'a HardwareWallets, + progress: (usize, usize), + email: Option<&'a str>, + ) -> Element { view::install( progress, + email, self.generating, self.config_path.as_ref(), self.warning.as_ref(), diff --git a/gui/src/installer/step/share_xpubs.rs b/gui/src/installer/step/share_xpubs.rs index 40dd1f5c..27dabe18 100644 --- a/gui/src/installer/step/share_xpubs.rs +++ b/gui/src/installer/step/share_xpubs.rs @@ -160,8 +160,14 @@ impl Step for ShareXpubs { true } - fn view<'a>(&'a self, hws: &'a HardwareWallets, _progress: (usize, usize)) -> Element { + fn view<'a>( + &'a self, + hws: &'a HardwareWallets, + _progress: (usize, usize), + email: Option<&'a str>, + ) -> Element { view::share_xpubs( + email, hws.list .iter() .enumerate() diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index e94285fb..b5b1049a 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -187,6 +187,7 @@ pub fn define_descriptor_advanced_settings<'a>(use_taproot: bool) -> Element<'a, #[allow(clippy::too_many_arguments)] pub fn define_descriptor<'a>( progress: (usize, usize), + email: Option<&'a str>, use_taproot: bool, spending_keys: Vec>, spending_threshold: usize, @@ -253,6 +254,7 @@ pub fn define_descriptor<'a>( layout( progress, + email, "Create the wallet", Column::new() .push(collapse::Collapse::new( @@ -376,6 +378,7 @@ pub fn recovery_path_view( pub fn import_descriptor<'a>( progress: (usize, usize), + email: Option<&'a str>, imported_descriptor: &form::Value, wrong_network: bool, error: Option<&String>, @@ -397,6 +400,7 @@ pub fn import_descriptor<'a>( .spacing(10); layout( progress, + email, "Import the wallet", Column::new() .push(Column::new().spacing(20).push(col_descriptor).push(text( @@ -605,11 +609,13 @@ pub fn hardware_wallet_xpubs<'a>( } pub fn share_xpubs<'a>( + email: Option<&'a str>, hws: Vec>, signer: Element<'a, Message>, ) -> Element<'a, Message> { layout( (0, 0), + email, "Share your public keys (Xpubs)", Column::new() .push( @@ -637,6 +643,7 @@ pub fn share_xpubs<'a>( #[allow(clippy::too_many_arguments)] pub fn register_descriptor<'a>( progress: (usize, usize), + email: Option<&'a str>, descriptor: String, hws: &'a [HardwareWallet], registered: &HashSet, @@ -723,6 +730,7 @@ pub fn register_descriptor<'a>( }; layout( progress, + email, "Register descriptor", Column::new() .push_maybe((!created_desc).then_some( @@ -785,13 +793,15 @@ pub fn register_descriptor<'a>( ) } -pub fn backup_descriptor<'a>( +pub fn backup_descriptor( progress: (usize, usize), + email: Option<&str>, descriptor: String, done: bool, -) -> Element<'a, Message> { +) -> Element<'_, Message> { layout( progress, + email, "Backup your wallet descriptor", Column::new() .push( @@ -982,6 +992,7 @@ pub fn define_bitcoin<'a>( }; layout( progress, + None, "Set up connection to the Bitcoin full node", Column::new() .push(col_address) @@ -1040,6 +1051,7 @@ pub fn define_bitcoin<'a>( pub fn select_bitcoind_type<'a>(progress: (usize, usize)) -> Element<'a, Message> { layout( progress, + None, "Bitcoin node management", Column::new().push( Row::new() @@ -1146,6 +1158,7 @@ pub fn start_internal_bitcoind<'a>( }; layout( progress, + None, "Start Bitcoin full node", Column::new() .push_maybe(download_state.map(|s| { @@ -1250,6 +1263,7 @@ pub fn start_internal_bitcoind<'a>( pub fn install<'a>( progress: (usize, usize), + email: Option<&'a str>, generating: bool, config_path: Option<&std::path::PathBuf>, warning: Option<&'a String>, @@ -1261,6 +1275,7 @@ pub fn install<'a>( }; layout( progress, + email, "Finalize installation", Column::new() .push_maybe(warning.map(|e| card::invalid(text(e)))) @@ -1813,11 +1828,13 @@ pub fn key_list_view<'a>( pub fn backup_mnemonic<'a>( progress: (usize, usize), + email: Option<&'a str>, words: &'a [&'static str; 12], done: bool, ) -> Element<'a, Message> { layout( progress, + email, "Backup your mnemonic", Column::new() .push(text(prompt::MNEMONIC_HELP)) @@ -1853,6 +1870,7 @@ pub fn backup_mnemonic<'a>( pub fn recover_mnemonic<'a>( progress: (usize, usize), + email: Option<&'a str>, words: &'a [(String, bool); 12], current: usize, suggestions: &'a [String], @@ -1861,6 +1879,7 @@ pub fn recover_mnemonic<'a>( ) -> Element<'a, Message> { layout( progress, + email, "Import Mnemonic", Column::new() .push(text(prompt::RECOVER_MNEMONIC_HELP)) @@ -1954,8 +1973,181 @@ pub fn recover_mnemonic<'a>( ) } +pub fn choose_backend( + progress: (usize, usize), + connection_step: Element, +) -> Element { + layout( + progress, + None, + "Choose backend", + Column::new() + .push( + Row::new() + .spacing(20) + .push( + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .width(Length::FillPortion(1)) + .push(image::liana_brand_grey().height(Length::Fixed(100.0))) + .push(text::p2_medium(LIANA_DESC).style(color::GREY_3)) + .push(button::primary(None, "Install local wallet").on_press( + Message::SelectBackend( + message::SelectBackend::ContinueWithLocalWallet, + ), + )), + ) + .push( + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .width(Length::FillPortion(1)) + .push(image::wizardsardine().height(Length::Fixed(100.0))) + .push(text::p2_medium(LIANALITE_DESC).style(color::GREY_3)) + .push(connection_step), + ), + ) + .spacing(50), + true, + Some(Message::Previous), + ) +} + +pub fn connection_step_enter_email<'a>( + email: &form::Value, + processing: bool, + connection_error: Option<&Error>, + auth_error: Option<&'static str>, +) -> Element<'a, Message> { + Column::new() + .spacing(20) + .push_maybe(connection_error.map(|e| text(e.to_string()).style(color::ORANGE))) + .push_maybe(auth_error.map(|e| text(e.to_string()).style(color::ORANGE))) + .push( + form::Form::new_trimmed("email", email, |msg| { + Message::SelectBackend(message::SelectBackend::EmailEdited(msg)) + }) + .size(text::P1_SIZE) + .padding(10) + .warning("Email is not valid"), + ) + .push( + button::primary(None, "Next").on_press_maybe(if processing || !email.valid { + None + } else { + Some(Message::SelectBackend(message::SelectBackend::RequestOTP)) + }), + ) + .into() +} + +pub fn connection_step_enter_otp<'a>( + email: &'a str, + otp: &form::Value, + processing: bool, + connection_error: Option<&Error>, + auth_error: Option<&'static str>, +) -> Element<'a, Message> { + Column::new() + .spacing(20) + .push(text(email).style(color::GREEN)) + .push(text("An authentication was send to you mail")) + .push_maybe(connection_error.map(|e| text(e.to_string()).style(color::ORANGE))) + .push_maybe(auth_error.map(|e| text(e.to_string()).style(color::ORANGE))) + .push( + form::Form::new_trimmed("Token", otp, |msg| { + Message::SelectBackend(message::SelectBackend::OTPEdited(msg)) + }) + .size(text::P1_SIZE) + .padding(10) + .warning("Token is not valid"), + ) + .push( + Row::new() + .spacing(10) + .push( + button::primary(Some(icon::previous_icon()), "Change Email") + .on_press(Message::SelectBackend(message::SelectBackend::EditEmail)), + ) + .push( + button::primary(None, "Resend token").on_press_maybe(if processing { + None + } else { + Some(Message::SelectBackend(message::SelectBackend::RequestOTP)) + }), + ), + ) + .into() +} + +pub fn connection_step_connected<'a>( + email: &'a str, + processing: bool, + wallet_name: Option<&str>, + connection_error: Option<&Error>, + auth_error: Option<&'static str>, +) -> Element<'a, Message> { + Column::new() + .spacing(20) + .push(text(email).style(color::GREEN)) + .push_maybe(connection_error.map(|e| text(e.to_string()).style(color::ORANGE))) + .push_maybe(auth_error.map(|e| text(e.to_string()).style(color::ORANGE))) + .push(if let Some(name) = wallet_name { + Container::new( + Column::new() + .spacing(20) + .push(text(format!("Wallet {} already exists", name))) + .push( + Row::new() + .spacing(10) + .push( + button::primary(Some(icon::previous_icon()), "Change Email") + .on_press(Message::SelectBackend( + message::SelectBackend::EditEmail, + )), + ) + .push( + button::primary(None, "Continue with existing wallet") + .on_press_maybe(if processing { + None + } else { + Some(Message::SelectBackend( + message::SelectBackend::ContinueWithRemoteBackend, + )) + }), + ), + ), + ) + } else { + Container::new( + Row::new() + .spacing(10) + .push( + button::primary(Some(icon::previous_icon()), "Change Email") + .on_press(Message::SelectBackend(message::SelectBackend::EditEmail)), + ) + .push( + button::primary(None, "Continue").on_press_maybe(if processing { + None + } else { + Some(Message::SelectBackend( + message::SelectBackend::ContinueWithRemoteBackend, + )) + }), + ), + ) + }) + .into() +} + +pub const LIANALITE_DESC: &str = "Use the connection to the Bitcoin network provided by Wizardsardine. This removes the need for running a Bitcoin full node on your machine. It also provides synchronisation of your wallet data (labels, transactions, etc..) across your machines and participants in your wallet. We will also keep a backup of your wallet descriptor for you. This is the most convenient option but has privacy implications: your data would be stored on our servers (but never shared with a third party)."; + +pub const LIANA_DESC: &str = "This option creates a wallet on your machine. The wallet will never access any of our servers, we would not even be able to know you use our wallet. This option requires a local Bitcoin full node. A full node is necessary to use Bitcoin in a sovereign way, but it is more accessible than it sounds. The Liana wallet can download and run one for you so you don't have to manage it yourself. It will never use more than a couple GB of disk space. The initial synchronisation of the node takes time and is computationally intensive, but past this point running a Bitcoin full node on your machine is seamless."; + fn layout<'a>( progress: (usize, usize), + email: Option<&'a str>, title: &'static str, content: impl Into>, padding_left: bool, @@ -1968,6 +2160,9 @@ fn layout<'a>( Container::new(scrollable( Column::new() .width(Length::Fill) + .push(Row::new().push(Space::with_width(Length::Fill)).push_maybe( + email.map(|e| Container::new(p1_regular(e).style(color::GREEN)).padding(20)), + )) .push(Space::with_height(Length::Fixed(100.0))) .push( Row::new() diff --git a/gui/src/lianalite/client/auth.rs b/gui/src/lianalite/client/auth.rs index 0d0412a9..b99d26d1 100644 --- a/gui/src/lianalite/client/auth.rs +++ b/gui/src/lianalite/client/auth.rs @@ -39,6 +39,7 @@ pub struct AuthClient { http: reqwest::Client, url: String, api_public_key: String, + pub email: String, } #[derive(Debug, Clone)] @@ -67,11 +68,12 @@ impl From for AuthError { } impl AuthClient { - pub fn new(url: String, api_public_key: String) -> Self { + pub fn new(url: String, api_public_key: String, email: String) -> Self { AuthClient { http: reqwest::Client::new(), url, api_public_key, + email, } } @@ -85,11 +87,11 @@ impl AuthClient { req } - pub async fn sign_in_otp(&self, email: &str) -> Result<(), AuthError> { + pub async fn sign_in_otp(&self) -> Result<(), AuthError> { let response: Response = self .request(Method::POST, &format!("{}/auth/v1/otp", self.url)) .json(&SignInOtp { - email, + email: &self.email, create_user: true, }) .send() @@ -105,12 +107,12 @@ impl AuthClient { Ok(()) } - pub async fn resend_otp(&self, email: &str) -> Result { + pub async fn resend_otp(&self) -> Result { let response: Response = self .request(Method::POST, &format!("{}/auth/v1/resend", self.url)) .json(&ResendOtp { - email, - kind: "email", + email: &self.email, + kind: "signup", }) .send() .await?; @@ -123,18 +125,14 @@ impl AuthClient { Ok(response) } - pub async fn verify_otp( - &self, - email: &str, - token: &str, - ) -> Result { + pub async fn verify_otp(&self, token: &str) -> Result { let response: Response = self .http .post(&format!("{}/auth/v1/verify", self.url)) .header("apikey", &self.api_public_key) .header("Content-Type", "application/json") .json(&VerifyOtp { - email, + email: &self.email, token, kind: "email", }) diff --git a/gui/src/lianalite/client/backend/api.rs b/gui/src/lianalite/client/backend/api.rs index 6649418a..7a9fcfd4 100644 --- a/gui/src/lianalite/client/backend/api.rs +++ b/gui/src/lianalite/client/backend/api.rs @@ -303,7 +303,7 @@ pub struct Address { } pub mod payload { - use liana::miniscript::bitcoin; + use liana::{descriptors::LianaDescriptor, miniscript::bitcoin}; use serde::{Serialize, Serializer}; pub fn ser_to_string( @@ -313,6 +313,13 @@ pub mod payload { s.serialize_str(&field.to_string()) } + #[derive(Serialize)] + pub struct CreateWallet<'a> { + pub name: &'a str, + #[serde(serialize_with = "ser_to_string")] + pub descriptor: &'a LianaDescriptor, + } + #[derive(Serialize)] pub struct ImportPsbt { pub psbt: String, diff --git a/gui/src/lianalite/client/backend/mod.rs b/gui/src/lianalite/client/backend/mod.rs index 6bff0861..b79a3e87 100644 --- a/gui/src/lianalite/client/backend/mod.rs +++ b/gui/src/lianalite/client/backend/mod.rs @@ -2,6 +2,7 @@ pub mod api; use std::{ collections::{HashMap, HashSet}, + path::Path, sync::Arc, }; @@ -17,6 +18,7 @@ use reqwest::{Error, IntoUrl, Method, RequestBuilder, Response}; use tokio::sync::RwLock; use crate::{ + app::settings::{AuthConfig, Settings}, daemon::{model::*, Daemon, DaemonBackend, DaemonError}, hw::HardwareWalletConfig, }; @@ -53,7 +55,7 @@ fn request( #[derive(Debug, Clone)] pub struct BackendClient { - auth: Arc>, + pub auth: Arc>, auth_client: auth::AuthClient, url: String, @@ -94,6 +96,10 @@ impl BackendClient { }) } + pub fn user_email(&self) -> &str { + &self.auth_client.email + } + pub async fn connect_first(self) -> Result<(BackendWalletClient, api::Wallet), DaemonError> { let wallets = self.list_wallets().await?; let first = wallets.first().cloned().ok_or(DaemonError::NoAnswer)?; @@ -129,6 +135,119 @@ impl BackendClient { let list: api::ListWallets = response.json().await?; Ok(list.wallets) } + + pub async fn create_wallet( + &self, + name: &str, + descriptor: &LianaDescriptor, + ) -> Result { + let response = self + .request(Method::POST, &format!("{}/v1/wallets", self.url)) + .await + .json(&api::payload::CreateWallet { name, descriptor }) + .send() + .await?; + if !response.status().is_success() { + return Err(DaemonError::Http( + Some(response.status().into()), + response.text().await?, + )); + } + + let wallet: api::Wallet = response.json().await?; + Ok(wallet) + } + + pub async fn update_wallet_metadata( + &self, + wallet_uuid: &str, + fingerprint_aliases: &HashMap, + hws: &[HardwareWalletConfig], + ) -> Result<(), DaemonError> { + let wallets = self.list_wallets().await?; + let wallet = wallets + .iter() + .find(|w| w.id == wallet_uuid) + .ok_or(DaemonError::Http( + Some(404), + "No wallet exists for this uui".to_string(), + ))?; + let ledger_kinds = [ + async_hwi::DeviceKind::Ledger.to_string(), + async_hwi::DeviceKind::LedgerSimulator.to_string(), + ]; + for cfg in hws { + if ledger_kinds.contains(&cfg.kind) + && !wallet.metadata.ledger_hmacs.iter().any(|ledger_hmac| { + ledger_hmac.fingerprint == cfg.fingerprint && ledger_hmac.hmac == cfg.token + }) + { + let response: Response = self + .request( + Method::PATCH, + &format!("{}/v1/wallets/{}", self.url, wallet_uuid), + ) + .await + .json(&api::payload::UpdateWallet { + ledger_hmac: Some(api::payload::UpdateLedgerHmac { + fingerprint: cfg.fingerprint.to_string(), + hmac: cfg.token.clone(), + }), + fingerprint_aliases: None, + }) + .send() + .await?; + + if !response.status().is_success() { + return Err(DaemonError::Http( + Some(response.status().into()), + response.text().await?, + )); + } + } + } + + if fingerprint_aliases.iter().any(|(fg, alias)| { + !wallet + .metadata + .fingerprint_aliases + .contains(&api::FingerprintAlias { + alias: alias.to_string(), + user_id: self.user_id.clone(), + fingerprint: *fg, + }) + }) { + let response: Response = self + .request( + Method::PATCH, + &format!("{}/v1/wallets/{}", self.url, wallet_uuid), + ) + .await + .json(&api::payload::UpdateWallet { + ledger_hmac: None, + fingerprint_aliases: Some( + fingerprint_aliases + .iter() + .map(|(fg, alias)| api::payload::UpdateFingerprintAlias { + fingerprint: fg.to_string(), + alias: alias.to_string(), + }) + .collect(), + ), + }) + .send() + .await?; + + if !response.status().is_success() { + return Err(DaemonError::Http( + Some(response.status().into()), + response.text().await?, + )); + } + } + + Ok(()) + } } #[derive(Debug, Clone)] @@ -140,10 +259,22 @@ pub struct BackendWalletClient { } impl BackendWalletClient { + pub fn inner_client(&self) -> &BackendClient { + &self.inner + } + pub fn user_id(&self) -> &str { &self.inner.user_id } + pub fn wallet_id(&self) -> String { + self.wallet_uuid.clone() + } + + pub fn user_email(&self) -> &str { + self.inner.user_email() + } + async fn get_wallet(&self) -> Result { let list = self.inner.list_wallets().await?; let wallet = list @@ -313,7 +444,7 @@ impl BackendWalletClient { Ok(res) } - async fn auth(&self) -> AccessTokenResponse { + pub async fn auth(&self) -> AccessTokenResponse { self.inner.auth.read().await.clone() } } @@ -329,7 +460,7 @@ impl Daemon for BackendWalletClient { } /// refresh the token if close to expiration. - async fn is_alive(&self) -> Result<(), DaemonError> { + async fn is_alive(&self, datadir: &Path, network: Network) -> Result<(), DaemonError> { let auth = self.auth().await; if auth.expires_at < Utc::now().timestamp() + 60 { match self.inner.auth.try_write() { @@ -344,6 +475,39 @@ impl Daemon for BackendWalletClient { .refresh_token(&auth.refresh_token) .await?; + let mut settings = Settings::from_file(datadir.to_path_buf(), network) + .map_err(|e| { + DaemonError::Unexpected(format!( + "Cannot access to settings.json file: {}", + e + )) + })?; + + if let Some(wallet_settings) = settings.wallets.iter_mut().find(|w| { + if let Some(auth) = &w.remote_backend_auth { + auth.wallet_id == self.wallet_uuid + } else { + false + } + }) { + wallet_settings.remote_backend_auth = Some(AuthConfig { + email: self.inner.auth_client.email.clone(), + wallet_id: self.wallet_id(), + refresh_token: new.refresh_token.clone(), + }); + } else { + tracing::info!("Wallet id was not found in the settings"); + } + + settings + .to_file(datadir.to_path_buf(), network) + .map_err(|e| { + DaemonError::Unexpected(format!( + "Cannot access to settings.json file: {}", + e + )) + })?; + *old = new; tracing::info!("Liana backend access was refreshed"); } @@ -856,84 +1020,9 @@ impl Daemon for BackendWalletClient { fingerprint_aliases: &HashMap, hws: &[HardwareWalletConfig], ) -> Result<(), DaemonError> { - let wallet = self.get_wallet().await?; - let ledger_kinds = [ - async_hwi::DeviceKind::Ledger.to_string(), - async_hwi::DeviceKind::LedgerSimulator.to_string(), - ]; - for cfg in hws { - if ledger_kinds.contains(&cfg.kind) - && !wallet.metadata.ledger_hmacs.iter().any(|ledger_hmac| { - ledger_hmac.fingerprint == cfg.fingerprint && ledger_hmac.hmac == cfg.token - }) - { - let response: Response = self - .inner - .request( - Method::PATCH, - &format!("{}/v1/wallets/{}", self.inner.url, self.wallet_uuid), - ) - .await - .json(&api::payload::UpdateWallet { - ledger_hmac: Some(api::payload::UpdateLedgerHmac { - fingerprint: cfg.fingerprint.to_string(), - hmac: cfg.token.clone(), - }), - fingerprint_aliases: None, - }) - .send() - .await?; - - if !response.status().is_success() { - return Err(DaemonError::Http( - Some(response.status().into()), - response.text().await?, - )); - } - } - } - - if fingerprint_aliases.iter().any(|(fg, alias)| { - !wallet - .metadata - .fingerprint_aliases - .contains(&api::FingerprintAlias { - alias: alias.to_string(), - user_id: self.inner.user_id.clone(), - fingerprint: *fg, - }) - }) { - let response: Response = self - .inner - .request( - Method::PATCH, - &format!("{}/v1/wallets/{}", self.inner.url, self.wallet_uuid), - ) - .await - .json(&api::payload::UpdateWallet { - ledger_hmac: None, - fingerprint_aliases: Some( - fingerprint_aliases - .iter() - .map(|(fg, alias)| api::payload::UpdateFingerprintAlias { - fingerprint: fg.to_string(), - alias: alias.to_string(), - }) - .collect(), - ), - }) - .send() - .await?; - - if !response.status().is_success() { - return Err(DaemonError::Http( - Some(response.status().into()), - response.text().await?, - )); - } - } - - Ok(()) + self.inner + .update_wallet_metadata(&self.wallet_uuid, fingerprint_aliases, hws) + .await } } diff --git a/gui/src/lianalite/login.rs b/gui/src/lianalite/login.rs new file mode 100644 index 00000000..037fd01e --- /dev/null +++ b/gui/src/lianalite/login.rs @@ -0,0 +1,537 @@ +use std::{path::PathBuf, sync::Arc}; + +use iced::{Alignment, Command, Length}; + +use liana::miniscript::bitcoin::Network; +use liana_ui::{ + color, + component::{button, form, network_banner, notification, text::*}, + icon, image, + widget::*, +}; + +use crate::{ + app::settings::{AuthConfig, Settings, SettingsError, WalletSetting}, + daemon::DaemonError, +}; + +use super::client::{ + auth::{AuthClient, AuthError}, + backend::{api, BackendClient, BackendWalletClient}, +}; + +#[derive(Debug, Clone)] +pub enum Error { + Auth(AuthError), + // DaemonError does not implement Clone. + // TODO: maybe Arc is overkill + Backend(Arc), + Settings(SettingsError), + Unexpected(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Auth(e) => write!(f, "Authentication error: {}", e), + Self::Backend(e) => write!(f, "Remote backend error: {}", e), + Self::Settings(e) => write!(f, "Settings file error: {}", e), + Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), + } + } +} + +impl From for Error { + fn from(value: DaemonError) -> Self { + Self::Backend(Arc::new(value)) + } +} + +impl From for Error { + fn from(value: AuthError) -> Self { + Self::Auth(value) + } +} + +impl From for Error { + fn from(value: SettingsError) -> Self { + Self::Settings(value) + } +} + +#[derive(Debug, Clone)] +pub enum Message { + View(ViewMessage), + OTPRequested(Result<(AuthClient, String), Error>), + OTPResent(Result<(), Error>), + Connected(Result), + // redirect to the installer with the remote backend connection. + Install(Option), + // redirect to the app runner with the remote backend connection. + Run(Result<(BackendWalletClient, api::Wallet), Error>), +} + +#[derive(Debug, Clone)] +pub enum ViewMessage { + RequestOTP, + EditEmail, + EmailEdited(String), + OTPEdited(String), + BackToLauncher, +} + +#[derive(Debug, Clone)] +pub enum BackendState { + NoWallet(BackendClient), + WalletExists(BackendWalletClient, api::Wallet), +} + +pub struct LianaLiteLogin { + pub datadir: PathBuf, + pub network: Network, + + processing: bool, + step: ConnectionStep, + + // Error due to connection + connection_error: Option, + // Authentification Error + auth_error: Option<&'static str>, +} + +pub enum ConnectionStep { + CheckingAuthFile, + EnterEmail { + email: form::Value, + }, + EnterOtp { + client: AuthClient, + backend_api_url: String, + email: String, + otp: form::Value, + }, +} + +impl LianaLiteLogin { + pub fn new(datadir: PathBuf, network: Network) -> (Self, Command) { + ( + Self { + network, + datadir: datadir.clone(), + step: ConnectionStep::CheckingAuthFile, + connection_error: None, + auth_error: None, + processing: true, + }, + Command::perform( + async move { + let auth_config = Settings::from_file(datadir.to_path_buf(), network)? + .wallets + .first() + .cloned() + .ok_or(SettingsError::NotFound)? + .remote_backend_auth + .ok_or(SettingsError::NotFound)?; + let service_config = super::client::get_service_config(network) + .await + .map_err(|e| Error::Unexpected(e.to_string()))?; + let client = AuthClient::new( + service_config.auth_api_url, + service_config.auth_api_public_key, + auth_config.email, + ); + connect_with_refresh_token( + client, + auth_config.refresh_token, + service_config.backend_api_url, + ) + .await + }, + Message::Connected, + ), + ) + } + + pub fn update(&mut self, message: Message) -> Command { + match &mut self.step { + ConnectionStep::CheckingAuthFile => { + if let Message::Connected(res) = message { + self.processing = false; + match res { + Ok(BackendState::NoWallet(_)) => { + self.auth_error = Some("No wallet found for the given email"); + } + Ok(BackendState::WalletExists(client, wallet)) => { + return Command::perform(async move { (client, wallet) }, |(c, w)| { + Message::Run(Ok((c, w))) + }); + } + Err(e) => { + self.connection_error = Some(e); + self.step = ConnectionStep::EnterEmail { + email: form::Value::default(), + }; + } + } + } + } + ConnectionStep::EnterEmail { email } => match message { + Message::View(ViewMessage::EmailEdited(value)) => { + email.valid = value.is_empty() + || email_address::EmailAddress::parse_with_options( + &value, + email_address::Options::default().with_required_tld(), + ) + .is_ok(); + email.value = value; + } + Message::View(ViewMessage::RequestOTP) => { + if email.value.is_empty() { + email.valid = false; + } else if email.valid { + let email = email.value.clone(); + let network = self.network; + self.processing = true; + self.connection_error = None; + self.auth_error = None; + return Command::perform( + async move { + let config = super::client::get_service_config(network) + .await + .map_err(|e| { + if e.status() == Some(reqwest::StatusCode::NOT_FOUND) { + Error::Unexpected( + "Remote servers are unresponsive".to_string(), + ) + } else { + Error::Unexpected(e.to_string()) + } + })?; + let client = AuthClient::new( + config.auth_api_url, + config.auth_api_public_key, + email, + ); + client.sign_in_otp().await?; + Ok((client, config.backend_api_url)) + }, + Message::OTPRequested, + ); + } + } + Message::OTPRequested(res) => { + self.processing = false; + match res { + Ok((client, backend_api_url)) => { + self.step = ConnectionStep::EnterOtp { + email: email.value.to_owned(), + otp: form::Value::default(), + client, + backend_api_url, + }; + } + Err(e) => { + self.connection_error = Some(e); + } + } + } + _ => {} + }, + ConnectionStep::EnterOtp { + client, + email, + otp, + backend_api_url, + } => match message { + Message::View(ViewMessage::EditEmail) => { + self.step = ConnectionStep::EnterEmail { + email: form::Value { + value: email.clone(), + valid: true, + }, + }; + } + Message::View(ViewMessage::RequestOTP) => { + *otp = form::Value::default(); + let client = client.clone(); + self.processing = true; + self.connection_error = None; + self.auth_error = None; + return Command::perform( + async move { + client.resend_otp().await?; + Ok(()) + }, + Message::OTPResent, + ); + } + Message::OTPResent(res) => match res { + Ok(()) => { + self.processing = false; + } + Err(e) => { + tracing::warn!("{}", e); + self.processing = false; + self.connection_error = Some(e); + } + }, + Message::View(ViewMessage::OTPEdited(value)) => { + otp.value = value.trim().to_string(); + otp.valid = true; + if otp.value.len() == 6 { + let client = client.clone(); + let otp = otp.value.clone(); + let backend_api_url = backend_api_url.clone(); + self.processing = true; + self.connection_error = None; + self.auth_error = None; + return Command::perform( + async move { connect(client, otp, backend_api_url).await }, + Message::Connected, + ); + } + } + + Message::Connected(res) => { + self.processing = false; + match res { + Ok(BackendState::NoWallet(client)) => { + return Command::perform(async move { Some(client) }, Message::Install); + } + Ok(BackendState::WalletExists(client, wallet)) => { + let datadir = self.datadir.clone(); + let network = self.network; + return Command::perform( + async move { + update_wallet_auth_settings( + datadir, + network, + wallet.clone(), + client.user_email().to_string(), + client.auth().await.refresh_token, + ) + .await?; + + Ok((client, wallet)) + }, + Message::Run, + ); + } + Err(e) => { + tracing::warn!("{}", e); + if let Error::Auth(AuthError { http_status, .. }) = e { + if http_status == Some(403) { + self.auth_error = Some("Token is expired or is invalid") + } else { + self.connection_error = Some(e); + } + } else { + self.connection_error = Some(e); + } + } + } + } + // Message::Run::Ok is handled by the upper level wrapping the LianaLiteLogin + // state. + Message::Run(Err(e)) => { + self.connection_error = Some(e); + } + _ => {} + }, + } + + Command::none() + } + + pub fn view(&self) -> Element { + let content = Into::>::into( + Container::new( + Column::new() + .spacing(100) + .align_items(Alignment::Center) + .push( + Column::new() + .align_items(Alignment::Center) + .spacing(20) + .width(Length::Fill) + .push(image::wizardsardine().height(Length::Fixed(100.0))) + .push( + Column::new() + .max_width(500) + .spacing(20) + .push(match &self.step { + ConnectionStep::CheckingAuthFile => Column::new(), + ConnectionStep::EnterEmail { email } => Column::new() + .spacing(20) + .push_maybe( + self.auth_error + .map(|e| text(e).style(color::ORANGE)), + ) + .push( + form::Form::new_trimmed("email", email, |msg| { + ViewMessage::EmailEdited(msg) + }) + .size(P1_SIZE) + .padding(10) + .warning("Email is not valid"), + ) + .push(button::primary(None, "Next").on_press_maybe( + if self.processing { + None + } else { + Some(ViewMessage::RequestOTP) + }, + )), + ConnectionStep::EnterOtp { otp, .. } => Column::new() + .push(text("An authentication was send to your email")) + .push_maybe( + self.auth_error + .map(|e| text(e).style(color::ORANGE)), + ) + .spacing(20) + .push( + form::Form::new_trimmed("Token", otp, |msg| { + ViewMessage::OTPEdited(msg) + }) + .size(P1_SIZE) + .padding(10) + .warning("Token is not valid"), + ) + .push( + Row::new() + .spacing(10) + .push( + button::primary( + Some(icon::previous_icon()), + "Change email", + ) + .on_press(ViewMessage::EditEmail), + ) + .push( + button::primary(None, "Resend token") + .on_press_maybe(if self.processing { + None + } else { + Some(ViewMessage::RequestOTP) + }), + ), + ), + }), + ), + ) + .push_maybe(if !matches!(self.step, ConnectionStep::CheckingAuthFile) { + Some( + button::secondary(Some(icon::previous_icon()), "Change network") + .width(Length::Fixed(200.0)) + .on_press(ViewMessage::BackToLauncher), + ) + } else { + None + }), + ) + .padding(50) + .width(Length::Fill) + .height(Length::Fill) + .center_x() + .center_y(), + ) + .map(Message::View); + + let mut col = Column::new(); + if self.network != Network::Bitcoin { + col = col.push(network_banner(self.network)); + } + if let Some(error) = &self.connection_error { + col = col.push( + notification::warning("Connection failed".to_string(), error.to_string()) + .width(Length::Fill), + ); + } + + col.push(content).into() + } +} + +async fn update_wallet_auth_settings( + datadir: PathBuf, + network: Network, + wallet: api::Wallet, + email: String, + refresh_token: String, +) -> Result<(), Error> { + let mut settings = Settings::from_file(datadir.clone(), network)?; + + let descriptor_checksum = wallet + .descriptor + .to_string() + .split_once('#') + .map(|(_, checksum)| checksum) + .expect("Failed to get checksum from a valid LianaDescriptor") + .to_string(); + + let remote_backend_auth = Some(AuthConfig { + email, + wallet_id: wallet.id.clone(), + refresh_token, + }); + + if let Some(wallet_settings) = settings.wallets.iter_mut().find(|w| { + if let Some(auth) = &w.remote_backend_auth { + auth.wallet_id == wallet.id + } else { + false + } + }) { + wallet_settings.remote_backend_auth = remote_backend_auth; + } else { + tracing::info!("Wallet id was not found in the settings, adding now the wallet settings to the settings.json file"); + settings.wallets.insert( + 0, + WalletSetting { + name: wallet.name, + descriptor_checksum, + keys: Vec::new(), + hardware_wallets: Vec::new(), + remote_backend_auth, + }, + ); + } + + settings.to_file(datadir, network).map_err(|e| { + DaemonError::Unexpected(format!("Cannot access to settings.json file: {}", e)) + })?; + + Ok(()) +} + +pub async fn connect( + auth: AuthClient, + token: String, + backend_api_url: String, +) -> Result { + let access = auth.verify_otp(token.trim_end()).await?; + let client = BackendClient::connect(auth, backend_api_url, access.clone()).await?; + + if !client.list_wallets().await?.is_empty() { + let (wallet_client, wallet) = client.connect_first().await?; + Ok(BackendState::WalletExists(wallet_client, wallet)) + } else { + Ok(BackendState::NoWallet(client)) + } +} + +pub async fn connect_with_refresh_token( + auth: AuthClient, + refresh_token: String, + backend_api_url: String, +) -> Result { + let access = auth.refresh_token(&refresh_token).await?; + let client = BackendClient::connect(auth, backend_api_url, access.clone()).await?; + + if !client.list_wallets().await?.is_empty() { + let (wallet_client, wallet) = client.connect_first().await?; + Ok(BackendState::WalletExists(wallet_client, wallet)) + } else { + Ok(BackendState::NoWallet(client)) + } +} diff --git a/gui/src/lianalite/mod.rs b/gui/src/lianalite/mod.rs index 231cb2a9..d482bdd4 100644 --- a/gui/src/lianalite/mod.rs +++ b/gui/src/lianalite/mod.rs @@ -1,86 +1,2 @@ pub mod client; - -use std::collections::HashMap; -use std::fs::OpenOptions; -use std::io::Write; -use std::path::PathBuf; - -use liana::miniscript::bitcoin::Network; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Config { - auth: HashMap, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct NetworkAuthConfig { - email: String, - access_token: String, - expires_at: i64, - refresh_token: String, -} - -pub const DEFAULT_FILE_NAME: &str = "lite.json"; - -impl Config { - pub fn file_path(datadir: PathBuf, network: Network) -> PathBuf { - let mut path = datadir; - path.push(network.to_string()); - path.push(DEFAULT_FILE_NAME); - path - } - pub fn from_file(datadir: PathBuf, network: Network) -> Result { - let path = Self::file_path(datadir, network); - - let config = std::fs::read(path) - .map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => ConfigError::NotFound, - _ => ConfigError::ReadingFile(format!("Reading settings file: {}", e)), - }) - .and_then(|file_content| { - serde_json::from_slice::(&file_content) - .map_err(|e| ConfigError::ReadingFile(format!("Parsing settings file: {}", e))) - })?; - Ok(config) - } - - pub fn to_file(&self, datadir: PathBuf, network: Network) -> Result<(), ConfigError> { - let path = Self::file_path(datadir, network); - - let content = serde_json::to_string_pretty(&self).map_err(|e| { - ConfigError::WritingFile(format!("Failed to serialize settings: {}", e)) - })?; - - let mut settings_file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(path) - .map_err(|e| ConfigError::WritingFile(e.to_string()))?; - - settings_file.write_all(content.as_bytes()).map_err(|e| { - tracing::warn!("failed to write to file: {:?}", e); - ConfigError::WritingFile(e.to_string()) - }) - } -} - -#[derive(PartialEq, Eq, Debug, Clone)] -pub enum ConfigError { - NotFound, - ReadingFile(String), - WritingFile(String), - Unexpected(String), -} - -impl std::fmt::Display for ConfigError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::NotFound => write!(f, "Settings file not found"), - Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e), - Self::WritingFile(e) => write!(f, "Error while writing file: {}", e), - Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), - } - } -} +pub mod login; diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 915b579b..b29f7e7c 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod bitcoind; pub mod daemon; +pub mod datadir; pub mod download; pub mod hw; pub mod installer; diff --git a/gui/src/main.rs b/gui/src/main.rs index 02b4095d..f3224995 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -27,10 +27,14 @@ use liana_gui::{ wallet::Wallet, App, }, + datadir::{self, create_directory}, hw::HardwareWalletConfig, installer::{self, Installer}, launcher::{self, Launcher}, - lianalite::client::{auth::AuthClient, backend::BackendClient, get_service_config}, + lianalite::{ + client::{backend::api, backend::BackendWalletClient}, + login, + }, loader::{self, Loader}, logger::Logger, VERSION, @@ -41,8 +45,6 @@ enum Arg { ConfigPath(PathBuf), DatadirPath(PathBuf), Network(bitcoin::Network), - Email(String), - RefreshToken(String), } fn parse_args(args: Vec) -> Result, Box> { @@ -85,18 +87,6 @@ Options: } else { return Err("missing arg to --datadir".into()); } - } else if arg == "--email" { - if let Some(a) = args.get(i + 1) { - res.push(Arg::Email(a.to_string())); - } else { - return Err("missing arg to --email".into()); - } - } else if arg == "--refresh_token" { - if let Some(a) = args.get(i + 1) { - res.push(Arg::RefreshToken(a.to_string())); - } else { - return Err("missing arg to --access_token".into()); - } } else if arg.contains("--") { let network = bitcoin::Network::from_str(args[i].trim_start_matches("--"))?; res.push(Arg::Network(network)); @@ -117,6 +107,7 @@ enum State { Launcher(Box), Installer(Box), Loader(Box), + Login(Box), App(App), } @@ -133,6 +124,7 @@ pub enum Message { Install(Box), Load(Box), Run(Box), + Login(Box), KeyPressed(Key), Event(iced::Event), } @@ -177,7 +169,7 @@ impl Application for GUI { if !datadir_path.exists() { // datadir is created right before launching the installer // so logs can go in /installer.log - if let Err(e) = create_datadir(&datadir_path) { + if let Err(e) = create_directory(&datadir_path) { error!("Failed to create datadir: {}", e); } else { info!( @@ -190,7 +182,7 @@ impl Application for GUI { datadir_path.clone(), log_level.unwrap_or(LevelFilter::INFO), ); - let (install, command) = Installer::new(datadir_path, network); + let (install, command) = Installer::new(datadir_path, network, None); cmds.push(command.map(|msg| Message::Install(Box::new(msg)))); State::Installer(Box::new(install)) } @@ -204,99 +196,6 @@ impl Application for GUI { cmds.push(command.map(|msg| Message::Load(Box::new(msg)))); State::Loader(Box::new(loader)) } - Config::RunWithRemoteBackend(email, refresh_token) => { - let rt = tokio::runtime::Runtime::new().unwrap(); - - // Spawn the root task - let (wallet, client) = rt.block_on(async { - let config = get_service_config(bitcoin::Network::Signet).await.unwrap(); - let backend_url = config.backend_api_url.to_owned(); - - let supabase_client = - AuthClient::new(config.auth_api_url, config.auth_api_public_key); - let access = match refresh_token { - None => { - supabase_client.sign_in_otp(&email).await.unwrap(); - - eprintln!("Please enter token:"); - let mut token = String::new(); - std::io::stdin() - .read_line(&mut token) - .expect("Failed to read line"); - - supabase_client - .verify_otp(&email, token.trim_end()) - .await - .unwrap() - } - Some(token) => supabase_client.refresh_token(&token).await.unwrap(), - }; - - let client = - BackendClient::connect(supabase_client, backend_url, access.clone()) - .await - .unwrap(); - let (client, wallet) = client.connect_first().await.unwrap(); - eprintln!( - "Connected, next time connect directly without otp verification with:" - ); - eprintln!( - "cargo run -- --email {} --refresh_token {}", - email, access.refresh_token - ); - - (wallet, client) - }); - let hws: Vec = wallet - .metadata - .ledger_hmacs - .into_iter() - .map(|ledger_hmac| HardwareWalletConfig { - kind: async_hwi::DeviceKind::Ledger.to_string(), - fingerprint: ledger_hmac.fingerprint, - token: ledger_hmac.hmac, - }) - .collect(); - let aliases: HashMap = wallet - .metadata - .fingerprint_aliases - .into_iter() - .filter_map(|a| { - if a.user_id == client.user_id() { - Some((a.fingerprint, a.alias)) - } else { - None - } - }) - .collect(); - let (app, command) = App::new( - Cache { - network: bitcoin::Network::Signet, - coins: Vec::new(), - rescan_progress: None, - datadir_path: default_datadir().unwrap(), - blockheight: wallet.tip_height.unwrap_or(0), - }, - Arc::new( - Wallet::new(wallet.descriptor) - .with_name(wallet.name) - .with_key_aliases(aliases) - .with_hardware_wallets(hws), - ), - app::Config { - daemon_config_path: None, - daemon_rpc_path: None, - log_level: None, - debug: None, - start_internal_bitcoind: false, - }, - Arc::new(client), - default_datadir().unwrap(), - None, - ); - cmds.push(command.map(|msg| Message::Run(Box::new(msg)))); - State::App(app) - } }; ( Self { @@ -317,6 +216,7 @@ impl Application for GUI { State::Launcher(s) => s.stop(), State::Installer(s) => s.stop(), State::App(s) => s.stop(), + State::Login(_) => {} }; iced::window::close(iced::window::Id::MAIN) } @@ -333,7 +233,7 @@ impl Application for GUI { if !datadir_path.exists() { // datadir is created right before launching the installer // so logs can go in /installer.log - if let Err(e) = create_datadir(&datadir_path) { + if let Err(e) = datadir::create_directory(&datadir_path) { error!("Failed to create datadir: {}", e); } else { info!( @@ -346,7 +246,8 @@ impl Application for GUI { datadir_path.clone(), self.log_level.unwrap_or(LevelFilter::INFO), ); - let (install, command) = Installer::new(datadir_path, network); + + let (install, command) = Installer::new(datadir_path, network, None); self.state = State::Installer(Box::new(install)); command.map(|msg| Message::Install(Box::new(msg))) } @@ -357,38 +258,102 @@ impl Application for GUI { self.log_level .unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)), ); - let (loader, command) = Loader::new(datadir_path, cfg, network, None); - self.state = State::Loader(Box::new(loader)); - command.map(|msg| Message::Load(Box::new(msg))) + if app::settings::Settings::from_file(datadir_path.clone(), network).is_ok_and( + { + |s| { + s.wallets.first().map(|w| w.remote_backend_auth.is_some()) + == Some(true) + } + }, + ) { + let (login, command) = login::LianaLiteLogin::new(datadir_path, network); + self.state = State::Login(Box::new(login)); + command.map(|msg| Message::Login(Box::new(msg))) + } else { + let (loader, command) = Loader::new(datadir_path, cfg, network, None); + self.state = State::Loader(Box::new(loader)); + command.map(|msg| Message::Load(Box::new(msg))) + } } _ => l.update(*msg).map(|msg| Message::Launch(Box::new(msg))), }, + (State::Login(l), Message::Login(msg)) => match *msg { + login::Message::View(login::ViewMessage::BackToLauncher) => { + let launcher = Launcher::new(l.datadir.clone()); + self.state = State::Launcher(Box::new(launcher)); + Command::none() + } + login::Message::Install(remote_backend) => { + let (install, command) = + Installer::new(l.datadir.clone(), l.network, remote_backend); + self.state = State::Installer(Box::new(install)); + command.map(|msg| Message::Install(Box::new(msg))) + } + login::Message::Run(Ok((backend_client, wallet))) => { + let config = app::Config::from_file( + &l.datadir + .join(l.network.to_string()) + .join(app::config::DEFAULT_FILE_NAME), + ) + .expect("A gui configuration file must be present"); + self.logger.set_running_mode( + l.datadir.clone(), + l.network, + config.log_level().unwrap_or(LevelFilter::INFO), + ); + + let (app, command) = create_app_with_remote_backend( + backend_client, + wallet, + l.datadir.clone(), + config, + ); + + self.state = State::App(app); + command.map(|msg| Message::Run(Box::new(msg))) + } + _ => l.update(*msg).map(|msg| Message::Login(Box::new(msg))), + }, (State::Installer(i), Message::Install(msg)) => { if let installer::Message::Exit(path, internal_bitcoind) = *msg { - let cfg = app::Config::from_file(&path).unwrap(); - let daemon_cfg = - DaemonConfig::from_file(cfg.daemon_config_path.clone()).unwrap(); - let datadir_path = daemon_cfg - .data_dir - .as_ref() - .expect("Installer must have set it") - .clone(); + let settings = app::settings::Settings::from_file(i.datadir.clone(), i.network) + .expect("A settings file was created"); + if settings + .wallets + .first() + .map(|w| w.remote_backend_auth.is_some()) + == Some(true) + { + let (login, command) = + login::LianaLiteLogin::new(i.datadir.clone(), i.network); + self.state = State::Login(Box::new(login)); + command.map(|msg| Message::Login(Box::new(msg))) + } else { + let cfg = app::Config::from_file(&path).expect("A config file was created"); + let daemon_cfg = + DaemonConfig::from_file(cfg.daemon_config_path.clone()).unwrap(); + let datadir_path = daemon_cfg + .data_dir + .as_ref() + .expect("Installer must have set it") + .clone(); - self.logger.set_running_mode( - datadir_path.clone(), - daemon_cfg.bitcoin_config.network, - self.log_level - .unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)), - ); - self.logger.remove_install_log_file(datadir_path.clone()); - let (loader, command) = Loader::new( - datadir_path, - cfg, - daemon_cfg.bitcoin_config.network, - internal_bitcoind, - ); - self.state = State::Loader(Box::new(loader)); - command.map(|msg| Message::Load(Box::new(msg))) + self.logger.set_running_mode( + datadir_path.clone(), + daemon_cfg.bitcoin_config.network, + self.log_level + .unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)), + ); + self.logger.remove_install_log_file(datadir_path.clone()); + let (loader, command) = Loader::new( + datadir_path, + cfg, + daemon_cfg.bitcoin_config.network, + internal_bitcoind, + ); + self.state = State::Loader(Box::new(loader)); + command.map(|msg| Message::Load(Box::new(msg))) + } } else if let installer::Message::BackToLauncher = *msg { let launcher = Launcher::new(i.destination_path()); self.state = State::Launcher(Box::new(launcher)); @@ -431,6 +396,7 @@ impl Application for GUI { State::Loader(v) => v.subscription().map(|msg| Message::Load(Box::new(msg))), State::App(v) => v.subscription().map(|msg| Message::Run(Box::new(msg))), State::Launcher(v) => v.subscription().map(|msg| Message::Launch(Box::new(msg))), + State::Login(_) => Subscription::none(), }, iced::event::listen_with(|event, status| match (&event, status) { ( @@ -463,6 +429,7 @@ impl Application for GUI { State::App(v) => v.view().map(|msg| Message::Run(Box::new(msg))), State::Launcher(v) => v.view().map(|msg| Message::Launch(Box::new(msg))), State::Loader(v) => v.view().map(|msg| Message::Load(Box::new(msg))), + State::Login(v) => v.view().map(|msg| Message::Login(Box::new(msg))), } } @@ -471,30 +438,59 @@ impl Application for GUI { } } -fn create_datadir(datadir_path: &std::path::Path) -> Result<(), Box> { - #[cfg(unix)] - return { - use std::fs::DirBuilder; - use std::os::unix::fs::DirBuilderExt; - - let mut builder = DirBuilder::new(); - builder.mode(0o700).recursive(true).create(datadir_path)?; - Ok(()) - }; - - // TODO: permissions on Windows.. - #[cfg(not(unix))] - return { - std::fs::create_dir_all(datadir_path)?; - Ok(()) - }; +pub fn create_app_with_remote_backend( + remote_backend: BackendWalletClient, + wallet: api::Wallet, + datadir: PathBuf, + config: app::Config, +) -> (app::App, iced::Command) { + let hws: Vec = wallet + .metadata + .ledger_hmacs + .into_iter() + .map(|ledger_hmac| HardwareWalletConfig { + kind: async_hwi::DeviceKind::Ledger.to_string(), + fingerprint: ledger_hmac.fingerprint, + token: ledger_hmac.hmac, + }) + .collect(); + let aliases: HashMap = wallet + .metadata + .fingerprint_aliases + .into_iter() + .filter_map(|a| { + if a.user_id == remote_backend.user_id() { + Some((a.fingerprint, a.alias)) + } else { + None + } + }) + .collect(); + App::new( + Cache { + network: bitcoin::Network::Signet, + coins: Vec::new(), + rescan_progress: None, + datadir_path: default_datadir().unwrap(), + blockheight: wallet.tip_height.unwrap_or(0), + }, + Arc::new( + Wallet::new(wallet.descriptor) + .with_name(wallet.name) + .with_key_aliases(aliases) + .with_hardware_wallets(hws), + ), + config, + Arc::new(remote_backend), + datadir, + None, + ) } pub enum Config { Run(PathBuf, app::Config, bitcoin::Network), Launcher(PathBuf), Install(PathBuf, bitcoin::Network), - RunWithRemoteBackend(String, Option), } impl Config { @@ -524,12 +520,6 @@ fn main() -> Result<(), Box> { let datadir_path = default_datadir().unwrap(); Config::new(datadir_path, None) } - [Arg::Email(email)] => Ok(Config::RunWithRemoteBackend(email.to_string(), None)), - [Arg::Email(email), Arg::RefreshToken(token)] - | [Arg::RefreshToken(token), Arg::Email(email)] => Ok(Config::RunWithRemoteBackend( - email.to_string(), - Some(token.to_string()), - )), [Arg::Network(network)] => { let datadir_path = default_datadir().unwrap(); Config::new(datadir_path, Some(*network)) diff --git a/gui/ui/src/image.rs b/gui/ui/src/image.rs index f2ebffb5..db6e542c 100644 --- a/gui/ui/src/image.rs +++ b/gui/ui/src/image.rs @@ -4,6 +4,7 @@ use iced::{widget::svg::Handle, window::icon}; const LIANA_APP_ICON: &[u8] = include_bytes!("../static/logos/liana-app-icon.png"); const LIANA_LOGO_GREY: &[u8] = include_bytes!("../static/logos/LIANA_SYMBOL_Gray.svg"); const LIANA_BRAND_GREY: &[u8] = include_bytes!("../static/logos/LIANA_BRAND_Gray.svg"); +const WIZARDSARDINE_LETTERING: &[u8] = include_bytes!("../static/logos/logo-wizardsardine.svg"); pub fn liana_app_icon() -> icon::Icon { icon::from_file_data(LIANA_APP_ICON, None).unwrap() @@ -19,6 +20,11 @@ pub fn liana_brand_grey() -> Svg { Svg::new(h) } +pub fn wizardsardine() -> Svg { + let h = Handle::from_memory(WIZARDSARDINE_LETTERING.to_vec()); + Svg::new(h) +} + const CREATE_NEW_WALLET_ICON: &[u8] = include_bytes!("../static/icons/blueprint.svg"); pub fn create_new_wallet_icon() -> Svg { diff --git a/gui/ui/static/logos/logo-wizardsardine.svg b/gui/ui/static/logos/logo-wizardsardine.svg new file mode 100644 index 00000000..16dea44d --- /dev/null +++ b/gui/ui/static/logos/logo-wizardsardine.svg @@ -0,0 +1 @@ + \ No newline at end of file