Skip to content

Commit 13799a4

Browse files
authored
Merge pull request #1049 from jkczyz/2026-08-funding-rebroadcast-workarounds
Guard payment records against splice funding rebroadcasts
2 parents 5e4197b + 925566e commit 13799a4

3 files changed

Lines changed: 477 additions & 1 deletion

File tree

src/wallet/mod.rs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
25852632
fn 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

tests/common/logging.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,47 @@ impl LogWriter for MultiNodeLogger {
173173
print!("{}", log);
174174
}
175175
}
176+
177+
/// Collects every log message a node emits, for tests that assert a specific line was logged.
178+
pub(crate) struct CollectingLogWriter {
179+
logs: Mutex<Vec<String>>,
180+
}
181+
182+
impl CollectingLogWriter {
183+
pub(crate) fn new() -> Self {
184+
Self { logs: Mutex::new(Vec::new()) }
185+
}
186+
187+
pub(crate) fn contains(&self, text: &str) -> bool {
188+
self.count(text) > 0
189+
}
190+
191+
pub(crate) fn count(&self, text: &str) -> usize {
192+
self.logs.lock().unwrap().iter().filter(|message| message.contains(text)).count()
193+
}
194+
195+
/// Waits up to ten seconds for a logged message containing `text`, returning whether one
196+
/// arrived. Polling beats a fixed sleep: it returns as soon as the line lands and only pays
197+
/// the full timeout when the line never comes.
198+
pub(crate) async fn wait_for(&self, text: &str) -> bool {
199+
self.wait_for_count(text, 1).await
200+
}
201+
202+
/// Waits up to ten seconds for `occurrences` logged messages containing `text`, returning
203+
/// whether they arrived.
204+
pub(crate) async fn wait_for_count(&self, text: &str, occurrences: usize) -> bool {
205+
for _ in 0..100 {
206+
if self.count(text) >= occurrences {
207+
return true;
208+
}
209+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
210+
}
211+
false
212+
}
213+
}
214+
215+
impl LogWriter for CollectingLogWriter {
216+
fn log(&self, record: LogRecord) {
217+
self.logs.lock().unwrap().push(record.args.to_string());
218+
}
219+
}

0 commit comments

Comments
 (0)