Skip to content

Commit 0548348

Browse files
committed
Use lower endorsement payments to increase confidence of higher endorsement payments
1 parent 5ec4b6b commit 0548348

File tree

3 files changed

+121
-105
lines changed

3 files changed

+121
-105
lines changed
Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023 ACINQ SAS
2+
* Copyright 2025 ACINQ SAS
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,76 +17,95 @@
1717
package fr.acinq.eclair.reputation
1818

1919
import fr.acinq.bitcoin.scalacompat.ByteVector32
20-
import fr.acinq.eclair.reputation.Reputation.HtlcId
2120
import fr.acinq.eclair.{MilliSatoshi, TimestampMilli}
2221

22+
import scala.collection.mutable
2323
import scala.concurrent.duration.FiniteDuration
2424

2525
/**
26-
* Created by thomash on 21/07/2023.
26+
*
27+
* @param weight How much fees we would have collected in the past if all payments had succeeded (exponential moving average).
28+
* @param score How much fees we have collected in the past (exponential moving average).
29+
* @param lastSettlementAt Timestamp of the last recorded payment settlement.
2730
*/
31+
case class PastScore(weight: Double, score: Double, lastSettlementAt: TimestampMilli)
32+
33+
/** We're relaying that payment and are waiting for it to settle. */
34+
case class PendingPayment(fee: MilliSatoshi, endorsement: Int, startedAt: TimestampMilli) {
35+
def weight(now: TimestampMilli, minDuration: FiniteDuration, multiplier: Double): Double = {
36+
val duration = now - startedAt
37+
fee.toLong.toDouble * (duration / minDuration).max(multiplier)
38+
}
39+
}
40+
41+
case class HtlcId(channelId: ByteVector32, id: Long)
2842

