Merge #75: Add spend panel

6f75515f9b572d6fa77facdba9ddd2cd1bf94a64 Add coins and recipients view (edouard)
a043f024e2e6fd2355b8bff3e27c7e8e58886c92 fix clippy errors (edouard)
063786fe5485e4f5a857ed9c45f3fbec3fc5f69a Create spend transaction (edouard)
7fda64f4adae04e2f95439975e6e82a66c0a6035 Add select inputs step (edouard)
0b2f64279b92bf0502f4edc1d9bee3c231e933e8 Add choose feerate step (edouard)
5b9414260b34c37e9cddc7d79eb0a16d8d42767e Add choose recipient step (edouard)
e1209d2cff01a11053263c2e4934e80c088a83bd Add spend panel (edouard)
95aa8a152993a43dc2451c45efcfe73d68f4c45b Add spend txs to app cache (edouard)

Pull request description:

  based on #71

ACKs for top commit:
  edouardparis:
    Self-ACK 6f75515f9b572d6fa77facdba9ddd2cd1bf94a64

Tree-SHA512: 7edeb66b21af61f610d492576aac56b2dc1bfe1eb12a665787e5db03f54b55970c607d6ff29d6227360c7544bae4168f8d22ade648a0e13bc2f9050af0d95d06
This commit is contained in:
edouard 2022-11-04 18:29:56 +01:00
commit 76bd319d88
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
31 changed files with 2286 additions and 34 deletions

1
gui/Cargo.lock generated
View File

@ -1619,6 +1619,7 @@ version = "0.0.1"
dependencies = [
"async-hwi",
"backtrace",
"base64",
"chrono",
"dirs",
"fern",

View File

@ -17,6 +17,7 @@ path = "src/main.rs"
async-hwi = { git = "https://github.com/revault/async-hwi", branch = "master" }
minisafe = { git = "https://github.com/revault/minisafe", branch = "master", default-features = false }
backtrace = "0.3"
base64 = "0.13"
iced = { version = "0.4", default-features= false, features = ["tokio", "wgpu", "svg", "qr_code", "pure"] }
iced_native = "0.5"

View File

@ -1,7 +1,20 @@
use crate::daemon::model::Coin;
use crate::daemon::model::{Coin, SpendTx};
use minisafe::miniscript::bitcoin::Network;
#[derive(Default)]
pub struct Cache {
pub network: Network,
pub blockheight: i32,
pub coins: Vec<Coin>,
pub spend_txs: Vec<SpendTx>,
}
impl std::default::Default for Cache {
fn default() -> Self {
Self {
network: Network::Bitcoin,
blockheight: 0,
coins: Vec::new(),
spend_txs: Vec::new(),
}
}
}

View File

@ -8,6 +8,7 @@ pub enum Error {
Config(String),
Daemon(DaemonError),
Unexpected(String),
HardwareWallet(async_hwi::Error),
}
impl std::fmt::Display for Error {
@ -35,6 +36,7 @@ impl std::fmt::Display for Error {
}
},
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
Self::HardwareWallet(e) => write!(f, "{}", e),
}
}
}
@ -50,3 +52,9 @@ impl From<DaemonError> for Error {
Error::Daemon(error)
}
}
impl From<async_hwi::Error> for Error {
fn from(error: async_hwi::Error) -> Self {
Error::HardwareWallet(error)
}
}

View File

@ -2,6 +2,8 @@
pub enum Menu {
Home,
Receive,
Spend,
Settings,
Coins,
CreateSpendTx,
}

View File

@ -1,8 +1,15 @@
use minisafe::{config::Config as DaemonConfig, miniscript::bitcoin::Address};
use minisafe::{
config::Config as DaemonConfig,
miniscript::bitcoin::{
util::{bip32::Fingerprint, psbt::Psbt},
Address,
},
};
use crate::{
app::{error::Error, view},
daemon::model::*,
hw::HardwareWallet,
};
#[derive(Debug)]
@ -15,4 +22,9 @@ pub enum Message {
BlockHeight(Result<i32, Error>),
ReceiveAddress(Result<Address, Error>),
Coins(Result<Vec<Coin>, Error>),
SpendTxs(Result<Vec<SpendTx>, Error>),
Psbt(Result<Psbt, Error>),
Signed(Result<(Psbt, Fingerprint), Error>),
Updated(Result<(), Error>),
ConnectedHardwareWallets(Vec<HardwareWallet>),
}

View File

