From 0a4861f409867744c768228889b1107ea2ae8a9a Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Wed, 11 Dec 2024 12:06:17 +0000 Subject: [PATCH 1/4] refactor(gui): use common function for coins summary --- liana-gui/src/app/mod.rs | 1 + liana-gui/src/app/state/mod.rs | 264 ++++++++++++++++++++++++++++----- 2 files changed, 225 insertions(+), 40 deletions(-) diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 6d6536fd..a326f361 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -75,6 +75,7 @@ impl Panels { cache.last_poll_timestamp, cache.last_poll_at_startup, ), + cache.blockheight, ), coins: CoinsPanel::new(&cache.coins, wallet.main_descriptor.first_timelock_value()), transactions: TransactionsPanel::new(wallet.clone()), diff --git a/liana-gui/src/app/state/mod.rs b/liana-gui/src/app/state/mod.rs index cda02912..826b537f 100644 --- a/liana-gui/src/app/state/mod.rs +++ b/liana-gui/src/app/state/mod.rs @@ -71,6 +71,46 @@ pub fn redirect(menu: Menu) -> Command { }) } +/// Returns the confirmed and unconfirmed balances from `coins`, as well +/// as: +/// - the `OutPoint`s of those coins, if any, for which the current +/// `tip_height` is within 10% of the `timelock` expiring. +/// - the smallest number of blocks until the expiry of `timelock` among +/// all confirmed coins, if any. +fn coins_summary( + coins: &[Coin], + tip_height: u32, + timelock: u16, +) -> (Amount, Amount, Vec, Option) { + let mut balance = Amount::from_sat(0); + let mut unconfirmed_balance = Amount::from_sat(0); + let mut expiring_coins = Vec::new(); + let mut remaining_seq = None; + for coin in coins { + if coin.spend_info.is_none() { + if coin.block_height.is_some() { + balance += coin.amount; + let seq = remaining_sequence(coin, tip_height, timelock); + // Warn user for coins that are expiring in less than 10 percent of + // the timelock. + if seq <= timelock as u32 * 10 / 100 { + expiring_coins.push(coin.outpoint); + } + if let Some(last) = &mut remaining_seq { + if seq < *last { + *last = seq + } + } else { + remaining_seq = Some(seq); + } + } else { + unconfirmed_balance += coin.amount; + } + } + } + (balance, unconfirmed_balance, expiring_coins, remaining_seq) +} + pub struct Home { wallet: Arc, sync_status: SyncStatus, @@ -87,18 +127,16 @@ pub struct Home { } impl Home { - pub fn new(wallet: Arc, coins: &[Coin], sync_status: SyncStatus) -> Self { - let (balance, unconfirmed_balance) = coins.iter().fold( - (Amount::from_sat(0), Amount::from_sat(0)), - |(balance, unconfirmed_balance), coin| { - if coin.spend_info.is_some() { - (balance, unconfirmed_balance) - } else if coin.block_height.is_some() { - (balance + coin.amount, unconfirmed_balance) - } else { - (balance, unconfirmed_balance + coin.amount) - } - }, + pub fn new( + wallet: Arc, + coins: &[Coin], + sync_status: SyncStatus, + tip_height: i32, + ) -> Self { + let (balance, unconfirmed_balance, _, _) = coins_summary( + coins, + tip_height as u32, + wallet.main_descriptor.first_timelock_value(), ); Self { @@ -158,34 +196,16 @@ impl State for Home { Err(e) => self.warning = Some(e), Ok(coins) => { self.warning = None; - self.balance = Amount::from_sat(0); - self.unconfirmed_balance = Amount::from_sat(0); - self.remaining_sequence = None; - self.expiring_coins = Vec::new(); - for coin in coins { - if coin.spend_info.is_none() { - if coin.block_height.is_some() { - self.balance += coin.amount; - let timelock = self.wallet.main_descriptor.first_timelock_value(); - let seq = - remaining_sequence(&coin, cache.blockheight as u32, timelock); - // Warn user for coins that are expiring in less than 10 percent of - // the timelock. - if seq <= timelock as u32 * 10 / 100 { - self.expiring_coins.push(coin.outpoint); - } - if let Some(last) = &mut self.remaining_sequence { - if seq < *last { - *last = seq - } - } else { - self.remaining_sequence = Some(seq); - } - } else { - self.unconfirmed_balance += coin.amount; - } - } - } + ( + self.balance, + self.unconfirmed_balance, + self.expiring_coins, + self.remaining_sequence, + ) = coins_summary( + &coins, + cache.blockheight as u32, + self.wallet.main_descriptor.first_timelock_value(), + ); } }, Message::HistoryTransactions(res) => match res { @@ -360,3 +380,167 @@ impl From for Box { Box::new(s) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::model::Coin; + use liana::miniscript::bitcoin; + use lianad::commands::LCSpendInfo; + use std::str::FromStr; + #[tokio::test] + async fn test_coins_summary() { + // Will use the same address for all coins. + let dummy_address = + bitcoin::Address::from_str("bc1qvrl2849aggm6qry9ea7xqp2kk39j8vaa8r3cwg") + .unwrap() + .assume_checked(); + // Will use the same txid for all outpoints and spend info. + let dummy_txid = bitcoin::Txid::from_str( + "f7bd1b2a995b689d326e51eb742eb1088c4a8f110d9cb56128fd553acc9f88e5", + ) + .unwrap(); + + let tip_height = 800_000; + let timelock = 10_000; + let mut coins = Vec::new(); + // Without coins, all values are 0 / empty / None: + assert_eq!( + coins_summary(&coins, tip_height, timelock), + (Amount::from_sat(0), Amount::from_sat(0), Vec::new(), None) + ); + // Add a spending coin. + coins.push(Coin { + outpoint: OutPoint::new(dummy_txid, 0), + amount: Amount::from_sat(100), + address: dummy_address.clone(), + derivation_index: bitcoin::bip32::ChildNumber::Normal { index: 0 }, + block_height: Some(1), + is_immature: false, + is_change: false, + is_from_self: false, + spend_info: Some(LCSpendInfo { + txid: dummy_txid, + height: None, + }), + }); + // Spending coin is ignored. + assert_eq!( + coins_summary(&coins, tip_height, timelock), + (Amount::from_sat(0), Amount::from_sat(0), Vec::new(), None) + ); + // Add unconfirmed change coin not from self. + coins.push(Coin { + outpoint: OutPoint::new(dummy_txid, 1), + amount: Amount::from_sat(109), + address: dummy_address.clone(), + derivation_index: bitcoin::bip32::ChildNumber::Normal { index: 1 }, + block_height: None, + is_immature: false, + is_change: true, + is_from_self: false, + spend_info: None, + }); + // Included in unconfirmed balance. Other values remain the same. + assert_eq!( + coins_summary(&coins, tip_height, timelock), + (Amount::from_sat(0), Amount::from_sat(109), Vec::new(), None) + ); + // Add unconfirmed coin from self. + coins.push(Coin { + outpoint: OutPoint::new(dummy_txid, 2), + amount: Amount::from_sat(111), + address: dummy_address.clone(), + derivation_index: bitcoin::bip32::ChildNumber::Normal { index: 2 }, + block_height: None, + is_immature: false, + is_change: false, + is_from_self: true, + spend_info: None, + }); + // Included in unconfirmed balance. Other values remain the same. + assert_eq!( + coins_summary(&coins, tip_height, timelock), + (Amount::from_sat(0), Amount::from_sat(220), Vec::new(), None) + ); + // Add a confirmed coin 1 more than 10% from expiry: + coins.push(Coin { + outpoint: OutPoint::new(dummy_txid, 3), + amount: Amount::from_sat(101), + address: dummy_address.clone(), + derivation_index: bitcoin::bip32::ChildNumber::Normal { index: 3 }, + block_height: Some(791_001), // 791_001 + timelock - tip_height = 1_001 > 1_000 = (timelock / 10) + is_immature: false, + is_change: false, + is_from_self: false, + spend_info: None, + }); + // Coin is added to confirmed balance. Not expiring, but remaining seq is set. + assert_eq!( + coins_summary(&coins, tip_height, timelock), + ( + Amount::from_sat(101), + Amount::from_sat(220), + Vec::new(), + Some(1_001) + ) + ); + // Now decrease the last coin's confirmation height by 1 so that + // it is within 10% of expiry: + coins.last_mut().unwrap().block_height = Some(791_000); + // Its outpoint has been added to expiring coins and remaining seq is lower. + assert_eq!( + coins_summary(&coins, tip_height, timelock), + ( + Amount::from_sat(101), + Amount::from_sat(220), + vec![OutPoint::new(dummy_txid, 3)], + Some(1_000) + ) + ); + // Now add a confirmed coin that is not yet expiring. + coins.push(Coin { + outpoint: OutPoint::new(dummy_txid, 4), + amount: Amount::from_sat(105), + address: dummy_address.clone(), + derivation_index: bitcoin::bip32::ChildNumber::Normal { index: 4 }, + block_height: Some(792_000), + is_immature: false, + is_change: false, + is_from_self: false, + spend_info: None, + }); + // Only confirmed balance has changed. + assert_eq!( + coins_summary(&coins, tip_height, timelock), + ( + Amount::from_sat(206), + Amount::from_sat(220), + vec![OutPoint::new(dummy_txid, 3)], + Some(1_000) + ) + ); + // Now add another confirmed coin that is expiring. + coins.push(Coin { + outpoint: OutPoint::new(dummy_txid, 5), + amount: Amount::from_sat(108), + address: dummy_address.clone(), + derivation_index: bitcoin::bip32::ChildNumber::Normal { index: 5 }, + block_height: Some(790_500), + is_immature: false, + is_change: false, + is_from_self: false, + spend_info: None, + }); + // Confirmed balance updated, as well as expiring coins and the remaining seq. + assert_eq!( + coins_summary(&coins, tip_height, timelock), + ( + Amount::from_sat(314), + Amount::from_sat(220), + vec![OutPoint::new(dummy_txid, 3), OutPoint::new(dummy_txid, 5)], + Some(500) + ) + ); + } +} From 31224ba44369e8eb76f004fefb0ddb4206458f4d Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Thu, 12 Dec 2024 09:06:56 +0000 Subject: [PATCH 2/4] gui: set missing fields for new home page These fields are returned by the new `coins_summary()` function so we may as well use them. --- liana-gui/src/app/state/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/liana-gui/src/app/state/mod.rs b/liana-gui/src/app/state/mod.rs index 826b537f..8024da7f 100644 --- a/liana-gui/src/app/state/mod.rs +++ b/liana-gui/src/app/state/mod.rs @@ -133,7 +133,7 @@ impl Home { sync_status: SyncStatus, tip_height: i32, ) -> Self { - let (balance, unconfirmed_balance, _, _) = coins_summary( + let (balance, unconfirmed_balance, expiring_coins, remaining_seq) = coins_summary( coins, tip_height as u32, wallet.main_descriptor.first_timelock_value(), @@ -144,8 +144,8 @@ impl Home { sync_status, balance, unconfirmed_balance, - remaining_sequence: None, - expiring_coins: Vec::new(), + remaining_sequence: remaining_seq, + expiring_coins, selected_event: None, events: Vec::new(), labels_edited: LabelsEdited::default(), From 8d99981c143b424d660b04b829da17afdfb1608a Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Wed, 11 Dec 2024 14:03:43 +0000 Subject: [PATCH 3/4] gui: include unconfirmed from self in confirmed balance --- liana-gui/src/app/state/mod.rs | 36 +++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/liana-gui/src/app/state/mod.rs b/liana-gui/src/app/state/mod.rs index 8024da7f..abd2d805 100644 --- a/liana-gui/src/app/state/mod.rs +++ b/liana-gui/src/app/state/mod.rs @@ -77,6 +77,9 @@ pub fn redirect(menu: Menu) -> Command { /// `tip_height` is within 10% of the `timelock` expiring. /// - the smallest number of blocks until the expiry of `timelock` among /// all confirmed coins, if any. +/// +/// The confirmed balance includes the values of any unconfirmed coins +/// from self. fn coins_summary( coins: &[Coin], tip_height: u32, @@ -88,8 +91,14 @@ fn coins_summary( let mut remaining_seq = None; for coin in coins { if coin.spend_info.is_none() { - if coin.block_height.is_some() { + // Include unconfirmed coins from self in confirmed balance. + if coin.block_height.is_some() || coin.is_from_self { balance += coin.amount; + // Only consider confirmed coins for remaining seq + // (they would not be considered as expiring so we can also skip that part) + if coin.block_height.is_none() { + continue; + } let seq = remaining_sequence(coin, tip_height, timelock); // Warn user for coins that are expiring in less than 10 percent of // the timelock. @@ -458,10 +467,15 @@ mod tests { is_from_self: true, spend_info: None, }); - // Included in unconfirmed balance. Other values remain the same. + // Included in confirmed balance. Other values remain the same. assert_eq!( coins_summary(&coins, tip_height, timelock), - (Amount::from_sat(0), Amount::from_sat(220), Vec::new(), None) + ( + Amount::from_sat(111), + Amount::from_sat(109), + Vec::new(), + None + ) ); // Add a confirmed coin 1 more than 10% from expiry: coins.push(Coin { @@ -479,8 +493,8 @@ mod tests { assert_eq!( coins_summary(&coins, tip_height, timelock), ( - Amount::from_sat(101), - Amount::from_sat(220), + Amount::from_sat(212), + Amount::from_sat(109), Vec::new(), Some(1_001) ) @@ -492,8 +506,8 @@ mod tests { assert_eq!( coins_summary(&coins, tip_height, timelock), ( - Amount::from_sat(101), - Amount::from_sat(220), + Amount::from_sat(212), + Amount::from_sat(109), vec![OutPoint::new(dummy_txid, 3)], Some(1_000) ) @@ -514,8 +528,8 @@ mod tests { assert_eq!( coins_summary(&coins, tip_height, timelock), ( - Amount::from_sat(206), - Amount::from_sat(220), + Amount::from_sat(317), + Amount::from_sat(109), vec![OutPoint::new(dummy_txid, 3)], Some(1_000) ) @@ -536,8 +550,8 @@ mod tests { assert_eq!( coins_summary(&coins, tip_height, timelock), ( - Amount::from_sat(314), - Amount::from_sat(220), + Amount::from_sat(425), + Amount::from_sat(109), vec![OutPoint::new(dummy_txid, 3), OutPoint::new(dummy_txid, 5)], Some(500) ) From 715aeb9298d1bac41ab3c904201696ddf9a794be Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Thu, 12 Dec 2024 09:39:03 +0000 Subject: [PATCH 4/4] gui(lianalite): use is_from_self from response --- liana-gui/src/lianalite/client/backend/api.rs | 1 + liana-gui/src/lianalite/client/backend/mod.rs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/liana-gui/src/lianalite/client/backend/api.rs b/liana-gui/src/lianalite/client/backend/api.rs index eb3ad34e..571e1f2a 100644 --- a/liana-gui/src/lianalite/client/backend/api.rs +++ b/liana-gui/src/lianalite/client/backend/api.rs @@ -201,6 +201,7 @@ pub struct Coin { pub spend_info: Option, pub is_immature: bool, pub is_change_address: bool, + pub is_from_self: bool, } #[derive(Clone, Deserialize)] diff --git a/liana-gui/src/lianalite/client/backend/mod.rs b/liana-gui/src/lianalite/client/backend/mod.rs index 1118c686..54b9e36b 100644 --- a/liana-gui/src/lianalite/client/backend/mod.rs +++ b/liana-gui/src/lianalite/client/backend/mod.rs @@ -649,7 +649,7 @@ impl Daemon for BackendWalletClient { txid: info.txid, height: info.height, }), - is_from_self: false, // FIXME: use value from backend + is_from_self: c.is_from_self, }) .collect(), }) @@ -1134,7 +1134,7 @@ fn history_tx_from_api(value: api::Transaction, network: Network) -> HistoryTran txid: info.txid, height: info.height, }), - is_from_self: false, // FIXME: use value from backend + is_from_self: c.is_from_self, }); } } @@ -1190,7 +1190,7 @@ fn spend_tx_from_api( txid: info.txid, height: info.height, }), - is_from_self: false, // FIXME: use value from backend + is_from_self: c.is_from_self, }); } }