Merge #1644: Add Import/Export Xpub feature

0b2ebd3aaad945afdf8b4dea4931179626663b89 export: typo 'Fail to' => 'Failed to' (pythcoiner)
8dd16fb586b4edc87d602b0a1d6ad396fe5340b3 export: trim string read from files (pythcoiner)
541c63419b1a39d0159ca87492360037e2119fbb installer: integrate import xpub feature (pythcoiner)
98bacb9f4c88ad63c537b7819a437e4e17bdd0b5 export: add import xpub feature (pythcoiner)
d7e388707b3368e12b73b578524b8e0c667ddb92 installer: integrate export xpub feature (pythcoiner)
986a982d8e7148b51bf20e4913502bfa1ce1ffe6 export: add export xpub feature (pythcoiner)

Pull request description:

  - [x] Add export feature to `export`
  - [x] Installer: Export xpub to a file instead copying to clipboard

  ![image](https://github.com/user-attachments/assets/87218f66-08fb-4083-8f37-c5589dae763c)

  - [x] Add import feature to `export`
  - [x] Installer: Add import Xpub feature at descriptor creation step

  before importing key:

  ![image](https://github.com/user-attachments/assets/56d57860-057c-4327-9002-545adff1b1c0)

  after importing key:

  ![image](https://github.com/user-attachments/assets/94cffb17-01b7-4a56-8974-f76a1791428c)

  closes #1633

ACKs for top commit:
  edouardparis:
    ACK 0b2ebd3aaad945afdf8b4dea4931179626663b89

Tree-SHA512: 44540efd25525a6489b27b1a928bac1c94e06720c5ba82e1eeada09abdb967db3f0ec8f2e404274818d1720efc508e19c8d9a5002f1e047c0335c8b8844ef7e1
This commit is contained in:
edouardparis 2025-04-15 10:05:45 +02:00
commit 896407aba3
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
9 changed files with 191 additions and 31 deletions

View File

@ -43,6 +43,8 @@ impl ExportModal {
match self.import_export_type {
ImportExportType::Transactions => "Export Transactions",
ImportExportType::ExportPsbt(_) => "Export PSBT",
ImportExportType::ExportXpub(_) => "Export Xpub",
ImportExportType::ImportXpub(_) => "Import Xpub",
ImportExportType::ExportBackup(_) => "Export Backup",
ImportExportType::Descriptor(_) => "Export Descriptor",
ImportExportType::ExportProcessBackup(..) | ImportExportType::ExportLabels => {
@ -62,6 +64,7 @@ impl ExportModal {
format!("liana-txs-{date}.csv")
}
ImportExportType::ExportPsbt(_) => "psbt.psbt".into(),
ImportExportType::ExportXpub(_) | ImportExportType::ImportXpub(_) => "liana.pub".into(),
ImportExportType::Descriptor(descriptor) => {
let checksum = descriptor
.to_string()
@ -127,6 +130,14 @@ impl ExportModal {
}
// TODO: forward PSBT
}
Progress::Xpub(xpub_str) => {
if matches!(self.import_export_type, ImportExportType::ExportXpub(_)) {
self.state = ImportExportState::Ended;
}
return Task::perform(async {}, move |_| {
ImportExportMessage::Xpub(xpub_str.clone()).into()
});
}
Progress::Descriptor(_) => {
if self.import_export_type == ImportExportType::ImportDescriptor {
self.state = ImportExportState::Ended;
@ -214,6 +225,7 @@ impl ExportModal {
}
}
ImportExportMessage::UpdateAliases(_) => { /* unexpected */ }
ImportExportMessage::Xpub(_) => { /* unexpected */ }
}
Task::none()
}

View File

@ -50,7 +50,7 @@ impl From<&Error> for WarningMessage {
Error::Desc(e) => WarningMessage(format!("Descriptor analysis error: '{}'.", e)),
Error::Spend(e) => WarningMessage(format!("Spend creation error: '{}'.", e)),
Error::ImportExport(e) => WarningMessage(format!("{e}")),
Error::RestoreBackup(e) => WarningMessage(format!("Fail to restore backup: {e}")),
Error::RestoreBackup(e) => WarningMessage(format!("Failed to restore backup: {e}")),
}
}
}

View File

@ -15,7 +15,10 @@ use async_hwi::bitbox::api::btc::Fingerprint;
use chrono::{DateTime, Duration, Utc};
use liana::{
descriptors::LianaDescriptor,
miniscript::bitcoin::{Amount, Network, Psbt, Txid},
miniscript::{
bitcoin::{Amount, Network, Psbt, Txid},
DescriptorPublicKey,
},
};
use lianad::{
bip329::{error::ExportError, Labels},
@ -80,6 +83,7 @@ pub enum ImportExportMessage {
Overwrite,
Ignore,
UpdateAliases(HashMap<Fingerprint, settings::KeySetting>),
Xpub(String),
}
impl From<ImportExportMessage> for view::Message {
@ -117,6 +121,8 @@ pub enum Error {
Bip329Export(String),
BackupImport(String),
Backup(backup::Error),
ParseXpub,
XpubNetwork,
}
impl Display for Error {
@ -136,6 +142,8 @@ impl Display for Error {
Error::Bip329Export(e) => write!(f, "Bip329Export: {e}"),
Error::BackupImport(e) => write!(f, "BackupImport: {e}"),
Error::Backup(e) => write!(f, "Backup: {e}"),
Error::ParseXpub => write!(f, "Failed to parse Xpub from file"),
Error::XpubNetwork => write!(f, "Xpub is for another network"),
}
}
}
@ -144,6 +152,7 @@ impl Display for Error {
pub enum ImportExportType {
Transactions,
ExportPsbt(String),
ExportXpub(String),
ExportBackup(String),
ExportProcessBackup(PathBuf, Network, Arc<Config>, Arc<Wallet>),
ImportBackup(
@ -154,6 +163,7 @@ pub enum ImportExportType {
Descriptor(LianaDescriptor),
ExportLabels,
ImportPsbt,
ImportXpub(Network),
ImportDescriptor,
}
@ -165,9 +175,11 @@ impl ImportExportType {
| ImportExportType::ExportBackup(_)
| ImportExportType::Descriptor(_)
| ImportExportType::ExportProcessBackup(..)
| ImportExportType::ExportXpub(_)
| ImportExportType::ExportLabels => "Export successful!",
ImportExportType::ImportBackup(_, _)
| ImportExportType::ImportPsbt
| ImportExportType::ImportXpub(_)
| ImportExportType::WalletFromBackup
| ImportExportType::ImportDescriptor => "Import successful",
}
@ -229,6 +241,7 @@ pub enum Progress {
None,
Psbt(Psbt),
Descriptor(LianaDescriptor),
Xpub(String),
LabelsConflict(Sender<bool>),
KeyAliasesConflict(Sender<bool>),
UpdateAliases(HashMap<Fingerprint, settings::KeySetting>),
@ -282,8 +295,10 @@ impl Export {
}
ImportExportType::ExportLabels => export_labels(&sender, daemon, path).await,
ImportExportType::ImportPsbt => import_psbt(&sender, path).await,
ImportExportType::ImportXpub(network) => import_xpub(&sender, path, network).await,
ImportExportType::ImportDescriptor => import_descriptor(&sender, path).await,
ImportExportType::ExportBackup(str) => export_string(&sender, path, str).await,
ImportExportType::ExportXpub(xpub_str) => export_string(&sender, path, xpub_str).await,
ImportExportType::ExportProcessBackup(datadir, network, config, wallet) => {
app_backup_export(
datadir,
@ -556,10 +571,10 @@ pub async fn export_descriptor(
pub async fn export_string(
sender: &UnboundedSender<Progress>,
path: PathBuf,
psbt: String,
str: String,
) -> Result<(), Error> {
let mut file = open_file_write(&path).await?;
file.write_all(psbt.as_bytes())?;
file.write_all(str.as_bytes())?;
send_progress!(sender, Progress(100.0));
send_progress!(sender, Ended);
Ok(())
@ -570,6 +585,7 @@ pub async fn import_psbt(sender: &UnboundedSender<Progress>, path: PathBuf) -> R
let mut psbt_str = String::new();
file.read_to_string(&mut psbt_str)?;
psbt_str = psbt_str.trim().to_string();
let psbt = Psbt::from_str(&psbt_str).map_err(|_| Error::ParsePsbt)?;
@ -586,13 +602,45 @@ pub async fn import_descriptor(
let mut descr_str = String::new();
file.read_to_string(&mut descr_str)?;
let descriptor = LianaDescriptor::from_str(&descr_str).map_err(|_| Error::ParseDescriptor)?;
let descr_str = descr_str.trim();
let descriptor = LianaDescriptor::from_str(descr_str).map_err(|_| Error::ParseDescriptor)?;
send_progress!(sender, Progress(100.0));
send_progress!(sender, Descriptor(descriptor));
Ok(())
}
pub async fn import_xpub(
sender: &UnboundedSender<Progress>,
path: PathBuf,
network: Network,
) -> Result<(), Error> {
let mut file = File::open(path)?;
let mut xpub_str = String::new();
file.read_to_string(&mut xpub_str)?;
let xpub_str = xpub_str.trim().to_string();
if let Ok(DescriptorPublicKey::XPub(key)) = DescriptorPublicKey::from_str(&xpub_str) {
let valid = if network == Network::Bitcoin {
key.xkey.network == Network::Bitcoin.into()
} else {
key.xkey.network == Network::Testnet.into()
};
if valid {
send_progress!(sender, Progress(100.0));
send_progress!(sender, Xpub(xpub_str));
} else {
return Err(Error::XpubNetwork);
}
} else {
return Err(Error::ParseXpub);
}
Ok(())
}
/// Import a backup in an already existing wallet:
/// - Load backup from file
/// - check if networks matches
@ -623,6 +671,7 @@ pub async fn import_backup(
let mut backup_str = String::new();
file.read_to_string(&mut backup_str)?;
backup_str = backup_str.trim().to_string();
let backup: Result<Backup, _> = serde_json::from_str(&backup_str);
let backup = match backup {
@ -687,7 +736,7 @@ pub async fn import_backup(
let db_labels = match daemon.get_labels_bip329(0, u32::MAX).await {
Ok(l) => l,
Err(_) => {
return Err(Error::BackupImport("Fail to dump DB labels".into()));
return Err(Error::BackupImport("Failed to dump DB labels".into()));
}
};
@ -710,7 +759,7 @@ pub async fn import_backup(
write_labels = match ack_receiver.recv().await {
Some(b) => b,
None => {
return Err(Error::BackupImport("Fail to receive labels ACK".into()));
return Err(Error::BackupImport("Failed to receive labels ACK".into()));
}
}
}
@ -724,11 +773,11 @@ pub async fn import_backup(
Some(c) => match &c.data_dir {
Some(dd) => dd,
None => {
return Err(Error::BackupImport("Fail to get Daemon config".into()));
return Err(Error::BackupImport("Failed to get Daemon config".into()));
}
},
None => {
return Err(Error::BackupImport("Fail to get Daemon config".into()));
return Err(Error::BackupImport("Failed to get Daemon config".into()));
}
};
@ -738,7 +787,7 @@ pub async fn import_backup(
let settings = match Settings::from_file(datadir.to_path_buf(), network) {
Ok(s) => s,
Err(_) => {
return Err(Error::BackupImport("Fail to get App Settings".into()));
return Err(Error::BackupImport("Failed to get App Settings".into()));
}
};
@ -776,7 +825,7 @@ pub async fn import_backup(
write_aliases = match ack_receiver.recv().await {
Some(a) => a,
None => {
return Err(Error::BackupImport("Fail to receive aliases ACK".into()));
return Err(Error::BackupImport("Failed to receive aliases ACK".into()));
}
};
}
@ -797,7 +846,7 @@ pub async fn import_backup(
if daemon.update_deriv_indexes(receive, change).await.is_err() {
return Err(Error::BackupImport(
"Fail to update derivation indexes".into(),
"Failed to update derivation indexes".into(),
));
}
@ -809,7 +858,7 @@ pub async fn import_backup(
psbts.push(p);
}
Err(_) => {
return Err(Error::BackupImport("Fail to parse PSBT".into()));
return Err(Error::BackupImport("Failed to parse PSBT".into()));
}
}
}
@ -817,7 +866,7 @@ pub async fn import_backup(
// import PSBTs
for psbt in psbts {
if daemon.update_spend_tx(&psbt).await.is_err() {
return Err(Error::BackupImport("Fail to store PSBT".into()));
return Err(Error::BackupImport("Failed to store PSBT".into()));
}
}
@ -834,7 +883,7 @@ pub async fn import_backup(
})
.collect();
if daemon.update_labels(&labels).await.is_err() {
return Err(Error::BackupImport("Fail to import labels".into()));
return Err(Error::BackupImport("Failed to import labels".into()));
}
}
@ -855,7 +904,7 @@ pub async fn import_backup(
settings.wallets.get_mut(0).expect("already checked").keys =
settings_aliases.clone().into_values().collect();
if settings.to_file(datadir.to_path_buf(), network).is_err() {
return Err(Error::BackupImport("Fail to import keys aliases".into()));
return Err(Error::BackupImport("Failed to import keys aliases".into()));
} else {
// Update wallet state
send_progress!(sender, UpdateAliases(settings_aliases));
@ -927,6 +976,7 @@ pub async fn wallet_from_backup(
let mut backup_str = String::new();
file.read_to_string(&mut backup_str)?;
backup_str = backup_str.trim().to_string();
let backup: Result<Backup, _> = serde_json::from_str(&backup_str);
let backup = match backup {
@ -1087,7 +1137,7 @@ pub async fn import_backup_at_launch(
// import PSBTs
for psbt in psbts {
if let Err(e) = daemon.update_spend_tx(&psbt).await {
tracing::error!("Fail to restore PSBT: {e}")
tracing::error!("Failed to restore PSBT: {e}")
}
}

View File

@ -56,6 +56,7 @@ pub enum Message {
AllKeysRedeemed,
BackupWallet,
ExportWallet(Result<String, backup::Error>),
ExportXpub(String),
ImportExport(ImportExportMessage),
ImportBackup,
WalletFromBackup((HashMap<Fingerprint, settings::KeySetting>, Backup)),
@ -179,6 +180,7 @@ pub enum ImportKeyModal {
TokenEdited(String),
ConfirmToken,
SelectKey(usize),
ImportXpub(Network),
}
#[derive(Debug, Clone)]

View File

@ -14,6 +14,8 @@ use liana::miniscript::{
use liana_ui::{component::form, widget::Element};
use crate::app::state::export::ExportModal;
use crate::export::{ImportExportMessage, ImportExportType};
use crate::{
app::settings::ProviderKey,
hw::{HardwareWallet, HardwareWallets},
@ -86,6 +88,7 @@ pub struct EditXpubModal {
hot_signer: Arc<Mutex<Signer>>,
hot_signer_fingerprint: Fingerprint,
chosen_signer: Option<Key>,
modal: Option<ExportModal>,
}
impl EditXpubModal {
@ -135,6 +138,7 @@ impl EditXpubModal {
hot_signer_fingerprint,
hot_signer,
duplicate_master_fg: false,
modal: None,
}
}
@ -243,6 +247,28 @@ impl super::DescriptorEditModal for EditXpubModal {
.unwrap_or_default();
self.form_name.valid = true;
}
Message::ImportExport(import_msg) => match import_msg {
ImportExportMessage::Close => {
if self.modal.is_some() {
self.modal = None;
}
}
ImportExportMessage::Xpub(xpub_str) => {
if self.modal.is_some() {
self.modal = None;
return Task::perform(async move { xpub_str }, |xpub_str| {
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(
message::ImportKeyModal::XPubEdited(xpub_str),
))
});
}
}
m => {
if let Some(modal) = self.modal.as_mut() {
return modal.update(m);
}
}
},
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(msg)) => match msg {
message::ImportKeyModal::FetchedKey(res) => {
self.processing = false;
@ -323,6 +349,14 @@ impl super::DescriptorEditModal for EditXpubModal {
self.form_token.valid = s.is_empty() || self.form_token_warning.is_none();
self.form_token.value = s;
}
message::ImportKeyModal::ImportXpub(network) => {
if self.modal.is_none() {
let modal = ExportModal::new(None, ImportExportType::ImportXpub(network));
let launch = modal.launch(false);
self.modal = Some(modal);
return launch;
}
}
message::ImportKeyModal::XPubEdited(s) => {
self.chosen_signer = None;
if let Ok(DescriptorPublicKey::XPub(key)) = DescriptorPublicKey::from_str(&s) {
@ -416,7 +450,14 @@ impl super::DescriptorEditModal for EditXpubModal {
}
fn subscription(&self, hws: &HardwareWallets) -> Subscription<Message> {
hws.refresh().map(Message::HardwareWallets)
let hw = hws.refresh().map(Message::HardwareWallets);
if let Some(modal) = self.modal.as_ref() {
if let Some(sub) = modal.subscription() {
let import = sub.map(|m| Message::ImportExport(ImportExportMessage::Progress(m)));
return Subscription::batch(vec![hw, import]);
}
}
hw
}
fn view<'a>(&'a self, hws: &'a HardwareWallets) -> Element<'a, Message> {
@ -436,7 +477,7 @@ impl super::DescriptorEditModal for EditXpubModal {
})
.collect();
let chosen_signer = self.chosen_signer.as_ref().map(|s| s.fingerprint);
view::editor::edit_key_modal(
let content = view::editor::edit_key_modal(
"Set your key",
self.network,
self.path_kind,
@ -512,7 +553,12 @@ impl super::DescriptorEditModal for EditXpubModal {
self.form_token_warning.as_ref(),
self.form_key_source_kind.as_ref(),
self.duplicate_master_fg,
)
);
if let Some(modal) = &self.modal {
modal.view(content)
} else {
content
}
}
}

View File

@ -9,6 +9,8 @@ use liana::miniscript::bitcoin::{
use liana_ui::widget::Element;
use crate::{
app::state::export::ExportModal,
export::{ImportExportMessage, ImportExportType},
hw::{HardwareWallet, HardwareWallets},
installer::{
message::Message,
@ -70,6 +72,7 @@ pub struct ShareXpubs {
network: Network,
hw_xpubs: Vec<HardwareWalletXpubs>,
xpubs_signer: SignerXpubs,
modal: Option<ExportModal>,
}
impl ShareXpubs {
@ -78,6 +81,7 @@ impl ShareXpubs {
network,
hw_xpubs: Vec::new(),
xpubs_signer: SignerXpubs::new(signer),
modal: None,
}
}
}
@ -102,6 +106,24 @@ impl Step for ShareXpubs {
}
}
}
Message::ExportXpub(xpub_str) => {
if self.modal.is_none() {
let modal = ExportModal::new(None, ImportExportType::ExportXpub(xpub_str));
let launch = modal.launch(true);
self.modal = Some(modal);
return launch;
}
}
Message::ImportExport(ImportExportMessage::Close) => {
if self.modal.is_some() {
self.modal = None;
}
}
Message::ImportExport(msg) => {
if let Some(modal) = self.modal.as_mut() {
return modal.update(msg);
}
}
Message::UseHotSigner => {
self.xpubs_signer.select(self.network);
}
@ -150,7 +172,14 @@ impl Step for ShareXpubs {
}
fn subscription(&self, hws: &HardwareWallets) -> Subscription<Message> {
hws.refresh().map(Message::HardwareWallets)
let hw = hws.refresh().map(Message::HardwareWallets);
if let Some(modal) = self.modal.as_ref() {
if let Some(sub) = modal.subscription() {
let export = sub.map(|m| Message::ImportExport(ImportExportMessage::Progress(m)));
return Subscription::batch(vec![hw, export]);
}
}
hw
}
fn apply(&mut self, ctx: &mut Context) -> bool {
@ -166,7 +195,7 @@ impl Step for ShareXpubs {
_progress: (usize, usize),
email: Option<&'a str>,
) -> Element<Message> {
view::share_xpubs(
let content = view::share_xpubs(
email,
hws.list
.iter()
@ -190,7 +219,13 @@ impl Step for ShareXpubs {
})
.collect(),
self.xpubs_signer.view(),
)
);
if let Some(modal) = &self.modal {
modal.view(content)
} else {
content
}
}
}

View File

@ -352,6 +352,7 @@ pub fn edit_key_modal<'a>(
form_key_source_kind: Option<&KeySourceKind>,
duplicate_master_fg: bool,
) -> Element<'a, Message> {
let xpub_valid = form_xpub.valid && !form_xpub.value.is_empty();
let content = Column::new()
.padding(25)
.push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string())))
@ -392,8 +393,22 @@ pub fn edit_key_modal<'a>(
.push(
Row::new()
.align_y(Alignment::Center)
.push(p1_regular("Enter an extended public key:").width(Length::Fill))
.push(image::success_mark_icon().width(Length::Fixed(50.0)))
.push(p1_regular("Enter/import an extended public key:").width(Length::Fill))
.push_maybe(if !xpub_valid{
Some(
button::primary(Some(icon::restore_icon()), "Import")
.on_press(
Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::ImportXpub(network),),)
))
} else { None }
)
.push_maybe(
if xpub_valid {
Some(image::success_mark_icon().width(Length::Fixed(50.0)))
} else {None}
)
)
.push(
Row::new()
@ -422,7 +437,7 @@ pub fn edit_key_modal<'a>(
.align_y(Alignment::Center)
.spacing(10)
.push(icon::import_icon())
.push(p1_regular("Enter an extended public key"))
.push(p1_regular("Enter/import an extended public key"))
)
.padding(20)
.width(Length::Fill)

View File

@ -446,8 +446,8 @@ pub fn signer_xpubs<'a>(
)
.push(
Container::new(
button::secondary(Some(icon::clipboard_icon()), "Copy")
.on_press(Message::Clibpboard(xpub.clone()))
button::primary(Some(icon::backup_icon()), "Export")
.on_press(Message::ExportXpub(xpub.clone()))
.width(Length::Shrink),
)
.padding(10),
@ -539,8 +539,8 @@ pub fn hardware_wallet_xpubs<'a>(
)
.push(
Container::new(
button::secondary(Some(icon::clipboard_icon()), "Copy")
.on_press(Message::Clibpboard(xpub.clone()))
button::primary(Some(icon::backup_icon()), "Export")
.on_press(Message::ExportXpub(xpub.clone()))
.width(Length::Shrink),
)
.padding(10),

View File

@ -405,7 +405,7 @@ impl GUI {
command.map(|msg| Message::Run(Box::new(msg)))
}
loader::Message::App(Err(e), _) => {
tracing::error!("Fail to import backup: {e}");
tracing::error!("Failed to import backup: {e}");
Task::none()
}