Merge #1756: Add an option for add a descriptor from file/backup for liana-connect

31798b7e4317125e671e2d52e28e9ddc16815bf0 export: allow to import a backup from a simple descriptor (pythcoiner)
54c83615b070a982ec99f3c58278ecabba8a264e export: add test cases for import_descriptor() (pythcoiner)
d66b596cf930597254b1e48426c9488fd77d210d import: allow to import a descriptor from a backup in export::import_descriptor() (pythcoiner)
53839e92744b24e1365c3b57108b350c729b17b5 gui: add a spacer below the main column at Add wallet step in liana-connect flow (pythcoiner)
4bb4269975e0469205c96751bcbf24311177d53d gui: add import descriptor from file feature in liana-connect flow (pythcoiner)

Pull request description:

  This PR closes #1751:
   - add the option to import a descriptor from a file.
   - add a spacer at the bottom of the screen.
   - add the possibility to import also a descriptor from a backup file.

ACKs for top commit:
  edouardparis:
    ACK 31798b7e4317125e671e2d52e28e9ddc16815bf0

Tree-SHA512: 7e5ae0f3bbc4af1d779e1cd97e9cadceabe62317f857c895d9f5de49ed84f81c1da1df8df4d935ff0b1c2a040e1fa03f598bbdc6d64e82b225089c884c774743
This commit is contained in:
edouardparis 2025-06-25 15:38:37 +02:00
commit eff5894da6
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
8 changed files with 251 additions and 17 deletions

View File

