Merge #289: Gui multisig

ca39b15edd366d23d4bcf7433273663807fa2aab installer: generate multiple xpubs in participate step (edouard)
445ad733fbb6604f2b1acfc93db454eb4794baf7 Add signatures information to spend (edouard)
bf1e9e4b808a7c275766975467e637b883524236 gui: new module settings (edouard)
689f19a4f22009a9043a7308e00c96bf1de97a45 Edit key name in installer (edouard)
bcd223f3fb8cf7224eede75b5cfeb97ae8e93ef9 Add sigs number and threshold to spend list view (edouard)
1f3399bd0f78593a689f0ec50934b1cf4f34f692 Add PartialSpendInfo to SpendTx model (edouard)
9a1cda2f5ed80a66324b272b7cf4c3bd7202e953 Use spend state in recovery panel (edouard)
a2ac34e6b03ef92025ac0f9c9cb709719ea6375a installer: Participate in a new wallet section (edouard)
dfc10eba61818b87ef12f057507a9b9660851d61 Add multisig wallet creation in installer (edouard)

Pull request description:

ACKs for top commit:
  edouardparis:
    Self-ACK ca39b15edd366d23d4bcf7433273663807fa2aab

Tree-SHA512: 67898dd5cc9ddfe135ddd2e57c6f3d6a6a10cf63211fe44c35e36cd182d4023cdefb8b15d2bd07290d83d631e360b20114b60e85728f9bbdc8c0e0d16b37f746
This commit is contained in:
edouard 2023-02-02 12:56:36 +01:00
commit ada2d010c9
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
32 changed files with 2371 additions and 895 deletions

2
gui/Cargo.lock generated
View File

