Skip to content

Commit 93797a2

Browse files
jkczyzclaude
andcommitted
Classify on-chain payments with a durable transaction type
On-chain payment records don't capture what a transaction was for -- a channel open, splice, close, sweep, or a plain send. Record that classification on each on-chain payment, derived from the type LDK reports when broadcasting the transaction, so it survives restarts alongside the payment. The tag keeps only which channels a transaction relates to; amounts and fees stay on the payment. Existing records keep decoding unchanged. Compatible with the on-chain transaction classification proposed in lightningdevkit#791. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ad4a3a8 commit 93797a2

5 files changed

Lines changed: 234 additions & 9 deletions

File tree

src/payment/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pub use onchain::OnchainPayment;
2323
pub(crate) use pending_payment_store::PendingPaymentDetails;
2424
pub use spontaneous::SpontaneousPayment;
2525
pub use store::{
26-
ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind,
27-
PaymentStatus,
26+
Channel, ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind,
27+
PaymentStatus, TransactionType,
2828
};
2929
pub use unified::{UnifiedPayment, UnifiedPaymentResult};

src/payment/pending_payment_store.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ mod tests {
108108
fn pending_onchain_payment(payment_id: PaymentId, txid: Txid) -> PaymentDetails {
109109
PaymentDetails::new(
110110
payment_id,
111-
PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed },
111+
PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None },
112112
Some(1_000),
113113
Some(100),
114114
PaymentDirection::Outbound,

src/payment/store.rs

Lines changed: 227 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::{
@@ -282,6 +285,15 @@ impl StorableObject for PaymentDetails {
282285
}
283286
}
284287

288+
if let Some(tx_type_update) = update.tx_type {
289+
match self.kind {
290+
PaymentKind::Onchain { ref mut tx_type, .. } => {
291+
update_if_necessary!(*tx_type, tx_type_update);
292+
},
293+
_ => {},
294+
}
295+
}
296+
285297
if updated {
286298
self.latest_update_timestamp = SystemTime::now()
287299
.duration_since(UNIX_EPOCH)
@@ -330,6 +342,156 @@ impl_writeable_tlv_based_enum!(PaymentStatus,
330342
(4, Failed) => {}
331343
);
332344

345+
/// A channel referenced by a [`TransactionType`].
346+
#[derive(Clone, Debug, PartialEq, Eq)]
347+
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
348+
pub struct Channel {
349+
/// The `node_id` of the channel counterparty.
350+
pub counterparty_node_id: PublicKey,
351+
/// The ID of the channel.
352+
pub channel_id: ChannelId,
353+
}
354+
355+
impl_writeable_tlv_based!(Channel, {
356+
(0, counterparty_node_id, required),
357+
(2, channel_id, required),
358+
});
359+
360+
/// The classification of a [`PaymentKind::Onchain`] transaction, as reported by LDK when the
361+
/// transaction was broadcast.
362+
///
363+
/// Mirrors [`lightning::chain::chaininterface::TransactionType`], retaining the channel references
364+
/// but dropping the broadcast-time contribution data; a transaction's amount and fee are tracked on
365+
/// the [`PaymentDetails`] itself.
366+
#[derive(Clone, Debug, PartialEq, Eq)]
367+
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
368+
pub enum TransactionType {
369+
/// A funding transaction establishing one or more new channels.
370+
Funding {
371+
/// The channels being funded.
372+
channels: Vec<Channel>,
373+
},
374+
/// A transaction cooperatively closing a channel.
375+
CooperativeClose {
376+
/// The `node_id` of the channel counterparty.
377+
counterparty_node_id: PublicKey,
378+
/// The ID of the channel being closed.
379+
channel_id: ChannelId,
380+
},
381+
/// A transaction force-closing a channel.
382+
UnilateralClose {
383+
/// The `node_id` of the channel counterparty.
384+
counterparty_node_id: PublicKey,
385+
/// The ID of the channel being force-closed.
386+
channel_id: ChannelId,
387+
},
388+
/// An anchor transaction CPFP fee-bumping a closing transaction.
389+
AnchorBump {
390+
/// The `node_id` of the channel counterparty.
391+
counterparty_node_id: PublicKey,
392+
/// The ID of the channel whose closing transaction is being fee-bumped.
393+
channel_id: ChannelId,
394+
},
395+
/// A transaction resolving an output spendable by both us and our counterparty.
396+
Claim {
397+
/// The `node_id` of the channel counterparty.
398+
counterparty_node_id: PublicKey,
399+
/// The ID of the channel from which outputs are being claimed.
400+
channel_id: ChannelId,
401+
},
402+
/// A transaction sweeping spendable outputs to the on-chain wallet.
403+
Sweep {
404+
/// The channels from which outputs are being swept, if known.
405+
channels: Vec<Channel>,
406+
},
407+
/// An interactively-negotiated funding transaction: a splice, or (once supported) a V2
408+
/// dual-funded channel open.
409+
InteractiveFunding {
410+
/// The channels participating in the negotiation.
411+
channels: Vec<Channel>,
412+
},
413+
}
414+
415+
impl_writeable_tlv_based_enum!(TransactionType,
416+
(0, Funding) => {
417+
(0, channels, optional_vec),
418+
},
419+
(2, CooperativeClose) => {
420+
(0, counterparty_node_id, required),
421+
(2, channel_id, required),
422+
},
423+
(4, UnilateralClose) => {
424+
(0, counterparty_node_id, required),
425+
(2, channel_id, required),
426+
},
427+
(6, AnchorBump) => {
428+
(0, counterparty_node_id, required),
429+
(2, channel_id, required),
430+
},
431+
(8, Claim) => {
432+
(0, counterparty_node_id, required),
433+
(2, channel_id, required),
434+
},
435+
(10, Sweep) => {
436+
(0, channels, optional_vec),
437+
},
438+
(12, InteractiveFunding) => {
439+
(0, channels, optional_vec),
440+
}
441+
);
442+
443+
impl From<LdkTransactionType> for TransactionType {
444+
fn from(tx_type: LdkTransactionType) -> Self {
445+
let to_channels = |channels: Vec<(PublicKey, ChannelId)>| -> Vec<Channel> {
446+
channels
447+
.into_iter()
448+
.map(|(counterparty_node_id, channel_id)| Channel {
449+
counterparty_node_id,
450+
channel_id,
451+
})
452+
.collect()
453+
};
454+
match tx_type {
455+
LdkTransactionType::Funding { channels } => {
456+
TransactionType::Funding { channels: to_channels(channels) }
457+
},
458+
LdkTransactionType::CooperativeClose { counterparty_node_id, channel_id } => {
459+
TransactionType::CooperativeClose { counterparty_node_id, channel_id }
460+
},
461+
LdkTransactionType::UnilateralClose { counterparty_node_id, channel_id } => {
462+
TransactionType::UnilateralClose { counterparty_node_id, channel_id }
463+
},
464+
LdkTransactionType::AnchorBump { counterparty_node_id, channel_id } => {
465+
TransactionType::AnchorBump { counterparty_node_id, channel_id }
466+
},
467+
LdkTransactionType::Claim { counterparty_node_id, channel_id } => {
468+
TransactionType::Claim { counterparty_node_id, channel_id }
469+
},
470+
LdkTransactionType::Sweep { channels } => {
471+
TransactionType::Sweep { channels: to_channels(channels) }
472+
},
473+
LdkTransactionType::InteractiveFunding { candidates } => {
474+
// Every candidate (the original negotiation plus any RBF replacements) references
475+
// the same channel(s); take the active (last) candidate's channel references.
476+
let channels = candidates
477+
.last()
478+
.map(|candidate| {
479+
candidate
480+
.channels
481+
.iter()
482+
.map(|cf| Channel {
483+
counterparty_node_id: cf.counterparty_node_id,
484+
channel_id: cf.channel_id,
485+
})
486+
.collect()
487+
})
488+
.unwrap_or_default();
489+
TransactionType::InteractiveFunding { channels }
490+
},
491+
}
492+
}
493+
}
494+
333495
/// Represents the kind of a payment.
334496
#[derive(Clone, Debug, PartialEq, Eq)]
335497
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
@@ -345,6 +507,11 @@ pub enum PaymentKind {
345507
txid: Txid,
346508
/// The confirmation status of this payment.
347509
status: ConfirmationStatus,
510+
/// The classification of this transaction, if known.
511+
///
512+
/// `None` for plain on-chain sends, and for records written by versions of LDK Node that
513+
/// predate on-chain transaction classification.
514+
tx_type: Option<TransactionType>,
348515
},
349516
/// A [BOLT 11] payment.
350517
///
@@ -423,6 +590,7 @@ pub enum PaymentKind {
423590
impl_writeable_tlv_based_enum!(PaymentKind,
424591
(0, Onchain) => {
425592
(0, txid, required),
593+
(1, tx_type, option),
426594
(2, status, required),
427595
},
428596
(2, Bolt11) => {
@@ -522,6 +690,7 @@ pub(crate) struct PaymentDetailsUpdate {
522690
pub status: Option<PaymentStatus>,
523691
pub confirmation_status: Option<ConfirmationStatus>,
524692
pub txid: Option<Txid>,
693+
pub tx_type: Option<Option<TransactionType>>,
525694
}
526695

527696
impl PaymentDetailsUpdate {
@@ -538,6 +707,7 @@ impl PaymentDetailsUpdate {
538707
status: None,
539708
confirmation_status: None,
540709
txid: None,
710+
tx_type: None,
541711
}
542712
}
543713
}
@@ -552,9 +722,11 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
552722
_ => (None, None, None),
553723
};
554724

555-
let (confirmation_status, txid) = match &value.kind {
556-
PaymentKind::Onchain { status, txid, .. } => (Some(*status), Some(*txid)),
557-
_ => (None, None),
725+
let (confirmation_status, txid, tx_type) = match &value.kind {
726+
PaymentKind::Onchain { status, txid, tx_type } => {
727+
(Some(*status), Some(*txid), Some(tx_type.clone()))
728+
},
729+
_ => (None, None, None),
558730
};
559731

560732
let counterparty_skimmed_fee_msat = match value.kind {
@@ -576,6 +748,7 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
576748
status: Some(value.status),
577749
confirmation_status,
578750
txid,
751+
tx_type,
579752
}
580753
}
581754
}
@@ -697,6 +870,57 @@ mod tests {
697870
}
698871
}
699872

873+
#[derive(Clone, Debug, PartialEq, Eq)]
874+
struct OldOnchainKind {
875+
txid: Txid,
876+
status: ConfirmationStatus,
877+
}
878+
879+
impl_writeable_tlv_based!(OldOnchainKind, {
880+
(0, txid, required),
881+
(2, status, required),
882+
});
883+
884+
#[test]
885+
fn onchain_tx_type_deser_compat() {
886+
use bitcoin::hashes::Hash;
887+
use std::str::FromStr;
888+
889+
let txid = Txid::from_byte_array([7u8; 32]);
890+
let status = ConfirmationStatus::Unconfirmed;
891+
892+
// An `Onchain` record written before `tx_type` existed (only txid + status) must read back
893+
// with `tx_type: None`.
894+
let old = OldOnchainKind { txid, status };
895+
let mut on_disk = Vec::new();
896+
0u8.write(&mut on_disk).unwrap(); // the `Onchain` enum discriminant
897+
on_disk.extend_from_slice(&old.encode());
898+
match PaymentKind::read(&mut &*on_disk).unwrap() {
899+
PaymentKind::Onchain { txid: t, status: s, tx_type } => {
900+
assert_eq!(t, txid);
901+
assert_eq!(s, status);
902+
assert_eq!(tx_type, None);
903+
},
904+
other => panic!("Unexpected kind: {:?}", other),
905+
}
906+
907+
// A populated `tx_type` round-trips.
908+
let kind = PaymentKind::Onchain {
909+
txid,
910+
status,
911+
tx_type: Some(TransactionType::InteractiveFunding {
912+
channels: vec![Channel {
913+
counterparty_node_id: PublicKey::from_str(
914+
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
915+
)
916+
.unwrap(),
917+
channel_id: ChannelId([3u8; 32]),
918+
}],
919+
}),
920+
};
921+
assert_eq!(kind, PaymentKind::read(&mut &*kind.encode()).unwrap());
922+
}
923+
700924
#[derive(Clone, Debug, PartialEq, Eq)]
701925
struct LegacyBolt11JitKind {
702926
hash: PaymentHash,

src/wallet/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ impl Wallet {
310310
PaymentKind::Onchain {
311311
txid,
312312
status: ConfirmationStatus::Unconfirmed,
313+
..
313314
} if payment.details.direction == PaymentDirection::Outbound => {
314315
unconfirmed_outbound_txids.push(txid);
315316
},
@@ -1171,7 +1172,7 @@ impl Wallet {
11711172
// here to determine the `PaymentKind`, but that's not really satisfactory, so
11721173
// we're punting on it until we can come up with a better solution.
11731174

1174-
let kind = PaymentKind::Onchain { txid, status: confirmation_status };
1175+
let kind = PaymentKind::Onchain { txid, status: confirmation_status, tx_type: None };
11751176

11761177
let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO);
11771178
let (sent, received) = locked_wallet.sent_and_received(tx);

tests/integration_tests_rust.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ async fn onchain_send_receive() {
610610

611611
let payment_a = node_a.payment(&payment_id).unwrap();
612612
match payment_a.kind {
613-
PaymentKind::Onchain { txid: _txid, status } => {
613+
PaymentKind::Onchain { txid: _txid, status, .. } => {
614614
assert_eq!(_txid, txid);
615615
assert!(matches!(status, ConfirmationStatus::Confirmed { .. }));
616616
},
@@ -619,7 +619,7 @@ async fn onchain_send_receive() {
619619

620620
let payment_b = node_a.payment(&payment_id).unwrap();
621621
match payment_b.kind {
622-
PaymentKind::Onchain { txid: _txid, status } => {
622+
PaymentKind::Onchain { txid: _txid, status, .. } => {
623623
assert_eq!(_txid, txid);
624624
assert!(matches!(status, ConfirmationStatus::Confirmed { .. }));
625625
},

0 commit comments

Comments
 (0)