diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 92201240..bce27ac0 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -44,7 +44,7 @@ chrono = "0.4.38" # Used for managing internal bitcoind base64 = "0.21" bitcoin_hashes = "0.12" -reqwest = { version = "0.11", default-features=false, features = ["rustls-tls"] } +reqwest = { version = "0.11", default-features=false, features = ["json", "rustls-tls"] } rust-ini = "0.19.0" diff --git a/gui/src/app/error.rs b/gui/src/app/error.rs index b13f8ae7..2ca312d7 100644 --- a/gui/src/app/error.rs +++ b/gui/src/app/error.rs @@ -29,10 +29,10 @@ impl std::fmt::Display for Error { DaemonError::Unexpected(e) => write!(f, "{}", e), DaemonError::NoAnswer => write!(f, "Daemon did not answer"), DaemonError::DaemonStopped => write!(f, "Daemon stopped"), - DaemonError::Transport(Some(ErrorKind::ConnectionRefused), _) => { + DaemonError::RpcSocket(Some(ErrorKind::ConnectionRefused), _) => { write!(f, "Failed to connect to daemon") } - DaemonError::Transport(kind, e) => { + DaemonError::RpcSocket(kind, e) => { if let Some(k) = kind { write!(f, "{} [{:?}]", e, k) } else { @@ -48,6 +48,9 @@ impl std::fmt::Display for Error { DaemonError::Rpc(code, e) => { write!(f, "[{:?}] {}", code, e) } + DaemonError::Http(code, e) => { + write!(f, "[{:?}] {}", code, e) + } DaemonError::CoinSelectionError => write!(f, "{}", e), }, Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 9b9de75d..97b77417 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -23,8 +23,7 @@ pub enum Message { View(view::Message), LoadDaemonConfig(Box), DaemonConfigLoaded(Result<(), Error>), - LoadWallet, - WalletLoaded(Result, Error>), + LoadWallet(Wallet), Info(Result), ReceiveAddress(Result<(Address, ChildNumber), Error>), Coins(Result, Error>), @@ -34,7 +33,7 @@ pub enum Message { RbfPsbt(Result), Recovery(Result), Signed(Fingerprint, Result), - WalletRegistered(Result), + WalletUpdated(Result, Error>), Updated(Result<(), Error>), Saved(Result<(), Error>), Verified(Fingerprint, Result<(), Error>), diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 4f65b62f..cddfb770 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -36,7 +36,7 @@ use state::{ use crate::{ app::{cache::Cache, error::Error, menu::Menu, wallet::Wallet}, bitcoind::Bitcoind, - daemon::{embedded::EmbeddedDaemon, Daemon}, + daemon::{embedded::EmbeddedDaemon, Daemon, DaemonBackend}, }; use self::state::SettingsState; @@ -58,6 +58,7 @@ impl Panels { cache: &Cache, wallet: Arc, data_dir: PathBuf, + daemon_backend: DaemonBackend, internal_bitcoind: Option<&Bitcoind>, ) -> Panels { Self { @@ -77,6 +78,7 @@ impl Panels { settings: state::SettingsState::new( data_dir, wallet.clone(), + daemon_backend, internal_bitcoind.is_some(), ), } @@ -116,7 +118,6 @@ impl Panels { } pub struct App { - data_dir: PathBuf, cache: Cache, config: Config, wallet: Arc, @@ -138,14 +139,14 @@ impl App { let mut panels = Panels::new( &cache, wallet.clone(), - data_dir.clone(), + data_dir, + daemon.backend(), internal_bitcoind.as_ref(), ); let cmd = panels.home.reload(daemon.clone(), wallet.clone()); ( Self { panels, - data_dir, cache, config, daemon, @@ -218,14 +219,26 @@ impl App { pub fn subscription(&self) -> Subscription { Subscription::batch(vec![ - time::every(Duration::from_secs(10)).map(|_| Message::Tick), + time::every(Duration::from_secs( + // LianaLite has no rescan feature, the cache refresh loop is only + // to fetch the new block height tip which is only used to warn user + // about recovery availability. + if self.daemon.backend() == DaemonBackend::RemoteBackend { + 120 + // For the rescan feature, we set a higher frequency of cache refresh + // to give to user an up-to-date view of the rescan progress. + } else { + 10 + }, + )) + .map(|_| Message::Tick), self.panels.current().subscription(), ]) } pub fn stop(&mut self) { info!("Close requested"); - if !self.daemon.is_external() { + if self.daemon.backend() == DaemonBackend::EmbeddedLianad { if let Err(e) = Handle::current().block_on(async { self.daemon.stop().await }) { error!("{}", e); } else { @@ -245,6 +258,7 @@ impl App { 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?; let info = daemon.get_info().await?; @@ -276,9 +290,13 @@ impl App { let res = self.load_daemon_config(&path, *cfg); self.update(Message::DaemonConfigLoaded(res)) } - Message::LoadWallet => { - let res = self.load_wallet(); - self.update(Message::WalletLoaded(res)) + Message::WalletUpdated(Ok(wallet)) => { + self.wallet = wallet.clone(); + self.panels.current_mut().update( + self.daemon.clone(), + &self.cache, + Message::WalletUpdated(Ok(wallet)), + ) } Message::View(view::Message::Menu(menu)) => self.set_current_panel(menu), Message::View(view::Message::Clipboard(text)) => clipboard::write(text), @@ -313,15 +331,6 @@ impl App { }) } - pub fn load_wallet(&mut self) -> Result, Error> { - let wallet = Wallet::new(self.wallet.main_descriptor.clone()) - .load_settings(&self.data_dir, self.cache.network)?; - - self.wallet = Arc::new(wallet); - - Ok(self.wallet.clone()) - } - pub fn view(&self) -> Element { let content = self.panels.current().view(&self.cache).map(Message::View); if self.cache.network != bitcoin::Network::Bitcoin { diff --git a/gui/src/app/state/settings/mod.rs b/gui/src/app/state/settings/mod.rs index ff325e27..0d758bf8 100644 --- a/gui/src/app/state/settings/mod.rs +++ b/gui/src/app/state/settings/mod.rs @@ -14,22 +14,29 @@ use wallet::WalletSettingsState; use crate::{ app::{cache::Cache, error::Error, message::Message, state::State, view, wallet::Wallet}, - daemon::Daemon, + daemon::{Daemon, DaemonBackend}, }; pub struct SettingsState { data_dir: PathBuf, wallet: Arc, setting: Option>, + daemon_backend: DaemonBackend, internal_bitcoind: bool, } impl SettingsState { - pub fn new(data_dir: PathBuf, wallet: Arc, internal_bitcoind: bool) -> Self { + pub fn new( + data_dir: PathBuf, + wallet: Arc, + daemon_backend: DaemonBackend, + internal_bitcoind: bool, + ) -> Self { Self { data_dir, wallet, setting: None, + daemon_backend, internal_bitcoind, } } @@ -48,7 +55,7 @@ impl State for SettingsState { BitcoindSettingsState::new( daemon.config().cloned(), cache, - daemon.is_external(), + daemon.backend() != DaemonBackend::EmbeddedLianad, self.internal_bitcoind, ) .into(), @@ -97,7 +104,7 @@ impl State for SettingsState { if let Some(setting) = &self.setting { setting.view(cache) } else { - view::settings::list(cache) + view::settings::list(cache, self.daemon_backend == DaemonBackend::RemoteBackend) } } diff --git a/gui/src/app/state/settings/wallet.rs b/gui/src/app/state/settings/wallet.rs index 3f2710d2..34690539 100644 --- a/gui/src/app/state/settings/wallet.rs +++ b/gui/src/app/state/settings/wallet.rs @@ -16,7 +16,7 @@ use crate::{ app::{ cache::Cache, error::Error, message::Message, settings, state::State, view, wallet::Wallet, }, - daemon::Daemon, + daemon::{Daemon, DaemonBackend}, hw::{HardwareWallet, HardwareWalletConfig, HardwareWallets}, }; @@ -106,30 +106,21 @@ impl State for WalletSettingsState { message: Message, ) -> Command { match message { - Message::Updated(res) => match res { - Ok(()) => { - self.processing = false; - self.updated = true; - Command::perform(async {}, |_| Message::LoadWallet) - } - Err(e) => { - self.processing = false; - self.warning = Some(e); + Message::WalletUpdated(res) => { + self.processing = false; + if let Some(modal) = &mut self.modal { + modal.update(daemon, cache, Message::WalletUpdated(res)) + } else { + match res { + Ok(wallet) => { + self.keys_aliases = Self::keys_aliases(&wallet); + self.wallet = wallet; + self.updated = true; + } + Err(e) => self.warning = Some(e), + }; Command::none() } - }, - Message::WalletLoaded(res) => { - match res { - Ok(wallet) => { - if let Some(modal) = &mut self.modal { - modal.wallet = wallet.clone(); - } - self.keys_aliases = Self::keys_aliases(&wallet); - self.wallet = wallet; - } - Err(e) => self.warning = Some(e), - }; - Command::none() } Message::View(view::Message::Settings( view::SettingsMessage::FingerprintAliasEdited(fg, value), @@ -156,8 +147,9 @@ impl State for WalletSettingsState { .iter() .map(|(fg, name)| (*fg, name.value.to_owned())) .collect(), + daemon, ), - Message::Updated, + Message::WalletUpdated, ) } Message::View(view::Message::Close) => { @@ -246,7 +238,7 @@ impl RegisterWalletModal { fn update( &mut self, - _daemon: Arc, + daemon: Arc, cache: &Cache, message: Message, ) -> Command { @@ -263,13 +255,16 @@ impl RegisterWalletModal { Command::none() } }, - Message::WalletRegistered(res) => { + Message::WalletUpdated(res) => { self.processing = false; self.chosen_hw = None; match res { - Ok(fingerprint) => { - self.registered.insert(fingerprint); - return Command::perform(async {}, |_| Message::LoadWallet); + Ok(wallet) => { + self.registered = HashSet::new(); + for hw in &wallet.hardware_wallets { + self.registered.insert(hw.fingerprint); + } + self.wallet = wallet; } Err(e) => { if !matches!(e, Error::HardwareWallet(async_hwi::Error::UserRefused)) { @@ -295,8 +290,9 @@ impl RegisterWalletModal { device.clone(), *fingerprint, self.wallet.clone(), + daemon, ), - Message::WalletRegistered, + Message::WalletUpdated, ) } else { Command::none() @@ -313,40 +309,61 @@ async fn register_wallet( hw: std::sync::Arc, fingerprint: Fingerprint, wallet: Arc, -) -> Result { + daemon: Arc, +) -> Result, Error> { let hmac = hw .register_wallet(&wallet.name, &wallet.main_descriptor.to_string()) .await .map_err(Error::from)?; if let Some(hmac) = hmac { - let mut settings = settings::Settings::from_file(data_dir.clone(), network)?; - let checksum = wallet.descriptor_checksum(); - if let Some(wallet_setting) = settings - .wallets - .iter_mut() - .find(|w| w.descriptor_checksum == checksum) - { - let kind = hw.device_kind().to_string(); - if let Some(hw_config) = wallet_setting - .hardware_wallets + let kind = hw.device_kind().to_string(); + let hw_cfg = HardwareWalletConfig { + kind: kind.clone(), + token: hex::encode(hmac), + fingerprint, + }; + + if daemon.backend() != DaemonBackend::RemoteBackend { + let mut settings = settings::Settings::from_file(data_dir.clone(), network)?; + let checksum = wallet.descriptor_checksum(); + + if let Some(wallet_setting) = settings + .wallets .iter_mut() - .find(|cfg| cfg.kind == kind && cfg.fingerprint == fingerprint) + .find(|w| w.descriptor_checksum == checksum) { - hw_config.token = hex::encode(hmac); - } else { - wallet_setting.hardware_wallets.push(HardwareWalletConfig { - kind, - token: hex::encode(hmac), - fingerprint, - }) + if let Some(hw_config) = wallet_setting + .hardware_wallets + .iter_mut() + .find(|cfg| cfg.kind == kind && cfg.fingerprint == fingerprint) + { + *hw_config = hw_cfg.clone(); + } else { + wallet_setting.hardware_wallets.push(hw_cfg.clone()) + } } + + settings.to_file(data_dir, network)?; } - settings.to_file(data_dir, network)?; + let mut wallet = wallet.as_ref().clone(); + if let Some(hw_config) = wallet + .hardware_wallets + .iter_mut() + .find(|cfg| cfg.kind == kind && cfg.fingerprint == fingerprint) + { + *hw_config = hw_cfg.clone(); + } else { + wallet.hardware_wallets.push(hw_cfg) + } + daemon + .update_wallet_metadata(&wallet.keys_aliases, &wallet.hardware_wallets) + .await?; + return Ok(Arc::new(wallet)); } - Ok(fingerprint) + Ok(wallet) } async fn update_keys_aliases( @@ -354,24 +371,34 @@ async fn update_keys_aliases( network: Network, wallet: Arc, keys_aliases: Vec<(Fingerprint, String)>, -) -> Result<(), Error> { - let mut settings = settings::Settings::from_file(data_dir.clone(), network)?; - let checksum = wallet.descriptor_checksum(); - if let Some(wallet_setting) = settings - .wallets - .iter_mut() - .find(|w| w.descriptor_checksum == checksum) - { - wallet_setting.keys = keys_aliases - .into_iter() - .map(|(master_fingerprint, name)| settings::KeySetting { - master_fingerprint, - name, - }) - .collect(); + daemon: Arc, +) -> Result, Error> { + if daemon.backend() != DaemonBackend::RemoteBackend { + let mut settings = settings::Settings::from_file(data_dir.clone(), network)?; + let checksum = wallet.descriptor_checksum(); + if let Some(wallet_setting) = settings + .wallets + .iter_mut() + .find(|w| w.descriptor_checksum == checksum) + { + wallet_setting.keys = keys_aliases + .iter() + .map(|(master_fingerprint, name)| settings::KeySetting { + master_fingerprint: *master_fingerprint, + name: name.clone(), + }) + .collect(); + } + + settings.to_file(data_dir, network)?; } - settings.to_file(data_dir, network)?; + let mut wallet = wallet.as_ref().clone(); + wallet.keys_aliases = keys_aliases.into_iter().collect(); - Ok(()) + daemon + .update_wallet_metadata(&wallet.keys_aliases, &wallet.hardware_wallets) + .await?; + + Ok(Arc::new(wallet)) } diff --git a/gui/src/app/view/settings.rs b/gui/src/app/view/settings.rs index 433a6f5a..92352f9d 100644 --- a/gui/src/app/view/settings.rs +++ b/gui/src/app/view/settings.rs @@ -32,7 +32,7 @@ use crate::{ hw::HardwareWallet, }; -pub fn list(cache: &Cache) -> Element { +pub fn list(cache: &Cache, is_remote_backend: bool) -> Element { dashboard( &Menu::Settings, cache, @@ -44,23 +44,27 @@ pub fn list(cache: &Cache) -> Element { Button::new(text("Settings").size(30).bold()) .style(theme::Button::Transparent) .on_press(Message::Menu(Menu::Settings))) - .push( - Container::new( - Button::new( - Row::new() - .push(badge::Badge::new(icon::bitcoin_icon())) - .push(text("Bitcoin Core").bold()) - .padding(10) - .spacing(20) - .align_items(Alignment::Center) - .width(Length::Fill), + .push_maybe( + if !is_remote_backend { + Some(Container::new( + Button::new( + Row::new() + .push(badge::Badge::new(icon::bitcoin_icon())) + .push(text("Bitcoin Core").bold()) + .padding(10) + .spacing(20) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + .on_press(Message::Settings(SettingsMessage::EditBitcoindSettings)) ) .width(Length::Fill) - .style(theme::Button::TransparentBorder) - .on_press(Message::Settings(SettingsMessage::EditBitcoindSettings)) - ) - .width(Length::Fill) - .style(theme::Container::Card(theme::Card::Simple)) + .style(theme::Container::Card(theme::Card::Simple))) + } else { + None + } ) .push( Container::new( diff --git a/gui/src/app/view/warning.rs b/gui/src/app/view/warning.rs index 1ec4dfd2..1496646a 100644 --- a/gui/src/app/view/warning.rs +++ b/gui/src/app/view/warning.rs @@ -25,12 +25,16 @@ impl From<&Error> for WarningMessage { WarningMessage("Internal error".to_string()) } } + DaemonError::Http(Some(code), error) => { + WarningMessage(format!("HTTP error {}: {}", code, error)) + } + DaemonError::Http(None, error) => WarningMessage(format!("HTTP error: {}", error)), DaemonError::Unexpected(_) => WarningMessage("Unknown error".to_string()), DaemonError::Start(_) => WarningMessage("Daemon failed to start".to_string()), DaemonError::ClientNotSupported => { WarningMessage("Daemon client is not supported".to_string()) } - DaemonError::NoAnswer | DaemonError::Transport(..) => { + DaemonError::NoAnswer | DaemonError::RpcSocket(..) => { WarningMessage("Communication with Daemon failed".to_string()) } DaemonError::DaemonStopped => WarningMessage("Daemon stopped".to_string()), diff --git a/gui/src/app/wallet.rs b/gui/src/app/wallet.rs index d6b4405d..2fb83226 100644 --- a/gui/src/app/wallet.rs +++ b/gui/src/app/wallet.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::sync::Arc; use crate::{app::settings, hw::HardwareWalletConfig, signer::Signer}; @@ -24,13 +25,13 @@ pub fn wallet_name(main_descriptor: &LianaDescriptor) -> String { ) } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Wallet { pub name: String, pub main_descriptor: LianaDescriptor, pub keys_aliases: HashMap, pub hardware_wallets: Vec, - pub signer: Option, + pub signer: Option>, } impl Wallet { @@ -60,7 +61,7 @@ impl Wallet { } pub fn with_signer(mut self, signer: Signer) -> Self { - self.signer = Some(signer); + self.signer = Some(Arc::new(signer)); self } @@ -87,12 +88,12 @@ impl Wallet { .to_string() } - pub fn load_settings( + pub fn load_from_settings( self, datadir_path: &Path, network: bitcoin::Network, ) -> Result { - let mut wallet = match settings::Settings::from_file(datadir_path.to_path_buf(), network) { + let wallet = match settings::Settings::from_file(datadir_path.to_path_buf(), network) { Ok(settings) => { if let Some(wallet_setting) = settings.wallets.first() { self.with_name(wallet_setting.name.clone()) @@ -114,6 +115,14 @@ impl Wallet { Err(e) => return Err(e.into()), }; + Ok(wallet) + } + + pub fn load_hotsigners( + self, + datadir_path: &Path, + network: bitcoin::Network, + ) -> Result { let hot_signers = match HotSigner::from_datadir(datadir_path, network) { Ok(signers) => signers, Err(e) => match e { @@ -129,15 +138,15 @@ impl Wallet { }; let curve = bitcoin::secp256k1::Secp256k1::signing_only(); - let keys = wallet.descriptor_keys(); + let keys = self.descriptor_keys(); if let Some(hot_signer) = hot_signers .into_iter() .find(|s| keys.contains(&s.fingerprint(&curve))) { - wallet = wallet.with_signer(Signer::new(hot_signer)); + Ok(self.with_signer(Signer::new(hot_signer))) + } else { + Ok(self) } - - Ok(wallet) } } diff --git a/gui/src/daemon/client/jsonrpc.rs b/gui/src/daemon/client/jsonrpc.rs index 1d9fd302..3eeb12dd 100644 --- a/gui/src/daemon/client/jsonrpc.rs +++ b/gui/src/daemon/client/jsonrpc.rs @@ -240,13 +240,13 @@ impl error::Error for Error { impl From for super::DaemonError { fn from(e: Error) -> super::DaemonError { match e { - Error::Io(e) => super::DaemonError::Transport(Some(e.kind()), format!("io: {:?}", e)), - Error::Json(e) => super::DaemonError::Transport(None, format!("json decode: {}", e)), + Error::Io(e) => super::DaemonError::RpcSocket(Some(e.kind()), format!("io: {:?}", e)), + Error::Json(e) => super::DaemonError::RpcSocket(None, format!("json decode: {}", e)), Error::NonceMismatch => { - super::DaemonError::Transport(None, format!("transport: {}", e)) + super::DaemonError::RpcSocket(None, format!("transport: {}", e)) } Error::VersionMismatch => { - super::DaemonError::Transport(None, format!("transport: {}", e)) + super::DaemonError::RpcSocket(None, format!("transport: {}", e)) } Error::NoErrorOrResult => super::DaemonError::NoAnswer, Error::NotSupported => super::DaemonError::ClientNotSupported, diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 57cd637b..8f546f18 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -18,7 +18,7 @@ use liana::{ miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, }; -use super::{model::*, Daemon, DaemonError}; +use super::{model::*, Daemon, DaemonBackend, DaemonError}; pub trait Client { type Error: Into + Debug; @@ -55,8 +55,8 @@ impl Lianad { #[async_trait] impl Daemon for Lianad { - fn is_external(&self) -> bool { - true + fn backend(&self) -> DaemonBackend { + DaemonBackend::ExternalLianad } fn config(&self) -> Option<&Config> { diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index 98e63037..f9ee842c 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use tokio::sync::Mutex; -use super::{model::*, Daemon, DaemonError}; +use super::{model::*, Daemon, DaemonBackend, DaemonError}; use async_trait::async_trait; use liana::{ commands::{CoinStatus, LabelItem}, @@ -49,8 +49,8 @@ impl std::fmt::Debug for EmbeddedDaemon { #[async_trait] impl Daemon for EmbeddedDaemon { - fn is_external(&self) -> bool { - false + fn backend(&self) -> DaemonBackend { + DaemonBackend::EmbeddedLianad } fn config(&self) -> Option<&Config> { diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 0a462a6c..43398ce6 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -13,16 +13,22 @@ use async_trait::async_trait; use liana::{ commands::{CoinStatus, LabelItem, TransactionInfo}, config::Config, - miniscript::bitcoin::{address, psbt::Psbt, secp256k1, Address, OutPoint, Txid}, + miniscript::bitcoin::{ + address, bip32::Fingerprint, psbt::Psbt, secp256k1, Address, OutPoint, Txid, + }, StartupError, }; +use crate::hw::HardwareWalletConfig; + #[derive(Debug)] pub enum DaemonError { /// Something was wrong with the request. Rpc(i32, String), - /// Something was wrong with the communication. - Transport(Option, String), + /// Something was wrong with the rpc socket communication. + RpcSocket(Option, String), + /// Something was wrong with the http communication. + Http(Option, String), /// Something unexpected happened. Unexpected(String), /// No response. @@ -43,7 +49,8 @@ impl std::fmt::Display for DaemonError { Self::Rpc(code, e) => write!(f, "Daemon error rpc call: [{:?}] {}", code, e), Self::NoAnswer => write!(f, "Daemon returned no answer"), Self::DaemonStopped => write!(f, "Daemon stopped"), - Self::Transport(kind, e) => write!(f, "Daemon transport error: [{:?}] {}", kind, e), + Self::RpcSocket(kind, e) => write!(f, "Daemon transport error: [{:?}] {}", kind, e), + Self::Http(kind, e) => write!(f, "Http error: [{:?}] {}", kind, e), Self::Unexpected(e) => write!(f, "Daemon unexpected error: {}", e), Self::Start(e) => write!(f, "Daemon did not start: {}", e), Self::ClientNotSupported => write!(f, "Daemon communication is not supported"), @@ -52,9 +59,16 @@ impl std::fmt::Display for DaemonError { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaemonBackend { + EmbeddedLianad, + ExternalLianad, + RemoteBackend, +} + #[async_trait] pub trait Daemon: Debug { - fn is_external(&self) -> bool; + fn backend(&self) -> DaemonBackend; fn config(&self) -> Option<&Config>; async fn is_alive(&self) -> Result<(), DaemonError>; async fn stop(&self) -> Result<(), DaemonError>; @@ -263,6 +277,10 @@ pub trait Daemon: Debug { } } + if txids.is_empty() { + return Ok(Vec::new()); + } + let txs = self.list_txs(&txids).await?.transactions; let mut txs = txs .into_iter() @@ -295,6 +313,14 @@ pub trait Daemon: Debug { load_labels(self, &mut txs).await?; Ok(txs) } + /// Implemented by LianaLite backend + async fn update_wallet_metadata( + &self, + _fingerprint_aliases: &HashMap, + _hws: &[HardwareWalletConfig], + ) -> Result<(), DaemonError> { + Ok(()) + } } async fn load_labels( diff --git a/gui/src/lianalite/client/auth.rs b/gui/src/lianalite/client/auth.rs new file mode 100644 index 00000000..0d0412a9 --- /dev/null +++ b/gui/src/lianalite/client/auth.rs @@ -0,0 +1,176 @@ +use reqwest::{Error, IntoUrl, Method, RequestBuilder, Response}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SignInOtp<'a> { + email: &'a str, + create_user: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VerifyOtp<'a, 'b> { + email: &'a str, + token: &'b str, + #[serde(rename = "type")] + kind: &'static str, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResendOtp<'a> { + email: &'a str, + #[serde(rename = "type")] + kind: &'static str, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RefreshToken<'a> { + refresh_token: &'a str, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccessTokenResponse { + pub access_token: String, + pub expires_at: i64, + pub refresh_token: String, +} + +#[derive(Debug, Clone)] +pub struct AuthClient { + http: reqwest::Client, + url: String, + api_public_key: String, +} + +#[derive(Debug, Clone)] +pub struct AuthError { + pub http_status: Option, + pub error: String, +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if let Some(status) = self.http_status { + write!(f, "{}: {}", status, self.error) + } else { + write!(f, "{}", self.error) + } + } +} + +impl From for AuthError { + fn from(value: Error) -> Self { + AuthError { + http_status: None, + error: value.to_string(), + } + } +} + +impl AuthClient { + pub fn new(url: String, api_public_key: String) -> Self { + AuthClient { + http: reqwest::Client::new(), + url, + api_public_key, + } + } + + fn request(&self, method: Method, url: U) -> RequestBuilder { + let req = self + .http + .request(method, url) + .header("apikey", &self.api_public_key) + .header("Content-Type", "application/json"); + tracing::debug!("Sending http request: {:?}", req); + req + } + + pub async fn sign_in_otp(&self, email: &str) -> Result<(), AuthError> { + let response: Response = self + .request(Method::POST, &format!("{}/auth/v1/otp", self.url)) + .json(&SignInOtp { + email, + create_user: true, + }) + .send() + .await?; + + if !response.status().is_success() { + return Err(AuthError { + http_status: Some(response.status().into()), + error: response.text().await?, + }); + } + + Ok(()) + } + + pub async fn resend_otp(&self, email: &str) -> Result { + let response: Response = self + .request(Method::POST, &format!("{}/auth/v1/resend", self.url)) + .json(&ResendOtp { + email, + kind: "email", + }) + .send() + .await?; + if !response.status().is_success() { + return Err(AuthError { + http_status: Some(response.status().into()), + error: response.text().await?, + }); + } + Ok(response) + } + + pub async fn verify_otp( + &self, + email: &str, + 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, + token, + kind: "email", + }) + .send() + .await?; + if !response.status().is_success() { + return Err(AuthError { + http_status: Some(response.status().into()), + error: response.text().await?, + }); + } + + Ok(response.json().await?) + } + + pub async fn refresh_token( + &self, + refresh_token: &str, + ) -> Result { + let response: Response = self + .http + .post(&format!( + "{}/auth/v1/token?grant_type=refresh_token", + self.url + )) + .header("apikey", &self.api_public_key) + .header("Content-Type", "application/json") + .json(&RefreshToken { refresh_token }) + .send() + .await?; + if !response.status().is_success() { + return Err(AuthError { + http_status: Some(response.status().into()), + error: response.text().await?, + }); + } + Ok(response.json().await?) + } +} diff --git a/gui/src/lianalite/client/backend/api.rs b/gui/src/lianalite/client/backend/api.rs new file mode 100644 index 00000000..6649418a --- /dev/null +++ b/gui/src/lianalite/client/backend/api.rs @@ -0,0 +1,416 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use liana::{ + descriptors::LianaDescriptor, + miniscript::bitcoin::{self, bip32, consensus, hashes::hex::FromHex, Amount, OutPoint, Txid}, +}; +use serde::{de, Deserialize, Deserializer}; + +pub fn deser_fromstr<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: FromStr, + ::Err: std::fmt::Display, +{ + let string = String::deserialize(deserializer)?; + T::from_str(&string).map_err(de::Error::custom) +} + +/// Deserialize an address from string, assuming the network was checked. +pub fn deser_addr_assume_checked<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let string = String::deserialize(deserializer)?; + bitcoin::Address::from_str(&string) + .map(|addr| addr.assume_checked()) + .map_err(de::Error::custom) +} + +/// Deserialize an amount from sats +pub fn deser_amount_from_sats<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let a = u64::deserialize(deserializer)?; + Ok(bitcoin::Amount::from_sat(a)) +} + +pub fn deser_hex<'de, D, T>(d: D) -> Result +where + D: Deserializer<'de>, + T: consensus::Decodable, +{ + let s = String::deserialize(d)?; + let s = Vec::from_hex(&s).map_err(de::Error::custom)?; + consensus::deserialize(&s).map_err(de::Error::custom) +} + +/// The maximum number of item to return. +pub const DEFAULT_LIMIT: usize = 20; +/// The maximum number of outpoints that can be provided as a filter. +pub const DEFAULT_OUTPOINTS_LIMIT: usize = 50; +/// The maximum number of items that can be provided as a filter. +pub const DEFAULT_LABEL_ITEMS_LIMIT: usize = 50; + +#[derive(Deserialize)] +pub struct Claims { + pub sub: String, +} + +#[derive(Deserialize)] +pub struct NetworkInfo { + pub feerate: Feerate, + pub rates: HashMap, +} + +#[derive(Deserialize)] +pub struct Feerate { + pub low: Option, + pub high: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WalletBalance { + /// Total of funds that present in a block. + pub confirmed: u64, + /// Total of funds that is not yet in a block. + pub unconfirmed: u64, + /// Total of funds that are mined but not yet available + pub immature: u64, + /// Total of funds that are unconfirmed but are coming from + /// the wallet + pub unconfirmed_change: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WalletStatus { + Normal, + Recovering, + Recovered, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RecoveryPath { + pub sequence: u16, + pub available_balance: u64, + pub total_coins: usize, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Wallet { + pub id: String, + pub name: String, + #[serde(deserialize_with = "deser_fromstr")] + pub descriptor: LianaDescriptor, + pub recovery_paths: Vec, + pub biggest_remaining_sequence: Option, + pub smallest_remaining_sequence: Option, + pub metadata: WalletMetadata, + pub created_at: i64, + pub balance: WalletBalance, + pub status: WalletStatus, + pub tip_height: Option, +} + +#[derive(Deserialize)] +pub struct ListWallets { + pub wallets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WalletMetadata { + pub ledger_hmacs: Vec, + pub fingerprint_aliases: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LedgerHmac { + #[serde(deserialize_with = "deser_fromstr")] + pub fingerprint: bip32::Fingerprint, + pub user_id: String, + pub hmac: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct FingerprintAlias { + #[serde(deserialize_with = "deser_fromstr")] + pub fingerprint: bip32::Fingerprint, + pub user_id: String, + pub alias: String, +} + +#[derive(Deserialize)] +pub struct WalletLabels { + pub labels: HashMap, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PaymentKind { + Outgoing, + Incoming, +} + +#[derive(Deserialize)] +pub struct Payment { + pub txuuid: String, + pub txid: String, + pub vout: u32, + pub amount: u64, + pub block_height: Option, + pub confirmed_at: Option, + pub label: Option, + pub address_label: Option, + pub transaction_label: Option, + pub kind: PaymentKind, + pub is_single: bool, +} + +#[derive(Deserialize)] +pub struct ListPayments { + pub payments: Vec, +} + +#[derive(Clone, Deserialize)] +pub struct Coin { + #[serde(deserialize_with = "deser_addr_assume_checked")] + pub address: bitcoin::Address, + #[serde(deserialize_with = "deser_amount_from_sats")] + pub amount: Amount, + pub derivation_index: bip32::ChildNumber, + pub outpoint: OutPoint, + pub block_height: Option, + pub spend_info: Option, + pub is_immature: bool, + pub is_change_address: bool, +} + +#[derive(Clone, Deserialize)] +pub struct CoinSpendInfo { + pub txid: Txid, + pub height: Option, +} + +#[derive(Deserialize)] +pub struct ListCoins { + pub coins: Vec, +} + +#[derive(Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum UTXOKind { + Deposit, + Change, + External, +} + +#[derive(Clone, Deserialize)] +pub struct Transaction { + pub uuid: String, + pub txid: String, + pub fee: u64, + pub fee_rate: u64, + pub block_height: Option, + pub confirmed_at: Option, + pub label: Option, + #[serde(deserialize_with = "deser_hex")] + pub raw: bitcoin::Transaction, + pub inputs: Vec, + pub outputs: Vec, + /// If the transaction has multiple incoming or ougoing payment. + pub is_batch: bool, +} + +#[derive(Deserialize)] +pub struct ListTransactions { + pub transactions: Vec, +} + +#[derive(Clone, Deserialize)] +pub struct Output { + pub address: Option, + pub label: Option, + pub address_label: Option, + pub amount: u64, + pub kind: UTXOKind, + pub coin: Option, +} + +#[derive(Clone, Deserialize)] +pub struct Input { + pub txid: String, + pub vout: usize, + pub amount: Option, + pub label: Option, + pub kind: UTXOKind, + pub coin: Option, +} + +#[derive(Clone, Deserialize)] +pub struct Psbt { + pub uuid: String, + pub txid: Txid, + pub fee: Option, + pub fee_rate: Option, + pub label: Option, + #[serde(deserialize_with = "deser_fromstr")] + pub raw: bitcoin::Psbt, + pub inputs: Vec, + pub outputs: Vec, + pub is_batch: bool, + pub updated_at: i64, +} + +#[derive(Clone, Deserialize)] +#[serde(untagged)] +pub enum DraftPsbtResult { + Success(DraftPsbt), + InsufficientFunds(InsufficientFundsInfo), +} + +#[derive(Clone, Deserialize)] +pub struct InsufficientFundsInfo { + pub missing: u64, +} + +#[derive(Clone, Deserialize)] +pub struct DraftPsbt { + pub uuid: Option, + pub txid: Txid, + pub fee: u64, + pub fee_rate: u64, + pub label: Option, + #[serde(deserialize_with = "deser_fromstr")] + pub raw: bitcoin::Psbt, + pub inputs: Vec, + pub outputs: Vec, + pub warnings: Vec, +} + +#[derive(Deserialize)] +pub struct ListPsbts { + pub psbts: Vec, +} + +#[derive(Deserialize)] +pub struct Address { + #[serde(deserialize_with = "deser_addr_assume_checked")] + pub address: bitcoin::Address, + pub derivation_index: bip32::ChildNumber, +} + +pub mod payload { + use liana::miniscript::bitcoin; + use serde::{Serialize, Serializer}; + + pub fn ser_to_string( + field: T, + s: S, + ) -> Result { + s.serialize_str(&field.to_string()) + } + + #[derive(Serialize)] + pub struct ImportPsbt { + pub psbt: String, + } + + #[derive(Serialize)] + pub struct Recipient { + /// Recipient cannot have an empty amount and is_max set to false + /// Amount cannot be less that the DUST limit. + pub amount: Option, + pub address: bitcoin::Address, + /// If is_max is set to true, API will calculate the remaining funds and + /// use it for psbt output amount. + /// Only one recipient can have is_max set to true + pub is_max: bool, + } + + #[derive(Serialize)] + pub struct GeneratePsbt<'a> { + pub recipients: Vec, + /// The outpoints of coins to use as transaction inputs. If empty, + /// coins will be selected automatically from the set of confirmed coins + /// and those unconfirmed coins at a change address, excluding immature + /// coins. + pub inputs: &'a [bitcoin::OutPoint], + // The feerate to use for this transaction. + pub feerate: u64, + /// If save is set to true, API will save in database the generated psbt + /// and store the generated change address. + pub save: bool, + } + + #[derive(Serialize)] + pub struct GenerateRecoveryPsbt { + /// The address to sweep funds to. + pub address: bitcoin::Address, + // The feerate to use for this transaction. + pub feerate: u64, + /// Timelock of the recovery path to use. + pub timelock: u16, + /// If save is set to true, API will save in database the generated psbt + /// and store the generated change address. + pub save: bool, + } + + #[derive(Serialize)] + pub struct Labels { + pub labels: Vec