Skip to content

Commit 942087b

Browse files
authored
Merge pull request #791 from tnull/2026-02-track-onchain-tx-types
Classify onchain transactions
2 parents f04fa4d + d66e53f commit 942087b

5 files changed

Lines changed: 326 additions & 132 deletions

File tree

‎src/payment/store.rs‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,9 @@ impl StorableObject for PaymentDetails {
288288
if let Some(tx_type_update) = update.tx_type {
289289
match self.kind {
290290
PaymentKind::Onchain { ref mut tx_type, .. } => {
291-
update_if_necessary!(*tx_type, tx_type_update);
291+
if tx_type.is_none() || tx_type_update.is_some() {
292+
update_if_necessary!(*tx_type, tx_type_update);
293+
}
292294
},
293295
_ => {},
294296
}
@@ -922,6 +924,103 @@ mod tests {
922924
assert_eq!(kind, PaymentKind::read(&mut &*kind.encode()).unwrap());
923925
}
924926

927+
#[test]
928+
fn known_onchain_tx_type_survives_unknown_update() {
929+
use bitcoin::hashes::Hash;
930+
use std::str::FromStr;
931+
932+
let txid = Txid::from_byte_array([8u8; 32]);
933+
let payment_id = PaymentId(txid.to_byte_array());
934+
let pubkey = PublicKey::from_str(
935+
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
936+
)
937+
.unwrap();
938+
let tx_type = TransactionType::CooperativeClose {
939+
counterparty_node_id: pubkey,
940+
channel_id: ChannelId([4u8; 32]),
941+
};
942+
let mut classified = PaymentDetails::new(
943+
payment_id,
944+
PaymentKind::Onchain {
945+
txid,
946+
status: ConfirmationStatus::Unconfirmed,
947+
tx_type: Some(tx_type.clone()),
948+
},
949+
Some(1_000),
950+
Some(100),
951+
PaymentDirection::Inbound,
952+
PaymentStatus::Pending,
953+
);
954+
let wallet_sync_update = PaymentDetails::new(
955+
payment_id,
956+
PaymentKind::Onchain {
957+
txid,
958+
status: ConfirmationStatus::Confirmed {
959+
block_hash: BlockHash::from_byte_array([9u8; 32]),
960+
height: 42,
961+
timestamp: 123,
962+
},
963+
tx_type: None,
964+
},
965+
Some(1_000),
966+
Some(100),
967+
PaymentDirection::Inbound,
968+
PaymentStatus::Pending,
969+
);
970+
971+
assert!(classified.update(PaymentDetailsUpdate::from(&wallet_sync_update)));
972+
match classified.kind {
973+
PaymentKind::Onchain { status, tx_type: Some(updated_tx_type), .. } => {
974+
assert!(matches!(status, ConfirmationStatus::Confirmed { height: 42, .. }));
975+
assert_eq!(updated_tx_type, tx_type);
976+
},
977+
other => panic!("Unexpected payment kind: {:?}", other),
978+
}
979+
}
980+
981+
#[test]
982+
fn transaction_type_from_ldk_variants() {
983+
use std::str::FromStr;
984+
985+
let pubkey = PublicKey::from_str(
986+
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
987+
)
988+
.unwrap();
989+
let channel_id = ChannelId([5u8; 32]);
990+
let channel = Channel { counterparty_node_id: pubkey, channel_id };
991+
992+
let variants = vec![
993+
(
994+
LdkTransactionType::Funding { channels: vec![(pubkey, channel_id)] },
995+
TransactionType::Funding { channels: vec![channel.clone()] },
996+
),
997+
(
998+
LdkTransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id },
999+
TransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id },
1000+
),
1001+
(
1002+
LdkTransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id },
1003+
TransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id },
1004+
),
1005+
(
1006+
LdkTransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id },
1007+
TransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id },
1008+
),
1009+
(
1010+
LdkTransactionType::Claim { counterparty_node_id: pubkey, channel_id },
1011+
TransactionType::Claim { counterparty_node_id: pubkey, channel_id },
1012+
),
1013+
(
1014+
LdkTransactionType::Sweep { channels: vec![(pubkey, channel_id)] },
1015+
TransactionType::Sweep { channels: vec![channel] },
1016+
),
1017+
];
1018+
1019+
for (ldk_type, expected_type) in variants {
1020+
assert_eq!(TransactionType::from(ldk_type), expected_type);
1021+
}
1022+
}
1023+
9251024
#[derive(Clone, Debug, PartialEq, Eq)]
9261025
struct LegacyBolt11JitKind {
9271026
hash: PaymentHash,

‎src/tx_broadcaster.rs‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ use std::ops::Deref;
99
use std::sync::{Mutex as StdMutex, Weak};
1010

1111
use bitcoin::Transaction;
12-
use lightning::chain::chaininterface::{BroadcasterInterface, TransactionType};
12+
use lightning::chain::chaininterface::{
13+
BroadcasterInterface, TransactionType as LdkTransactionType,
14+
};
1315
use tokio::sync::{mpsc, Mutex, MutexGuard};
1416

1517
use crate::logger::{log_error, LdkLogger};
@@ -22,16 +24,21 @@ const BCAST_PACKAGE_QUEUE_SIZE: usize = 50;
2224
/// call, along with each transaction's type. Queued until the background task classifies and
2325
/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated
2426
/// transactions can't be grouped into one package by accident.
25-
pub(crate) struct BroadcastPackage(Vec<(Transaction, TransactionType)>);
27+
pub(crate) struct BroadcastPackage(Vec<(Transaction, Option<LdkTransactionType>)>);
2628

2729
impl BroadcastPackage {
2830
/// Builds a package from the transactions of a single `broadcast_transactions` call.
29-
fn new(txs: &[(&Transaction, TransactionType)]) -> Self {
30-
Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), tx_type.clone())).collect())
31+
fn new(txs: &[(&Transaction, LdkTransactionType)]) -> Self {
32+
Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), Some(tx_type.clone()))).collect())
33+
}
34+
35+
/// Builds a package for wallet-originated broadcasts that have no LDK classification.
36+
fn unclassified(txs: Vec<Transaction>) -> Self {
37+
Self(txs.into_iter().map(|tx| (tx, None)).collect())
3138
}
3239

