[1/?] Local reputation: subsystem core, read only - #10919
GeorgeTsagk wants to merge 6 commits into
Conversation
PR Severity: CRITICAL
CRITICAL (4 files)
MEDIUM (16 files)
LOW (13 files -- excluded from counts)
AnalysisThis PR introduces a new channel reputation system for HTLC jamming mitigation. The critical classification is driven by direct modifications to Key concerns warranting careful review:
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 |
1be208a to
5bdb907
Compare
615d701 to
516c694
Compare
516c694 to
8813fe2
Compare
|
Chatted to @GeorgeTsagk about strategies to break up this PR up and lighten review burden on the LND team! PR BreakdownI was talking to claude about this, and produced this plan, but zero promises because I haven't even read it - just an artifact from this discussion! (commits marked with * are dead code for the sake of incremental steps, could be squashed if that's not okay) 1. Implement reputation tracking*
2. Connect to switch
3. Restarts and in-flight
Once we get to this point, we get a very rudimentary "would this HTLC in isolation be able to enter the protected bucket (if needed)" sanity check. It doesn't take into account that there may be other HTLCs in flight, or whether we'll actually need to use protected resources, but this is a very valuable sanity check that we can't otherwise obtain with the data that's currently surfaced in LND (because we don't have historical failed forwards). 4. Implement bucketing logic*
5. Utilize buckets
Other RPCs/snapshots can be added after that, but if the majority of folks aren't running LND with Review@elnosh and I are happy to review here! We'll be able to provide strong reviews on the jamming work, since it's our focus. I should be able to provide reasonable review on the switch interactions, though my view of this system is of course a few years stale! |
|
Thanks @carlaKC for writing the summary. So I believe the next step here is to strip some things away from this PR and only keep 1 & 2:
This should leave us with a more minimal & lean diff, leaving out any noisy parts related to restarts/persistence and cold start. Another comment on this strategy: if we ever deploy 1&2, then reputation systems in the wild will already start recording values from forwarding, at that point I don't think it would make sense to ship historical-read as a follow-up update to this system, we are practically doing a slow-bootstrap already. |
Yeah SGTM! If we're okay with a bit of temporarily dead code, I think it makes sense to do 1 / 2 as separate PRs for the sake of small incremental steps. That's a question of project preferences, so depends on how LND prefers to do things nowadays.
Indeed! We do need 6 months data to get realreal values, so perhaps for (3) we could just focus on persistence, because we won't get far if we lose all our data every time we restart. Just 2x fields per channel, so not too bad! @erickcestari also agreed to help out with review ❣️ |
8813fe2 to
d061d4a
Compare
304508e to
81315c4
Compare
|
Ok marking this as ready for review, it now adds:
|
carlaKC
left a comment
There was a problem hiding this comment.
Primarily reviewed the first commit, haven't looked at the tests yet.
High level thoughts:
- I think it's worth spending a bit more time thinking about how this interacts with the switch, and whether a queue is the right call here.
- There are a few places where this can be better aligned with how LND does things, both major things like using existing interfaces and shorter comments
- I am concerned by pointing a LLM at the LDK pr, it puts us at risk of propagating bugs and makes the process of improving the spec by having to implement it weaker
- Snapshot and dev rpc are pretty low value IMO, would far rather see benchmarking
Checked, we call the reputation hook right after the strict pick takes place. So it's the real pick from the get go: Lines 3038 to 3068 in 85bd467 Edit: will add a test, won't hurt |
85bd467 to
15630d7
Compare
|
Thanks for the feedback @erickcestari and @carlaKC. I have addressed your comments (some threads still open for discussion, will resolve async as we figure things out). |
|
@carlaKC: review reminder |
There was a problem hiding this comment.
Apologies for a late review. I am, of course, not that deeply familiar with LND codebase so these are just some general observations more directed towards the jamming side implementation in the reputation package as I don't have that much context on all the htlcswitch inner workings.
Just to clear a doubt - the README says "It holds no persisted state, so reputation resets on restart and re-accrues from live forwarding traffic." but this comment says that persistence will be added. I assume there will be follow-up PR to add persistence of at least the reputation and revenue values? otherwise it would be very fragile to lose all this historical behavior after a restart.
One other high-level-ish note is if there should be any fallback handling for nodes that set fees to 0. I just looked at the graph and there a few policies with both base and proportional fees to 0 so there could be a fallback "sane default" fee policy for such channels but I don't think it should be that much of a deal.
Last thing, I think capturing "will this node have reputation for this sole HTLC" is the more useful piece of information but the current impl will effectively only measure that (?) if no one flips the accountable signal then there will be no accountable HTLCs contributing to a total in-flight risk.
| if warmup < 1 { | ||
| warmup = 1 | ||
| } |
There was a problem hiding this comment.
small note - I agree with this behavior and think we should probably change the spec to account for it
| func (m *Manager) getOrCreateChannel(scid uint64, | ||
| at time.Time) *channelReputation { | ||
|
|
||
| if c, ok := m.channels[scid]; ok { | ||
| return c | ||
| } | ||
|
|
||
| c := newChannelReputation(m.cfg, at) | ||
| m.channels[scid] = c | ||
|
|
||
| return c | ||
| } |
There was a problem hiding this comment.
seems there is no way to remove channels from the manager. Perhaps not that big of a deal right now but could be good to add way to remove closed channels from the manager.
There was a problem hiding this comment.
Agreed, though not urgent while this is in-memory and log-only. The follow-up persistence PR has to deal with channel lifecycle anyway, so I'd handle removal of closed channels there.
| if s.cfg.ReputationManager != nil && circuit != nil { | ||
| outgoing := packet.outgoingChanID | ||
| if circuit.Outgoing != nil { | ||
| outgoing = circuit.Outgoing.ChanID | ||
| } | ||
| s.cfg.ReputationManager.OnFail(circuit.Incoming, outgoing) | ||
| } |
There was a problem hiding this comment.
OnFail reports outgoing scid 0 whenever the outgoing link rejects an add, so the manager never releases the pending HTLC it recorded on the forward.
memoryMailBox.FailAdd builds a fresh fail packet and doesn't copy outgoingChanID (mailbox.go:736), and the keystone was never set because the link only assigns it after AddHTLC succeeds (link.go:1668). So both sources in handlePacketFail (switch.go:3219-3221, here) are empty, resolveHTLC misses on m.channels[0] (er) and returns nil.
I've made this test at reputation_hooks_test.go that should pass after adding the outgoingChanID value on the htlcPacket struct that memoryMailBox.FailAdd creates.
// TestSwitchReputationLocalFailAddResolvesPending drives the real
// memoryMailBox.FailAdd path end to end and asserts the reputation manager is
// told to resolve the HTLC against the same outgoing channel it was told to
// track it under. Matching keys are what let the manager release the pending
// HTLC it recorded on the forward.
//
// The add is delivered to the outgoing link but never committed, so no circuit
// keystone is written. FailAdd is then invoked exactly as the link does when
// channel.AddHTLC rejects the HTLC, or as the mailbox does when the delivery
// deadline expires. On that path the fail packet is the only remaining record
// of which channel the switch chose, so if FailAdd drops outgoingChanID the
// manager sees the zero scid, matches nothing, and leaks the pending forever.
func TestSwitchReputationLocalFailAddResolvesPending(t *testing.T) {
t.Parallel()
repMgr := &mockReputationManager{}
s, aliceLink, bobLink := newReputationTestSwitch(t, repMgr)
preimage, err := genPreimage()
require.NoError(t, err, "unable to generate preimage")
rhash := sha256.Sum256(preimage[:])
addPkt := &htlcPacket{
incomingChanID: aliceLink.ShortChanID(),
incomingHTLCID: 0,
outgoingChanID: bobLink.ShortChanID(),
obfuscator: NewMockObfuscator(),
htlc: &lnwire.UpdateAddHTLC{
PaymentHash: rhash,
Amount: 1,
},
}
require.NoError(t, s.ForwardPackets(nil, addPkt))
// Take the add off the outgoing link's mailbox without completing the
// circuit: the keystone is only written once the link has added the
// HTLC to its commitment.
var delivered *htlcPacket
select {
case delivered = <-bobLink.packets:
case <-time.After(time.Second):
t.Fatal("add was not propagated to destination")
}
forwards, _, _ := repMgr.snapshot()
require.Len(t, forwards, 1)
require.Equal(t, bobLink.ShortChanID(), forwards[0].out,
"forward must be tracked against the outgoing channel")
// The outgoing link rejects the add. This is the real FailAdd, which
// builds the fail packet and hands it back to the switch.
bobLink.mailBox.FailAdd(delivered)
select {
case pkt := <-aliceLink.packets:
require.NoError(t, aliceLink.deleteCircuit(pkt))
case <-time.After(time.Second):
t.Fatal("fail was not propagated upstream")
}
// Both keys must match the forward, otherwise the manager cannot find
// the pending HTLC it recorded and never releases it.
_, _, fails := repMgr.snapshot()
require.Len(t, fails, 1)
require.Equal(t, forwards[0].in, fails[0].in,
"resolve must report the same htlc as the forward")
require.Equal(t, forwards[0].out, fails[0].out,
"resolve must report the same channel as the forward")
}There was a problem hiding this comment.
I've just noticed that this is the same issue as @elnosh commented.
There was a problem hiding this comment.
Fixed it a different way after elnosh flagged the same leak: instead of stamping outgoingChanID onto the FailAdd packet, resolutions now identify the HTLC by its incoming circuit key alone and the manager recovers the outgoing channel from an index recorded at forward time. That covers every path that lacks the scid without touching the mailbox. Added a mailbox FailAdd test along the lines of yours.
Add the numeric primitives underlying local reputation scoring, following the "Decaying Average" and "Revenue Threshold Aggregation" sections of BOLT lightningnetwork#1280, plus a package README describing the subsystem: - saturatedI64: int64 arithmetic that clamps rather than wraps, so the long-window fee accumulators never silently flip sign. - decayingAverage: a value decaying as e^(-elapsed/window) per the spec's decay_rate. - aggregatedWindowAverage: a decaying average over several windows with the spec's exponential warm-up factor.
Add the per-channel reputation state and the BOLT lightningnetwork#1280 scoring rules built on the decaying-average primitives: - Config: the tunable parameters (resolution period, revenue window, reputation multiplier, revenue window count) with the spec defaults. - effectiveFee/opportunityCost/inFlightRisk: an HTLC's contribution to reputation and its worst-case in-flight risk. - channelReputation: the per-channel outgoing reputation, incoming revenue threshold and pending HTLCs, plus the sufficiency inequality outgoing_reputation - risk >= revenue_threshold.
15630d7 to
0ebdac9
Compare
|
@elnosh on the review notes: yes, a follow-up PR will persist the reputation and revenue values, including decaying them correctly across downtime. On zero-fee policies I'd leave it as is, such channels simply never accrue reputation, which fails on the strict side. And right, until peers set the accountable bit the in-flight term is zero and both verdicts coincide, which is why both are logged. |
0ebdac9 to
babef31
Compare
elnosh
left a comment
There was a problem hiding this comment.
changes addressing previous review look good to me.
On zero-fee policies I'd leave it as is, such channels simply never accrue reputation, which fails on the strict side.
in this purely log phase, either seems fine. But noting that if a node sets its fees to 0 across all its channels, it means nothing in protected bucket is "protected". Everything is a function of the fees so outgoing_channel_reputation - in_flight_risk >= incoming_revenue_threshold is true. Meaning, everything is accepted.
| if ts.Before(d.lastUpdated) { | ||
| return 0, errBackwardsTime | ||
| } |
There was a problem hiding this comment.
just to note that we (in LDK) decided to clamp on this rather than error. Specially on cases where the HTLC succeeded.
|
@carlaKC: review reminder |
2 similar comments
|
@carlaKC: review reminder |
|
@carlaKC: review reminder |
Add the Manager that ties the scoring together behind the OnForward/OnSettle/ OnFail hooks. The hooks run synchronously under a single lock: OnForward records the pending HTLC and computes (and logs) the reputation decision, both for the HTLC in isolation and against the risk already in flight on its outgoing channel, while OnSettle/OnFail resolve it and update the outgoing reputation and incoming revenue averages. The subsystem is log-only and holds no persisted state, so reputation re-accrues from live traffic after a restart. Every resolution drops its own pending HTLC, so a pending that outlives the worst case time it could be held for means a resolution was never reported to us. A periodic check warns about those and deliberately leaves them in place rather than sweeping them away, so the underlying bug stays visible. Includes unit tests and benchmarks for the per-forward hook cost.
Feed forwarded HTLCs to the reputation subsystem through a read-only seam on the switch. The switch calls OnForward/OnSettle/OnFail at the circuit layer behind a nil check, so the subsystem is skipped entirely when disabled. The manager is wrapped in a panic boundary before being handed to the switch: a bug in the (log-only) subsystem can never take down HTLC forwarding. Only the outgoing channel is reported to the subsystem, not an outgoing circuit key: at forward time the switch has not yet handed the packet to the outgoing link, so no outgoing HTLC ID exists yet. The subsystem is enabled by default and can be disabled with the new routing.no-reputation flag. Includes unit tests for the switch seam: each hook fires once with the right keys, a nil manager is a no-op, local sends are skipped, a hook panic is absorbed by the guard, and a non-strict forward reports the channel the HTLC actually went out on for both the add and its resolution.
Add an integration test asserting that a forwarding node running the log-only reputation subsystem forwards, fails and restarts exactly as it would without it, while emitting the expected reputation log lines.
babef31 to
a79ffec
Compare
erickcestari
left a comment
There was a problem hiding this comment.
LGTM!
The best of my knowledge everything looks good to me. Nice work!
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 in a log-only mode, meaning that:
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
[ ] (?) Handle cold start (historical traffic read)for 2nd part[x] (?) Properly handle in-flight HTLCs when restartingfor 2nd part