Skip to content

Commit f7b2ad4

Browse files
committed
Add local reputation
1 parent 055695f commit f7b2ad4

24 files changed

+724
-100
lines changed

.mvn/maven.config

Lines changed: 0 additions & 8 deletions
This file was deleted.

docs/release-notes/eclair-vnext.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,33 @@ See https://github.com/lightning/bolts/pull/1044 for more details.
2323
Support is disabled by default as the spec is not yet final.
2424
It can be enabled by setting `eclair.features.option_attributable_failure = optional` at the risk of being incompatible with the final spec.
2525

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

2855
- `listoffers` now returns more details about each offer.

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,23 @@ eclair {
251251
// Number of blocks before the incoming HTLC expires that an async payment must be triggered by the receiver
252252
cancel-safety-before-timeout-blocks = 144
253253
}
254+
255+
// We assign reputation to our peers to prioritize payments during congestion.
256+
// The reputation is computed as fees paid divided by what should have been paid if all payments were successful.
257+
peer-reputation {
258+
// Set this parameter to false to disable the reputation algorithm and simply relay the incoming endorsement
259+
// value, as described by https://github.com/lightning/blips/blob/master/blip-0004.md,
260+
enabled = true
261+
// Reputation decays with the following half life to emphasize recent behavior.
262+
half-life = 15 days
263+
// 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.
270+
}
254271
}
255272

256273
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
@@ -33,6 +33,7 @@ import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
3333
import fr.acinq.eclair.payment.offer.OffersConfig
3434
import fr.acinq.eclair.payment.relay.OnTheFlyFunding
3535
import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams}
36+
import fr.acinq.eclair.reputation.Reputation
3637
import fr.acinq.eclair.router.Announcements.AddressException
3738
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, PaymentWeightRatios}
3839
import fr.acinq.eclair.router.Router._
@@ -640,7 +641,13 @@ object NodeParams extends Logging {
640641
privateChannelFees = getRelayFees(config.getConfig("relay.fees.private-channels")),
641642
minTrampolineFees = getRelayFees(config.getConfig("relay.fees.min-trampoline")),
642643
enforcementDelay = FiniteDuration(config.getDuration("relay.fees.enforcement-delay").getSeconds, TimeUnit.SECONDS),
643-
asyncPaymentsParams = AsyncPaymentsParams(asyncPaymentHoldTimeoutBlocks, asyncPaymentCancelSafetyBeforeTimeoutBlocks)
644+
asyncPaymentsParams = AsyncPaymentsParams(asyncPaymentHoldTimeoutBlocks, asyncPaymentCancelSafetyBeforeTimeoutBlocks),
645+
peerReputationConfig = Reputation.Config(
646+
enabled = config.getBoolean("relay.peer-reputation.enabled"),
647+
halfLife = FiniteDuration(config.getDuration("relay.peer-reputation.half-life").getSeconds, TimeUnit.SECONDS),
648+
maxRelayDuration = FiniteDuration(config.getDuration("relay.peer-reputation.max-relay-duration").getSeconds, TimeUnit.SECONDS),
649+
pendingMultiplier = config.getDouble("relay.peer-reputation.pending-multiplier"),
650+
),
644651
),
645652
db = database,
646653
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
@@ -45,6 +45,7 @@ import fr.acinq.eclair.payment.offer.{DefaultOfferHandler, OfferManager}
4545
import fr.acinq.eclair.payment.receive.PaymentHandler
4646
import fr.acinq.eclair.payment.relay.{AsyncPaymentTriggerer, PostRestartHtlcCleaner, Relayer}
4747
import fr.acinq.eclair.payment.send.{Autoprobe, PaymentInitiator}
48+
import fr.acinq.eclair.reputation.ReputationRecorder
4849
import fr.acinq.eclair.router._
4950
import fr.acinq.eclair.tor.{Controller, TorProtocolHandler}
5051
import fr.acinq.eclair.wire.protocol.NodeAddress
@@ -379,7 +380,12 @@ class Setup(val datadir: File,
379380
paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume))
380381
triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer")
381382
peerReadyManager = system.spawn(Behaviors.supervise(PeerReadyManager()).onFailure(typed.SupervisorStrategy.restart), name = "peer-ready-manager")
382-
relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, router, register, paymentHandler, Some(postRestartCleanUpInitialized)), "relayer", SupervisorStrategy.Resume))
383+
reputationRecorder_opt = if (nodeParams.relayParams.peerReputationConfig.enabled) {
384+
Some(system.spawn(Behaviors.supervise(ReputationRecorder(nodeParams.relayParams.peerReputationConfig, Map.empty)).onFailure(typed.SupervisorStrategy.resume), name = "reputation-recorder"))
385+
} else {
386+
None
387+
}
388+
relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, router, register, paymentHandler, reputationRecorder_opt, Some(postRestartCleanUpInitialized)), "relayer", SupervisorStrategy.Resume))
383389
_ = relayer ! PostRestartHtlcCleaner.Init(channels)
384390
// Before initializing the switchboard (which re-connects us to the network) and the user-facing parts of the system,
385391
// 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
@@ -130,6 +130,8 @@ case class ExpiryTooBig (override val channelId: Byte
130130
case class HtlcValueTooSmall (override val channelId: ByteVector32, minimum: MilliSatoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"htlc value too small: minimum=$minimum actual=$actual")
131131
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")
132132
case class TooManyAcceptedHtlcs (override val channelId: ByteVector32, maximum: Long) extends ChannelException(channelId, s"too many accepted htlcs: maximum=$maximum")
133+
case class TooManySmallHtlcs (override val channelId: ByteVector32, number: Long, below: MilliSatoshi) extends ChannelException(channelId, s"too many small htlcs: $number HTLCs below $below")
134+
case class ConfidenceTooLow (override val channelId: ByteVector32, confidence: Double, occupancy: Double) extends ChannelException(channelId, s"confidence too low: confidence=$confidence occupancy=$occupancy")
133135
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")
134136
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")
135137
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
@@ -448,7 +448,7 @@ case class Commitment(fundingTxIndex: Long,
448448
localCommit.spec.htlcs.collect(DirectedHtlc.incoming).filter(nearlyExpired)
449449
}
450450

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

@@ -524,6 +525,18 @@ case class Commitment(fundingTxIndex: Long,
524525
return Left(RemoteDustHtlcExposureTooHigh(params.channelId, maxDustExposure, remoteDustExposureAfterAdd))
525526
}
526527

528+
// Jamming protection
529+
// Must be the last checks so that they can be ignored for shadow deployment.
530+
for ((amountMsat, i) <- outgoingHtlcs.toSeq.map(_.amountMsat).sorted.zipWithIndex) {
531+
if ((amountMsat.toLong < 1) || (math.log(amountMsat.toLong.toDouble) * maxAcceptedHtlcs / math.log(params.localParams.maxHtlcValueInFlightMsat.toLong.toDouble / maxAcceptedHtlcs) < i)) {
532+
return Left(TooManySmallHtlcs(params.channelId, number = i + 1, below = amountMsat))
533+
}
534+
}
535+
val occupancy = (outgoingHtlcs.size.toDouble / maxAcceptedHtlcs).max(htlcValueInFlight.toLong.toDouble / allowedHtlcValueInFlight.toLong.toDouble)
536+
if (confidence + 0.05 < occupancy) {
537+
return Left(ConfidenceTooLow(params.channelId, confidence, occupancy))
538+
}
539+
527540
Right(())
528541
}
529542

