routerrpc: implement DeleteForwardingHistory RPC handler
What changed, and why it matters
This commit adds a new RPC command that lets an authenticated user permanently delete old Lightning Network forwarding records from the database. It includes safety guards: the cutoff time must not be before 1970, and it must be at least one hour in the past (or a dev/test override). The change is mostly defensive, but it introduces a powerful deletion capability that could be abused by anyone who steals the right admin/macaroon credentials or exploits a caller with that permission.
Review macaroon scope and access controls for the new offchain:write action; ensure only intended privileged callers can invoke DeleteForwardingHistory. Verify the minimum-age guard cannot be bypassed in production builds (dev_integration.go is test-only). Consider adding rate limiting or audit logging beyond the current info logs. Confirm the DB layer's DeleteForwardingEvents correctly handles context cancellation and large batch sizes.
Security signals we found
New destructive RPC added with offchain:write macaroon permission
Pre-epoch cutoff guard prevents uint64 UnixNano wrap-around that could delete entire forwarding bucket
Minimum age guard (default 1h) limits accidental or malicious deletion of recent events
Context is threaded to DB layer so cancellation can abort between batches
Hardcoded batch size (10000) noted as temporary; default 0 fallback exists in DB layer
Duration parsing supports custom units (d, w, M, y) via parseDuration helper
Dev/test config flag can reduce or disable the minimum-age guard
Evidence from the diff
The patch implements DeleteForwardingHistory in lnrpc/routerrpc. It wires a new ForwardingLogDB interface into RouterBackend, adds macaroon permission mapping (offchain:write), parses either an absolute Unix timestamp or a relative duration, enforces a minimum age guard (default 1h, overridable via dev config), rejects pre-epoch cutoffs to avoid uint64 wrap-around, and calls DeleteForwardingEvents with the request context and a hardcoded 10,000 batch size. The operation is idempotent and returns deletion statistics.
Changed components
lnrpc/routerrpc/router_backend.golnrpc/routerrpc/router_server.golncfg/dev.golncfg/dev_integration.goInspect captured patch +131 / −0
diff --git a/lncfg/dev.go b/lncfg/dev.go
index 8e0c9dd..15c9367 100644
--- a/lncfg/dev.go
+++ b/lncfg/dev.go
@@ -60,6 +60,12 @@ func (d *DevConfig) GetUnsafeConnect() bool {
return false
}
+// GetMinFwdHistoryAge returns 0 for production builds, causing the caller to
+// use the hardcoded default of 1h.
+func (d *DevConfig) GetMinFwdHistoryAge() time.Duration {
+ return 0
+}
+
// ChannelCloseConfs returns the config value for channel close confirmations
// override, which is always None for production build.
func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] {
diff --git a/lncfg/dev_integration.go b/lncfg/dev_integration.go
index b299fb4..3793e7c 100644
--- a/lncfg/dev_integration.go
+++ b/lncfg/dev_integration.go
@@ -29,6 +29,7 @@ type DevConfig struct {
MaxWaitNumBlocksFundingConf uint32 `long:"maxwaitnumblocksfundingconf" description:"Maximum blocks to wait for funding confirmation before discarding non-initiated channels."`
UnsafeConnect bool `long:"unsafeconnect" description:"Allow the rpcserver to connect to a peer even if there's already a connection."`
ForceChannelCloseConfs uint32 `long:"force-channel-close-confs" description:"Force a specific number of confirmations for channel closes (dev/test only)"`
+ MinFwdHistoryAge time.Duration `long:"min-fwd-history-age" description:"Minimum age of forwarding events before they can be deleted via DeleteForwardingHistory (dev/test only, default: 1h)"`
}
// ChannelReadyWait returns the config value `ProcessChannelReadyWait`.
@@ -74,6 +75,12 @@ func (d *DevConfig) GetUnsafeConnect() bool {
return d.UnsafeConnect
}
+// GetMinFwdHistoryAge returns the minimum age for forwarding history deletion.
+// Returns 0 if unset, which causes the caller to use the default (1h).
+func (d *DevConfig) GetMinFwdHistoryAge() time.Duration {
+ return d.MinFwdHistoryAge
+}
+
// ChannelCloseConfs returns the forced confirmation count if set, or None if
// the default behavior should be used.
func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] {
diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go
index 62e98d5..ca05606 100644
--- a/lnrpc/routerrpc/router_backend.go
+++ b/lnrpc/routerrpc/router_backend.go
@@ -15,6 +15,7 @@ import (
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/wire"
sphinx "github.com/lightningnetwork/lightning-onion"
+ "github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/feature"
"github.com/lightningnetwork/lnd/fn/v2"
@@ -129,6 +130,30 @@ type RouterBackend struct {
// Clock is the clock used to validate payment requests expiry.
// It is useful for testing.
Clock clock.Clock
+
+ // ForwardingLog provides access to forwarding log database operations.
+ ForwardingLog ForwardingLogDB
+
+ // MinForwardingHistoryAge is the minimum age a forwarding event must
+ // have before it can be deleted. If zero the handler defaults to 1
+ // hour.
+ MinForwardingHistoryAge time.Duration
+}
+
+// ForwardingLogDB defines the interface for forwarding log database operations.
+// This interface allows the router RPC to interact with the forwarding log
+// without depending directly on the channeldb implementation, making testing
+// and future refactoring easier.
+type ForwardingLogDB interface {
+ // DeleteForwardingEvents deletes all forwarding events with a
+ // timestamp at or before the specified endTime. The deletion is
+ // performed in batches of the given size to avoid holding large
+ // database locks. It returns statistics about the deletion including
+ // the number of events deleted and the total fees earned from those
+ // events. If the context is cancelled between batches, partial
+ // statistics are returned along with the context error.
+ DeleteForwardingEvents(ctx context.Context, endTime time.Time,
+ batchSize int) (channeldb.DeleteStats, error)
}
// MissionControl defines the mission control dependencies of routerrpc.
diff --git a/lnrpc/routerrpc/router_server.go b/lnrpc/routerrpc/router_server.go
index 7f2514a..40201cb 100644
--- a/lnrpc/routerrpc/router_server.go
+++ b/lnrpc/routerrpc/router_server.go
@@ -168,6 +168,10 @@ var (
Entity: "offchain",
Action: "write",
}},
+ "/routerrpc.Router/DeleteForwardingHistory": {{
+ Entity: "offchain",
+ Action: "write",
+ }},
}
// DefaultRouterMacFilename is the default name of the router macaroon
@@ -1972,3 +1976,92 @@ func (s *Server) UpdateChanStatus(_ context.Context,
}
return &UpdateChanStatusResponse{}, nil
}
+
+// DeleteForwardingHistory deletes forwarding history events with a timestamp
+// at or before a specified time. This method is useful for implementing data
+// retention policies for privacy purposes.
+func (s *Server) DeleteForwardingHistory(ctx context.Context,
+ req *DeleteForwardingHistoryRequest) (*DeleteForwardingHistoryResponse,
+ error) {
+
+ now := s.cfg.RouterBackend.Clock.Now()
+
+ // Determine the deletion cutoff time from the request.
+ var deleteBeforeTime time.Time
+ switch timeSpec := req.TimeSpec.(type) {
+ case *DeleteForwardingHistoryRequest_DeleteBeforeTime:
+ deleteBeforeTime = time.Unix(
+ int64(timeSpec.DeleteBeforeTime), 0,
+ )
+
+ case *DeleteForwardingHistoryRequest_DeleteBeforeDuration:
+ // Parse duration using hybrid approach: try standard library
+ // first, fall back to custom units (d, w, M, y) if needed.
+ duration, err := parseDuration(timeSpec.DeleteBeforeDuration)
+ if err != nil {
+ return nil, fmt.Errorf("invalid duration format: %w",
+ err)
+ }
+
+ // Calculate the absolute time by adding the (negative)
+ // duration to now.
+ deleteBeforeTime = now.Add(duration)
+
+ default:
+ return nil, fmt.Errorf("time specification required: either " +
+ "delete_before_time or delete_before_duration must " +
+ "be provided")
+ }
+
+ // Guard against pre-epoch timestamps. A very large negative duration
+ // (e.g. -100y) would push deleteBeforeTime before the Unix epoch,
+ // causing uint64(endTime.UnixNano()) in the DB layer to wrap to a
+ // near-max value and delete the entire bucket.
+ if deleteBeforeTime.Before(time.Unix(0, 0)) {
+ return nil, fmt.Errorf("delete_before_time must not be " +
+ "before the Unix epoch")
+ }
+
+ // Require the cutoff to be at least minAge in the past to prevent
+ // accidental deletion of recent data. The default is 1 hour;
+ // integration tests may lower this via the dev config flag.
+ minAge := s.cfg.RouterBackend.MinForwardingHistoryAge
+ if minAge == 0 {
+ minAge = time.Hour
+ }
+ if now.Sub(deleteBeforeTime) < minAge {
+ return nil, fmt.Errorf("delete_before_time must be at "+
+ "least %v in the past to prevent accidental deletion "+
+ "of recent data (requested: %v, now: %v)",
+ minAge, deleteBeforeTime, now)
+ }
+
+ // Default batch size is 10000, will be replaces by a config value in
+ // later commit.
+ batchSize := 10000
+
+ log.Infof("DeleteForwardingHistory: deleting events at or before %v "+
+ "with batch size %d", deleteBeforeTime, batchSize)
+
+ // Call the database deletion method, threading the request context
+ // through so the operation can be aborted between batches if the
+ // caller disconnects or times out. A batch size of 0 is fine — the
+ // DB layer applies the default.
+ stats, err := s.cfg.RouterBackend.ForwardingLog.DeleteForwardingEvents(
+ ctx, deleteBeforeTime, batchSize,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to delete forwarding events: %w",
+ err)
+ }
+
+ log.Infof("DeleteForwardingHistory: deleted %d events, total fees: "+
+ "%d msat", stats.NumEventsDeleted, stats.TotalFeeMsat)
+
+ return &DeleteForwardingHistoryResponse{
+ EventsDeleted: stats.NumEventsDeleted,
+ TotalFeeMsat: stats.TotalFeeMsat,
+ Status: fmt.Sprintf("Successfully deleted %d forwarding events",
+ stats.NumEventsDeleted),
+ }, nil
+}
Why this scored 41/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.