Add transaction history to home panel

This commit is contained in:
edouard 2022-11-21 15:40:01 +01:00
parent dc23f3667a
commit 7e96cdd494
11 changed files with 511 additions and 48 deletions

2
gui/Cargo.lock generated
View File

@ -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",

View File

@ -28,4 +28,6 @@ pub enum Message {
Updated(Result<(), Error>),
StartRescan(Result<(), Error>),
ConnectedHardwareWallets(Vec<HardwareWallet>),
HistoryTransactions(Result<Vec<HistoryTransaction>, Error>),
PendingTransactions(Result<Vec<HistoryTransaction>, Error>),
}

View File

@ -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<HistoryTransaction>,
events: Vec<HistoryTransaction>,
selected_event: Option<usize>,
warning: Option<Error>,
}
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<dyn Daemon + Sync + Send>,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
_message: Message,
message: Message,
) -> Command<Message> {
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<dyn Daemon + Sync + Send>) -> Command<Message> {
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,
),
])
}
}

View File

@ -89,9 +89,15 @@ fn coin_list_view(coin: &Coin, timelock: u32, blockheight: u32) -> Element<Messa
.width(Length::Fill),
)
.push(
text(&format!("{} BTC", coin.amount.to_btc()))
.bold()
.width(Length::Shrink),
row()
.spacing(5)
.push(
text(&format!("{:.8}", coin.amount.to_btc()))
.bold()
.width(Length::Shrink),
)
.push(text("BTC"))
.align_items(Alignment::Center),
)
.align_items(Alignment::Center)
.spacing(20)

View File

@ -1,18 +1,178 @@
use chrono::NaiveDateTime;
use iced::{
pure::{column, Element},
Alignment,
alignment,
pure::{button, column, container, row, Element},
Alignment, Length,
};
use crate::ui::{
component::{badge, button::Style, card, text::*},
util::Collection,
};
use liana::miniscript::bitcoin;
use crate::ui::component::text::*;
use crate::{app::view::message::Message, daemon::model::HistoryTransaction};
use super::message::Message;
pub const HISTORY_EVENT_PAGE_SIZE: u64 = 20;
pub fn home_view(balance: &bitcoin::Amount) -> Element<Message> {
pub fn home_view<'a>(
balance: &'a bitcoin::Amount,
pending_events: &[HistoryTransaction],
events: &Vec<HistoryTransaction>,
) -> 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()
}

View File

@ -30,6 +30,7 @@ pub enum SpendTxMessage {
Confirm,
Cancel,
SelectHardwareWallet(usize),
Next,
}
#[derive(Debug, Clone)]

View File

@ -267,11 +267,11 @@ pub fn dashboard<'a, T: Into<Element<'a, Message>>>(
.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<Element<'a, Message>>>(
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)

View File

@ -118,6 +118,22 @@ impl<C: Client + Debug> Daemon for Lianad<C> {
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<ListTransactionsResult, DaemonError> {
self.call(
"listconfirmed",
Some(vec![json!(start), json!(end), json!(limit)]),
)
}
fn list_txs(&self, txids: &[Txid]) -> Result<ListTransactionsResult, DaemonError> {
self.call("list_transactions", Some(vec![txids]))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@ -108,6 +108,33 @@ impl Daemon for EmbeddedDaemon {
.list_spend())
}
fn list_confirmed_txs(
&self,
start: u32,
end: u32,
limit: u64,
) -> Result<ListTransactionsResult, DaemonError> {
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<ListTransactionsResult, DaemonError> {
Ok(self
.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.read()
.unwrap()
.control
.list_transactions(txids))
}
fn create_spend_tx(
&self,
coins_outpoints: &[OutPoint],

View File

@ -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<model::GetInfoResult, DaemonError>;
fn get_new_address(&self) -> Result<model::GetAddressResult, DaemonError>;
fn list_coins(&self) -> Result<model::ListCoinsResult, DaemonError>;
fn list_spend_txs(&self) -> Result<model::ListSpendResult, DaemonError>;
fn create_spend_tx(
&self,
coins_outpoints: &[OutPoint],
destinations: &HashMap<Address, u64>,
feerate_vb: u64,
) -> Result<model::CreateSpendResult, DaemonError>;
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<model::ListTransactionsResult, DaemonError>;
fn list_txs(&self, txid: &[Txid]) -> Result<model::ListTransactionsResult, DaemonError>;
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, 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<Address, u64>,
feerate_vb: u64,
) -> Result<model::CreateSpendResult, DaemonError>;
start: u32,
end: u32,
limit: u64,
) -> Result<Vec<model::HistoryTransaction>, 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<Vec<model::HistoryTransaction>, DaemonError> {
let coins = self.list_coins()?.coins;
let mut txids: Vec<Txid> = 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())
}
}

View File

@ -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<Coin>,
pub change_indexes: Vec<usize>,
pub tx: Transaction,
pub outgoing_amount: Amount,
pub incoming_amount: Amount,
pub fee_amount: Option<Amount>,
pub height: Option<i32>,
pub time: Option<u32>,
}
impl HistoryTransaction {
pub fn new(
tx: Transaction,
height: Option<i32>,
time: Option<u32>,
coins: Vec<Coin>,
change_indexes: Vec<usize>,
) -> 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()
}
}