Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
# 0.7.0-rc.31 (Synonym Fork)
# 0.7.0-rc.32 (Synonym Fork)

## Bug Fixes

- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
each time it's called; repeated fee estimations would burn through change addresses without
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
to unmark the change address for reuse.
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
`FeerateEstimationUpdateTimeout` on node start.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]

[package]
name = "ldk-node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = ["Elias Rohrer <dev@tnull.de>"]
homepage = "https://lightningdevkit.org/"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@

import PackageDescription

let tag = "v0.7.0-rc.31"
let checksum = "aa189d355dc048664652832747f9236eddd97cdf78eb1659d7def06eae72b9b4"
let tag = "v0.7.0-rc.32"
let checksum = "5a9ac761833dd2aad0a88da7da8887923fc6ac8b33a14035b95c7d3a9d0d2b3c"
let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip"

let package = Package(
Expand Down
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-android/gradle.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-jvm/gradle.properties
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "ldk_node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = [
{ name="Elias Rohrer", email="dev@tnull.de" },
]
Expand Down
840 changes: 410 additions & 430 deletions bindings/swift/Sources/LDKNode/LDKNode.swift

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions crates/bdk-wallet-aggregate/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,19 @@ where
Ok(())
}

/// Cancel a dry-run transaction on the primary wallet without persisting.
///
/// Unmarks change addresses that were marked "used" by `finish()`, so
/// the next `finish()` reuses them instead of revealing new ones. The
/// reveal itself is left in the staged changeset and persists harmlessly.
///
/// Only targets the primary wallet. All current build paths use the primary.
pub fn cancel_dry_run_tx(&mut self, tx: &Transaction) {
let primary =
self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist");
primary.cancel_tx(tx);
}

// ─── Fee Calculation ────────────────────────────────────────────────

/// Calculate the fee of a PSBT by summing input values and subtracting
Expand DownExpand Up@@ -1346,3 +1359,277 @@ where
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
}
}

#[cfg(test)]
mod tests {
use bdk_wallet::template::Bip84;
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
use bitcoin::bip32::Xpriv;
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};

use super::*;

struct NoopPersister;

impl WalletPersister for NoopPersister {
type Error = std::convert::Infallible;

fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
Ok(ChangeSet::default())
}

fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
Ok(())
}
}

fn test_xprv() -> Xpriv {
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
}

fn create_funded_wallet(
persister: &mut NoopPersister, amount: Amount,
) -> PersistedWallet<NoopPersister> {
let xprv = test_xprv();
let mut wallet = PersistedWallet::create(
persister,
Wallet::create(
Bip84(xprv, KeychainKind::External),
Bip84(xprv, KeychainKind::Internal),
)
.network(Network::Regtest),
)
.unwrap();

let addr = wallet.reveal_next_address(KeychainKind::External);
let funding_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
..Default::default()
}],
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
};
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
wallet
}

fn recipient_script() -> ScriptBuf {
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
}

/// Demonstrates the bug: without cancel_tx, each finish() call
/// cumulatively advances the internal (change) derivation index.
#[test]
fn test_finish_without_cancel_leaks_change_index() {
let mut persister = NoopPersister;
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = wallet.derivation_index(KeychainKind::Internal);

// First dry-run finish(), no cancel
let mut b1 = wallet.build_tx();
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt1 = b1.finish().unwrap();
let after_first = wallet.derivation_index(KeychainKind::Internal);

// Second dry-run finish(), no cancel
let mut b2 = wallet.build_tx();
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt2 = b2.finish().unwrap();
let after_second = wallet.derivation_index(KeychainKind::Internal);

// Third dry-run finish(), no cancel
let mut b3 = wallet.build_tx();
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt3 = b3.finish().unwrap();
let after_third = wallet.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should advance the internal index"
);
assert!(
after_second > after_first,
"Second finish() without cancel should advance further: {:?} > {:?}",
after_second,
after_first,
);
assert!(
after_third > after_second,
"Third finish() without cancel should advance further: {:?} > {:?}",
after_third,
after_second,
);
}

/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
/// so subsequent finish() calls reuse the same index.
#[test]
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Dry-run 1: finish + cancel
let psbt1 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
let after_first = agg.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should reveal an internal address"
);

// Dry-run 2: finish + cancel
let psbt2 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
let after_second = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
after_first, after_second,
"cancel_dry_run_tx should prevent cumulative index advance"
);

// Dry-runs 3-5: confirm stability
for i in 3..=5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
let idx = agg.derivation_index(KeychainKind::Internal);
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
}
}

/// Verifies that cancel_dry_run_tx prevents index leak even when an
/// intermediate operation fails between finish() and the cancel.
#[test]
fn test_cancel_after_failed_intermediate_prevents_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Baseline
let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Simulate finish() + failed intermediate + unconditional cancel.
for _ in 0..5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};

// Simulate an intermediate operation that fails.
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");

// Cancel regardless of failure.
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
}

let final_idx = agg.derivation_index(KeychainKind::Internal);

assert!(
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
"Index should be revealed at least once"
);

// One more dry-run + cancel should not change it.
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
assert_eq!(
final_idx,
agg.derivation_index(KeychainKind::Internal),
"Sixth dry-run should still reuse the same index"
);
}

/// After a cancelled dry-run, the next real finish() reuses the same
/// change address. Verifies the fix doesn't break real transactions.
#[test]
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Dry-run + cancel
let dry_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);

let idx_after_dry = agg.derivation_index(KeychainKind::Internal);

// Real tx (no cancel — this would be signed and broadcast)
let real_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let real_change_script: Option<ScriptBuf> = real_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());

let idx_after_real = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
idx_after_dry, idx_after_real,
"Real tx should not advance index beyond what dry-run already revealed"
);
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
{
assert_eq!(
dry_change, real_change,
"Real tx should reuse the same change address as the cancelled dry-run"
);
}
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
# 0.7.0-rc.31 (Synonym Fork)
# 0.7.0-rc.32 (Synonym Fork)

