Skip to content

multi: allow partial channel policy updates - #11528

Queued
saubyk wants to merge 6 commits into
lightningnetwork:masterfrom
saubyk:updatechanpolicy-partial
Queued

saubyk wants to merge 6 commits into
lightningnetwork:masterfrom
saubyk:updatechanpolicy-partial

Conversation

@saubyk

@saubyk saubyk commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

UpdateChannelPolicy can't tell an unset base_fee_msat, fee_rate/fee_rate_ppm or time_lock_delta from zero, so lncli updatechanpolicy requires all three on every call and its help shows a misleading (default: 0).

Design

Making these fields "unset means keep" by default would break existing clients. Proto3 doesn't put zero values on the wire, so today's request for a zero base fee looks the same as an omitted one. The change is therefore opt-in:

  • partial_update (new PartialPolicyUpdate message) has one bool each for base_fee_msat, fee_rate and time_lock_delta. When it is set, only the marked fields are applied and an unmarked field keeps its current value.
    • fee_rate covers fee_rate/fee_rate_ppm (both zero sets the rate to 0).
    • A marked time_lock_delta goes through the usual validation, so zero is rejected.
  • Without partial_update, behaviour is unchanged.
  • In a partial update, a non-zero value in an unmarked field is rejected instead of silently ignored.
  • With create_missing_edge, a recreated edge has no current value, so an unmarked field takes the default policy.

A separate message is used instead of proto3 optional, as suggested in #10499 (comment), because optional would turn the generated Go fields into pointers and break lnrpc consumers such as lndclient at compile time.

lncli

updatechanpolicy always sends a partial update, so each field can be set on its own. The policy flags are now parsed explicitly, which drops (default: 0) from the help and rejects invalid input instead of treating it as zero.

Individual fields are set with the flags. Positional values are only accepted when all three fields are set, and an argument left over after the policy values and one channel point is an error.

An older lnd ignores partial_update. It still rejects a call without time_lock_delta, but a call with it and a missing fee field would reset that fee to 0. For that case lncli checks the lnd version via GetInfo first and refuses below 0.21.99.

Tests

  • Unit tests for the request conversion (including the legacy zero-value cases), the manager's kept fields and the CLI parsing.
  • New itest update_channel_policy_partial, which also checks over gRPC that a legacy request still applies a zero base fee.

Supersedes #10500, whose *_specified defaults would have kept existing clients' fee values unchanged.

@saubyk saubyk self-assigned this Nov 6, 2026
@saubyk saubyk added this to the v0.22.0 milestone Nov 6, 2026
@saubyk saubyk added this to lnd v0.22 Nov 6, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in lnd v0.22 Nov 6, 2026
@saubyk saubyk moved this from Backlog to In progress in lnd v0.22 Nov 6, 2026
@saubyk saubyk added the ci-hold CI is held; remove this label to run CI label Nov 6, 2026
@saubyk

saubyk commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

/gateway review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 2 findings

🔴 0 Blocker · 🟠 2 Major · 🟡 0 Minor · 🔵 0 Nit

Summary

The PR lets UpdateChannelPolicy and lncli updatechanpolicy change one field at a time. It adds an opt-in partial_update flag plus *_specified booleans. Requests that don't set the flag behave exactly as before. The server-side conversion is tested, and so is how updateEdge handles the new Keep* flags. In a partial update, a non-zero value without its flag is rejected, which closes the most obvious ambiguity. The create_missing_edge path still works: a recreated edge keeps the default values for unset fields, which matches what the existing field documentation says.

Both concerns come from the opt-in design. In each case a client gets the old "unset means zero" behaviour while expecting partial semantics, and nothing tells it. One case is the new lncli talking to an older lnd. The other is a gRPC client that sets a *_specified flag but forgets partial_update. Either way, the fee fields on every channel in scope are reset to 0 and announced to the network.

Reviewed at medium effort.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (maintainers or PR author)

Comment thread cmd/commands/commands.go Outdated
MaxHtlcMsat: ctx.Uint64("max_htlc_msat"),
InboundFee: inboundFee,
CreateMissingEdge: createMissingEdge,
PartialUpdate: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 F1 (Major) — New lncli silently zeroes fees on older lnd · cmd/commands/commands.go:2839

