Create spend transaction
This commit is contained in:
parent
7fda64f4ad
commit
063786fe54
1
gui/Cargo.lock
generated
1
gui/Cargo.lock
generated
@ -1619,6 +1619,7 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"async-hwi",
|
||||
"backtrace",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"fern",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>),
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
368
gui/src/app/state/spend/detail.rs
Normal file
368
gui/src/app/state/spend/detail.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
327
gui/src/app/view/spend/detail.rs
Normal file
327
gui/src/app/view/spend/detail.rs
Normal 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()
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -33,6 +33,7 @@ impl From<&Error> for WarningMessage {
|
||||
}
|
||||
},
|
||||
Error::Unexpected(_) => WarningMessage("Unknown error".to_string()),
|
||||
Error::HardwareWallet(_) => WarningMessage("Hardware wallet error".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user