## Bug Fixes

- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
each time it's called; repeated fee estimations would burn through change addresses without
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
to unmark the change address for reuse.
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
`FeerateEstimationUpdateTimeout` on node start.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]

[package]
name = "ldk-node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = ["Elias Rohrer <dev@tnull.de>"]
homepage = "https://lightningdevkit.org/"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@

import PackageDescription

let tag = "v0.7.0-rc.31"
let checksum = "aa189d355dc048664652832747f9236eddd97cdf78eb1659d7def06eae72b9b4"
let tag = "v0.7.0-rc.32"
let checksum = "5a9ac761833dd2aad0a88da7da8887923fc6ac8b33a14035b95c7d3a9d0d2b3c"
let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip"

let package = Package(
Expand Down
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-android/gradle.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-jvm/gradle.properties
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "ldk_node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = [
{ name="Elias Rohrer", email="dev@tnull.de" },
]
Expand Down
840 changes: 410 additions & 430 deletions bindings/swift/Sources/LDKNode/LDKNode.swift

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions crates/bdk-wallet-aggregate/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,19 @@ where
Ok(())
}

/// Cancel a dry-run transaction on the primary wallet without persisting.
///
/// Unmarks change addresses that were marked "used" by `finish()`, so
/// the next `finish()` reuses them instead of revealing new ones. The
/// reveal itself is left in the staged changeset and persists harmlessly.
///
/// Only targets the primary wallet. All current build paths use the primary.
pub fn cancel_dry_run_tx(&mut self, tx: &Transaction) {
let primary =
self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist");
primary.cancel_tx(tx);
}

// ─── Fee Calculation ────────────────────────────────────────────────

/// Calculate the fee of a PSBT by summing input values and subtracting
Expand DownExpand Up@@ -1346,3 +1359,277 @@ where
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
}
}

#[cfg(test)]
mod tests {
use bdk_wallet::template::Bip84;
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
use bitcoin::bip32::Xpriv;
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};

use super::*;

struct NoopPersister;

impl WalletPersister for NoopPersister {
type Error = std::convert::Infallible;

fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
Ok(ChangeSet::default())
}

fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
Ok(())
}
}

fn test_xprv() -> Xpriv {
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
}

fn create_funded_wallet(
persister: &mut NoopPersister, amount: Amount,
) -> PersistedWallet<NoopPersister> {
let xprv = test_xprv();
let mut wallet = PersistedWallet::create(
persister,
Wallet::create(
Bip84(xprv, KeychainKind::External),
Bip84(xprv, KeychainKind::Internal),
)
.network(Network::Regtest),
)
.unwrap();

let addr = wallet.reveal_next_address(KeychainKind::External);
let funding_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
..Default::default()
}],
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
};
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
wallet
}

fn recipient_script() -> ScriptBuf {
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
}

/// Demonstrates the bug: without cancel_tx, each finish() call
/// cumulatively advances the internal (change) derivation index.
#[test]
fn test_finish_without_cancel_leaks_change_index() {
let mut persister = NoopPersister;
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = wallet.derivation_index(KeychainKind::Internal);

// First dry-run finish(), no cancel
let mut b1 = wallet.build_tx();
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt1 = b1.finish().unwrap();
let after_first = wallet.derivation_index(KeychainKind::Internal);

// Second dry-run finish(), no cancel
let mut b2 = wallet.build_tx();
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt2 = b2.finish().unwrap();
let after_second = wallet.derivation_index(KeychainKind::Internal);

// Third dry-run finish(), no cancel
let mut b3 = wallet.build_tx();
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt3 = b3.finish().unwrap();
let after_third = wallet.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should advance the internal index"
);
assert!(
after_second > after_first,
"Second finish() without cancel should advance further: {:?} > {:?}",
after_second,
after_first,
);
assert!(
after_third > after_second,
"Third finish() without cancel should advance further: {:?} > {:?}",
after_third,
after_second,
);
}

/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
/// so subsequent finish() calls reuse the same index.
#[test]
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Dry-run 1: finish + cancel
let psbt1 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
let after_first = agg.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should reveal an internal address"
);

// Dry-run 2: finish + cancel
let psbt2 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
let after_second = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
after_first, after_second,
"cancel_dry_run_tx should prevent cumulative index advance"
);

// Dry-runs 3-5: confirm stability
for i in 3..=5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
let idx = agg.derivation_index(KeychainKind::Internal);
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
}
}

/// Verifies that cancel_dry_run_tx prevents index leak even when an
/// intermediate operation fails between finish() and the cancel.
#[test]
fn test_cancel_after_failed_intermediate_prevents_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Baseline
let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Simulate finish() + failed intermediate + unconditional cancel.
for _ in 0..5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};

// Simulate an intermediate operation that fails.
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");

// Cancel regardless of failure.
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
}

let final_idx = agg.derivation_index(KeychainKind::Internal);

assert!(
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
"Index should be revealed at least once"
);

// One more dry-run + cancel should not change it.
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
assert_eq!(
final_idx,
agg.derivation_index(KeychainKind::Internal),
"Sixth dry-run should still reuse the same index"
);
}

/// After a cancelled dry-run, the next real finish() reuses the same
/// change address. Verifies the fix doesn't break real transactions.
#[test]
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Dry-run + cancel
let dry_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);

let idx_after_dry = agg.derivation_index(KeychainKind::Internal);

// Real tx (no cancel — this would be signed and broadcast)
let real_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let real_change_script: Option<ScriptBuf> = real_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());

let idx_after_real = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
idx_after_dry, idx_after_real,
"Real tx should not advance index beyond what dry-run already revealed"
);
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
{
assert_eq!(
dry_change, real_change,
"Real tx should reuse the same change address as the cancelled dry-run"
);
}
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
# 0.7.0-rc.31 (Synonym Fork)
# 0.7.0-rc.32 (Synonym Fork)

## Bug Fixes

- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
each time it's called; repeated fee estimations would burn through change addresses without
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
to unmark the change address for reuse.
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
`FeerateEstimationUpdateTimeout` on node start.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]

[package]
name = "ldk-node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = ["Elias Rohrer <dev@tnull.de>"]
homepage = "https://lightningdevkit.org/"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@

import PackageDescription

let tag = "v0.7.0-rc.31"
let checksum = "aa189d355dc048664652832747f9236eddd97cdf78eb1659d7def06eae72b9b4"
let tag = "v0.7.0-rc.32"
let checksum = "5a9ac761833dd2aad0a88da7da8887923fc6ac8b33a14035b95c7d3a9d0d2b3c"
let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip"

let package = Package(
Expand Down
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-android/gradle.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-jvm/gradle.properties
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "ldk_node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = [
{ name="Elias Rohrer", email="dev@tnull.de" },
]
Expand Down
840 changes: 410 additions & 430 deletions bindings/swift/Sources/LDKNode/LDKNode.swift

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions crates/bdk-wallet-aggregate/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,19 @@ where
Ok(())
}

/// Cancel a dry-run transaction on the primary wallet without persisting.
///
/// Unmarks change addresses that were marked "used" by `finish()`, so
/// the next `finish()` reuses them instead of revealing new ones. The
/// reveal itself is left in the staged changeset and persists harmlessly.
///
/// Only targets the primary wallet. All current build paths use the primary.
pub fn cancel_dry_run_tx(&mut self, tx: &Transaction) {
let primary =
self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist");
primary.cancel_tx(tx);
}

// ─── Fee Calculation ────────────────────────────────────────────────

/// Calculate the fee of a PSBT by summing input values and subtracting
Expand DownExpand Up@@ -1346,3 +1359,277 @@ where
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
}
}

#[cfg(test)]
mod tests {
use bdk_wallet::template::Bip84;
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
use bitcoin::bip32::Xpriv;
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};

use super::*;

struct NoopPersister;

impl WalletPersister for NoopPersister {
type Error = std::convert::Infallible;

fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
Ok(ChangeSet::default())
}

fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
Ok(())
}
}

fn test_xprv() -> Xpriv {
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
}

fn create_funded_wallet(
persister: &mut NoopPersister, amount: Amount,
) -> PersistedWallet<NoopPersister> {
let xprv = test_xprv();
let mut wallet = PersistedWallet::create(
persister,
Wallet::create(
Bip84(xprv, KeychainKind::External),
Bip84(xprv, KeychainKind::Internal),
)
.network(Network::Regtest),
)
.unwrap();

let addr = wallet.reveal_next_address(KeychainKind::External);
let funding_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
..Default::default()
}],
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
};
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
wallet
}

fn recipient_script() -> ScriptBuf {
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
}

/// Demonstrates the bug: without cancel_tx, each finish() call
/// cumulatively advances the internal (change) derivation index.
#[test]
fn test_finish_without_cancel_leaks_change_index() {
let mut persister = NoopPersister;
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = wallet.derivation_index(KeychainKind::Internal);

// First dry-run finish(), no cancel
let mut b1 = wallet.build_tx();
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt1 = b1.finish().unwrap();
let after_first = wallet.derivation_index(KeychainKind::Internal);

// Second dry-run finish(), no cancel
let mut b2 = wallet.build_tx();
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt2 = b2.finish().unwrap();
let after_second = wallet.derivation_index(KeychainKind::Internal);

// Third dry-run finish(), no cancel
let mut b3 = wallet.build_tx();
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt3 = b3.finish().unwrap();
let after_third = wallet.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should advance the internal index"
);
assert!(
after_second > after_first,
"Second finish() without cancel should advance further: {:?} > {:?}",
after_second,
after_first,
);
assert!(
after_third > after_second,
"Third finish() without cancel should advance further: {:?} > {:?}",
after_third,
after_second,
);
}

/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
/// so subsequent finish() calls reuse the same index.
#[test]
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Dry-run 1: finish + cancel
let psbt1 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
let after_first = agg.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should reveal an internal address"
);

// Dry-run 2: finish + cancel
let psbt2 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
let after_second = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
after_first, after_second,
"cancel_dry_run_tx should prevent cumulative index advance"
);

// Dry-runs 3-5: confirm stability
for i in 3..=5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
let idx = agg.derivation_index(KeychainKind::Internal);
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
}
}

/// Verifies that cancel_dry_run_tx prevents index leak even when an
/// intermediate operation fails between finish() and the cancel.
#[test]
fn test_cancel_after_failed_intermediate_prevents_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Baseline
let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Simulate finish() + failed intermediate + unconditional cancel.
for _ in 0..5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};

// Simulate an intermediate operation that fails.
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");

// Cancel regardless of failure.
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
}

let final_idx = agg.derivation_index(KeychainKind::Internal);

assert!(
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
"Index should be revealed at least once"
);

// One more dry-run + cancel should not change it.
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
assert_eq!(
final_idx,
agg.derivation_index(KeychainKind::Internal),
"Sixth dry-run should still reuse the same index"
);
}

/// After a cancelled dry-run, the next real finish() reuses the same
/// change address. Verifies the fix doesn't break real transactions.
#[test]
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Dry-run + cancel
let dry_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);

let idx_after_dry = agg.derivation_index(KeychainKind::Internal);

// Real tx (no cancel — this would be signed and broadcast)
let real_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let real_change_script: Option<ScriptBuf> = real_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());

let idx_after_real = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
idx_after_dry, idx_after_real,
"Real tx should not advance index beyond what dry-run already revealed"
);
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
{
assert_eq!(
dry_change, real_change,
"Real tx should reuse the same change address as the cancelled dry-run"
);
}
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
# 0.7.0-rc.31 (Synonym Fork)
# 0.7.0-rc.32 (Synonym Fork)

## Bug Fixes

- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
each time it's called; repeated fee estimations would burn through change addresses without
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
to unmark the change address for reuse.
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
`FeerateEstimationUpdateTimeout` on node start.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]

