Skip to content

Commit 441630c

Browse files
jkczyzclaude
andcommitted
Tie funding payment status transitions to Lightning lifecycle events
Channel-opening and splice transactions transition to Succeeded when ChannelReady fires, not after ANTI_REORG_DELAY confirmations. This matches the point at which the Lightning layer considers the channel usable: a zero-conf channel graduates as soon as its counterparty signals, and a high-conf channel waits however many confirmations the peer requires, rather than always stopping at six. For splice RBF, the payment records whichever candidate actually confirmed, with that candidate's amount and this node's share of the fee — not the fee-estimate used for weight at coin-selection time, and not the whole-tx fee for a multi-contributor splice. A channel closure whose funding or splice never confirmed discards its payment record instead of leaving it pending forever. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a07b5fc commit 441630c

7 files changed

Lines changed: 806 additions & 30 deletions

File tree

Cargo.toml

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,18 @@ default = []
4040
#lightning-macros = { version = "0.2.0" }
4141
#lightning-dns-resolver = { version = "0.3.0" }
4242

43-
lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["std"] }
44-
lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" }
45-
lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["std"] }
46-
lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" }
47-
lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["tokio"] }
48-
lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" }
49-
lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" }
50-
lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["rest-client", "rpc-client", "tokio"] }
51-
lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["esplora-async-https", "time", "electrum-rustls-ring"] }
52-
lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["std"] }
53-
lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" }
54-
lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144" }
43+
lightning = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05", features = ["std"] }
44+
lightning-types = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05" }
45+
lightning-invoice = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05", features = ["std"] }
46+
lightning-net-tokio = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05" }
47+
lightning-persister = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05", features = ["tokio"] }
48+
lightning-background-processor = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05" }
49+
lightning-rapid-gossip-sync = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05" }
50+
lightning-block-sync = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05", features = ["rest-client", "rpc-client", "tokio"] }
51+
lightning-transaction-sync = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05", features = ["esplora-async-https", "time", "electrum-rustls-ring"] }
52+
lightning-liquidity = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05", features = ["std"] }
53+
lightning-macros = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05" }
54+
lightning-dns-resolver = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05" }
5555

5656
bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] }
5757
bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]}
@@ -81,13 +81,13 @@ async-trait = { version = "0.1", default-features = false }
8181
vss-client = { package = "vss-client-ng", version = "0.5" }
8282
prost = { version = "0.11.6", default-features = false}
8383
#bitcoin-payment-instructions = { version = "0.6" }
84-
bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "679dac50cc0d81ec4d31da94b93d467e5308f16a" }
84+
bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "91b60116d87e19b42c06bcdf1cbf1affb566ffc2" }
8585

8686
[target.'cfg(windows)'.dependencies]
8787
winapi = { version = "0.3", features = ["winbase"] }
8888

8989
[dev-dependencies]
90-
lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "369a2cf9c8ef810deea0cd2b4cf6ed0691b78144", features = ["std", "_test_utils"] }
90+
lightning = { git = "https://github.com/jkczyz/rust-lightning", rev = "86dcedebe380737cbed0dd1d4230b4bc1e90dd05", features = ["std", "_test_utils"] }
9191
rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] }
9292
proptest = "1.0.0"
9393
regex = "1.5.6"

src/builder.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,6 +1496,8 @@ fn build_with_store_internal(
14961496
Arc::clone(&pending_payment_store),
14971497
));
14981498

1499+
tx_broadcaster.set_wallet(Arc::downgrade(&wallet));
1500+
14991501
// Initialize the KeysManager
15001502
let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| {
15011503
log_error!(logger, "Failed to get current time: {}", e);

src/event.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,18 @@ where
15181518
);
15191519
}
15201520

1521+
if let Err(e) =
1522+
self.wallet.handle_channel_ready(channel_id, funding_txo.map(|txo| txo.txid))
1523+
{
1524+
log_error!(
1525+
self.logger,
1526+
"Failed to graduate funding payment on ChannelReady for channel {}: {:?}",
1527+
channel_id,
1528+
e,
1529+
);
1530+
return Err(ReplayEvent());
1531+
}
1532+
15211533
if let Some(liquidity_source) = self.liquidity_source.as_ref() {
15221534
liquidity_source
15231535
.handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id)
@@ -1547,6 +1559,16 @@ where
15471559
} => {
15481560
log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason);
15491561

1562+
if let Err(e) = self.wallet.handle_channel_closed(channel_id) {
1563+
log_error!(
1564+
self.logger,
1565+
"Failed to handle ChannelClosed for channel {}: {:?}",
1566+
channel_id,
1567+
e,
1568+
);
1569+
return Err(ReplayEvent());
1570+
}
1571+
15501572
let event = Event::ChannelClosed {
15511573
channel_id,
15521574
user_channel_id: UserChannelId(user_channel_id),

src/payment/pending_payment_store.rs

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,107 @@
55
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
66
// accordance with one or both of these licenses.
77

8+
use bitcoin::secp256k1::PublicKey;
89
use bitcoin::Txid;
910
use lightning::impl_writeable_tlv_based;
11+
use lightning::impl_writeable_tlv_based_enum;
1012
use lightning::ln::channelmanager::PaymentId;
13+
use lightning::ln::funding::FundingContribution;
14+
use lightning::ln::types::ChannelId;
1115

1216
use crate::data_store::{StorableObject, StorableObjectUpdate};
1317
use crate::payment::store::PaymentDetailsUpdate;
1418
use crate::payment::PaymentDetails;
1519

20+
/// Identifies which channel lifecycle event a [`FundingDetails`] record tracks.
21+
#[derive(Clone, Debug, PartialEq, Eq)]
22+
pub enum FundingPurpose {
23+
/// The funding transaction opens a channel.
24+
Establishment,
25+
/// The funding transaction splices an already-open channel.
26+
Splice,
27+
}
28+
29+
impl_writeable_tlv_based_enum!(FundingPurpose,
30+
(0, Establishment) => {},
31+
(2, Splice) => {}
32+
);
33+
34+
/// One broadcast of a funding transaction (channel open or splice) in which this node
35+
/// had a stake.
36+
///
37+
/// When an RBF produces multiple candidates for the same [`FundingDetails`], each
38+
/// broadcast is recorded as its own [`FundingCandidate`] so that whichever candidate
39+
/// actually confirms can be identified and its contribution values restored onto the
40+
/// outer [`PaymentDetails`].
41+
///
42+
/// `contribution` is set for dual-funded opens and splices, where the local node submits
43+
/// a [`FundingContribution`] describing its inputs, outputs, and fee share. It is `None`
44+
/// for single-funded opens, which have exactly one candidate and no alternative to swap
45+
/// to.
46+
#[derive(Clone, Debug, PartialEq, Eq)]
47+
pub struct FundingCandidate {
48+
/// Transaction ID of this broadcast.
49+
pub txid: Txid,
50+
/// The contribution used to build this candidate, if any.
51+
pub contribution: Option<FundingContribution>,
52+
}
53+
54+
impl_writeable_tlv_based!(FundingCandidate, {
55+
(0, txid, required),
56+
(2, contribution, option),
57+
});
58+
59+
/// Marks an on-chain payment as belonging to a channel's funding lifecycle (open or
60+
/// splice), and carries the per-candidate state needed to react to RBF replacements.
61+
///
62+
/// The candidate whose `txid` matches the outer [`PaymentDetails`]`::kind.txid` is the
63+
/// one currently reflected by the payment's `amount_msat` and `fee_paid_msat`. On RBF, a
64+
/// new candidate is appended and becomes active.
65+
#[derive(Clone, Debug, PartialEq, Eq)]
66+
pub struct FundingDetails {
67+
/// The channel whose funding is being tracked.
68+
pub channel_id: ChannelId,
69+
/// The channel's counterparty.
70+
pub counterparty_node_id: PublicKey,
71+
/// Whether this funding is for a channel open or a splice.
72+
pub purpose: FundingPurpose,
73+
/// Broadcast candidates, in the order they were observed.
74+
pub candidates: Vec<FundingCandidate>,
75+
}
76+
77+
impl_writeable_tlv_based!(FundingDetails, {
78+
(0, channel_id, required),
79+
(2, counterparty_node_id, required),
80+
(4, purpose, required),
81+
(6, candidates, optional_vec),
82+
});
83+
1684
/// Represents a pending payment
1785
#[derive(Clone, Debug, PartialEq, Eq)]
1886
pub struct PendingPaymentDetails {
1987
/// The full payment details
2088
pub details: PaymentDetails,
2189
/// Transaction IDs that have replaced or conflict with this payment.
2290
pub conflicting_txids: Vec<Txid>,
91+
/// Set when the payment's transaction is a channel funding (open or splice). The
92+
/// record transitions to [`PaymentStatus::Succeeded`] on `ChannelReady` instead of
93+
/// after [`ANTI_REORG_DELAY`] confirmations.
94+
///
95+
/// [`PaymentStatus::Succeeded`]: crate::payment::store::PaymentStatus::Succeeded
96+
/// [`ANTI_REORG_DELAY`]: lightning::chain::channelmonitor::ANTI_REORG_DELAY
97+
pub funding_details: Option<FundingDetails>,
2398
}
2499

25100
impl PendingPaymentDetails {
26101
pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec<Txid>) -> Self {
27-
Self { details, conflicting_txids }
102+
Self { details, conflicting_txids, funding_details: None }
103+
}
104+
105+
pub(crate) fn with_funding_details(
106+
details: PaymentDetails, conflicting_txids: Vec<Txid>, funding_details: FundingDetails,
107+
) -> Self {
108+
Self { details, conflicting_txids, funding_details: Some(funding_details) }
28109
}
29110

30111
/// Convert to finalized payment for the main payment store
@@ -36,13 +117,15 @@ impl PendingPaymentDetails {
36117
impl_writeable_tlv_based!(PendingPaymentDetails, {
37118
(0, details, required),
38119
(2, conflicting_txids, optional_vec),
120+
(4, funding_details, option),
39121
});
40122

41123
#[derive(Clone, Debug, PartialEq, Eq)]
42124
pub(crate) struct PendingPaymentDetailsUpdate {
43125
pub id: PaymentId,
44126
pub payment_update: Option<PaymentDetailsUpdate>,
45127
pub conflicting_txids: Option<Vec<Txid>>,
128+
pub funding_details: Option<Option<FundingDetails>>,
46129
}
47130

48131
impl StorableObject for PendingPaymentDetails {
@@ -68,6 +151,13 @@ impl StorableObject for PendingPaymentDetails {
68151
}
69152
}
70153

154+
if let Some(new_funding_details) = update.funding_details {
155+
if self.funding_details != new_funding_details {
156+
self.funding_details = new_funding_details;
157+
updated = true;
158+
}
159+
}
160+
71161
updated
72162
}
73163

@@ -89,6 +179,11 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate {
89179
} else {
90180
Some(value.conflicting_txids.clone())
91181
};
92-
Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids }
182+
Self {
183+
id: value.id(),
184+
payment_update: Some(value.details.to_update()),
185+
conflicting_txids,
186+
funding_details: Some(value.funding_details.clone()),
187+
}
93188
}
94189
}