@ -21,7 +21,7 @@ pub use minisafe::config::Config as DaemonConfig;
pub use config::Config;
pub use message::Message;
use state::{CoinsPanel, Home, ReceivePanel, State};
use state::{CoinsPanel, CreateSpendPanel, Home, ReceivePanel, SpendPanel, State};
use crate::{
app::{cache::Cache, error::Error, menu::Menu},
@ -65,6 +65,10 @@ impl App {
menu::Menu::Home => Home::new(&self.cache.coins).into(),
menu::Menu::Coins => CoinsPanel::new(&self.cache.coins).into(),
menu::Menu::Receive => ReceivePanel::default().into(),
menu::Menu::Spend => SpendPanel::new(self.config.clone(), &self.cache.spend_txs).into(),
menu::Menu::CreateSpendTx => {
CreateSpendPanel::new(self.config.clone(), &self.cache.coins).into()
}
};
self.state.load(self.daemon.clone())
}
@ -102,6 +106,9 @@ impl App {
Message::Coins(Ok(coins)) => {
self.cache.coins = coins.clone();
}
Message::SpendTxs(Ok(txs)) => {
self.cache.spend_txs = txs.clone();
}
Message::BlockHeight(Ok(blockheight)) => {
self.cache.blockheight = *blockheight;
}

View File

@ -25,9 +25,10 @@ impl CoinsPanel {
}
impl State for CoinsPanel {
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
view::dashboard(
&Menu::Coins,
cache,
self.warning.as_ref(),
view::coins::coins_view(&self.coins),
)

View File

@ -1,5 +1,6 @@
mod coins;
mod settings;
mod spend;
use std::sync::Arc;
@ -12,6 +13,7 @@ use crate::daemon::{model::Coin, Daemon};
pub use coins::CoinsPanel;
pub use settings::SettingsState;
pub use spend::{CreateSpendPanel, SpendPanel};
pub trait State {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>;
@ -36,14 +38,30 @@ pub struct Home {
impl Home {
pub fn new(coins: &[Coin]) -> Self {
Self {
balance: Amount::from_sat(coins.iter().map(|coin| coin.amount.to_sat()).sum()),
balance: Amount::from_sat(
coins
.iter()
.map(|coin| {
if coin.spend_info.is_none() {
coin.amount.to_sat()
} else {
0
}
})
.sum(),
),
}
}
}
impl State for Home {
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
view::dashboard(&Menu::Home, None, view::home::home_view(&self.balance))
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
view::dashboard(
&Menu::Home,
cache,
None,
view::home::home_view(&self.balance),
)
}
fn update(
@ -83,15 +101,16 @@ pub struct ReceivePanel {
}
impl State for ReceivePanel {
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
if let Some(address) = &self.address {
view::dashboard(
&Menu::Receive,
cache,
self.warning.as_ref(),
view::receive::receive(address, self.qr_code.as_ref().unwrap()),
)
} else {
view::dashboard(&Menu::Receive, self.warning.as_ref(), column())
view::dashboard(&Menu::Receive, cache, self.warning.as_ref(), column())
}
}
fn update(
@ -133,6 +152,13 @@ impl From<ReceivePanel> for Box<dyn State> {
}
}
/// redirect to another state with a message menu
pub fn redirect(menu: Menu) -> Command<Message> {
Command::perform(async { menu }, |menu| {
Message::View(view::Message::Menu(menu))
})
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -103,6 +103,7 @@ impl State for SettingsState {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
let can_edit = self.current.is_none() && !self.daemon_is_external;
view::settings::list(
cache,
self.warning.as_ref(),
self.settings
.iter()

View File

@ -0,0 +1,369 @@
use std::sync::Arc;
use iced::pure::Element;
use iced::Command;
use minisafe::miniscript::bitcoin::util::{bip32::Fingerprint, psbt::Psbt};
use crate::{
app::{
cache::Cache, config::Config, error::Error, message::Message, view, view::spend::detail,
},
daemon::{
model::{SpendStatus, SpendTx},
Daemon,
},
hw::{list_hardware_wallets, HardwareWallet},
};
trait Action {
fn warning(&self) -> Option<&Error> {
None
}
fn updated(&self) -> bool {
false
}
fn load(&self, _daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
Command::none()
}
fn update(
&mut self,
_daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
_message: Message,
_tx: &mut SpendTx,
) -> Command<Message> {
Command::none()
}
fn view(&self) -> Element<view::Message>;
}
pub struct SpendTxState {
config: Config,
tx: SpendTx,
saved: bool,
action: Box<dyn Action>,
}
impl SpendTxState {
pub fn new(config: Config, tx: SpendTx, saved: bool) -> Self {
Self {
action: choose_action(&config, saved, &tx),
config,
tx,
saved,
}
}
pub fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
self.action.load(daemon)
}
pub fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Command<Message> {
let cmd = match &message {
Message::View(view::Message::Spend(msg)) => match msg {
view::SpendTxMessage::Cancel => {
self.action = choose_action(&self.config, self.saved, &self.tx);
self.action.load(daemon.clone())
}
view::SpendTxMessage::Delete => {
self.action = Box::new(DeleteAction::default());
self.action.load(daemon.clone())
}
_ => self
.action
.update(daemon.clone(), cache, message, &mut self.tx),
},
_ => self
.action
.update(daemon.clone(), cache, message, &mut self.tx),
};
if self.action.updated() {
self.saved = true;
self.action = choose_action(&self.config, self.saved, &self.tx);
self.action.load(daemon)
} else {
cmd
}
}
pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
detail::spend_view(
self.action.warning(),
&self.tx,
self.action.view(),
self.saved,
cache.network,
)
}
}
fn choose_action(config: &Config, saved: bool, tx: &SpendTx) -> Box<dyn Action> {
if saved {
match tx.status {
SpendStatus::Deprecated | SpendStatus::Broadcasted => {
return Box::new(NoAction::default());
}
_ => {}
}
if !tx.psbt.inputs.first().unwrap().partial_sigs.is_empty() {
return Box::new(BroadcastAction::default());
} else {
return Box::new(SignAction::new(config.clone()));
}
}
Box::new(SaveAction::default())
}
#[derive(Default)]
pub struct SaveAction {
saved: bool,
error: Option<Error>,
}
impl Action for SaveAction {
fn warning(&self) -> Option<&Error> {
self.error.as_ref()
}
fn updated(&self) -> bool {
self.saved
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
tx: &mut SpendTx,
) -> Command<Message> {
match message {
Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => {
let daemon = daemon.clone();
let psbt = tx.psbt.clone();
return Command::perform(
async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) },
Message::Updated,
);
}
Message::Updated(res) => match res {
Ok(()) => self.saved = true,
Err(e) => self.error = Some(e),
},
_ => {}
}
Command::none()
}
fn view(&self) -> Element<view::Message> {
detail::save_action(self.saved)
}
}
#[derive(Default)]
pub struct BroadcastAction {
broadcasted: bool,
error: Option<Error>,
}
impl Action for BroadcastAction {
fn warning(&self) -> Option<&Error> {
self.error.as_ref()
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
tx: &mut SpendTx,
) -> Command<Message> {
match message {
Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => {
let daemon = daemon.clone();
let psbt = tx.psbt.clone();
self.error = None;
return Command::perform(
async move {
daemon
.broadcast_spend_tx(&psbt.unsigned_tx.txid())
.map_err(|e| e.into())
},
Message::Updated,
);
}
Message::Updated(res) => match res {
Ok(()) => self.broadcasted = true,
Err(e) => self.error = Some(e),
},
_ => {}
}
Command::none()
}
fn view(&self) -> Element<view::Message> {
detail::broadcast_action(self.broadcasted)
}
}
#[derive(Default)]
pub struct DeleteAction {
deleted: bool,
error: Option<Error>,
}
impl Action for DeleteAction {
fn warning(&self) -> Option<&Error> {
self.error.as_ref()
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
tx: &mut SpendTx,
) -> Command<Message> {
match message {
Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => {
let daemon = daemon.clone();
let psbt = tx.psbt.clone();
self.error = None;
return Command::perform(
async move {
daemon
.delete_spend_tx(&psbt.unsigned_tx.txid())
.map_err(|e| e.into())
},
Message::Updated,
);
}
Message::Updated(res) => match res {
Ok(()) => self.deleted = true,
Err(e) => self.error = Some(e),
},
_ => {}
}
Command::none()
}
fn view(&self) -> Element<view::Message> {
detail::delete_action(self.deleted)
}
}
pub struct SignAction {
config: Config,
chosen_hw: Option<usize>,
processing: bool,
hws: Vec<HardwareWallet>,
error: Option<Error>,
signed: Vec<Fingerprint>,
updated: bool,
}
impl SignAction {
pub fn new(config: Config) -> Self {
Self {
config,
chosen_hw: None,
processing: false,
hws: Vec::new(),
error: None,
signed: Vec::new(),
updated: false,
}
}
}
impl Action for SignAction {
fn warning(&self) -> Option<&Error> {
self.error.as_ref()
}
fn updated(&self) -> bool {
self.updated
}
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let config = self.config.clone();
let desc = daemon.config().main_descriptor.to_string();
Command::perform(
list_hws(config, "Minisafe".to_string(), desc),
Message::ConnectedHardwareWallets,
)
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
tx: &mut SpendTx,
) -> Command<Message> {
match message {
Message::View(view::Message::Spend(view::SpendTxMessage::SelectHardwareWallet(i))) => {
if let Some(hw) = self.hws.get(i) {
let device = hw.device.clone();
self.chosen_hw = Some(i);
self.processing = true;
let psbt = tx.psbt.clone();
return Command::perform(
sign_psbt(device, hw.fingerprint, psbt),
Message::Signed,
);
}
}
Message::Signed(res) => match res {
Err(e) => self.error = Some(e),
Ok((psbt, fingerprint)) => {
self.error = None;
self.signed.push(fingerprint);
let daemon = daemon.clone();
tx.psbt = psbt.clone();
return Command::perform(
async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) },
Message::Updated,
);
}
},
Message::Updated(res) => match res {
Ok(()) => self.updated = true,
Err(e) => self.error = Some(e),
},
Message::ConnectedHardwareWallets(hws) => {
self.hws = hws;
}
Message::View(view::Message::Reload) => {
return self.load(daemon);
}
_ => {}
};
Command::none()
}
fn view(&self) -> Element<view::Message> {
view::spend::detail::sign_action(&self.hws, self.processing, self.chosen_hw, &self.signed)
}
}
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 sign_psbt(
hw: std::sync::Arc<dyn async_hwi::HWI + Send + Sync>,
fingerprint: Fingerprint,
mut psbt: Psbt,
) -> Result<(Psbt, Fingerprint), Error> {
hw.sign_tx(&mut psbt).await.map_err(Error::from)?;
Ok((psbt, fingerprint))
}
#[derive(Default)]
pub struct NoAction {}
impl Action for NoAction {
fn view(&self) -> Element<view::Message> {
iced::pure::column().into()
}
}

View File

@ -0,0 +1,178 @@
mod detail;
mod step;
use std::sync::Arc;
use iced::{pure::Element, Command};
use super::{redirect, State};
use crate::{
app::{cache::Cache, config::Config, error::Error, menu::Menu, message::Message, view},
daemon::{
model::{Coin, SpendTx},
Daemon,
},
};
pub struct SpendPanel {
config: Config,
selected_tx: Option<detail::SpendTxState>,
spend_txs: Vec<SpendTx>,
warning: Option<Error>,
}
impl SpendPanel {
pub fn new(config: Config, spend_txs: &[SpendTx]) -> Self {
Self {
config,
spend_txs: spend_txs.to_vec(),
warning: None,
selected_tx: None,
}
}
}
impl State for SpendPanel {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
if let Some(tx) = &self.selected_tx {
tx.view(cache)
} else {
view::dashboard(
&Menu::Spend,
cache,
self.warning.as_ref(),
view::spend::spend_view(&self.spend_txs),
)
}
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Command<Message> {
match message {
Message::SpendTxs(res) => match res {
Err(e) => self.warning = Some(e),
Ok(txs) => {
self.warning = None;
self.spend_txs = txs;
}
},
Message::View(view::Message::Close) => {
if self.selected_tx.is_some() {
self.selected_tx = None;
return self.load(daemon);
}
}
Message::View(view::Message::Select(i)) => {
if let Some(tx) = self.spend_txs.get(i) {
let tx = detail::SpendTxState::new(self.config.clone(), tx.clone(), true);
let cmd = tx.load(daemon);
self.selected_tx = Some(tx);
return cmd;
}
}
_ => {
if let Some(tx) = &mut self.selected_tx {
return tx.update(daemon, cache, message);
}
}
}
Command::none()
}
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let daemon = daemon.clone();
Command::perform(
async move { daemon.list_spend_transactions().map_err(|e| e.into()) },
Message::SpendTxs,
)
}
}
impl From<SpendPanel> for Box<dyn State> {
fn from(s: SpendPanel) -> Box<dyn State> {
Box::new(s)
}
}
pub struct CreateSpendPanel {
draft: step::TransactionDraft,
current: usize,
steps: Vec<Box<dyn step::Step>>,
}
impl CreateSpendPanel {
pub fn new(config: Config, coins: &[Coin]) -> Self {
Self {
draft: step::TransactionDraft::default(),
current: 0,
steps: vec![
Box::new(step::ChooseRecipients::default()),
Box::new(step::ChooseCoins::new(coins.to_vec())),
Box::new(step::ChooseFeerate::default()),
Box::new(step::SaveSpend::new(config)),
],
}
}
}
impl State for CreateSpendPanel {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
self.steps.get(self.current).unwrap().view(cache)
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Command<Message> {
if matches!(message, Message::View(view::Message::Close)) {
return redirect(Menu::Spend);
}
if matches!(message, Message::View(view::Message::Next)) {
if let Some(step) = self.steps.get(self.current) {
step.apply(&mut self.draft);
}
if let Some(step) = self.steps.get_mut(self.current + 1) {
self.current += 1;
step.load(&self.draft);
}
}
if matches!(message, Message::View(view::Message::Previous))
&& self.steps.get(self.current - 1).is_some()
{
self.current -= 1;
}
if let Some(step) = self.steps.get_mut(self.current) {
return step.update(daemon, cache, &self.draft, message);
}
Command::none()
}
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let daemon = daemon.clone();
Command::perform(
async move {
daemon
.list_coins()
.map(|res| res.coins)
.map_err(|e| e.into())
},
Message::Coins,
)
}
}
impl From<CreateSpendPanel> for Box<dyn State> {
fn from(s: CreateSpendPanel) -> Box<dyn State> {
Box::new(s)
}
}