[package]
name = "ldk-node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = ["Elias Rohrer <dev@tnull.de>"]
homepage = "https://lightningdevkit.org/"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@

import PackageDescription

let tag = "v0.7.0-rc.31"
let checksum = "aa189d355dc048664652832747f9236eddd97cdf78eb1659d7def06eae72b9b4"
let tag = "v0.7.0-rc.32"
let checksum = "5a9ac761833dd2aad0a88da7da8887923fc6ac8b33a14035b95c7d3a9d0d2b3c"
let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip"

let package = Package(
Expand Down
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-android/gradle.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-jvm/gradle.properties
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "ldk_node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = [
{ name="Elias Rohrer", email="dev@tnull.de" },
]
Expand Down
840 changes: 410 additions & 430 deletions bindings/swift/Sources/LDKNode/LDKNode.swift

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions crates/bdk-wallet-aggregate/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,19 @@ where
Ok(())
}

/// Cancel a dry-run transaction on the primary wallet without persisting.
///
/// Unmarks change addresses that were marked "used" by `finish()`, so
/// the next `finish()` reuses them instead of revealing new ones. The
/// reveal itself is left in the staged changeset and persists harmlessly.
///
/// Only targets the primary wallet. All current build paths use the primary.
pub fn cancel_dry_run_tx(&mut self, tx: &Transaction) {
let primary =
self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist");
primary.cancel_tx(tx);
}

// ─── Fee Calculation ────────────────────────────────────────────────

/// Calculate the fee of a PSBT by summing input values and subtracting
Expand DownExpand Up@@ -1346,3 +1359,277 @@ where
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
}
}

#[cfg(test)]
mod tests {
use bdk_wallet::template::Bip84;
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
use bitcoin::bip32::Xpriv;
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};

use super::*;

struct NoopPersister;

impl WalletPersister for NoopPersister {
type Error = std::convert::Infallible;

fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
Ok(ChangeSet::default())
}

fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
Ok(())
}
}

fn test_xprv() -> Xpriv {
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
}

fn create_funded_wallet(
persister: &mut NoopPersister, amount: Amount,
) -> PersistedWallet<NoopPersister> {
let xprv = test_xprv();
let mut wallet = PersistedWallet::create(
persister,
Wallet::create(
Bip84(xprv, KeychainKind::External),
Bip84(xprv, KeychainKind::Internal),
)
.network(Network::Regtest),
)
.unwrap();

let addr = wallet.reveal_next_address(KeychainKind::External);
let funding_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
..Default::default()
}],
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
};
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
wallet
}

fn recipient_script() -> ScriptBuf {
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
}

/// Demonstrates the bug: without cancel_tx, each finish() call
/// cumulatively advances the internal (change) derivation index.
#[test]
fn test_finish_without_cancel_leaks_change_index() {
let mut persister = NoopPersister;
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = wallet.derivation_index(KeychainKind::Internal);

// First dry-run finish(), no cancel
let mut b1 = wallet.build_tx();
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt1 = b1.finish().unwrap();
let after_first = wallet.derivation_index(KeychainKind::Internal);

// Second dry-run finish(), no cancel
let mut b2 = wallet.build_tx();
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt2 = b2.finish().unwrap();
let after_second = wallet.derivation_index(KeychainKind::Internal);

// Third dry-run finish(), no cancel
let mut b3 = wallet.build_tx();
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt3 = b3.finish().unwrap();
let after_third = wallet.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should advance the internal index"
);
assert!(
after_second > after_first,
"Second finish() without cancel should advance further: {:?} > {:?}",
after_second,
after_first,
);
assert!(
after_third > after_second,
"Third finish() without cancel should advance further: {:?} > {:?}",
after_third,
after_second,
);
}

/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
/// so subsequent finish() calls reuse the same index.
#[test]
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Dry-run 1: finish + cancel
let psbt1 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
let after_first = agg.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should reveal an internal address"
);

// Dry-run 2: finish + cancel
let psbt2 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
let after_second = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
after_first, after_second,
"cancel_dry_run_tx should prevent cumulative index advance"
);

// Dry-runs 3-5: confirm stability
for i in 3..=5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
let idx = agg.derivation_index(KeychainKind::Internal);
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
}
}

/// Verifies that cancel_dry_run_tx prevents index leak even when an
/// intermediate operation fails between finish() and the cancel.
#[test]
fn test_cancel_after_failed_intermediate_prevents_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Baseline
let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Simulate finish() + failed intermediate + unconditional cancel.
for _ in 0..5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};

// Simulate an intermediate operation that fails.
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");

// Cancel regardless of failure.
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
}

let final_idx = agg.derivation_index(KeychainKind::Internal);

assert!(
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
"Index should be revealed at least once"
);

// One more dry-run + cancel should not change it.
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
assert_eq!(
final_idx,
agg.derivation_index(KeychainKind::Internal),
"Sixth dry-run should still reuse the same index"
);
}

/// After a cancelled dry-run, the next real finish() reuses the same
/// change address. Verifies the fix doesn't break real transactions.
#[test]
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Dry-run + cancel
let dry_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);

let idx_after_dry = agg.derivation_index(KeychainKind::Internal);

// Real tx (no cancel — this would be signed and broadcast)
let real_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let real_change_script: Option<ScriptBuf> = real_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());

let idx_after_real = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
idx_after_dry, idx_after_real,
"Real tx should not advance index beyond what dry-run already revealed"
);
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
{
assert_eq!(
dry_change, real_change,
"Real tx should reuse the same change address as the cancelled dry-run"
);
}
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
# 0.7.0-rc.31 (Synonym Fork)
# 0.7.0-rc.32 (Synonym Fork)

## Bug Fixes

- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
each time it's called; repeated fee estimations would burn through change addresses without
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
to unmark the change address for reuse.
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
`FeerateEstimationUpdateTimeout` on node start.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]

[package]
name = "ldk-node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = ["Elias Rohrer <dev@tnull.de>"]
homepage = "https://lightningdevkit.org/"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@

