Skip to content

lnrpc: add incoming/outgoing channel ids filter to forwarding history request #9356

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions channeldb/forwarding_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,16 @@ type ForwardingEventQuery struct {

// NumMaxEvents is the max number of events to return.
NumMaxEvents uint32

// IncomingChanIds is the list of channels to filter HTLCs being
// received from a particular channel.
// If the list is empty, then it is ignored.
IncomingChanIDs fn.Set[uint64]

// OutgoingChanIds is the list of channels to filter HTLCs being
// forwarded to a particular channel.
// If the list is empty, then it is ignored.
OutgoingChanIDs fn.Set[uint64]
}

// ForwardingLogTimeSlice is the response to a forwarding query. It includes
Expand Down Expand Up @@ -323,9 +333,13 @@ func (f *ForwardingLog) Query(q ForwardingEventQuery) (ForwardingLogTimeSlice,
return nil
}

// If we're not yet past the user defined offset, then
// If no incoming or outgoing channel IDs were provided
// and we're not yet past the user defined offset, then
// we'll continue to seek forward.
if recordsToSkip > 0 {
if recordsToSkip > 0 &&
q.IncomingChanIDs.IsEmpty() &&
q.OutgoingChanIDs.IsEmpty() {

recordsToSkip--
continue
}
Expand All @@ -349,11 +363,41 @@ func (f *ForwardingLog) Query(q ForwardingEventQuery) (ForwardingLogTimeSlice,
return err
}

// Check if the incoming channel ID matches the
// filter criteria. Either no filtering is
// applied (IsEmpty), or the ID is explicitly
// included.
incomingMatch := q.IncomingChanIDs.IsEmpty() ||
q.IncomingChanIDs.Contains(
event.IncomingChanID.ToUint64(),
)

// Check if the outgoing channel ID matches the
// filter criteria. Either no filtering is
// applied (IsEmpty), or the ID is explicitly
// included.
outgoingMatch := q.OutgoingChanIDs.IsEmpty() ||
q.OutgoingChanIDs.Contains(
event.OutgoingChanID.ToUint64(),
)

// Skip this event if it doesn't match the
// filters.
if !incomingMatch || !outgoingMatch {
continue
}
// If we're not yet past the user defined offset
// then we'll continue to seek forward.
if recordsToSkip > 0 {
recordsToSkip--
continue
}

event.Timestamp = currentTime
resp.ForwardingEvents = append(
resp.ForwardingEvents, event,
resp.ForwardingEvents,
event,
)

recordOffset++
}

Expand Down
131 changes: 131 additions & 0 deletions channeldb/forwarding_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,3 +475,134 @@ func writeOldFormatEvents(db *DB, events []ForwardingEvent) error {
return nil
})
}

// TestForwardingLogQueryChanIDs tests that querying the forwarding log with
// various combinations of incoming and/or outgoing channel IDs returns the
// correct subset of forwarding events.
func TestForwardingLogQueryChanIDs(t *testing.T) {
t.Parallel()

db, err := MakeTestDB(t)
require.NoError(t, err, "unable to make test db")

log := ForwardingLog{db: db}

initialTime := time.Unix(1234, 0)
endTime := initialTime

numEvents := 10
incomingChanIDs := []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(2001),
lnwire.NewShortChanIDFromInt(2002),
lnwire.NewShortChanIDFromInt(2003),
}
outgoingChanIDs := []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(3001),
lnwire.NewShortChanIDFromInt(3002),
lnwire.NewShortChanIDFromInt(3003),
}

events := make([]ForwardingEvent, numEvents)
for i := 0; i < numEvents; i++ {
events[i] = ForwardingEvent{
Timestamp: endTime,
IncomingChanID: incomingChanIDs[i%len(incomingChanIDs)],
OutgoingChanID: outgoingChanIDs[i%len(outgoingChanIDs)],
AmtIn: lnwire.MilliSatoshi(rand.Int63()),
AmtOut: lnwire.MilliSatoshi(rand.Int63()),
IncomingHtlcID: fn.Some(uint64(i)),
OutgoingHtlcID: fn.Some(uint64(i)),
}
endTime = endTime.Add(10 * time.Minute)
}

require.NoError(
t,
log.AddForwardingEvents(events),
"unable to add events",
)

