Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions reputation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
reputation
==========

[![Build Status](http://img.shields.io/travis/lightningnetwork/lnd.svg)](https://travis-ci.org/lightningnetwork/lnd)
[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/lightningnetwork/lnd/blob/master/LICENSE)
[![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](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
```
73 changes: 73 additions & 0 deletions reputation/bench_test.go
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)
}
}
}
54 changes: 54 additions & 0 deletions reputation/channel.go
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
Comment thread
GeorgeTsagk marked this conversation as resolved.

// 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
}
97 changes: 97 additions & 0 deletions reputation/config.go
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)
}
70 changes: 70 additions & 0 deletions reputation/config_test.go
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)
}
})
}
}
Loading