Add liana-connect cache file

This commit is contained in:
edouardparis 2025-04-29 15:14:53 +02:00
parent 102a8d841d
commit 1746314e7d
8 changed files with 290 additions and 126 deletions

16
Cargo.lock generated
View File

@ -254,6 +254,21 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fd-lock"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7569377d7062165f6f7834d9cb3051974a2d141433cc201c2f94c149e993cccf"
dependencies = [
"async-trait",
"cfg-if",
"pin-project",
"rustix",
"thiserror 1.0.69",
"tokio",
"windows-sys 0.52.0",
]
[[package]]
name = "async-fs"
version = "2.1.2"
@ -2993,6 +3008,7 @@ dependencies = [
name = "liana-gui"
version = "10.0.0"
dependencies = [
"async-fd-lock",
"async-hwi",
"async-trait",
"backtrace",

View File

@ -29,6 +29,7 @@ iced_runtime = "0.13.1"
email_address = "0.2.7"
tokio = {version = "1.21.0", features = ["signal"]}
async-fd-lock = "0.2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@ -66,7 +66,21 @@ impl Settings {
pub struct AuthConfig {
pub email: String,
pub wallet_id: String,
pub refresh_token: String,
// legacy field, refresh_token is now stored in the connect cache file
// Keep it in case, user want to open the wallet with a previous Liana-GUI version.
// Field cannot be ignored as the settings file is override during settings update.
// TODO: remove later after multiple versions.
pub refresh_token: Option<String>,
}
impl AuthConfig {
pub fn new(email: String, wallet_id: String) -> Self {
Self {
email,
wallet_id,
refresh_token: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -241,7 +255,6 @@ pub enum SettingsError {
WritingFile(String),
Unexpected(String),
}
impl std::fmt::Display for SettingsError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {

View File

@ -13,6 +13,7 @@ use liana_ui::{
widget::{Column, Element},
};
use lianad::config::Config;
use std::ops::Deref;
use tracing::{error, info, warn};
use std::io::Write;
@ -37,6 +38,7 @@ use crate::{
api::payload::{Provider, ProviderKey},
BackendClient, BackendWalletClient,
},
cache::update_connect_cache,
},
},
signer::Signer,
@ -550,6 +552,22 @@ pub async fn create_remote_wallet(
info!("Settings file created");
let backend = remote_backend.inner_client();
if let Err(e) = update_connect_cache(
&network_datadir,
backend.auth.read().await.deref(),
backend.auth_client(),
false,
)
.await
{
// this error is not critical, the liana-connect backend stored the wallet
// and user can reauthenticate.
tracing::error!("Failed to update Liana-Connect cache: {}", e);
} else {
info!("Liana-Connect cache updated");
};
Ok(gui_config_path)
}
@ -599,6 +617,22 @@ pub async fn import_remote_wallet(
info!("Gui configuration file created");
let backend = backend.inner_client();
if let Err(e) = update_connect_cache(
&network_datadir,
backend.auth.read().await.deref(),
backend.auth_client(),
false,
)
.await
{
// this error is not critical, the liana-connect backend stored the wallet
// and user can reauthenticate.
tracing::error!("Failed to update Liana-Connect cache: {}", e);
} else {
info!("Liana-Connect cache updated");
};
Ok(gui_config_path)
}
@ -631,19 +665,16 @@ pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletC
.expect("LianaDescriptor.to_string() always include the checksum")
.to_string();
let auth = backend.inner_client().auth.read().await;
Settings {
wallets: vec![WalletSetting {
name: wallet_name(descriptor),
descriptor_checksum,
keys: Vec::new(),
hardware_wallets: Vec::new(),
remote_backend_auth: Some(AuthConfig {
email: backend.user_email().to_string(),
wallet_id: backend.wallet_id(),
refresh_token: auth.refresh_token.clone(),
}),
remote_backend_auth: Some(AuthConfig::new(
backend.user_email().to_string(),
backend.wallet_id(),
)),
}],
}
}

View File

@ -20,7 +20,6 @@ use reqwest::{Error, IntoUrl, Method, RequestBuilder, Response};
use tokio::sync::RwLock;
use crate::{
app::settings::{AuthConfig, Settings},
daemon::{model::*, Daemon, DaemonBackend, DaemonError},
dir::LianaDirectory,
hw::HardwareWalletConfig,
@ -28,7 +27,10 @@ use crate::{
use self::api::{UTXOKind, DEFAULT_OUTPOINTS_LIMIT};
use super::auth::{self, AccessTokenResponse, AuthError};
use super::{
auth::{self, AccessTokenResponse, AuthError},
cache::update_connect_cache,
};
impl From<Error> for DaemonError {
fn from(value: Error) -> Self {
@ -101,6 +103,10 @@ impl BackendClient {
})
}
pub fn auth_client(&self) -> &auth::AuthClient {
&self.auth_client
}
pub fn user_email(&self) -> &str {
&self.auth_client.email
}
@ -532,45 +538,20 @@ impl Daemon for BackendWalletClient {
return Ok(());
}
Ok(mut old) => {
let new = self
.inner
.auth_client
.refresh_token(&auth.refresh_token)
.await?;
let network_dir = datadir.network_directory(network);
let mut settings = Settings::from_file(&network_dir).map_err(|e| {
DaemonError::Unexpected(format!(
"Cannot access to settings.json file: {}",
e
))
})?;
if let Some(wallet_settings) = settings.wallets.iter_mut().find(|w| {
if let Some(auth) = &w.remote_backend_auth {
auth.wallet_id == self.wallet_uuid
} else {
false
}
}) {
wallet_settings.remote_backend_auth = Some(AuthConfig {
email: self.inner.auth_client.email.clone(),
wallet_id: self.wallet_id(),
refresh_token: new.refresh_token.clone(),
});
} else {
tracing::info!("Wallet id was not found in the settings");
}
settings.to_file(&network_dir).map_err(|e| {
DaemonError::Unexpected(format!(
"Cannot access to settings.json file: {}",
e
))
let new = update_connect_cache(
&network_dir,
&old,
&self.inner.auth_client,
true, // refresh the token
)
.await
.map_err(|e| {
DaemonError::Unexpected(format!("Cannot update Liana-connect cache: {}", e))
})?;
*old = new;
tracing::info!("Liana backend access was refreshed");
}
}
}

View File

@ -0,0 +1,159 @@
use crate::dir::NetworkDirectory;
use async_fd_lock::LockWrite;
use serde::{Deserialize, Serialize};
use std::io::SeekFrom;
use tokio::fs::OpenOptions;
use tokio::io::AsyncSeekExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::auth::{AccessTokenResponse, AuthClient, AuthError};
pub const CONNECT_CACHE_FILENAME: &str = "connect.json";
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct ConnectCache {
pub accounts: Vec<Account>,
}
impl ConnectCache {
fn upsert_credential(&mut self, email: &str, tokens: AccessTokenResponse) {
if let Some(c) = self.accounts.iter_mut().find(|c| c.email == email) {
c.tokens = tokens;
} else {
self.accounts.push(Account {
email: email.to_string(),
tokens,
})
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Account {
pub email: String,
pub tokens: AccessTokenResponse,
}
impl Account {
pub fn from_cache(
network_dir: &NetworkDirectory,
email: &str,
) -> Result<Option<Self>, ConnectCacheError> {
let mut path = network_dir.path().to_path_buf();
path.push(CONNECT_CACHE_FILENAME);
std::fs::read(path)
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ConnectCacheError::NotFound,
_ => ConnectCacheError::ReadingFile(format!("Reading settings file: {}", e)),
})
.and_then(|file_content| {
serde_json::from_slice::<ConnectCache>(&file_content).map_err(|e| {
ConnectCacheError::ReadingFile(format!("Parsing settings file: {}", e))
})
})
.map(|cache| cache.accounts.into_iter().find(|c| c.email == email))
}
}
pub async fn update_connect_cache(
network_dir: &NetworkDirectory,
current_tokens: &AccessTokenResponse,
client: &AuthClient,
refresh: bool,
) -> Result<AccessTokenResponse, ConnectCacheError> {
let email = &client.email;
let mut path = network_dir.path().to_path_buf();
path.push(CONNECT_CACHE_FILENAME);
let file_exists = tokio::fs::try_exists(&path).await.unwrap_or(false);
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.await
.map_err(|e| ConnectCacheError::ReadingFile(format!("Opening file: {}", e)))?
.lock_write()
.await
.map_err(|e| ConnectCacheError::ReadingFile(format!("Locking file: {:?}", e)))?;
let mut cache = if file_exists {
let mut file_content = Vec::new();
file.read_to_end(&mut file_content)
.await
.map_err(|e| ConnectCacheError::ReadingFile(format!("Reading file content: {}", e)))?;
match serde_json::from_slice::<ConnectCache>(&file_content) {
Ok(cache) => cache,
Err(e) => {
tracing::warn!("Something wrong with Liana-Connect cache file: {:?}", e);
tracing::warn!("Liana-Connect cache file is reset");
ConnectCache::default()
}
}
} else {
ConnectCache::default()
};
if let Some(c) = cache.accounts.iter().find(|cred| cred.email == *email) {
// An other process updated the tokens
if current_tokens.expires_at < c.tokens.expires_at {
tracing::debug!("Liana-Connect authentication tokens are up to date, nothing to do");
return Ok(c.tokens.clone());
}
}
let tokens = if refresh {
client
.refresh_token(&current_tokens.refresh_token)
.await
.map_err(ConnectCacheError::Updating)?
} else {
current_tokens.clone()
};
cache.upsert_credential(email, tokens.clone());
let content = serde_json::to_vec_pretty(&cache).map_err(|e| {
ConnectCacheError::WritingFile(format!("Failed to serialize settings: {}", e))
})?;
file.seek(SeekFrom::Start(0)).await.map_err(|e| {
ConnectCacheError::WritingFile(format!("Failed to seek to start of file: {}", e))
})?;
file.write_all(&content).await.map_err(|e| {
tracing::warn!("failed to write to file: {:?}", e);
ConnectCacheError::WritingFile(e.to_string())
})?;
file.inner_mut()
.set_len(content.len() as u64)
.await
.map_err(|e| ConnectCacheError::WritingFile(format!("Failed to truncate file: {}", e)))?;
Ok(tokens)
}
#[derive(Debug, Clone)]
pub enum ConnectCacheError {
NotFound,
ReadingFile(String),
WritingFile(String),
Unexpected(String),
Updating(AuthError),
}
impl std::fmt::Display for ConnectCacheError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::NotFound => write!(f, "ConnectCache file not found"),
Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e),
Self::WritingFile(e) => write!(f, "Error while writing file: {}", e),
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
Self::Updating(e) => write!(f, "Error while updating cache file: {}", e),
}
}
}

View File

@ -1,5 +1,6 @@
pub mod auth;
pub mod backend;
pub mod cache;
use liana::miniscript::bitcoin;

View File

@ -13,7 +13,7 @@ use lianad::commands::ListCoinsResult;
use crate::{
app::{
cache::coins_to_cache,
settings::{AuthConfig, Settings, SettingsError, WalletSetting},
settings::{Settings, SettingsError},
},
daemon::DaemonError,
dir::LianaDirectory,
@ -22,15 +22,18 @@ use crate::{
use super::client::{
auth::{AuthClient, AuthError},
backend::{api, BackendClient, BackendWalletClient},
cache::{self, update_connect_cache, ConnectCacheError},
};
#[derive(Debug, Clone)]
pub enum Error {
Auth(AuthError),
CredentialsMissing,
// DaemonError does not implement Clone.
// TODO: maybe Arc is overkill
Backend(Arc<DaemonError>),
Settings(SettingsError),
Cache(cache::ConnectCacheError),
Unexpected(String),
}
@ -38,8 +41,10 @@ impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Auth(e) => write!(f, "Authentication error: {}", e),
Self::CredentialsMissing => write!(f, "credentials missing"),
Self::Backend(e) => write!(f, "Remote backend error: {}", e),
Self::Settings(e) => write!(f, "Settings file error: {}", e),
Self::Cache(e) => write!(f, "Connect cache file error: {}", e),
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
}
}
@ -63,6 +68,12 @@ impl From<SettingsError> for Error {
}
}
impl From<ConnectCacheError> for Error {
fn from(value: ConnectCacheError) -> Self {
Self::Cache(value)
}
}
#[derive(Debug, Clone)]
pub enum Message {
View(ViewMessage),
@ -156,13 +167,13 @@ impl LianaLiteLogin {
},
Task::none(),
),
Ok(auth_config) => (
Ok(setting) => (
Self {
network,
datadir,
datadir: datadir.clone(),
step: ConnectionStep::CheckingAuthFile,
connection_error: None,
wallet_id: auth_config.wallet_id.clone(),
wallet_id: setting.wallet_id.clone(),
auth_error: None,
processing: true,
},
@ -174,14 +185,14 @@ impl LianaLiteLogin {
let client = AuthClient::new(
service_config.auth_api_url,
service_config.auth_api_public_key,
auth_config.email,
setting.email,
);
connect_with_refresh_token(
connect_with_credentials(
client,
auth_config.refresh_token,
auth_config.wallet_id,
setting.wallet_id,
service_config.backend_api_url,
network,
datadir,
)
.await
},
@ -327,9 +338,11 @@ impl LianaLiteLogin {
self.auth_error = None;
let wallet_id = self.wallet_id.clone();
let network = self.network;
let datadir = self.datadir.clone();
return Task::perform(
async move {
connect(client, otp, wallet_id, backend_api_url, network).await
connect(client, otp, wallet_id, backend_api_url, network, datadir)
.await
},
Message::Connected,
);
@ -343,21 +356,8 @@ impl LianaLiteLogin {
return Task::perform(async move { Some(client) }, Message::Install);
}
Ok(BackendState::WalletExists(client, wallet, coins)) => {
let datadir = self.datadir.clone();
let network = self.network;
return Task::perform(
async move {
update_wallet_auth_settings(
datadir,
network,
wallet.clone(),
client.user_email().to_string(),
client.auth().await.refresh_token,
)
.await?;
Ok((client, wallet, coins))
},
async move { Ok((client, wallet, coins)) },
Message::Run,
);
}
@ -494,68 +494,20 @@ impl LianaLiteLogin {
}
}
async fn update_wallet_auth_settings(
datadir: LianaDirectory,
network: Network,
wallet: api::Wallet,
email: String,
refresh_token: String,
) -> Result<(), Error> {
let network_dir = datadir.network_directory(network);
let mut settings = Settings::from_file(&network_dir)?;
let descriptor_checksum = wallet
.descriptor
.to_string()
.split_once('#')
.map(|(_, checksum)| checksum)
.expect("Failed to get checksum from a valid LianaDescriptor")
.to_string();
let remote_backend_auth = Some(AuthConfig {
email,
wallet_id: wallet.id.clone(),
refresh_token,
});
if let Some(wallet_settings) = settings.wallets.iter_mut().find(|w| {
if let Some(auth) = &w.remote_backend_auth {
auth.wallet_id == wallet.id
} else {
false
}
}) {
wallet_settings.remote_backend_auth = remote_backend_auth;
} else {
tracing::info!("Wallet id was not found in the settings, adding now the wallet settings to the settings.json file");
settings.wallets.insert(
0,
WalletSetting {
name: wallet.name,
descriptor_checksum,
keys: Vec::new(),
hardware_wallets: Vec::new(),
remote_backend_auth,
},
);
}
settings.to_file(&network_dir).map_err(|e| {
DaemonError::Unexpected(format!("Cannot access to settings.json file: {}", e))
})?;
Ok(())
}
pub async fn connect(
auth: AuthClient,
token: String,
wallet_id: String,
backend_api_url: String,
network: Network,
liana_directory: LianaDirectory,
) -> Result<BackendState, Error> {
let network_dir = liana_directory.network_directory(network);
let access = auth.verify_otp(token.trim_end()).await?;
let client = BackendClient::connect(auth, backend_api_url, access.clone(), network).await?;
let client =
BackendClient::connect(auth.clone(), backend_api_url, access.clone(), network).await?;
update_connect_cache(&network_dir, &access, &auth, false).await?;
let wallets = client.list_wallets().await?;
if wallets.is_empty() {
@ -566,25 +518,35 @@ pub async fn connect(
let first = wallets.first().cloned().ok_or(DaemonError::NoAnswer)?;
let (wallet_client, wallet) = client.connect_wallet(first);
let coins = coins_to_cache(Arc::new(wallet_client.clone())).await?;
Ok(BackendState::WalletExists(wallet_client, wallet, coins))
} else if let Some(wallet) = wallets.into_iter().find(|w| w.id == wallet_id) {
let (wallet_client, wallet) = client.connect_wallet(wallet);
let coins = coins_to_cache(Arc::new(wallet_client.clone())).await?;
Ok(BackendState::WalletExists(wallet_client, wallet, coins))
} else {
Ok(BackendState::NoWallet(client))
}
}
pub async fn connect_with_refresh_token(
pub async fn connect_with_credentials(
auth: AuthClient,
refresh_token: String,
wallet_id: String,
backend_api_url: String,
network: Network,
liana_directory: LianaDirectory,
) -> Result<BackendState, Error> {
let access = auth.refresh_token(&refresh_token).await?;
let client = BackendClient::connect(auth, backend_api_url, access.clone(), network).await?;
let network_dir = liana_directory.network_directory(network);
let mut tokens = cache::Credential::from_cache(&network_dir, &auth.email)?
.ok_or(Error::CredentialsMissing)?
.tokens;
if tokens.expires_at < chrono::Utc::now().timestamp() {
tokens = cache::update_connect_cache(&network_dir, &tokens, &auth, true).await?;
}
let client = BackendClient::connect(auth, backend_api_url, tokens, network).await?;
if let Some(wallet) = client
.list_wallets()