From 0193198fa9ec83474605b11a0452e99cff804b67 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 22 Jan 2025 04:33:53 +0100 Subject: [PATCH 1/4] export: rename 'State' into 'Export' and separate export logic --- liana-gui/src/app/state/export.rs | 6 +- liana-gui/src/export.rs | 385 ++++++++++++++++-------------- 2 files changed, 217 insertions(+), 174 deletions(-) diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index 56de4565e..4492a8caf 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -117,7 +117,11 @@ impl ExportModal { ExportState::Started | ExportState::Progress(_) => { Some(iced::subscription::unfold( "transactions", - export::State::new(self.daemon.clone(), Box::new(path.to_path_buf())), + export::Export::new( + self.daemon.clone(), + Box::new(path.to_path_buf()), + export::ExportType::Transactions, + ), export::export_subscription, )) } diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index e7bec3c6f..1bf97673b 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -7,7 +7,7 @@ use std::{ mpsc::{channel, Receiver, Sender}, Arc, Mutex, }, - time::{self}, + time, }; use chrono::{DateTime, Duration, Utc}; @@ -93,6 +93,13 @@ pub enum Error { TxTimeMissing, } +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum ExportType { + Transactions, + Psbt, + Descriptor, +} + impl From for Error { fn from(value: JoinError) -> Self { Error::JoinError(format!("{:?}", value)) @@ -128,203 +135,58 @@ pub enum ExportProgress { None, } -pub struct State { +pub struct Export { pub receiver: Receiver, pub sender: Option>, pub handle: Option>>>, pub daemon: Arc, pub path: Box, + pub export_type: ExportType, } -impl State { - pub fn new(daemon: Arc, path: Box) -> Self { +impl Export { + pub fn new( + daemon: Arc, + path: Box, + export_type: ExportType, + ) -> Self { let (sender, receiver) = channel(); - State { + Export { receiver, sender: Some(sender), handle: None, daemon, path, + export_type, } } + pub async fn export_logic( + export_type: ExportType, + sender: Sender, + daemon: Arc, + path: PathBuf, + ) { + match export_type { + ExportType::Transactions => export_transactions(sender, daemon, path).await, + ExportType::Psbt => todo!(), + ExportType::Descriptor => todo!(), + }; + } + pub async fn start(&mut self) { if let (true, Some(sender)) = (self.handle.is_none(), self.sender.take()) { let daemon = self.daemon.clone(); let path = self.path.clone(); let cloned_sender = sender.clone(); + let export_type = self.export_type; let handle = tokio::spawn(async move { - let dir = match path.parent() { - Some(dir) => dir, - None => { - send_error!(sender, NoParentDir); - return; - } - }; - if !dir.exists() { - if let Err(e) = fs::create_dir_all(dir) { - send_error!(sender, e.into()); - return; - } - } - let mut file = match File::create(path.as_path()) { - Ok(f) => f, - Err(e) => { - send_error!(sender, e.into()); - return; - } - }; - - let header = "Date,Label,Value,Fee,Txid,Block\n".to_string(); - if let Err(e) = file.write_all(header.as_bytes()) { - send_error!(sender, e.into()); - return; - } - - // 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; - let total_txs = daemon.list_confirmed_txs(0, end, u32::MAX as u64).await; - let total_txs = match total_txs { - Ok(r) => r.transactions.len(), - Err(e) => { - send_error!(sender, e.into()); - return; - } - }; - - if total_txs == 0 { - send_progress!(sender, Ended); - } else { - send_progress!(sender, Progress(5.0)); - } - - let max = match daemon.backend() { - DaemonBackend::RemoteBackend => DEFAULT_LIMIT as u64, - _ => u32::MAX as u64, - }; - - // store txs in a map to avoid duplicates - let mut map = HashMap::::new(); - let mut limit = max; - - loop { - let history = daemon.list_history_txs(0, end, limit).await; - let history_txs = match history { - Ok(h) => h, - Err(e) => { - send_error!(sender, e.into()); - return; - } - }; - let dl = map.len() + history_txs.len(); - if dl > 0 { - let progress = (dl as f32) / (total_txs as f32) * 80.0; - send_progress!(sender, Progress(progress)); - } - // all txs have been fetched - if history_txs.is_empty() { - break; - } - if history_txs.len() == limit as usize { - let first = if let Some(t) = history_txs.first().expect("checked").time { - t - } else { - send_error!(sender, TxTimeMissing); - return; - }; - let last = if let Some(t) = history_txs.last().expect("checked").time { - t - } else { - send_error!(sender, TxTimeMissing); - return; - }; - // 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 mut txs: Vec<_> = map.into_values().collect(); - txs.sort_by(|a, b| b.compare(a)); - - for mut tx in txs { - let date_time = tx - .time - .map(|t| { - let mut str = DateTime::from_timestamp(t as i64, 0) - .expect("bitcoin timestamp") - .to_rfc3339(); - //str has the form `1996-12-19T16:39:57-08:00` - // ^ ^^^^^^ - // replace `T` by ` `| | drop this part - str = str.replace("T", " "); - str[0..(str.len() - 6)].to_string() - }) - .unwrap_or("".to_string()); - - let txid = tx.txid.clone().to_string(); - let txid_label = tx.labels().get(&txid).cloned(); - let mut label = if let Some(txid) = txid_label { - txid - } else { - "".to_string() - }; - if !label.is_empty() { - label = format!("\"{}\"", label); - } - let txid = tx.txid.to_string(); - let fee = tx.fee_amount.unwrap_or(Amount::ZERO).to_sat() as i128; - let mut inputs_amount = 0; - tx.coins.iter().for_each(|(_, coin)| { - inputs_amount += coin.amount.to_sat() as i128; - }); - let value = tx.incoming_amount.to_sat() as i128 - inputs_amount; - let value = value as f64 / 100_000_000.0; - let fee = fee as f64 / 100_000_000.0; - let block = tx.height.map(|h| h.to_string()).unwrap_or("".to_string()); - let fee = if fee != 0.0 { - fee.to_string() - } else { - "".into() - }; - - let line = format!( - "{},{},{},{},{},{}\n", - date_time, label, value, fee, txid, block - ); - if let Err(e) = file.write_all(line.as_bytes()) { - send_error!(sender, e.into()); - return; - } - } - send_progress!(sender, Progress(100.0)); - send_progress!(sender, Ended); + Self::export_logic(export_type, cloned_sender, daemon, *path).await; }); let handle = Arc::new(Mutex::new(handle)); + let cloned_sender = sender.clone(); // we send the handle to the GUI so we can kill the thread on timeout // or user cancel action send_progress!(cloned_sender, Started(handle.clone())); @@ -343,7 +205,7 @@ impl State { } } -pub async fn export_subscription(mut state: State) -> (ExportProgress, State) { +pub async fn export_subscription(mut state: Export) -> (ExportProgress, Export) { match state.state() { Status::Init => { state.start().await; @@ -381,6 +243,183 @@ pub async fn export_subscription(mut state: State) -> (ExportProgress, State) { (ExportProgress::None, state) } +pub async fn export_transactions( + sender: Sender, + daemon: Arc, + path: PathBuf, +) { + async move { + let dir = match path.parent() { + Some(dir) => dir, + None => { + send_error!(sender, NoParentDir); + return; + } + }; + if !dir.exists() { + if let Err(e) = fs::create_dir_all(dir) { + send_error!(sender, e.into()); + return; + } + } + let mut file = match File::create(path.as_path()) { + Ok(f) => f, + Err(e) => { + send_error!(sender, e.into()); + return; + } + }; + + let header = "Date,Label,Value,Fee,Txid,Block\n".to_string(); + if let Err(e) = file.write_all(header.as_bytes()) { + send_error!(sender, e.into()); + return; + } + + // 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; + let total_txs = daemon.list_confirmed_txs(0, end, u32::MAX as u64).await; + let total_txs = match total_txs { + Ok(r) => r.transactions.len(), + Err(e) => { + send_error!(sender, e.into()); + return; + } + }; + + if total_txs == 0 { + send_progress!(sender, Ended); + } else { + send_progress!(sender, Progress(5.0)); + } + + let max = match daemon.backend() { + DaemonBackend::RemoteBackend => DEFAULT_LIMIT as u64, + _ => u32::MAX as u64, + }; + + // store txs in a map to avoid duplicates + let mut map = HashMap::::new(); + let mut limit = max; + + loop { + let history = daemon.list_history_txs(0, end, limit).await; + let history_txs = match history { + Ok(h) => h, + Err(e) => { + send_error!(sender, e.into()); + return; + } + }; + let dl = map.len() + history_txs.len(); + if dl > 0 { + let progress = (dl as f32) / (total_txs as f32) * 80.0; + send_progress!(sender, Progress(progress)); + } + // all txs have been fetched + if history_txs.is_empty() { + break; + } + if history_txs.len() == limit as usize { + let first = if let Some(t) = history_txs.first().expect("checked").time { + t + } else { + send_error!(sender, TxTimeMissing); + return; + }; + let last = if let Some(t) = history_txs.last().expect("checked").time { + t + } else { + send_error!(sender, TxTimeMissing); + return; + }; + // 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 mut txs: Vec<_> = map.into_values().collect(); + txs.sort_by(|a, b| b.compare(a)); + + for mut tx in txs { + let date_time = tx + .time + .map(|t| { + let mut str = DateTime::from_timestamp(t as i64, 0) + .expect("bitcoin timestamp") + .to_rfc3339(); + //str has the form `1996-12-19T16:39:57-08:00` + // ^ ^^^^^^ + // replace `T` by ` `| | drop this part + str = str.replace("T", " "); + str[0..(str.len() - 6)].to_string() + }) + .unwrap_or("".to_string()); + + let txid = tx.txid.clone().to_string(); + let txid_label = tx.labels().get(&txid).cloned(); + let mut label = if let Some(txid) = txid_label { + txid + } else { + "".to_string() + }; + if !label.is_empty() { + label = format!("\"{}\"", label); + } + let txid = tx.txid.to_string(); + let fee = tx.fee_amount.unwrap_or(Amount::ZERO).to_sat() as i128; + let mut inputs_amount = 0; + tx.coins.iter().for_each(|(_, coin)| { + inputs_amount += coin.amount.to_sat() as i128; + }); + let value = tx.incoming_amount.to_sat() as i128 - inputs_amount; + let value = value as f64 / 100_000_000.0; + let fee = fee as f64 / 100_000_000.0; + let block = tx.height.map(|h| h.to_string()).unwrap_or("".to_string()); + let fee = if fee != 0.0 { + fee.to_string() + } else { + "".into() + }; + + let line = format!( + "{},{},{},{},{},{}\n", + date_time, label, value, fee, txid, block + ); + if let Err(e) = file.write_all(line.as_bytes()) { + send_error!(sender, e.into()); + return; + } + } + send_progress!(sender, Progress(100.0)); + send_progress!(sender, Ended); + } + .await; +} + pub async fn get_path() -> Option { let date = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S"); let file_name = format!("liana-txs-{date}.csv"); From 267cd6fe5ea8810560d497a354213511eb9fe2a9 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 22 Jan 2025 05:10:15 +0100 Subject: [PATCH 2/4] gui: make 'ExportModal' generic --- liana-gui/src/app/state/export.rs | 19 ++++++++++++++++--- liana-gui/src/app/state/transactions.rs | 7 +++++-- liana-gui/src/export.rs | 6 ++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index 4492a8caf..d9748d19b 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -13,7 +13,7 @@ use crate::{ view::{self, export::export_modal}, }, daemon::Daemon, - export::{self, get_path, ExportMessage, ExportProgress, ExportState}, + export::{self, get_path, ExportMessage, ExportProgress, ExportState, ExportType}, }; #[derive(Debug)] @@ -23,22 +23,35 @@ pub struct ExportModal { state: ExportState, error: Option, daemon: Arc, + export_type: ExportType, } impl ExportModal { #[allow(clippy::new_without_default)] - pub fn new(daemon: Arc) -> Self { + pub fn new(daemon: Arc, export_type: ExportType) -> Self { Self { path: None, handle: None, state: ExportState::Init, error: None, daemon, + export_type, + } + } + + pub fn default_filename(&self) -> String { + match self.export_type { + ExportType::Transactions => { + let date = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S"); + format!("liana-txs-{date}.csv") + } + ExportType::Psbt => todo!(), + ExportType::Descriptor => todo!(), } } pub fn launch(&self) -> Command { - Command::perform(get_path(), |m| { + Command::perform(get_path(self.default_filename()), |m| { Message::View(view::Message::Export(ExportMessage::Path(m))) }) } diff --git a/liana-gui/src/app/state/transactions.rs b/liana-gui/src/app/state/transactions.rs index 5aa619222..b799e8ebf 100644 --- a/liana-gui/src/app/state/transactions.rs +++ b/liana-gui/src/app/state/transactions.rs @@ -28,7 +28,7 @@ use crate::{ wallet::Wallet, }, daemon::model::{self, LabelsLoader}, - export::ExportMessage, + export::{ExportMessage, ExportType}, }; use crate::daemon::{ @@ -264,7 +264,10 @@ impl State for TransactionsPanel { } Message::View(view::Message::Export(ExportMessage::Open)) => { if let TransactionsModal::None = &self.modal { - self.modal = TransactionsModal::Export(ExportModal::new(daemon)); + self.modal = TransactionsModal::Export(ExportModal::new( + daemon, + ExportType::Transactions, + )); if let TransactionsModal::Export(m) = &self.modal { return m.launch(); } diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index 1bf97673b..f7947d402 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -420,12 +420,10 @@ pub async fn export_transactions( .await; } -pub async fn get_path() -> Option { - let date = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S"); - let file_name = format!("liana-txs-{date}.csv"); +pub async fn get_path(default_filename: String) -> Option { rfd::AsyncFileDialog::new() .set_title("Choose a location to export...") - .set_file_name(file_name) + .set_file_name(default_filename) .save_file() .await .map(|fh| fh.path().to_path_buf()) From c695be6f420c2393f61e9f96c9d3303b1ff406f0 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 22 Jan 2025 07:24:01 +0100 Subject: [PATCH 3/4] export: implement export for descriptor --- liana-gui/src/app/state/export.rs | 14 ++++-- liana-gui/src/export.rs | 79 ++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index d9748d19b..ab17caf05 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -40,13 +40,21 @@ impl ExportModal { } pub fn default_filename(&self) -> String { - match self.export_type { + match &self.export_type { ExportType::Transactions => { let date = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S"); format!("liana-txs-{date}.csv") } - ExportType::Psbt => todo!(), - ExportType::Descriptor => todo!(), + ExportType::Psbt(p) => todo!(), + ExportType::Descriptor(descriptor) => { + let checksum = descriptor + .to_string() + .split_once('#') + .map(|(_, checksum)| checksum) + .unwrap() + .to_string(); + format!("liana-{}.descriptor", checksum) + } } } diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index f7947d402..8ce5fe602 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -11,7 +11,10 @@ use std::{ }; use chrono::{DateTime, Duration, Utc}; -use liana::miniscript::bitcoin::{Amount, Txid}; +use liana::{ + descriptors::LianaDescriptor, + miniscript::bitcoin::{Amount, Txid}, +}; use tokio::{ task::{JoinError, JoinHandle}, time::sleep, @@ -52,6 +55,31 @@ macro_rules! send_progress { }; } +macro_rules! open_file { + ($path:ident, $sender:ident) => {{ + let dir = match $path.parent() { + Some(dir) => dir, + None => { + send_error!($sender, NoParentDir); + return; + } + }; + if !dir.exists() { + if let Err(e) = fs::create_dir_all(dir) { + send_error!($sender, e.into()); + return; + } + } + match File::create($path.as_path()) { + Ok(f) => f, + Err(e) => { + send_error!($sender, e.into()); + return; + } + } + }}; +} + #[derive(Debug, Clone)] pub enum ExportMessage { Open, @@ -93,11 +121,11 @@ pub enum Error { TxTimeMissing, } -#[derive(Debug, PartialEq, Clone, Copy)] +#[derive(Debug, PartialEq, Clone)] pub enum ExportType { Transactions, - Psbt, - Descriptor, + Psbt(String), + Descriptor(LianaDescriptor), } impl From for Error { @@ -169,8 +197,8 @@ impl Export { ) { match export_type { ExportType::Transactions => export_transactions(sender, daemon, path).await, - ExportType::Psbt => todo!(), - ExportType::Descriptor => todo!(), + ExportType::Psbt(p) => todo!(), + ExportType::Descriptor(descriptor) => export_descriptor(sender, path, descriptor), }; } @@ -180,7 +208,7 @@ impl Export { let path = self.path.clone(); let cloned_sender = sender.clone(); - let export_type = self.export_type; + let export_type = self.export_type.clone(); let handle = tokio::spawn(async move { Self::export_logic(export_type, cloned_sender, daemon, *path).await; }); @@ -249,26 +277,7 @@ pub async fn export_transactions( path: PathBuf, ) { async move { - let dir = match path.parent() { - Some(dir) => dir, - None => { - send_error!(sender, NoParentDir); - return; - } - }; - if !dir.exists() { - if let Err(e) = fs::create_dir_all(dir) { - send_error!(sender, e.into()); - return; - } - } - let mut file = match File::create(path.as_path()) { - Ok(f) => f, - Err(e) => { - send_error!(sender, e.into()); - return; - } - }; + let mut file = open_file!(path, sender); let header = "Date,Label,Value,Fee,Txid,Block\n".to_string(); if let Err(e) = file.write_all(header.as_bytes()) { @@ -420,6 +429,22 @@ pub async fn export_transactions( .await; } +pub fn export_descriptor( + sender: Sender, + path: PathBuf, + descriptor: LianaDescriptor, +) { + let mut file = open_file!(path, sender); + + let descr_string = descriptor.to_string(); + if let Err(e) = file.write_all(descr_string.as_bytes()) { + send_error!(sender, e.into()); + return; + } + send_progress!(sender, Progress(100.0)); + send_progress!(sender, Ended); +} + pub async fn get_path(default_filename: String) -> Option { rfd::AsyncFileDialog::new() .set_title("Choose a location to export...") From 28cd58223392ad430ff40541207483341d8d9a6b Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 22 Jan 2025 07:33:34 +0100 Subject: [PATCH 4/4] export: implement export for psbt --- liana-gui/src/app/state/export.rs | 2 +- liana-gui/src/export.rs | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index ab17caf05..270d10391 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -45,7 +45,7 @@ impl ExportModal { let date = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S"); format!("liana-txs-{date}.csv") } - ExportType::Psbt(p) => todo!(), + ExportType::Psbt(_) => "psbt.psbt".into(), ExportType::Descriptor(descriptor) => { let checksum = descriptor .to_string() diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index 8ce5fe602..b06c50860 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -197,7 +197,7 @@ impl Export { ) { match export_type { ExportType::Transactions => export_transactions(sender, daemon, path).await, - ExportType::Psbt(p) => todo!(), + ExportType::Psbt(psbt) => export_psbt(sender, path, psbt), ExportType::Descriptor(descriptor) => export_descriptor(sender, path, descriptor), }; } @@ -445,6 +445,17 @@ pub fn export_descriptor( send_progress!(sender, Ended); } +pub fn export_psbt(sender: Sender, path: PathBuf, psbt: String) { + let mut file = open_file!(path, sender); + + if let Err(e) = file.write_all(psbt.as_bytes()) { + send_error!(sender, e.into()); + return; + } + send_progress!(sender, Progress(100.0)); + send_progress!(sender, Ended); +} + pub async fn get_path(default_filename: String) -> Option { rfd::AsyncFileDialog::new() .set_title("Choose a location to export...")