gui: implement backup

This commit is contained in:
pythcoiner 2025-02-25 13:54:17 +01:00
parent f7ed341d28
commit f1b62074d3
No known key found for this signature in database
GPG Key ID: C1048AEEDF303B88
6 changed files with 490 additions and 7 deletions

View File

@ -6,9 +6,15 @@ use std::io::Write;
use std::path::PathBuf;
use liana::miniscript::bitcoin::{bip32::Fingerprint, Network};
use liana_ui::component::form;
use serde::{Deserialize, Serialize};
use crate::{hw::HardwareWalletConfig, lianalite::client::backend, services};
use crate::{
backup::{Key, KeyRole, KeyType},
hw::HardwareWalletConfig,
lianalite::client::backend,
services,
};
pub const DEFAULT_FILE_NAME: &str = "settings.json";
@ -151,6 +157,67 @@ pub struct KeySetting {
pub provider_key: Option<ProviderKey>,
}
impl KeySetting {
pub fn to_backup(&self) -> Key {
if let Some(provider_key) = &self.provider_key {
if let Ok(metadata) = serde_json::to_value(provider_key) {
return Key {
key: self.master_fingerprint,
alias: Some(self.name.clone()),
role: None,
key_type: Some(KeyType::ThirdParty),
metadata,
};
}
}
Key {
key: self.master_fingerprint,
alias: Some(self.name.clone()),
role: None,
key_type: None,
metadata: serde_json::Value::Null,
}
}
pub fn from_backup(
name: String,
fg: Fingerprint,
_role: Option<KeyRole>,
key_type: Option<KeyType>,
metadata: serde_json::Value,
) -> Option<Self> {
if let Some(KeyType::ThirdParty) = key_type {
let provider_key = serde_json::from_value(metadata).ok();
Some(Self {
name,
master_fingerprint: fg,
provider_key,
})
} else {
Some(Self {
name,
master_fingerprint: fg,
provider_key: None,
})
}
}
pub fn to_form(&self) -> form::Value<String> {
form::Value {
value: self.name.clone(),
valid: true,
}
}
pub fn name(&self) -> String {
self.name.clone()
}
pub fn has_name(&self) -> bool {
!self.name.is_empty()
}
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum SettingsError {
NotFound,

View File

@ -179,6 +179,28 @@ impl Wallet {
Ok(self)
}
}
pub fn keys(&self) -> HashMap<Fingerprint, settings::KeySetting> {
let mut map = HashMap::new();
self.keys_aliases.iter().for_each(|(fg, alias)| {
map.insert(
*fg,
settings::KeySetting {
name: alias.clone(),
master_fingerprint: *fg,
provider_key: None,
},
);
});
self.provider_keys.iter().for_each(|(fg, key)| {
if let Some(entry) = map.get_mut(fg) {
entry.provider_key = Some(key.clone())
}
});
map
}
}
#[allow(clippy::large_enum_variant)]

393
liana-gui/src/backup.rs Normal file
View File

