-
Notifications
You must be signed in to change notification settings - Fork 2.3k
[1/?] Local reputation: subsystem core, read only #10919
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
Open
GeorgeTsagk
wants to merge
6
commits into
lightningnetwork:master
Choose a base branch
from
GeorgeTsagk:local-reputation-subsystem
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6d4cedd
reputation: add decaying average and revenue aggregation
GeorgeTsagk a22c0fe
reputation: add HTLC fee and reputation scoring
GeorgeTsagk a024bf8
reputation: add the reputation manager
GeorgeTsagk 8782054
htlcswitch+lnd: connect reputation manager to the switch
GeorgeTsagk e57ff3a
itest: test the local reputation subsystem end-to-end
GeorgeTsagk c6480fe
docs: add release note for the local reputation subsystem
GeorgeTsagk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| reputation | ||
| ========== | ||
|
|
||
| [](https://travis-ci.org/lightningnetwork/lnd) | ||
| [](https://github.com/lightningnetwork/lnd/blob/master/LICENSE) | ||
| [](http://godoc.org/github.com/lightningnetwork/lnd/reputation) | ||
|
|
||
| The reputation package implements local reputation tracking to help mitigate | ||
| channel jamming, following the scoring recommended in [BOLT | ||
| \#1280](https://github.com/lightning/bolts/pull/1280) (local resource | ||
| conservation). A forwarding node uses it to build an unforgeable history of how | ||
| each channel has behaved as an outgoing peer, so that it can later distinguish | ||
| peers that are likely being used to jam its channels from those that are not. | ||
|
|
||
| The package is **observational only**: it watches the HTLCs the node forwards, | ||
| maintains a per-channel reputation score, and logs the decision it would make | ||
| for each HTLC. It never affects forwarding, alters the wire, or writes to disk. | ||
|
|
||
| ## Reputation scoring | ||
|
|
||
| Every forwarded HTLC contributes an `effective_fee` to its outgoing channel's | ||
| reputation, adjusted for how long it was held: HTLCs that resolve within a | ||
| `resolution_period` contribute their full fee, while slower ones are penalised | ||
| by an `opportunity_cost` that grows with the overrun. Unaccountable HTLCs can | ||
| only ever help reputation, never harm it. | ||
|
|
||
| Three quantities determine whether a channel has sufficient reputation for a | ||
| given HTLC: | ||
|
|
||
| * **Outgoing channel reputation**: the sum of effective fees the outgoing | ||
| channel has earned over a long rolling window, tracked as a decaying | ||
| average. | ||
| * **Incoming channel revenue threshold**: the routing revenue the incoming | ||
| channel has generated over a shorter window, aggregated over several | ||
| windows so a peer cannot cheaply move its own threshold. | ||
| * **In-flight risk**: the worst-case opportunity cost of the HTLC assuming it | ||
| is held until just before its incoming CLTV expiry. | ||
|
|
||
| An HTLC's outgoing channel is considered to have sufficient reputation when: | ||
|
|
||
| outgoing_channel_reputation - in_flight_risk >= incoming_revenue_threshold | ||
|
|
||
| The rolling windows are implemented as decaying averages to avoid storing | ||
| per-HTLC history; see `decaying_average.go`. | ||
|
|
||
| ## Integration with the switch | ||
|
|
||
| The subsystem observes forwarding through three read-only hooks the switch | ||
| calls at the circuit layer: `OnForward` when it commits to forwarding an HTLC, | ||
| and `OnSettle`/`OnFail` when the HTLC resolves. The hooks run synchronously and | ||
| do only a handful of map lookups and floating-point operations, so they sit on | ||
| the forwarding path without a background worker. | ||
|
|
||
| When the subsystem is disabled the switch skips the hooks behind a nil check. | ||
| When it is enabled the manager is wrapped in a panic boundary before being | ||
| handed to the switch, so a bug in this (log-only) package can never take down | ||
| HTLC forwarding. | ||
|
|
||
| ## Operational notes | ||
|
|
||
| The subsystem is enabled by default and can be disabled with the | ||
| `routing.no-reputation` configuration flag. It holds no persisted state, so | ||
| reputation resets on restart and re-accrues from live forwarding traffic. | ||
|
|
||
| ## Installation and Updating | ||
|
|
||
| ```shell | ||
| $ go get -u github.com/lightningnetwork/lnd/reputation | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| package reputation | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/lightningnetwork/lnd/clock" | ||
| ) | ||
|
|
||
| // BenchmarkForwardResolve measures the full per-HTLC cost the reputation | ||
| // subsystem adds to a forward: the synchronous OnForward hook (record pending + | ||
| // compute the decision) plus the OnSettle hook (resolve + update the averages). | ||
| // When the subsystem is disabled the switch skips these hooks behind a single | ||
| // nil check, so this is the "enabled minus disabled" overhead per forwarded | ||
| // HTLC. | ||
| func BenchmarkForwardResolve(b *testing.B) { | ||
| m, err := NewManager( | ||
| DefaultConfig(), clock.NewTestClock(time.Unix(1_000_000, 0)), | ||
| ) | ||
| if err != nil { | ||
| b.Fatalf("NewManager: %v", err) | ||
| } | ||
|
|
||
| in := circuit(1, 0) | ||
| out := circuit(2, 0) | ||
|
|
||
| b.ReportAllocs() | ||
| b.ResetTimer() | ||
| for i := 0; i < b.N; i++ { | ||
| m.OnForward(in, out, 2000, 1000, 1000, 200, 100, false) | ||
| m.OnSettle(in, out) | ||
| } | ||
| } | ||
|
|
||
| // BenchmarkOnForward measures the cost of the forwarding hook alone (record | ||
| // pending + compute the reputation decision), which is the work that sits on | ||
| // the switch's forwarding path. Each iteration uses a distinct HTLC id and is | ||
| // resolved immediately so the pending map stays bounded. | ||
| func BenchmarkOnForward(b *testing.B) { | ||
| m, err := NewManager( | ||
| DefaultConfig(), clock.NewTestClock(time.Unix(1_000_000, 0)), | ||
| ) | ||
| if err != nil { | ||
| b.Fatalf("NewManager: %v", err) | ||
| } | ||
|
|
||
| out := circuit(2, 0) | ||
|
|
||
| b.ReportAllocs() | ||
| b.ResetTimer() | ||
| for i := 0; i < b.N; i++ { | ||
| in := circuit(1, uint64(i)) | ||
| m.OnForward(in, out, 2000, 1000, 1000, 200, 100, false) | ||
|
|
||
| b.StopTimer() | ||
| m.OnSettle(in, out) | ||
| b.StartTimer() | ||
| } | ||
| } | ||
|
|
||
| // BenchmarkDecayingAverageAdd measures the core decaying-average update, the | ||
| // hot primitive underlying every reputation and revenue mutation. | ||
| func BenchmarkDecayingAverageAdd(b *testing.B) { | ||
| d := newDecayingAverage(0, DefaultConfig().reputationWindow()) | ||
|
|
||
| b.ReportAllocs() | ||
| b.ResetTimer() | ||
| for i := 0; i < b.N; i++ { | ||
| if _, err := d.add(1000, uint64(i)); err != nil { | ||
| b.Fatalf("add: %v", err) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package reputation | ||
|
|
||
| // channelReputation holds all per-channel reputation state. A single channel | ||
| // plays both roles: as an outgoing link it accrues reputation and holds the | ||
| // pending HTLCs it is responsible for; as an incoming link it accrues the | ||
| // revenue that sets its reputation threshold. | ||
| type channelReputation struct { | ||
| // outgoingReputation is the reputation this channel has accrued as an | ||
| // outgoing link. | ||
| outgoingReputation *decayingAverage | ||
|
|
||
| // incomingRevenue is the revenue this channel has earned us as an | ||
| // incoming link. It is aggregated over several windows so that a peer | ||
| // cannot cheaply move its own threshold by manipulating recent | ||
| // forwarding. | ||
| incomingRevenue *aggregatedWindowAverage | ||
|
|
||
| // pendingHTLCs tracks the in-flight HTLCs for which this channel is the | ||
| // outgoing link, keyed by their incoming circuit. | ||
| pendingHTLCs map[htlcRef]*pendingHTLC | ||
| } | ||
|
|
||
| // newChannelReputation builds empty reputation state for a channel as of the | ||
| // provided start time. | ||
| func newChannelReputation(cfg Config, start uint64) *channelReputation { | ||
| return &channelReputation{ | ||
| outgoingReputation: newDecayingAverage( | ||
| start, cfg.reputationWindow(), | ||
| ), | ||
| incomingRevenue: newAggregatedWindowAverage( | ||
| cfg.RevenueWindow, cfg.RevenueWindowCount, start, | ||
| ), | ||
| pendingHTLCs: make(map[htlcRef]*pendingHTLC), | ||
| } | ||
| } | ||
|
|
||
| // sufficientReputation evaluates the reputation inequality | ||
| // | ||
| // outgoing_reputation - htlc_risk >= revenue_threshold | ||
| // | ||
| // against this (incoming) channel's revenue threshold, returning the verdict | ||
| // and the threshold value used. The caller chooses which risk to pass in. | ||
| func (c *channelReputation) sufficientReputation(htlcRisk uint64, | ||
| outgoingReputation int64, at uint64) (bool, int64, error) { | ||
|
|
||
| threshold, err := c.incomingRevenue.valueAt(at) | ||
| if err != nil { | ||
| return false, 0, err | ||
| } | ||
|
|
||
| net := saturatedI64(outgoingReputation).Sub(satFromUint(htlcRisk)) | ||
|
|
||
| return net.Int64() >= threshold, threshold, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| package reputation | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "time" | ||
| ) | ||
|
|
||
| const ( | ||
| // defaultResolutionPeriod is the amount of time an HTLC is allowed to | ||
| // resolve in that classifies as "good" behaviour. The protocol allows | ||
| // for a 60s MPP timeout, so BOLT #1280 recommends 90s. | ||
| defaultResolutionPeriod = 90 * time.Second | ||
|
|
||
| // defaultRevenueWindow is the largest cltv delta from the current block | ||
| // height that a node will allow before failing with expiry_too_far, | ||
| // expressed as a duration assuming 10 minute blocks (2016 blocks ~= 2 | ||
| // weeks). | ||
| defaultRevenueWindow = 2016 * 10 * time.Minute | ||
|
|
||
| // defaultReputationMultiplier is the multiplier applied to the revenue | ||
| // window to determine the rolling window over which the outgoing | ||
| // channel's forwarding history is considered (default 12 => ~24 weeks). | ||
| // This sizes the outgoing-reputation window only. | ||
| defaultReputationMultiplier = 12 | ||
|
|
||
| // defaultRevenueWindowCount is the number of rolling windows over which | ||
| // the incoming-revenue aggregated average is measured; BOLT #1280 | ||
| // (window_total) recommends at least 6. It is distinct from | ||
| // defaultReputationMultiplier, which sizes only the outgoing-reputation | ||
| // window. | ||
| defaultRevenueWindowCount = 6 | ||
|
|
||
| // blockInterval is the assumed seconds per block (10 minutes) used to | ||
| // convert cltv deltas to durations. | ||
| blockInterval = 10 * 60 | ||
| ) | ||
|
|
||
| // Config holds the tunable parameters of the reputation subsystem. The | ||
| // zero value is not valid; use DefaultConfig and override as needed. | ||
| type Config struct { | ||
| // ResolutionPeriod is the duration within which an HTLC resolution is | ||
| // considered "good" behaviour (no opportunity cost). | ||
| ResolutionPeriod time.Duration | ||
|
|
||
| // RevenueWindow is the rolling window over which incoming-channel | ||
| // revenue is measured. | ||
| RevenueWindow time.Duration | ||
|
|
||
| // ReputationMultiplier scales RevenueWindow to give the (longer) window | ||
| // over which outgoing-channel reputation is measured. | ||
| ReputationMultiplier uint8 | ||
|
|
||
| // RevenueWindowCount is the number of rolling RevenueWindow-sized | ||
| // windows over which the incoming-revenue aggregated average is | ||
| // measured. It is distinct from ReputationMultiplier, which sizes only | ||
| // the outgoing-reputation window. | ||
| RevenueWindowCount uint8 | ||
| } | ||
|
|
||
| // DefaultConfig returns the recommended default configuration. | ||
| func DefaultConfig() Config { | ||
| return Config{ | ||
| ResolutionPeriod: defaultResolutionPeriod, | ||
| RevenueWindow: defaultRevenueWindow, | ||
| ReputationMultiplier: defaultReputationMultiplier, | ||
| RevenueWindowCount: defaultRevenueWindowCount, | ||
| } | ||
| } | ||
|
|
||
| // Validate ensures the configuration is internally consistent. | ||
| func (c Config) Validate() error { | ||
| if c.ResolutionPeriod <= 0 { | ||
| return fmt.Errorf("resolution period must be positive, got %v", | ||
| c.ResolutionPeriod) | ||
| } | ||
|
|
||
| if c.RevenueWindow <= 0 { | ||
| return fmt.Errorf("revenue window must be positive, got %v", | ||
| c.RevenueWindow) | ||
| } | ||
|
|
||
| if c.ReputationMultiplier == 0 { | ||
| return fmt.Errorf("reputation multiplier must be positive") | ||
| } | ||
|
|
||
| if c.RevenueWindowCount == 0 { | ||
| return fmt.Errorf("revenue window count must be positive") | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // reputationWindow returns the rolling window over which outgoing-channel | ||
| // reputation is tracked. | ||
| func (c Config) reputationWindow() time.Duration { | ||
| return c.RevenueWindow * time.Duration(c.ReputationMultiplier) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package reputation | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| // TestConfigValidate exercises the config validation table. | ||
| func TestConfigValidate(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| cfg Config | ||
| wantErr bool | ||
| }{ | ||
| { | ||
| name: "default is valid", | ||
| cfg: DefaultConfig(), | ||
| }, | ||
| { | ||
| name: "zero resolution period invalid", | ||
| cfg: Config{ | ||
| RevenueWindow: time.Hour, | ||
| ReputationMultiplier: 12, | ||
| RevenueWindowCount: 6, | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "zero revenue window invalid", | ||
| cfg: Config{ | ||
| ResolutionPeriod: time.Second, | ||
| ReputationMultiplier: 12, | ||
| RevenueWindowCount: 6, | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "zero multiplier invalid", | ||
| cfg: Config{ | ||
| ResolutionPeriod: time.Second, | ||
| RevenueWindow: time.Hour, | ||
| RevenueWindowCount: 6, | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "zero revenue window count invalid", | ||
| cfg: Config{ | ||
| ResolutionPeriod: time.Second, | ||
| RevenueWindow: time.Hour, | ||
| ReputationMultiplier: 12, | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| err := tc.cfg.Validate() | ||
| if tc.wantErr && err == nil { | ||
| t.Fatalf("expected error, got nil") | ||
| } | ||
| if !tc.wantErr && err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| }) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.