3340
/// The packaged transactions and their types, for classification.
34-
fn transactions(&self) -> &[(Transaction, TransactionType)] {
41+
fn transactions(&self) -> &[(Transaction, Option<LdkTransactionType>)] {
3542
&self.0
3643
}
3744

@@ -91,18 +98,26 @@ where
9198
let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade);
9299
if let Some(wallet) = wallet_opt {
93100
for (tx, tx_type) in package.transactions() {
94-
wallet.classify_broadcast(tx, tx_type).await?;
101+
if let Some(tx_type) = tx_type {
102+
wallet.classify_broadcast(tx, tx_type).await?;
103+
}
95104
}
96105
}
97106
Ok(package)
98107
}
108+
109+
pub(crate) fn broadcast_unclassified_transactions(&self, txs: Vec<Transaction>) {
110+
self.queue_sender.try_send(BroadcastPackage::unclassified(txs)).unwrap_or_else(|e| {
111+
log_error!(self.logger, "Failed to broadcast transactions: {}", e);
112+
});
113+
}
99114
}
100115

101116
impl<L: Deref> BroadcasterInterface for TransactionBroadcaster<L>
102117
where
103118
L::Target: LdkLogger,
104119
{
105-
fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) {
120+
fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) {
106121
self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| {
107122
log_error!(self.logger, "Failed to broadcast transactions: {}", e);
108123
});

‎src/wallet/mod.rs‎

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ use bitcoin::{
3232
WPubkeyHash, Weight, WitnessProgram, WitnessVersion,
3333
};
3434
use lightning::chain::chaininterface::{
35-
BroadcasterInterface, FundingCandidate, TransactionType as LdkTransactionType,
35+
FundingCandidate, TransactionType as LdkTransactionType,
3636
INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT,
3737
};
3838
use lightning::chain::channelmonitor::ANTI_REORG_DELAY;
@@ -335,21 +335,12 @@ impl Wallet {
335335
.collect();
336336

337337
if !txs_to_broadcast.is_empty() {
338-
let tx_refs: Vec<(
339-
&Transaction,
340-
lightning::chain::chaininterface::TransactionType,
341-
)> =
342-
txs_to_broadcast
343-
.iter()
344-
.map(|tx| {
345-
(tx, lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] })
346-
})
347-
.collect();
348-
self.broadcaster.broadcast_transactions(&tx_refs);
338+
let tx_count = txs_to_broadcast.len();
339+
self.broadcaster.broadcast_unclassified_transactions(txs_to_broadcast);
349340
log_info!(
350341
self.logger,
351342
"Rebroadcast {} unconfirmed transactions on chain tip change",
352-
txs_to_broadcast.len()
343+
tx_count
353344
);
354345
}
355346
}
@@ -889,12 +880,8 @@ impl Wallet {
889880
})?
890881
};
891882

