Skip to content

Add local reputation subsystem (read-only)#10919

Draft
GeorgeTsagk wants to merge 8 commits into
lightningnetwork:masterfrom
GeorgeTsagk:local-reputation-subsystem
Draft

Add local reputation subsystem (read-only)#10919
GeorgeTsagk wants to merge 8 commits into
lightningnetwork:masterfrom
GeorgeTsagk:local-reputation-subsystem

Conversation

@GeorgeTsagk

@GeorgeTsagk GeorgeTsagk commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds a subsystem that implements local reputation as proposed here.

You can read more about channel jamming mitigationa here.

The current goal is to only record and calculate revenue/reputation averages and implement the unprotected/protected buckets in a log-only mode, meaning that:

  • we record HTLC add/settle/fail times
  • we simulate the bucket slots, and apply the rules normally
  • we don't affect HTLC forwarding at all: an HTLC rejection due to insufficient reputation is a no-op

This PR aims to be non-invasive to existing HTLC forwarding code paths. A reviewer treating the reputation subsystem as a black-box should be confident that by recording HTLC events via the reputation subsystem we're not interrupting any other operation.

Checklist for undrafting

  • Break up commits into smaller self-explanatory ones
  • Add better itest coverage (probably a debug API to verify reputation numbers as well)
  • (?) Handle cold start (historical traffic read)
  • (?) Properly handle in-flight HTLCs when restarting

@GeorgeTsagk GeorgeTsagk self-assigned this Jun 23, 2026
@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Jun 23, 2026
@github-actions

Copy link
Copy Markdown

PR Severity: CRITICAL

Automated classification | 21 non-test files | ~3,427 lines changed (excluding tests/generated)

CRITICAL (4 files)
  • htlcswitch/interfaces.go - htlcswitch package; HTLC forwarding/payment routing state machine
  • htlcswitch/reputation_guard.go - htlcswitch package; new reputation gating for HTLC forwarding
  • htlcswitch/switch.go - htlcswitch package; core switch integration
  • server.go - Core server coordination
