Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions channeldb/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,13 @@ type openChannelTlvData struct {
// confirmationHeight records the block height at which the funding
// transaction was first confirmed.
confirmationHeight tlv.RecordT[tlv.TlvType8, uint32]

// closeConfirmationHeight records the block height at which the closing
// transaction was first confirmed. This is used to calculate the
// remaining confirmations until the channel is considered fully closed.
// Note: if not set, it means either the channel has not been
// closed yet, or it was closed before this field was introduced.
closeConfirmationHeight tlv.OptionalRecordT[tlv.TlvType9, uint32]
Comment thread
ziggie1984 marked this conversation as resolved.
}

// encode serializes the openChannelTlvData to the given io.Writer.
Expand All @@ -287,6 +294,11 @@ func (c *openChannelTlvData) encode(w io.Writer) error {
c.customBlob.WhenSome(func(blob tlv.RecordT[tlv.TlvType7, tlv.Blob]) {
tlvRecords = append(tlvRecords, blob.Record())
})
c.closeConfirmationHeight.WhenSome(
func(h tlv.RecordT[tlv.TlvType9, uint32]) {
tlvRecords = append(tlvRecords, h.Record())
},
)

tlv.SortRecords(tlvRecords)

Expand All @@ -304,6 +316,7 @@ func (c *openChannelTlvData) decode(r io.Reader) error {
memo := c.memo.Zero()
tapscriptRoot := c.tapscriptRoot.Zero()
blob := c.customBlob.Zero()
closeConfHeight := c.closeConfirmationHeight.Zero()

// Create the tlv stream.
tlvStream, err := tlv.NewStream(
Expand All @@ -315,6 +328,7 @@ func (c *openChannelTlvData) decode(r io.Reader) error {
tapscriptRoot.Record(),
blob.Record(),
c.confirmationHeight.Record(),
closeConfHeight.Record(),
)
if err != nil {
return err
Expand All @@ -334,6 +348,9 @@ func (c *openChannelTlvData) decode(r io.Reader) error {
if _, ok := tlvs[c.customBlob.TlvType()]; ok {
c.customBlob = tlv.SomeRecordT(blob)
}
if _, ok := tlvs[closeConfHeight.TlvType()]; ok {
c.closeConfirmationHeight = tlv.SomeRecordT(closeConfHeight)
}

return nil
}
Expand Down Expand Up @@ -945,6 +962,14 @@ type OpenChannel struct {
// transaction was first confirmed.
ConfirmationHeight uint32

// CloseConfirmationHeight records the block height at which the closing
// transaction was first confirmed. This is used to track remaining
// confirmations until the channel is considered fully closed. It is
// None if the closing transaction has not yet been confirmed, or if
// this data was not available (e.g. channels closed before this
// field was introduced).
CloseConfirmationHeight fn.Option[uint32]

// NumConfsRequired is the number of confirmations a channel's funding
// transaction must have received in order to be considered available
// for normal transactional use.
Expand Down Expand Up @@ -1257,6 +1282,9 @@ func (c *OpenChannel) amendTlvData(auxData openChannelTlvData) {
auxData.customBlob.WhenSomeV(func(blob tlv.Blob) {
c.CustomBlob = fn.Some(blob)
})
auxData.closeConfirmationHeight.WhenSomeV(func(h uint32) {
c.CloseConfirmationHeight = fn.Some(h)
})
}

// extractTlvData creates a new openChannelTlvData from the given channel.
Expand Down Expand Up @@ -1294,6 +1322,11 @@ func (c *OpenChannel) extractTlvData() openChannelTlvData {
tlv.NewPrimitiveRecord[tlv.TlvType7](blob),
)
})
c.CloseConfirmationHeight.WhenSome(func(h uint32) {
auxData.closeConfirmationHeight = tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType9](h),
)
})

return auxData
}
Expand Down Expand Up @@ -1575,6 +1608,45 @@ func (c *OpenChannel) MarkConfirmationHeight(height uint32) error {
return nil
}

// ResetCloseConfirmationHeight clears the channel's close confirmation height
// when the spending transaction is reorged out.
func (c *OpenChannel) ResetCloseConfirmationHeight() error {
return c.MarkCloseConfirmationHeight(fn.None[uint32]())
}

// MarkCloseConfirmationHeight updates the channel's close confirmation height
// when the closing transaction is first detected in a block (spend height).
func (c *OpenChannel) MarkCloseConfirmationHeight(
height fn.Option[uint32]) error {

c.Lock()
defer c.Unlock()

if err := kvdb.Update(c.Db.backend, func(tx kvdb.RwTx) error {
chanBucket, err := fetchChanBucketRw(
tx, c.IdentityPub, &c.FundingOutpoint, c.ChainHash,
)
if err != nil {
return err
}

channel, err := fetchOpenChannel(chanBucket, &c.FundingOutpoint)
if err != nil {
return err
}

channel.CloseConfirmationHeight = height

return putOpenChannel(chanBucket, channel)
}, func() {}); err != nil {
return err
}

c.CloseConfirmationHeight = height

return nil
}