@@ -849,7 +862,7 @@ case class Commitments(params: ChannelParams,
849862
* @param cmd add HTLC command
850863
* @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)
851864
*/
852-
def sendAdd(cmd: CMD_ADD_HTLC, currentHeight: BlockHeight, channelConf: ChannelConf, feerates: FeeratesPerKw, feeConf: OnChainFeeConf): Either[ChannelException, (Commitments, UpdateAddHtlc)] = {
865+
def sendAdd(cmd: CMD_ADD_HTLC, currentHeight: BlockHeight, channelConf: ChannelConf, feerates: FeeratesPerKw, feeConf: OnChainFeeConf)(implicit log: LoggingAdapter): Either[ChannelException, (Commitments, UpdateAddHtlc)] = {
853866
// we must ensure we're not relaying htlcs that are already expired, otherwise the downstream channel will instantly close
854867
// NB: we add a 3 blocks safety to reduce the probability of running into this when our bitcoin node is slightly outdated
855868
val minExpiry = CltvExpiry(currentHeight + 3)
@@ -873,8 +886,24 @@ case class Commitments(params: ChannelParams,
873886
val changes1 = changes.addLocalProposal(add).copy(localNextHtlcId = changes.localNextHtlcId + 1)
874887
val originChannels1 = originChannels + (add.id -> cmd.origin)
875888
// we verify that this htlc is allowed in every active commitment
876-
active.map(_.canSendAdd(add.amountMsat, params, changes1, feerates, feeConf))
877-
.collectFirst { case Left(f) => Left(f) }
889+
val canSendAdds = active.map(_.canSendAdd(add.amountMsat, params, changes1, feerates, feeConf, cmd.confidence))
890+
// Log only for jamming protection.
891+
canSendAdds.collectFirst {
892+
case Left(f: TooManySmallHtlcs) =>
893+
log.info("TooManySmallHtlcs: {} outgoing HTLCs are below {}}", f.number, f.below)
894+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
895+
case Left(f: ConfidenceTooLow) =>
896+
log.info("ConfidenceTooLow: confidence is {}% while channel is {}% full", (100 * f.confidence).toInt, (100 * f.occupancy).toInt)
897+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
898+
}
899+
canSendAdds.flatMap { // TODO: We ignore jamming protection, delete this flatMap to activate jamming protection.
900+
case Left(_: TooManySmallHtlcs) | Left(_: ConfidenceTooLow) => None
901+
case x => Some(x)
902+
}
903+
.collectFirst { case Left(f) =>
904+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
905+
Left(f)
906+
}
878907
.getOrElse(Right(copy(changes = changes1, originChannels = originChannels1), add))
879908
}
880909

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)