From 39867a47d3693154b407307a55d4ef843576b342 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 21:49:44 -0500 Subject: [PATCH 01/10] gui: run embedded daemon calls on blocking pool Embedded lianad exposes async GUI methods, but the underlying DaemonControl API is synchronous and may block on database work, backend RPC, poller coordination, or daemon shutdown. Move those calls through tokio::task::spawn_blocking and keep the existing single-command serialization with a std mutex. This prevents synchronous embedded daemon work from occupying async executor workers while preserving the previous access semantics. --- liana-gui/src/daemon/embedded.rs | 120 +++++++++++++++++++------------ 1 file changed, 74 insertions(+), 46 deletions(-) diff --git a/liana-gui/src/daemon/embedded.rs b/liana-gui/src/daemon/embedded.rs index 5b867bfdf..2736d7b44 100644 --- a/liana-gui/src/daemon/embedded.rs +++ b/liana-gui/src/daemon/embedded.rs @@ -1,7 +1,9 @@ use lianad::bip329::Labels; use lianad::commands::UpdateDerivIndexesResult; -use std::collections::{HashMap, HashSet}; -use tokio::sync::Mutex; +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, Mutex}, +}; use super::{model::*, node, Daemon, DaemonBackend, DaemonError}; use crate::dir::LianaDirectory; @@ -17,7 +19,7 @@ use lianad::{ pub struct EmbeddedDaemon { config: Config, - handle: Mutex>, + handle: Arc>>, } impl EmbeddedDaemon { @@ -25,20 +27,24 @@ impl EmbeddedDaemon { let handle = DaemonHandle::start_default(config.clone(), false).map_err(DaemonError::Start)?; Ok(Self { - handle: Mutex::new(Some(handle)), + handle: Arc::new(Mutex::new(Some(handle))), config, }) } pub async fn command(&self, method: F) -> Result where - F: FnOnce(&mut DaemonControl) -> Result, + T: Send + 'static, + F: FnOnce(&mut DaemonControl) -> Result + Send + 'static, { - match self.handle.lock().await.as_mut() { + let handle = self.handle.clone(); + tokio::task::spawn_blocking(move || match handle.lock()?.as_mut() { Some(DaemonHandle::Controller { control, .. }) => method(control), Some(_) => unreachable!("No lianad rpc server must be started"), None => Err(DaemonError::DaemonStopped), - } + }) + .await + .map_err(|e| DaemonError::Unexpected(format!("Embedded daemon task failed: {e}")))? } } @@ -74,27 +80,37 @@ impl Daemon for EmbeddedDaemon { _datadir: &LianaDirectory, _network: Network, ) -> Result<(), DaemonError> { - let mut handle = self.handle.lock().await; - if let Some(h) = handle.as_ref() { - if h.is_alive() { - return Ok(()); + let handle = self.handle.clone(); + tokio::task::spawn_blocking(move || { + let mut handle = handle.lock()?; + if let Some(h) = handle.as_ref() { + if h.is_alive() { + return Ok(()); + } } - } - // if the daemon poller is not alive, we try to terminate it to fetch the error. - if let Some(h) = handle.take() { - h.stop() - .map_err(|e| DaemonError::Unexpected(e.to_string()))?; - } - Ok(()) + // if the daemon poller is not alive, we try to terminate it to fetch the error. + if let Some(h) = handle.take() { + h.stop() + .map_err(|e| DaemonError::Unexpected(e.to_string()))?; + } + Ok(()) + }) + .await + .map_err(|e| DaemonError::Unexpected(format!("Embedded daemon task failed: {e}")))? } async fn stop(&self) -> Result<(), DaemonError> { - let mut handle = self.handle.lock().await; - if let Some(h) = handle.take() { - h.stop() - .map_err(|e| DaemonError::Unexpected(e.to_string()))?; - } - Ok(()) + let handle = self.handle.clone(); + tokio::task::spawn_blocking(move || { + let mut handle = handle.lock()?; + if let Some(h) = handle.take() { + h.stop() + .map_err(|e| DaemonError::Unexpected(e.to_string()))?; + } + Ok(()) + }) + .await + .map_err(|e| DaemonError::Unexpected(format!("Embedded daemon task failed: {e}")))? } async fn get_info(&self) -> Result { @@ -112,7 +128,7 @@ impl Daemon for EmbeddedDaemon { limit: usize, start_index: Option, ) -> Result { - self.command(|daemon| { + self.command(move |daemon| { daemon .list_revealed_addresses(is_change, exclude_used, limit, start_index) .map_err(|e| DaemonError::Unexpected(e.to_string())) @@ -125,7 +141,7 @@ impl Daemon for EmbeddedDaemon { receive: Option, change: Option, ) -> Result { - self.command(|daemon| { + self.command(move |daemon| { daemon .update_deriv_indexes(receive, change) .map_err(|e| DaemonError::Unexpected(e.to_string())) @@ -138,7 +154,9 @@ impl Daemon for EmbeddedDaemon { statuses: &[CoinStatus], outpoints: &[OutPoint], ) -> Result { - self.command(|daemon| Ok(daemon.list_coins(statuses, outpoints))) + let statuses = statuses.to_vec(); + let outpoints = outpoints.to_vec(); + self.command(move |daemon| Ok(daemon.list_coins(&statuses, &outpoints))) .await } @@ -157,12 +175,13 @@ impl Daemon for EmbeddedDaemon { end: u32, limit: u64, ) -> Result { - self.command(|daemon| Ok(daemon.list_confirmed_transactions(start, end, limit))) + self.command(move |daemon| Ok(daemon.list_confirmed_transactions(start, end, limit))) .await } async fn list_txs(&self, txids: &[Txid]) -> Result { - self.command(|daemon| Ok(daemon.list_transactions(txids))) + let txids = txids.to_vec(); + self.command(move |daemon| Ok(daemon.list_transactions(&txids))) .await } @@ -173,9 +192,11 @@ impl Daemon for EmbeddedDaemon { feerate_vb: u64, change_address: Option>, ) -> Result { - self.command(|daemon| { + let coins_outpoints = coins_outpoints.to_vec(); + let destinations = destinations.clone(); + self.command(move |daemon| { daemon - .create_spend(destinations, coins_outpoints, feerate_vb, change_address) + .create_spend(&destinations, &coins_outpoints, feerate_vb, change_address) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) .await @@ -187,42 +208,46 @@ impl Daemon for EmbeddedDaemon { is_cancel: bool, feerate_vb: Option, ) -> Result { - self.command(|daemon| { + let txid = *txid; + self.command(move |daemon| { daemon - .rbf_psbt(txid, is_cancel, feerate_vb) + .rbf_psbt(&txid, is_cancel, feerate_vb) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) .await } async fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> { - self.command(|daemon| { + let psbt = psbt.clone(); + self.command(move |daemon| { daemon - .update_spend(psbt.clone()) + .update_spend(psbt) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) .await } async fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> { - self.command(|daemon| { - daemon.delete_spend(txid); + let txid = *txid; + self.command(move |daemon| { + daemon.delete_spend(&txid); Ok(()) }) .await } async fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> { - self.command(|daemon| { + let txid = *txid; + self.command(move |daemon| { daemon - .broadcast_spend(txid) + .broadcast_spend(&txid) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) .await } async fn start_rescan(&self, t: u32) -> Result<(), DaemonError> { - self.command(|daemon| { + self.command(move |daemon| { daemon .start_rescan(t) .map_err(|e| DaemonError::Unexpected(e.to_string())) @@ -237,9 +262,10 @@ impl Daemon for EmbeddedDaemon { feerate_vb: u64, sequence: Option, ) -> Result { - self.command(|daemon| { + let coins_outpoints = coins_outpoints.to_vec(); + self.command(move |daemon| { daemon - .create_recovery(address, coins_outpoints, feerate_vb, sequence) + .create_recovery(address, &coins_outpoints, feerate_vb, sequence) .map(|res| res.psbt) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) @@ -250,7 +276,8 @@ impl Daemon for EmbeddedDaemon { &self, items: &HashSet, ) -> Result, DaemonError> { - self.command(|daemon| Ok(daemon.get_labels(items).labels)) + let items = items.clone(); + self.command(move |daemon| Ok(daemon.get_labels(&items).labels)) .await } @@ -258,15 +285,16 @@ impl Daemon for EmbeddedDaemon { &self, items: &HashMap>, ) -> Result<(), DaemonError> { - self.command(|daemon| { - daemon.update_labels(items); + let items = items.clone(); + self.command(move |daemon| { + daemon.update_labels(&items); Ok(()) }) .await } async fn get_labels_bip329(&self, offset: u32, limit: u32) -> Result { - self.command(|daemon| Ok(daemon.get_labels_bip329(offset, limit).labels)) + self.command(move |daemon| Ok(daemon.get_labels_bip329(offset, limit).labels)) .await } } From 99e22d4276b627a88d5b0c50e37690fc6c7c47c3 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 22:10:10 -0500 Subject: [PATCH 02/10] gui: avoid blocking sleep in loader sync The loader's async sync helper used std::thread::sleep, which blocks a runtime worker even though the only intent is to delay a follow-up daemon info request. Use tokio::time::sleep so the task yields during the delay. This keeps the async runtime responsive while preserving the existing one-second wait behavior. --- liana-gui/src/loader.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/liana-gui/src/loader.rs b/liana-gui/src/loader.rs index 26aa4fcfe..2611cff02 100644 --- a/liana-gui/src/loader.rs +++ b/liana-gui/src/loader.rs @@ -602,7 +602,7 @@ async fn sync( sleep: bool, ) -> Result { if sleep { - std::thread::sleep(std::time::Duration::from_secs(1)); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } daemon.get_info().await } From 543877138a89e585018e1454267ed13d5b03ddf4 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 22:15:02 -0500 Subject: [PATCH 03/10] gui: load preselected items asynchronously Menu navigation for preselected transactions and PSBTs used Handle::block_on to query the daemon before changing panels. With an embedded daemon this waited synchronously on GUI update handling, making navigation sensitive to daemon latency. Route those lookups through Task::perform and apply the preselection when the result arrives. If the item is unavailable or stale, fall back to the normal panel reload path without blocking the UI update. --- liana-gui/src/app/message.rs | 2 + liana-gui/src/app/mod.rs | 76 ++++++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index 0568bf504..1d98fdfe0 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -60,6 +60,8 @@ pub enum Message { HardwareWallets(HardwareWalletMessage), HistoryTransactionsExtension(Result, Error>), HistoryTransactions(Result, Error>), + PreselectedHistoryTransaction(Txid, Result, Error>), + PreselectedSpendTransaction(Txid, Result, Error>), Payments(Result, Error>), PaymentsExtension(Result, Error>), Payment(Result<(HistoryTransaction, usize), Error>), diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 7f953a847..67fa83449 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -247,31 +247,37 @@ impl App { match &menu { menu::Menu::TransactionPreSelected(txid) => { - if let Ok(Some(tx)) = Handle::current().block_on(async { - self.daemon - .get_history_txs(&[*txid]) - .await - .map(|txs| txs.first().cloned()) - }) { - self.panels.transactions.preselect(tx); - self.panels.current = menu; - return Task::none(); - }; + let txid = *txid; + let daemon = self.daemon.clone(); + self.panels.current = menu; + return Task::perform( + async move { + daemon + .get_history_txs(&[txid]) + .await + .map(|txs| txs.first().cloned()) + .map_err(Error::from) + }, + move |res| Message::PreselectedHistoryTransaction(txid, res), + ); } menu::Menu::PsbtPreSelected(txid) => { // Get preselected spend from DB in case it's not yet in the cache. // We only need this single spend as we will go straight to its view and not show the PSBTs list. // In case of any error loading the spend or if it doesn't exist, load PSBTs list in usual way. - if let Ok(Some(spend_tx)) = Handle::current().block_on(async { - self.daemon - .list_spend_transactions(Some(&[*txid])) - .await - .map(|txs| txs.first().cloned()) - }) { - self.panels.psbts.preselect(spend_tx); - self.panels.current = menu; - return Task::none(); - }; + let txid = *txid; + let daemon = self.daemon.clone(); + self.panels.current = menu; + return Task::perform( + async move { + daemon + .list_spend_transactions(Some(&[txid])) + .await + .map(|txs| txs.first().cloned()) + .map_err(Error::from) + }, + move |res| Message::PreselectedSpendTransaction(txid, res), + ); } menu::Menu::SettingsPreSelected(setting) => { self.panels.current = menu.clone(); @@ -553,6 +559,36 @@ impl App { Message::WalletUpdated(Ok(wallet)), ) } + Message::PreselectedHistoryTransaction(txid, Ok(Some(tx))) + if self.panels.current == Menu::TransactionPreSelected(txid) => + { + self.panels.transactions.preselect(tx); + Task::none() + } + Message::PreselectedHistoryTransaction(txid, _) => { + if self.panels.current == Menu::TransactionPreSelected(txid) { + return self + .panels + .current_mut() + .reload(self.daemon.clone(), self.wallet.clone()); + } + Task::none() + } + Message::PreselectedSpendTransaction(txid, Ok(Some(spend_tx))) + if self.panels.current == Menu::PsbtPreSelected(txid) => + { + self.panels.psbts.preselect(spend_tx); + Task::none() + } + Message::PreselectedSpendTransaction(txid, _) => { + if self.panels.current == Menu::PsbtPreSelected(txid) { + return self + .panels + .current_mut() + .reload(self.daemon.clone(), self.wallet.clone()); + } + Task::none() + } Message::View(view::Message::Menu(menu)) => self.set_current_panel(menu), Message::View(view::Message::OpenUrl(url)) => { if let Err(e) = open::that_detached(&url) { From bed2c8d214f420dea1b6389879d63ef036c4a868 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 22:19:14 -0500 Subject: [PATCH 04/10] gui: reload daemon config without blocking updates Saving node settings stopped the current daemon, started a replacement, and wrote daemon.toml directly from the app update path. That path used Handle::block_on around daemon.stop(), so slow shutdown or startup could freeze GUI message handling. Run the reload as an iced task instead. The old daemon is stopped asynchronously, the blocking embedded daemon startup and config write run on the blocking pool, and the app swaps in the new daemon only after the task succeeds. --- liana-gui/src/app/message.rs | 2 + liana-gui/src/app/mod.rs | 75 ++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index 1d98fdfe0..f285b8622 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -16,6 +16,7 @@ use crate::{ wallet::Wallet, }, daemon::model::*, + daemon::Daemon, export::ImportExportMessage, hw::HardwareWalletMessage, services::fiat::{ @@ -34,6 +35,7 @@ pub enum Message { UpdatePanelCache(/* is current panel */ bool), View(view::Message), LoadDaemonConfig(Box), + DaemonConfigReloaded(Result, Error>), DaemonConfigLoaded(Result<(), Error>), LoadWallet(Wallet), Info(Result), diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 67fa83449..4b5dace62 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -548,8 +548,27 @@ impl App { Task::batch(commands) } Message::LoadDaemonConfig(cfg) => { - let res = self.load_daemon_config(self.cache.datadir_path.clone(), *cfg); - self.update(Message::DaemonConfigLoaded(res)) + let daemon = self.daemon.clone(); + let datadir_path = self.cache.datadir_path.clone(); + let wallet_id = self.wallet.id(); + Task::perform( + reload_daemon_config(datadir_path, wallet_id, daemon, *cfg), + Message::DaemonConfigReloaded, + ) + } + Message::DaemonConfigReloaded(res) => { + let res = match res { + Ok(daemon) => { + self.daemon = daemon; + Ok(()) + } + Err(e) => Err(e), + }; + self.panels.current_mut().update( + self.daemon.clone(), + &self.cache, + Message::DaemonConfigLoaded(res), + ) } Message::WalletUpdated(Ok(wallet)) => { self.wallet = wallet.clone(); @@ -604,24 +623,39 @@ impl App { } } - pub fn load_daemon_config( - &mut self, - datadir_path: LianaDirectory, - cfg: DaemonConfig, - ) -> Result<(), Error> { - Handle::current().block_on(async { self.daemon.stop().await })?; + pub fn view(&self) -> Element<'_, Message> { + let content = self.panels.current().view(&self.cache).map(Message::View); + if self.cache.network != bitcoin::Network::Bitcoin { + Column::with_children(vec![network_banner(self.cache.network).into(), content]).into() + } else { + content + } + } + + pub fn datadir_path(&self) -> &LianaDirectory { + &self.cache.datadir_path + } +} + +async fn reload_daemon_config( + datadir_path: LianaDirectory, + wallet_id: WalletId, + old_daemon: Arc, + cfg: DaemonConfig, +) -> Result, Error> { + old_daemon.stop().await?; + tokio::task::spawn_blocking(move || { let network = cfg.bitcoin_config.network; let daemon = EmbeddedDaemon::start(cfg)?; - self.daemon = Arc::new(daemon); let mut daemon_config_path = datadir_path .network_directory(network) - .lianad_data_directory(&self.wallet.id()) + .lianad_data_directory(&wallet_id) .path() .to_path_buf(); daemon_config_path.push("daemon.toml"); let content = - toml::to_string(&self.daemon.config()).map_err(|e| Error::Config(e.to_string()))?; + toml::to_string(&daemon.config()).map_err(|e| Error::Config(e.to_string()))?; OpenOptions::new() .write(true) @@ -632,21 +666,12 @@ impl App { .map_err(|e| { warn!("failed to write to file: {:?}", e); Error::Config(e.to_string()) - }) - } + })?; - pub fn view(&self) -> Element<'_, Message> { - let content = self.panels.current().view(&self.cache).map(Message::View); - if self.cache.network != bitcoin::Network::Bitcoin { - Column::with_children(vec![network_banner(self.cache.network).into(), content]).into() - } else { - content - } - } - - pub fn datadir_path(&self) -> &LianaDirectory { - &self.cache.datadir_path - } + Ok(Arc::new(daemon) as Arc) + }) + .await + .map_err(|e| Error::Unexpected(format!("Daemon config reload task failed: {e}")))? } fn new_recovery_panel(wallet: Arc, cache: &Cache) -> CreateSpendPanel { From 582ca481f08626dc7392bf36e80298b924ff601d Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 22:28:09 -0500 Subject: [PATCH 05/10] gui: redraft spends without blocking updates Spend form updates recalculated draft transactions with Handle::block_on around daemon create_spend_tx/create_recovery calls. With an embedded daemon, backend latency in those calls could block iced update processing while the user edits recipients, feerate, or coin selection. Move redrafting into Task::perform and carry the previous result data back through an explicit RedraftSpend message. A monotonically increasing request id makes stale daemon results harmless, so rapid edits cannot apply an older auto-selection or max-recipient estimate over newer form state. --- liana-gui/src/app/message.rs | 15 +++- liana-gui/src/app/state/spend/step.rs | 122 +++++++++++++++++++------- 2 files changed, 104 insertions(+), 33 deletions(-) diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index f285b8622..1ef35fcd0 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -2,9 +2,10 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use liana::miniscript::bitcoin::{ + address, bip32::{ChildNumber, Fingerprint}, psbt::Psbt, - Address, Txid, + Address, Amount, Txid, }; use lianad::config::Config as DaemonConfig; @@ -51,6 +52,7 @@ pub enum Message { Labels(Result, Error>), SpendTxs(Result, Error>), Psbt(Result<(Psbt, Vec), Error>), + RedraftSpend(u64, RedraftSpendResult), RbfPsbt(Result), Recovery(Result), Signed(Fingerprint, Result), @@ -73,6 +75,17 @@ pub enum Message { Export(ImportExportMessage), } +#[derive(Debug)] +pub struct RedraftSpendResult { + pub send_max_to_recipient: Option, + pub total_recipients: usize, + pub max_address: Address, + pub is_user_coin_selection: bool, + pub is_recovery: bool, + pub result: Result, + pub amount_left_to_select: Option, +} + impl From for Message { fn from(value: ImportExportMessage) -> Self { Message::View(view::Message::ImportExport(value)) diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs index 2b04a101e..c2cbbcf8d 100644 --- a/liana-gui/src/app/state/spend/step.rs +++ b/liana-gui/src/app/state/spend/step.rs @@ -27,7 +27,7 @@ use crate::{ app::{ cache::Cache, error::Error, - message::Message, + message::{Message, RedraftSpendResult}, state::{fiat_converter_for_wallet, psbt}, view::{self, fiat::FiatAmount}, wallet::Wallet, @@ -166,6 +166,7 @@ pub struct DefineSpend { /// Whether this is the first step of the spend creation. /// Required in order to know whether the user can navigate to a previous step. is_first_step: bool, + redraft_request_id: u64, } impl DefineSpend { @@ -205,6 +206,7 @@ impl DefineSpend { amount_left_to_select: None, warning: None, is_first_step, + redraft_request_id: 0, } } @@ -284,7 +286,10 @@ impl DefineSpend { } /// redraft calculates the amount left to select and auto selects coins /// if the user did not select a coin manually - fn redraft(&mut self, daemon: Arc) { + fn redraft(&mut self, daemon: Arc) -> Task { + self.redraft_request_id = self.redraft_request_id.wrapping_add(1); + let redraft_request_id = self.redraft_request_id; + if !self.form_values_are_valid(true) || self.exists_duplicate() { // The current form details are not valid to draft a spend, so remove any previously // calculated amount as it will no longer be valid and could be misleading, e.g. if @@ -304,7 +309,7 @@ impl DefineSpend { ); } self.fee_amount = None; - return; + return Task::none(); } let is_self_transfer = self.recipients.is_empty(); // Define the destinations for a primary path spend from all non-max recipients. @@ -384,7 +389,7 @@ impl DefineSpend { // Note that for a recovery, the amount left to select is ignored by the view. self.amount_left_to_select = Some(Amount::from_sat(destinations.values().sum())); self.fee_amount = None; - return; + return Task::none(); } outpoints } else if !self.is_user_coin_selection && self.send_max_to_recipient.is_some() { @@ -418,32 +423,77 @@ impl DefineSpend { let feerate_vb = self.feerate.value.parse::().expect("Checked before"); let recovery_timelock = self.recovery_timelock; - match tokio::runtime::Handle::current().block_on(async { + let send_max_to_recipient = self.send_max_to_recipient; + let is_user_coin_selection = self.is_user_coin_selection; + let is_recovery = recovery_timelock.is_some(); + let max_address_for_result = max_address.clone(); + + Task::perform( + async move { + let result = if let Some(reco_tl) = recovery_timelock { + daemon + .create_recovery(max_address.clone(), &outpoints, feerate_vb, Some(reco_tl)) + .await + // Map the PSBT to `CreateSpendResult` result. We only need the PSBT below. + .map(|psbt| CreateSpendResult::Success { + psbt, + warnings: vec![], + }) + .map_err(|e| e.into()) + } else { + daemon + .create_spend_tx( + &outpoints, + &destinations, + feerate_vb, + Some(max_address.clone()), + ) + .await + .map_err(|e| e.into()) + }; + + RedraftSpendResult { + send_max_to_recipient, + total_recipients, + max_address: max_address_for_result, + is_user_coin_selection, + is_recovery, + result, + amount_left_to_select: Some(Amount::from_sat(0)), + } + }, + move |result| Message::RedraftSpend(redraft_request_id, result), + ) + } + + fn apply_redraft(&mut self, redraft: RedraftSpendResult) { + let RedraftSpendResult { + send_max_to_recipient, + total_recipients, + max_address, + is_user_coin_selection, + is_recovery, + result, + amount_left_to_select, + } = redraft; + + let recipient_with_max = if let Some(i) = send_max_to_recipient { + Some(( + i, + self.recipients + .get_mut(i) + .expect("max has been requested for this recipient so it must exist"), + )) + } else { + None + }; + + match result { // If recovery timelock is set, create a recovery transaction. Otherwise, a regular spend. - if let Some(reco_tl) = recovery_timelock { - daemon - .create_recovery(max_address.clone(), &outpoints, feerate_vb, Some(reco_tl)) - .await - // Map the PSBT to `CreateSpendResult` result. We only need the PSBT below. - .map(|psbt| CreateSpendResult::Success { - psbt, - warnings: vec![], - }) - } else { - daemon - .create_spend_tx( - &outpoints, - &destinations, - feerate_vb, - Some(max_address.clone()), - ) - .await - } - }) { Ok(CreateSpendResult::Success { psbt, .. }) => { self.fee_amount = Some(psbt.fee().expect("Valid fees")); // Update selected coins for auto-selection (non-recovery case). - if !self.is_user_coin_selection && self.recovery_timelock.is_none() { + if !is_user_coin_selection && !is_recovery { let selected_coins: Vec = psbt .unsigned_tx .input @@ -456,7 +506,7 @@ impl DefineSpend { } } // As coin selection was successful, we can assume there is nothing left to select. - self.amount_left_to_select = Some(Amount::from_sat(0)); + self.amount_left_to_select = amount_left_to_select; if let Some((i, recipient)) = recipient_with_max { // If there's no change output, any excess must be below the dust threshold // and so the max available for this recipient is 0. @@ -478,8 +528,8 @@ impl DefineSpend { } Ok(CreateSpendResult::InsufficientFunds { missing }) => handle_max_under_dust( &mut self.fee_amount, - self.is_user_coin_selection, - self.recovery_timelock.is_none(), + is_user_coin_selection, + !is_recovery, &mut self.coins, recipient_with_max, &mut self.amount_left_to_select, @@ -489,7 +539,7 @@ impl DefineSpend { self.network, ), Err(e) => { - self.warning = Some(e.into()); + self.warning = Some(e); self.fee_amount = None; } } @@ -768,8 +818,15 @@ impl Step for DefineSpend { // - all form values have been added and validated // - not a self-send // - user has not yet selected coins manually - self.redraft(daemon); + let redraft = self.redraft(daemon); self.check_valid(); + return redraft; + } + Message::RedraftSpend(redraft_request_id, redraft) => { + if redraft_request_id == self.redraft_request_id { + self.apply_redraft(redraft); + self.check_valid(); + } } Message::Psbt(res) => match res { Ok(psbt) => { @@ -805,8 +862,9 @@ impl Step for DefineSpend { // In case some selected coins are not spendable anymore and // new coins make more sense to be selected. A redraft is triggered // if all forms are valid (checked in the redraft method) - self.redraft(daemon); + let redraft = self.redraft(daemon); self.check_valid(); + return redraft; } (Err(e), _) | (Ok(_), Err(e)) => self.warning = Some(e), }, From ec6b12182c57dab231ade1ae938c78210a9c27d1 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 22:33:06 -0500 Subject: [PATCH 06/10] gui: clean failed installs asynchronously A failed install tried to remove partially-created wallet data with Handle::block_on from the installer update path. That made the GUI wait for filesystem cleanup and settings/cache updates before it could render the installation error. Report the install failure to the current step immediately and schedule delete_failed_install as an iced task. The cleanup result is routed back only for logging, preserving the best-effort cleanup while keeping installer message handling responsive. --- liana-gui/src/installer/message.rs | 1 + liana-gui/src/installer/mod.rs | 50 ++++++++++++++++++------------ 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index 70dc2f4fb..b55b6f5dd 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -47,6 +47,7 @@ pub enum Message { Select(usize), UseHotSigner, Installed(settings::WalletId, Result), + FailedInstallCleaned(String, Result<(), String>), CreateTaprootDescriptor(bool), SelectDescriptorTemplate(context::DescriptorTemplate), SelectBackend(SelectBackend), diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 0342420c6..43e747874 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -15,7 +15,6 @@ use liana_ui::{ }; use lianad::config::{BitcoinBackend, BitcoindConfig, BitcoindRpcAuth, Config}; use std::{collections::HashMap, fmt::Debug, ops::Deref}; -use tokio::runtime::Handle; use tracing::{error, info, warn}; use std::io::Write; @@ -326,28 +325,39 @@ impl LianaInstaller { .context .liana_directory .network_directory(self.context.bitcoin_config.network); - // In case of failure during install, block the thread to - // deleted the data_dir/network directory in order to start clean again. + let cleanup_network_directory = network_directory.clone(); + let cleanup_wallet_id = wallet_id.clone(); + let cleanup_path = network_directory.path().to_string_lossy().to_string(); + warn!("Installation failed. Cleaning up the network directory."); - if let Err(e) = Handle::current().block_on(delete::delete_failed_install( - &network_directory, - &wallet_id, - )) { - error!( - "Failed to completely clean the network directory (path: '{}'): {}", - network_directory.path().to_string_lossy(), - e - ); - } else { - warn!( - "Successfully cleaned network directory at '{}'.", - network_directory.path().to_string_lossy() - ); - }; - self.steps + let step_task = self + .steps .get_mut(self.current) .expect("There is always a step") - .update(&mut self.hws, Message::Installed(wallet_id, Err(e))) + .update(&mut self.hws, Message::Installed(wallet_id, Err(e))); + let cleanup_task = Task::perform( + async move { + delete::delete_failed_install( + &cleanup_network_directory, + &cleanup_wallet_id, + ) + .await + .map_err(|e| e.to_string()) + }, + move |result| Message::FailedInstallCleaned(cleanup_path.clone(), result), + ); + + Task::batch([step_task, cleanup_task]) + } + Message::FailedInstallCleaned(path, result) => { + match result { + Ok(()) => warn!("Successfully cleaned network directory at '{}'.", path), + Err(e) => error!( + "Failed to completely clean the network directory (path: '{}'): {}", + path, e + ), + } + Task::none() } _ => self .steps From 5274614e585d05eb63a4184555a4e8121c5eb6e4 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 22:38:09 -0500 Subject: [PATCH 07/10] gui: delete wallets without blocking launcher The launcher opened the delete-wallet modal by synchronously checking the user's Liana Connect membership, and confirmed deletion by synchronously awaiting delete_wallet from the modal update path. Both operations can involve network and filesystem work, so they could freeze launcher interactions. Open the modal immediately, run membership lookup and deletion as iced tasks, and apply results only when they still match the wallet shown by the modal. The message boundary uses a small local role enum instead of carrying the upstream UserRole type, keeping launcher messages cloneable and debuggable. --- liana-gui/src/launcher.rs | 134 ++++++++++++++++++++++++++------------ 1 file changed, 94 insertions(+), 40 deletions(-) diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index 03fe6c42c..a05e8c23b 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -11,7 +11,6 @@ use liana_ui::{ widget::{modal::Modal, Column, ColumnExt, Container, Element, Row, RowExt, SpaceExt}, }; use lianad::config::ConfigError; -use tokio::runtime::Handle; use crate::{ app::{ @@ -132,13 +131,15 @@ impl Launcher { } else { None }; - self.delete_wallet_modal = Some(DeleteWalletModal::new( + let (modal, task) = DeleteWalletModal::new( self.network, wallet_datadir, wallets[i].clone(), internal_bitcoind, self.backend_type, - )); + ); + self.delete_wallet_modal = Some(modal); + return task; } Task::none() } @@ -443,6 +444,8 @@ pub enum Message { Install(LianaDirectory, Network, UserFlow), Checked(Result), Run(LianaDirectory, app::config::Config, Network, WalletSettings), + DeleteWalletMembership(WalletId, Result, String>), + WalletDeleted(WalletId, Result<(), String>), } #[derive(Debug, Clone)] @@ -467,14 +470,30 @@ pub enum DeleteWalletMessage { Deleted, } +#[derive(Debug, Clone, Copy)] +pub enum DeleteWalletUserRole { + Owner, + Member, +} + +impl From for DeleteWalletUserRole { + fn from(value: UserRole) -> Self { + match value { + UserRole::Owner => Self::Owner, + UserRole::Member => Self::Member, + } + } +} + struct DeleteWalletModal { network: Network, network_directory: NetworkDirectory, wallet_settings: WalletSettings, - warning: Option, + warning: Option, deleted: bool, + deleting: bool, delete_liana_connect: bool, - user_role: Option, + user_role: Option, // `None` means we were not able to determine whether wallet uses internal bitcoind. internal_bitcoind: Option, backend_type: BackendType, @@ -487,57 +506,92 @@ impl DeleteWalletModal { wallet_settings: WalletSettings, internal_bitcoind: Option, backend_type: BackendType, - ) -> Self { - let mut modal = Self { + ) -> (Self, Task) { + let modal = Self { network, wallet_settings, network_directory, warning: None, deleted: false, + deleting: false, delete_liana_connect: false, internal_bitcoind, user_role: None, backend_type, }; - if let Some(auth) = &modal.wallet_settings.remote_backend_auth { - match Handle::current().block_on(check_membership( - modal.network, - &modal.network_directory, - auth, - modal.backend_type, - )) { - Err(e) => { - modal.warning = Some(e); - } - Ok(user_role) => { - modal.user_role = user_role; - } - } - } - modal + let task = if let Some(auth) = modal.wallet_settings.remote_backend_auth.clone() { + let network = modal.network; + let network_directory = modal.network_directory.clone(); + let backend_type = modal.backend_type; + let wallet_id = modal.wallet_settings.wallet_id(); + Task::perform( + async move { + check_membership(network, &network_directory, &auth, backend_type) + .await + .map(|role| role.map(DeleteWalletUserRole::from)) + .map_err(|e| e.to_string()) + }, + move |res| Message::DeleteWalletMembership(wallet_id.clone(), res), + ) + } else { + Task::none() + }; + + (modal, task) } fn update(&mut self, message: Message) -> Task { match message { + Message::DeleteWalletMembership(wallet_id, result) => { + if wallet_id == self.wallet_settings.wallet_id() { + match result { + Ok(user_role) => self.user_role = user_role, + Err(e) => self.warning = Some(e), + } + } + } + Message::WalletDeleted(wallet_id, result) => { + if wallet_id == self.wallet_settings.wallet_id() { + self.deleting = false; + match result { + Ok(()) => { + self.deleted = true; + return Task::perform(async {}, |_| { + Message::View(ViewMessage::DeleteWallet( + DeleteWalletMessage::Deleted, + )) + }); + } + Err(e) => self.warning = Some(e), + } + } + } Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Confirm(wallet_id))) => { if wallet_id != self.wallet_settings.wallet_id() { return Task::none(); } self.warning = None; - if let Err(e) = Handle::current().block_on(delete_wallet( - self.network, - &self.network_directory, - &self.wallet_settings, - self.delete_liana_connect, - self.backend_type, - )) { - self.warning = Some(e); - } else { - self.deleted = true; - return Task::perform(async {}, |_| { - Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Deleted)) - }); - }; + self.deleting = true; + + let network = self.network; + let network_directory = self.network_directory.clone(); + let wallet_settings = self.wallet_settings.clone(); + let delete_liana_connect = self.delete_liana_connect; + let backend_type = self.backend_type; + return Task::perform( + async move { + delete_wallet( + network, + &network_directory, + &wallet_settings, + delete_liana_connect, + backend_type, + ) + .await + .map_err(|e| e.to_string()) + }, + move |result| Message::WalletDeleted(wallet_id.clone(), result), + ); } Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::DeleteLianaConnect( delete, @@ -553,7 +607,7 @@ impl DeleteWalletModal { let mut confirm_button = button::secondary(None, "Delete wallet") .width(Length::Fixed(200.0)) .style(theme::button::destructive); - if self.warning.is_none() { + if self.warning.is_none() && !self.deleting { confirm_button = confirm_button.on_press(ViewMessage::DeleteWallet( DeleteWalletMessage::Confirm(self.wallet_settings.wallet_id()), )); @@ -610,8 +664,8 @@ impl DeleteWalletModal { .push_maybe(self.wallet_settings.remote_backend_auth.as_ref().map(|a| { checkbox(self.delete_liana_connect) .label(match self.user_role { - Some(UserRole::Owner) | None => "Also permanently delete this wallet from Liana Connect (for all members).".to_string(), - Some(UserRole::Member) => format!("Also disassociate {} from this Liana Connect wallet.", a.email), + Some(DeleteWalletUserRole::Owner) | None => "Also permanently delete this wallet from Liana Connect (for all members).".to_string(), + Some(DeleteWalletUserRole::Member) => format!("Also disassociate {} from this Liana Connect wallet.", a.email), }) .on_toggle_maybe(if !self.deleted { Some(|v| { From bcfb407e17c78f201dafbaf77e318ff63202aab6 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 22:43:18 -0500 Subject: [PATCH 08/10] gui: sync remote aliases without blocking startup Remote-backend app startup updated daemon settings with Handle::block_on when the backend wallet alias differed from the local settings alias. That put an async settings-file write on the synchronous app-construction path. Create the app immediately and batch the alias update with the app's startup task. The new app message only logs failures, preserving the previous best-effort behavior without blocking the transition into the wallet UI. --- liana-gui/src/app/message.rs | 1 + liana-gui/src/app/mod.rs | 6 +++++ liana-gui/src/gui/tab.rs | 49 +++++++++++++++++++++--------------- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index 1ef35fcd0..2fe7761ba 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -30,6 +30,7 @@ use crate::{ pub enum Message { Tick, RedirectLianaConnectLogin, + RemoteBackendAliasUpdated(Result<(), String>), UpdateDaemonCache(Result), CacheUpdated, Fiat(FiatMessage), diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 4b5dace62..98c4e960b 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -485,6 +485,12 @@ impl App { pub fn update(&mut self, message: Message) -> Task { match message { + Message::RemoteBackendAliasUpdated(result) => { + if let Err(e) = result { + tracing::error!("Failed to update wallet settings with remote alias: {}", e); + } + Task::none() + } Message::Fiat(FiatMessage::GetPriceResult(fiat_price)) => { let relevant = self.wallet.fiat_price_is_relevant(&fiat_price); tracing::trace!( diff --git a/liana-gui/src/gui/tab.rs b/liana-gui/src/gui/tab.rs index a354959a8..7f997420f 100644 --- a/liana-gui/src/gui/tab.rs +++ b/liana-gui/src/gui/tab.rs @@ -586,24 +586,31 @@ pub fn create_app_with_remote_backend( // If someone modified the wallet_alias on Liana-Connect, // then the new alias is imported and stored in the settings file. - if wallet.metadata.wallet_alias != wallet_settings.alias { - if let Err(e) = tokio::runtime::Handle::current().block_on(async { - update_settings_file(&network_directory, |mut settings: LianaSettings| { - if let Some(w) = settings - .wallets - .iter_mut() - .find(|w| w.wallet_id() == wallet_id) - { - w.alias = wallet.metadata.wallet_alias.clone(); - tracing::info!("Wallet alias was changed. Settings updated."); - } - settings - }) - .await - }) { - tracing::error!("Failed to update wallet settings with remote alias: {}", e); - } - } + let remote_alias = wallet.metadata.wallet_alias.clone(); + let alias_update_task = if remote_alias != wallet_settings.alias { + let network_directory = network_directory.clone(); + let wallet_id = wallet_id.clone(); + Task::perform( + async move { + update_settings_file(&network_directory, |mut settings: LianaSettings| { + if let Some(w) = settings + .wallets + .iter_mut() + .find(|w| w.wallet_id() == wallet_id) + { + w.alias = remote_alias.clone(); + tracing::info!("Wallet alias was changed. Settings updated."); + } + settings + }) + .await + .map_err(|e| e.to_string()) + }, + app::Message::RemoteBackendAliasUpdated, + ) + } else { + Task::none() + }; let hws: Vec = wallet .metadata @@ -636,7 +643,7 @@ pub fn create_app_with_remote_backend( .map(|pk| (pk.fingerprint, pk.into())) .collect(); - Ok(app::App::new( + let (app, task) = app::App::new( Cache { variant: liana_ui::Variant::Liana, network, @@ -677,7 +684,9 @@ pub fn create_app_with_remote_backend( liana_dir, None, false, - )) + ); + + Ok((app, Task::batch([task, alias_update_task]))) } /// Connect to backend for liana-business using cached tokens. From f7ae1910f07fb4a5a2f167f255d7cf7701c0785c Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 22:48:40 -0500 Subject: [PATCH 09/10] gui: stop embedded services without blocking close The app and loader close hooks still used Handle::block_on to stop an embedded daemon, then synchronously stopped the managed bitcoind. Close handling therefore still had a sync wait on lianad shutdown, which is exactly the boundary this cleanup is trying to avoid. Schedule shutdown on the runtime instead. The task preserves the existing order by awaiting daemon.stop() first and then running bitcoind.stop() on the blocking pool, while the GUI close path no longer blocks on either operation. --- liana-gui/src/app/mod.rs | 24 +++++++++++++++--------- liana-gui/src/loader.rs | 35 ++++++++++++++++++++++------------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 98c4e960b..2b2fe2e48 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -17,7 +17,6 @@ use std::sync::Arc; use std::time::Duration; use iced::{clipboard, Subscription, Task}; -use tokio::runtime::Handle; use tracing::{error, info, warn}; pub use liana::miniscript::bitcoin; @@ -330,14 +329,21 @@ impl App { pub fn stop(&mut self) { info!("Close requested"); if self.daemon.backend().is_embedded() { - if let Err(e) = Handle::current().block_on(async { self.daemon.stop().await }) { - error!("{}", e); - } else { - info!("Internal daemon stopped"); - } - if let Some(bitcoind) = self.internal_bitcoind.take() { - bitcoind.stop(); - } + let daemon = self.daemon.clone(); + let bitcoind = self.internal_bitcoind.take(); + tokio::spawn(async move { + if let Err(e) = daemon.stop().await { + error!("{}", e); + } else { + info!("Internal daemon stopped"); + } + + if let Some(bitcoind) = bitcoind { + if let Err(e) = tokio::task::spawn_blocking(move || bitcoind.stop()).await { + error!("Internal bitcoind shutdown task failed: {}", e); + } + } + }); } } diff --git a/liana-gui/src/loader.rs b/liana-gui/src/loader.rs index 2611cff02..a10b699ce 100644 --- a/liana-gui/src/loader.rs +++ b/liana-gui/src/loader.rs @@ -8,7 +8,6 @@ use std::time::Duration; use iced::futures::{SinkExt, Stream}; use iced::stream::channel; use iced::{Alignment, Length, Subscription, Task}; -use tokio::runtime::Handle; use tracing::{debug, info, warn}; use liana::miniscript::bitcoin; @@ -290,21 +289,31 @@ impl Loader { pub fn stop(&mut self) { info!("Close requested"); - if let Step::Syncing { daemon, .. } = &mut self.step { - if daemon.backend().is_embedded() { - info!("Stopping internal daemon..."); - if let Err(e) = Handle::current().block_on(async { daemon.stop().await }) { - warn!("Internal daemon failed to stop: {}", e); - } else { - info!("Internal daemon stopped"); - } - } - } + let daemon = match &self.step { + Step::Syncing { daemon, .. } if daemon.backend().is_embedded() => Some(daemon.clone()), + _ => None, + }; // NOTE: we take() the internal_bitcoind here to make sure the debug.log reader // subscription is dropped. - if let Some(bitcoind) = self.internal_bitcoind.take() { - bitcoind.stop(); + let bitcoind = self.internal_bitcoind.take(); + if daemon.is_some() || bitcoind.is_some() { + tokio::spawn(async move { + if let Some(daemon) = daemon { + info!("Stopping internal daemon..."); + if let Err(e) = daemon.stop().await { + warn!("Internal daemon failed to stop: {}", e); + } else { + info!("Internal daemon stopped"); + } + } + + if let Some(bitcoind) = bitcoind { + if let Err(e) = tokio::task::spawn_blocking(move || bitcoind.stop()).await { + warn!("Internal bitcoind shutdown task failed: {}", e); + } + } + }); } } From 9f4cf9797cc9b5f123013eb8c1c5e5d6649f6700 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 15 May 2026 23:48:53 -0500 Subject: [PATCH 10/10] ui: avoid shadow redraw artifacts in cards Focused text inputs request periodic redraws for cursor blinking. Inputs inside card::simple could trigger a renderer artifact where the card's semi-transparent shadow was blended repeatedly, making the surrounding card darker on every blink. Make card::simple draw the regular card surface and border without a shadow. This keeps the shared card styling used by Send, Wallet alias, Blockchain rescan, and other form cards, while removing the translucent primitive from text-input redraw paths. --- liana-ui/src/theme/card.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/liana-ui/src/theme/card.rs b/liana-ui/src/theme/card.rs index a7f223033..7bee970ac 100644 --- a/liana-ui/src/theme/card.rs +++ b/liana-ui/src/theme/card.rs @@ -55,7 +55,7 @@ fn card_with_shadow(palette: &ContainerPalette, btn: bool) -> Style { } pub fn simple(theme: &Theme) -> Style { - card_with_shadow(&theme.colors.cards.simple, false) + card(&theme.colors.cards.simple) } pub fn button_simple(theme: &Theme) -> Style {