Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a channel congestion control mechanism #2330

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,11 @@ eclair {
dust-limit-satoshis = 546
max-remote-dust-limit-satoshis = 600
htlc-minimum-msat = 1
// The following parameters apply to each HTLC direction (incoming or outgoing), which means that the total HTLC limits will be twice what is set here
// The following parameters apply to each HTLC direction (incoming or outgoing), which means that the total HTLC limits will be twice what is set here.
// The smallest value of max-htlc-value-in-flight-msat and max-htlc-value-in-flight-percent will be applied when opening channels.
// If for example you open a 60 mBTC channel, eclair will set max-htlc-value-in-flight to 27 mBTC.
max-htlc-value-in-flight-msat = 5000000000 // 50 mBTC
max-htlc-value-in-flight-percent = 45 // 45% of the channel capacity
max-accepted-htlcs = 30

reserve-to-funding-ratio = 0.01 // recommended by BOLT #2
Expand Down
3 changes: 2 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,8 @@ object NodeParams extends Logging {
dustLimit = dustLimitSatoshis,
maxRemoteDustLimit = Satoshi(config.getLong("channel.max-remote-dust-limit-satoshis")),
htlcMinimum = htlcMinimum,
maxHtlcValueInFlightMsat = UInt64(config.getLong("channel.max-htlc-value-in-flight-msat")),
maxHtlcValueInFlightMsat = MilliSatoshi(config.getLong("channel.max-htlc-value-in-flight-msat")),
maxHtlcValueInFlightPercent = config.getInt("channel.max-htlc-value-in-flight-percent"),
maxAcceptedHtlcs = maxAcceptedHtlcs,
reserveToFundingRatio = config.getDouble("channel.reserve-to-funding-ratio"),
maxReserveToFundingRatio = config.getDouble("channel.max-reserve-to-funding-ratio"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright 2022 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.channel

import akka.event.LoggingAdapter
import fr.acinq.bitcoin.scalacompat.Satoshi
import fr.acinq.eclair.MilliSatoshi
import fr.acinq.eclair.channel.HtlcFiltering.FilteredHtlcs
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.transactions.{CommitmentSpec, IncomingHtlc, OutgoingHtlc}
import fr.acinq.eclair.wire.protocol.UpdateAddHtlc

/**
* Created by t-bast on 22/06/2022.
*/

/**
* Channels have a limited number of HTLCs that can be in-flight at a given time, because the commitment transaction
* cannot have an unbounded number of outputs. Malicious actors can exploit this by filling our channels with HTLCs and
* waiting as long as possible before failing them.
*
* To increase the cost of this attack, we don't let our channels be filled with low-value HTLCs. When we already have
* many low-value HTLCs in-flight, we only accept higher value HTLCs. Attackers will have to lock non-negligible amounts
* to carry out the attack.
*/
object ChannelCongestionControl {

case class HtlcBucket(threshold: MilliSatoshi, size: Int) {
def allowHtlc(add: UpdateAddHtlc, current: Seq[UpdateAddHtlc]): Boolean = {
// We allow the HTLC if it belongs to a bigger bucket or if the bucket isn't full.
add.amountMsat > threshold || current.count(_.amountMsat <= threshold) < size
}
}

case class CongestionConfig(maxAcceptedHtlcs: Int, maxHtlcValueInFlight: MilliSatoshi, buckets: Seq[HtlcBucket]) {
def allowHtlc(add: UpdateAddHtlc, current: Seq[UpdateAddHtlc], trimThreshold: Satoshi)(implicit log: LoggingAdapter): Boolean = {
// We allow the HTLC if it's trimmed (since it doesn't use an output in the commit tx) or if we can find a bucket that isn't full.
val allow = add.amountMsat < trimThreshold || buckets.forall(_.allowHtlc(add, current))
if (!allow) {
log.info("htlc rejected by congestion control (amount={} max-accepted={} max-in-flight={}): current={}", add.amountMsat, maxAcceptedHtlcs, maxHtlcValueInFlight, current.map(_.amountMsat).sorted.mkString(", "))
}
allow
}
}

object CongestionConfig {
/**
* With the following configuration, if we allow 30 HTLCs and a maximum value in-flight of 250 000 sats, an attacker
* would need to lock 165 000 sats in order to fill our channel.
*/
def apply(maxAcceptedHtlcs: Int, maxHtlcValueInFlight: MilliSatoshi): CongestionConfig = {
val buckets = Seq(
HtlcBucket(maxHtlcValueInFlight / 100, maxAcceptedHtlcs / 2), // allow at most 50% of htlcs below 1% of our max-in-flight
HtlcBucket(maxHtlcValueInFlight * 5 / 100, maxAcceptedHtlcs * 8 / 10), // allow at most 80% of htlcs below 5% of our max-in-flight
HtlcBucket(maxHtlcValueInFlight * 10 / 100, maxAcceptedHtlcs * 9 / 10), // allow at most 90% of htlcs below 10% of our max-in-flight
)
CongestionConfig(maxAcceptedHtlcs, maxHtlcValueInFlight, buckets)
}
}

def shouldSendHtlc(add: UpdateAddHtlc,
localSpec: CommitmentSpec,
localDustLimit: Satoshi,
localMaxAcceptedHtlcs: Int,
remoteSpec: CommitmentSpec,
remoteDustLimit: Satoshi,
remoteMaxAcceptedHtlcs: Int,
maxHtlcValueInFlight: MilliSatoshi,
commitmentFormat: CommitmentFormat)(implicit log: LoggingAdapter): Boolean = {
// We apply the most restrictive value between our peer's and ours.
val maxAcceptedHtlcs = localMaxAcceptedHtlcs.min(remoteMaxAcceptedHtlcs)
val config = CongestionConfig(maxAcceptedHtlcs, maxHtlcValueInFlight)
val localOk = {
val pending = trimOfferedHtlcs(localDustLimit, localSpec, commitmentFormat).map(_.add)
val trimThreshold = offeredHtlcTrimThreshold(localDustLimit, localSpec, commitmentFormat)
config.allowHtlc(add, pending, trimThreshold)
}
val remoteOk = {
val pending = trimReceivedHtlcs(remoteDustLimit, remoteSpec, commitmentFormat).map(_.add)
val trimThreshold = receivedHtlcTrimThreshold(remoteDustLimit, remoteSpec, commitmentFormat)
config.allowHtlc(add, pending, trimThreshold)
}
localOk && remoteOk
}

def filterBeforeForward(localSpec: CommitmentSpec,
localDustLimit: Satoshi,
localMaxAcceptedHtlcs: Int,
remoteSpec: CommitmentSpec,
remoteDustLimit: Satoshi,
receivedHtlcs: FilteredHtlcs,
maxHtlcValueInFlight: MilliSatoshi,
commitmentFormat: CommitmentFormat)(implicit log: LoggingAdapter): FilteredHtlcs = {
val config = CongestionConfig(localMaxAcceptedHtlcs, maxHtlcValueInFlight)
val (_, _, result) = receivedHtlcs.accepted.foldLeft((localSpec, remoteSpec, receivedHtlcs.copy(accepted = Seq.empty))) {
case ((currentLocalSpec, currentRemoteSpec, currentHtlcs), add) =>
val localOk = {
val pending = trimReceivedHtlcs(localDustLimit, currentLocalSpec, commitmentFormat).map(_.add)
val trimThreshold = receivedHtlcTrimThreshold(localDustLimit, currentLocalSpec, commitmentFormat)
config.allowHtlc(add, pending, trimThreshold)
}
val remoteOk = {
val pending = trimOfferedHtlcs(remoteDustLimit, currentRemoteSpec, commitmentFormat).map(_.add)
val trimThreshold = offeredHtlcTrimThreshold(remoteDustLimit, currentRemoteSpec, commitmentFormat)
config.allowHtlc(add, pending, trimThreshold)
}
if (localOk && remoteOk) {
val nextLocalSpec = CommitmentSpec.addHtlc(currentLocalSpec, IncomingHtlc(add))
val nextRemoteSpec = CommitmentSpec.addHtlc(currentRemoteSpec, OutgoingHtlc(add))
(nextLocalSpec, nextRemoteSpec, currentHtlcs.accept(add))
} else {
(currentLocalSpec, currentRemoteSpec, currentHtlcs.reject(add))
}
}
result
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ final case class DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT(commitments: Com
case class LocalParams(nodeId: PublicKey,
fundingKeyPath: DeterministicWallet.KeyPath,
dustLimit: Satoshi,
maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi
maxHtlcValueInFlightMsat: MilliSatoshi,
requestedChannelReserve_opt: Option[Satoshi],
htlcMinimum: MilliSatoshi,
toSelfDelay: CltvExpiryDelta,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ case class ExpiryTooBig (override val channelId: Byte
case class HtlcValueTooSmall (override val channelId: ByteVector32, minimum: MilliSatoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"htlc value too small: minimum=$minimum actual=$actual")
case class HtlcValueTooHighInFlight (override val channelId: ByteVector32, maximum: UInt64, actual: MilliSatoshi) extends ChannelException(channelId, s"in-flight htlcs hold too much value: maximum=$maximum actual=$actual")
case class TooManyAcceptedHtlcs (override val channelId: ByteVector32, maximum: Long) extends ChannelException(channelId, s"too many accepted htlcs: maximum=$maximum")
case class HtlcRejectedByCongestionControl (override val channelId: ByteVector32, amount: MilliSatoshi) extends ChannelException(channelId, s"htlc rejected to avoid channel congestion: amount=$amount")
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")
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")
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")
Expand Down
Loading