@ -1,7 +1,10 @@
use chrono::{Duration, Utc};
use liana::miniscript::{
self,
bitcoin::{bip32::Fingerprint, Network, Txid},
use liana::{
descriptors::LianaDescriptor,
miniscript::{
self,
bitcoin::{bip32::Fingerprint, Network, Txid},
},
};
use lianad::{
bip329,
@ -111,6 +114,23 @@ impl Debug for Backup {
}
impl Backup {
/// Create a Backup from a descriptor
///
/// # Arguments
/// * `descriptor` - the descriptor
/// * `network` - the network
pub fn from_descriptor(descriptor: LianaDescriptor, network: Network) -> Self {
let account = Account::new(descriptor.to_string());
Backup {
name: None,
alias: None,
accounts: vec![account],
network,
date: None,
proprietary: Default::default(),
version: default_version(),
}
}
/// Create a Backup from the Installer context
///
/// # Arguments

View File

@ -246,7 +246,7 @@ pub enum Progress {
WalletFromBackup(
(
LianaDescriptor,
Network,
Option<Network>,
HashMap<Fingerprint, settings::KeySetting>,
Backup,
),
@ -637,7 +637,19 @@ pub async fn import_descriptor(
file.read_to_string(&mut descr_str)?;
let descr_str = descr_str.trim();
let descriptor = LianaDescriptor::from_str(descr_str).map_err(|_| Error::ParseDescriptor)?;
let descriptor = if let Ok(d) = LianaDescriptor::from_str(descr_str) {
Some(d)
} else {
let backup: Result<Backup, _> = serde_json::from_str(descr_str);
backup.ok().and_then(|b| {
if b.accounts.len() == 1 {
LianaDescriptor::from_str(&b.accounts[0].descriptor).ok()
} else {
None
}
})
};
let descriptor = descriptor.ok_or(Error::ParseDescriptor)?;
send_progress!(sender, Progress(100.0));
send_progress!(sender, Descriptor(descriptor));
@ -1033,13 +1045,33 @@ pub async fn wallet_from_backup(
let backup: Result<Backup, _> = serde_json::from_str(&backup_str);
let backup = match backup {
Ok(psbt) => psbt,
Ok(b) => b,
Err(e) => {
return Err(Error::BackupImport(format!("{:?}", e)));
// try to import as bare descriptor
let descr = LianaDescriptor::from_str(&backup_str);
match descr {
Ok(descr) => {
let network = if descr.all_xpubs_net_is(Network::Bitcoin) {
Network::Bitcoin
} else {
Network::Signet
};
Backup::from_descriptor(descr, network)
}
Err(e2) => {
return Err(Error::BackupImport(format!(
"A backup or descriptor file is expected: {e}, {e2}"
)));
}
}
}
};
let network = backup.network;
let network = if backup.network == Network::Bitcoin {
Some(backup.network)
} else {
None
};
let account = match backup.accounts.len() {
0 => {
@ -1274,8 +1306,44 @@ pub async fn app_backup_export(
#[cfg(test)]
mod tests {
use std::env;
use super::*;
#[tokio::test]
async fn test_import_descriptor_from_file() {
let (sender, mut receiver) = unbounded_channel();
let path = env::current_dir()
.unwrap()
.join("test_assets")
.join("liana-jz5sm0xn.txt");
println!("path: {}", path.display());
import_descriptor(&sender, path).await.unwrap();
let _msg = receiver.try_recv().unwrap();
assert!(matches!(Progress::Progress(100.0), _msg));
let raw_descriptor = "wsh(or_d(pk([8a550171/48'/1'/0'/2']tpubDFnCs5ZaCqopaNhgLCiXAwbkaBdcnuMt1VFoPsRpUrpidyvzG67MYjkfxw6HnTBhHqeU3xw2ioNBVcWY3jXwGhSyppEQvtn38GsL7RH1eef/<0;1>/*),and_v(v:pkh([8a550171/48'/1'/0'/2']tpubDFnCs5ZaCqopaNhgLCiXAwbkaBdcnuMt1VFoPsRpUrpidyvzG67MYjkfxw6HnTBhHqeU3xw2ioNBVcWY3jXwGhSyppEQvtn38GsL7RH1eef/<2;3>/*),older(52596))))#jz5sm0xn";
let descr = LianaDescriptor::from_str(raw_descriptor).unwrap();
let _msg = receiver.try_recv().unwrap();
assert!(matches!(Progress::Descriptor(descr), _msg));
}
#[tokio::test]
async fn test_import_descriptor_from_backup_file() {
let (sender, mut receiver) = unbounded_channel();
let path = env::current_dir()
.unwrap()
.join("test_assets")
.join("liana-backup-2025-06-23T13-23-54.json");
println!("path: {}", path.display());
import_descriptor(&sender, path).await.unwrap();
let _msg = receiver.try_recv().unwrap();
assert!(matches!(Progress::Progress(100.0), _msg));
let raw_descriptor = "wsh(or_d(pk([8a550171/48'/1'/0'/2']tpubDFnCs5ZaCqopaNhgLCiXAwbkaBdcnuMt1VFoPsRpUrpidyvzG67MYjkfxw6HnTBhHqeU3xw2ioNBVcWY3jXwGhSyppEQvtn38GsL7RH1eef/<0;1>/*),and_v(v:pkh([8a550171/48'/1'/0'/2']tpubDFnCs5ZaCqopaNhgLCiXAwbkaBdcnuMt1VFoPsRpUrpidyvzG67MYjkfxw6HnTBhHqeU3xw2ioNBVcWY3jXwGhSyppEQvtn38GsL7RH1eef/<2;3>/*),older(52596))))#jz5sm0xn";
let descr = LianaDescriptor::from_str(raw_descriptor).unwrap();
let _msg = receiver.try_recv().unwrap();
assert!(matches!(Progress::Descriptor(descr), _msg));
}
#[test]
fn test_parse_coldcard_xpub() {
let raw = r#"

View File

@ -117,6 +117,8 @@ pub enum ImportRemoteWallet {
InvitationFetched(Result<api::WalletInvitation, Error>),
AcceptInvitation,
InvitationAccepted(Result<api::Wallet, Error>),
ImportDescriptorFromFile,
ImportExport(ImportExportMessage),
}
#[derive(Debug, Clone)]

View File

@ -1,13 +1,15 @@
use std::str::FromStr;
use iced::Task;
use iced::{Subscription, Task};
use liana::{descriptors::LianaDescriptor, miniscript::bitcoin::Network};
use liana_ui::{component::form, widget::Element};
use crate::{
app::state::export::ExportModal,
daemon::DaemonError,
dir::NetworkDirectory,
export::{ImportExportMessage, ImportExportType, Progress},
hw::HardwareWallets,
installer::{
context::{self, Context, RemoteBackend},
@ -448,6 +450,7 @@ pub struct ImportRemoteWallet {
error: Option<String>,
backend: context::RemoteBackend,
wallets: Vec<api::Wallet>,
modal: Option<ExportModal>,
// wallet alias is stored here to be applied to context
// and be modified in a following step
wallet_alias: Option<String>,
@ -464,6 +467,7 @@ impl ImportRemoteWallet {
error: None,
backend: context::RemoteBackend::Undefined,
wallets: Vec::new(),
modal: None,
wallet_alias: None,
}
}
@ -502,6 +506,37 @@ impl Step for ImportRemoteWallet {
// Verification of the values is happening when the user click on Next button.
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Task<Message> {
match message {
Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportDescriptorFromFile) => {
let modal = ExportModal::new(None, ImportExportType::ImportDescriptor);
let launch = modal.launch(false);
self.modal = Some(modal);
return launch;
}
Message::ImportExport(ImportExportMessage::Path(p)) => {
if let Some(modal) = self.modal.as_mut() {
return modal.update(ImportExportMessage::Path(p));
}
}
Message::ImportExport(ImportExportMessage::Close) => self.modal = None,
Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportExport(m)) => match m {
ImportExportMessage::Close => self.modal = None,
ImportExportMessage::Progress(Progress::Descriptor(d)) => {
self.modal = None;
return Task::batch([
Task::done(Message::ImportRemoteWallet(
message::ImportRemoteWallet::ImportDescriptor(d.to_string()),
)),
Task::done(Message::ImportRemoteWallet(
message::ImportRemoteWallet::ConfirmDescriptor,
)),
]);
}
m => {
if let Some(modal) = self.modal.as_mut() {
return modal.update(m);
}
}
},
Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportDescriptor(desc)) => {
self.imported_descriptor.value = desc;
if !self.imported_descriptor.value.is_empty() {
@ -643,6 +678,19 @@ impl Step for ImportRemoteWallet {
Task::none()
}
fn subscription(&self, _hws: &HardwareWallets) -> iced::Subscription<Message> {
if let Some(modal) = &self.modal {
if let Some(sub) = modal.subscription() {
return sub.map(|m| {
Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportExport(
ImportExportMessage::Progress(m),
))
});
}
}
Subscription::none()
}
fn apply(&mut self, ctx: &mut Context) -> bool {
// Set to true in order to force the registration process to be shown to user.
ctx.hw_is_used = true;
@ -662,7 +710,7 @@ impl Step for ImportRemoteWallet {
progress: (usize, usize),
email: Option<&'a str>,
) -> Element<Message> {
view::import_wallet_or_descriptor(
let content = view::import_wallet_or_descriptor(
progress,
email,
&self.invitation_token,
@ -675,7 +723,12 @@ impl Step for ImportRemoteWallet {
.iter()
.map(|w| (&w.name, w.metadata.wallet_alias.as_ref()))
.collect(),
)
);
if let Some(modal) = &self.modal {
modal.view(content)
} else {
content
}
}
}

View File

@ -120,12 +120,26 @@ impl Step for ImportDescriptor {
}
Message::ImportExport(ImportExportMessage::Progress(Progress::WalletFromBackup(r))) => {
let (descriptor, network, aliases, backup) = r;
if self.network == network {
self.imported_backup = Some(backup);
self.imported_descriptor.value = descriptor.to_string();
self.imported_aliases = Some(aliases);
if let Some(n) = network {
if self.network == n {
self.imported_backup = Some(backup);
self.imported_descriptor.value = descriptor.to_string();
self.imported_aliases = Some(aliases);
} else {
self.error =
Some("Backup network do not match the selected network!".into());
}
} else {
self.error = Some("Backup network do not match the selected network!".into());
// The backup have been inferred from a bare descriptor, we check whether
// the descriptor match any test network
if self.network != Network::Bitcoin {
self.imported_backup = Some(backup);
self.imported_descriptor.value = descriptor.to_string();
self.imported_aliases = Some(aliases);
} else {
self.error =
Some("Backup network do not match the selected network!".into());
}
}
}
Message::ImportExport(m) => {

View File

@ -233,6 +233,12 @@ pub fn import_wallet_or_descriptor<'a>(
.size(text::P1_SIZE)
.padding(10),
)
.push(text("or").bold())
.push(button::primary(None, "Import descriptor").on_press(
Message::ImportRemoteWallet(
message::ImportRemoteWallet::ImportDescriptorFromFile,
),
))
.spacing(10),
)
.push(
@ -268,7 +274,8 @@ pub fn import_wallet_or_descriptor<'a>(
.push_maybe(error.map(|e| card::error("Something wrong happened", e.to_string())))
.push(card_wallets)
.push(card::simple(col_invitation_token).padding(0))
.push(card::simple(col_descriptor).padding(0)),
.push(card::simple(col_descriptor).padding(0))
.push(Space::with_height(10)),
true,
Some(Message::Previous),
)

View File

@ -0,0 +1,69 @@
{
"name": "Liana-jz5sm0xn",
"alias": "My Liana Regtest wallet",
"accounts": [
{
"name": "Liana-jz5sm0xn",
"descriptor": "wsh(or_d(pk([8a550171/48'/1'/0'/2']tpubDFnCs5ZaCqopaNhgLCiXAwbkaBdcnuMt1VFoPsRpUrpidyvzG67MYjkfxw6HnTBhHqeU3xw2ioNBVcWY3jXwGhSyppEQvtn38GsL7RH1eef/<0;1>/*),and_v(v:pkh([8a550171/48'/1'/0'/2']tpubDFnCs5ZaCqopaNhgLCiXAwbkaBdcnuMt1VFoPsRpUrpidyvzG67MYjkfxw6HnTBhHqeU3xw2ioNBVcWY3jXwGhSyppEQvtn38GsL7RH1eef/<2;3>/*),older(52596))))#jz5sm0xn",
"receive_index": 1,
"change_index": 2,
"timestamp": 1748013169,
"keys": {
"8a550171": {
"key": "8a550171",
"alias": "a"
}
},
"labels": [],
"transactions": [
"020000000001042e410675d4d1b009e257ca11345502e5c1f00709590db95e2ac9fe1a11044d4d0000000000fdffffff2503d8712de399f674baab16b844a54870bd2142650c609edb3d1cd60bcbf77b0000000000fdffffff4e093e78a81157ebcb11780bfe010fc0752231015d0c2a8114630ac7c6c159c50000000000fdffffffb5e6c596c3659f7af56ebaa9e8ae618c5ac7f338033172e9475f1933f04e348a0000000000fdffffff02d263060000000000225120e49d1997805dbb7290369fb95f94451b0ca3e738119b9c6b6e5a70a33384913100e1f50500000000220020fd22758b64471a67371158c546ab5918732659085eb21c9af6591cb811e56c660247304402203c379630bf2b827abf013ef8ed6a37a18dfc98fef28199a208bc19bc68b441f40220248456a96302579d0739ce8dbc63550835961a811198859aaa250c8611e46466012103df49913c09de6d937101041d2e413a09e5991ba3ef31534d88054b319d2d74510247304402202a8e2ffcaa86fef2fc40caf21d0d59b4801ad553701b4466c2872e5ac3ffb23f02200f10f5493543f8c4fe37627a7ad6bfa93b01a22873983656003ababe9d6c4ca60121031337b42040d5f2700ba46ae7cf70dc172c2b4f695313a8ed0fe62ea843278e6c0247304402204ee9ae50ff1bbde05832236fdea9f3709a4d9fa87014cf86a63e9333641c737c02206394e7124539adf560f76ddbb87ec4614d9a9e70ac995c88d1b2b6e614ab4e430121034547cd6c5fe44edbc9af9193df5a9a7fc0055c3f47b968e92ae30d6630a6cd08024730440220135820533d687ed853e92f7e52ef73de755f13ace2db60f5f85a07681bd62e2a022015290d93eb8b0d53f17c2851f2fb53ba2859ee15aacb0a188041f46f249101100121029f4c9445023494227a966609268d0db59bf82eb21433188470bbdcdd63f5508caf5d0000"
],
"psbts": [
"cHNidP8BAF4CAAAAAcb4e2jUBqxxBTgMZFk6JUCes6Q3cvgpYJ2uJDUOBbudAQAAAAD9////AX3g9QUAAAAAIgAgCkktj1GAtl5qSGMFiltgrcw2ozjyaWF4XBktdHaUItuyXQAAAAEA/bICAgAAAAABBC5BBnXU0bAJ4lfKETRVAuXB8AcJWQ25XirJ/hoRBE1NAAAAAAD9////JQPYcS3jmfZ0uqsWuESlSHC9IUJlDGCe2z0c1gvL93sAAAAAAP3///9OCT54qBFX68sReAv+AQ/AdSIxAV0MKoEUYwrHxsFZxQAAAAAA/f///7XmxZbDZZ969W66qeiuYYxax/M4AzFy6UdfGTPwTjSKAAAAAAD9////AtJjBgAAAAAAIlEg5J0Zl4Bdu3KQNp+5X5RFGwyj5zgRm5xrblpwozOEkTEA4fUFAAAAACIAIP0idYtkRxpnNxFYxUarWRhzJlkIXrIcmvZZHLgR5WxmAkcwRAIgPDeWML8rgnq/AT747Wo3oY38mP7ygZmiCLwZvGi0QfQCICSEVqljAledBznOjbxjVQg1lhqBEZiFmqolDIYR5GRmASED30mRPAnebZNxAQQdLkE6CeWZG6PvMVNNiAVLMZ0tdFECRzBEAiAqji/8qob+8vxAyvIdDVm0gBrVU3AbRGbChy5aw/+yPwIgDxD1STVD+MT+N2J6eta/qTsBoihzmDZWADq6vp1sTKYBIQMTN7QgQNXycAukaufPcNwXLCtPaVMTqO0P5i6oQyeObAJHMEQCIE7prlD/G73gWDIjb96p83CaTZ+ocBTPhqY+kzNkHHN8AiBjlOcSRTmt9WD3bdu4fsRhTZqecKyZXIjRsrbmFKtOQwEhA0VHzWxf5E7bya+Rk99amn/ABVw/R7lo6SrjDWYwps0IAkcwRAIgE1ggUz1ofthT6S9+Uu9z3nVfE6zi22D1+FoHaBvWLioCIBUpDZPriw1T8XwoUfL7U7ooWe4VqssKGIBB9G8kkQEQASECn0yURQI0lCJ6lmYJJo0NtZv4LrIUMxiEcLvc3WP1UIyvXQAAAQErAOH1BQAAAAAiACD9InWLZEcaZzcRWMVGq1kYcyZZCF6yHJr2WRy4EeVsZiICA2vrvUCtFrmnsdsUyMDb2kT4uowEfiQ5D/1l+cWZ1vDWRzBEAiAaM9aLqBTG5C4tK9p9c28Jz439Emr7XPoxUYyJllYLMAIgFA6/yH55PAeZedfY2h6at7dB/k/wPf8M3+y0IEcQgKIBIgIDio+/H1qygPy0Phj9olT5VctStZGyB6qVDiVdlfiIWTpHMEQCIGUetlDH7qqci2rSxbHnvoqcs1pEJHgaLbRAnMPjffv1AiBHhT5DcOQhVhn0vtXO8Ij5ii3BafsK9VX2SN2l9H1/9QEBBUQhA2vrvUCtFrmnsdsUyMDb2kT4uowEfiQ5D/1l+cWZ1vDWrHNkdqkUXG6thIRwLnqHwJdmKl7XWxiKxDyIrQN0zQCyaCIGA2vrvUCtFrmnsdsUyMDb2kT4uowEfiQ5D/1l+cWZ1vDWHIpVAXEwAACAAQAAgAAAAIACAACAAAAAAAEAAAAiBgOKj78fWrKA/LQ+GP2iVPlVy1K1kbIHqpUOJV2V+IhZOhyKVQFxMAAAgAEAAIAAAACAAgAAgAIAAAABAAAAACICA1BIFqUUMoNtx4WVqTmwuL2iEpyL6ZLAflqiZpCHcVveHIpVAXEwAACAAQAAgAAAAIACAACAAwAAAAIAAAAiAgO+QA+pMN6tM8/CHiT8Rt32H2jEOY2nZpV2rV/QYKGJEhyKVQFxMAAAgAEAAIAAAACAAgAAgAEAAAACAAAAAA=="
],
"coins": {
"9dbb050e3524ae9d6029f87237a4b39e40253a59640c380571ac06d4687bf8c6:1": {
"amount": 100000000,
"outpoint": "9dbb050e3524ae9d6029f87237a4b39e40253a59640c380571ac06d4687bf8c6:1",
"address": "bcrt1ql538tzmygudxwdc3trz5d26erpejvkggt6epexhktywtsy09d3nqzqf3d4",
"block_height": 23984,
"account": 0,
"derivation_index": 1,
"is_coinbase": null,
"is_from_self": false
}
},
"chain_tip": {
"block_height": 24332,
"block_hash": null
},
"proprietary": {
"config": {
"debug": null,
"log_level": null,
"start_internal_bitcoind": false
},
"liana_version": "11.0",
"settings": {
"alias": "My Liana Regtest wallet",
"descriptor_checksum": "jz5sm0xn",
"hardware_wallets": [],
"keys": [
{
"master_fingerprint": "8a550171",
"name": "a",
"provider_key": null
}
],
"name": "Liana-jz5sm0xn",
"pinned_at": 1748013169,
"remote_backend_auth": null,
"start_internal_bitcoind": false
}
}
}
],
"network": "regtest",
"date": 1750677837,
"version": 0
}

View File

@ -0,0 +1 @@
wsh(or_d(pk([8a550171/48'/1'/0'/2']tpubDFnCs5ZaCqopaNhgLCiXAwbkaBdcnuMt1VFoPsRpUrpidyvzG67MYjkfxw6HnTBhHqeU3xw2ioNBVcWY3jXwGhSyppEQvtn38GsL7RH1eef/<0;1>/*),and_v(v:pkh([8a550171/48'/1'/0'/2']tpubDFnCs5ZaCqopaNhgLCiXAwbkaBdcnuMt1VFoPsRpUrpidyvzG67MYjkfxw6HnTBhHqeU3xw2ioNBVcWY3jXwGhSyppEQvtn38GsL7RH1eef/<2;3>/*),older(52596))))#jz5sm0xn