Skip to content

Commit 4fa06b4

Browse files
committed
fix: prevent cumulative change-address index leak in dry-run tx builds
1 parent f045195 commit 4fa06b4

File tree

11 files changed

+744
-453
lines changed

11 files changed

+744
-453
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
# 0.7.0-rc.31 (Synonym Fork)
1+
# 0.7.0-rc.32 (Synonym Fork)
22

33
## Bug Fixes
44

5+
- Fixed cumulative change-address derivation index leak during fee estimation and dry-run
6+
transaction builds. BDK's `TxBuilder::finish()` advances the internal (change) keychain index
7+
each time it's called; repeated fee estimations would burn through change addresses without
8+
broadcasting any transaction. All dry-run and error-after-`finish()` paths now cancel the PSBT
9+
to unmark the change address for reuse.
510
- Bumped `FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS` and `TX_BROADCAST_TIMEOUT_SECS` from 5s to 15s.
611
The 5s node-level timeout fires before Electrum can complete a request (10s timeout), causing
712
`FeerateEstimationUpdateTimeout` on node start.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"]
44

55
[package]
66
name = "ldk-node"
7-
version = "0.7.0-rc.31"
7+
version = "0.7.0-rc.32"
88
authors = ["Elias Rohrer <dev@tnull.de>"]
99
homepage = "https://lightningdevkit.org/"
1010
license = "MIT OR Apache-2.0"

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
import PackageDescription
55

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

1010
let package = Package(

bindings/kotlin/ldk-node-android/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ android.useAndroidX=true
33
android.enableJetifier=true
44
kotlin.code.style=official
55
group=com.synonym
6-
version=0.7.0-rc.31
6+
version=0.7.0-rc.32
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
org.gradle.jvmargs=-Xmx1536m
22
kotlin.code.style=official
33
group=com.synonym
4-
version=0.7.0-rc.31
4+
version=0.7.0-rc.32
Binary file not shown.
Binary file not shown.

bindings/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ldk_node"
3-
version = "0.7.0-rc.31"
3+
version = "0.7.0-rc.32"
44
authors = [
55
{ name="Elias Rohrer", email="dev@tnull.de" },
66
]

bindings/swift/Sources/LDKNode/LDKNode.swift

Lines changed: 410 additions & 430 deletions
Large diffs are not rendered by default.

crates/bdk-wallet-aggregate/src/lib.rs

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,19 @@ where
414414
Ok(())
415415
}
416416

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

