Merge #1775: Delete wallet on Liana-Connect

f62d9429581371d830ef455feb1158024199739f Add check of user membership to delete wallet modal (edouardparis)
1ca02a48979a7a36440ac57d8d83f988241af92e Delete wallet on Liana-Connect (edouardparis)

Pull request description:

ACKs for top commit:
  edouardparis:
    Self-ACK f62d9429581371d830ef455feb1158024199739f

Tree-SHA512: d624955703c83b8308378231a4563c575ed13c7e419377f3f4d8636bdb2b1a7fc157f8ae75f47ccabbf4c9a44ae3f449314987052dbe8337e8e44f8a145700d2
This commit is contained in:
edouardparis 2025-07-21 11:59:16 +02:00
commit 0e7a87ea80
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
6 changed files with 290 additions and 47 deletions

View File

@ -1,16 +1,25 @@
use liana::miniscript::bitcoin::Network;
use std::collections::HashSet;
use crate::{
app::settings::{self, SettingsError, WalletId},
app::settings::{self, SettingsError, WalletSettings},
dir::NetworkDirectory,
services::connect::client::cache::{self, ConnectCacheError},
services::connect::{
client::{
auth::AuthClient,
cache::{self, ConnectCacheError},
get_service_config,
},
login::{connect_with_credentials, BackendState},
},
signer,
};
pub enum DeleteError {
Io(std::io::Error),
Settings(SettingsError),
Connect(ConnectCacheError),
ConnectCache(ConnectCacheError),
Connect(String),
}
impl std::fmt::Display for DeleteError {
@ -18,6 +27,7 @@ impl std::fmt::Display for DeleteError {
match self {
Self::Io(e) => write!(f, "{}", e),
Self::Settings(e) => write!(f, "{}", e),
Self::ConnectCache(e) => write!(f, "{}", e),
Self::Connect(e) => write!(f, "{}", e),
}
}
@ -37,11 +47,12 @@ fn ignore_not_found<T>(result: std::io::Result<T>) -> std::io::Result<Option<T>>
}
}
pub async fn delete_wallet(
pub async fn delete_failed_install(
network_dir: &NetworkDirectory,
wallet_id: &WalletId,
wallet_id: &settings::WalletId,
) -> Result<(), DeleteError> {
let lianad_directory = network_dir.lianad_data_directory(wallet_id);
if !wallet_id.is_legacy() {
ignore_not_found(tokio::fs::remove_dir_all(lianad_directory.path()).await)?;
} else {
@ -77,7 +88,95 @@ pub async fn delete_wallet(
cache::filter_connect_cache(network_dir, &remaining_accounts)
.await
.map_err(DeleteError::Connect)?;
.map_err(DeleteError::ConnectCache)?;
signer::delete_wallet_mnemonics(
network_dir,
&wallet_id.descriptor_checksum,
wallet_id.timestamp,
)
.map_err(DeleteError::Io)?;
Ok(())
}
pub async fn delete_wallet(
network: Network,
network_dir: &NetworkDirectory,
wallet: &WalletSettings,
delete_liana_connect: bool,
) -> Result<(), DeleteError> {
let wallet_id = wallet.wallet_id();
let lianad_directory = network_dir.lianad_data_directory(&wallet_id);
if !wallet_id.is_legacy() {
ignore_not_found(tokio::fs::remove_dir_all(lianad_directory.path()).await)?;
} else {
// if this is a legacy wallet, then it is the only wallet in the network directory.
ignore_not_found(tokio::fs::remove_file(lianad_directory.sqlite_db_file_path()).await)?;
ignore_not_found(
tokio::fs::remove_dir_all(lianad_directory.lianad_watchonly_wallet_path()).await,
)?;
ignore_not_found(
tokio::fs::remove_file(lianad_directory.path().join("daemon.toml")).await,
)?;
}
if delete_liana_connect {
if let Some(auth) = &wallet.remote_backend_auth {
let service_config = get_service_config(network)
.await
.map_err(|e| DeleteError::Connect(e.to_string()))?;
let client = AuthClient::new(
service_config.auth_api_url,
service_config.auth_api_public_key,
auth.email.to_string(),
);
if let BackendState::WalletExists(client, _, _) = connect_with_credentials(
client,
auth.wallet_id.clone(),
service_config.backend_api_url,
network,
network_dir,
)
.await
.map_err(|e| DeleteError::Connect(e.to_string()))?
{
tracing::info!("Deleting wallet on Liana-Connect {} backend", network);
client
.delete_wallet()
.await
.map_err(|e| DeleteError::Connect(e.to_string()))?;
} else {
tracing::warn!("Wallet not found on the platform");
}
}
}
let mut remaining_accounts = HashSet::<String>::new();
settings::update_settings_file(network_dir, |mut settings| {
settings
.wallets
.retain(|settings| settings.wallet_id() != wallet_id);
remaining_accounts = settings
.wallets
.iter()
.filter_map(|settings| {
settings
.remote_backend_auth
.as_ref()
.map(|auth| auth.email.clone())
})
.collect();
settings
})
.await
.map_err(DeleteError::Settings)?;
cache::filter_connect_cache(network_dir, &remaining_accounts)
.await
.map_err(DeleteError::ConnectCache)?;
signer::delete_wallet_mnemonics(
network_dir,

View File

@ -299,9 +299,10 @@ impl Installer {
// In case of failure during install, block the thread to
// deleted the data_dir/network directory in order to start clean again.
warn!("Installation failed. Cleaning up the network directory.");
if let Err(e) = Handle::current()
.block_on(delete::delete_wallet(&network_directory, &wallet_id))
{
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(),

View File

@ -1,6 +1,6 @@
use iced::{
alignment::Horizontal,
widget::{pick_list, scrollable, Button, Space},
widget::{checkbox, pick_list, scrollable, Button, Space},
Alignment, Length, Subscription, Task,
};
@ -16,11 +16,15 @@ use tokio::runtime::Handle;
use crate::{
app::{
self,
settings::{self, WalletId, WalletSettings},
settings::{self, AuthConfig, WalletId, WalletSettings},
},
delete::{delete_wallet, DeleteError},
dir::{LianaDirectory, NetworkDirectory},
installer::UserFlow,
services::connect::{
client::{auth::AuthClient, backend::api::UserRole, get_service_config},
login::{connect_with_credentials, BackendState},
},
};
const NETWORKS: [Network; 4] = [
@ -124,6 +128,7 @@ impl Launcher {
None
};
self.delete_wallet_modal = Some(DeleteWalletModal::new(
self.network,
wallet_datadir,
wallets[i].clone(),
internal_bitcoind,
@ -425,52 +430,83 @@ pub enum DeleteWalletMessage {
ShowModal(usize),
CloseModal,
Confirm(WalletId),
DeleteLianaConnect(bool),
Deleted,
}
struct DeleteWalletModal {
network: Network,
network_directory: NetworkDirectory,
wallet_settings: WalletSettings,
warning: Option<DeleteError>,
deleted: bool,
delete_liana_connect: bool,
user_role: Option<UserRole>,
// `None` means we were not able to determine whether wallet uses internal bitcoind.
internal_bitcoind: Option<bool>,
}
impl DeleteWalletModal {
fn new(
network: Network,
network_directory: NetworkDirectory,
wallet_settings: WalletSettings,
internal_bitcoind: Option<bool>,
) -> Self {
Self {
let mut modal = Self {
network,
wallet_settings,
network_directory,
warning: None,
deleted: false,
delete_liana_connect: false,
internal_bitcoind,
user_role: None,
};
if let Some(auth) = &modal.wallet_settings.remote_backend_auth {
match Handle::current().block_on(check_membership(
modal.network,
&modal.network_directory,
auth,
)) {
Err(e) => {
modal.warning = Some(e);
}
Ok(user_role) => {
modal.user_role = user_role;
}
}
}
modal
}
fn update(&mut self, message: Message) -> Task<Message> {
if let Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Confirm(wallet_id))) =
message
{
if wallet_id != self.wallet_settings.wallet_id() {
return Task::none();
match message {
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.warning = Some(e);
} else {
self.deleted = true;
return Task::perform(async {}, |_| {
Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Deleted))
});
};
}
self.warning = None;
if let Err(e) = Handle::current().block_on(delete_wallet(
&self.network_directory,
&self.wallet_settings.wallet_id(),
)) {
self.warning = Some(e);
} else {
self.deleted = true;
return Task::perform(async {}, |_| {
Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Deleted))
});
};
Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::DeleteLianaConnect(
delete,
))) => {
self.delete_liana_connect = delete;
}
_ => {}
}
Task::none()
}
@ -485,18 +521,22 @@ impl DeleteWalletModal {
));
}
// Use separate `Row`s for help text in order to have better spacing.
let help_text_1 = if let Some(alias) = &self.wallet_settings.alias {
format!(
"Are you sure you want to delete the configuration and all associated data for the wallet {} (Liana-{})?",
alias,
self.wallet_settings.descriptor_checksum
)
} else {
format!(
"Are you sure you want to delete the configuration and all associated data for the wallet Liana-{}?",
&self.wallet_settings.descriptor_checksum,
)
};
let help_text_1 = format!(
"Are you sure you want to {} for the wallet {}",
if self.wallet_settings.remote_backend_auth.is_some() {
"delete locally the configuration"
} else {
"delete the configuration and all associated data"
},
if let Some(alias) = &self.wallet_settings.alias {
format!(
"{} (Liana-{})?",
alias, self.wallet_settings.descriptor_checksum
)
} else {
format!("Liana-{}?", &self.wallet_settings.descriptor_checksum)
}
);
let help_text_2 = match self.internal_bitcoind {
Some(true) => Some("(The Liana-managed Bitcoin node for this network will not be affected by this action.)"),
Some(false) => None,
@ -529,6 +569,22 @@ impl DeleteWalletModal {
.map(|t| Row::new().push(p1_regular(t).style(theme::text::secondary))),
)
.push(Row::new())
.push_maybe(self.wallet_settings.remote_backend_auth.as_ref().map(|a| {
checkbox(
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),
},
self.delete_liana_connect,
)
.on_toggle_maybe(if !self.deleted {
Some(|v| {
ViewMessage::DeleteWallet(DeleteWalletMessage::DeleteLianaConnect(v))
})
} else {
None
})
}))
.push(Row::new().push(text(help_text_3)))
.push_maybe(self.warning.as_ref().map(|w| {
notification::warning(w.to_string(), w.to_string()).width(Length::Fill)
@ -554,6 +610,40 @@ impl DeleteWalletModal {
}
}
pub async fn check_membership(
network: Network,
network_dir: &NetworkDirectory,
auth: &AuthConfig,
) -> Result<Option<UserRole>, DeleteError> {
let service_config = get_service_config(network)
.await
.map_err(|e| DeleteError::Connect(e.to_string()))?;
if let BackendState::WalletExists(client, _, _) = connect_with_credentials(
AuthClient::new(
service_config.auth_api_url,
service_config.auth_api_public_key,
auth.email.to_string(),
),
auth.wallet_id.clone(),
service_config.backend_api_url,
network,
network_dir,
)
.await
.map_err(|e| DeleteError::Connect(e.to_string()))?
{
Ok(Some(
client
.user_wallet_membership()
.await
.map_err(|e| DeleteError::Connect(e.to_string()))?,
))
} else {
Ok(None)
}
}
async fn check_network_datadir(path: NetworkDirectory) -> Result<State, String> {
let mut config_path = path.clone().path().to_path_buf();
config_path.push(app::config::DEFAULT_FILE_NAME);

View File

@ -122,6 +122,24 @@ pub struct ListWallets {
pub wallets: Vec<Wallet>,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UserRole {
Owner,
Member,
}
#[derive(Deserialize)]
pub struct ListWalletMembers {
pub members: Vec<Member>,
}
#[derive(Deserialize)]
pub struct Member {
pub user_id: String,
pub role: UserRole,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Provider {
pub uuid: String,

View File

@ -468,9 +468,45 @@ impl BackendWalletClient {
.await
}
pub async fn user_wallet_membership(&self) -> Result<api::UserRole, DaemonError> {
let list: api::ListWalletMembers = self
.inner
.request(
Method::GET,
&format!("{}/v1/wallets/{}/members", self.inner.url, self.wallet_uuid),
)
.await
.send()
.await?
.check_success()
.await?
.json_or_error()
.await?;
list.members
.into_iter()
.find(|m| m.user_id == self.user_id())
.map(|m| m.role)
.ok_or(DaemonError::Unexpected("Membership not found".to_string()))
}
pub async fn auth(&self) -> AccessTokenResponse {
self.inner.auth.read().await.clone()
}
pub async fn delete_wallet(&self) -> Result<(), DaemonError> {
self.inner
.request(
Method::DELETE,
&format!("{}/v1/wallets/{}", self.inner.url, self.wallet_uuid),
)
.await
.send()
.await?
.check_success()
.await?;
Ok(())
}
}
#[async_trait]

View File

@ -16,7 +16,7 @@ use crate::{
settings::{SettingsError, WalletSettings},
},
daemon::DaemonError,
dir::LianaDirectory,
dir::{LianaDirectory, NetworkDirectory},
};
use super::client::{
@ -170,7 +170,7 @@ impl LianaLiteLogin {
auth.wallet_id,
service_config.backend_api_url,
network,
datadir,
&datadir.network_directory(network),
)
.await
},
@ -483,10 +483,9 @@ pub async fn connect_with_credentials(
wallet_id: String,
backend_api_url: String,
network: Network,
liana_directory: LianaDirectory,
network_dir: &NetworkDirectory,
) -> Result<BackendState, Error> {
let network_dir = liana_directory.network_directory(network);
let mut tokens = cache::Account::from_cache(&network_dir, &auth.email)
let mut tokens = cache::Account::from_cache(network_dir, &auth.email)
.map_err(|e| match e {
ConnectCacheError::NotFound => Error::CredentialsMissing,
_ => e.into(),
@ -495,7 +494,7 @@ pub async fn connect_with_credentials(
.tokens;
if tokens.expires_at < chrono::Utc::now().timestamp() {
tokens = cache::update_connect_cache(&network_dir, &tokens, &auth, true).await?;
tokens = cache::update_connect_cache(network_dir, &tokens, &auth, true).await?;
}
let client = BackendClient::connect(auth, backend_api_url, tokens, network).await?;