import PackageDescription

let tag = "v0.7.0-rc.31"
let checksum = "aa189d355dc048664652832747f9236eddd97cdf78eb1659d7def06eae72b9b4"
let tag = "v0.7.0-rc.32"
let checksum = "5a9ac761833dd2aad0a88da7da8887923fc6ac8b33a14035b95c7d3a9d0d2b3c"
let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip"

let package = Package(
Expand Down
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-android/gradle.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-jvm/gradle.properties
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "ldk_node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = [
{ name="Elias Rohrer", email="dev@tnull.de" },
]
Expand Down
840 changes: 410 additions & 430 deletions bindings/swift/Sources/LDKNode/LDKNode.swift

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions crates/bdk-wallet-aggregate/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,19 @@ where
Ok(())
}

/// Cancel a dry-run transaction on the primary wallet without persisting.
///
/// Unmarks change addresses that were marked "used" by `finish()`, so
/// the next `finish()` reuses them instead of revealing new ones. The
/// reveal itself is left in the staged changeset and persists harmlessly.
///
/// Only targets the primary wallet. All current build paths use the primary.
pub fn cancel_dry_run_tx(&mut self, tx: &Transaction) {
let primary =
self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist");
primary.cancel_tx(tx);
}

// ─── Fee Calculation ────────────────────────────────────────────────

/// Calculate the fee of a PSBT by summing input values and subtracting
Expand DownExpand Up@@ -1346,3 +1359,277 @@ where
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
}
}

#[cfg(test)]
mod tests {
use bdk_wallet::template::Bip84;
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
use bitcoin::bip32::Xpriv;
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};

use super::*;

struct NoopPersister;

impl WalletPersister for NoopPersister {
type Error = std::convert::Infallible;

fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
Ok(ChangeSet::default())
}

fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
Ok(())
}
}

fn test_xprv() -> Xpriv {
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
}

fn create_funded_wallet(
persister: &mut NoopPersister, amount: Amount,
) -> PersistedWallet<NoopPersister> {
let xprv = test_xprv();
let mut wallet = PersistedWallet::create(
persister,
Wallet::create(
Bip84(xprv, KeychainKind::External),
Bip84(xprv, KeychainKind::Internal),
)
.network(Network::Regtest),
)
.unwrap();

let addr = wallet.reveal_next_address(KeychainKind::External);
let funding_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
..Default::default()
}],
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
};
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
wallet
}

fn recipient_script() -> ScriptBuf {
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
}

/// Demonstrates the bug: without cancel_tx, each finish() call
/// cumulatively advances the internal (change) derivation index.
#[test]
fn test_finish_without_cancel_leaks_change_index() {
let mut persister = NoopPersister;
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = wallet.derivation_index(KeychainKind::Internal);

// First dry-run finish(), no cancel
let mut b1 = wallet.build_tx();
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt1 = b1.finish().unwrap();
let after_first = wallet.derivation_index(KeychainKind::Internal);

// Second dry-run finish(), no cancel
let mut b2 = wallet.build_tx();
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt2 = b2.finish().unwrap();
let after_second = wallet.derivation_index(KeychainKind::Internal);

// Third dry-run finish(), no cancel
let mut b3 = wallet.build_tx();
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt3 = b3.finish().unwrap();
let after_third = wallet.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should advance the internal index"
);
assert!(
after_second > after_first,
"Second finish() without cancel should advance further: {:?} > {:?}",
after_second,
after_first,
);
assert!(
after_third > after_second,
"Third finish() without cancel should advance further: {:?} > {:?}",
after_third,
after_second,
);
}

/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
/// so subsequent finish() calls reuse the same index.
#[test]
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Dry-run 1: finish + cancel
let psbt1 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
let after_first = agg.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should reveal an internal address"
);

// Dry-run 2: finish + cancel
let psbt2 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
let after_second = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
after_first, after_second,
"cancel_dry_run_tx should prevent cumulative index advance"
);

// Dry-runs 3-5: confirm stability
for i in 3..=5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
let idx = agg.derivation_index(KeychainKind::Internal);
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
}
}

/// Verifies that cancel_dry_run_tx prevents index leak even when an
/// intermediate operation fails between finish() and the cancel.
#[test]
fn test_cancel_after_failed_intermediate_prevents_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Baseline
let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Simulate finish() + failed intermediate + unconditional cancel.
for _ in 0..5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};

// Simulate an intermediate operation that fails.
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");

// Cancel regardless of failure.
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
}

let final_idx = agg.derivation_index(KeychainKind::Internal);

assert!(
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
"Index should be revealed at least once"
);

// One more dry-run + cancel should not change it.
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
assert_eq!(
final_idx,
agg.derivation_index(KeychainKind::Internal),
"Sixth dry-run should still reuse the same index"
);
}

/// After a cancelled dry-run, the next real finish() reuses the same
/// change address. Verifies the fix doesn't break real transactions.
#[test]
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Dry-run + cancel
let dry_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);

let idx_after_dry = agg.derivation_index(KeychainKind::Internal);

// Real tx (no cancel — this would be signed and broadcast)
let real_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let real_change_script: Option<ScriptBuf> = real_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());

let idx_after_real = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
idx_after_dry, idx_after_real,
"Real tx should not advance index beyond what dry-run already revealed"
);
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
{
assert_eq!(
dry_change, real_change,
"Real tx should reuse the same change address as the cancelled dry-run"
);
}
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
# 0.7.0-rc.31 (Synonym Fork)
# 0.7.0-rc.32 (Synonym Fork)

## Bug Fixes

- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
each time it's called; repeated fee estimations would burn through change addresses without
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
to unmark the change address for reuse.
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
`FeerateEstimationUpdateTimeout` on node start.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]

[package]
name = "ldk-node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = ["Elias Rohrer <dev@tnull.de>"]
homepage = "https://lightningdevkit.org/"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@

import PackageDescription

