channeldb: tombstone closed channels without bulk delete
What changed, and why it matters
This commit changes how LND stores closed Lightning channels on certain database backends. Instead of deleting all the old channel data immediately when a channel closes, it now leaves the data in place and just flips a marker saying 'this channel is closed.' This is a performance optimization for SQL-based backends, not a fix for an active security bug. The main risk is that leftover channel data might be read by code that expects only open channels, but the commit adds a guard so repeated close attempts are rejected and the historical/closed-channel records are still written correctly.
Treat as a design/robustness change rather than an urgent security patch. Review the follow-up commit that updates open-channel-bucket readers to respect tombstoned channels, since leaving stale channel buckets readable could confuse callers. Ensure migration tooling for native SQL channel-state cleanup properly reclaims the retained disk space. Run the new tests and any integration tests around channel closure and historical-channel queries.
Security signals we found
New database close path leaves sensitive per-channel state on disk instead of deleting it
Relies on a single index flag (outpointClosed) as the authoritative 'channel is closed' marker
Adds redundant-close guard (ErrChannelNotFound) to prevent duplicate archive writes
Commit message acknowledges open-channel readers may still see tombstoned channels in a follow-up commit
No input validation, cryptographic, or network changes
Evidence from the diff
The patch introduces a tombstone close path in channeldb for KV-over-SQL backends. When OptionTombstoneClosedChannels is enabled, CloseChannel skips the cascading deletion of the chanBucket, revocation log, and forwarding-package bucket; instead it flips the outpoint index from outpointOpen to outpointClosed and archives the close summary/historical channel as before. locateOpenChannel now rejects already-closed chanKeys with ErrChannelNotFound to prevent re-archiving on redundant closes. Tests verify both the tombstone and synchronous paths. The commit explicitly notes that open-channel-bucket readers still surfacing tombstoned channels is a known gap to be addressed in a follow-up commit.
Changed components
channeldb/channel.gochanneldb/close_channel_test.goChannelStateDB.CloseChannelcloseChannelSynccloseChannelTombstonelocateOpenChannelarchiveClosedChannelInspect captured patch +376 / −16
diff --git a/channeldb/channel.go b/channeldb/channel.go
index 973ea22..c180e7a 100644
--- a/channeldb/channel.go
+++ b/channeldb/channel.go
@@ -4000,13 +4000,16 @@ type ChannelCloseSummary struct {
LastChanSyncMsg *lnwire.ChannelReestablish
}
-// CloseChannel closes a previously active Lightning channel. Closing a channel
-// entails deleting all saved state within the database concerning this
-// channel. This method also takes a struct that summarizes the state of the
-// channel at closing, this compact representation will be the only component
-// of a channel left over after a full closing. It takes an optional set of
-// channel statuses which will be written to the historical channel bucket.
-// These statuses are used to record close initiators.
+// CloseChannel closes a previously active Lightning channel. Closing a
+// channel entails persisting a record of the close while either purging the
+// nested per-channel state inline (synchronous backends like bbolt and etcd)
+// or skipping the cascading delete on tombstone-enabled backends, where the
+// outpoint-index flip to outpointClosed is the authoritative marker. The
+// compact summary written to closedChannelBucket and the historical record
+// under historicalChannelBucket are populated identically across both paths,
+// so historical reads remain uniform regardless of backend. The optional set
+// of channel statuses is OR'd into the chanStatus written to the historical
+// bucket and is used to record close initiators.
func (c *OpenChannel) CloseChannel(summary *ChannelCloseSummary,
statuses ...ChannelStatus) error {
@@ -4016,19 +4019,27 @@ func (c *OpenChannel) CloseChannel(summary *ChannelCloseSummary,
return c.Db.CloseChannel(c, summary, statuses...)
}
-// CloseChannel closes the supplied channel by deleting its per-channel state
-// — the revocation log, the per-channel forwarding-package bucket, and the
-// chanBucket itself — inline within a single write transaction, then
-// archiving the historical record and close summary.
+// CloseChannel closes the supplied channel via the strategy selected at DB
+// construction. On synchronous backends the channel's nested state — the
+// revocation log, the per-channel forwarding-package bucket, and the
+// chanBucket itself — is deleted inline. On tombstone-enabled backends none
+// of the bulk state is touched; the outpointBucket flip to outpointClosed
+// signals that the channel is logically closed.
func (c *ChannelStateDB) CloseChannel(channel *OpenChannel,
summary *ChannelCloseSummary, statuses ...ChannelStatus) error {
+ if c.tombstoneClosedChannels {
+ return c.closeChannelTombstone(channel, summary, statuses...)
+ }
+
return c.closeChannelSync(channel, summary, statuses...)
}
// locateOpenChannel performs the open-channel-bucket descent for a
// CloseChannel transaction: it returns the chain bucket, the channel bucket,
-// and the serialized chanKey for the supplied OpenChannel.
+// and the serialized chanKey for the supplied OpenChannel. A chanKey already
+// flipped to outpointClosed surfaces ErrChannelNotFound so a redundant
+// CloseChannel does not re-archive or re-flip the index.
func locateOpenChannel(tx kvdb.RwTx, channel *OpenChannel) (kvdb.RwBucket,
kvdb.RwBucket, []byte, error) {
@@ -4063,6 +4074,18 @@ func locateOpenChannel(tx kvdb.RwTx, channel *OpenChannel) (kvdb.RwBucket,
return nil, nil, nil, ErrNoActiveChannels
}
+ // A channel whose outpoint is already flipped to outpointClosed must
+ // not be re-closed: on tombstone backends the chanBucket survives a
+ // previous close, but the index flip is the authoritative record that
+ // the channel is gone from the open-channel view.
+ closed, err := isOutpointClosed(tx.ReadBucket(outpointBucket), chanKey)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ if closed {
+ return nil, nil, nil, ErrChannelNotFound
+ }
+
return chainBucket, chanBucket, chanKey, nil
}
@@ -4125,10 +4148,11 @@ func archiveClosedChannel(tx kvdb.RwTx, chanKey []byte,
return putChannelCloseSummary(tx, chanKey, summary, chanState)
}
-// closeChannelSync performs the synchronous close path: in a single write
-// transaction it wipes the forwarding-package state, deletes the channel
-// bucket and its nested revocation log entries, updates the outpoint index,
-// and archives the close summary.
+// closeChannelSync performs the historical synchronous close path: in a
+// single write transaction it wipes the forwarding-package state, deletes
+// the channel bucket and its nested revocation log entries, updates the
+// outpoint index, and archives the close summary. It is used by backends
+// where nested-bucket deletion is cheap (bbolt, etcd).
func (c *ChannelStateDB) closeChannelSync(channel *OpenChannel,
summary *ChannelCloseSummary, statuses ...ChannelStatus) error {
@@ -4181,6 +4205,39 @@ func (c *ChannelStateDB) closeChannelSync(channel *OpenChannel,
}, func() {})
}
+// closeChannelTombstone performs the tombstone close path used by
+// KV-over-SQL backends. The channel's per-channel state is left intact —
+// touching it would trigger the cascading nested-bucket delete this path
+// exists to avoid — and the outpointBucket flip from outpointOpen to
+// outpointClosed serves as the authoritative closed-channel marker. The
+// disk space is reclaimed wholesale by the upcoming native-SQL
+// channel-state migration.
+func (c *ChannelStateDB) closeChannelTombstone(channel *OpenChannel,
+ summary *ChannelCloseSummary, statuses ...ChannelStatus) error {
+
+ return kvdb.Update(c.backend, func(tx kvdb.RwTx) error {
+ _, chanBucket, chanKey, err := locateOpenChannel(tx, channel)
+ if err != nil {
+ return err
+ }
+
+ chanState, err := fetchOpenChannel(
+ chanBucket, &channel.FundingOutpoint,
+ )
+ if err != nil {
+ return err
+ }
+
+ if err := updateClosedOutpointIndex(tx, chanKey); err != nil {
+ return err
+ }
+
+ return archiveClosedChannel(
+ tx, chanKey, chanState, summary, statuses...,
+ )
+ }, func() {})
+}
+
// ChannelSnapshot is a frozen snapshot of the current channel state. A
// snapshot is detached from the original channel that generated it, providing
// read-only access to the current or prior state of an active channel.
diff --git a/channeldb/close_channel_test.go b/channeldb/close_channel_test.go
new file mode 100644
index 0000000..af011a0
--- /dev/null
+++ b/channeldb/close_channel_test.go
@@ -0,0 +1,303 @@
+package channeldb
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/btcsuite/btcd/wire"
+ graphdb "github.com/lightningnetwork/lnd/graph/db"
+ "github.com/lightningnetwork/lnd/kvdb"
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
+)
+
+// writeTestRevlogEntries writes n entries directly into the
+// revocationLogBucket of the given channel. The helper navigates the raw KV
+// tree so the test does not depend on the higher-level commit-chain
+// machinery.
+func writeTestRevlogEntries(t *testing.T, ch *OpenChannel, n int) {
+ t.Helper()
+
+ err := kvdb.Update(ch.Db.backend, func(tx kvdb.RwTx) error {
+ openChanBkt := tx.ReadWriteBucket(openChannelBucket)
+ require.NotNil(t, openChanBkt, "openChannelBucket missing")
+
+ nodePub := ch.IdentityPub.SerializeCompressed()
+ nodeBkt := openChanBkt.NestedReadWriteBucket(nodePub)
+ require.NotNil(t, nodeBkt, "node bucket missing")
+
+ chainBkt := nodeBkt.NestedReadWriteBucket(ch.ChainHash[:])
+ require.NotNil(t, chainBkt, "chain bucket missing")
+
+ var chanKeyBuf bytes.Buffer
+ err := graphdb.WriteOutpoint(&chanKeyBuf, &ch.FundingOutpoint)
+ require.NoError(t, err)
+
+ chanBkt := chainBkt.NestedReadWriteBucket(chanKeyBuf.Bytes())
+ require.NotNil(t, chanBkt, "channel bucket missing")
+
+ logBkt, err := chanBkt.CreateBucketIfNotExists(
+ revocationLogBucket,
+ )
+ require.NoError(t, err)
+
+ for i := range n {
+ commit := testChannelCommit
+ commit.CommitHeight = uint64(i)
+
+ err := putRevocationLog(logBkt, &commit, 0, 1, false)
+ require.NoError(t, err)
+ }
+
+ return nil
+ }, func() {})
+ require.NoError(t, err)
+}
+
+// writeTestForwardingPackages writes n empty forwarding packages for the
+// given channel using distinct remote commitment heights.
+func writeTestForwardingPackages(t *testing.T, ch *OpenChannel, n int) {
+ t.Helper()
+
+ packager := NewChannelPackager(ch.ShortChanID())
+ err := kvdb.Update(ch.Db.backend, func(tx kvdb.RwTx) error {
+ for i := range n {
+ pkg := NewFwdPkg(
+ ch.ShortChanID(), uint64(i), nil, nil,
+ )
+ if err := packager.AddFwdPkg(tx, pkg); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }, func() {})
+ require.NoError(t, err)
+}
+
+// countRevlogEntries returns the number of entries in the revocationLogBucket
+// for the given channel, or -1 if the channel bucket no longer exists in
+// openChannelBucket.
+func countRevlogEntries(t *testing.T, ch *OpenChannel) int {
+ t.Helper()
+
+ count := -1
+ err := kvdb.View(ch.Db.backend, func(tx kvdb.RTx) error {
+ openChanBkt := tx.ReadBucket(openChannelBucket)
+ if openChanBkt == nil {
+ return nil
+ }
+
+ nodePub := ch.IdentityPub.SerializeCompressed()
+ nodeBkt := openChanBkt.NestedReadBucket(nodePub)
+ if nodeBkt == nil {
+ return nil
+ }
+
+ chainBkt := nodeBkt.NestedReadBucket(ch.ChainHash[:])
+ if chainBkt == nil {
+ return nil
+ }
+
+ var chanKeyBuf bytes.Buffer
+ if err := graphdb.WriteOutpoint(
+ &chanKeyBuf, &ch.FundingOutpoint,
+ ); err != nil {
+ return err
+ }
+
+ chanBkt := chainBkt.NestedReadBucket(chanKeyBuf.Bytes())
+ if chanBkt == nil {
+ return nil
+ }
+
+ logBkt := chanBkt.NestedReadBucket(revocationLogBucket)
+ if logBkt == nil {
+ count = 0
+ return nil
+ }
+
+ c := 0
+ if err := logBkt.ForEach(func(_, _ []byte) error {
+ c++
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ count = c
+
+ return nil
+ }, func() {})
+ require.NoError(t, err)
+
+ return count
+}
+
+// readOutpointStatus decodes the indexStatus TLV byte stored under
+// outpointBucket for the given outpoint. Used to verify the index flip
+// performed by the close path.
+func readOutpointStatus(t *testing.T, cdb *ChannelStateDB,
+ op wire.OutPoint) indexStatus {
+
+ t.Helper()
+
+ var chanKeyBuf bytes.Buffer
+ require.NoError(t, graphdb.WriteOutpoint(&chanKeyBuf, &op))
+
+ var status uint8
+ err := kvdb.View(cdb.backend, func(tx kvdb.RTx) error {
+ bkt := tx.ReadBucket(outpointBucket)
+ require.NotNil(t, bkt, "outpointBucket missing")
+
+ raw := bkt.Get(chanKeyBuf.Bytes())
+ require.NotNil(t, raw, "outpoint entry missing")
+
+ statusRecord := tlv.MakePrimitiveRecord(
+ indexStatusType, &status,
+ )
+ stream, err := tlv.NewStream(statusRecord)
+ if err != nil {
+ return err
+ }
+
+ return stream.Decode(bytes.NewReader(raw))
+ }, func() {})
+ require.NoError(t, err)
+
+ return indexStatus(status)
+}
+
+// closeChannelForTest invokes CloseChannel on a freshly created OpenChannel
+// using a minimal close summary derived from the channel state itself.
+func closeChannelForTest(t *testing.T, cdb *ChannelStateDB, ch *OpenChannel) {
+ t.Helper()
+
+ summary := &ChannelCloseSummary{
+ ChanPoint: ch.FundingOutpoint,
+ RemotePub: ch.IdentityPub,
+ ChainHash: ch.ChainHash,
+ ShortChanID: ch.ShortChannelID,
+ CloseType: CooperativeClose,
+ }
+ require.NoError(t, cdb.CloseChannel(ch, summary))
+}
+
+// TestCloseChannelTombstoneWritePath verifies the on-disk artefacts the
+// tombstone close path produces in a single write transaction: the outpoint
+// index flips from open to closed, the historical-channel record and close
+// summary are written, and the bulk per-channel state (revocation log,
+// forwarding packages) is left intact — that retention is the entire reason
+// for the tombstone path on these backends.
+func TestCloseChannelTombstoneWritePath(t *testing.T) {
+ t.Parallel()
+
+ fullDB, err := MakeTestDB(t, OptionTombstoneClosedChannels(true))
+ require.NoError(t, err)
+
+ cdb := fullDB.ChannelStateDB()
+ require.True(t, cdb.tombstoneClosedChannels)
+
+ ch := createTestChannel(t, cdb, openChannelOption())
+
+ const numRevlogEntries = 5
+ const numFwdPkgs = 3
+ writeTestRevlogEntries(t, ch, numRevlogEntries)
+ writeTestForwardingPackages(t, ch, numFwdPkgs)
+
+ closeChannelForTest(t, cdb, ch)
+
+ // Outpoint index flipped from open to closed — the authoritative
+ // closed-channel marker on tombstone backends.
+ require.Equal(t, outpointClosed, readOutpointStatus(
+ t, cdb, ch.FundingOutpoint,
+ ))
+
+ // Historical-channel record exists for this chanKey.
+ histChan, err := cdb.FetchHistoricalChannel(&ch.FundingOutpoint)
+ require.NoError(t, err)
+ require.Equal(t, ch.FundingOutpoint, histChan.FundingOutpoint)
+
+ // Close summary readable via FetchClosedChannel.
+ closeSummary, err := cdb.FetchClosedChannel(&ch.FundingOutpoint)
+ require.NoError(t, err)
+ require.Equal(t, ch.FundingOutpoint, closeSummary.ChanPoint)
+
+ // Bulk state preserved on disk — tombstoning's whole point.
+ require.Equal(t, numRevlogEntries, countRevlogEntries(t, ch))
+
+ packager := NewChannelPackager(ch.ShortChanID())
+ var fwdPkgs []*FwdPkg
+ require.NoError(t, kvdb.View(cdb.backend, func(tx kvdb.RTx) error {
+ fwdPkgs, err = packager.LoadFwdPkgs(tx)
+ return err
+ }, func() {}))
+ require.Len(t, fwdPkgs, numFwdPkgs)
+}
+
+// TestCloseChannelTombstoneRedundantClose verifies that a second CloseChannel
+// call against an already-closed channel is rejected with ErrChannelNotFound
+// rather than silently re-archiving or re-flipping the outpoint. The guard
+// lives in locateOpenChannel.
+func TestCloseChannelTombstoneRedundantClose(t *testing.T) {
+ t.Parallel()
+
+ fullDB, err := MakeTestDB(t, OptionTombstoneClosedChannels(true))
+ require.NoError(t, err)
+
+ cdb := fullDB.ChannelStateDB()
+ ch := createTestChannel(t, cdb, openChannelOption())
+
+ closeChannelForTest(t, cdb, ch)
+
+ summary := &ChannelCloseSummary{
+ ChanPoint: ch.FundingOutpoint,
+ RemotePub: ch.IdentityPub,
+ ChainHash: ch.ChainHash,
+ ShortChanID: ch.ShortChannelID,
+ CloseType: CooperativeClose,
+ }
+ require.ErrorIs(t, cdb.CloseChannel(ch, summary), ErrChannelNotFound)
+}
+
+// TestCloseChannelSync exercises the synchronous one-shot close path used by
+// backends that do not opt in to tombstones (bbolt, etcd). It locks in the
+// invariant that after CloseChannel returns the channel bucket and its
+// revocation-log entries are already gone, and that the close summary,
+// historical record, and outpoint flip are all in place.
+func TestCloseChannelSync(t *testing.T) {
+ t.Parallel()
+
+ fullDB, err := MakeTestDB(t)
+ require.NoError(t, err)
+
+ cdb := fullDB.ChannelStateDB()
+ require.False(t, cdb.tombstoneClosedChannels)
+
+ ch := createTestChannel(t, cdb, openChannelOption())
+
+ const numRevlogEntries = 4
+ writeTestRevlogEntries(t, ch, numRevlogEntries)
+ writeTestForwardingPackages(t, ch, 3)
+
+ closeChannelForTest(t, cdb, ch)
+
+ // The synchronous path wipes the chanBucket inline, so
+ // countRevlogEntries must report -1 (bucket is gone, not just empty).
+ require.Equal(t, -1, countRevlogEntries(t, ch),
+ "channel bucket must be deleted after sync close")
+
+ // Forwarding packages are wiped inline.
+ var fwdPkgs []*FwdPkg
+ packager := NewChannelPackager(ch.ShortChanID())
+ require.NoError(t, kvdb.View(cdb.backend, func(tx kvdb.RTx) error {
+ fwdPkgs, err = packager.LoadFwdPkgs(tx)
+ return err
+ }, func() {}))
+ require.Empty(t, fwdPkgs)
+
+ // The outpoint index reflects the close.
+ require.Equal(t, outpointClosed, readOutpointStatus(
+ t, cdb, ch.FundingOutpoint,
+ ))
+}
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.