View File

@ -0,0 +1,381 @@
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use iced::pure::Element;
use iced::Command;
use minisafe::miniscript::bitcoin::{
util::psbt::Psbt, Address, Amount, Denomination, OutPoint, Script,
};
use crate::{
app::{
cache::Cache, config::Config, error::Error, message::Message, state::spend::detail, view,
},
daemon::{
model::{Coin, SpendTx},
Daemon,
},
ui::component::form,
};
#[derive(Default, Clone)]
pub struct TransactionDraft {
inputs: Vec<Coin>,
outputs: HashMap<Address, u64>,
feerate: u64,
generated: Option<Psbt>,
}
pub trait Step {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>;
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
draft: &TransactionDraft,
message: Message,
) -> Command<Message>;
fn apply(&self, _draft: &mut TransactionDraft) {}
fn load(&mut self, _draft: &TransactionDraft) {}
}
pub struct ChooseRecipients {
recipients: Vec<Recipient>,
}
impl std::default::Default for ChooseRecipients {
fn default() -> Self {
Self {
recipients: vec![Recipient::default()],
}
}
}
impl Step for ChooseRecipients {
fn update(
&mut self,
_daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
_draft: &TransactionDraft,
message: Message,
) -> Command<Message> {
if let Message::View(view::Message::CreateSpend(msg)) = message {
match &msg {
view::CreateSpendMessage::AddRecipient => {
self.recipients.push(Recipient::default());
}
view::CreateSpendMessage::DeleteRecipient(i) => {
self.recipients.remove(*i);
}
view::CreateSpendMessage::RecipientEdited(i, _, _) => {
self.recipients.get_mut(*i).unwrap().update(msg);
}
_ => {}
}
}
Command::none()
}
fn apply(&self, draft: &mut TransactionDraft) {
let mut outputs: HashMap<Address, u64> = HashMap::new();
for recipient in &self.recipients {
outputs.insert(
Address::from_str(&recipient.address.value).expect("Checked before"),
recipient.amount().expect("Checked before"),
);
}
draft.outputs = outputs;
}
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
view::spend::step::choose_recipients_view(
self.recipients
.iter()
.enumerate()
.map(|(i, recipient)| recipient.view(i).map(view::Message::CreateSpend))
.collect(),
!self.recipients.iter().any(|recipient| !recipient.valid()),
)
}
}
#[derive(Default)]
struct Recipient {
address: form::Value<String>,
amount: form::Value<String>,
}
impl Recipient {
fn amount(&self) -> Result<u64, Error> {
if self.amount.value.is_empty() {
return Err(Error::Unexpected("Amount should be non-zero".to_string()));
}
let amount = Amount::from_str_in(&self.amount.value, Denomination::Bitcoin)
.map_err(|_| Error::Unexpected("cannot parse output amount".to_string()))?;
if amount.to_sat() == 0 {
return Err(Error::Unexpected("Amount should be non-zero".to_string()));
}
if let Ok(address) = Address::from_str(&self.address.value) {
if amount <= address.script_pubkey().dust_value() {
return Err(Error::Unexpected(
"Amount must be superior to script dust value".to_string(),
));
}
}
Ok(amount.to_sat())
}
fn valid(&self) -> bool {
!self.address.value.is_empty()
&& self.address.valid
&& !self.amount.value.is_empty()
&& self.amount.valid
}
fn update(&mut self, message: view::CreateSpendMessage) {
match message {
view::CreateSpendMessage::RecipientEdited(_, "address", address) => {
self.address.value = address;
if self.address.value.is_empty() {
// Make the error disappear if we deleted the invalid address
self.address.valid = true;
} else if Address::from_str(&self.address.value).is_ok() {
self.address.valid = true;
if !self.amount.value.is_empty() {
self.amount.valid = self.amount().is_ok();
}
} else {
self.address.valid = false;
}
}
view::CreateSpendMessage::RecipientEdited(_, "amount", amount) => {
self.amount.value = amount;
if !self.amount.value.is_empty() {
self.amount.valid = self.amount().is_ok();
} else {
// Make the error disappear if we deleted the invalid amount
self.amount.valid = true;
}
}
_ => {}
};
}
fn view(&self, i: usize) -> Element<view::CreateSpendMessage> {
view::spend::step::recipient_view(i, &self.address, &self.amount)
}
}
#[derive(Default)]
pub struct ChooseFeerate {
feerate: form::Value<String>,
generated: Option<Psbt>,
warning: Option<Error>,
}
impl Step for ChooseFeerate {
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
draft: &TransactionDraft,
message: Message,
) -> Command<Message> {
match message {
Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited(
s,
))) => {
if s.parse::<u64>().is_ok() {
self.feerate.value = s;
self.feerate.valid = true;
} else if s.is_empty() {
self.feerate.value = "".to_string();
self.feerate.valid = true;
} else {
self.feerate.valid = false;
}
self.warning = None;
}
Message::View(view::Message::CreateSpend(view::CreateSpendMessage::Generate)) => {
let inputs: Vec<OutPoint> = draft.inputs.iter().map(|c| c.outpoint).collect();
let outputs = draft.outputs.clone();
let feerate_vb = self.feerate.value.parse::<u64>().unwrap_or(0);
self.warning = None;
return Command::perform(
async move {
daemon
.create_spend_tx(&inputs, &outputs, feerate_vb)
.map(|res| res.psbt)
.map_err(|e| e.into())
},
Message::Psbt,
);
}
Message::Psbt(res) => match res {
Ok(psbt) => {
self.generated = Some(psbt);
return Command::perform(async {}, |_| Message::View(view::Message::Next));
}
Err(e) => self.warning = Some(e),
},
_ => {}
}
Command::none()
}
fn apply(&self, draft: &mut TransactionDraft) {
draft.feerate = self.feerate.value.parse::<u64>().expect("Checked before");
draft.generated = self.generated.clone();
}
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
view::spend::step::choose_feerate_view(
&self.feerate,
self.feerate.valid && !self.feerate.value.is_empty(),
self.warning.as_ref(),
)
}
}
#[derive(Default)]
pub struct ChooseCoins {
coins: Vec<(Coin, bool)>,
/// draft output amount must be superior to total input amount.
is_valid: bool,
total_needed: Option<Amount>,
}
impl ChooseCoins {
pub fn new(coins: Vec<Coin>) -> Self {
Self {
coins: coins
.into_iter()
.filter_map(|c| {
if c.spend_info.is_none() {
Some((c, false))
} else {
None
}
})
.collect(),
is_valid: false,
total_needed: None,
}
}
}
impl Step for ChooseCoins {
fn load(&mut self, draft: &TransactionDraft) {
self.total_needed = Some(Amount::from_sat(
draft.outputs.values().fold(0, |acc, a| acc + *a),
));
}
fn update(
&mut self,
_daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
_draft: &TransactionDraft,
message: Message,
) -> Command<Message> {
if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectCoin(i))) =
message
{
if let Some(coin) = self.coins.get_mut(i) {
coin.1 = !coin.1;
}
self.is_valid = self
.coins
.iter()
.filter_map(|(coin, selected)| {
if *selected {
Some(coin.amount.to_sat())
} else {
None
}
})
.sum::<u64>()
> self.total_needed.map(|a| a.to_sat()).unwrap_or(0);
}
Command::none()
}
fn apply(&self, draft: &mut TransactionDraft) {
draft.inputs = self
.coins
.iter()
.filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None })
.collect();
}
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
view::spend::step::choose_coins_view(&self.coins, self.total_needed.as_ref(), self.is_valid)
}
}
pub struct SaveSpend {
config: Config,
spend: Option<detail::SpendTxState>,
}
impl SaveSpend {
pub fn new(config: Config) -> Self {
Self {
config,
spend: None,
}
}
}
impl Step for SaveSpend {
fn load(&mut self, draft: &TransactionDraft) {
let outputs_script_pubkeys: Vec<Script> = draft
.outputs
.keys()
.map(|addr| addr.script_pubkey())
.collect();
let index = if let Some(psbt) = &draft.generated {
psbt.unsigned_tx
.output
.iter()
.position(|output| outputs_script_pubkeys.contains(&output.script_pubkey))
} else {
None
};
self.spend = Some(detail::SpendTxState::new(
self.config.clone(),
SpendTx::new(
draft.generated.clone().unwrap(),
index,
draft.inputs.clone(),
),
false,
));
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
_draft: &TransactionDraft,
message: Message,
) -> Command<Message> {
if let Some(spend) = &mut self.spend {
spend.update(daemon, cache, message)
} else {
Command::none()
}
}
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
self.spend.as_ref().unwrap().view(cache)
}
}

