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..abd2d805 100644 --- a/liana-gui/src/app/state/mod.rs +++ b/liana-gui/src/app/state/mod.rs @@ -71,6 +71,55 @@ 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. +/// +/// The confirmed balance includes the values of any unconfirmed coins +/// from self. +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() { + // 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. + 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 +136,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, expiring_coins, remaining_seq) = coins_summary( + coins, + tip_height as u32, + wallet.main_descriptor.first_timelock_value(), ); Self { @@ -106,8 +153,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(), @@ -158,34 +205,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 +389,172 @@ 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 confirmed balance. Other values remain the same. + assert_eq!( + coins_summary(&coins, tip_height, timelock), + ( + Amount::from_sat(111), + Amount::from_sat(109), + 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(212), + Amount::from_sat(109), + 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(212), + Amount::from_sat(109), + 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(317), + Amount::from_sat(109), + 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(425), + Amount::from_sat(109), + vec![OutPoint::new(dummy_txid, 3), OutPoint::new(dummy_txid, 5)], + Some(500) + ) + ); + } +} 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, }); } }