Skip to content

Commit ddde930

Browse files
committed
Track TransactionType for on-chain payment classification
Introduce a local `TransactionType` enum mirroring LDK's `BroadcasterInterface::TransactionType` variants plus a local `OnchainSend` variant, and attach it to `PaymentKind::Onchain` as `tx_type: Option<TransactionType>`. The `TransactionBroadcaster` now intercepts and records the transaction type per-txid in an in-memory `HashMap` during broadcasting. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer <dev@tnull.de>
1 parent d30c0f3 commit ddde930

File tree

7 files changed

+325
-31
lines changed

7 files changed

+325
-31
lines changed

bindings/ldk_node.udl

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,9 +458,26 @@ interface ClosureReason {
458458
PeerFeerateTooLow(u32 peer_feerate_sat_per_kw, u32 required_feerate_sat_per_kw);
459459
};
460460

461+
dictionary Channel {
462+
PublicKey counterparty_node_id;
463+
ChannelId channel_id;
464+
};
465+
466+
[Enum]
467+
interface TransactionType {
468+
Funding(sequence<Channel> channels);
469+
CooperativeClose(PublicKey counterparty_node_id, ChannelId channel_id);
470+
UnilateralClose(PublicKey counterparty_node_id, ChannelId channel_id);
471+
AnchorBump(PublicKey counterparty_node_id, ChannelId channel_id);
472+
Claim(PublicKey counterparty_node_id, ChannelId channel_id);
473+
Sweep(sequence<Channel> channels);
474+
Splice(PublicKey counterparty_node_id, ChannelId channel_id);
475+
OnchainSend();
476+
};
477+
461478
[Enum]
462479
interface PaymentKind {
463-
Onchain(Txid txid, ConfirmationStatus status);
480+
Onchain(Txid txid, ConfirmationStatus status, TransactionType? tx_type);
464481
Bolt11(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret);
465482
Bolt11Jit(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret, u64? counterparty_skimmed_fee_msat, LSPFeeLimits lsp_fee_limits);
466483
Bolt12Offer(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret, OfferId offer_id, UntrustedString? payer_note, u64? quantity);

src/ffi/types.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ pub use crate::graph::{ChannelInfo, ChannelUpdateInfo, NodeAnnouncementInfo, Nod
5454
pub use crate::liquidity::{LSPS1OrderStatus, LSPS2ServiceConfig};
5555
pub use crate::logger::{LogLevel, LogRecord, LogWriter};
5656
pub use crate::payment::store::{
57-
ConfirmationStatus, LSPFeeLimits, PaymentDirection, PaymentKind, PaymentStatus,
57+
Channel, ConfirmationStatus, LSPFeeLimits, PaymentDirection, PaymentKind, PaymentStatus,
58+
TransactionType,
5859
};
5960
pub use crate::payment::UnifiedPaymentResult;
6061
use crate::{hex_utils, SocketAddress, UniffiCustomTypeConverter, UserChannelId};

src/payment/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub use onchain::OnchainPayment;
2222
pub use pending_payment_store::PendingPaymentDetails;
2323
pub use spontaneous::SpontaneousPayment;
2424
pub use store::{
25-
ConfirmationStatus, LSPFeeLimits, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus,
25+
Channel, ConfirmationStatus, LSPFeeLimits, PaymentDetails, PaymentDirection, PaymentKind,
26+
PaymentStatus, TransactionType,
2627
};
2728
pub use unified::{UnifiedPayment, UnifiedPaymentResult};

src/payment/store.rs

Lines changed: 263 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77

88
use std::time::{Duration, SystemTime, UNIX_EPOCH};
99

10+
use bitcoin::secp256k1::PublicKey;
1011
use bitcoin::{BlockHash, Txid};
12+
use lightning::chain::chaininterface::TransactionType as LdkTransactionType;
1113
use lightning::ln::channelmanager::PaymentId;
1214
use lightning::ln::msgs::DecodeError;
15+
use lightning::ln::types::ChannelId;
1316
use lightning::offers::offer::OfferId;
1417
use lightning::util::ser::{Readable, Writeable};
1518
use lightning::{
@@ -22,6 +25,138 @@ use lightning_types::string::UntrustedString;
2225
use crate::data_store::{StorableObject, StorableObjectId, StorableObjectUpdate};
2326
use crate::hex_utils;
2427

28+
/// A helper struct pairing a counterparty node id with a channel id.
29+
///
30+
/// This is used in [`TransactionType`] variants that may reference one or more channels.
31+
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
32+
pub struct Channel {
33+
/// The `node_id` of the channel counterparty.
34+
pub counterparty_node_id: PublicKey,
35+
/// The ID of the channel.
36+
pub channel_id: ChannelId,
37+
}
38+
39+
impl_writeable_tlv_based!(Channel, {
40+
(0, counterparty_node_id, required),
41+
(2, channel_id, required),
42+
});
43+
44+
/// Represents the class of transaction being broadcast.
45+
///
46+
/// This is used to provide context about the type of transaction being broadcast, which may be
47+
/// useful for logging, filtering, or prioritization purposes.
48+
#[derive(Clone, Debug, PartialEq, Eq)]
49+
pub enum TransactionType {
50+
/// A funding transaction establishing a new channel.
51+
Funding {
52+
/// The counterparty node IDs and channel IDs of the channels being funded.
53+
///
54+
/// A single funding transaction may establish multiple channels when using batch funding.
55+
channels: Vec<Channel>,
56+
},
57+
/// A transaction cooperatively closing a channel.
58+
CooperativeClose {
59+
/// The `node_id` of the channel counterparty.
60+
counterparty_node_id: PublicKey,
61+
/// The ID of the channel being closed.
62+
channel_id: ChannelId,
63+
},
64+
/// A transaction being broadcast to force-close the channel.
65+
UnilateralClose {
66+
/// The `node_id` of the channel counterparty.
67+
counterparty_node_id: PublicKey,
68+
/// The ID of the channel being force-closed.
69+
channel_id: ChannelId,
70+
},
71+
/// An anchor bumping transaction used for CPFP fee-bumping a closing transaction.
72+
AnchorBump {
73+
/// The `node_id` of the channel counterparty.
74+
counterparty_node_id: PublicKey,
75+
/// The ID of the channel whose closing transaction is being fee-bumped.
76+
channel_id: ChannelId,
77+
},
78+
/// A transaction which is resolving an output spendable by both us and our counterparty.
79+
///
80+
/// When a channel closes via the unilateral close path, there may be transaction outputs which
81+
/// are spendable by either our counterparty or us and represent some lightning state. In order
82+
/// to resolve that state, any such outputs will be spent, ensuring funds are only available to
83+
/// us. This transaction is one such transaction - resolving in-flight HTLCs or punishing our
84+
/// counterparty if they broadcasted an outdated state.
85+
Claim {
86+
/// The `node_id` of the channel counterparty.
87+
counterparty_node_id: PublicKey,
88+
/// The ID of the channel from which outputs are being claimed.
89+
channel_id: ChannelId,
90+
},
91+
/// A transaction sweeping spendable outputs to the user's wallet.
92+
Sweep {
93+
/// The counterparty node IDs and channel IDs from which outputs are being swept, if known.
94+
///
95+
/// A single sweep transaction may aggregate outputs from multiple channels.
96+
channels: Vec<Channel>,
97+
},
98+
/// A splice transaction modifying an existing channel's funding.
99+
Splice {
100+
/// The `node_id` of the channel counterparty.
101+
counterparty_node_id: PublicKey,
102+
/// The ID of the channel being spliced.
103+
channel_id: ChannelId,
104+
},
105+
/// A user-initiated on-chain payment to an external address.
106+
OnchainSend,
107+
}
108+
109+
impl_writeable_tlv_based_enum!(TransactionType,
110+
(0, Funding) => { (0, channels, optional_vec) },
111+
(2, CooperativeClose) => { (0, counterparty_node_id, required), (2, channel_id, required) },
112+
(4, UnilateralClose) => { (0, counterparty_node_id, required), (2, channel_id, required) },
113+
(6, AnchorBump) => { (0, counterparty_node_id, required), (2, channel_id, required) },
114+
(8, Claim) => { (0, counterparty_node_id, required), (2, channel_id, required) },
115+
(10, Sweep) => { (0, channels, optional_vec) },
116+
(12, Splice) => { (0, counterparty_node_id, required), (2, channel_id, required) },
117+
(14, OnchainSend) => {}
118+
);
119+
120+
impl From<LdkTransactionType> for TransactionType {
121+
fn from(ldk_type: LdkTransactionType) -> Self {
122+
match ldk_type {
123+
LdkTransactionType::Funding { channels } => TransactionType::Funding {
124+
channels: channels
125+
.into_iter()
126+
.map(|(node_id, chan_id)| Channel {
127+
counterparty_node_id: node_id,
128+
channel_id: chan_id,
129+
})
130+
.collect(),
131+
},
132+
LdkTransactionType::CooperativeClose { counterparty_node_id, channel_id } => {
133+
TransactionType::CooperativeClose { counterparty_node_id, channel_id }
134+
},
135+
LdkTransactionType::UnilateralClose { counterparty_node_id, channel_id } => {
136+
TransactionType::UnilateralClose { counterparty_node_id, channel_id }
137+
},
138+
LdkTransactionType::AnchorBump { counterparty_node_id, channel_id } => {
139+
TransactionType::AnchorBump { counterparty_node_id, channel_id }
140+
},
141+
LdkTransactionType::Claim { counterparty_node_id, channel_id } => {
142+
TransactionType::Claim { counterparty_node_id, channel_id }
143+
},
144+
LdkTransactionType::Sweep { channels } => TransactionType::Sweep {
145+
channels: channels
146+
.into_iter()
147+
.map(|(node_id, chan_id)| Channel {
148+
counterparty_node_id: node_id,
149+
channel_id: chan_id,
150+
})
151+
.collect(),
152+
},
153+
LdkTransactionType::Splice { counterparty_node_id, channel_id } => {
154+
TransactionType::Splice { counterparty_node_id, channel_id }
155+
},
156+
}
157+
}
158+
}
159+
25160
/// Represents a payment.
26161
#[derive(Clone, Debug, PartialEq, Eq)]
27162
pub struct PaymentDetails {
@@ -291,6 +426,19 @@ impl StorableObject for PaymentDetails {
291426
}
292427
}
293428

429+
if let Some(Some(ref new_tx_type)) = update.tx_type {
430+
match self.kind {
431+
PaymentKind::Onchain { ref mut tx_type, .. } => {
432+
debug_assert!(
433+
tx_type.is_none() || tx_type.as_ref() == Some(new_tx_type),
434+
"We should never change a transaction type after being initially set"
435+
);
436+
update_if_necessary!(*tx_type, Some(new_tx_type.clone()));
437+
},
438+
_ => {},
439+
}
440+
}
441+
294442
if updated {
295443
self.latest_update_timestamp = SystemTime::now()
296444
.duration_since(UNIX_EPOCH)
@@ -351,6 +499,8 @@ pub enum PaymentKind {
351499
txid: Txid,
352500
/// The confirmation status of this payment.
353501
status: ConfirmationStatus,
502+
/// The type of the on-chain transaction, if known.
503+
tx_type: Option<TransactionType>,
354504
},
355505
/// A [BOLT 11] payment.
356506
///
@@ -448,6 +598,7 @@ pub enum PaymentKind {
448598
impl_writeable_tlv_based_enum!(PaymentKind,
449599
(0, Onchain) => {
450600
(0, txid, required),
601+
(1, tx_type, option),
451602
(2, status, required),
452603
},
453604
(2, Bolt11) => {
@@ -540,6 +691,7 @@ pub(crate) struct PaymentDetailsUpdate {
540691
pub direction: Option<PaymentDirection>,
541692
pub status: Option<PaymentStatus>,
542693
pub confirmation_status: Option<ConfirmationStatus>,
694+
pub tx_type: Option<Option<TransactionType>>,
543695
}
544696

545697
impl PaymentDetailsUpdate {
@@ -555,6 +707,7 @@ impl PaymentDetailsUpdate {
555707
direction: None,
556708
status: None,
557709
confirmation_status: None,
710+
tx_type: None,
558711
}
559712
}
560713
}
@@ -570,9 +723,11 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
570723
_ => (None, None, None),
571724
};
572725

573-
let confirmation_status = match value.kind {
574-
PaymentKind::Onchain { status, .. } => Some(status),
575-
_ => None,
726+
let (confirmation_status, tx_type) = match value.kind {
727+
PaymentKind::Onchain { status, ref tx_type, .. } => {
728+
(Some(status), Some(tx_type.clone()))
729+
},
730+
_ => (None, None),
576731
};
577732

578733
let counterparty_skimmed_fee_msat = match value.kind {
@@ -593,6 +748,7 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
593748
direction: Some(value.direction),
594749
status: Some(value.status),
595750
confirmation_status,
751+
tx_type,
596752
}
597753
}
598754
}
@@ -761,4 +917,108 @@ mod tests {
761917
}
762918
}
763919
}
920+
921+
#[test]
922+
fn transaction_type_serialization_roundtrip() {
923+
use lightning::util::ser::Writeable;
924+
925+
let pubkey = PublicKey::from_slice(&[
926+
2, 238, 199, 36, 93, 107, 125, 44, 203, 48, 56, 11, 251, 226, 163, 100, 140, 215, 169,
927+
66, 101, 63, 90, 163, 64, 237, 206, 161, 242, 131, 104, 102, 25,
928+
])
929+
.unwrap();
930+
let channel_id = ChannelId([42u8; 32]);
931+
let channel = Channel { counterparty_node_id: pubkey, channel_id };
932+
933+
// Test all variants
934+
let variants: Vec<TransactionType> = vec![
935+
TransactionType::Funding { channels: vec![channel.clone()] },
936+
TransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id },
937+
TransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id },
938+
TransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id },
939+
TransactionType::Claim { counterparty_node_id: pubkey, channel_id },
940+
TransactionType::Sweep { channels: vec![channel.clone()] },
941+
TransactionType::Splice { counterparty_node_id: pubkey, channel_id },
942+
TransactionType::OnchainSend,
943+
// Also test empty channel lists
944+
TransactionType::Funding { channels: vec![] },
945+
TransactionType::Sweep { channels: vec![] },
946+
];
947+
948+
for variant in variants {
949+
let encoded = variant.encode();
950+
let decoded = TransactionType::read(&mut &*encoded).unwrap();
951+
assert_eq!(variant, decoded);
952+
}
953+
}
954+
955+
#[test]
956+
fn onchain_payment_kind_backward_compat() {
957+
// Simulate the old serialization format for PaymentKind::Onchain (without tx_type).
958+
// The old format only had fields (0, txid) and (2, status).
959+
use bitcoin::hashes::Hash;
960+
use lightning::util::ser::Writeable;
961+
962+
let txid = bitcoin::Txid::from_slice(&[42u8; 32]).unwrap();
963+
let status = ConfirmationStatus::Unconfirmed;
964+
965+
// Manually create a PaymentKind::Onchain with tx_type = None and verify roundtrip.
966+
let kind = PaymentKind::Onchain { txid, status, tx_type: None };
967+
let encoded = kind.encode();
968+
let decoded = PaymentKind::read(&mut &*encoded).unwrap();
969+
assert_eq!(kind, decoded);
970+
971+
// Also test with tx_type = Some
972+
let kind_with_type =
973+
PaymentKind::Onchain { txid, status, tx_type: Some(TransactionType::OnchainSend) };
974+
let encoded_with_type = kind_with_type.encode();
975+
let decoded_with_type = PaymentKind::read(&mut &*encoded_with_type).unwrap();
976+
assert_eq!(kind_with_type, decoded_with_type);
977+
978+
// Verify that the encoding without tx_type is shorter (backward compat: old readers
979+
// skip the unknown odd field).
980+
assert!(encoded.len() < encoded_with_type.len());
981+
}
982+
983+
#[test]
984+
fn from_ldk_transaction_type() {
985+
let pubkey = PublicKey::from_slice(&[
986+
2, 238, 199, 36, 93, 107, 125, 44, 203, 48, 56, 11, 251, 226, 163, 100, 140, 215, 169,
987+
66, 101, 63, 90, 163, 64, 237, 206, 161, 242, 131, 104, 102, 25,
988+
])
989+
.unwrap();
990+
let channel_id = ChannelId([42u8; 32]);
991+
992+
// Test Funding conversion
993+
let ldk_funding = LdkTransactionType::Funding { channels: vec![(pubkey, channel_id)] };
994+
let local_funding = TransactionType::from(ldk_funding);
995+
assert_eq!(
996+
local_funding,
997+
TransactionType::Funding {
998+
channels: vec![Channel { counterparty_node_id: pubkey, channel_id }]
999+
}
1000+
);
1001+
1002+
// Test CooperativeClose conversion
1003+
let ldk_coop =
1004+
LdkTransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id };
1005+
let local_coop = TransactionType::from(ldk_coop);
1006+
assert_eq!(
1007+
local_coop,
1008+
TransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }
1009+
);
1010+
1011+
// Test Sweep conversion with empty channels
1012+
let ldk_sweep = LdkTransactionType::Sweep { channels: vec![] };
1013+
let local_sweep = TransactionType::from(ldk_sweep);
1014+
assert_eq!(local_sweep, TransactionType::Sweep { channels: vec![] });
1015+
1016+
// Test Splice conversion
1017+
let ldk_splice = LdkTransactionType::Splice { counterparty_node_id: pubkey, channel_id };
1018+
let local_splice = TransactionType::from(ldk_splice);
1019+
assert_eq!(
1020+
local_splice,
1021+
TransactionType::Splice { counterparty_node_id: pubkey, channel_id }
1022+
);
1023+
}
7641024
}

0 commit comments

Comments
 (0)