src/tx_broadcaster.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
// accordance with one or both of these licenses.
77

88
use std::ops::Deref;
9+
use std::sync::{Mutex as StdMutex, Weak};
910

1011
use bitcoin::Transaction;
1112
use lightning::chain::chaininterface::{BroadcasterInterface, TransactionType};
1213
use tokio::sync::{mpsc, Mutex, MutexGuard};
1314

1415
use crate::logger::{log_error, LdkLogger};
16+
use crate::types::Wallet;
1517

1618
const BCAST_PACKAGE_QUEUE_SIZE: usize = 50;
1719

@@ -21,6 +23,12 @@ where
2123
{
2224
queue_sender: mpsc::Sender<Vec<Transaction>>,
2325
queue_receiver: Mutex<mpsc::Receiver<Vec<Transaction>>>,
26+
/// Weak handle to the [`Wallet`] that performs classification of funding broadcasts
27+
/// (channel opens and splices) into payment records. Remains `None` while the
28+
/// builder is wiring the node up, during which broadcasts are still forwarded to
29+
/// the queue but no payment record is written. [`Self::set_wallet`] installs the
30+
/// handle once the [`Wallet`] exists.
31+
wallet: StdMutex<Option<Weak<Wallet>>>,
2432
logger: L,
2533
}
2634

@@ -30,7 +38,19 @@ where
3038
{
3139
pub(crate) fn new(logger: L) -> Self {
3240
let (queue_sender, queue_receiver) = mpsc::channel(BCAST_PACKAGE_QUEUE_SIZE);
33-
Self { queue_sender, queue_receiver: Mutex::new(queue_receiver), logger }
41+
Self {
42+
queue_sender,
43+
queue_receiver: Mutex::new(queue_receiver),
44+
wallet: StdMutex::new(None),
45+
logger,
46+
}
47+
}
48+
49+
/// Installs the [`Wallet`] handle used to classify funding broadcasts (channel
50+
/// opens and splices) into payment records. Called once the builder has constructed
51+
/// both the broadcaster and the wallet.
52+
pub(crate) fn set_wallet(&self, wallet: Weak<Wallet>) {
53+
*self.wallet.lock().expect("lock") = Some(wallet);
3454
}
3555

3656
pub(crate) async fn get_broadcast_queue(
@@ -45,6 +65,19 @@ where
4565
L::Target: LdkLogger,
4666
{
4767
fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) {
68+
let wallet = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade);
69+
if let Some(wallet) = wallet {
70+
for (tx, tx_type) in txs {
71+
if let Err(e) = wallet.classify_broadcast(tx, tx_type) {
72+
log_error!(
73+
self.logger,
74+
"Failed to classify broadcast tx {}: {:?}",
75+
tx.compute_txid(),
76+
e,
77+
);
78+
}
79+
}
80+
}
4881
let package = txs.iter().map(|(t, _)| (*t).clone()).collect::<Vec<Transaction>>();
4982
self.queue_sender.try_send(package).unwrap_or_else(|e| {
5083
log_error!(self.logger, "Failed to broadcast transactions: {}", e);

0 commit comments

Comments
 (0)