let tag = "v0.7.0-rc.31"
let checksum = "aa189d355dc048664652832747f9236eddd97cdf78eb1659d7def06eae72b9b4"
let tag = "v0.7.0-rc.32"
let checksum = "5a9ac761833dd2aad0a88da7da8887923fc6ac8b33a14035b95c7d3a9d0d2b3c"
let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip"

let package = Package(
Expand Down
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-android/gradle.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-jvm/gradle.properties
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "ldk_node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = [
{ name="Elias Rohrer", email="dev@tnull.de" },
]
Expand Down
840 changes: 410 additions & 430 deletions bindings/swift/Sources/LDKNode/LDKNode.swift

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions crates/bdk-wallet-aggregate/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,19 @@ where
Ok(())
}

/// Cancel a dry-run transaction on the primary wallet without persisting.
///
/// Unmarks change addresses that were marked "used" by `finish()`, so
/// the next `finish()` reuses them instead of revealing new ones. The
/// reveal itself is left in the staged changeset and persists harmlessly.
///
/// Only targets the primary wallet. All current build paths use the primary.
pub fn cancel_dry_run_tx(&mut self, tx: &Transaction) {
let primary =
self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist");
primary.cancel_tx(tx);
}

// ─── Fee Calculation ────────────────────────────────────────────────

/// Calculate the fee of a PSBT by summing input values and subtracting
Expand DownExpand Up@@ -1346,3 +1359,277 @@ where
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
}
}

#[cfg(test)]
mod tests {
use bdk_wallet::template::Bip84;
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
use bitcoin::bip32::Xpriv;
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};

use super::*;

struct NoopPersister;

impl WalletPersister for NoopPersister {
type Error = std::convert::Infallible;

fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
Ok(ChangeSet::default())
}

fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
Ok(())
}
}

fn test_xprv() -> Xpriv {
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
}

fn create_funded_wallet(
persister: &mut NoopPersister, amount: Amount,
) -> PersistedWallet<NoopPersister> {
let xprv = test_xprv();
let mut wallet = PersistedWallet::create(
persister,
Wallet::create(
Bip84(xprv, KeychainKind::External),
Bip84(xprv, KeychainKind::Internal),
)
.network(Network::Regtest),
)
.unwrap();

let addr = wallet.reveal_next_address(KeychainKind::External);
let funding_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
..Default::default()
}],
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
};
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
wallet
}

fn recipient_script() -> ScriptBuf {
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
}

/// Demonstrates the bug: without cancel_tx, each finish() call
/// cumulatively advances the internal (change) derivation index.
#[test]
fn test_finish_without_cancel_leaks_change_index() {
let mut persister = NoopPersister;
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = wallet.derivation_index(KeychainKind::Internal);

// First dry-run finish(), no cancel
let mut b1 = wallet.build_tx();
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt1 = b1.finish().unwrap();
let after_first = wallet.derivation_index(KeychainKind::Internal);

// Second dry-run finish(), no cancel
let mut b2 = wallet.build_tx();
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt2 = b2.finish().unwrap();
let after_second = wallet.derivation_index(KeychainKind::Internal);

// Third dry-run finish(), no cancel
let mut b3 = wallet.build_tx();
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt3 = b3.finish().unwrap();
let after_third = wallet.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should advance the internal index"
);
assert!(
after_second > after_first,
"Second finish() without cancel should advance further: {:?} > {:?}",
after_second,
after_first,
);
assert!(
after_third > after_second,
"Third finish() without cancel should advance further: {:?} > {:?}",
after_third,
after_second,
);
}

/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
/// so subsequent finish() calls reuse the same index.
#[test]
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Dry-run 1: finish + cancel
let psbt1 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
let after_first = agg.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should reveal an internal address"
);

// Dry-run 2: finish + cancel
let psbt2 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
let after_second = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
after_first, after_second,
"cancel_dry_run_tx should prevent cumulative index advance"
);

// Dry-runs 3-5: confirm stability
for i in 3..=5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
let idx = agg.derivation_index(KeychainKind::Internal);
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
}
}

/// Verifies that cancel_dry_run_tx prevents index leak even when an
/// intermediate operation fails between finish() and the cancel.
#[test]
fn test_cancel_after_failed_intermediate_prevents_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Baseline
let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Simulate finish() + failed intermediate + unconditional cancel.
for _ in 0..5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};

// Simulate an intermediate operation that fails.
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");

// Cancel regardless of failure.
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
}

let final_idx = agg.derivation_index(KeychainKind::Internal);

assert!(
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
"Index should be revealed at least once"
);

// One more dry-run + cancel should not change it.
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
assert_eq!(
final_idx,
agg.derivation_index(KeychainKind::Internal),
"Sixth dry-run should still reuse the same index"
);
}

/// After a cancelled dry-run, the next real finish() reuses the same
/// change address. Verifies the fix doesn't break real transactions.
#[test]
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Dry-run + cancel
let dry_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);

let idx_after_dry = agg.derivation_index(KeychainKind::Internal);

// Real tx (no cancel — this would be signed and broadcast)
let real_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let real_change_script: Option<ScriptBuf> = real_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());

let idx_after_real = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
idx_after_dry, idx_after_real,
"Real tx should not advance index beyond what dry-run already revealed"
);
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
{
assert_eq!(
dry_change, real_change,
"Real tx should reuse the same change address as the cancelled dry-run"
);
}
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
# 0.7.0-rc.31 (Synonym Fork)
# 0.7.0-rc.32 (Synonym Fork)

## Bug Fixes

- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
each time it's called; repeated fee estimations would burn through change addresses without
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
to unmark the change address for reuse.
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
`FeerateEstimationUpdateTimeout` on node start.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]

[package]
name = "ldk-node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = ["Elias Rohrer <dev@tnull.de>"]
homepage = "https://lightningdevkit.org/"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@

import PackageDescription

let tag = "v0.7.0-rc.31"
let checksum = "aa189d355dc048664652832747f9236eddd97cdf78eb1659d7def06eae72b9b4"
let tag = "v0.7.0-rc.32"
let checksum = "5a9ac761833dd2aad0a88da7da8887923fc6ac8b33a14035b95c7d3a9d0d2b3c"
let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip"