View File

@ -3,7 +3,10 @@ use iced::{
Alignment, Length,
};
use crate::ui::component::{badge, button::Style, card, text::*};
use crate::ui::{
component::{badge, button::Style, card, text::*},
util::Collection,
};
use crate::{app::view::message::Message, daemon::model::Coin};
@ -39,7 +42,11 @@ fn coin_list_view<'a>(i: usize, coin: &Coin) -> Element<'a, Message> {
.push(
row()
.push(badge::coin())
.push(text(&format!("block: {}", coin.block_height.unwrap_or(0))).small())
.push_maybe(coin.spend_info.map(|_| {
container(text(" Spent ").small())
.padding(3)
.style(badge::PillStyle::Success)
}))
.spacing(10)
.align_items(Alignment::Center)
.width(Length::Fill),

View File

@ -8,6 +8,28 @@ pub enum Message {
Close,
Select(usize),
Settings(usize, SettingsMessage),
CreateSpend(CreateSpendMessage),
Spend(SpendTxMessage),
Next,
Previous,
}
#[derive(Debug, Clone)]
pub enum CreateSpendMessage {
AddRecipient,
DeleteRecipient(usize),
SelectCoin(usize),
RecipientEdited(usize, &'static str, String),
FeerateEdited(String),
Generate,
}
#[derive(Debug, Clone)]
pub enum SpendTxMessage {
Delete,
Confirm,
Cancel,
SelectHardwareWallet(usize),
}
#[derive(Debug, Clone)]

View File

@ -5,6 +5,7 @@ pub mod coins;
pub mod home;
pub mod receive;
pub mod settings;
pub mod spend;
pub use message::*;
use warning::warn;
@ -16,13 +17,14 @@ use iced::{
use crate::ui::{
color,
component::{button, separation, text::*},
icon::{coin_icon, home_icon, receive_icon, settings_icon},
component::{badge, button, separation, text::*},
icon::{coin_icon, cross_icon, home_icon, receive_icon, send_icon, settings_icon},
util::Collection,
};
use crate::app::{error::Error, menu::Menu};
use crate::app::{cache::Cache, error::Error, menu::Menu};
pub fn sidebar(menu: &Menu) -> widget::Container<Message> {
pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> widget::Container<'a, Message> {
let home_button = if *menu == Menu::Home {
button::primary(Some(home_icon()), "Home")
.on_press(Message::Reload)
@ -34,13 +36,155 @@ pub fn sidebar(menu: &Menu) -> widget::Container<Message> {
};
let coins_button = if *menu == Menu::Coins {
button::primary(Some(coin_icon()), "Coins")
.on_press(Message::Reload)
.width(iced::Length::Units(200))
iced::pure::widget::button::Button::new(
container(
row()
.push(
row()
.push(coin_icon())
.push(text("Coins"))
.spacing(10)
.width(iced::Length::Fill)
.align_items(iced::Alignment::Center),
)
.push(
container(
text(&format!(
" {} ",
cache
.coins
.iter()
// TODO: Remove when cache contains only current coins.
.filter(|coin| coin.spend_info.is_none())
.count()
))
.small()
.bold(),
)
.style(badge::PillStyle::InversePrimary),
)
.spacing(10)
.width(iced::Length::Fill)
.align_items(iced::Alignment::Center),
)
.width(iced::Length::Fill)
.padding(5)
.center_x(),
)
.style(button::Style::Primary)
.on_press(Message::Reload)
.width(iced::Length::Units(200))
} else {
button::transparent(Some(coin_icon()), "Coins")
.on_press(Message::Menu(Menu::Coins))
.width(iced::Length::Units(200))
iced::pure::widget::button::Button::new(
container(
row()
.push(
row()
.push(coin_icon())
.push(text("Coins"))
.spacing(10)
.width(iced::Length::Fill)
.align_items(iced::Alignment::Center),
)
.push(
container(
text(&format!(
" {} ",
cache
.coins
.iter()
// TODO: Remove when cache contains only current coins.
.filter(|coin| coin.spend_info.is_none())
.count()
))
.small()
.bold(),
)
.style(badge::PillStyle::Primary),
)
.spacing(10)
.width(iced::Length::Fill)
.align_items(iced::Alignment::Center),
)
.width(iced::Length::Fill)
.padding(5)
.center_x(),
)
.style(button::Style::Transparent)
.on_press(Message::Menu(Menu::Coins))
.width(iced::Length::Units(200))
};
let spend_button = if *menu == Menu::Spend {
iced::pure::widget::button::Button::new(
container(
row()
.push(
row()
.push(send_icon())
.push(text("Send"))
.spacing(10)
.width(iced::Length::Fill)
.align_items(iced::Alignment::Center),
)
.push_maybe(if cache.spend_txs.is_empty() {
None
} else {
Some(
container(
text(&format!(" {} ", cache.spend_txs.len()))
.small()
.bold(),
)
.style(badge::PillStyle::InversePrimary),
)
})
.spacing(10)
.width(iced::Length::Fill)
.align_items(iced::Alignment::Center),
)
.width(iced::Length::Fill)
.padding(5)
.center_x(),
)
.style(button::Style::Primary)
.on_press(Message::Reload)
.width(iced::Length::Units(200))
} else {
iced::pure::widget::button::Button::new(
container(
row()
.push(
row()
.push(send_icon())
.push(text("Send"))
.spacing(10)
.width(iced::Length::Fill)
.align_items(iced::Alignment::Center),
)
.push_maybe(if cache.spend_txs.is_empty() {
None
} else {
Some(
container(
text(&format!(" {} ", cache.spend_txs.len()))
.small()
.bold(),
)
.style(badge::PillStyle::Primary),
)
})
.spacing(10)
.width(iced::Length::Fill)
.align_items(iced::Alignment::Center),
)
.width(iced::Length::Fill)
.padding(5)
.center_x(),
)
.style(button::Style::Transparent)
.on_press(Message::Menu(Menu::Spend))
.width(iced::Length::Units(200))
};
let receive_button = if *menu == Menu::Receive {
@ -76,6 +220,7 @@ pub fn sidebar(menu: &Menu) -> widget::Container<Message> {
)
.push(home_button)
.push(coins_button)
.push(spend_button)
.push(receive_button)
.spacing(15)
.height(Length::Fill),
@ -99,11 +244,16 @@ impl widget::container::StyleSheet for SidebarStyle {
pub fn dashboard<'a, T: Into<Element<'a, Message>>>(
menu: &'a Menu,
cache: &'a Cache,
warning: Option<&Error>,
content: T,
) -> Element<'a, Message> {
row()
.push(sidebar(menu).width(Length::Shrink).height(Length::Fill))
.push(
sidebar(menu, cache)
.width(Length::Shrink)
.height(Length::Fill),
)
.push(
column().push(warn(warning)).push(
main_section(container(scrollable(content)))
@ -134,3 +284,53 @@ impl widget::container::StyleSheet for MainSectionStyle {
}
}
}
pub fn modal<'a, T: Into<Element<'a, Message>>>(
is_previous: bool,
warning: Option<&Error>,
content: T,
) -> Element<'a, Message> {
column()
.push(warn(warning))
.push(
container(
row()
.push(if is_previous {
column()
.push(
button::transparent(None, "< Previous").on_press(Message::Previous),
)
.width(Length::Fill)
} else {
column().width(Length::Fill)
})
.align_items(iced::Alignment::Center)
.push(button::primary(Some(cross_icon()), "Close").on_press(Message::Close)),
)
.padding(10)
.style(ModalSectionStyle),
)
.push(modal_section(container(scrollable(content))))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn modal_section<'a, T: 'a>(menu: widget::Container<'a, T>) -> widget::Container<'a, T> {
container(menu.max_width(1500))
.padding(20)
.style(ModalSectionStyle)
.center_x()
.width(Length::Fill)
.height(Length::Fill)
}
pub struct ModalSectionStyle;
impl widget::container::StyleSheet for ModalSectionStyle {
fn style(&self) -> widget::container::Style {
widget::container::Style {
background: color::BACKGROUND.into(),
..widget::container::Style::default()
}
}
}

View File

@ -12,7 +12,7 @@ use super::{
};
use crate::{
app::{error::Error, menu::Menu},
app::{cache::Cache, error::Error, menu::Menu},
ui::{
color,
component::{badge, button, card, form, separation, text::*},
@ -21,11 +21,13 @@ use crate::{
};
pub fn list<'a>(
cache: &'a Cache,
warning: Option<&Error>,
settings: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
dashboard(
&Menu::Settings,
cache,
warning,
widget::Column::with_children(settings).spacing(20),
)

View File

@ -0,0 +1,412 @@
use iced::{
pure::{column, container, row, scrollable, Element},
Alignment, Length,
};
use minisafe::miniscript::bitcoin::{
util::{bip32::Fingerprint, psbt::Psbt},
Address, Amount, Network,
};
use crate::{
app::{
error::Error,
view::{message::*, modal_section, warning::warn, ModalSectionStyle},
},
daemon::model::{Coin, SpendStatus, SpendTx},
hw::HardwareWallet,
ui::{
color,
component::{
badge, button, card, separation,
text::{text, Text},
},
icon,
util::Collection,
},
};
pub fn spend_view<'a, T: Into<Element<'a, Message>>>(
warning: Option<&Error>,
tx: &SpendTx,
action: T,
show_delete: bool,
network: Network,
) -> Element<'a, Message> {
spend_modal(
show_delete,
warning,
column()
.spacing(20)
.push(spend_overview_view(tx))
.push(action)
.push(inputs_and_outputs_view(
&tx.coins,
&tx.psbt,
network,
tx.change_index,
)),
)
}
pub fn save_action<'a>(saved: bool) -> Element<'a, Message> {
if saved {
card::simple(text("Transaction is saved"))
.width(Length::Fill)
.align_x(iced::alignment::Horizontal::Center)
.into()
} else {
card::simple(
column()
.spacing(10)
.push(text("Save the transaction"))
.push(row().push(column().width(Length::Fill)).push(
button::primary(None, "Save").on_press(Message::Spend(SpendTxMessage::Confirm)),
)),
)
.width(Length::Fill)
.into()
}
}
pub fn broadcast_action<'a>(saved: bool) -> Element<'a, Message> {
if saved {
card::simple(text("Transaction is broadcasted"))
.width(Length::Fill)
.align_x(iced::alignment::Horizontal::Center)
.into()
} else {
card::simple(
column()
.spacing(10)
.push(text("Broadcast the transaction"))
.push(
row().push(column().width(Length::Fill)).push(
button::primary(None, "Broadcast")
.on_press(Message::Spend(SpendTxMessage::Confirm)),
),
),
)
.width(Length::Fill)
.into()
}
}
pub fn delete_action<'a>(deleted: bool) -> Element<'a, Message> {
if deleted {
card::simple(text("Transaction is deleted"))
.align_x(iced::alignment::Horizontal::Center)
.width(Length::Fill)
.into()
} else {
card::simple(
column()
.spacing(10)
.push(text("Delete the transaction draft"))
.push(
row()
.push(column().width(Length::Fill))
.push(
button::transparent(None, "Cancel")
.on_press(Message::Spend(SpendTxMessage::Cancel)),
)
.push(
button::alert(None, "Delete")
.on_press(Message::Spend(SpendTxMessage::Confirm)),
),
),
)
.width(Length::Fill)
.into()
}
}
pub fn spend_modal<'a, T: Into<Element<'a, Message>>>(
show_delete: bool,
warning: Option<&Error>,
content: T,
) -> Element<'a, Message> {
column()
.push(warn(warning))
.push(
container(
row()
.push(if show_delete {
column()
.push(
button::alert(Some(icon::trash_icon()), "Delete")
.on_press(Message::Spend(SpendTxMessage::Delete)),
)
.width(Length::Fill)
} else {
column().width(Length::Fill)
})
.align_items(iced::Alignment::Center)
.push(
button::primary(Some(icon::cross_icon()), "Close").on_press(Message::Close),
),
)
.padding(10)
.style(ModalSectionStyle),
)
.push(modal_section(container(scrollable(content))))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn spend_overview_view<'a>(tx: &SpendTx) -> Element<'a, Message> {
column()
.spacing(20)
.align_items(Alignment::Center)
.push(
row()
.push(badge::Badge::new(icon::send_icon()).style(badge::Style::Standard))
.push(text("Spend").bold())
.spacing(5)
.align_items(Alignment::Center),
)
.push_maybe(match tx.status {
SpendStatus::Deprecated => Some(
container(text(" Deprecated ").small())
.padding(3)
.style(badge::PillStyle::Simple),
),
SpendStatus::Broadcasted => Some(
container(text(" Broadcasted ").small())
.padding(3)
.style(badge::PillStyle::Success),
),
_ => None,
})
.push(
column()
.align_items(Alignment::Center)
.push(
text(&format!("- {} BTC", tx.spend_amount.to_btc()))
.bold()
.size(50),
)
.push(container(text(&format!(
"Miner Fee: {} BTC",
tx.fee_amount.to_btc()
)))),
)
.push(card::simple(
column()
.push(container(
row()
.push(
container(
row()
.push(container(icon::key_icon().size(30).width(Length::Fill)))
.push(column().push(text("Number of signatures:").bold()).push(
text(&format!("{}", tx.psbt.inputs[0].partial_sigs.len(),)),
))
.align_items(Alignment::Center)
.spacing(20),
)
.width(Length::FillPortion(1)),
)
.align_items(Alignment::Center)
.spacing(20),
))
.push(separation().width(Length::Fill))
.push(
column()
.push(
row()
.push(text("Tx ID:").bold().width(Length::Fill))
.push(text(&format!("{}", tx.psbt.unsigned_tx.txid())).small())
.align_items(Alignment::Center),
)
.push(
row()
.push(text("Psbt:").bold().width(Length::Fill))
.push(
button::transparent(Some(icon::clipboard_icon()), "Copy")
.on_press(Message::Clipboard(tx.psbt.to_string())),
)
.align_items(Alignment::Center),
),
)
.spacing(20),
))
.into()
}
fn inputs_and_outputs_view<'a>(
coins: &[Coin],
psbt: &Psbt,
network: Network,
change_index: Option<usize>,
) -> Element<'a, Message> {
column()
.push(
row()
.spacing(10)
.push(
column()
.spacing(10)
.push(text("Spent coins:").bold())
.push(coins.iter().fold(column().spacing(10), |col, coin| {
col.push(
card::simple(
column()
.width(Length::Fill)
.push(text(&format!("{} BTC", coin.amount.to_btc())).bold())
.push(text(&format!("{}", coin.outpoint)).small()),
)
.width(Length::Fill),
)
}))
.width(Length::FillPortion(1)),
)
.push(
column()
.spacing(10)
.push(text("Recipients:").bold())
.push(psbt.unsigned_tx.output.iter().enumerate().fold(
column().spacing(10),
|col, (i, output)| {
col.push(
card::simple(
column()
.width(Length::Fill)
.push(
text(&format!(
"{} BTC",
Amount::from_sat(output.value).to_btc()
))
.bold(),
)
.push(
text(&format!(
"{}",
Address::from_script(
&output.script_pubkey,
network
)
.unwrap()
))
.small(),
)
.push_maybe(if Some(i) == change_index {
Some(
container(text("Change"))
.padding(5)
.style(badge::PillStyle::Success),
)
} else {
None
}),
)
.width(Length::Fill),
)
},
))
.width(Length::FillPortion(1)),
),
)
.into()
}
pub fn sign_action<'a>(
hws: &[HardwareWallet],
processing: bool,
chosen_hw: Option<usize>,
signed: &[Fingerprint],
) -> Element<'a, Message> {
card::simple(
column()
.push(if !hws.is_empty() {
column()
.push(text("Select hardware wallet to sign with:").bold())
.spacing(10)
.push(
hws.iter()
.enumerate()
.fold(column().spacing(10), |col, (i, hw)| {
col.push(hw_list_view(
i,
hw,
Some(i) == chosen_hw,
processing,
signed.contains(&hw.fingerprint),
))
}),
)
.width(Length::Fill)
} else {
column()
.push(
card::simple(
column()
.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),
)
.width(Length::Fill)
})
.width(Length::Fill)
.align_items(Alignment::Center),
)
.width(Length::Fill)
.into()
}
fn hw_list_view<'a>(
i: usize,
hw: &HardwareWallet,
chosen: bool,
processing: bool,
signed: bool,
) -> Element<'a, Message> {
let mut bttn = iced::pure::button(
row()
.push(
column()
.push(text(&format!("{}", hw.kind)).bold())
.push(text(&format!("fingerprint: {}", hw.fingerprint)).small())
.spacing(5)
.width(Length::Fill),
)
.push_maybe(if chosen && processing {
Some(
column()
.push(text("Processing..."))
.push(text("Please check your device").small()),
)
} else {
None
})
.push_maybe(if signed {
Some(
column().push(
row()
.spacing(5)
.push(icon::circle_check_icon().color(color::SUCCESS))
.push(text("Signed").color(color::SUCCESS)),
),
)
} else {
None
})
.align_items(Alignment::Center)
.width(Length::Fill),
)
.padding(10)
.style(button::Style::Border)
.width(Length::Fill);
if !processing {
bttn = bttn.on_press(Message::Spend(SpendTxMessage::SelectHardwareWallet(i)));
}
container(bttn)
.width(Length::Fill)
.style(card::SimpleCardStyle)
.into()
}

View File

@ -0,0 +1,91 @@
pub mod detail;
pub mod step;
use iced::{
pure::{button, column, container, row, Element},
Alignment, Length,
};
use crate::{
app::menu::Menu,
daemon::model::{SpendStatus, SpendTx},
ui::{
component::{badge, button, card, text::*},
icon,
util::Collection,
},
};
use super::message::Message;
pub fn spend_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> {
column()
.push(
row().push(column().width(Length::Fill)).push(
button::primary(Some(icon::plus_icon()), "Create a new transaction")
.on_press(Message::Menu(Menu::CreateSpendTx)),
),
)
.push(
container(
row()
.push(text(&format!(" {}", spend_txs.len())).bold())
.push(text(" draft transactions")),
)
.width(Length::Fill),
)
.push(
column().spacing(10).push(
spend_txs
.iter()
.enumerate()
.fold(column().spacing(10), |col, (i, tx)| {
col.push(spend_tx_list_view(i, tx))
}),
),
)
.align_items(Alignment::Center)
.spacing(20)
.into()
}
fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> {
container(
button(
row()
.push(
row()
.push(badge::spend())
.push_maybe(match tx.status {
SpendStatus::Deprecated => Some(
container(text(" Deprecated ").small())
.padding(3)
.style(badge::PillStyle::Simple),
),
SpendStatus::Broadcasted => Some(
container(text(" Broadcasted ").small())
.padding(3)
.style(badge::PillStyle::Success),
),
_ => None,
})
.spacing(10)
.align_items(Alignment::Center)
.width(Length::Fill),
)
.push(
column()
.push(text(&format!("{} BTC", tx.spend_amount.to_btc())).bold())
.push(text(&format!("fee: {}", tx.fee_amount.to_btc())).small())
.width(Length::Shrink),
)
.align_items(Alignment::Center)
.spacing(20),
)
.padding(10)
.on_press(Message::Select(i))
.style(button::Style::TransparentBorder),
)
.style(card::SimpleCardStyle)
.into()
}

View File

@ -0,0 +1,198 @@
use iced::{
pure::{column, container, row, widget, Element},
Alignment, Length,
};
use minisafe::miniscript::bitcoin::Amount;
use crate::{
app::{
error::Error,
view::{message::*, modal},
},
daemon::model::Coin,
ui::{
component::{
badge, button, card, form,
text::{text, Text},
},
icon,
util::Collection,
},
};
pub fn choose_recipients_view(
recipients: Vec<Element<Message>>,
is_valid: bool,
) -> Element<Message> {
modal(
false,
None,
column()
.push(text("Choose recipients").bold().size(50))
.push(
column()
.push(widget::Column::with_children(recipients).spacing(10))
.push(
button::transparent(Some(icon::plus_icon()), "Add recipient")
.on_press(Message::CreateSpend(CreateSpendMessage::AddRecipient)),
)
.max_width(1000)
.spacing(10),
)
.push_maybe(if is_valid {
Some(
button::primary(None, "Next")
.on_press(Message::Next)
.width(Length::Units(100)),
)
} else {
None
})
.spacing(20)
.align_items(Alignment::Center),
)
}
pub fn recipient_view<'a>(
index: usize,
address: &form::Value<String>,
amount: &form::Value<String>,
) -> Element<'a, CreateSpendMessage> {
row()
.push(
form::Form::new("Address", address, move |msg| {
CreateSpendMessage::RecipientEdited(index, "address", msg)
})
.warning("Please enter correct bitcoin address")
.size(20)
.padding(10),
)
.push(
container(
form::Form::new("Amount", amount, move |msg| {
CreateSpendMessage::RecipientEdited(index, "amount", msg)
})
.warning("Please enter correct amount")
.size(20)
.padding(10),
)
.width(Length::Units(250)),
)
.spacing(5)
.push(
button::transparent(Some(icon::trash_icon()), "")
.on_press(CreateSpendMessage::DeleteRecipient(index))
.width(Length::Shrink),
)
.width(Length::Fill)
.into()
}
pub fn choose_feerate_view<'a>(
feerate: &form::Value<String>,
is_valid: bool,
error: Option<&Error>,
) -> Element<'a, Message> {
modal(
true,
None,
column()
.push(text("Choose feerate").bold().size(50))
.push(
container(
form::Form::new("Feerate", feerate, move |msg| {
Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg))
})
.warning("Please enter correct feerate")
.size(20)
.padding(10),
)
.width(Length::Units(250)),
)
.push_maybe(error.map(|e| card::error("Failed to create spend", &e.to_string())))
.push_maybe(if is_valid {
Some(
button::primary(None, "Next")
.on_press(Message::CreateSpend(CreateSpendMessage::Generate))
.width(Length::Units(100)),
)
} else {
None
})
.spacing(20)
.align_items(Alignment::Center),
)
}
pub fn choose_coins_view<'a>(
coins: &[(Coin, bool)],
total_needed: Option<&Amount>,
is_valid: bool,
) -> Element<'a, Message> {
modal(
true,
None,
column()
.push(text("Choose coins").bold().size(50))
.push(
column().spacing(10).push(
coins
.iter()
.enumerate()
.fold(column().spacing(10), |col, (i, (coin, selected))| {
col.push(coin_list_view(i, coin, *selected))
}),
),
)
.push_maybe(if is_valid {
Some(container(
button::primary(None, "Next")
.on_press(Message::Next)
.width(Length::Units(100)),
))
} else if total_needed.is_some() {
Some(container(card::warning(&format!(
"Total amount must be superior to {}",
total_needed.unwrap().to_btc(),
))))
} else {
None
})
.spacing(20)
.align_items(Alignment::Center),
)
}
fn coin_list_view<'a>(i: usize, coin: &Coin, selected: bool) -> Element<'a, Message> {
container(
iced::pure::button(
row()
.push(
row()
.push(if selected {
icon::square_check_icon()
} else {
icon::square_icon()
})
.push(badge::coin())
.push(text(&format!("block: {}", coin.block_height.unwrap_or(0))).small())
.spacing(10)
.align_items(Alignment::Center)
.width(Length::Fill),
)
.push(
text(&format!("{} BTC", coin.amount.to_btc()))
.bold()
.width(Length::Shrink),
)
.align_items(Alignment::Center)
.spacing(20),
)
.padding(10)
.on_press(Message::CreateSpend(CreateSpendMessage::SelectCoin(i)))
.style(button::Style::TransparentBorder),
)
.style(card::SimpleCardStyle)
.into()
}

