@@ -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