Skip to content

Commit 83a5627

Browse files
committed
Add local reputation
1 parent 7aacd4b commit 83a5627

23 files changed

+664
-84
lines changed

docs/release-notes/eclair-vnext.md

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

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

27+
### Local reputation and HTLC endorsement
28+
29+
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.
30+
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.
31+
The reputation is per incoming node and endorsement level.
32+
The confidence that the HTLC will be fulfilled is transmitted to the next node using the endorsement TLV of the `update_add_htlc` message.
33+
34+
To configure, edit `eclair.conf`:
35+
```eclair.conf
36+
eclair.local-reputation {
37+
# Reputation decays with the following half life to emphasize recent behavior.
38+
half-life = 7 days
39+
# HTLCs that stay pending for longer than this get penalized
40+
good-htlc-duration = 12 seconds
41+
# How much to penalize pending HLTCs. A pending HTLC is considered equivalent to this many fast-failing HTLCs.
42+
pending-multiplier = 1000
43+
}
44+
```
45+
2746
### API changes
2847

2948
<insert changes>

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,15 @@ eclair {
547547
enabled = true // enable automatic purges of expired invoices from the database
548548
interval = 24 hours // interval between expired invoice purges
549549
}
550+
551+
local-reputation {
552+
# Reputation decays with the following half life to emphasize recent behavior.
553+
half-life = 7 days
554+
# HTLCs that stay pending for longer than this get penalized
555+
good-htlc-duration = 12 seconds # 95% of successful payments settle in less than 12 seconds, only the slowest 5% will be penalized.
556+
# How much to penalize pending HLTCs. A pending HTLC is considered equivalent to this many fast-failing HTLCs.
557+
pending-multiplier = 1000
558+
}
550559
}
551560

552561
akka {

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly, RelayPolicy
3131
import fr.acinq.eclair.io.PeerConnection
3232
import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
3333
import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams}
34+
import fr.acinq.eclair.reputation.Reputation.ReputationConfig
3435
import fr.acinq.eclair.router.Announcements.AddressException
3536
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, WeightRatios}
3637
import fr.acinq.eclair.router.Router._
@@ -87,7 +88,8 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
8788
blockchainWatchdogSources: Seq[String],
8889
onionMessageConfig: OnionMessageConfig,
8990
purgeInvoicesInterval: Option[FiniteDuration],
90-
revokedHtlcInfoCleanerConfig: RevokedHtlcInfoCleaner.Config) {
91+
revokedHtlcInfoCleanerConfig: RevokedHtlcInfoCleaner.Config,
92+
localReputationConfig: ReputationConfig) {
9193
val privateKey: Crypto.PrivateKey = nodeKeyManager.nodeKey.privateKey
9294

9395
val nodeId: PublicKey = nodeKeyManager.nodeId
@@ -611,7 +613,12 @@ object NodeParams extends Logging {
611613
revokedHtlcInfoCleanerConfig = RevokedHtlcInfoCleaner.Config(
612614
batchSize = config.getInt("db.revoked-htlc-info-cleaner.batch-size"),
613615
interval = FiniteDuration(config.getDuration("db.revoked-htlc-info-cleaner.interval").getSeconds, TimeUnit.SECONDS)
614-
)
616+
),
617+
localReputationConfig = ReputationConfig(
618+
FiniteDuration(config.getDuration("local-reputation.half-life").getSeconds, TimeUnit.SECONDS),
619+
FiniteDuration(config.getDuration("local-reputation.good-htlc-duration").getSeconds, TimeUnit.SECONDS),
620+
config.getDouble("local-reputation.pending-multiplier"),
621+
),
615622
)
616623
}
617624
}

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

Lines changed: 3 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
@@ -360,7 +361,8 @@ class Setup(val datadir: File,
360361
offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, router, paymentTimeout = 1 minute)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager")
361362
paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume))
362363
triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer")
363-
relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, router, register, paymentHandler, triggerer, Some(postRestartCleanUpInitialized)), "relayer", SupervisorStrategy.Resume))
364+
reputationRecorder = system.spawn(Behaviors.supervise(ReputationRecorder(nodeParams.localReputationConfig, Map.empty)).onFailure(typed.SupervisorStrategy.resume), name = "reputation-recorder")
365+
relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, router, register, paymentHandler, triggerer, reputationRecorder, Some(postRestartCleanUpInitialized)), "relayer", SupervisorStrategy.Resume))
364366
_ = relayer ! PostRestartHtlcCleaner.Init(channels)
365367
// Before initializing the switchboard (which re-connects us to the network) and the user-facing parts of the system,
366368
// 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
@@ -118,6 +118,8 @@ case class ExpiryTooBig (override val channelId: Byte
118118
case class HtlcValueTooSmall (override val channelId: ByteVector32, minimum: MilliSatoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"htlc value too small: minimum=$minimum actual=$actual")
119119
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")
120120
case class TooManyAcceptedHtlcs (override val channelId: ByteVector32, maximum: Long) extends ChannelException(channelId, s"too many accepted htlcs: maximum=$maximum")
121+
case class TooManySmallHtlcs (override val channelId: ByteVector32, number: Long, below: MilliSatoshi) extends ChannelException(channelId, s"too many small htlcs: $number HTLCs below $below")
122+
case class ConfidenceTooLow (override val channelId: ByteVector32, confidence: Double, occupancy: Double) extends ChannelException(channelId, s"confidence too low: confidence=$confidence occupancy=$occupancy")
121123
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")
122124
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")
123125
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: 58 additions & 8 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

@@ -552,6 +565,14 @@ case class Commitment(fundingTxIndex: Long,
552565
return Left(TooManyAcceptedHtlcs(params.channelId, maximum = params.localParams.maxAcceptedHtlcs))
553566
}
554567

568+
// Jamming protection
569+
// Must be the last checks so that they can be ignored for shadow deployment.
570+
for ((amountMsat, i) <- incomingHtlcs.toSeq.map(_.amountMsat).sorted.zipWithIndex) {
571+
if ((amountMsat.toLong < 1) || (math.log(amountMsat.toLong.toDouble) * params.localParams.maxAcceptedHtlcs / math.log(params.localParams.maxHtlcValueInFlightMsat.toLong.toDouble / params.localParams.maxAcceptedHtlcs) < i)) {
572+
return Left(TooManySmallHtlcs(params.channelId, number = i + 1, below = amountMsat))
573+
}
574+
}
575+
555576
Right(())
556577
}
557578

