What changed, and why it matters
This commit updates how the Lightning Network Daemon (LND) decides whether a payment channel is a 'zombie'—meaning it has gone stale and should be ignored or pruned. Previously, zombie detection only looked at wall-clock timestamps, which works for older v1 gossip channels but not for newer v2 channels that use block heights to show freshness. The change lets the code inspect the full channel update information, including its version and freshness type, so v2 channels are correctly evaluated by block height rather than by time. This is a correctness improvement, not an obvious active vulnerability, but misclassifying v2 channels could affect routing reliability and graph health.
Review the new isTimestampStale logic and the updated call sites to confirm that v2 block-height freshness is correctly propagated everywhere IsZombieChannel is invoked. Run the new TestIsZombieChannel tests and any integration tests covering gossip sync and channel pruning. Monitor for any edge cases where zero/invalid block heights or Unix timestamps are treated as stale, which could cause unintended pruning.
Security signals we found
Version-aware zombie pruning closes a correctness gap where v2 channel freshness was evaluated only by time, not block height
Removal of a TODO noting that v2 block-height freshness was not handled in gossip sync
Refactoring of a security-relevant predicate (zombie detection) across gossip and routing stacks
Potential for changed graph pruning behavior on v2 channels, which could affect routing decisions and channel availability
Evidence from the diff
The commit refactors IsZombieChannel / isStillZombieChannel from func(time.Time, time.Time) bool to func(graphdb.ChannelUpdateInfo) bool across discovery/gossiper.go, discovery/sync_manager.go, discovery/syncer.go, and graph/builder.go. Builder.IsZombieChannel now delegates to a new isTimestampStale helper that selects expiry logic based on lnwire.GossipVersion: UnixTimestamp wall-clock expiry for GossipVersion1, and BlockHeightTimestamp block-count expiry (ChannelPruneExpiry / avgBitcoinBlockTime) for v2+. The syncer no longer strips v2 block-height freshness down to time.Time before calling the zombie predicate, resolving an explicit TODO about v2 handling. Tests are added in graph/builder_test.go covering v1/v2 and strict/non-strict cases.
Changed components
graph/builder.godiscovery/gossiper.godiscovery/sync_manager.godiscovery/syncer.gograph/builder_test.goInspect captured patch +149 / −50
diff --git a/discovery/gossiper.go b/discovery/gossiper.go
index fd0f371..49481a1 100644
--- a/discovery/gossiper.go
+++ b/discovery/gossiper.go
@@ -380,10 +380,9 @@ type Config struct {
FindChannel func(node *btcec.PublicKey, chanID lnwire.ChannelID) (
*channeldb.OpenChannel, error)
- // IsStillZombieChannel takes the timestamps of the latest channel
- // updates for a channel and returns true if the channel should be
- // considered a zombie based on these timestamps.
- IsStillZombieChannel func(time.Time, time.Time) bool
+ // IsStillZombieChannel returns true if the channel described by info
+ // should still be considered a zombie.
+ IsStillZombieChannel func(graphdb.ChannelUpdateInfo) bool
// AssumeChannelValid toggles whether the gossiper will check for
// spent-ness of channel outpoints. For neutrino, this saves long
diff --git a/discovery/sync_manager.go b/discovery/sync_manager.go
index 56e81e6..4ea3eac 100644
--- a/discovery/sync_manager.go
+++ b/discovery/sync_manager.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/btcsuite/btcd/chaincfg/chainhash"
+ graphdb "github.com/lightningnetwork/lnd/graph/db"
"github.com/lightningnetwork/lnd/lnpeer"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
@@ -129,10 +130,9 @@ type SyncManagerCfg struct {
// PassiveSync.
PinnedSyncers PinnedSyncers
- // IsStillZombieChannel takes the timestamps of the latest channel
- // updates for a channel and returns true if the channel should be
- // considered a zombie based on these timestamps.
- IsStillZombieChannel func(time.Time, time.Time) bool
+ // IsStillZombieChannel returns true if the channel described by info
+ // should still be considered a zombie.
+ IsStillZombieChannel func(graphdb.ChannelUpdateInfo) bool
// AllotedMsgBytesPerSecond is the allotted bandwidth rate, expressed in
// bytes/second that the gossip manager can consume. Once we exceed this
diff --git a/discovery/syncer.go b/discovery/syncer.go
index f6426c0..7c59cc2 100644
--- a/discovery/syncer.go
+++ b/discovery/syncer.go
@@ -284,10 +284,9 @@ type gossipSyncerCfg struct {
// for a single QueryChannelRange request.
maxQueryChanRangeReplies uint32
- // isStillZombieChannel takes the timestamps of the latest channel
- // updates for a channel and returns true if the channel should be
- // considered a zombie based on these timestamps.
- isStillZombieChannel func(time.Time, time.Time) bool
+ // isStillZombieChannel returns true if the channel described by info
+ // should still be considered a zombie.
+ isStillZombieChannel func(graphdb.ChannelUpdateInfo) bool
// timestampQueueSize is the size of the timestamp range queue. If not
// set, defaults to the global timestampQueueSize constant.
@@ -974,7 +973,9 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context,
g.prevReplyChannelRange = msg
for i, scid := range msg.ShortChanIDs {
- info := graphdb.NewV1ChannelUpdateInfo(scid, time.Time{}, time.Time{})
+ info := graphdb.NewV1ChannelUpdateInfo(
+ scid, time.Time{}, time.Time{},
+ )
if len(msg.Timestamps) != 0 {
info.Node1Freshness = lnwire.UnixTimestamp(
@@ -1060,17 +1061,9 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context,
// Otherwise, this is the final response, so we'll now check to see
// which channels they know of that we don't.
- // TODO(elle): isStillZombieChannel only inspects v1 time-based
- // timestamps; once the gossip sync protocol supports v2, this
- // should be updated to handle block-height freshness for v2
- // channels.
- isZombieChan := func(info graphdb.ChannelUpdateInfo) bool {
- return g.cfg.isStillZombieChannel(
- info.Node1FreshnessTime(), info.Node2FreshnessTime(),
- )
- }
newChans, err := g.cfg.channelSeries.FilterKnownChanIDs(
- g.cfg.chainHash, g.bufferedChanRangeReplies, isZombieChan,
+ g.cfg.chainHash, g.bufferedChanRangeReplies,
+ g.cfg.isStillZombieChannel,
)
if err != nil {
return fmt.Errorf("unable to filter chan ids: %w", err)
diff --git a/graph/builder.go b/graph/builder.go
index 3040709..2e17ef5 100644
--- a/graph/builder.go
+++ b/graph/builder.go
@@ -465,30 +465,57 @@ func (b *Builder) syncGraphWithChain() error {
return nil
}
-// isPolicyZombie returns true if the given edge policy is considered stale
-// based on version-specific freshness criteria. For v1 policies, staleness is
-// determined by wall-clock time since the last update. For v2 policies,
-// staleness is determined by how many blocks have elapsed since the last
-// update block height.
-func (b *Builder) isPolicyZombie(e *models.ChannelEdgePolicy) bool {
+// isTimestampStale returns true if the given freshness timestamp is considered
+// stale based on the gossip version. For v1, staleness is determined by
+// wall-clock time since the unix timestamp. For v2, staleness is determined by
+// how many blocks have elapsed since the block height timestamp.
+func (b *Builder) isTimestampStale(v lnwire.GossipVersion,
+ freshness lnwire.Timestamp) bool {
+
chanExpiry := b.cfg.ChannelPruneExpiry
- switch e.Version {
+ switch v {
case lnwire.GossipVersion1:
- return time.Since(e.LastUpdate) >= chanExpiry
+ ts, ok := freshness.(lnwire.UnixTimestamp)
+ if !ok || ts.IsZero() {
+ return true
+ }
+
+ t := time.Unix(int64(ts), 0)
+
+ return time.Since(t) >= chanExpiry
default:
+ h, ok := freshness.(lnwire.BlockHeightTimestamp)
+ if !ok || uint32(h) == 0 {
+ return true
+ }
+
expiryBlocks := uint32(chanExpiry / avgBitcoinBlockTime)
currentHeight := b.bestHeight.Load()
+ height := uint32(h)
- if e.LastBlockHeight > currentHeight {
+ if height > currentHeight {
return false
}
- return currentHeight-e.LastBlockHeight >= expiryBlocks
+ return currentHeight-height >= expiryBlocks
}
}
+// isPolicyZombie returns true if the given edge policy is considered stale
+// based on version-specific freshness criteria.
+func (b *Builder) isPolicyZombie(e *models.ChannelEdgePolicy) bool {
+ var freshness lnwire.Timestamp
+ if e.Version == lnwire.GossipVersion1 {
+ freshness = lnwire.UnixTimestamp(e.LastUpdate.Unix())
+ } else {
+ freshness = lnwire.BlockHeightTimestamp(e.LastBlockHeight)
+ }
+
+ return b.isTimestampStale(e.Version, freshness)
+}
+
// isZombieChannel takes two edge policy updates and determines if the
// corresponding channel should be considered a zombie. The first boolean is
// true if the policy update from node 1 is considered a zombie, the second
@@ -510,28 +537,17 @@ func (b *Builder) isZombieChannel(e1,
return e1Zombie, e2Zombie, e1Zombie && e2Zombie
}
-// IsZombieChannel takes the timestamps of the latest channel updates for a
-// channel and returns true if the channel should be considered a zombie based
-// on these timestamps.
-func (b *Builder) IsZombieChannel(updateTime1,
- updateTime2 time.Time) bool {
-
- chanExpiry := b.cfg.ChannelPruneExpiry
-
- e1Zombie := updateTime1.IsZero() ||
- time.Since(updateTime1) >= chanExpiry
-
- e2Zombie := updateTime2.IsZero() ||
- time.Since(updateTime2) >= chanExpiry
+// IsZombieChannel returns true if the channel described by info should be
+// considered a zombie. For v1 channels, freshness is a unix timestamp; for v2+
+// channels it is a block height.
+func (b *Builder) IsZombieChannel(info graphdb.ChannelUpdateInfo) bool {
+ e1Zombie := b.isTimestampStale(info.Version, info.Node1Freshness)
+ e2Zombie := b.isTimestampStale(info.Version, info.Node2Freshness)
- // If we're using strict zombie pruning, then a channel is only
- // considered live if both edges have a recent update we know of.
if b.cfg.StrictZombiePruning {
return e1Zombie || e2Zombie
}
- // Otherwise, if we're using the less strict variant, then a channel is
- // considered live if either of the edges have a recent update.
return e1Zombie && e2Zombie
}
diff --git a/graph/builder_test.go b/graph/builder_test.go
index 8e7e5f2..5b62439 100644
--- a/graph/builder_test.go
+++ b/graph/builder_test.go
@@ -1037,6 +1037,97 @@ func TestIsPolicyZombie(t *testing.T) {
}
}
+// TestIsZombieChannel verifies that IsZombieChannel uses version-aware
+// freshness checks and applies strict zombie pruning correctly.
+func TestIsZombieChannel(t *testing.T) {
+ t.Parallel()
+
+ const (
+ pruneExpiry = time.Hour
+ currentHeight = uint32(1000)
+ )
+
+ tests := []struct {
+ name string
+ strictZombiePruning bool
+ info graphdb.ChannelUpdateInfo
+ zombie bool
+ }{
+ {
+ name: "v1 both stale",
+ info: graphdb.NewV1ChannelUpdateInfo(
+ lnwire.ShortChannelID{},
+ time.Now().Add(-2*pruneExpiry),
+ time.Now().Add(-2*pruneExpiry),
+ ),
+ zombie: true,
+ },
+ {
+ name: "v1 one stale not strict",
+ info: graphdb.NewV1ChannelUpdateInfo(
+ lnwire.ShortChannelID{},
+ time.Now().Add(-2*pruneExpiry),
+ time.Now(),
+ ),
+ zombie: false,
+ },
+ {
+ name: "v1 one stale strict",
+ strictZombiePruning: true,
+ info: graphdb.NewV1ChannelUpdateInfo(
+ lnwire.ShortChannelID{},
+ time.Now().Add(-2*pruneExpiry),
+ time.Now(),
+ ),
+ zombie: true,
+ },
+ {
+ name: "v2 both stale",
+ info: graphdb.NewV2ChannelUpdateInfo(
+ lnwire.ShortChannelID{}, 987, 988,
+ ),
+ zombie: true,
+ },
+ {
+ name: "v2 one stale not strict",
+ info: graphdb.NewV2ChannelUpdateInfo(
+ lnwire.ShortChannelID{}, 987, 995,
+ ),
+ zombie: false,
+ },
+ {
+ name: "v2 one stale strict",
+ strictZombiePruning: true,
+ info: graphdb.NewV2ChannelUpdateInfo(
+ lnwire.ShortChannelID{}, 987, 995,
+ ),
+ zombie: true,
+ },
+ }
+
+ for _, test := range tests {
+ test := test
+
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+
+ strictPruning := test.strictZombiePruning
+ b := &Builder{
+ cfg: &Config{
+ ChannelPruneExpiry: pruneExpiry,
+ StrictZombiePruning: strictPruning,
+ },
+ }
+ b.bestHeight.Store(currentHeight)
+
+ require.Equal(
+ t, test.zombie,
+ b.IsZombieChannel(test.info),
+ )
+ })
+ }
+}
+
// TestIsStaleNode tests that the IsStaleNode method properly detects stale
// node announcements.
func TestIsStaleNode(t *testing.T) {
Why this scored 40/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.