let package = Package(
Expand Down
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-android/gradle.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-jvm/gradle.properties
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "ldk_node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = [
{ name="Elias Rohrer", email="dev@tnull.de" },
]
Expand Down
840 changes: 410 additions & 430 deletions bindings/swift/Sources/LDKNode/LDKNode.swift

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions crates/bdk-wallet-aggregate/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,19 @@ where
Ok(())
}

/// Cancel a dry-run transaction on the primary wallet without persisting.
///
/// Unmarks change addresses that were marked "used" by `finish()`, so
/// the next `finish()` reuses them instead of revealing new ones. The
/// reveal itself is left in the staged changeset and persists harmlessly.
///
/// Only targets the primary wallet. All current build paths use the primary.
pub fn cancel_dry_run_tx(&mut self, tx: &Transaction) {
let primary =
self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist");
primary.cancel_tx(tx);
}

// ─── Fee Calculation ────────────────────────────────────────────────

/// Calculate the fee of a PSBT by summing input values and subtracting
Expand DownExpand Up@@ -1346,3 +1359,277 @@ where
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
}
}

#[cfg(test)]
mod tests {
use bdk_wallet::template::Bip84;
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
use bitcoin::bip32::Xpriv;
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};

use super::*;

struct NoopPersister;

impl WalletPersister for NoopPersister {
type Error = std::convert::Infallible;

fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
Ok(ChangeSet::default())
}

fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
Ok(())
}
}

fn test_xprv() -> Xpriv {
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
}

fn create_funded_wallet(
persister: &mut NoopPersister, amount: Amount,
) -> PersistedWallet<NoopPersister> {
let xprv = test_xprv();
let mut wallet = PersistedWallet::create(
persister,
Wallet::create(
Bip84(xprv, KeychainKind::External),
Bip84(xprv, KeychainKind::Internal),
)
.network(Network::Regtest),
)
.unwrap();

let addr = wallet.reveal_next_address(KeychainKind::External);
let funding_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
..Default::default()
}],
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
};
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
wallet
}

fn recipient_script() -> ScriptBuf {
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
}

/// Demonstrates the bug: without cancel_tx, each finish() call
/// cumulatively advances the internal (change) derivation index.
#[test]
fn test_finish_without_cancel_leaks_change_index() {
let mut persister = NoopPersister;
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = wallet.derivation_index(KeychainKind::Internal);

// First dry-run finish(), no cancel
let mut b1 = wallet.build_tx();
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt1 = b1.finish().unwrap();
let after_first = wallet.derivation_index(KeychainKind::Internal);

// Second dry-run finish(), no cancel
let mut b2 = wallet.build_tx();
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt2 = b2.finish().unwrap();
let after_second = wallet.derivation_index(KeychainKind::Internal);

// Third dry-run finish(), no cancel
let mut b3 = wallet.build_tx();
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt3 = b3.finish().unwrap();
let after_third = wallet.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should advance the internal index"
);
assert!(
after_second > after_first,
"Second finish() without cancel should advance further: {:?} > {:?}",
after_second,
after_first,
);
assert!(
after_third > after_second,
"Third finish() without cancel should advance further: {:?} > {:?}",
after_third,
after_second,
);
}

/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
/// so subsequent finish() calls reuse the same index.
#[test]
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Dry-run 1: finish + cancel
let psbt1 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
let after_first = agg.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should reveal an internal address"
);

// Dry-run 2: finish + cancel
let psbt2 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
let after_second = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
after_first, after_second,
"cancel_dry_run_tx should prevent cumulative index advance"
);

// Dry-runs 3-5: confirm stability
for i in 3..=5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
let idx = agg.derivation_index(KeychainKind::Internal);
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
}
}

/// Verifies that cancel_dry_run_tx prevents index leak even when an
/// intermediate operation fails between finish() and the cancel.
#[test]
fn test_cancel_after_failed_intermediate_prevents_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Baseline
let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Simulate finish() + failed intermediate + unconditional cancel.
for _ in 0..5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};

// Simulate an intermediate operation that fails.
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");

// Cancel regardless of failure.
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
}

let final_idx = agg.derivation_index(KeychainKind::Internal);

assert!(
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
"Index should be revealed at least once"
);

// One more dry-run + cancel should not change it.
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
assert_eq!(
final_idx,
agg.derivation_index(KeychainKind::Internal),
"Sixth dry-run should still reuse the same index"
);
}

/// After a cancelled dry-run, the next real finish() reuses the same
/// change address. Verifies the fix doesn't break real transactions.
#[test]
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Dry-run + cancel
let dry_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);

let idx_after_dry = agg.derivation_index(KeychainKind::Internal);

// Real tx (no cancel — this would be signed and broadcast)
let real_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let real_change_script: Option<ScriptBuf> = real_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());

let idx_after_real = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
idx_after_dry, idx_after_real,
"Real tx should not advance index beyond what dry-run already revealed"
);
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
{
assert_eq!(
dry_change, real_change,
"Real tx should reuse the same change address as the cancelled dry-run"
);
}
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
# 0.7.0-rc.31 (Synonym Fork)
# 0.7.0-rc.32 (Synonym Fork)

## Bug Fixes

- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
each time it's called; repeated fee estimations would burn through change addresses without
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
to unmark the change address for reuse.
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
`FeerateEstimationUpdateTimeout` on node start.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]

[package]
name = "ldk-node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = ["Elias Rohrer <dev@tnull.de>"]
homepage = "https://lightningdevkit.org/"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,8 @@

import PackageDescription

let tag = "v0.7.0-rc.31"
let checksum = "aa189d355dc048664652832747f9236eddd97cdf78eb1659d7def06eae72b9b4"
let tag = "v0.7.0-rc.32"
let checksum = "5a9ac761833dd2aad0a88da7da8887923fc6ac8b33a14035b95c7d3a9d0d2b3c"
let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip"

