diff --git a/Cargo.lock b/Cargo.lock index e863b48..f15f845 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,7 +297,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "x11rb", ] @@ -1671,7 +1671,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2055,7 +2055,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4436,7 +4436,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5471,7 +5471,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -5685,7 +5685,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6081,7 +6081,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6161,7 +6161,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs 1.0.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6499,7 +6499,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.2.8", + "errno 0.3.14", "libc", ] @@ -6893,7 +6893,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8132,7 +8132,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/app/Cargo.toml b/app/Cargo.toml index 937c0c3..de456c1 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -40,7 +40,7 @@ tokio-util = { workspace = true, features = ["rt"] } tonic = { workspace = true } tonic-health = { workspace = true } tower = { workspace = true } -tower-http = { workspace = true, features = ["request-id", "trace"] } +tower-http = { workspace = true, features = ["cors", "request-id", "trace"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } diff --git a/app/app.rs b/app/app.rs index 6ab35a5..bc9c19c 100644 --- a/app/app.rs +++ b/app/app.rs @@ -281,30 +281,29 @@ impl App { Ok(()) } - pub fn get_new_main_address( + pub async fn get_new_main_address( &self, ) -> Result, Error> { let Some(miner) = self.miner.as_ref() else { return Err(Error::NoCusfMainchainWalletClient); }; - let address = self.runtime.block_on({ - let miner = miner.clone(); - async move { - let mut miner_write = miner.write().await; - let cusf_mainchain = &mut miner_write.cusf_mainchain; - let mainchain_info = cusf_mainchain.get_chain_info().await?; - let cusf_mainchain_wallet = - &mut miner_write.cusf_mainchain_wallet; - let res = cusf_mainchain_wallet - .create_new_address() - .await? - .require_network(mainchain_info.network) - .unwrap(); - drop(miner_write); - Result::<_, Error>::Ok(res) - } - })?; - Ok(address) + let mut miner_write = miner.write().await; + let cusf_mainchain = &mut miner_write.cusf_mainchain; + let mainchain_info = cusf_mainchain.get_chain_info().await?; + let cusf_mainchain_wallet = &mut miner_write.cusf_mainchain_wallet; + let res = cusf_mainchain_wallet + .create_new_address() + .await? + .require_network(mainchain_info.network) + .unwrap(); + drop(miner_write); + Ok(res) + } + + pub fn get_new_main_address_blocking( + &self, + ) -> Result, Error> { + self.runtime.block_on(self.get_new_main_address()) } /** Get all paymail. @@ -614,7 +613,7 @@ impl App { Ok(()) } - pub fn deposit( + pub async fn deposit( &self, address: Address, amount: bitcoin::Amount, @@ -623,15 +622,22 @@ impl App { let Some(miner) = self.miner.as_ref() else { return Err(Error::NoCusfMainchainWalletClient); }; - self.runtime.block_on(async { - let mut miner_write = miner.write().await; - let txid = miner_write - .cusf_mainchain_wallet - .create_deposit_tx(address, amount.to_sat(), fee.to_sat()) - .await?; - drop(miner_write); - Ok(txid) - }) + let mut miner_write = miner.write().await; + let txid = miner_write + .cusf_mainchain_wallet + .create_deposit_tx(address, amount.to_sat(), fee.to_sat()) + .await?; + drop(miner_write); + Ok(txid) + } + + pub fn deposit_blocking( + &self, + address: Address, + amount: bitcoin::Amount, + fee: bitcoin::Amount, + ) -> Result { + self.runtime.block_on(self.deposit(address, amount, fee)) } } diff --git a/app/gui/activity/mempool_explorer.rs b/app/gui/activity/mempool_explorer.rs index 40604a1..ffba124 100644 --- a/app/gui/activity/mempool_explorer.rs +++ b/app/gui/activity/mempool_explorer.rs @@ -18,165 +18,196 @@ impl MempoolExplorer { let utxos = app .and_then(|app| app.wallet.get_utxos().ok()) .unwrap_or_default(); - egui::SidePanel::left("transaction_picker") - .resizable(false) - .show_inside(ui, |ui| { - ui.heading("Transactions"); - ui.separator(); - egui::Grid::new("transactions") - .striped(true) - .show(ui, |ui| { - ui.monospace("txid"); - ui.monospace("value out"); - ui.monospace("fee"); - ui.end_row(); - for (index, transaction) in - transactions.iter().enumerate() - { - let value_out: bitcoin::Amount = transaction - .transaction - .outputs - .iter() - .map(GetValue::get_value) - .sum(); - let value_in: bitcoin::Amount = transaction - .transaction - .inputs - .iter() - .map(|input| { - utxos.get(input).map(GetValue::get_value) - }) - .sum::>() - .unwrap_or(bitcoin::Amount::ZERO); - let txid = - &format!("{}", transaction.transaction.txid()) - [0..8]; - if value_in >= value_out { - let fee = value_in - value_out; - ui.selectable_value( - &mut self.current, - index, - txid.to_string(), - ); - ui.with_layout( - egui::Layout::right_to_left( - egui::Align::Max, - ), - |ui| { - ui.monospace(format!("{value_out}")); - }, - ); - ui.with_layout( - egui::Layout::right_to_left( - egui::Align::Max, - ), - |ui| { - ui.monospace(format!("{fee}")); - }, - ); - ui.end_row(); - } else { - ui.selectable_value( - &mut self.current, - index, - txid.to_string(), - ); - ui.monospace("invalid"); - ui.end_row(); + egui::ScrollArea::horizontal().show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + ui.heading("Transactions"); + ui.separator(); + egui::Grid::new("transactions").striped(true).show( + ui, + |ui| { + ui.monospace("txid"); + ui.monospace("value out"); + ui.monospace("fee"); + ui.end_row(); + for (index, transaction) in + transactions.iter().enumerate() + { + let value_out: bitcoin::Amount = transaction + .transaction + .outputs + .iter() + .map(GetValue::get_value) + .sum(); + let value_in: bitcoin::Amount = transaction + .transaction + .inputs + .iter() + .map(|input| { + utxos + .get(input) + .map(GetValue::get_value) + }) + .sum::>() + .unwrap_or(bitcoin::Amount::ZERO); + let txid = &format!( + "{}", + transaction.transaction.txid() + )[0..8]; + if value_in >= value_out { + let fee = value_in - value_out; + ui.selectable_value( + &mut self.current, + index, + txid.to_string(), + ); + ui.with_layout( + egui::Layout::right_to_left( + egui::Align::Max, + ), + |ui| { + ui.monospace(format!( + "{value_out}" + )); + }, + ); + ui.with_layout( + egui::Layout::right_to_left( + egui::Align::Max, + ), + |ui| { + ui.monospace(format!("{fee}")); + }, + ); + ui.end_row(); + } else { + ui.selectable_value( + &mut self.current, + index, + txid.to_string(), + ); + ui.monospace("invalid"); + ui.end_row(); + } } - } + }, + ); + }); + if let Some(transaction) = transactions.get(self.current) { + ui.separator(); + ui.vertical(|ui| { + ui.set_width(250.0); + ui.heading("Inputs"); + ui.separator(); + egui::Grid::new("inputs").striped(true).show( + ui, + |ui| { + ui.monospace("kind"); + ui.monospace("outpoint"); + ui.monospace("value"); + ui.end_row(); + for input in &transaction.transaction.inputs { + let (kind, hash, vout) = match input { + OutPoint::Regular { txid, vout } => ( + "regular", + format!("{txid}"), + *vout, + ), + OutPoint::Deposit(outpoint) => ( + "deposit", + format!("{}", outpoint.txid), + outpoint.vout, + ), + OutPoint::Coinbase { + merkle_root, + vout, + } => ( + "coinbase", + format!("{merkle_root}"), + *vout, + ), + }; + let output = &utxos[input]; + let hash = &hash[0..8]; + let value = output.get_value(); + ui.monospace(kind.to_string()); + ui.monospace(format!("{hash}:{vout}",)); + ui.monospace(format!("{value}",)); + ui.end_row(); + } + }, + ); }); - }); - if let Some(transaction) = transactions.get(self.current) { - egui::SidePanel::left("inputs") - .resizable(false) - .show_inside(ui, |ui| { - ui.heading("Inputs"); ui.separator(); - egui::Grid::new("inputs").striped(true).show(ui, |ui| { - ui.monospace("kind"); - ui.monospace("outpoint"); - ui.monospace("value"); - ui.end_row(); - for input in &transaction.transaction.inputs { - let (kind, hash, vout) = match input { - OutPoint::Regular { txid, vout } => { - ("regular", format!("{txid}"), *vout) + ui.vertical(|ui| { + ui.set_width(250.0); + ui.heading("Outputs"); + ui.separator(); + egui::Grid::new("outputs").striped(true).show( + ui, + |ui| { + ui.monospace("vout"); + ui.monospace("address"); + ui.monospace("value"); + ui.end_row(); + for (vout, output) in transaction + .transaction + .outputs + .iter() + .enumerate() + { + let address = + &format!("{}", output.address)[0..8]; + let value = output.get_value(); + ui.monospace(format!("{vout}")); + ui.monospace(address.to_string()); + ui.monospace(format!("{value}")); + ui.end_row(); } - OutPoint::Deposit(outpoint) => ( - "deposit", - format!("{}", outpoint.txid), - outpoint.vout, - ), - OutPoint::Coinbase { merkle_root, vout } => ( - "coinbase", - format!("{merkle_root}"), - *vout, - ), - }; - let output = &utxos[input]; - let hash = &hash[0..8]; - let value = output.get_value(); - ui.monospace(kind.to_string()); - ui.monospace(format!("{hash}:{vout}",)); - ui.monospace(format!("{value}",)); - ui.end_row(); - } + }, + ); }); - }); - egui::SidePanel::left("outputs") - .resizable(false) - .show_inside(ui, |ui| { - ui.heading("Outputs"); ui.separator(); - egui::Grid::new("inputs").striped(true).show(ui, |ui| { - ui.monospace("vout"); - ui.monospace("address"); - ui.monospace("value"); - ui.end_row(); - for (vout, output) in - transaction.transaction.outputs.iter().enumerate() + ui.vertical(|ui| { + ui.set_width(400.0); + ui.heading("Viewing"); + ui.separator(); + let txid = transaction.transaction.txid(); + ui.monospace(format!("Txid: {txid}")); + let transaction_size = bincode::serialize(&transaction) + .unwrap_or(vec![]) + .len(); + let transaction_size = if let Ok(transaction_size) = + SpecificSize::new(transaction_size as f64, Byte) { - let address = &format!("{}", output.address)[0..8]; - let value = output.get_value(); - ui.monospace(format!("{vout}")); - ui.monospace(address.to_string()); - ui.monospace(format!("{value}")); - ui.end_row(); - } + let bytes = transaction_size.to_bytes(); + if bytes < 1024 { + format!("{transaction_size}") + } else if bytes < 1024 * 1024 { + let transaction_size: SpecificSize = + transaction_size.into(); + format!("{transaction_size}") + } else { + let transaction_size: SpecificSize = + transaction_size.into(); + format!("{transaction_size}") + } + } else { + "".into() + }; + ui.monospace(format!( + "Transaction size: {transaction_size}" + )); }); - }); - egui::CentralPanel::default().show_inside(ui, |ui| { - ui.heading("Viewing"); - ui.separator(); - let txid = transaction.transaction.txid(); - ui.monospace(format!("Txid: {txid}")); - let transaction_size = - bincode::serialize(&transaction).unwrap_or(vec![]).len(); - let transaction_size = if let Ok(transaction_size) = - SpecificSize::new(transaction_size as f64, Byte) - { - let bytes = transaction_size.to_bytes(); - if bytes < 1024 { - format!("{transaction_size}") - } else if bytes < 1024 * 1024 { - let transaction_size: SpecificSize = - transaction_size.into(); - format!("{transaction_size}") - } else { - let transaction_size: SpecificSize = - transaction_size.into(); - format!("{transaction_size}") - } } else { - "".into() - }; - ui.monospace(format!("Transaction size: {transaction_size}")); - }); - } else { - egui::CentralPanel::default().show_inside(ui, |ui| { - ui.heading("No transactions in mempool"); + ui.separator(); + ui.vertical(|ui| { + ui.set_width(400.0); + ui.heading("No transactions in mempool"); + }); + } }); - } + }); } } diff --git a/app/gui/coins/transfer_receive.rs b/app/gui/coins/transfer_receive.rs index f359aef..51a69c4 100644 --- a/app/gui/coins/transfer_receive.rs +++ b/app/gui/coins/transfer_receive.rs @@ -97,7 +97,7 @@ impl Receive { }; let address = app .wallet - .get_new_address() + .get_or_generate_last_address() .map_err(anyhow::Error::from) .inspect_err(|err| tracing::error!("{err:#}")); Self { @@ -119,7 +119,13 @@ impl Receive { .add_enabled(app.is_some(), Button::new("generate")) .clicked() { - *self = Self::new(app) + let address = app + .unwrap() + .wallet + .get_new_address() + .map_err(anyhow::Error::from) + .inspect_err(|err| tracing::error!("{err:#}")); + self.address = Some(address); } } } diff --git a/app/gui/coins/tx_builder.rs b/app/gui/coins/tx_builder.rs index 3fcebb1..28a32f8 100644 --- a/app/gui/coins/tx_builder.rs +++ b/app/gui/coins/tx_builder.rs @@ -42,7 +42,7 @@ impl TxBuilder { ui.separator(); ui.monospace(format!("Total: {value_in}")); ui.separator(); - egui::Grid::new("utxos").striped(true).show(ui, |ui| { + egui::Grid::new("spent_utxos").striped(true).show(ui, |ui| { ui.monospace("kind"); ui.monospace("outpoint"); ui.monospace("value"); @@ -106,33 +106,29 @@ impl TxBuilder { ui: &mut egui::Ui, ) -> anyhow::Result<()> { egui::ScrollArea::horizontal().show(ui, |ui| { - egui::SidePanel::left("spend_utxo") - .exact_width(250.) - .resizable(false) - .show_inside(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); self.utxo_selector.show(app, ui, &mut self.base_tx); }); - egui::SidePanel::left("value_in") - .exact_width(250.) - .resizable(false) - .show_inside(ui, |ui| { + ui.separator(); + ui.vertical(|ui| { + ui.set_width(250.0); let () = self.show_value_in(app, ui); }); - egui::SidePanel::left("value_out") - .exact_width(250.) - .resizable(false) - .show_inside(ui, |ui| { + ui.separator(); + ui.vertical(|ui| { + ui.set_width(250.0); let () = self.show_value_out(ui); }); - egui::SidePanel::left("create_utxo") - .exact_width(450.) - .resizable(false) - .show_separator_line(false) - .show_inside(ui, |ui| { + ui.separator(); + ui.vertical(|ui| { + ui.set_width(450.0); self.utxo_creator.show(app, ui, &mut self.base_tx); ui.separator(); self.tx_creator.show(app, ui, &mut self.base_tx).unwrap(); }); + }); }); Ok(()) } diff --git a/app/gui/coins/utxo_creator.rs b/app/gui/coins/utxo_creator.rs index 94cf34b..4136648 100644 --- a/app/gui/coins/utxo_creator.rs +++ b/app/gui/coins/utxo_creator.rs @@ -212,7 +212,7 @@ impl UtxoCreator { .add_enabled(app.is_some(), Button::new("generate")) .clicked() { - match app.unwrap().get_new_main_address() { + match app.unwrap().get_new_main_address_blocking() { Ok(main_address) => { self.main_address = format!("{main_address}"); } diff --git a/app/gui/parent_chain/info.rs b/app/gui/parent_chain/info.rs index 23a87d4..d05efcb 100644 --- a/app/gui/parent_chain/info.rs +++ b/app/gui/parent_chain/info.rs @@ -10,44 +10,67 @@ struct Inner { sidechain_wealth: bitcoin::Amount, } -pub(super) struct Info(Option>); +pub(super) struct Info { + promise: Option>>, + last_value: Option>, +} impl Info { - fn get_parent_chain_info(app: &App) -> anyhow::Result { - let mainchain_tip_info = - app.runtime.block_on(app.node.with_cusf_mainchain( - |cusf_mainchain| cusf_mainchain.get_chain_tip().boxed(), - ))?; - let sidechain_wealth = app.node.get_sidechain_wealth()?; - Ok(Inner { - mainchain_tip_info, - sidechain_wealth, - }) - } - pub fn new(app: Option<&App>) -> Self { - let inner = app.map(|app| { - Self::get_parent_chain_info(app) - .inspect_err(|err| tracing::error!("{err:#}")) - }); - Self(inner) + let mut this = Self { + promise: None, + last_value: None, + }; + if let Some(app) = app { + this.refresh_parent_chain_info(app); + } + this } fn refresh_parent_chain_info(&mut self, app: &App) { - self.0 = Some( - Self::get_parent_chain_info(app) - .inspect_err(|err| tracing::error!("{err:#}")), - ); + let app = app.clone(); + self.promise = Some(poll_promise::Promise::spawn_async(async move { + let mainchain_tip_info = app + .node + .with_cusf_mainchain(|cusf_mainchain| { + cusf_mainchain.get_chain_tip().boxed() + }) + .await?; + let sidechain_wealth = app.node.get_sidechain_wealth()?; + Ok(Inner { + mainchain_tip_info, + sidechain_wealth, + }) + })); } pub fn show(&mut self, app: Option<&App>, ui: &mut egui::Ui) { - if ui - .add_enabled(app.is_some(), Button::new("Refresh")) - .clicked() - { - let () = self.refresh_parent_chain_info(app.unwrap()); + if let Some(result) = self.promise.as_ref().and_then(|p| p.ready()) { + self.last_value = Some(match result { + Ok(inner) => Ok(inner.clone()), + Err(err) => Err(anyhow::anyhow!("{err:#}")), + }); + self.promise = None; } - let parent_chain_info = match self.0.as_ref() { + + ui.horizontal(|ui| { + let is_refreshing = self.promise.is_some(); + if ui + .add_enabled( + app.is_some() && !is_refreshing, + Button::new("Refresh"), + ) + .clicked() + { + self.refresh_parent_chain_info(app.unwrap()); + } + if is_refreshing { + ui.spinner(); + ui.label("Refreshing parent chain info..."); + } + }); + + let parent_chain_info = match self.last_value.as_ref() { Some(Ok(parent_chain_info)) => parent_chain_info, Some(Err(err)) => { ui.monospace_selectable_multiline(format!("{err:#}")); diff --git a/app/gui/parent_chain/transfer.rs b/app/gui/parent_chain/transfer.rs index e1d9e61..4286bed 100644 --- a/app/gui/parent_chain/transfer.rs +++ b/app/gui/parent_chain/transfer.rs @@ -2,14 +2,55 @@ use eframe::egui::{self, Button}; use crate::app::App; -#[derive(Debug, Default)] +#[derive(Default)] pub struct Deposit { amount: String, fee: String, + promise: Option>>, +} + +impl std::fmt::Debug for Deposit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Deposit") + .field("amount", &self.amount) + .field("fee", &self.fee) + .field("promise_active", &self.promise.is_some()) + .finish() + } } impl Deposit { pub fn show(&mut self, app: Option<&App>, ui: &mut egui::Ui) { + if let Some(promise) = &self.promise { + match promise.ready() { + None => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label( + "Creating deposit transaction on parent chain...", + ); + }); + return; + } + Some(Ok(txid)) => { + tracing::info!("Deposit transaction created: {}", txid); + self.promise = None; + *self = Self::default(); + return; + } + Some(Err(err)) => { + ui.colored_label( + egui::Color32::RED, + format!("Error: {err}"), + ); + if ui.button("Dismiss").clicked() { + self.promise = None; + } + return; + } + } + } + ui.add_sized((110., 10.), |ui: &mut egui::Ui| { ui.horizontal(|ui| { let amount_edit = egui::TextEdit::singleline(&mut self.amount) @@ -46,26 +87,50 @@ impl Deposit { ) .clicked() { - let app = app.unwrap(); - if let Err(err) = app.deposit( - app.wallet.get_new_address().expect("should not happen"), - amount.expect("should not happen"), - fee.expect("should not happen"), - ) { - tracing::error!("{err}"); - } else { - *self = Self::default(); + let app = app.unwrap().clone(); + let amount = amount.expect("should not happen"); + let fee = fee.expect("should not happen"); + + match app.wallet.get_new_address() { + Ok(address) => { + self.promise = + Some(poll_promise::Promise::spawn_async(async move { + app.deposit(address, amount, fee) + .await + .map_err(|e| format!("{e:#}")) + })); + } + Err(err) => { + tracing::error!("Failed to get new address: {err}"); + } } } } } -#[derive(Debug, Default)] +#[derive(Default)] pub struct Withdrawal { mainchain_address: String, amount: String, fee: String, mainchain_fee: String, + generate_promise: Option< + poll_promise::Promise< + Result, String>, + >, + >, +} + +impl std::fmt::Debug for Withdrawal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Withdrawal") + .field("mainchain_address", &self.mainchain_address) + .field("amount", &self.amount) + .field("fee", &self.fee) + .field("mainchain_fee", &self.mainchain_fee) + .field("generate_active", &self.generate_promise.is_some()) + .finish() + } } fn create_withdrawal( @@ -87,6 +152,22 @@ fn create_withdrawal( impl Withdrawal { pub fn show(&mut self, app: Option<&App>, ui: &mut egui::Ui) { + if let Some(promise) = &self.generate_promise { + match promise.ready() { + None => {} + Some(Ok(address)) => { + self.mainchain_address = address.to_string(); + self.generate_promise = None; + } + Some(Err(err)) => { + tracing::error!( + "Failed to generate mainchain address: {err}" + ); + self.generate_promise = None; + } + } + } + ui.add_sized((250., 10.), |ui: &mut egui::Ui| { ui.horizontal(|ui| { let mainchain_address_edit = @@ -94,19 +175,24 @@ impl Withdrawal { .hint_text("mainchain address") .desired_width(150.); ui.add(mainchain_address_edit); + + let is_generating = self.generate_promise.is_some(); + let generate_btn = if is_generating { + Button::new("generating...") + } else { + Button::new("generate") + }; if ui - .add_enabled(app.is_some(), Button::new("generate")) + .add_enabled(app.is_some() && !is_generating, generate_btn) .clicked() { - match app.unwrap().get_new_main_address() { - Ok(main_address) => { - self.mainchain_address = main_address.to_string(); - } - Err(err) => { - let err = anyhow::Error::new(err); - tracing::error!("{err:#}") - } - }; + let app = app.unwrap().clone(); + self.generate_promise = + Some(poll_promise::Promise::spawn_async(async move { + app.get_new_main_address() + .await + .map_err(|e| format!("{e:#}")) + })); } }) .response @@ -178,7 +264,16 @@ impl Withdrawal { ) { tracing::error!("{err:#}"); } else { + let is_generating = self.generate_promise.is_some(); *self = Self::default(); + if is_generating { + // Keep the generation promise active if it was running + // (though unlikely to happen during a withdrawal) + self.generate_promise = + Some(poll_promise::Promise::spawn_async(async move { + unreachable!() + })); + } } } } diff --git a/app/main.rs b/app/main.rs index 9b69f22..284bda9 100644 --- a/app/main.rs +++ b/app/main.rs @@ -1,5 +1,5 @@ #![feature(try_find)] -use std::path::Path; +use std::{path::Path, sync::Arc}; use clap::Parser as _; use mimalloc::MiMalloc; @@ -178,9 +178,13 @@ fn run_egui_app( line_buffer: LineBuffer, app: Option, ) -> Result<(), eframe::Error> { - let native_options = eframe::NativeOptions::default(); + let native_options = eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default() + .with_inner_size(eframe::egui::vec2(1280.0, 720.0)), + ..Default::default() + }; let rpc_url = url::Url::parse(&format!("http://{}", config.rpc_addr)) - .expect("failed to parse rpc addr"); + .expect("failed to parse rpc url"); eframe::run_native( "Plain Bitnames", native_options, @@ -215,14 +219,21 @@ fn main() -> anyhow::Result<()> { }); }); if !config.headless { - let app = match app { - Ok(app) => Some(app), + let (app, rt) = match app { + Ok(app) => { + let rt = Arc::clone(&app.runtime); + (Some(app), rt) + } Err(err) => { let err = anyhow::Error::from(err); tracing::error!("{err:#}"); - None + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + (None, Arc::new(rt)) } }; + let _rt_guard = rt.enter(); // For GUI mode we want the GUI to start, even if the app fails to start. return run_egui_app(&config, line_buffer, app) .map_err(|e| anyhow::anyhow!("failed to run egui app: {e:#}")); diff --git a/app/rpc_server.rs b/app/rpc_server.rs index 071dd38..294d52f 100644 --- a/app/rpc_server.rs +++ b/app/rpc_server.rs @@ -21,6 +21,7 @@ use plain_bitnames::{ }; use plain_bitnames_app_rpc_api::{RpcServer, TxInfo}; use tower_http::{ + cors::CorsLayer, request_id::{ MakeRequestId, PropagateRequestIdLayer, RequestId, SetRequestIdLayer, }, @@ -77,7 +78,7 @@ impl RpcServer for RpcServerImpl { ) -> RpcResult { let app = self.app.clone(); tokio::task::spawn_blocking(move || { - app.deposit( + app.deposit_blocking( address, bitcoin::Amount::from_sat(value_sats), bitcoin::Amount::from_sat(fee_sats), @@ -562,7 +563,9 @@ pub async fn run_server( ))) .into_inner(); - let http_middleware = tower::ServiceBuilder::new().layer(tracer); + let http_middleware = tower::ServiceBuilder::new() + .layer(tracer) + .layer(CorsLayer::permissive()); let rpc_middleware = RpcServiceBuilder::new().rpc_logger(1024); let server = Server::builder() diff --git a/lib/state/two_way_peg_data.rs b/lib/state/two_way_peg_data.rs index 4d5f31f..43fb06a 100644 --- a/lib/state/two_way_peg_data.rs +++ b/lib/state/two_way_peg_data.rs @@ -785,7 +785,7 @@ fn disconnect_withdrawal_bundle_failed( &OutPointKey::from(outpoint), &spent_output, )?; - if state.utxos.delete(rwtxn, &OutPointKey::from(outpoint))? { + if !state.utxos.delete(rwtxn, &OutPointKey::from(outpoint))? { return Err(error::NoUtxo { outpoint: *outpoint, } @@ -928,7 +928,7 @@ pub fn disconnect( ); assert_eq!(block_height - 1, last_withdrawal_bundle_event_block_height); if !state - .deposit_blocks + .withdrawal_bundle_event_blocks .delete(rwtxn, &last_withdrawal_bundle_event_block_seq_idx)? { return Err(Error::NoWithdrawalBundleEventBlock); @@ -981,3 +981,179 @@ pub fn disconnect( } Ok(()) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use bitcoin::hashes::Hash as _; + use hashlink::LinkedHashMap; + + use crate::{ + state::{ + HeightStamped, RollBack, State, WithdrawalBundleInfo, + two_way_peg_data::{ + disconnect, disconnect_withdrawal_bundle_failed, + }, + }, + types::{ + Address, BitcoinOutputContent, FilledOutput, FilledOutputContent, + InPoint, M6id, OutPoint, OutPointKey, Txid, WithdrawalBundle, + WithdrawalBundleEvent, WithdrawalBundleEventStatus, + WithdrawalBundleStatus, + proto::mainchain::{BlockEvent, BlockInfo, TwoWayPegData}, + }, + }; + + // open a fresh state-backed env in a unique temp dir + fn temp_env() -> sneed::Env { + let mut path = std::env::temp_dir(); + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + path.push(format!("bitnames-test-{}-{unique}", std::process::id())); + std::fs::create_dir_all(&path).unwrap(); + let mut opts = heed::EnvOpenOptions::new(); + opts.map_size(16 * 1024 * 1024).max_dbs(State::NUM_DBS); + unsafe { sneed::Env::open(&opts, &path) }.unwrap() + } + + // a failed known bundle reinstates its utxos as spendable, so disconnecting + // the failure must spend them again + #[test] + fn disconnect_failed_bundle_spends_reinstated_utxo() { + let env = temp_env(); + let state = State::new(&env).unwrap(); + let outpoint = OutPoint::Regular { + txid: Txid::from([1; 32]), + vout: 0, + }; + let output = FilledOutput { + address: Address::ALL_ZEROS, + content: FilledOutputContent::Bitcoin(BitcoinOutputContent( + bitcoin::Amount::from_sat(1000), + )), + memo: Vec::new(), + }; + let key = OutPointKey::from(&outpoint); + + let m6id = { + let mut spend_utxos = BTreeMap::new(); + spend_utxos.insert(outpoint, output.clone()); + let bundle = WithdrawalBundle::new( + 1, + bitcoin::Amount::ZERO, + spend_utxos, + Vec::new(), + ) + .unwrap(); + let m6id = bundle.compute_m6id(); + let mut bundle_status = RollBack::>::new( + WithdrawalBundleStatus::Submitted, + 0, + ); + bundle_status + .push(WithdrawalBundleStatus::Failed, 1) + .unwrap(); + let mut rwtxn = env.write_txn().unwrap(); + state + .withdrawal_bundles + .put( + &mut rwtxn, + &m6id, + &(WithdrawalBundleInfo::Known(bundle), bundle_status), + ) + .unwrap(); + state + .latest_failed_withdrawal_bundle + .put( + &mut rwtxn, + &(), + &RollBack::>::new(m6id, 1), + ) + .unwrap(); + // the failure reinstated the utxo + state.utxos.put(&mut rwtxn, &key, &output).unwrap(); + rwtxn.commit().unwrap(); + m6id + }; + + let mut rwtxn = env.write_txn().unwrap(); + disconnect_withdrawal_bundle_failed(&state, &mut rwtxn, 1, m6id) + .unwrap(); + assert!(state.utxos.try_get(&rwtxn, &key).unwrap().is_none()); + let stxo = state.stxos.try_get(&rwtxn, &key).unwrap().unwrap(); + assert_eq!(stxo.inpoint, InPoint::Withdrawal { m6id }); + } + + // disconnecting a withdrawal bundle event must remove its + // withdrawal_bundle_event_blocks record, not a deposit_blocks record that + // happens to share the same sequence index + #[test] + fn disconnect_withdrawal_event_block_uses_correct_db() { + let env = temp_env(); + let state = State::new(&env).unwrap(); + + let block_height = 5u32; + let m6id = M6id(bitcoin::Txid::from_byte_array([7; 32])); + let event_block_hash = bitcoin::BlockHash::from_byte_array([9; 32]); + let deposit_block_hash = bitcoin::BlockHash::from_byte_array([3; 32]); + + let mut rwtxn = env.write_txn().unwrap(); + state.height.put(&mut rwtxn, &(), &block_height).unwrap(); + state + .withdrawal_bundles + .put( + &mut rwtxn, + &m6id, + &( + WithdrawalBundleInfo::Unknown, + RollBack::>::new( + WithdrawalBundleStatus::Submitted, + block_height, + ), + ), + ) + .unwrap(); + state + .withdrawal_bundle_event_blocks + .put(&mut rwtxn, &0, &(event_block_hash, block_height - 1)) + .unwrap(); + // a deposit record at the same sequence index that must survive + state + .deposit_blocks + .put(&mut rwtxn, &0, &(deposit_block_hash, block_height - 1)) + .unwrap(); + rwtxn.commit().unwrap(); + + let two_way_peg_data = { + let mut block_info = LinkedHashMap::new(); + block_info.insert( + event_block_hash, + BlockInfo { + bmm_commitment: None, + events: vec![BlockEvent::WithdrawalBundle( + WithdrawalBundleEvent { + m6id, + status: WithdrawalBundleEventStatus::Submitted, + }, + )], + }, + ); + TwoWayPegData { block_info } + }; + + let mut rwtxn = env.write_txn().unwrap(); + disconnect(&state, &mut rwtxn, &two_way_peg_data).unwrap(); + assert!( + state + .withdrawal_bundle_event_blocks + .try_get(&rwtxn, &0) + .unwrap() + .is_none() + ); + assert!(state.deposit_blocks.try_get(&rwtxn, &0).unwrap().is_some()); + rwtxn.commit().unwrap(); + } +} diff --git a/lib/wallet/mod.rs b/lib/wallet/mod.rs index 86c3a84..f8303a8 100644 --- a/lib/wallet/mod.rs +++ b/lib/wallet/mod.rs @@ -523,7 +523,7 @@ impl Wallet { .checked_add(main_fee) .ok_or(AmountOverflowError)?, )?; - let change = total - value - fee; + let change = total - value - fee - main_fee; let inputs = coins.into_keys().collect(); let outputs = vec![ Output::new( @@ -915,6 +915,23 @@ impl Wallet { }) } + /// Gets the latest generated address + pub fn try_get_last_address(&self) -> Result, Error> { + let rotxn = self.env.read_txn()?; + let last = self.index_to_address.last(&rotxn)?; + Ok(last.map(|(_, address)| address)) + } + + /// Gets the latest generated address, or generates a new one if no + /// addresses have already been generated + pub fn get_or_generate_last_address(&self) -> Result { + if let Some(address) = self.try_get_last_address()? { + Ok(address) + } else { + self.get_new_address() + } + } + pub fn get_num_addresses(&self) -> Result { let rotxn = self.env.read_txn()?; let res = self.index_to_address.len(&rotxn)? as u32; @@ -996,3 +1013,56 @@ impl Watchable<()> for Wallet { }) } } + +#[cfg(test)] +mod test { + use crate::wallet::Wallet; + + #[test] + fn test_get_or_generate_last_address() -> anyhow::Result<()> { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos(); + let test_dir = + std::env::temp_dir().join(format!("bitnames_test_wallet_{nanos}")); + + // Ensure clean state + if test_dir.exists() { + let _unused = std::fs::remove_dir_all(&test_dir); + } + + let wallet = Wallet::new(&test_dir)?; + + // Seed must be set before we can generate addresses + assert!(!wallet.has_seed()?); + let seed = [1u8; 64]; + wallet.set_seed(&seed)?; + assert!(wallet.has_seed()?); + + // Get last address when none have been generated + let last = wallet.try_get_last_address()?; + assert!(last.is_none()); + + // The first call should generate the first address. + let addr1 = wallet.get_or_generate_last_address()?; + + let last = wallet.try_get_last_address()?; + assert_eq!(last, Some(addr1)); + + let addr2 = wallet.get_or_generate_last_address()?; + assert_eq!(addr1, addr2); + + let addr3 = wallet.get_new_address()?; + assert_ne!(addr1, addr3); + + let last = wallet.try_get_last_address()?; + assert_eq!(last, Some(addr3)); + + let addr4 = wallet.get_or_generate_last_address()?; + assert_eq!(addr3, addr4); + + // Clean up + let _unused = std::fs::remove_dir_all(&test_dir); + Ok(()) + } +} diff --git a/types/lib.rs b/types/lib.rs index 646f0ba..185bdd0 100644 --- a/types/lib.rs +++ b/types/lib.rs @@ -676,16 +676,16 @@ pub struct AggregatedWithdrawal { impl Ord for AggregatedWithdrawal { fn cmp(&self, other: &Self) -> Ordering { - if self == other { - Ordering::Equal - } else if self.main_fee > other.main_fee - || self.value > other.value - || self.main_address > other.main_address - { - Ordering::Greater - } else { - Ordering::Less - } + // A *total* order (lexicographic by main_fee, value, main_address). The + // previous `OR of >` was not antisymmetric/transitive, so the + // withdrawal-bundle output order (and hence compute_m6id) depended on + // HashMap iteration order and could differ across nodes. A real total order makes + // the sorted bundle canonical regardless of aggregation order. + (self.main_fee, self.value, &self.main_address).cmp(&( + other.main_fee, + other.value, + &other.main_address, + )) } } @@ -782,3 +782,66 @@ impl From for Version { } } } + +#[cfg(test)] +mod withdrawal_bundle_order_regression { + use super::*; + use std::collections::{BTreeMap, HashMap}; + + use bitcoin::{Address, Amount, address::NetworkUnchecked}; + + fn aw(value: u64, main_fee: u64) -> AggregatedWithdrawal { + // value/main_fee drive the comparison; one address is enough to expose it. + let addr: Address = + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + .parse() + .unwrap(); + AggregatedWithdrawal { + spend_utxos: HashMap::new(), + main_address: addr, + value: Amount::from_sat(value), + main_fee: Amount::from_sat(main_fee), + } + } + + // Build the bundle m6id exactly as `collect_withdrawal_bundle` does, for a given + // (HashMap-determined) input order. + fn bundle_m6id(mut aggregated: Vec) -> M6id { + aggregated.sort_by_key(|a| std::cmp::Reverse(a.clone())); + let outputs: Vec = aggregated + .iter() + .map(|a| bitcoin::TxOut { + value: a.value, + script_pubkey: a + .main_address + .assume_checked_ref() + .script_pubkey(), + }) + .collect(); + WithdrawalBundle::new(0, Amount::ZERO, BTreeMap::new(), outputs) + .unwrap() + .compute_m6id() + } + + // The withdrawal bundle's m6id must not depend on the order in which withdrawals + // were aggregated (HashMap iteration order is randomized per process). Before the + // total-order fix, the comparator was non-transitive and this failed. + #[test] + fn m6id_is_independent_of_aggregation_order() { + let a = aw(1, 3); + let b = aw(3, 2); + let c = aw(2, 1); + let m = bundle_m6id(vec![a.clone(), b.clone(), c.clone()]); + for perm in [ + vec![c.clone(), b.clone(), a.clone()], + vec![b.clone(), a.clone(), c.clone()], + vec![a.clone(), c.clone(), b.clone()], + ] { + assert_eq!( + m, + bundle_m6id(perm), + "m6id must not depend on aggregation order" + ); + } + } +} diff --git a/types/transaction/mod.rs b/types/transaction/mod.rs index e275771..18815d3 100644 --- a/types/transaction/mod.rs +++ b/types/transaction/mod.rs @@ -217,8 +217,15 @@ impl<'a> BytesDecode<'a> for OutPointKey { } #[cfg(test)] -mod tests { - use super::{OUTPOINT_KEY_SIZE, OutPoint, OutPointKey}; +mod test { + use crate::{ + Address, + transaction::{ + Content, FilledContent, FilledOutput, FilledTransaction, + GetValue as _, OUTPOINT_KEY_SIZE, OutPoint, OutPointKey, Output, + Transaction, output_content, + }, + }; #[test] fn check_outpoint_key_size() -> anyhow::Result<()> { @@ -253,6 +260,50 @@ mod tests { } Ok(()) } + + // a withdrawal output must be funded for both its payout and its mainchain + // fee, since both leave the treasury + #[test] + fn withdrawal_value_includes_main_fee() { + let value = bitcoin::Amount::from_sat(1000); + let main_fee = bitcoin::Amount::from_sat(300); + let main_address = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2" + .parse::>() + .unwrap(); + let withdrawal = Output { + address: Address::ALL_ZEROS, + content: Content::Withdrawal(output_content::WithdrawalContent { + value, + main_fee, + main_address, + }), + memo: Vec::new(), + }; + assert_eq!(withdrawal.get_value(), value + main_fee); + + let value_output = |amount| FilledOutput { + address: Address::ALL_ZEROS, + content: FilledContent::Bitcoin(output_content::BitcoinContent( + amount, + )), + memo: Vec::new(), + }; + let withdrawal_tx = |funding| FilledTransaction { + transaction: Transaction { + outputs: vec![withdrawal.clone()], + ..Default::default() + }, + spent_utxos: vec![value_output(funding)], + }; + + // inputs covering only the payout are insufficient + assert!(withdrawal_tx(value).get_fee().is_err()); + // inputs covering payout plus mainchain fee fully fund it + assert_eq!( + withdrawal_tx(value + main_fee).get_fee().unwrap(), + bitcoin::Amount::ZERO + ); + } } /// Reference to a tx input. diff --git a/types/transaction/output_content.rs b/types/transaction/output_content.rs index 486ce75..a5d0939 100644 --- a/types/transaction/output_content.rs +++ b/types/transaction/output_content.rs @@ -233,7 +233,15 @@ mod withdrawal_content { impl crate::GetValue for WithdrawalContent { fn get_value(&self) -> bitcoin::Amount { - self.value + let Self { + value, + main_fee, + main_address: _, + } = self; + // a withdrawal removes both the payout and the mainchain fee + // from the sidechain, since the enforcer pays both out of the + // treasury + value.checked_add(*main_fee).unwrap_or(bitcoin::Amount::MAX) } } }