What changed, and why it matters
This commit changes how the Lightning Network Daemon (LND) decides when a payment channel is a 'zombie' (inactive and should be removed from the network graph). Previously, all channels were judged by wall-clock time since their last update. Now, newer 'v2' gossip channels are judged by how many Bitcoin blocks have passed since their last update block height. This is a correctness/robustness improvement for the newer gossip protocol, not a fix for an active exploit. It reduces the risk that v2 channels are wrongly pruned or kept due to clock skew.
Review the hard-coded avgBitcoinBlockTime assumption and consider whether it should be configurable or adjusted for testnet/signet. Ensure that downstream callers of IsZombieChannel still behave correctly now that isZombieChannel no longer uses it. No urgent security patch is indicated.
Security signals we found
Version-aware staleness logic reduces risk of incorrect graph pruning for v2 gossip channels
Future LastBlockHeight is explicitly treated as non-zombie, preventing premature pruning
Hard-coded avgBitcoinBlockTime (10 minutes) introduces approximation risk if block times diverge significantly
Refactor removes indirect call through exported IsZombieChannel, centralizing policy logic
Evidence from the diff
The patch introduces isPolicyZombie in graph/builder.go, which switches zombie detection logic based on the channel edge policy’s GossipVersion. For GossipVersion1, staleness remains time.Since(e.LastUpdate) >= ChannelPruneExpiry. For GossipVersion2 (default case), it converts the configured time expiry to a block count using avgBitcoinBlockTime (10 minutes) and compares current best chain height against e.LastBlockHeight. isZombieChannel is refactored to call isPolicyZombie per edge and inline strict/non-strict pruning logic, removing the indirect call through IsZombieChannel. Tests cover v1 time-based, v2 block-based, boundary, and future-block cases.
Changed components
graph/builder.gograph/builder_test.goChannelEdgePolicy zombie/pruning logicGossipVersion2 channel announcementsInspect captured patch +135 / −11
diff --git a/graph/builder.go b/graph/builder.go
index 391195f..1dc5aff 100644
--- a/graph/builder.go
+++ b/graph/builder.go
@@ -29,6 +29,11 @@ const (
// if a channel should be pruned or not.
DefaultChannelPruneExpiry = time.Hour * 24 * 14
+ // avgBitcoinBlockTime is the approximate time between Bitcoin blocks,
+ // used to convert a time-based channel prune expiry into a
+ // block-height-based expiry for v2 gossip channels.
+ avgBitcoinBlockTime = 10 * time.Minute
+
// DefaultFirstTimePruneDelay is the time we'll wait after startup
// before attempting to prune the graph for zombie channels. We don't
// do it immediately after startup to allow lnd to start up without
@@ -460,6 +465,30 @@ 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 {
+ chanExpiry := b.cfg.ChannelPruneExpiry
+
+ switch e.Version {
+ case lnwire.GossipVersion1:
+ return time.Since(e.LastUpdate) >= chanExpiry
+
+ default:
+ expiryBlocks := uint32(chanExpiry / avgBitcoinBlockTime)
+ currentHeight := b.bestHeight.Load()
+
+ if e.LastBlockHeight > currentHeight {
+ return false
+ }
+
+ return currentHeight-e.LastBlockHeight >= expiryBlocks
+ }
+}
+
// 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
@@ -468,20 +497,17 @@ func (b *Builder) syncGraphWithChain() error {
func (b *Builder) isZombieChannel(e1,
e2 *models.ChannelEdgePolicy) (bool, bool, bool) {
- chanExpiry := b.cfg.ChannelPruneExpiry
+ e1Zombie := e1 == nil || b.isPolicyZombie(e1)
+ e2Zombie := e2 == nil || b.isPolicyZombie(e2)
- e1Zombie := e1 == nil || time.Since(e1.LastUpdate) >= chanExpiry
- e2Zombie := e2 == nil || time.Since(e2.LastUpdate) >= chanExpiry
-
- var e1Time, e2Time time.Time
- if e1 != nil {
- e1Time = e1.LastUpdate
- }
- if e2 != nil {
- e2Time = e2.LastUpdate
+ // If strict zombie pruning is enabled, a channel is a zombie if
+ // either edge is stale.
+ if b.cfg.StrictZombiePruning {
+ return e1Zombie, e2Zombie, e1Zombie || e2Zombie
}
- return e1Zombie, e2Zombie, b.IsZombieChannel(e1Time, e2Time)
+ // Otherwise a channel is only a zombie if both edges are stale.
+ return e1Zombie, e2Zombie, e1Zombie && e2Zombie
}
// IsZombieChannel takes the timestamps of the latest channel updates for a
diff --git a/graph/builder_test.go b/graph/builder_test.go
index 2f9dc41..8e7e5f2 100644
--- a/graph/builder_test.go
+++ b/graph/builder_test.go
@@ -939,6 +939,104 @@ func testPruneChannelGraphDoubleDisabled(t *testing.T, assumeValid bool) {
}
}
+// TestIsPolicyZombie verifies that isPolicyZombie correctly classifies edge
+// policies as stale or fresh for both gossip versions.
+func TestIsPolicyZombie(t *testing.T) {
+ t.Parallel()
+
+ const (
+ pruneExpiry = time.Hour
+ currentHeight = uint32(1000)
+ )
+
+ // expiryBlocks is the number of blocks equivalent to pruneExpiry using
+ // the approximate block time.
+ expiryBlocks := uint32(pruneExpiry / avgBitcoinBlockTime)
+
+ b := &Builder{
+ cfg: &Config{
+ ChannelPruneExpiry: pruneExpiry,
+ },
+ }
+ b.bestHeight.Store(currentHeight)
+
+ tests := []struct {
+ name string
+ policy *models.ChannelEdgePolicy
+ zombie bool
+ }{
+ {
+ // A v1 policy updated half an expiry ago is fresh.
+ name: "v1 fresh",
+ policy: &models.ChannelEdgePolicy{
+ Version: lnwire.GossipVersion1,
+ LastUpdate: time.Now().Add(-(pruneExpiry / 2)),
+ },
+ zombie: false,
+ },
+ {
+ // A v1 policy with a zero timestamp is stale.
+ name: "v1 stale",
+ policy: &models.ChannelEdgePolicy{
+ Version: lnwire.GossipVersion1,
+ LastUpdate: time.Unix(0, 0),
+ },
+ zombie: true,
+ },
+ {
+ // A v2 policy updated one block before the
+ // expiry threshold is still fresh.
+ name: "v2 fresh",
+ policy: &models.ChannelEdgePolicy{
+ Version: lnwire.GossipVersion2,
+ LastBlockHeight: currentHeight -
+ expiryBlocks + 1,
+ },
+ zombie: false,
+ },
+ {
+ // A v2 policy exactly at the expiry boundary
+ // is stale.
+ name: "v2 stale at boundary",
+ policy: &models.ChannelEdgePolicy{
+ Version: lnwire.GossipVersion2,
+ LastBlockHeight: currentHeight -
+ expiryBlocks,
+ },
+ zombie: true,
+ },
+ {
+ // A v2 policy older than the expiry threshold
+ // is stale.
+ name: "v2 stale",
+ policy: &models.ChannelEdgePolicy{
+ Version: lnwire.GossipVersion2,
+ LastBlockHeight: currentHeight -
+ expiryBlocks - 10,
+ },
+ zombie: true,
+ },
+ {
+ // A v2 policy with a future block height is
+ // never stale.
+ name: "v2 future block",
+ policy: &models.ChannelEdgePolicy{
+ Version: lnwire.GossipVersion2,
+ LastBlockHeight: currentHeight + 1,
+ },
+ zombie: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ require.Equal(t, tc.zombie, b.isPolicyZombie(tc.policy))
+ })
+ }
+}
+
// TestIsStaleNode tests that the IsStaleNode method properly detects stale
// node announcements.
func TestIsStaleNode(t *testing.T) {
Why this scored 36/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.