2943
/**
30-
* Local reputation for a given incoming node, that should be tracked for each incoming endorsement level.
44+
* Local reputation for a given node.
3145
*
32-
* @param pastWeight How much fees we would have collected in the past if all payments had succeeded (exponential moving average).
33-
* @param pastScore How much fees we have collected in the past (exponential moving average).
34-
* @param lastSettlementAt Timestamp of the last recorded payment settlement.
3546
* @param pending Set of pending payments (payments may contain multiple HTLCs when using trampoline).
3647
* @param halfLife Half life for the exponential moving average.
3748
* @param maxRelayDuration Duration after which payments are penalized for staying pending too long.
3849
* @param pendingMultiplier How much to penalize pending payments.
3950
*/
40-
case class Reputation(pastWeight: Double, pastScore: Double, lastSettlementAt: TimestampMilli, pending: Map[HtlcId, Reputation.PendingPayment], halfLife: FiniteDuration, maxRelayDuration: FiniteDuration, pendingMultiplier: Double) {
41-
private def decay(now: TimestampMilli): Double = scala.math.pow(0.5, (now - lastSettlementAt) / halfLife)
42-
43-
private def pendingWeight(now: TimestampMilli): Double = pending.values.map(_.weight(now, maxRelayDuration, pendingMultiplier)).sum
51+
case class Reputation(pastScores: Array[PastScore], pending: mutable.Map[HtlcId, PendingPayment], halfLife: FiniteDuration, maxRelayDuration: FiniteDuration, pendingMultiplier: Double) {
52+
private def decay(now: TimestampMilli, lastSettlementAt: TimestampMilli): Double = scala.math.pow(0.5, (now - lastSettlementAt) / halfLife)
4453

4554
/**
4655
* Estimate the confidence that a payment will succeed.
4756
*/
48-
def getConfidence(fee: MilliSatoshi, now: TimestampMilli = TimestampMilli.now()): Double = {
49-
val d = decay(now)
50-
d * pastScore / (d * pastWeight + pendingWeight(now) + fee.toLong.toDouble * pendingMultiplier)
57+
def getConfidence(fee: MilliSatoshi, endorsement: Int, now: TimestampMilli = TimestampMilli.now()): Double = {
58+
val weights = Array.fill(Reputation.endorsementLevels)(0.0)
59+
val scores = Array.fill(Reputation.endorsementLevels)(0.0)
60+
for (e <- 0 until Reputation.endorsementLevels) {
61+
val d = decay(now, pastScores(e).lastSettlementAt)
62+
weights(e) += d * pastScores(e).weight
63+
scores(e) += d * pastScores(e).score
64+
}
65+
for (p <- pending.values) {
66+
weights(p.endorsement) += p.weight(now, maxRelayDuration, pendingMultiplier)
67+
}
68+
weights(endorsement) += fee.toLong.toDouble * pendingMultiplier
69+
/*
70+
Higher endorsement buckets may have fewer payments which makes the weight of pending payments disproportionately
71+
important. To counter this effect, we try adding payments from the lower buckets to see if it gives us a higher
72+
confidence score.
73+
It is acceptable to use payments with lower endorsements to increase the confidence score but not payments with
74+
higher endorsements.
75+
*/
76+
var score = scores(endorsement)
77+
var weight = weights(endorsement)
78+
var confidence = score / weight
79+
for (e <- Range.inclusive(endorsement - 1, 0, step = -1)) {
80+
score += scores(e)
81+
weight += weights(e)
82+
confidence = confidence.max(score / weight)
83+
}
84+
confidence
5185
}
5286

5387
/**
5488
* Register a pending relay.
55-
*
56-
* @return updated reputation
5789
*/
58-
def attempt(htlcId: HtlcId, fee: MilliSatoshi, now: TimestampMilli = TimestampMilli.now()): Reputation =
59-
copy(pending = pending + (htlcId -> Reputation.PendingPayment(fee, now)))
90+
def attempt(htlcId: HtlcId, fee: MilliSatoshi, endorsement: Int, now: TimestampMilli = TimestampMilli.now()): Unit =
91+
pending(htlcId) = PendingPayment(fee, endorsement, now)
6092

6193
/**
6294
* When a payment is settled, we record whether it succeeded and how long it took.
63-
*
64-
* @return updated reputation
6595
*/
66-
def record(htlcId: HtlcId, isSuccess: Boolean, now: TimestampMilli = TimestampMilli.now()): Reputation = {
67-
pending.get(htlcId) match {
68-
case Some(p) =>
69-
val d = decay(now)
70-
val newWeight = d * pastWeight + p.weight(now, maxRelayDuration, if (isSuccess) 1.0 else 0.0)
71-
val newScore = d * pastScore + (if (isSuccess) p.fee.toLong.toDouble else 0)
72-
Reputation(newWeight, newScore, now, pending - htlcId, halfLife, maxRelayDuration, pendingMultiplier)
73-
case None => this
74-
}
75-
}
96+
def record(htlcId: HtlcId, isSuccess: Boolean, now: TimestampMilli = TimestampMilli.now()): Unit =
97+
pending.remove(htlcId).foreach(p => {
98+
val d = decay(now, pastScores(p.endorsement).lastSettlementAt)
99+
val newWeight = d * pastScores(p.endorsement).weight + p.weight(now, maxRelayDuration, if (isSuccess) 1.0 else 0.0)
100+
val newScore = d * pastScores(p.endorsement).score + (if (isSuccess) p.fee.toLong.toDouble else 0)
101+
pastScores(p.endorsement) = PastScore(newWeight, newScore, now)
102+
})
76103
}
77104

78105
object Reputation {
79-
case class HtlcId(channelId: ByteVector32, id: Long)
80-
81-
/** We're relaying that payment and are waiting for it to settle. */
82-
case class PendingPayment(fee: MilliSatoshi, startedAt: TimestampMilli) {
83-
def weight(now: TimestampMilli, minDuration: FiniteDuration, multiplier: Double): Double = {
84-
val duration = now - startedAt
85-
fee.toLong.toDouble * (duration / minDuration).max(multiplier)
86-
}
87-
}
106+
val endorsementLevels = 8
88107

89108
case class Config(enabled: Boolean, halfLife: FiniteDuration, maxRelayDuration: FiniteDuration, pendingMultiplier: Double)
90109

91-
def init(config: Config): Reputation = Reputation(0.0, 0.0, TimestampMilli.min, Map.empty, config.halfLife, config.maxRelayDuration, config.pendingMultiplier)
110+
def init(config: Config): Reputation = Reputation(Array.fill(endorsementLevels)(PastScore(0.0, 0.0, TimestampMilli.min)), mutable.HashMap.empty, config.halfLife, config.maxRelayDuration, config.pendingMultiplier)
92111
}

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

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023 ACINQ SAS
2+
* Copyright 2025 ACINQ SAS
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -23,17 +23,11 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2323
import fr.acinq.eclair.MilliSatoshi
2424
import fr.acinq.eclair.channel.Upstream.Hot
2525
import fr.acinq.eclair.channel.{OutgoingHtlcAdded, OutgoingHtlcFailed, OutgoingHtlcFulfilled, Upstream}
26-
import fr.acinq.eclair.reputation.Reputation.HtlcId
2726
import fr.acinq.eclair.wire.protocol.{UpdateFailHtlc, UpdateFailMalformedHtlc}
2827
import ReputationRecorder._
2928

3029
import scala.collection.mutable
3130

32-
33-
/**
34-
* Created by thomash on 21/07/2023.
35-
*/
36-
3731
object ReputationRecorder {
3832
// @formatter:off
3933
sealed trait Command
@@ -44,16 +38,6 @@ object ReputationRecorder {
4438
private case class WrappedOutgoingHtlcFulfilled(fulfilled: OutgoingHtlcFulfilled) extends Command
4539
// @formatter:on
4640

47-
/**
48-
* @param nodeId nodeId of the upstream peer.
49-
* @param endorsement endorsement value set by the upstream peer in the HTLC we received.
50-
*/
51-
case class PeerEndorsement(nodeId: PublicKey, endorsement: Int)
52-
53-
object PeerEndorsement {
54-
def apply(channel: Upstream.Hot.Channel): PeerEndorsement = PeerEndorsement(channel.receivedFrom, channel.add.endorsement)
55-
}
56-
5741
/** Confidence that the outgoing HTLC will succeed. */
5842
case class Confidence(value: Double)
5943

@@ -67,24 +51,37 @@ object ReputationRecorder {
6751
}
6852

6953
class ReputationRecorder(config: Reputation.Config, context: ActorContext[ReputationRecorder.Command]) {
70-
private val reputations: mutable.Map[PeerEndorsement, Reputation] = mutable.HashMap.empty.withDefaultValue(Reputation.init(config))
54+
private val reputations: mutable.Map[PublicKey, Reputation] = mutable.HashMap.empty.withDefault(_ => Reputation.init(config))
7155
private val pending: mutable.Map[HtlcId, Upstream.Hot] = mutable.HashMap.empty
7256

57+
private def getReputation(nodeId: PublicKey): Reputation = {
58+
reputations.get(nodeId) match {
59+
case Some(reputation) => reputation
60+
case None => {
61+
val reputation = Reputation.init(config)
62+
reputations(nodeId) = reputation
63+
reputation
64+
}
65+
}
66+
}
67+
7368
def run(): Behavior[Command] =
7469
Behaviors.receiveMessage {
7570
case GetConfidence(replyTo, upstream, fee) =>
76-
val confidence = reputations(PeerEndorsement(upstream)).getConfidence(fee)
71+
val confidence = reputations(upstream.receivedFrom).getConfidence(fee, upstream.add.endorsement)
7772
replyTo ! Confidence(confidence)
7873
Behaviors.same
7974

8075
case GetTrampolineConfidence(replyTo, upstream, totalFee) =>
8176
val confidence =
8277
upstream.received
83-
.groupMapReduce(r => PeerEndorsement(r.receivedFrom, r.add.endorsement))(_.add.amountMsat)(_ + _)
78+
.groupMapReduce(_.receivedFrom)(r => (r.add.amountMsat, r.add.endorsement)){
79+
case ((amount1, endorsement1), (amount2, endorsement2)) => (amount1 + amount2, endorsement1 min endorsement2)
80+
}
8481
.map {
85-
case (peerEndorsement, amount) =>
82+
case (nodeId, (amount, endorsement)) =>
8683
val fee = amount * totalFee.toLong / upstream.amountIn.toLong
87-
reputations(peerEndorsement).getConfidence(fee)
84+
reputations(nodeId).getConfidence(fee, endorsement)
8885
}
8986
.min
9087
replyTo ! Confidence(confidence)
@@ -94,11 +91,11 @@ class ReputationRecorder(config: Reputation.Config, context: ActorContext[Reputa
9491
val htlcId = HtlcId(add.channelId, add.id)
9592
upstream match {
9693
case channel: Hot.Channel =>
97-
reputations(PeerEndorsement(channel)) = reputations(PeerEndorsement(channel)).attempt(htlcId, fee)
94+
getReputation(channel.receivedFrom).attempt(htlcId, fee, channel.add.endorsement)
9895
pending += (htlcId -> upstream)
9996
case trampoline: Hot.Trampoline =>
10097
trampoline.received.foreach(channel =>
101-
reputations(PeerEndorsement(channel)) = reputations(PeerEndorsement(channel)).attempt(htlcId, fee * channel.amountIn.toLong / trampoline.amountIn.toLong)
98+
getReputation(channel.receivedFrom).attempt(htlcId, fee * channel.amountIn.toLong / trampoline.amountIn.toLong, channel.add.endorsement)
10299
)
103100
pending += (htlcId -> upstream)
104101
case _: Upstream.Local => ()
@@ -112,10 +109,10 @@ class ReputationRecorder(config: Reputation.Config, context: ActorContext[Reputa
112109
}
113110
pending.get(htlcId) match {
114111
case Some(channel: Hot.Channel) =>
115-
reputations(PeerEndorsement(channel)) = reputations(PeerEndorsement(channel)).record(htlcId, isSuccess = false)
112+
getReputation(channel.receivedFrom).record(htlcId, isSuccess = false)
116113
case Some(trampoline: Hot.Trampoline) =>
117114
trampoline.received.foreach(channel =>
118-
reputations(PeerEndorsement(channel)) = reputations(PeerEndorsement(channel)).record(htlcId, isSuccess = false)
115+
getReputation(channel.receivedFrom).record(htlcId, isSuccess = false)
119116
)
120117
case _ => ()
121118
}
@@ -126,10 +123,10 @@ class ReputationRecorder(config: Reputation.Config, context: ActorContext[Reputa
126123
val htlcId = HtlcId(fulfill.channelId, fulfill.id)
127124
pending.get(htlcId) match {
128125
case Some(channel: Hot.Channel) =>
129-
reputations(PeerEndorsement(channel)) = reputations(PeerEndorsement(channel)).record(htlcId, isSuccess = true)
126+
getReputation(channel.receivedFrom).record(htlcId, isSuccess = true)
130127
case Some(trampoline: Hot.Trampoline) =>
131128
trampoline.received.foreach(channel =>
132-
reputations(PeerEndorsement(channel)) = reputations(PeerEndorsement(channel)).record(htlcId, isSuccess = true)
129+
getReputation(channel.receivedFrom).record(htlcId, isSuccess = true)
133130
)
134131
case _ => ()
135132
}

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

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,48 +27,48 @@ class ReputationSpec extends AnyFunSuite {
2727
val (htlcId1, htlcId2, htlcId3, htlcId4, htlcId5, htlcId6, htlcId7) = (HtlcId(randomBytes32(), 1), HtlcId(randomBytes32(), 2), HtlcId(randomBytes32(), 3), HtlcId(randomBytes32(), 4), HtlcId(randomBytes32(), 5), HtlcId(randomBytes32(), 6), HtlcId(randomBytes32(), 7))
2828

2929
test("basic") {
30-
val r0 = Reputation.init(Config(enabled = true, 1 day, 1 second, 2))
31-
assert(r0.getConfidence(10000 msat) == 0)
32-
val r1 = r0.attempt(htlcId1, 10000 msat)
33-
val r2 = r1.record(htlcId1, isSuccess = true)
34-
val r3 = r2.attempt(htlcId2, 10000 msat)
35-
assert(r2.getConfidence(10000 msat) === (1.0 / 3) +- 0.001)
36-
val r4 = r3.attempt(htlcId3, 10000 msat)
37-
assert(r3.getConfidence(10000 msat) === (1.0 / 5) +- 0.001)
38-
val r5 = r4.record(htlcId2, isSuccess = true)
39-
val r6 = r5.record(htlcId3, isSuccess = true)
40-
val r7 = r6.attempt(htlcId4, 1 msat)
41-
assert(r6.getConfidence(1 msat) === 1.0 +- 0.001)
42-
val r8 = r7.attempt(htlcId5, 40000 msat)
43-
assert(r7.getConfidence(40000 msat) === (3.0 / 11) +- 0.001)
44-
val r9 = r8.attempt(htlcId6, 10000 msat)
45-
assert(r8.getConfidence(10000 msat) === (3.0 / 13) +- 0.001)
46-
val r10 = r9.record(htlcId6, isSuccess = false)
47-
assert(r10.getConfidence(10000 msat) === (3.0 / 13) +- 0.001)
30+
val r = Reputation.init(Config(enabled = true, 1 day, 1 second, 2))
31+
assert(r.getConfidence(10000 msat, 0) == 0)
32+
r.attempt(htlcId1, 10000 msat, 0)
33+
r.record(htlcId1, isSuccess = true)
34+
r.attempt(htlcId2, 10000 msat, 0)
35+
assert(r.getConfidence(10000 msat, 0) === (1.0 / 3) +- 0.001)
36+
r.attempt(htlcId3, 10000 msat, 0)
37+
assert(r.getConfidence(10000 msat, 0) === (1.0 / 5) +- 0.001)
38+
r.record(htlcId2, isSuccess = true)
39+
r.record(htlcId3, isSuccess = true)
40+
r.attempt(htlcId4, 1 msat, 0)
41+
assert(r.getConfidence(1 msat, 0) === 1.0 +- 0.001)
42+
r.attempt(htlcId5, 40000 msat, 0)
43+
assert(r.getConfidence(40000 msat, 0) === (3.0 / 11) +- 0.001)
44+
r.attempt(htlcId6, 10000 msat, 0)
45+
assert(r.getConfidence(10000 msat, 0) === (3.0 / 13) +- 0.001)
46+
r.record(htlcId6, isSuccess = false)
47+
assert(r.getConfidence(10000 msat, 0) === (3.0 / 13) +- 0.001)
4848
}
4949

5050
test("long HTLC") {
51-
val r0 = Reputation.init(Config(enabled = true, 1000 day, 1 second, 10))
52-
assert(r0.getConfidence(100000 msat, TimestampMilli(0)) == 0)
53-
val r1 = r0.attempt(htlcId1, 100000 msat, TimestampMilli(0))
54-
val r2 = r1.record(htlcId1, isSuccess = true, now = TimestampMilli(0))
55-
assert(r2.getConfidence(1000 msat, TimestampMilli(0)) === (10.0 / 11) +- 0.001)
56-
val r3 = r2.attempt(htlcId2, 1000 msat, TimestampMilli(0))
57-
val r4 = r3.record(htlcId2, isSuccess = false, now = TimestampMilli(0) + 100.seconds)
58-
assert(r4.getConfidence(0 msat, now = TimestampMilli(0) + 100.seconds) === 0.5 +- 0.001)
51+
val r = Reputation.init(Config(enabled = true, 1000 day, 1 second, 10))
52+
assert(r.getConfidence(100000 msat, 0, TimestampMilli(0)) == 0)
53+
r.attempt(htlcId1, 100000 msat, 0, TimestampMilli(0))
54+
r.record(htlcId1, isSuccess = true, now = TimestampMilli(0))
55+
assert(r.getConfidence(1000 msat, 0, TimestampMilli(0)) === (10.0 / 11) +- 0.001)
56+
r.attempt(htlcId2, 1000 msat, 0, TimestampMilli(0))
57+
r.record(htlcId2, isSuccess = false, now = TimestampMilli(0) + 100.seconds)
58+
assert(r.getConfidence(0 msat, 0, now = TimestampMilli(0) + 100.seconds) === 0.5 +- 0.001)
5959
}
6060

6161
test("exponential decay") {
62-
val r0 = Reputation.init(Config(enabled = true, 100 seconds, 1 second, 1))
63-
val r1 = r0.attempt(htlcId1, 1000 msat, TimestampMilli(0))
64-
val r2 = r1.record(htlcId1, isSuccess = true, now = TimestampMilli(0))
65-
assert(r2.getConfidence(1000 msat, TimestampMilli(0)) == 1.0 / 2)
66-
val r3 = r2.attempt(htlcId2, 1000 msat, TimestampMilli(0))
67-
val r4 = r3.record(htlcId2, isSuccess = true, now = TimestampMilli(0))
68-
assert(r4.getConfidence(1000 msat, TimestampMilli(0)) == 2.0 / 3)
69-
val r5 = r4.attempt(htlcId3, 1000 msat, TimestampMilli(0))
70-
val r6 = r5.record(htlcId3, isSuccess = true, now = TimestampMilli(0))
71-
assert(r6.getConfidence(1000 msat, TimestampMilli(0) + 100.seconds) == 1.5 / 2.5)
72-
assert(r6.getConfidence(1000 msat, TimestampMilli(0) + 1.hour) < 0.000001)
62+
val r = Reputation.init(Config(enabled = true, 100 seconds, 1 second, 1))
63+
r.attempt(htlcId1, 1000 msat, 0, TimestampMilli(0))
64+
r.record(htlcId1, isSuccess = true, now = TimestampMilli(0))
65+
assert(r.getConfidence(1000 msat, 0, TimestampMilli(0)) == 1.0 / 2)
66+
r.attempt(htlcId2, 1000 msat, 0, TimestampMilli(0))
67+
r.record(htlcId2, isSuccess = true, now = TimestampMilli(0))
68+
assert(r.getConfidence(1000 msat, 0, TimestampMilli(0)) == 2.0 / 3)
69+
r.attempt(htlcId3, 1000 msat, 0, TimestampMilli(0))
70+
r.record(htlcId3, isSuccess = true, now = TimestampMilli(0))
71+
assert(r.getConfidence(1000 msat, 0, TimestampMilli(0) + 100.seconds) == 1.5 / 2.5)
72+
assert(r.getConfidence(1000 msat, 0, TimestampMilli(0) + 1.hour) < 0.000001)
7373
}
7474
}

0 commit comments

Comments
 (0)