lncli updatechanpolicy --time_lock_delta=80 against an lnd older than this change resets base fee and fee rate to 0 on every channel (global scope by default), and the zero-fee policy is broadcast. The older lnd discards the unknown partial_update field and applies the omitted fees as 0.

Why this matters

The CLI now always sends PartialUpdate: true with omitted fields left at zero. Proto3 drops unknown fields on older servers, so the request is processed as a legacy one. Calls without a time lock delta are still rejected there, but any call that passes time_lock_delta with a fee field missing goes through. The only safeguard is a line of help text and a release note, which an operator running a newer lncli against an older node won't see at the moment it matters. The old CLI required all three values, so before this PR that call couldn't drop fees at all.

Fix: before sending, call GetInfo and refuse when the daemon version predates partial-update support. Alternatively, have the CLI still require base fee and fee rate whenever the server's support can't be confirmed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. When time_lock_delta is passed and a fee field is left out, lncli now checks GetInfo and refuses if lnd predates partial updates (< 0.21.99). That's the only case an older lnd accepts, since it rejects a missing time_lock_delta. Gate is supportsPartialPolicyUpdate, with a unit test. One gap: master builds from before this PR also report 0.21.99.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If && becomes || in checkPartialPolicyUpdate, --time_lock_delta=80 --base_fee_msat=1000 sets the fee rate to 0 on an old lnd, and no test fails. Could you add that case?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as base fee without fee rate in TestCheckPartialPolicyUpdate.

Comment thread rpcserver.go Outdated
// In a partial update, a field the caller didn't mark as specified is
// left unchanged. Reject non-zero values for such fields, as they
// would otherwise be silently ignored.
keepBaseFee := req.PartialUpdate && !req.BaseFeeMsatSpecified

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 F2 (Major) — Specified flags without partial_update silently ignored · rpcserver.go:7474

A request that sets base_fee_msat_specified or fee_rate_specified but not partial_update is processed as a legacy full update. Every fee field the caller left at zero, meaning to keep it, is applied as 0. This happens silently and can affect all channels.

Why this matters

keepBaseFee and keepFeeRate are only ever true when req.PartialUpdate is set, so without it the *_specified flags have no effect. For example, a client sends {BaseFeeMsatSpecified: true, BaseFeeMsat: 1000, TimeLockDelta: 80} and forgets partial_update: the fee rate drops to 0 on every targeted channel. The PR already rejects the mirror-image mistake (a non-zero value without its flag in a partial update), so this direction should be rejected too.

Fix: return codes.InvalidArgument when !req.PartialUpdate && (req.BaseFeeMsatSpecified || req.FeeRateSpecified).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. base_fee_msat_specified or fee_rate_specified without partial_update now returns InvalidArgument. Covered in TestChannelPolicyFromRequest.

@lightninglabs-gateway

lightninglabs-gateway Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

@saubyk
saubyk force-pushed the updatechanpolicy-partial branch from 2beda0e to 9e5a03a Compare November 6, 2026 02:39
@saubyk

saubyk commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

/gateway re-review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 1 finding

🔴 0 Blocker · 🟠 0 Major · 🟡 1 Minor · 🔵 0 Nit

Summary

Both earlier findings are fixed. The server now rejects *_specified flags sent without partial_update. Before lncli sends a partial update that includes time_lock_delta but leaves out a fee field, it checks the node's version. That is the only partial update an older lnd would accept, so the check is in the right place.

The version check has one gap, which the author already pointed out: builds from master before this PR also report 0.21.99. Those are unreleased development builds, so I'm not raising it again as an open finding. The one new issue is that the check adds a GetInfo call. A macaroon that has only offchain:write can't make that call, so a partial update with such a macaroon now fails.

Lower-severity findings (1)

🟡 F3 (Minor) — Partial update fails for macaroons without info:read · cmd/commands/commands.go:2905

