diff --git a/liana-gui/src/app/state/psbt.rs b/liana-gui/src/app/state/psbt.rs index 3734ce06..0245e160 100644 --- a/liana-gui/src/app/state/psbt.rs +++ b/liana-gui/src/app/state/psbt.rs @@ -187,6 +187,7 @@ impl PsbtState { cache.datadir_path.clone(), cache.network, self.saved, + self.tx.recovery_timelock(), ); let cmd = modal.load(daemon); self.modal = Some(PsbtModal::Sign(modal)); @@ -438,6 +439,7 @@ pub struct SignModal { signed: HashSet, is_saved: bool, display_modal: bool, + recovery_timelock: Option, } impl SignModal { @@ -447,6 +449,7 @@ impl SignModal { datadir_path: LianaDirectory, network: Network, is_saved: bool, + recovery_timelock: Option, ) -> Self { Self { signing: HashSet::new(), @@ -456,6 +459,7 @@ impl SignModal { signed, is_saved, display_modal: true, + recovery_timelock, } } } @@ -565,6 +569,7 @@ impl Modal for SignModal { view::psbt::sign_action( self.error.as_ref(), &self.hws.list, + &self.wallet.main_descriptor, self.wallet.signer.as_ref().map(|s| s.fingerprint()), self.wallet .signer @@ -572,6 +577,7 @@ impl Modal for SignModal { .and_then(|signer| self.wallet.keys_aliases.get(&signer.fingerprint)), &self.signed, &self.signing, + self.recovery_timelock, ), ) .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) diff --git a/liana-gui/src/app/view/hw.rs b/liana-gui/src/app/view/hw.rs index b57ba0c0..1e4da3d6 100644 --- a/liana-gui/src/app/view/hw.rs +++ b/liana-gui/src/app/view/hw.rs @@ -13,6 +13,7 @@ pub fn hw_list_view( hw: &HardwareWallet, signed: bool, signing: bool, + can_sign: bool, ) -> Element { let mut bttn = Button::new(match hw { HardwareWallet::Supported { @@ -40,6 +41,8 @@ pub fn hw_list_view( alias.as_ref(), "The wallet descriptor is not registered on the device.\n You can register it in the settings.", ) + } else if !can_sign { + hw::disabled_hardware_wallet(kind, version.as_ref(), fingerprint, "This signing device is not part of this spending path.") } else { hw::supported_hardware_wallet(kind, version.as_ref(), fingerprint, alias.as_ref()) } @@ -71,7 +74,7 @@ pub fn hw_list_view( }) .style(theme::button::secondary) .width(Length::Fill); - if !signing { + if can_sign && !signing { if let HardwareWallet::Supported { registered, .. } = hw { if *registered != Some(false) { bttn = bttn.on_press(Message::SelectHardwareWallet(i)); diff --git a/liana-gui/src/app/view/psbt.rs b/liana-gui/src/app/view/psbt.rs index ad9413f0..aaf34281 100644 --- a/liana-gui/src/app/view/psbt.rs +++ b/liana-gui/src/app/view/psbt.rs @@ -5,6 +5,7 @@ use iced::{ Alignment, Length, }; +use liana::descriptors::LianaDescriptor; use liana::{ descriptors::{LianaPolicy, PathInfo, PathSpendInfo}, miniscript::bitcoin::{ @@ -1014,13 +1015,16 @@ fn change_view(output: &TxOut, network: Network) -> Element { .into() } +#[allow(clippy::too_many_arguments)] pub fn sign_action<'a>( warning: Option<&Error>, hws: &'a [HardwareWallet], + descriptor: &LianaDescriptor, signer: Option, signer_alias: Option<&'a String>, signed: &HashSet, signing: &HashSet, + recovery_timelock: Option, ) -> Element<'a, Message> { Column::new() .push_maybe(warning.map(|w| warn(Some(w)))) @@ -1037,29 +1041,37 @@ pub fn sign_action<'a>( .push(hws.iter().enumerate().fold( Column::new().spacing(10), |col, (i, hw)| { - col.push(hw_list_view( - i, - hw, - hw.fingerprint() - .map(|f| signed.contains(&f)) - .unwrap_or(false), - hw.fingerprint() - .map(|f| signing.contains(&f)) - .unwrap_or(false), - )) + let (signed, signing, can_sign) = + hw.fingerprint().map_or((false, false, false), |f| { + ( + signed.contains(&f), + signing.contains(&f), + descriptor + .contains_fingerprint_in_path(f, recovery_timelock), + ) + }); + col.push(hw_list_view(i, hw, signed, signing, can_sign)) }, )) - .push_maybe(signer.map(|fingerprint| { - Button::new(if signed.contains(&fingerprint) { - hw::sign_success_hot_signer(fingerprint, signer_alias) - } else { - hw::hot_signer(fingerprint, signer_alias) + .push_maybe({ + signer.map(|fingerprint| { + let can_sign = descriptor + .contains_fingerprint_in_path(fingerprint, recovery_timelock); + let btn = Button::new(if signed.contains(&fingerprint) { + hw::sign_success_hot_signer(fingerprint, signer_alias) + } else { + hw::hot_signer(fingerprint, signer_alias, can_sign) + }) + .padding(10) + .style(theme::button::secondary) + .width(Length::Fill); + if can_sign { + btn.on_press(Message::Spend(SpendTxMessage::SelectHotSigner)) + } else { + btn + } }) - .on_press(Message::Spend(SpendTxMessage::SelectHotSigner)) - .padding(10) - .style(theme::button::secondary) - .width(Length::Fill) - })) + }) .width(Length::Fill), ) .spacing(20) diff --git a/liana-gui/src/daemon/model.rs b/liana-gui/src/daemon/model.rs index 53606e89..f9dc00f1 100644 --- a/liana-gui/src/daemon/model.rs +++ b/liana-gui/src/daemon/model.rs @@ -205,6 +205,10 @@ impl SpendTx { .find(|&path| path.sigs_count >= path.threshold) } + pub fn recovery_timelock(&self) -> Option { + self.sigs.recovery_paths().keys().max().cloned() + } + pub fn signers(&self) -> HashSet { let mut signers = HashSet::new(); for fg in self.sigs.primary_path().signed_pubkeys.keys() { diff --git a/liana-ui/src/component/hw.rs b/liana-ui/src/component/hw.rs index 6d99db46..964455ec 100644 --- a/liana-ui/src/component/hw.rs +++ b/liana-ui/src/component/hw.rs @@ -125,10 +125,11 @@ pub fn unimplemented_method_hardware_wallet<'a, T: 'a, K: Display, V: Display, F .width(Length::Fill) } -pub fn unrelated_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>( +pub fn disabled_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>( kind: K, version: Option, fingerprint: F, + label: &'static str, ) -> Container<'a, T> { let key = column(vec![ text::p1_regular(format!("#{}", fingerprint)).into(), @@ -144,9 +145,7 @@ pub fn unrelated_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>( .push(key) .push(Space::with_width(15)) .push(Space::with_width(Length::Fill)) - .push(text::text( - "This signing device is not related to this Liana wallet.", - )) + .push(text::text(label)) .push(Space::with_width(Length::Fill)) .align_y(Vertical::Center), ) @@ -157,6 +156,19 @@ pub fn unrelated_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>( .width(Length::Fill) } +pub fn unrelated_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>( + kind: K, + version: Option, + fingerprint: F, +) -> Container<'a, T> { + disabled_hardware_wallet( + kind, + version, + fingerprint, + "This signing device is not related to this Liana wallet.", + ) +} + pub fn processing_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>( kind: K, version: Option, @@ -484,20 +496,31 @@ pub fn unselected_hot_signer<'a, T: 'a, F: Display>( pub fn hot_signer<'a, T: 'a, F: Display>( fingerprint: F, alias: Option> + Display>, + can_sign: bool, ) -> Container<'a, T> { Container::new( - column(vec![ - Row::new() - .spacing(5) - .push_maybe(alias.map(|a| text::p1_bold(a))) - .push(text::p1_regular(format!("#{}", fingerprint))) - .into(), - Row::new() - .spacing(5) - .push(text::caption("This computer")) - .into(), - ]) - .width(Length::Fill), + Row::new() + .push(column(vec![ + Row::new() + .spacing(5) + .push_maybe(alias.map(|a| text::p1_bold(a))) + .push(text::p1_regular(format!("#{}", fingerprint))) + .into(), + Row::new() + .spacing(5) + .push(text::caption("This computer")) + .into(), + ])) + .push(Space::with_width(Length::Fixed(20.0))) + .push_maybe(if !can_sign { + Some(text::text( + "This hot signer is not part of this spending path.", + )) + } else { + None + }) + .push(Space::with_width(Length::Fill)) + .align_y(Vertical::Center), ) .padding(10) } diff --git a/liana/src/descriptors/analysis.rs b/liana/src/descriptors/analysis.rs index e724a346..cf859782 100644 --- a/liana/src/descriptors/analysis.rs +++ b/liana/src/descriptors/analysis.rs @@ -9,6 +9,7 @@ use miniscript::{ RelLockTime, ScriptContext, Threshold, }; +use miniscript::bitcoin::bip32::Fingerprint; use std::{ collections::{BTreeMap, HashMap, HashSet}, convert::TryFrom, @@ -368,6 +369,11 @@ impl PathInfo { ), }) } + + /// Determine whether the fingerprint is part of this path. + pub fn contains_fingerprint(&self, fingerprint: Fingerprint) -> bool { + self.thresh_origins().1.contains_key(&fingerprint) + } } // See diff --git a/liana/src/descriptors/mod.rs b/liana/src/descriptors/mod.rs index f89577f7..16f5e262 100644 --- a/liana/src/descriptors/mod.rs +++ b/liana/src/descriptors/mod.rs @@ -217,6 +217,39 @@ impl LianaDescriptor { .for_any_key(|k| k.master_fingerprint() == fg) } + /// Determine whether the fingerprint is part of a specific path of this descriptor. + /// If recovery_timelock is None, checks in the primary path. + /// If recovery_timelock is Some(timelock), checks in the recovery path with specified timelock. + pub fn contains_fingerprint_in_path( + &self, + fingerprint: Fingerprint, + recovery_timelock: Option, + ) -> bool { + match recovery_timelock { + None => self.contains_fingerprint_in_primary_path(fingerprint), + Some(timelock) => self.contains_fingerprint_in_recovery_path(fingerprint, timelock), + } + } + + /// Determine whether the fingerprint is part of the primary path of this descriptor. + fn contains_fingerprint_in_primary_path(&self, fingerprint: Fingerprint) -> bool { + self.policy().primary_path.contains_fingerprint(fingerprint) + } + + /// Determine whether the fingerprint is part of the recovery path of this descriptor for the + /// specified timelock. + fn contains_fingerprint_in_recovery_path( + &self, + fingerprint: Fingerprint, + recovery_timelock: u16, + ) -> bool { + self.policy() + .recovery_paths + .get(&recovery_timelock) + .map(|path_info| path_info.contains_fingerprint(fingerprint)) + .unwrap_or(false) + } + /// Get the descriptor for receiving addresses. pub fn receive_descriptor(&self) -> &SinglePathLianaDesc { &self.receive_desc @@ -751,9 +784,9 @@ impl DerivedSinglePathLianaDesc { mod tests { use super::*; - use bitcoin::{hashes::Hash, Sequence}; - use crate::signer::HotSigner; + use bitcoin::{hashes::Hash, Sequence}; + use miniscript::bitcoin::bip32::Fingerprint; fn random_desc_key( secp: &secp256k1::Secp256k1, @@ -2210,13 +2243,120 @@ mod tests { } #[test] - fn descriptor_contains_fingerprint() { + fn descriptor_contains_fingerprint_in_primary_path_multi() { let descr = LianaDescriptor::from_str("wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*)),older(26352))))").unwrap(); - assert!(descr.contains_fingerprint(Fingerprint::from_str("aabb0011").unwrap())); - assert!(descr.contains_fingerprint(Fingerprint::from_str("aabb0012").unwrap())); - assert!(descr.contains_fingerprint(Fingerprint::from_str("aabb0013").unwrap())); - assert!(!descr.contains_fingerprint(Fingerprint::from_str("aabb0014").unwrap())); + assert!( + descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0011").unwrap()) + ); + assert!( + descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0012").unwrap()) + ); + assert!( + descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0013").unwrap()) + ); + assert!( + !descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0014").unwrap()) + ); + } + + #[test] + fn descriptor_contains_fingerprint_in_primary_path_single_key() { + let descr = LianaDescriptor::from_str("wsh(or_d(pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*),and_v(v:thresh(1,pkh([bbcc2233/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*)),older(26352))))").unwrap(); + + assert!( + descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0011").unwrap()) + ); + assert!( + !descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("bbcc2233").unwrap()) + ); + assert!( + !descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("ddeeff00").unwrap()) + ); + } + + #[test] + fn descriptor_contains_fingerprint_in_recovery_path_multi() { + let descr = LianaDescriptor::from_str("wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*)),older(26352))))").unwrap(); + + assert!(descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("aabb0011").unwrap(), + 26352 + )); + assert!(descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("aabb0012").unwrap(), + 26352 + )); + assert!(descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("aabb0013").unwrap(), + 26352 + )); + assert!(!descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("aabb0013").unwrap(), + 1000 + )); + assert!(!descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("aabb0014").unwrap(), + 26352 + )); + } + + #[test] + fn descriptor_contains_fingerprint_in_recovery_path_single_key() { + let descr = LianaDescriptor::from_str("wsh(or_d(pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*),and_v(v:thresh(1,pkh([bbcc2233/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*)),older(26352))))").unwrap(); + + assert!(!descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("aabb0011").unwrap(), + 26352 + )); + assert!(descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("bbcc2233").unwrap(), + 26352 + )); + assert!(!descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("ddeeff00").unwrap(), + 26352 + )); + } + + #[test] + fn descriptor_contains_fingerprint_in_recovery_path_multiple_keys() { + let descr = LianaDescriptor::from_str("wsh(or_d(c:or_i(and_v(v:older(38305),and_v(v:pkh([d6bba22a/84'/1'/0']tpubDCwMfgJWBGfJuZFmgTAP9qdSwJeC4fEKaYXQx7CNiTzsB5WrpVSmySdPFnKDu8ChWZweYNh7MoAoFsCNY7gTRFSGtDYbG9s6vAKNKzT1Hii/<0;1>/*),pk_h([e9e6c583/48'/1'/0'/2']tpubDEWn2LRKdyREaweKHxj7XzSjcxXGTVbFkL5Qi5AWsJzGvN28cKQwGqCND9TP6EPtPaE13eK9SnyuiQ4qsfy5UuGD3p32Ew36mWfKmYCJRcz/<0;1>/*))),pk_k([de8abde2/48'/1'/0'/2']tpubDES5ZQEwEuj7Fpe6d6wkwD8SdequEa2cqq57QHQ43pb1x2HxbLp6anHwutDNzrMhDAbx1YgxCFAbRi6EhWwQLaGMSSmxJRaAzCUgn6VwpVD/<0;1>/*)),and_v(v:thresh(1,pkh([d6bba22a/84'/1'/0']tpubDCwMfgJWBGfJuZFmgTAP9qdSwJeC4fEKaYXQx7CNiTzsB5WrpVSmySdPFnKDu8ChWZweYNh7MoAoFsCNY7gTRFSGtDYbG9s6vAKNKzT1Hii/<2;3>/*),a:pkh([e9e6c583/48'/1'/0'/2']tpubDEWn2LRKdyREaweKHxj7XzSjcxXGTVbFkL5Qi5AWsJzGvN28cKQwGqCND9TP6EPtPaE13eK9SnyuiQ4qsfy5UuGD3p32Ew36mWfKmYCJRcz/<2;3>/*)),older(52596))))").unwrap(); + + assert!(descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("d6bba22a").unwrap(), + 38305 + )); + assert!(descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("e9e6c583").unwrap(), + 38305 + )); + assert!(descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("d6bba22a").unwrap(), + 52596 + )); + assert!(descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("e9e6c583").unwrap(), + 52596 + )); + + assert!(!descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("d6bba22a").unwrap(), + 12345 + )); + assert!(!descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("e9e6c583").unwrap(), + 12345 + )); + + assert!(!descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("ffffffff").unwrap(), + 38305 + )); + assert!(!descr.contains_fingerprint_in_recovery_path( + Fingerprint::from_str("ffffffff").unwrap(), + 52596 + )); } // TODO: test error conditions of deserialization.