View File

@ -33,6 +33,7 @@ impl From<&Error> for WarningMessage {
}
},
Error::Unexpected(_) => WarningMessage("Unknown error".to_string()),
Error::HardwareWallet(_) => WarningMessage("Hardware wallet error".to_string()),
}
}
}

View File

@ -1,13 +1,18 @@
use std::collections::HashMap;
use std::fmt::Debug;
use log::{error, info};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
pub mod error;
pub mod jsonrpc;
use minisafe::config::Config;
use minisafe::{
config::Config,
miniscript::bitcoin::{consensus, util::psbt::Psbt, Address, OutPoint, Txid},
};
use super::{model::*, Daemon, DaemonError};
@ -70,6 +75,44 @@ impl<C: Client + Debug> Daemon for Minisafed<C> {
fn list_coins(&self) -> Result<ListCoinsResult, DaemonError> {
self.call("listcoins", Option::<Request>::None)
}
fn list_spend_txs(&self) -> Result<ListSpendResult, DaemonError> {
self.call("listspend", Option::<Request>::None)
}
fn create_spend_tx(
&self,
coins_outpoints: &[OutPoint],
destinations: &HashMap<Address, u64>,
feerate_vb: u64,
) -> Result<CreateSpendResult, DaemonError> {
self.call(
"createspend",
Some(vec![
json!(coins_outpoints),
json!(destinations),
json!(feerate_vb),
]),
)
}
fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> {
let spend_tx = base64::encode(&consensus::serialize(psbt));
let _res: serde_json::value::Value = self.call("updatespend", Some(vec![spend_tx]))?;
Ok(())
}
fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> {
let _res: serde_json::value::Value =
self.call("deletespend", Some(vec![txid.to_string()]))?;
Ok(())
}
fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> {
let _res: serde_json::value::Value =
self.call("broadcastspend", Some(vec![txid.to_string()]))?;
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@ -1,7 +1,12 @@
use std::collections::HashMap;
use std::sync::Mutex;
use super::{model::*, Daemon, DaemonError};
use minisafe::{config::Config, DaemonHandle};
use minisafe::{
config::Config,
miniscript::bitcoin::{util::psbt::Psbt, Address, OutPoint, Txid},
DaemonHandle,
};
pub struct EmbeddedDaemon {
config: Config,
@ -91,4 +96,64 @@ impl Daemon for EmbeddedDaemon {
.control
.list_coins())
}
fn list_spend_txs(&self) -> Result<ListSpendResult, DaemonError> {
Ok(self
.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.list_spend())
}
fn create_spend_tx(
&self,
coins_outpoints: &[OutPoint],
destinations: &HashMap<Address, u64>,
feerate_vb: u64,
) -> Result<CreateSpendResult, DaemonError> {
self.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.create_spend(coins_outpoints, destinations, feerate_vb)
.map_err(|e| DaemonError::Unexpected(e.to_string()))
}
fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> {
self.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.update_spend(psbt.clone())
.map_err(|e| DaemonError::Unexpected(e.to_string()))
}
fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> {
self.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.delete_spend(txid);
Ok(())
}
fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> {
self.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.broadcast_spend(txid)
.map_err(|e| DaemonError::Unexpected(e.to_string()))
}
}

View File

@ -2,10 +2,14 @@ pub mod client;
pub mod embedded;
pub mod model;
use std::collections::HashMap;
use std::fmt::Debug;
use std::io::ErrorKind;
use minisafe::config::Config;
use minisafe::{
config::Config,
miniscript::bitcoin::{util::psbt::Psbt, Address, OutPoint, Txid},
};
#[derive(Debug, Clone)]
pub enum DaemonError {
@ -49,4 +53,39 @@ pub trait Daemon: Debug {
fn get_new_address(&self) -> Result<model::GetAddressResult, DaemonError>;
fn list_coins(&self) -> Result<model::ListCoinsResult, DaemonError>;
fn list_spend_txs(&self) -> Result<model::ListSpendResult, DaemonError>;
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, DaemonError> {
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, tx.change_index.map(|i| i as usize), coins)
})
.collect())
}
fn create_spend_tx(
&self,
coins_outpoints: &[OutPoint],
destinations: &HashMap<Address, u64>,
feerate_vb: u64,
) -> Result<model::CreateSpendResult, DaemonError>;
fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError>;
fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>;
fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>;
}

