channeldb: hide tombstoned channels from open views
What changed, and why it matters
This commit fixes an internal bookkeeping bug in LND's channel database. A new 'tombstone' close feature keeps closed channel data on disk but marks the channel as closed in a separate index. Before this fix, several database readers still treated those tombstoned channels as if they were open, which could make the node try to use or report already-closed channels as active. The patch wires every reader to check the closed-channel index first. It is a correctness fix rather than a remote exploit, but it prevents state confusion that could affect routing, channel management, or peer protections.
Reviewers should confirm that no additional open-channel readers bypass the outpointBucket check, and that the follow-up commit enabling OptionTombstoneClosedChannels for sqlite/postgres lands together with this change. Operators on bbolt/etcd are unaffected because tombstones remain disabled there. No immediate emergency response is warranted, but the fix should be included in the next release.
Security signals we found
State-consistency bug: closed channels could be surfaced as open
Tombstone index (outpointBucket) not consulted by multiple readers
Potential for stale channel state to influence routing, peer management, or channel operations
Fix is preparatory: tombstone feature not yet enabled for sqlite/postgres in this commit
No remote attacker-controlled input path visible in the diff
Evidence from the diff
The patch updates six call sites in channeldb that descend into openChannelBucket so they consult isOutpointClosed against the outpointBucket index before treating a chanKey as open. Affected paths: fetchChanBucket/fetchChanBucketRw (direct lookups used by Refresh, MarkBorked, and other OpenChannel methods), fetchNodeChannels (per-node iteration used by FetchAllChannels/FetchOpenChannels), FetchPermAndTempPeers (cross-node iteration), and channelScanner (used by FetchChannel/FetchChannelByID). On tombstone-enabled backends the per-channel bucket is intentionally retained, so the outpoint index is the source of truth for open-vs-closed. The change is defensive: without it, a follow-up commit enabling tombstones for sqlite/postgres would surface closed channels as open. Tests verify closed channels disappear from open scans while historical state and peer closed-channel flags remain correct.
Changed components
channeldb/channel.gochanneldb/db.gofetchChanBucket / fetchChanBucketRwfetchNodeChannelsFetchPermAndTempPeerschannelScannerFetchAllChannelsFetchOpenChannelsFetchChannel / FetchChannelByIDOpenChannel.Refresh / MarkBorkedInspect captured patch +197 / −8
diff --git a/channeldb/channel.go b/channeldb/channel.go
index c180e7a..127e0ac 100644
--- a/channeldb/channel.go
+++ b/channeldb/channel.go
@@ -1452,7 +1452,20 @@ func fetchChanBucket(tx kvdb.RTx, nodeKey *btcec.PublicKey,
if err := graphdb.WriteOutpoint(&chanPointBuf, outPoint); err != nil {
return nil, err
}
- chanBucket := chainBucket.NestedReadBucket(chanPointBuf.Bytes())
+ chanKey := chanPointBuf.Bytes()
+
+ // Treat already-closed channels as gone. The chanBucket may still
+ // exist on tombstone-enabled backends; the outpoint flip is the
+ // source of truth.
+ closed, err := isOutpointClosed(tx.ReadBucket(outpointBucket), chanKey)
+ if err != nil {
+ return nil, err
+ }
+ if closed {
+ return nil, ErrChannelNotFound
+ }
+
+ chanBucket := chainBucket.NestedReadBucket(chanKey)
if chanBucket == nil {
return nil, ErrChannelNotFound
}
@@ -1499,7 +1512,20 @@ func fetchChanBucketRw(tx kvdb.RwTx, nodeKey *btcec.PublicKey,
if err := graphdb.WriteOutpoint(&chanPointBuf, outPoint); err != nil {
return nil, err
}
- chanBucket := chainBucket.NestedReadWriteBucket(chanPointBuf.Bytes())
+ chanKey := chanPointBuf.Bytes()
+
+ // Treat already-closed channels as gone. The chanBucket may still
+ // exist on tombstone-enabled backends; the outpoint flip is the
+ // source of truth.
+ closed, err := isOutpointClosed(tx.ReadBucket(outpointBucket), chanKey)
+ if err != nil {
+ return nil, err
+ }
+ if closed {
+ return nil, ErrChannelNotFound
+ }
+
+ chanBucket := chainBucket.NestedReadWriteBucket(chanKey)
if chanBucket == nil {
return nil, ErrChannelNotFound
}
diff --git a/channeldb/close_channel_test.go b/channeldb/close_channel_test.go
index af011a0..3a940b7 100644
--- a/channeldb/close_channel_test.go
+++ b/channeldb/close_channel_test.go
@@ -260,6 +260,109 @@ func TestCloseChannelTombstoneRedundantClose(t *testing.T) {
require.ErrorIs(t, cdb.CloseChannel(ch, summary), ErrChannelNotFound)
}
+// TestCloseChannelTombstoneRemovesFromOpenScans verifies that after a
+// tombstone close the channel disappears from every open-channel scan
+// (FetchAllChannels, FetchOpenChannels, FetchPermAndTempPeers) while the
+// outpoint index reflects the close. The bulk historical state remains on
+// disk — that is the entire point of the tombstone path.
+func TestCloseChannelTombstoneRemovesFromOpenScans(t *testing.T) {
+ t.Parallel()
+
+ fullDB, err := MakeTestDB(t, OptionTombstoneClosedChannels(true))
+ require.NoError(t, err)
+
+ cdb := fullDB.ChannelStateDB()
+ require.True(t, cdb.tombstoneClosedChannels)
+
+ // Two channels share an identity pubkey via createTestChannel, so we
+ // can verify the closed one disappears while the other is still
+ // surfaced by per-peer lookups.
+ ch1 := createTestChannel(t, cdb, openChannelOption())
+ ch2 := createTestChannel(t, cdb, openChannelOption())
+
+ const numRevlogEntries = 5
+ writeTestRevlogEntries(t, ch1, numRevlogEntries)
+
+ openChans, err := cdb.FetchAllChannels()
+ require.NoError(t, err)
+ require.Len(t, openChans, 2)
+
+ closeChannelForTest(t, cdb, ch1)
+
+ openChans, err = cdb.FetchAllChannels()
+ require.NoError(t, err)
+ require.Len(t, openChans, 1)
+ require.Equal(
+ t, ch2.FundingOutpoint, openChans[0].FundingOutpoint,
+ )
+
+ openChans, err = cdb.FetchOpenChannels(ch1.IdentityPub)
+ require.NoError(t, err)
+ require.Len(t, openChans, 1)
+ require.Equal(
+ t, ch2.FundingOutpoint, openChans[0].FundingOutpoint,
+ )
+
+ // FetchPermAndTempPeers should still mark the peer as having a closed
+ // channel (via the historical-channel second pass), even though the
+ // open-channel-bucket pass now skips the closed chanKey.
+ peers, err := cdb.FetchPermAndTempPeers(ch1.ChainHash[:])
+ require.NoError(t, err)
+ peerKey := string(ch1.IdentityPub.SerializeCompressed())
+ require.True(t, peers[peerKey].HasOpenOrClosedChan)
+
+ // The bulk historical state stays put — that is the whole point of
+ // the tombstone path on these backends.
+ require.Equal(t, numRevlogEntries, countRevlogEntries(t, ch1))
+
+ // The outpoint index for ch1 must flip to closed; ch2's stays open.
+ require.Equal(t, outpointClosed, readOutpointStatus(
+ t, cdb, ch1.FundingOutpoint,
+ ))
+ require.Equal(t, outpointOpen, readOutpointStatus(
+ t, cdb, ch2.FundingOutpoint,
+ ))
+}
+
+// TestClosedChannelHiddenFromFetchChannel verifies that a targeted
+// FetchChannel lookup returns ErrChannelNotFound for a closed channel.
+// FetchChannel goes through channelScanner, exercising the closed-state
+// check inside that iteration site rather than the direct fetchChanBucket
+// lookup path.
+func TestClosedChannelHiddenFromFetchChannel(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)
+
+ _, err = cdb.FetchChannel(ch.FundingOutpoint)
+ require.ErrorIs(t, err, ErrChannelNotFound)
+}
+
+// TestClosedChannelHiddenFromDirectMethods verifies that direct OpenChannel
+// methods which descend through fetchChanBucket / fetchChanBucketRw observe
+// the closed-state flip and return ErrChannelNotFound rather than reading
+// stale per-channel state.
+func TestClosedChannelHiddenFromDirectMethods(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)
+
+ require.ErrorIs(t, ch.Refresh(), ErrChannelNotFound)
+ require.ErrorIs(t, ch.MarkBorked(), 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
diff --git a/channeldb/db.go b/channeldb/db.go
index d1a7ad0..9cd627f 100644
--- a/channeldb/db.go
+++ b/channeldb/db.go
@@ -628,7 +628,7 @@ func (c *ChannelStateDB) fetchOpenChannels(tx kvdb.RTx,
// Finally, we both of the necessary buckets retrieved, fetch
// all the active channels related to this node.
- nodeChannels, err := c.fetchNodeChannels(chainBucket)
+ nodeChannels, err := c.fetchNodeChannels(tx, chainBucket)
if err != nil {
return fmt.Errorf("unable to read channel for "+
"chain_hash=%x, node_key=%x: %v",
@@ -644,12 +644,19 @@ func (c *ChannelStateDB) fetchOpenChannels(tx kvdb.RTx,
// fetchNodeChannels retrieves all active channels from the target chainBucket
// which is under a node's dedicated channel bucket. This function is typically
-// used to fetch all the active channels related to a particular node.
-func (c *ChannelStateDB) fetchNodeChannels(chainBucket kvdb.RBucket) (
- []*OpenChannel, error) {
+// used to fetch all the active channels related to a particular node. Channels
+// already flipped to outpointClosed in the outpoint index are skipped silently
+// — readers see only channels that are still considered open.
+func (c *ChannelStateDB) fetchNodeChannels(tx kvdb.RTx,
+ chainBucket kvdb.RBucket) ([]*OpenChannel, error) {
var channels []*OpenChannel
+ // Hoist the outpoint-bucket lookup so the closed-channel check inside
+ // the loop is a per-iteration map probe rather than a tx-level bucket
+ // resolve.
+ opBucket := tx.ReadBucket(outpointBucket)
+
// A node may have channels on several chains, so for each known chain,
// we'll extract all the channels.
err := chainBucket.ForEach(func(chanPoint, v []byte) error {
@@ -658,12 +665,24 @@ func (c *ChannelStateDB) fetchNodeChannels(chainBucket kvdb.RBucket) (
return nil
}
+ // Skip already-closed channels. The chanBucket still exists
+ // on disk on tombstone-enabled backends; the outpoint flip is
+ // the sole signal that the channel should be treated as
+ // closed.
+ isClosed, err := isOutpointClosed(opBucket, chanPoint)
+ if err != nil {
+ return err
+ }
+ if isClosed {
+ return nil
+ }
+
// Once we've found a valid channel bucket, we'll extract it
// from the node's chain bucket.
chanBucket := chainBucket.NestedReadBucket(chanPoint)
var outPoint wire.OutPoint
- err := graphdb.ReadOutpoint(
+ err = graphdb.ReadOutpoint(
bytes.NewReader(chanPoint), &outPoint,
)
if err != nil {
@@ -778,6 +797,11 @@ func (c *ChannelStateDB) FetchPermAndTempPeers(
return ErrNoChanDBExists
}
+ // Hoist the outpoint-bucket lookup so the closed-channel check
+ // inside the nested chainBucket.ForEach below is a per-channel
+ // map probe rather than a tx-level bucket resolve.
+ opBucket := tx.ReadBucket(outpointBucket)
+
openChanErr := openChanBucket.ForEach(func(nodePub,
v []byte) error {
@@ -811,6 +835,22 @@ func (c *ChannelStateDB) FetchPermAndTempPeers(
return nil
}
+ // Skip already-closed channels: they are
+ // logically closed even though their
+ // per-channel state still resides under
+ // chainBucket. The closed peer's protected
+ // status is established below via the
+ // historical-channel scan.
+ isClosed, err := isOutpointClosed(
+ opBucket, chanPoint,
+ )
+ if err != nil {
+ return err
+ }
+ if isClosed {
+ return nil
+ }
+
chanBucket := chainBucket.NestedReadBucket(
chanPoint,
)
@@ -983,6 +1023,11 @@ func (c *ChannelStateDB) channelScanner(tx kvdb.RTx,
return ErrNoActiveChannels
}
+ // Hoist the outpoint-bucket lookup so the closed-channel
+ // check inside the per-chain ForEach below pays one tx-level
+ // bucket resolve total instead of one per visited chanKey.
+ opBucket := tx.ReadBucket(outpointBucket)
+
// Within the node channel bucket, are the set of node pubkeys
// we have channels with, we don't know the entire set, so we'll
// check them all.
@@ -1031,6 +1076,19 @@ func (c *ChannelStateDB) channelScanner(tx kvdb.RTx,
return err
}
+ // An already-closed channel is logically gone
+ // and must not be surfaced by lookup-style
+ // scans.
+ isClosed, err := isOutpointClosed(
+ opBucket, targetChanBytes,
+ )
+ if err != nil {
+ return err
+ }
+ if isClosed {
+ return nil
+ }
+
chanBucket := chainBucket.NestedReadBucket(
targetChanBytes,
)
@@ -1194,7 +1252,9 @@ func fetchChannels(c *ChannelStateDB, filters ...fetchChannelsFilter) (
"bucket for chain=%x", chainHash[:])
}
- nodeChans, err := c.fetchNodeChannels(chainBucket)
+ nodeChans, err := c.fetchNodeChannels(
+ tx, chainBucket,
+ )
if err != nil {
return fmt.Errorf("unable to read "+
"channel for chain_hash=%x, "+
Why this scored 59/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.