@@ -835,7 +856,7 @@ case class Commitments(params: ChannelParams,
835856
* @param cmd add HTLC command
836857
* @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)
837858
*/
838-
def sendAdd(cmd: CMD_ADD_HTLC, currentHeight: BlockHeight, channelConf: ChannelConf, feerates: FeeratesPerKw, feeConf: OnChainFeeConf): Either[ChannelException, (Commitments, UpdateAddHtlc)] = {
859+
def sendAdd(cmd: CMD_ADD_HTLC, currentHeight: BlockHeight, channelConf: ChannelConf, feerates: FeeratesPerKw, feeConf: OnChainFeeConf)(implicit log: LoggingAdapter): Either[ChannelException, (Commitments, UpdateAddHtlc)] = {
839860
// we must ensure we're not relaying htlcs that are already expired, otherwise the downstream channel will instantly close
840861
// NB: we add a 3 blocks safety to reduce the probability of running into this when our bitcoin node is slightly outdated
841862
val minExpiry = CltvExpiry(currentHeight + 3)
@@ -859,12 +880,28 @@ case class Commitments(params: ChannelParams,
859880
val changes1 = changes.addLocalProposal(add).copy(localNextHtlcId = changes.localNextHtlcId + 1)
860881
val originChannels1 = originChannels + (add.id -> cmd.origin)
861882
// 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) }
883+
val canSendAdds = active.map(_.canSendAdd(add.amountMsat, params, changes1, feerates, feeConf, cmd.confidence))
884+
// Log only for jamming protection.
885+
canSendAdds.collectFirst {
886+
case Left(f: TooManySmallHtlcs) =>
887+
log.info("TooManySmallHtlcs: {} outgoing HTLCs are below {}}", f.number, f.below)
888+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
889+
case Left(f: ConfidenceTooLow) =>
890+
log.info("ConfidenceTooLow: confidence is {}% while channel is {}% full", (100 * f.confidence).toInt, (100 * f.occupancy).toInt)
891+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
892+
}
893+
canSendAdds.flatMap { // TODO: We ignore jamming protection, delete this flatMap to activate jamming protection.
894+
case Left(_: TooManySmallHtlcs) | Left(_: ConfidenceTooLow) => None
895+
case x => Some(x)
896+
}
897+
.collectFirst { case Left(f) =>
898+
Metrics.dropHtlc(f, Tags.Directions.Outgoing)
899+
Left(f)
900+
}
864901
.getOrElse(Right(copy(changes = changes1, originChannels = originChannels1), add))
865902
}
866903

867-
def receiveAdd(add: UpdateAddHtlc, feerates: FeeratesPerKw, feeConf: OnChainFeeConf): Either[ChannelException, Commitments] = {
904+
def receiveAdd(add: UpdateAddHtlc, feerates: FeeratesPerKw, feeConf: OnChainFeeConf)(implicit log: LoggingAdapter): Either[ChannelException, Commitments] = {
868905
if (add.id != changes.remoteNextHtlcId) {
869906
return Left(UnexpectedHtlcId(channelId, expected = changes.remoteNextHtlcId, actual = add.id))
870907
}
@@ -877,8 +914,21 @@ case class Commitments(params: ChannelParams,
877914

878915
val changes1 = changes.addRemoteProposal(add).copy(remoteNextHtlcId = changes.remoteNextHtlcId + 1)
879916
// we verify that this htlc is allowed in every active commitment
880-
active.map(_.canReceiveAdd(add.amountMsat, params, changes1, feerates, feeConf))
881-
.collectFirst { case Left(f) => Left(f) }
917+
val canReceiveAdds = active.map(_.canReceiveAdd(add.amountMsat, params, changes1, feerates, feeConf))
918+
// Log only for jamming protection.
919+
canReceiveAdds.collectFirst {
920+
case Left(f: TooManySmallHtlcs) =>
921+
log.info("TooManySmallHtlcs: {} incoming HTLCs are below {}}", f.number, f.below)
922+
Metrics.dropHtlc(f, Tags.Directions.Incoming)
923+
}
924+
canReceiveAdds.flatMap { // TODO: We ignore jamming protection, delete this flatMap to activate jamming protection.
925+
case Left(_: TooManySmallHtlcs) | Left(_: ConfidenceTooLow) => None
926+
case x => Some(x)
927+
}
928+
.collectFirst { case Left(f) =>
929+
Metrics.dropHtlc(f, Tags.Directions.Incoming)
930+
Left(f)
931+
}
882932
.getOrElse(Right(copy(changes = changes1)))
883933
}
884934

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)