@@ -1551,7 +1551,54 @@ impl Wallet {
15511551 let txid = tx. compute_txid ( ) ;
15521552 let ( amount_msat, fee_paid_msat, direction) = self . onchain_payment_fields ( tx) ;
15531553
1554+ // A funding transaction that moves no wallet funds carries nothing to record — e.g. LDK
1555+ // re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding path,
1556+ // including splices the interactive-funding classification deliberately declined (no
1557+ // local contribution, or a splice-out moving no wallet funds). Recording it here would
1558+ // mint a zero-amount payment that nothing ever confirms. Skip on the wallet-derived
1559+ // amount alone — the condition `classify_interactive_funding` declines on; anything
1560+ // declined there must be skipped here, or its re-broadcast resurrects the record. The fee
1561+ // is no participation signal: the wallet resolves a splice's shared input whenever the
1562+ // previous funding transaction touched it (e.g. it funded the original channel open).
1563+ //
1564+ // TODO(https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4878): The
1565+ // re-typed re-broadcasts are upstream behavior that should be fixed in `rust-lightning`:
1566+ // the re-offer ought to keep its `InteractiveFunding` classification, or not recur at
1567+ // all. `zero_conf_splice_out_funding_rebroadcast_canary` pins the current behavior by
1568+ // asserting the log line below; when it fails against a newer LDK, re-evaluate whether
1569+ // this skip still sees traffic.
1570+ if amount_msat == Some ( 0 ) {
1571+ log_trace ! (
1572+ self . logger,
1573+ "Not recording channel-funding broadcast {} as a payment: no wallet-level activity" ,
1574+ txid,
1575+ ) ;
1576+ return Ok ( ( ) ) ;
1577+ }
1578+
15541579 let payment_id = PaymentId ( txid. to_byte_array ( ) ) ;
1580+
1581+ // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed
1582+ // and carrying wallet-view figures; `funding_reclassification_update` declines the
1583+ // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can
1584+ // observe the traffic. The read cannot go stale: only the broadcast loop writes
1585+ // interactive-funding classifications, and it runs this classification too.
1586+ if let Some ( current) = self . payment_store . get ( & payment_id) {
1587+ if matches ! (
1588+ current. kind,
1589+ PaymentKind :: Onchain {
1590+ tx_type: Some ( TransactionType :: InteractiveFunding { .. } ) ,
1591+ ..
1592+ }
1593+ ) {
1594+ log_trace ! (
1595+ self . logger,
1596+ "Keeping interactive-funding classification over funding-typed rebroadcast {}" ,
1597+ txid,
1598+ ) ;
1599+ }
1600+ }
1601+
15551602 let details = PaymentDetails :: new (
15561603 payment_id,
15571604 PaymentKind :: Onchain {
@@ -2585,6 +2632,28 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight {
25852632fn funding_reclassification_update (
25862633 details : PaymentDetails , candidates : & [ FundingTxCandidate ] , current : Option < & PaymentDetails > ,
25872634) -> PaymentDetailsUpdate {
2635+ // A funding-typed classification of a record already classified as interactive funding is a
2636+ // downgrade, not news: LDK re-broadcasts a promoted-but-unconfirmed splice through its
2637+ // generic funding path, where the figures are wallet-view rather than contribution-derived.
2638+ // Keep the record as classified; wallet-sync events own its confirmation state.
2639+ //
2640+ // TODO(https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4878): The
2641+ // re-typed re-broadcasts are upstream behavior that should be fixed in `rust-lightning`:
2642+ // the re-offer ought to keep its `InteractiveFunding` classification, or not recur at all.
2643+ // `zero_conf_splice_in_funding_rebroadcast_canary` pins the current behavior via the
2644+ // arrival log in `classify_funding`; when it fails against a newer LDK, re-evaluate
2645+ // whether this guard still sees traffic.
2646+ if let (
2647+ Some ( PaymentKind :: Onchain {
2648+ tx_type : Some ( TransactionType :: InteractiveFunding { .. } ) ,
2649+ ..
2650+ } ) ,
2651+ PaymentKind :: Onchain { tx_type : Some ( TransactionType :: Funding { .. } ) , .. } ,
2652+ ) = ( current. map ( |payment| & payment. kind ) , & details. kind )
2653+ {
2654+ return PaymentDetailsUpdate :: new ( details. id ) ;
2655+ }
2656+
25882657 let mut update = PaymentDetailsUpdate :: funding_reclassification ( details) ;
25892658 if let Some ( PaymentKind :: Onchain {
25902659 txid : confirmed_txid,
@@ -3689,6 +3758,35 @@ mod tests {
36893758 assert_eq ! ( update. txid, Some ( active_txid) ) ;
36903759 }
36913760
3761+ /// A funding-typed (re)classification of a record already classified as interactive funding
3762+ /// carries nothing the record doesn't have — LDK re-broadcasts a promoted-but-unconfirmed
3763+ /// splice through its generic funding path with wallet-view figures — so the update must
3764+ /// move nothing.
3765+ #[ test]
3766+ fn funding_reclassification_update_skips_funding_over_interactive_funding ( ) {
3767+ let txid = Txid :: from_byte_array ( [ 1u8 ; 32 ] ) ;
3768+ let payment_id = PaymentId ( txid. to_byte_array ( ) ) ;
3769+ let current = interactive_funding_details ( payment_id, txid, Some ( 1_000_000 ) , Some ( 500 ) ) ;
3770+
3771+ let rebroadcast = PaymentDetails :: new (
3772+ payment_id,
3773+ PaymentKind :: Onchain {
3774+ txid,
3775+ status : ConfirmationStatus :: Unconfirmed ,
3776+ tx_type : Some ( TransactionType :: Funding { channels : vec ! [ ] } ) ,
3777+ } ,
3778+ Some ( 10_000_000 ) ,
3779+ Some ( 0 ) ,
3780+ PaymentDirection :: Inbound ,
3781+ PaymentStatus :: Pending ,
3782+ ) ;
3783+
3784+ let update = funding_reclassification_update ( rebroadcast, & [ ] , Some ( & current) ) ;
3785+ let mut updated = current. clone ( ) ;
3786+ assert ! ( !updated. update( update) , "the rebroadcast must not move the record" ) ;
3787+ assert_eq ! ( updated, current) ;
3788+ }
3789+
36923790 /// Graduation must decide from the live record and write only the status: a pending-store
36933791 /// snapshot taken before a concurrent classification landed must not roll the record's
36943792 /// figures back when the payment graduates to `Succeeded`.
@@ -3831,6 +3929,148 @@ mod tests {
38313929 assert_eq ! ( wallet. find_payment_by_txid( txid2) , Some ( payment_id) ) ;
38323930 }
38333931
3932+ /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded.
3933+ /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding
3934+ /// path, so a splice the interactive-funding classification deliberately declined — no local
3935+ /// contribution, or none of the moved funds are the wallet's — would otherwise come back as
3936+ /// a spurious zero-amount record that nothing ever confirms.
3937+ #[ tokio:: test]
3938+ async fn funding_broadcast_without_wallet_activity_is_not_recorded ( ) {
3939+ let store: Arc < DynStore > = Arc :: new ( DynStoreWrapper ( InMemoryStore :: new ( ) ) ) ;
3940+ let wallet = new_test_wallet ( store, false ) . await ;
3941+
3942+ let counterparty_node_id = PublicKey :: from_str (
3943+ "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ,
3944+ )
3945+ . unwrap ( ) ;
3946+ let channels = vec ! [ ( counterparty_node_id, ChannelId ( [ 7u8 ; 32 ] ) ) ] ;
3947+ let tx_type = TransactionType :: Funding { channels : vec ! [ ] } ;
3948+
3949+ // No inputs or outputs involve the wallet: nothing to record.
3950+ wallet. classify_funding ( & dummy_tx ( ) , & channels, tx_type. clone ( ) ) . await . unwrap ( ) ;
3951+ assert ! ( wallet. payment_store. list_filter( |_| true ) . is_empty( ) ) ;
3952+ assert ! ( wallet. pending_payment_store. list_filter( |_| true ) . is_empty( ) ) ;
3953+
3954+ // A computable fee is not wallet participation. The wallet can resolve a splice's shared
3955+ // input whenever the previous funding transaction touched it (e.g. it funded the original
3956+ // channel open), so it derives the splice's fee even when no wallet funds move.
3957+ let prev_funding_outpoint = OutPoint { txid : Txid :: from_byte_array ( [ 8u8 ; 32 ] ) , vout : 0 } ;
3958+ wallet. inner . lock ( ) . unwrap ( ) . insert_txout (
3959+ prev_funding_outpoint,
3960+ TxOut { value : Amount :: from_sat ( 100_000 ) , script_pubkey : ScriptBuf :: new ( ) } ,
3961+ ) ;
3962+ let splice_tx = Transaction {
3963+ version : bitcoin:: transaction:: Version :: TWO ,
3964+ lock_time : LockTime :: ZERO ,
3965+ input : vec ! [ bitcoin:: TxIn {
3966+ previous_output: prev_funding_outpoint,
3967+ ..Default :: default ( )
3968+ } ] ,
3969+ output : vec ! [ TxOut {
3970+ value: Amount :: from_sat( 99_000 ) ,
3971+ script_pubkey: ScriptBuf :: new( ) ,
3972+ } ] ,
3973+ } ;
3974+ wallet. classify_funding ( & splice_tx, & channels, tx_type. clone ( ) ) . await . unwrap ( ) ;
3975+ assert ! ( wallet. payment_store. list_filter( |_| true ) . is_empty( ) ) ;
3976+
3977+ // Control: a funding transaction the wallet participates in is still recorded.
3978+ let script_pubkey = wallet
3979+ . inner
3980+ . lock ( )
3981+ . unwrap ( )
3982+ . reveal_next_address ( KeychainKind :: External )
3983+ . address
3984+ . script_pubkey ( ) ;
3985+ let funded_tx = Transaction {
3986+ version : bitcoin:: transaction:: Version :: TWO ,
3987+ lock_time : LockTime :: ZERO ,
3988+ input : Vec :: new ( ) ,
3989+ output : vec ! [ TxOut { value: Amount :: from_sat( 10_000 ) , script_pubkey } ] ,
3990+ } ;
3991+ wallet. classify_funding ( & funded_tx, & channels, tx_type) . await . unwrap ( ) ;
3992+ let payments = wallet. payment_store . list_filter ( |_| true ) ;
3993+ assert_eq ! ( payments. len( ) , 1 ) ;
3994+ assert_eq ! ( payments[ 0 ] . id, PaymentId ( funded_tx. compute_txid( ) . to_byte_array( ) ) ) ;
3995+ }
3996+
3997+ /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding
3998+ /// path: same txid, but typed as a plain funding transaction with wallet-view figures and no
3999+ /// contribution metadata. The rebroadcast must not overwrite the contribution-derived
4000+ /// figures or the interactive-funding classification — neither while the record is
4001+ /// unconfirmed nor once it confirmed under that same txid, where updates naming the
4002+ /// confirmed txid may otherwise move figures.
4003+ #[ tokio:: test]
4004+ async fn funding_rebroadcast_keeps_interactive_funding_classification ( ) {
4005+ let store: Arc < DynStore > = Arc :: new ( DynStoreWrapper ( InMemoryStore :: new ( ) ) ) ;
4006+ let wallet = new_test_wallet ( store, false ) . await ;
4007+
4008+ // The rebroadcast passes the wallet-activity guard: a splice-in funds the new channel
4009+ // output partly from the wallet, so the wallet sees movement.
4010+ let script_pubkey = wallet
4011+ . inner
4012+ . lock ( )
4013+ . unwrap ( )
4014+ . reveal_next_address ( KeychainKind :: External )
4015+ . address
4016+ . script_pubkey ( ) ;
4017+ let tx = Transaction {
4018+ version : bitcoin:: transaction:: Version :: TWO ,
4019+ lock_time : LockTime :: ZERO ,
4020+ input : Vec :: new ( ) ,
4021+ output : vec ! [ TxOut { value: Amount :: from_sat( 10_000 ) , script_pubkey } ] ,
4022+ } ;
4023+ let txid = tx. compute_txid ( ) ;
4024+ let payment_id = PaymentId ( txid. to_byte_array ( ) ) ;
4025+
4026+ let candidates = vec ! [ FundingTxCandidate {
4027+ txid,
4028+ amount_msat: Some ( 1_000_000 ) ,
4029+ fee_paid_msat: Some ( 500 ) ,
4030+ } ] ;
4031+ let details = interactive_funding_details ( payment_id, txid, Some ( 1_000_000 ) , Some ( 500 ) ) ;
4032+ wallet. persist_funding_payment ( details, candidates) . await . unwrap ( ) ;
4033+
4034+ let counterparty_node_id = PublicKey :: from_str (
4035+ "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ,
4036+ )
4037+ . unwrap ( ) ;
4038+ let channels = vec ! [ ( counterparty_node_id, ChannelId ( [ 7u8 ; 32 ] ) ) ] ;
4039+ let tx_type = TransactionType :: Funding { channels : vec ! [ ] } ;
4040+
4041+ let assert_unchanged = |confirmed : bool | {
4042+ let payments = wallet. payment_store . list_filter ( |_| true ) ;
4043+ assert_eq ! ( payments. len( ) , 1 , "the rebroadcast must not mint a second record" ) ;
4044+ let payment = & payments[ 0 ] ;
4045+ assert_eq ! ( payment. id, payment_id) ;
4046+ assert_eq ! ( payment. amount_msat, Some ( 1_000_000 ) ) ;
4047+ assert_eq ! ( payment. fee_paid_msat, Some ( 500 ) ) ;
4048+ match & payment. kind {
4049+ PaymentKind :: Onchain {
4050+ status,
4051+ tx_type : Some ( TransactionType :: InteractiveFunding { .. } ) ,
4052+ ..
4053+ } => assert_eq ! ( matches!( status, ConfirmationStatus :: Confirmed { .. } ) , confirmed) ,
4054+ kind => panic ! ( "unexpected kind {:?}" , kind) ,
4055+ }
4056+ } ;
4057+
4058+ wallet. classify_funding ( & tx, & channels, tx_type. clone ( ) ) . await . unwrap ( ) ;
4059+ assert_unchanged ( false ) ;
4060+
4061+ // Confirm the record, then replay the rebroadcast: a monitor-update completion can race
4062+ // wallet sync around confirmation.
4063+ let event = WalletEvent :: TxConfirmed {
4064+ txid,
4065+ tx : Arc :: new ( tx. clone ( ) ) ,
4066+ block_time : confirmed_block_time ( 5 ) ,
4067+ old_block_time : None ,
4068+ } ;
4069+ wallet. update_payment_store ( vec ! [ event] ) . await . unwrap ( ) ;
4070+ wallet. classify_funding ( & tx, & channels, tx_type) . await . unwrap ( ) ;
4071+ assert_unchanged ( true ) ;
4072+ }
4073+
38344074 /// Barrier test, classification-first ordering: wallet sync's confirmation handling must
38354075 /// wait for classification's two-store write pair. Classification is parked between its
38364076 /// payment-store and pending-store writes (the torn window) and only then is the
0 commit comments