From dbcb7bda38de29fa2bc5a782ab5080cdf2568844 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 12 May 2026 19:14:14 -0500 Subject: [PATCH 1/4] Bump bitcoin-payment-instructions base --- Cargo.toml | 2 +- src/payment/unified.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d34710a6e0..cb3cbf4e3e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,7 +81,7 @@ async-trait = { version = "0.1", default-features = false } vss-client = { package = "vss-client-ng", version = "0.5" } prost = { version = "0.11.6", default-features = false} #bitcoin-payment-instructions = { version = "0.6" } -bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "679dac50cc0d81ec4d31da94b93d467e5308f16a" } +bitcoin-payment-instructions = { git = "https://github.com/benthecarman/bitcoin-payment-instructions", rev = "23bb47b2d568571c3191d59881ff048d21537ebd" } [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["winbase"] } diff --git a/src/payment/unified.rs b/src/payment/unified.rs index 3708afe8e6..d279111853 100644 --- a/src/payment/unified.rs +++ b/src/payment/unified.rs @@ -256,6 +256,7 @@ impl UnifiedPayment { PaymentMethod::LightningBolt12(_) => 0, PaymentMethod::LightningBolt11(_) => 1, PaymentMethod::OnChain(_) => 2, + PaymentMethod::Cashu(_) => 3, }); for method in sorted_payment_methods { @@ -331,6 +332,10 @@ impl UnifiedPayment { let txid = self.onchain_payment.send_to_address(&address, amt_sats, None)?; return Ok(UnifiedPaymentResult::Onchain { txid }); }, + PaymentMethod::Cashu(_) => { + log_error!(self.logger, "Cashu payment methods are not supported. Skipping."); + continue; + }, } } From 617b1a75bbc27654ad8f17ce1a6605199444ba5f Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 12 May 2026 19:34:56 -0500 Subject: [PATCH 2/4] Bump rust-lightning, fix splice fee accounting Update rust-lightning to commit 2313bd5. The new splice API computes its fee estimate independently of the BDK coin selection it drives, so any surplus from BDK's higher reservation flows into the new funding output instead of staying in change. Bridge the gap in select_confirmed_utxos by stripping 5 WU per foreign input when calling add_foreign_utxo so BDK doesn't double-count the empty script_sig byte and witness-count varint that LDK's satisfaction_weight already includes. Also bump change by BDK's residual per-component fee rounding surplus so the extra reservation stays in change instead of flowing into the new funding output. --- Cargo.toml | 26 +++++++++++++------------- src/lib.rs | 11 ++--------- src/wallet/mod.rs | 29 +++++++++++++++++++++++++---- tests/integration_tests_rust.rs | 16 ++++++++++++---- 4 files changed, 52 insertions(+), 30 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cb3cbf4e3e..ce2bc37426 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,18 +40,18 @@ default = [] #lightning-macros = { version = "0.2.0" } #lightning-dns-resolver = { version = "0.3.0" } -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["std"] } -lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" } -lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" } -lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" } -lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["std"] } -lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" } -lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" } +lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c", features = ["std"] } +lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c" } +lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c" } +lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c" } +lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c" } +lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c", features = ["std"] } +lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c" } +lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -87,7 +87,7 @@ bitcoin-payment-instructions = { git = "https://github.com/benthecarman/bitcoin- winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "2313bd584d2c46a50d67b8266f488c07516e0b3c", features = ["std", "_test_utils"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" regex = "1.5.6" diff --git a/src/lib.rs b/src/lib.rs index 6d877ae10d..025c6d3bb1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1699,15 +1699,8 @@ impl Node { value: Amount::from_sat(splice_amount_sats), script_pubkey: address.script_pubkey(), }]; - let contribution = self - .runtime - .block_on(funding_template.splice_out( - outputs, - min_feerate, - max_feerate, - Arc::clone(&self.wallet), - )) - .map_err(|e| { + let contribution = + funding_template.splice_out(outputs, min_feerate, max_feerate).map_err(|e| { log_error!(self.logger, "Failed to splice channel: {}", e); Error::ChannelSplicingFailed })?; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index daeb7becb3..f4e4df6800 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -574,7 +574,7 @@ impl Wallet { witness_utxo: Some(input.previous_utxo.clone()), ..Default::default() }; - let weight = Weight::from_wu(input.satisfaction_weight); + let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); tx_builder.only_witness_utxo().exclude_unconfirmed(); tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|e| { log_error!(self.logger, "Failed to add shared input for fee estimation: {e}"); @@ -916,7 +916,7 @@ impl Wallet { witness_utxo: Some(input.previous_utxo.clone()), ..Default::default() }; - let weight = Weight::from_wu(input.satisfaction_weight); + let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; } @@ -958,8 +958,7 @@ impl Wallet { let change_output = unsigned_tx .output .into_iter() - .filter(|txout| must_pay_to.iter().all(|output| output != txout)) - .next(); + .find(|txout| must_pay_to.iter().all(|output| output != txout)); if change_output.is_some() { locked_wallet.persist(&mut locked_persister).map_err(|e| { @@ -1717,6 +1716,28 @@ impl ChangeDestinationSource for WalletKeysManager { } } +/// Convert LDK's `Input::satisfaction_weight` to the value BDK's +/// [`bdk_wallet::TxBuilder::add_foreign_utxo`] expects. +/// +/// LDK and BDK disagree on what `satisfaction_weight` includes for a SegWit input. LDK +/// treats it as the full weight of the spent input's `script_sig` and `witness` *each +/// with their lengths included* — i.e., the empty `script_sig` length byte (4 WU) and +/// the witness-elements-count varint (1 WU) are part of the value. BDK adds +/// `TxIn::default().segwit_weight()` internally, which already accounts for those same +/// 5 WU (an empty TxIn has a 1-byte empty `script_sig` length and a 1-byte empty +/// witness-count varint). Passing LDK's value directly to BDK therefore double-counts +/// 5 WU per foreign input, which inflates BDK's fee estimate and ultimately funnels the +/// surplus into the new funding output during splice negotiation. +fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { + const EMPTY_SCRIPT_SIG_WEIGHT: u64 = + 1 /* empty script_sig length byte */ * WITNESS_SCALE_FACTOR as u64; + const EMPTY_WITNESS_COUNT_WEIGHT: u64 = 1 /* witness elements count varint */; + Weight::from_wu( + ldk_satisfaction_weight + .saturating_sub(EMPTY_SCRIPT_SIG_WEIGHT + EMPTY_WITNESS_COUNT_WEIGHT), + ) +} + // FIXME/TODO: This is copied-over from bdk_wallet and only used to generate `WalletEvent`s after // applying mempool transactions. We should drop this when BDK offers to generate events for // mempool transactions natively. diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index d2c057a164..8f099bd693 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1067,7 +1067,9 @@ async fn splice_channel() { expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); - let expected_splice_in_fee_sat = 255; + let expected_splice_in_fee_sat = 251; + let expected_splice_in_onchain_cost_sat = 254; + let expected_splice_in_lightning_balance_sat = 4_000_003; let payments = node_b.list_payments(); let payment = @@ -1076,9 +1078,12 @@ async fn splice_channel() { assert_eq!( node_b.list_balances().total_onchain_balance_sats, - premine_amount_sat - 4_000_000 - expected_splice_in_fee_sat + premine_amount_sat - 4_000_000 - expected_splice_in_onchain_cost_sat + ); + assert_eq!( + node_b.list_balances().total_lightning_balance_sats, + expected_splice_in_lightning_balance_sat ); - assert_eq!(node_b.list_balances().total_lightning_balance_sats, 4_000_000); let payment_id = node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None).unwrap(); @@ -1093,7 +1098,10 @@ async fn splice_channel() { node_a.list_balances().total_lightning_balance_sats, 4_000_000 - closing_transaction_fee_sat - anchor_output_sat + amount_msat / 1000 ); - assert_eq!(node_b.list_balances().total_lightning_balance_sats, 4_000_000 - amount_msat / 1000); + assert_eq!( + node_b.list_balances().total_lightning_balance_sats, + expected_splice_in_lightning_balance_sat - amount_msat / 1000 + ); // Splice-out funds for Node A from the payment sent by Node B let address = node_a.onchain_payment().new_address().unwrap(); From 62e43fbb973367b971fb9379590d92e85fb29009 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 13 Apr 2026 12:36:05 -0500 Subject: [PATCH 3/4] Allow building 0.7.0 node with different store --- tests/common/mod.rs | 4 ++++ tests/integration_tests_rust.rs | 26 +++++++++++++------------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 00c8808a7b..5ae7a638d0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -416,6 +416,7 @@ pub(crate) enum TestChainSource<'a> { pub(crate) enum TestStoreType { TestSyncStore, Sqlite, + FilesystemStore, } impl Default for TestStoreType { @@ -592,6 +593,9 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> builder.build_with_store(config.node_entropy.into(), kv_store).unwrap() }, TestStoreType::Sqlite => builder.build(config.node_entropy.into()).unwrap(), + TestStoreType::FilesystemStore => { + builder.build_with_fs_store(config.node_entropy.into()).unwrap() + }, }; if config.recovery_mode { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 8f099bd693..071729d9fe 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -24,7 +24,7 @@ use common::{ generate_listening_addresses, open_channel, open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, - wait_for_tx, TestChainSource, TestStoreType, TestSyncStore, + wait_for_tx, TestChainSource, TestConfig, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::Node as BitcoinD; use electrsd::ElectrsD; @@ -2549,15 +2549,19 @@ async fn build_0_6_2_node( } async fn build_0_7_0_node( - bitcoind: &BitcoinD, electrsd: &ElectrsD, storage_path: String, esplora_url: String, - seed_bytes: [u8; 64], + bitcoind: &BitcoinD, electrsd: &ElectrsD, esplora_url: String, seed_bytes: [u8; 64], + config: &TestConfig, ) -> (u64, bitcoin::secp256k1::PublicKey) { let mut builder_old = ldk_node_070::Builder::new(); builder_old.set_network(bitcoin::Network::Regtest); - builder_old.set_storage_dir_path(storage_path); + builder_old.set_storage_dir_path(config.node_config.storage_dir_path.clone()); builder_old.set_entropy_seed_bytes(seed_bytes); builder_old.set_chain_source_esplora(esplora_url, None); - let node_old = builder_old.build().unwrap(); + let node_old = match config.store_type { + TestStoreType::FilesystemStore => builder_old.build_with_fs_store().unwrap(), + TestStoreType::Sqlite => builder_old.build().unwrap(), + TestStoreType::TestSyncStore => panic!("TestSyncStore not supported in v0.7.0 builder"), + }; node_old.start().unwrap(); let addr_old = node_old.onchain_payment().new_address().unwrap(); @@ -2598,14 +2602,10 @@ async fn do_persistence_backwards_compatibility(version: OldLdkVersion) { .await }, OldLdkVersion::V0_7_0 => { - build_0_7_0_node( - &bitcoind, - &electrsd, - storage_path.clone(), - esplora_url.clone(), - seed_bytes, - ) - .await + let mut config = TestConfig::default(); + config.store_type = TestStoreType::Sqlite; + config.node_config.storage_dir_path = storage_path.clone(); + build_0_7_0_node(&bitcoind, &electrsd, esplora_url.clone(), seed_bytes, &config).await }, }; From 7d303818bec28aff089edfe08faaa76b5a2eb84b Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 13 Apr 2026 12:36:29 -0500 Subject: [PATCH 4/4] Safely migrate to FileSystemStoreV2 Before moving to PaginatedKVStore everywhere we need to use FileSystemStoreV2 instead of FileSystemStoreV1. This will safely migrate over to it on first start up. Also adds a test to make sure we handle it properly. --- src/builder.rs | 21 +-- src/io/utils.rs | 267 ++++++++++++++++++++++++++++++-- tests/integration_tests_rust.rs | 43 +++++ 3 files changed, 312 insertions(+), 19 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 54a2f51abc..b719f40a73 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -43,7 +43,6 @@ use lightning::util::persist::{ use lightning::util::ser::ReadableArgs; use lightning::util::sweep::OutputSweeper; use lightning_dns_resolver::OMDomainResolver; -use lightning_persister::fs_store::v1::FilesystemStore; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; @@ -59,8 +58,9 @@ use crate::fee_estimator::OnchainFeeEstimator; use crate::gossip::GossipSource; use crate::io::sqlite_store::SqliteStore; use crate::io::utils::{ - read_all_objects, read_event_queue, read_external_pathfinding_scores_from_cache, - read_network_graph, read_node_metrics, read_output_sweeper, read_peer_info, read_scorer, + open_or_migrate_fs_store, read_all_objects, read_event_queue, + read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics, + read_output_sweeper, read_peer_info, read_scorer, }; use crate::io::vss_store::VssStoreBuilder; use crate::io::{ @@ -644,18 +644,19 @@ impl NodeBuilder { self.build_with_store_and_logger(node_entropy, kv_store, logger) } - /// Builds a [`Node`] instance with a [`FilesystemStore`] backend and according to the options + /// Builds a [`Node`] instance with a [`FilesystemStoreV2`] backend and according to the options /// previously configured. + /// + /// If the storage directory contains data from a v1 filesystem store, it will be + /// automatically migrated to the v2 format. + /// + /// [`FilesystemStoreV2`]: lightning_persister::fs_store::v2::FilesystemStoreV2 pub fn build_with_fs_store(&self, node_entropy: NodeEntropy) -> Result { let logger = setup_logger(&self.log_writer_config, &self.config)?; let mut storage_dir_path: PathBuf = self.config.storage_dir_path.clone().into(); storage_dir_path.push("fs_store"); - fs::create_dir_all(storage_dir_path.clone()).map_err(|e| { - log_error!(logger, "Failed to setup Filesystem store: {}", e); - BuildError::StoragePathAccessFailed - })?; - let kv_store = FilesystemStore::new(storage_dir_path); + let kv_store = open_or_migrate_fs_store(storage_dir_path)?; self.build_with_store_and_logger(node_entropy, kv_store, logger) } @@ -1115,7 +1116,7 @@ impl ArcedNodeBuilder { self.inner.read().expect("lock").build(*node_entropy).map(Arc::new) } - /// Builds a [`Node`] instance with a [`FilesystemStore`] backend and according to the options + /// Builds a [`Node`] instance with a [`FilesystemStoreV2`] backend and according to the options /// previously configured. pub fn build_with_fs_store( &self, node_entropy: Arc, diff --git a/src/io/utils.rs b/src/io/utils.rs index 5b51b88592..798ef4c531 100644 --- a/src/io/utils.rs +++ b/src/io/utils.rs @@ -10,7 +10,7 @@ use std::io::Write; use std::ops::Deref; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use bdk_chain::indexer::keychain_txout::ChangeSet as BdkIndexerChangeSet; @@ -26,14 +26,16 @@ use lightning::routing::scoring::{ ChannelLiquidities, ProbabilisticScorer, ProbabilisticScoringDecayParameters, }; use lightning::util::persist::{ - KVStore, KVStoreSync, KVSTORE_NAMESPACE_KEY_ALPHABET, KVSTORE_NAMESPACE_KEY_MAX_LEN, - NETWORK_GRAPH_PERSISTENCE_KEY, NETWORK_GRAPH_PERSISTENCE_PRIMARY_NAMESPACE, - NETWORK_GRAPH_PERSISTENCE_SECONDARY_NAMESPACE, OUTPUT_SWEEPER_PERSISTENCE_KEY, - OUTPUT_SWEEPER_PERSISTENCE_PRIMARY_NAMESPACE, OUTPUT_SWEEPER_PERSISTENCE_SECONDARY_NAMESPACE, - SCORER_PERSISTENCE_KEY, SCORER_PERSISTENCE_PRIMARY_NAMESPACE, - SCORER_PERSISTENCE_SECONDARY_NAMESPACE, + migrate_kv_store_data, KVStore, KVStoreSync, KVSTORE_NAMESPACE_KEY_ALPHABET, + KVSTORE_NAMESPACE_KEY_MAX_LEN, NETWORK_GRAPH_PERSISTENCE_KEY, + NETWORK_GRAPH_PERSISTENCE_PRIMARY_NAMESPACE, NETWORK_GRAPH_PERSISTENCE_SECONDARY_NAMESPACE, + OUTPUT_SWEEPER_PERSISTENCE_KEY, OUTPUT_SWEEPER_PERSISTENCE_PRIMARY_NAMESPACE, + OUTPUT_SWEEPER_PERSISTENCE_SECONDARY_NAMESPACE, SCORER_PERSISTENCE_KEY, + SCORER_PERSISTENCE_PRIMARY_NAMESPACE, SCORER_PERSISTENCE_SECONDARY_NAMESPACE, }; use lightning::util::ser::{Readable, ReadableArgs, Writeable}; +use lightning_persister::fs_store::v1::FilesystemStore; +use lightning_persister::fs_store::v2::{FilesystemStoreV2, FilesystemStoreV2Error}; use lightning_types::string::PrintableString; use super::*; @@ -47,7 +49,7 @@ use crate::logger::{log_error, LdkLogger, Logger}; use crate::peer_store::PeerStore; use crate::types::{Broadcaster, DynStore, KeysManager, Sweeper}; use crate::wallet::ser::{ChangeSetDeserWrapper, ChangeSetSerWrapper}; -use crate::{Error, EventQueue, NodeMetrics}; +use crate::{BuildError, Error, EventQueue, NodeMetrics}; pub const EXTERNAL_PATHFINDING_SCORES_CACHE_KEY: &str = "external_pathfinding_scores_cache"; @@ -619,10 +621,103 @@ pub(crate) fn read_bdk_wallet_change_set( Ok(Some(change_set)) } +/// Opens a [`FilesystemStoreV2`], automatically migrating from v1 format if necessary. +/// +/// If the directory contains v1 data (files at the top level), the data is migrated to v2 format +/// in a temporary directory, the original is renamed to `fs_store_v1_backup`, and the migrated +/// directory is moved into place. +pub(crate) fn open_or_migrate_fs_store( + storage_dir_path: PathBuf, +) -> Result { + let parent_dir = storage_dir_path.parent().ok_or(BuildError::StoragePathAccessFailed)?; + fs::create_dir_all(parent_dir).map_err(|_| BuildError::StoragePathAccessFailed)?; + recover_incomplete_fs_store_migration(&storage_dir_path)?; + if !storage_dir_path.exists() { + fs::create_dir_all(storage_dir_path.clone()) + .map_err(|_| BuildError::StoragePathAccessFailed)?; + } + + match FilesystemStoreV2::new(storage_dir_path.clone()) { + Ok(store) => Ok(store), + Err(FilesystemStoreV2Error::V1DataDetected(_)) => { + // The directory contains v1 data, migrate to v2. + let mut v1_store = FilesystemStore::new(storage_dir_path.clone()); + + let v2_dir = fs_store_sibling_path(&storage_dir_path, "fs_store_v2_migrating"); + fs::create_dir_all(v2_dir.clone()).map_err(|_| BuildError::StoragePathAccessFailed)?; + let mut v2_store = FilesystemStoreV2::new(v2_dir.clone()) + .map_err(|_| BuildError::KVStoreSetupFailed)?; + + migrate_kv_store_data(&mut v1_store, &mut v2_store) + .map_err(|_| BuildError::KVStoreSetupFailed)?; + + // Swap directories: rename v1 out of the way, move v2 into place. + let backup_dir = fs_store_sibling_path(&storage_dir_path, "fs_store_v1_backup"); + fs::rename(&storage_dir_path, &backup_dir) + .map_err(|_| BuildError::KVStoreSetupFailed)?; + fs::rename(&v2_dir, &storage_dir_path).map_err(|_| BuildError::KVStoreSetupFailed)?; + + FilesystemStoreV2::new(storage_dir_path).map_err(|_| BuildError::KVStoreSetupFailed) + }, + Err(_) => Err(BuildError::KVStoreSetupFailed), + } +} + +fn fs_store_sibling_path(storage_dir_path: &Path, file_name: &str) -> PathBuf { + let mut sibling_path = storage_dir_path.to_path_buf(); + sibling_path.set_file_name(file_name); + sibling_path +} + +fn recover_incomplete_fs_store_migration(storage_dir_path: &Path) -> Result<(), BuildError> { + let v2_dir = fs_store_sibling_path(storage_dir_path, "fs_store_v2_migrating"); + let backup_dir = fs_store_sibling_path(storage_dir_path, "fs_store_v1_backup"); + + if storage_dir_path.exists() { + if v2_dir.exists() { + // The original store is still in place, so a temp migration dir is from a crash before + // the rename step and can be discarded before retrying migration. + fs::remove_dir_all(&v2_dir).map_err(|_| BuildError::KVStoreSetupFailed)?; + } + return Ok(()); + } + + if backup_dir.exists() { + if v2_dir.exists() { + // Prefer retrying from the v1 backup instead of deciding here whether the temp v2 dir is + // usable. open_or_migrate_fs_store owns the actual v1-to-v2 migration. + fs::remove_dir_all(&v2_dir).map_err(|_| BuildError::KVStoreSetupFailed)?; + } + // The crash happened after moving v1 aside; restore it so normal startup can migrate it. + fs::rename(&backup_dir, storage_dir_path).map_err(|_| BuildError::KVStoreSetupFailed)?; + return Ok(()); + } + + if v2_dir.exists() { + // There is no v1 backup to retry from. Move the temp dir into place and let + // open_or_migrate_fs_store decide whether it is a valid v2 store. + fs::rename(&v2_dir, storage_dir_path).map_err(|_| BuildError::KVStoreSetupFailed)?; + } + + Ok(()) +} + #[cfg(test)] mod tests { - use super::read_or_generate_seed_file; + use std::fs; + use std::path::{Path, PathBuf}; + + use lightning::util::persist::{migrate_kv_store_data, KVStoreSync}; + use lightning_persister::fs_store::v1::FilesystemStore; + use lightning_persister::fs_store::v2::FilesystemStoreV2; + use super::test_utils::random_storage_path; + use super::{open_or_migrate_fs_store, read_or_generate_seed_file}; + + const TEST_PRIMARY_NAMESPACE: &str = "test_primary_namespace"; + const TEST_SECONDARY_NAMESPACE: &str = "test_secondary_namespace"; + const TEST_KEY: &str = "test_key"; + const TEST_VALUE: &[u8] = b"test_value"; #[test] fn generated_seed_is_readable() { @@ -632,4 +727,158 @@ mod tests { let read_seed_bytes = read_or_generate_seed_file(&rand_path.to_str().unwrap()).unwrap(); assert_eq!(expected_seed_bytes, read_seed_bytes); } + + #[test] + fn fs_store_migration_recovers_before_v1_backup_rename() { + let fs_store_path = fs_store_path(); + let mut v1_store = write_v1_test_data(&fs_store_path); + let v2_migrating_path = sibling_path(&fs_store_path, "fs_store_v2_migrating"); + let mut v2_store = FilesystemStoreV2::new(v2_migrating_path.clone()).unwrap(); + migrate_kv_store_data(&mut v1_store, &mut v2_store).unwrap(); + + let migrated_store = open_or_migrate_fs_store(fs_store_path.clone()).unwrap(); + assert_eq!( + KVStoreSync::read( + &migrated_store, + TEST_PRIMARY_NAMESPACE, + TEST_SECONDARY_NAMESPACE, + TEST_KEY + ) + .unwrap(), + TEST_VALUE + ); + assert!(fs_store_path.exists()); + assert!(!v2_migrating_path.exists()); + } + + #[test] + fn fs_store_migration_recovers_after_v1_backup_rename() { + let fs_store_path = fs_store_path(); + let mut v1_store = write_v1_test_data(&fs_store_path); + let v2_migrating_path = sibling_path(&fs_store_path, "fs_store_v2_migrating"); + let mut v2_store = FilesystemStoreV2::new(v2_migrating_path.clone()).unwrap(); + migrate_kv_store_data(&mut v1_store, &mut v2_store).unwrap(); + + let backup_path = sibling_path(&fs_store_path, "fs_store_v1_backup"); + fs::rename(&fs_store_path, backup_path).unwrap(); + + let migrated_store = open_or_migrate_fs_store(fs_store_path.clone()).unwrap(); + assert_eq!( + KVStoreSync::read( + &migrated_store, + TEST_PRIMARY_NAMESPACE, + TEST_SECONDARY_NAMESPACE, + TEST_KEY + ) + .unwrap(), + TEST_VALUE + ); + assert!(fs_store_path.exists()); + assert!(!v2_migrating_path.exists()); + } + + #[test] + fn fs_store_migration_recovers_after_v2_rename() { + let fs_store_path = fs_store_path(); + let mut v1_store = write_v1_test_data(&fs_store_path); + let v2_migrating_path = sibling_path(&fs_store_path, "fs_store_v2_migrating"); + let mut v2_store = FilesystemStoreV2::new(v2_migrating_path.clone()).unwrap(); + migrate_kv_store_data(&mut v1_store, &mut v2_store).unwrap(); + + let backup_path = sibling_path(&fs_store_path, "fs_store_v1_backup"); + fs::rename(&fs_store_path, &backup_path).unwrap(); + fs::rename(&v2_migrating_path, &fs_store_path).unwrap(); + + let migrated_store = open_or_migrate_fs_store(fs_store_path.clone()).unwrap(); + assert_eq!( + KVStoreSync::read( + &migrated_store, + TEST_PRIMARY_NAMESPACE, + TEST_SECONDARY_NAMESPACE, + TEST_KEY + ) + .unwrap(), + TEST_VALUE + ); + assert!(fs_store_path.exists()); + assert!(backup_path.exists()); + assert!(!v2_migrating_path.exists()); + } + + #[test] + fn fs_store_migration_recovers_backup_without_migrating_dir() { + let fs_store_path = fs_store_path(); + write_v1_test_data(&fs_store_path); + + let backup_path = sibling_path(&fs_store_path, "fs_store_v1_backup"); + fs::rename(&fs_store_path, backup_path).unwrap(); + + let migrated_store = open_or_migrate_fs_store(fs_store_path.clone()).unwrap(); + assert_eq!( + KVStoreSync::read( + &migrated_store, + TEST_PRIMARY_NAMESPACE, + TEST_SECONDARY_NAMESPACE, + TEST_KEY + ) + .unwrap(), + TEST_VALUE + ); + assert!(fs_store_path.exists()); + assert!(!sibling_path(&fs_store_path, "fs_store_v1_backup").exists()); + } + + #[test] + fn fs_store_migration_recovers_unexpected_migrating_dir_without_backup() { + let fs_store_path = fs_store_path(); + let v2_migrating_path = sibling_path(&fs_store_path, "fs_store_v2_migrating"); + let v2_store = FilesystemStoreV2::new(v2_migrating_path.clone()).unwrap(); + KVStoreSync::write( + &v2_store, + TEST_PRIMARY_NAMESPACE, + TEST_SECONDARY_NAMESPACE, + TEST_KEY, + TEST_VALUE.to_vec(), + ) + .unwrap(); + + let migrated_store = open_or_migrate_fs_store(fs_store_path.clone()).unwrap(); + assert_eq!( + KVStoreSync::read( + &migrated_store, + TEST_PRIMARY_NAMESPACE, + TEST_SECONDARY_NAMESPACE, + TEST_KEY + ) + .unwrap(), + TEST_VALUE + ); + assert!(fs_store_path.exists()); + assert!(!v2_migrating_path.exists()); + } + + fn fs_store_path() -> PathBuf { + let mut fs_store_path = random_storage_path(); + fs_store_path.push("fs_store"); + fs_store_path + } + + fn sibling_path(path: &Path, file_name: &str) -> PathBuf { + let mut sibling_path = path.to_path_buf(); + sibling_path.set_file_name(file_name); + sibling_path + } + + fn write_v1_test_data(fs_store_path: &Path) -> FilesystemStore { + let v1_store = FilesystemStore::new(fs_store_path.to_path_buf()); + KVStoreSync::write( + &v1_store, + TEST_PRIMARY_NAMESPACE, + TEST_SECONDARY_NAMESPACE, + TEST_KEY, + TEST_VALUE.to_vec(), + ) + .unwrap(); + v1_store + } } diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 071729d9fe..6f845bcf34 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2642,6 +2642,49 @@ async fn persistence_backwards_compatibility() { do_persistence_backwards_compatibility(OldLdkVersion::V0_7_0).await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn fs_store_persistence_backwards_compatibility() { + let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let storage_path = common::random_storage_path().to_str().unwrap().to_owned(); + let seed_bytes = [42u8; 64]; + + // Build a node using v0.7.0's build_with_fs_store (FilesystemStore v1). + let mut config = TestConfig::default(); + config.node_config.storage_dir_path = storage_path.clone(); + config.store_type = TestStoreType::FilesystemStore; + let (old_balance, old_node_id) = + build_0_7_0_node(&bitcoind, &electrsd, esplora_url.clone(), seed_bytes, &config).await; + + // Now reopen with current code's build_with_fs_store, which should + // auto-migrate from FilesystemStore v1 to FilesystemStoreV2. + #[cfg(feature = "uniffi")] + let builder_new = Builder::new(); + #[cfg(not(feature = "uniffi"))] + let mut builder_new = Builder::new(); + builder_new.set_network(bitcoin::Network::Regtest); + builder_new.set_storage_dir_path(storage_path); + builder_new.set_chain_source_esplora(esplora_url, None); + + #[cfg(feature = "uniffi")] + let node_entropy = NodeEntropy::from_seed_bytes(seed_bytes.to_vec()).unwrap(); + #[cfg(not(feature = "uniffi"))] + let node_entropy = NodeEntropy::from_seed_bytes(seed_bytes); + let node_new = builder_new.build_with_fs_store(node_entropy.into()).unwrap(); + + node_new.start().unwrap(); + node_new.sync_wallets().unwrap(); + + let new_balance = node_new.list_balances().spendable_onchain_balance_sats; + let new_node_id = node_new.node_id(); + + assert_eq!(old_node_id, new_node_id); + assert_eq!(old_balance, new_balance); + + node_new.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn onchain_fee_bump_rbf() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();