MEDIUM (16 files)
  • lncfg/routing.go - lncfg/* routing config
  • log.go - top-level logger registration
  • reputation_adapter.go - adapter wiring reputation manager into server
  • reputation/buckets.go - new reputation package (uncategorized)
  • reputation/channel.go - new reputation package
  • reputation/channels.go - new reputation package
  • reputation/clock.go - new reputation package
  • reputation/config.go - new reputation package
  • reputation/decaying_average.go - new reputation package
  • reputation/decision.go - new reputation package
  • reputation/htlc.go - new reputation package
  • reputation/log.go - new reputation package
  • reputation/manager.go - new reputation package
  • reputation/manager_startup.go - new reputation package
  • reputation/revenue.go - new reputation package
  • reputation/store.go - new reputation package
LOW (13 files -- excluded from counts)
  • htlcswitch/reputation_hooks_test.go, reputation/*_test.go -- test files
  • itest/list_on_test.go, itest/lnd_reputation_test.go -- integration tests
  • lntest/harness_assertion.go -- test harness
  • reputation/DESIGN.md -- documentation

Analysis

This PR introduces a new channel reputation system for HTLC jamming mitigation. The critical classification is driven by direct modifications to htlcswitch -- one of lnd's most sensitive packages governing HTLC forwarding and the payment routing state machine -- and to server.go (core server coordination).

Key concerns warranting careful review:

  • htlcswitch/switch.go: Integration of reputation gating into the HTLC forwarding path. Any regression could cause incorrect HTLC accept/reject decisions, affecting payment reliability.
  • htlcswitch/reputation_guard.go (new file, 82 lines): New guard logic sitting in the critical forwarding path.
  • htlcswitch/interfaces.go: Interface additions that all implementors must satisfy; watch for subtle behavioral changes.
  • reputation/manager.go (650 lines): Large new manager with in-memory state, startup logic, and a backing store -- persistence correctness and concurrency safety should be verified.

Both severity-bump thresholds are exceeded (21 non-test files, ~3,427 non-test lines), but the base severity was already CRITICAL.


To override, add a severity-override-{critical,high,medium,low} label.
<!-- pr-severity-bot -->

@GeorgeTsagk GeorgeTsagk force-pushed the local-reputation-subsystem branch 2 times, most recently from 1be208a to 5bdb907 Compare June 24, 2026 12:46
@github-actions github-actions Bot added severity-critical Requires expert review - security/consensus critical and removed severity-critical Requires expert review - security/consensus critical labels Jun 24, 2026
@GeorgeTsagk GeorgeTsagk force-pushed the local-reputation-subsystem branch 3 times, most recently from 615d701 to 516c694 Compare June 25, 2026 13:12
Add a self-contained, log-only subsystem implementing the local resource
conservation scheme from the lightning/bolts proposal (outgoing reputation + HTLC
accountability), modelled on rust-lightning's resource_manager.

The subsystem observes forwarded HTLCs and computes, per channel: an
outgoing-channel reputation (decaying average of effective fees), an
incoming-channel revenue threshold, a sufficiency verdict, and a
general/congestion/protected resource-bucket assignment. It is strictly
observational: it never fails, delays, or re-prioritises an HTLC and never
touches the wire. Reputation is built only from live traffic (no historical
read); decaying-average state is persisted so it survives restarts, while
in-flight HTLC tracking is discarded on restart (a documented log-only
trade-off). Arithmetic mirrors the LDK reference so the two agree numerically.

When a channel needs every slot in the general bucket (small channels whose
per-channel allocation meets or exceeds the bucket's total slot count), the
slots are now assigned deterministically rather than drawn from the ChaCha20
keystream, which could otherwise fail to converge within the attempt budget
and make slot assignment intermittently error.
Add three nil-guarded hooks at the switch's circuit-matching layer
(handlePacketAdd/handlePacketSettle/handlePacketFail) that feed forwarded
HTLC add/settle/fail events to an optional ReputationManager. The switch
defines the interface; it never imports the reputation package, keeping the
dependency a black box that can only call observation methods.

The hooks pass copies of scalar values (circuit keys, amounts, cltv, the
accountable bit) — no pointers into switch state — so they cannot mutate
forwarding. A guardedReputationManager wraps the manager in a recover()
boundary: because the hooks run on the forwarding goroutine, a panic in the
(log-only) subsystem must never take down HTLC forwarding, so on a panic the
guard logs, disables the subsystem, and forwarding continues unaffected.
Construct the local reputation manager in the server when the experimental
routing.reputation flag is set (default off), and wire it into the switch via
a small adapter that bridges the switch's ReputationManager interface to the
reputation package. When the flag is off the manager is nil and the hooks are
no-ops with zero overhead, so the switch is byte-for-byte unchanged.

This also registers the REPM subsystem logger with the root logger; without
it the log-only subsystem would emit nothing in a running node.
Add an integration test that runs a forwarding node with the reputation
subsystem enabled through a successful forward, a downstream-failed forward,
and a warm restart. It asserts non-interference (payments behave exactly as
without the subsystem) and, via a new HarnessTest.AssertNodeLogContains helper,
that the node actually computes reputation by matching the "Reputation gained"
log line after a forward. After the warm restart it asserts the node keeps
forwarding successfully; post-restart reputation state is verified in a later
commit via the FetchReputation read-out (a post-restart log re-assertion would
be tautological, since lnd's log rotator appends to the same file and the
pre-restart line survives).
Previously the local reputation subsystem discarded all in-flight HTLCs on
restart: Manager.ReplayInFlight was implemented and unit-tested but never
called, so the pending set started empty and resolutions of HTLCs that
spanned the restart were tolerated as no-ops. That under-counts in-flight
risk and bucket occupancy, and (once enforcement lands) is an attack surface
where a restart wipes a peer's accountability.

Wire it up. The switch circuit map retains only the four cheap fields per
open circuit (incoming/outgoing keys + amounts); the two missing fields are
recovered from the live incoming-channel commitment HTLC:

  - IncomingCltv: HTLC.RefundTimeout.
  - Accountable: the experimental accountable custom record (TLV 106823,
    value 7), which is persisted in the incoming update_add_htlc custom
    records and round-trips through OpenChannel ExtraData, so it IS
    recoverable after a restart (no guessing / no default needed).

Add a read-only ActiveCircuits() snapshot accessor to the circuit map (and a
Switch passthrough) to enumerate open keystoned circuits. In server.go, after
the switch starts, join those circuits (filtered to real forwards:
Incoming.ChanID != hop.Source and keystone set) with FetchAllOpenChannels()
ActiveHtlcs() by (incoming chan id, incoming htlc id) and feed the result to
ReplayInFlight, guarded by s.reputationMgr != nil. Circuits whose incoming
HTLC is no longer live (resolved/closed concurrently with startup) are
skipped rather than replayed with bogus metadata.

The join logic lives behind small seams (circuitSource/openChannelSource) in
reputation_adapter.go and is unit-tested with fakes. DESIGN.md §6/§9 updated:
in-flight HTLCs are now reconstructed (not discarded) and ReplayInFlight is
wired (no longer parked).
Add Snapshot(), a pure read that returns a copy of per-channel computed
reputation state under the manager lock, mirroring the snapshot-under-lock
pattern used by the persistence flush. Per channel it exposes the scid,
outgoing reputation, incoming revenue threshold, current in-flight risk,
the at-rest sufficiency verdict, pending-HTLC count, and the slot/liquidity
occupancy of the general/congestion/protected buckets.

Decayed values are computed via new non-mutating peekAt helpers on the
decaying and aggregated-window averages, so a snapshot never advances the
live state. Unit-tested for the forward+settle lifecycle, at-rest emptiness,
and non-mutation.
Expose the local reputation subsystem's computed state through a new unary,
read-only FetchReputation method on the devrpc (dev/debug) sub-server. The
handler calls Manager.Snapshot() and marshals the per-channel state into the
new ChannelReputation / BucketOccupancy proto messages.

The reputation manager is wired into the devrpc Config through
PopulateDependencies. It is nil unless the experimental --routing.reputation
flag is set; the handler is nil-safe and returns a clear "reputation
subsystem disabled" error in that case. The method requires a read-only
offchain macaroon permission, and an lncli "fetchreputation" command and a
harness RPC wrapper are added to drive it.

Generated proto stubs regenerated via 'make rpc' (docker-based gen_protos).
After the successful Alice->Bob->Carol forward settles, the local reputation
itest now calls the new devrpc FetchReputation RPC on Bob and asserts the
exact computed values instead of only matching a log line: the outgoing
(Bob->Carol) channel's reputation increased by exactly the forwarding fee
Bob earned (read as ground truth from his forwarding history), the incoming
(Alice->Bob) channel's revenue moved by the same fee, and all bucket
occupancy and pending state returned to zero once the HTLC resolved.

Forwarding events flush on a periodic ticker, so the read of Bob's forwarding
history is wrapped in wait.NoError and polled until exactly one event is
reported before the fee is read, instead of asserting the length once and
racing the flush.

After the warm restart the test fetches the reputation snapshot again and
asserts the persisted values reloaded unchanged (the outgoing reputation is
still positive and both values match their pre-restart figures up to integer
rounding over the decay window). This replaces the previous, tautological
post-restart 'Reputation gained' log re-assertion (lnd's log rotator appends
to the same file, so the pre-restart line survives a restart regardless of
whether any recomputation happened). The existing 'Reputation gained' log
assertion before the restart is kept as the synchronization point that the
resolution has been observed.
@GeorgeTsagk GeorgeTsagk force-pushed the local-reputation-subsystem branch from 516c694 to 8813fe2 Compare July 2, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

severity-critical Requires expert review - security/consensus critical

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant