Add splice RBF support#888
Conversation
|
👋 I see @joostjager was un-assigned. |
|
@tnull Any feedback on the last few commits would be appreciated: https://github.com/lightningdevkit/ldk-node/pull/888/changes/492ec9124ae02a4048ecb5270309174b05b366d2..c770999887e1287bef8abe4f4c92ca36e9434067 |
198bfbf to
d5b6bda
Compare
d5b6bda to
91166b9
Compare
tnull
left a comment
There was a problem hiding this comment.
Excuse the delay. Took a first look, generally makes sense, though it's kind off odd to block the broadcast queue on persistence. Hopefully we'd never get a lot of backpressure from remote persistence, for example.
I have yet to review the (rather verbose) new code for updating/persisting the pending payments in wallet.rs in more detail.
91166b9 to
c4d3282
Compare
|
@jkczyz Is this ready for review or are we waiting for any upstream changes still? |
Ah, was unaware of that cross dependency, will see to review #882 next to keep things moving then. |
|
🔔 1st Reminder Hey @tankyleo! This PR has been waiting for your review. |
|
🔔 2nd Reminder Hey @tankyleo! This PR has been waiting for your review. |
|
🔔 3rd Reminder Hey @tankyleo! This PR has been waiting for your review. |
Camillarhi
left a comment
There was a problem hiding this comment.
Thanks for this. I took a look at this, and I have a few questions. I have taken a little look at splicing in general.
For this PR, if a user calls bump_fee_rbf on a record with funding_details, the replacement is picked up as a WalletEvent::TxReplaced event on the next sync, and this will currently rewrite the funding_details as NONE. Is bumping a funding/splice payment through bump_fee_rbf meant to be possible at all, or should those records be excluded here in favor of rbf_channel? AND is overwriting an existing funding_details with None intended in the TxReplaced path?
|
🔔 4th Reminder Hey @tankyleo! This PR has been waiting for your review. |
|
🔔 5th Reminder Hey @tankyleo! This PR has been waiting for your review. |
|
🔔 6th Reminder Hey @tankyleo! This PR has been waiting for your review. |
c4d3282 to
bec7723
Compare
|
Rebased and dropped commits added in #912.
Sorry, that should have said #872. Relevant commits were moved to #912, which has been merged.
Good catch @Camillarhi! Updated to disallow using Overwriting the existing |
|
🔔 7th Reminder Hey @tankyleo! This PR has been waiting for your review. |
|
🔔 8th Reminder Hey @tankyleo! This PR has been waiting for your review. |
|
🔔 2nd Reminder Hey @benthecarman! This PR has been waiting for your review. |
|
🔔 3rd Reminder Hey @benthecarman! This PR has been waiting for your review. |
| Some((PaymentDirection::Inbound, amt)) => { | ||
| amount_inbound = amount_inbound.saturating_add(amt); | ||
| }, | ||
| None => {}, |
There was a problem hiding this comment.
Should we debug_assert here? Seems like a state we shouldn't be hitting. If not at least a comment would be good
There was a problem hiding this comment.
Currently, I don't think we can hit this since we don't allow mixed mode. But once LDK supports batched splices, we could run into this. For now, simplified this quite a bit by looking at FundingContribution::net_value and removing this case.
| self.persist_pending_payment(pending_payment)?; | ||
| }, | ||
| WalletEvent::TxReplaced { txid, conflicts, .. } => { | ||
| let Some(payment_id) = self.find_payment_by_txid(txid) else { |
There was a problem hiding this comment.
claude:
a graduated funding payment can be resurrected into the pending store, tripping the
ChainTipChangeddebug_assert (PLAUSIBLE)
find_payment_by_txidnow also matches the main store by Onchain txid. After
graduation, the record is gone from the pending store, so the TxReplaced guard
pending_payment_store.get(id).map_or(false, |p| p.funding_details.is_some())is
false. ATxReplacedfor the confirmed funding txid (a reorg evicting it while a
conflict exists) therefore falls through tocreate_pending_payment_from_tx, which
copiesstatus = Succeeded,funding_details = None, and re-inserts it into the pending
store. The nextChainTipChangedsweep then hits
debug_assert!(status == Pending, "Non-pending payment ... found in pending store")and,
in release, the Succeeded record lingers forever. The TxReplaced arm is the only
wallet-event arm that doesn't callapply_funding_details_status_updatefirst — mirroring
the other three arms would close the gap.
| self.runtime.block_on(self.payment_store.insert_or_update(pending.details.clone()))?; | ||
| self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?; |
There was a problem hiding this comment.
would it be better to do a join on these futures and then block on?
There was a problem hiding this comment.
Hmmm... if the payment_store update fails, are we ok with the pending_payment_store completing? Given this called in places where we simply log an error and continue, I wonder what the downstream consequences of that are?
Also, IIUC, if persistence fails we'll have an in-memory representation that is out-of-sync with the persisted representation, whether or not these are done sequentially.
Further, a failure here would prevent us from broadcasting the transaction, but our counterparty may still do so.
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>
Record channel-open and splice funding transactions as on-chain payments at broadcast, and carry them to Succeeded through ANTI_REORG_DELAY confirmations like any other on-chain payment, instead of tying their status to the Lightning channel lifecycle. A splice's recorded amount and fee are this node's share of the funding contribution, which wallet sync preserves rather than overwriting with its own view of the (possibly multi-party) transaction. On-chain RBF of these payments is rejected: LDK drives funding and splice transactions, so replacing one would broadcast a transaction it isn't tracking and, for a splice, can't re-sign. Addresses review feedback to keep on-chain payment status confirmation- driven rather than gated on ChannelReady. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A splice's funding transaction can be stuck at too low a fee rate with no way to raise it: on-chain RBF is rejected for funding transactions, and re-issuing splice_in / splice_out errors while a splice is already pending. Add bump_channel_funding_fee, which replaces the pending splice's funding transaction at a higher fee rate while preserving its amount and destination, and point the "a prior splice contribution is pending" errors at it. Replacing the transaction also requires signing a funding input the wallet already treats as spent by the splice being replaced, which it would otherwise skip after syncing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the wallet-event-driven funding payment lifecycle end to end: a channel-open funding payment reaches Succeeded from wallet sync alone, asserted before any ChannelReady event is drained to show payment status no longer depends on the channel-ready signal; and a splice fee-bumped via RBF stays a single on-chain payment that follows the winning candidate while keeping its interactive-funding classification across the replacement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A splice funding payment can be fee-bumped via RBF, producing several candidate transactions with increasing fees. The payment recorded the last-broadcast candidate's amount and fee and kept them on confirmation, but the candidate that actually confirms need not be the last one broadcast — so an earlier, lower-fee candidate confirming left the payment over-reporting its fee. Record each candidate's amount and fee, keyed by txid, so that on confirmation the payment reflects the candidate that actually confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
splice_channel only checked the splice-out fee; also assert it is recorded as a confirmed interactive-funding payment. Add a test that a confirmed splice payment returns to unconfirmed when its block is reorged out, exercising the unconfirm path for funding payments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributing to an already-pending splice — e.g. adding our funds to a counterparty-initiated splice via splice_in or splice_out — replaces the in-flight funding transaction, so the funding template requires at least the RBF minimum feerate. We passed our plain ChannelFunding feerate estimate, which can sit below that minimum (it does at the regtest floor), so the contribution was rejected with FeeRateBelowRbfMinimum. Raise the contribution feerate to the template's RBF minimum when one applies, capped by our max, so it can replace the pending splice. A node can therefore now contribute to a counterparty's pending splice; the rbf_splice_channel check that expected splice_out to fail while a splice was pending relied on this very bug and is dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 1.5x-of-estimate funding feerate ceiling was open-coded identically in splice_in and splice_out. Route both through the max_funding_feerate helper so the ceiling lives in one place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
328bdc3 to
f2990d5
Compare
| if let Some(channel_details) = | ||
| open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) | ||
| { | ||
| let min_feerate = |
There was a problem hiding this comment.
Yeah, good catch. Done.
| /// # Experimental API | ||
| /// | ||
| /// This API is experimental and may change in the future. | ||
| pub fn bump_channel_funding_fee( |
There was a problem hiding this comment.
Note that we currently limit how much we'll pay as an acceptor (i.e., if we lose quiescence) to 1.5x the channel funding fee rate. This is max_feerate below. The minimum rate we use here is the least the spec requires us to bump. And we need to make sure to use our min_feereate if it is above the spec-compliant fee rate. But should we fail if this is above max_feerate?
I'd imagine yes as anything above that means we'd be overpaying based on our fee estimator. Unless of course we want to allow users to target something quicker than 12 blocks (ChannelFunding) like OnchainPayment's 6 blocks, if they are using splice-out to make a payment, for instance.
| /// | ||
| /// # Experimental API | ||
| /// | ||
| /// This API is experimental and may change in the future. |
There was a problem hiding this comment.
Yeah, dropped. There's similar notes on the other splicing methods, but they mention payment direction.
| Some((PaymentDirection::Inbound, amt)) => { | ||
| amount_inbound = amount_inbound.saturating_add(amt); | ||
| }, | ||
| None => {}, |
There was a problem hiding this comment.
Currently, I don't think we can hit this since we don't allow mixed mode. But once LDK supports batched splices, we could run into this. For now, simplified this quite a bit by looking at FundingContribution::net_value and removing this case.
| // Skip broadcasts that don't move funds in or out of our on-chain wallet — e.g. | ||
| // a splice-out we initiated toward an external address. |
There was a problem hiding this comment.
@tnull Do we skip because this wouldn't be considered "on-chain"? That is, we could be paying someone from our lightning balance?
There was a problem hiding this comment.
It seems that code you're referring to is no longer there? Not sure I'm understanding the context - could you reiterate the question?
There was a problem hiding this comment.
Ah, it should be this: https://github.com/jkczyz/ldk-node/blob/f2990d57329e23016708af857b985f7fcba82b03/src/wallet/mod.rs#L1314-L1326
Basically, a splice-out to an address that is not ours.
| ) -> Result<BroadcastPackage, Error> { | ||
| let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade); | ||
| if let Some(wallet) = wallet_opt { | ||
| tokio::task::spawn_blocking(move || { |
There was a problem hiding this comment.
Yeah, just had to make all the other methods it calls async.
| pub conflicting_txids: Vec<Txid>, | ||
| /// Set when the payment's transaction is an interactive-funding broadcast (channel | ||
| /// open or splice). The record transitions to [`PaymentStatus::Succeeded`] on | ||
| /// `ChannelReady` instead of after [`ANTI_REORG_DELAY`] confirmations. |
There was a problem hiding this comment.
Discussed offline. Abandoned this approach to avoid tying payment status with channel lifecycle.
| self.runtime.block_on(self.payment_store.insert_or_update(pending.details.clone()))?; | ||
| self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?; |
There was a problem hiding this comment.
Hmmm... if the payment_store update fails, are we ok with the pending_payment_store completing? Given this called in places where we simply log an error and continue, I wonder what the downstream consequences of that are?
Also, IIUC, if persistence fails we'll have an in-memory representation that is out-of-sync with the persisted representation, whether or not these are done sequentially.
Further, a failure here would prevent us from broadcasting the transaction, but our counterparty may still do so.
| pending.details.fee_paid_msat = aggregate.fee_paid_msat; | ||
| } | ||
|
|
||
| // Preserve the confirmation status already on the record (set by wallet sync if |
There was a problem hiding this comment.
I think this may be obsolete now.
tnull
left a comment
There was a problem hiding this comment.
Kotlin tests are failing right now.
Adds a public
Node::rbf_channelAPI that replaces an in-flight splice transaction with a higher-feerate version.An RBF leaves multiple candidate splice transactions in flight; only one of them will confirm on chain, and the payment record needs to reflect whichever one did — its amount and this node's share of the fee. The transition from
PendingtoSucceededshould also match when the Lightning protocol actually considers the new channel state usable — the momentChannelReadyfires — rather than afterANTI_REORG_DELAYconfirmations, which doesn't fit the zero-conf extreme on one end or higher-confirmation-depth peer configurations on the other.To support those properties, this PR:
Every broadcast from LDK now triggers a store write. For a remote KV store, running that write on LDK's thread would block message handling for hundreds of milliseconds per call, so persistence happens asynchronously while still guaranteeing that the record is committed before the transaction reaches the chain client.
Based on #878.