tests := []struct {
name string
query ForwardingEventQuery
expected func(e ForwardingEvent) bool
}{
{
name: "only incomingChanIDs filter",
query: ForwardingEventQuery{
StartTime: initialTime,
EndTime: endTime,
IncomingChanIDs: fn.NewSet(
incomingChanIDs[0].ToUint64(),
incomingChanIDs[1].ToUint64(),
),
IndexOffset: 0,
NumMaxEvents: 10,
},
expected: func(e ForwardingEvent) bool {
return e.IncomingChanID == incomingChanIDs[0] ||
e.IncomingChanID == incomingChanIDs[1]
},
},
{
name: "only outgoingChanIDs filter",
query: ForwardingEventQuery{
StartTime: initialTime,
EndTime: endTime,
OutgoingChanIDs: fn.NewSet(
outgoingChanIDs[0].ToUint64(),
outgoingChanIDs[1].ToUint64(),
),
IndexOffset: 0,
NumMaxEvents: 10,
},
expected: func(e ForwardingEvent) bool {
return e.OutgoingChanID == outgoingChanIDs[0] ||
e.OutgoingChanID == outgoingChanIDs[1]
},
},
{
name: "incoming and outgoingChanIDs filter",
query: ForwardingEventQuery{
StartTime: initialTime,
EndTime: endTime,
IncomingChanIDs: fn.NewSet(
incomingChanIDs[0].ToUint64(),
incomingChanIDs[1].ToUint64(),
),
OutgoingChanIDs: fn.NewSet(
outgoingChanIDs[0].ToUint64(),
outgoingChanIDs[1].ToUint64(),
),
IndexOffset: 0,
NumMaxEvents: 10,
},
expected: func(e ForwardingEvent) bool {
return e.IncomingChanID ==
incomingChanIDs[0] ||
e.IncomingChanID ==
incomingChanIDs[1] ||
e.OutgoingChanID ==
outgoingChanIDs[0] ||
e.OutgoingChanID ==
outgoingChanIDs[1]
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := log.Query(tc.query)
require.NoError(t, err, "query failed")

expected := make([]ForwardingEvent, 0)
for _, e := range events {
if tc.expected(e) {
expected = append(expected, e)
}
}

require.Equal(t, expected, result.ForwardingEvents)
})
}
}
39 changes: 35 additions & 4 deletions cmd/commands/cmd_payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -1521,10 +1521,11 @@ func listPayments(ctx *cli.Context) error {
}

var forwardingHistoryCommand = cli.Command{
Name: "fwdinghistory",
Category: "Payments",
Usage: "Query the history of all forwarded HTLCs.",
ArgsUsage: "start_time [end_time] [index_offset] [max_events]",
Name: "fwdinghistory",
Category: "Payments",
Usage: "Query the history of all forwarded HTLCs.",
ArgsUsage: "start_time [end_time] [index_offset] [max_events]" +
"[--incoming_channel_ids] [--outgoing_channel_ids]",
Description: `
Query the HTLC switch's internal forwarding log for all completed
payment circuits (HTLCs) over a particular time range (--start_time and
Expand All @@ -1539,6 +1540,9 @@ var forwardingHistoryCommand = cli.Command{
The max number of events returned is 50k. The default number is 100,
callers can use the --max_events param to modify this value.

Incoming and outgoing channel IDs can be provided to further filter
the events. If not provided, all events will be returned.

Finally, callers can skip a series of events using the --index_offset
parameter. Each response will contain the offset index of the last
entry. Using this callers can manually paginate within a time slice.
Expand Down Expand Up @@ -1567,6 +1571,18 @@ var forwardingHistoryCommand = cli.Command{
Usage: "skip the peer alias lookup per forwarding " +
"event in order to improve performance",
},
cli.Int64SliceFlag{
Name: "incoming_chan_ids",
Usage: "the short channel id of the incoming " +
"channel to filter events by; can be " +
"specified multiple times in the same command",
},
cli.Int64SliceFlag{
Name: "outgoing_chan_ids",
Usage: "the short channel id of the outgoing " +
"channel to filter events by; can be " +
"specified multiple times in the same command",
},
},
Action: actionDecorator(forwardingHistory),
}
Expand Down Expand Up @@ -1647,6 +1663,21 @@ func forwardingHistory(ctx *cli.Context) error {
NumMaxEvents: maxEvents,
PeerAliasLookup: lookupPeerAlias,
}
outgoingChannelIDs := ctx.Int64Slice("outgoing_chan_ids")
if len(outgoingChannelIDs) != 0 {
req.OutgoingChanIds = make([]uint64, len(outgoingChannelIDs))
for i, c := range outgoingChannelIDs {
req.OutgoingChanIds[i] = uint64(c)
}
}

incomingChannelIDs := ctx.Int64Slice("incoming_chan_ids")
if len(incomingChannelIDs) != 0 {
req.IncomingChanIds = make([]uint64, len(incomingChannelIDs))
for i, c := range incomingChannelIDs {
req.IncomingChanIds[i] = uint64(c)
}
}
resp, err := client.ForwardingHistory(ctxc, req)
if err != nil {
return err
Expand Down
11 changes: 11 additions & 0 deletions docs/release-notes/release-notes-0.20.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,22 @@ circuit. The indices are only available for forwarding events saved after v0.20.
the option to specify multiple channels this control can be extended to
multiple hops leading to the node.


* The `lnrpc.ForwardingHistory` RPC method now supports filtering by
[`incoming_chan_ids` and `outgoing_chan_ids`](https://github.com/lightningnetwork/lnd/pull/9356).
This allows to retrieve forwarding events for specific channels.

## lncli Additions

* [`lncli sendpayment` and `lncli queryroutes` now support the
`--route_hints` flag](https://github.com/lightningnetwork/lnd/pull/9721) to
support routing through private channels.


* The `lncli fwdinghistory` command now supports two new flags:
[`--incoming_chan_ids` and `--outgoing_chan_ids`](https://github.com/lightningnetwork/lnd/pull/9356).
These filters allows to query forwarding events for specific channels.

# Improvements
## Functional Updates

Expand Down Expand Up @@ -105,4 +115,5 @@ circuit. The indices are only available for forwarding events saved after v0.20.

* Abdulkbk
* Elle Mouton
* Funyug
* Pins
Loading
Loading