routerrpc: remove deprecated outgoing_chan_id field handling
What changed, and why it matters
This commit removes backward-compatible handling of an old, single outgoing channel ID field in LND's router RPC. Previously, callers could use either the old field or the newer list field; now the old field is rejected with an error. The main risk is that existing callers still using the deprecated field will see their requests fail instead of being silently treated as unrestricted. This is a hardening/cleanup change rather than a fix for an active vulnerability.
Review release notes and RPC documentation to warn users that OutgoingChanId is now rejected. Ensure client SDKs and integrations have migrated to OutgoingChanIds. No immediate patch is required, but operators should monitor for broken integrations after upgrade.
Security signals we found
Removal of deprecated RPC field fallback
Explicit rejection of deprecated field to avoid silent loss of outgoing channel restrictions
Behavior change from fallback/error-on-both to always-error-on-deprecated-field
Test updates reflect new strict rejection behavior
Evidence from the diff
The patch removes the compatibility fallback for OutgoingChanId in QueryRoutes and ExtractPaymentIntent (SendPayment). Previously, if OutgoingChanId was set alongside OutgoingChanIds, an error was returned; if only OutgoingChanId was set, it was mapped to OutgoingChannelIDs. Now any non-zero OutgoingChanId returns an error. This prevents callers from accidentally believing their route/payment is restricted to a single channel when the server no longer honors that field. The change is defensive: it makes deprecation strict rather than silently ignoring the old field.
Changed components
lnrpc/routerrpc/router_backend.golnrpc/routerrpc/router_backend_test.goQueryRoutes RPCSendPayment / ExtractPaymentIntent RPCInspect captured patch +25 / −83
diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go
index 3bb4d82..a639138 100644
--- a/lnrpc/routerrpc/router_backend.go
+++ b/lnrpc/routerrpc/router_backend.go
@@ -447,19 +447,16 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) (
BlindedPaymentPathSet: blindedPathSet,
}
- // We set the outgoing channel restrictions if the user provides a
- // list of channel ids. We also handle the case where the user
- // provides the deprecated `OutgoingChanId` field.
- switch {
- case len(in.OutgoingChanIds) > 0 && in.OutgoingChanId != 0:
- return nil, errors.New("outgoing_chan_id and " +
- "outgoing_chan_ids cannot both be set")
+ // The deprecated single outgoing_chan_id field is no longer
+ // honored. Reject requests that still set it so that callers do
+ // not silently get an unrestricted route.
+ if in.OutgoingChanId != 0 {
+ return nil, errors.New("outgoing_chan_id is deprecated, " +
+ "use outgoing_chan_ids")
+ }
- case len(in.OutgoingChanIds) > 0:
+ if len(in.OutgoingChanIds) > 0 {
restrictions.OutgoingChannelIDs = in.OutgoingChanIds
-
- case in.OutgoingChanId != 0:
- restrictions.OutgoingChannelIDs = []uint64{in.OutgoingChanId}
}
// Pass along a last hop restriction if specified.
@@ -880,21 +877,16 @@ func (r *RouterBackend) extractIntentFromSendRequest(
}
payIntent.TimePref = rpcPayReq.TimePref
- // Pass along restrictions on the outgoing channels that may be used.
- payIntent.OutgoingChannelIDs = rpcPayReq.OutgoingChanIds
-
- // Add the deprecated single outgoing channel restriction if present.
+ // The deprecated single outgoing_chan_id field is no longer
+ // honored. Reject requests that still set it so that callers do
+ // not silently get an unrestricted payment.
if rpcPayReq.OutgoingChanId != 0 {
- if payIntent.OutgoingChannelIDs != nil {
- return nil, errors.New("outgoing_chan_id and " +
- "outgoing_chan_ids are mutually exclusive")
- }
-
- payIntent.OutgoingChannelIDs = append(
- payIntent.OutgoingChannelIDs, rpcPayReq.OutgoingChanId,
- )
+ return nil, errors.New("outgoing_chan_id is deprecated, " +
+ "use outgoing_chan_ids")
}
+ payIntent.OutgoingChannelIDs = rpcPayReq.OutgoingChanIds
+
// Pass along a last hop restriction if specified.
if len(rpcPayReq.LastHopPubkey) > 0 {
lastHop, err := route.NewVertexFromBytes(
diff --git a/lnrpc/routerrpc/router_backend_test.go b/lnrpc/routerrpc/router_backend_test.go
index e572f80..e7ca056 100644
--- a/lnrpc/routerrpc/router_backend_test.go
+++ b/lnrpc/routerrpc/router_backend_test.go
@@ -33,39 +33,28 @@ var (
node2 = route.Vertex{11}
)
-var (
- singleChanID = "singleChanID"
- multiChanID = "multiChanID"
- bothChanIds = "bothChanIds"
-)
-
// TestQueryRoutes asserts that query routes rpc parameters are properly parsed
// and passed onto path finding.
func TestQueryRoutes(t *testing.T) {
t.Run("no mission control", func(t *testing.T) {
- testQueryRoutes(t, false, false, true, singleChanID)
+ testQueryRoutes(t, false, false, true)
})
t.Run("no mission control and msat", func(t *testing.T) {
- testQueryRoutes(t, false, true, true, singleChanID)
+ testQueryRoutes(t, false, true, true)
})
t.Run("with mission control", func(t *testing.T) {
- testQueryRoutes(t, true, false, true, singleChanID)
+ testQueryRoutes(t, true, false, true)
})
t.Run("no mission control bad cltv limit", func(t *testing.T) {
- testQueryRoutes(t, false, false, false, singleChanID)
+ testQueryRoutes(t, false, false, false)
})
-
- t.Run("both outgoing chan id and chan ids", func(t *testing.T) {
- testQueryRoutes(t, true, false, true, bothChanIds)
- })
-
t.Run("multiple outgoing chan ids", func(t *testing.T) {
- testQueryRoutes(t, false, true, true, multiChanID)
+ testQueryRoutes(t, false, true, true)
})
}
func testQueryRoutes(t *testing.T, useMissionControl bool, useMsat bool,
- setTimelock bool, outgoingChanConfig string) {
+ setTimelock bool) {
ignoreNodeBytes, err := hex.DecodeString(ignoreNodeKey)
if err != nil {
@@ -82,7 +71,6 @@ func testQueryRoutes(t *testing.T, useMissionControl bool, useMsat bool,
var (
lastHop = route.Vertex{64}
- outgoingChan = uint64(383322)
outgoingChanIds = []uint64{383322, 383323}
)
@@ -137,17 +125,7 @@ func testQueryRoutes(t *testing.T, useMissionControl bool, useMsat bool,
}
}
- switch outgoingChanConfig {
- case singleChanID:
- request.OutgoingChanId = outgoingChan
-
- case multiChanID:
- request.OutgoingChanIds = outgoingChanIds
-
- case bothChanIds:
- request.OutgoingChanId = outgoingChan
- request.OutgoingChanIds = outgoingChanIds
- }
+ request.OutgoingChanIds = outgoingChanIds
findRoute := func(req *routing.RouteRequest) (*route.Route, float64,
error) {
@@ -190,19 +168,9 @@ func testQueryRoutes(t *testing.T, useMissionControl bool, useMsat bool,
t.Fatal("unexpected last hop")
}
- switch outgoingChanConfig {
- case singleChanID:
- require.Equal(
- t, restrictions.OutgoingChannelIDs,
- []uint64{outgoingChan},
- )
-
- case multiChanID:
- require.Equal(
- t, restrictions.OutgoingChannelIDs,
- outgoingChanIds,
- )
- }
+ require.Equal(
+ t, restrictions.OutgoingChannelIDs, outgoingChanIds,
+ )
if !restrictions.DestFeatures.HasFeature(lnwire.MPPOptional) {
t.Fatal("unexpected dest features")
@@ -265,13 +233,6 @@ func testQueryRoutes(t *testing.T, useMissionControl bool, useMsat bool,
resp, err := backend.QueryRoutes(t.Context(), request)
- // If we're using both OutgoingChanId and OutgoingChanIds, we should get
- // an error.
- if outgoingChanConfig == bothChanIds {
- require.Error(t, err)
- return
- }
-
// If no MaxTotalTimelock was set for the QueryRoutes request, make
// sure an error was returned.
if !setTimelock {
@@ -573,17 +534,6 @@ func TestExtractIntentFromSendRequest(t *testing.T) {
valid: false,
expectedErrorMsg: "time preference out of range",
},
- {
- name: "Outgoing channel exclusivity violation",
- backend: &RouterBackend{},
- sendReq: &SendPaymentRequest{
- OutgoingChanId: 38484,
- OutgoingChanIds: []uint64{383322},
- },
- valid: false,
- expectedErrorMsg: "outgoing_chan_id and " +
- "outgoing_chan_ids are mutually exclusive",
- },
{
name: "Invalid last hop pubkey length",
backend: &RouterBackend{},
Why this scored 32/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.