@ -0,0 +1,393 @@
use chrono::{Duration, Utc};
use liana::miniscript::{
self,
bitcoin::{bip32::Fingerprint, Network, Txid},
};
use lianad::bip329;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fmt::{Debug, Display},
path::PathBuf,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
app::{settings::Settings, wallet::Wallet, Config},
daemon::{model::HistoryTransaction, Daemon, DaemonBackend, DaemonError},
installer::{
extract_daemon_config, extract_local_gui_settings, extract_remote_gui_settings, Context,
RemoteBackend,
},
lianalite::client::backend::api::DEFAULT_LIMIT,
};
const CONFIG_KEY: &str = "config";
const SETTINGS_KEY: &str = "settings";
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("cannot fail")
.as_secs()
}
#[derive(Serialize, Deserialize)]
pub struct Backup {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub accounts: Vec<Account>,
pub network: Network,
pub date: u64,
/// App proprietary metadata (settings, configuration, etc..)
#[serde(skip_serializing_if = "serde_json::Map::is_empty")]
pub proprietary: serde_json::Map<String, serde_json::Value>,
}
pub enum Error {
DescriptorMissing,
NotSingleWallet,
Json,
SettingsFromFile,
Daemon(DaemonError),
TxTimeMissing,
}
impl From<DaemonError> for Error {
fn from(value: DaemonError) -> Self {
Error::Daemon(value)
}
}
impl Display for Backup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = serde_json::to_string(self).map_err(|_| std::fmt::Error)?;
write!(f, "{str}")
}
}
impl Debug for Backup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = serde_json::to_string_pretty(self).map_err(|_| std::fmt::Error)?;
write!(f, "{str}")
}
}
impl Backup {
/// Create a Backup from the Installer context
///
/// # Arguments
/// * `ctx` - the installer context
/// * `timestamp` - whether to record the current timestamp as wallet creation time
/// (we should want to set timestamp = false for a wallet import for instance)
pub async fn from_installer(ctx: Context, timestamp: bool) -> Result<Self, Error> {
let descriptor = ctx
.descriptor
.clone()
.ok_or(Error::DescriptorMissing)?
.to_string();
let now = now();
let mut account = Account::new(descriptor);
let mut proprietary = serde_json::Map::new();
let config = extract_daemon_config(&ctx);
if let Ok(config) = serde_json::to_value(config) {
proprietary.insert(CONFIG_KEY.to_string(), config);
}
let settings = if ctx.bitcoin_backend.is_some() {
Some(extract_local_gui_settings(&ctx))
} else {
match &ctx.remote_backend {
RemoteBackend::WithWallet(backend) => {
Some(extract_remote_gui_settings(&ctx, backend).await)
}
_ => None,
}
};
let name = if let Some(settings) = settings {
assert_eq!(settings.wallets.len(), 1);
if settings.wallets.len() != 1 {
return Err(Error::NotSingleWallet);
}
let settings = settings.wallets.first().expect("only one wallet");
let name = settings.name.clone();
if let Ok(settings) = serde_json::to_value(settings) {
proprietary.insert(SETTINGS_KEY.to_string(), settings);
}
Some(name)
} else {
None
};
ctx.keys.iter().for_each(|(k, s)| {
account.keys.insert(*k, s.to_backup());
});
account.proprietary = proprietary;
account.name = name.clone();
if timestamp {
account.timestamp = Some(now);
}
Ok(Backup {
name,
accounts: vec![account],
network: ctx.network,
proprietary: serde_json::Map::new(),
date: now,
})
}
/// Create a Backup from the Liana App context
pub async fn from_app(
datadir: PathBuf,
network: Network,
config: Config,
wallet: Arc<Wallet>,
daemon: Arc<dyn Daemon + Sync + Send>,
) -> Result<Self, Error> {
let mut proprietary = serde_json::Map::new();
let name = wallet.name.clone();
let descriptor = wallet.main_descriptor.to_string();
let keys = wallet.keys();
let settings =
Settings::from_file(datadir, network).map_err(|_| Error::SettingsFromFile)?;
if settings.wallets.len() == 1 {
if let Ok(settings) = serde_json::to_value(settings.wallets[0].clone()) {
proprietary.insert(SETTINGS_KEY.to_string(), settings);
}
}
if let Ok(config) = serde_json::to_value(config) {
proprietary.insert(CONFIG_KEY.to_string(), config);
}
let mut account = Account::new(descriptor);
account.proprietary = proprietary;
account.name = Some(name.clone());
let info = daemon.get_info().await?;
account.timestamp = Some(info.timestamp as u64);
account.change_index = Some(info.change_index);
account.receive_index = Some(info.receive_index);
for (fg, setting) in keys {
account.keys.insert(fg, setting.to_backup());
}
const MAX_LABEL_BIP329: u32 = 100;
let labels = {
let mut buff = Vec::new();
let mut start = 0;
loop {
let mut fetched = daemon.get_labels_bip329(start, 100).await?.into_vec();
if fetched.len() < MAX_LABEL_BIP329 as usize {
buff.append(&mut fetched);
break;
} else {
buff.append(&mut fetched);
start += MAX_LABEL_BIP329;
}
}
bip329::Labels::new(buff)
};
account.labels = Some(labels);
account.transactions = get_transactions(&daemon)
.await?
.into_iter()
.map(|tx| miniscript::bitcoin::consensus::encode::serialize_hex(&tx.tx))
.collect();
account.psbts = daemon
.list_spend_transactions(None)
.await?
.into_iter()
.map(|tx| tx.psbt.serialize_hex())
.collect();
Ok(Backup {
name: Some(name),
accounts: vec![account],
network,
proprietary: serde_json::Map::new(),
date: now(),
})
}
fn account(&self) -> Result<&Account, Error> {
if self.accounts.len() != 1 {
Err(Error::NotSingleWallet)
} else {
Ok(self.accounts.first().expect("single account"))
}
}
pub fn config(&self) -> Result<Option<Config>, Error> {
let account = self.account()?;
if let Some(config) = account.proprietary.get(CONFIG_KEY) {
let config: Config = serde_json::from_value(config.clone()).map_err(|_| Error::Json)?;
Ok(Some(config))
} else {
Ok(None)
}
}
pub fn settings(&self) -> Result<Option<Settings>, Error> {
let account = self.account()?;
if let Some(settings) = account.proprietary.get(SETTINGS_KEY) {
let settings: Settings =
serde_json::from_value(settings.clone()).map_err(|_| Error::Json)?;
Ok(Some(settings))
} else {
Ok(None)
}
}
}
async fn get_transactions(
daemon: &Arc<dyn Daemon + Sync + Send>,
) -> Result<Vec<HistoryTransaction>, Error> {
let max = match daemon.backend() {
DaemonBackend::RemoteBackend => DEFAULT_LIMIT as u64,
_ => u32::MAX as u64,
};
// look 2 hour forward
// https://github.com/bitcoin/bitcoin/blob/62bd61de110b057cbfd6e31e4d0b727d93119c72/src/chain.h#L29
let mut end = ((Utc::now() + Duration::hours(2)).timestamp()) as u32;
// store txs in a map to avoid duplicates
let mut map = HashMap::<Txid, HistoryTransaction>::new();
let mut limit = max;
loop {
let history_txs = daemon.list_history_txs(0, end, limit).await?;
// all txs have been fetched
if history_txs.is_empty() {
return Ok(Vec::new());
}
if history_txs.len() == limit as usize {
let first = if let Some(t) = history_txs.first().expect("checked").time {
t
} else {
return Err(Error::TxTimeMissing);
};
let last = if let Some(t) = history_txs.last().expect("checked").time {
t
} else {
return Err(Error::TxTimeMissing);
};
// limit too low, all tx are in the same timestamp
// we must increase limit and retry
if first == last {
limit += DEFAULT_LIMIT as u64;
continue;
} else {
// add txs to map
for tx in history_txs {
let txid = tx.txid;
map.insert(txid, tx);
}
limit = max;
end = first.min(last);
continue;
}
} else
/* history_txs.len() < limit */
{
// add txs to map
for tx in history_txs {
let txid = tx.txid;
map.insert(txid, tx);
}
break;
}
}
let vec: Vec<_> = map.into_values().collect();
Ok(vec)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Account {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub descriptor: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub receive_index: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub change_index: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<u64>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub keys: BTreeMap<Fingerprint, Key>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<bip329::Labels>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub transactions: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub psbts: Vec<String>,
#[serde(skip_serializing_if = "serde_json::Map::is_empty")]
pub proprietary: serde_json::Map<String, serde_json::Value>,
}
impl Account {
pub fn new(descriptor: String) -> Self {
Self {
name: None,
descriptor,
receive_index: None,
change_index: None,
timestamp: None,
keys: BTreeMap::new(),
labels: None,
transactions: Vec::new(),
psbts: Vec::new(),
proprietary: serde_json::Map::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Key {
pub key: Fingerprint,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<KeyRole>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_type: Option<KeyType>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub metadata: Value,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum KeyRole {
/// Key to be used in normal spending condition
Main,
/// Key that will be used for recover in case loss of main key(s)
Recovery,
/// Key that wil inherit coins if main user disapear
Inheritance,
/// Key that will cosign a spend in order to enforce some policy
Cosigning,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum KeyType {
/// Main user
Internal,
/// Heirs or friends
External,
/// Service the user pay for
ThirdParty,
}

View File

@ -5,6 +5,7 @@ mod prompt;
mod step;
mod view;
pub use context::{Context, RemoteBackend};
use iced::{clipboard, Subscription, Task};
use liana::miniscript::bitcoin::{self, Network};
use liana_ui::{
@ -14,8 +15,6 @@ use liana_ui::{
use lianad::config::Config;
use tracing::{error, info, warn};
use context::{Context, RemoteBackend};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
@ -429,7 +428,7 @@ pub async fn install_local_wallet(
info!("Gui configuration file created");
// create liana GUI settings file
let settings: gui_settings::Settings = extract_local_gui_settings(&ctx).await;
let settings: gui_settings::Settings = extract_local_gui_settings(&ctx);
create_and_write_file(
network_datadir_path,
gui_settings::DEFAULT_FILE_NAME,
@ -669,7 +668,7 @@ pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletC
}
}
pub async fn extract_local_gui_settings(ctx: &Context) -> Settings {
pub fn extract_local_gui_settings(ctx: &Context) -> Settings {
let descriptor = ctx
.descriptor
.as_ref()

View File

@ -1,4 +1,5 @@
pub mod app;
pub mod backup;
pub mod daemon;
pub mod datadir;
pub mod download;

View File

@ -6,6 +6,7 @@ use liana::{
};
use liana_ui::component::form;
use lianad::config::BitcoindConfig;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
@ -154,7 +155,7 @@ impl std::fmt::Display for RpcAuthParseError {
}
/// Represents RPC auth credentials as stored in bitcoin.conf.
#[derive(PartialEq, Eq, Debug, Clone)]
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
pub struct RpcAuth {
pub user: String,
salt: String,
@ -209,7 +210,7 @@ impl std::str::FromStr for RpcAuth {
}
/// Represents section for a single network in `bitcoin.conf` file.
#[derive(PartialEq, Eq, Debug, Clone)]
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
pub struct InternalBitcoindNetworkConfig {
pub rpc_port: u16,
pub p2p_port: u16,