892-
self.broadcaster.broadcast_transactions(&[(
893-
&tx,
894-
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
895-
)]);
896-
897883
let txid = tx.compute_txid();
884+
self.broadcaster.broadcast_unclassified_transactions(vec![tx]);
898885

899886
match send_amount {
900887
OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => {
@@ -1184,9 +1171,8 @@ impl Wallet {
11841171
Ok(tx)
11851172
}
11861173

1187-
/// Classifies a funding broadcast (channel open or splice) handed to the broadcaster by LDK,
1188-
/// recording a payment for it before it is sent. Other transaction types are left for wallet
1189-
/// sync to record normally.
1174+
/// Classifies an on-chain broadcast handed to the broadcaster by LDK, recording a payment for it
1175+
/// before it is sent when it affects this node's wallet.
11901176
pub(crate) async fn classify_broadcast(
11911177
&self, tx: &Transaction, tx_type: &LdkTransactionType,
11921178
) -> Result<(), Error> {
@@ -1197,7 +1183,13 @@ impl Wallet {
11971183
LdkTransactionType::InteractiveFunding { candidates } => {
11981184
self.classify_interactive_funding(tx, candidates, tx_type.clone().into()).await
11991185
},
1200-
_ => Ok(()),
1186+
LdkTransactionType::CooperativeClose { .. }
1187+
| LdkTransactionType::UnilateralClose { .. }
1188+
| LdkTransactionType::AnchorBump { .. }
1189+
| LdkTransactionType::Claim { .. }
1190+
| LdkTransactionType::Sweep { .. } => {
1191+
self.classify_regular_broadcast(tx, tx_type.clone().into()).await
1192+
},
12011193
}
12021194
}
12031195

@@ -1338,6 +1330,40 @@ impl Wallet {
13381330
Ok(())
13391331
}
13401332

1333+
/// Records a non-funding LDK broadcast as an on-chain payment, tagged with its transaction type.
1334+
/// Wallet sync later refreshes confirmation status while preserving the type.
1335+
async fn classify_regular_broadcast(
1336+
&self, tx: &Transaction, tx_type: TransactionType,
1337+
) -> Result<(), Error> {
1338+
let txid = tx.compute_txid();
1339+
let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx);
1340+
1341+
if amount_msat == Some(0) && fee_paid_msat == Some(0) {
1342+
log_trace!(
1343+
self.logger,
1344+
"Not recording classified broadcast {} as a payment: no wallet-level activity",
1345+
txid,
1346+
);
1347+
return Ok(());
1348+
}
1349+
1350+
let details = PaymentDetails::new(
1351+
PaymentId(txid.to_byte_array()),
1352+
PaymentKind::Onchain {
1353+
txid,
1354+
status: ConfirmationStatus::Unconfirmed,
1355+
tx_type: Some(tx_type),
1356+
},
1357+
amount_msat,
1358+
fee_paid_msat,
1359+
direction,
1360+
PaymentStatus::Pending,
1361+
);
1362+
self.payment_store.insert_or_update(details).await?;
1363+
log_debug!(self.logger, "Recorded classified on-chain broadcast {}", txid);
1364+
Ok(())
1365+
}
1366+
13411367
/// Writes a freshly-classified funding payment to the authoritative payment store and adds a
13421368
/// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`.
13431369
async fn persist_funding_payment(
@@ -1719,11 +1745,6 @@ impl Wallet {
17191745

17201746
let new_txid = fee_bumped_tx.compute_txid();
17211747

1722-
self.broadcaster.broadcast_transactions(&[(
1723-
&fee_bumped_tx,
1724-
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
1725-
)]);
1726-
17271748
let new_payment = self.create_payment_from_tx(
17281749
&locked_wallet,
17291750
new_txid,
@@ -1736,9 +1757,11 @@ impl Wallet {
17361757
let pending_payment_store =
17371758
self.create_pending_payment_from_tx(new_payment.clone(), Vec::new());
17381759

1760+
self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?;
17391761
self.runtime
17401762
.block_on(self.pending_payment_store.insert_or_update(pending_payment_store))?;
1741-
self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?;
1763+
1764+
self.broadcaster.broadcast_unclassified_transactions(vec![fee_bumped_tx]);
17421765

17431766
log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid);
17441767

0 commit comments

Comments
 (0)