From 21197af84b22d07aee39dddf951af00453ee50a7 Mon Sep 17 00:00:00 2001 From: Fumin Date: Wed, 20 May 2026 02:06:02 +0800 Subject: [PATCH 01/12] ui: fix spend button not shown in Coins/Transaction Builder tab --- app/gui/coins/tx_builder.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/gui/coins/tx_builder.rs b/app/gui/coins/tx_builder.rs index 3fcebb1..70e2bf3 100644 --- a/app/gui/coins/tx_builder.rs +++ b/app/gui/coins/tx_builder.rs @@ -107,13 +107,13 @@ impl TxBuilder { ) -> anyhow::Result<()> { egui::ScrollArea::horizontal().show(ui, |ui| { egui::SidePanel::left("spend_utxo") - .exact_width(250.) + .exact_width(300.) .resizable(false) .show_inside(ui, |ui| { self.utxo_selector.show(app, ui, &mut self.base_tx); }); egui::SidePanel::left("value_in") - .exact_width(250.) + .exact_width(300.) .resizable(false) .show_inside(ui, |ui| { let () = self.show_value_in(app, ui); From 2f8f1893074d41f79a48ef33d98bdbc515d799b0 Mon Sep 17 00:00:00 2001 From: Ash Manning Date: Thu, 11 Jun 2026 16:15:57 +0800 Subject: [PATCH 02/12] Update deps --- Cargo.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) 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]] From c03841acb8b4daffc868e101018352b7cbfc6b49 Mon Sep 17 00:00:00 2001 From: ekulkisnek Date: Tue, 19 May 2026 15:33:39 -0500 Subject: [PATCH 03/12] Prevent address generation on startup Avoid generating and writing a new wallet address to LMDB every time the GUI application starts up, which could lead to performance degradation. Instead, reuse the last generated address on initialization, and only generate a new address on user demand (via explicit 'generate' button). --- app/gui/coins/transfer_receive.rs | 10 ++++- lib/wallet/mod.rs | 74 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/gui/coins/transfer_receive.rs b/app/gui/coins/transfer_receive.rs index f359aef..626b521 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_address_or_new() .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/lib/wallet/mod.rs b/lib/wallet/mod.rs index 86c3a84..415f021 100644 --- a/lib/wallet/mod.rs +++ b/lib/wallet/mod.rs @@ -915,6 +915,20 @@ impl Wallet { }) } + pub fn 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)) + } + + pub fn get_address_or_new(&self) -> Result { + if let Some(address) = self.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 +1010,63 @@ impl Watchable<()> for Wallet { }) } } + +#[cfg(test)] +mod test { + use crate::wallet::{Error, Wallet}; + + #[test] + fn test_get_address_or_new() -> Result<(), Error> { + let test_dir = std::env::temp_dir().join(format!( + "bitnames_test_wallet_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_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.get_last_address()?; + assert!(last.is_none()); + + // Get address or new should generate the first address + let addr1 = wallet.get_address_or_new()?; + + // Now last address should be addr1 + let last = wallet.get_last_address()?; + assert_eq!(last, Some(addr1)); + + // Subsequent get_address_or_new calls should return the same addr1 + let addr2 = wallet.get_address_or_new()?; + assert_eq!(addr1, addr2); + + // Generating a new address explicitly should give a new one + let addr3 = wallet.get_new_address()?; + assert_ne!(addr1, addr3); + + // Now last address should be addr3 + let last = wallet.get_last_address()?; + assert_eq!(last, Some(addr3)); + + // And get_address_or_new should return addr3 + let addr4 = wallet.get_address_or_new()?; + assert_eq!(addr3, addr4); + + // Clean up + let _unused = std::fs::remove_dir_all(&test_dir); + Ok(()) + } +} \ No newline at end of file From 82d0a301aba9621ae6b876584a99c2473fd73b27 Mon Sep 17 00:00:00 2001 From: ekulkisnek Date: Tue, 19 May 2026 17:59:10 -0500 Subject: [PATCH 04/12] Fix GUI layout squishing and small default window size --- app/Cargo.toml | 2 +- app/app.rs | 65 +++--- app/gui/activity/mempool_explorer.rs | 331 +++++++++++++++------------ app/gui/coins/tx_builder.rs | 32 ++- app/gui/parent_chain/info.rs | 79 ++++--- app/gui/parent_chain/transfer.rs | 137 +++++++++-- app/main.rs | 23 +- app/rpc_server.rs | 5 +- lib/wallet/mod.rs | 2 +- 9 files changed, 421 insertions(+), 255 deletions(-) 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..8b29027 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_async( &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( + &self, + ) -> Result, Error> { + self.runtime.block_on(self.get_new_main_address_async()) } /** Get all paymail. @@ -614,7 +613,7 @@ impl App { Ok(()) } - pub fn deposit( + pub async fn deposit_async( &self, address: Address, amount: bitcoin::Amount, @@ -623,15 +622,23 @@ 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( + &self, + address: Address, + amount: bitcoin::Amount, + fee: bitcoin::Amount, + ) -> Result { + self.runtime + .block_on(self.deposit_async(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/tx_builder.rs b/app/gui/coins/tx_builder.rs index 70e2bf3..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(300.) - .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(300.) - .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/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..396af7e 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_async(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_async() + .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..6c0acc1 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, }, @@ -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/wallet/mod.rs b/lib/wallet/mod.rs index 415f021..0705f56 100644 --- a/lib/wallet/mod.rs +++ b/lib/wallet/mod.rs @@ -1069,4 +1069,4 @@ mod test { let _unused = std::fs::remove_dir_all(&test_dir); Ok(()) } -} \ No newline at end of file +} From 626bef030e32041c1f0d69e5ad42f22d85e78f96 Mon Sep 17 00:00:00 2001 From: ekulkisnek Date: Sun, 31 May 2026 12:09:37 -0500 Subject: [PATCH 05/12] Address PR review feedback --- app/app.rs | 13 +++++---- app/gui/coins/transfer_receive.rs | 2 +- app/gui/coins/utxo_creator.rs | 2 +- app/gui/parent_chain/transfer.rs | 4 +-- app/rpc_server.rs | 2 +- lib/wallet/mod.rs | 44 ++++++++++++++----------------- 6 files changed, 31 insertions(+), 36 deletions(-) diff --git a/app/app.rs b/app/app.rs index 8b29027..bc9c19c 100644 --- a/app/app.rs +++ b/app/app.rs @@ -281,7 +281,7 @@ impl App { Ok(()) } - pub async fn get_new_main_address_async( + pub async fn get_new_main_address( &self, ) -> Result, Error> { let Some(miner) = self.miner.as_ref() else { @@ -300,10 +300,10 @@ impl App { Ok(res) } - pub fn get_new_main_address( + pub fn get_new_main_address_blocking( &self, ) -> Result, Error> { - self.runtime.block_on(self.get_new_main_address_async()) + self.runtime.block_on(self.get_new_main_address()) } /** Get all paymail. @@ -613,7 +613,7 @@ impl App { Ok(()) } - pub async fn deposit_async( + pub async fn deposit( &self, address: Address, amount: bitcoin::Amount, @@ -631,14 +631,13 @@ impl App { Ok(txid) } - pub fn deposit( + pub fn deposit_blocking( &self, address: Address, amount: bitcoin::Amount, fee: bitcoin::Amount, ) -> Result { - self.runtime - .block_on(self.deposit_async(address, amount, fee)) + self.runtime.block_on(self.deposit(address, amount, fee)) } } diff --git a/app/gui/coins/transfer_receive.rs b/app/gui/coins/transfer_receive.rs index 626b521..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_address_or_new() + .get_or_generate_last_address() .map_err(anyhow::Error::from) .inspect_err(|err| tracing::error!("{err:#}")); Self { 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/transfer.rs b/app/gui/parent_chain/transfer.rs index 396af7e..4286bed 100644 --- a/app/gui/parent_chain/transfer.rs +++ b/app/gui/parent_chain/transfer.rs @@ -95,7 +95,7 @@ impl Deposit { Ok(address) => { self.promise = Some(poll_promise::Promise::spawn_async(async move { - app.deposit_async(address, amount, fee) + app.deposit(address, amount, fee) .await .map_err(|e| format!("{e:#}")) })); @@ -189,7 +189,7 @@ impl Withdrawal { let app = app.unwrap().clone(); self.generate_promise = Some(poll_promise::Promise::spawn_async(async move { - app.get_new_main_address_async() + app.get_new_main_address() .await .map_err(|e| format!("{e:#}")) })); diff --git a/app/rpc_server.rs b/app/rpc_server.rs index 6c0acc1..294d52f 100644 --- a/app/rpc_server.rs +++ b/app/rpc_server.rs @@ -78,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), diff --git a/lib/wallet/mod.rs b/lib/wallet/mod.rs index 0705f56..ea28c22 100644 --- a/lib/wallet/mod.rs +++ b/lib/wallet/mod.rs @@ -915,14 +915,17 @@ impl Wallet { }) } - pub fn get_last_address(&self) -> Result, Error> { + /// 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)) } - pub fn get_address_or_new(&self) -> Result { - if let Some(address) = self.get_last_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() @@ -1013,17 +1016,15 @@ impl Watchable<()> for Wallet { #[cfg(test)] mod test { - use crate::wallet::{Error, Wallet}; + use crate::wallet::Wallet; #[test] - fn test_get_address_or_new() -> Result<(), Error> { - let test_dir = std::env::temp_dir().join(format!( - "bitnames_test_wallet_{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); + 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() { @@ -1039,30 +1040,25 @@ mod test { assert!(wallet.has_seed()?); // Get last address when none have been generated - let last = wallet.get_last_address()?; + let last = wallet.try_get_last_address()?; assert!(last.is_none()); - // Get address or new should generate the first address - let addr1 = wallet.get_address_or_new()?; + // The first call should generate the first address. + let addr1 = wallet.get_or_generate_last_address()?; - // Now last address should be addr1 - let last = wallet.get_last_address()?; + let last = wallet.try_get_last_address()?; assert_eq!(last, Some(addr1)); - // Subsequent get_address_or_new calls should return the same addr1 - let addr2 = wallet.get_address_or_new()?; + let addr2 = wallet.get_or_generate_last_address()?; assert_eq!(addr1, addr2); - // Generating a new address explicitly should give a new one let addr3 = wallet.get_new_address()?; assert_ne!(addr1, addr3); - // Now last address should be addr3 - let last = wallet.get_last_address()?; + let last = wallet.try_get_last_address()?; assert_eq!(last, Some(addr3)); - // And get_address_or_new should return addr3 - let addr4 = wallet.get_address_or_new()?; + let addr4 = wallet.get_or_generate_last_address()?; assert_eq!(addr3, addr4); // Clean up From 04064d8544b5f2bb065e1108289aea4961765f33 Mon Sep 17 00:00:00 2001 From: /dev/fd0 Date: Mon, 8 Jun 2026 09:16:40 +0530 Subject: [PATCH 06/12] fix: delete reinstated utxo when disconnecting failed withdrawal bundle --- lib/state/two_way_peg_data.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/state/two_way_peg_data.rs b/lib/state/two_way_peg_data.rs index 4d5f31f..79462cd 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, } From cc6409f9d06c9340ccfa08999ecdb1be52f5999d Mon Sep 17 00:00:00 2001 From: /dev/fd0 Date: Mon, 8 Jun 2026 09:21:06 +0530 Subject: [PATCH 07/12] test: disconnect of failed withdrawal bundle spends reinstated utxo --- lib/state/two_way_peg_data.rs | 100 ++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/lib/state/two_way_peg_data.rs b/lib/state/two_way_peg_data.rs index 79462cd..c70abab 100644 --- a/lib/state/two_way_peg_data.rs +++ b/lib/state/two_way_peg_data.rs @@ -981,3 +981,103 @@ pub fn disconnect( } Ok(()) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{ + state::{ + HeightStamped, RollBack, State, WithdrawalBundleInfo, + two_way_peg_data::disconnect_withdrawal_bundle_failed, + }, + types::{ + Address, BitcoinOutputContent, FilledOutput, FilledOutputContent, + InPoint, OutPoint, OutPointKey, Txid, WithdrawalBundle, + WithdrawalBundleStatus, + }, + }; + + // 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 }); + rwtxn.commit().unwrap(); + } +} From ec8749cd19feda0d67ad22368989b032294828b6 Mon Sep 17 00:00:00 2001 From: /dev/fd0 Date: Mon, 8 Jun 2026 09:30:31 +0530 Subject: [PATCH 08/12] fix: delete withdrawal bundle event block from the correct db on disconnect --- lib/state/two_way_peg_data.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/state/two_way_peg_data.rs b/lib/state/two_way_peg_data.rs index c70abab..1035a31 100644 --- a/lib/state/two_way_peg_data.rs +++ b/lib/state/two_way_peg_data.rs @@ -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); From e6bea9e51c2590074e0519044c1459053368db99 Mon Sep 17 00:00:00 2001 From: /dev/fd0 Date: Mon, 8 Jun 2026 09:32:43 +0530 Subject: [PATCH 09/12] test: disconnect removes withdrawal bundle event block from the correct db --- lib/state/two_way_peg_data.rs | 80 ++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/lib/state/two_way_peg_data.rs b/lib/state/two_way_peg_data.rs index 1035a31..43fb06a 100644 --- a/lib/state/two_way_peg_data.rs +++ b/lib/state/two_way_peg_data.rs @@ -986,15 +986,22 @@ pub fn disconnect( 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_withdrawal_bundle_failed, + two_way_peg_data::{ + disconnect, disconnect_withdrawal_bundle_failed, + }, }, types::{ Address, BitcoinOutputContent, FilledOutput, FilledOutputContent, - InPoint, OutPoint, OutPointKey, Txid, WithdrawalBundle, + InPoint, M6id, OutPoint, OutPointKey, Txid, WithdrawalBundle, + WithdrawalBundleEvent, WithdrawalBundleEventStatus, WithdrawalBundleStatus, + proto::mainchain::{BlockEvent, BlockInfo, TwoWayPegData}, }, }; @@ -1078,6 +1085,75 @@ mod tests { 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(); } } From 48208c5489ccef68b7dd6c99b6eef92609a0c473 Mon Sep 17 00:00:00 2001 From: /dev/fd0 Date: Mon, 8 Jun 2026 09:48:48 +0530 Subject: [PATCH 10/12] fix: account for mainchain fee in withdrawal output value --- lib/wallet/mod.rs | 2 +- types/transaction/output_content.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/wallet/mod.rs b/lib/wallet/mod.rs index ea28c22..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( 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) } } } From 09191e82f69a53f8bfbbb2afedb1390e4171f2ea Mon Sep 17 00:00:00 2001 From: /dev/fd0 Date: Mon, 8 Jun 2026 09:50:22 +0530 Subject: [PATCH 11/12] test: withdrawal output value covers payout and mainchain fee --- types/transaction/mod.rs | 55 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) 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. From e2d652a2a3c43fb18ff5eba1dbb045772978c25f Mon Sep 17 00:00:00 2001 From: ace-degen <278693119+ace-degen@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:46:36 +0000 Subject: [PATCH 12/12] two_way_peg: make withdrawal-bundle output order deterministic `AggregatedWithdrawal`'s `Ord` impl is not a valid total order: it returns `Greater` if any of `main_fee`, `value`, or `main_address` is greater (an OR of `>`), which violates antisymmetry and transitivity. `collect_withdrawal_bundle` aggregates withdrawals into a `HashMap`, takes `into_values()` (iteration order is randomized per process) and `sort_by_key`s with that comparator. Because the comparator is non-transitive the sorted order depends on the input order, and nothing canonicalizes it before the bundle's outputs are hashed into the m6id (`compute_txid`). Two honest nodes with the identical UTXO set can therefore compute different m6ids for the same withdrawal bundle, then disagree on the on-chain M6 and diverge. Replace the comparator with a real total order (lexicographic over `(main_fee, value, main_address)`) so the sorted bundle is canonical regardless of `HashMap` iteration order. --- types/lib.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 10 deletions(-) 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" + ); + } + } +}