Skip to content

Commit da1b1a6

Browse files
committed
Add local reputation
1 parent de42c8a commit da1b1a6

23 files changed

+712
-92
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ Existing `static_remote_key` channels will continue to work. You can override th
3333

3434
Eclair will not allow remote peers to open new obsolete channels that do not support `option_static_remotekey`.
3535

36+
### Local reputation and HTLC endorsement
37+
38+
To protect against jamming attacks, eclair gives a reputation to its neighbors and uses it to decide if a HTLC should be relayed given how congested the outgoing channel is.
39+
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.
40+
The reputation is per incoming node and endorsement level.
41+
The confidence that the HTLC will be fulfilled is transmitted to the next node using the endorsement TLV of the `update_add_htlc` message.
42+
Note that HTLCs that are considered dangerous are still relayed: this is the first phase of a network-wide experimentation aimed at collecting data.
43+
44+
To configure, edit `eclair.conf`:
45+
46+
```eclair.conf
47+
// We assign reputations to our peers to prioritize payments during congestion.
48+
// The reputation is computed as fees paid divided by what should have been paid if all payments were successful.
49+
eclair.relay.peer-reputation {
50+
// Set this parameter to false to disable the reputation algorithm and simply relay the incoming endorsement
51+
// value, as described by https://github.com/lightning/blips/blob/master/blip-0004.md,
52+
enabled = true
53+
// Reputation decays with the following half life to emphasize recent behavior.
54+
half-life = 7 days
55+
// Payments that stay pending for longer than this get penalized
56+
max-relay-duration = 12 seconds
57+
// Pending payments are counted as failed, and because they could potentially stay pending for a very long time,
58+
// the following multiplier is applied.
59+
pending-multiplier = 1000 // A pending payment counts as a thousand failed ones.
60+
}
61+
```
62+
3663
### API changes
3764

3865
- `channelstats` now takes optional parameters `--count` and `--skip` to control pagination. By default, it will return first 10 entries. (#2890)

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,23 @@ eclair {
238238
// Number of blocks before the incoming HTLC expires that an async payment must be triggered by the receiver
239239
cancel-safety-before-timeout-blocks = 144
240240
}
241+
242+
// We assign reputation to our peers to prioritize payments during congestion.
243+
// The reputation is computed as fees paid divided by what should have been paid if all payments were successful.
244+
peer-reputation {
245+
// Set this parameter to false to disable the reputation algorithm and simply relay the incoming endorsement
246+
// value, as described by https://github.com/lightning/blips/blob/master/blip-0004.md,
247+
enabled = true
248+
// Reputation decays with the following half life to emphasize recent behavior.
249+
half-life = 15 days
250+
// Payments that stay pending for longer than this get penalized.
251+
max-relay-duration = 12 seconds
252+
// Pending payments are counted as failed, and because they could potentially stay pending for a very long time,
253+
// the following multiplier is applied. We want it to be as close as possible to the true cost of a worst case
254+
// HTLC (max-cltv-delta / max-relay-duration, around 100000 with default parameters) while still being comparable
255+
// to the number of HTLCs received per peer during twice the half life.
256+
pending-multiplier = 200 // A pending payment counts as two hundred failed ones.
257+
}
241258
}
242259