419432
/// Calculate the fee of a PSBT by summing input values and subtracting
@@ -1346,3 +1359,277 @@ where
13461359
self.wallets.values().any(|w| w.is_mine(script_pubkey.clone()))
13471360
}
13481361
}
1362+
1363+
#[cfg(test)]
1364+
mod tests {
1365+
use bdk_wallet::template::Bip84;
1366+
use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Wallet, WalletPersister};
1367+
use bitcoin::bip32::Xpriv;
1368+
use bitcoin::{Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};
1369+
1370+
use super::*;
1371+
1372+
struct NoopPersister;
1373+
1374+
impl WalletPersister for NoopPersister {
1375+
type Error = std::convert::Infallible;
1376+
1377+
fn initialize(_: &mut Self) -> Result<ChangeSet, Self::Error> {
1378+
Ok(ChangeSet::default())
1379+
}
1380+
1381+
fn persist(_: &mut Self, _: &ChangeSet) -> Result<(), Self::Error> {
1382+
Ok(())
1383+
}
1384+
}
1385+
1386+
fn test_xprv() -> Xpriv {
1387+
Xpriv::new_master(Network::Regtest, &[0x42; 32]).unwrap()
1388+
}
1389+
1390+
fn create_funded_wallet(
1391+
persister: &mut NoopPersister, amount: Amount,
1392+
) -> PersistedWallet<NoopPersister> {
1393+
let xprv = test_xprv();
1394+
let mut wallet = PersistedWallet::create(
1395+
persister,
1396+
Wallet::create(
1397+
Bip84(xprv, KeychainKind::External),
1398+
Bip84(xprv, KeychainKind::Internal),
1399+
)
1400+
.network(Network::Regtest),
1401+
)
1402+
.unwrap();
1403+
1404+
let addr = wallet.reveal_next_address(KeychainKind::External);
1405+
let funding_tx = Transaction {
1406+
version: bitcoin::transaction::Version::TWO,
1407+
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
1408+
input: vec![TxIn {
1409+
previous_output: OutPoint { txid: Txid::from_byte_array([0x01; 32]), vout: 0 },
1410+
..Default::default()
1411+
}],
1412+
output: vec![TxOut { value: amount, script_pubkey: addr.address.script_pubkey() }],
1413+
};
1414+
wallet.apply_unconfirmed_txs([(funding_tx, 0)]);
1415+
wallet
1416+
}
1417+
1418+
fn recipient_script() -> ScriptBuf {
1419+
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]))
1420+
}
1421+
1422+
/// Demonstrates the bug: without cancel_tx, each finish() call
1423+
/// cumulatively advances the internal (change) derivation index.
1424+
#[test]
1425+
fn test_finish_without_cancel_leaks_change_index() {
1426+
let mut persister = NoopPersister;
1427+
let mut wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
1428+
1429+
let recipient = recipient_script();
1430+
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();
1431+
1432+
let initial_idx = wallet.derivation_index(KeychainKind::Internal);
1433+
1434+
// First dry-run finish(), no cancel
1435+
let mut b1 = wallet.build_tx();
1436+
b1.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
1437+
let _psbt1 = b1.finish().unwrap();
1438+
let after_first = wallet.derivation_index(KeychainKind::Internal);
1439+
1440+
// Second dry-run finish(), no cancel
1441+
let mut b2 = wallet.build_tx();
1442+
b2.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
1443+
let _psbt2 = b2.finish().unwrap();
1444+
let after_second = wallet.derivation_index(KeychainKind::Internal);
1445+
1446+
// Third dry-run finish(), no cancel
1447+
let mut b3 = wallet.build_tx();
1448+
b3.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
1449+
let _psbt3 = b3.finish().unwrap();
1450+
let after_third = wallet.derivation_index(KeychainKind::Internal);
1451+
1452+
assert!(
1453+
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
1454+
"First finish() should advance the internal index"
1455+
);
1456+
assert!(
1457+
after_second > after_first,
1458+
"Second finish() without cancel should advance further: {:?} > {:?}",
1459+
after_second,
1460+
after_first,
1461+
);
1462+
assert!(
1463+
after_third > after_second,
1464+
"Third finish() without cancel should advance further: {:?} > {:?}",
1465+
after_third,
1466+
after_second,
1467+
);
1468+
}
1469+
1470+
/// Demonstrates the fix: cancel_dry_run_tx unmarks the change address,
1471+
/// so subsequent finish() calls reuse the same index.
1472+
#[test]
1473+
fn test_cancel_dry_run_prevents_cumulative_index_leak() {
1474+
let mut persister = NoopPersister;
1475+
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
1476+
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);
1477+
1478+
let recipient = recipient_script();
1479+
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();
1480+
1481+
let initial_idx = agg.derivation_index(KeychainKind::Internal);
1482+
1483+
// Dry-run 1: finish + cancel
1484+
let psbt1 = {
1485+
let w = agg.primary_wallet_mut();
1486+
let mut b = w.build_tx();
1487+
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
1488+
b.finish().unwrap()
1489+
};
1490+
agg.cancel_dry_run_tx(&psbt1.unsigned_tx);
1491+
let after_first = agg.derivation_index(KeychainKind::Internal);
1492+
1493+
assert!(
1494+
after_first > initial_idx || (initial_idx.is_none() && after_first.is_some()),
1495+
"First finish() should reveal an internal address"
1496+
);
1497+
1498+
// Dry-run 2: finish + cancel
1499+
let psbt2 = {
1500+
let w = agg.primary_wallet_mut();
1501+
let mut b = w.build_tx();
1502+
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
1503+
b.finish().unwrap()
1504+
};
1505+
agg.cancel_dry_run_tx(&psbt2.unsigned_tx);
1506+
let after_second = agg.derivation_index(KeychainKind::Internal);
1507+
1508+
assert_eq!(
1509+
after_first, after_second,
1510+
"cancel_dry_run_tx should prevent cumulative index advance"
1511+
);
1512+
1513+
// Dry-runs 3-5: confirm stability
1514+
for i in 3..=5 {
1515+
let psbt = {
1516+
let w = agg.primary_wallet_mut();
1517+
let mut b = w.build_tx();
1518+
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
1519+
b.finish().unwrap()
1520+
};
1521+
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
1522+
let idx = agg.derivation_index(KeychainKind::Internal);
1523+
assert_eq!(after_first, idx, "Dry-run {} should still reuse the same index", i);
1524+
}
1525+
}
1526+
1527+
/// Verifies that cancel_dry_run_tx prevents index leak even when an
1528+
/// intermediate operation fails between finish() and the cancel.
1529+
#[test]
1530+
fn test_cancel_after_failed_intermediate_prevents_leak() {
1531+
let mut persister = NoopPersister;
1532+
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000));
1533+
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);
1534+
1535+
let recipient = recipient_script();
1536+
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();
1537+
1538+
// Baseline
1539+
let initial_idx = agg.derivation_index(KeychainKind::Internal);
1540+
1541+
// Simulate finish() + failed intermediate + unconditional cancel.
1542+
for _ in 0..5 {
1543+
let psbt = {
1544+
let w = agg.primary_wallet_mut();
1545+
let mut b = w.build_tx();
1546+
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
1547+
b.finish().unwrap()
1548+
};
1549+
1550+
// Simulate an intermediate operation that fails.
1551+
let _simulated_failure: Result<u64, &str> = Err("simulated fee calculation failure");
1552+
1553+
// Cancel regardless of failure.
1554+
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
1555+
}
1556+
1557+
let final_idx = agg.derivation_index(KeychainKind::Internal);
1558+
1559+
assert!(
1560+
final_idx > initial_idx || (initial_idx.is_none() && final_idx.is_some()),
1561+
"Index should be revealed at least once"
1562+
);
1563+
1564+
// One more dry-run + cancel should not change it.
1565+
let psbt = {
1566+
let w = agg.primary_wallet_mut();
1567+
let mut b = w.build_tx();
1568+
b.add_recipient(recipient.clone(), Amount::from_sat(30_000)).fee_rate(fee_rate);
1569+
b.finish().unwrap()
1570+
};
1571+
agg.cancel_dry_run_tx(&psbt.unsigned_tx);
1572+
assert_eq!(
1573+
final_idx,
1574+
agg.derivation_index(KeychainKind::Internal),
1575+
"Sixth dry-run should still reuse the same index"
1576+
);
1577+
}
1578+
1579+
/// After a cancelled dry-run, the next real finish() reuses the same
1580+
/// change address. Verifies the fix doesn't break real transactions.
1581+
#[test]
1582+
fn test_dry_run_cancel_then_real_tx_reuses_change_address() {
1583+
let mut persister = NoopPersister;
1584+
let wallet = create_funded_wallet(&mut persister, Amount::from_sat(200_000));
1585+
let mut agg = AggregateWallet::<u8, _>::new(wallet, persister, 0, vec![]);
1586+
1587+
let recipient = recipient_script();
1588+
let fee_rate = FeeRate::from_sat_per_vb(2).unwrap();
1589+
1590+
// Dry-run + cancel
1591+
let dry_psbt = {
1592+
let w = agg.primary_wallet_mut();
1593+
let mut b = w.build_tx();
1594+
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
1595+
b.finish().unwrap()
1596+
};
1597+
let dry_run_change_script: Option<ScriptBuf> = dry_psbt
1598+
.unsigned_tx
1599+
.output
1600+
.iter()
1601+
.find(|o| agg.is_mine(o.script_pubkey.clone()))
1602+
.map(|o| o.script_pubkey.clone());
1603+
agg.cancel_dry_run_tx(&dry_psbt.unsigned_tx);
1604+
1605+
let idx_after_dry = agg.derivation_index(KeychainKind::Internal);
1606+
1607+
// Real tx (no cancel — this would be signed and broadcast)
1608+
let real_psbt = {
1609+
let w = agg.primary_wallet_mut();
1610+
let mut b = w.build_tx();
1611+
b.add_recipient(recipient.clone(), Amount::from_sat(50_000)).fee_rate(fee_rate);
1612+
b.finish().unwrap()
1613+
};
1614+
let real_change_script: Option<ScriptBuf> = real_psbt
1615+
.unsigned_tx
1616+
.output
1617+
.iter()
1618+
.find(|o| agg.is_mine(o.script_pubkey.clone()))
1619+
.map(|o| o.script_pubkey.clone());
1620+
1621+
let idx_after_real = agg.derivation_index(KeychainKind::Internal);
1622+
1623+
assert_eq!(
1624+
idx_after_dry, idx_after_real,
1625+
"Real tx should not advance index beyond what dry-run already revealed"
1626+
);
1627+
if let (Some(dry_change), Some(real_change)) = (&dry_run_change_script, &real_change_script)
1628+
{
1629+
assert_eq!(
1630+
dry_change, real_change,
1631+
"Real tx should reuse the same change address as the cancelled dry-run"
1632+
);
1633+
}
1634+
}
1635+
}

0 commit comments

Comments
 (0)