Create spend transaction

This commit is contained in:
edouard 2022-10-28 15:36:31 +02:00
parent 7fda64f4ad
commit 063786fe54
23 changed files with 1244 additions and 86 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

@ -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

@ -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)]
@ -16,4 +23,8 @@ pub enum Message {
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

@ -65,8 +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.cache.coins, &self.cache.spend_txs).into(),
menu::Menu::CreateSpendTx => CreateSpendPanel::new(&self.cache.coins).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())
}

View File

@ -38,7 +38,18 @@ 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(),
),
}
}
}

View File

@ -0,0 +1,368 @@
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,
)
}
}
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

@ -1,3 +1,4 @@
mod detail;
mod step;
use std::sync::Arc;
@ -5,7 +6,7 @@ use iced::{pure::Element, Command};
use super::{redirect, State};
use crate::{
app::{cache::Cache, error::Error, menu::Menu, message::Message, view},
app::{cache::Cache, config::Config, error::Error, menu::Menu, message::Message, view},
daemon::{
model::{Coin, SpendTx},
Daemon,
@ -13,14 +14,16 @@ use crate::{
};
pub struct SpendPanel {
selected_tx: Option<usize>,
config: Config,
selected_tx: Option<detail::SpendTxState>,
spend_txs: Vec<SpendTx>,
warning: Option<Error>,
}
impl SpendPanel {
pub fn new(_coins: &[Coin], spend_txs: &[SpendTx]) -> Self {
pub fn new(config: Config, spend_txs: &[SpendTx]) -> Self {
Self {
config,
spend_txs: spend_txs.to_vec(),
warning: None,
selected_tx: None,
@ -30,18 +33,22 @@ impl SpendPanel {
impl State for SpendPanel {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
view::dashboard(
&Menu::Spend,
cache,
self.warning.as_ref(),
view::spend::spend_view(&self.spend_txs),
)
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,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Command<Message> {
match message {
@ -52,10 +59,25 @@ impl State for SpendPanel {
self.spend_txs = txs;
}
},
Message::View(view::Message::Select(i)) => {
self.selected_tx = Some(i);
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()
}
@ -63,12 +85,7 @@ impl State for SpendPanel {
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let daemon = daemon.clone();
Command::perform(
async move {
daemon
.list_spend_txs()
.map(|res| res.spend_txs)
.map_err(|e| e.into())
},
async move { daemon.list_spend_transactions().map_err(|e| e.into()) },
Message::SpendTxs,
)
}
@ -87,7 +104,7 @@ pub struct CreateSpendPanel {
}
impl CreateSpendPanel {
pub fn new(coins: &[Coin]) -> Self {
pub fn new(config: Config, coins: &[Coin]) -> Self {
Self {
draft: step::TransactionDraft::default(),
current: 0,
@ -95,6 +112,7 @@ impl CreateSpendPanel {
Box::new(step::ChooseRecipients::default()),
Box::new(step::ChooseCoins::new(coins.to_vec())),
Box::new(step::ChooseFeerate::default()),
Box::new(step::SaveSpend::new(config)),
],
}
}
@ -120,8 +138,9 @@ impl State for CreateSpendPanel {
step.apply(&mut self.draft);
}
if self.steps.get(self.current + 1).is_some() {
if let Some(step) = self.steps.get_mut(self.current + 1) {
self.current += 1;
step.load(&self.draft);
}
}

View File

@ -2,20 +2,27 @@ use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use iced::pure::{column, Element};
use iced::pure::Element;
use iced::Command;
use minisafe::miniscript::bitcoin::{util::psbt::Psbt, Address, Amount, Denomination, OutPoint};
use minisafe::miniscript::bitcoin::{
util::psbt::Psbt, Address, Amount, Denomination, OutPoint, Script,
};
use crate::{
app::{cache::Cache, error::Error, menu::Menu, message::Message, view},
daemon::{model::Coin, Daemon},
app::{
cache::Cache, config::Config, error::Error, message::Message, state::spend::detail, view,
},
daemon::{
model::{Coin, SpendTx},
Daemon,
},
ui::component::form,
};
#[derive(Default)]
#[derive(Default, Clone)]
pub struct TransactionDraft {
inputs: Vec<OutPoint>,
outputs: HashMap<Address, Amount>,
inputs: Vec<Coin>,
outputs: HashMap<Address, u64>,
feerate: u64,
generated: Option<Psbt>,
}
@ -29,8 +36,8 @@ pub trait Step {
draft: &TransactionDraft,
message: Message,
) -> Command<Message>;
fn apply(&self, draft: &mut TransactionDraft);
fn apply(&self, _draft: &mut TransactionDraft) {}
fn load(&mut self, _draft: &TransactionDraft) {}
}
pub struct ChooseRecipients {
@ -72,11 +79,11 @@ impl Step for ChooseRecipients {
}
fn apply(&self, draft: &mut TransactionDraft) {
let mut outputs: HashMap<Address, Amount> = HashMap::new();
let mut outputs: HashMap<Address, u64> = HashMap::new();
for recipient in &self.recipients {
outputs.insert(
Address::from_str(&recipient.address.value).expect("Checked before"),
Amount::from_sat(recipient.amount().expect("Checked before")),
recipient.amount().expect("Checked before"),
);
}
draft.outputs = outputs;
@ -168,29 +175,56 @@ impl Recipient {
#[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>,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
_draft: &TransactionDraft,
draft: &TransactionDraft,
message: Message,
) -> Command<Message> {
if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited(
s,
))) = message
{
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;
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()
@ -198,12 +232,14 @@ impl Step for ChooseFeerate {
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(),
)
}
}
@ -219,7 +255,16 @@ pub struct ChooseCoins {
impl ChooseCoins {
pub fn new(coins: Vec<Coin>) -> Self {
Self {
coins: coins.into_iter().map(|c| (c, false)).collect(),
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,
}
@ -227,11 +272,17 @@ impl ChooseCoins {
}
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,
_draft: &TransactionDraft,
message: Message,
) -> Command<Message> {
if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectCoin(i))) =
@ -241,19 +292,18 @@ impl Step for ChooseCoins {
coin.1 = !coin.1;
}
let total_needed = draft
.outputs
.values()
.fold(Amount::from_sat(0), |acc, a| acc + *a);
self.is_valid = self
.coins
.iter()
.filter_map(|(coin, selected)| if *selected { Some(coin.amount) } else { None })
.sum::<Amount>()
> total_needed;
self.total_needed = Some(total_needed);
.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()
@ -263,7 +313,7 @@ impl Step for ChooseCoins {
draft.inputs = self
.coins
.iter()
.filter_map(|(coin, selected)| if *selected { Some(coin.outpoint) } else { None })
.filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None })
.collect();
}
@ -271,3 +321,62 @@ impl Step for ChooseCoins {
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

@ -9,6 +9,7 @@ pub enum Message {
Select(usize),
Settings(usize, SettingsMessage),
CreateSpend(CreateSpendMessage),
Spend(SpendTxMessage),
Next,
Previous,
}
@ -21,7 +22,14 @@ pub enum CreateSpendMessage {
RecipientEdited(usize, &'static str, String),
FeerateEdited(String),
Generate,
Save,
}
#[derive(Debug, Clone)]
pub enum SpendTxMessage {
Delete,
Confirm,
Cancel,
SelectHardwareWallet(usize),
}
#[derive(Debug, Clone)]

View File

@ -48,8 +48,20 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> widget::Container<'a, Messa
.align_items(iced::Alignment::Center),
)
.push(
container(text(&format!(" {} ", cache.coins.len())).small().bold())
.style(badge::PillStyle::InversePrimary),
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)
@ -75,8 +87,20 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> widget::Container<'a, Messa
.align_items(iced::Alignment::Center),
)
.push(
container(text(&format!(" {} ", cache.coins.len())).small().bold())
.style(badge::PillStyle::Primary),
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)

View File

@ -0,0 +1,327 @@
use iced::{
pure::{column, container, row, scrollable, Element},
Alignment, Length,
};
use minisafe::miniscript::bitcoin::util::bip32::Fingerprint;
use crate::{
app::{
error::Error,
view::{message::*, modal_section, warning::warn, ModalSectionStyle},
},
daemon::model::{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,
) -> Element<'a, Message> {
spend_modal(
show_delete,
warning,
column()
.spacing(20)
.push(spend_overview_view(tx))
.push(action),
)
.into()
}
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()
}
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

@ -1,3 +1,4 @@
pub mod detail;
pub mod step;
use iced::{
@ -7,10 +8,11 @@ use iced::{
use crate::{
app::menu::Menu,
daemon::model::SpendTx,
daemon::model::{SpendStatus, SpendTx},
ui::{
component::{badge, button, card, text::*},
icon,
util::Collection,
},
};
@ -47,18 +49,36 @@ pub fn spend_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> {
.into()
}
fn spend_tx_list_view<'a>(i: usize, _tx: &SpendTx) -> Element<'a, Message> {
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(text(&format!("{} BTC", 0)).bold().width(Length::Shrink))
.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),
)

View File

@ -6,7 +6,10 @@ use iced::{
use minisafe::miniscript::bitcoin::Amount;
use crate::{
app::view::{message::*, modal},
app::{
error::Error,
view::{message::*, modal},
},
daemon::model::Coin,
ui::{
component::{
@ -82,7 +85,6 @@ pub fn recipient_view<'a>(
.on_press(CreateSpendMessage::DeleteRecipient(index))
.width(Length::Shrink),
)
.align_items(Alignment::Center)
.width(Length::Fill)
.into()
}
@ -90,6 +92,7 @@ pub fn recipient_view<'a>(
pub fn choose_feerate_view<'a>(
feerate: &form::Value<String>,
is_valid: bool,
error: Option<&Error>,
) -> Element<'a, Message> {
modal(
true,
@ -107,10 +110,11 @@ pub fn choose_feerate_view<'a>(
)
.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::Next)
.on_press(Message::CreateSpend(CreateSpendMessage::Generate))
.width(Length::Units(100)),
)
} else {

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};
@ -74,6 +79,40 @@ impl<C: Client + Debug> Daemon for Minisafed<C> {
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,
@ -102,4 +107,53 @@ impl Daemon for EmbeddedDaemon {
.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> {
Ok(self
.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.delete_spend(txid))
}
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 {
@ -51,4 +55,37 @@ pub trait Daemon: Debug {
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)
})
.map(|c| c.clone())
.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,7 +1,63 @@
pub use minisafe::commands::{
GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult, ListSpendEntry,
ListSpendResult,
pub use minisafe::{
commands::{
CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult,
ListSpendEntry, ListSpendResult,
},
miniscript::bitcoin::{util::psbt::Psbt, Amount},
};
pub type Coin = ListCoinsEntry;
pub type SpendTx = ListSpendEntry;
#[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

@ -133,8 +133,7 @@ impl Loader {
.map(|res| res.coins)
.unwrap_or_else(|_| Vec::new());
let spend_txs = daemon
.list_spend_txs()
.map(|res| res.spend_txs)
.list_spend_transactions()
.unwrap_or_else(|_| Vec::new());
(info, coins, spend_txs, daemon)
},

View File

@ -121,6 +121,8 @@ pub fn coin<T>() -> widget::container::Container<'static, T> {
pub enum PillStyle {
InversePrimary,
Primary,
Success,
Simple,
}
impl widget::container::StyleSheet for PillStyle {
@ -138,6 +140,18 @@ impl widget::container::StyleSheet for PillStyle {
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,
},
}
}
}