View File

@ -1,3 +1,63 @@
pub use minisafe::commands::{GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult};
pub use minisafe::{
commands::{
CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult,
ListSpendEntry, ListSpendResult,
},
miniscript::bitcoin::{util::psbt::Psbt, Amount},
};
pub type Coin = ListCoinsEntry;
#[derive(Debug, Clone)]
pub struct SpendTx {
pub coins: Vec<Coin>,
pub psbt: Psbt,
pub change_index: Option<usize>,
pub spend_amount: Amount,
pub fee_amount: Amount,
pub status: SpendStatus,
}
#[derive(Debug, Clone)]
pub enum SpendStatus {
Pending,
Deprecated,
Broadcasted,
}
impl SpendTx {
pub fn new(psbt: Psbt, change_index: Option<usize>, coins: Vec<Coin>) -> Self {
let (change_amount, spend_amount) = psbt.unsigned_tx.output.iter().enumerate().fold(
(Amount::from_sat(0), Amount::from_sat(0)),
|(change, spend), (i, output)| {
if Some(i) == change_index {
(change + Amount::from_sat(output.value), spend)
} else {
(change, spend + Amount::from_sat(output.value))
}
},
);
let mut inputs_amount = Amount::from_sat(0);
let mut status = SpendStatus::Pending;
for coin in &coins {
inputs_amount += coin.amount;
if let Some(info) = coin.spend_info {
if info.txid == psbt.unsigned_tx.txid() {
status = SpendStatus::Broadcasted
} else {
status = SpendStatus::Deprecated
}
}
}
Self {
coins,
psbt,
change_index,
spend_amount,
fee_amount: inputs_amount - spend_amount - change_amount,
status,
}
}
}

