Merge #1576: Wallet backup
f8ca6f123fd56ac71a2a3bffe9568c0ecb5a9b4d backup: add ser/de test & minors changes (pythcoiner)
477e914801f3d9c0b06be56617a5dc2f3273d136 export: remove NotImplemented from RestoreBackupError (pythcoiner)
df8d98b275bbf0aa96729f46afc53a24728ed60e gui: use Display instead Debug to print ExportModal errors (pythcoiner)
1abe503431892c45b9459849f4ac94b814c67e71 backup: update local state when alias updated from a backup restore (pythcoiner)
2e6cceea96783b85c6e7654cfc7600d63c86133c backup: rename Key::metadata in Key::proprietary (pythcoiner)
4e17483340971b1f3ff4674385843aa1bc068000 lianad: reverse the byte order of txid before parsing bip329 label transactions (pythcoiner)
cac7bce60aedc5097bbdf9ffb1184688f4bcb48a installer: minor UI fixes (pythcoiner)
2291138fb64d5be7d66e24e72cbfb11af6a55915 lianad: docs & tests for getlabelsbip329 command (pythcoiner)
9417549ddb5e6ffb51e668228e6faf8d18937db5 lianad: docs & tests for updatederivationindexes command (pythcoiner)
e1b90b056fe6a339acd4f9726cc10813d96bc741 lianad: in DaemonControl::update_deriv_indexes() limit the gap the index can be incremented and return db indexes (pythcoiner)
107e81071c7e60157fb3d00f90e3e3258c213aff lianad; update docs & tests for getinfo command (pythcoiner)
091557a8dfd03788cfaa202ad87c2dd15e1e347f installer: add existing wallet from backup (pythcoiner)
aa53c3e44e375656b0b9d3076167c82f2204273e import: implement wallet_from_backup() (pythcoiner)
03476de35abd779de95595f538b081d2c945285f gui: call import_backup_at_launch (pythcoiner)
33e39316c7c265ff64c55133fbf6e7ac908e660a import: implement import_backup_at_launch() (pythcoiner)
e19644ae8cf0acf7bd21eec2a609f76cb6810293 gui: display export error (pythcoiner)
29b1673460fd2160e2e768c30f5e7f151f065b92 fix user ACK + call import in settings/wallet (pythcoiner)
2fe297879b022d5da9e562816774d70075e40536 gui: integrate restore backup to settings (pythcoiner)
d0d7007e8398035e43d6d32eff149f167301ae32 import: implement import_backup() (pythcoiner)
b6f900355c6803490a79c0c058252ca5a45afb0c lianad: implement LabelItem.from_bip329() (pythcoiner)
02e28bbd3e72ae5aacde38552ac5dc892639a20e gui: implement Daemon.update_wallet_metadata() for lianad (pythcoiner)
6988fa40c92d2b2c518456284d685810c1ffb575 gui: implement Daemon.update_deriv_indexes() (pythcoiner)
39f71d3de8570e776096f2ce904cc1d783d96703 lianad: add update_deriv_indexes() command (pythcoiner)
4c2adec67c09befc604e7dd70d5a19e5fae6adf4 backup: serialize PSBT w/ Psbt::to_string() instead Psbt::serialize_hex() (pythcoiner)
31f834f41dd355116dd039b05541f24d798b1fe3 backup: default deserialization (pythcoiner)
4ba6486645766b65d12be063b8e1ab644b7c45bb gui: add import/export features in settings (pythcoiner)
325e2dc9b91c4553384c4751cdcfe9bee57f5a33 backup: add chain tip (pythcoiner)
c4585e8efe8f0ff27b92b8e930582650b6460e9f backup: add backup & Liana versions (pythcoiner)
61793b35ce4b329690970fc26a6a6f629513f66e backup: backup coins (pythcoiner)
68bb742755b3a277aa91b15f540ab6d8012e1384 installer: add wallet export feature at descriptor backup step (pythcoiner)
b0e560c0b523fba9994697c1f410ce0245c8ce09 export: make ExportModal::view() generic (pythcoiner)
5dcecc5542f3feeb989d40de2c8cc3063f1409d5 gui: make ExportModal.daemon optionnal (pythcoiner)
530b8c12bcd79075e39c7ff7fa289c4234d9fa15 gui: clippyfy (pythcoiner)
ab3c8007bac711c3e964fb21f192c03c5753b276 gui: implement ExportModal.modal_title() (pythcoiner)
f41de56e38164d9a9bad78a0abffaebdd92e6768 gui: add import/export features in settings (pythcoiner)
f1b62074d309059c4a8b5cc1d954c25bb7cff0d7 gui: implement backup (pythcoiner)
f7ed341d284d0563a93314be439a8facff971767 gui: Daemon trait => add receive and change indexes to GetInfoResult (pythcoiner)
86313f528221ddb7d766b3f59d4f255b4f49eedf export: implement export labels using BIP-0329 (pythcoiner)
38eed1b8810976ef903063d7f74b39dd06affcfc lianad: add feature to dump labels in BIP-0329 format (pythcoiner)
158651ebe7fbbf07fa8f8fbd74aa927ea53034ce import: implement import from file for psbt & descriptor (pythcoiner)
2b9324993a92ecb082d59c6aa013e6136b025aba export: implement export for psbt (pythcoiner)
baf4e75efe3b1150a4a8277f3ed8b1f265f75cdd export: implement export for descriptor (pythcoiner)
a3bf250696c7305e2af2667792ba6c1d02e8a111 gui: make 'ExportModal' generic (pythcoiner)
f13cd1fe73f2fc1afda0a4f35cac7f4840e5c7c8 export: rename 'State' into 'Export' and separate export logic (pythcoiner)
Pull request description:
This PR:
- [x] Implement export of PSBTs
- [x] Implement export of Descriptor
- [x] Implement export of Labels in BIP-0329 format
- [x] Implement export of PSBTs
- [x] Implement export of Descriptor
- [x] Implement import of Labels in BIP-0329 format (restore for liana-connect not addressed here)
- [x] add `receive_index` and `change_index` to `Daemon.get_info()` result+
- [x] add test & update docs for `get_info()`
- [x] add `get_labels_bip329()` to `Daemon` trait & RPC commands
- [x] add tests & docs for `get_labels_bip329()`
- [x] add `update_deriv_indexes()` to `Daemon` trait & RPC commands
- [x] add tests & docs for `update_deriv_indexes()`
- [x] implement wallet backup/export feature
- [x] implement wallet backup/import feature (restore for liana-connect not addressed here)
- [x] Integrate the exports & backup feature to the settings panel (GUI)
- [x] Integrate the imports & restore feature to the settings panel (GUI) (restore for liana-connect not addressed here)
- [x] Integrate the backup feature at wallet descriptor backup step (Installer)
- [x] Integrate the "Add existing wallet" from a previous backup (Installer) (restore for liana-connect not addressed here)
- [x] Handle and display gracefully errors in the GUI/Installer
- [x] Update `Wallet` after restoring a backup from Settings
- [x] write serializing/deserializing tests for backup
- [x] backing up coins
- [x] backing up chain tip (~`block_hash`~ + `block_height`)
- [x] rename label export name to `.jsonl`
- [x] add backup version + liana version
This PR depends on [this PR](https://github.com/wizardsardine/liana-backend/pull/288) on our backend
comments:
- [x] The title should be "Backup your wallet" without "descriptor";
- [x] The "Backup" button should be the primary action
- [x] We could also consider calling it "Backup Wallet";
- [x] The checkbox should become "I have backed up my wallet" or alternatively "I have backed up my wallet/descriptor".
- [x] Aliases and transaction labels seem to not be imported correctly in the GUI when doing a Backup and Restore of the wallet.
- [x] Another thing I wanted to mention, which I think is required, is that we have the same icon for all the new actions (backup wallet, restore wallet and all the options in the new "Import/export" section of the settings). We should differentiate by function.
- [x] I get "Labels conflict" in the text also when I change the aliases and not the labels: we should keep it generic with something like "One or more elements conflict. (...)"
- [x] I would reduce the space between the title and the text in the modal.
- [x] For the always visible explanation: "A backup of your wallet configuration is necessary to recover your funds. Please make sure to store your Wallet backup file (or alternatively to copy and paste the descriptor string) in one or more secure and accessible locations. You still need to back up your seed phrases too, since they are not included in the file.”
- [x] In the "Learn more" part, where it says "(...) this is your descriptor", I would just add "(...) this is your descriptor, included in your wallet backup file.".
- [x] As we were discussing, we should change "backup" to "back up" when it is a verb (sorry for the confusion).
- [x] The title in "Back up your wallet" (and "Back up your mnemonic" too);
- [x] The button "Back Up Wallet" in the back up step and in the settings (both in "Wallet" and "Import/Export").
- [x] increase modal height of 30 pixels in order to display well 2 liner messages
Should be done as follow-up:
- add a `retailler` field in proprietary
- add `block_hash` to get_info()
- replace `sync_channel()` by `tokio::mpsc::channel()`
- handle if index increment gap > 1000
- in `SqliteConn::set_derivation_indexes()` addresses should be inserted in batch
- restore wallet backup for liana-connect
- finalyze labels import
- finalyze PSBT export
- finalyze PSBT import
- finalyze descriptor(only) import?
ACKs for top commit:
jp1ac4:
ACK f8ca6f123f.
Tree-SHA512: b9a2f21e95f77d9902abe1fcdd364dabd1ba562789023a81a7dd6d6d0a7ca86975cca02dfad8de1bfc09d711ccb1af0c8347376a543ce170c4f10e03b249cda9
This commit is contained in:
commit
bd54fb29bf
1290
Cargo.lock
generated
1290
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
55
doc/API.md
55
doc/API.md
@ -9,8 +9,9 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`.
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| [`stop`](#stop) | Stops liana daemon |
|
||||
| [`getinfo`](#getinfo) | Get general information about the daemon |
|
||||
| [`updatederivationindexes`](#updatederivationindexes) | Update last generated addresses derivation indexes |
|
||||
| [`getnewaddress`](#getnewaddress) | Get a new receiving address |
|
||||
| [`listaddresses`](#listaddresses) | List addresses given start_index and count |
|
||||
| [`listaddresses`](#listaddresses) | List addresses given start_index and count |
|
||||
| [`listcoins`](#listcoins) | List all wallet transaction outputs. |
|
||||
| [`createspend`](#createspend) | Create a new Spend transaction |
|
||||
| [`updatespend`](#updatespend) | Store a created Spend transaction |
|
||||
@ -24,6 +25,7 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`.
|
||||
| [`createrecovery`](#createrecovery) | Create a recovery transaction to sweep expired coins |
|
||||
| [`updatelabels`](#updatelabels) | Update the labels |
|
||||
| [`getlabels`](#getlabels) | Get the labels for the given addresses, txids and outpoints |
|
||||
| [`getlabelsbip329`](#getlabelsbip329) | Get the labels in BIP-0329 format |
|
||||
|
||||
# Reference
|
||||
|
||||
@ -63,6 +65,38 @@ This command does not take any parameter for now.
|
||||
| `rescan_progress` | float or null | Progress of an ongoing rescan as a percentage (between 0 and 1) if there is any |
|
||||
| `timestamp` | integer | Unix timestamp of wallet creation date |
|
||||
| `last_poll_timestamp`| integer or null | Unix timestamp of last poll (if any) of the blockchain |
|
||||
| `receive_index` | integer | Last index used to generate a receive address |
|
||||
| `change_index` | integer | Last index used to generate a change address |
|
||||
|
||||
|
||||
### `updatederivationindexes`
|
||||
|
||||
Updates the last generated address derivation indexes in the wallet database.
|
||||
At least one of the `receive` or `change` arguments is required.
|
||||
|
||||
Derivation indexes **must be unhardened**. If a provided index is lower than
|
||||
the one currently stored in the database, it will be ignored.
|
||||
|
||||
**Note:** Each time a derivation index in the database is incremented, the
|
||||
corresponding new addresses must be inserted into the database. To prevent
|
||||
excessive increments, there is a limit: the derivation index can only be
|
||||
incremented by a maximum of **1000** from its current value.
|
||||
|
||||
The updated indexes will be returned in the response.
|
||||
|
||||
#### Request
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|-------------------|----------------------------------------------------------|
|
||||
| `receive` | integer(optional) | The latest receive address derivation index to update |
|
||||
| `change` | integer(optional) | The latest change address derivation index to update |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|---------|----------------------------------------------------------|
|
||||
| `receive` | integer | The updated receive address derivation index |
|
||||
| `change` | integer | The updated change address derivation index |
|
||||
|
||||
### `getnewaddress`
|
||||
|
||||
@ -427,3 +461,22 @@ Items without labels are not present in the response map.
|
||||
| Field | Type | Description |
|
||||
| -------- | ------ | -------------------------------------------------------------------------------- |
|
||||
| `labels` | object | A mapping of bitcoin addresses, txids and outpoints as keys, and string as values |
|
||||
|
||||
### `getlabelsbip329`
|
||||
|
||||
Retrieve a list of labels in [BIP-0329](https://github.com/bitcoin/bips/blob/master/bip-0329.mediawiki)
|
||||
format, with pagination support.
|
||||
|
||||
#### Request
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------- | ------- | ------------------------------------------ |
|
||||
| `offset` | integer | Index to start returning labels from |
|
||||
| `limit` | integer | Maximum number of labels to return |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------- | ------ | ------------------------------------------------- |
|
||||
| `labels` | array | A list of BIP-0329-formatted label objects |
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ use lianad::config::ConfigError;
|
||||
use crate::{
|
||||
app::{settings::SettingsError, wallet::WalletError},
|
||||
daemon::DaemonError,
|
||||
export::{self, RestoreBackupError},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -18,6 +19,8 @@ pub enum Error {
|
||||
HardwareWallet(async_hwi::Error),
|
||||
Desc(LianaDescError),
|
||||
Spend(SpendCreationError),
|
||||
ImportExport(export::Error),
|
||||
RestoreBackup(RestoreBackupError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
@ -53,10 +56,13 @@ impl std::fmt::Display for Error {
|
||||
write!(f, "[{:?}] {}", code, e)
|
||||
}
|
||||
DaemonError::CoinSelectionError => write!(f, "{}", e),
|
||||
DaemonError::NotImplemented => write!(f, "{}", e),
|
||||
},
|
||||
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
|
||||
Self::HardwareWallet(e) => write!(f, "error: {}\nPlease check if the device is still connected and unlocked with the correct firmware open for the current network and no other application is accessing the device.", e),
|
||||
Self::Desc(e) => write!(f, "Liana descriptor error: {}", e),
|
||||
Self::ImportExport(e) => write!(f, "{e}"),
|
||||
Self::RestoreBackup(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ use lianad::config::Config as DaemonConfig;
|
||||
use crate::{
|
||||
app::{cache::Cache, error::Error, view, wallet::Wallet},
|
||||
daemon::model::*,
|
||||
export::ExportMessage,
|
||||
export::ImportExportMessage,
|
||||
hw::HardwareWalletMessage,
|
||||
};
|
||||
|
||||
@ -47,5 +47,11 @@ pub enum Message {
|
||||
LabelsUpdated(Result<HashMap<String, Option<String>>, Error>),
|
||||
BroadcastModal(Result<HashSet<Txid>, Error>),
|
||||
RbfModal(Box<HistoryTransaction>, bool, Result<HashSet<Txid>, Error>),
|
||||
Export(ExportMessage),
|
||||
Export(ImportExportMessage),
|
||||
}
|
||||
|
||||
impl From<ImportExportMessage> for Message {
|
||||
fn from(value: ImportExportMessage) -> Self {
|
||||
Message::View(view::Message::ImportExport(value))
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,7 @@ impl Panels {
|
||||
data_dir: PathBuf,
|
||||
daemon_backend: DaemonBackend,
|
||||
internal_bitcoind: Option<&Bitcoind>,
|
||||
config: Arc<Config>,
|
||||
) -> Panels {
|
||||
Self {
|
||||
current: Menu::Home,
|
||||
@ -93,6 +94,7 @@ impl Panels {
|
||||
wallet.clone(),
|
||||
daemon_backend,
|
||||
internal_bitcoind.is_some(),
|
||||
config.clone(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@ -132,7 +134,7 @@ impl Panels {
|
||||
|
||||
pub struct App {
|
||||
cache: Cache,
|
||||
config: Config,
|
||||
config: Arc<Config>,
|
||||
wallet: Arc<Wallet>,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
internal_bitcoind: Option<Bitcoind>,
|
||||
@ -149,12 +151,14 @@ impl App {
|
||||
data_dir: PathBuf,
|
||||
internal_bitcoind: Option<Bitcoind>,
|
||||
) -> (App, Task<Message>) {
|
||||
let config = Arc::new(config);
|
||||
let mut panels = Panels::new(
|
||||
&cache,
|
||||
wallet.clone(),
|
||||
data_dir,
|
||||
daemon.backend(),
|
||||
internal_bitcoind.as_ref(),
|
||||
config.clone(),
|
||||
);
|
||||
let cmd = panels.home.reload(daemon.clone(), wallet.clone());
|
||||
(
|
||||
|
||||
@ -6,9 +6,15 @@ use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use liana::miniscript::bitcoin::{bip32::Fingerprint, Network};
|
||||
use liana_ui::component::form;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{hw::HardwareWalletConfig, lianalite::client::backend, services};
|
||||
use crate::{
|
||||
backup::{Key, KeyRole, KeyType},
|
||||
hw::HardwareWalletConfig,
|
||||
lianalite::client::backend,
|
||||
services,
|
||||
};
|
||||
|
||||
pub const DEFAULT_FILE_NAME: &str = "settings.json";
|
||||
|
||||
@ -101,6 +107,25 @@ impl WalletSetting {
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
pub fn update_alias(&mut self, key: &Fingerprint, alias: &str) {
|
||||
let key_aliases = self.keys_aliases();
|
||||
if key_aliases.contains_key(key) {
|
||||
self.keys = self
|
||||
.keys
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|mut ks| {
|
||||
if ks.master_fingerprint == *key {
|
||||
ks.name = alias.into();
|
||||
ks
|
||||
} else {
|
||||
ks
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
|
||||
@ -151,6 +176,67 @@ pub struct KeySetting {
|
||||
pub provider_key: Option<ProviderKey>,
|
||||
}
|
||||
|
||||
impl KeySetting {
|
||||
pub fn to_backup(&self) -> Key {
|
||||
if let Some(provider_key) = &self.provider_key {
|
||||
if let Ok(metadata) = serde_json::to_value(provider_key) {
|
||||
return Key {
|
||||
key: self.master_fingerprint,
|
||||
alias: Some(self.name.clone()),
|
||||
role: None,
|
||||
key_type: Some(KeyType::ThirdParty),
|
||||
proprietary: metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
Key {
|
||||
key: self.master_fingerprint,
|
||||
alias: Some(self.name.clone()),
|
||||
role: None,
|
||||
key_type: None,
|
||||
proprietary: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_backup(
|
||||
name: String,
|
||||
fg: Fingerprint,
|
||||
_role: Option<KeyRole>,
|
||||
key_type: Option<KeyType>,
|
||||
metadata: serde_json::Value,
|
||||
) -> Option<Self> {
|
||||
if let Some(KeyType::ThirdParty) = key_type {
|
||||
let provider_key = serde_json::from_value(metadata).ok();
|
||||
Some(Self {
|
||||
name,
|
||||
master_fingerprint: fg,
|
||||
provider_key,
|
||||
})
|
||||
} else {
|
||||
Some(Self {
|
||||
name,
|
||||
master_fingerprint: fg,
|
||||
provider_key: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_form(&self) -> form::Value<String> {
|
||||
form::Value {
|
||||
value: self.name.clone(),
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
pub fn has_name(&self) -> bool {
|
||||
!self.name.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||
pub enum SettingsError {
|
||||
NotFound,
|
||||
|
||||
@ -8,110 +8,231 @@ use liana_ui::{component::modal::Modal, widget::Element};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
self,
|
||||
view::{self, export::export_modal},
|
||||
},
|
||||
app::view::{export::export_modal, Close},
|
||||
daemon::Daemon,
|
||||
export::{self, get_path, ExportMessage, ExportProgress, ExportState},
|
||||
export::{self, get_path, ImportExportMessage, ImportExportState, ImportExportType, Progress},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExportModal {
|
||||
path: Option<PathBuf>,
|
||||
handle: Option<Arc<Mutex<JoinHandle<()>>>>,
|
||||
state: ExportState,
|
||||
state: ImportExportState,
|
||||
error: Option<export::Error>,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
daemon: Option<Arc<dyn Daemon + Sync + Send>>,
|
||||
import_export_type: ImportExportType,
|
||||
}
|
||||
|
||||
impl ExportModal {
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new(daemon: Arc<dyn Daemon + Sync + Send>) -> Self {
|
||||
pub fn new(
|
||||
daemon: Option<Arc<dyn Daemon + Sync + Send>>,
|
||||
export_type: ImportExportType,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: None,
|
||||
handle: None,
|
||||
state: ExportState::Init,
|
||||
state: ImportExportState::Init,
|
||||
error: None,
|
||||
daemon,
|
||||
import_export_type: export_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch(&self) -> Task<app::message::Message> {
|
||||
Task::perform(get_path(), |m| {
|
||||
app::message::Message::View(view::Message::Export(ExportMessage::Path(m)))
|
||||
pub fn modal_title(&self) -> &'static str {
|
||||
match self.import_export_type {
|
||||
ImportExportType::Transactions => "Export Transactions",
|
||||
ImportExportType::ExportPsbt(_) => "Export PSBT",
|
||||
ImportExportType::ExportBackup(_) => "Export Backup",
|
||||
ImportExportType::Descriptor(_) => "Export Descriptor",
|
||||
ImportExportType::ExportLabels => "Export Labels",
|
||||
ImportExportType::ImportPsbt => "Import PSBT",
|
||||
ImportExportType::ImportDescriptor => "Import Descriptor",
|
||||
ImportExportType::ImportBackup(..) => "Restore Backup",
|
||||
ImportExportType::WalletFromBackup => "Import existing wallet from backup",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_filename(&self) -> String {
|
||||
let date = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S");
|
||||
match &self.import_export_type {
|
||||
ImportExportType::Transactions => {
|
||||
format!("liana-txs-{date}.csv")
|
||||
}
|
||||
ImportExportType::ExportPsbt(_) => "psbt.psbt".into(),
|
||||
ImportExportType::Descriptor(descriptor) => {
|
||||
let checksum = descriptor
|
||||
.to_string()
|
||||
.split_once('#')
|
||||
.map(|(_, checksum)| checksum)
|
||||
.expect("cannot fail")
|
||||
.to_string();
|
||||
format!("liana-{}.descriptor", checksum)
|
||||
}
|
||||
ImportExportType::ImportPsbt => "psbt.psbt".into(),
|
||||
ImportExportType::ImportDescriptor => "descriptor.descriptor".into(),
|
||||
ImportExportType::ExportLabels => format!("liana-labels-{date}.jsonl"),
|
||||
ImportExportType::ExportBackup(_) => {
|
||||
format!("liana-backup-{date}.json")
|
||||
}
|
||||
ImportExportType::WalletFromBackup | ImportExportType::ImportBackup(_, _) => {
|
||||
"liana-backup.json".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch<M: From<ImportExportMessage> + Send + 'static>(&self, write: bool) -> Task<M> {
|
||||
Task::perform(get_path(self.default_filename(), write), move |m| {
|
||||
ImportExportMessage::Path(m).into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update(&mut self, message: ExportMessage) -> Task<app::message::Message> {
|
||||
pub fn update<M: From<ImportExportMessage> + Send + 'static>(
|
||||
&mut self,
|
||||
message: ImportExportMessage,
|
||||
) -> Task<M> {
|
||||
match message {
|
||||
ExportMessage::ExportProgress(m) => match m {
|
||||
ExportProgress::Started(handle) => {
|
||||
ImportExportMessage::Progress(m) => match m {
|
||||
Progress::Started(handle) => {
|
||||
self.handle = Some(handle);
|
||||
self.state = ExportState::Progress(0.0);
|
||||
self.state = ImportExportState::Progress(0.0);
|
||||
}
|
||||
ExportProgress::Progress(p) => {
|
||||
if let ExportState::Progress(_) = self.state {
|
||||
self.state = ExportState::Progress(p);
|
||||
Progress::Progress(p) => {
|
||||
if let ImportExportState::Progress(_) = self.state {
|
||||
self.state = ImportExportState::Progress(p);
|
||||
}
|
||||
}
|
||||
ExportProgress::Finished | ExportProgress::Ended => self.state = ExportState::Ended,
|
||||
ExportProgress::Error(e) => self.error = Some(e),
|
||||
ExportProgress::None => {}
|
||||
Progress::Finished | Progress::Ended => self.state = ImportExportState::Ended,
|
||||
Progress::KeyAliasesConflict(ref sender) => {
|
||||
if let ImportExportType::ImportBackup(_, None) = &self.import_export_type {
|
||||
self.import_export_type =
|
||||
ImportExportType::ImportBackup(None, Some(sender.clone()));
|
||||
}
|
||||
}
|
||||
Progress::LabelsConflict(ref sender) => {
|
||||
if let ImportExportType::ImportBackup(None, _) = &self.import_export_type {
|
||||
self.import_export_type =
|
||||
ImportExportType::ImportBackup(Some(sender.clone()), None);
|
||||
}
|
||||
}
|
||||
Progress::Error(e) => {
|
||||
self.error = Some(e.clone());
|
||||
}
|
||||
Progress::None => {}
|
||||
Progress::Psbt(_) => {
|
||||
if self.import_export_type == ImportExportType::ImportPsbt {
|
||||
self.state = ImportExportState::Ended;
|
||||
}
|
||||
// TODO: forward PSBT
|
||||
}
|
||||
Progress::Descriptor(_) => {
|
||||
if self.import_export_type == ImportExportType::ImportDescriptor {
|
||||
self.state = ImportExportState::Ended;
|
||||
}
|
||||
// TODO: forward Descriptor
|
||||
}
|
||||
Progress::UpdateAliases(map) => {
|
||||
return Task::perform(async {}, move |_| {
|
||||
ImportExportMessage::UpdateAliases(map.clone()).into()
|
||||
});
|
||||
}
|
||||
Progress::WalletFromBackup(_) => {}
|
||||
},
|
||||
ExportMessage::TimedOut => {
|
||||
self.stop(ExportState::TimedOut);
|
||||
ImportExportMessage::TimedOut => {
|
||||
self.stop(ImportExportState::TimedOut);
|
||||
}
|
||||
ExportMessage::UserStop => {
|
||||
self.stop(ExportState::Aborted);
|
||||
ImportExportMessage::UserStop => {
|
||||
self.stop(ImportExportState::Aborted);
|
||||
}
|
||||
ExportMessage::Path(p) => {
|
||||
ImportExportMessage::Path(p) => {
|
||||
if let Some(path) = p {
|
||||
self.path = Some(path);
|
||||
self.start();
|
||||
} else {
|
||||
return Task::perform(async {}, |_| {
|
||||
app::message::Message::View(view::Message::Export(ExportMessage::Close))
|
||||
});
|
||||
return Task::perform(async {}, |_| ImportExportMessage::Close.into());
|
||||
}
|
||||
}
|
||||
ExportMessage::Close | ExportMessage::Open => { /* unreachable */ }
|
||||
ImportExportMessage::Close | ImportExportMessage::Open => { /* unreachable */ }
|
||||
ImportExportMessage::Overwrite => {
|
||||
if let ImportExportType::ImportBackup(labels, aliases) =
|
||||
&mut self.import_export_type
|
||||
{
|
||||
if let Some(sender) = labels.take() {
|
||||
if sender.send(true).is_err() {
|
||||
tracing::error!("ExportModal.update(): fail to send labels ACK");
|
||||
}
|
||||
} else if let Some(sender) = aliases.take() {
|
||||
if sender.send(true).is_err() {
|
||||
tracing::error!("ExportModal.update(): fail to send aliases ACK");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ImportExportMessage::Ignore => {
|
||||
if let ImportExportType::ImportBackup(labels, aliases) =
|
||||
&mut self.import_export_type
|
||||
{
|
||||
if let Some(sender) = labels.take() {
|
||||
if sender.send(false).is_err() {
|
||||
tracing::error!("ExportModal.update(): fail to send labels NACK");
|
||||
}
|
||||
} else if let Some(sender) = aliases.take() {
|
||||
if sender.send(false).is_err() {
|
||||
tracing::error!("ExportModal.update(): fail to send aliases NACK");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ImportExportMessage::UpdateAliases(_) => { /* unexpected */ }
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
pub fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<view::Message> {
|
||||
|
||||
pub fn view<'a, M>(&'a self, content: Element<'a, M>) -> Element<M>
|
||||
where
|
||||
M: 'a + Close + Clone + From<export::ImportExportMessage>,
|
||||
{
|
||||
let modal = Modal::new(
|
||||
content,
|
||||
export_modal(&self.state, self.error.as_ref(), "Transactions"),
|
||||
export_modal(
|
||||
&self.state,
|
||||
self.error.as_ref(),
|
||||
self.modal_title(),
|
||||
&self.import_export_type,
|
||||
),
|
||||
);
|
||||
match self.state {
|
||||
ExportState::TimedOut
|
||||
| ExportState::Aborted
|
||||
| ExportState::Ended
|
||||
| ExportState::Closed => modal.on_blur(Some(view::Message::Close)),
|
||||
ImportExportState::TimedOut
|
||||
| ImportExportState::Aborted
|
||||
| ImportExportState::Ended
|
||||
| ImportExportState::Closed => modal.on_blur(Some(M::close())),
|
||||
_ => modal,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
self.state = ExportState::Started;
|
||||
self.state = ImportExportState::Started;
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, state: ExportState) {
|
||||
pub fn stop(&mut self, state: ImportExportState) {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.lock().expect("poisoned").abort();
|
||||
self.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscription(&self) -> Option<Subscription<export::ExportProgress>> {
|
||||
pub fn subscription(&self) -> Option<Subscription<export::Progress>> {
|
||||
if let Some(path) = &self.path {
|
||||
match &self.state {
|
||||
ExportState::Started | ExportState::Progress(_) => {
|
||||
ImportExportState::Started | ImportExportState::Progress(_) => {
|
||||
Some(iced::Subscription::run_with_id(
|
||||
"transactions",
|
||||
export::export_subscription(self.daemon.clone(), path.to_path_buf()),
|
||||
self.modal_title(),
|
||||
export::export_subscription(
|
||||
self.daemon.clone(),
|
||||
path.to_path_buf(),
|
||||
self.import_export_type.clone(),
|
||||
),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
mod coins;
|
||||
mod export;
|
||||
pub mod export;
|
||||
mod label;
|
||||
mod psbt;
|
||||
mod psbts;
|
||||
|
||||
@ -754,6 +754,8 @@ mod tests {
|
||||
"block_height": 1000,
|
||||
"sync": 1.0,
|
||||
"descriptors": { "main": LianaDescriptor::from_str(DESC).unwrap() },
|
||||
"receive_index": 4,
|
||||
"change_index": 3,
|
||||
"timestamp": 1000,
|
||||
})),
|
||||
),
|
||||
|
||||
@ -10,7 +10,7 @@ use iced::Task;
|
||||
use liana_ui::{component::form, widget::Element};
|
||||
|
||||
use bitcoind::BitcoindSettingsState;
|
||||
use wallet::WalletSettingsState;
|
||||
use wallet::{app_backup, WalletSettingsState};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
@ -20,16 +20,21 @@ use crate::{
|
||||
state::State,
|
||||
view::{self},
|
||||
wallet::Wallet,
|
||||
Config,
|
||||
},
|
||||
daemon::{Daemon, DaemonBackend},
|
||||
export::{self, ImportExportMessage, ImportExportType},
|
||||
};
|
||||
|
||||
use super::export::ExportModal;
|
||||
|
||||
pub struct SettingsState {
|
||||
data_dir: PathBuf,
|
||||
wallet: Arc<Wallet>,
|
||||
setting: Option<Box<dyn State>>,
|
||||
daemon_backend: DaemonBackend,
|
||||
internal_bitcoind: bool,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl SettingsState {
|
||||
@ -38,6 +43,7 @@ impl SettingsState {
|
||||
wallet: Arc<Wallet>,
|
||||
daemon_backend: DaemonBackend,
|
||||
internal_bitcoind: bool,
|
||||
config: Arc<Config>,
|
||||
) -> Self {
|
||||
Self {
|
||||
data_dir,
|
||||
@ -45,6 +51,7 @@ impl SettingsState {
|
||||
setting: None,
|
||||
daemon_backend,
|
||||
internal_bitcoind,
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -79,6 +86,12 @@ impl State for SettingsState {
|
||||
self.setting = Some(BackendSettingsState::new().into());
|
||||
Task::none()
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ImportExportSection)) => {
|
||||
self.setting = Some(
|
||||
ImportExportSettingsState::new(self.wallet.clone(), self.config.clone()).into(),
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::AboutSection)) => {
|
||||
self.setting = Some(AboutSettingsState::default().into());
|
||||
let wallet = self.wallet.clone();
|
||||
@ -89,7 +102,12 @@ impl State for SettingsState {
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::EditWalletSettings)) => {
|
||||
self.setting = Some(
|
||||
WalletSettingsState::new(self.data_dir.clone(), self.wallet.clone()).into(),
|
||||
WalletSettingsState::new(
|
||||
self.data_dir.clone(),
|
||||
self.wallet.clone(),
|
||||
self.config.clone(),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
let wallet = self.wallet.clone();
|
||||
self.setting
|
||||
@ -145,6 +163,144 @@ impl From<SettingsState> for Box<dyn State> {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImportExportSettingsState {
|
||||
warning: Option<Error>,
|
||||
modal: Option<ExportModal>,
|
||||
wallet: Arc<Wallet>,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl ImportExportSettingsState {
|
||||
pub fn new(wallet: Arc<Wallet>, config: Arc<Config>) -> Self {
|
||||
Self {
|
||||
warning: None,
|
||||
modal: None,
|
||||
wallet,
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! launch {
|
||||
($s:ident, $m: ident, $write:ident) => {
|
||||
let launch = $m.launch($write);
|
||||
$s.modal = Some($m);
|
||||
return launch
|
||||
};
|
||||
}
|
||||
|
||||
impl State for ImportExportSettingsState {
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
let content = view::settings::import_export(cache, self.warning.as_ref());
|
||||
if let Some(modal) = &self.modal {
|
||||
modal.view(content)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription(&self) -> iced::Subscription<Message> {
|
||||
if let Some(modal) = &self.modal {
|
||||
if let Some(sub) = modal.subscription() {
|
||||
return sub.map(|m| {
|
||||
Message::View(view::Message::Settings(
|
||||
view::SettingsMessage::ImportExport(ImportExportMessage::Progress(m)),
|
||||
))
|
||||
});
|
||||
}
|
||||
}
|
||||
iced::Subscription::none()
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
cache: &Cache,
|
||||
message: Message,
|
||||
) -> Task<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::ImportExport(ImportExportMessage::Close)) => {
|
||||
self.modal = None;
|
||||
}
|
||||
Message::View(view::Message::ImportExport(m)) => {
|
||||
if let Some(modal) = self.modal.as_mut() {
|
||||
return modal.update(m);
|
||||
};
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ImportExport(m))) => {
|
||||
if let Some(modal) = self.modal.as_mut() {
|
||||
return modal.update(m);
|
||||
};
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ExportDescriptor)) => {
|
||||
if self.modal.is_none() {
|
||||
let modal = ExportModal::new(
|
||||
Some(daemon),
|
||||
ImportExportType::Descriptor(self.wallet.main_descriptor.clone()),
|
||||
);
|
||||
launch!(self, modal, true);
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ExportTransactions)) => {
|
||||
if self.modal.is_none() {
|
||||
let modal = ExportModal::new(Some(daemon), ImportExportType::Transactions);
|
||||
launch!(self, modal, true);
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ExportLabels)) => {
|
||||
if self.modal.is_none() {
|
||||
let modal = ExportModal::new(Some(daemon), ImportExportType::ExportLabels);
|
||||
launch!(self, modal, true);
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ExportWallet)) => {
|
||||
if self.modal.is_none() {
|
||||
let datadir = cache.datadir_path.clone();
|
||||
let network = cache.network;
|
||||
let config = self.config.clone();
|
||||
let wallet = self.wallet.clone();
|
||||
let daemon = daemon.clone();
|
||||
return Task::perform(
|
||||
async move { app_backup(datadir, network, config, wallet, daemon).await },
|
||||
|s| {
|
||||
Message::View(view::Message::Settings(
|
||||
view::SettingsMessage::ExportBackup(s),
|
||||
))
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ExportBackup(backup))) => {
|
||||
let backup = match backup {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.warning = Some(Error::ImportExport(export::Error::Backup(e)));
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let modal = ExportModal::new(Some(daemon), ImportExportType::ExportBackup(backup));
|
||||
launch!(self, modal, true);
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ImportWallet)) => {
|
||||
if self.modal.is_none() {
|
||||
let modal =
|
||||
ExportModal::new(Some(daemon), ImportExportType::ImportBackup(None, None));
|
||||
launch!(self, modal, false);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ImportExportSettingsState> for Box<dyn State> {
|
||||
fn from(s: ImportExportSettingsState) -> Box<dyn State> {
|
||||
Box::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AboutSettingsState {
|
||||
daemon_version: Option<String>,
|
||||
|
||||
@ -17,34 +17,57 @@ use liana_ui::{
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache, error::Error, message::Message, settings, state::State, view, wallet::Wallet,
|
||||
cache::Cache,
|
||||
error::Error,
|
||||
message::Message,
|
||||
settings,
|
||||
state::{export::ExportModal, State},
|
||||
view,
|
||||
wallet::Wallet,
|
||||
Config,
|
||||
},
|
||||
backup::{self, Backup},
|
||||
daemon::{Daemon, DaemonBackend},
|
||||
export::{self, ImportExportMessage, ImportExportType},
|
||||
hw::{HardwareWallet, HardwareWalletConfig, HardwareWallets},
|
||||
};
|
||||
|
||||
enum Modal {
|
||||
None,
|
||||
RegisterWallet(RegisterWalletModal),
|
||||
ImportExport(ExportModal),
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
fn is_none(&self) -> bool {
|
||||
matches!(self, Modal::None)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WalletSettingsState {
|
||||
data_dir: PathBuf,
|
||||
warning: Option<Error>,
|
||||
descriptor: LianaDescriptor,
|
||||
keys_aliases: Vec<(Fingerprint, form::Value<String>)>,
|
||||
wallet: Arc<Wallet>,
|
||||
modal: Option<RegisterWalletModal>,
|
||||
modal: Modal,
|
||||
processing: bool,
|
||||
updated: bool,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl WalletSettingsState {
|
||||
pub fn new(data_dir: PathBuf, wallet: Arc<Wallet>) -> Self {
|
||||
pub fn new(data_dir: PathBuf, wallet: Arc<Wallet>, config: Arc<Config>) -> Self {
|
||||
WalletSettingsState {
|
||||
data_dir,
|
||||
descriptor: wallet.main_descriptor.clone(),
|
||||
keys_aliases: Self::keys_aliases(&wallet),
|
||||
wallet,
|
||||
warning: None,
|
||||
modal: None,
|
||||
modal: Modal::None,
|
||||
processing: false,
|
||||
updated: false,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,20 +109,31 @@ impl State for WalletSettingsState {
|
||||
self.processing,
|
||||
self.updated,
|
||||
);
|
||||
if let Some(m) = &self.modal {
|
||||
modal::Modal::new(content, m.view())
|
||||
|
||||
match &self.modal {
|
||||
Modal::None => content,
|
||||
Modal::RegisterWallet(m) => modal::Modal::new(content, m.view())
|
||||
.on_blur(Some(view::Message::Close))
|
||||
.into()
|
||||
} else {
|
||||
content
|
||||
.into(),
|
||||
Modal::ImportExport(m) => m.view(content),
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
if let Some(modal) = &self.modal {
|
||||
modal.subscription()
|
||||
} else {
|
||||
Subscription::none()
|
||||
match &self.modal {
|
||||
Modal::None => Subscription::none(),
|
||||
Modal::RegisterWallet(modal) => modal.subscription(),
|
||||
Modal::ImportExport(modal) => {
|
||||
if let Some(sub) = modal.subscription() {
|
||||
sub.map(|m| {
|
||||
Message::View(view::Message::Settings(
|
||||
view::SettingsMessage::ImportExport(ImportExportMessage::Progress(m)),
|
||||
))
|
||||
})
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,7 +146,7 @@ impl State for WalletSettingsState {
|
||||
match message {
|
||||
Message::WalletUpdated(res) => {
|
||||
self.processing = false;
|
||||
if let Some(modal) = &mut self.modal {
|
||||
if let Modal::RegisterWallet(modal) = &mut self.modal {
|
||||
modal.update(daemon, cache, Message::WalletUpdated(res))
|
||||
} else {
|
||||
match res {
|
||||
@ -139,7 +173,7 @@ impl State for WalletSettingsState {
|
||||
Task::none()
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::Save)) => {
|
||||
self.modal = None;
|
||||
self.modal = Modal::None;
|
||||
self.processing = true;
|
||||
self.updated = false;
|
||||
Task::perform(
|
||||
@ -157,22 +191,106 @@ impl State for WalletSettingsState {
|
||||
)
|
||||
}
|
||||
Message::View(view::Message::Close) => {
|
||||
self.modal = None;
|
||||
self.modal = Modal::None;
|
||||
Task::none()
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::RegisterWallet)) => {
|
||||
self.modal = Some(RegisterWalletModal::new(
|
||||
self.modal = Modal::RegisterWallet(RegisterWalletModal::new(
|
||||
self.data_dir.clone(),
|
||||
self.wallet.clone(),
|
||||
cache.network,
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
_ => self
|
||||
.modal
|
||||
.as_mut()
|
||||
.map(|m| m.update(daemon, cache, message))
|
||||
.unwrap_or_else(Task::none),
|
||||
|
||||
Message::View(view::Message::ImportExport(ImportExportMessage::UpdateAliases(
|
||||
aliases,
|
||||
))) => {
|
||||
self.processing = true;
|
||||
self.updated = false;
|
||||
Task::perform(
|
||||
update_keys_aliases(
|
||||
self.data_dir.clone(),
|
||||
cache.network,
|
||||
self.wallet.clone(),
|
||||
aliases.into_iter().map(|(fg, ks)| (fg, ks.name)).collect(),
|
||||
daemon,
|
||||
),
|
||||
Message::WalletUpdated,
|
||||
)
|
||||
}
|
||||
Message::View(view::Message::ImportExport(ImportExportMessage::Close)) => {
|
||||
if let Modal::ImportExport(_) = &self.modal {
|
||||
self.modal = Modal::None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::View(view::Message::ImportExport(m)) => {
|
||||
if let Modal::ImportExport(modal) = &mut self.modal {
|
||||
modal.update(m)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ImportExport(m))) => {
|
||||
if let Modal::ImportExport(modal) = &mut self.modal {
|
||||
modal.update(m)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ExportWallet)) => {
|
||||
if self.modal.is_none() {
|
||||
let datadir = cache.datadir_path.clone();
|
||||
let network = cache.network;
|
||||
let config = self.config.clone();
|
||||
let wallet = self.wallet.clone();
|
||||
let daemon = daemon.clone();
|
||||
Task::perform(
|
||||
async move { app_backup(datadir, network, config, wallet, daemon).await },
|
||||
|s| {
|
||||
Message::View(view::Message::Settings(
|
||||
view::SettingsMessage::ExportBackup(s),
|
||||
))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ExportBackup(backup))) => {
|
||||
if self.modal.is_none() {
|
||||
let backup = match backup {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.warning = Some(Error::ImportExport(export::Error::Backup(e)));
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let modal =
|
||||
ExportModal::new(Some(daemon), ImportExportType::ExportBackup(backup));
|
||||
let launch = modal.launch(true);
|
||||
self.modal = Modal::ImportExport(modal);
|
||||
launch
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::ImportWallet)) => {
|
||||
if self.modal.is_none() {
|
||||
let modal =
|
||||
ExportModal::new(Some(daemon), ImportExportType::ImportBackup(None, None));
|
||||
let launch = modal.launch(false);
|
||||
self.modal = Modal::ImportExport(modal);
|
||||
launch
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
_ => match &mut self.modal {
|
||||
Modal::RegisterWallet(m) => m.update(daemon, cache, message),
|
||||
_ => Task::none(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -370,7 +488,7 @@ async fn register_wallet(
|
||||
Ok(wallet)
|
||||
}
|
||||
|
||||
async fn update_keys_aliases(
|
||||
pub async fn update_keys_aliases(
|
||||
data_dir: PathBuf,
|
||||
network: Network,
|
||||
wallet: Arc<Wallet>,
|
||||
@ -407,3 +525,14 @@ async fn update_keys_aliases(
|
||||
|
||||
Ok(Arc::new(wallet))
|
||||
}
|
||||
|
||||
pub async fn app_backup(
|
||||
datadir: PathBuf,
|
||||
network: Network,
|
||||
config: Arc<Config>,
|
||||
wallet: Arc<Wallet>,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
) -> Result<String, backup::Error> {
|
||||
let backup = Backup::from_app(datadir, network, config, wallet, daemon).await?;
|
||||
serde_json::to_string_pretty(&backup).map_err(|_| backup::Error::Json)
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ use crate::{
|
||||
wallet::Wallet,
|
||||
},
|
||||
daemon::model::{self, LabelsLoader},
|
||||
export::ExportMessage,
|
||||
export::{ImportExportMessage, ImportExportType},
|
||||
};
|
||||
|
||||
use crate::daemon::{
|
||||
@ -266,15 +266,18 @@ impl State for TransactionsPanel {
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Export(ExportMessage::Open)) => {
|
||||
Message::View(view::Message::ImportExport(ImportExportMessage::Open)) => {
|
||||
if let TransactionsModal::None = &self.modal {
|
||||
self.modal = TransactionsModal::Export(ExportModal::new(daemon));
|
||||
self.modal = TransactionsModal::Export(ExportModal::new(
|
||||
Some(daemon),
|
||||
ImportExportType::Transactions,
|
||||
));
|
||||
if let TransactionsModal::Export(m) = &self.modal {
|
||||
return m.launch();
|
||||
return m.launch(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Export(ExportMessage::Close)) => {
|
||||
Message::View(view::Message::ImportExport(ImportExportMessage::Close)) => {
|
||||
if let TransactionsModal::Export(_) = &self.modal {
|
||||
self.modal = TransactionsModal::None;
|
||||
}
|
||||
@ -283,8 +286,8 @@ impl State for TransactionsPanel {
|
||||
return match &mut self.modal {
|
||||
TransactionsModal::CreateRbf(modal) => modal.update(daemon, _cache, message),
|
||||
TransactionsModal::Export(modal) => {
|
||||
if let Message::View(view::Message::Export(m)) = msg {
|
||||
modal.update(m.clone())
|
||||
if let Message::View(view::Message::ImportExport(m)) = msg {
|
||||
modal.update::<Message>(m.clone())
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
@ -327,7 +330,9 @@ impl State for TransactionsPanel {
|
||||
if let TransactionsModal::Export(modal) = &self.modal {
|
||||
if let Some(sub) = modal.subscription() {
|
||||
return sub.map(|m| {
|
||||
Message::View(view::Message::Export(ExportMessage::ExportProgress(m)))
|
||||
Message::View(view::Message::ImportExport(ImportExportMessage::Progress(
|
||||
m,
|
||||
)))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,49 +11,102 @@ use liana_ui::{
|
||||
widget::Element,
|
||||
};
|
||||
|
||||
use crate::export::{Error, ExportMessage};
|
||||
use crate::{app::view::message::Message, export::ExportState};
|
||||
use crate::export::ImportExportState;
|
||||
use crate::export::{Error, ImportExportMessage, ImportExportType};
|
||||
|
||||
/// Return the modal view for an export task
|
||||
pub fn export_modal<'a>(
|
||||
state: &ExportState,
|
||||
pub fn export_modal<'a, Message: From<ImportExportMessage> + Clone + 'a>(
|
||||
state: &ImportExportState,
|
||||
error: Option<&'a Error>,
|
||||
export_type: &str,
|
||||
title: &str,
|
||||
import_export_type: &ImportExportType,
|
||||
) -> Element<'a, Message> {
|
||||
let button = match state {
|
||||
ExportState::Started | ExportState::Progress(_) => {
|
||||
Some(button::secondary(None, "Cancel").on_press(ExportMessage::UserStop.into()))
|
||||
let cancel_close = match state {
|
||||
ImportExportState::Started | ImportExportState::Progress(_) => {
|
||||
Some(button::secondary(None, "Cancel").on_press(ImportExportMessage::UserStop.into()))
|
||||
}
|
||||
ExportState::Ended | ExportState::TimedOut | ExportState::Aborted => {
|
||||
Some(button::secondary(None, "Close").on_press(ExportMessage::Close.into()))
|
||||
ImportExportState::Ended | ImportExportState::TimedOut | ImportExportState::Aborted => {
|
||||
Some(button::secondary(None, "Close").on_press(ImportExportMessage::Close.into()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
.map(Container::new);
|
||||
|
||||
let msg = if let Some(error) = error {
|
||||
format!("{:?}", error)
|
||||
format!("{}", error)
|
||||
} else {
|
||||
match state {
|
||||
ExportState::Init => "".to_string(),
|
||||
ExportState::ChoosePath => {
|
||||
ImportExportState::Init => "".to_string(),
|
||||
ImportExportState::ChoosePath => {
|
||||
"Select the path you want to export in the popup window...".into()
|
||||
}
|
||||
ExportState::Path(_) => "".into(),
|
||||
ExportState::Started => "Starting export...".into(),
|
||||
ExportState::Progress(p) => format!("Progress: {}%", p.round()),
|
||||
ExportState::TimedOut => "Export failed: timeout".into(),
|
||||
ExportState::Aborted => "Export canceled".into(),
|
||||
ExportState::Ended => "Export successful!".into(),
|
||||
ExportState::Closed => "".into(),
|
||||
ImportExportState::Path(_) => "".into(),
|
||||
ImportExportState::Started => "Starting export...".into(),
|
||||
ImportExportState::Progress(p) => format!("Progress: {}%", p.round()),
|
||||
ImportExportState::TimedOut => "Export failed: timeout".into(),
|
||||
ImportExportState::Aborted => "Export canceled".into(),
|
||||
ImportExportState::Ended => import_export_type.end_message().into(),
|
||||
ImportExportState::Closed => "".into(),
|
||||
}
|
||||
};
|
||||
let p = match state {
|
||||
ExportState::Init => 0.0,
|
||||
ExportState::ChoosePath | ExportState::Path(_) | ExportState::Started => 5.0,
|
||||
ExportState::Progress(p) => *p,
|
||||
ExportState::TimedOut | ExportState::Aborted | ExportState::Ended | ExportState::Closed => {
|
||||
100.0
|
||||
}
|
||||
let labels_btn = (
|
||||
"Labels conflict, what do you want to do?".to_string(),
|
||||
Some(Container::new(
|
||||
Row::new()
|
||||
.push(
|
||||
button::secondary(None, "Overwrite")
|
||||
.on_press(ImportExportMessage::Overwrite.into()),
|
||||
)
|
||||
.push(Space::with_width(30))
|
||||
.push(
|
||||
button::secondary(None, "Ignore").on_press(ImportExportMessage::Ignore.into()),
|
||||
),
|
||||
)),
|
||||
);
|
||||
let aliases_btn = (
|
||||
"Aliases conflict, what do you want to do?".to_string(),
|
||||
Some(Container::new(
|
||||
Row::new()
|
||||
.push(
|
||||
button::secondary(None, "Overwrite")
|
||||
.on_press(ImportExportMessage::Overwrite.into()),
|
||||
)
|
||||
.push(Space::with_width(30))
|
||||
.push(
|
||||
button::secondary(None, "Ignore").on_press(ImportExportMessage::Ignore.into()),
|
||||
),
|
||||
)),
|
||||
);
|
||||
let (msg, button) = match import_export_type {
|
||||
ImportExportType::ImportBackup(labels, aliases) => match (labels, aliases) {
|
||||
(Some(_), _) => labels_btn,
|
||||
|
||||
(_, Some(_)) => aliases_btn,
|
||||
_ => (msg, cancel_close),
|
||||
},
|
||||
_ => (msg, cancel_close),
|
||||
};
|
||||
let button = button.map(|b| {
|
||||
Container::new(b)
|
||||
.align_x(Horizontal::Center)
|
||||
.width(Length::Fill)
|
||||
});
|
||||
|
||||
let mut p = match state {
|
||||
ImportExportState::Init => 0.0,
|
||||
ImportExportState::ChoosePath | ImportExportState::Path(_) | ImportExportState::Started => {
|
||||
5.0
|
||||
}
|
||||
ImportExportState::Progress(p) => *p,
|
||||
ImportExportState::TimedOut
|
||||
| ImportExportState::Aborted
|
||||
| ImportExportState::Ended
|
||||
| ImportExportState::Closed => 100.0,
|
||||
};
|
||||
// keep progress bar visible
|
||||
if p == 0.0 {
|
||||
p += 2.5;
|
||||
}
|
||||
let progress_bar_row = Row::new()
|
||||
.push(Space::with_width(30))
|
||||
.push(progress_bar(0.0..=100.0, p))
|
||||
@ -61,20 +114,16 @@ pub fn export_modal<'a>(
|
||||
card::simple(
|
||||
Column::new()
|
||||
.spacing(10)
|
||||
.push(Container::new(h4_bold(format!("Export {export_type}"))).width(Length::Fill))
|
||||
.push(Container::new(h4_bold(title)).width(Length::Fill))
|
||||
.push(Space::with_height(Length::Fill))
|
||||
.push(progress_bar_row)
|
||||
.push(Space::with_height(Length::Fill))
|
||||
.push(Row::new().push(text(msg)))
|
||||
.push(Space::with_height(Length::Fill))
|
||||
.push_maybe(button.map(|b| {
|
||||
Container::new(b)
|
||||
.align_x(Horizontal::Right)
|
||||
.width(Length::Fill)
|
||||
}))
|
||||
.push_maybe(button)
|
||||
.push(Space::with_height(5)),
|
||||
)
|
||||
.width(Length::Fixed(500.0))
|
||||
.height(Length::Fixed(220.0))
|
||||
.height(Length::Fixed(250.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
use crate::{app::menu::Menu, export::ExportMessage, node::bitcoind::RpcAuthType};
|
||||
use crate::{app::menu::Menu, backup, export::ImportExportMessage, node::bitcoind::RpcAuthType};
|
||||
use liana::miniscript::bitcoin::{bip32::Fingerprint, OutPoint};
|
||||
|
||||
pub trait Close {
|
||||
fn close() -> Self;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
Reload,
|
||||
@ -19,7 +23,13 @@ pub enum Message {
|
||||
SelectHardwareWallet(usize),
|
||||
CreateRbf(CreateRbfMessage),
|
||||
ShowQrCode(usize),
|
||||
Export(ExportMessage),
|
||||
ImportExport(ImportExportMessage),
|
||||
}
|
||||
|
||||
impl Close for Message {
|
||||
fn close() -> Self {
|
||||
Self::Close
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -70,9 +80,17 @@ pub enum SettingsMessage {
|
||||
BitcoindSettings(SettingsEditMessage),
|
||||
ElectrumSettings(SettingsEditMessage),
|
||||
RescanSettings(SettingsEditMessage),
|
||||
ImportExport(ImportExportMessage),
|
||||
EditRemoteBackendSettings,
|
||||
RemoteBackendSettings(RemoteBackendSettingsMessage),
|
||||
EditWalletSettings,
|
||||
ImportExportSection,
|
||||
ExportDescriptor,
|
||||
ExportTransactions,
|
||||
ExportLabels,
|
||||
ExportWallet,
|
||||
ExportBackup(Result<String, backup::Error>),
|
||||
ImportWallet,
|
||||
AboutSection,
|
||||
RegisterWallet,
|
||||
FingerprintAliasEdited(Fingerprint, String),
|
||||
|
||||
@ -106,6 +106,13 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element<Message> {
|
||||
Message::Settings(SettingsMessage::EditWalletSettings),
|
||||
);
|
||||
|
||||
let import_export = settings_section(
|
||||
"Import/export",
|
||||
None,
|
||||
icon::wallet_icon(),
|
||||
Message::Settings(SettingsMessage::ImportExportSection),
|
||||
);
|
||||
|
||||
let recovery = settings_section(
|
||||
"Recovery",
|
||||
Some("In case of loss of the main key, the recovery key can move the funds after a certain time."),
|
||||
@ -130,6 +137,7 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element<Message> {
|
||||
.push(header)
|
||||
.push(if !is_remote_backend { node } else { backend })
|
||||
.push(wallet)
|
||||
.push(import_export)
|
||||
.push(recovery)
|
||||
.push(about),
|
||||
)
|
||||
@ -153,6 +161,60 @@ pub fn bitcoind_settings<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn import_export<'a>(cache: &'a Cache, warning: Option<&Error>) -> Element<'a, Message> {
|
||||
let header = header("Import/Export", SettingsMessage::ImportExportSection);
|
||||
|
||||
let export_descriptor = settings_section(
|
||||
"Export descriptor",
|
||||
None,
|
||||
icon::backup_icon(),
|
||||
Message::Settings(SettingsMessage::ExportDescriptor),
|
||||
);
|
||||
|
||||
let export_transactions = settings_section(
|
||||
"Export transactions",
|
||||
None,
|
||||
icon::backup_icon(),
|
||||
Message::Settings(SettingsMessage::ExportTransactions),
|
||||
);
|
||||
|
||||
let export_labels = settings_section(
|
||||
"Export labels",
|
||||
None,
|
||||
icon::backup_icon(),
|
||||
Message::Settings(SettingsMessage::ExportLabels),
|
||||
);
|
||||
|
||||
let export_wallet = settings_section(
|
||||
"Back Up Wallet",
|
||||
None,
|
||||
icon::backup_icon(),
|
||||
Message::Settings(SettingsMessage::ExportWallet),
|
||||
);
|
||||
|
||||
let import_wallet = settings_section(
|
||||
"Restore wallet",
|
||||
None,
|
||||
icon::restore_icon(),
|
||||
Message::Settings(SettingsMessage::ImportWallet),
|
||||
);
|
||||
|
||||
dashboard(
|
||||
&Menu::Settings,
|
||||
cache,
|
||||
warning,
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(header)
|
||||
.push(export_descriptor)
|
||||
.push(export_transactions)
|
||||
.push(export_labels)
|
||||
.push(export_wallet)
|
||||
.push(import_wallet)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn about_section<'a>(
|
||||
cache: &'a Cache,
|
||||
warning: Option<&Error>,
|
||||
@ -856,6 +918,18 @@ pub fn wallet_settings<'a>(
|
||||
) -> Element<'a, Message> {
|
||||
let header = header("Wallet", SettingsMessage::EditWalletSettings);
|
||||
|
||||
let import_export = Row::new()
|
||||
.push(
|
||||
button::secondary(Some(icon::backup_icon()), "Backup")
|
||||
.on_press(Message::Settings(SettingsMessage::ExportWallet)),
|
||||
)
|
||||
.push(Space::with_width(10))
|
||||
.push(
|
||||
button::secondary(Some(icon::restore_icon()), "Restore")
|
||||
.on_press(Message::Settings(SettingsMessage::ImportWallet)),
|
||||
)
|
||||
.push(Space::with_width(Length::Fill));
|
||||
|
||||
let descr = card::simple(
|
||||
Column::new()
|
||||
.push(text("Wallet descriptor:").bold())
|
||||
@ -943,6 +1017,7 @@ pub fn wallet_settings<'a>(
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(header)
|
||||
.push(import_export)
|
||||
.push(descr)
|
||||
.push(
|
||||
card::simple(display_policy(
|
||||
|
||||
@ -25,7 +25,7 @@ use crate::{
|
||||
},
|
||||
},
|
||||
daemon::model::{HistoryTransaction, Txid},
|
||||
export::ExportMessage,
|
||||
export::ImportExportMessage,
|
||||
};
|
||||
|
||||
pub fn transactions_view<'a>(
|
||||
@ -44,7 +44,10 @@ pub fn transactions_view<'a>(
|
||||
Row::new()
|
||||
.push(Container::new(h3("Transactions")))
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(button::secondary(None, "Export").on_press(ExportMessage::Open.into())),
|
||||
.push(
|
||||
button::secondary(None, "Export")
|
||||
.on_press(ImportExportMessage::Open.into()),
|
||||
),
|
||||
)
|
||||
.push(
|
||||
Column::new()
|
||||
|
||||
@ -41,11 +41,16 @@ impl From<&Error> for WarningMessage {
|
||||
DaemonError::CoinSelectionError => {
|
||||
WarningMessage("Error when selecting coins for spend".to_string())
|
||||
}
|
||||
DaemonError::NotImplemented => {
|
||||
WarningMessage("Feature not implemented for this backend".to_string())
|
||||
}
|
||||
},
|
||||
Error::Unexpected(_) => WarningMessage("Unknown error".to_string()),
|
||||
Error::HardwareWallet(_) => WarningMessage("Hardware wallet error".to_string()),
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,6 +179,28 @@ impl Wallet {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> HashMap<Fingerprint, settings::KeySetting> {
|
||||
let mut map = HashMap::new();
|
||||
self.keys_aliases.iter().for_each(|(fg, alias)| {
|
||||
map.insert(
|
||||
*fg,
|
||||
settings::KeySetting {
|
||||
name: alias.clone(),
|
||||
master_fingerprint: *fg,
|
||||
provider_key: None,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
self.provider_keys.iter().for_each(|(fg, key)| {
|
||||
if let Some(entry) = map.get_mut(fg) {
|
||||
entry.provider_key = Some(key.clone())
|
||||
}
|
||||
});
|
||||
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
|
||||
545
liana-gui/src/backup.rs
Normal file
545
liana-gui/src/backup.rs
Normal file
@ -0,0 +1,545 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use liana::miniscript::{
|
||||
self,
|
||||
bitcoin::{bip32::Fingerprint, Network, Txid},
|
||||
};
|
||||
use lianad::{
|
||||
bip329,
|
||||
commands::{CoinStatus, ListCoinsEntry},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fmt::{Debug, Display},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{settings::Settings, wallet::Wallet, Config},
|
||||
daemon::{model::HistoryTransaction, Daemon, DaemonBackend, DaemonError},
|
||||
installer::{
|
||||
extract_daemon_config, extract_local_gui_settings, extract_remote_gui_settings, Context,
|
||||
RemoteBackend,
|
||||
},
|
||||
lianalite::client::backend::api::DEFAULT_LIMIT,
|
||||
VERSION,
|
||||
};
|
||||
|
||||
const CONFIG_KEY: &str = "config";
|
||||
const SETTINGS_KEY: &str = "settings";
|
||||
const LIANA_VERSION_KEY: &str = "liana_version";
|
||||
|
||||
pub fn liana_version() -> String {
|
||||
format!("{}.{}.{}", VERSION.major, VERSION.minor, VERSION.patch)
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("cannot fail")
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct Backup {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub accounts: Vec<Account>,
|
||||
pub network: Network,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date: Option<u64>,
|
||||
/// App proprietary metadata (settings, configuration, etc..)
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub proprietary: serde_json::Map<String, serde_json::Value>,
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Error {
|
||||
DescriptorMissing,
|
||||
NotSingleWallet,
|
||||
Json,
|
||||
SettingsFromFile,
|
||||
Daemon(String),
|
||||
TxTimeMissing,
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::DescriptorMissing => write!(f, "Backup: descriptor missing"),
|
||||
Error::NotSingleWallet => write!(f, "Backup: Zero or several wallets"),
|
||||
Error::Json => write!(f, "Backup: json error"),
|
||||
Error::SettingsFromFile => write!(f, "Backup: fail to parse setting from file"),
|
||||
Error::Daemon(e) => write!(f, "Backup daemon error: {e}"),
|
||||
Error::TxTimeMissing => write!(f, "Backup: transaction block height missing"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DaemonError> for Error {
|
||||
fn from(value: DaemonError) -> Self {
|
||||
Error::Daemon(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Backup {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let str = serde_json::to_string(self).map_err(|_| std::fmt::Error)?;
|
||||
write!(f, "{str}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for Backup {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let str = serde_json::to_string_pretty(self).map_err(|_| std::fmt::Error)?;
|
||||
write!(f, "{str}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Backup {
|
||||
/// Create a Backup from the Installer context
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ctx` - the installer context
|
||||
/// * `timestamp` - whether to record the current timestamp as wallet creation time
|
||||
/// (we should want to set timestamp = false for a wallet import for instance)
|
||||
pub async fn from_installer(ctx: Context, timestamp: bool) -> Result<Self, Error> {
|
||||
let descriptor = ctx
|
||||
.descriptor
|
||||
.clone()
|
||||
.ok_or(Error::DescriptorMissing)?
|
||||
.to_string();
|
||||
|
||||
let now = now();
|
||||
|
||||
let mut account = Account::new(descriptor);
|
||||
|
||||
let mut proprietary = serde_json::Map::new();
|
||||
proprietary.insert(LIANA_VERSION_KEY.to_string(), liana_version().into());
|
||||
|
||||
let config = extract_daemon_config(&ctx);
|
||||
if let Ok(config) = serde_json::to_value(config) {
|
||||
proprietary.insert(CONFIG_KEY.to_string(), config);
|
||||
}
|
||||
let settings = if ctx.bitcoin_backend.is_some() {
|
||||
Some(extract_local_gui_settings(&ctx))
|
||||
} else {
|
||||
match &ctx.remote_backend {
|
||||
RemoteBackend::WithWallet(backend) => {
|
||||
Some(extract_remote_gui_settings(&ctx, backend).await)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let name = if let Some(settings) = settings {
|
||||
assert_eq!(settings.wallets.len(), 1);
|
||||
if settings.wallets.len() != 1 {
|
||||
return Err(Error::NotSingleWallet);
|
||||
}
|
||||
let settings = settings.wallets.first().expect("only one wallet");
|
||||
let name = settings.name.clone();
|
||||
if let Ok(settings) = serde_json::to_value(settings) {
|
||||
proprietary.insert(SETTINGS_KEY.to_string(), settings);
|
||||
}
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ctx.keys.iter().for_each(|(k, s)| {
|
||||
account.keys.insert(*k, s.to_backup());
|
||||
});
|
||||
|
||||
account.proprietary = proprietary;
|
||||
account.name = name.clone();
|
||||
if timestamp {
|
||||
account.timestamp = Some(now);
|
||||
}
|
||||
|
||||
Ok(Backup {
|
||||
name,
|
||||
accounts: vec![account],
|
||||
network: ctx.network,
|
||||
proprietary: serde_json::Map::new(),
|
||||
date: Some(now),
|
||||
version: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a Backup from the Liana App context
|
||||
pub async fn from_app(
|
||||
datadir: PathBuf,
|
||||
network: Network,
|
||||
config: Arc<Config>,
|
||||
wallet: Arc<Wallet>,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
) -> Result<Self, Error> {
|
||||
let mut proprietary = serde_json::Map::new();
|
||||
proprietary.insert(LIANA_VERSION_KEY.to_string(), liana_version().into());
|
||||
|
||||
let name = wallet.name.clone();
|
||||
let descriptor = wallet.main_descriptor.to_string();
|
||||
let keys = wallet.keys();
|
||||
|
||||
let settings =
|
||||
Settings::from_file(datadir, network).map_err(|_| Error::SettingsFromFile)?;
|
||||
if settings.wallets.len() == 1 {
|
||||
if let Ok(settings) = serde_json::to_value(settings.wallets[0].clone()) {
|
||||
proprietary.insert(SETTINGS_KEY.to_string(), settings);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(config) = serde_json::to_value((*config).clone()) {
|
||||
proprietary.insert(CONFIG_KEY.to_string(), config);
|
||||
}
|
||||
|
||||
let info = daemon.get_info().await?;
|
||||
|
||||
let mut account = Account::new(descriptor);
|
||||
|
||||
account.chain_tip = Some(ChainTip {
|
||||
block_height: info.block_height,
|
||||
block_hash: None,
|
||||
});
|
||||
account.proprietary = proprietary;
|
||||
account.name = Some(name.clone());
|
||||
account.timestamp = Some(info.timestamp as u64);
|
||||
account.change_index = Some(info.change_index);
|
||||
account.receive_index = Some(info.receive_index);
|
||||
for (fg, setting) in keys {
|
||||
account.keys.insert(fg, setting.to_backup());
|
||||
}
|
||||
|
||||
const MAX_LABEL_BIP329: u32 = 100;
|
||||
|
||||
let labels = {
|
||||
let mut buff = Vec::new();
|
||||
let mut start = 0;
|
||||
loop {
|
||||
let mut fetched = daemon.get_labels_bip329(start, 100).await?.into_vec();
|
||||
|
||||
if fetched.len() < MAX_LABEL_BIP329 as usize {
|
||||
buff.append(&mut fetched);
|
||||
break;
|
||||
} else {
|
||||
buff.append(&mut fetched);
|
||||
start += MAX_LABEL_BIP329;
|
||||
}
|
||||
}
|
||||
bip329::Labels::new(buff)
|
||||
};
|
||||
|
||||
account.labels = Some(labels);
|
||||
account.transactions = get_transactions(&daemon)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|tx| miniscript::bitcoin::consensus::encode::serialize_hex(&tx.tx))
|
||||
.collect();
|
||||
account.psbts = daemon
|
||||
.list_spend_transactions(None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|tx| tx.psbt.to_string())
|
||||
.collect();
|
||||
|
||||
let statuses = [
|
||||
CoinStatus::Unconfirmed,
|
||||
CoinStatus::Confirmed,
|
||||
CoinStatus::Spending,
|
||||
];
|
||||
account.coins = daemon
|
||||
.list_coins(&statuses, &[])
|
||||
.await?
|
||||
.coins
|
||||
.into_iter()
|
||||
.map(|c| (c.outpoint.clone().to_string(), Coin::from(c)))
|
||||
.collect();
|
||||
|
||||
Ok(Backup {
|
||||
name: Some(name),
|
||||
accounts: vec![account],
|
||||
network,
|
||||
proprietary: serde_json::Map::new(),
|
||||
date: Some(now()),
|
||||
version: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn account(&self) -> Result<&Account, Error> {
|
||||
if self.accounts.len() != 1 {
|
||||
Err(Error::NotSingleWallet)
|
||||
} else {
|
||||
Ok(self.accounts.first().expect("single account"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> Result<Option<Config>, Error> {
|
||||
let account = self.account()?;
|
||||
if let Some(config) = account.proprietary.get(CONFIG_KEY) {
|
||||
let config: Config = serde_json::from_value(config.clone()).map_err(|_| Error::Json)?;
|
||||
Ok(Some(config))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> Result<Option<Settings>, Error> {
|
||||
let account = self.account()?;
|
||||
if let Some(settings) = account.proprietary.get(SETTINGS_KEY) {
|
||||
let settings: Settings =
|
||||
serde_json::from_value(settings.clone()).map_err(|_| Error::Json)?;
|
||||
Ok(Some(settings))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_transactions(
|
||||
daemon: &Arc<dyn Daemon + Sync + Send>,
|
||||
) -> Result<Vec<HistoryTransaction>, Error> {
|
||||
let max = match daemon.backend() {
|
||||
DaemonBackend::RemoteBackend => DEFAULT_LIMIT as u64,
|
||||
_ => u32::MAX as u64,
|
||||
};
|
||||
|
||||
// look 2 hour forward
|
||||
// https://github.com/bitcoin/bitcoin/blob/62bd61de110b057cbfd6e31e4d0b727d93119c72/src/chain.h#L29
|
||||
let mut end = ((Utc::now() + Duration::hours(2)).timestamp()) as u32;
|
||||
|
||||
// store txs in a map to avoid duplicates
|
||||
let mut map = HashMap::<Txid, HistoryTransaction>::new();
|
||||
let mut limit = max;
|
||||
|
||||
loop {
|
||||
let history_txs = daemon.list_history_txs(0, end, limit).await?;
|
||||
// all txs have been fetched
|
||||
if history_txs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if history_txs.len() == limit as usize {
|
||||
let first = if let Some(t) = history_txs.first().expect("checked").time {
|
||||
t
|
||||
} else {
|
||||
return Err(Error::TxTimeMissing);
|
||||
};
|
||||
|
||||
let last = if let Some(t) = history_txs.last().expect("checked").time {
|
||||
t
|
||||
} else {
|
||||
return Err(Error::TxTimeMissing);
|
||||
};
|
||||
|
||||
// limit too low, all tx are in the same timestamp
|
||||
// we must increase limit and retry
|
||||
if first == last {
|
||||
limit += DEFAULT_LIMIT as u64;
|
||||
continue;
|
||||
} else {
|
||||
// add txs to map
|
||||
for tx in history_txs {
|
||||
let txid = tx.txid;
|
||||
map.insert(txid, tx);
|
||||
}
|
||||
limit = max;
|
||||
end = first.min(last);
|
||||
continue;
|
||||
}
|
||||
} else
|
||||
/* history_txs.len() < limit */
|
||||
{
|
||||
// add txs to map
|
||||
for tx in history_txs {
|
||||
let txid = tx.txid;
|
||||
map.insert(txid, tx);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let vec: Vec<_> = map.into_values().collect();
|
||||
Ok(vec)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct Account {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub descriptor: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub receive_index: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub change_index: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub keys: BTreeMap<Fingerprint, Key>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<bip329::Labels>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub transactions: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub psbts: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub coins: BTreeMap<String, Coin>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub chain_tip: Option<ChainTip>,
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub proprietary: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct ChainTip {
|
||||
pub block_height: i32,
|
||||
pub block_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct Coin {
|
||||
amount: u64,
|
||||
outpoint: String,
|
||||
address: String,
|
||||
block_height: Option<i32>,
|
||||
account: u32,
|
||||
derivation_index: u32,
|
||||
is_coinbase: Option<bool>,
|
||||
is_from_self: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<ListCoinsEntry> for Coin {
|
||||
fn from(value: ListCoinsEntry) -> Self {
|
||||
Self {
|
||||
amount: value.amount.to_sat(),
|
||||
outpoint: value.outpoint.to_string(),
|
||||
address: value.address.to_string(),
|
||||
block_height: value.block_height,
|
||||
account: if value.is_change { 1 } else { 0 },
|
||||
derivation_index: value.derivation_index.into(),
|
||||
is_coinbase: if value.is_immature { Some(true) } else { None },
|
||||
is_from_self: Some(value.is_from_self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub fn new(descriptor: String) -> Self {
|
||||
Self {
|
||||
name: None,
|
||||
descriptor,
|
||||
receive_index: None,
|
||||
change_index: None,
|
||||
timestamp: None,
|
||||
keys: BTreeMap::new(),
|
||||
labels: None,
|
||||
transactions: Vec::new(),
|
||||
psbts: Vec::new(),
|
||||
coins: BTreeMap::new(),
|
||||
proprietary: serde_json::Map::new(),
|
||||
chain_tip: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct Key {
|
||||
pub key: Fingerprint,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<KeyRole>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_type: Option<KeyType>,
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
pub proprietary: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyRole {
|
||||
/// Key to be used in normal spending condition
|
||||
Main,
|
||||
/// Key that will be used for recover in case loss of main key(s)
|
||||
Recovery,
|
||||
/// Key that wil inherit coins if main user disapear
|
||||
Inheritance,
|
||||
/// Key that will cosign a spend in order to enforce some policy
|
||||
Cosigning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyType {
|
||||
/// Main user
|
||||
Internal,
|
||||
/// Heirs or friends
|
||||
External,
|
||||
/// Service the user pay for
|
||||
ThirdParty,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
fn round_trip(backup: &Backup) -> bool {
|
||||
let serialized = serde_json::to_string(backup).unwrap();
|
||||
let parsed: Backup = serde_json::from_str(&serialized).unwrap();
|
||||
*backup == parsed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_serde() {
|
||||
let mut backup = Backup {
|
||||
name: None,
|
||||
accounts: Vec::new(),
|
||||
network: Network::Signet,
|
||||
date: Some(0),
|
||||
proprietary: serde_json::Map::new(),
|
||||
version: 0,
|
||||
};
|
||||
let serialized = serde_json::to_string(&backup).unwrap();
|
||||
let expected = r#"{"accounts":[],"network":"signet","date":0,"version":0}"#;
|
||||
assert_eq!(expected, serialized);
|
||||
assert!(round_trip(&backup));
|
||||
|
||||
backup.name = Some("Liana".into());
|
||||
|
||||
let serialized = serde_json::to_string(&backup).unwrap();
|
||||
let expected = r#"{"name":"Liana","accounts":[],"network":"signet","date":0,"version":0}"#;
|
||||
assert_eq!(expected, serialized);
|
||||
assert!(round_trip(&backup));
|
||||
|
||||
let descr_str = r#"wsh(or_d(pk([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<0;1>/*),and_v(v:pkh([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<2;3>/*),older(52596))))#x6u6lmej"#.to_string();
|
||||
|
||||
let account = Account::new(descr_str);
|
||||
backup.accounts.push(account);
|
||||
|
||||
let serialized = serde_json::to_string(&backup).unwrap();
|
||||
println!("{serialized}");
|
||||
let expected = r#"{"name":"Liana","accounts":[{"descriptor":"wsh(or_d(pk([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<0;1>/*),and_v(v:pkh([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<2;3>/*),older(52596))))#x6u6lmej"}],"network":"signet","date":0,"version":0}"#;
|
||||
assert_eq!(expected, serialized);
|
||||
assert!(round_trip(&backup));
|
||||
|
||||
// if there is no version, the default is 0
|
||||
let no_version = r#"{"name":"Liana","accounts":[{"descriptor":"wsh(or_d(pk([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<0;1>/*),and_v(v:pkh([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<2;3>/*),older(52596))))#x6u6lmej"}],"network":"signet","date":0}"#;
|
||||
let parsed: Backup = serde_json::from_str(no_version).unwrap();
|
||||
assert_eq!(parsed.version, 0);
|
||||
|
||||
// Network is mandatory for an account
|
||||
let no_network = r#"{"name":"Liana","accounts":[{"descriptor":"wsh(or_d(pk([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<0;1>/*),and_v(v:pkh([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<2;3>/*),older(52596))))#x6u6lmej"}],"date":0,"version":0}"#;
|
||||
let parsed: Result<Backup, _> = serde_json::from_str(no_network);
|
||||
assert!(parsed.is_err());
|
||||
|
||||
// But it's the only mandatory field, w/ accounts array
|
||||
let minimal = r#"{"network":"signet","accounts":[]}"#;
|
||||
let _parsed: Backup = serde_json::from_str(minimal).unwrap();
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@ use std::iter::FromIterator;
|
||||
use std::path::Path;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use lianad::bip329::Labels;
|
||||
use lianad::commands::{GetLabelsBip329Result, UpdateDerivIndexesResult};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@ -81,6 +83,14 @@ impl<C: Client + Send + Sync + Debug> Daemon for Lianad<C> {
|
||||
self.call("getnewaddress", Option::<Request>::None)
|
||||
}
|
||||
|
||||
async fn update_deriv_indexes(
|
||||
&self,
|
||||
receive: Option<u32>,
|
||||
change: Option<u32>,
|
||||
) -> Result<UpdateDerivIndexesResult, DaemonError> {
|
||||
self.call("updatederivationindexes", Some(vec![receive, change]))
|
||||
}
|
||||
|
||||
async fn list_coins(
|
||||
&self,
|
||||
statuses: &[CoinStatus],
|
||||
@ -205,6 +215,12 @@ impl<C: Client + Send + Sync + Debug> Daemon for Lianad<C> {
|
||||
let _res: serde_json::value::Value = self.call("updatelabels", Some(vec![labels]))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_labels_bip329(&self, offset: u32, limit: u32) -> Result<Labels, DaemonError> {
|
||||
let res: GetLabelsBip329Result =
|
||||
self.call("getlabelsbip329", Some(vec![json!(offset), json!(limit)]))?;
|
||||
Ok(res.labels)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
use lianad::bip329::Labels;
|
||||
use lianad::commands::UpdateDerivIndexesResult;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use tokio::sync::Mutex;
|
||||
@ -97,6 +99,19 @@ impl Daemon for EmbeddedDaemon {
|
||||
self.command(|daemon| Ok(daemon.get_new_address())).await
|
||||
}
|
||||
|
||||
async fn update_deriv_indexes(
|
||||
&self,
|
||||
receive: Option<u32>,
|
||||
change: Option<u32>,
|
||||
) -> Result<UpdateDerivIndexesResult, DaemonError> {
|
||||
self.command(|daemon| {
|
||||
daemon
|
||||
.update_deriv_indexes(receive, change)
|
||||
.map_err(|e| DaemonError::Unexpected(e.to_string()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_coins(
|
||||
&self,
|
||||
statuses: &[CoinStatus],
|
||||
@ -227,4 +242,9 @@ impl Daemon for EmbeddedDaemon {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_labels_bip329(&self, offset: u32, limit: u32) -> Result<Labels, DaemonError> {
|
||||
self.command(|daemon| Ok(daemon.get_labels_bip329(offset, limit).labels))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,12 +14,15 @@ use async_trait::async_trait;
|
||||
use liana::miniscript::bitcoin::{
|
||||
address, bip32::Fingerprint, psbt::Psbt, secp256k1, Address, Network, OutPoint, Txid,
|
||||
};
|
||||
use lianad::bip329::Labels;
|
||||
use lianad::commands::UpdateDerivIndexesResult;
|
||||
use lianad::{
|
||||
commands::{CoinStatus, LabelItem, TransactionInfo},
|
||||
config::Config,
|
||||
StartupError,
|
||||
};
|
||||
|
||||
use crate::app::settings::Settings;
|
||||
use crate::{hw::HardwareWalletConfig, node};
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -42,6 +45,8 @@ pub enum DaemonError {
|
||||
ClientNotSupported,
|
||||
/// Error when selecting coins for spend.
|
||||
CoinSelectionError,
|
||||
/// Not implemented feature
|
||||
NotImplemented,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DaemonError {
|
||||
@ -56,6 +61,7 @@ impl std::fmt::Display for DaemonError {
|
||||
Self::Start(e) => write!(f, "Daemon did not start: {}", e),
|
||||
Self::ClientNotSupported => write!(f, "Daemon communication is not supported"),
|
||||
Self::CoinSelectionError => write!(f, "Coin selection error"),
|
||||
Self::NotImplemented => write!(f, "This feature is not implemented for this backend"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -81,6 +87,11 @@ pub trait Daemon: Debug {
|
||||
async fn stop(&self) -> Result<(), DaemonError>;
|
||||
async fn get_info(&self) -> Result<model::GetInfoResult, DaemonError>;
|
||||
async fn get_new_address(&self) -> Result<model::GetAddressResult, DaemonError>;
|
||||
async fn update_deriv_indexes(
|
||||
&self,
|
||||
receive: Option<u32>,
|
||||
change: Option<u32>,
|
||||
) -> Result<UpdateDerivIndexesResult, DaemonError>;
|
||||
async fn list_coins(
|
||||
&self,
|
||||
statuses: &[CoinStatus],
|
||||
@ -125,6 +136,7 @@ pub trait Daemon: Debug {
|
||||
&self,
|
||||
labels: &HashMap<LabelItem, Option<String>>,
|
||||
) -> Result<(), DaemonError>;
|
||||
async fn get_labels_bip329(&self, offset: u32, limit: u32) -> Result<Labels, DaemonError>;
|
||||
async fn send_wallet_invitation(&self, _email: &str) -> Result<(), DaemonError> {
|
||||
Ok(())
|
||||
}
|
||||
@ -354,12 +366,33 @@ pub trait Daemon: Debug {
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Implemented by LianaLite backend
|
||||
/// Reimplemented by LianaLite backend
|
||||
async fn update_wallet_metadata(
|
||||
&self,
|
||||
_fingerprint_aliases: &HashMap<Fingerprint, String>,
|
||||
fingerprint_aliases: &HashMap<Fingerprint, String>,
|
||||
_hws: &[HardwareWalletConfig],
|
||||
) -> Result<(), DaemonError> {
|
||||
if let Some(datadir) = self
|
||||
.config()
|
||||
.ok_or(DaemonError::Unexpected("Config missing".into()))?
|
||||
.data_dir()
|
||||
{
|
||||
let network = self.get_info().await?.network;
|
||||
let mut settings = Settings::from_file(datadir, network)
|
||||
.map_err(|_| DaemonError::Unexpected("Fail to read Settings from file".into()))?;
|
||||
let wallet = if settings.wallets.len() == 1 {
|
||||
settings.wallets.get_mut(0).expect("already checked")
|
||||
} else {
|
||||
return Err(DaemonError::Unexpected(
|
||||
"Settings file contains more than one wallet".into(),
|
||||
));
|
||||
};
|
||||
for fg in wallet.keys_aliases().keys() {
|
||||
if fingerprint_aliases.contains_key(fg) {
|
||||
wallet.update_alias(fg, fingerprint_aliases.get(fg).expect("checked"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
app::settings::KeySetting,
|
||||
backup::Backup,
|
||||
lianalite::client::backend::{BackendClient, BackendWalletClient},
|
||||
node::bitcoind::{Bitcoind, InternalBitcoindConfig},
|
||||
signer::Signer,
|
||||
@ -69,6 +70,7 @@ pub struct Context {
|
||||
pub internal_bitcoind_config: Option<InternalBitcoindConfig>,
|
||||
pub internal_bitcoind: Option<Bitcoind>,
|
||||
pub remote_backend: RemoteBackend,
|
||||
pub backup: Option<Backup>,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
@ -95,6 +97,7 @@ impl Context {
|
||||
internal_bitcoind_config: None,
|
||||
internal_bitcoind: None,
|
||||
remote_backend,
|
||||
backup: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,17 @@ use liana::miniscript::{
|
||||
bitcoin::{bip32::Fingerprint, Network},
|
||||
DescriptorPublicKey,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use super::{context, Error};
|
||||
use crate::{
|
||||
app::settings::ProviderKey,
|
||||
app::{
|
||||
settings::{self, ProviderKey},
|
||||
view::Close,
|
||||
},
|
||||
backup::{self, Backup},
|
||||
download::{DownloadError, Progress},
|
||||
export::ImportExportMessage,
|
||||
hw::HardwareWalletMessage,
|
||||
installer::descriptor::{Key, PathKind},
|
||||
lianalite::client::{auth::AuthClient, backend::api},
|
||||
@ -49,6 +54,23 @@ pub enum Message {
|
||||
RedeemNextKey,
|
||||
KeyRedeemed(ProviderKey, Result<(), services::Error>),
|
||||
AllKeysRedeemed,
|
||||
BackupWallet,
|
||||
ExportWallet(Result<String, backup::Error>),
|
||||
ImportExport(ImportExportMessage),
|
||||
ImportBackup,
|
||||
WalletFromBackup((HashMap<Fingerprint, settings::KeySetting>, Backup)),
|
||||
}
|
||||
|
||||
impl Close for Message {
|
||||
fn close() -> Self {
|
||||
Self::Close
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ImportExportMessage> for Message {
|
||||
fn from(value: ImportExportMessage) -> Self {
|
||||
Message::ImportExport(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@ -5,6 +5,7 @@ mod prompt;
|
||||
mod step;
|
||||
mod view;
|
||||
|
||||
pub use context::{Context, RemoteBackend};
|
||||
use iced::{clipboard, Subscription, Task};
|
||||
use liana::miniscript::bitcoin::{self, Network};
|
||||
use liana_ui::{
|
||||
@ -14,8 +15,6 @@ use liana_ui::{
|
||||
use lianad::config::Config;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use context::{Context, RemoteBackend};
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@ -26,6 +25,7 @@ use crate::{
|
||||
settings::{self as gui_settings, AuthConfig, Settings, SettingsError, WalletSetting},
|
||||
wallet::wallet_name,
|
||||
},
|
||||
backup,
|
||||
daemon::DaemonError,
|
||||
datadir::create_directory,
|
||||
hw::{HardwareWalletConfig, HardwareWallets},
|
||||
@ -66,7 +66,7 @@ pub struct Installer {
|
||||
signer: Arc<Mutex<Signer>>,
|
||||
|
||||
/// Context is data passed through each step.
|
||||
context: Context,
|
||||
pub context: Context,
|
||||
}
|
||||
|
||||
impl Installer {
|
||||
@ -292,6 +292,11 @@ impl Installer {
|
||||
.expect("There is always a step")
|
||||
.update(&mut self.hws, Message::Installed(Err(e)))
|
||||
}
|
||||
Message::WalletFromBackup((ks, backup)) => {
|
||||
self.context.keys = ks;
|
||||
self.context.backup = Some(backup);
|
||||
Task::none()
|
||||
}
|
||||
_ => self
|
||||
.steps
|
||||
.get_mut(self.current)
|
||||
@ -429,7 +434,7 @@ pub async fn install_local_wallet(
|
||||
info!("Gui configuration file created");
|
||||
|
||||
// create liana GUI settings file
|
||||
let settings: gui_settings::Settings = extract_local_gui_settings(&ctx).await;
|
||||
let settings: gui_settings::Settings = extract_local_gui_settings(&ctx);
|
||||
create_and_write_file(
|
||||
network_datadir_path,
|
||||
gui_settings::DEFAULT_FILE_NAME,
|
||||
@ -669,7 +674,7 @@ pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletC
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract_local_gui_settings(ctx: &Context) -> Settings {
|
||||
pub fn extract_local_gui_settings(ctx: &Context) -> Settings {
|
||||
let descriptor = ctx
|
||||
.descriptor
|
||||
.as_ref()
|
||||
@ -731,6 +736,7 @@ pub enum Error {
|
||||
CannotGetAvailablePort(String),
|
||||
Unexpected(String),
|
||||
HardwareWallet(async_hwi::Error),
|
||||
Backup(backup::Error),
|
||||
}
|
||||
|
||||
impl From<jsonrpc::simple_http::Error> for Error {
|
||||
@ -784,6 +790,7 @@ impl std::fmt::Display for Error {
|
||||
Self::CannotCreateFile(e) => write!(f, "Failed to create file: {}", e),
|
||||
Self::Unexpected(e) => write!(f, "Unexpected: {}", e),
|
||||
Self::HardwareWallet(e) => write!(f, "Hardware Wallet: {}", e),
|
||||
Self::Backup(e) => write!(f, "Backup: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
pub const BACKUP_DESCRIPTOR_MESSAGE: &str = "The descriptor is necessary to recover your funds. The backup of your key (via mnemonics, sometimes called 'seed words') is not enough. Please make sure you have backed up both your private key and your descriptor.";
|
||||
pub const BACKUP_DESCRIPTOR_HELP: &str = "In Bitcoin, the coins are locked using a Script (related to the 'address'). In order to recover your funds you need to know both the Scripts you have participated in (your 'addresses'), and be able to sign a transaction that spends from those. For the ability to sign, you back up your private key, this is your mnemonic ('seed words'). For finding the coins that belong to you, you back up a template of your Script (your 'addresses'), this is your descriptor. However, note the descriptor need not be stored as securely as the private key. A thief who steals your descriptor but not your private key cannot steal your funds.";
|
||||
pub const BACKUP_DESCRIPTOR_MESSAGE: &str = "A backup of your wallet configuration is necessary to recover your funds. Please make sure to store your Wallet backup file (or alternatively to copy and paste the descriptor string) in one or more secure and accessible locations. You still need to back up your seed phrases too, since they are not included in the file.";
|
||||
pub const BACKUP_DESCRIPTOR_HELP: &str = "In Bitcoin, the coins are locked using a Script (related to the 'address'). In order to recover your funds you need to know both the Scripts you have participated in (your 'addresses'), and be able to sign a transaction that spends from those. For the ability to sign, you back up your private key, this is your mnemonic ('seed words'). For finding the coins that belong to you, you back up a template of your Script (your 'addresses'), this is your descriptor, included in your wallet backup file. However, note the descriptor need not be stored as securely as the private key. A thief who steals your descriptor but not your private key cannot steal your funds.";
|
||||
pub const DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP: &str =
|
||||
"The alias is applied on all the keys derived from the same seed";
|
||||
pub const REGISTER_DESCRIPTOR_HELP: &str = "To be used with the wallet, a signing device needs the descriptor. If the descriptor contains one or more keys imported from an external signing device, the descriptor must be registered on it. Registration confirms that the device is able to handle the policy. Registration on a device is not a substitute for backing up the descriptor.";
|
||||
|
||||
@ -16,7 +16,9 @@ use liana_ui::{component::form, widget::Element};
|
||||
use async_hwi::DeviceKind;
|
||||
|
||||
use crate::{
|
||||
app::{settings::KeySetting, wallet::wallet_name},
|
||||
app::{settings::KeySetting, state::export::ExportModal, wallet::wallet_name},
|
||||
backup::{self, Backup},
|
||||
export::{ImportExportMessage, ImportExportType, Progress},
|
||||
hw::{HardwareWallet, HardwareWallets},
|
||||
installer::{
|
||||
message::{self, Message},
|
||||
@ -30,6 +32,8 @@ pub struct ImportDescriptor {
|
||||
imported_descriptor: form::Value<String>,
|
||||
wrong_network: bool,
|
||||
error: Option<String>,
|
||||
modal: Option<ExportModal>,
|
||||
imported_backup: bool,
|
||||
}
|
||||
|
||||
impl ImportDescriptor {
|
||||
@ -39,6 +43,8 @@ impl ImportDescriptor {
|
||||
imported_descriptor: form::Value::default(),
|
||||
wrong_network: false,
|
||||
error: None,
|
||||
modal: None,
|
||||
imported_backup: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,14 +81,55 @@ impl Step for ImportDescriptor {
|
||||
fn skip(&self, ctx: &Context) -> bool {
|
||||
ctx.remote_backend.is_some()
|
||||
}
|
||||
// form value is set as valid each time it is edited.
|
||||
// Verification of the values is happening when the user click on Next button.
|
||||
|
||||
fn subscription(&self, _hws: &HardwareWallets) -> Subscription<Message> {
|
||||
if let Some(modal) = &self.modal {
|
||||
if let Some(sub) = modal.subscription() {
|
||||
sub.map(|m| Message::ImportExport(ImportExportMessage::Progress(m)))
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Task<Message> {
|
||||
if let Message::DefineDescriptor(message::DefineDescriptor::ImportDescriptor(desc)) =
|
||||
message
|
||||
{
|
||||
self.imported_descriptor.value = desc;
|
||||
self.check_descriptor(self.network);
|
||||
match message {
|
||||
Message::DefineDescriptor(message::DefineDescriptor::ImportDescriptor(desc)) => {
|
||||
self.imported_descriptor.value = desc;
|
||||
self.check_descriptor(self.network);
|
||||
}
|
||||
Message::ImportExport(ImportExportMessage::Close) => {
|
||||
self.modal = None;
|
||||
}
|
||||
Message::ImportBackup => {
|
||||
if !self.imported_backup {
|
||||
let modal = ExportModal::new(None, ImportExportType::WalletFromBackup);
|
||||
let launch = modal.launch(false);
|
||||
self.modal = Some(modal);
|
||||
return launch;
|
||||
}
|
||||
}
|
||||
Message::ImportExport(ImportExportMessage::Progress(Progress::WalletFromBackup(r))) => {
|
||||
let (descriptor, network, aliases, backup) = r;
|
||||
if self.network == network {
|
||||
self.imported_backup = true;
|
||||
self.imported_descriptor.value = descriptor.to_string();
|
||||
return Task::perform(async move { (aliases, backup) }, |(a, b)| {
|
||||
Message::WalletFromBackup((a, b))
|
||||
});
|
||||
} else {
|
||||
self.error = Some("Backup network do not match the selected network!".into());
|
||||
}
|
||||
}
|
||||
Message::ImportExport(m) => {
|
||||
if let Some(modal) = self.modal.as_mut() {
|
||||
let task: Task<Message> = modal.update(m);
|
||||
return task;
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@ -106,13 +153,19 @@ impl Step for ImportDescriptor {
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::import_descriptor(
|
||||
let content = view::import_descriptor(
|
||||
progress,
|
||||
email,
|
||||
&self.imported_descriptor,
|
||||
self.imported_backup,
|
||||
self.wrong_network,
|
||||
self.error.as_ref(),
|
||||
)
|
||||
);
|
||||
if let Some(modal) = &self.modal {
|
||||
modal.view(content)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -292,16 +345,71 @@ pub struct BackupDescriptor {
|
||||
done: bool,
|
||||
descriptor: Option<LianaDescriptor>,
|
||||
keys: HashMap<Fingerprint, KeySetting>,
|
||||
modal: Option<ExportModal>,
|
||||
error: Option<Error>,
|
||||
context: Option<Context>,
|
||||
}
|
||||
|
||||
impl Step for BackupDescriptor {
|
||||
fn subscription(&self, _hws: &HardwareWallets) -> Subscription<Message> {
|
||||
if let Some(modal) = &self.modal {
|
||||
if let Some(sub) = modal.subscription() {
|
||||
sub.map(|m| Message::ImportExport(ImportExportMessage::Progress(m)))
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Task<Message> {
|
||||
if let Message::UserActionDone(done) = message {
|
||||
self.done = done;
|
||||
match message {
|
||||
Message::ImportExport(ImportExportMessage::Close) => {
|
||||
self.modal = None;
|
||||
}
|
||||
Message::ImportExport(m) => {
|
||||
if let Some(modal) = self.modal.as_mut() {
|
||||
let task: Task<Message> = modal.update(m);
|
||||
return task;
|
||||
};
|
||||
}
|
||||
Message::BackupWallet => {
|
||||
if let (None, Some(ctx)) = (&self.modal, self.context.as_ref()) {
|
||||
let ctx = ctx.clone();
|
||||
return Task::perform(
|
||||
async move {
|
||||
let backup = Backup::from_installer(ctx, true).await?;
|
||||
serde_json::to_string_pretty(&backup).map_err(|_| backup::Error::Json)
|
||||
},
|
||||
Message::ExportWallet,
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::ExportWallet(str) => {
|
||||
if self.modal.is_none() {
|
||||
let str = match str {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("{e:?}");
|
||||
self.error = Some(Error::Backup(e));
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let modal = ExportModal::new(None, ImportExportType::ExportBackup(str));
|
||||
let launch = modal.launch(true);
|
||||
self.modal = Some(modal);
|
||||
return launch;
|
||||
}
|
||||
}
|
||||
Message::UserActionDone(done) => {
|
||||
self.done = done;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn load_context(&mut self, ctx: &Context) {
|
||||
self.context = Some(ctx.clone());
|
||||
if self.descriptor != ctx.descriptor {
|
||||
self.descriptor.clone_from(&ctx.descriptor);
|
||||
self.done = false;
|
||||
@ -318,13 +426,19 @@ impl Step for BackupDescriptor {
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::backup_descriptor(
|
||||
let content = view::backup_descriptor(
|
||||
progress,
|
||||
email,
|
||||
self.descriptor.as_ref().expect("Must be a descriptor"),
|
||||
&self.keys,
|
||||
self.error.as_ref(),
|
||||
self.done,
|
||||
)
|
||||
);
|
||||
if let Some(modal) = &self.modal {
|
||||
modal.view(content)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -264,11 +264,15 @@ pub fn import_descriptor<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
imported_descriptor: &form::Value<String>,
|
||||
imported_backup: bool,
|
||||
wrong_network: bool,
|
||||
error: Option<&String>,
|
||||
) -> Element<'a, Message> {
|
||||
let valid = !imported_descriptor.value.is_empty() && imported_descriptor.valid;
|
||||
|
||||
let col_descriptor = Column::new()
|
||||
.push(text("Descriptor:").bold())
|
||||
.push(Space::with_height(10))
|
||||
.push(
|
||||
form::Form::new_trimmed("Descriptor", imported_descriptor, |msg| {
|
||||
Message::DefineDescriptor(message::DefineDescriptor::ImportDescriptor(msg))
|
||||
@ -280,21 +284,65 @@ pub fn import_descriptor<'a>(
|
||||
})
|
||||
.size(text::P1_SIZE)
|
||||
.padding(10),
|
||||
);
|
||||
|
||||
let descriptor = if imported_backup {
|
||||
None
|
||||
} else {
|
||||
Some(col_descriptor)
|
||||
};
|
||||
|
||||
let or = if !valid && !imported_backup {
|
||||
Some(
|
||||
Row::new()
|
||||
.push(text("or").bold())
|
||||
.push(Space::with_width(Length::Fill)),
|
||||
)
|
||||
.spacing(10);
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let import_backup = if !valid && !imported_backup {
|
||||
Some(
|
||||
Row::new()
|
||||
.push(button::secondary(None, "Import backup").on_press(Message::ImportBackup))
|
||||
.push(Space::with_width(Length::Fill)),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let backup_imported = if imported_backup {
|
||||
Some(
|
||||
Row::new()
|
||||
.push(text("Backup successfuly imported!").bold())
|
||||
.push(Space::with_width(Length::Fill)),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Import the wallet",
|
||||
Column::new()
|
||||
.push(Column::new().spacing(20).push(col_descriptor).push(text(
|
||||
"If you are using a Bitcoin Core node, \
|
||||
.push(
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push_maybe(descriptor)
|
||||
.push_maybe(or)
|
||||
.push_maybe(import_backup)
|
||||
.push_maybe(backup_imported)
|
||||
.push(text(
|
||||
"If you are using a Bitcoin Core node, \
|
||||
you will need to perform a rescan of \
|
||||
the blockchain after creating the wallet \
|
||||
in order to see your coins and past \
|
||||
transactions. This can be done in \
|
||||
Settings > Node.",
|
||||
)))
|
||||
)),
|
||||
)
|
||||
.push(
|
||||
if imported_descriptor.value.is_empty() || !imported_descriptor.valid {
|
||||
button::secondary(None, "Next").width(Length::Fixed(200.0))
|
||||
@ -689,12 +737,20 @@ pub fn backup_descriptor<'a>(
|
||||
email: Option<&'a str>,
|
||||
descriptor: &'a LianaDescriptor,
|
||||
keys: &'a HashMap<Fingerprint, settings::KeySetting>,
|
||||
error: Option<&Error>,
|
||||
done: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let backup_button = if done {
|
||||
button::secondary(Some(icon::backup_icon()), "Back Up Wallet")
|
||||
.on_press(Message::BackupWallet)
|
||||
} else {
|
||||
button::primary(Some(icon::backup_icon()), "Back Up Wallet").on_press(Message::BackupWallet)
|
||||
};
|
||||
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Backup your wallet descriptor",
|
||||
"Back Up your wallet",
|
||||
Column::new()
|
||||
.push(
|
||||
Column::new()
|
||||
@ -724,6 +780,7 @@ pub fn backup_descriptor<'a>(
|
||||
))
|
||||
.max_width(1000),
|
||||
)
|
||||
.push_maybe(error.map(|e| card::error("Failed to export backup", e.to_string())))
|
||||
.push(
|
||||
card::simple(
|
||||
Column::new()
|
||||
@ -741,10 +798,14 @@ pub fn backup_descriptor<'a>(
|
||||
),
|
||||
)
|
||||
.push(
|
||||
Row::new().push(Column::new().width(Length::Fill)).push(
|
||||
button::secondary(Some(icon::clipboard_icon()), "Copy")
|
||||
.on_press(Message::Clibpboard(descriptor.to_string())),
|
||||
),
|
||||
Row::new()
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(backup_button)
|
||||
.push(Space::with_width(10))
|
||||
.push(
|
||||
button::secondary(Some(icon::clipboard_icon()), "Copy")
|
||||
.on_press(Message::Clibpboard(descriptor.to_string())),
|
||||
),
|
||||
)
|
||||
.spacing(10),
|
||||
)
|
||||
@ -756,10 +817,11 @@ pub fn backup_descriptor<'a>(
|
||||
.max_width(1500),
|
||||
)
|
||||
.push(
|
||||
checkbox("I have backed up my descriptor", done).on_toggle(Message::UserActionDone),
|
||||
checkbox("I have backed up my wallet/descriptor", done)
|
||||
.on_toggle(Message::UserActionDone),
|
||||
)
|
||||
.push(if done {
|
||||
button::secondary(None, "Next")
|
||||
button::primary(None, "Next")
|
||||
.on_press(Message::Next)
|
||||
.width(Length::Fixed(200.0))
|
||||
} else {
|
||||
|
||||
@ -105,6 +105,8 @@ pub struct Wallet {
|
||||
pub name: String,
|
||||
#[serde(deserialize_with = "deser_fromstr")]
|
||||
pub descriptor: LianaDescriptor,
|
||||
pub deposit_derivation_index: u32,
|
||||
pub change_derivation_index: u32,
|
||||
pub recovery_paths: Vec<RecoveryPath>,
|
||||
pub biggest_remaining_sequence: Option<u32>,
|
||||
pub smallest_remaining_sequence: Option<u32>,
|
||||
@ -333,6 +335,11 @@ pub struct ListPsbts {
|
||||
pub psbts: Vec<Psbt>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Labels {
|
||||
pub labels: lianad::bip329::Labels,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Address {
|
||||
#[serde(deserialize_with = "deser_addr_assume_checked")]
|
||||
|
||||
@ -13,7 +13,8 @@ use liana::{
|
||||
miniscript::bitcoin::{address, psbt::Psbt, Address, Network, OutPoint, Txid},
|
||||
};
|
||||
use lianad::{
|
||||
commands::{CoinStatus, GetInfoDescriptors, LCSpendInfo, LabelItem},
|
||||
bip329::Labels,
|
||||
commands::{CoinStatus, GetInfoDescriptors, LCSpendInfo, LabelItem, UpdateDerivIndexesResult},
|
||||
config::Config,
|
||||
};
|
||||
use reqwest::{Error, IntoUrl, Method, RequestBuilder, Response};
|
||||
@ -593,6 +594,8 @@ impl Daemon for BackendWalletClient {
|
||||
timestamp: wallet.created_at as u32,
|
||||
// We can ignore this field for remote backend as the wallet should remain synced.
|
||||
last_poll_timestamp: None,
|
||||
receive_index: wallet.deposit_derivation_index,
|
||||
change_index: wallet.change_derivation_index,
|
||||
})
|
||||
}
|
||||
|
||||
@ -624,6 +627,14 @@ impl Daemon for BackendWalletClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_deriv_indexes(
|
||||
&self,
|
||||
_receive: Option<u32>,
|
||||
_change: Option<u32>,
|
||||
) -> Result<UpdateDerivIndexesResult, DaemonError> {
|
||||
Err(DaemonError::NotImplemented)
|
||||
}
|
||||
|
||||
/// Spent coins are not returned if statuses is empty, unless their outpoints are specified.
|
||||
async fn list_coins(
|
||||
&self,
|
||||
@ -1115,6 +1126,24 @@ impl Daemon for BackendWalletClient {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_labels_bip329(&self, offset: u32, limit: u32) -> Result<Labels, DaemonError> {
|
||||
let response: Response = self
|
||||
.inner
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!(
|
||||
"{}/v1/wallets/{}/labels/bip329?offset={}&limit={}",
|
||||
self.inner.url, self.wallet_uuid, offset, limit
|
||||
),
|
||||
)
|
||||
.await
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let res: api::Labels = response.json().await?;
|
||||
Ok(res.labels)
|
||||
}
|
||||
}
|
||||
|
||||
fn history_tx_from_api(value: api::Transaction, network: Network) -> HistoryTransaction {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod backup;
|
||||
pub mod daemon;
|
||||
pub mod datadir;
|
||||
pub mod download;
|
||||
|
||||
@ -23,6 +23,9 @@ use lianad::{
|
||||
StartupError,
|
||||
};
|
||||
|
||||
use crate::app;
|
||||
use crate::backup::Backup;
|
||||
use crate::export::RestoreBackupError;
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache,
|
||||
@ -56,7 +59,7 @@ pub struct Loader {
|
||||
pub daemon_started: bool,
|
||||
pub internal_bitcoind: Option<Bitcoind>,
|
||||
pub waiting_daemon_bitcoind: bool,
|
||||
|
||||
pub backup: Option<Backup>,
|
||||
step: Step,
|
||||
}
|
||||
|
||||
@ -83,6 +86,20 @@ pub enum Message {
|
||||
Cache,
|
||||
Arc<dyn Daemon + Sync + Send>,
|
||||
Option<Bitcoind>,
|
||||
Option<Backup>,
|
||||
),
|
||||
Error,
|
||||
>,
|
||||
),
|
||||
App(
|
||||
Result<
|
||||
(
|
||||
Cache,
|
||||
Arc<Wallet>,
|
||||
app::Config,
|
||||
Arc<dyn Daemon + Sync + Send>,
|
||||
PathBuf,
|
||||
Option<Bitcoind>,
|
||||
),
|
||||
Error,
|
||||
>,
|
||||
@ -100,6 +117,7 @@ impl Loader {
|
||||
gui_config: GUIConfig,
|
||||
network: bitcoin::Network,
|
||||
internal_bitcoind: Option<Bitcoind>,
|
||||
backup: Option<Backup>,
|
||||
) -> (Self, Task<Message>) {
|
||||
let path = gui_config
|
||||
.daemon_rpc_path
|
||||
@ -114,6 +132,7 @@ impl Loader {
|
||||
daemon_started: false,
|
||||
internal_bitcoind,
|
||||
waiting_daemon_bitcoind: false,
|
||||
backup,
|
||||
},
|
||||
Task::perform(connect(path), Message::Loaded),
|
||||
)
|
||||
@ -134,6 +153,7 @@ impl Loader {
|
||||
self.datadir_path.clone(),
|
||||
self.network,
|
||||
self.internal_bitcoind.clone(),
|
||||
self.backup.clone(),
|
||||
),
|
||||
Message::Synced,
|
||||
);
|
||||
@ -232,6 +252,7 @@ impl Loader {
|
||||
self.datadir_path.clone(),
|
||||
self.network,
|
||||
self.internal_bitcoind.clone(),
|
||||
self.backup.clone(),
|
||||
),
|
||||
Message::Synced,
|
||||
);
|
||||
@ -290,6 +311,7 @@ impl Loader {
|
||||
self.gui_config.clone(),
|
||||
self.network,
|
||||
self.internal_bitcoind.clone(),
|
||||
self.backup.clone(),
|
||||
);
|
||||
*self = loader;
|
||||
cmd
|
||||
@ -391,12 +413,14 @@ pub async fn load_application(
|
||||
datadir_path: PathBuf,
|
||||
network: bitcoin::Network,
|
||||
internal_bitcoind: Option<Bitcoind>,
|
||||
backup: Option<Backup>,
|
||||
) -> Result<
|
||||
(
|
||||
Arc<Wallet>,
|
||||
Cache,
|
||||
Arc<dyn Daemon + Sync + Send>,
|
||||
Option<Bitcoind>,
|
||||
Option<Backup>,
|
||||
),
|
||||
Error,
|
||||
> {
|
||||
@ -421,7 +445,7 @@ pub async fn load_application(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok((Arc::new(wallet), cache, daemon, internal_bitcoind))
|
||||
Ok((Arc::new(wallet), cache, daemon, internal_bitcoind, backup))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@ -582,6 +606,7 @@ pub enum Error {
|
||||
Daemon(DaemonError),
|
||||
Bitcoind(StartInternalBitcoindError),
|
||||
BitcoindLogs(std::io::Error),
|
||||
RestoreBackup(RestoreBackupError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
@ -592,6 +617,7 @@ impl std::fmt::Display for Error {
|
||||
Self::Daemon(e) => write!(f, "Liana daemon error: {}", e),
|
||||
Self::Bitcoind(e) => write!(f, "Bitcoind error: {}", e),
|
||||
Self::BitcoindLogs(e) => write!(f, "Bitcoind logs error: {}", e),
|
||||
Self::RestoreBackup(e) => write!(f, "Restore backup: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,11 +24,12 @@ use lianad::config::Config as DaemonConfig;
|
||||
use liana_gui::{
|
||||
app::{self, cache::Cache, config::default_datadir, wallet::Wallet, App},
|
||||
datadir,
|
||||
export::import_backup_at_launch,
|
||||
hw::HardwareWalletConfig,
|
||||
installer::{self, Installer},
|
||||
launcher::{self, Launcher},
|
||||
lianalite::{
|
||||
client::{backend::api, backend::BackendWalletClient},
|
||||
client::backend::{api, BackendWalletClient},
|
||||
login,
|
||||
},
|
||||
loader::{self, Loader},
|
||||
@ -162,7 +163,7 @@ impl GUI {
|
||||
network,
|
||||
log_level.unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)),
|
||||
);
|
||||
let (loader, command) = Loader::new(datadir_path, cfg, network, None);
|
||||
let (loader, command) = Loader::new(datadir_path, cfg, network, None, None);
|
||||
cmds.push(command.map(|msg| Message::Load(Box::new(msg))));
|
||||
State::Loader(Box::new(loader))
|
||||
}
|
||||
@ -242,12 +243,13 @@ impl GUI {
|
||||
self.state = State::Login(Box::new(login));
|
||||
command.map(|msg| Message::Login(Box::new(msg)))
|
||||
} else {
|
||||
let (loader, command) = Loader::new(datadir_path, cfg, network, None);
|
||||
let (loader, command) =
|
||||
Loader::new(datadir_path, cfg, network, None, None);
|
||||
self.state = State::Loader(Box::new(loader));
|
||||
command.map(|msg| Message::Load(Box::new(msg)))
|
||||
}
|
||||
} else {
|
||||
let (loader, command) = Loader::new(datadir_path, cfg, network, None);
|
||||
let (loader, command) = Loader::new(datadir_path, cfg, network, None, None);
|
||||
self.state = State::Loader(Box::new(loader));
|
||||
command.map(|msg| Message::Load(Box::new(msg)))
|
||||
}
|
||||
@ -334,6 +336,7 @@ impl GUI {
|
||||
cfg,
|
||||
daemon_cfg.bitcoin_config.network,
|
||||
internal_bitcoind,
|
||||
i.context.backup.take(),
|
||||
);
|
||||
self.state = State::Loader(Box::new(loader));
|
||||
command.map(|msg| Message::Load(Box::new(msg)))
|
||||
@ -353,18 +356,45 @@ impl GUI {
|
||||
self.state = State::Launcher(Box::new(launcher));
|
||||
command.map(|msg| Message::Launch(Box::new(msg)))
|
||||
}
|
||||
loader::Message::Synced(Ok((wallet, cache, daemon, bitcoind))) => {
|
||||
let (app, command) = App::new(
|
||||
cache,
|
||||
wallet,
|
||||
loader.gui_config.clone(),
|
||||
daemon,
|
||||
loader.datadir_path.clone(),
|
||||
bitcoind,
|
||||
);
|
||||
loader::Message::Synced(Ok((wallet, cache, daemon, bitcoind, backup))) => {
|
||||
if let Some(backup) = backup {
|
||||
let config = loader.gui_config.clone();
|
||||
let datadir = loader.datadir_path.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
import_backup_at_launch(
|
||||
cache, wallet, config, daemon, datadir, bitcoind, backup,
|
||||
)
|
||||
.await
|
||||
},
|
||||
|r| {
|
||||
let r = r.map_err(loader::Error::RestoreBackup);
|
||||
Message::Load(Box::new(loader::Message::App(r)))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let (app, command) = App::new(
|
||||
cache,
|
||||
wallet,
|
||||
loader.gui_config.clone(),
|
||||
daemon,
|
||||
loader.datadir_path.clone(),
|
||||
bitcoind,
|
||||
);
|
||||
self.state = State::App(app);
|
||||
command.map(|msg| Message::Run(Box::new(msg)))
|
||||
}
|
||||
}
|
||||
loader::Message::App(Ok((cache, wallet, config, daemon, datadir, bitcoind))) => {
|
||||
let (app, command) = App::new(cache, wallet, config, daemon, datadir, bitcoind);
|
||||
self.state = State::App(app);
|
||||
command.map(|msg| Message::Run(Box::new(msg)))
|
||||
}
|
||||
loader::Message::App(Err(e)) => {
|
||||
tracing::error!("Fail to import backup: {e}");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
_ => loader.update(*msg).map(|msg| Message::Load(Box::new(msg))),
|
||||
},
|
||||
(State::App(i), Message::Run(msg)) => {
|
||||
@ -574,6 +604,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
fonts: font::load(),
|
||||
};
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut window_settings = iced::window::Settings {
|
||||
icon: Some(image::liana_app_icon()),
|
||||
position: iced::window::Position::Default,
|
||||
|
||||
@ -6,6 +6,7 @@ use liana::{
|
||||
};
|
||||
use liana_ui::component::form;
|
||||
use lianad::config::BitcoindConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
@ -154,7 +155,7 @@ impl std::fmt::Display for RpcAuthParseError {
|
||||
}
|
||||
|
||||
/// Represents RPC auth credentials as stored in bitcoin.conf.
|
||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcAuth {
|
||||
pub user: String,
|
||||
salt: String,
|
||||
@ -209,7 +210,7 @@ impl std::str::FromStr for RpcAuth {
|
||||
}
|
||||
|
||||
/// Represents section for a single network in `bitcoin.conf` file.
|
||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InternalBitcoindNetworkConfig {
|
||||
pub rpc_port: u16,
|
||||
pub p2p_port: u16,
|
||||
|
||||
@ -123,6 +123,14 @@ pub fn round_key_icon() -> Text<'static> {
|
||||
bootstrap_icon('\u{F44E}')
|
||||
}
|
||||
|
||||
pub fn backup_icon() -> Text<'static> {
|
||||
bootstrap_icon('\u{F356}')
|
||||
}
|
||||
|
||||
pub fn restore_icon() -> Text<'static> {
|
||||
bootstrap_icon('\u{F358}')
|
||||
}
|
||||
|
||||
const ICONEX_ICONS: Font = Font::with_name("Untitled1");
|
||||
|
||||
fn iconex_icon(unicode: char) -> Text<'static> {
|
||||
|
||||
@ -55,3 +55,6 @@ rusqlite = { version = "0.30", features = ["bundled", "unlock_notify"] }
|
||||
|
||||
# To talk to bitcoind
|
||||
jsonrpc = { version = "0.17", features = ["minreq_http"], default-features = false }
|
||||
|
||||
# import/export labels
|
||||
bip329 = "0.3.0"
|
||||
|
||||
@ -36,7 +36,11 @@ use std::{
|
||||
};
|
||||
|
||||
use miniscript::{
|
||||
bitcoin::{self, address, bip32, psbt::Psbt},
|
||||
bitcoin::{
|
||||
self, address,
|
||||
bip32::{self, ChildNumber},
|
||||
psbt::Psbt,
|
||||
},
|
||||
psbt::PsbtExt,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -314,6 +318,8 @@ impl DaemonControl {
|
||||
let mut db_conn = self.db.connection();
|
||||
let block_height = db_conn.chain_tip().map(|tip| tip.height).unwrap_or(0);
|
||||
let wallet = db_conn.wallet();
|
||||
let receive_index: u32 = db_conn.receive_index().into();
|
||||
let change_index: u32 = db_conn.change_index().into();
|
||||
let rescan_progress = wallet
|
||||
.rescan_timestamp
|
||||
.map(|_| self.bitcoin.rescan_progress().unwrap_or(1.0));
|
||||
@ -328,6 +334,8 @@ impl DaemonControl {
|
||||
rescan_progress,
|
||||
timestamp: wallet.timestamp,
|
||||
last_poll_timestamp: wallet.last_poll_timestamp,
|
||||
receive_index,
|
||||
change_index,
|
||||
}
|
||||
}
|
||||
|
||||
@ -349,6 +357,60 @@ impl DaemonControl {
|
||||
GetAddressResult::new(address, index)
|
||||
}
|
||||
|
||||
/// Update derivation indexes
|
||||
pub fn update_deriv_indexes(
|
||||
&self,
|
||||
receive: Option<u32>,
|
||||
change: Option<u32>,
|
||||
) -> Result<UpdateDerivIndexesResult, CommandError> {
|
||||
let mut db_conn = self.db.connection();
|
||||
|
||||
const MAX_INCREMENT_GAP: u32 = 1_000;
|
||||
|
||||
let db_receive = db_conn.receive_index().into();
|
||||
let mut final_receive = db_receive;
|
||||
|
||||
let db_change = db_conn.change_index().into();
|
||||
let mut final_change = db_change;
|
||||
|
||||
if let Some(index) = receive {
|
||||
ChildNumber::from_normal_idx(index)
|
||||
.map_err(|_| CommandError::InvalidDerivationIndex)?;
|
||||
if index > db_receive {
|
||||
let delta = (index - db_receive).min(MAX_INCREMENT_GAP);
|
||||
let index = db_receive + delta;
|
||||
final_receive = index;
|
||||
match ChildNumber::from_normal_idx(index) {
|
||||
Ok(i) => {
|
||||
db_conn.set_receive_index(i, &self.secp);
|
||||
}
|
||||
Err(_) => return Err(CommandError::InvalidDerivationIndex),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(index) = change {
|
||||
ChildNumber::from_normal_idx(index)
|
||||
.map_err(|_| CommandError::InvalidDerivationIndex)?;
|
||||
if index > db_change {
|
||||
let delta = (index - db_change).min(MAX_INCREMENT_GAP);
|
||||
let index = db_change + delta;
|
||||
final_change = index;
|
||||
match ChildNumber::from_normal_idx(index) {
|
||||
Ok(i) => {
|
||||
db_conn.set_change_index(i, &self.secp);
|
||||
}
|
||||
Err(_) => return Err(CommandError::InvalidDerivationIndex),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UpdateDerivIndexesResult {
|
||||
receive: final_receive,
|
||||
change: final_change,
|
||||
})
|
||||
}
|
||||
|
||||
/// list addresses
|
||||
pub fn list_addresses(
|
||||
&self,
|
||||
@ -687,6 +749,13 @@ impl DaemonControl {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_labels_bip329(&self, offset: u32, limit: u32) -> GetLabelsBip329Result {
|
||||
let mut db_conn = self.db.connection();
|
||||
GetLabelsBip329Result {
|
||||
labels: db_conn.get_labels_bip329(offset, limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_spend(
|
||||
&self,
|
||||
txids: Option<Vec<bitcoin::Txid>>,
|
||||
@ -1161,6 +1230,16 @@ pub struct GetInfoResult {
|
||||
pub timestamp: u32,
|
||||
/// Timestamp of last poll, if any.
|
||||
pub last_poll_timestamp: Option<u32>,
|
||||
/// Last index used to generate a receive address
|
||||
pub receive_index: u32,
|
||||
/// Last index used to generate a change address
|
||||
pub change_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateDerivIndexesResult {
|
||||
pub receive: u32,
|
||||
pub change: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -1184,6 +1263,11 @@ pub struct GetLabelsResult {
|
||||
pub labels: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GetLabelsBip329Result {
|
||||
pub labels: crate::bip329::Labels,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct AddressInfo {
|
||||
index: u32,
|
||||
|
||||
@ -20,7 +20,8 @@ use std::{
|
||||
sync,
|
||||
};
|
||||
|
||||
use miniscript::bitcoin::{self, bip32, psbt::Psbt, secp256k1};
|
||||
use bip329::Labels;
|
||||
use miniscript::bitcoin::{self, bip32, psbt::Psbt, secp256k1, Address, Network, OutPoint, Txid};
|
||||
|
||||
/// Information about the wallet.
|
||||
///
|
||||
@ -190,6 +191,9 @@ pub trait DatabaseConnection {
|
||||
&mut self,
|
||||
txids: &[bitcoin::Txid],
|
||||
) -> Vec<(bitcoin::Transaction, Option<i32>, Option<u32>)>;
|
||||
|
||||
/// Dump all labels
|
||||
fn get_labels_bip329(&mut self, offset: u32, limit: u32) -> Labels;
|
||||
}
|
||||
|
||||
impl DatabaseConnection for SqliteConn {
|
||||
@ -367,6 +371,15 @@ impl DatabaseConnection for SqliteConn {
|
||||
HashMap::from_iter(labels.into_iter().map(|label| (label.item, label.value)))
|
||||
}
|
||||
|
||||
fn get_labels_bip329(&mut self, offset: u32, limit: u32) -> Labels {
|
||||
let labels = self
|
||||
.labels_bip329(offset, limit)
|
||||
.into_iter()
|
||||
.map(|l| l.into())
|
||||
.collect();
|
||||
Labels::new(labels)
|
||||
}
|
||||
|
||||
fn rollback_tip(&mut self, new_tip: &BlockChainTip) {
|
||||
self.rollback_tip(new_tip)
|
||||
}
|
||||
@ -558,6 +571,47 @@ impl LabelItem {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_bip329(label: &bip329::Label, network: Network) -> Option<(Self, String)> {
|
||||
match label {
|
||||
bip329::Label::Transaction(tx_record) => {
|
||||
if let (Some(txid), Some(label)) = (
|
||||
Txid::from_str(&tx_record.ref_.to_string()).ok(),
|
||||
tx_record.label.clone(),
|
||||
) {
|
||||
Some((Self::Txid(txid), label))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
bip329::Label::Address(address_record) => {
|
||||
if let (Some(addr), Some(label)) = (
|
||||
Address::from_str(&address_record.ref_.clone().assume_checked().to_string())
|
||||
.ok(),
|
||||
address_record.label.clone(),
|
||||
) {
|
||||
if addr.is_valid_for_network(network) {
|
||||
Some((Self::Address(addr.assume_checked()), label))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
bip329::Label::Output(output_record) => {
|
||||
if let (Some(outpoint), Some(label)) = (
|
||||
OutPoint::from_str(&output_record.ref_.to_string()).ok(),
|
||||
output_record.label.clone(),
|
||||
) {
|
||||
Some((Self::OutPoint(outpoint), label))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -678,6 +678,18 @@ impl SqliteConn {
|
||||
.expect("Db must not fail")
|
||||
}
|
||||
|
||||
pub fn labels_bip329(&mut self, offset: u32, limit: u32) -> Vec<DbLabel> {
|
||||
db_query(
|
||||
&mut self.conn,
|
||||
"SELECT * FROM labels \
|
||||
ORDER BY id \
|
||||
LIMIT ?1 OFFSET ?2",
|
||||
rusqlite::params![limit, offset],
|
||||
|row| row.try_into(),
|
||||
)
|
||||
.expect("Db must not fail")
|
||||
}
|
||||
|
||||
/// Retrieves a limited and ordered list of transactions ids that happened during the given
|
||||
/// range.
|
||||
pub fn db_list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec<bitcoin::Txid> {
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
use bip329::Label;
|
||||
use liana::descriptors::LianaDescriptor;
|
||||
|
||||
use std::{convert::TryFrom, str::FromStr};
|
||||
|
||||
use miniscript::bitcoin::{self, address, bip32, consensus::encode, psbt::Psbt};
|
||||
use miniscript::bitcoin::{
|
||||
self,
|
||||
address::{self, NetworkUnchecked},
|
||||
bip32,
|
||||
consensus::encode,
|
||||
psbt::Psbt,
|
||||
Address, OutPoint, Txid,
|
||||
};
|
||||
|
||||
// Due to limitations of Sqlite's ALTER TABLE command and in order not to recreate
|
||||
// tables during migration:
|
||||
@ -366,6 +374,40 @@ impl From<i64> for DbLabelledKind {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DbLabel> for Label {
|
||||
fn from(value: DbLabel) -> Self {
|
||||
let mut ref_ = value.item;
|
||||
if value.item_kind == DbLabelledKind::Txid {
|
||||
let frontward: Txid = bitcoin::consensus::encode::deserialize_hex(&ref_).unwrap();
|
||||
ref_ = frontward.to_string();
|
||||
}
|
||||
let label = if value.value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.value)
|
||||
};
|
||||
match value.item_kind {
|
||||
DbLabelledKind::Address => Label::Address(bip329::AddressRecord {
|
||||
ref_: Address::<NetworkUnchecked>::from_str(&ref_)
|
||||
.expect("db contains valid adresses"),
|
||||
label,
|
||||
}),
|
||||
DbLabelledKind::OutPoint => Label::Output(bip329::OutputRecord {
|
||||
ref_: OutPoint::from_str(&ref_).expect(" db contais valid outpoints"),
|
||||
label,
|
||||
spendable: true,
|
||||
}),
|
||||
DbLabelledKind::Txid => Label::Transaction(bip329::TransactionRecord {
|
||||
ref_: bitcoin::consensus::encode::deserialize_hex(&ref_)
|
||||
.expect("db contains valid txid"),
|
||||
label,
|
||||
// FIXME: "Optional key origin information referencing the wallet associated with the label"
|
||||
origin: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&rusqlite::Row<'_>> for DbLabel {
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
|
||||
@ -199,6 +199,50 @@ fn list_addresses(
|
||||
Ok(serde_json::json!(&res))
|
||||
}
|
||||
|
||||
fn update_deriv_indexes(
|
||||
control: &DaemonControl,
|
||||
params: Params,
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
let receive = params.get(0, "receive");
|
||||
let change = params.get(1, "change");
|
||||
|
||||
if receive.is_none() && change.is_none() {
|
||||
return Err(Error::invalid_params(
|
||||
"Missing 'receive' or 'change' parameter",
|
||||
));
|
||||
}
|
||||
|
||||
let receive = match receive {
|
||||
Some(i) => {
|
||||
let res = i.as_i64().ok_or(Error::invalid_params(
|
||||
"Invalid value for 'receive' param".to_string(),
|
||||
))?;
|
||||
let res = res
|
||||
.try_into()
|
||||
.map_err(|_| Error::invalid_params("Invalid value for 'receive' param"))?;
|
||||
Some(res)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let change = match change {
|
||||
Some(i) => {
|
||||
let res = i.as_i64().ok_or(Error::invalid_params(
|
||||
"Invalid value for 'change' param".to_string(),
|
||||
))?;
|
||||
let res = res
|
||||
.try_into()
|
||||
.map_err(|_| Error::invalid_params("Invalid value for 'change' param"))?;
|
||||
Some(res)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(serde_json::json!(
|
||||
control.update_deriv_indexes(receive, change)?
|
||||
))
|
||||
}
|
||||
|
||||
fn list_confirmed(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let start: u32 = params
|
||||
.get(0, "start")
|
||||
@ -364,6 +408,22 @@ fn get_labels(control: &DaemonControl, params: Params) -> Result<serde_json::Val
|
||||
Ok(serde_json::json!(control.get_labels(&items)))
|
||||
}
|
||||
|
||||
fn get_labels_bip329(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let offset: u32 = params
|
||||
.get(0, "offset")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'offset' parameter."))?
|
||||
.as_u64()
|
||||
.and_then(|t| t.try_into().ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'offset' parameter."))?;
|
||||
let limit: u32 = params
|
||||
.get(1, "limit")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'limit' parameter."))?
|
||||
.as_u64()
|
||||
.and_then(|t| t.try_into().ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'limit' parameter."))?;
|
||||
Ok(serde_json::json!(control.get_labels_bip329(offset, limit)))
|
||||
}
|
||||
|
||||
/// Handle an incoming JSONRPC2 request.
|
||||
pub fn handle_request(control: &mut DaemonControl, req: Request) -> Result<Response, Error> {
|
||||
let result = match req.method.as_str() {
|
||||
@ -401,6 +461,12 @@ pub fn handle_request(control: &mut DaemonControl, req: Request) -> Result<Respo
|
||||
}
|
||||
"getinfo" => serde_json::json!(&control.get_info()),
|
||||
"getnewaddress" => serde_json::json!(&control.get_new_address()),
|
||||
"updatederivationindexes" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params("Missing 'receive' or 'change' parameters.")
|
||||
})?;
|
||||
update_deriv_indexes(control, params)?
|
||||
}
|
||||
"listcoins" => {
|
||||
let params = req.params;
|
||||
list_coins(control, params)?
|
||||
@ -451,6 +517,12 @@ pub fn handle_request(control: &mut DaemonControl, req: Request) -> Result<Respo
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'items' parameter."))?;
|
||||
get_labels(control, params)?
|
||||
}
|
||||
"getlabelsbip329" => {
|
||||
let params = req
|
||||
.params
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'offset' and 'limit' parameters."))?;
|
||||
get_labels_bip329(control, params)?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::method_not_found());
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ mod jsonrpc;
|
||||
mod testutils;
|
||||
|
||||
pub use bdk_electrum::electrum_client;
|
||||
pub use bip329;
|
||||
use bitcoin::electrum;
|
||||
pub use miniscript;
|
||||
|
||||
|
||||
@ -523,6 +523,10 @@ impl DatabaseConnection for DummyDatabase {
|
||||
}
|
||||
wallet_txs
|
||||
}
|
||||
|
||||
fn get_labels_bip329(&mut self, _offset: u32, _limit: u32) -> bip329::Labels {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyLiana {
|
||||
|
||||
@ -20,6 +20,8 @@ from test_framework.utils import (
|
||||
USE_TAPROOT,
|
||||
)
|
||||
|
||||
MAX_DERIV = 2**31 - 1
|
||||
|
||||
|
||||
def test_getinfo(lianad):
|
||||
res = lianad.rpc.getinfo()
|
||||
@ -36,6 +38,129 @@ def test_getinfo(lianad):
|
||||
time.sleep(lianad.poll_interval_secs + 1)
|
||||
res = lianad.rpc.getinfo()
|
||||
assert res["last_poll_timestamp"] > last_poll_timestamp
|
||||
assert res["receive_index"] == 0
|
||||
assert res["change_index"] == 0
|
||||
|
||||
|
||||
def test_update_derivation_indexes(lianad):
|
||||
info = lianad.rpc.getinfo()
|
||||
assert info["receive_index"] == 0
|
||||
assert info["change_index"] == 0
|
||||
|
||||
ret = lianad.rpc.updatederivationindexes(0, 0)
|
||||
info = lianad.rpc.getinfo()
|
||||
assert info["receive_index"] == 0
|
||||
assert info["change_index"] == 0
|
||||
assert ret["receive"] == 0
|
||||
assert ret["change"] == 0
|
||||
|
||||
ret = lianad.rpc.updatederivationindexes(receive=3)
|
||||
info = lianad.rpc.getinfo()
|
||||
assert info["receive_index"] == 3
|
||||
assert info["change_index"] == 0
|
||||
assert ret["receive"] == 3
|
||||
assert ret["change"] == 0
|
||||
|
||||
ret = lianad.rpc.updatederivationindexes(change=4)
|
||||
info = lianad.rpc.getinfo()
|
||||
assert info["receive_index"] == 3
|
||||
assert info["change_index"] == 4
|
||||
assert ret["receive"] == 3
|
||||
assert ret["change"] == 4
|
||||
|
||||
ret = lianad.rpc.updatederivationindexes(receive=1, change=2)
|
||||
info = lianad.rpc.getinfo()
|
||||
assert info["receive_index"] == 3
|
||||
assert info["change_index"] == 4
|
||||
assert ret["receive"] == 3
|
||||
assert ret["change"] == 4
|
||||
|
||||
ret = lianad.rpc.updatederivationindexes(5, 6)
|
||||
info = lianad.rpc.getinfo()
|
||||
assert info["receive_index"] == 5
|
||||
assert info["change_index"] == 6
|
||||
assert ret["receive"] == 5
|
||||
assert ret["change"] == 6
|
||||
|
||||
ret = lianad.rpc.updatederivationindexes(0, 0)
|
||||
info = lianad.rpc.getinfo()
|
||||
assert info["receive_index"] == 5
|
||||
assert info["change_index"] == 6
|
||||
assert ret["receive"] == 5
|
||||
assert ret["change"] == 6
|
||||
|
||||
# Will explicitly error on invalid indexes
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match=re.escape(
|
||||
"Invalid params: Invalid value for \'receive\' param"
|
||||
),
|
||||
):
|
||||
lianad.rpc.updatederivationindexes(-1)
|
||||
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match=re.escape(
|
||||
"Invalid params: Invalid value for \'change\' param"
|
||||
),
|
||||
):
|
||||
lianad.rpc.updatederivationindexes(0, -1)
|
||||
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match=re.escape(
|
||||
"Unhardened or overflowing BIP32 derivation index."
|
||||
),
|
||||
):
|
||||
lianad.rpc.updatederivationindexes(MAX_DERIV + 1, 2)
|
||||
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match=re.escape(
|
||||
"Unhardened or overflowing BIP32 derivation index."
|
||||
),
|
||||
):
|
||||
lianad.rpc.updatederivationindexes(0, MAX_DERIV + 1)
|
||||
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match=re.escape(
|
||||
"Unhardened or overflowing BIP32 derivation index."
|
||||
),
|
||||
):
|
||||
lianad.rpc.updatederivationindexes(receive=(MAX_DERIV+1))
|
||||
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match=re.escape(
|
||||
"Unhardened or overflowing BIP32 derivation index."
|
||||
),
|
||||
):
|
||||
lianad.rpc.updatederivationindexes(change=(MAX_DERIV+1))
|
||||
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match=re.escape(
|
||||
"Invalid params: Missing \'receive\' or \'change\' parameter"
|
||||
),
|
||||
):
|
||||
lianad.rpc.updatederivationindexes()
|
||||
|
||||
last_derivs = lianad.rpc.updatederivationindexes(0, 0)
|
||||
last_receive = last_derivs["receive"]
|
||||
last_change = last_derivs["change"]
|
||||
|
||||
ret = lianad.rpc.updatederivationindexes(0, (MAX_DERIV - 1))
|
||||
assert ret["receive"] == last_receive
|
||||
assert ret["change"] == last_change + 1000
|
||||
|
||||
last_derivs = lianad.rpc.updatederivationindexes(0, 0)
|
||||
last_receive = last_derivs["receive"]
|
||||
last_change = last_derivs["change"]
|
||||
|
||||
ret = lianad.rpc.updatederivationindexes((MAX_DERIV -1 ), 0)
|
||||
assert ret["receive"] == last_receive + 1000
|
||||
assert ret["change"] == last_change
|
||||
|
||||
|
||||
def test_getaddress(lianad):
|
||||
@ -45,6 +170,9 @@ def test_getaddress(lianad):
|
||||
assert res["address"] != lianad.rpc.getnewaddress()["address"]
|
||||
# new address has derivation_index higher than the previous one
|
||||
assert lianad.rpc.getnewaddress()["derivation_index"] == res["derivation_index"] + 2
|
||||
info = lianad.rpc.getinfo()
|
||||
assert info["receive_index"] == res["derivation_index"] + 3
|
||||
assert info["change_index"] == 0
|
||||
|
||||
|
||||
def test_listaddresses(lianad):
|
||||
@ -420,6 +548,8 @@ def test_create_spend(lianad, bitcoind):
|
||||
assert len(spend_psbt.o) == 4
|
||||
assert len(spend_psbt.tx.vout) == 4
|
||||
|
||||
assert lianad.rpc.getinfo()["change_index"] == 15
|
||||
|
||||
# The transaction must contain the spent transaction for each input for P2WSH. But not for Taproot.
|
||||
# We don't make assumptions about the ordering of PSBT inputs.
|
||||
if USE_TAPROOT:
|
||||
@ -1081,6 +1211,76 @@ def test_labels(lianad, bitcoind):
|
||||
assert res[random_address] == "this address is random"
|
||||
|
||||
|
||||
def test_labels_bip329(lianad, bitcoind):
|
||||
# Label 5 addresses
|
||||
addresses = []
|
||||
for i in range(0,5):
|
||||
addr = lianad.rpc.getnewaddress()["address"]
|
||||
addresses.append(addr)
|
||||
lianad.rpc.updatelabels({addr: f"addr{i}"})
|
||||
|
||||
# Label 5 coin
|
||||
txids = []
|
||||
for i in range(0,5):
|
||||
addr = lianad.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 1)
|
||||
txids.append(txid)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == i+1 )
|
||||
|
||||
coins = lianad.rpc.listcoins()["coins"]
|
||||
for i in range(0,5):
|
||||
coin = coins[i]
|
||||
lianad.rpc.updatelabels({coin["outpoint"]: f"coin{i}"})
|
||||
|
||||
# Label 5 transactions
|
||||
for i, txid in enumerate(txids):
|
||||
lianad.rpc.updatelabels({txid: f"tx{i}"})
|
||||
|
||||
# Get Bip-0329 labels
|
||||
bip329_labels = lianad.rpc.getlabelsbip329(0,100)["labels"]
|
||||
assert len(bip329_labels) == 15
|
||||
|
||||
def label_found(name, labels):
|
||||
for label in labels:
|
||||
if label["label"] == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
# All transactions are labelled
|
||||
for i in range(0, len(txids)):
|
||||
assert label_found(f"tx{i}", bip329_labels)
|
||||
|
||||
# All adresses are labelled
|
||||
for i in range(0, len(addresses)):
|
||||
assert label_found(f"addr{i}", bip329_labels)
|
||||
|
||||
# All coins are labelled
|
||||
for i in range(0, len(coins)):
|
||||
assert label_found(f"coin{i}", bip329_labels)
|
||||
|
||||
# There is no conflict between batches
|
||||
batch1 = lianad.rpc.getlabelsbip329(0,5)["labels"]
|
||||
assert len(batch1) == 5
|
||||
|
||||
batch2 = lianad.rpc.getlabelsbip329(5,5)["labels"]
|
||||
assert len(batch2) == 5
|
||||
|
||||
batch3 = lianad.rpc.getlabelsbip329(10,5)["labels"]
|
||||
assert len(batch3) == 5
|
||||
|
||||
for label in batch1:
|
||||
print(label)
|
||||
name = label["label"]
|
||||
|
||||
assert not label_found(name, batch2)
|
||||
assert not label_found(name, batch3)
|
||||
|
||||
for label in batch2:
|
||||
name = label["label"]
|
||||
assert not label_found(name, batch1)
|
||||
assert not label_found(name, batch3)
|
||||
|
||||
|
||||
def test_rbfpsbt_bump_fee(lianad, bitcoind):
|
||||
"""Test the use of RBF to bump the fee of a transaction."""
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user