From eaa85d313e691d44bf027f9a3d23d7760f294a3e Mon Sep 17 00:00:00 2001 From: octobocto Date: Fri, 4 Sep 2026 20:36:24 +0200 Subject: [PATCH] wallet: keep a receive address until it receives --- app/app.rs | 4 +- integration_tests/integration_test.rs | 6 ++ integration_tests/main.rs | 1 + integration_tests/receive_address.rs | 148 ++++++++++++++++++++++++++ lib/wallet.rs | 83 +++++++++++++++ 5 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 integration_tests/receive_address.rs diff --git a/app/app.rs b/app/app.rs index 6b756e80..ee496294 100644 --- a/app/app.rs +++ b/app/app.rs @@ -446,7 +446,9 @@ impl App { let coinbase = match tx_fees { bitcoin::Amount::ZERO => Vec::new(), _ => vec![types::Output { - address: self.wallet.get_new_address()?, + // A template is built on every poll and mostly thrown + // away, so it must not derive an address each time. + address: self.wallet.get_receive_address()?, content: types::OutputContent::Value(tx_fees), }], }; diff --git a/integration_tests/integration_test.rs b/integration_tests/integration_test.rs index 5ff99bab..ad994a37 100644 --- a/integration_tests/integration_test.rs +++ b/integration_tests/integration_test.rs @@ -14,6 +14,7 @@ use thunder_app_rpc_api::node::RpcClient as _; use crate::{ block_template::block_template_trial, ibd::ibd_trial, + receive_address::receive_address_trial, setup::{Init, PostSetup}, unknown_withdrawal::unknown_withdrawal_trial, util::BinPaths, @@ -178,6 +179,11 @@ pub fn tests( file_registry.clone(), failure_collector.clone(), ), + receive_address_trial( + bin_paths.clone(), + file_registry.clone(), + failure_collector.clone(), + ), unknown_withdrawal_trial(bin_paths, file_registry, failure_collector), ] } diff --git a/integration_tests/main.rs b/integration_tests/main.rs index a8b80495..7706ea43 100644 --- a/integration_tests/main.rs +++ b/integration_tests/main.rs @@ -7,6 +7,7 @@ use tracing_subscriber::{filter as tracing_filter, layer::SubscriberExt}; mod block_template; mod ibd; mod integration_test; +mod receive_address; mod setup; mod unknown_withdrawal; mod util; diff --git a/integration_tests/receive_address.rs b/integration_tests/receive_address.rs new file mode 100644 index 00000000..975e27b5 --- /dev/null +++ b/integration_tests/receive_address.rs @@ -0,0 +1,148 @@ +//! Test that the wallet reuses its receive address until it receives + +use bip300301_enforcer_integration_tests::{ + integration_test::{ + activate_sidechain, deposit, fund_enforcer, propose_sidechain, + }, + setup::{ + Mode, Network, PostSetup as EnforcerPostSetup, + PreSetup as EnforcerPreSetup, SetupOpts as EnforcerSetupOpts, + Sidechain as _, + }, + util::{ + AbortOnDrop, AsyncTrial, BinPaths as EnforcerBinPaths, + TestFailureCollector, TestFileRegistry, + }, +}; +use bitcoin::Amount; +use futures::{ + FutureExt as _, StreamExt as _, channel::mpsc, future::BoxFuture, +}; +use thunder_app_rpc_api::wallet::RpcClient as _; +use tokio::time::sleep; +use tracing::Instrument as _; + +use crate::{ + setup::{Init, PostSetup}, + util::BinPaths, +}; + +const DEPOSIT_AMOUNT: Amount = Amount::from_sat(21_000_000); +const DEPOSIT_FEE: Amount = Amount::from_sat(1_000_000); + +/// Initial setup for the test +async fn setup( + enforcer_bin_paths: &EnforcerBinPaths, + res_tx: mpsc::UnboundedSender>, +) -> anyhow::Result { + let enforcer_pre_setup = + EnforcerPreSetup::new(enforcer_bin_paths, Network::Regtest)?; + let mut enforcer_post_setup = { + let setup_opts: EnforcerSetupOpts = Default::default(); + enforcer_pre_setup + .setup(Mode::Mempool, setup_opts, res_tx.clone()) + .await? + }; + let () = propose_sidechain::(&mut enforcer_post_setup).await?; + let () = activate_sidechain::(&mut enforcer_post_setup).await?; + let () = fund_enforcer::(&mut enforcer_post_setup).await?; + Ok(enforcer_post_setup) +} + +async fn receive_address_task( + bin_paths: BinPaths, + res_tx: mpsc::UnboundedSender>, +) -> anyhow::Result<()> { + let mut enforcer_post_setup = + setup(&bin_paths.others, res_tx.clone()).await?; + let mut sidechain = PostSetup::setup( + Init { + thunder_app: bin_paths.thunder()?.clone(), + data_dir_suffix: None, + }, + &enforcer_post_setup, + res_tx, + ) + .await?; + tracing::info!("Setup thunder node successfully"); + + // Setup asks for one address and keeps it as the deposit address. + let deposit_address = sidechain.get_deposit_address().await?; + let before = sidechain.rpc_client.get_wallet_addresses().await?.len(); + + tracing::debug!("Checking that a template asks for no new address"); + for _ in 0..10 { + let _template = sidechain.rpc_client.get_block_template().await?; + } + anyhow::ensure!( + sidechain.rpc_client.get_wallet_addresses().await?.len() == before + ); + + tracing::debug!("Checking that a fresh address is still fresh"); + let fresh = sidechain.rpc_client.get_new_address().await?; + anyhow::ensure!(fresh.to_string() != deposit_address); + anyhow::ensure!( + sidechain.rpc_client.get_wallet_addresses().await?.len() == before + 1 + ); + + tracing::debug!("Depositing, so the receive address receives"); + let () = deposit( + &mut enforcer_post_setup, + &mut sidechain, + &deposit_address, + DEPOSIT_AMOUNT, + DEPOSIT_FEE, + ) + .await?; + let after_deposit = + sidechain.rpc_client.get_wallet_addresses().await?.len(); + + tracing::debug!("Checking that a template still asks for no new address"); + for _ in 0..10 { + let _template = sidechain.rpc_client.get_block_template().await?; + } + anyhow::ensure!( + sidechain.rpc_client.get_wallet_addresses().await?.len() + == after_deposit + ); + + drop(sidechain); + tracing::info!( + "Removing {}", + enforcer_post_setup.directories.base_dir.path().display() + ); + drop(enforcer_post_setup.tasks); + // Wait for tasks to die + sleep(std::time::Duration::from_secs(1)).await; + enforcer_post_setup.directories.base_dir.cleanup()?; + Ok(()) +} + +async fn receive_address(bin_paths: BinPaths) -> anyhow::Result<()> { + let (res_tx, mut res_rx) = mpsc::unbounded(); + let _test_task: AbortOnDrop<()> = tokio::task::spawn({ + let res_tx = res_tx.clone(); + async move { + let res = receive_address_task(bin_paths, res_tx.clone()).await; + let _send_err: Result<(), _> = res_tx.unbounded_send(res); + } + .in_current_span() + }) + .into(); + res_rx.next().await.ok_or_else(|| { + anyhow::anyhow!("Unexpected end of test task result stream") + })? +} + +pub fn receive_address_trial( + bin_paths: BinPaths, + file_registry: TestFileRegistry, + failure_collector: TestFailureCollector, +) -> AsyncTrial>> { + AsyncTrial::new( + "receive_address", + receive_address(bin_paths).boxed(), + file_registry, + failure_collector, + ) +} diff --git a/lib/wallet.rs b/lib/wallet.rs index 3543a69a..5b313525 100644 --- a/lib/wallet.rs +++ b/lib/wallet.rs @@ -473,6 +473,8 @@ impl Wallet { }) } + /// Derives an address the wallet never used. A change output takes one of + /// these, so two transactions never share a change address. pub fn get_new_address(&self) -> Result { let mut txn = self.env.write_txn().map_err(EnvError::from)?; let (last_index, _) = self @@ -495,6 +497,43 @@ impl Wallet { Ok(address) } + /// The address to receive at. Derives a new one only once the current one + /// receives. + pub fn get_receive_address(&self) -> Result { + { + let rotxn = self.env.read_txn().map_err(EnvError::from)?; + let last = + self.index_to_address.last(&rotxn).map_err(DbError::from)?; + if let Some((_, address)) = last + && !self.address_received(&rotxn, &address)? + { + return Ok(address); + } + } + self.get_new_address() + } + + /// True when any output the wallet holds or held pays this address. + fn address_received( + &self, + rotxn: &RoTxn, + address: &Address, + ) -> Result { + let mut utxos = self.utxos.iter(rotxn).map_err(DbError::from)?; + while let Some((_, output)) = utxos.next().map_err(DbError::from)? { + if output.address == *address { + return Ok(true); + } + } + let mut stxos = self.stxos.iter(rotxn).map_err(DbError::from)?; + while let Some((_, spent)) = stxos.next().map_err(DbError::from)? { + if spent.output.address == *address { + return Ok(true); + } + } + Ok(false) + } + /// Gets the latest generated address. pub fn try_get_last_address(&self) -> Result, Error> { let txn = self.env.read_txn().map_err(EnvError::from)?; @@ -582,6 +621,50 @@ impl Watchable<()> for Wallet { mod tests { use super::*; + #[test] + fn test_get_receive_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!("thunder_test_receive_{nanos}")); + if test_dir.exists() { + let _unused = std::fs::remove_dir_all(&test_dir); + } + + let wallet = Wallet::new(&test_dir)?; + wallet.set_seed(&[1u8; 64])?; + + // An address that never received comes back every time. + let first = wallet.get_receive_address()?; + for _ in 0..10 { + assert_eq!(wallet.get_receive_address()?, first); + } + assert_eq!(wallet.get_addresses()?.len(), 1); + + // A fresh address is still fresh, so a change output never reuses one. + let fresh = wallet.get_new_address()?; + assert_ne!(fresh, first); + assert_eq!(wallet.get_addresses()?.len(), 2); + + // The receive address moves on once it receives. + let outpoint = OutPoint::Regular { + txid: [0; 32].into(), + vout: 0, + }; + let output = Output { + address: wallet.get_receive_address()?, + content: OutputContent::Value(bitcoin::Amount::from_sat(1000)), + }; + wallet.put_utxos(&HashMap::from([(outpoint, output)]))?; + let second = wallet.get_receive_address()?; + assert_ne!(second, first); + assert_eq!(wallet.get_receive_address()?, second); + + let _unused = std::fs::remove_dir_all(&test_dir); + Ok(()) + } + #[test] fn test_get_or_generate_last_address() -> anyhow::Result<()> { let nanos = std::time::SystemTime::now()