Skip to content

Commit

Permalink
Documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
thomash-acinq committed Jul 4, 2024
1 parent 0136caf commit ff97580
Show file tree
Hide file tree
Showing 8 changed files with 118 additions and 84 deletions.
19 changes: 19 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<insert changes>
Expand Down
7 changes: 5 additions & 2 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
8 changes: 4 additions & 4 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

0 comments on commit ff97580

Please sign in to comment.