View File

@ -42,7 +42,12 @@ enum Step {
pub enum Message {
Event(iced_native::Event),
Syncing(Result<GetInfoResult, DaemonError>),
Synced(GetInfoResult, Vec<Coin>, Arc<dyn Daemon + Sync + Send>),
Synced(
GetInfoResult,
Vec<Coin>,
Vec<SpendTx>,
Arc<dyn Daemon + Sync + Send>,
),
Started(Result<Arc<dyn Daemon + Sync + Send>, Error>),
Loaded(Result<Arc<dyn Daemon + Sync + Send>, Error>),
Failure(DaemonError),
@ -127,9 +132,12 @@ impl Loader {
.list_coins()
.map(|res| res.coins)
.unwrap_or_else(|_| Vec::new());
(info, coins, daemon)
let spend_txs = daemon
.list_spend_transactions()
.unwrap_or_else(|_| Vec::new());
(info, coins, spend_txs, daemon)
},
|res| Message::Synced(res.0, res.1, res.2),
|res| Message::Synced(res.0, res.1, res.2, res.3),
);
} else {
*progress = info.sync

View File

@ -161,10 +161,12 @@ impl Application for GUI {
}
}
(State::Loader(loader), Message::Load(msg)) => {
if let loader::Message::Synced(info, coins, minisafed) = *msg {
if let loader::Message::Synced(info, coins, spend_txs, minisafed) = *msg {
let cache = Cache {
network: minisafed.config().bitcoin_config.network,
blockheight: info.blockheight,
coins,
spend_txs,
};
let (app, command) = App::new(cache, loader.gui_config.clone(), minisafed);

View File

@ -117,3 +117,41 @@ pub fn coin<T>() -> widget::container::Container<'static, T> {
.center_x()
.center_y()
}
pub enum PillStyle {
InversePrimary,
Primary,
Success,
Simple,
}
impl widget::container::StyleSheet for PillStyle {
fn style(&self) -> widget::container::Style {
match self {
Self::Primary => widget::container::Style {
background: color::PRIMARY.into(),
border_radius: 10.0,
text_color: iced::Color::WHITE.into(),
..widget::container::Style::default()
},
Self::InversePrimary => widget::container::Style {
background: color::FOREGROUND.into(),
border_radius: 10.0,
text_color: color::PRIMARY.into(),
..widget::container::Style::default()
},
Self::Success => widget::container::Style {
background: color::SUCCESS.into(),
border_radius: 10.0,
text_color: iced::Color::WHITE.into(),
..widget::container::Style::default()
},
Self::Simple => widget::container::Style {
background: color::BACKGROUND.into(),
border_radius: 10.0,
text_color: iced::Color::BLACK.into(),
..widget::container::Style::default()
},
}
}
}

View File

@ -7,6 +7,10 @@ use iced::{Alignment, Color, Length, Vector};
use super::text::text;
use crate::ui::color;
pub fn alert<'a, T: 'a>(icon: Option<iced::Text>, t: &str) -> button::Button<'a, T> {
button::Button::new(content(icon, t)).style(Style::Destructive)
}
pub fn primary<'a, T: 'a>(icon: Option<iced::Text>, t: &str) -> button::Button<'a, T> {
button::Button::new(content(icon, t)).style(Style::Primary)
}
@ -41,6 +45,8 @@ pub enum Style {
Primary,
Transparent,
TransparentBorder,
Border,
Destructive,
}
impl button::StyleSheet for Style {
@ -54,6 +60,14 @@ impl button::StyleSheet for Style {
border_color: Color::TRANSPARENT,
text_color: color::FOREGROUND,
},
Style::Destructive => button::Style {
shadow_offset: Vector::default(),
background: color::FOREGROUND.into(),
border_radius: 10.0,
border_width: 0.0,
border_color: color::ALERT,
text_color: color::ALERT,
},
Style::Transparent | Style::TransparentBorder => button::Style {
shadow_offset: Vector::default(),
background: Color::TRANSPARENT.into(),
@ -62,6 +76,14 @@ impl button::StyleSheet for Style {
border_color: Color::TRANSPARENT,
text_color: Color::BLACK,
},
Style::Border => button::Style {
shadow_offset: Vector::default(),
background: Color::TRANSPARENT.into(),
border_radius: 10.0,
border_width: 1.2,
border_color: color::BACKGROUND,
text_color: Color::BLACK,
},
}
}
@ -75,6 +97,14 @@ impl button::StyleSheet for Style {
border_color: Color::TRANSPARENT,
text_color: color::FOREGROUND,
},
Style::Destructive => button::Style {
shadow_offset: Vector::default(),
background: color::FOREGROUND.into(),
border_radius: 10.0,
border_width: 0.0,
border_color: color::ALERT,
text_color: color::ALERT,
},
Style::Transparent => button::Style {
shadow_offset: Vector::default(),
background: color::BACKGROUND.into(),
@ -91,6 +121,14 @@ impl button::StyleSheet for Style {
border_color: Color::BLACK,
text_color: Color::BLACK,
},
Style::Border => button::Style {
shadow_offset: Vector::default(),
background: Color::TRANSPARENT.into(),
border_radius: 10.0,
border_width: 1.0,
border_color: Color::BLACK,
text_color: Color::BLACK,
},
}
}
}