243260
on-chain-fees {

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import fr.acinq.eclair.io.{PeerConnection, PeerReadyNotifier}
3232
import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
3333
import fr.acinq.eclair.payment.relay.OnTheFlyFunding
3434
import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams}
35+
import fr.acinq.eclair.reputation.Reputation
3536
import fr.acinq.eclair.router.Announcements.AddressException
3637
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, WeightRatios}
3738
import fr.acinq.eclair.router.Router._
@@ -619,7 +620,13 @@ object NodeParams extends Logging {
619620
privateChannelFees = getRelayFees(config.getConfig("relay.fees.private-channels")),
620621
minTrampolineFees = getRelayFees(config.getConfig("relay.fees.min-trampoline")),
621622
enforcementDelay = FiniteDuration(config.getDuration("relay.fees.enforcement-delay").getSeconds, TimeUnit.SECONDS),
622-
asyncPaymentsParams = AsyncPaymentsParams(asyncPaymentHoldTimeoutBlocks, asyncPaymentCancelSafetyBeforeTimeoutBlocks)
623+
asyncPaymentsParams = AsyncPaymentsParams(asyncPaymentHoldTimeoutBlocks, asyncPaymentCancelSafetyBeforeTimeoutBlocks),
624+
peerReputationConfig = Reputation.Config(
625+
enabled = config.getBoolean("relay.peer-reputation.enabled"),
626+
halfLife = FiniteDuration(config.getDuration("relay.peer-reputation.half-life").getSeconds, TimeUnit.SECONDS),
627+
maxRelayDuration = FiniteDuration(config.getDuration("relay.peer-reputation.max-relay-duration").getSeconds, TimeUnit.SECONDS),
628+
pendingMultiplier = config.getDouble("relay.peer-reputation.pending-multiplier"),
629+
),
623630
),
624631
db = database,
625632
autoReconnect = config.getBoolean("auto-reconnect"),

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import fr.acinq.eclair.payment.offer.OfferManager
4444
import fr.acinq.eclair.payment.receive.PaymentHandler
4545
import fr.acinq.eclair.payment.relay.{AsyncPaymentTriggerer, PostRestartHtlcCleaner, Relayer}
4646
import fr.acinq.eclair.payment.send.{Autoprobe, PaymentInitiator}
47+
import fr.acinq.eclair.reputation.ReputationRecorder
4748
import fr.acinq.eclair.router._
4849
import fr.acinq.eclair.tor.{Controller, TorProtocolHandler}
4950
import fr.acinq.eclair.wire.protocol.NodeAddress
@@ -362,7 +363,12 @@ class Setup(val datadir: File,
362363
paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume))
363364
triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer")
364365
peerReadyManager = system.spawn(Behaviors.supervise(PeerReadyManager()).onFailure(typed.SupervisorStrategy.restart), name = "peer-ready-manager")
365-
relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, router, register, paymentHandler, Some(postRestartCleanUpInitialized)), "relayer", SupervisorStrategy.Resume))
366+
reputationRecorder_opt = if (nodeParams.relayParams.peerReputationConfig.enabled) {
367+
Some(system.spawn(Behaviors.supervise(ReputationRecorder(nodeParams.relayParams.peerReputationConfig, Map.empty)).onFailure(typed.SupervisorStrategy.resume), name = "reputation-recorder"))
368+
} else {
369+
None
370+
}
371+
relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, router, register, paymentHandler, reputationRecorder_opt, Some(postRestartCleanUpInitialized)), "relayer", SupervisorStrategy.Resume))
366372
_ = relayer ! PostRestartHtlcCleaner.Init(channels)
367373
// Before initializing the switchboard (which re-connects us to the network) and the user-facing parts of the system,
368374
// we want to make sure the handler for post-restart broken HTLCs has finished initializing.

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ case class ExpiryTooBig (override val channelId: Byte
123123
case class HtlcValueTooSmall (override val channelId: ByteVector32, minimum: MilliSatoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"htlc value too small: minimum=$minimum actual=$actual")
124124
case class HtlcValueTooHighInFlight (override val channelId: ByteVector32, maximum: MilliSatoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"in-flight htlcs hold too much value: maximum=$maximum actual=$actual")
125125
case class TooManyAcceptedHtlcs (override val channelId: ByteVector32, maximum: Long) extends ChannelException(channelId, s"too many accepted htlcs: maximum=$maximum")
126+
case class TooManySmallHtlcs (override val channelId: ByteVector32, number: Long, below: MilliSatoshi) extends ChannelException(channelId, s"too many small htlcs: $number HTLCs below $below")
127+
case class ConfidenceTooLow (override val channelId: ByteVector32, confidence: Double, occupancy: Double) extends ChannelException(channelId, s"confidence too low: confidence=$confidence occupancy=$occupancy")
126128
case class LocalDustHtlcExposureTooHigh (override val channelId: ByteVector32, maximum: Satoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"dust htlcs hold too much value: maximum=$maximum actual=$actual")
127129
case class RemoteDustHtlcExposureTooHigh (override val channelId: ByteVector32, maximum: Satoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"dust htlcs hold too much value: maximum=$maximum actual=$actual")
128130
case class InsufficientFunds (override val channelId: ByteVector32, amount: MilliSatoshi, missing: Satoshi, reserve: Satoshi, fees: Satoshi) extends ChannelException(channelId, s"insufficient funds: missing=$missing reserve=$reserve fees=$fees")

eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ case class Commitment(fundingTxIndex: Long,
429429
localCommit.spec.htlcs.collect(DirectedHtlc.incoming).filter(nearlyExpired)
430430
}
431431

432-
def canSendAdd(amount: MilliSatoshi, params: ChannelParams, changes: CommitmentChanges, feerates: FeeratesPerKw, feeConf: OnChainFeeConf): Either[ChannelException, Unit] = {
432+
def canSendAdd(amount: MilliSatoshi, params: ChannelParams, changes: CommitmentChanges, feerates: FeeratesPerKw, feeConf: OnChainFeeConf, confidence: Double): Either[ChannelException, Unit] = {
433433
// we allowed mismatches between our feerates and our remote's as long as commitments didn't contain any HTLC at risk
434434
// we need to verify that we're not disagreeing on feerates anymore before offering new HTLCs
435435
// NB: there may be a pending update_fee that hasn't been applied yet that needs to be taken into account
@@ -488,7 +488,8 @@ case class Commitment(fundingTxIndex: Long,
488488
if (allowedHtlcValueInFlight < htlcValueInFlight) {
489489
return Left(HtlcValueTooHighInFlight(params.channelId, maximum = allowedHtlcValueInFlight, actual = htlcValueInFlight))
490490
}
491-
if (Seq(params.localParams.maxAcceptedHtlcs, params.remoteParams.maxAcceptedHtlcs).min < outgoingHtlcs.size) {
491+
val maxAcceptedHtlcs = params.localParams.maxAcceptedHtlcs.min(params.remoteParams.maxAcceptedHtlcs)
492+
if (maxAcceptedHtlcs < outgoingHtlcs.size) {
492493
return Left(TooManyAcceptedHtlcs(params.channelId, maximum = Seq(params.localParams.maxAcceptedHtlcs, params.remoteParams.maxAcceptedHtlcs).min))
493494
}
494495

@@ -505,6 +506,18 @@ case class Commitment(fundingTxIndex: Long,
505506
return Left(RemoteDustHtlcExposureTooHigh(params.channelId, maxDustExposure, remoteDustExposureAfterAdd))
506507
}
507508

509+
// Jamming protection
510+
// Must be the last checks so that they can be ignored for shadow deployment.
511+
for ((amountMsat, i) <- outgoingHtlcs.toSeq.map(_.amountMsat).sorted.zipWithIndex) {
512+
if ((amountMsat.toLong < 1) || (math.log(amountMsat.toLong.toDouble) * maxAcceptedHtlcs / math.log(params.localParams.maxHtlcValueInFlightMsat.toLong.toDouble / maxAcceptedHtlcs) < i)) {
513+
return Left(TooManySmallHtlcs(params.channelId, number = i + 1, below = amountMsat))
514+
}
515+
}
516+
val occupancy = (outgoingHtlcs.size.toDouble / maxAcceptedHtlcs).max(htlcValueInFlight.toLong.toDouble / allowedHtlcValueInFlight.toLong.toDouble)
517+
if (confidence + 0.05 < occupancy) {
518+
return Left(ConfidenceTooLow(params.channelId, confidence, occupancy))
519+
}
520+
508521
Right(())
509522
}
510523

@@ -835,7 +848,7 @@ case class Commitments(params: ChannelParams,
835848
* @param cmd add HTLC command
836849
* @return either Left(failure, error message) where failure is a failure message (see BOLT #4 and the Failure Message class) or Right(new commitments, updateAddHtlc)
837850
*/
838-
def sendAdd(cmd: CMD_ADD_HTLC, currentHeight: BlockHeight, channelConf: ChannelConf, feerates: FeeratesPerKw, feeConf: OnChainFeeConf): Either[ChannelException, (Commitments, UpdateAddHtlc)] = {
851+
def sendAdd(cmd: CMD_ADD_HTLC, currentHeight: BlockHeight, channelConf: ChannelConf, feerates: FeeratesPerKw, feeConf: OnChainFeeConf)(implicit log: LoggingAdapter): Either[ChannelException, (Commitments, UpdateAddHtlc)] = {
839852
// we must ensure we're not relaying htlcs that are already expired, otherwise the downstream channel will instantly close
840853
// NB: we add a 3 blocks safety to reduce the probability of running into this when our bitcoin node is slightly outdated
841854
val minExpiry = CltvExpiry(currentHeight + 3)
@@ -859,8 +872,24 @@ case class Commitments(params: ChannelParams,
859872
val changes1 = changes.addLocalProposal(add).copy(localNextHtlcId = changes.localNextHtlcId + 1)
860873
val originChannels1 = originChannels + (add.id -> cmd.origin)
861874
// we verify that this htlc is allowed in every active commitment
862-
active.map(_.canSendAdd(add.amountMsat, params, changes1, feerates, feeConf))
863-
.collectFirst { case Left(f) => Left(f) }
875+
val canSendAdds = active.map(_.canSendAdd(add.amountMsat, params, changes1, feerates, feeConf, cmd.confidence))
876+
// Log only for jamming protection.
877+
canSendAdds.collectFirst {
878+
case Left(f: TooManySmallHtlcs) =>
879+
log.info("TooManySmallHtlcs: {} outgoing HTLCs are below {}}", f.number, f.below)
880+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
881+
case Left(f: ConfidenceTooLow) =>
882+
log.info("ConfidenceTooLow: confidence is {}% while channel is {}% full", (100 * f.confidence).toInt, (100 * f.occupancy).toInt)
883+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
884+
}
885+
canSendAdds.flatMap { // TODO: We ignore jamming protection, delete this flatMap to activate jamming protection.
886+
case Left(_: TooManySmallHtlcs) | Left(_: ConfidenceTooLow) => None
887+
case x => Some(x)
888+
}
889+
.collectFirst { case Left(f) =>
890+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
891+
Left(f)
892+
}
864893
.getOrElse(Right(copy(changes = changes1, originChannels = originChannels1), add))
865894
}
866895

eclair-core/src/main/scala/fr/acinq/eclair/channel/Monitoring.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ object Monitoring {
3535
val RemoteFeeratePerByte = Kamon.histogram("channels.remote-feerate-per-byte")
3636
val Splices = Kamon.histogram("channels.splices", "Splices")
3737
val ProcessMessage = Kamon.timer("channels.messages-processed")
38+
val HtlcDropped = Kamon.counter("channels.htlc-dropped")
3839

3940
def recordHtlcsInFlight(remoteSpec: CommitmentSpec, previousRemoteSpec: CommitmentSpec): Unit = {
4041
for (direction <- Tags.Directions.Incoming :: Tags.Directions.Outgoing :: Nil) {
@@ -75,6 +76,10 @@ object Monitoring {
7576
Metrics.Splices.withTag(Tags.Origin, Tags.Origins.Remote).withTag(Tags.SpliceType, Tags.SpliceTypes.SpliceCpfp).record(Math.abs(fundingParams.remoteContribution.toLong))
7677
}
7778
}
79+
80+
def dropHtlc(reason: ChannelException, direction: String): Unit = {
81+
HtlcDropped.withTag(Tags.Reason, reason.getClass.getSimpleName).withTag(Tags.Direction, direction).increment()
82+
}
7883
}
7984

8085
object Tags {
@@ -85,6 +90,7 @@ object Monitoring {
8590
val State = "state"
8691
val CommitmentFormat = "commitment-format"
8792
val SpliceType = "splice-type"
93+
val Reason = "reason"
8894

8995
object Events {
9096
val Created = "created"

0 commit comments

Comments
 (0)