let package = Package(
Expand Down
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-android/gradle.properties
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
2 changes: 1 addition & 1 deletion bindings/kotlin/ldk-node-jvm/gradle.properties
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
kotlin.code.style=official
group=com.synonym
version=0.7.0-rc.31
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "ldk_node"
version = "0.7.0-rc.31"
version = "0.7.0-rc.32"
authors = [
{ name="Elias Rohrer", email="dev@tnull.de" },
]
Expand Down
840 changes: 410 additions & 430 deletions bindings/swift/Sources/LDKNode/LDKNode.swift

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions crates/bdk-wallet-aggregate/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,19 @@ where
Ok(())
}

/// Cancel a dry-run transaction on the primary wallet without persisting.
///
/// Unmarks change addresses that were marked "used" by `finish()`, so
/// the next `finish()` reuses them instead of revealing new ones. The
/// reveal itself is left in the staged changeset and persists harmlessly.
///
/// Only targets the primary wallet. All current build paths use the primary.
pub fn cancel_dry_run_tx(&mut self, tx: &Transaction) {
let primary =
self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist");
primary.cancel_tx(tx);
}

// ─── Fee Calculation ────────────────────────────────────────────────

/// Calculate the fee of a PSBT by summing input values and subtracting
Expand DownExpand Up@@ -1346,3 +1359,277 @@ where
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
}
}

#[cfg(test)]
mod tests {
use bdk_wallet::template::Bip84;
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
use bitcoin::bip32::Xpriv;
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};

use super::*;

struct NoopPersister;

impl WalletPersister for NoopPersister {
type Error = std::convert::Infallible;

fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
Ok(ChangeSet::default())
}

fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
Ok(())
}
}

fn test_xprv() -> Xpriv {
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
}

fn create_funded_wallet(
persister: &mut NoopPersister, amount: Amount,
) -> PersistedWallet<NoopPersister> {
let xprv = test_xprv();
let mut wallet = PersistedWallet::create(
persister,
Wallet::create(
Bip84(xprv, KeychainKind::External),
Bip84(xprv, KeychainKind::Internal),
)
.network(Network::Regtest),
)
.unwrap();

let addr = wallet.reveal_next_address(KeychainKind::External);
let funding_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
..Default::default()
}],
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
};
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
wallet
}

fn recipient_script() -> ScriptBuf {
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
}

/// Demonstrates the bug: without cancel_tx, each finish() call
/// cumulatively advances the internal (change) derivation index.
#[test]
fn test_finish_without_cancel_leaks_change_index() {
let mut persister = NoopPersister;
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = wallet.derivation_index(KeychainKind::Internal);

// First dry-run finish(), no cancel
let mut b1 = wallet.build_tx();
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt1 = b1.finish().unwrap();
let after_first = wallet.derivation_index(KeychainKind::Internal);

// Second dry-run finish(), no cancel
let mut b2 = wallet.build_tx();
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt2 = b2.finish().unwrap();
let after_second = wallet.derivation_index(KeychainKind::Internal);

// Third dry-run finish(), no cancel
let mut b3 = wallet.build_tx();
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
let _psbt3 = b3.finish().unwrap();
let after_third = wallet.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should advance the internal index"
);
assert!(
after_second > after_first,
"Second finish() without cancel should advance further: {:?} > {:?}",
after_second,
after_first,
);
assert!(
after_third > after_second,
"Third finish() without cancel should advance further: {:?} > {:?}",
after_third,
after_second,
);
}

/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
/// so subsequent finish() calls reuse the same index.
#[test]
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Dry-run 1: finish + cancel
let psbt1 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
let after_first = agg.derivation_index(KeychainKind::Internal);

assert!(
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
"First finish() should reveal an internal address"
);

// Dry-run 2: finish + cancel
let psbt2 = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
let after_second = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
after_first, after_second,
"cancel_dry_run_tx should prevent cumulative index advance"
);

// Dry-runs 3-5: confirm stability
for i in 3..=5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
let idx = agg.derivation_index(KeychainKind::Internal);
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
}
}

/// Verifies that cancel_dry_run_tx prevents index leak even when an
/// intermediate operation fails between finish() and the cancel.
#[test]
fn test_cancel_after_failed_intermediate_prevents_leak() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Baseline
let initial_idx = agg.derivation_index(KeychainKind::Internal);

// Simulate finish() + failed intermediate + unconditional cancel.
for _ in 0..5 {
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};

// Simulate an intermediate operation that fails.
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");

// Cancel regardless of failure.
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
}

let final_idx = agg.derivation_index(KeychainKind::Internal);

assert!(
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
"Index should be revealed at least once"
);

// One more dry-run + cancel should not change it.
let psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
assert_eq!(
final_idx,
agg.derivation_index(KeychainKind::Internal),
"Sixth dry-run should still reuse the same index"
);
}

/// After a cancelled dry-run, the next real finish() reuses the same
/// change address. Verifies the fix doesn't break real transactions.
#[test]
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
let mut persister = NoopPersister;
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);

let recipient = recipient_script();
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();

// Dry-run + cancel
let dry_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);

let idx_after_dry = agg.derivation_index(KeychainKind::Internal);

// Real tx (no cancel — this would be signed and broadcast)
let real_psbt = {
let w = agg.primary_wallet_mut();
let mut b = w.build_tx();
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
b.finish().unwrap()
};
let real_change_script: Option<ScriptBuf> = real_psbt
.unsigned_tx
.output
.iter()
.find(|o| agg.is_mine(o.script_pubkey.clone()))
.map(|o| o.script_pubkey.clone());

let idx_after_real = agg.derivation_index(KeychainKind::Internal);

assert_eq!(
idx_after_dry, idx_after_real,
"Real tx should not advance index beyond what dry-run already revealed"
);
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
{
assert_eq!(
dry_change, real_change,
"Real tx should reuse the same change address as the cancelled dry-run"
);
}
}
}
Loading