Repository navigation
Conversation
|
👋 Thanks for assigning @jkczyz as a reviewer! |
5385ef8 to
2932580
Compare
|
Drafting this for now as it might make sense to wait for #962 to land first, and then rebase this. |
2932580 to
61b7adf
Compare
I'd say either here or in a dedicated PR. It can be done independent of #930, which is already pretty big. That touches |
61b7adf to
4e7409c
Compare
Now added a commit here, let me know what you think |
Jolah1
left a comment
There was a problem hiding this comment.
The overall approach looks good, but I found one persistence-failure issue that should be addressed before merging. Both channel-funding creation and splice coin selection can leave their selected inputs durably locked when persisting the locks fails.
I reproduced this with a fault-injecting store: the operation returned an error, the inputs remained locked, and after persistence recovered the locks survived a wallet reload. The same issue occurs in select_confirmed_utxos at line 1384.
I left the details inline.
|
|
||
| (tx, locked_wallet.take_staged().unwrap_or_default()) | ||
| }; | ||
| locked_persister.persist_changeset(change_set).await.map_err(|e| { |
There was a problem hiding this comment.
If this persistence fails, the inputs locked above are leaked. persist_changeset retains the staged changeset on failure, but this function returns without the transaction, so the caller cannot release the locks and LDK cannot later emit DiscardFunding.
A subsequent successful wallet persistence makes the orphan locks durable. I reproduced this across a wallet reload with a fault-injecting store. The same issue occurs in select_confirmed_utxos below. Could we roll back both the in-memory and staged locks before returning the error, with regression tests for both paths?
4e7409c to
ff48307
Compare
|
Rebased to resolve some conflicts. |
ff48307 to
6605157
Compare
LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK's channel state, and no event of LDK's ever returns what the wallet reserved for it — today the addresses its outputs pay; once lightningdevkit#1037 locks a contribution's inputs in the wallet, those too, forever. At startup, reconcile each persisted splice intent against live channel state: release the reservations of a splice LDK no longer holds and drop its record, re-anchor a queued splice whose predecessor locked while the node was down, and keep — minus any inputs no surviving round still claims — those LDK resumes on its own. A splice whose channel closed meanwhile is released only if no round of it reached signing: a signed round is one the channel's monitor watches until the close matures, and what it reserved is spent by it or returned through DiscardFunding then. Reconciliation holds the lock that serializes splice submissions, as the event handlers settling intents do. Recovery fabricates no failure event for a splice lost this way: the initiating call already returned, and the channel simply no longer shows a pending splice. LDK itself reports the loss of a contribution it was still queueing or negotiating when it was last persisted — it fails the contribution as it is written and replays the failure at startup. The replay runs after reconciliation, so that report carries the splice's parameters only where reconciliation kept the intent: for a splice queued behind a pending one of ours, or a fee bump of one, but not for a channel's only splice, whose intent reconciliation settled. Reconciliation runs before background syncing and broadcasting start, so nothing can act on the stale reservations first. Events LDK replays from its last persisted state (e.g. a DiscardFunding for a splice that died before the node stopped) are likewise consumed before the node is running, so they cannot act on state a new user operation set up since. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK's channel state, and no event of LDK's ever returns what the wallet reserved for it — today the addresses its outputs pay; once lightningdevkit#1037 locks a contribution's inputs in the wallet, those too, forever. At startup, reconcile each persisted splice intent against live channel state: release the reservations of a splice LDK no longer holds and drop its record, re-anchor a queued splice whose predecessor locked while the node was down, and keep — minus any inputs no surviving round still claims — those LDK resumes on its own. A splice whose channel closed meanwhile is released only if no round of it reached signing: a signed round is one the channel's monitor watches until the close matures, and what it reserved is spent by it or returned through DiscardFunding then. Reconciliation holds the lock that serializes splice submissions, as the event handlers settling intents do. Recovery fabricates no failure event for a splice lost this way: the initiating call already returned, and the channel simply no longer shows a pending splice. LDK itself reports the loss of a contribution it was still queueing or negotiating when it was last persisted — it fails the contribution as it is written and replays the failure at startup. The replay runs after reconciliation, so that report carries the splice's parameters only where reconciliation kept the intent: for a splice queued behind a pending one of ours, or a fee bump of one, but not for a channel's only splice, whose intent reconciliation settled. Reconciliation runs before background syncing and broadcasting start, so nothing can act on the stale reservations first. Events LDK replays from its last persisted state (e.g. a DiscardFunding for a splice that died before the node stopped) are likewise consumed before the node is running, so they cannot act on state a new user operation set up since. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK's channel state, and no event of LDK's ever returns what the wallet reserved for it — today the addresses its outputs pay; once lightningdevkit#1037 locks a contribution's inputs in the wallet, those too, forever. At startup, reconcile each persisted splice intent against live channel state: release the reservations of a splice LDK no longer holds and drop its record, re-anchor a queued splice whose predecessor locked while the node was down, and keep — minus any inputs no surviving round still claims — those LDK resumes on its own. A splice whose channel closed meanwhile is released only if no round of it reached signing: a signed round is one the channel's monitor watches until the close matures, and what it reserved is spent by it or returned through DiscardFunding then. Reconciliation holds the lock that serializes splice submissions, as the event handlers settling intents do. Recovery fabricates no failure event for a splice lost this way: the initiating call already returned, and the channel simply no longer shows a pending splice. LDK itself reports the loss of a contribution it was still queueing or negotiating when it was last persisted — it fails the contribution as it is written and replays the failure at startup. The replay runs after reconciliation, so that report carries the splice's parameters only where reconciliation kept the intent: for a splice queued behind a pending one of ours, or a fee bump of one, but not for a channel's only splice, whose intent reconciliation settled. Reconciliation runs before background syncing and broadcasting start, so nothing can act on the stale reservations first. Events LDK replays from its last persisted state (e.g. a DiscardFunding for a splice that died before the node stopped) are likewise consumed before the node is running, so they cannot act on state a new user operation set up since. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(lightningdevkit#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK's channel state, and no event of LDK's ever returns what the wallet reserved for it — today the addresses its outputs pay; once lightningdevkit#1037 locks a contribution's inputs in the wallet, those too, forever. At startup, reconcile each persisted splice intent against live channel state: release the reservations of a splice LDK no longer holds and drop its record, re-anchor a queued splice whose predecessor locked while the node was down, and keep — minus any inputs no surviving round still claims — those LDK resumes on its own. A splice whose channel closed meanwhile is released only if no round of it reached signing: a signed round is one the channel's monitor watches until the close matures, and what it reserved is spent by it or returned through DiscardFunding then. Reconciliation holds the lock that serializes splice submissions, as the event handlers settling intents do. Recovery fabricates no failure event for a splice lost this way: the initiating call already returned, and the channel simply no longer shows a pending splice. LDK itself reports the loss of a contribution it was still queueing or negotiating when it was last persisted — it fails the contribution as it is written and replays the failure at startup. The replay runs after reconciliation, so that report carries the splice's parameters only where reconciliation kept the intent: for a splice queued behind a pending one of ours, or a fee bump of one, but not for a channel's only splice, whose intent reconciliation settled. Reconciliation runs before background syncing and broadcasting start, so nothing can act on the stale reservations first. Events LDK replays from its last persisted state (e.g. a DiscardFunding for a splice that died before the node stopped) are likewise consumed before the node is running, so they cannot act on state a new user operation set up since. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(lightningdevkit#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(lightningdevkit#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(lightningdevkit#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(lightningdevkit#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(lightningdevkit#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(lightningdevkit#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| // Keep selected wallet inputs unavailable until LDK either broadcasts a transaction | ||
| // spending them or returns them through `DiscardFunding`. | ||
| for txin in unsigned_tx.input.iter().filter(|txin| { | ||
| must_spend.iter().all(|input| input.outpoint != txin.previous_output) | ||
| }) { | ||
| locked_wallet.lock_outpoint(txin.previous_output); | ||
| } |
There was a problem hiding this comment.
Claude:
The locks are persisted here, but nothing records yet which splice they belong to. If anything fails before that record exists, they stay locked for good:
validate_inputsrejects an input (oversizedprevtx) right after this returns.- The persist below fails (Jolah1's point at 673).
- The node crashes.
Once the splice confirms, nothing unlocks its inputs either.
Once #1057/#1079/#1080 land, #1080 persists a record of each splice, inputs included, before calling LDK, and releases from it at startup. So:
- Stage the locks here and let Track in-flight splices for failure reporting and crash recovery #1080 persist them right after its record. That covers the persist failure and the crash.
- Exclude oversized UTXOs during selection, so
validate_inputscan't fail after locking.- Unlock a round's inputs when it confirms.
| locked_wallet.list_unspent().filter(|u| confirmed_txs.contains(&u.outpoint.txid)); | ||
| let unspent_confirmed_utxos = locked_wallet.list_unspent().filter(|u| { | ||
| confirmed_txs.contains(&u.outpoint.txid) | ||
| && !locked_wallet.is_outpoint_locked(u.outpoint) |
There was a problem hiding this comment.
Claude:
A splice-in that selects every confirmed UTXO starves anchor CPFP for the whole negotiation. Leave an anchor reserve unlocked (or don't filter here), and keep the original assertion at 917, which this would have tripped.
| Some(LdkTransactionType::Funding { .. }) => { | ||
| wallet.prepare_funding_broadcast(tx).await? | ||
| }, |
There was a problem hiding this comment.
Note that #1057 removes classify_package and wallet` from the broadcaster, so we may want to consider landing that first. This would move to the event handler, IIUC.
| .any(|txin| locked_wallet.is_outpoint_locked(txin.previous_output)); | ||
| let owns_initial_funding_locks = is_funding && !was_known; | ||
|
|
||
| if unavailable_conflict || (has_locked_input && !owns_initial_funding_locks) { |
There was a problem hiding this comment.
Cluade:
After a splice round we contributed to is evicted from the mempool, the tip-change rebroadcast (495) sends it back through here:
has_locked_inputis true because its own inputs are still locked fromselect_confirmed_utxos, andowns_initial_funding_locksis false because it isn'tFunding-typed, so our own round is refused as if a newer selection held those inputs. The check can't tell a transaction's own locks from another's; once #1057/#1079/#1080 land, the splice tracker knows each round's inputs and can answer that.
| .map(|script_pubkey| bitcoin::TxOut { value: bitcoin::Amount::ZERO, script_pubkey }) | ||
| .collect(), | ||
| }), | ||
| FundingInfo::OutPoint { .. } => None, |
There was a problem hiding this comment.
Claude:
An LSPS2 manual-broadcast channel closed before broadcast lands here, so the inputs locked in
create_funding_transactionare never released. The handler can fetch the transaction the service stored, or the wallet can keep the created transaction in the graph and cancel by txid.
| let mempool_entries_cache = mempool_entries_cache.lock().await; | ||
| let observed_at = | ||
| SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); | ||
| let evicted_at = observed_at.max(latest_mempool_timestamp.load(Ordering::Relaxed)); |
There was a problem hiding this comment.
Claude:
If a poll runs before Bitcoin Core has a just-applied transaction,
evicted_atequals itslast_seenand BDK drops it (it needslast_evicted < last_seen), so its inputs are selectable again. Skip txids whoselast_seenis at or after the snapshot time.
Record wallet transactions before broadcast and reserve funding inputs until their transactions are durable. This prevents concurrent operations from selecting the same inputs. Dropped transactions remain recoverable through the existing rebroadcast path. Co-Authored-By: HAL 9000
Keep wallet-reuse validation compatible with main’s asynchronous data store API and explicit cache policies. The wallet mutex must not cross an await because background wallet tasks are Send. Co-Authored-By: HAL 9000
Reserve wallet inputs as soon as splice coin selection returns so concurrent wallet operations cannot reuse them before the funding transaction reaches the wallet. Release discarded contributions so failed or superseded splice rounds do not strand funds. Co-Authored-By: HAL 9000
6605157 to
f7b63a8
Compare
Fixes #41.
For the longest time BDK didn't offer any UTXO locking mechanisms and only considered transactions canonical once seen in the mempool during syncing. This always left a gap between the time of transaction signing/broadcast and the time of sync during which the wallet could double-spend itself. Since
bdk_walletv3.0 they finally offer UTXO locking APIs which we finally use here to close this gap for funding transactions and onchain spends.Note: We intentionally leave splicing transactions out-of-scope of this PR because with #962 and #930 there are related PRs inflight. Depending on the order these land, this PR or they need to be updated to marry the two approaches. (cc @jkczyz)