@ -1640,7 +1640,7 @@ dependencies = [
[[package]]
name = "liana"
version = "0.1.0"
source = "git+https://github.com/revault/liana?branch=master#863cea55d7d84ea2262a68dcd006393a6ee239a4"
source = "git+https://github.com/wizardsardine/liana?branch=master#f433002e91b09ab700d06026e1d94c462aca9756"
dependencies = [
"backtrace",
"base64",

View File

@ -15,7 +15,7 @@ path = "src/main.rs"
[dependencies]
async-hwi = "0.0.2"
liana = { git = "https://github.com/revault/liana", branch = "master", default-features = false }
liana = { git = "https://github.com/wizardsardine/liana", branch = "master", default-features = false }
backtrace = "0.3"
base64 = "0.13"

View File

@ -1,6 +1,7 @@
use crate::daemon::model::{Coin, SpendTx};
use liana::miniscript::bitcoin::Network;
#[derive(Debug)]
pub struct Cache {
pub network: Network,
pub blockheight: i32,

View File

@ -11,19 +11,19 @@ pub struct Config {
/// Use iced debug feature if true.
pub debug: Option<bool>,
/// hardware wallets config.
#[serde(default)]
pub hardware_wallets: Vec<HardwareWalletConfig>,
/// LEGACY: Use Settings module instead.
pub hardware_wallets: Option<Vec<HardwareWalletConfig>>,
}
pub const DEFAULT_FILE_NAME: &str = "gui.toml";
impl Config {
pub fn new(daemon_config_path: PathBuf, hardware_wallets: Vec<HardwareWalletConfig>) -> Self {
pub fn new(daemon_config_path: PathBuf) -> Self {
Self {
daemon_config_path,
log_level: None,
debug: None,
hardware_wallets,
hardware_wallets: None,
}
}
@ -40,14 +40,6 @@ impl Config {
})?;
Ok(config)
}
pub fn default_path() -> Result<PathBuf, ConfigError> {
let mut datadir = default_datadir().map_err(|_| {
ConfigError::Unexpected("Could not locate the default datadir directory.".to_owned())
})?;
datadir.push(DEFAULT_FILE_NAME);
Ok(datadir)
}
}
#[derive(PartialEq, Eq, Debug, Clone)]

View File

@ -23,6 +23,7 @@ pub enum Message {
Coins(Result<Vec<Coin>, Error>),
SpendTxs(Result<Vec<SpendTx>, Error>),
Psbt(Result<Psbt, Error>),
Recovery(Result<SpendTx, Error>),
Signed(Result<(Psbt, Fingerprint), Error>),
Updated(Result<(), Error>),
Saved(Result<(), Error>),

View File

@ -2,6 +2,7 @@ pub mod cache;
pub mod config;
pub mod menu;
pub mod message;
pub mod settings;
pub mod state;
pub mod view;
pub mod wallet;
@ -31,14 +32,14 @@ pub struct App {
state: Box<dyn State>,
cache: Cache,
config: Config,
wallet: Wallet,
wallet: Arc<Wallet>,
daemon: Arc<dyn Daemon + Sync + Send>,
}
impl App {
pub fn new(
cache: Cache,
wallet: Wallet,
wallet: Arc<Wallet>,
config: Config,
daemon: Arc<dyn Daemon + Sync + Send>,
) -> (App, Command<Message>) {
@ -72,22 +73,15 @@ impl App {
.into(),
menu::Menu::Recovery => RecoveryPanel::new(
self.wallet.clone(),
self.config.clone(),
&self.cache.coins,
self.wallet.main_descriptor.timelock_value(),
self.cache.blockheight as u32,
)
.into(),
menu::Menu::Receive => ReceivePanel::default().into(),
menu::Menu::Spend => SpendPanel::new(
self.wallet.clone(),
self.config.clone(),
&self.cache.spend_txs,
)
.into(),
menu::Menu::Spend => SpendPanel::new(self.wallet.clone(), &self.cache.spend_txs).into(),
menu::Menu::CreateSpendTx => CreateSpendPanel::new(
self.wallet.clone(),
self.config.clone(),
&self.cache.coins,
self.cache.blockheight as u32,
)

75
gui/src/app/settings.rs Normal file
View File

@ -0,0 +1,75 @@
use std::collections::HashMap;
use std::path::Path;
use liana::miniscript::bitcoin::util::bip32::Fingerprint;
use serde::{Deserialize, Serialize};
use crate::hw::HardwareWalletConfig;
///! Settings is the module to handle the GUI settings file.
///! The settings file is used by the GUI to store useful information.
pub const DEFAULT_FILE_NAME: &str = "settings.json";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Settings {
pub wallets: Vec<WalletSetting>,
}
impl Settings {
pub fn from_file(path: &Path) -> Result<Self, SettingsError> {
let config = std::fs::read(path)
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => SettingsError::NotFound,
_ => SettingsError::ReadingFile(format!("Reading settings file: {}", e)),
})
.and_then(|file_content| {
serde_json::from_slice::<Settings>(&file_content).map_err(|e| {
SettingsError::ReadingFile(format!("Parsing settings file: {}", e))
})
})?;
Ok(config)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WalletSetting {
pub name: String,
pub descriptor_checksum: String,
#[serde(default)]
pub keys: Vec<KeySetting>,
#[serde(default)]
pub hardware_wallets: Vec<HardwareWalletConfig>,
}
impl WalletSetting {
pub fn keys_aliases(&self) -> HashMap<Fingerprint, String> {
let mut map = HashMap::new();
for key in self.keys.clone() {
map.insert(key.master_fingerprint, key.name);
}
map
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KeySetting {
pub name: String,
pub master_fingerprint: Fingerprint,
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum SettingsError {
NotFound,
ReadingFile(String),
Unexpected(String),
}
impl std::fmt::Display for SettingsError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::NotFound => write!(f, "Settings file not found"),
Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e),
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
}
}
}

View File

@ -39,7 +39,7 @@ pub trait State {
}
pub struct Home {
wallet: Wallet,
wallet: Arc<Wallet>,
balance: Amount,
recovery_warning: Option<(Amount, usize)>,
recovery_alert: Option<(Amount, usize)>,
@ -50,7 +50,7 @@ pub struct Home {
}
impl Home {
pub fn new(wallet: Wallet, coins: &[Coin]) -> Self {
pub fn new(wallet: Arc<Wallet>, coins: &[Coin]) -> Self {
Self {
wallet,
balance: Amount::from_sat(

View File

@ -6,48 +6,37 @@ use iced::{Command, Element};
use crate::{
app::{
cache::Cache,
config::Config,
error::Error,
menu::Menu,
message::Message,
state::spend::detail,
state::{redirect, State},
view,
wallet::Wallet,
},
daemon::{
model::{remaining_sequence, Coin},
model::{remaining_sequence, Coin, SpendTx},
Daemon,
},
hw::{list_hardware_wallets, HardwareWallet},
ui::component::form,
};
use liana::miniscript::bitcoin::{util::psbt::Psbt, Address, Amount, Network};
use liana::miniscript::bitcoin::{Address, Amount, Network};
pub struct RecoveryPanel {
wallet: Wallet,
config: Config,
wallet: Arc<Wallet>,
locked_coins: (usize, Amount),
recoverable_coins: (usize, Amount),
warning: Option<Error>,
feerate: form::Value<String>,
recipient: form::Value<String>,
generated: Option<Psbt>,
hws: Vec<HardwareWallet>,
selected_hw: Option<usize>,
signed: bool,
generated: Option<detail::SpendTxState>,
/// timelock value to pass for the heir to consume a coin.
timelock: u32,
}
impl RecoveryPanel {
pub fn new(
wallet: Wallet,
config: Config,
coins: &[Coin],
timelock: u32,
blockheight: u32,
) -> Self {
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], timelock: u32, blockheight: u32) -> Self {
let mut locked_coins = (0, Amount::from_sat(0));
let mut recoverable_coins = (0, Amount::from_sat(0));
for coin in coins {
@ -64,7 +53,6 @@ impl RecoveryPanel {
}
Self {
wallet,
config,
locked_coins,
recoverable_coins,
warning: None,
@ -72,30 +60,27 @@ impl RecoveryPanel {
recipient: form::Value::default(),
generated: None,
timelock,
hws: Vec::new(),
selected_hw: None,
signed: false,
}
}
}
impl State for RecoveryPanel {
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
view::modal(
false,
self.warning.as_ref(),
view::recovery::recovery(
&self.locked_coins,
&self.recoverable_coins,
&self.feerate,
&self.recipient,
self.generated.as_ref(),
&self.hws,
self.selected_hw,
self.signed,
),
None::<Element<view::Message>>,
)
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
if let Some(generated) = &self.generated {
generated.view(cache)
} else {
view::modal(
false,
self.warning.as_ref(),
view::recovery::recovery(
&self.locked_coins,
&self.recoverable_coins,
&self.feerate,
&self.recipient,
),
None::<Element<view::Message>>,
)
}
}
fn update(
@ -127,27 +112,13 @@ impl State for RecoveryPanel {
}
}
},
// We add the new hws without dropping the reference of the previous ones.
Message::ConnectedHardwareWallets(hws) => {
for h in hws {
if !self.hws.iter().any(|hw| hw.fingerprint == h.fingerprint) {
self.hws.push(h);
}
Message::Recovery(res) => match res {
Ok(tx) => {
self.generated = Some(detail::SpendTxState::new(self.wallet.clone(), tx, false))
}
}
Message::Psbt(res) => match res {
Ok(psbt) => self.generated = Some(psbt),
Err(e) => self.warning = Some(e),
},
Message::Updated(res) => match res {
Err(e) => self.warning = Some(e),
Ok(()) => {
self.warning = None;
self.signed = true;
}
},
Message::View(msg) => match msg {
view::Message::Reload => return self.load(daemon),
view::Message::Close => return redirect(Menu::Settings),
view::Message::Previous => self.generated = None,
view::Message::CreateSpend(view::CreateSpendMessage::RecipientEdited(
@ -175,70 +146,56 @@ impl State for RecoveryPanel {
let address = Address::from_str(&self.recipient.value).expect("Checked before");
let feerate_vb = self.feerate.value.parse::<u64>().expect("Checked before");
self.warning = None;
let desc = self.wallet.main_descriptor.clone();
return Command::perform(
async move {
daemon
.create_recovery(address, feerate_vb)
.map_err(|e| e.into())
let psbt = daemon.create_recovery(address, feerate_vb)?;
let coins = daemon.list_coins().map(|res| res.coins)?;
let coins = coins
.iter()
.filter(|coin| {
psbt.unsigned_tx
.input
.iter()
.any(|input| input.previous_output == coin.outpoint)
})
.copied()
.collect();
let sigs = desc.partial_spend_info(&psbt).unwrap();
Ok(SpendTx::new(psbt, coins, sigs))
},
Message::Psbt,
Message::Recovery,
);
}
view::Message::Spend(view::SpendTxMessage::SelectHardwareWallet(i)) => {
if let Some(hw) = self.hws.get(i) {
let device = hw.device.clone();
self.selected_hw = Some(i);
let psbt = self.generated.clone().unwrap();
return Command::perform(
send_funds(daemon, device, psbt),
Message::Updated,
);
_ => {
if let Some(generated) = &mut self.generated {
return generated.update(daemon, cache, Message::View(msg));
}
}
_ => {}
},
_ => {}
_ => {
if let Some(generated) = &mut self.generated {
return generated.update(daemon, cache, message);
}
}
};
Command::none()
}
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let config = self.config.clone();
let desc = self.wallet.main_descriptor.to_string();
let daemon = daemon.clone();
Command::batch(vec![
Command::perform(
async move {
daemon
.list_coins()
.map(|res| res.coins)
.map_err(|e| e.into())
},
Message::Coins,
),
Command::perform(
list_hws(config, self.wallet.name.clone(), desc),
Message::ConnectedHardwareWallets,
),
])
Command::perform(
async move {
daemon
.list_coins()
.map(|res| res.coins)
.map_err(|e| e.into())
},
Message::Coins,
)
}
}
async fn list_hws(config: Config, wallet_name: String, descriptor: String) -> Vec<HardwareWallet> {
list_hardware_wallets(&config.hardware_wallets, Some((&wallet_name, &descriptor))).await
}
async fn send_funds(
daemon: Arc<dyn Daemon + Sync + Send>,
hw: std::sync::Arc<dyn async_hwi::HWI + Send + Sync>,
mut psbt: Psbt,
) -> Result<(), Error> {
hw.sign_tx(&mut psbt).await.map_err(Error::from)?;
daemon.update_spend_tx(&psbt)?;
daemon.broadcast_spend_tx(&psbt.unsigned_tx.txid())?;
Ok(())
}
impl From<RecoveryPanel> for Box<dyn State> {
fn from(s: RecoveryPanel) -> Box<dyn State> {
Box::new(s)

View File

@ -1,15 +1,17 @@
use std::sync::Arc;
use iced::{Command, Element};
use liana::miniscript::bitcoin::{
consensus,
util::{bip32::Fingerprint, psbt::Psbt},
use liana::{
descriptors::LianaDescInfo,
miniscript::bitcoin::{
consensus,
util::{bip32::Fingerprint, psbt::Psbt},
},
};
use crate::{
app::{
cache::Cache, config::Config, error::Error, message::Message, view, view::spend::detail,
wallet::Wallet,
cache::Cache, error::Error, message::Message, view, view::spend::detail, wallet::Wallet,
},
daemon::{
model::{SpendStatus, SpendTx},
@ -39,19 +41,19 @@ trait Action {
}
pub struct SpendTxState {
wallet: Wallet,
config: Config,
wallet: Arc<Wallet>,
desc_info: LianaDescInfo,
tx: SpendTx,
saved: bool,
action: Option<Box<dyn Action>>,
}
impl SpendTxState {
pub fn new(wallet: Wallet, config: Config, tx: SpendTx, saved: bool) -> Self {
pub fn new(wallet: Arc<Wallet>, tx: SpendTx, saved: bool) -> Self {
Self {
desc_info: wallet.main_descriptor.info(),
wallet,
action: None,
config,
tx,
saved,
}
@ -80,7 +82,7 @@ impl SpendTxState {
self.action = Some(Box::new(DeleteAction::default()));
}
view::SpendTxMessage::Sign => {
let action = SignAction::new(self.config.clone());
let action = SignAction::new();
let cmd = action.load(&self.wallet, daemon);
self.action = Some(Box::new(action));
return cmd;
@ -119,7 +121,13 @@ impl SpendTxState {
}
pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
let content = detail::spend_view(&self.tx, self.saved, cache.network);
let content = detail::spend_view(
&self.tx,
self.saved,
&self.desc_info,
&self.wallet.keys_aliases,
cache.network,
);
if let Some(action) = &self.action {
modal::Modal::new(content, action.view())
.on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel)))
@ -252,7 +260,6 @@ impl Action for DeleteAction {
}
pub struct SignAction {
config: Config,
chosen_hw: Option<usize>,
processing: bool,
hws: Vec<HardwareWallet>,
@ -261,9 +268,8 @@ pub struct SignAction {
}
impl SignAction {
pub fn new(config: Config) -> Self {
pub fn new() -> Self {
Self {
config,
chosen_hw: None,
processing: false,
hws: Vec::new(),
@ -279,13 +285,8 @@ impl Action for SignAction {
}
fn load(&self, wallet: &Wallet, _daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let config = self.config.clone();
let desc = wallet.main_descriptor.to_string();
let name = wallet.name.clone();
Command::perform(
list_hws(config, name, desc),
Message::ConnectedHardwareWallets,
)
let wallet = wallet.clone();
Command::perform(list_hws(wallet), Message::ConnectedHardwareWallets)
}
fn update(
&mut self,
@ -321,7 +322,10 @@ impl Action for SignAction {
}
},
Message::Updated(res) => match res {
Ok(()) => self.processing = false,
Ok(()) => {
self.processing = false;
tx.sigs = wallet.main_descriptor.partial_spend_info(&tx.psbt).unwrap();
}
Err(e) => self.error = Some(e),
},
// We add the new hws without dropping the reference of the previous ones.
@ -350,8 +354,12 @@ impl Action for SignAction {
}
}
async fn list_hws(config: Config, wallet_name: String, descriptor: String) -> Vec<HardwareWallet> {
list_hardware_wallets(&config.hardware_wallets, Some((&wallet_name, &descriptor))).await
async fn list_hws(wallet: Wallet) -> Vec<HardwareWallet> {
list_hardware_wallets(
&wallet.hardware_wallets,
Some((&wallet.name, &wallet.main_descriptor.to_string())),
)
.await
}
async fn sign_psbt(
@ -399,7 +407,7 @@ impl Action for UpdateAction {
fn update(
&mut self,
_wallet: &Wallet,
wallet: &Wallet,
daemon: Arc<dyn Daemon + Sync + Send>,
message: Message,
tx: &mut SpendTx,
@ -436,6 +444,7 @@ impl Action for UpdateAction {
.extend(updated_input.partial_sigs.clone().into_iter());
}
}
tx.sigs = wallet.main_descriptor.partial_spend_info(&tx.psbt).unwrap();
}
Err(e) => self.error = e.into(),
}

View File

@ -1,4 +1,4 @@
mod detail;
pub mod detail;
mod step;
use std::sync::Arc;
@ -8,10 +8,7 @@ use liana::miniscript::bitcoin::{consensus, util::psbt::Psbt};
use super::{redirect, State};
use crate::{
app::{
cache::Cache, config::Config, error::Error, menu::Menu, message::Message, view,
wallet::Wallet,
},
app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet},
daemon::{
model::{Coin, SpendTx},
Daemon,
@ -20,8 +17,7 @@ use crate::{
};
pub struct SpendPanel {
wallet: Wallet,
config: Config,
wallet: Arc<Wallet>,
selected_tx: Option<detail::SpendTxState>,
spend_txs: Vec<SpendTx>,
warning: Option<Error>,
@ -29,10 +25,9 @@ pub struct SpendPanel {
}
impl SpendPanel {
pub fn new(wallet: Wallet, config: Config, spend_txs: &[SpendTx]) -> Self {
pub fn new(wallet: Arc<Wallet>, spend_txs: &[SpendTx]) -> Self {
Self {
wallet,
config,
spend_txs: spend_txs.to_vec(),
warning: None,
selected_tx: None,
@ -97,12 +92,7 @@ impl State for SpendPanel {
}
Message::View(view::Message::Select(i)) => {
if let Some(tx) = self.spend_txs.get(i) {
let tx = detail::SpendTxState::new(
self.wallet.clone(),
self.config.clone(),
tx.clone(),
true,
);
let tx = detail::SpendTxState::new(self.wallet.clone(), tx.clone(), true);
let cmd = tx.load(daemon);
self.selected_tx = Some(tx);
return cmd;
@ -143,7 +133,7 @@ pub struct CreateSpendPanel {
}
impl CreateSpendPanel {
pub fn new(wallet: Wallet, config: Config, coins: &[Coin], blockheight: u32) -> Self {
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], blockheight: u32) -> Self {
let descriptor = wallet.main_descriptor.clone();
let timelock = descriptor.timelock_value();
Self {
@ -157,7 +147,7 @@ impl CreateSpendPanel {
timelock,
blockheight,
)),
Box::new(step::SaveSpend::new(wallet, config)),
Box::new(step::SaveSpend::new(wallet)),
],
}
}

View File

@ -12,8 +12,7 @@ use liana::{
use crate::{
app::{
cache::Cache, config::Config, error::Error, message::Message, state::spend::detail, view,
wallet::Wallet,
cache::Cache, error::Error, message::Message, state::spend::detail, view, wallet::Wallet,
},
daemon::{
model::{remaining_sequence, Coin, SpendTx},
@ -430,16 +429,14 @@ impl Step for ChooseCoins {
}
pub struct SaveSpend {
wallet: Wallet,
config: Config,
wallet: Arc<Wallet>,
spend: Option<detail::SpendTxState>,
}
impl SaveSpend {
pub fn new(wallet: Wallet, config: Config) -> Self {
pub fn new(wallet: Arc<Wallet>) -> Self {
Self {
wallet,
config,
spend: None,
}
}
@ -447,10 +444,15 @@ impl SaveSpend {
impl Step for SaveSpend {
fn load(&mut self, draft: &TransactionDraft) {
let psbt = draft.generated.clone().unwrap();
let sigs = self
.wallet
.main_descriptor
.partial_spend_info(&psbt)
.unwrap();
self.spend = Some(detail::SpendTxState::new(
self.wallet.clone(),
self.config.clone(),
SpendTx::new(draft.generated.clone().unwrap(), draft.inputs.clone()),
SpendTx::new(psbt, draft.inputs.clone(), sigs),
false,
));
}

View File

@ -1,18 +1,14 @@
use iced::{
widget::{Button, Column, Container, Row, Space},
widget::{Column, Container, Row, Space},
Alignment, Element, Length,
};
use liana::miniscript::bitcoin::{util::psbt::Psbt, Amount};
use liana::miniscript::bitcoin::Amount;
use crate::{
app::view::{
hw::hw_list_view,
message::{CreateSpendMessage, Message},
},
hw::HardwareWallet,
app::view::message::{CreateSpendMessage, Message},
ui::{
component::{button, card, form, text::*},
component::{button, form, text::*},
icon,
util::Collection,
},
@ -24,10 +20,6 @@ pub fn recovery<'a>(
recoverable_coins: &(usize, Amount),
feerate: &form::Value<String>,
address: &'a form::Value<String>,
generated: Option<&Psbt>,
hws: &[HardwareWallet],
chosen_hw: Option<usize>,
done: bool,
) -> Element<'a, Message> {
Column::new()
.push(Space::with_height(Length::Units(100)))
@ -59,173 +51,7 @@ pub fn recovery<'a>(
None
})
.push(Space::with_height(Length::Units(20)))
.push(if let Some(psbt) = generated {
if done {
Column::new()
.spacing(20)
.align_items(Alignment::Center)
.push(text("Funds were sweeped"))
.push(card::simple(
Column::new()
.push(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(
text(format!(
"{}",
Amount::from_sat(psbt.unsigned_tx.output[0].value)
))
.small()
.bold(),
)
.push(text(" to ").small())
.push(text(&address.value).small().bold()),
)
.push(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(
text(format!("Txid: {}", psbt.unsigned_tx.txid())).small(),
)
.push(
Button::new(icon::clipboard_icon().small())
.on_press(Message::Clipboard(
psbt.unsigned_tx.txid().to_string(),
))
.style(button::Style::Border.into()),
),
)
.push_maybe(
if recoverable_coins.1.to_sat() > psbt.unsigned_tx.output[0].value {
Some(
Row::new().push(
text(format!(
"Fees: {}",
recoverable_coins.1
- Amount::from_sat(
psbt.unsigned_tx.output[0].value
)
))
.small(),
),
)
} else {
None
},
),
))
} else {
Column::new()
.spacing(20)
.align_items(Alignment::Center)
.push_maybe(if chosen_hw.is_none() {
Some(button::border(None, "< Previous").on_press(Message::Previous))
} else {
None
})
.push(text("Sign the transaction to sweep the funds").bold())
.push(card::simple(
Column::new()
.push(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(
text(format!(
"{}",
Amount::from_sat(psbt.unsigned_tx.output[0].value)
))
.small()
.bold(),
)
.push(text(" to ").small())
.push(text(&address.value).small().bold()),
)
.push(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(
text(format!("Txid: {}", psbt.unsigned_tx.txid())).small(),
)
.push(
Button::new(icon::clipboard_icon().small())
.on_press(Message::Clipboard(
psbt.unsigned_tx.txid().to_string(),
))
.style(button::Style::Border.into()),
),
)
.push_maybe(
if recoverable_coins.1.to_sat() > psbt.unsigned_tx.output[0].value {
Some(
Row::new().push(
text(format!(
"Fees: {}",
recoverable_coins.1
- Amount::from_sat(
psbt.unsigned_tx.output[0].value
)
))
.small(),
),
)
} else {
None
},
),
))
.push(if !hws.is_empty() {
Column::new()
.push(
Row::new()
.align_items(Alignment::Center)
.push(
text("Select hardware wallet to sign with:")
.bold()
.width(Length::Fill),
)
.push_maybe(if chosen_hw.is_none() {
Some(
button::border(None, "Refresh")
.on_press(Message::Reload),
)
} else {
None
}),
)
.spacing(10)
.push(hws.iter().enumerate().fold(
Column::new().spacing(10),
|col, (i, hw)| {
col.push(hw_list_view(
i,
hw,
Some(i) == chosen_hw,
chosen_hw.is_some(),
false,
))
},
))
.max_width(500)
} else {
Column::new()
.push(
Column::new()
.spacing(20)
.width(Length::Fill)
.push("Please connect a hardware wallet")
.push(
button::primary(None, "Refresh").on_press(Message::Reload),
)
.align_items(Alignment::Center),
)
.width(Length::Fill)
})
}
} else {
.push(
Column::new()
.push(text("Enter destination address and feerate:").bold())
.push(
@ -267,8 +93,8 @@ pub fn recovery<'a>(
},
)
.spacing(20)
.align_items(Alignment::Center)
})
.align_items(Alignment::Center),
)
.align_items(Alignment::Center)
.spacing(20)
.into()

View File

@ -1,9 +1,14 @@
use std::collections::HashMap;
use iced::{
widget::{Button, Column, Container, Row, Scrollable, Space},
widget::{scrollable, tooltip, Button, Column, Container, Row, Scrollable, Space},
Alignment, Element, Length,
};
use liana::miniscript::bitcoin::{util::bip32::Fingerprint, Address, Amount, Network, Transaction};
use liana::{
descriptors::{LianaDescInfo, PathInfo, PathSpendInfo},
miniscript::bitcoin::{util::bip32::Fingerprint, Address, Amount, Network, Transaction},
};
use crate::{
app::{
@ -25,7 +30,13 @@ use crate::{
},
};
pub fn spend_view(tx: &SpendTx, saved: bool, network: Network) -> Element<Message> {
pub fn spend_view<'a>(
tx: &'a SpendTx,
saved: bool,
desc_info: &'a LianaDescInfo,
key_aliases: &'a HashMap<Fingerprint, String>,
network: Network,
) -> Element<'a, Message> {
spend_modal(
saved,
None,
@ -33,7 +44,7 @@ pub fn spend_view(tx: &SpendTx, saved: bool, network: Network) -> Element<Messag
.align_items(Alignment::Center)
.spacing(20)
.push(spend_header(tx))
.push(spend_overview_view(tx))
.push(spend_overview_view(tx, desc_info, key_aliases))
.push(inputs_and_outputs_view(
&tx.coins,
&tx.psbt.unsigned_tx,
@ -187,7 +198,11 @@ fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> {
.push(
Row::new()
.push(badge::Badge::new(icon::send_icon()).style(badge::Style::Standard))
.push(text("Spend").bold())
.push(if tx.sigs.recovery_path().is_some() {
text("Recovery").bold()
} else {
text("Spend").bold()
})
.spacing(5)
.align_items(Alignment::Center),
)
@ -217,67 +232,17 @@ fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> {
.into()
}
fn spend_overview_view<'a>(tx: &SpendTx) -> Element<'a, Message> {
card::simple(
fn spend_overview_view<'a>(
tx: &'a SpendTx,
desc_info: &'a LianaDescInfo,
key_aliases: &'a HashMap<Fingerprint, String>,
) -> Element<'a, Message> {
Container::new(
Column::new()
.push(Container::new(
Row::new()
.push(
Container::new(
Row::new()
.push(Container::new(
icon::key_icon().size(30).width(Length::Fill),
))
.push(
Column::new()
.push(text("Number of signatures:").bold())
.push(text(format!(
"{}",
tx.psbt.inputs[0].partial_sigs.len(),
)))
.width(Length::Fill),
)
.push_maybe(if tx.status == SpendStatus::Pending {
if !tx.is_signed() {
Some(
button::primary(None, "Sign")
.on_press(Message::Spend(SpendTxMessage::Sign)),
)
} else {
Some(
button::primary(None, "Broadcast").on_press(
Message::Spend(SpendTxMessage::Broadcast),
),
)
}
} else {
None
})
.align_items(Alignment::Center)
.spacing(20),
)
.width(Length::FillPortion(1)),
)
.align_items(Alignment::Center)
.spacing(20),
))
.push(separation().width(Length::Fill))
.push(
Column::new()
.padding(15)
.spacing(10)
.push(
Row::new()
.push(text("Tx ID:").bold().width(Length::Fill))
.push(text(tx.psbt.unsigned_tx.txid().to_string()).small())
.push(
Button::new(icon::clipboard_icon())
.on_press(Message::Clipboard(
tx.psbt.unsigned_tx.txid().to_string(),
))
.style(button::Style::TransparentBorder.into()),
)
.align_items(Alignment::Center),
)
.push(
Row::new()
.align_items(Alignment::Center)
@ -295,10 +260,209 @@ fn spend_overview_view<'a>(tx: &SpendTx) -> Element<'a, Message> {
),
)
.align_items(Alignment::Center),
)
.push(
Row::new()
.push(text("Tx ID:").bold().width(Length::Fill))
.push(text(tx.psbt.unsigned_tx.txid().to_string()).small())
.push(
Button::new(icon::clipboard_icon())
.on_press(Message::Clipboard(
tx.psbt.unsigned_tx.txid().to_string(),
))
.style(button::Style::TransparentBorder.into()),
)
.align_items(Alignment::Center),
),
)
.spacing(20),
.push(signatures(tx, desc_info, key_aliases)),
)
.style(card::SimpleCardStyle)
.into()
}
pub fn signatures<'a>(
tx: &'a SpendTx,
desc_info: &'a LianaDescInfo,
keys_aliases: &'a HashMap<Fingerprint, String>,
) -> Element<'a, Message> {
Column::new()
.push(Collapse::new(
move || {
Button::new(
Row::new()
.align_items(Alignment::Center)
.push(if tx.is_ready() {
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(icon::circle_check_icon().style(color::SUCCESS))
.push(text("Ready").bold().style(color::SUCCESS))
.width(Length::Fill)
} else {
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(icon::circle_cross_icon())
.push(text("Not ready").bold())
.width(Length::Fill)
})
.push(icon::collapse_icon()),
)
.padding(15)
.width(Length::Fill)
.style(button::Style::TransparentBorder.into())
},
move || {
Button::new(
Row::new()
.align_items(Alignment::Center)
.push(if tx.is_ready() {
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(icon::circle_check_icon().style(color::SUCCESS))
.push(text("Ready").bold().style(color::SUCCESS))
.width(Length::Fill)
} else {
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(icon::circle_cross_icon())
.push(text("Not ready").bold())
.width(Length::Fill)
})
.push(icon::collapsed_icon()),
)
.padding(15)
.width(Length::Fill)
.style(button::Style::TransparentBorder.into())
},
move || {
Into::<Element<'a, Message>>::into(
Column::new().push(separation().width(Length::Fill)).push(
Column::new()
.padding(15)
.spacing(10)
.push(path_view(
desc_info.primary_path(),
tx.sigs.primary_path(),
keys_aliases,
))
.push_maybe(tx.sigs.recovery_path().as_ref().map(|path| {
let (_, keys) = desc_info.recovery_path();
path_view(keys, path, keys_aliases)
})),
),
)
},
))
.push_maybe(if tx.status == SpendStatus::Pending {
Some(
Column::new().push(separation().width(Length::Fill)).push(
Container::new(
Row::new()
.push(Space::with_width(Length::Fill))
.push_maybe(if !tx.is_ready() {
Some(
button::primary(None, "Sign")
.on_press(Message::Spend(SpendTxMessage::Sign))
.width(Length::Units(150)),
)
} else {
Some(
button::primary(None, "Broadcast")
.on_press(Message::Spend(SpendTxMessage::Broadcast))
.width(Length::Units(150)),
)
})
.align_items(Alignment::Center)
.spacing(20),
)
.padding(15),
),
)
} else {
None
})
.into()
}
pub fn path_view<'a>(
path: &'a PathInfo,
sigs: &'a PathSpendInfo,
key_aliases: &'a HashMap<Fingerprint, String>,
) -> Element<'a, Message> {
let mut keys: Vec<Fingerprint> = path.thresh_fingerprints().1.into_iter().collect();
keys.sort();
Scrollable::new(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(if sigs.signed_pubkeys.len() >= sigs.threshold {
icon::circle_check_icon().style(color::SUCCESS)
} else {
icon::circle_cross_icon()
})
.push(
Container::new(text(format!(" {} ", sigs.threshold))).style(
if sigs.signed_pubkeys.len() >= sigs.threshold {
badge::PillStyle::Success
} else {
badge::PillStyle::Simple
},
),
)
.push(text(format!(
"signature{} out of",
if sigs.threshold > 1 { "s" } else { "" }
)))
.push(
sigs.signed_pubkeys
.keys()
.fold(Row::new().spacing(5), |row, value| {
row.push(if let Some(alias) = key_aliases.get(value) {
Container::new(
tooltip::Tooltip::new(
Container::new(text(alias))
.padding(3)
.style(badge::PillStyle::Success),
value.to_string(),
tooltip::Position::Bottom,
)
.style(card::SimpleCardStyle),
)
} else {
Container::new(text(value.to_string()))
.padding(3)
.style(badge::PillStyle::Success)
})
}),
)
.push(keys.iter().fold(Row::new().spacing(5), |row, &value| {
row.push_maybe(if !sigs.signed_pubkeys.contains_key(&value) {
Some(if let Some(alias) = key_aliases.get(&value) {
Container::new(
tooltip::Tooltip::new(
Container::new(text(alias))
.padding(3)
.style(badge::PillStyle::Simple),
value.to_string(),
tooltip::Position::Bottom,
)
.style(card::SimpleCardStyle),
)
} else {
Container::new(text(value.to_string()))
.padding(3)
.style(badge::PillStyle::Simple)
})
} else {
None
})
})),
)
.horizontal_scroll(scrollable::Properties::new().width(2).scroller_width(2))
.into()
}
@ -513,26 +677,26 @@ pub fn sign_action<'a>(
chosen_hw: Option<usize>,
signed: &[Fingerprint],
) -> Element<'a, Message> {
card::simple(
Column::new()
.push_maybe(warning.map(|w| warn(Some(w))))
.push(if !hws.is_empty() {
Column::new()
.push(
Row::new()
.push(
text("Select hardware wallet to sign with:")
.bold()
.width(Length::Fill),
)
.push(button::border(None, "Refresh").on_press(Message::Reload))
.align_items(Alignment::Center),
)
.spacing(10)
.push(
hws.iter()
.enumerate()
.fold(Column::new().spacing(10), |col, (i, hw)| {
Column::new()
.push_maybe(warning.map(|w| warn(Some(w))))
.push(card::simple(
Column::new()
.push(if !hws.is_empty() {
Column::new()
.push(
Row::new()
.push(
text("Select hardware wallet to sign with:")
.bold()
.width(Length::Fill),
)
.push(button::border(None, "Refresh").on_press(Message::Reload))
.align_items(Alignment::Center),
)
.spacing(10)
.push(hws.iter().enumerate().fold(
Column::new().spacing(10),
|col, (i, hw)| {
col.push(hw_list_view(
i,
hw,
@ -540,27 +704,27 @@ pub fn sign_action<'a>(
processing,
signed.contains(&hw.fingerprint),
))
}),
)
.width(Length::Fill)
} else {
Column::new()
.push(
Column::new()
.spacing(15)
.width(Length::Fill)
.push("Please connect a hardware wallet")
.push(button::border(None, "Refresh").on_press(Message::Reload))
.align_items(Alignment::Center),
)
.width(Length::Fill)
})
.spacing(20)
.width(Length::Fill)
.align_items(Alignment::Center),
)
.width(Length::Units(500))
.into()
},
))
.width(Length::Fill)
} else {
Column::new()
.push(
Column::new()
.spacing(15)
.width(Length::Fill)
.push("Please connect a hardware wallet")
.push(button::border(None, "Refresh").on_press(Message::Reload))
.align_items(Alignment::Center),
)
.width(Length::Fill)
})
.spacing(20)
.width(Length::Fill)
.align_items(Alignment::Center),
))
.width(Length::Units(500))
.into()
}
pub fn update_spend_view<'a>(

View File

@ -111,23 +111,63 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> {
.push(
Row::new()
.push(badge::spend())
.push_maybe(match tx.status {
SpendStatus::Deprecated => Some(
Container::new(text(" Deprecated ").small())
.padding(3)
.style(badge::PillStyle::Simple),
),
SpendStatus::Broadcast => Some(
Container::new(text(" Broadcast ").small())
.padding(3)
.style(badge::PillStyle::Success),
),
_ => None,
.push(if let Some(sigs) = tx.sigs.recovery_path() {
Row::new()
.spacing(10)
.align_items(Alignment::Center)
.push(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(text(format!(
"{}/{}",
if sigs.signed_pubkeys.len() <= sigs.threshold {
sigs.signed_pubkeys.len()
} else {
sigs.threshold
},
sigs.threshold
)))
.push(icon::key_icon()),
)
.push(
Container::new(text(" Recovery ").small())
.padding(3)
.style(badge::PillStyle::Simple),
)
} else {
let sigs = tx.sigs.primary_path();
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(text(format!(
"{}/{}",
if sigs.signed_pubkeys.len() <= sigs.threshold {
sigs.signed_pubkeys.len()
} else {
sigs.threshold
},
sigs.threshold
)))
.push(icon::key_icon())
})
.spacing(10)
.align_items(Alignment::Center)
.width(Length::Fill),
)
.push_maybe(match tx.status {
SpendStatus::Deprecated => Some(
Container::new(text(" Deprecated ").small())
.padding(3)
.style(badge::PillStyle::Simple),
),
SpendStatus::Broadcast => Some(
Container::new(text(" Broadcast ").small())
.padding(3)
.style(badge::PillStyle::Success),
),
_ => None,
})
.push(
Column::new()
.push(amount(&tx.spend_amount))

View File

@ -1,16 +1,46 @@
use liana::descriptors::MultipathDescriptor;
use std::collections::HashMap;
#[derive(Clone)]
use crate::hw::HardwareWalletConfig;
use liana::descriptors::MultipathDescriptor;
use liana::miniscript::bitcoin::util::bip32::Fingerprint;
pub const DEFAULT_WALLET_NAME: &str = "Liana";
#[derive(Debug, Clone)]
pub struct Wallet {
pub name: String,
pub main_descriptor: MultipathDescriptor,
pub keys_aliases: HashMap<Fingerprint, String>,
pub hardware_wallets: Vec<HardwareWalletConfig>,
}
impl Wallet {
pub fn new(main_descriptor: MultipathDescriptor) -> Self {
pub fn new(name: String, main_descriptor: MultipathDescriptor) -> Self {
Self {
name: "Liana".to_string(),
name,
main_descriptor,
keys_aliases: HashMap::new(),
hardware_wallets: Vec::new(),
}
}
pub fn legacy(main_descriptor: MultipathDescriptor) -> Self {
Self {
name: DEFAULT_WALLET_NAME.to_string(),
main_descriptor,
keys_aliases: HashMap::new(),
hardware_wallets: Vec::new(),
}
}
pub fn with_key_aliases(mut self, aliases: HashMap<Fingerprint, String>) -> Self {
self.keys_aliases = aliases;
self
}
pub fn with_harware_wallets(mut self, hardware_wallets: Vec<HardwareWalletConfig>) -> Self {
self.hardware_wallets = hardware_wallets;
self
}
}

View File

@ -72,25 +72,29 @@ pub trait Daemon: Debug {
fn list_txs(&self, txid: &[Txid]) -> Result<model::ListTransactionsResult, DaemonError>;
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins;
let spend_txs = self.list_spend_txs()?.spend_txs;
Ok(spend_txs
.into_iter()
.map(|tx| {
let coins = coins
.iter()
.filter(|coin| {
tx.psbt
.unsigned_tx
.input
.iter()
.any(|input| input.previous_output == coin.outpoint)
})
.copied()
.collect();
model::SpendTx::new(tx.psbt, coins)
})
.collect())
let mut spend_txs = Vec::new();
for tx in self.list_spend_txs()?.spend_txs {
let coins = coins
.iter()
.filter(|coin| {
tx.psbt
.unsigned_tx
.input
.iter()
.any(|input| input.previous_output == coin.outpoint)
})
.copied()
.collect();
let sigs = info
.descriptors
.main
.partial_spend_info(&tx.psbt)
.map_err(|e| DaemonError::Unexpected(e.to_string()))?;
spend_txs.push(model::SpendTx::new(tx.psbt, coins, sigs))
}
Ok(spend_txs)
}
fn list_history_txs(

View File

@ -3,6 +3,7 @@ pub use liana::{
CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult,
ListSpendEntry, ListSpendResult, ListTransactionsResult, TransactionInfo,
},
descriptors::PartialSpendInfo,
miniscript::bitcoin::{util::psbt::Psbt, Amount, Transaction},
};
@ -28,6 +29,7 @@ pub struct SpendTx {
pub spend_amount: Amount,
pub fee_amount: Amount,
pub status: SpendStatus,
pub sigs: PartialSpendInfo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -38,7 +40,7 @@ pub enum SpendStatus {
}
impl SpendTx {
pub fn new(psbt: Psbt, coins: Vec<Coin>) -> Self {
pub fn new(psbt: Psbt, coins: Vec<Coin>, sigs: PartialSpendInfo) -> Self {
let mut change_indexes = Vec::new();
let (change_amount, spend_amount) = psbt.unsigned_tx.output.iter().enumerate().fold(
(Amount::from_sat(0), Amount::from_sat(0)),
@ -72,11 +74,21 @@ impl SpendTx {
spend_amount,
fee_amount: inputs_amount - spend_amount - change_amount,
status,
sigs,
}
}
pub fn is_signed(&self) -> bool {
!self.psbt.inputs.first().unwrap().partial_sigs.is_empty()
pub fn is_ready(&self) -> bool {
let path = self.sigs.primary_path();
if path.signed_pubkeys.len() >= path.threshold {
return true;
}
if let Some(path) = self.sigs.recovery_path() {
if path.signed_pubkeys.len() >= path.threshold {
return true;
}
}
false
}
}

View File

@ -2,7 +2,7 @@ use std::convert::TryFrom;
use liana::config::Config as LianaConfig;
use super::step::Context;
use super::Context;
pub const DEFAULT_FILE_NAME: &str = "daemon.toml";

View File

@ -0,0 +1,87 @@
use std::path::PathBuf;
use std::time::Duration;
use crate::{
app::{
settings::{KeySetting, Settings, WalletSetting},
wallet::DEFAULT_WALLET_NAME,
},
hw::HardwareWalletConfig,
};
use async_hwi::DeviceKind;
use liana::{
config::Config,
config::{BitcoinConfig, BitcoindConfig},
descriptors::MultipathDescriptor,
miniscript::bitcoin,
};
#[derive(Clone)]
pub struct Context {
pub bitcoin_config: BitcoinConfig,
pub bitcoind_config: Option<BitcoindConfig>,
pub descriptor: Option<MultipathDescriptor>,
pub keys: Vec<KeySetting>,
pub hws: Vec<(
DeviceKind,
bitcoin::util::bip32::Fingerprint,
Option<[u8; 32]>,
)>,
pub data_dir: PathBuf,
}
impl Context {
pub fn new(network: bitcoin::Network, data_dir: PathBuf) -> Self {
Self {
bitcoin_config: BitcoinConfig {
network,
poll_interval_secs: Duration::from_secs(30),
},
hws: Vec::new(),
keys: Vec::new(),
bitcoind_config: None,
descriptor: None,
data_dir,
}
}
pub fn extract_gui_settings(&self) -> Settings {
let hardware_wallets = self
.hws
.iter()
.filter_map(|(kind, fingerprint, token)| {
token
.as_ref()
.map(|token| HardwareWalletConfig::new(kind, fingerprint, token))
})
.collect();
Settings {
wallets: vec![WalletSetting {
name: DEFAULT_WALLET_NAME.to_string(),
descriptor_checksum: self
.descriptor
.as_ref()
.unwrap()
.to_string()
.split_once('#')
.map(|(_, checksum)| checksum)
.unwrap()
.to_string(),
keys: self.keys.clone(),
hardware_wallets,
}],
}
}
pub fn extract_daemon_config(&self) -> Config {
Config {
#[cfg(unix)]
daemon: false,
log_level: log::LevelFilter::Info,
main_descriptor: self.descriptor.clone().unwrap(),
data_dir: Some(self.data_dir.clone()),
bitcoin_config: self.bitcoin_config.clone(),
bitcoind_config: self.bitcoind_config.clone(),
}
}
}

View File

@ -1,4 +1,7 @@
use liana::miniscript::bitcoin::{util::bip32::Fingerprint, Network};
use liana::miniscript::{
bitcoin::{util::bip32::Fingerprint, Network},
DescriptorPublicKey,
};
use std::path::PathBuf;
use super::Error;
@ -7,8 +10,9 @@ use crate::hw::HardwareWallet;
#[derive(Debug, Clone)]
pub enum Message {
CreateWallet,
ParticipateWallet,
ImportWallet,
BackupDone(bool),
UserActionDone(bool),
Exit(PathBuf),
Clibpboard(String),
Next,
@ -21,6 +25,7 @@ pub enum Message {
Network(Network),
DefineBitcoind(DefineBitcoind),
DefineDescriptor(DefineDescriptor),
ImportXpub(usize, Result<DescriptorPublicKey, Error>),
ConnectedHardwareWallets(Vec<HardwareWallet>),
WalletRegistered(Result<(Fingerprint, Option<[u8; 32]>), Error>),
}
@ -34,10 +39,22 @@ pub enum DefineBitcoind {
#[derive(Debug, Clone)]
pub enum DefineDescriptor {
ImportDescriptor(String),
ImportUserHWXpub,
ImportHeirHWXpub,
XpubImported(Result<String, Error>),
UserXpubEdited(String),
HeirXpubEdited(String),
/// AddKey(is_recovery)
AddKey(bool),
Key(bool, usize, DefineKey),
HWXpubImported(Result<DescriptorPublicKey, Error>),
XPubEdited(String),
EditName,
NameEdited(String),
SequenceEdited(String),
ThresholdEdited(bool, usize),
ConfirmXpub,
}
#[derive(Debug, Clone)]
pub enum DefineKey {
Delete,
Edit,
Clipboard(String),
Edited(String, DescriptorPublicKey),
}

View File

@ -1,4 +1,4 @@
mod config;
mod context;
mod message;
mod prompt;
mod step;
@ -7,17 +7,15 @@ mod view;
use iced::{clipboard, Command, Element, Subscription};
use liana::miniscript::bitcoin;
use std::convert::TryInto;
use context::Context;
use std::io::Write;
use std::path::PathBuf;
use crate::{
app::config as gui_config, hw::HardwareWalletConfig, installer::config::DEFAULT_FILE_NAME,
};
use crate::app::{config as gui_config, settings as gui_settings};
pub use message::Message;
use step::{
BackupDescriptor, Context, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor,
BackupDescriptor, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, ParticipateXpub,
RegisterDescriptor, Step, Welcome,
};
@ -100,10 +98,22 @@ impl Installer {
];
self.next()
}
Message::ParticipateWallet => {
self.steps = vec![
Welcome::default().into(),
ParticipateXpub::new().into(),
ImportDescriptor::new(false).into(),
BackupDescriptor::default().into(),
RegisterDescriptor::default().into(),
DefineBitcoind::new().into(),
Final::new().into(),
];
self.next()
}
Message::ImportWallet => {
self.steps = vec![
Welcome::default().into(),
ImportDescriptor::new().into(),
ImportDescriptor::new(true).into(),
RegisterDescriptor::default().into(),
DefineBitcoind::new().into(),
Final::new().into(),
@ -152,19 +162,7 @@ impl Installer {
}
pub async fn install(ctx: Context) -> Result<PathBuf, Error> {
let hardware_wallets = ctx
.hws
.iter()
.filter_map(|(kind, fingerprint, token)| {
token
.as_ref()
.map(|token| HardwareWalletConfig::new(kind, fingerprint, token))
})
.collect();
let mut cfg: liana::config::Config = ctx
.try_into()
.expect("Everything should be checked at this point");
let mut cfg: liana::config::Config = ctx.extract_daemon_config();
// Start Daemon to check correctness of installation
let daemon = liana::DaemonHandle::start_default(cfg.clone()).map_err(|e| {
Error::Unexpected(format!("Failed to start daemon with entered config: {}", e))
@ -179,42 +177,55 @@ pub async fn install(ctx: Context) -> Result<PathBuf, Error> {
let mut datadir_path = cfg.data_dir.clone().unwrap();
datadir_path.push(cfg.bitcoin_config.network.to_string());
// create lianad configuration file
let mut daemon_config_path = datadir_path.clone();
daemon_config_path.push(DEFAULT_FILE_NAME);
let mut daemon_config_file = std::fs::File::create(&daemon_config_path)
.map_err(|e| Error::CannotCreateFile(e.to_string()))?;
// Step needed because of ValueAfterTable error in the toml serialize implementation.
let daemon_config =
toml::Value::try_from(&cfg).expect("daemon::Config has a proper Serialize implementation");
daemon_config_file
.write_all(daemon_config.to_string().as_bytes())
.map_err(|e| Error::CannotWriteToFile(e.to_string()))?;
// create lianad configuration file
let daemon_config_path = create_and_write_file(
datadir_path.clone(),
"daemon.toml",
daemon_config.to_string().as_bytes(),
)?;
// create liana GUI configuration file
let mut gui_config_path = datadir_path;
gui_config_path.push(gui_config::DEFAULT_FILE_NAME);
let mut gui_config_file = std::fs::File::create(&gui_config_path)
.map_err(|e| Error::CannotCreateFile(e.to_string()))?;
let gui_config_path = create_and_write_file(
datadir_path.clone(),
gui_config::DEFAULT_FILE_NAME,
toml::to_string(&gui_config::Config::new(
daemon_config_path.canonicalize().map_err(|e| {
Error::Unexpected(format!("Failed to canonicalize daemon config path: {}", e))
})?,
))
.unwrap()
.as_bytes(),
)?;
gui_config_file
.write_all(
toml::to_string(&gui_config::Config::new(
daemon_config_path.canonicalize().map_err(|e| {
Error::Unexpected(format!("Failed to canonicalize daemon config path: {}", e))
})?,
hardware_wallets,
))
.unwrap()
.as_bytes(),
)
.map_err(|e| Error::CannotWriteToFile(e.to_string()))?;
// create liana GUI settings file
let settings: gui_settings::Settings = ctx.extract_gui_settings();
create_and_write_file(
datadir_path,
gui_settings::DEFAULT_FILE_NAME,
serde_json::to_string_pretty(&settings).unwrap().as_bytes(),
)?;
Ok(gui_config_path)
}
pub fn create_and_write_file(
mut network_datadir: PathBuf,
file_name: &str,
data: &[u8],
) -> Result<PathBuf, Error> {
network_datadir.push(file_name);
let path = network_datadir;
let mut file =
std::fs::File::create(&path).map_err(|e| Error::CannotCreateFile(e.to_string()))?;
file.write_all(data)
.map_err(|e| Error::CannotWriteToFile(e.to_string()))?;
Ok(path)
}
#[derive(Debug, Clone)]
pub enum Error {
CannotCreateDatadir(String),

View File

@ -1,2 +1,8 @@
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 both to know 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 backup your private key, this is your mnemonics ('seed words'). For finding the coins that belongs to you you backup a template of your Script ( / 'addresses'), this is your descriptor. Note however the descriptor needs not be as securely stored as the private key. A thief that steals your descriptor but not your private key will not be able to steal your funds.";
pub const DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP: &str =
"This is the keys that can spend received coins immediately,\n with no time restriction.";
pub const DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP: &str =
"Number of blocks after a coin is received \nfor which the recovery path is not available";
pub const DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP: &str =
"The alias is applied on all the keys derived from the same seed";

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,18 @@
mod descriptor;
pub use descriptor::{BackupDescriptor, DefineDescriptor, ImportDescriptor, RegisterDescriptor};
pub use descriptor::{
BackupDescriptor, DefineDescriptor, ImportDescriptor, ParticipateXpub, RegisterDescriptor,
};
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use async_hwi::DeviceKind;
use iced::{Command, Element};
use liana::{
config::{BitcoinConfig, BitcoindConfig},
descriptors::MultipathDescriptor,
miniscript::bitcoin,
};
use liana::{config::BitcoindConfig, miniscript::bitcoin};
use crate::ui::component::form;
use crate::installer::{
context::Context,
message::{self, Message},
view,
};
@ -37,34 +34,6 @@ pub trait Step {
}
}
#[derive(Clone)]
pub struct Context {
pub bitcoin_config: BitcoinConfig,
pub bitcoind_config: Option<BitcoindConfig>,
pub descriptor: Option<MultipathDescriptor>,
pub hws: Vec<(
DeviceKind,
bitcoin::util::bip32::Fingerprint,
Option<[u8; 32]>,
)>,
pub data_dir: PathBuf,
}
impl Context {
pub fn new(network: bitcoin::Network, data_dir: PathBuf) -> Self {
Self {
bitcoin_config: BitcoinConfig {
network,
poll_interval_secs: Duration::from_secs(30),
},
hws: Vec::new(),
bitcoind_config: None,
descriptor: None,
data_dir,
}
}
}
#[derive(Default)]
pub struct Welcome {}

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,12 @@ use liana::{
};
use crate::{
app::config::{default_datadir, Config as GUIConfig},
app::{
cache::Cache,
config::{default_datadir, Config as GUIConfig},
settings::{self, Settings},
wallet::Wallet,
},
daemon::{client, embedded::EmbeddedDaemon, model::*, Daemon, DaemonError},
ui::{
component::{button, notification, text::*},
@ -30,6 +35,7 @@ type Lianad = client::Lianad<client::jsonrpc::JsonRPCClient>;
pub struct Loader {
pub datadir_path: Option<PathBuf>,
pub network: bitcoin::Network,
pub gui_config: GUIConfig,
pub daemon_started: bool,
@ -47,16 +53,12 @@ pub enum Step {
Error(Box<Error>),
}
#[allow(clippy::type_complexity)]
#[derive(Debug)]
pub enum Message {
View(ViewMessage),
Syncing(Result<GetInfoResult, DaemonError>),
Synced(
GetInfoResult,
Vec<Coin>,
Vec<SpendTx>,
Arc<dyn Daemon + Sync + Send>,
),
Synced(Result<(Arc<Wallet>, Cache, Arc<dyn Daemon + Sync + Send>), Error>),
Started(Result<Arc<dyn Daemon + Sync + Send>, Error>),
Loaded(Result<Arc<dyn Daemon + Sync + Send>, Error>),
Failure(DaemonError),
@ -75,6 +77,7 @@ impl Loader {
.unwrap();
(
Loader {
network: daemon_config.bitcoin_config.network,
datadir_path,
daemon_config: daemon_config.clone(),
gui_config,
@ -141,18 +144,47 @@ impl Loader {
Ok(info) => {
if (info.sync - 1.0_f64).abs() < f64::EPSILON {
let daemon = daemon.clone();
let settings_path =
settings_path(&self.datadir_path, self.network).unwrap();
let gui_config_hws = self
.gui_config
.hardware_wallets
.as_ref()
.cloned()
.unwrap_or_default();
return Command::perform(
async move {
let coins = daemon
.list_coins()
.map(|res| res.coins)
.unwrap_or_else(|_| Vec::new());
let spend_txs = daemon
.list_spend_transactions()
.unwrap_or_else(|_| Vec::new());
(info, coins, spend_txs, daemon)
let coins = daemon.list_coins().map(|res| res.coins)?;
let spend_txs = daemon.list_spend_transactions()?;
let cache = Cache {
network: info.network,
blockheight: info.block_height,
coins,
spend_txs,
..Default::default()
};
let wallet = match Settings::from_file(&settings_path) {
Ok(settings) => {
if let Some(wallet_setting) = settings.wallets.first() {
Wallet::legacy(info.descriptors.main)
.with_harware_wallets(
wallet_setting.hardware_wallets.clone(),
)
.with_key_aliases(wallet_setting.keys_aliases())
} else {
Wallet::legacy(info.descriptors.main)
.with_harware_wallets(gui_config_hws)
}
}
Err(settings::SettingsError::NotFound) => {
Wallet::legacy(info.descriptors.main)
.with_harware_wallets(gui_config_hws)
}
Err(e) => return Err(e.into()),
};
Ok((Arc::new(wallet), cache, daemon))
},
|res| Message::Synced(res.0, res.1, res.2, res.3),
Message::Synced,
);
} else {
*progress = info.sync
@ -333,6 +365,7 @@ async fn sync(
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum Error {
Settings(settings::SettingsError),
Config(ConfigError),
Daemon(DaemonError),
}
@ -340,12 +373,19 @@ pub enum Error {
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Settings(e) => write!(f, "Settings error: {}", e),
Self::Config(e) => write!(f, "Config error: {}", e),
Self::Daemon(e) => write!(f, "Liana daemon error: {}", e),
}
}
}
impl From<settings::SettingsError> for Error {
fn from(error: settings::SettingsError) -> Self {
Error::Settings(error)
}
}
impl From<ConfigError> for Error {
fn from(error: ConfigError) -> Self {
Error::Config(error)
@ -372,3 +412,18 @@ fn socket_path(
path.push("lianad_rpc");
Ok(path)
}
/// default liana settings path is .liana/bitcoin/settings.json
fn settings_path(
datadir: &Option<PathBuf>,
network: bitcoin::Network,
) -> Result<PathBuf, ConfigError> {
let mut path = if let Some(ref datadir) = datadir {
datadir.clone()
} else {
default_datadir().map_err(|_| ConfigError::DatadirNotFound)?
};
path.push(network.to_string());
path.push(settings::DEFAULT_FILE_NAME);
Ok(path)
}

View File

@ -11,9 +11,7 @@ use liana::{config::Config as DaemonConfig, miniscript::bitcoin};
use liana_gui::{
app::{
self,
cache::Cache,
config::{default_datadir, ConfigError},
wallet::Wallet,
App,
},
installer::{self, Installer},
@ -205,17 +203,7 @@ impl Application for GUI {
)));
Command::none()
}
loader::Message::Synced(info, coins, spend_txs, daemon) => {
let cache = Cache {
network: info.network,
blockheight: info.block_height,
coins,
spend_txs,
..Default::default()
};
let wallet = Wallet::new(info.descriptors.main);
loader::Message::Synced(Ok((wallet, cache, daemon))) => {
let (app, command) = App::new(cache, wallet, loader.gui_config.clone(), daemon);
self.state = State::App(app);
command.map(|msg| Message::Run(Box::new(msg)))

View File

@ -35,6 +35,36 @@ impl From<SimpleCardStyle> for iced::theme::Container {
}
}
pub fn invalid<'a, T: 'a, C: Into<Element<'a, T>>>(content: C) -> widget::Container<'a, T> {
Container::new(content).padding(15).style(InvalidCardStyle)
}
pub struct InvalidCardStyle;
impl widget::container::StyleSheet for InvalidCardStyle {
type Style = iced::Theme;
fn appearance(&self, _style: &Self::Style) -> widget::container::Appearance {
widget::container::Appearance {
border_radius: 10.0,
border_color: color::ALERT,
border_width: 1.0,
background: color::FOREGROUND.into(),
..widget::container::Appearance::default()
}
}
}
impl From<InvalidCardStyle> for Box<dyn widget::container::StyleSheet<Style = iced::Theme>> {
fn from(s: InvalidCardStyle) -> Box<dyn widget::container::StyleSheet<Style = iced::Theme>> {
Box::new(s)
}
}
impl From<InvalidCardStyle> for iced::theme::Container {
fn from(i: InvalidCardStyle) -> iced::theme::Container {
iced::theme::Container::Custom(i.into())
}
}
/// display an error card with the message and the error in a tooltip.
pub fn warning<'a, T: 'a>(message: String) -> widget::Container<'a, T> {
Container::new(

View File

@ -7,6 +7,9 @@ pub mod form;
pub mod modal;
pub mod notification;
pub mod text;
pub mod tooltip;
pub use tooltip::tooltip;
use iced::widget::{Column, Container, Text};
use iced::Length;

View File

@ -0,0 +1,36 @@
use crate::ui::{color, icon};
use iced::widget::{self, Tooltip};
pub fn tooltip<'a, T: 'a>(help: &'static str) -> Tooltip<'a, T> {
Tooltip::new(
icon::tooltip_icon().style(color::DARK_GREY),
help,
widget::tooltip::Position::Right,
)
.style(TooltipStyle)
}
pub struct TooltipStyle;
impl widget::container::StyleSheet for TooltipStyle {
type Style = iced::Theme;
fn appearance(&self, _style: &Self::Style) -> widget::container::Appearance {
widget::container::Appearance {
border_radius: 10.0,
border_color: color::DARK_GREY,
border_width: 1.5,
background: color::FOREGROUND.into(),
..widget::container::Appearance::default()
}
}
}
impl From<TooltipStyle> for Box<dyn widget::container::StyleSheet<Style = iced::Theme>> {
fn from(s: TooltipStyle) -> Box<dyn widget::container::StyleSheet<Style = iced::Theme>> {
Box::new(s)
}
}
impl From<TooltipStyle> for iced::theme::Container {
fn from(i: TooltipStyle) -> iced::theme::Container {
iced::theme::Container::Custom(i.into())
}
}

View File

@ -13,6 +13,10 @@ fn icon(unicode: char) -> Text<'static> {
.size(20)
}
pub fn arrow_down() -> Text<'static> {
icon('\u{F128}')
}
pub fn recovery_icon() -> Text<'static> {
icon('\u{F467}')
}
@ -117,6 +121,10 @@ pub fn circle_check_icon() -> Text<'static> {
icon('\u{F26B}')
}
pub fn circle_cross_icon() -> Text<'static> {
icon('\u{F623}')
}
pub fn network_icon() -> Text<'static> {
icon('\u{F40D}')
}
@ -206,3 +214,15 @@ pub fn collapse_icon() -> Text<'static> {
pub fn collapsed_icon() -> Text<'static> {
icon('\u{F282}')
}
pub fn down_icon() -> Text<'static> {
icon('\u{F279}')
}
pub fn up_icon() -> Text<'static> {
icon('\u{F27C}')
}
pub fn people_icon() -> Text<'static> {
icon('\u{F4CF}')
}