Skip to content

Commit 5703cd4

Browse files
Use actual CLTV delta for reputation (#3134)
When computing reputation, instead of using a fixed multiplier for pending HTLCs, we now use the CLTV expiry of the HTLC (assuming a new block is mined every 10 minutes). The reason for the fixed multiplier was that a HTLC with the maximum CLTV delta could stay pending for several orders of magnitude longer than a regular HTLC and would have an oversized impact on the reputation. We mitigate this by increasing the expected settlement time (from a few seconds to a few minutes), using more historical data (increasing the half-life), and counting on the fact that most HTLCs will have a CLTV expiry a lot lower than the maximum.
1 parent af3cd55 commit 5703cd4

File tree

11 files changed

+161
-141
lines changed

11 files changed

+161
-141
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,9 @@ eclair.relay.peer-reputation {
4343
// value, as described by https://github.com/lightning/blips/blob/master/blip-0004.md,
4444
enabled = true
4545
// Reputation decays with the following half life to emphasize recent behavior.
46-
half-life = 15 days
46+
half-life = 30 days
4747
// Payments that stay pending for longer than this get penalized
48-
max-relay-duration = 12 seconds
49-
// Pending payments are counted as failed, and because they could potentially stay pending for a very long time,
50-
// the following multiplier is applied.
51-
pending-multiplier = 200 // A pending payment counts as two hundred failed ones.
48+
max-relay-duration = 5 minutes
5249
}
5350
```
5451

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -259,14 +259,9 @@ eclair {
259259
// value, as described by https://github.com/lightning/blips/blob/master/blip-0004.md,
260260
enabled = true
261261
// Reputation decays with the following half life to emphasize recent behavior.
262-
half-life = 15 days
262+
half-life = 30 days
263263
// Payments that stay pending for longer than this get penalized.
264-
max-relay-duration = 12 seconds
265-
// Pending payments are counted as failed, and because they could potentially stay pending for a very long time,
266-
// the following multiplier is applied. We want it to be as close as possible to the true cost of a worst case
267-
// HTLC (max-cltv-delta / max-relay-duration, around 100000 with default parameters) while still being comparable
268-
// to the number of HTLCs received per peer during twice the half life.
269-
pending-multiplier = 200 // A pending payment counts as two hundred failed ones.
264+
max-relay-duration = 5 minutes
270265
}
271266
}
272267

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,6 @@ object NodeParams extends Logging {
647647
enabled = config.getBoolean("relay.peer-reputation.enabled"),
648648
halfLife = FiniteDuration(config.getDuration("relay.peer-reputation.half-life").getSeconds, TimeUnit.SECONDS),
649649
maxRelayDuration = FiniteDuration(config.getDuration("relay.peer-reputation.max-relay-duration").getSeconds, TimeUnit.SECONDS),
650-
pendingMultiplier = config.getDouble("relay.peer-reputation.pending-multiplier"),
651650
),
652651
),
653652
db = database,

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
517517
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.aliases, commitments1, d.lastAnnouncement_opt))
518518
val relayFee = nodeFee(d.channelUpdate.relayFees, add.amountMsat)
519519
context.system.eventStream.publish(OutgoingHtlcAdded(add, remoteNodeId, c.origin.upstream, relayFee))
520-
log.info("OutgoingHtlcAdded: channelId={}, id={}, endorsement={}, remoteNodeId={}, upstream={}, fee={}", Array(add.channelId.toHex, add.id, add.endorsement, remoteNodeId.toHex, c.origin.upstream.toString, relayFee))
520+
log.info("OutgoingHtlcAdded: channelId={}, id={}, endorsement={}, remoteNodeId={}, upstream={}, fee={}, now={}, blockHeight={}, expiry={}", Array(add.channelId.toHex, add.id, add.endorsement, remoteNodeId.toHex, c.origin.upstream.toString, relayFee, TimestampMilli.now().toLong, nodeParams.currentBlockHeight.toLong, add.cltvExpiry))
521521
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending add
522522
case Left(cause) => handleAddHtlcCommandError(c, cause, Some(d.channelUpdate))
523523
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ object ChannelRelay {
7979
val upstream = Upstream.Hot.Channel(r.add.removeUnknownTlvs(), r.receivedAt, originNode, incomingChannelOccupancy)
8080
reputationRecorder_opt match {
8181
case Some(reputationRecorder) =>
82-
reputationRecorder ! GetConfidence(context.messageAdapter(WrappedReputationScore(_)), upstream, channels.values.headOption.map(_.nextNodeId), r.relayFeeMsat)
82+
reputationRecorder ! GetConfidence(context.messageAdapter(WrappedReputationScore(_)), upstream, channels.values.headOption.map(_.nextNodeId), r.relayFeeMsat, nodeParams.currentBlockHeight, r.outgoingCltv)
8383
case None =>
8484
context.self ! WrappedReputationScore(Reputation.Score.fromEndorsement(r.add.endorsement))
8585
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
7777
case Event(RouteResponse(route +: _), WaitingForRoute(request, failures, ignore)) =>
7878
log.info(s"route found: attempt=${failures.size + 1}/${request.maxAttempts} route=${route.printNodes()} channels=${route.printChannels()}")
7979
reputationRecorder_opt match {
80-
case Some(reputationRecorder) => reputationRecorder ! GetConfidence(self, cfg.upstream, Some(route.hops.head.nextNodeId), route.hops.head.fee(request.amount))
80+
case Some(reputationRecorder) =>
81+
val cltvExpiry = route.fullRoute.map(_.cltvExpiryDelta).foldLeft(request.recipient.expiry)(_ + _)
82+
reputationRecorder ! GetConfidence(self, cfg.upstream, Some(route.hops.head.nextNodeId), route.hops.head.fee(request.amount), nodeParams.currentBlockHeight, cltvExpiry)
8183
case None =>
8284
val endorsement = cfg.upstream match {
8385
case Hot.Channel(add, _, _, _) => add.endorsement

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

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ import fr.acinq.bitcoin.scalacompat.ByteVector32
2020
import fr.acinq.eclair.channel.{ChannelJammingException, ChannelParams, Commitments, IncomingConfidenceTooLow, OutgoingConfidenceTooLow, TooManySmallHtlcs}
2121
import fr.acinq.eclair.transactions.DirectedHtlc
2222
import fr.acinq.eclair.wire.protocol.UpdateAddHtlc
23-
import fr.acinq.eclair.{MilliSatoshi, TimestampMilli}
23+
import fr.acinq.eclair.{BlockHeight, CltvExpiry, MilliSatoshi, TimestampMilli}
2424

25-
import scala.concurrent.duration.FiniteDuration
25+
import scala.concurrent.duration.{DurationInt, FiniteDuration}
2626

2727
/**
2828
* Reputation score per endorsement level.
@@ -34,31 +34,36 @@ import scala.concurrent.duration.FiniteDuration
3434
case class PastScore(weight: Double, score: Double, lastSettlementAt: TimestampMilli)
3535

3636
/** We're relaying that HTLC and are waiting for it to settle. */
37-
case class PendingHtlc(fee: MilliSatoshi, endorsement: Int, startedAt: TimestampMilli) {
38-
def weight(now: TimestampMilli, minDuration: FiniteDuration, multiplier: Double): Double = {
39-
val duration = now - startedAt
40-
fee.toLong.toDouble * (duration / minDuration).max(multiplier)
37+
case class PendingHtlc(fee: MilliSatoshi, endorsement: Int, startedAt: TimestampMilli, expiry: CltvExpiry) {
38+
def weight(now: TimestampMilli, minDuration: FiniteDuration, currentBlockHeight: BlockHeight): Double = {
39+
val alreadyPending = now - startedAt
40+
val untilExpiry = (expiry.toLong - currentBlockHeight.toLong) * 10.minutes
41+
val duration = alreadyPending + untilExpiry
42+
fee.toLong.toDouble * (duration / minDuration)
4143
}
4244
}
4345

4446
case class HtlcId(channelId: ByteVector32, id: Long)
4547

48+
case object HtlcId {
49+
def apply(add: UpdateAddHtlc): HtlcId = HtlcId(add.channelId, add.id)
50+
}
51+
4652
/**
4753
* Local reputation for a given node.
4854
*
4955
* @param pastScores Scores from past HTLCs for each endorsement level.
5056
* @param pending Set of pending HTLCs.
5157
* @param halfLife Half life for the exponential moving average.
5258
* @param maxRelayDuration Duration after which HTLCs are penalized for staying pending too long.
53-
* @param pendingMultiplier How much to penalize pending HTLCs.
5459
*/
55-
case class Reputation(pastScores: Map[Int, PastScore], pending: Map[HtlcId, PendingHtlc], halfLife: FiniteDuration, maxRelayDuration: FiniteDuration, pendingMultiplier: Double) {
60+
case class Reputation(pastScores: Map[Int, PastScore], pending: Map[HtlcId, PendingHtlc], halfLife: FiniteDuration, maxRelayDuration: FiniteDuration) {
5661
private def decay(now: TimestampMilli, lastSettlementAt: TimestampMilli): Double = scala.math.pow(0.5, (now - lastSettlementAt) / halfLife)
5762

5863
/**
5964
* Estimate the confidence that a payment will succeed.
6065
*/
61-
def getConfidence(fee: MilliSatoshi, endorsement: Int, now: TimestampMilli = TimestampMilli.now()): Double = {
66+
def getConfidence(fee: MilliSatoshi, endorsement: Int, currentBlockHeight: BlockHeight, expiry: CltvExpiry, now: TimestampMilli = TimestampMilli.now()): Double = {
6267
val weights = Array.fill(Reputation.endorsementLevels)(0.0)
6368
val scores = Array.fill(Reputation.endorsementLevels)(0.0)
6469
for (e <- 0 until Reputation.endorsementLevels) {
@@ -67,9 +72,9 @@ case class Reputation(pastScores: Map[Int, PastScore], pending: Map[HtlcId, Pend
6772
scores(e) += d * pastScores(e).score
6873
}
6974
for (p <- pending.values) {
70-
weights(p.endorsement) += p.weight(now, maxRelayDuration, pendingMultiplier)
75+
weights(p.endorsement) += p.weight(now, maxRelayDuration, currentBlockHeight)
7176
}
72-
weights(endorsement) += fee.toLong.toDouble * pendingMultiplier
77+
weights(endorsement) += PendingHtlc(fee, endorsement, now, expiry).weight(now, maxRelayDuration, currentBlockHeight)
7378
/*
7479
Higher endorsement buckets may have fewer payments which makes the weight of pending payments disproportionately
7580
important. To counter this effect, we try adding payments from the lower buckets to see if it gives us a higher
@@ -91,17 +96,23 @@ case class Reputation(pastScores: Map[Int, PastScore], pending: Map[HtlcId, Pend
9196
/**
9297
* Register a pending relay.
9398
*/
94-
def addPendingHtlc(htlcId: HtlcId, fee: MilliSatoshi, endorsement: Int, now: TimestampMilli = TimestampMilli.now()): Reputation =
95-
copy(pending = pending + (htlcId -> PendingHtlc(fee, endorsement, now)))
99+
def addPendingHtlc(add: UpdateAddHtlc, fee: MilliSatoshi, endorsement: Int, now: TimestampMilli = TimestampMilli.now()): Reputation =
100+
copy(pending = pending + (HtlcId(add) -> PendingHtlc(fee, endorsement, now, add.cltvExpiry)))
96101

97102
/**
98103
* When a HTLC is settled, we record whether it succeeded and how long it took.
99104
*/
100105
def settlePendingHtlc(htlcId: HtlcId, isSuccess: Boolean, now: TimestampMilli = TimestampMilli.now()): Reputation = {
101106
val newScores = pending.get(htlcId).map(p => {
102107
val d = decay(now, pastScores(p.endorsement).lastSettlementAt)
103-
val newWeight = d * pastScores(p.endorsement).weight + p.weight(now, maxRelayDuration, if (isSuccess) 1.0 else 0.0)
104-
val newScore = d * pastScores(p.endorsement).score + (if (isSuccess) p.fee.toLong.toDouble else 0)
108+
val duration = now - p.startedAt
109+
val (weight, score) = if (isSuccess) {
110+
(p.fee.toLong.toDouble * (duration / maxRelayDuration).max(1.0), p.fee.toLong.toDouble)
111+
} else {
112+
(p.fee.toLong.toDouble * (duration / maxRelayDuration), 0.0)
113+
}
114+
val newWeight = d * pastScores(p.endorsement).weight + weight
115+
val newScore = d * pastScores(p.endorsement).score + score
105116
pastScores + (p.endorsement -> PastScore(newWeight, newScore, now))
106117
}).getOrElse(pastScores)
107118
copy(pending = pending - htlcId, pastScores = newScores)
@@ -112,9 +123,9 @@ object Reputation {
112123
val endorsementLevels = 8
113124
val maxEndorsement = endorsementLevels - 1
114125

115-
case class Config(enabled: Boolean, halfLife: FiniteDuration, maxRelayDuration: FiniteDuration, pendingMultiplier: Double)
126+
case class Config(enabled: Boolean, halfLife: FiniteDuration, maxRelayDuration: FiniteDuration)
116127

117-
def init(config: Config): Reputation = Reputation(Map.empty.withDefaultValue(PastScore(0.0, 0.0, TimestampMilli.min)), Map.empty, config.halfLife, config.maxRelayDuration, config.pendingMultiplier)
128+
def init(config: Config): Reputation = Reputation(Map.empty.withDefaultValue(PastScore(0.0, 0.0, TimestampMilli.min)), Map.empty, config.halfLife, config.maxRelayDuration)
118129

119130
/**
120131
* @param incomingConfidence Confidence that the outgoing HTLC will succeed given the reputation of the incoming peer

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import akka.actor.typed.eventstream.EventStream
2020
import akka.actor.typed.scaladsl.Behaviors
2121
import akka.actor.typed.{ActorRef, Behavior}
2222
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
23-
import fr.acinq.eclair.MilliSatoshi
23+
import fr.acinq.eclair.{BlockHeight, CltvExpiry, MilliSatoshi}
2424
import fr.acinq.eclair.channel.Upstream.Hot
2525
import fr.acinq.eclair.channel.{OutgoingHtlcAdded, OutgoingHtlcFailed, OutgoingHtlcFulfilled, OutgoingHtlcSettled, Upstream}
2626
import fr.acinq.eclair.reputation.ReputationRecorder._
@@ -31,7 +31,7 @@ import scala.collection.mutable
3131
object ReputationRecorder {
3232
// @formatter:off
3333
sealed trait Command
34-
case class GetConfidence(replyTo: ActorRef[Reputation.Score], upstream: Upstream.Hot, downstream_opt: Option[PublicKey], fee: MilliSatoshi) extends Command
34+
case class GetConfidence(replyTo: ActorRef[Reputation.Score], upstream: Upstream.Hot, downstream_opt: Option[PublicKey], fee: MilliSatoshi, currentBlockHeight: BlockHeight, expiry: CltvExpiry) extends Command
3535
private case class WrappedOutgoingHtlcAdded(added: OutgoingHtlcAdded) extends Command
3636
private case class WrappedOutgoingHtlcSettled(settled: OutgoingHtlcSettled) extends Command
3737
// @formatter:on
@@ -60,17 +60,17 @@ class ReputationRecorder(config: Reputation.Config) {
6060

6161
def run(): Behavior[Command] =
6262
Behaviors.receiveMessage {
63-
case GetConfidence(replyTo, _: Upstream.Local, _, _) =>
63+
case GetConfidence(replyTo, _: Upstream.Local, _, _, _, _) =>
6464
replyTo ! Reputation.Score.max
6565
Behaviors.same
6666

67-
case GetConfidence(replyTo, upstream: Upstream.Hot.Channel, downstream_opt, fee) =>
68-
val incomingConfidence = incomingReputations.get(upstream.receivedFrom).map(_.getConfidence(fee, upstream.add.endorsement)).getOrElse(0.0)
69-
val outgoingConfidence = downstream_opt.flatMap(outgoingReputations.get).map(_.getConfidence(fee, Reputation.toEndorsement(incomingConfidence))).getOrElse(0.0)
67+
case GetConfidence(replyTo, upstream: Upstream.Hot.Channel, downstream_opt, fee, currentBlockHeight, expiry) =>
68+
val incomingConfidence = incomingReputations.get(upstream.receivedFrom).map(_.getConfidence(fee, upstream.add.endorsement, currentBlockHeight, expiry)).getOrElse(0.0)
69+
val outgoingConfidence = downstream_opt.flatMap(outgoingReputations.get).map(_.getConfidence(fee, Reputation.toEndorsement(incomingConfidence), currentBlockHeight, expiry)).getOrElse(0.0)
7070
replyTo ! Reputation.Score(incomingConfidence, outgoingConfidence)
7171
Behaviors.same
7272

73-
case GetConfidence(replyTo, upstream: Upstream.Hot.Trampoline, downstream_opt, totalFee) =>
73+
case GetConfidence(replyTo, upstream: Upstream.Hot.Trampoline, downstream_opt, totalFee, currentBlockHeight, expiry) =>
7474
val incomingConfidence =
7575
upstream.received
7676
.groupMapReduce(_.receivedFrom)(r => (r.add.amountMsat, r.add.endorsement)) {
@@ -79,29 +79,29 @@ class ReputationRecorder(config: Reputation.Config) {
7979
.map {
8080
case (nodeId, (amount, endorsement)) =>
8181
val fee = amount * totalFee.toLong / upstream.amountIn.toLong
82-
incomingReputations.get(nodeId).map(_.getConfidence(fee, endorsement)).getOrElse(0.0)
82+
incomingReputations.get(nodeId).map(_.getConfidence(fee, endorsement, currentBlockHeight, expiry)).getOrElse(0.0)
8383
}
8484
.min
85-
val outgoingConfidence = downstream_opt.flatMap(outgoingReputations.get).map(_.getConfidence(totalFee, Reputation.toEndorsement(incomingConfidence))).getOrElse(0.0)
85+
val outgoingConfidence = downstream_opt.flatMap(outgoingReputations.get).map(_.getConfidence(totalFee, Reputation.toEndorsement(incomingConfidence), currentBlockHeight, expiry)).getOrElse(0.0)
8686
replyTo ! Reputation.Score(incomingConfidence, outgoingConfidence)
8787
Behaviors.same
8888

8989
case WrappedOutgoingHtlcAdded(OutgoingHtlcAdded(add, remoteNodeId, upstream, fee)) =>
90-
val htlcId = HtlcId(add.channelId, add.id)
90+
val htlcId = HtlcId(add)
9191
upstream match {
9292
case channel: Hot.Channel =>
93-
incomingReputations(channel.receivedFrom) = incomingReputations(channel.receivedFrom).addPendingHtlc(htlcId, fee, channel.add.endorsement)
93+
incomingReputations(channel.receivedFrom) = incomingReputations(channel.receivedFrom).addPendingHtlc(add, fee, channel.add.endorsement)
9494
case trampoline: Hot.Trampoline =>
9595
trampoline.received
9696
.groupMapReduce(_.receivedFrom)(r => (r.add.amountMsat, r.add.endorsement)) {
9797
case ((amount1, endorsement1), (amount2, endorsement2)) => (amount1 + amount2, endorsement1 min endorsement2)
9898
}
9999
.foreach { case (nodeId, (amount, endorsement)) =>
100-
incomingReputations(nodeId) = incomingReputations(nodeId).addPendingHtlc(htlcId, fee * amount.toLong / trampoline.amountIn.toLong, endorsement)
100+
incomingReputations(nodeId) = incomingReputations(nodeId).addPendingHtlc(add, fee * amount.toLong / trampoline.amountIn.toLong, endorsement)
101101
}
102102
case _: Upstream.Local => ()
103103
}
104-
outgoingReputations(remoteNodeId) = outgoingReputations(remoteNodeId).addPendingHtlc(htlcId, fee, add.endorsement)
104+
outgoingReputations(remoteNodeId) = outgoingReputations(remoteNodeId).addPendingHtlc(add, fee, add.endorsement)
105105
pending(htlcId) = PendingHtlc(add, upstream, remoteNodeId)
106106
Behaviors.same
107107

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ object TestConstants {
180180
feeProportionalMillionths = 30),
181181
enforcementDelay = 10 minutes,
182182
asyncPaymentsParams = AsyncPaymentsParams(1008, CltvExpiryDelta(144)),
183-
peerReputationConfig = Reputation.Config(enabled = true, 1 day, 10 seconds, 100),
183+
peerReputationConfig = Reputation.Config(enabled = true, 1 day, 10 minutes),
184184
),
185185
db = TestDatabases.inMemoryDb(),
186186
autoReconnect = false,
@@ -372,7 +372,7 @@ object TestConstants {
372372
feeProportionalMillionths = 30),
373373
enforcementDelay = 10 minutes,
374374
asyncPaymentsParams = AsyncPaymentsParams(1008, CltvExpiryDelta(144)),
375-
peerReputationConfig = Reputation.Config(enabled = true, 2 day, 20 seconds, 200),
375+
peerReputationConfig = Reputation.Config(enabled = true, 2 day, 20 minutes),
376376
),
377377
db = TestDatabases.inMemoryDb(),
378378
autoReconnect = false,

0 commit comments

Comments
 (0)