From 7e96cdd4949c15723ce1a150a1cc02dd5a402622 Mon Sep 17 00:00:00 2001 From: edouard Date: Mon, 21 Nov 2022 15:40:01 +0100 Subject: [PATCH] Add transaction history to home panel --- gui/Cargo.lock | 2 +- gui/src/app/message.rs | 2 + gui/src/app/state/mod.rs | 161 +++++++++++++++++++++++++++++---- gui/src/app/view/coins.rs | 12 ++- gui/src/app/view/home.rs | 170 +++++++++++++++++++++++++++++++++-- gui/src/app/view/message.rs | 1 + gui/src/app/view/mod.rs | 11 ++- gui/src/daemon/client/mod.rs | 16 ++++ gui/src/daemon/embedded.rs | 27 ++++++ gui/src/daemon/mod.rs | 94 +++++++++++++++---- gui/src/daemon/model.rs | 63 ++++++++++++- 11 files changed, 511 insertions(+), 48 deletions(-) diff --git a/gui/Cargo.lock b/gui/Cargo.lock index b99426fd..21f3b175 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -1412,7 +1412,7 @@ dependencies = [ [[package]] name = "liana" version = "0.0.1" -source = "git+https://github.com/revault/liana?branch=master#72a7bbea4c2bb931ff78286cff8be4fbe9c44b48" +source = "git+https://github.com/revault/liana?branch=master#dc23f3667a977dae93cfeb13ee9694e41d020251" dependencies = [ "backtrace", "base64", diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index c864081b..438d0977 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -28,4 +28,6 @@ pub enum Message { Updated(Result<(), Error>), StartRescan(Result<(), Error>), ConnectedHardwareWallets(Vec), + HistoryTransactions(Result, Error>), + PendingTransactions(Result, Error>), } diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 6fe42540..0d3dfd6a 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -2,15 +2,20 @@ mod coins; mod settings; mod spend; +use std::convert::TryInto; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use iced::pure::{column, Element}; use iced::{widget::qr_code, Command, Subscription}; use liana::miniscript::bitcoin::{Address, Amount}; use super::{cache::Cache, error::Error, menu::Menu, message::Message, view}; -use crate::daemon::{model::Coin, Daemon}; +use crate::daemon::{ + model::{Coin, HistoryTransaction}, + Daemon, +}; pub use coins::CoinsPanel; pub use settings::SettingsState; pub use spend::{CreateSpendPanel, SpendPanel}; @@ -33,6 +38,10 @@ pub trait State { pub struct Home { balance: Amount, + pending_events: Vec, + events: Vec, + selected_event: Option, + warning: Option, } impl Home { @@ -42,7 +51,8 @@ impl Home { coins .iter() .map(|coin| { - if coin.spend_info.is_none() { + // If the coin is not spent and is its transaction is confirmed + if coin.spend_info.is_none() && coin.block_height.is_some() { coin.amount.to_sat() } else { 0 @@ -50,40 +60,161 @@ impl Home { }) .sum(), ), + selected_event: None, + events: Vec::new(), + pending_events: Vec::new(), + warning: None, } } } impl State for Home { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + if let Some(i) = self.selected_event { + return view::modal( + false, + self.warning.as_ref(), + view::home::event_view(&self.events[i]), + ); + } view::dashboard( &Menu::Home, cache, None, - view::home::home_view(&self.balance), + view::home::home_view(&self.balance, &self.pending_events, &self.events), ) } fn update( &mut self, - _daemon: Arc, + daemon: Arc, _cache: &Cache, - _message: Message, + message: Message, ) -> Command { + match message { + Message::Coins(res) => match res { + Err(e) => self.warning = Some(e), + Ok(coins) => { + self.warning = None; + self.balance = Amount::from_sat( + coins + .iter() + .map(|coin| { + // If the coin is not spent and is its transaction is confirmed + if coin.spend_info.is_none() && coin.block_height.is_some() { + coin.amount.to_sat() + } else { + 0 + } + }) + .sum(), + ); + } + }, + Message::HistoryTransactions(res) => match res { + Err(e) => self.warning = Some(e), + Ok(events) => { + self.warning = None; + for event in events { + if !self.events.iter().any(|other| other.tx == event.tx) { + self.events.push(event); + } + } + } + }, + Message::PendingTransactions(res) => match res { + Err(e) => self.warning = Some(e), + Ok(events) => { + self.warning = None; + for event in events { + if !self.pending_events.iter().any(|other| other.tx == event.tx) { + self.pending_events.push(event); + } + } + } + }, + Message::View(view::Message::Close) => { + self.selected_event = None; + } + Message::View(view::Message::Select(i)) => { + self.selected_event = Some(i); + } + Message::View(view::Message::Next) => { + if let Some(last) = self.events.last() { + let daemon = daemon.clone(); + let last_event_date = last.time.unwrap() as u32; + return Command::perform( + async move { + let mut limit = view::home::HISTORY_EVENT_PAGE_SIZE; + let mut events = + daemon.list_history_txs(0_u32, last_event_date, limit)?; + + // because gethistory cursor is inclusive and use blocktime + // multiple events can occur in the same block. + // If there is more event in the same block that the + // HISTORY_EVENT_PAGE_SIZE they can not be retrieved by changing + // the cursor value (blocktime) but by increasing the limit. + // + // 1. Check if the events retrieved have all the same blocktime + let blocktime = if let Some(event) = events.first() { + event.time + } else { + return Ok(events); + }; + + // 2. Retrieve a larger batch of event with the same cursor but + // a larger limit. + while !events.iter().any(|evt| evt.time != blocktime) + && events.len() as u64 == limit + { + // increments of the equivalent of one page more. + limit += view::home::HISTORY_EVENT_PAGE_SIZE; + events = daemon.list_history_txs(0, last_event_date, limit)?; + } + Ok(events) + }, + Message::HistoryTransactions, + ); + } + } + _ => {} + }; Command::none() } fn load(&self, daemon: Arc) -> Command { - let daemon = daemon.clone(); - Command::perform( - async move { - daemon - .list_coins() - .map(|res| res.coins) - .map_err(|e| e.into()) - }, - Message::Coins, - ) + let daemon1 = daemon.clone(); + let daemon2 = daemon.clone(); + let daemon3 = daemon.clone(); + let now: u32 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .try_into() + .unwrap(); + Command::batch(vec![ + Command::perform( + async move { daemon3.list_pending_txs().map_err(|e| e.into()) }, + Message::PendingTransactions, + ), + Command::perform( + async move { + daemon1 + .list_history_txs(0, now, view::home::HISTORY_EVENT_PAGE_SIZE) + .map_err(|e| e.into()) + }, + Message::HistoryTransactions, + ), + Command::perform( + async move { + daemon2 + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ), + ]) } } diff --git a/gui/src/app/view/coins.rs b/gui/src/app/view/coins.rs index b7311476..fd938204 100644 --- a/gui/src/app/view/coins.rs +++ b/gui/src/app/view/coins.rs @@ -89,9 +89,15 @@ fn coin_list_view(coin: &Coin, timelock: u32, blockheight: u32) -> Element Element { +pub fn home_view<'a>( + balance: &'a bitcoin::Amount, + pending_events: &[HistoryTransaction], + events: &Vec, +) -> Element<'a, Message> { column() .push(column().padding(40)) .push(text(&format!("{} BTC", balance.to_btc())).bold().size(50)) + .push( + column() + .spacing(10) + .push( + pending_events + .iter() + .enumerate() + .fold(column().spacing(10), |col, (i, event)| { + col.push(event_list_view(i, event)) + }), + ) + .push( + events + .iter() + .enumerate() + .fold(column().spacing(10), |col, (i, event)| { + col.push(event_list_view(i, event)) + }), + ) + .push_maybe( + if events.len() % HISTORY_EVENT_PAGE_SIZE as usize == 0 && !events.is_empty() { + Some( + container( + button( + text("See more") + .width(Length::Fill) + .horizontal_alignment(alignment::Horizontal::Center), + ) + .width(Length::Fill) + .padding(15) + .style(Style::TransparentBorder) + .on_press(Message::Next), + ) + .width(Length::Fill) + .style(card::SimpleCardStyle), + ) + } else { + None + }, + ), + ) .align_items(Alignment::Center) .spacing(20) .into() } + +fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Element<'a, Message> { + container( + button( + row() + .push( + row() + .push(if event.is_external() { + badge::receive() + } else { + badge::spend() + }) + .push(if let Some(t) = event.time { + container( + text(&format!("{}", NaiveDateTime::from_timestamp(t as i64, 0))) + .small(), + ) + } else { + container(text(" Pending ").small()) + .padding(3) + .style(badge::PillStyle::Success) + }) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .push( + row() + .push( + text(&{ + if event.is_external() { + format!("+ {:.8}", event.incoming_amount.to_btc()) + } else { + format!("- {:.8}", event.outgoing_amount.to_btc()) + } + }) + .bold() + .width(Length::Shrink), + ) + .push(text("BTC")) + .spacing(5) + .align_items(Alignment::Center), + ) + .align_items(Alignment::Center) + .spacing(20), + ) + .padding(10) + .on_press(Message::Select(i)) + .style(Style::TransparentBorder), + ) + .style(card::SimpleCardStyle) + .into() +} + +pub fn event_view<'a>(event: &HistoryTransaction) -> Element<'a, Message> { + column() + .push( + row() + .push(if event.is_external() { + badge::receive() + } else { + badge::spend() + }) + .spacing(10) + .align_items(Alignment::Center), + ) + .push( + text(&{ + if event.is_external() { + format!("+ {} BTC", event.incoming_amount.to_btc()) + } else { + format!("- {} BTC", event.outgoing_amount.to_btc()) + } + }) + .bold() + .size(50) + .width(Length::Shrink), + ) + .push_maybe( + event + .fee_amount + .map(|fee| container(text(&format!("Miner Fee: {} BTC", fee.to_btc())))), + ) + .push(card::simple( + column() + .push_maybe(event.time.map(|t| { + let date = NaiveDateTime::from_timestamp(t as i64, 0); + row() + .width(Length::Fill) + .push(container(text("Date:").bold()).width(Length::Fill)) + .push(container(text(&format!("{}", date))).width(Length::Shrink)) + })) + .push( + row() + .width(Length::Fill) + .push(container(text("Txid:").bold()).width(Length::Fill)) + .push( + container(text(&format!("{}", event.tx.txid()))).width(Length::Shrink), + ), + ) + .spacing(5), + )) + .align_items(Alignment::Center) + .spacing(20) + .max_width(750) + .into() +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index 86daa08f..21affcad 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -30,6 +30,7 @@ pub enum SpendTxMessage { Confirm, Cancel, SelectHardwareWallet(usize), + Next, } #[derive(Debug, Clone)] diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 5b72c4a4..a9f92870 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -267,11 +267,11 @@ pub fn dashboard<'a, T: Into>>( .height(Length::Fill), ) .push( - column().push(warn(warning)).push( - main_section(container(scrollable(content))) - .width(Length::Fill) - .height(Length::Fill), - ), + column() + .push(warn(warning)) + .push(main_section(container(scrollable( + container(content).padding(20), + )))), ) .width(iced::Length::Fill) .height(iced::Length::Fill) @@ -280,7 +280,6 @@ pub fn dashboard<'a, T: Into>>( fn main_section<'a, T: 'a>(menu: widget::Container<'a, T>) -> widget::Container<'a, T> { container(menu.max_width(1500)) - .padding(20) .style(MainSectionStyle) .center_x() .width(Length::Fill) diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 8f5a942b..b9f7c023 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -118,6 +118,22 @@ impl Daemon for Lianad { let _res: serde_json::value::Value = self.call("startrescan", Some(vec![t]))?; Ok(()) } + + fn list_confirmed_txs( + &self, + start: u32, + end: u32, + limit: u64, + ) -> Result { + self.call( + "listconfirmed", + Some(vec![json!(start), json!(end), json!(limit)]), + ) + } + + fn list_txs(&self, txids: &[Txid]) -> Result { + self.call("list_transactions", Some(vec![txids])) + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index fb4424ea..e882ee4d 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -108,6 +108,33 @@ impl Daemon for EmbeddedDaemon { .list_spend()) } + fn list_confirmed_txs( + &self, + start: u32, + end: u32, + limit: u64, + ) -> Result { + Ok(self + .handle + .as_ref() + .ok_or(DaemonError::NoAnswer)? + .read() + .unwrap() + .control + .list_confirmed_transactions(start, end, limit)) + } + + fn list_txs(&self, txids: &[Txid]) -> Result { + Ok(self + .handle + .as_ref() + .ok_or(DaemonError::NoAnswer)? + .read() + .unwrap() + .control + .list_transactions(txids)) + } + fn create_spend_tx( &self, coins_outpoints: &[OutPoint], diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 15de35c0..cd8de64c 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -39,22 +39,32 @@ impl std::fmt::Display for DaemonError { pub trait Daemon: Debug { fn is_external(&self) -> bool; - fn load_config(&mut self, _cfg: Config) -> Result<(), DaemonError> { Ok(()) } - fn config(&self) -> &Config; - fn stop(&mut self) -> Result<(), DaemonError>; - fn get_info(&self) -> Result; - fn get_new_address(&self) -> Result; - fn list_coins(&self) -> Result; - fn list_spend_txs(&self) -> Result; + fn create_spend_tx( + &self, + coins_outpoints: &[OutPoint], + destinations: &HashMap, + feerate_vb: u64, + ) -> Result; + fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError>; + fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>; + fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>; + fn start_rescan(&self, t: u32) -> Result<(), DaemonError>; + fn list_confirmed_txs( + &self, + _start: u32, + _end: u32, + _limit: u64, + ) -> Result; + fn list_txs(&self, txid: &[Txid]) -> Result; fn list_spend_transactions(&self) -> Result, DaemonError> { let coins = self.list_coins()?.coins; @@ -78,15 +88,67 @@ pub trait Daemon: Debug { .collect()) } - fn create_spend_tx( + fn list_history_txs( &self, - coins_outpoints: &[OutPoint], - destinations: &HashMap, - feerate_vb: u64, - ) -> Result; + start: u32, + end: u32, + limit: u64, + ) -> Result, DaemonError> { + let coins = self.list_coins()?.coins; + let txs = self.list_confirmed_txs(start, end, limit)?.transactions; + Ok(txs + .into_iter() + .map(|tx| { + let mut tx_coins = Vec::new(); + let mut change_indexes = Vec::new(); + for coin in &coins { + if coin.outpoint.txid == tx.tx.txid() { + change_indexes.push(coin.outpoint.vout as usize) + } else if tx + .tx + .input + .iter() + .any(|input| input.previous_output == coin.outpoint) + { + tx_coins.push(*coin); + } + } + model::HistoryTransaction::new(tx.tx, tx.height, tx.time, tx_coins, change_indexes) + }) + .collect()) + } - fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError>; - fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>; - fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>; - fn start_rescan(&self, t: u32) -> Result<(), DaemonError>; + fn list_pending_txs(&self) -> Result, DaemonError> { + let coins = self.list_coins()?.coins; + let mut txids: Vec = Vec::new(); + for coin in &coins { + if let Some(spend) = coin.spend_info { + if spend.height.is_none() && !txids.contains(&spend.txid) { + txids.push(spend.txid); + } + } + } + + let txs = self.list_txs(&txids)?.transactions; + Ok(txs + .into_iter() + .map(|tx| { + let mut tx_coins = Vec::new(); + let mut change_indexes = Vec::new(); + for coin in &coins { + if coin.outpoint.txid == tx.tx.txid() { + change_indexes.push(coin.outpoint.vout as usize) + } else if tx + .tx + .input + .iter() + .any(|input| input.previous_output == coin.outpoint) + { + tx_coins.push(*coin); + } + } + model::HistoryTransaction::new(tx.tx, tx.height, tx.time, tx_coins, change_indexes) + }) + .collect()) + } } diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index 551399ac..e33c252a 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -1,9 +1,9 @@ pub use liana::{ commands::{ CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult, - ListSpendEntry, ListSpendResult, + ListSpendEntry, ListSpendResult, ListTransactionsResult, TransactionInfo, }, - miniscript::bitcoin::{util::psbt::Psbt, Amount}, + miniscript::bitcoin::{util::psbt::Psbt, Amount, Transaction}, }; pub type Coin = ListCoinsEntry; @@ -61,3 +61,62 @@ impl SpendTx { } } } + +#[derive(Debug, Clone)] +pub struct HistoryTransaction { + pub coins: Vec, + pub change_indexes: Vec, + pub tx: Transaction, + pub outgoing_amount: Amount, + pub incoming_amount: Amount, + pub fee_amount: Option, + pub height: Option, + pub time: Option, +} + +impl HistoryTransaction { + pub fn new( + tx: Transaction, + height: Option, + time: Option, + coins: Vec, + change_indexes: Vec, + ) -> Self { + let (incoming_amount, outgoing_amount) = tx.output.iter().enumerate().fold( + (Amount::from_sat(0), Amount::from_sat(0)), + |(change, spend), (i, output)| { + if change_indexes.contains(&i) { + (change + Amount::from_sat(output.value), spend) + } else { + (change, spend + Amount::from_sat(output.value)) + } + }, + ); + + let mut inputs_amount = Amount::from_sat(0); + for coin in &coins { + inputs_amount += coin.amount; + } + + let fee_amount = if inputs_amount > outgoing_amount + incoming_amount { + Some(inputs_amount - outgoing_amount - incoming_amount) + } else { + None + }; + + Self { + tx, + coins, + change_indexes, + outgoing_amount, + incoming_amount, + fee_amount, + height, + time, + } + } + + pub fn is_external(&self) -> bool { + self.coins.is_empty() + } +}