View File

@ -17,6 +17,32 @@ impl widget::container::StyleSheet for SimpleCardStyle {
}
}
/// display an error card with the message and the error in a tooltip.
pub fn warning<'a, T: 'a>(message: &str) -> widget::Container<'a, T> {
container(
row()
.spacing(20)
.align_items(iced::Alignment::Center)
.push(icon::warning_octagon_icon().color(color::WARNING))
.push(text(message).color(color::WARNING)),
)
.padding(15)
.style(WarningCardStyle)
}
pub struct WarningCardStyle;
impl widget::container::StyleSheet for WarningCardStyle {
fn style(&self) -> widget::container::Style {
widget::container::Style {
border_radius: 10.0,
border_color: color::WARNING,
border_width: 1.5,
background: color::FOREGROUND.into(),
..widget::container::Style::default()
}
}
}
/// display an error card with the message and the error in a tooltip.
pub fn error<'a, T: 'a>(message: &str, error: &str) -> widget::Container<'a, T> {
container(

View File

@ -5,7 +5,7 @@ use iced::pure::{
};
use iced::Length;
use crate::ui::{color, component::text::text};
use crate::ui::{color, component::text::*};
#[derive(Debug, Clone)]
pub struct Value<T> {
@ -75,7 +75,7 @@ impl<'a, Message: 'a + Clone> From<Form<'a, Message>> for Element<'a, Message> {
return container(
column()
.push(form.input.style(InvalidFormStyle))
.push(text(message).color(color::ALERT))
.push(text(message).color(color::ALERT).small())
.width(Length::Fill)
.spacing(5),
)