From 7410ded1bba0ceb16fb3113128114237a77d1c9f Mon Sep 17 00:00:00 2001 From: emma31-dev Date: Sat, 7 Nov 2026 17:47:07 +0100 Subject: [PATCH] fix(repl): persist wallet after each command The REPL loaded a persisted wallet but never called `wallet.persist()`, so every change made in a session was lost on exit: the address index rewound (reusing already handed-out addresses), and created or broadcast transactions, UTXO locks and scan results disappeared. Persist after each successful command, matching the non-REPL path, so the state also survives a crash or Ctrl-C instead of only being flushed on exit. Add a regression test that runs `wallet new_address` in two consecutive REPL processes and asserts the address index advances. --- CHANGELOG.md | 1 + src/main.rs | 4 +++ tests/integration/offline.rs | 66 ++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0bdf59..746b7cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. - Fixed `compile` panicking instead of returning an error when a key is valid on its own but the wrong shape for the requested script type (e.g. an x-only key under `sh`/`wsh`/`sh-wsh`, or an uncompressed key under `tr`) - Updated dependencies to resolve RUSTSEC advisories in rustls, rustls-webpki, webpki-roots, quinn-proto, h2 and zstd-sys; bumped async-hwi to 0.0.35. - Fixed `create_tx`, `create_dns_tx`, `create_sp_tx` and `bump_fee` building transactions over Bitcoin Core's 400,000 wu (100,000 vB) standard weight limit, which no default node relays +- Fixed REPL mode not persisting wallet changes, so a new session resumed from stale state (e.g. reusing already-handed-out addresses) and lost transactions, UTXO locks and scan results ## [4.0.0] diff --git a/src/main.rs b/src/main.rs index 7ed73d46..a7ef4f95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -202,6 +202,10 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { .await .map_err(Error::Generic)?; + // Persist after every command so the session's changes survive an exit, + // a crash, or Ctrl-C, matching the non-REPL path. + wallet.persist()?; + if should_exit { break; } diff --git a/tests/integration/offline.rs b/tests/integration/offline.rs index c4be0a21..6648fe1c 100644 --- a/tests/integration/offline.rs +++ b/tests/integration/offline.rs @@ -491,3 +491,69 @@ mod multipath_tests { .failure(); } } + +#[cfg(all(feature = "repl", feature = "sqlite"))] +mod repl_persist_tests { + use crate::common::BdkCli; + use serde_json::Value; + use tempfile::TempDir; + + const WALLET_NAME: &str = "repl_persist_wallet"; + + fn setup_repl_wallet() -> (BdkCli, TempDir) { + let temp = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp.path().to_path_buf())); + + let desc = cli.cmd("descriptor", &["--type", "tr"]).output().unwrap(); + let v: Value = serde_json::from_slice(&desc.stdout).unwrap(); + let ext = v["private_descriptors"]["external"].as_str().unwrap(); + let int = v["private_descriptors"]["internal"].as_str().unwrap(); + + cli.build_base_cmd() + .args(["wallet", "--wallet", WALLET_NAME, "config"]) + .args(["--ext-descriptor", ext, "--int-descriptor", int]) + .args(["--database-type", "sqlite"]) + .assert() + .success(); + + (cli, temp) + } + + /// Runs `wallet new_address` in a fresh REPL process and returns the index it printed. + fn repl_new_address_index(cli: &BdkCli) -> u64 { + let output = cli + .build_base_cmd() + .args(["repl", "--wallet", WALLET_NAME]) + .write_stdin("wallet new_address\nexit\n") + .output() + .expect("failed to run repl"); + + assert!( + output.status.success(), + "repl exited non-zero: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let marker = "\"index\": "; + let start = stdout + .find(marker) + .unwrap_or_else(|| panic!("new_address output missing index:\n{stdout}")) + + marker.len(); + stdout[start..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .expect("index was not a number") + } + + #[test] + fn repl_persists_address_index_across_sessions() { + let (cli, _temp) = setup_repl_wallet(); + + assert_eq!(repl_new_address_index(&cli), 0); + // A second, separate REPL process must see the index persisted by the first one. + assert_eq!(repl_new_address_index(&cli), 1); + } +}