Skip to content

Commit 73efca3

Browse files
committed
Documentation
1 parent 0136caf commit 73efca3

File tree

8 files changed

+118
-84
lines changed

8 files changed

+118
-84
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ To enable CoinGrinder at all fee rates and prevent the automatic consolidation o
1717
consolidatefeerate=0
1818
```
1919

20+
### Local reputation and HTLC endorsement
21+
22+
To protect against jamming attacks, eclair gives a reputation to its neighbors and uses to decide if a HTLC should be relayed given how congested is the outgoing channel.
23+
The reputation is basically how much this node paid us in fees divided by how much they should have paid us for the liquidity and slots that they blocked.
24+
The reputation is per incoming node and endorsement level.
25+
The confidence that the HTLC will be fulfilled is transmitted to the next node using the endorsement TLV of the `update_add_htlc` message.
26+
27+
To configure, edit `eclair.conf`:
28+
```eclair.conf
29+
eclair.local-reputation {
30+
# Reputation decays with the following half life to emphasize recent behavior.
31+
half-life = 7 days
32+
# HTLCs that stay pending for longer than this get penalized
33+
good-htlc-duration = 12 seconds
34+
# How much to penalize pending HLTCs. A pending HTLC is considered equivalent to this many fast-failing HTLCs.
35+
pending-multiplier = 1000
36+
}
37+
```
38+
2039
### API changes
2140

2241
<insert changes>

eclair-core/src/main/resources/reference.conf

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,8 +547,11 @@ eclair {
547547
}
548548

549549
local-reputation {
550-
max-weight-msat = 100000000000 # 1 BTC
551-
min-duration = 12 seconds
550+
# Reputation decays with the following half life to emphasize recent behavior.
551+
half-life = 7 days
552+
# HTLCs that stay pending for longer than this get penalized
553+
good-htlc-duration = 12 seconds # 95% of successful payments settle in less than 12 seconds, only the slowest 5% will be penalized.
554+
# How much to penalize pending HLTCs. A pending HTLC is considered equivalent to this many fast-failing HTLCs.
552555
pending-multiplier = 1000
553556
}
554557
}

eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -613,10 +613,10 @@ object NodeParams extends Logging {
613613
batchSize = config.getInt("db.revoked-htlc-info-cleaner.batch-size"),
614614
interval = FiniteDuration(config.getDuration("db.revoked-htlc-info-cleaner.interval").getSeconds, TimeUnit.SECONDS)
615615
),
616-
localReputationConfig = ReputationConfig(MilliSatoshi(
617-
config.getLong("local-reputation.max-weight-msat")),
618-
FiniteDuration(config.getDuration("local-reputation.min-duration").getSeconds, TimeUnit.SECONDS),
619-
config.getDouble("local-reputation.pending-multiplier")
616+
localReputationConfig = ReputationConfig(
617+
FiniteDuration(config.getDuration("local-reputation.half-life").getSeconds, TimeUnit.SECONDS),
618+
FiniteDuration(config.getDuration("local-reputation.good-htlc-duration").getSeconds, TimeUnit.SECONDS),
619+
config.getDouble("local-reputation.pending-multiplier"),
620620
),
621621
)
622622
}

eclair-core/src/main/scala/fr/acinq/eclair/reputation/Reputation.scala

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,38 +22,61 @@ import fr.acinq.eclair.{MilliSatoshi, TimestampMilli}
2222
import java.util.UUID
2323
import scala.concurrent.duration.FiniteDuration
2424

25-
case class Reputation(pastWeight: Double, pending: Map[UUID, Pending], pastScore: Double, maxWeight: Double, minDuration: FiniteDuration, pendingMultiplier: Double) {
26-
private def pendingWeight(now: TimestampMilli): Double = pending.values.map(_.weight(now, minDuration, pendingMultiplier)).sum
25+
/** Local reputation per incoming node and endorsement level
26+
*
27+
* @param pastWeight How much fees we would have collected in the past if all HTLCs had succeeded (exponential moving average).
28+
* @param pastScore How much fees we have collected in the past (exponential moving average).
29+
* @param lastSettlementAt Timestamp of the last recorded HTLC settlement.
30+
* @param pending Set of pending HTLCs.
31+
* @param halfLife Half life for the exponential moving average.
32+
* @param goodDuration Duration after which HTLCs are penalized for staying pending too long.
33+
* @param pendingMultiplier How much to penalize pending HTLCs.
34+
*/
35+
case class Reputation(pastWeight: Double, pastScore: Double, lastSettlementAt: TimestampMilli, pending: Map[UUID, Pending], halfLife: FiniteDuration, goodDuration: FiniteDuration, pendingMultiplier: Double) {
36+
private def decay(now: TimestampMilli): Double = scala.math.pow(0.5, (now - lastSettlementAt) / halfLife)
2737

28-
def confidence(now: TimestampMilli = TimestampMilli.now()): Double = pastScore / (pastWeight + pendingWeight(now))
38+
private def pendingWeight(now: TimestampMilli): Double = pending.values.map(_.weight(now, goodDuration, pendingMultiplier)).sum
2939

30-
def attempt(relayId: UUID, fee: MilliSatoshi, startedAt: TimestampMilli = TimestampMilli.now()): Reputation =
31-
copy(pending = pending + (relayId -> Pending(fee, startedAt)))
40+
/** Register a HTLC to relay and estimate the confidence that it will succeed.
41+
* @return (updated reputation, confidence)
42+
*/
43+
def attempt(relayId: UUID, fee: MilliSatoshi, now: TimestampMilli = TimestampMilli.now()): (Reputation, Double) = {
44+
val d = decay(now)
45+
val newReputation = copy(pending = pending + (relayId -> Pending(fee, now)))
46+
val confidence = d * pastScore / (d * pastWeight + newReputation.pendingWeight(now))
47+
(newReputation, confidence)
48+
}
3249

50+
/** Mark a previously registered HTLC as failed without trying to relay it (usually because its confidence was too low).
51+
* @return updated reputation
52+
*/
3353
def cancel(relayId: UUID): Reputation = copy(pending = pending - relayId)
3454

55+
/** When a HTLC is settled, we record whether it succeeded and how long it took.
56+
*
57+
* @param feeOverride When relaying trampoline payments, the actual fee is only known when the payment succeeds. This
58+
* is used instead of the fee upper bound that was known when first attempting the relay.
59+
* @return updated reputation
60+
*/
3561
def record(relayId: UUID, isSuccess: Boolean, feeOverride: Option[MilliSatoshi] = None, now: TimestampMilli = TimestampMilli.now()): Reputation = {
62+
val d = decay(now)
3663
var p = pending.getOrElse(relayId, Pending(MilliSatoshi(0), now))
3764
feeOverride.foreach(fee => p = p.copy(fee = fee))
38-
val newWeight = pastWeight + p.weight(now, minDuration, 1.0)
39-
val newScore = if (isSuccess) pastScore + p.fee.toLong.toDouble else pastScore
40-
if (newWeight > maxWeight) {
41-
Reputation(maxWeight, pending - relayId, newScore * maxWeight / newWeight, maxWeight, minDuration, pendingMultiplier)
42-
} else {
43-
Reputation(newWeight, pending - relayId, newScore, maxWeight, minDuration, pendingMultiplier)
44-
}
65+
val newWeight = d * pastWeight + p.weight(now, goodDuration, 1.0)
66+
val newScore = d * pastScore + (if (isSuccess) p.fee.toLong.toDouble else 0)
67+
Reputation(newWeight, newScore, now, pending - relayId, halfLife, goodDuration, pendingMultiplier)
4568
}
4669
}
4770

4871
object Reputation {
4972
case class Pending(fee: MilliSatoshi, startedAt: TimestampMilli) {
50-
def weight(now: TimestampMilli, minDuration: FiniteDuration, pendingMultiplier: Double): Double = {
73+
def weight(now: TimestampMilli, minDuration: FiniteDuration, multiplier: Double): Double = {
5174
val duration = now - startedAt
52-
fee.toLong.toDouble * (duration / minDuration).max(pendingMultiplier)
75+
fee.toLong.toDouble * (duration / minDuration).max(multiplier)
5376
}
5477
}
5578

56-
case class ReputationConfig(maxWeight: MilliSatoshi, minDuration: FiniteDuration, pendingMultiplier: Double)
79+
case class ReputationConfig(halfLife: FiniteDuration, goodDuration: FiniteDuration, pendingMultiplier: Double)
5780

58-
def init(config: ReputationConfig): Reputation = Reputation(0.0, Map.empty, 0.0, config.maxWeight.toLong.toDouble, config.minDuration, config.pendingMultiplier)
81+
def init(config: ReputationConfig): Reputation = Reputation(0.0, 0.0, TimestampMilli.min, Map.empty, config.halfLife, config.goodDuration, config.pendingMultiplier)
5982
}

eclair-core/src/main/scala/fr/acinq/eclair/reputation/ReputationRecorder.scala

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ object ReputationRecorder {
4242
def apply(reputationConfig: ReputationConfig, reputations: Map[(PublicKey, Int), Reputation]): Behavior[Command] = {
4343
Behaviors.receiveMessage {
4444
case GetConfidence(replyTo, originNode, endorsement, relayId, fee) =>
45-
val updatedReputation = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).attempt(relayId, fee)
46-
replyTo ! Confidence(updatedReputation.confidence())
45+
val (updatedReputation, confidence) = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).attempt(relayId, fee)
46+
replyTo ! Confidence(confidence)
4747
ReputationRecorder(reputationConfig, reputations.updated((originNode, endorsement), updatedReputation))
4848
case CancelRelay(originNode, endorsement, relayId) =>
4949
val updatedReputation = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).cancel(relayId)
@@ -53,9 +53,8 @@ object ReputationRecorder {
5353
ReputationRecorder(reputationConfig, reputations.updated((originNode, endorsement), updatedReputation))
5454
case GetTrampolineConfidence(replyTo, fees, relayId) =>
5555
val (confidence, updatedReputations) = fees.foldLeft((1.0, reputations)){case ((c, r), ((originNode, endorsement), fee)) =>
56-
val updatedReputation = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).attempt(relayId, fee)
57-
val updatedConfidence = c.min(updatedReputation.confidence())
58-
(updatedConfidence, r.updated((originNode, endorsement), updatedReputation))
56+
val (updatedReputation, confidence) = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).attempt(relayId, fee)
57+
(c.min(confidence), r.updated((originNode, endorsement), updatedReputation))
5958
}
6059
replyTo ! Confidence(confidence)
6160
ReputationRecorder(reputationConfig, updatedReputations)

eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ object TestConstants {
231231
),
232232
purgeInvoicesInterval = None,
233233
revokedHtlcInfoCleanerConfig = RevokedHtlcInfoCleaner.Config(10, 100 millis),
234-
localReputationConfig = ReputationConfig(1000000 msat, 10 seconds, 100),
234+
localReputationConfig = ReputationConfig(1 day, 10 seconds, 100),
235235
)
236236

237237
def channelParams: LocalParams = OpenChannelInterceptor.makeChannelParams(
@@ -399,7 +399,7 @@ object TestConstants {
399399
),
400400
purgeInvoicesInterval = None,
401401
revokedHtlcInfoCleanerConfig = RevokedHtlcInfoCleaner.Config(10, 100 millis),
402-
localReputationConfig = ReputationConfig(2000000 msat, 20 seconds, 200),
402+
localReputationConfig = ReputationConfig(2 days, 20 seconds, 200),
403403
)
404404

405405
def channelParams: LocalParams = OpenChannelInterceptor.makeChannelParams(

eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationRecorderSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class ReputationRecorderSpec extends ScalaTestWithActorTestKit(ConfigFactory.loa
3636
case class FixtureParam(config: ReputationConfig, reputationRecorder: ActorRef[Command], replyTo: TestProbe[Confidence])
3737

3838
override def withFixture(test: OneArgTest): Outcome = {
39-
val config = ReputationConfig(1000000000 msat, 10 seconds, 2)
39+
val config = ReputationConfig(1 day, 10 seconds, 2)
4040
val replyTo = TestProbe[Confidence]("confidence")
4141
val reputationRecorder = testKit.spawn(ReputationRecorder(config, Map.empty))
4242
withFixture(test.toNoArgTest(FixtureParam(config, reputationRecorder.ref, replyTo)))

eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationSpec.scala

Lines changed: 44 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -28,64 +28,54 @@ class ReputationSpec extends AnyFunSuite {
2828
val (uuid1, uuid2, uuid3, uuid4, uuid5, uuid6, uuid7) = (UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID())
2929

3030
test("basic") {
31-
var r = Reputation.init(ReputationConfig(1000000000 msat, 1 second, 2))
32-
r = r.attempt(uuid1, 10000 msat)
33-
assert(r.confidence() == 0)
34-
r = r.record(uuid1, isSuccess = true)
35-
r = r.attempt(uuid2, 10000 msat)
36-
assert(r.confidence() === (1.0 / 3) +- 0.001)
37-
r = r.attempt(uuid3, 10000 msat)
38-
assert(r.confidence() === (1.0 / 5) +- 0.001)
39-
r = r.record(uuid2, isSuccess = true)
40-
r = r.record(uuid3, isSuccess = true)
41-
assert(r.confidence() == 1)
42-
r = r.attempt(uuid4, 1 msat)
43-
assert(r.confidence() === 1.0 +- 0.001)
44-
r = r.attempt(uuid5, 40000 msat)
45-
assert(r.confidence() === (3.0 / 11) +- 0.001)
46-
r = r.attempt(uuid6, 10000 msat)
47-
assert(r.confidence() === (3.0 / 13) +- 0.001)
48-
r = r.cancel(uuid5)
49-
assert(r.confidence() === (3.0 / 5) +- 0.001)
50-
r = r.record(uuid6, isSuccess = false)
51-
assert(r.confidence() === (3.0 / 4) +- 0.001)
52-
r = r.attempt(uuid7, 10000 msat)
53-
assert(r.confidence() === (3.0 / 6) +- 0.001)
31+
val r0 = Reputation.init(ReputationConfig(1 day, 1 second, 2))
32+
val (r1, c1) = r0.attempt(uuid1, 10000 msat)
33+
assert(c1 == 0)
34+
val r2 = r1.record(uuid1, isSuccess = true)
35+
val (r3, c3) = r2.attempt(uuid2, 10000 msat)
36+
assert(c3 === (1.0 / 3) +- 0.001)
37+
val (r4, c4) = r3.attempt(uuid3, 10000 msat)
38+
assert(c4 === (1.0 / 5) +- 0.001)
39+
val r5 = r4.record(uuid2, isSuccess = true)
40+
val r6 = r5.record(uuid3, isSuccess = true)
41+
val (r7, c7) = r6.attempt(uuid4, 1 msat)
42+
assert(c7 === 1.0 +- 0.001)
43+
val (r8, c8) = r7.attempt(uuid5, 40000 msat)
44+
assert(c8 === (3.0 / 11) +- 0.001)
45+
val (r9, c9) = r8.attempt(uuid6, 10000 msat)
46+
assert(c9 === (3.0 / 13) +- 0.001)
47+
val r10 = r9.cancel(uuid5)
48+
val r11 = r10.record(uuid6, isSuccess = false)
49+
val (_, c12) = r11.attempt(uuid7, 10000 msat)
50+
assert(c12 === (3.0 / 6) +- 0.001)
5451
}
5552

5653
test("long HTLC") {
57-
var r = Reputation.init(ReputationConfig(1000000000 msat, 1 second, 10))
58-
r = r.attempt(uuid1, 100000 msat)
59-
assert(r.confidence() == 0)
60-
r = r.record(uuid1, isSuccess = true)
61-
assert(r.confidence() == 1)
62-
r = r.attempt(uuid2, 1000 msat, TimestampMilli(0))
63-
assert(r.confidence(TimestampMilli(0)) === (10.0 / 11) +- 0.001)
64-
assert(r.confidence(TimestampMilli(0) + 100.seconds) == 0.5)
65-
r = r.record(uuid2, isSuccess = false, now = TimestampMilli(0) + 100.seconds)
66-
assert(r.confidence() == 0.5)
54+
val r0 = Reputation.init(ReputationConfig(1000 day, 1 second, 10))
55+
val (r1, c1) = r0.attempt(uuid1, 100000 msat, TimestampMilli(0))
56+
assert(c1 == 0)
57+
val r2 = r1.record(uuid1, isSuccess = true, now = TimestampMilli(0))
58+
val (r3, c3) = r2.attempt(uuid2, 1000 msat, TimestampMilli(0))
59+
assert(c3 === (10.0 / 11) +- 0.001)
60+
val r4 = r3.record(uuid2, isSuccess = false, now = TimestampMilli(0) + 100.seconds)
61+
val (_, c5) = r4.attempt(uuid3, 0 msat, now = TimestampMilli(0) + 100.seconds)
62+
assert(c5 === 0.5 +- 0.001)
6763
}
6864

69-
test("max weight") {
70-
var r = Reputation.init(ReputationConfig(100 msat, 1 second, 10))
71-
// build perfect reputation
72-
for(i <- 1 to 100){
73-
val uuid = UUID.randomUUID()
74-
r = r.attempt(uuid, 10 msat)
75-
r = r.record(uuid, isSuccess = true)
76-
}
77-
assert(r.confidence() == 1)
78-
r = r.attempt(uuid1, 1 msat)
79-
assert(r.confidence() === (100.0 / 110) +- 0.001)
80-
r = r.record(uuid1, isSuccess = false)
81-
assert(r.confidence() === (100.0 / 101) +- 0.001)
82-
r = r.attempt(uuid2, 1 msat)
83-
assert(r.confidence() === (100.0 / 101) * (100.0 / 110) +- 0.001)
84-
r = r.record(uuid2, isSuccess = false)
85-
assert(r.confidence() === (100.0 / 101) * (100.0 / 101) +- 0.001)
86-
r = r.attempt(uuid3, 1 msat)
87-
assert(r.confidence() === (100.0 / 101) * (100.0 / 101) * (100.0 / 110) +- 0.001)
88-
r = r.record(uuid3, isSuccess = false)
89-
assert(r.confidence() === (100.0 / 101) * (100.0 / 101) * (100.0 / 101) +- 0.001)
65+
test("exponential decay") {
66+
val r0 = Reputation.init(ReputationConfig(100 seconds, 1 second, 1))
67+
val (r1, _) = r0.attempt(uuid1, 1000 msat, TimestampMilli(0))
68+
val r2 = r1.record(uuid1, isSuccess = true, now = TimestampMilli(0))
69+
val (r3, c3) = r2.attempt(uuid2, 1000 msat, TimestampMilli(0))
70+
assert(c3 == 1.0 / 2)
71+
val r4 = r3.record(uuid2, isSuccess = true, now = TimestampMilli(0))
72+
val (r5, c5) = r4.attempt(uuid3, 1000 msat, TimestampMilli(0))
73+
assert(c5 == 2.0 / 3)
74+
val r6 = r5.record(uuid3, isSuccess = true, now = TimestampMilli(0))
75+
val (r7, c7) = r6.attempt(uuid4, 1000 msat, TimestampMilli(0) + 100.seconds)
76+
assert(c7 == 1.5 / 2.5)
77+
val r8 = r7.cancel(uuid4)
78+
val (_, c9) = r8.attempt(uuid5, 1000 msat, TimestampMilli(0) + 1.hour)
79+
assert(c9 < 0.000001)
9080
}
9181
}

0 commit comments

Comments
 (0)