From ff97580e89544a0c1278d465736d1d736569ae37 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Thu, 4 Jul 2024 16:55:21 +0200 Subject: [PATCH] Documentation --- docs/release-notes/eclair-vnext.md | 19 ++++ eclair-core/src/main/resources/reference.conf | 7 +- .../scala/fr/acinq/eclair/NodeParams.scala | 8 +- .../acinq/eclair/reputation/Reputation.scala | 55 ++++++++--- .../reputation/ReputationRecorder.scala | 9 +- .../scala/fr/acinq/eclair/TestConstants.scala | 4 +- .../reputation/ReputationRecorderSpec.scala | 2 +- .../eclair/reputation/ReputationSpec.scala | 98 +++++++++---------- 8 files changed, 118 insertions(+), 84 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 9dec4af576..02750da348 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -17,6 +17,25 @@ To enable CoinGrinder at all fee rates and prevent the automatic consolidation o consolidatefeerate=0 ``` +### Local reputation and HTLC endorsement + +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. +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. +The reputation is per incoming node and endorsement level. +The confidence that the HTLC will be fulfilled is transmitted to the next node using the endorsement TLV of the `update_add_htlc` message. + +To configure, edit `eclair.conf`: +```eclair.conf +eclair.local-reputation { + # Reputation decays with the following half life to emphasize recent behavior. + half-life = 1 week + # HTLCs that stay pending for longer than this get penalized + good-htlc-duration = 12 seconds + # How much to penalize pending HLTCs. A pending HTLC is considered equivalent to this many fast-failing HTLCs. + pending-multiplier = 1000 +} +``` + ### API changes diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 35e420595c..d3a30bc83b 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -547,8 +547,11 @@ eclair { } local-reputation { - max-weight-msat = 100000000000 # 1 BTC - min-duration = 12 seconds + # Reputation decays with the following half life to emphasize recent behavior. + half-life = 1 week + # HTLCs that stay pending for longer than this get penalized + good-htlc-duration = 12 seconds # 95% of successful payments settle in less than 12 seconds, only the slowest 5% will be penalized. + # How much to penalize pending HLTCs. A pending HTLC is considered equivalent to this many fast-failing HTLCs. pending-multiplier = 1000 } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index eb8565a068..88826b70e2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -613,10 +613,10 @@ object NodeParams extends Logging { batchSize = config.getInt("db.revoked-htlc-info-cleaner.batch-size"), interval = FiniteDuration(config.getDuration("db.revoked-htlc-info-cleaner.interval").getSeconds, TimeUnit.SECONDS) ), - localReputationConfig = ReputationConfig(MilliSatoshi( - config.getLong("local-reputation.max-weight-msat")), - FiniteDuration(config.getDuration("local-reputation.min-duration").getSeconds, TimeUnit.SECONDS), - config.getDouble("local-reputation.pending-multiplier") + localReputationConfig = ReputationConfig( + FiniteDuration(config.getDuration("local-reputation.half-life").getSeconds, TimeUnit.SECONDS), + FiniteDuration(config.getDuration("local-reputation.good-htlc-duration").getSeconds, TimeUnit.SECONDS), + config.getDouble("local-reputation.pending-multiplier"), ), ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/reputation/Reputation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/reputation/Reputation.scala index 2bf4044d2f..1cbbfd291e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/reputation/Reputation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/reputation/Reputation.scala @@ -22,38 +22,61 @@ import fr.acinq.eclair.{MilliSatoshi, TimestampMilli} import java.util.UUID import scala.concurrent.duration.FiniteDuration -case class Reputation(pastWeight: Double, pending: Map[UUID, Pending], pastScore: Double, maxWeight: Double, minDuration: FiniteDuration, pendingMultiplier: Double) { - private def pendingWeight(now: TimestampMilli): Double = pending.values.map(_.weight(now, minDuration, pendingMultiplier)).sum +/** Local reputation per incoming node and endorsement level + * + * @param pastWeight How much fees we would have collected in the past if all HTLCs had succeeded (exponential moving average). + * @param pastScore How much fees we have collected in the past (exponential moving average). + * @param lastSettlementAt Timestamp of the last recorded HTLC settlement. + * @param pending Set of pending HTLCs. + * @param halfLife Half life for the exponential moving average. + * @param goodDuration Duration after which HTLCs are penalized for staying pending too long. + * @param pendingMultiplier How much to penalize pending HTLCs. + */ +case class Reputation(pastWeight: Double, pastScore: Double, lastSettlementAt: TimestampMilli, pending: Map[UUID, Pending], halfLife: FiniteDuration, goodDuration: FiniteDuration, pendingMultiplier: Double) { + private def decay(now: TimestampMilli): Double = scala.math.pow(0.5, (now - lastSettlementAt) / halfLife) - def confidence(now: TimestampMilli = TimestampMilli.now()): Double = pastScore / (pastWeight + pendingWeight(now)) + private def pendingWeight(now: TimestampMilli): Double = pending.values.map(_.weight(now, goodDuration, pendingMultiplier)).sum - def attempt(relayId: UUID, fee: MilliSatoshi, startedAt: TimestampMilli = TimestampMilli.now()): Reputation = - copy(pending = pending + (relayId -> Pending(fee, startedAt))) + /** Register a HTLC to relay and estimate the confidence that it will succeed. + * @return (updated reputation, confidence) + */ + def attempt(relayId: UUID, fee: MilliSatoshi, now: TimestampMilli = TimestampMilli.now()): (Reputation, Double) = { + val d = decay(now) + val newReputation = copy(pending = pending + (relayId -> Pending(fee, now))) + val confidence = d * pastScore / (d * pastWeight + newReputation.pendingWeight(now)) + (newReputation, confidence) + } + /** Mark a previously registered HTLC as failed without trying to relay it (usually because its confidence was too low). + * @return updated reputation + */ def cancel(relayId: UUID): Reputation = copy(pending = pending - relayId) + /** When a HTLC is settled, we record whether it succeeded and how long it took. + * + * @param feeOverride When relaying trampoline payments, the actual fee is only known when the payment succeeds. This + * is used instead of the fee upper bound that was known when first attempting the relay. + * @return updated reputation + */ def record(relayId: UUID, isSuccess: Boolean, feeOverride: Option[MilliSatoshi] = None, now: TimestampMilli = TimestampMilli.now()): Reputation = { + val d = decay(now) var p = pending.getOrElse(relayId, Pending(MilliSatoshi(0), now)) feeOverride.foreach(fee => p = p.copy(fee = fee)) - val newWeight = pastWeight + p.weight(now, minDuration, 1.0) - val newScore = if (isSuccess) pastScore + p.fee.toLong.toDouble else pastScore - if (newWeight > maxWeight) { - Reputation(maxWeight, pending - relayId, newScore * maxWeight / newWeight, maxWeight, minDuration, pendingMultiplier) - } else { - Reputation(newWeight, pending - relayId, newScore, maxWeight, minDuration, pendingMultiplier) - } + val newWeight = d * pastWeight + p.weight(now, goodDuration, 1.0) + val newScore = d * pastScore + (if (isSuccess) p.fee.toLong.toDouble else 0) + Reputation(newWeight, newScore, now, pending - relayId, halfLife, goodDuration, pendingMultiplier) } } object Reputation { case class Pending(fee: MilliSatoshi, startedAt: TimestampMilli) { - def weight(now: TimestampMilli, minDuration: FiniteDuration, pendingMultiplier: Double): Double = { + def weight(now: TimestampMilli, minDuration: FiniteDuration, multiplier: Double): Double = { val duration = now - startedAt - fee.toLong.toDouble * (duration / minDuration).max(pendingMultiplier) + fee.toLong.toDouble * (duration / minDuration).max(multiplier) } } - case class ReputationConfig(maxWeight: MilliSatoshi, minDuration: FiniteDuration, pendingMultiplier: Double) + case class ReputationConfig(halfLife: FiniteDuration, goodDuration: FiniteDuration, pendingMultiplier: Double) - def init(config: ReputationConfig): Reputation = Reputation(0.0, Map.empty, 0.0, config.maxWeight.toLong.toDouble, config.minDuration, config.pendingMultiplier) + def init(config: ReputationConfig): Reputation = Reputation(0.0, 0.0, TimestampMilli.min, Map.empty, config.halfLife, config.goodDuration, config.pendingMultiplier) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/reputation/ReputationRecorder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/reputation/ReputationRecorder.scala index 47ce40100c..b10fd218f7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/reputation/ReputationRecorder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/reputation/ReputationRecorder.scala @@ -42,8 +42,8 @@ object ReputationRecorder { def apply(reputationConfig: ReputationConfig, reputations: Map[(PublicKey, Int), Reputation]): Behavior[Command] = { Behaviors.receiveMessage { case GetConfidence(replyTo, originNode, endorsement, relayId, fee) => - val updatedReputation = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).attempt(relayId, fee) - replyTo ! Confidence(updatedReputation.confidence()) + val (updatedReputation, confidence) = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).attempt(relayId, fee) + replyTo ! Confidence(confidence) ReputationRecorder(reputationConfig, reputations.updated((originNode, endorsement), updatedReputation)) case CancelRelay(originNode, endorsement, relayId) => val updatedReputation = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).cancel(relayId) @@ -53,9 +53,8 @@ object ReputationRecorder { ReputationRecorder(reputationConfig, reputations.updated((originNode, endorsement), updatedReputation)) case GetTrampolineConfidence(replyTo, fees, relayId) => val (confidence, updatedReputations) = fees.foldLeft((1.0, reputations)){case ((c, r), ((originNode, endorsement), fee)) => - val updatedReputation = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).attempt(relayId, fee) - val updatedConfidence = c.min(updatedReputation.confidence()) - (updatedConfidence, r.updated((originNode, endorsement), updatedReputation)) + val (updatedReputation, confidence) = reputations.getOrElse((originNode, endorsement), Reputation.init(reputationConfig)).attempt(relayId, fee) + (c.min(confidence), r.updated((originNode, endorsement), updatedReputation)) } replyTo ! Confidence(confidence) ReputationRecorder(reputationConfig, updatedReputations) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 4608ea84e6..aade5a14a6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -231,7 +231,7 @@ object TestConstants { ), purgeInvoicesInterval = None, revokedHtlcInfoCleanerConfig = RevokedHtlcInfoCleaner.Config(10, 100 millis), - localReputationConfig = ReputationConfig(1000000 msat, 10 seconds, 100), + localReputationConfig = ReputationConfig(1 day, 10 seconds, 100), ) def channelParams: LocalParams = OpenChannelInterceptor.makeChannelParams( @@ -399,7 +399,7 @@ object TestConstants { ), purgeInvoicesInterval = None, revokedHtlcInfoCleanerConfig = RevokedHtlcInfoCleaner.Config(10, 100 millis), - localReputationConfig = ReputationConfig(2000000 msat, 20 seconds, 200), + localReputationConfig = ReputationConfig(2 days, 20 seconds, 200), ) def channelParams: LocalParams = OpenChannelInterceptor.makeChannelParams( diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationRecorderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationRecorderSpec.scala index d53cd5b27e..310ee4fbab 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationRecorderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationRecorderSpec.scala @@ -36,7 +36,7 @@ class ReputationRecorderSpec extends ScalaTestWithActorTestKit(ConfigFactory.loa case class FixtureParam(config: ReputationConfig, reputationRecorder: ActorRef[Command], replyTo: TestProbe[Confidence]) override def withFixture(test: OneArgTest): Outcome = { - val config = ReputationConfig(1000000000 msat, 10 seconds, 2) + val config = ReputationConfig(1 day, 10 seconds, 2) val replyTo = TestProbe[Confidence]("confidence") val reputationRecorder = testKit.spawn(ReputationRecorder(config, Map.empty)) withFixture(test.toNoArgTest(FixtureParam(config, reputationRecorder.ref, replyTo))) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationSpec.scala index f919079359..9d369aacab 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/reputation/ReputationSpec.scala @@ -28,64 +28,54 @@ class ReputationSpec extends AnyFunSuite { val (uuid1, uuid2, uuid3, uuid4, uuid5, uuid6, uuid7) = (UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID()) test("basic") { - var r = Reputation.init(ReputationConfig(1000000000 msat, 1 second, 2)) - r = r.attempt(uuid1, 10000 msat) - assert(r.confidence() == 0) - r = r.record(uuid1, isSuccess = true) - r = r.attempt(uuid2, 10000 msat) - assert(r.confidence() === (1.0 / 3) +- 0.001) - r = r.attempt(uuid3, 10000 msat) - assert(r.confidence() === (1.0 / 5) +- 0.001) - r = r.record(uuid2, isSuccess = true) - r = r.record(uuid3, isSuccess = true) - assert(r.confidence() == 1) - r = r.attempt(uuid4, 1 msat) - assert(r.confidence() === 1.0 +- 0.001) - r = r.attempt(uuid5, 40000 msat) - assert(r.confidence() === (3.0 / 11) +- 0.001) - r = r.attempt(uuid6, 10000 msat) - assert(r.confidence() === (3.0 / 13) +- 0.001) - r = r.cancel(uuid5) - assert(r.confidence() === (3.0 / 5) +- 0.001) - r = r.record(uuid6, isSuccess = false) - assert(r.confidence() === (3.0 / 4) +- 0.001) - r = r.attempt(uuid7, 10000 msat) - assert(r.confidence() === (3.0 / 6) +- 0.001) + val r0 = Reputation.init(ReputationConfig(1 day, 1 second, 2)) + val (r1, c1) = r0.attempt(uuid1, 10000 msat) + assert(c1 == 0) + val r2 = r1.record(uuid1, isSuccess = true) + val (r3, c3) = r2.attempt(uuid2, 10000 msat) + assert(c3 === (1.0 / 3) +- 0.001) + val (r4, c4) = r3.attempt(uuid3, 10000 msat) + assert(c4 === (1.0 / 5) +- 0.001) + val r5 = r4.record(uuid2, isSuccess = true) + val r6 = r5.record(uuid3, isSuccess = true) + val (r7, c7) = r6.attempt(uuid4, 1 msat) + assert(c7 === 1.0 +- 0.001) + val (r8, c8) = r7.attempt(uuid5, 40000 msat) + assert(c8 === (3.0 / 11) +- 0.001) + val (r9, c9) = r8.attempt(uuid6, 10000 msat) + assert(c9 === (3.0 / 13) +- 0.001) + val r10 = r9.cancel(uuid5) + val r11 = r10.record(uuid6, isSuccess = false) + val (_, c12) = r11.attempt(uuid7, 10000 msat) + assert(c12 === (3.0 / 6) +- 0.001) } test("long HTLC") { - var r = Reputation.init(ReputationConfig(1000000000 msat, 1 second, 10)) - r = r.attempt(uuid1, 100000 msat) - assert(r.confidence() == 0) - r = r.record(uuid1, isSuccess = true) - assert(r.confidence() == 1) - r = r.attempt(uuid2, 1000 msat, TimestampMilli(0)) - assert(r.confidence(TimestampMilli(0)) === (10.0 / 11) +- 0.001) - assert(r.confidence(TimestampMilli(0) + 100.seconds) == 0.5) - r = r.record(uuid2, isSuccess = false, now = TimestampMilli(0) + 100.seconds) - assert(r.confidence() == 0.5) + val r0 = Reputation.init(ReputationConfig(1000 day, 1 second, 10)) + val (r1, c1) = r0.attempt(uuid1, 100000 msat, TimestampMilli(0)) + assert(c1 == 0) + val r2 = r1.record(uuid1, isSuccess = true, now = TimestampMilli(0)) + val (r3, c3) = r2.attempt(uuid2, 1000 msat, TimestampMilli(0)) + assert(c3 === (10.0 / 11) +- 0.001) + val r4 = r3.record(uuid2, isSuccess = false, now = TimestampMilli(0) + 100.seconds) + val (_, c5) = r4.attempt(uuid3, 0 msat, now = TimestampMilli(0) + 100.seconds) + assert(c5 === 0.5 +- 0.001) } - test("max weight") { - var r = Reputation.init(ReputationConfig(100 msat, 1 second, 10)) - // build perfect reputation - for(i <- 1 to 100){ - val uuid = UUID.randomUUID() - r = r.attempt(uuid, 10 msat) - r = r.record(uuid, isSuccess = true) - } - assert(r.confidence() == 1) - r = r.attempt(uuid1, 1 msat) - assert(r.confidence() === (100.0 / 110) +- 0.001) - r = r.record(uuid1, isSuccess = false) - assert(r.confidence() === (100.0 / 101) +- 0.001) - r = r.attempt(uuid2, 1 msat) - assert(r.confidence() === (100.0 / 101) * (100.0 / 110) +- 0.001) - r = r.record(uuid2, isSuccess = false) - assert(r.confidence() === (100.0 / 101) * (100.0 / 101) +- 0.001) - r = r.attempt(uuid3, 1 msat) - assert(r.confidence() === (100.0 / 101) * (100.0 / 101) * (100.0 / 110) +- 0.001) - r = r.record(uuid3, isSuccess = false) - assert(r.confidence() === (100.0 / 101) * (100.0 / 101) * (100.0 / 101) +- 0.001) + test("exponential decay") { + val r0 = Reputation.init(ReputationConfig(100 seconds, 1 second, 1)) + val (r1, _) = r0.attempt(uuid1, 1000 msat, TimestampMilli(0)) + val r2 = r1.record(uuid1, isSuccess = true, now = TimestampMilli(0)) + val (r3, c3) = r2.attempt(uuid2, 1000 msat, TimestampMilli(0)) + assert(c3 == 1.0 / 2) + val r4 = r3.record(uuid2, isSuccess = true, now = TimestampMilli(0)) + val (r5, c5) = r4.attempt(uuid3, 1000 msat, TimestampMilli(0)) + assert(c5 == 2.0 / 3) + val r6 = r5.record(uuid3, isSuccess = true, now = TimestampMilli(0)) + val (r7, c7) = r6.attempt(uuid4, 1000 msat, TimestampMilli(0) + 100.seconds) + assert(c7 == 1.5 / 2.5) + val r8 = r7.cancel(uuid4) + val (_, c9) = r8.attempt(uuid5, 1000 msat, TimestampMilli(0) + 1.hour) + assert(c9 < 0.000001) } } \ No newline at end of file