The version check calls GetInfo, which needs info:read, while UpdateChannelPolicy only needs offchain:write. So lncli --macaroonpath=fees.macaroon updatechanpolicy --time_lock_delta=80 with a fee-management macaroon now fails with "unable to check lnd version: permission denied". The same macaroon could have made the update itself. Either mention this requirement in the error message, or fall back to requiring both fee fields when the version can't be read.


Status of prior findings

  • F1 addressed: Fixed in cmd/commands/commands.go:2902. When TimeLockDelta != 0 and either fee field is unspecified, updateChannelPolicy calls GetInfo and refuses if supportsPartialPolicyUpdate(info.Version) is false. Calls without time_lock_delta are still rejected by older lnd, so this covers the only path where an older lnd would reset fees to 0. Remaining gap, already acknowledged in the thread: master builds from before this PR also report 0.21.99.
  • F2 addressed: Fixed in rpcserver.go:7470. channelPolicyFromRequest returns codes.InvalidArgument when !req.PartialUpdate && (req.BaseFeeMsatSpecified || req.FeeRateSpecified). The "specified flag without partial update" case in TestChannelPolicyFromRequest (rpcserver_test.go:238) covers it.

Reviewed at medium effort.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (maintainers or PR author)

@saubyk

saubyk commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

F3 fixed: if GetInfo fails (e.g. a macaroon without info:read), the error now explains that lncli needs info:read to confirm partial-update support, or that both fee fields must be passed. The check is now checkPartialPolicyUpdate, covered by TestCheckPartialPolicyUpdate, which includes the permission-denied case.

@saubyk
saubyk force-pushed the updatechanpolicy-partial branch from 9e5a03a to 894f96a Compare November 6, 2026 02:46
@saubyk

saubyk commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

/gateway re-review

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

gh pr view | 13 files | 1042 lines changed

🔴 Critical (1 file)
  • rpcserver.go - core server coordination file; modifies the UpdateChannelPolicy RPC handler, including new partial-update validation logic
🟠 High (4 files)
  • lnrpc/lightning.proto - RPC/API definition change, adds partial-update fields to PolicyUpdateRequest
  • lnrpc/lightning.pb.go - generated code from the proto change above
  • lnrpc/lightning.swagger.json - generated API docs from the proto change above
  • routing/localchans/manager.go - routing package logic for applying local channel policy updates
  • routing/router.go - routing package
🟡 Medium (1 file)
  • cmd/commands/commands.go - CLI client command (lncli updatechanpolicy); classified as CLI, not by the server-side packages it talks to
🟢 Low (7 files)
  • docs/release-notes/unreleased/11528.md - release notes
  • cmd/commands/commands_test.go - test only
  • itest/list_on_test.go - test only
  • itest/lnd_channel_policy_test.go - test only
  • routing/localchans/manager_test.go - test only
  • rpcserver_test.go - test only

Analysis

The highest-severity file touched is rpcserver.go, which is explicitly listed as CRITICAL (core server coordination). The change modifies the UpdateChannelPolicy gRPC handler to support partial fee/time-lock-delta updates, alongside corresponding changes to the lnrpc proto/generated files and routing/localchans manager logic that applies the policy. This is a behavior change to a widely-used RPC that controls live channel fee policy, with real risk of silently zeroing out fees if misused across client/server version mismatches (flagged separately by prior review comments on this PR). No bump beyond CRITICAL was warranted (file count, line count, and distinct critical-package count are all under the bump thresholds), but none was needed either given the explicit rpcserver.go classification.


To override, add a severity-override-{critical,high,medium,low} label.

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review

No findings.


Status of prior findings

  • F1 addressed: Fixed in cmd/commands/commands.go:2680-2705. checkPartialPolicyUpdate calls GetInfo and refuses below 0.21.99 when time_lock_delta is set and a fee field is left out. That is the only case an older lnd accepts and misapplies.
  • F2 addressed: Fixed in rpcserver.go channelPolicyFromRequest. base_fee_msat_specified or fee_rate_specified without partial_update now returns InvalidArgument ("…require partial_update"). The "specified flag without partial update" case in TestChannelPolicyFromRequest covers it.
  • F3 addressed: Fixed in cmd/commands/commands.go:2690-2695. The GetInfo failure now reads "unable to check that lnd supports partial policy updates (needs info:read permission), pass base_fee_msat and fee_rate or fee_rate_ppm as well". It names the macaroon requirement and the workaround. The "no info permission" case in cmd/commands/commands_test.go:692-698 covers it.