// MarkAsOpen marks a channel as fully open given a locator that uniquely
// describes its location within the chain.
func (c *OpenChannel) MarkAsOpen(openLoc lnwire.ShortChannelID) error {
Expand Down
22 changes: 21 additions & 1 deletion contractcourt/chain_watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,18 @@ func (c *chainWatcher) processDetectedSpend(
numConfs := c.requiredConfsForSpend()
txid := spend.SpenderTxHash

// Record the close confirmation height. This is the height at which
// the closing tx was first included in a block. We store this so we
// can report the remaining confirmations to the user.
err := c.cfg.chanState.MarkCloseConfirmationHeight(
fn.Some(uint32(spend.SpendingHeight)),
)
if err != nil {
log.Warnf("ChannelPoint(%v): unable to mark close "+
"confirmation height: %v",
c.cfg.chanState.FundingOutpoint, err)
}

newConfNtfn, err := c.cfg.notifier.RegisterConfirmationsNtfn(
txid, spend.SpendingTx.TxOut[0].PkScript, numConfs,
uint32(spend.SpendingHeight),
Expand Down Expand Up @@ -931,8 +943,16 @@ func (c *chainWatcher) closeObserver() {
confNtfn = nil
pendingSpend = nil

// Reset the close confirmation height since the spend
// was reorged out.
err := c.cfg.chanState.ResetCloseConfirmationHeight()
if err != nil {
log.Warnf("ChannelPoint(%v): unable to reset "+
"close confirmation height: %v",
c.cfg.chanState.FundingOutpoint, err)
}

spendNtfn.Cancel()
var err error
spendNtfn, err = registerForSpend()
if err != nil {
log.Errorf("Unable to re-register for "+
Expand Down
9 changes: 9 additions & 0 deletions docs/release-notes/release-notes-0.21.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@

## RPC Additions

* The `WaitingCloseChannel` response in `PendingChannels` now includes two
new fields via [#10509](https://github.com/lightningnetwork/lnd/pull/10509):
`blocks_til_close_confirmed`, showing the remaining confirmations until a
closed channel is considered fully resolved, and `close_height`, the block
height at which the closing transaction was first confirmed. These build on
the reorg-safe confirmation logic introduced in
[#10331](https://github.com/lightningnetwork/lnd/pull/10331), where the
required number of confirmations scales with channel capacity.

* [Added support for coordinator-based MuSig2 signing
patterns](https://github.com/lightningnetwork/lnd/pull/10436) with two new
RPCs: `MuSig2RegisterCombinedNonce` allows registering a pre-aggregated
Expand Down
130 changes: 128 additions & 2 deletions itest/lnd_coop_close_rbf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ func testRBFCoopCloseDisconnect(ht *lntest.HarnessTest) {
// testCoopCloseRBFWithReorg tests that when a cooperative close transaction
Comment thread
ziggie1984 marked this conversation as resolved.
// is reorganized out during confirmation waiting, the system properly handles
// RBF replacements and re-registration for any spend of the funding output.
// It also verifies the blocks_til_close_confirmed field correctly tracks
// remaining confirmations and resets appropriately after a reorg.
func testCoopCloseRBFWithReorg(ht *lntest.HarnessTest) {
// Skip this test for neutrino backend as we can't trigger reorgs.
if ht.IsNeutrinoBackend() {
Expand Down Expand Up @@ -224,6 +226,22 @@ func testCoopCloseRBFWithReorg(ht *lntest.HarnessTest) {
require.NoError(ht, err)
firstRbfTx := ht.AssertTxInMempool(*firstRbfTxid)

// Verify blocks_til_close_confirmed equals requiredConfs and
// close_height is zero when the tx is unconfirmed.
waitingClose := ht.AssertNumWaitingClose(alice, 1)
blocksTilCloseConfirmed := waitingClose[0].BlocksTilCloseConfirmed
require.Equal(
ht, uint32(requiredConfs), blocksTilCloseConfirmed,
"expected blocks_til_close_confirmed to equal %d when "+
"unconfirmed, got %d", requiredConfs,
blocksTilCloseConfirmed,
)
require.Equal(
ht, uint32(0), waitingClose[0].CloseHeight,
"expected close_height=0 when unconfirmed, got %d",
waitingClose[0].CloseHeight,
)

_, bestHeight := ht.GetBestBlock()
ht.Logf("Current block height: %d", bestHeight)

Expand All @@ -235,10 +253,55 @@ func testCoopCloseRBFWithReorg(ht *lntest.HarnessTest) {

ht.Logf("Mined block %d with first RBF tx", bestHeight+1)

// Verify blocks_til_close_confirmed decremented to
// requiredConfs - 1 = 2, and close_height is set to the mined height.
_, closeHeight := ht.GetBestBlock()
err = wait.NoError(func() error {
resp := alice.RPC.PendingChannels()
if len(resp.WaitingCloseChannels) != 1 {
return fmt.Errorf("expected 1 waiting close channel, "+
"got %d", len(resp.WaitingCloseChannels))
}
wc := resp.WaitingCloseChannels[0]
expected := uint32(requiredConfs - 1)
if wc.BlocksTilCloseConfirmed != expected {
return fmt.Errorf("expected "+
"blocks_til_close_confirmed=%d, got %d",
expected, wc.BlocksTilCloseConfirmed)
}

return nil
}, defaultTimeout)
require.NoError(ht, err)

waitingClose = ht.AssertNumWaitingClose(alice, 1)
require.Equal(
ht, uint32(closeHeight), waitingClose[0].CloseHeight,
"expected close_height=%d, got %d",
closeHeight, waitingClose[0].CloseHeight,
)

block2 := ht.MineEmptyBlocks(1)[0]

ht.Logf("Mined block %d", bestHeight+2)

// Verify blocks_til_close_confirmed decremented to 1.
err = wait.NoError(func() error {
resp := alice.RPC.PendingChannels()
if len(resp.WaitingCloseChannels) != 1 {
return fmt.Errorf("expected 1 waiting close channel, "+
"got %d", len(resp.WaitingCloseChannels))
}
blocks := resp.WaitingCloseChannels[0].BlocksTilCloseConfirmed
if blocks != 1 {
return fmt.Errorf("expected "+
"blocks_til_close_confirmed=1, got %d", blocks)
}

return nil
}, defaultTimeout)
require.NoError(ht, err)

ht.Logf("Re-orging two blocks to remove first RBF tx")

// Trigger a reorganization that removes the last 2 blocks. This is safe
Expand All @@ -257,12 +320,47 @@ func testCoopCloseRBFWithReorg(ht *lntest.HarnessTest) {

ht.Log("Mining blocks to surpass previous chain")

// Mine 2 empty blocks to trigger the reorg on the nodes.
ht.MineEmptyBlocks(2)
// Mine 3 empty blocks to create a longer chain without the closing tx.
// This ensures the reorg is fully processed by the nodes.
ht.MineEmptyBlocks(3)

_, bestHeight = ht.GetBestBlock()
ht.Logf("Mined blocks to reach height: %d", bestHeight)

// Wait for Alice to sync to the new chain.
ht.WaitForNodeBlockHeight(alice, bestHeight)

// After the reorg, the closing tx is no longer confirmed.
// blocks_til_close_confirmed should reset to requiredConfs.
err = wait.NoError(func() error {
resp := alice.RPC.PendingChannels()
if len(resp.WaitingCloseChannels) != 1 {
return fmt.Errorf("expected 1 waiting close channel, "+
"got %d", len(resp.WaitingCloseChannels))
}
wc := resp.WaitingCloseChannels[0]
if wc.BlocksTilCloseConfirmed != uint32(requiredConfs) {
return fmt.Errorf("expected "+
"blocks_til_close_confirmed=%d after reorg, "+
"got %d", requiredConfs,
wc.BlocksTilCloseConfirmed)
}

return nil
}, defaultTimeout)
require.NoError(ht, err)

// close_height should also be zero after the reorg.
waitingClose = ht.AssertNumWaitingClose(alice, 1)
require.Equal(
ht, uint32(0), waitingClose[0].CloseHeight,
"expected close_height=0 after reorg, got %d",
waitingClose[0].CloseHeight,
)

ht.Logf("blocks_til_close_confirmed correctly reset to %d after reorg",
requiredConfs)

// Now, instead of mining the second RBF, mine the INITIAL transaction
// to test that the system can handle any valid spend of the funding
// output.
Expand All @@ -271,6 +369,34 @@ func testCoopCloseRBFWithReorg(ht *lntest.HarnessTest) {
)
ht.AssertTxInBlock(block, *initialCloseTxid)

// Verify blocks_til_close_confirmed resumes countdown after re-mining
// and close_height is set to the new confirmation height.
_, reCloseHeight := ht.GetBestBlock()
err = wait.NoError(func() error {
resp := alice.RPC.PendingChannels()
if len(resp.WaitingCloseChannels) != 1 {
return fmt.Errorf("expected 1 waiting close channel, "+
"got %d", len(resp.WaitingCloseChannels))
}
blocks := resp.WaitingCloseChannels[0].BlocksTilCloseConfirmed
expected := uint32(requiredConfs - 1)
if blocks != expected {
return fmt.Errorf("expected "+
"blocks_til_close_confirmed=%d, got %d",
expected, blocks)
}

return nil
}, defaultTimeout)
require.NoError(ht, err)

waitingClose = ht.AssertNumWaitingClose(alice, 1)
require.Equal(
ht, uint32(reCloseHeight), waitingClose[0].CloseHeight,
"expected close_height=%d after re-mine, got %d",
reCloseHeight, waitingClose[0].CloseHeight,
)

// Mine additional blocks to reach the required confirmations (3 total).
ht.MineEmptyBlocks(requiredConfs - 1)

Expand Down
Loading
Loading