Reviewed at medium effort.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (maintainers or PR author)

@saubyk

saubyk commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

/gateway approve

@saubyk saubyk moved this from In progress to In review in lnd v0.22 Nov 6, 2026

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — all prior findings addressed.

Findings recap

Findings:

  • F1 (major) — addressed: lncli updatechanpolicy --time_lock_delta=80 against an lnd older than this change resets base fee and fee rate to 0 on every channel (glob…
  • F2 (major) — addressed: A request that sets base_fee_msat_specified or fee_rate_specified but not partial_update is processed as a legacy full update. Every f…
  • F3 (minor) — addressed: The version check calls GetInfo, which needs info:read, while UpdateChannelPolicy only needs offchain:write. So `lncli --macaroonpat…

Approved by @saubyk via /gateway approve. Last reviewed at 894f96a. Skill v0.6.0, model claude-opus-5-5.

// TestUpdatePolicyKeepFields checks that the Keep* flags of a policy update
// leave the matching fields of the existing edge unchanged, both on the edge
// sent to the gossiper and on the policy sent to the links.
func TestUpdatePolicyKeepFields(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great coverage of the Keep* flags on the updateEdge path.

One gap: the createMissingEdge + Keep* combination is untested
this test passes createMissingEdge=false, the upstream missing-edge
case uses a legacy policy, and CreateMissingEdge appears in no other
test. Since createEdge seeds from DefaultRoutingPolicy, kept fields
fall back to defaults there. Worth one case asserting that?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two cases to TestUpdatePolicyKeepFields with createMissingEdge=true: kept fields on a recreated edge fall back to the default policy.

Comment thread cmd/commands/commands.go
baseFeeSet = true

case args.Present():
baseFee, err = strconv.ParseInt(args.First(), 10, 64)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lncli updatechanpolicy --fee_rate_ppm=100 <chan_point> fails here, because the chan point is read as the base fee. And --fee_rate_ppm=5 40 sets the base fee to 40 with no error.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced both cases.

Case 1 looks like a real one: in --fee_rate_ppm=100 <chan_point> the chan point is the first bare arg, so it lands in the base-fee slot. A
base fee can never contain ':', so a bare arg that fails ParseInt but
parses as a chan point could be routed to the chan-point slot instead
of erroring. That shape ("only change the fee rate of this channel")
is exactly what partial updates invite.

Case 2 seems fine by design, though worth an explicit call-out: 40
is the first bare arg, so it fills the base-fee slot per the documented
order. Master would have failed later with "time_lock_delta argument
missing", which made the misalignment visible; now it succeeds
silently. Not sure there's a safe fix (intent is unknowable), but a
line in the help text on how bare args fill untagged fields would
help.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, both fixed. A trailing argument containing : is now taken as the chan point first, so --fee_rate_ppm=100 <chan_point> works. Positional values are only accepted when base fee, fee rate and time lock delta are all set; --fee_rate_ppm=5 40 now errors and points to the flags. Help text and release note updated, tests added in TestParsePolicyUpdateRequest.

@saubyk
saubyk force-pushed the updatechanpolicy-partial branch from 894f96a to 1e455cf Compare November 10, 2026 04:00

@TechLateef TechLateef left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM
re-checked the follow-up push: the trailing chan-point
extraction and the positional gate cover both cases from
@Vandit1604's thread, and tests pass locally
(cmd/commands, routing/localchans).

@saubyk saubyk added the cli Related to the command line interface label Nov 10, 2026
@saubyk
saubyk requested review from GustavoStingelin and bitromortac and removed request for Vandit1604 November 10, 2026 17:37

@bitromortac bitromortac left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this old issue on. My main request is to replace the three partial update fields with one message. The other comments are small.

Comment thread lnrpc/lightning.proto Outdated
Comment on lines +4771 to +4784
// If true, only the fields the caller marks as specified are updated and
// all others keep their current value: base_fee_msat is applied only if
// base_fee_msat_specified is set, fee_rate/fee_rate_ppm only if
// fee_rate_specified is set, and time_lock_delta only if non-zero. If
// false, base_fee_msat, fee_rate/fee_rate_ppm and time_lock_delta are
// always applied, with unset values treated as zero.
bool partial_update = 12;

// If true and partial_update is set, base_fee_msat is applied.
bool base_fee_msat_specified = 13;

// If true and partial_update is set, fee_rate or fee_rate_ppm is applied.
// If both are zero, the fee rate is set to zero.
bool fee_rate_specified = 14;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three loose fields allow combinations that mean nothing, so the server needs the extra rule against a specified mark without partial_update. Could you use one partial_update field of a new message PartialPolicyUpdate with the bools base_fee_msat, fee_rate and time_lock_delta? A nil message is a legacy update, and an unmarked field is kept. Then the extra rule goes away, zero time_lock_delta no longer means "keep", and the server only rejects a non-zero value in an unmarked field.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. partial_update is now a PartialPolicyUpdate message with the three bools. Nil is a legacy update, an unmarked field is kept, and the server only rejects a non-zero value in an unmarked field. A marked zero time_lock_delta fails the usual minimum check.

Comment thread lnrpc/lightning.proto Outdated
Comment on lines +4771 to +4772
// If true, only the fields the caller marks as specified are updated and
// all others keep their current value: base_fee_msat is applied only if

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "All others keep their current value" is true only for the base fee, the fee rate and the time lock delta. inbound_fee and the HTLC limits keep their own rules.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The comment now names the three fields and says inbound_fee and the HTLC limits aren't affected.

Comment thread lnrpc/lightning.proto Outdated
Comment on lines +4773 to +4776
// base_fee_msat_specified is set, fee_rate/fee_rate_ppm only if
// fee_rate_specified is set, and time_lock_delta only if non-zero. If
// false, base_fee_msat, fee_rate/fee_rate_ppm and time_lock_delta are
// always applied, with unset values treated as zero.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: With create_missing_edge, a recreated edge takes the default policy for each kept field, as the create_missing_edge comment says. This comment promises the current value. Could you add a sentence about the recreated edge here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a sentence: a recreated edge has no current value, so an unmarked field takes the default policy.

Comment thread routing/router.go Outdated
Comment on lines +227 to +232
// KeepBaseFee, KeepFeeRate and KeepTimeLockDelta leave the current
// value of the respective field unchanged instead of applying the one
// in this policy.
KeepBaseFee bool
KeepFeeRate bool
KeepTimeLockDelta bool

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The Keep* flags duplicate the fields, so KeepBaseFee: true with a non-zero BaseFee is valid but unclear. Could BaseFee, FeeRate and TimeLockDelta become fn.Option values, like FeeSchema.InboundFee, with None meaning "keep"?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. BaseFee, FeeRate and TimeLockDelta are fn.Option now, None keeps the current value, and the Keep* flags are gone.

Comment thread cmd/commands/commands.go Outdated
Comment on lines +2714 to +2717
var major, minor, patch uint32
_, err := fmt.Sscanf(version, "%d.%d.%d", &major, &minor, &patch)
if err != nil {
return false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sscanf reads 0.22.0garbage as 0.22.0, but this guard must fail closed. Could you use golang.org/x/mod/semver (already in go.sum) and compare with v0.21.99-0, so that 0.21.99-beta still passes?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, using semver against v0.21.99-0. 0.22.0garbage now fails and 0.21.99-beta passes, both covered in the test. This makes golang.org/x/mod a direct dependency in go.mod.

Comment thread cmd/commands/commands.go
timeLockDeltaStr = args.First()
positional = true
args = args.Tail()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: An empty --time_lock_delta="" is silently ignored, but an empty --base_fee_msat="" fails. Could you parse the value whenever the flag is set, and add a test case?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The value is parsed whenever the flag is set, so --time_lock_delta="" fails. Test case added.

Comment thread cmd/commands/commands.go
Comment on lines +2788 to +2790
if n := len(args); n > 0 && strings.Contains(args[n-1], ":") {
chanPointStr = args[n-1]
args = args[:n-1]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about returning an error when positional arguments are left over? With the trailing chan point taken first, the remaining args are never checked, so a malformed call is now silently accepted:

  • lncli updatechanpolicy 1000 0.001 40 garbage <chan_point> succeeds and ignores garbage. Before this PR, garbage landed in the chan point slot and failed to parse.
  • lncli updatechanpolicy 1000 0.001 40 <chan_A> <chan_B> now updates B. Before this PR it updated A (and also ignored B).

Since this command changes live policy, a typo should fail instead of updating a different channel than the one the user may have meant. Checking that no args remain after the policy values and one chan point would cover both, plus a TestParsePolicyUpdateRequest case for each.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Any argument left after the policy values and one chan point is now an error, so both examples fail. --chan_point together with a positional chan point is rejected too. Cases added to TestParsePolicyUpdateRequest.

Comment thread lnrpc/lightning.proto Outdated
Comment on lines +4771 to +4784
// If true, only the fields the caller marks as specified are updated and
// all others keep their current value: base_fee_msat is applied only if
// base_fee_msat_specified is set, fee_rate/fee_rate_ppm only if
// fee_rate_specified is set, and time_lock_delta only if non-zero. If
// false, base_fee_msat, fee_rate/fee_rate_ppm and time_lock_delta are
// always applied, with unset values treated as zero.
bool partial_update = 12;

// If true and partial_update is set, base_fee_msat is applied.
bool base_fee_msat_specified = 13;

// If true and partial_update is set, fee_rate or fee_rate_ppm is applied.
// If both are zero, the fee rate is set to zero.
bool fee_rate_specified = 14;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@bitromortac bitromortac left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates! All my earlier comments are addressed. I did some cli tests and only the help text needs one more change, otherwise LGTM 🎉

Comment thread cmd/commands/commands.go Resolved
Comment thread cmd/commands/commands.go Resolved
@saubyk
saubyk force-pushed the updatechanpolicy-partial branch from 642e6b6 to 407eb16 Compare November 13, 2026 15:25

@GustavoStingelin GustavoStingelin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! pending rebase

PolicyUpdateRequest can't tell an unset base fee, fee rate or time lock
delta from zero, so every update has to send all three. Add an opt-in
partial_update message that marks which of the three fields to apply,
so callers can update individual fields. Without partial_update the
existing semantics are unchanged, as current clients rely on omitted
(zero) values being applied.

Fixes lightningnetwork#1523.
Turn BaseFee, FeeRate and TimeLockDelta of ChannelPolicy into options,
so the local channel manager leaves the matching fields of the current
edge untouched when they're empty. UpdateChannelPolicy keeps setting all
three.
Move the request validation and conversion of UpdateChannelPolicy into
channelPolicyFromRequest and honor partial_update there. Non-zero values
for fields not marked in partial_update are rejected rather than
silently ignored.
updatechanpolicy no longer requires base_fee_msat, fee_rate and
time_lock_delta on every call; fields that aren't passed keep their
current value. The flags are now string flags parsed explicitly, which
removes the misleading "(default: 0)" from the help text and rejects
invalid input instead of treating it as zero.

Individual fields are set with the flags. Positional arguments are only
accepted when all three fields are set, since it's otherwise ambiguous
which field a value fills. A trailing channel point is recognised by its
colon, so it's never read as a policy field, and any argument left over
after the policy values and one channel point is rejected.

An lnd that predates partial updates would reset an omitted fee to zero,
so lncli checks the version first and refuses such an update.

Fixes lightningnetwork#1523.
@ziggie1984
ziggie1984 force-pushed the updatechanpolicy-partial branch from 407eb16 to acf4f3f Compare November 13, 2026 21:56
@saubyk saubyk removed the ci-hold CI is held; remove this label to run CI label Nov 14, 2026
@saubyk
saubyk added this pull request to the merge queue Nov 14, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 14, 2026
@saubyk
saubyk added this pull request to the merge queue Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Related to the command line interface gateway-active gateway-approved severity-critical Requires expert review - security/consensus critical

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

5 participants