From fb3797975b38c01f6a3ec9df7eeeaf791535ae63 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 9 Sep 2021 18:34:22 +0200 Subject: [PATCH 01/18] Add max dust htlc exposure config field This configuration field lets node operators decide on the amount of dust htlcs that can be in-flight in each channel. In case the channel is force-closed, up to this amount may be lost in miner fees. When sending and receiving htlcs, we check whether they would overflow our configured dust exposure, and fail them instantly if they do. --- eclair-core/src/main/resources/reference.conf | 5 + .../scala/fr/acinq/eclair/NodeParams.scala | 6 +- .../eclair/blockchain/fee/FeeEstimator.scala | 2 +- .../fr/acinq/eclair/channel/Channel.scala | 56 ++++++--- .../eclair/channel/ChannelExceptions.scala | 1 + .../fr/acinq/eclair/channel/Commitments.scala | 106 +++++++++++++++--- .../eclair/transactions/CommitmentSpec.scala | 43 ++++++- .../eclair/transactions/Transactions.scala | 14 +++ .../scala/fr/acinq/eclair/StartupSpec.scala | 14 ++- .../scala/fr/acinq/eclair/TestConstants.scala | 12 +- .../blockchain/fee/FeeEstimatorSpec.scala | 14 ++- .../eclair/channel/CommitmentsSpec.scala | 61 +++++++--- .../channel/states/e/NormalStateSpec.scala | 82 ++++++++++++++ .../transactions/CommitmentSpecSpec.scala | 87 +++++++++++++- 14 files changed, 430 insertions(+), 73 deletions(-) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 497466182c..d21b3dc295 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -142,6 +142,10 @@ eclair { // when using anchor outputs, we only need to use a commitment feerate that allows the tx to propagate: we will use CPFP to speed up confirmation if needed. // the following value is the maximum feerate we'll use for our commit tx (in sat/byte) anchor-output-max-commit-feerate = 10 + // dust htlcs cannot be claimed on-chain and will instead go to miners if the channel is force-closed + // a malicious peer may want to abuse that, so we limit the value of pending dust htlcs in a channel + // this value cannot be lowered too much if you plan to relay a lot of htlcs + max-dust-htlc-exposure-satoshis = 100000 } override-feerate-tolerance = [ // optional per-node feerate tolerance # { @@ -150,6 +154,7 @@ eclair { # ratio-low = 0.1 # ratio-high = 20.0 # anchor-output-max-commit-feerate = 10 + # max-dust-htlc-exposure-satoshis = 25000 # } # } ] diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 734f24123a..626aafd046 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -383,14 +383,16 @@ object NodeParams extends Logging { defaultFeerateTolerance = FeerateTolerance( config.getDouble("on-chain-fees.feerate-tolerance.ratio-low"), config.getDouble("on-chain-fees.feerate-tolerance.ratio-high"), - FeeratePerKw(FeeratePerByte(Satoshi(config.getLong("on-chain-fees.feerate-tolerance.anchor-output-max-commit-feerate")))) + FeeratePerKw(FeeratePerByte(Satoshi(config.getLong("on-chain-fees.feerate-tolerance.anchor-output-max-commit-feerate")))), + Satoshi(config.getLong("on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis")) ), perNodeFeerateTolerance = config.getConfigList("on-chain-fees.override-feerate-tolerance").asScala.map { e => val nodeId = PublicKey(ByteVector.fromValidHex(e.getString("nodeid"))) val tolerance = FeerateTolerance( e.getDouble("feerate-tolerance.ratio-low"), e.getDouble("feerate-tolerance.ratio-high"), - FeeratePerKw(FeeratePerByte(Satoshi(e.getLong("feerate-tolerance.anchor-output-max-commit-feerate")))) + FeeratePerKw(FeeratePerByte(Satoshi(e.getLong("feerate-tolerance.anchor-output-max-commit-feerate")))), + Satoshi(e.getLong("feerate-tolerance.max-dust-htlc-exposure-satoshis")) ) nodeId -> tolerance }.toMap diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala index 5d8d3ce957..fa3696c9fb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala @@ -32,7 +32,7 @@ trait FeeEstimator { case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int) -case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw) { +case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw, maxDustHtlcExposure: Satoshi) { /** * @param channelType channel type * @param networkFeerate reference fee rate (value we estimate from our view of the network) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index 0443668cb1..c0f05996b9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -30,6 +30,7 @@ import fr.acinq.eclair.blockchain._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient +import fr.acinq.eclair.channel.Commitments.PostRevocationAction import fr.acinq.eclair.channel.Helpers.{Closing, Funding, getRelayFees} import fr.acinq.eclair.channel.Monitoring.Metrics.ProcessMessage import fr.acinq.eclair.channel.Monitoring.{Metrics, Tags} @@ -41,6 +42,7 @@ import fr.acinq.eclair.db.DbEventHandler.ChannelEvent.EventType import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer import fr.acinq.eclair.payment.PaymentSettlingOnChain +import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.Transactions.{ClosingTx, TxOwner} import fr.acinq.eclair.transactions._ @@ -839,19 +841,28 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo case Event(revocation: RevokeAndAck, d: DATA_NORMAL) => // we received a revocation because we sent a signature // => all our changes have been acked - Commitments.receiveRevocation(d.commitments, revocation) match { - case Right((commitments1, forwards)) => + Commitments.receiveRevocation(d.commitments, revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).maxDustHtlcExposure) match { + case Right((commitments1, actions)) => cancelTimer(RevocationTimeout.toString) log.debug("received a new rev, spec:\n{}", Commitments.specs2String(commitments1)) - forwards.foreach { - case Right(forwardAdd) => - log.debug("forwarding {} to relayer", forwardAdd) - relayer ! forwardAdd - case Left(result) => + actions.foreach { + case PostRevocationAction.RelayHtlc(add) => + log.debug("forwarding incoming htlc {} to relayer", add) + relayer ! Relayer.RelayForward(add) + case PostRevocationAction.RejectHtlc(add) => + log.debug("rejecting incoming htlc {}", add) + // NB: we don't set commit = true, we will sign all updates at once afterwards. + self ! CMD_FAIL_HTLC(add.id, Right(TemporaryChannelFailure(d.channelUpdate))) + case PostRevocationAction.RelayFailure(result) => log.debug("forwarding {} to relayer", result) relayer ! result } - if (Commitments.localHasChanges(commitments1) && d.commitments.remoteNextCommitInfo.left.map(_.reSignAsap) == Left(true)) { + val signAsap = actions.exists { + case _: PostRevocationAction.RejectHtlc => true + case _: PostRevocationAction.RelayHtlc => false + case _: PostRevocationAction.RelayFailure => false + } || (Commitments.localHasChanges(commitments1) && d.commitments.remoteNextCommitInfo.left.map(_.reSignAsap) == Left(true)) + if (signAsap) { self ! CMD_SIGN() } if (d.remoteShutdown.isDefined && !Commitments.localHasUnsignedOutgoingHtlcs(commitments1)) { @@ -1199,18 +1210,22 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo case Event(revocation: RevokeAndAck, d@DATA_SHUTDOWN(commitments, localShutdown, remoteShutdown, closingFeerates)) => // we received a revocation because we sent a signature // => all our changes have been acked including the shutdown message - Commitments.receiveRevocation(commitments, revocation) match { - case Right((commitments1, forwards)) => + Commitments.receiveRevocation(commitments, revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).maxDustHtlcExposure) match { + case Right((commitments1, actions)) => cancelTimer(RevocationTimeout.toString) log.debug("received a new rev, spec:\n{}", Commitments.specs2String(commitments1)) - forwards.foreach { - case Right(forwardAdd) => + actions.foreach { + case PostRevocationAction.RelayHtlc(add) => // BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown. - log.debug("closing in progress: failing {}", forwardAdd.add) - self ! CMD_FAIL_HTLC(forwardAdd.add.id, Right(PermanentChannelFailure), commit = true) - case Left(forward) => - log.debug("forwarding {} to relayer", forward) - relayer ! forward + log.debug("closing in progress: failing {}", add) + self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure)) + case PostRevocationAction.RejectHtlc(add) => + // BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown. + log.debug("closing in progress: rejecting {}", add) + self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure)) + case PostRevocationAction.RelayFailure(result) => + log.debug("forwarding {} to relayer", result) + relayer ! result } if (commitments1.hasNoPendingHtlcsOrFeeUpdate) { log.debug("switching to NEGOTIATING spec:\n{}", Commitments.specs2String(commitments1)) @@ -1223,7 +1238,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo goto(NEGOTIATING) using DATA_NEGOTIATING(commitments1, localShutdown, remoteShutdown, closingTxProposed = List(List()), bestUnpublishedClosingTx_opt = None) storing() } } else { - if (Commitments.localHasChanges(commitments1) && d.commitments.remoteNextCommitInfo.left.map(_.reSignAsap) == Left(true)) { + val signAsap = actions.exists { + case _: PostRevocationAction.RelayHtlc => true + case _: PostRevocationAction.RejectHtlc => true + case _: PostRevocationAction.RelayFailure => false + } || (Commitments.localHasChanges(commitments1) && d.commitments.remoteNextCommitInfo.left.map(_.reSignAsap) == Left(true)) + if (signAsap) { self ! CMD_SIGN() } stay() using d.copy(commitments = commitments1) storing() diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index 879a115fb2..6a74524c9a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala @@ -78,6 +78,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 DustHtlcExposureTooHighInFlight (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") case class RemoteCannotAffordFeesForNewHtlc (override val channelId: ByteVector32, amount: MilliSatoshi, missing: Satoshi, reserve: Satoshi, fees: Satoshi) extends ChannelException(channelId, s"remote can't afford increased commit tx fees once new HTLC is added: missing=$missing reserve=$reserve fees=$fees") case class InvalidHtlcPreimage (override val channelId: ByteVector32, id: Long) extends ChannelException(channelId, s"invalid htlc preimage for htlc id=$id") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index ee3c039950..0fd6134d74 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -26,13 +26,14 @@ import fr.acinq.eclair.channel.Monitoring.Metrics import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager import fr.acinq.eclair.crypto.{Generators, ShaChain} import fr.acinq.eclair.payment.OutgoingPacket -import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.transactions.DirectedHtlc._ import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.transactions._ import fr.acinq.eclair.wire.protocol._ import scodec.bits.ByteVector +import scala.annotation.tailrec + // @formatter:off case class LocalChanges(proposed: List[UpdateMessage], signed: List[UpdateMessage], acked: List[UpdateMessage]) { def all: List[UpdateMessage] = proposed ++ signed ++ acked @@ -135,7 +136,7 @@ case class Commitments(channelId: ByteVector32, remoteChanges.all.exists(_.isInstanceOf[UpdateAddHtlc]) def timedOutOutgoingHtlcs(blockheight: Long): Set[UpdateAddHtlc] = { - def expired(add: UpdateAddHtlc) = blockheight >= add.cltvExpiry.toLong + def expired(add: UpdateAddHtlc): Boolean = blockheight >= add.cltvExpiry.toLong localCommit.spec.htlcs.collect(outgoing).filter(expired) ++ remoteCommit.spec.htlcs.collect(incoming).filter(expired) ++ @@ -179,11 +180,49 @@ case class Commitments(channelId: ByteVector32, * and our HTLC success in case of a force-close. */ def almostTimedOutIncomingHtlcs(blockheight: Long, fulfillSafety: CltvExpiryDelta): Set[UpdateAddHtlc] = { - def nearlyExpired(add: UpdateAddHtlc) = blockheight >= (add.cltvExpiry - fulfillSafety).toLong + def nearlyExpired(add: UpdateAddHtlc): Boolean = blockheight >= (add.cltvExpiry - fulfillSafety).toLong localCommit.spec.htlcs.collect(incoming).filter(nearlyExpired) } + /** Compute our dust exposure in our commit tx (local) and their commit tx (remote). */ + def currentDustExposure(): (MilliSatoshi, MilliSatoshi) = { + val localCommitDustExposure = CommitmentSpec.dustExposure(localCommit.spec, localParams.dustLimit, commitmentFormat) + val remoteCommitDustExposure = CommitmentSpec.dustExposure(remoteCommit.spec, remoteParams.dustLimit, commitmentFormat) + (localCommitDustExposure, remoteCommitDustExposure) + } + + /** Test whether the given incoming htlc contributes to the dust exposure in the local or remote commit tx. */ + def contributesToDustExposure(add: UpdateAddHtlc): (Boolean, Boolean) = { + val contributesToLocalCommitDustExposure = CommitmentSpec.contributesToDustExposure(OutgoingHtlc(add), localCommit.spec, localParams.dustLimit, commitmentFormat) + val contributesToRemoteCommitDustExposure = CommitmentSpec.contributesToDustExposure(IncomingHtlc(add), remoteCommit.spec, remoteParams.dustLimit, commitmentFormat) + (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) + } + + /** Select which incoming HTLCs we should accept and which HTLCs we should reject to avoid overflowing our dust exposure. */ + @tailrec + final def addHtlcsUntilDustExposureReached(maxDustExposure: Satoshi, + localCommitDustExposure: MilliSatoshi, + remoteCommitDustExposure: MilliSatoshi, + receivedHtlcs: Seq[UpdateAddHtlc], + acceptedHtlcs: Seq[UpdateAddHtlc] = Nil, + rejectedHtlcs: Seq[UpdateAddHtlc] = Nil): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { + receivedHtlcs match { + case add :: remaining => + val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = contributesToDustExposure(add) + val rejectHtlc = (contributesToLocalCommitDustExposure && localCommitDustExposure + add.amountMsat > maxDustExposure) || + (contributesToRemoteCommitDustExposure && remoteCommitDustExposure + add.amountMsat > maxDustExposure) + if (rejectHtlc) { + addHtlcsUntilDustExposureReached(maxDustExposure, localCommitDustExposure, remoteCommitDustExposure, remaining, acceptedHtlcs, rejectedHtlcs :+ add) + } else { + val localCommitDustExposure1 = if (contributesToLocalCommitDustExposure) localCommitDustExposure + add.amountMsat else localCommitDustExposure + val remoteCommitDustExposure1 = if (contributesToRemoteCommitDustExposure) remoteCommitDustExposure + add.amountMsat else remoteCommitDustExposure + addHtlcsUntilDustExposureReached(maxDustExposure, localCommitDustExposure1, remoteCommitDustExposure1, remaining, acceptedHtlcs :+ add, rejectedHtlcs) + } + case Nil => (acceptedHtlcs, rejectedHtlcs) + } + } + /** * Return a fully signed commit tx, that can be published as-is. */ @@ -386,6 +425,17 @@ object Commitments { return Left(TooManyAcceptedHtlcs(commitments.channelId, maximum = Seq(commitments1.localParams.maxAcceptedHtlcs, commitments1.remoteParams.maxAcceptedHtlcs).min)) } + // If sending this htlc would overflow our dust exposure, we reject it. + val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).maxDustHtlcExposure + val (localCommitDustExposure, remoteCommitDustExposure) = commitments.currentDustExposure() + val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = commitments.contributesToDustExposure(add) + if (contributesToLocalCommitDustExposure && localCommitDustExposure + add.amountMsat > maxDustExposure) { + return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, localCommitDustExposure + add.amountMsat)) + } + if (contributesToRemoteCommitDustExposure && remoteCommitDustExposure + add.amountMsat > maxDustExposure) { + return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, remoteCommitDustExposure + add.amountMsat)) + } + Right(commitments1, add) } @@ -692,28 +742,56 @@ object Commitments { Right(commitments1, revocation) } - def receiveRevocation(commitments: Commitments, revocation: RevokeAndAck): Either[ChannelException, (Commitments, Seq[Either[RES_ADD_SETTLED[Origin, HtlcResult], Relayer.RelayForward]])] = { - import commitments._ + // @formatter:off + sealed trait PostRevocationAction + object PostRevocationAction { + case class RelayHtlc(incomingHtlc: UpdateAddHtlc) extends PostRevocationAction + case class RejectHtlc(incomingHtlc: UpdateAddHtlc) extends PostRevocationAction + case class RelayFailure(result: RES_ADD_SETTLED[Origin, HtlcResult]) extends PostRevocationAction + } + // @formatter:on + + def receiveRevocation(commitments: Commitments, revocation: RevokeAndAck, maxDustExposure: Satoshi): Either[ChannelException, (Commitments, Seq[PostRevocationAction])] = { // we receive a revocation because we just sent them a sig for their next commit tx - remoteNextCommitInfo match { - case Left(_) if revocation.perCommitmentSecret.publicKey != remoteCommit.remotePerCommitmentPoint => + commitments.remoteNextCommitInfo match { + case Left(_) if revocation.perCommitmentSecret.publicKey != commitments.remoteCommit.remotePerCommitmentPoint => Left(InvalidRevocation(commitments.channelId)) case Left(WaitingForRevocation(theirNextCommit, _, _, _)) => - val forwards = commitments.remoteChanges.signed collect { + val receivedHtlcs = commitments.remoteChanges.signed.collect { // we forward adds downstream only when they have been committed by both sides // it always happen when we receive a revocation, because they send the add, then they sign it, then we sign it - case add: UpdateAddHtlc => Right(Relayer.RelayForward(add)) + case add: UpdateAddHtlc => add + } + val failedHtlcs = commitments.remoteChanges.signed.collect { // same for fails: we need to make sure that they are in neither commitment before propagating the fail upstream case fail: UpdateFailHtlc => val origin = commitments.originChannels(fail.id) val add = commitments.remoteCommit.spec.findIncomingHtlcById(fail.id).map(_.add).get - Left(RES_ADD_SETTLED(origin, add, HtlcResult.RemoteFail(fail))) + RES_ADD_SETTLED(origin, add, HtlcResult.RemoteFail(fail)) // same as above case fail: UpdateFailMalformedHtlc => val origin = commitments.originChannels(fail.id) val add = commitments.remoteCommit.spec.findIncomingHtlcById(fail.id).map(_.add).get - Left(RES_ADD_SETTLED(origin, add, HtlcResult.RemoteFailMalformed(fail))) + RES_ADD_SETTLED(origin, add, HtlcResult.RemoteFailMalformed(fail)) + } + val (acceptedHtlcs, rejectedHtlcs) = { + // the received htlcs have already been added to our commitment (they've been signed by our peer), and may already + // overflow our dust exposure: we artificially remove them before deciding which we'll keep and relay and which + // we'll fail without relaying + val previousCommitments = commitments.copy(localCommit = commitments.localCommit.copy(spec = commitments.localCommit.spec.copy( + htlcs = commitments.localCommit.spec.htlcs.filter { + case IncomingHtlc(add) if receivedHtlcs.contains(add) => false + case _ => true + })) + ) + val (localCommitDustExposure, remoteCommitDustExposure) = previousCommitments.currentDustExposure() + // we sort incoming htlcs by decreasing amount: we want to prioritize higher amounts. + val sortedReceivedHtlcs = receivedHtlcs.sortBy(_.amountMsat).reverse + previousCommitments.addHtlcsUntilDustExposureReached(maxDustExposure, localCommitDustExposure, remoteCommitDustExposure, sortedReceivedHtlcs) } + val actions = acceptedHtlcs.map(add => PostRevocationAction.RelayHtlc(add)) ++ + rejectedHtlcs.map(add => PostRevocationAction.RejectHtlc(add)) ++ + failedHtlcs.map(res => PostRevocationAction.RelayFailure(res)) // the outgoing following htlcs have been completed (fulfilled or failed) when we received this revocation // they have been removed from both local and remote commitment // (since fulfill/fail are sent by remote, they are (1) signed by them, (2) revoked by us, (3) signed by us, (4) revoked by them @@ -721,13 +799,13 @@ object Commitments { // we remove the newly completed htlcs from the origin map val originChannels1 = commitments.originChannels -- completedOutgoingHtlcs val commitments1 = commitments.copy( - localChanges = localChanges.copy(signed = Nil, acked = localChanges.acked ++ localChanges.signed), - remoteChanges = remoteChanges.copy(signed = Nil), + localChanges = commitments.localChanges.copy(signed = Nil, acked = commitments.localChanges.acked ++ commitments.localChanges.signed), + remoteChanges = commitments.remoteChanges.copy(signed = Nil), remoteCommit = theirNextCommit, remoteNextCommitInfo = Right(revocation.nextPerCommitmentPoint), remotePerCommitmentSecrets = commitments.remotePerCommitmentSecrets.addHash(revocation.perCommitmentSecret.value, 0xFFFFFFFFFFFFL - commitments.remoteCommit.index), originChannels = originChannels1) - Right(commitments1, forwards) + Right(commitments1, actions) case Right(_) => Left(UnexpectedRevocation(commitments.channelId)) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index 20113214a4..45d6a15074 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -16,11 +16,11 @@ package fr.acinq.eclair.transactions -import fr.acinq.bitcoin.SatoshiLong -import fr.acinq.eclair.MilliSatoshi -import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.bitcoin.{Satoshi, SatoshiLong} +import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong} /** * Created by PM on 07/12/2016. @@ -126,6 +126,43 @@ object CommitmentSpec { } } + /** + * We include in our dust exposure HTLCs that aren't trimmed but would be if the feerate increased. + * This ensures that we pre-emptively fail some of these untrimmed HTLCs, so that when the feerate increases we reduce + * the risk that we'll overflow our dust exposure. + * However, this cannot fully protect us if the feerate increases too much (in which case we may have to force-close). + */ + def dustBufferFeerate(currentFeerate: FeeratePerKw): FeeratePerKw = { + (currentFeerate * 1.25).max(currentFeerate + FeeratePerKw(FeeratePerByte(10 sat))) + } + + /** Test whether the given HTLC contributes to our dust exposure. */ + def contributesToDustExposure(htlc: DirectedHtlc, spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Boolean = { + val feerate = dustBufferFeerate(spec.htlcTxFeerate(commitmentFormat)) + val threshold = htlc match { + case _: IncomingHtlc => Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) + case _: OutgoingHtlc => Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) + } + htlc.add.amountMsat < threshold + } + + /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes). */ + def dustExposure(spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { + val feerate = dustBufferFeerate(spec.htlcTxFeerate(commitmentFormat)) + dustExposure(spec, feerate, dustLimit, commitmentFormat) + } + + /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes). */ + def dustExposure(spec: CommitmentSpec, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { + val incomingHtlcThreshold = Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) + val outgoingHtlcThreshold = Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) + spec.htlcs.foldLeft(0 msat) { + case (exposure, IncomingHtlc(add)) if add.amountMsat < incomingHtlcThreshold => exposure + add.amountMsat + case (exposure, OutgoingHtlc(add)) if add.amountMsat < outgoingHtlcThreshold => exposure + add.amountMsat + case (exposure, _) => exposure + } + } + def reduce(localCommitSpec: CommitmentSpec, localChanges: List[UpdateMessage], remoteChanges: List[UpdateMessage]): CommitmentSpec = { val spec1 = localChanges.foldLeft(localCommitSpec) { case (spec, u: UpdateAddHtlc) => addHtlc(spec, OutgoingHtlc(u)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 51ae6cb6a6..c8a5160969 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -206,6 +206,13 @@ object Transactions { def offeredHtlcTrimThreshold(dustLimit: Satoshi, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Satoshi = dustLimit + weight2fee(spec.htlcTxFeerate(commitmentFormat), commitmentFormat.htlcTimeoutWeight) + def offeredHtlcTrimThreshold(dustLimit: Satoshi, feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Satoshi = { + commitmentFormat match { + case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat => dustLimit + case _ => dustLimit + weight2fee(feerate, commitmentFormat.htlcTimeoutWeight) + } + } + def trimOfferedHtlcs(dustLimit: Satoshi, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Seq[OutgoingHtlc] = { val threshold = offeredHtlcTrimThreshold(dustLimit, spec, commitmentFormat) spec.htlcs @@ -217,6 +224,13 @@ object Transactions { def receivedHtlcTrimThreshold(dustLimit: Satoshi, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Satoshi = dustLimit + weight2fee(spec.htlcTxFeerate(commitmentFormat), commitmentFormat.htlcSuccessWeight) + def receivedHtlcTrimThreshold(dustLimit: Satoshi, feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Satoshi = { + commitmentFormat match { + case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat => dustLimit + case _ => dustLimit + weight2fee(feerate, commitmentFormat.htlcSuccessWeight) + } + } + def trimReceivedHtlcs(dustLimit: Satoshi, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Seq[IncomingHtlc] = { val threshold = receivedHtlcTrimThreshold(dustLimit, spec, commitmentFormat) spec.htlcs diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index c52968528d..ce76c160d8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -16,10 +16,10 @@ package fr.acinq.eclair -import com.typesafe.config.{Config, ConfigFactory, ConfigResolveOptions} +import com.typesafe.config.{Config, ConfigFactory} import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{Block, SatoshiLong} -import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} +import fr.acinq.eclair.FeatureSupport.Mandatory import fr.acinq.eclair.Features._ import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw, FeerateTolerance} import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager} @@ -58,7 +58,7 @@ class StartupSpec extends AnyFunSuite { assert(baseUkraineAlias.getBytes.length === 27) // we add 2 UTF-8 chars, each is 3-bytes long -> total new length 33 bytes! - val goUkraineGo = s"${threeBytesUTFChar}BitcoinLightningNodeUkraine${threeBytesUTFChar}" + val goUkraineGo = s"${threeBytesUTFChar}BitcoinLightningNodeUkraine$threeBytesUTFChar" assert(goUkraineGo.length === 29) assert(goUkraineGo.getBytes.length === 33) // too long for the alias, should be truncated @@ -174,6 +174,7 @@ class StartupSpec extends AnyFunSuite { | ratio-low = 0.1 | ratio-high = 15.0 | anchor-output-max-commit-feerate = 15 + | max-dust-htlc-exposure-satoshis = 25000 | } | }, | { @@ -182,6 +183,7 @@ class StartupSpec extends AnyFunSuite { | ratio-low = 0.75 | ratio-high = 5.0 | anchor-output-max-commit-feerate = 5 + | max-dust-htlc-exposure-satoshis = 50000 | } | }, | ] @@ -189,9 +191,9 @@ class StartupSpec extends AnyFunSuite { ) val nodeParams = makeNodeParamsWithDefaults(perNodeConf.withFallback(defaultConf)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) === FeerateTolerance(0.1, 15.0, FeeratePerKw(FeeratePerByte(15 sat)))) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) === FeerateTolerance(0.75, 5.0, FeeratePerKw(FeeratePerByte(5 sat)))) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) === FeerateTolerance(0.5, 10.0, FeeratePerKw(FeeratePerByte(10 sat)))) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) === FeerateTolerance(0.1, 15.0, FeeratePerKw(FeeratePerByte(15 sat)), 25_000 sat)) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) === FeerateTolerance(0.75, 5.0, FeeratePerKw(FeeratePerByte(5 sat)), 50_000 sat)) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) === FeerateTolerance(0.5, 10.0, FeeratePerKw(FeeratePerByte(10 sat)), 100_000 sat)) } test("NodeParams should fail if htlc-minimum-msat is set to 0") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index de489dfeac..0f0d45c53a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -117,7 +117,7 @@ object TestConstants { feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, - defaultFeerateTolerance = FeerateTolerance(0.5, 8.0, anchorOutputsFeeratePerKw), + defaultFeerateTolerance = FeerateTolerance(0.5, 8.0, anchorOutputsFeeratePerKw, 25_000 sat), perNodeFeerateTolerance = Map.empty ), maxHtlcValueInFlightMsat = UInt64(500000000), @@ -171,7 +171,7 @@ object TestConstants { encodingType = EncodingType.COMPRESSED_ZLIB, channelRangeChunkSize = 20, channelQueryChunkSize = 5, - pathFindingExperimentConf = PathFindingExperimentConf(Map(("alice-test-experiment" -> PathFindingConf( + pathFindingExperimentConf = PathFindingExperimentConf(Map("alice-test-experiment" -> PathFindingConf( randomize = false, boundaries = SearchBoundaries( maxFeeFlat = (21 sat).toMilliSatoshi, @@ -190,7 +190,7 @@ object TestConstants { maxParts = 10, ), experimentName = "alice-test-experiment", - experimentPercentage = 100)))) + experimentPercentage = 100))) ), socksProxy_opt = None, maxPaymentAttempts = 5, @@ -243,7 +243,7 @@ object TestConstants { feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, - defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw), + defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw, 50_000 sat), perNodeFeerateTolerance = Map.empty ), maxHtlcValueInFlightMsat = UInt64.MaxValue, // Bob has no limit on the combined max value of in-flight htlcs @@ -297,7 +297,7 @@ object TestConstants { encodingType = EncodingType.UNCOMPRESSED, channelRangeChunkSize = 20, channelQueryChunkSize = 5, - pathFindingExperimentConf = PathFindingExperimentConf(Map(("bob-test-experiment" -> PathFindingConf( + pathFindingExperimentConf = PathFindingExperimentConf(Map("bob-test-experiment" -> PathFindingConf( randomize = false, boundaries = SearchBoundaries( maxFeeFlat = (21 sat).toMilliSatoshi, @@ -316,7 +316,7 @@ object TestConstants { maxParts = 10, ), experimentName = "bob-test-experiment", - experimentPercentage = 100)))) + experimentPercentage = 100))) ), socksProxy_opt = None, maxPaymentAttempts = 5, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala index b4c139a2ec..48f3477491 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala @@ -25,8 +25,10 @@ import org.scalatest.funsuite.AnyFunSuite class FeeEstimatorSpec extends AnyFunSuite { + val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), 15000 sat) + test("should update fee when diff ratio exceeded") { - val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat)), Map.empty) + val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1000 sat))) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(900 sat))) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1100 sat))) @@ -37,7 +39,7 @@ class FeeEstimatorSpec extends AnyFunSuite { test("get commitment feerate") { val feeEstimator = new TestFeeEstimator() val channelType = ChannelTypes.Standard - val feeConf = OnChainFeeConf(FeeTargets(1, 2, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat)), Map.empty) + val feeConf = OnChainFeeConf(FeeTargets(1, 2, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(blocks_2 = FeeratePerKw(5000 sat))) assert(feeConf.getCommitmentFeerate(randomKey().publicKey, channelType, 100000 sat, None) === FeeratePerKw(5000 sat)) @@ -49,10 +51,10 @@ class FeeEstimatorSpec extends AnyFunSuite { test("get commitment feerate (anchor outputs)") { val feeEstimator = new TestFeeEstimator() val defaultNodeId = randomKey().publicKey - val defaultMaxCommitFeerate = FeeratePerKw(2500 sat) + val defaultMaxCommitFeerate = defaultFeerateTolerance.anchorOutputMaxCommitFeerate val overrideNodeId = randomKey().publicKey val overrideMaxCommitFeerate = defaultMaxCommitFeerate * 2 - val feeConf = OnChainFeeConf(FeeTargets(1, 2, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, FeerateTolerance(0.5, 2.0, defaultMaxCommitFeerate), Map(overrideNodeId -> FeerateTolerance(0.5, 2.0, overrideMaxCommitFeerate))) + val feeConf = OnChainFeeConf(FeeTargets(1, 2, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate))) feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(blocks_2 = defaultMaxCommitFeerate / 2, mempoolMinFee = FeeratePerKw(250 sat))) assert(feeConf.getCommitmentFeerate(defaultNodeId, ChannelTypes.AnchorOutputs, 100000 sat, None) === defaultMaxCommitFeerate / 2) @@ -87,7 +89,7 @@ class FeeEstimatorSpec extends AnyFunSuite { } test("fee difference too high") { - val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat)) + val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), maxDustHtlcExposure = 25000 sat) val channelType = ChannelTypes.Standard val testCases = Seq( (FeeratePerKw(500 sat), FeeratePerKw(500 sat), false), @@ -106,7 +108,7 @@ class FeeEstimatorSpec extends AnyFunSuite { } test("fee difference too high (anchor outputs)") { - val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat)) + val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), maxDustHtlcExposure = 25000 sat) val testCases = Seq( (FeeratePerKw(500 sat), FeeratePerKw(500 sat)), (FeeratePerKw(500 sat), FeeratePerKw(2500 sat)), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index 7a7f50b29e..33f0879b87 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -41,7 +41,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with implicit val log: akka.event.LoggingAdapter = akka.event.NoLogging - val feeConfNoMismatch = OnChainFeeConf(FeeTargets(6, 2, 2, 6), new TestFeeEstimator, closeOnOfflineMismatch = false, 1.0, FeerateTolerance(0.00001, 100000.0, TestConstants.anchorOutputsFeeratePerKw), Map.empty) + val feeConfNoMismatch = OnChainFeeConf(FeeTargets(6, 2, 2, 6), new TestFeeEstimator, closeOnOfflineMismatch = false, 1.0, FeerateTolerance(0.00001, 100000.0, TestConstants.anchorOutputsFeeratePerKw, 100000 sat), Map.empty) override def withFixture(test: OneArgTest): Outcome = { val setup = init() @@ -61,6 +61,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val b = 190000000 msat // initial balance bob val p = 42000000 msat // a->b payment val htlcOutputFee = 2 * 1720000 msat // fee due to the additional htlc output; we count it twice because we keep a reserve for a x2 feerate increase + val maxDustExposure = 500000 sat val ac0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments val bc0 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments @@ -88,7 +89,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc2.availableBalanceForSend == b) assert(bc2.availableBalanceForReceive == a - p - htlcOutputFee) - val Right((ac3, _)) = receiveRevocation(ac2, revocation1) + val Right((ac3, _)) = receiveRevocation(ac2, revocation1, maxDustExposure) assert(ac3.availableBalanceForSend == a - p - htlcOutputFee) assert(ac3.availableBalanceForReceive == b) @@ -100,7 +101,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac4.availableBalanceForSend == a - p - htlcOutputFee) assert(ac4.availableBalanceForReceive == b) - val Right((bc4, _)) = receiveRevocation(bc3, revocation2) + val Right((bc4, _)) = receiveRevocation(bc3, revocation2, maxDustExposure) assert(bc4.availableBalanceForSend == b) assert(bc4.availableBalanceForReceive == a - p - htlcOutputFee) @@ -121,7 +122,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac6.availableBalanceForSend == a - p) assert(ac6.availableBalanceForReceive == b + p) - val Right((bc7, _)) = receiveRevocation(bc6, revocation3) + val Right((bc7, _)) = receiveRevocation(bc6, revocation3, maxDustExposure) assert(bc7.availableBalanceForSend == b + p) assert(bc7.availableBalanceForReceive == a - p) @@ -133,7 +134,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc8.availableBalanceForSend == b + p) assert(bc8.availableBalanceForReceive == a - p) - val Right((ac8, _)) = receiveRevocation(ac7, revocation4) + val Right((ac8, _)) = receiveRevocation(ac7, revocation4, maxDustExposure) assert(ac8.availableBalanceForSend == a - p) assert(ac8.availableBalanceForReceive == b + p) } @@ -145,6 +146,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val b = 190000000 msat // initial balance bob val p = 42000000 msat // a->b payment val htlcOutputFee = 2 * 1720000 msat // fee due to the additional htlc output; we count it twice because we keep a reserve for a x2 feerate increase + val maxDustExposure = 500000 sat val ac0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments val bc0 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments @@ -172,7 +174,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc2.availableBalanceForSend == b) assert(bc2.availableBalanceForReceive == a - p - htlcOutputFee) - val Right((ac3, _)) = receiveRevocation(ac2, revocation1) + val Right((ac3, _)) = receiveRevocation(ac2, revocation1, maxDustExposure) assert(ac3.availableBalanceForSend == a - p - htlcOutputFee) assert(ac3.availableBalanceForReceive == b) @@ -184,7 +186,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac4.availableBalanceForSend == a - p - htlcOutputFee) assert(ac4.availableBalanceForReceive == b) - val Right((bc4, _)) = receiveRevocation(bc3, revocation2) + val Right((bc4, _)) = receiveRevocation(bc3, revocation2, maxDustExposure) assert(bc4.availableBalanceForSend == b) assert(bc4.availableBalanceForReceive == a - p - htlcOutputFee) @@ -205,7 +207,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac6.availableBalanceForSend == a) assert(ac6.availableBalanceForReceive == b) - val Right((bc7, _)) = receiveRevocation(bc6, revocation3) + val Right((bc7, _)) = receiveRevocation(bc6, revocation3, maxDustExposure) assert(bc7.availableBalanceForSend == b) assert(bc7.availableBalanceForReceive == a) @@ -217,7 +219,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc8.availableBalanceForSend == b) assert(bc8.availableBalanceForReceive == a) - val Right((ac8, _)) = receiveRevocation(ac7, revocation4) + val Right((ac8, _)) = receiveRevocation(ac7, revocation4, maxDustExposure) assert(ac8.availableBalanceForSend == a) assert(ac8.availableBalanceForReceive == b) } @@ -231,6 +233,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val p2 = 20000000 msat // a->b payment val p3 = 40000000 msat // b->a payment val htlcOutputFee = 2 * 1720000 msat // fee due to the additional htlc output; we count it twice because we keep a reserve for a x2 feerate increase + val maxDustExposure = 500000 sat val ac0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments val bc0 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments @@ -277,7 +280,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc4.availableBalanceForSend == b - p3) assert(bc4.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee) - val Right((ac5, _)) = receiveRevocation(ac4, revocation1) + val Right((ac5, _)) = receiveRevocation(ac4, revocation1, maxDustExposure) assert(ac5.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee) assert(ac5.availableBalanceForReceive == b - p3) @@ -289,7 +292,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac6.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) // alice has acknowledged b's hltc so it needs to pay the fee for it assert(ac6.availableBalanceForReceive == b - p3) - val Right((bc6, _)) = receiveRevocation(bc5, revocation2) + val Right((bc6, _)) = receiveRevocation(bc5, revocation2, maxDustExposure) assert(bc6.availableBalanceForSend == b - p3) assert(bc6.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) @@ -301,7 +304,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc7.availableBalanceForSend == b - p3) assert(bc7.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) - val Right((ac8, _)) = receiveRevocation(ac7, revocation3) + val Right((ac8, _)) = receiveRevocation(ac7, revocation3, maxDustExposure) assert(ac8.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) assert(ac8.availableBalanceForReceive == b - p3) @@ -340,7 +343,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc11.availableBalanceForSend == b + p1 - p3) assert(bc11.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee + p3) - val Right((ac13, _)) = receiveRevocation(ac12, revocation4) + val Right((ac13, _)) = receiveRevocation(ac12, revocation4, maxDustExposure) assert(ac13.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee + p3) assert(ac13.availableBalanceForReceive == b + p1 - p3) @@ -352,7 +355,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac14.availableBalanceForSend == a - p1 + p3) assert(ac14.availableBalanceForReceive == b + p1 - p3) - val Right((bc13, _)) = receiveRevocation(bc12, revocation5) + val Right((bc13, _)) = receiveRevocation(bc12, revocation5, maxDustExposure) assert(bc13.availableBalanceForSend == b + p1 - p3) assert(bc13.availableBalanceForReceive == a - p1 + p3) @@ -364,7 +367,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc14.availableBalanceForSend == b + p1 - p3) assert(bc14.availableBalanceForReceive == a - p1 + p3) - val Right((ac16, _)) = receiveRevocation(ac15, revocation6) + val Right((ac16, _)) = receiveRevocation(ac15, revocation6, maxDustExposure) assert(ac16.availableBalanceForSend == a - p1 + p3) assert(ac16.availableBalanceForReceive == b + p1 - p3) } @@ -460,6 +463,34 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } } + test("add htlcs until we reach our maximum dust exposure") { f => + import f._ + + val ac0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments + assert(ac0.currentDustExposure() === (0 msat, 0 msat)) + assert(ac0.contributesToDustExposure(UpdateAddHtlc(channelId(alice), 0, 9000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket)) === (true, true)) + assert(ac0.contributesToDustExposure(UpdateAddHtlc(channelId(alice), 0, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket)) === (false, true)) + assert(ac0.contributesToDustExposure(UpdateAddHtlc(channelId(alice), 0, 10000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket)) === (false, false)) + + addHtlc(9000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + addHtlc(9500.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + val ac1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments + assert(ac1.currentDustExposure() === (18500.sat.toMilliSatoshi, 9000.sat.toMilliSatoshi)) + + val receivedHtlcs = Seq( + UpdateAddHtlc(channelId(alice), 5, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), + UpdateAddHtlc(channelId(alice), 6, 5000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), + UpdateAddHtlc(channelId(alice), 7, 1000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), + UpdateAddHtlc(channelId(alice), 8, 400.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), + UpdateAddHtlc(channelId(alice), 9, 400.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), + UpdateAddHtlc(channelId(alice), 10, 50000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), + ) + val (accepted, rejected) = ac1.addHtlcsUntilDustExposureReached(25000 sat, 10000.sat.toMilliSatoshi, 10000.sat.toMilliSatoshi, receivedHtlcs) + assert(accepted.map(_.id).toSet === Set(5, 6, 8, 10)) + assert(rejected.map(_.id).toSet === Set(7, 9)) + } + } object CommitmentsSpec { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index f43835a66a..60ca848362 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -375,6 +375,48 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob2alice.expectNoMessage(200 millis) } + test("recv CMD_ADD_HTLC (over max dust htlc exposure)") { f => + import f._ + val sender = TestProbe() + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + val aliceCommitments = initialState.commitments + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).maxDustHtlcExposure === 25_000.sat) + assert(Transactions.offeredHtlcTrimThreshold(aliceCommitments.localParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 7730.sat) + assert(Transactions.receivedHtlcTrimThreshold(aliceCommitments.localParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 8130.sat) + assert(Transactions.offeredHtlcTrimThreshold(aliceCommitments.remoteParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 7630.sat) + assert(Transactions.receivedHtlcTrimThreshold(aliceCommitments.remoteParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 8030.sat) + + // Alice sends HTLCs to Bob that add 10 000 sat to the dust exposure: + addHtlc(500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) // dust htlc + addHtlc(1250.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) // trimmed htlc + addHtlc(8250.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) // slightly above the trimmed threshold -> included in the dust exposure + addHtlc(15000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) // way above the trimmed threshold -> not included in the dust exposure + crossSign(alice, bob, alice2bob, bob2alice) + + // Bob sends HTLCs to Alice that add 14 500 sat to the dust exposure: + addHtlc(300.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // dust htlc + addHtlc(6000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // trimmed htlc + addHtlc(8200.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // slightly above the trimmed threshold -> included in the dust exposure + addHtlc(18000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // way above the trimmed threshold -> not included in the dust exposure + crossSign(bob, alice, bob2alice, alice2bob) + + // HTLCs that take Alice's dust exposure above her threshold are rejected. + val dustAdd = CMD_ADD_HTLC(sender.ref, 501.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + alice ! dustAdd + sender.expectMsg(RES_ADD_FAILED(dustAdd, DustHtlcExposureTooHighInFlight(channelId(alice), 25000.sat, 25001.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + val trimmedAdd = CMD_ADD_HTLC(sender.ref, 5000.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + alice ! trimmedAdd + sender.expectMsg(RES_ADD_FAILED(trimmedAdd, DustHtlcExposureTooHighInFlight(channelId(alice), 25000.sat, 29500.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + val justAboveTrimmedAdd = CMD_ADD_HTLC(sender.ref, 8500.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + alice ! justAboveTrimmedAdd + sender.expectMsg(RES_ADD_FAILED(justAboveTrimmedAdd, DustHtlcExposureTooHighInFlight(channelId(alice), 25000.sat, 33000.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + + // HTLCs that don't contribute to dust exposure are accepted. + alice ! CMD_ADD_HTLC(sender.ref, 25000.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]] + alice2bob.expectMsgType[UpdateAddHtlc] + } + test("recv CMD_ADD_HTLC (over capacity)", Tag(ChannelStateTestsTags.NoMaxHtlcValueInFlight)) { f => import f._ val sender = TestProbe() @@ -1127,6 +1169,46 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectMsgType[WatchTxConfirmed] } + test("recv RevokeAndAck (over max dust htlc exposure)") { f => + import f._ + val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).maxDustHtlcExposure === 25_000.sat) + assert(Transactions.offeredHtlcTrimThreshold(aliceCommitments.localParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 7730.sat) + assert(Transactions.receivedHtlcTrimThreshold(aliceCommitments.remoteParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 8030.sat) + + // Alice sends HTLCs to Bob that add 10 000 sat to the dust exposure: + addHtlc(500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) // dust htlc + addHtlc(1250.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) // trimmed htlc + addHtlc(8250.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) // slightly above the trimmed threshold -> included in the dust exposure + crossSign(alice, bob, alice2bob, bob2alice) + + // Bob sends HTLCs to Alice that overflow the dust exposure: + val (_, dust1) = addHtlc(500.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // dust htlc + val (_, dust2) = addHtlc(500.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // dust htlc + val (_, trimmed1) = addHtlc(4000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // trimmed htlc + val (_, trimmed2) = addHtlc(6400.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // trimmed htlc + val (_, almostTrimmed) = addHtlc(8500.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // slightly above the trimmed threshold -> included in the dust exposure + val (_, nonDust) = addHtlc(20000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // way above the trimmed threshold -> not included in the dust exposure + crossSign(bob, alice, bob2alice, alice2bob) + + // Alice forwards HTLCs that fit in the dust exposure + relayerA.expectMsgAllOf( + RelayForward(nonDust), + RelayForward(almostTrimmed), + RelayForward(trimmed2), + ) + relayerA.expectNoMessage(100 millis) + // And instantly fails the others + val failedHtlcs = Seq( + alice2bob.expectMsgType[UpdateFailHtlc], + alice2bob.expectMsgType[UpdateFailHtlc], + alice2bob.expectMsgType[UpdateFailHtlc] + ) + assert(failedHtlcs.map(_.id).toSet === Set(dust1.id, dust2.id, trimmed1.id)) + alice2bob.expectMsgType[CommitSig] + alice2bob.expectNoMessage(100 millis) + } + test("recv RevokeAndAck (unexpectedly)") { f => import f._ val tx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala index a144195afb..6c9309b698 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala @@ -17,9 +17,9 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.{ByteVector32, Crypto, SatoshiLong} -import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.wire.protocol.{UpdateAddHtlc, UpdateFailHtlc, UpdateFulfillHtlc} -import fr.acinq.eclair.{CltvExpiry, MilliSatoshiLong, TestConstants, randomBytes32} +import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, MilliSatoshiLong, TestConstants, ToMilliSatoshiConversion, randomBytes32} import org.scalatest.funsuite.AnyFunSuite class CommitmentSpecSpec extends AnyFunSuite { @@ -75,4 +75,87 @@ class CommitmentSpecSpec extends AnyFunSuite { assert(spec.htlcTxFeerate(Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === FeeratePerKw(0 sat)) } + def createHtlc(amount: MilliSatoshi): UpdateAddHtlc = { + UpdateAddHtlc(ByteVector32.Zeroes, 0, amount, randomBytes32(), CltvExpiry(500), TestConstants.emptyOnionPacket) + } + + test("compute dust exposure") { + { + val htlcs = Set[DirectedHtlc]( + IncomingHtlc(createHtlc(449.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(449.sat.toMilliSatoshi)), + IncomingHtlc(createHtlc(450.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(450.sat.toMilliSatoshi)), + IncomingHtlc(createHtlc(499.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(499.sat.toMilliSatoshi)), + IncomingHtlc(createHtlc(500.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(500.sat.toMilliSatoshi)), + ) + val spec = CommitmentSpec(htlcs, FeeratePerKw(FeeratePerByte(50 sat)), 50000 msat, 75000 msat) + assert(CommitmentSpec.dustExposure(spec, 450 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 898.sat.toMilliSatoshi) + assert(CommitmentSpec.dustExposure(spec, 500 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 2796.sat.toMilliSatoshi) + assert(CommitmentSpec.dustExposure(spec, 500 sat, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 3796.sat.toMilliSatoshi) + } + { + // Low feerate: buffer adds 10 sat/byte + val dustLimit = 500.sat + val feerate = FeeratePerKw(FeeratePerByte(10 sat)) + assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, Transactions.DefaultCommitmentFormat) === 2257.sat) + assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, Transactions.DefaultCommitmentFormat) === 2157.sat) + assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate * 2, Transactions.DefaultCommitmentFormat) === 4015.sat) + assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate * 2, Transactions.DefaultCommitmentFormat) === 3815.sat) + val htlcs = Set[DirectedHtlc]( + // Below the dust limit. + IncomingHtlc(createHtlc(450.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(450.sat.toMilliSatoshi)), + // Above the dust limit, trimmed at 10 sat/byte + IncomingHtlc(createHtlc(2250.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(2150.sat.toMilliSatoshi)), + // Above the dust limit, trimmed at 20 sat/byte + IncomingHtlc(createHtlc(4010.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(3810.sat.toMilliSatoshi)), + // Above the dust limit, untrimmed at 20 sat/byte + IncomingHtlc(createHtlc(4020.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(3820.sat.toMilliSatoshi)), + ) + val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) + val expected = 450.sat + 450.sat + 2250.sat + 2150.sat + 4010.sat + 3810.sat + assert(CommitmentSpec.dustExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat) === expected.toMilliSatoshi) + assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(4010.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(3810.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(4020.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(3820.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) + } + { + // High feerate: buffer adds 25% + val dustLimit = 1000.sat + val feerate = FeeratePerKw(FeeratePerByte(80 sat)) + assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 15120.sat) + assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 14320.sat) + assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate * 1.25, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 18650.sat) + assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate * 1.25, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 17650.sat) + val htlcs = Set[DirectedHtlc]( + // Below the dust limit. + IncomingHtlc(createHtlc(900.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(900.sat.toMilliSatoshi)), + // Above the dust limit, trimmed at 80 sat/byte + IncomingHtlc(createHtlc(15000.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(14000.sat.toMilliSatoshi)), + // Above the dust limit, trimmed at 100 sat/byte + IncomingHtlc(createHtlc(18000.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(17000.sat.toMilliSatoshi)), + // Above the dust limit, untrimmed at 100 sat/byte + IncomingHtlc(createHtlc(19000.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(18000.sat.toMilliSatoshi)), + ) + val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) + val expected = 900.sat + 900.sat + 15000.sat + 14000.sat + 18000.sat + 17000.sat + assert(CommitmentSpec.dustExposure(spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === expected.toMilliSatoshi) + assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(17000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(!CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(19000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(!CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + } + } + } From e6176ffe5d183436b78addf4f7bfeddbae103bf0 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 13 Sep 2021 17:31:12 +0200 Subject: [PATCH 02/18] Add option to force-close after fee update A large `update_fee` may overflow our dust exposure by removing from the commit tx htlcs that were previously untrimmed. Node operators can choose to automatically force-close when that happens, to avoid risking losing large dust amounts to miner fees. --- eclair-core/src/main/resources/reference.conf | 4 ++ .../scala/fr/acinq/eclair/NodeParams.scala | 6 ++- .../eclair/blockchain/fee/FeeEstimator.scala | 2 +- .../fr/acinq/eclair/channel/Channel.scala | 4 +- .../fr/acinq/eclair/channel/Commitments.scala | 38 +++++++++++++++---- .../scala/fr/acinq/eclair/StartupSpec.scala | 8 ++-- .../scala/fr/acinq/eclair/TestConstants.scala | 4 +- .../blockchain/fee/FeeEstimatorSpec.scala | 6 +-- .../eclair/channel/CommitmentsSpec.scala | 11 +++++- .../channel/states/e/NormalStateSpec.scala | 37 ++++++++++++++++++ .../transactions/CommitmentSpecSpec.scala | 2 + 11 files changed, 100 insertions(+), 22 deletions(-) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index d21b3dc295..532f7e1d1c 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -146,6 +146,9 @@ eclair { // a malicious peer may want to abuse that, so we limit the value of pending dust htlcs in a channel // this value cannot be lowered too much if you plan to relay a lot of htlcs max-dust-htlc-exposure-satoshis = 100000 + // when we receive an UpdateFee, it could increase our dust exposure and overflow max-dust-htlc-exposure-satoshis + // this parameter should be set to true if you want to force-close the channel when that happens + close-on-update-fee-dust-exposure-overflow = false } override-feerate-tolerance = [ // optional per-node feerate tolerance # { @@ -155,6 +158,7 @@ eclair { # ratio-high = 20.0 # anchor-output-max-commit-feerate = 10 # max-dust-htlc-exposure-satoshis = 25000 + # close-on-update-fee-dust-exposure-overflow = true # } # } ] diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 626aafd046..6d85723cb6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -384,7 +384,8 @@ object NodeParams extends Logging { config.getDouble("on-chain-fees.feerate-tolerance.ratio-low"), config.getDouble("on-chain-fees.feerate-tolerance.ratio-high"), FeeratePerKw(FeeratePerByte(Satoshi(config.getLong("on-chain-fees.feerate-tolerance.anchor-output-max-commit-feerate")))), - Satoshi(config.getLong("on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis")) + Satoshi(config.getLong("on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis")), + config.getBoolean("on-chain-fees.feerate-tolerance.close-on-update-fee-dust-exposure-overflow") ), perNodeFeerateTolerance = config.getConfigList("on-chain-fees.override-feerate-tolerance").asScala.map { e => val nodeId = PublicKey(ByteVector.fromValidHex(e.getString("nodeid"))) @@ -392,7 +393,8 @@ object NodeParams extends Logging { e.getDouble("feerate-tolerance.ratio-low"), e.getDouble("feerate-tolerance.ratio-high"), FeeratePerKw(FeeratePerByte(Satoshi(e.getLong("feerate-tolerance.anchor-output-max-commit-feerate")))), - Satoshi(e.getLong("feerate-tolerance.max-dust-htlc-exposure-satoshis")) + Satoshi(e.getLong("feerate-tolerance.max-dust-htlc-exposure-satoshis")), + e.getBoolean("feerate-tolerance.close-on-update-fee-dust-exposure-overflow") ) nodeId -> tolerance }.toMap diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala index fa3696c9fb..35412db10c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala @@ -32,7 +32,7 @@ trait FeeEstimator { case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int) -case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw, maxDustHtlcExposure: Satoshi) { +case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw, maxDustHtlcExposure: Satoshi, closeOnUpdateFeeDustExposureOverflow: Boolean) { /** * @param channelType channel type * @param networkFeerate reference fee rate (value we estimate from our view of the network) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index c0f05996b9..541eaa2133 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -771,7 +771,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo } case Event(c: CMD_UPDATE_FEE, d: DATA_NORMAL) => - Commitments.sendFee(d.commitments, c) match { + Commitments.sendFee(d.commitments, c, nodeParams.onChainFeeConf) match { case Right((commitments1, fee)) => if (c.commit) self ! CMD_SIGN() context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.shortChannelId, commitments1)) @@ -1138,7 +1138,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo } case Event(c: CMD_UPDATE_FEE, d: DATA_SHUTDOWN) => - Commitments.sendFee(d.commitments, c) match { + Commitments.sendFee(d.commitments, c, nodeParams.onChainFeeConf) match { case Right((commitments1, fee)) => if (c.commit) self ! CMD_SIGN() handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fee diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 0fd6134d74..ca791c1a0c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -573,7 +573,7 @@ object Commitments { } } - def sendFee(commitments: Commitments, cmd: CMD_UPDATE_FEE): Either[ChannelException, (Commitments, UpdateFee)] = { + def sendFee(commitments: Commitments, cmd: CMD_UPDATE_FEE, feeConf: OnChainFeeConf): Either[ChannelException, (Commitments, UpdateFee)] = { if (!commitments.localParams.isFunder) { Left(FundeeCannotSendUpdateFee(commitments.channelId)) } else { @@ -588,10 +588,22 @@ object Commitments { val fees = commitTxTotalCost(commitments1.remoteParams.dustLimit, reduced, commitments.commitmentFormat) val missing = reduced.toRemote.truncateToSatoshi - commitments1.remoteParams.channelReserve - fees if (missing < 0.sat) { - Left(CannotAffordFees(commitments.channelId, missing = -missing, reserve = commitments1.localParams.channelReserve, fees = fees)) - } else { - Right(commitments1, fee) + return Left(CannotAffordFees(commitments.channelId, missing = -missing, reserve = commitments1.localParams.channelReserve, fees = fees)) } + + // if we would overflow our dust exposure with the new feerate, we avoid sending this fee update + if (feeConf.feerateToleranceFor(commitments.remoteNodeId).closeOnUpdateFeeDustExposureOverflow) { + val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).maxDustHtlcExposure + val dustExposureAfterFeeUpdate = Seq( + CommitmentSpec.dustExposure(commitments1.localCommit.spec, cmd.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat), + CommitmentSpec.dustExposure(reduced, cmd.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) + ).max + if (dustExposureAfterFeeUpdate > maxDustExposure) { + return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, dustExposureAfterFeeUpdate)) + } + } + + Right(commitments1, fee) } } @@ -621,10 +633,22 @@ object Commitments { val fees = commitTxTotalCost(commitments1.remoteParams.dustLimit, reduced, commitments.commitmentFormat) val missing = reduced.toRemote.truncateToSatoshi - commitments1.localParams.channelReserve - fees if (missing < 0.sat) { - Left(CannotAffordFees(commitments.channelId, missing = -missing, reserve = commitments1.localParams.channelReserve, fees = fees)) - } else { - Right(commitments1) + return Left(CannotAffordFees(commitments.channelId, missing = -missing, reserve = commitments1.localParams.channelReserve, fees = fees)) + } + + // if we would overflow our dust exposure with the new feerate, we reject this fee update + if (feeConf.feerateToleranceFor(commitments.remoteNodeId).closeOnUpdateFeeDustExposureOverflow) { + val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).maxDustHtlcExposure + val dustExposureAfterFeeUpdate = Seq( + CommitmentSpec.dustExposure(commitments1.localCommit.spec, fee.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat), + CommitmentSpec.dustExposure(reduced, fee.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat), + ).max + if (dustExposureAfterFeeUpdate > maxDustExposure) { + return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, dustExposureAfterFeeUpdate)) + } } + + Right(commitments1) } } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index ce76c160d8..be8f4df4f8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -175,6 +175,7 @@ class StartupSpec extends AnyFunSuite { | ratio-high = 15.0 | anchor-output-max-commit-feerate = 15 | max-dust-htlc-exposure-satoshis = 25000 + | close-on-update-fee-dust-exposure-overflow = true | } | }, | { @@ -184,6 +185,7 @@ class StartupSpec extends AnyFunSuite { | ratio-high = 5.0 | anchor-output-max-commit-feerate = 5 | max-dust-htlc-exposure-satoshis = 50000 + | close-on-update-fee-dust-exposure-overflow = false | } | }, | ] @@ -191,9 +193,9 @@ class StartupSpec extends AnyFunSuite { ) val nodeParams = makeNodeParamsWithDefaults(perNodeConf.withFallback(defaultConf)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) === FeerateTolerance(0.1, 15.0, FeeratePerKw(FeeratePerByte(15 sat)), 25_000 sat)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) === FeerateTolerance(0.75, 5.0, FeeratePerKw(FeeratePerByte(5 sat)), 50_000 sat)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) === FeerateTolerance(0.5, 10.0, FeeratePerKw(FeeratePerByte(10 sat)), 100_000 sat)) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) === FeerateTolerance(0.1, 15.0, FeeratePerKw(FeeratePerByte(15 sat)), 25_000 sat, closeOnUpdateFeeDustExposureOverflow = true)) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) === FeerateTolerance(0.75, 5.0, FeeratePerKw(FeeratePerByte(5 sat)), 50_000 sat, closeOnUpdateFeeDustExposureOverflow = false)) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) === FeerateTolerance(0.5, 10.0, FeeratePerKw(FeeratePerByte(10 sat)), 100_000 sat, closeOnUpdateFeeDustExposureOverflow = false)) } test("NodeParams should fail if htlc-minimum-msat is set to 0") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 0f0d45c53a..7dba9beb97 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -117,7 +117,7 @@ object TestConstants { feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, - defaultFeerateTolerance = FeerateTolerance(0.5, 8.0, anchorOutputsFeeratePerKw, 25_000 sat), + defaultFeerateTolerance = FeerateTolerance(0.5, 8.0, anchorOutputsFeeratePerKw, 25_000 sat, closeOnUpdateFeeDustExposureOverflow = true), perNodeFeerateTolerance = Map.empty ), maxHtlcValueInFlightMsat = UInt64(500000000), @@ -243,7 +243,7 @@ object TestConstants { feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, - defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw, 50_000 sat), + defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw, 50_000 sat, closeOnUpdateFeeDustExposureOverflow = true), perNodeFeerateTolerance = Map.empty ), maxHtlcValueInFlightMsat = UInt64.MaxValue, // Bob has no limit on the combined max value of in-flight htlcs diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala index 48f3477491..94d6b36238 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala @@ -25,7 +25,7 @@ import org.scalatest.funsuite.AnyFunSuite class FeeEstimatorSpec extends AnyFunSuite { - val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), 15000 sat) + val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), 15000 sat, closeOnUpdateFeeDustExposureOverflow = false) test("should update fee when diff ratio exceeded") { val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) @@ -89,7 +89,7 @@ class FeeEstimatorSpec extends AnyFunSuite { } test("fee difference too high") { - val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), maxDustHtlcExposure = 25000 sat) + val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), maxDustHtlcExposure = 25000 sat, closeOnUpdateFeeDustExposureOverflow = false) val channelType = ChannelTypes.Standard val testCases = Seq( (FeeratePerKw(500 sat), FeeratePerKw(500 sat), false), @@ -108,7 +108,7 @@ class FeeEstimatorSpec extends AnyFunSuite { } test("fee difference too high (anchor outputs)") { - val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), maxDustHtlcExposure = 25000 sat) + val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), maxDustHtlcExposure = 25000 sat, closeOnUpdateFeeDustExposureOverflow = false) val testCases = Seq( (FeeratePerKw(500 sat), FeeratePerKw(500 sat)), (FeeratePerKw(500 sat), FeeratePerKw(2500 sat)), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index 33f0879b87..1cd056ed26 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -41,7 +41,14 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with implicit val log: akka.event.LoggingAdapter = akka.event.NoLogging - val feeConfNoMismatch = OnChainFeeConf(FeeTargets(6, 2, 2, 6), new TestFeeEstimator, closeOnOfflineMismatch = false, 1.0, FeerateTolerance(0.00001, 100000.0, TestConstants.anchorOutputsFeeratePerKw, 100000 sat), Map.empty) + val feeConfNoMismatch = OnChainFeeConf( + FeeTargets(6, 2, 2, 6), + new TestFeeEstimator(), + closeOnOfflineMismatch = false, + 1.0, + FeerateTolerance(0.00001, 100000.0, TestConstants.anchorOutputsFeeratePerKw, 100000 sat, closeOnUpdateFeeDustExposureOverflow = false), + Map.empty + ) override def withFixture(test: OneArgTest): Outcome = { val setup = init() @@ -381,7 +388,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(c1.availableBalanceForSend === 0.msat) // We should be able to handle a fee increase. - val Right((c2, _)) = sendFee(c1, CMD_UPDATE_FEE(FeeratePerKw(3000 sat))) + val Right((c2, _)) = sendFee(c1, CMD_UPDATE_FEE(FeeratePerKw(3000 sat)), feeConfNoMismatch) // Now we shouldn't be able to send until we receive enough to handle the updated commit tx fee (even trimmed HTLCs shouldn't be sent). val (_, cmdAdd1) = makeCmdAdd(100 msat, randomKey().publicKey, f.currentBlockHeight) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 60ca848362..c3c23226e4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -1765,6 +1765,22 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testCmdUpdateFee _ } + test("recv CMD_UPDATE_FEE (over max dust htlc exposure)") { f => + import f._ + + // Alice sends HTLCs to Bob that are not included in the dust exposure at the current feerate: + addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(15000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.currentDustExposure() === (0 msat, 0 msat)) + + // A large feerate increase would make these HTLCs overflow alice's dust exposure, so she rejects it: + val sender = TestProbe() + val cmd = CMD_UPDATE_FEE(FeeratePerKw(20000 sat), replyTo_opt = Some(sender.ref)) + alice ! cmd + sender.expectMsg(RES_FAILURE(cmd, DustHtlcExposureTooHighInFlight(channelId(alice), 25000 sat, 29000000 msat))) + } + test("recv CMD_UPDATE_FEE (two in a row)") { f => import f._ val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] @@ -1921,6 +1937,27 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob2blockchain.expectMsgType[WatchTxConfirmed] } + test("recv UpdateFee (over max dust htlc exposure)") { f => + import f._ + + // Alice sends HTLCs to Bob that are not included in the dust exposure at the current feerate: + addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(14500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(15000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(15500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.currentDustExposure() === (0 msat, 0 msat)) + val tx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx + + // A large feerate increase would make these HTLCs overflow bob's dust exposure, so he force-closes: + bob.feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(25000 sat))) + bob ! UpdateFee(channelId(bob), FeeratePerKw(25000 sat)) + val error = bob2alice.expectMsgType[Error] + assert(new String(error.data.toArray) === DustHtlcExposureTooHighInFlight(channelId(bob), 50000 sat, 59000000 msat).getMessage) + assert(bob2blockchain.expectMsgType[PublishRawTx].tx.txid === tx.txid) + awaitCond(bob.stateName == CLOSING) + } + test("recv CMD_UPDATE_RELAY_FEE ") { f => import f._ val sender = TestProbe() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala index 6c9309b698..8d59b3600f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala @@ -121,6 +121,7 @@ class CommitmentSpecSpec extends AnyFunSuite { val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) val expected = 450.sat + 450.sat + 2250.sat + 2150.sat + 4010.sat + 3810.sat assert(CommitmentSpec.dustExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat) === expected.toMilliSatoshi) + assert(CommitmentSpec.dustExposure(spec, feerate * 2, dustLimit, Transactions.DefaultCommitmentFormat) === CommitmentSpec.dustExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(4010.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(3810.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(!CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(4020.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) @@ -151,6 +152,7 @@ class CommitmentSpecSpec extends AnyFunSuite { val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) val expected = 900.sat + 900.sat + 15000.sat + 14000.sat + 18000.sat + 17000.sat assert(CommitmentSpec.dustExposure(spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === expected.toMilliSatoshi) + assert(CommitmentSpec.dustExposure(spec, feerate * 1.25, dustLimit, Transactions.DefaultCommitmentFormat) === CommitmentSpec.dustExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) assert(CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(17000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) assert(!CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(19000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) From 155f2bb92e13398bd1ee311f4dce10b9c447a803 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 1 Oct 2021 17:00:39 +0200 Subject: [PATCH 03/18] Add release notes --- docs/release-notes/eclair-vnext.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 8182ac6503..4f5c156881 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -53,6 +53,23 @@ You **MUST** ensure you have some utxos available in your Bitcoin Core wallet fo Do note that anchor outputs may still be unsafe in high-fee environments until the Bitcoin network provides support for [package relay](https://bitcoinops.org/en/topics/package-relay/). +### Configurable dust tolerance + +Dust HTLCs are converted to miner fees when a channel is force-closed and these HTLCs are still pending. +Node operators can now configure the maximum amount of dust HTLCs that can be pending in a channel by setting `eclair.on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis` in their `eclair.conf`. + +Choosing the right value for your node involves trade-offs. +The lower you set it, the more protection it will offer against malicious peers. +But if it's too low, your node may reject some dust HTLCs that it would have otherwise relayed, which lowers the amount of relay fees you will be able to collect. + +Another related parameter has been added: `eclair.on-chain-fees.feerate-tolerance.close-on-update-fee-dust-exposure-overflow`. +When this parameter is set to `true`, your node will automatically close channels when the amount of dust HTLCs overflows your configured limits. +This gives you a better protection against malicious peers, but may end up closing channels with honest peers as well. +This parameter is deactivated by default and unnecessary when using `option_anchors_zero_fee_htlc_tx`. + +Note that you can override these values for specific peers, thanks to the `eclair.on-chain-fees.override-feerate-tolerance` mechanism. +You can for example set a high `eclair.on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis` with peers that you trust. + ### Path-finding improvements This release contains many improvements to path-finding and paves the way for future experimentation. From ca14cd3509e05c1271fef9071c407d13ea671352 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 5 Oct 2021 16:01:33 +0200 Subject: [PATCH 04/18] Update default configured dust exposure --- eclair-core/src/main/resources/reference.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 532f7e1d1c..e0dd491327 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -145,7 +145,7 @@ eclair { // dust htlcs cannot be claimed on-chain and will instead go to miners if the channel is force-closed // a malicious peer may want to abuse that, so we limit the value of pending dust htlcs in a channel // this value cannot be lowered too much if you plan to relay a lot of htlcs - max-dust-htlc-exposure-satoshis = 100000 + max-dust-htlc-exposure-satoshis = 50000 // when we receive an UpdateFee, it could increase our dust exposure and overflow max-dust-htlc-exposure-satoshis // this parameter should be set to true if you want to force-close the channel when that happens close-on-update-fee-dust-exposure-overflow = false From d9d30c74c0798b3caae81ebf970365ed83cf3de9 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 5 Oct 2021 19:40:24 +0200 Subject: [PATCH 05/18] fixup! Add release notes --- docs/release-notes/eclair-vnext.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 4f5c156881..750ef023bc 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -56,6 +56,8 @@ Do note that anchor outputs may still be unsafe in high-fee environments until t ### Configurable dust tolerance Dust HTLCs are converted to miner fees when a channel is force-closed and these HTLCs are still pending. +This can be used as a griefing attack by malicious peers, as described in [CVE-2021-41591](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-41591). + Node operators can now configure the maximum amount of dust HTLCs that can be pending in a channel by setting `eclair.on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis` in their `eclair.conf`. Choosing the right value for your node involves trade-offs. @@ -70,6 +72,10 @@ This parameter is deactivated by default and unnecessary when using `option_anch Note that you can override these values for specific peers, thanks to the `eclair.on-chain-fees.override-feerate-tolerance` mechanism. You can for example set a high `eclair.on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis` with peers that you trust. +Note that if you were previously running eclair with the default configuration, your exposure to this issue was quite low because the default `max-accepted-htlc` is set to 30. +With an on-chain feerate of `10 sat/byte`, your maximum exposure would be ~70 000 satoshis per channel. +With an on-chain feerate of `5 sat/byte`, your maximum exposure would be ~40 000 satoshis per channel. + ### Path-finding improvements This release contains many improvements to path-finding and paves the way for future experimentation. From c28d2c10212422aa9d6cf5a5b0cdcb642da49b39 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 5 Oct 2021 20:15:55 +0200 Subject: [PATCH 06/18] fixup! Update default configured dust exposure --- .../src/test/scala/fr/acinq/eclair/StartupSpec.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index be8f4df4f8..1b82abda1a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -184,7 +184,7 @@ class StartupSpec extends AnyFunSuite { | ratio-low = 0.75 | ratio-high = 5.0 | anchor-output-max-commit-feerate = 5 - | max-dust-htlc-exposure-satoshis = 50000 + | max-dust-htlc-exposure-satoshis = 40000 | close-on-update-fee-dust-exposure-overflow = false | } | }, @@ -194,8 +194,8 @@ class StartupSpec extends AnyFunSuite { val nodeParams = makeNodeParamsWithDefaults(perNodeConf.withFallback(defaultConf)) assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) === FeerateTolerance(0.1, 15.0, FeeratePerKw(FeeratePerByte(15 sat)), 25_000 sat, closeOnUpdateFeeDustExposureOverflow = true)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) === FeerateTolerance(0.75, 5.0, FeeratePerKw(FeeratePerByte(5 sat)), 50_000 sat, closeOnUpdateFeeDustExposureOverflow = false)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) === FeerateTolerance(0.5, 10.0, FeeratePerKw(FeeratePerByte(10 sat)), 100_000 sat, closeOnUpdateFeeDustExposureOverflow = false)) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) === FeerateTolerance(0.75, 5.0, FeeratePerKw(FeeratePerByte(5 sat)), 40_000 sat, closeOnUpdateFeeDustExposureOverflow = false)) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) === FeerateTolerance(0.5, 10.0, FeeratePerKw(FeeratePerByte(10 sat)), 50_000 sat, closeOnUpdateFeeDustExposureOverflow = false)) } test("NodeParams should fail if htlc-minimum-msat is set to 0") { From 68710eb152792b6d45e2bf33b2e4e9a8d4d17f66 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 6 Oct 2021 12:15:21 +0200 Subject: [PATCH 07/18] Move dust parameters to their own config section --- docs/release-notes/eclair-vnext.md | 6 ++--- eclair-core/src/main/resources/reference.conf | 25 +++++++++++-------- .../scala/fr/acinq/eclair/NodeParams.scala | 12 ++++++--- .../eclair/blockchain/fee/FeeEstimator.scala | 8 +++++- .../fr/acinq/eclair/channel/Channel.scala | 4 +-- .../fr/acinq/eclair/channel/Commitments.scala | 10 ++++---- .../scala/fr/acinq/eclair/StartupSpec.scala | 20 +++++++++------ .../scala/fr/acinq/eclair/TestConstants.scala | 4 +-- .../blockchain/fee/FeeEstimatorSpec.scala | 6 ++--- .../eclair/channel/CommitmentsSpec.scala | 4 +-- .../channel/states/e/NormalStateSpec.scala | 4 +-- 11 files changed, 61 insertions(+), 42 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 750ef023bc..db708442c7 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -58,19 +58,19 @@ Do note that anchor outputs may still be unsafe in high-fee environments until t Dust HTLCs are converted to miner fees when a channel is force-closed and these HTLCs are still pending. This can be used as a griefing attack by malicious peers, as described in [CVE-2021-41591](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-41591). -Node operators can now configure the maximum amount of dust HTLCs that can be pending in a channel by setting `eclair.on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis` in their `eclair.conf`. +Node operators can now configure the maximum amount of dust HTLCs that can be pending in a channel by setting `eclair.on-chain-fees.feerate-tolerance.dust-tolerance.max-exposure-satoshis` in their `eclair.conf`. Choosing the right value for your node involves trade-offs. The lower you set it, the more protection it will offer against malicious peers. But if it's too low, your node may reject some dust HTLCs that it would have otherwise relayed, which lowers the amount of relay fees you will be able to collect. -Another related parameter has been added: `eclair.on-chain-fees.feerate-tolerance.close-on-update-fee-dust-exposure-overflow`. +Another related parameter has been added: `eclair.on-chain-fees.feerate-tolerance.dust-tolerance.close-on-update-fee-overflow`. When this parameter is set to `true`, your node will automatically close channels when the amount of dust HTLCs overflows your configured limits. This gives you a better protection against malicious peers, but may end up closing channels with honest peers as well. This parameter is deactivated by default and unnecessary when using `option_anchors_zero_fee_htlc_tx`. Note that you can override these values for specific peers, thanks to the `eclair.on-chain-fees.override-feerate-tolerance` mechanism. -You can for example set a high `eclair.on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis` with peers that you trust. +You can for example set a high `eclair.on-chain-fees.feerate-tolerance.dust-tolerance.max-exposure-satoshis` with peers that you trust. Note that if you were previously running eclair with the default configuration, your exposure to this issue was quite low because the default `max-accepted-htlc` is set to 30. With an on-chain feerate of `10 sat/byte`, your maximum exposure would be ~70 000 satoshis per channel. diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index e0dd491327..f63859ff35 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -142,13 +142,16 @@ eclair { // when using anchor outputs, we only need to use a commitment feerate that allows the tx to propagate: we will use CPFP to speed up confirmation if needed. // the following value is the maximum feerate we'll use for our commit tx (in sat/byte) anchor-output-max-commit-feerate = 10 - // dust htlcs cannot be claimed on-chain and will instead go to miners if the channel is force-closed - // a malicious peer may want to abuse that, so we limit the value of pending dust htlcs in a channel - // this value cannot be lowered too much if you plan to relay a lot of htlcs - max-dust-htlc-exposure-satoshis = 50000 - // when we receive an UpdateFee, it could increase our dust exposure and overflow max-dust-htlc-exposure-satoshis - // this parameter should be set to true if you want to force-close the channel when that happens - close-on-update-fee-dust-exposure-overflow = false + // the following section lets you configure your tolerance to dust outputs + dust-tolerance { + // dust htlcs cannot be claimed on-chain and will instead go to miners if the channel is force-closed + // a malicious peer may want to abuse that, so we limit the value of pending dust htlcs in a channel + // this value cannot be lowered too much if you plan to relay a lot of htlcs + max-exposure-satoshis = 50000 + // when we receive an update_fee, it could increase our dust exposure and overflow max-exposure-satoshis + // this parameter should be set to true if you want to force-close the channel when that happens + close-on-update-fee-overflow = false + } } override-feerate-tolerance = [ // optional per-node feerate tolerance # { @@ -157,8 +160,10 @@ eclair { # ratio-low = 0.1 # ratio-high = 20.0 # anchor-output-max-commit-feerate = 10 - # max-dust-htlc-exposure-satoshis = 25000 - # close-on-update-fee-dust-exposure-overflow = true + # dust-tolerance { + # max-exposure-satoshis = 25000 + # close-on-update-fee-overflow = true + # } # } # } ] @@ -397,6 +402,6 @@ akka { backend.min-nr-of-members = 1 frontend.min-nr-of-members = 0 } - seed-nodes = [ "akka://eclair-node@127.0.0.1:25520" ] + seed-nodes = ["akka://eclair-node@127.0.0.1:25520"] } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 6d85723cb6..289f4955e5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -384,8 +384,10 @@ object NodeParams extends Logging { config.getDouble("on-chain-fees.feerate-tolerance.ratio-low"), config.getDouble("on-chain-fees.feerate-tolerance.ratio-high"), FeeratePerKw(FeeratePerByte(Satoshi(config.getLong("on-chain-fees.feerate-tolerance.anchor-output-max-commit-feerate")))), - Satoshi(config.getLong("on-chain-fees.feerate-tolerance.max-dust-htlc-exposure-satoshis")), - config.getBoolean("on-chain-fees.feerate-tolerance.close-on-update-fee-dust-exposure-overflow") + DustTolerance( + Satoshi(config.getLong("on-chain-fees.feerate-tolerance.dust-tolerance.max-exposure-satoshis")), + config.getBoolean("on-chain-fees.feerate-tolerance.dust-tolerance.close-on-update-fee-overflow") + ) ), perNodeFeerateTolerance = config.getConfigList("on-chain-fees.override-feerate-tolerance").asScala.map { e => val nodeId = PublicKey(ByteVector.fromValidHex(e.getString("nodeid"))) @@ -393,8 +395,10 @@ object NodeParams extends Logging { e.getDouble("feerate-tolerance.ratio-low"), e.getDouble("feerate-tolerance.ratio-high"), FeeratePerKw(FeeratePerByte(Satoshi(e.getLong("feerate-tolerance.anchor-output-max-commit-feerate")))), - Satoshi(e.getLong("feerate-tolerance.max-dust-htlc-exposure-satoshis")), - e.getBoolean("feerate-tolerance.close-on-update-fee-dust-exposure-overflow") + DustTolerance( + Satoshi(e.getLong("feerate-tolerance.dust-tolerance.max-exposure-satoshis")), + e.getBoolean("feerate-tolerance.dust-tolerance.close-on-update-fee-overflow") + ) ) nodeId -> tolerance }.toMap diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala index 35412db10c..7dd4ec744b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala @@ -32,7 +32,13 @@ trait FeeEstimator { case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int) -case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw, maxDustHtlcExposure: Satoshi, closeOnUpdateFeeDustExposureOverflow: Boolean) { +/** + * @param maxExposure maximum exposure to pending dust htlcs we tolerate: we will automatically fail HTLCs when going above this threshold. + * @param closeOnUpdateFeeOverflow force-close channels when an update_fee forces us to go above our max exposure. + */ +case class DustTolerance(maxExposure: Satoshi, closeOnUpdateFeeOverflow: Boolean) + +case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw, dustTolerance: DustTolerance) { /** * @param channelType channel type * @param networkFeerate reference fee rate (value we estimate from our view of the network) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index 541eaa2133..5afd42fd1d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -841,7 +841,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo case Event(revocation: RevokeAndAck, d: DATA_NORMAL) => // we received a revocation because we sent a signature // => all our changes have been acked - Commitments.receiveRevocation(d.commitments, revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).maxDustHtlcExposure) match { + Commitments.receiveRevocation(d.commitments, revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).dustTolerance.maxExposure) match { case Right((commitments1, actions)) => cancelTimer(RevocationTimeout.toString) log.debug("received a new rev, spec:\n{}", Commitments.specs2String(commitments1)) @@ -1210,7 +1210,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo case Event(revocation: RevokeAndAck, d@DATA_SHUTDOWN(commitments, localShutdown, remoteShutdown, closingFeerates)) => // we received a revocation because we sent a signature // => all our changes have been acked including the shutdown message - Commitments.receiveRevocation(commitments, revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).maxDustHtlcExposure) match { + Commitments.receiveRevocation(commitments, revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).dustTolerance.maxExposure) match { case Right((commitments1, actions)) => cancelTimer(RevocationTimeout.toString) log.debug("received a new rev, spec:\n{}", Commitments.specs2String(commitments1)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index ca791c1a0c..0cf00f5697 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -426,7 +426,7 @@ object Commitments { } // If sending this htlc would overflow our dust exposure, we reject it. - val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).maxDustHtlcExposure + val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure val (localCommitDustExposure, remoteCommitDustExposure) = commitments.currentDustExposure() val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = commitments.contributesToDustExposure(add) if (contributesToLocalCommitDustExposure && localCommitDustExposure + add.amountMsat > maxDustExposure) { @@ -592,8 +592,8 @@ object Commitments { } // if we would overflow our dust exposure with the new feerate, we avoid sending this fee update - if (feeConf.feerateToleranceFor(commitments.remoteNodeId).closeOnUpdateFeeDustExposureOverflow) { - val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).maxDustHtlcExposure + if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { + val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure val dustExposureAfterFeeUpdate = Seq( CommitmentSpec.dustExposure(commitments1.localCommit.spec, cmd.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat), CommitmentSpec.dustExposure(reduced, cmd.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) @@ -637,8 +637,8 @@ object Commitments { } // if we would overflow our dust exposure with the new feerate, we reject this fee update - if (feeConf.feerateToleranceFor(commitments.remoteNodeId).closeOnUpdateFeeDustExposureOverflow) { - val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).maxDustHtlcExposure + if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { + val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure val dustExposureAfterFeeUpdate = Seq( CommitmentSpec.dustExposure(commitments1.localCommit.spec, fee.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat), CommitmentSpec.dustExposure(reduced, fee.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index 1b82abda1a..d4a7709b9b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -21,7 +21,7 @@ import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{Block, SatoshiLong} import fr.acinq.eclair.FeatureSupport.Mandatory import fr.acinq.eclair.Features._ -import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw, FeerateTolerance} +import fr.acinq.eclair.blockchain.fee.{DustTolerance, FeeratePerByte, FeeratePerKw, FeerateTolerance} import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager} import org.scalatest.funsuite.AnyFunSuite import scodec.bits.{ByteVector, HexStringSyntax} @@ -174,8 +174,10 @@ class StartupSpec extends AnyFunSuite { | ratio-low = 0.1 | ratio-high = 15.0 | anchor-output-max-commit-feerate = 15 - | max-dust-htlc-exposure-satoshis = 25000 - | close-on-update-fee-dust-exposure-overflow = true + | dust-tolerance { + | max-exposure-satoshis = 25000 + | close-on-update-fee-overflow = true + | } | } | }, | { @@ -184,8 +186,10 @@ class StartupSpec extends AnyFunSuite { | ratio-low = 0.75 | ratio-high = 5.0 | anchor-output-max-commit-feerate = 5 - | max-dust-htlc-exposure-satoshis = 40000 - | close-on-update-fee-dust-exposure-overflow = false + | dust-tolerance { + | max-exposure-satoshis = 40000 + | close-on-update-fee-overflow = false + | } | } | }, | ] @@ -193,9 +197,9 @@ class StartupSpec extends AnyFunSuite { ) val nodeParams = makeNodeParamsWithDefaults(perNodeConf.withFallback(defaultConf)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) === FeerateTolerance(0.1, 15.0, FeeratePerKw(FeeratePerByte(15 sat)), 25_000 sat, closeOnUpdateFeeDustExposureOverflow = true)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) === FeerateTolerance(0.75, 5.0, FeeratePerKw(FeeratePerByte(5 sat)), 40_000 sat, closeOnUpdateFeeDustExposureOverflow = false)) - assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) === FeerateTolerance(0.5, 10.0, FeeratePerKw(FeeratePerByte(10 sat)), 50_000 sat, closeOnUpdateFeeDustExposureOverflow = false)) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) === FeerateTolerance(0.1, 15.0, FeeratePerKw(FeeratePerByte(15 sat)), DustTolerance(25_000 sat, closeOnUpdateFeeOverflow = true))) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) === FeerateTolerance(0.75, 5.0, FeeratePerKw(FeeratePerByte(5 sat)), DustTolerance(40_000 sat, closeOnUpdateFeeOverflow = false))) + assert(nodeParams.onChainFeeConf.feerateToleranceFor(PublicKey(hex"02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) === FeerateTolerance(0.5, 10.0, FeeratePerKw(FeeratePerByte(10 sat)), DustTolerance(50_000 sat, closeOnUpdateFeeOverflow = false))) } test("NodeParams should fail if htlc-minimum-msat is set to 0") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 7dba9beb97..40ecf7a3db 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -117,7 +117,7 @@ object TestConstants { feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, - defaultFeerateTolerance = FeerateTolerance(0.5, 8.0, anchorOutputsFeeratePerKw, 25_000 sat, closeOnUpdateFeeDustExposureOverflow = true), + defaultFeerateTolerance = FeerateTolerance(0.5, 8.0, anchorOutputsFeeratePerKw, DustTolerance(25_000 sat, closeOnUpdateFeeOverflow = true)), perNodeFeerateTolerance = Map.empty ), maxHtlcValueInFlightMsat = UInt64(500000000), @@ -243,7 +243,7 @@ object TestConstants { feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, - defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw, 50_000 sat, closeOnUpdateFeeDustExposureOverflow = true), + defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw, DustTolerance(50_000 sat, closeOnUpdateFeeOverflow = true)), perNodeFeerateTolerance = Map.empty ), maxHtlcValueInFlightMsat = UInt64.MaxValue, // Bob has no limit on the combined max value of in-flight htlcs diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala index 94d6b36238..571f262704 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala @@ -25,7 +25,7 @@ import org.scalatest.funsuite.AnyFunSuite class FeeEstimatorSpec extends AnyFunSuite { - val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), 15000 sat, closeOnUpdateFeeDustExposureOverflow = false) + val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), DustTolerance(15000 sat, closeOnUpdateFeeOverflow = false)) test("should update fee when diff ratio exceeded") { val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) @@ -89,7 +89,7 @@ class FeeEstimatorSpec extends AnyFunSuite { } test("fee difference too high") { - val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), maxDustHtlcExposure = 25000 sat, closeOnUpdateFeeDustExposureOverflow = false) + val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), DustTolerance(25000 sat, closeOnUpdateFeeOverflow = false)) val channelType = ChannelTypes.Standard val testCases = Seq( (FeeratePerKw(500 sat), FeeratePerKw(500 sat), false), @@ -108,7 +108,7 @@ class FeeEstimatorSpec extends AnyFunSuite { } test("fee difference too high (anchor outputs)") { - val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), maxDustHtlcExposure = 25000 sat, closeOnUpdateFeeDustExposureOverflow = false) + val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), DustTolerance(25000 sat, closeOnUpdateFeeOverflow = false)) val testCases = Seq( (FeeratePerKw(500 sat), FeeratePerKw(500 sat)), (FeeratePerKw(500 sat), FeeratePerKw(2500 sat)), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index 1cd056ed26..ec8d90b108 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector64, DeterministicWallet, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.TestConstants.TestFeeEstimator -import fr.acinq.eclair.blockchain.fee.{FeeTargets, FeeratePerKw, FeerateTolerance, OnChainFeeConf} +import fr.acinq.eclair.blockchain.fee.{DustTolerance, FeeTargets, FeeratePerKw, FeerateTolerance, OnChainFeeConf} import fr.acinq.eclair.channel.Commitments._ import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel.states.ChannelStateTestsBase @@ -46,7 +46,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with new TestFeeEstimator(), closeOnOfflineMismatch = false, 1.0, - FeerateTolerance(0.00001, 100000.0, TestConstants.anchorOutputsFeeratePerKw, 100000 sat, closeOnUpdateFeeDustExposureOverflow = false), + FeerateTolerance(0.00001, 100000.0, TestConstants.anchorOutputsFeeratePerKw, DustTolerance(100000 sat, closeOnUpdateFeeOverflow = false)), Map.empty ) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index c3c23226e4..486ad5aedf 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -380,7 +380,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val sender = TestProbe() val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] val aliceCommitments = initialState.commitments - assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).maxDustHtlcExposure === 25_000.sat) + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) assert(Transactions.offeredHtlcTrimThreshold(aliceCommitments.localParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 7730.sat) assert(Transactions.receivedHtlcTrimThreshold(aliceCommitments.localParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 8130.sat) assert(Transactions.offeredHtlcTrimThreshold(aliceCommitments.remoteParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 7630.sat) @@ -1172,7 +1172,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv RevokeAndAck (over max dust htlc exposure)") { f => import f._ val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).maxDustHtlcExposure === 25_000.sat) + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) assert(Transactions.offeredHtlcTrimThreshold(aliceCommitments.localParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 7730.sat) assert(Transactions.receivedHtlcTrimThreshold(aliceCommitments.remoteParams.dustLimit, aliceCommitments.localCommit.spec, aliceCommitments.commitmentFormat) === 8030.sat) From d0ffd73c1c1c49ceb1fd7c1b789fbfa9067da98f Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 6 Oct 2021 12:58:26 +0200 Subject: [PATCH 08/18] Address PR comments --- .../fr/acinq/eclair/channel/Channel.scala | 20 +++------ .../fr/acinq/eclair/channel/Commitments.scala | 45 +++++++++---------- .../eclair/transactions/CommitmentSpec.scala | 6 +-- .../eclair/channel/CommitmentsSpec.scala | 10 ++--- 4 files changed, 33 insertions(+), 48 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index 5afd42fd1d..c92e9fcff4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -852,17 +852,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo case PostRevocationAction.RejectHtlc(add) => log.debug("rejecting incoming htlc {}", add) // NB: we don't set commit = true, we will sign all updates at once afterwards. - self ! CMD_FAIL_HTLC(add.id, Right(TemporaryChannelFailure(d.channelUpdate))) + self ! CMD_FAIL_HTLC(add.id, Right(TemporaryChannelFailure(d.channelUpdate)), commit = true) case PostRevocationAction.RelayFailure(result) => log.debug("forwarding {} to relayer", result) relayer ! result } - val signAsap = actions.exists { - case _: PostRevocationAction.RejectHtlc => true - case _: PostRevocationAction.RelayHtlc => false - case _: PostRevocationAction.RelayFailure => false - } || (Commitments.localHasChanges(commitments1) && d.commitments.remoteNextCommitInfo.left.map(_.reSignAsap) == Left(true)) - if (signAsap) { + if (Commitments.localHasChanges(commitments1) && d.commitments.remoteNextCommitInfo.left.map(_.reSignAsap) == Left(true)) { self ! CMD_SIGN() } if (d.remoteShutdown.isDefined && !Commitments.localHasUnsignedOutgoingHtlcs(commitments1)) { @@ -1218,11 +1213,11 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo case PostRevocationAction.RelayHtlc(add) => // BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown. log.debug("closing in progress: failing {}", add) - self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure)) + self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure), commit = true) case PostRevocationAction.RejectHtlc(add) => // BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown. log.debug("closing in progress: rejecting {}", add) - self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure)) + self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure), commit = true) case PostRevocationAction.RelayFailure(result) => log.debug("forwarding {} to relayer", result) relayer ! result @@ -1238,12 +1233,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo goto(NEGOTIATING) using DATA_NEGOTIATING(commitments1, localShutdown, remoteShutdown, closingTxProposed = List(List()), bestUnpublishedClosingTx_opt = None) storing() } } else { - val signAsap = actions.exists { - case _: PostRevocationAction.RelayHtlc => true - case _: PostRevocationAction.RejectHtlc => true - case _: PostRevocationAction.RelayFailure => false - } || (Commitments.localHasChanges(commitments1) && d.commitments.remoteNextCommitInfo.left.map(_.reSignAsap) == Left(true)) - if (signAsap) { + if (Commitments.localHasChanges(commitments1) && d.commitments.remoteNextCommitInfo.left.map(_.reSignAsap) == Left(true)) { self ! CMD_SIGN() } stay() using d.copy(commitments = commitments1) storing() diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 0cf00f5697..d9f7af6b38 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -32,8 +32,6 @@ import fr.acinq.eclair.transactions._ import fr.acinq.eclair.wire.protocol._ import scodec.bits.ByteVector -import scala.annotation.tailrec - // @formatter:off case class LocalChanges(proposed: List[UpdateMessage], signed: List[UpdateMessage], acked: List[UpdateMessage]) { def all: List[UpdateMessage] = proposed ++ signed ++ acked @@ -192,35 +190,32 @@ case class Commitments(channelId: ByteVector32, (localCommitDustExposure, remoteCommitDustExposure) } - /** Test whether the given incoming htlc contributes to the dust exposure in the local or remote commit tx. */ - def contributesToDustExposure(add: UpdateAddHtlc): (Boolean, Boolean) = { - val contributesToLocalCommitDustExposure = CommitmentSpec.contributesToDustExposure(OutgoingHtlc(add), localCommit.spec, localParams.dustLimit, commitmentFormat) - val contributesToRemoteCommitDustExposure = CommitmentSpec.contributesToDustExposure(IncomingHtlc(add), remoteCommit.spec, remoteParams.dustLimit, commitmentFormat) + /** Test whether the given htlc contributes to the dust exposure in the local or remote commit tx. */ + def contributesToDustExposure(htlc: DirectedHtlc): (Boolean, Boolean) = { + val contributesToLocalCommitDustExposure = CommitmentSpec.contributesToDustExposure(htlc, localCommit.spec, localParams.dustLimit, commitmentFormat) + val contributesToRemoteCommitDustExposure = CommitmentSpec.contributesToDustExposure(htlc.opposite, remoteCommit.spec, remoteParams.dustLimit, commitmentFormat) (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) } - /** Select which incoming HTLCs we should accept and which HTLCs we should reject to avoid overflowing our dust exposure. */ - @tailrec - final def addHtlcsUntilDustExposureReached(maxDustExposure: Satoshi, - localCommitDustExposure: MilliSatoshi, - remoteCommitDustExposure: MilliSatoshi, - receivedHtlcs: Seq[UpdateAddHtlc], - acceptedHtlcs: Seq[UpdateAddHtlc] = Nil, - rejectedHtlcs: Seq[UpdateAddHtlc] = Nil): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { - receivedHtlcs match { - case add :: remaining => - val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = contributesToDustExposure(add) - val rejectHtlc = (contributesToLocalCommitDustExposure && localCommitDustExposure + add.amountMsat > maxDustExposure) || - (contributesToRemoteCommitDustExposure && remoteCommitDustExposure + add.amountMsat > maxDustExposure) + /** Accept as many incoming HTLCs as possible, in the order they are provided, while not overflowing our dust exposure. */ + def addHtlcsUntilDustExposureReached(maxDustExposure: Satoshi, + localCommitDustExposure: MilliSatoshi, + remoteCommitDustExposure: MilliSatoshi, + receivedHtlcs: Seq[UpdateAddHtlc]): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { + val (_, _, acceptedHtlcs, rejectedHtlcs) = receivedHtlcs.foldLeft((localCommitDustExposure, remoteCommitDustExposure, Seq.empty[UpdateAddHtlc], Seq.empty[UpdateAddHtlc])) { + case ((currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs), add) => + val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = contributesToDustExposure(IncomingHtlc(add)) + val rejectHtlc = (contributesToLocalCommitDustExposure && currentLocalCommitDustExposure + add.amountMsat > maxDustExposure) || + (contributesToRemoteCommitDustExposure && currentRemoteCommitDustExposure + add.amountMsat > maxDustExposure) if (rejectHtlc) { - addHtlcsUntilDustExposureReached(maxDustExposure, localCommitDustExposure, remoteCommitDustExposure, remaining, acceptedHtlcs, rejectedHtlcs :+ add) + (currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs :+ add) } else { - val localCommitDustExposure1 = if (contributesToLocalCommitDustExposure) localCommitDustExposure + add.amountMsat else localCommitDustExposure - val remoteCommitDustExposure1 = if (contributesToRemoteCommitDustExposure) remoteCommitDustExposure + add.amountMsat else remoteCommitDustExposure - addHtlcsUntilDustExposureReached(maxDustExposure, localCommitDustExposure1, remoteCommitDustExposure1, remaining, acceptedHtlcs :+ add, rejectedHtlcs) + val nextLocalCommitDustExposure = if (contributesToLocalCommitDustExposure) currentLocalCommitDustExposure + add.amountMsat else currentLocalCommitDustExposure + val nextRemoteCommitDustExposure = if (contributesToRemoteCommitDustExposure) currentRemoteCommitDustExposure + add.amountMsat else currentRemoteCommitDustExposure + (nextLocalCommitDustExposure, nextRemoteCommitDustExposure, acceptedHtlcs :+ add, rejectedHtlcs) } - case Nil => (acceptedHtlcs, rejectedHtlcs) } + (acceptedHtlcs, rejectedHtlcs) } /** @@ -428,7 +423,7 @@ object Commitments { // If sending this htlc would overflow our dust exposure, we reject it. val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure val (localCommitDustExposure, remoteCommitDustExposure) = commitments.currentDustExposure() - val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = commitments.contributesToDustExposure(add) + val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = commitments.contributesToDustExposure(OutgoingHtlc(add)) if (contributesToLocalCommitDustExposure && localCommitDustExposure + add.amountMsat > maxDustExposure) { return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, localCommitDustExposure + add.amountMsat)) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index 45d6a15074..24b08eac78 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -132,13 +132,13 @@ object CommitmentSpec { * the risk that we'll overflow our dust exposure. * However, this cannot fully protect us if the feerate increases too much (in which case we may have to force-close). */ - def dustBufferFeerate(currentFeerate: FeeratePerKw): FeeratePerKw = { + def feerateForDustExposure(currentFeerate: FeeratePerKw): FeeratePerKw = { (currentFeerate * 1.25).max(currentFeerate + FeeratePerKw(FeeratePerByte(10 sat))) } /** Test whether the given HTLC contributes to our dust exposure. */ def contributesToDustExposure(htlc: DirectedHtlc, spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Boolean = { - val feerate = dustBufferFeerate(spec.htlcTxFeerate(commitmentFormat)) + val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) val threshold = htlc match { case _: IncomingHtlc => Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) case _: OutgoingHtlc => Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) @@ -148,7 +148,7 @@ object CommitmentSpec { /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes). */ def dustExposure(spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { - val feerate = dustBufferFeerate(spec.htlcTxFeerate(commitmentFormat)) + val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) dustExposure(spec, feerate, dustLimit, commitmentFormat) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index ec8d90b108..d1bfbf2ba7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -19,13 +19,13 @@ package fr.acinq.eclair.channel import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector64, DeterministicWallet, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.TestConstants.TestFeeEstimator -import fr.acinq.eclair.blockchain.fee.{DustTolerance, FeeTargets, FeeratePerKw, FeerateTolerance, OnChainFeeConf} +import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.channel.Commitments._ import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel.states.ChannelStateTestsBase import fr.acinq.eclair.crypto.ShaChain -import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions.CommitTx +import fr.acinq.eclair.transactions.{CommitmentSpec, OutgoingHtlc} import fr.acinq.eclair.wire.protocol.{IncorrectOrUnknownPaymentDetails, UpdateAddHtlc} import fr.acinq.eclair.{TestKitBaseClass, _} import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -475,9 +475,9 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val ac0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments assert(ac0.currentDustExposure() === (0 msat, 0 msat)) - assert(ac0.contributesToDustExposure(UpdateAddHtlc(channelId(alice), 0, 9000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket)) === (true, true)) - assert(ac0.contributesToDustExposure(UpdateAddHtlc(channelId(alice), 0, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket)) === (false, true)) - assert(ac0.contributesToDustExposure(UpdateAddHtlc(channelId(alice), 0, 10000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket)) === (false, false)) + assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 9000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (true, true)) + assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (false, true)) + assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 10000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (false, false)) addHtlc(9000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) addHtlc(9500.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) From feffafd9989782755529bf05f45ed770e2f8b661 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 6 Oct 2021 13:28:36 +0200 Subject: [PATCH 09/18] Add more tests --- .../scala/fr/acinq/eclair/channel/CommitmentsSpec.scala | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index d1bfbf2ba7..342676a2d4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -25,7 +25,7 @@ import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel.states.ChannelStateTestsBase import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions.CommitTx -import fr.acinq.eclair.transactions.{CommitmentSpec, OutgoingHtlc} +import fr.acinq.eclair.transactions.{CommitmentSpec, IncomingHtlc, OutgoingHtlc} import fr.acinq.eclair.wire.protocol.{IncorrectOrUnknownPaymentDetails, UpdateAddHtlc} import fr.acinq.eclair.{TestKitBaseClass, _} import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -476,8 +476,13 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val ac0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments assert(ac0.currentDustExposure() === (0 msat, 0 msat)) assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 9000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (true, true)) + assert(ac0.contributesToDustExposure(IncomingHtlc(UpdateAddHtlc(channelId(alice), 0, 9000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (true, true)) + // NB: HTLC-success transactions are bigger than HTLC-timeout transactions. That means outgoing htlcs have a lower + // dust threshold than incoming htlcs in our local commit (and the opposite in the remote commit). assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (false, true)) + assert(ac0.contributesToDustExposure(IncomingHtlc(UpdateAddHtlc(channelId(alice), 0, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (true, false)) assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 10000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (false, false)) + assert(ac0.contributesToDustExposure(IncomingHtlc(UpdateAddHtlc(channelId(alice), 0, 10000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (false, false)) addHtlc(9000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) addHtlc(9500.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) From e94b6318b2daaf5d5708926ced707ee2fdf753c3 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 6 Oct 2021 14:58:56 +0200 Subject: [PATCH 10/18] fixup! Address PR comments --- .../eclair/transactions/CommitmentSpec.scala | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index 24b08eac78..ff92844eab 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -17,10 +17,10 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.{Satoshi, SatoshiLong} +import fr.acinq.eclair.MilliSatoshi import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong} /** * Created by PM on 07/12/2016. @@ -136,9 +136,14 @@ object CommitmentSpec { (currentFeerate * 1.25).max(currentFeerate + FeeratePerKw(FeeratePerByte(10 sat))) } - /** Test whether the given HTLC contributes to our dust exposure. */ + /** Test whether the given HTLC contributes to our dust exposure with the default dust feerate calculation. */ def contributesToDustExposure(htlc: DirectedHtlc, spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Boolean = { val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) + contributesToDustExposure(htlc, feerate, dustLimit, commitmentFormat) + } + + /** Test whether the given HTLC contributes to our dust exposure at the given feerate. */ + def contributesToDustExposure(htlc: DirectedHtlc, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Boolean = { val threshold = htlc match { case _: IncomingHtlc => Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) case _: OutgoingHtlc => Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) @@ -146,21 +151,16 @@ object CommitmentSpec { htlc.add.amountMsat < threshold } - /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes). */ + /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes) with the default dust feerate calculation. */ def dustExposure(spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) dustExposure(spec, feerate, dustLimit, commitmentFormat) } - /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes). */ + /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes) at the given feerate. */ def dustExposure(spec: CommitmentSpec, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { - val incomingHtlcThreshold = Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) - val outgoingHtlcThreshold = Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) - spec.htlcs.foldLeft(0 msat) { - case (exposure, IncomingHtlc(add)) if add.amountMsat < incomingHtlcThreshold => exposure + add.amountMsat - case (exposure, OutgoingHtlc(add)) if add.amountMsat < outgoingHtlcThreshold => exposure + add.amountMsat - case (exposure, _) => exposure - } + // NB: we need the `toSeq` because otherwise duplicate amountMsat would be removed (since `spec.htlcs` is a Set). + spec.htlcs.filter(htlc => contributesToDustExposure(htlc, feerate, dustLimit, commitmentFormat)).toSeq.map(_.add.amountMsat).sum } def reduce(localCommitSpec: CommitmentSpec, localChanges: List[UpdateMessage], remoteChanges: List[UpdateMessage]): CommitmentSpec = { From 65c02c3aa61bceb358660a305dc07bc28030ad09 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 6 Oct 2021 16:05:20 +0200 Subject: [PATCH 11/18] Use separate errors for local / remote --- .../eclair/channel/ChannelExceptions.scala | 3 +- .../fr/acinq/eclair/channel/Commitments.scala | 30 ++++++++++--------- .../channel/states/e/NormalStateSpec.scala | 10 +++---- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index 6a74524c9a..e5805a4511 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala @@ -78,7 +78,8 @@ 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 DustHtlcExposureTooHighInFlight (override val channelId: ByteVector32, maximum: Satoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"dust htlcs hold too much value: maximum=$maximum actual=$actual") +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") case class RemoteCannotAffordFeesForNewHtlc (override val channelId: ByteVector32, amount: MilliSatoshi, missing: Satoshi, reserve: Satoshi, fees: Satoshi) extends ChannelException(channelId, s"remote can't afford increased commit tx fees once new HTLC is added: missing=$missing reserve=$reserve fees=$fees") case class InvalidHtlcPreimage (override val channelId: ByteVector32, id: Long) extends ChannelException(channelId, s"invalid htlc preimage for htlc id=$id") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index d9f7af6b38..cfa1ba5e72 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -425,10 +425,10 @@ object Commitments { val (localCommitDustExposure, remoteCommitDustExposure) = commitments.currentDustExposure() val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = commitments.contributesToDustExposure(OutgoingHtlc(add)) if (contributesToLocalCommitDustExposure && localCommitDustExposure + add.amountMsat > maxDustExposure) { - return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, localCommitDustExposure + add.amountMsat)) + return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localCommitDustExposure + add.amountMsat)) } if (contributesToRemoteCommitDustExposure && remoteCommitDustExposure + add.amountMsat > maxDustExposure) { - return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, remoteCommitDustExposure + add.amountMsat)) + return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteCommitDustExposure + add.amountMsat)) } Right(commitments1, add) @@ -589,12 +589,13 @@ object Commitments { // if we would overflow our dust exposure with the new feerate, we avoid sending this fee update if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val dustExposureAfterFeeUpdate = Seq( - CommitmentSpec.dustExposure(commitments1.localCommit.spec, cmd.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat), - CommitmentSpec.dustExposure(reduced, cmd.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) - ).max - if (dustExposureAfterFeeUpdate > maxDustExposure) { - return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, dustExposureAfterFeeUpdate)) + val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(commitments1.localCommit.spec, cmd.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat) + if (localDustExposureAfterFeeUpdate > maxDustExposure) { + return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) + } + val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(reduced, cmd.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) + if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { + return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } } @@ -634,12 +635,13 @@ object Commitments { // if we would overflow our dust exposure with the new feerate, we reject this fee update if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val dustExposureAfterFeeUpdate = Seq( - CommitmentSpec.dustExposure(commitments1.localCommit.spec, fee.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat), - CommitmentSpec.dustExposure(reduced, fee.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat), - ).max - if (dustExposureAfterFeeUpdate > maxDustExposure) { - return Left(DustHtlcExposureTooHighInFlight(commitments.channelId, maxDustExposure, dustExposureAfterFeeUpdate)) + val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(commitments1.localCommit.spec, fee.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat) + if (localDustExposureAfterFeeUpdate > maxDustExposure) { + return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) + } + val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(reduced, fee.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) + if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { + return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 486ad5aedf..bcda556b0d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -403,13 +403,13 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // HTLCs that take Alice's dust exposure above her threshold are rejected. val dustAdd = CMD_ADD_HTLC(sender.ref, 501.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) alice ! dustAdd - sender.expectMsg(RES_ADD_FAILED(dustAdd, DustHtlcExposureTooHighInFlight(channelId(alice), 25000.sat, 25001.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + sender.expectMsg(RES_ADD_FAILED(dustAdd, LocalDustHtlcExposureTooHigh(channelId(alice), 25000.sat, 25001.sat.toMilliSatoshi), Some(initialState.channelUpdate))) val trimmedAdd = CMD_ADD_HTLC(sender.ref, 5000.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) alice ! trimmedAdd - sender.expectMsg(RES_ADD_FAILED(trimmedAdd, DustHtlcExposureTooHighInFlight(channelId(alice), 25000.sat, 29500.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + sender.expectMsg(RES_ADD_FAILED(trimmedAdd, LocalDustHtlcExposureTooHigh(channelId(alice), 25000.sat, 29500.sat.toMilliSatoshi), Some(initialState.channelUpdate))) val justAboveTrimmedAdd = CMD_ADD_HTLC(sender.ref, 8500.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) alice ! justAboveTrimmedAdd - sender.expectMsg(RES_ADD_FAILED(justAboveTrimmedAdd, DustHtlcExposureTooHighInFlight(channelId(alice), 25000.sat, 33000.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + sender.expectMsg(RES_ADD_FAILED(justAboveTrimmedAdd, LocalDustHtlcExposureTooHigh(channelId(alice), 25000.sat, 33000.sat.toMilliSatoshi), Some(initialState.channelUpdate))) // HTLCs that don't contribute to dust exposure are accepted. alice ! CMD_ADD_HTLC(sender.ref, 25000.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) @@ -1778,7 +1778,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val sender = TestProbe() val cmd = CMD_UPDATE_FEE(FeeratePerKw(20000 sat), replyTo_opt = Some(sender.ref)) alice ! cmd - sender.expectMsg(RES_FAILURE(cmd, DustHtlcExposureTooHighInFlight(channelId(alice), 25000 sat, 29000000 msat))) + sender.expectMsg(RES_FAILURE(cmd, RemoteDustHtlcExposureTooHigh(channelId(alice), 25000 sat, 29000000 msat))) } test("recv CMD_UPDATE_FEE (two in a row)") { f => @@ -1953,7 +1953,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob.feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(25000 sat))) bob ! UpdateFee(channelId(bob), FeeratePerKw(25000 sat)) val error = bob2alice.expectMsgType[Error] - assert(new String(error.data.toArray) === DustHtlcExposureTooHighInFlight(channelId(bob), 50000 sat, 59000000 msat).getMessage) + assert(new String(error.data.toArray) === LocalDustHtlcExposureTooHigh(channelId(bob), 50000 sat, 59000000 msat).getMessage) assert(bob2blockchain.expectMsgType[PublishRawTx].tx.txid === tx.txid) awaitCond(bob.stateName == CLOSING) } From d3ea51a9a0350f1e905d500a790ccf2a34e98cfc Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 6 Oct 2021 17:27:36 +0200 Subject: [PATCH 12/18] Reduce specs in sendFee/receiveFee --- .../scala/fr/acinq/eclair/channel/Commitments.scala | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index cfa1ba5e72..384548232a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -583,13 +583,15 @@ object Commitments { val fees = commitTxTotalCost(commitments1.remoteParams.dustLimit, reduced, commitments.commitmentFormat) val missing = reduced.toRemote.truncateToSatoshi - commitments1.remoteParams.channelReserve - fees if (missing < 0.sat) { - return Left(CannotAffordFees(commitments.channelId, missing = -missing, reserve = commitments1.localParams.channelReserve, fees = fees)) + return Left(CannotAffordFees(commitments.channelId, missing = -missing, reserve = commitments1.remoteParams.channelReserve, fees = fees)) } // if we would overflow our dust exposure with the new feerate, we avoid sending this fee update if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(commitments1.localCommit.spec, cmd.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat) + // we apply the other pending changes before evaluating our future dust exposure + val localReduced = CommitmentSpec.reduce(commitments1.localCommit.spec, commitments1.localChanges.proposed, commitments1.remoteChanges.acked) + val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(localReduced, cmd.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } @@ -626,7 +628,7 @@ object Commitments { val reduced = CommitmentSpec.reduce(commitments1.localCommit.spec, commitments1.localChanges.acked, commitments1.remoteChanges.proposed) // a node cannot spend pending incoming htlcs, and need to keep funds above the reserve required by the counterparty, after paying the fee - val fees = commitTxTotalCost(commitments1.remoteParams.dustLimit, reduced, commitments.commitmentFormat) + val fees = commitTxTotalCost(commitments1.localParams.dustLimit, reduced, commitments.commitmentFormat) val missing = reduced.toRemote.truncateToSatoshi - commitments1.localParams.channelReserve - fees if (missing < 0.sat) { return Left(CannotAffordFees(commitments.channelId, missing = -missing, reserve = commitments1.localParams.channelReserve, fees = fees)) @@ -635,11 +637,12 @@ object Commitments { // if we would overflow our dust exposure with the new feerate, we reject this fee update if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(commitments1.localCommit.spec, fee.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat) + val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(reduced, fee.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } - val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(reduced, fee.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) + val remoteReduced = CommitmentSpec.reduce(commitments1.remoteCommit.spec, commitments1.remoteChanges.proposed, commitments1.localChanges.acked) + val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(remoteReduced, fee.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } From 15214eea37d9de136dec433a8cecdbf76e59e608 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 7 Oct 2021 15:50:37 +0200 Subject: [PATCH 13/18] Properly handle concurrent updates And add many tests for the corresponding scenarios. --- .../fr/acinq/eclair/channel/Commitments.scala | 104 ++--- .../eclair/transactions/CommitmentSpec.scala | 35 +- .../scala/fr/acinq/eclair/TestConstants.scala | 2 +- .../eclair/channel/CommitmentsSpec.scala | 35 +- .../ChannelStateTestsHelperMethods.scala | 26 +- .../channel/states/e/NormalStateSpec.scala | 396 ++++++++++++++++-- .../transactions/CommitmentSpecSpec.scala | 37 +- 7 files changed, 500 insertions(+), 135 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 384548232a..748d2d6574 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -183,41 +183,6 @@ case class Commitments(channelId: ByteVector32, localCommit.spec.htlcs.collect(incoming).filter(nearlyExpired) } - /** Compute our dust exposure in our commit tx (local) and their commit tx (remote). */ - def currentDustExposure(): (MilliSatoshi, MilliSatoshi) = { - val localCommitDustExposure = CommitmentSpec.dustExposure(localCommit.spec, localParams.dustLimit, commitmentFormat) - val remoteCommitDustExposure = CommitmentSpec.dustExposure(remoteCommit.spec, remoteParams.dustLimit, commitmentFormat) - (localCommitDustExposure, remoteCommitDustExposure) - } - - /** Test whether the given htlc contributes to the dust exposure in the local or remote commit tx. */ - def contributesToDustExposure(htlc: DirectedHtlc): (Boolean, Boolean) = { - val contributesToLocalCommitDustExposure = CommitmentSpec.contributesToDustExposure(htlc, localCommit.spec, localParams.dustLimit, commitmentFormat) - val contributesToRemoteCommitDustExposure = CommitmentSpec.contributesToDustExposure(htlc.opposite, remoteCommit.spec, remoteParams.dustLimit, commitmentFormat) - (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) - } - - /** Accept as many incoming HTLCs as possible, in the order they are provided, while not overflowing our dust exposure. */ - def addHtlcsUntilDustExposureReached(maxDustExposure: Satoshi, - localCommitDustExposure: MilliSatoshi, - remoteCommitDustExposure: MilliSatoshi, - receivedHtlcs: Seq[UpdateAddHtlc]): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { - val (_, _, acceptedHtlcs, rejectedHtlcs) = receivedHtlcs.foldLeft((localCommitDustExposure, remoteCommitDustExposure, Seq.empty[UpdateAddHtlc], Seq.empty[UpdateAddHtlc])) { - case ((currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs), add) => - val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = contributesToDustExposure(IncomingHtlc(add)) - val rejectHtlc = (contributesToLocalCommitDustExposure && currentLocalCommitDustExposure + add.amountMsat > maxDustExposure) || - (contributesToRemoteCommitDustExposure && currentRemoteCommitDustExposure + add.amountMsat > maxDustExposure) - if (rejectHtlc) { - (currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs :+ add) - } else { - val nextLocalCommitDustExposure = if (contributesToLocalCommitDustExposure) currentLocalCommitDustExposure + add.amountMsat else currentLocalCommitDustExposure - val nextRemoteCommitDustExposure = if (contributesToRemoteCommitDustExposure) currentRemoteCommitDustExposure + add.amountMsat else currentRemoteCommitDustExposure - (nextLocalCommitDustExposure, nextRemoteCommitDustExposure, acceptedHtlcs :+ add, rejectedHtlcs) - } - } - (acceptedHtlcs, rejectedHtlcs) - } - /** * Return a fully signed commit tx, that can be published as-is. */ @@ -422,13 +387,15 @@ object Commitments { // If sending this htlc would overflow our dust exposure, we reject it. val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val (localCommitDustExposure, remoteCommitDustExposure) = commitments.currentDustExposure() - val (contributesToLocalCommitDustExposure, contributesToRemoteCommitDustExposure) = commitments.contributesToDustExposure(OutgoingHtlc(add)) - if (contributesToLocalCommitDustExposure && localCommitDustExposure + add.amountMsat > maxDustExposure) { - return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localCommitDustExposure + add.amountMsat)) + val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) + val localDustExposureAfterAdd = CommitmentSpec.dustExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) + if (localDustExposureAfterAdd > maxDustExposure) { + return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterAdd)) } - if (contributesToRemoteCommitDustExposure && remoteCommitDustExposure + add.amountMsat > maxDustExposure) { - return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteCommitDustExposure + add.amountMsat)) + val remoteReduced = CommitmentSpec.reduce(remoteCommit1.spec, commitments.remoteChanges.all, commitments1.localChanges.all) + val remoteDustExposureAfterAdd = CommitmentSpec.dustExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + if (remoteDustExposureAfterAdd > maxDustExposure) { + return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterAdd)) } Right(commitments1, add) @@ -589,13 +556,15 @@ object Commitments { // if we would overflow our dust exposure with the new feerate, we avoid sending this fee update if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - // we apply the other pending changes before evaluating our future dust exposure - val localReduced = CommitmentSpec.reduce(commitments1.localCommit.spec, commitments1.localChanges.proposed, commitments1.remoteChanges.acked) - val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(localReduced, cmd.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat) + // this is the commitment as it would be if our update_fee was immediately signed by both parties (it is only an + // estimate because there can be concurrent updates) + val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) + val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(localReduced, cmd.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } - val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(reduced, cmd.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) + val remoteReduced = CommitmentSpec.reduce(commitments.remoteCommit.spec, commitments.remoteChanges.all, commitments1.localChanges.all) + val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(remoteReduced, cmd.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } @@ -637,12 +606,15 @@ object Commitments { // if we would overflow our dust exposure with the new feerate, we reject this fee update if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(reduced, fee.feeratePerKw, commitments1.localParams.dustLimit, commitments1.commitmentFormat) + val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments.localChanges.all, commitments1.remoteChanges.all) + val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(localReduced, fee.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } - val remoteReduced = CommitmentSpec.reduce(commitments1.remoteCommit.spec, commitments1.remoteChanges.proposed, commitments1.localChanges.acked) - val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(remoteReduced, fee.feeratePerKw, commitments1.remoteParams.dustLimit, commitments1.commitmentFormat) + // this is the commitment as it would be if their update_fee was immediately signed by both parties (it is only an + // estimate because there can be concurrent updates) + val remoteReduced = CommitmentSpec.reduce(commitments.remoteCommit.spec, commitments1.remoteChanges.all, commitments.localChanges.all) + val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(remoteReduced, fee.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } @@ -799,19 +771,33 @@ object Commitments { RES_ADD_SETTLED(origin, add, HtlcResult.RemoteFailMalformed(fail)) } val (acceptedHtlcs, rejectedHtlcs) = { - // the received htlcs have already been added to our commitment (they've been signed by our peer), and may already - // overflow our dust exposure: we artificially remove them before deciding which we'll keep and relay and which - // we'll fail without relaying - val previousCommitments = commitments.copy(localCommit = commitments.localCommit.copy(spec = commitments.localCommit.spec.copy( - htlcs = commitments.localCommit.spec.htlcs.filter { - case IncomingHtlc(add) if receivedHtlcs.contains(add) => false - case _ => true - })) - ) - val (localCommitDustExposure, remoteCommitDustExposure) = previousCommitments.currentDustExposure() + // the received htlcs have already been added to commitments (they've been signed by our peer), and may already + // overflow our dust exposure (we cannot prevent them from adding htlcs): we artificially remove them before + // deciding which we'll keep and relay and which we'll fail without relaying. + val localSpecWithoutNewHtlcs = commitments.localCommit.spec.copy(htlcs = commitments.localCommit.spec.htlcs.filter { + case IncomingHtlc(add) if receivedHtlcs.contains(add) => false + case _ => true + }) + val remoteSpecWithoutNewHtlcs = theirNextCommit.spec.copy(htlcs = theirNextCommit.spec.htlcs.filter { + case OutgoingHtlc(add) if receivedHtlcs.contains(add) => false + case _ => true + }) + val localReduced = CommitmentSpec.reduce(localSpecWithoutNewHtlcs, commitments.localChanges.all, commitments.remoteChanges.acked) + val localCommitDustExposure = CommitmentSpec.dustExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) + val remoteReduced = CommitmentSpec.reduce(remoteSpecWithoutNewHtlcs, commitments.remoteChanges.acked, commitments.localChanges.all) + val remoteCommitDustExposure = CommitmentSpec.dustExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) // we sort incoming htlcs by decreasing amount: we want to prioritize higher amounts. val sortedReceivedHtlcs = receivedHtlcs.sortBy(_.amountMsat).reverse - previousCommitments.addHtlcsUntilDustExposureReached(maxDustExposure, localCommitDustExposure, remoteCommitDustExposure, sortedReceivedHtlcs) + CommitmentSpec.addIncomingHtlcsUntilDustExposureReached( + maxDustExposure, + localReduced, + commitments.localParams.dustLimit, + localCommitDustExposure, + remoteReduced, + commitments.remoteParams.dustLimit, + remoteCommitDustExposure, + sortedReceivedHtlcs, + commitments.commitmentFormat) } val actions = acceptedHtlcs.map(add => PostRevocationAction.RelayHtlc(add)) ++ rejectedHtlcs.map(add => PostRevocationAction.RejectHtlc(add)) ++ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index ff92844eab..9a5813c102 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -101,28 +101,28 @@ object CommitmentSpec { def fulfillIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { spec.findIncomingHtlcById(htlcId) match { case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => throw new RuntimeException(s"cannot find htlc id=$htlcId") + case None => spec } } def fulfillOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { spec.findOutgoingHtlcById(htlcId) match { case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => throw new RuntimeException(s"cannot find htlc id=$htlcId") + case None => spec } } def failIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { spec.findIncomingHtlcById(htlcId) match { case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => throw new RuntimeException(s"cannot find htlc id=$htlcId") + case None => spec } } def failOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { spec.findOutgoingHtlcById(htlcId) match { case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => throw new RuntimeException(s"cannot find htlc id=$htlcId") + case None => spec } } @@ -163,6 +163,33 @@ object CommitmentSpec { spec.htlcs.filter(htlc => contributesToDustExposure(htlc, feerate, dustLimit, commitmentFormat)).toSeq.map(_.add.amountMsat).sum } + /** Accept as many incoming HTLCs as possible, in the order they are provided, while not overflowing our dust exposure. */ + def addIncomingHtlcsUntilDustExposureReached(maxDustExposure: Satoshi, + localSpec: CommitmentSpec, + localDustLimit: Satoshi, + localCommitDustExposure: MilliSatoshi, + remoteSpec: CommitmentSpec, + remoteDustLimit: Satoshi, + remoteCommitDustExposure: MilliSatoshi, + receivedHtlcs: Seq[UpdateAddHtlc], + commitmentFormat: CommitmentFormat): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { + val (_, _, acceptedHtlcs, rejectedHtlcs) = receivedHtlcs.foldLeft((localCommitDustExposure, remoteCommitDustExposure, Seq.empty[UpdateAddHtlc], Seq.empty[UpdateAddHtlc])) { + case ((currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs), add) => + val contributesToLocalCommitDustExposure = contributesToDustExposure(IncomingHtlc(add), localSpec, localDustLimit, commitmentFormat) + val overflowsLocalCommitDustExposure = contributesToLocalCommitDustExposure && currentLocalCommitDustExposure + add.amountMsat > maxDustExposure + val contributesToRemoteCommitDustExposure = contributesToDustExposure(OutgoingHtlc(add), remoteSpec, remoteDustLimit, commitmentFormat) + val overflowsRemoteCommitDustExposure = contributesToRemoteCommitDustExposure && currentRemoteCommitDustExposure + add.amountMsat > maxDustExposure + if (overflowsLocalCommitDustExposure || overflowsRemoteCommitDustExposure) { + (currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs :+ add) + } else { + val nextLocalCommitDustExposure = if (contributesToLocalCommitDustExposure) currentLocalCommitDustExposure + add.amountMsat else currentLocalCommitDustExposure + val nextRemoteCommitDustExposure = if (contributesToRemoteCommitDustExposure) currentRemoteCommitDustExposure + add.amountMsat else currentRemoteCommitDustExposure + (nextLocalCommitDustExposure, nextRemoteCommitDustExposure, acceptedHtlcs :+ add, rejectedHtlcs) + } + } + (acceptedHtlcs, rejectedHtlcs) + } + def reduce(localCommitSpec: CommitmentSpec, localChanges: List[UpdateMessage], remoteChanges: List[UpdateMessage]): CommitmentSpec = { val spec1 = localChanges.foldLeft(localCommitSpec) { case (spec, u: UpdateAddHtlc) => addHtlc(spec, OutgoingHtlc(u)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 40ecf7a3db..daf8ab41db 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -243,7 +243,7 @@ object TestConstants { feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, - defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw, DustTolerance(50_000 sat, closeOnUpdateFeeOverflow = true)), + defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw, DustTolerance(30_000 sat, closeOnUpdateFeeOverflow = true)), perNodeFeerateTolerance = Map.empty ), maxHtlcValueInFlightMsat = UInt64.MaxValue, // Bob has no limit on the combined max value of in-flight htlcs diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index 342676a2d4..781337e2ce 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -24,8 +24,8 @@ import fr.acinq.eclair.channel.Commitments._ import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel.states.ChannelStateTestsBase import fr.acinq.eclair.crypto.ShaChain +import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions.CommitTx -import fr.acinq.eclair.transactions.{CommitmentSpec, IncomingHtlc, OutgoingHtlc} import fr.acinq.eclair.wire.protocol.{IncorrectOrUnknownPaymentDetails, UpdateAddHtlc} import fr.acinq.eclair.{TestKitBaseClass, _} import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -470,39 +470,6 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } } - test("add htlcs until we reach our maximum dust exposure") { f => - import f._ - - val ac0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(ac0.currentDustExposure() === (0 msat, 0 msat)) - assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 9000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (true, true)) - assert(ac0.contributesToDustExposure(IncomingHtlc(UpdateAddHtlc(channelId(alice), 0, 9000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (true, true)) - // NB: HTLC-success transactions are bigger than HTLC-timeout transactions. That means outgoing htlcs have a lower - // dust threshold than incoming htlcs in our local commit (and the opposite in the remote commit). - assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (false, true)) - assert(ac0.contributesToDustExposure(IncomingHtlc(UpdateAddHtlc(channelId(alice), 0, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (true, false)) - assert(ac0.contributesToDustExposure(OutgoingHtlc(UpdateAddHtlc(channelId(alice), 0, 10000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (false, false)) - assert(ac0.contributesToDustExposure(IncomingHtlc(UpdateAddHtlc(channelId(alice), 0, 10000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket))) === (false, false)) - - addHtlc(9000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) - addHtlc(9500.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) - crossSign(bob, alice, bob2alice, alice2bob) - val ac1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(ac1.currentDustExposure() === (18500.sat.toMilliSatoshi, 9000.sat.toMilliSatoshi)) - - val receivedHtlcs = Seq( - UpdateAddHtlc(channelId(alice), 5, 9500.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), - UpdateAddHtlc(channelId(alice), 6, 5000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), - UpdateAddHtlc(channelId(alice), 7, 1000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), - UpdateAddHtlc(channelId(alice), 8, 400.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), - UpdateAddHtlc(channelId(alice), 9, 400.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), - UpdateAddHtlc(channelId(alice), 10, 50000.sat.toMilliSatoshi, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket), - ) - val (accepted, rejected) = ac1.addHtlcsUntilDustExposureReached(25000 sat, 10000.sat.toMilliSatoshi, 10000.sat.toMilliSatoshi, receivedHtlcs) - assert(accepted.map(_.id).toSet === Set(5, 6, 8, 10)) - assert(rejected.map(_.id).toSet === Set(7, 9)) - } - } object CommitmentsSpec { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index ef60a8909b..4cb62e5187 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.TestConstants.{Alice, Bob, TestFeeEstimator} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeTargets, FeeratePerKw} -import fr.acinq.eclair.blockchain.{OnChainWallet, DummyOnChainWallet} +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.publish.TxPublisher import fr.acinq.eclair.channel.states.ChannelStateTestsHelperMethods.FakeTxPublisherFactory @@ -76,6 +76,10 @@ object ChannelStateTestsTags { val AliceLowMaxHtlcValueInFlight = "alice_low_max_htlc_value_in_flight" /** If set, channels will use option_upfront_shutdown_script. */ val OptionUpfrontShutdownScript = "option_upfront_shutdown_script" + /** If set, Alice will have a much higher dust limit than Bob. */ + val HighDustLimitDifferenceAliceBob = "high_dust_limit_difference_alice_bob" + /** If set, Bob will have a much higher dust limit than Alice. */ + val HighDustLimitDifferenceBobAlice = "high_dust_limit_difference_bob_alice" } trait ChannelStateTestsHelperMethods extends TestKitBase { @@ -96,7 +100,7 @@ trait ChannelStateTestsHelperMethods extends TestKitBase { def currentBlockHeight: Long = alice.underlyingActor.nodeParams.currentBlockHeight } - def init(nodeParamsA: NodeParams = TestConstants.Alice.nodeParams, nodeParamsB: NodeParams = TestConstants.Bob.nodeParams, wallet: OnChainWallet = new DummyOnChainWallet()): SetupFixture = { + def init(nodeParamsA: NodeParams = TestConstants.Alice.nodeParams, nodeParamsB: NodeParams = TestConstants.Bob.nodeParams, wallet: OnChainWallet = new DummyOnChainWallet(), tags: Set[String] = Set.empty): SetupFixture = { val alice2bob = TestProbe() val bob2alice = TestProbe() val alicePeer = TestProbe() @@ -111,8 +115,18 @@ trait ChannelStateTestsHelperMethods extends TestKitBase { system.eventStream.subscribe(channelUpdateListener.ref, classOf[LocalChannelUpdate]) system.eventStream.subscribe(channelUpdateListener.ref, classOf[LocalChannelDown]) val router = TestProbe() - val alice: TestFSMRef[ChannelState, ChannelData, Channel] = TestFSMRef(new Channel(nodeParamsA, wallet, Bob.nodeParams.nodeId, alice2blockchain.ref, relayerA.ref, FakeTxPublisherFactory(alice2blockchain)), alicePeer.ref) - val bob: TestFSMRef[ChannelState, ChannelData, Channel] = TestFSMRef(new Channel(nodeParamsB, wallet, Alice.nodeParams.nodeId, bob2blockchain.ref, relayerB.ref, FakeTxPublisherFactory(bob2blockchain)), bobPeer.ref) + val finalNodeParamsA = nodeParamsA + .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(5000 sat) + .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(1000 sat) + .modify(_.maxRemoteDustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(10000 sat) + .modify(_.maxRemoteDustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(10000 sat) + val finalNodeParamsB = nodeParamsB + .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(1000 sat) + .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(5000 sat) + .modify(_.maxRemoteDustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(10000 sat) + .modify(_.maxRemoteDustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(10000 sat) + val alice: TestFSMRef[ChannelState, ChannelData, Channel] = TestFSMRef(new Channel(finalNodeParamsA, wallet, finalNodeParamsB.nodeId, alice2blockchain.ref, relayerA.ref, FakeTxPublisherFactory(alice2blockchain)), alicePeer.ref) + val bob: TestFSMRef[ChannelState, ChannelData, Channel] = TestFSMRef(new Channel(finalNodeParamsB, wallet, finalNodeParamsA.nodeId, bob2blockchain.ref, relayerB.ref, FakeTxPublisherFactory(bob2blockchain)), bobPeer.ref) SetupFixture(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, router, relayerA, relayerB, channelUpdateListener, wallet, alicePeer, bobPeer) } @@ -142,10 +156,14 @@ trait ChannelStateTestsHelperMethods extends TestKitBase { .modify(_.walletStaticPaymentBasepoint).setToIf(channelType.paysDirectlyToWallet)(Some(Helpers.getWalletPaymentBasepoint(wallet))) .modify(_.maxHtlcValueInFlightMsat).setToIf(tags.contains(ChannelStateTestsTags.NoMaxHtlcValueInFlight))(UInt64.MaxValue) .modify(_.maxHtlcValueInFlightMsat).setToIf(tags.contains(ChannelStateTestsTags.AliceLowMaxHtlcValueInFlight))(UInt64(150000000)) + .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(5000 sat) + .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(1000 sat) val bobParams = Bob.channelParams .modify(_.initFeatures).setTo(bobInitFeatures) .modify(_.walletStaticPaymentBasepoint).setToIf(channelType.paysDirectlyToWallet)(Some(Helpers.getWalletPaymentBasepoint(wallet))) .modify(_.maxHtlcValueInFlightMsat).setToIf(tags.contains(ChannelStateTestsTags.NoMaxHtlcValueInFlight))(UInt64.MaxValue) + .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(1000 sat) + .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(5000 sat) (aliceParams, bobParams, channelType) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index bcda556b0d..3b4febaef6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -37,8 +37,8 @@ import fr.acinq.eclair.payment.OutgoingPacket import fr.acinq.eclair.payment.relay.Relayer._ import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.DirectedHtlc.{incoming, outgoing} -import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, HtlcSuccessTx, weight2fee} +import fr.acinq.eclair.transactions.{CommitmentSpec, Transactions} import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelUpdate, ClosingSigned, CommitSig, Error, FailureMessageCodecs, PermanentChannelFailure, RevokeAndAck, Shutdown, TemporaryNodeFailure, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc, Warning} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -57,7 +57,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with implicit val log: akka.event.LoggingAdapter = akka.event.NoLogging override def withFixture(test: OneArgTest): Outcome = { - val setup = init() + val setup = init(tags = test.tags) import setup._ within(30 seconds) { reachNormal(setup, test.tags) @@ -364,11 +364,11 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(initialState.commitments.localParams.maxAcceptedHtlcs === 30) // Bob accepts a maximum of 30 htlcs assert(initialState.commitments.remoteParams.maxAcceptedHtlcs === 100) // Alice accepts more, but Bob will stop at 30 HTLCs for (_ <- 0 until 30) { - bob ! CMD_ADD_HTLC(sender.ref, 2500000 msat, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + bob ! CMD_ADD_HTLC(sender.ref, 500000 msat, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]] bob2alice.expectMsgType[UpdateAddHtlc] } - val add = CMD_ADD_HTLC(sender.ref, 2500000 msat, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + val add = CMD_ADD_HTLC(sender.ref, 500000 msat, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) bob ! add val error = TooManyAcceptedHtlcs(channelId(bob), maximum = 30) sender.expectMsg(RES_ADD_FAILED(add, error, Some(initialState.channelUpdate))) @@ -417,6 +417,80 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2bob.expectMsgType[UpdateAddHtlc] } + test("recv CMD_ADD_HTLC (over max dust htlc exposure with pending local changes)") { f => + import f._ + val sender = TestProbe() + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) + + // Alice sends HTLCs to Bob that add 20 000 sat to the dust exposure. + // She signs them but Bob doesn't answer yet. + addHtlc(4000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(3000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(7000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(6000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + alice ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + alice2bob.expectMsgType[CommitSig] + + // Alice sends HTLCs to Bob that add 4 000 sat to the dust exposure. + addHtlc(2500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(1500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + + // HTLCs that take Alice's dust exposure above her threshold are rejected. + val add = CMD_ADD_HTLC(sender.ref, 1001.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + alice ! add + sender.expectMsg(RES_ADD_FAILED(add, LocalDustHtlcExposureTooHigh(channelId(alice), 25000.sat, 25001.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + } + + test("recv CMD_ADD_HTLC (over max dust htlc exposure in local commit only with pending local changes)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + val sender = TestProbe() + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) + assert(alice.underlyingActor.nodeParams.dustLimit === 1100.sat) + assert(bob.underlyingActor.nodeParams.dustLimit === 1000.sat) + + // Alice sends HTLCs to Bob that add 21 000 sat to the dust exposure. + // She signs them but Bob doesn't answer yet. + (1 to 20).foreach(_ => addHtlc(1050.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice)) + alice ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + alice2bob.expectMsgType[CommitSig] + + // Alice sends HTLCs to Bob that add 3 150 sat to the dust exposure. + (1 to 3).foreach(_ => addHtlc(1050.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice)) + + // HTLCs that take Alice's dust exposure above her threshold are rejected. + val add = CMD_ADD_HTLC(sender.ref, 1050.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + alice ! add + sender.expectMsg(RES_ADD_FAILED(add, LocalDustHtlcExposureTooHigh(channelId(alice), 25000.sat, 25200.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + } + + test("recv CMD_ADD_HTLC (over max dust htlc exposure in remote commit only with pending local changes)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + val sender = TestProbe() + val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] + assert(bob.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(alice.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 30_000.sat) + assert(alice.underlyingActor.nodeParams.dustLimit === 1100.sat) + assert(bob.underlyingActor.nodeParams.dustLimit === 1000.sat) + + // Bob sends HTLCs to Alice that add 21 000 sat to the dust exposure. + // He signs them but Alice doesn't answer yet. + (1 to 20).foreach(_ => addHtlc(1050.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob)) + bob ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + bob2alice.expectMsgType[CommitSig] + + // Bob sends HTLCs to Alice that add 8400 sat to the dust exposure. + (1 to 8).foreach(_ => addHtlc(1050.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob)) + + // HTLCs that take Bob's dust exposure above his threshold are rejected. + val add = CMD_ADD_HTLC(sender.ref, 1050.sat.toMilliSatoshi, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, localOrigin(sender.ref)) + bob ! add + sender.expectMsg(RES_ADD_FAILED(add, RemoteDustHtlcExposureTooHigh(channelId(bob), 30000.sat, 30450.sat.toMilliSatoshi), Some(initialState.channelUpdate))) + } + test("recv CMD_ADD_HTLC (over capacity)", Tag(ChannelStateTestsTags.NoMaxHtlcValueInFlight)) { f => import f._ val sender = TestProbe() @@ -680,12 +754,17 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val sender = TestProbe() // for the test to be really useful we have constraint on parameters assert(Alice.nodeParams.dustLimit > Bob.nodeParams.dustLimit) + // and a low feerate to avoid messing with dust exposure limits + val currentFeerate = FeeratePerKw(2500 sat) + alice.feeEstimator.setFeerate(FeeratesPerKw.single(currentFeerate)) + bob.feeEstimator.setFeerate(FeeratesPerKw.single(currentFeerate)) + updateFee(currentFeerate, alice, bob, alice2bob, bob2alice) // we're gonna exchange two htlcs in each direction, the goal is to have bob's commitment have 4 htlcs, and alice's // commitment only have 3. We will then check that alice indeed persisted 4 htlcs, and bob only 3. - val aliceMinReceive = Alice.nodeParams.dustLimit + weight2fee(TestConstants.feeratePerKw, DefaultCommitmentFormat.htlcSuccessWeight) - val aliceMinOffer = Alice.nodeParams.dustLimit + weight2fee(TestConstants.feeratePerKw, DefaultCommitmentFormat.htlcTimeoutWeight) - val bobMinReceive = Bob.nodeParams.dustLimit + weight2fee(TestConstants.feeratePerKw, DefaultCommitmentFormat.htlcSuccessWeight) - val bobMinOffer = Bob.nodeParams.dustLimit + weight2fee(TestConstants.feeratePerKw, DefaultCommitmentFormat.htlcTimeoutWeight) + val aliceMinReceive = Alice.nodeParams.dustLimit + weight2fee(currentFeerate, DefaultCommitmentFormat.htlcSuccessWeight) + val aliceMinOffer = Alice.nodeParams.dustLimit + weight2fee(currentFeerate, DefaultCommitmentFormat.htlcTimeoutWeight) + val bobMinReceive = Bob.nodeParams.dustLimit + weight2fee(currentFeerate, DefaultCommitmentFormat.htlcSuccessWeight) + val bobMinOffer = Bob.nodeParams.dustLimit + weight2fee(currentFeerate, DefaultCommitmentFormat.htlcTimeoutWeight) val a2b_1 = bobMinReceive + 10.sat // will be in alice and bob tx val a2b_2 = bobMinReceive + 20.sat // will be in alice and bob tx val b2a_1 = aliceMinReceive + 10.sat // will be in alice and bob tx @@ -714,13 +793,13 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // actual test starts here crossSign(alice, bob, alice2bob, bob2alice) // depending on who starts signing first, there will be one or two commitments because both sides have changes - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.index === 1) - assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.index === 2) - assert(alice.underlyingActor.nodeParams.db.channels.listHtlcInfos(alice.stateData.asInstanceOf[DATA_NORMAL].channelId, 0).size == 0) - assert(alice.underlyingActor.nodeParams.db.channels.listHtlcInfos(alice.stateData.asInstanceOf[DATA_NORMAL].channelId, 1).size == 2) - assert(alice.underlyingActor.nodeParams.db.channels.listHtlcInfos(alice.stateData.asInstanceOf[DATA_NORMAL].channelId, 2).size == 4) - assert(bob.underlyingActor.nodeParams.db.channels.listHtlcInfos(bob.stateData.asInstanceOf[DATA_NORMAL].channelId, 0).size == 0) - assert(bob.underlyingActor.nodeParams.db.channels.listHtlcInfos(bob.stateData.asInstanceOf[DATA_NORMAL].channelId, 1).size == 3) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.index === 2) + assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.index === 3) + assert(alice.underlyingActor.nodeParams.db.channels.listHtlcInfos(alice.stateData.asInstanceOf[DATA_NORMAL].channelId, 1).size == 0) + assert(alice.underlyingActor.nodeParams.db.channels.listHtlcInfos(alice.stateData.asInstanceOf[DATA_NORMAL].channelId, 2).size == 2) + assert(alice.underlyingActor.nodeParams.db.channels.listHtlcInfos(alice.stateData.asInstanceOf[DATA_NORMAL].channelId, 3).size == 4) + assert(bob.underlyingActor.nodeParams.db.channels.listHtlcInfos(bob.stateData.asInstanceOf[DATA_NORMAL].channelId, 1).size == 0) + assert(bob.underlyingActor.nodeParams.db.channels.listHtlcInfos(bob.stateData.asInstanceOf[DATA_NORMAL].channelId, 2).size == 3) } test("recv CMD_SIGN (htlcs with same pubkeyScript but different amounts)") { f => @@ -1191,14 +1270,14 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val (_, nonDust) = addHtlc(20000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) // way above the trimmed threshold -> not included in the dust exposure crossSign(bob, alice, bob2alice, alice2bob) - // Alice forwards HTLCs that fit in the dust exposure + // Alice forwards HTLCs that fit in the dust exposure. relayerA.expectMsgAllOf( RelayForward(nonDust), RelayForward(almostTrimmed), RelayForward(trimmed2), ) relayerA.expectNoMessage(100 millis) - // And instantly fails the others + // And instantly fails the others. val failedHtlcs = Seq( alice2bob.expectMsgType[UpdateFailHtlc], alice2bob.expectMsgType[UpdateFailHtlc], @@ -1209,6 +1288,94 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2bob.expectNoMessage(100 millis) } + test("recv RevokeAndAck (over max dust htlc exposure with pending local changes)") { f => + import f._ + val sender = TestProbe() + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) + + // Bob sends HTLCs to Alice that add 10 000 sat to the dust exposure. + addHtlc(4000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + addHtlc(6000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + relayerA.expectMsgType[RelayForward] + relayerA.expectMsgType[RelayForward] + + // Alice sends HTLCs to Bob that add 10 000 sat to the dust exposure but doesn't sign them yet. + addHtlc(6500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(3500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + + // Bob sends HTLCs to Alice that add 10 000 sat to the dust exposure. + val (_, rejectedHtlc) = addHtlc(7000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + val (_, acceptedHtlc) = addHtlc(3000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + bob ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + alice2bob.forward(bob) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + bob2alice.expectMsgType[RevokeAndAck] + bob2alice.forward(alice) + + // Alice forwards HTLCs that fit in the dust exposure and instantly fails the others. + relayerA.expectMsg(RelayForward(acceptedHtlc)) + relayerA.expectNoMessage(100 millis) + assert(alice2bob.expectMsgType[UpdateFailHtlc].id === rejectedHtlc.id) + alice2bob.expectMsgType[CommitSig] + alice2bob.expectNoMessage(100 millis) + } + + def testRevokeAndAckDustOverflowSingleCommit(f: FixtureParam): Unit = { + import f._ + val sender = TestProbe() + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) + + // Bob sends HTLCs to Alice that add 10 500 sat to the dust exposure. + (1 to 10).foreach(_ => addHtlc(1050.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob)) + crossSign(bob, alice, bob2alice, alice2bob) + (1 to 10).foreach(_ => relayerA.expectMsgType[RelayForward]) + + // Alice sends HTLCs to Bob that add 10 500 sat to the dust exposure but doesn't sign them yet. + (1 to 10).foreach(_ => addHtlc(1050.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice)) + + // Bob sends HTLCs to Alice that add 8 400 sat to the dust exposure. + (1 to 8).foreach(_ => addHtlc(1050.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob)) + bob ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + alice2bob.forward(bob) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + bob2alice.expectMsgType[RevokeAndAck] + bob2alice.forward(alice) + + // Alice forwards HTLCs that fit in the dust exposure and instantly fails the others. + (1 to 3).foreach(_ => relayerA.expectMsgType[RelayForward]) + relayerA.expectNoMessage(100 millis) + (1 to 5).foreach(_ => alice2bob.expectMsgType[UpdateFailHtlc]) + alice2bob.expectMsgType[CommitSig] + alice2bob.expectNoMessage(100 millis) + } + + test("recv RevokeAndAck (over max dust htlc exposure in local commit only with pending local changes)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs), Tag(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob)) { f => + import f._ + val sender = TestProbe() + assert(alice.underlyingActor.nodeParams.dustLimit === 5000.sat) + assert(bob.underlyingActor.nodeParams.dustLimit === 1000.sat) + testRevokeAndAckDustOverflowSingleCommit(f) + } + + test("recv RevokeAndAck (over max dust htlc exposure in remote commit only with pending local changes)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs), Tag(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice)) { f => + import f._ + val sender = TestProbe() + assert(alice.underlyingActor.nodeParams.dustLimit === 1000.sat) + assert(bob.underlyingActor.nodeParams.dustLimit === 5000.sat) + testRevokeAndAckDustOverflowSingleCommit(f) + } + test("recv RevokeAndAck (unexpectedly)") { f => import f._ val tx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx @@ -1225,7 +1392,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv RevokeAndAck (forward UpdateFailHtlc)") { f => import f._ - val (_, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlc) = addHtlc(150000000 msat, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) bob ! CMD_FAIL_HTLC(htlc.id, Right(PermanentChannelFailure)) val fail = bob2alice.expectMsgType[UpdateFailHtlc] @@ -1251,7 +1418,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv RevokeAndAck (forward UpdateFailMalformedHtlc)") { f => import f._ - val (_, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlc) = addHtlc(150000000 msat, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) bob ! CMD_FAIL_MALFORMED_HTLC(htlc.id, Sphinx.PaymentPacket.hash(htlc.onionRoutingPacket), FailureMessageCodecs.BADONION) val fail = bob2alice.expectMsgType[UpdateFailMalformedHtlc] @@ -1769,16 +1936,98 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ // Alice sends HTLCs to Bob that are not included in the dust exposure at the current feerate: + addHtlc(13000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) - addHtlc(15000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.currentDustExposure() === (0 msat, 0 msat)) + val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments + assert(CommitmentSpec.dustExposure(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(CommitmentSpec.dustExposure(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) // A large feerate increase would make these HTLCs overflow alice's dust exposure, so she rejects it: val sender = TestProbe() val cmd = CMD_UPDATE_FEE(FeeratePerKw(20000 sat), replyTo_opt = Some(sender.ref)) alice ! cmd - sender.expectMsg(RES_FAILURE(cmd, RemoteDustHtlcExposureTooHigh(channelId(alice), 25000 sat, 29000000 msat))) + sender.expectMsg(RES_FAILURE(cmd, LocalDustHtlcExposureTooHigh(channelId(alice), 25000 sat, 27000000 msat))) + } + + test("recv CMD_UPDATE_FEE (over max dust htlc exposure with pending local changes)") { f => + import f._ + val sender = TestProbe() + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) + + // Alice sends an HTLC to Bob that is not included in the dust exposure at the current feerate. + // She signs them but Bob doesn't answer yet. + addHtlc(13000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + alice ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + alice2bob.expectMsgType[CommitSig] + + // Alice sends another HTLC to Bob that is not included in the dust exposure at the current feerate. + addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments + assert(CommitmentSpec.dustExposure(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(CommitmentSpec.dustExposure(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + + // A large feerate increase would make these HTLCs overflow alice's dust exposure, so she rejects it: + val cmd = CMD_UPDATE_FEE(FeeratePerKw(20000 sat), replyTo_opt = Some(sender.ref)) + alice ! cmd + sender.expectMsg(RES_FAILURE(cmd, LocalDustHtlcExposureTooHigh(channelId(alice), 25000 sat, 27000000 msat))) + } + + def testCmdUpdateFeeDustOverflowSingleCommit(f: FixtureParam): Unit = { + import f._ + val sender = TestProbe() + // We start with a low feerate. + val initialFeerate = FeeratePerKw(500 sat) + alice.feeEstimator.setFeerate(FeeratesPerKw.single(initialFeerate)) + bob.feeEstimator.setFeerate(FeeratesPerKw.single(initialFeerate)) + updateFee(initialFeerate, alice, bob, alice2bob, bob2alice) + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + val aliceCommitments = initialState.commitments + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) + val higherDustLimit = Seq(aliceCommitments.localParams.dustLimit, aliceCommitments.remoteParams.dustLimit).max + val lowerDustLimit = Seq(aliceCommitments.localParams.dustLimit, aliceCommitments.remoteParams.dustLimit).min + // We have the following dust thresholds at the current feerate + assert(Transactions.offeredHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 6989.sat) + assert(Transactions.receivedHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 7109.sat) + assert(Transactions.offeredHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 2989.sat) + assert(Transactions.receivedHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 3109.sat) + // And the following thresholds after the feerate update + // NB: we apply the real feerate when sending update_fee, not the one adjusted for dust + val updatedFeerate = FeeratePerKw(4000 sat) + assert(Transactions.offeredHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = updatedFeerate), aliceCommitments.commitmentFormat) === 7652.sat) + assert(Transactions.receivedHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = updatedFeerate), aliceCommitments.commitmentFormat) === 7812.sat) + assert(Transactions.offeredHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = updatedFeerate), aliceCommitments.commitmentFormat) === 3652.sat) + assert(Transactions.receivedHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = updatedFeerate), aliceCommitments.commitmentFormat) === 3812.sat) + + // Alice send HTLCs to Bob that are not included in the dust exposure at the current feerate. + // She signs them but Bob doesn't answer yet. + (1 to 2).foreach(_ => addHtlc(7400.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice)) + alice ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + alice2bob.expectMsgType[CommitSig] + + // Alice sends other HTLCs to Bob that are not included in the dust exposure at the current feerate, without signing them. + (1 to 2).foreach(_ => addHtlc(7400.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice)) + + // A feerate increase makes these HTLCs become dust in one of the commitments but not the other. + val cmd = CMD_UPDATE_FEE(updatedFeerate, replyTo_opt = Some(sender.ref)) + alice.feeEstimator.setFeerate(FeeratesPerKw.single(updatedFeerate)) + bob.feeEstimator.setFeerate(FeeratesPerKw.single(updatedFeerate)) + alice ! cmd + if (higherDustLimit == aliceCommitments.localParams.dustLimit) { + sender.expectMsg(RES_FAILURE(cmd, LocalDustHtlcExposureTooHigh(channelId(alice), 25000 sat, 29600000 msat))) + } else { + sender.expectMsg(RES_FAILURE(cmd, RemoteDustHtlcExposureTooHigh(channelId(alice), 25000 sat, 29600000 msat))) + } + } + + test("recv CMD_UPDATE_FEE (over max dust htlc exposure in local commit only with pending local changes)", Tag(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob)) { f => + testCmdUpdateFeeDustOverflowSingleCommit(f) + } + + test("recv CMD_UPDATE_FEE (over max dust htlc exposure in remote commit only with pending local changes)", Tag(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice)) { f => + testCmdUpdateFeeDustOverflowSingleCommit(f) } test("recv CMD_UPDATE_FEE (two in a row)") { f => @@ -1941,23 +2190,108 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ // Alice sends HTLCs to Bob that are not included in the dust exposure at the current feerate: + addHtlc(13000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(13500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) - addHtlc(14500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) - addHtlc(15000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) - addHtlc(15500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) - assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.currentDustExposure() === (0 msat, 0 msat)) + val bobCommitments = bob.stateData.asInstanceOf[DATA_NORMAL].commitments + assert(CommitmentSpec.dustExposure(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(CommitmentSpec.dustExposure(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) val tx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - // A large feerate increase would make these HTLCs overflow bob's dust exposure, so he force-closes: - bob.feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(25000 sat))) - bob ! UpdateFee(channelId(bob), FeeratePerKw(25000 sat)) + // A large feerate increase would make these HTLCs overflow Bob's dust exposure, so he force-closes: + bob.feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(20000 sat))) + bob ! UpdateFee(channelId(bob), FeeratePerKw(20000 sat)) val error = bob2alice.expectMsgType[Error] - assert(new String(error.data.toArray) === LocalDustHtlcExposureTooHigh(channelId(bob), 50000 sat, 59000000 msat).getMessage) + assert(new String(error.data.toArray) === LocalDustHtlcExposureTooHigh(channelId(bob), 30000 sat, 40500000 msat).getMessage) assert(bob2blockchain.expectMsgType[PublishRawTx].tx.txid === tx.txid) awaitCond(bob.stateName == CLOSING) } + test("recv UpdateFee (over max dust htlc exposure with pending local changes)") { f => + import f._ + val sender = TestProbe() + assert(bob.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(alice.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 30_000.sat) + + // Bob sends HTLCs to Alice that are not included in the dust exposure at the current feerate. + // He signs them but Alice doesn't answer yet. + addHtlc(13000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + addHtlc(13500.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + bob ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + bob2alice.expectMsgType[CommitSig] + + // Bob sends another HTLC to Alice that is not included in the dust exposure at the current feerate. + addHtlc(14000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) + val bobCommitments = bob.stateData.asInstanceOf[DATA_NORMAL].commitments + assert(CommitmentSpec.dustExposure(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(CommitmentSpec.dustExposure(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + + // A large feerate increase would make these HTLCs overflow Bob's dust exposure, so he force-close: + val tx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx + bob.feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(20000 sat))) + bob ! UpdateFee(channelId(bob), FeeratePerKw(20000 sat)) + val error = bob2alice.expectMsgType[Error] + assert(new String(error.data.toArray) === LocalDustHtlcExposureTooHigh(channelId(bob), 30000 sat, 40500000 msat).getMessage) + assert(bob2blockchain.expectMsgType[PublishRawTx].tx.txid === tx.txid) + awaitCond(bob.stateName == CLOSING) + } + + def testUpdateFeeDustOverflowSingleCommit(f: FixtureParam): Unit = { + import f._ + val sender = TestProbe() + // We start with a low feerate. + val initialFeerate = FeeratePerKw(500 sat) + alice.feeEstimator.setFeerate(FeeratesPerKw.single(initialFeerate)) + bob.feeEstimator.setFeerate(FeeratesPerKw.single(initialFeerate)) + updateFee(initialFeerate, alice, bob, alice2bob, bob2alice) + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + val aliceCommitments = initialState.commitments + assert(alice.underlyingActor.nodeParams.onChainFeeConf.feerateToleranceFor(bob.underlyingActor.nodeParams.nodeId).dustTolerance.maxExposure === 25_000.sat) + val higherDustLimit = Seq(aliceCommitments.localParams.dustLimit, aliceCommitments.remoteParams.dustLimit).max + val lowerDustLimit = Seq(aliceCommitments.localParams.dustLimit, aliceCommitments.remoteParams.dustLimit).min + // We have the following dust thresholds at the current feerate + assert(Transactions.offeredHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 6989.sat) + assert(Transactions.receivedHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 7109.sat) + assert(Transactions.offeredHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 2989.sat) + assert(Transactions.receivedHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 3109.sat) + // And the following thresholds after the feerate update + // NB: we apply the real feerate when sending update_fee, not the one adjusted for dust + val updatedFeerate = FeeratePerKw(4000 sat) + assert(Transactions.offeredHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = updatedFeerate), aliceCommitments.commitmentFormat) === 7652.sat) + assert(Transactions.receivedHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = updatedFeerate), aliceCommitments.commitmentFormat) === 7812.sat) + assert(Transactions.offeredHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = updatedFeerate), aliceCommitments.commitmentFormat) === 3652.sat) + assert(Transactions.receivedHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = updatedFeerate), aliceCommitments.commitmentFormat) === 3812.sat) + + // Bob send HTLCs to Alice that are not included in the dust exposure at the current feerate. + // He signs them but Alice doesn't answer yet. + (1 to 3).foreach(_ => addHtlc(7400.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob)) + bob ! CMD_SIGN(Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + bob2alice.expectMsgType[CommitSig] + + // Bob sends other HTLCs to Alice that are not included in the dust exposure at the current feerate, without signing them. + (1 to 2).foreach(_ => addHtlc(7400.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob)) + + // A feerate increase makes these HTLCs become dust in one of the commitments but not the other. + val tx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx + bob.feeEstimator.setFeerate(FeeratesPerKw.single(updatedFeerate)) + bob ! UpdateFee(channelId(bob), updatedFeerate) + val error = bob2alice.expectMsgType[Error] + // NB: we don't need to distinguish local and remote, the error message is exactly the same. + assert(new String(error.data.toArray) === LocalDustHtlcExposureTooHigh(channelId(bob), 30000 sat, 37000000 msat).getMessage) + assert(bob2blockchain.expectMsgType[PublishRawTx].tx.txid === tx.txid) + awaitCond(bob.stateName == CLOSING) + } + + test("recv UpdateFee (over max dust htlc exposure in local commit only with pending local changes)", Tag(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice)) { f => + testUpdateFeeDustOverflowSingleCommit(f) + } + + test("recv UpdateFee (over max dust htlc exposure in remote commit only with pending local changes)", Tag(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob)) { f => + testUpdateFeeDustOverflowSingleCommit(f) + } + test("recv CMD_UPDATE_RELAY_FEE ") { f => import f._ val sender = TestProbe() @@ -2460,7 +2794,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv CurrentBlockCount (fulfilled proposed htlc acked but not committed by upstream peer)") { f => import f._ - val (r, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) + val (r, htlc) = addHtlc(150000000 msat, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) val listener = TestProbe() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala index 8d59b3600f..5f84f85d1f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala @@ -75,8 +75,8 @@ class CommitmentSpecSpec extends AnyFunSuite { assert(spec.htlcTxFeerate(Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === FeeratePerKw(0 sat)) } - def createHtlc(amount: MilliSatoshi): UpdateAddHtlc = { - UpdateAddHtlc(ByteVector32.Zeroes, 0, amount, randomBytes32(), CltvExpiry(500), TestConstants.emptyOnionPacket) + def createHtlc(amount: MilliSatoshi, id: Long = 0): UpdateAddHtlc = { + UpdateAddHtlc(ByteVector32.Zeroes, id, amount, randomBytes32(), CltvExpiry(500), TestConstants.emptyOnionPacket) } test("compute dust exposure") { @@ -160,4 +160,37 @@ class CommitmentSpecSpec extends AnyFunSuite { } } + test("add incoming htlcs until we reach our maximum dust exposure") { + val dustLimit = 1000.sat + val initialSpec = CommitmentSpec(Set.empty, FeeratePerKw(10000 sat), 0 msat, 0 msat) + assert(CommitmentSpec.dustExposure(initialSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 0.msat) + assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(9000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(9000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + // NB: HTLC-success transactions are bigger than HTLC-timeout transactions: that means incoming htlcs have a higher + // dust threshold than outgoing htlcs in our commitment. + assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(9500.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(9500.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(10000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(10000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + + val updatedSpec = initialSpec.copy(htlcs = Set( + OutgoingHtlc(createHtlc(9000.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(9500.sat.toMilliSatoshi)), + IncomingHtlc(createHtlc(9500.sat.toMilliSatoshi)), + )) + assert(CommitmentSpec.dustExposure(updatedSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 18500.sat.toMilliSatoshi) + + val receivedHtlcs = Seq( + createHtlc(id = 5, amount = 9500.sat.toMilliSatoshi), + createHtlc(id = 6, amount = 5000.sat.toMilliSatoshi), + createHtlc(id = 7, amount = 1000.sat.toMilliSatoshi), + createHtlc(id = 8, amount = 400.sat.toMilliSatoshi), + createHtlc(id = 9, amount = 400.sat.toMilliSatoshi), + createHtlc(id = 10, amount = 50000.sat.toMilliSatoshi), + ) + val (accepted, rejected) = CommitmentSpec.addIncomingHtlcsUntilDustExposureReached(25000 sat, updatedSpec, dustLimit, 10000.sat.toMilliSatoshi, initialSpec, dustLimit, 15000.sat.toMilliSatoshi, receivedHtlcs, Transactions.DefaultCommitmentFormat) + assert(accepted.map(_.id).toSet === Set(5, 6, 8, 10)) + assert(rejected.map(_.id).toSet === Set(7, 9)) + } + } From 612da6599c2632ec78e5bc6d779fa638d1d59ffe Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 7 Oct 2021 17:50:58 +0200 Subject: [PATCH 14/18] Move dust exposure utilities to separate file --- .../fr/acinq/eclair/channel/Commitments.scala | 18 +-- .../acinq/eclair/channel/DustExposure.scala | 96 ++++++++++++ .../eclair/transactions/CommitmentSpec.scala | 64 -------- .../eclair/channel/DustExposureSpec.scala | 146 ++++++++++++++++++ .../channel/states/e/NormalStateSpec.scala | 32 ++-- .../transactions/CommitmentSpecSpec.scala | 122 +-------------- 6 files changed, 271 insertions(+), 207 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 748d2d6574..9867f6d609 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -388,12 +388,12 @@ object Commitments { // If sending this htlc would overflow our dust exposure, we reject it. val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) - val localDustExposureAfterAdd = CommitmentSpec.dustExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) + val localDustExposureAfterAdd = DustExposure.compute(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterAdd > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterAdd)) } val remoteReduced = CommitmentSpec.reduce(remoteCommit1.spec, commitments.remoteChanges.all, commitments1.localChanges.all) - val remoteDustExposureAfterAdd = CommitmentSpec.dustExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + val remoteDustExposureAfterAdd = DustExposure.compute(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterAdd > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterAdd)) } @@ -559,12 +559,12 @@ object Commitments { // this is the commitment as it would be if our update_fee was immediately signed by both parties (it is only an // estimate because there can be concurrent updates) val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) - val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(localReduced, cmd.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) + val localDustExposureAfterFeeUpdate = DustExposure.compute(localReduced, cmd.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } val remoteReduced = CommitmentSpec.reduce(commitments.remoteCommit.spec, commitments.remoteChanges.all, commitments1.localChanges.all) - val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(remoteReduced, cmd.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + val remoteDustExposureAfterFeeUpdate = DustExposure.compute(remoteReduced, cmd.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } @@ -607,14 +607,14 @@ object Commitments { if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments.localChanges.all, commitments1.remoteChanges.all) - val localDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(localReduced, fee.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) + val localDustExposureAfterFeeUpdate = DustExposure.compute(localReduced, fee.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } // this is the commitment as it would be if their update_fee was immediately signed by both parties (it is only an // estimate because there can be concurrent updates) val remoteReduced = CommitmentSpec.reduce(commitments.remoteCommit.spec, commitments1.remoteChanges.all, commitments.localChanges.all) - val remoteDustExposureAfterFeeUpdate = CommitmentSpec.dustExposure(remoteReduced, fee.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + val remoteDustExposureAfterFeeUpdate = DustExposure.compute(remoteReduced, fee.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } @@ -783,12 +783,12 @@ object Commitments { case _ => true }) val localReduced = CommitmentSpec.reduce(localSpecWithoutNewHtlcs, commitments.localChanges.all, commitments.remoteChanges.acked) - val localCommitDustExposure = CommitmentSpec.dustExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) + val localCommitDustExposure = DustExposure.compute(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) val remoteReduced = CommitmentSpec.reduce(remoteSpecWithoutNewHtlcs, commitments.remoteChanges.acked, commitments.localChanges.all) - val remoteCommitDustExposure = CommitmentSpec.dustExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + val remoteCommitDustExposure = DustExposure.compute(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) // we sort incoming htlcs by decreasing amount: we want to prioritize higher amounts. val sortedReceivedHtlcs = receivedHtlcs.sortBy(_.amountMsat).reverse - CommitmentSpec.addIncomingHtlcsUntilDustExposureReached( + DustExposure.filterIncomingHtlcsUntilDustExposureReached( maxDustExposure, localReduced, commitments.localParams.dustLimit, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala new file mode 100644 index 0000000000..59badab516 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala @@ -0,0 +1,96 @@ +/* + * Copyright 2021 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 fr.acinq.bitcoin.{Satoshi, SatoshiLong} +import fr.acinq.eclair.MilliSatoshi +import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} +import fr.acinq.eclair.transactions.Transactions.CommitmentFormat +import fr.acinq.eclair.transactions._ +import fr.acinq.eclair.wire.protocol.UpdateAddHtlc + +/** + * Created by t-bast on 07/10/2021. + */ + +object DustExposure { + + /** + * We include in our dust exposure HTLCs that aren't trimmed but would be if the feerate increased. + * This ensures that we pre-emptively fail some of these untrimmed HTLCs, so that when the feerate increases we reduce + * the risk that we'll overflow our dust exposure. + * However, this cannot fully protect us if the feerate increases too much (in which case we may have to force-close). + */ + def feerateForDustExposure(currentFeerate: FeeratePerKw): FeeratePerKw = { + (currentFeerate * 1.25).max(currentFeerate + FeeratePerKw(FeeratePerByte(10 sat))) + } + + /** Test whether the given HTLC contributes to our dust exposure with the default dust feerate calculation. */ + def contributesToDustExposure(htlc: DirectedHtlc, spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Boolean = { + val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) + contributesToDustExposure(htlc, feerate, dustLimit, commitmentFormat) + } + + /** Test whether the given HTLC contributes to our dust exposure at the given feerate. */ + def contributesToDustExposure(htlc: DirectedHtlc, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Boolean = { + val threshold = htlc match { + case _: IncomingHtlc => Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) + case _: OutgoingHtlc => Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) + } + htlc.add.amountMsat < threshold + } + + /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes) with the default dust feerate calculation. */ + def compute(spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { + val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) + compute(spec, feerate, dustLimit, commitmentFormat) + } + + /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes) at the given feerate. */ + def compute(spec: CommitmentSpec, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { + // NB: we need the `toSeq` because otherwise duplicate amountMsat would be removed (since `spec.htlcs` is a Set). + spec.htlcs.filter(htlc => contributesToDustExposure(htlc, feerate, dustLimit, commitmentFormat)).toSeq.map(_.add.amountMsat).sum + } + + /** Accept as many incoming HTLCs as possible, in the order they are provided, while not overflowing our dust exposure. */ + def filterIncomingHtlcsUntilDustExposureReached(maxDustExposure: Satoshi, + localSpec: CommitmentSpec, + localDustLimit: Satoshi, + localCommitDustExposure: MilliSatoshi, + remoteSpec: CommitmentSpec, + remoteDustLimit: Satoshi, + remoteCommitDustExposure: MilliSatoshi, + receivedHtlcs: Seq[UpdateAddHtlc], + commitmentFormat: CommitmentFormat): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { + val (_, _, acceptedHtlcs, rejectedHtlcs) = receivedHtlcs.foldLeft((localCommitDustExposure, remoteCommitDustExposure, Seq.empty[UpdateAddHtlc], Seq.empty[UpdateAddHtlc])) { + case ((currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs), add) => + val contributesToLocalCommitDustExposure = contributesToDustExposure(IncomingHtlc(add), localSpec, localDustLimit, commitmentFormat) + val overflowsLocalCommitDustExposure = contributesToLocalCommitDustExposure && currentLocalCommitDustExposure + add.amountMsat > maxDustExposure + val contributesToRemoteCommitDustExposure = contributesToDustExposure(OutgoingHtlc(add), remoteSpec, remoteDustLimit, commitmentFormat) + val overflowsRemoteCommitDustExposure = contributesToRemoteCommitDustExposure && currentRemoteCommitDustExposure + add.amountMsat > maxDustExposure + if (overflowsLocalCommitDustExposure || overflowsRemoteCommitDustExposure) { + (currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs :+ add) + } else { + val nextLocalCommitDustExposure = if (contributesToLocalCommitDustExposure) currentLocalCommitDustExposure + add.amountMsat else currentLocalCommitDustExposure + val nextRemoteCommitDustExposure = if (contributesToRemoteCommitDustExposure) currentRemoteCommitDustExposure + add.amountMsat else currentRemoteCommitDustExposure + (nextLocalCommitDustExposure, nextRemoteCommitDustExposure, acceptedHtlcs :+ add, rejectedHtlcs) + } + } + (acceptedHtlcs, rejectedHtlcs) + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index 9a5813c102..92e3ac0233 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -126,70 +126,6 @@ object CommitmentSpec { } } - /** - * We include in our dust exposure HTLCs that aren't trimmed but would be if the feerate increased. - * This ensures that we pre-emptively fail some of these untrimmed HTLCs, so that when the feerate increases we reduce - * the risk that we'll overflow our dust exposure. - * However, this cannot fully protect us if the feerate increases too much (in which case we may have to force-close). - */ - def feerateForDustExposure(currentFeerate: FeeratePerKw): FeeratePerKw = { - (currentFeerate * 1.25).max(currentFeerate + FeeratePerKw(FeeratePerByte(10 sat))) - } - - /** Test whether the given HTLC contributes to our dust exposure with the default dust feerate calculation. */ - def contributesToDustExposure(htlc: DirectedHtlc, spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Boolean = { - val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) - contributesToDustExposure(htlc, feerate, dustLimit, commitmentFormat) - } - - /** Test whether the given HTLC contributes to our dust exposure at the given feerate. */ - def contributesToDustExposure(htlc: DirectedHtlc, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Boolean = { - val threshold = htlc match { - case _: IncomingHtlc => Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) - case _: OutgoingHtlc => Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, commitmentFormat) - } - htlc.add.amountMsat < threshold - } - - /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes) with the default dust feerate calculation. */ - def dustExposure(spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { - val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) - dustExposure(spec, feerate, dustLimit, commitmentFormat) - } - - /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes) at the given feerate. */ - def dustExposure(spec: CommitmentSpec, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { - // NB: we need the `toSeq` because otherwise duplicate amountMsat would be removed (since `spec.htlcs` is a Set). - spec.htlcs.filter(htlc => contributesToDustExposure(htlc, feerate, dustLimit, commitmentFormat)).toSeq.map(_.add.amountMsat).sum - } - - /** Accept as many incoming HTLCs as possible, in the order they are provided, while not overflowing our dust exposure. */ - def addIncomingHtlcsUntilDustExposureReached(maxDustExposure: Satoshi, - localSpec: CommitmentSpec, - localDustLimit: Satoshi, - localCommitDustExposure: MilliSatoshi, - remoteSpec: CommitmentSpec, - remoteDustLimit: Satoshi, - remoteCommitDustExposure: MilliSatoshi, - receivedHtlcs: Seq[UpdateAddHtlc], - commitmentFormat: CommitmentFormat): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { - val (_, _, acceptedHtlcs, rejectedHtlcs) = receivedHtlcs.foldLeft((localCommitDustExposure, remoteCommitDustExposure, Seq.empty[UpdateAddHtlc], Seq.empty[UpdateAddHtlc])) { - case ((currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs), add) => - val contributesToLocalCommitDustExposure = contributesToDustExposure(IncomingHtlc(add), localSpec, localDustLimit, commitmentFormat) - val overflowsLocalCommitDustExposure = contributesToLocalCommitDustExposure && currentLocalCommitDustExposure + add.amountMsat > maxDustExposure - val contributesToRemoteCommitDustExposure = contributesToDustExposure(OutgoingHtlc(add), remoteSpec, remoteDustLimit, commitmentFormat) - val overflowsRemoteCommitDustExposure = contributesToRemoteCommitDustExposure && currentRemoteCommitDustExposure + add.amountMsat > maxDustExposure - if (overflowsLocalCommitDustExposure || overflowsRemoteCommitDustExposure) { - (currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs :+ add) - } else { - val nextLocalCommitDustExposure = if (contributesToLocalCommitDustExposure) currentLocalCommitDustExposure + add.amountMsat else currentLocalCommitDustExposure - val nextRemoteCommitDustExposure = if (contributesToRemoteCommitDustExposure) currentRemoteCommitDustExposure + add.amountMsat else currentRemoteCommitDustExposure - (nextLocalCommitDustExposure, nextRemoteCommitDustExposure, acceptedHtlcs :+ add, rejectedHtlcs) - } - } - (acceptedHtlcs, rejectedHtlcs) - } - def reduce(localCommitSpec: CommitmentSpec, localChanges: List[UpdateMessage], remoteChanges: List[UpdateMessage]): CommitmentSpec = { val spec1 = localChanges.foldLeft(localCommitSpec) { case (spec, u: UpdateAddHtlc) => addHtlc(spec, OutgoingHtlc(u)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala new file mode 100644 index 0000000000..153cae2f35 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala @@ -0,0 +1,146 @@ +/* + * Copyright 2021 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 fr.acinq.bitcoin.{ByteVector32, SatoshiLong} +import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} +import fr.acinq.eclair.transactions._ +import fr.acinq.eclair.wire.protocol.UpdateAddHtlc +import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, MilliSatoshiLong, TestConstants, ToMilliSatoshiConversion, randomBytes32} +import org.scalatest.funsuite.AnyFunSuiteLike + +class DustExposureSpec extends AnyFunSuiteLike { + + def createHtlc(id: Long, amount: MilliSatoshi): UpdateAddHtlc = { + UpdateAddHtlc(ByteVector32.Zeroes, id, amount, randomBytes32(), CltvExpiry(500), TestConstants.emptyOnionPacket) + } + + test("compute dust exposure") { + { + val htlcs = Set[DirectedHtlc]( + IncomingHtlc(createHtlc(0, 449.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(0, 449.sat.toMilliSatoshi)), + IncomingHtlc(createHtlc(1, 450.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(1, 450.sat.toMilliSatoshi)), + IncomingHtlc(createHtlc(2, 499.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(2, 499.sat.toMilliSatoshi)), + IncomingHtlc(createHtlc(3, 500.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(3, 500.sat.toMilliSatoshi)), + ) + val spec = CommitmentSpec(htlcs, FeeratePerKw(FeeratePerByte(50 sat)), 50000 msat, 75000 msat) + assert(DustExposure.compute(spec, 450 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 898.sat.toMilliSatoshi) + assert(DustExposure.compute(spec, 500 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 2796.sat.toMilliSatoshi) + assert(DustExposure.compute(spec, 500 sat, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 3796.sat.toMilliSatoshi) + } + { + // Low feerate: buffer adds 10 sat/byte + val dustLimit = 500.sat + val feerate = FeeratePerKw(FeeratePerByte(10 sat)) + assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, Transactions.DefaultCommitmentFormat) === 2257.sat) + assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, Transactions.DefaultCommitmentFormat) === 2157.sat) + assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate * 2, Transactions.DefaultCommitmentFormat) === 4015.sat) + assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate * 2, Transactions.DefaultCommitmentFormat) === 3815.sat) + val htlcs = Set[DirectedHtlc]( + // Below the dust limit. + IncomingHtlc(createHtlc(0, 450.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(0, 450.sat.toMilliSatoshi)), + // Above the dust limit, trimmed at 10 sat/byte + IncomingHtlc(createHtlc(1, 2250.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(1, 2150.sat.toMilliSatoshi)), + // Above the dust limit, trimmed at 20 sat/byte + IncomingHtlc(createHtlc(2, 4010.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(2, 3810.sat.toMilliSatoshi)), + // Above the dust limit, untrimmed at 20 sat/byte + IncomingHtlc(createHtlc(3, 4020.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(3, 3820.sat.toMilliSatoshi)), + ) + val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) + val expected = 450.sat + 450.sat + 2250.sat + 2150.sat + 4010.sat + 3810.sat + assert(DustExposure.compute(spec, dustLimit, Transactions.DefaultCommitmentFormat) === expected.toMilliSatoshi) + assert(DustExposure.compute(spec, feerate * 2, dustLimit, Transactions.DefaultCommitmentFormat) === DustExposure.compute(spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(4, 4010.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(4, 3810.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(5, 4020.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(5, 3820.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) + } + { + // High feerate: buffer adds 25% + val dustLimit = 1000.sat + val feerate = FeeratePerKw(FeeratePerByte(80 sat)) + assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 15120.sat) + assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 14320.sat) + assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate * 1.25, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 18650.sat) + assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate * 1.25, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 17650.sat) + val htlcs = Set[DirectedHtlc]( + // Below the dust limit. + IncomingHtlc(createHtlc(0, 900.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(0, 900.sat.toMilliSatoshi)), + // Above the dust limit, trimmed at 80 sat/byte + IncomingHtlc(createHtlc(1, 15000.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(1, 14000.sat.toMilliSatoshi)), + // Above the dust limit, trimmed at 100 sat/byte + IncomingHtlc(createHtlc(2, 18000.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(2, 17000.sat.toMilliSatoshi)), + // Above the dust limit, untrimmed at 100 sat/byte + IncomingHtlc(createHtlc(3, 19000.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(3, 18000.sat.toMilliSatoshi)), + ) + val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) + val expected = 900.sat + 900.sat + 15000.sat + 14000.sat + 18000.sat + 17000.sat + assert(DustExposure.compute(spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === expected.toMilliSatoshi) + assert(DustExposure.compute(spec, feerate * 1.25, dustLimit, Transactions.DefaultCommitmentFormat) === DustExposure.compute(spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(4, 18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(4, 17000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(!DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(5, 19000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(!DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(5, 18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + } + } + + test("add incoming htlcs until we reach our maximum dust exposure") { + val dustLimit = 1000.sat + val initialSpec = CommitmentSpec(Set.empty, FeeratePerKw(10000 sat), 0 msat, 0 msat) + assert(DustExposure.compute(initialSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 0.msat) + assert(DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(0, 9000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(0, 9000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + // NB: HTLC-success transactions are bigger than HTLC-timeout transactions: that means incoming htlcs have a higher + // dust threshold than outgoing htlcs in our commitment. + assert(DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(0, 9500.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(0, 9500.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(0, 10000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(!DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(0, 10000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) + + val updatedSpec = initialSpec.copy(htlcs = Set( + OutgoingHtlc(createHtlc(2, 9000.sat.toMilliSatoshi)), + OutgoingHtlc(createHtlc(3, 9500.sat.toMilliSatoshi)), + IncomingHtlc(createHtlc(4, 9500.sat.toMilliSatoshi)), + )) + assert(DustExposure.compute(updatedSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 18500.sat.toMilliSatoshi) + + val receivedHtlcs = Seq( + createHtlc(5, 9500.sat.toMilliSatoshi), + createHtlc(6, 5000.sat.toMilliSatoshi), + createHtlc(7, 1000.sat.toMilliSatoshi), + createHtlc(8, 400.sat.toMilliSatoshi), + createHtlc(9, 400.sat.toMilliSatoshi), + createHtlc(10, 50000.sat.toMilliSatoshi), + ) + val (accepted, rejected) = DustExposure.filterIncomingHtlcsUntilDustExposureReached(25000 sat, updatedSpec, dustLimit, 10000.sat.toMilliSatoshi, initialSpec, dustLimit, 15000.sat.toMilliSatoshi, receivedHtlcs, Transactions.DefaultCommitmentFormat) + assert(accepted.map(_.id).toSet === Set(5, 6, 8, 10)) + assert(rejected.map(_.id).toSet === Set(7, 9)) + } + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 3b4febaef6..c8491107a6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -1940,8 +1940,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(CommitmentSpec.dustExposure(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) - assert(CommitmentSpec.dustExposure(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.compute(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.compute(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) // A large feerate increase would make these HTLCs overflow alice's dust exposure, so she rejects it: val sender = TestProbe() @@ -1965,8 +1965,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice sends another HTLC to Bob that is not included in the dust exposure at the current feerate. addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(CommitmentSpec.dustExposure(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) - assert(CommitmentSpec.dustExposure(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.compute(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.compute(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) // A large feerate increase would make these HTLCs overflow alice's dust exposure, so she rejects it: val cmd = CMD_UPDATE_FEE(FeeratePerKw(20000 sat), replyTo_opt = Some(sender.ref)) @@ -1988,10 +1988,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val higherDustLimit = Seq(aliceCommitments.localParams.dustLimit, aliceCommitments.remoteParams.dustLimit).max val lowerDustLimit = Seq(aliceCommitments.localParams.dustLimit, aliceCommitments.remoteParams.dustLimit).min // We have the following dust thresholds at the current feerate - assert(Transactions.offeredHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 6989.sat) - assert(Transactions.receivedHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 7109.sat) - assert(Transactions.offeredHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 2989.sat) - assert(Transactions.receivedHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 3109.sat) + assert(Transactions.offeredHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = DustExposure.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 6989.sat) + assert(Transactions.receivedHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = DustExposure.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 7109.sat) + assert(Transactions.offeredHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = DustExposure.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 2989.sat) + assert(Transactions.receivedHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = DustExposure.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 3109.sat) // And the following thresholds after the feerate update // NB: we apply the real feerate when sending update_fee, not the one adjusted for dust val updatedFeerate = FeeratePerKw(4000 sat) @@ -2195,8 +2195,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) val bobCommitments = bob.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(CommitmentSpec.dustExposure(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) - assert(CommitmentSpec.dustExposure(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.compute(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.compute(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) val tx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx // A large feerate increase would make these HTLCs overflow Bob's dust exposure, so he force-closes: @@ -2224,8 +2224,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob sends another HTLC to Alice that is not included in the dust exposure at the current feerate. addHtlc(14000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) val bobCommitments = bob.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(CommitmentSpec.dustExposure(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) - assert(CommitmentSpec.dustExposure(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.compute(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.compute(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) // A large feerate increase would make these HTLCs overflow Bob's dust exposure, so he force-close: val tx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx @@ -2251,10 +2251,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val higherDustLimit = Seq(aliceCommitments.localParams.dustLimit, aliceCommitments.remoteParams.dustLimit).max val lowerDustLimit = Seq(aliceCommitments.localParams.dustLimit, aliceCommitments.remoteParams.dustLimit).min // We have the following dust thresholds at the current feerate - assert(Transactions.offeredHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 6989.sat) - assert(Transactions.receivedHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 7109.sat) - assert(Transactions.offeredHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 2989.sat) - assert(Transactions.receivedHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = CommitmentSpec.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 3109.sat) + assert(Transactions.offeredHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = DustExposure.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 6989.sat) + assert(Transactions.receivedHtlcTrimThreshold(higherDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = DustExposure.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 7109.sat) + assert(Transactions.offeredHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = DustExposure.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 2989.sat) + assert(Transactions.receivedHtlcTrimThreshold(lowerDustLimit, aliceCommitments.localCommit.spec.copy(commitTxFeerate = DustExposure.feerateForDustExposure(initialFeerate)), aliceCommitments.commitmentFormat) === 3109.sat) // And the following thresholds after the feerate update // NB: we apply the real feerate when sending update_fee, not the one adjusted for dust val updatedFeerate = FeeratePerKw(4000 sat) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala index 5f84f85d1f..de93b261c0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/CommitmentSpecSpec.scala @@ -17,9 +17,9 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.{ByteVector32, Crypto, SatoshiLong} -import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.wire.protocol.{UpdateAddHtlc, UpdateFailHtlc, UpdateFulfillHtlc} -import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, MilliSatoshiLong, TestConstants, ToMilliSatoshiConversion, randomBytes32} +import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, MilliSatoshiLong, TestConstants, randomBytes32} import org.scalatest.funsuite.AnyFunSuite class CommitmentSpecSpec extends AnyFunSuite { @@ -75,122 +75,8 @@ class CommitmentSpecSpec extends AnyFunSuite { assert(spec.htlcTxFeerate(Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === FeeratePerKw(0 sat)) } - def createHtlc(amount: MilliSatoshi, id: Long = 0): UpdateAddHtlc = { - UpdateAddHtlc(ByteVector32.Zeroes, id, amount, randomBytes32(), CltvExpiry(500), TestConstants.emptyOnionPacket) - } - - test("compute dust exposure") { - { - val htlcs = Set[DirectedHtlc]( - IncomingHtlc(createHtlc(449.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(449.sat.toMilliSatoshi)), - IncomingHtlc(createHtlc(450.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(450.sat.toMilliSatoshi)), - IncomingHtlc(createHtlc(499.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(499.sat.toMilliSatoshi)), - IncomingHtlc(createHtlc(500.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(500.sat.toMilliSatoshi)), - ) - val spec = CommitmentSpec(htlcs, FeeratePerKw(FeeratePerByte(50 sat)), 50000 msat, 75000 msat) - assert(CommitmentSpec.dustExposure(spec, 450 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 898.sat.toMilliSatoshi) - assert(CommitmentSpec.dustExposure(spec, 500 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 2796.sat.toMilliSatoshi) - assert(CommitmentSpec.dustExposure(spec, 500 sat, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 3796.sat.toMilliSatoshi) - } - { - // Low feerate: buffer adds 10 sat/byte - val dustLimit = 500.sat - val feerate = FeeratePerKw(FeeratePerByte(10 sat)) - assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, Transactions.DefaultCommitmentFormat) === 2257.sat) - assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, Transactions.DefaultCommitmentFormat) === 2157.sat) - assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate * 2, Transactions.DefaultCommitmentFormat) === 4015.sat) - assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate * 2, Transactions.DefaultCommitmentFormat) === 3815.sat) - val htlcs = Set[DirectedHtlc]( - // Below the dust limit. - IncomingHtlc(createHtlc(450.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(450.sat.toMilliSatoshi)), - // Above the dust limit, trimmed at 10 sat/byte - IncomingHtlc(createHtlc(2250.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(2150.sat.toMilliSatoshi)), - // Above the dust limit, trimmed at 20 sat/byte - IncomingHtlc(createHtlc(4010.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(3810.sat.toMilliSatoshi)), - // Above the dust limit, untrimmed at 20 sat/byte - IncomingHtlc(createHtlc(4020.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(3820.sat.toMilliSatoshi)), - ) - val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) - val expected = 450.sat + 450.sat + 2250.sat + 2150.sat + 4010.sat + 3810.sat - assert(CommitmentSpec.dustExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat) === expected.toMilliSatoshi) - assert(CommitmentSpec.dustExposure(spec, feerate * 2, dustLimit, Transactions.DefaultCommitmentFormat) === CommitmentSpec.dustExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(4010.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(3810.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(!CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(4020.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(!CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(3820.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) - } - { - // High feerate: buffer adds 25% - val dustLimit = 1000.sat - val feerate = FeeratePerKw(FeeratePerByte(80 sat)) - assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 15120.sat) - assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 14320.sat) - assert(Transactions.receivedHtlcTrimThreshold(dustLimit, feerate * 1.25, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 18650.sat) - assert(Transactions.offeredHtlcTrimThreshold(dustLimit, feerate * 1.25, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 17650.sat) - val htlcs = Set[DirectedHtlc]( - // Below the dust limit. - IncomingHtlc(createHtlc(900.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(900.sat.toMilliSatoshi)), - // Above the dust limit, trimmed at 80 sat/byte - IncomingHtlc(createHtlc(15000.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(14000.sat.toMilliSatoshi)), - // Above the dust limit, trimmed at 100 sat/byte - IncomingHtlc(createHtlc(18000.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(17000.sat.toMilliSatoshi)), - // Above the dust limit, untrimmed at 100 sat/byte - IncomingHtlc(createHtlc(19000.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(18000.sat.toMilliSatoshi)), - ) - val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) - val expected = 900.sat + 900.sat + 15000.sat + 14000.sat + 18000.sat + 17000.sat - assert(CommitmentSpec.dustExposure(spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === expected.toMilliSatoshi) - assert(CommitmentSpec.dustExposure(spec, feerate * 1.25, dustLimit, Transactions.DefaultCommitmentFormat) === CommitmentSpec.dustExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) - assert(CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(17000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) - assert(!CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(19000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) - assert(!CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) - } - } - - test("add incoming htlcs until we reach our maximum dust exposure") { - val dustLimit = 1000.sat - val initialSpec = CommitmentSpec(Set.empty, FeeratePerKw(10000 sat), 0 msat, 0 msat) - assert(CommitmentSpec.dustExposure(initialSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 0.msat) - assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(9000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(9000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) - // NB: HTLC-success transactions are bigger than HTLC-timeout transactions: that means incoming htlcs have a higher - // dust threshold than outgoing htlcs in our commitment. - assert(CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(9500.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(!CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(9500.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(!CommitmentSpec.contributesToDustExposure(IncomingHtlc(createHtlc(10000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) - assert(!CommitmentSpec.contributesToDustExposure(OutgoingHtlc(createHtlc(10000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) - - val updatedSpec = initialSpec.copy(htlcs = Set( - OutgoingHtlc(createHtlc(9000.sat.toMilliSatoshi)), - OutgoingHtlc(createHtlc(9500.sat.toMilliSatoshi)), - IncomingHtlc(createHtlc(9500.sat.toMilliSatoshi)), - )) - assert(CommitmentSpec.dustExposure(updatedSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 18500.sat.toMilliSatoshi) - - val receivedHtlcs = Seq( - createHtlc(id = 5, amount = 9500.sat.toMilliSatoshi), - createHtlc(id = 6, amount = 5000.sat.toMilliSatoshi), - createHtlc(id = 7, amount = 1000.sat.toMilliSatoshi), - createHtlc(id = 8, amount = 400.sat.toMilliSatoshi), - createHtlc(id = 9, amount = 400.sat.toMilliSatoshi), - createHtlc(id = 10, amount = 50000.sat.toMilliSatoshi), - ) - val (accepted, rejected) = CommitmentSpec.addIncomingHtlcsUntilDustExposureReached(25000 sat, updatedSpec, dustLimit, 10000.sat.toMilliSatoshi, initialSpec, dustLimit, 15000.sat.toMilliSatoshi, receivedHtlcs, Transactions.DefaultCommitmentFormat) - assert(accepted.map(_.id).toSet === Set(5, 6, 8, 10)) - assert(rejected.map(_.id).toSet === Set(7, 9)) + def createHtlc(amount: MilliSatoshi): UpdateAddHtlc = { + UpdateAddHtlc(ByteVector32.Zeroes, 0, amount, randomBytes32(), CltvExpiry(500), TestConstants.emptyOnionPacket) } } From 647fa4de9468b219412c8ea50829c7123387f93b Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 7 Oct 2021 17:56:36 +0200 Subject: [PATCH 15/18] fixup! Move dust exposure utilities to separate file --- .../fr/acinq/eclair/channel/Commitments.scala | 2 +- .../fr/acinq/eclair/channel/DustExposure.scala | 18 +++++++++--------- .../eclair/channel/DustExposureSpec.scala | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 9867f6d609..3cb50de497 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -788,7 +788,7 @@ object Commitments { val remoteCommitDustExposure = DustExposure.compute(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) // we sort incoming htlcs by decreasing amount: we want to prioritize higher amounts. val sortedReceivedHtlcs = receivedHtlcs.sortBy(_.amountMsat).reverse - DustExposure.filterIncomingHtlcsUntilDustExposureReached( + DustExposure.filterBeforeForward( maxDustExposure, localReduced, commitments.localParams.dustLimit, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala index 59badab516..72aa09b7d9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala @@ -67,15 +67,15 @@ object DustExposure { } /** Accept as many incoming HTLCs as possible, in the order they are provided, while not overflowing our dust exposure. */ - def filterIncomingHtlcsUntilDustExposureReached(maxDustExposure: Satoshi, - localSpec: CommitmentSpec, - localDustLimit: Satoshi, - localCommitDustExposure: MilliSatoshi, - remoteSpec: CommitmentSpec, - remoteDustLimit: Satoshi, - remoteCommitDustExposure: MilliSatoshi, - receivedHtlcs: Seq[UpdateAddHtlc], - commitmentFormat: CommitmentFormat): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { + def filterBeforeForward(maxDustExposure: Satoshi, + localSpec: CommitmentSpec, + localDustLimit: Satoshi, + localCommitDustExposure: MilliSatoshi, + remoteSpec: CommitmentSpec, + remoteDustLimit: Satoshi, + remoteCommitDustExposure: MilliSatoshi, + receivedHtlcs: Seq[UpdateAddHtlc], + commitmentFormat: CommitmentFormat): (Seq[UpdateAddHtlc], Seq[UpdateAddHtlc]) = { val (_, _, acceptedHtlcs, rejectedHtlcs) = receivedHtlcs.foldLeft((localCommitDustExposure, remoteCommitDustExposure, Seq.empty[UpdateAddHtlc], Seq.empty[UpdateAddHtlc])) { case ((currentLocalCommitDustExposure, currentRemoteCommitDustExposure, acceptedHtlcs, rejectedHtlcs), add) => val contributesToLocalCommitDustExposure = contributesToDustExposure(IncomingHtlc(add), localSpec, localDustLimit, commitmentFormat) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala index 153cae2f35..e3426e3822 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala @@ -110,7 +110,7 @@ class DustExposureSpec extends AnyFunSuiteLike { } } - test("add incoming htlcs until we reach our maximum dust exposure") { + test("filter incoming htlcs before forwarding") { val dustLimit = 1000.sat val initialSpec = CommitmentSpec(Set.empty, FeeratePerKw(10000 sat), 0 msat, 0 msat) assert(DustExposure.compute(initialSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 0.msat) @@ -138,7 +138,7 @@ class DustExposureSpec extends AnyFunSuiteLike { createHtlc(9, 400.sat.toMilliSatoshi), createHtlc(10, 50000.sat.toMilliSatoshi), ) - val (accepted, rejected) = DustExposure.filterIncomingHtlcsUntilDustExposureReached(25000 sat, updatedSpec, dustLimit, 10000.sat.toMilliSatoshi, initialSpec, dustLimit, 15000.sat.toMilliSatoshi, receivedHtlcs, Transactions.DefaultCommitmentFormat) + val (accepted, rejected) = DustExposure.filterBeforeForward(25000 sat, updatedSpec, dustLimit, 10000.sat.toMilliSatoshi, initialSpec, dustLimit, 15000.sat.toMilliSatoshi, receivedHtlcs, Transactions.DefaultCommitmentFormat) assert(accepted.map(_.id).toSet === Set(5, 6, 8, 10)) assert(rejected.map(_.id).toSet === Set(7, 9)) } From ecc47cc83a7871695606944ee8aa8662ba0d6a1d Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 7 Oct 2021 17:57:24 +0200 Subject: [PATCH 16/18] Rename compute -> computeExposure --- .../fr/acinq/eclair/channel/Commitments.scala | 16 ++++++++-------- .../fr/acinq/eclair/channel/DustExposure.scala | 6 +++--- .../eclair/channel/DustExposureSpec.scala | 18 +++++++++--------- .../channel/states/e/NormalStateSpec.scala | 16 ++++++++-------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 3cb50de497..8b120c11c9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -388,12 +388,12 @@ object Commitments { // If sending this htlc would overflow our dust exposure, we reject it. val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) - val localDustExposureAfterAdd = DustExposure.compute(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) + val localDustExposureAfterAdd = DustExposure.computeExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterAdd > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterAdd)) } val remoteReduced = CommitmentSpec.reduce(remoteCommit1.spec, commitments.remoteChanges.all, commitments1.localChanges.all) - val remoteDustExposureAfterAdd = DustExposure.compute(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + val remoteDustExposureAfterAdd = DustExposure.computeExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterAdd > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterAdd)) } @@ -559,12 +559,12 @@ object Commitments { // this is the commitment as it would be if our update_fee was immediately signed by both parties (it is only an // estimate because there can be concurrent updates) val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) - val localDustExposureAfterFeeUpdate = DustExposure.compute(localReduced, cmd.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) + val localDustExposureAfterFeeUpdate = DustExposure.computeExposure(localReduced, cmd.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } val remoteReduced = CommitmentSpec.reduce(commitments.remoteCommit.spec, commitments.remoteChanges.all, commitments1.localChanges.all) - val remoteDustExposureAfterFeeUpdate = DustExposure.compute(remoteReduced, cmd.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + val remoteDustExposureAfterFeeUpdate = DustExposure.computeExposure(remoteReduced, cmd.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } @@ -607,14 +607,14 @@ object Commitments { if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments.localChanges.all, commitments1.remoteChanges.all) - val localDustExposureAfterFeeUpdate = DustExposure.compute(localReduced, fee.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) + val localDustExposureAfterFeeUpdate = DustExposure.computeExposure(localReduced, fee.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } // this is the commitment as it would be if their update_fee was immediately signed by both parties (it is only an // estimate because there can be concurrent updates) val remoteReduced = CommitmentSpec.reduce(commitments.remoteCommit.spec, commitments1.remoteChanges.all, commitments.localChanges.all) - val remoteDustExposureAfterFeeUpdate = DustExposure.compute(remoteReduced, fee.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + val remoteDustExposureAfterFeeUpdate = DustExposure.computeExposure(remoteReduced, fee.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) } @@ -783,9 +783,9 @@ object Commitments { case _ => true }) val localReduced = CommitmentSpec.reduce(localSpecWithoutNewHtlcs, commitments.localChanges.all, commitments.remoteChanges.acked) - val localCommitDustExposure = DustExposure.compute(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) + val localCommitDustExposure = DustExposure.computeExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) val remoteReduced = CommitmentSpec.reduce(remoteSpecWithoutNewHtlcs, commitments.remoteChanges.acked, commitments.localChanges.all) - val remoteCommitDustExposure = DustExposure.compute(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) + val remoteCommitDustExposure = DustExposure.computeExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) // we sort incoming htlcs by decreasing amount: we want to prioritize higher amounts. val sortedReceivedHtlcs = receivedHtlcs.sortBy(_.amountMsat).reverse DustExposure.filterBeforeForward( diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala index 72aa09b7d9..ab59dd6931 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala @@ -55,13 +55,13 @@ object DustExposure { } /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes) with the default dust feerate calculation. */ - def compute(spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { + def computeExposure(spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { val feerate = feerateForDustExposure(spec.htlcTxFeerate(commitmentFormat)) - compute(spec, feerate, dustLimit, commitmentFormat) + computeExposure(spec, feerate, dustLimit, commitmentFormat) } /** Compute our exposure to dust pending HTLCs (which will be lost as miner fees in case the channel force-closes) at the given feerate. */ - def compute(spec: CommitmentSpec, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { + def computeExposure(spec: CommitmentSpec, feerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { // NB: we need the `toSeq` because otherwise duplicate amountMsat would be removed (since `spec.htlcs` is a Set). spec.htlcs.filter(htlc => contributesToDustExposure(htlc, feerate, dustLimit, commitmentFormat)).toSeq.map(_.add.amountMsat).sum } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala index e3426e3822..6dbff89b48 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala @@ -42,9 +42,9 @@ class DustExposureSpec extends AnyFunSuiteLike { OutgoingHtlc(createHtlc(3, 500.sat.toMilliSatoshi)), ) val spec = CommitmentSpec(htlcs, FeeratePerKw(FeeratePerByte(50 sat)), 50000 msat, 75000 msat) - assert(DustExposure.compute(spec, 450 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 898.sat.toMilliSatoshi) - assert(DustExposure.compute(spec, 500 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 2796.sat.toMilliSatoshi) - assert(DustExposure.compute(spec, 500 sat, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 3796.sat.toMilliSatoshi) + assert(DustExposure.computeExposure(spec, 450 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 898.sat.toMilliSatoshi) + assert(DustExposure.computeExposure(spec, 500 sat, Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) === 2796.sat.toMilliSatoshi) + assert(DustExposure.computeExposure(spec, 500 sat, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === 3796.sat.toMilliSatoshi) } { // Low feerate: buffer adds 10 sat/byte @@ -70,8 +70,8 @@ class DustExposureSpec extends AnyFunSuiteLike { ) val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) val expected = 450.sat + 450.sat + 2250.sat + 2150.sat + 4010.sat + 3810.sat - assert(DustExposure.compute(spec, dustLimit, Transactions.DefaultCommitmentFormat) === expected.toMilliSatoshi) - assert(DustExposure.compute(spec, feerate * 2, dustLimit, Transactions.DefaultCommitmentFormat) === DustExposure.compute(spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(DustExposure.computeExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat) === expected.toMilliSatoshi) + assert(DustExposure.computeExposure(spec, feerate * 2, dustLimit, Transactions.DefaultCommitmentFormat) === DustExposure.computeExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(4, 4010.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(4, 3810.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(!DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(5, 4020.sat.toMilliSatoshi)), spec, dustLimit, Transactions.DefaultCommitmentFormat)) @@ -101,8 +101,8 @@ class DustExposureSpec extends AnyFunSuiteLike { ) val spec = CommitmentSpec(htlcs, feerate, 50000 msat, 75000 msat) val expected = 900.sat + 900.sat + 15000.sat + 14000.sat + 18000.sat + 17000.sat - assert(DustExposure.compute(spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === expected.toMilliSatoshi) - assert(DustExposure.compute(spec, feerate * 1.25, dustLimit, Transactions.DefaultCommitmentFormat) === DustExposure.compute(spec, dustLimit, Transactions.DefaultCommitmentFormat)) + assert(DustExposure.computeExposure(spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat) === expected.toMilliSatoshi) + assert(DustExposure.computeExposure(spec, feerate * 1.25, dustLimit, Transactions.DefaultCommitmentFormat) === DustExposure.computeExposure(spec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(4, 18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) assert(DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(4, 17000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) assert(!DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(5, 19000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) @@ -113,7 +113,7 @@ class DustExposureSpec extends AnyFunSuiteLike { test("filter incoming htlcs before forwarding") { val dustLimit = 1000.sat val initialSpec = CommitmentSpec(Set.empty, FeeratePerKw(10000 sat), 0 msat, 0 msat) - assert(DustExposure.compute(initialSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(initialSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 0.msat) assert(DustExposure.contributesToDustExposure(IncomingHtlc(createHtlc(0, 9000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) assert(DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(0, 9000.sat.toMilliSatoshi)), initialSpec, dustLimit, Transactions.DefaultCommitmentFormat)) // NB: HTLC-success transactions are bigger than HTLC-timeout transactions: that means incoming htlcs have a higher @@ -128,7 +128,7 @@ class DustExposureSpec extends AnyFunSuiteLike { OutgoingHtlc(createHtlc(3, 9500.sat.toMilliSatoshi)), IncomingHtlc(createHtlc(4, 9500.sat.toMilliSatoshi)), )) - assert(DustExposure.compute(updatedSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 18500.sat.toMilliSatoshi) + assert(DustExposure.computeExposure(updatedSpec, dustLimit, Transactions.DefaultCommitmentFormat) === 18500.sat.toMilliSatoshi) val receivedHtlcs = Seq( createHtlc(5, 9500.sat.toMilliSatoshi), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index c8491107a6..db780879da 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -1940,8 +1940,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(DustExposure.compute(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) - assert(DustExposure.compute(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) // A large feerate increase would make these HTLCs overflow alice's dust exposure, so she rejects it: val sender = TestProbe() @@ -1965,8 +1965,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice sends another HTLC to Bob that is not included in the dust exposure at the current feerate. addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(DustExposure.compute(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) - assert(DustExposure.compute(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(aliceCommitments.localCommit.spec, aliceCommitments.localParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(aliceCommitments.remoteCommit.spec, aliceCommitments.remoteParams.dustLimit, aliceCommitments.commitmentFormat) === 0.msat) // A large feerate increase would make these HTLCs overflow alice's dust exposure, so she rejects it: val cmd = CMD_UPDATE_FEE(FeeratePerKw(20000 sat), replyTo_opt = Some(sender.ref)) @@ -2195,8 +2195,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) val bobCommitments = bob.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(DustExposure.compute(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) - assert(DustExposure.compute(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) val tx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx // A large feerate increase would make these HTLCs overflow Bob's dust exposure, so he force-closes: @@ -2224,8 +2224,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob sends another HTLC to Alice that is not included in the dust exposure at the current feerate. addHtlc(14000.sat.toMilliSatoshi, bob, alice, bob2alice, alice2bob) val bobCommitments = bob.stateData.asInstanceOf[DATA_NORMAL].commitments - assert(DustExposure.compute(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) - assert(DustExposure.compute(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(bobCommitments.localCommit.spec, bobCommitments.localParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) + assert(DustExposure.computeExposure(bobCommitments.remoteCommit.spec, bobCommitments.remoteParams.dustLimit, bobCommitments.commitmentFormat) === 0.msat) // A large feerate increase would make these HTLCs overflow Bob's dust exposure, so he force-close: val tx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx From de2b1259ce772822c0303a1dfce7693c4117750f Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 7 Oct 2021 18:21:01 +0200 Subject: [PATCH 17/18] Use dedicated reduce function for dust --- .../fr/acinq/eclair/channel/Commitments.scala | 16 ++--- .../eclair/transactions/CommitmentSpec.scala | 72 +++++++++++++++++-- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 8b120c11c9..e4ba5ed58a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -387,12 +387,12 @@ object Commitments { // If sending this htlc would overflow our dust exposure, we reject it. val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) + val localReduced = CommitmentSpec.reduceForDustExposure(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) val localDustExposureAfterAdd = DustExposure.computeExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterAdd > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterAdd)) } - val remoteReduced = CommitmentSpec.reduce(remoteCommit1.spec, commitments.remoteChanges.all, commitments1.localChanges.all) + val remoteReduced = CommitmentSpec.reduceForDustExposure(remoteCommit1.spec, commitments.remoteChanges.all, commitments1.localChanges.all) val remoteDustExposureAfterAdd = DustExposure.computeExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterAdd > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterAdd)) @@ -558,12 +558,12 @@ object Commitments { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure // this is the commitment as it would be if our update_fee was immediately signed by both parties (it is only an // estimate because there can be concurrent updates) - val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) + val localReduced = CommitmentSpec.reduceForDustExposure(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) val localDustExposureAfterFeeUpdate = DustExposure.computeExposure(localReduced, cmd.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } - val remoteReduced = CommitmentSpec.reduce(commitments.remoteCommit.spec, commitments.remoteChanges.all, commitments1.localChanges.all) + val remoteReduced = CommitmentSpec.reduceForDustExposure(commitments.remoteCommit.spec, commitments.remoteChanges.all, commitments1.localChanges.all) val remoteDustExposureAfterFeeUpdate = DustExposure.computeExposure(remoteReduced, cmd.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) @@ -606,14 +606,14 @@ object Commitments { // if we would overflow our dust exposure with the new feerate, we reject this fee update if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val localReduced = CommitmentSpec.reduce(commitments.localCommit.spec, commitments.localChanges.all, commitments1.remoteChanges.all) + val localReduced = CommitmentSpec.reduceForDustExposure(commitments.localCommit.spec, commitments.localChanges.all, commitments1.remoteChanges.all) val localDustExposureAfterFeeUpdate = DustExposure.computeExposure(localReduced, fee.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } // this is the commitment as it would be if their update_fee was immediately signed by both parties (it is only an // estimate because there can be concurrent updates) - val remoteReduced = CommitmentSpec.reduce(commitments.remoteCommit.spec, commitments1.remoteChanges.all, commitments.localChanges.all) + val remoteReduced = CommitmentSpec.reduceForDustExposure(commitments.remoteCommit.spec, commitments1.remoteChanges.all, commitments.localChanges.all) val remoteDustExposureAfterFeeUpdate = DustExposure.computeExposure(remoteReduced, fee.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) @@ -782,9 +782,9 @@ object Commitments { case OutgoingHtlc(add) if receivedHtlcs.contains(add) => false case _ => true }) - val localReduced = CommitmentSpec.reduce(localSpecWithoutNewHtlcs, commitments.localChanges.all, commitments.remoteChanges.acked) + val localReduced = CommitmentSpec.reduceForDustExposure(localSpecWithoutNewHtlcs, commitments.localChanges.all, commitments.remoteChanges.acked) val localCommitDustExposure = DustExposure.computeExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) - val remoteReduced = CommitmentSpec.reduce(remoteSpecWithoutNewHtlcs, commitments.remoteChanges.acked, commitments.localChanges.all) + val remoteReduced = CommitmentSpec.reduceForDustExposure(remoteSpecWithoutNewHtlcs, commitments.remoteChanges.acked, commitments.localChanges.all) val remoteCommitDustExposure = DustExposure.computeExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) // we sort incoming htlcs by decreasing amount: we want to prioritize higher amounts. val sortedReceivedHtlcs = receivedHtlcs.sortBy(_.amountMsat).reverse diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index 92e3ac0233..03dbf9bfc2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -16,9 +16,9 @@ package fr.acinq.eclair.transactions -import fr.acinq.bitcoin.{Satoshi, SatoshiLong} +import fr.acinq.bitcoin.SatoshiLong import fr.acinq.eclair.MilliSatoshi -import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} import fr.acinq.eclair.wire.protocol._ @@ -86,6 +86,7 @@ final case class CommitmentSpec(htlcs: Set[DirectedHtlc], commitTxFeerate: Feera } object CommitmentSpec { + def removeHtlc(changes: List[UpdateMessage], id: Long): List[UpdateMessage] = changes.filterNot { case u: UpdateAddHtlc => u.id == id case _ => false @@ -101,28 +102,28 @@ object CommitmentSpec { def fulfillIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { spec.findIncomingHtlcById(htlcId) match { case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => spec + case None => throw new RuntimeException(s"cannot find htlc id=$htlcId") } } def fulfillOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { spec.findOutgoingHtlcById(htlcId) match { case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => spec + case None => throw new RuntimeException(s"cannot find htlc id=$htlcId") } } def failIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { spec.findIncomingHtlcById(htlcId) match { case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => spec + case None => throw new RuntimeException(s"cannot find htlc id=$htlcId") } } def failOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { spec.findOutgoingHtlcById(htlcId) match { case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => spec + case None => throw new RuntimeException(s"cannot find htlc id=$htlcId") } } @@ -154,4 +155,63 @@ object CommitmentSpec { spec5 } + def reduceForDustExposure(localCommitSpec: CommitmentSpec, localChanges: List[UpdateMessage], remoteChanges: List[UpdateMessage]): CommitmentSpec = { + // NB: when computing dust exposure, we usually apply all pending updates (proposed, signed and acked), which means + // that we will sometimes apply fulfill/fail on htlcs that have already been removed: that's why we don't use the + // normal function that would throw when that happens. + def safeFulfillIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { + spec.findIncomingHtlcById(htlcId) match { + case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) + case None => spec + } + } + + def safeFulfillOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { + spec.findOutgoingHtlcById(htlcId) match { + case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) + case None => spec + } + } + + def safeFailIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { + spec.findIncomingHtlcById(htlcId) match { + case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) + case None => spec + } + } + + def safeFailOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { + spec.findOutgoingHtlcById(htlcId) match { + case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) + case None => spec + } + } + + val spec1 = localChanges.foldLeft(localCommitSpec) { + case (spec, u: UpdateAddHtlc) => addHtlc(spec, OutgoingHtlc(u)) + case (spec, _) => spec + } + val spec2 = remoteChanges.foldLeft(spec1) { + case (spec, u: UpdateAddHtlc) => addHtlc(spec, IncomingHtlc(u)) + case (spec, _) => spec + } + val spec3 = localChanges.foldLeft(spec2) { + case (spec, u: UpdateFulfillHtlc) => safeFulfillIncomingHtlc(spec, u.id) + case (spec, u: UpdateFailHtlc) => safeFailIncomingHtlc(spec, u.id) + case (spec, u: UpdateFailMalformedHtlc) => safeFailIncomingHtlc(spec, u.id) + case (spec, _) => spec + } + val spec4 = remoteChanges.foldLeft(spec3) { + case (spec, u: UpdateFulfillHtlc) => safeFulfillOutgoingHtlc(spec, u.id) + case (spec, u: UpdateFailHtlc) => safeFailOutgoingHtlc(spec, u.id) + case (spec, u: UpdateFailMalformedHtlc) => safeFailOutgoingHtlc(spec, u.id) + case (spec, _) => spec + } + val spec5 = (localChanges ++ remoteChanges).foldLeft(spec4) { + case (spec, u: UpdateFee) => spec.copy(commitTxFeerate = u.feeratePerKw) + case (spec, _) => spec + } + spec5 + } + } \ No newline at end of file From ca5acf3cb5bbf65e4d2acd9562ec4d19bcd66aa5 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 7 Oct 2021 18:44:06 +0200 Subject: [PATCH 18/18] Move reduce to DustExposure --- .../fr/acinq/eclair/channel/Commitments.scala | 16 ++--- .../acinq/eclair/channel/DustExposure.scala | 61 ++++++++++++++++++- .../eclair/transactions/CommitmentSpec.scala | 59 ------------------ 3 files changed, 68 insertions(+), 68 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index e4ba5ed58a..e7b0550ba8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -387,12 +387,12 @@ object Commitments { // If sending this htlc would overflow our dust exposure, we reject it. val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val localReduced = CommitmentSpec.reduceForDustExposure(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) + val localReduced = DustExposure.reduceForDustExposure(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) val localDustExposureAfterAdd = DustExposure.computeExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterAdd > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterAdd)) } - val remoteReduced = CommitmentSpec.reduceForDustExposure(remoteCommit1.spec, commitments.remoteChanges.all, commitments1.localChanges.all) + val remoteReduced = DustExposure.reduceForDustExposure(remoteCommit1.spec, commitments.remoteChanges.all, commitments1.localChanges.all) val remoteDustExposureAfterAdd = DustExposure.computeExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterAdd > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterAdd)) @@ -558,12 +558,12 @@ object Commitments { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure // this is the commitment as it would be if our update_fee was immediately signed by both parties (it is only an // estimate because there can be concurrent updates) - val localReduced = CommitmentSpec.reduceForDustExposure(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) + val localReduced = DustExposure.reduceForDustExposure(commitments.localCommit.spec, commitments1.localChanges.all, commitments.remoteChanges.all) val localDustExposureAfterFeeUpdate = DustExposure.computeExposure(localReduced, cmd.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } - val remoteReduced = CommitmentSpec.reduceForDustExposure(commitments.remoteCommit.spec, commitments.remoteChanges.all, commitments1.localChanges.all) + val remoteReduced = DustExposure.reduceForDustExposure(commitments.remoteCommit.spec, commitments.remoteChanges.all, commitments1.localChanges.all) val remoteDustExposureAfterFeeUpdate = DustExposure.computeExposure(remoteReduced, cmd.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) @@ -606,14 +606,14 @@ object Commitments { // if we would overflow our dust exposure with the new feerate, we reject this fee update if (feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.closeOnUpdateFeeOverflow) { val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure - val localReduced = CommitmentSpec.reduceForDustExposure(commitments.localCommit.spec, commitments.localChanges.all, commitments1.remoteChanges.all) + val localReduced = DustExposure.reduceForDustExposure(commitments.localCommit.spec, commitments.localChanges.all, commitments1.remoteChanges.all) val localDustExposureAfterFeeUpdate = DustExposure.computeExposure(localReduced, fee.feeratePerKw, commitments.localParams.dustLimit, commitments.commitmentFormat) if (localDustExposureAfterFeeUpdate > maxDustExposure) { return Left(LocalDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, localDustExposureAfterFeeUpdate)) } // this is the commitment as it would be if their update_fee was immediately signed by both parties (it is only an // estimate because there can be concurrent updates) - val remoteReduced = CommitmentSpec.reduceForDustExposure(commitments.remoteCommit.spec, commitments1.remoteChanges.all, commitments.localChanges.all) + val remoteReduced = DustExposure.reduceForDustExposure(commitments.remoteCommit.spec, commitments1.remoteChanges.all, commitments.localChanges.all) val remoteDustExposureAfterFeeUpdate = DustExposure.computeExposure(remoteReduced, fee.feeratePerKw, commitments.remoteParams.dustLimit, commitments.commitmentFormat) if (remoteDustExposureAfterFeeUpdate > maxDustExposure) { return Left(RemoteDustHtlcExposureTooHigh(commitments.channelId, maxDustExposure, remoteDustExposureAfterFeeUpdate)) @@ -782,9 +782,9 @@ object Commitments { case OutgoingHtlc(add) if receivedHtlcs.contains(add) => false case _ => true }) - val localReduced = CommitmentSpec.reduceForDustExposure(localSpecWithoutNewHtlcs, commitments.localChanges.all, commitments.remoteChanges.acked) + val localReduced = DustExposure.reduceForDustExposure(localSpecWithoutNewHtlcs, commitments.localChanges.all, commitments.remoteChanges.acked) val localCommitDustExposure = DustExposure.computeExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) - val remoteReduced = CommitmentSpec.reduceForDustExposure(remoteSpecWithoutNewHtlcs, commitments.remoteChanges.acked, commitments.localChanges.all) + val remoteReduced = DustExposure.reduceForDustExposure(remoteSpecWithoutNewHtlcs, commitments.remoteChanges.acked, commitments.localChanges.all) val remoteCommitDustExposure = DustExposure.computeExposure(remoteReduced, commitments.remoteParams.dustLimit, commitments.commitmentFormat) // we sort incoming htlcs by decreasing amount: we want to prioritize higher amounts. val sortedReceivedHtlcs = receivedHtlcs.sortBy(_.amountMsat).reverse diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala index ab59dd6931..84c68182b3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala @@ -21,7 +21,7 @@ import fr.acinq.eclair.MilliSatoshi import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.transactions.Transactions.CommitmentFormat import fr.acinq.eclair.transactions._ -import fr.acinq.eclair.wire.protocol.UpdateAddHtlc +import fr.acinq.eclair.wire.protocol._ /** * Created by t-bast on 07/10/2021. @@ -93,4 +93,63 @@ object DustExposure { (acceptedHtlcs, rejectedHtlcs) } + def reduceForDustExposure(localCommitSpec: CommitmentSpec, localChanges: List[UpdateMessage], remoteChanges: List[UpdateMessage]): CommitmentSpec = { + // NB: when computing dust exposure, we usually apply all pending updates (proposed, signed and acked), which means + // that we will sometimes apply fulfill/fail on htlcs that have already been removed: that's why we don't use the + // normal functions from CommitmentSpec that would throw when that happens. + def fulfillIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { + spec.findIncomingHtlcById(htlcId) match { + case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) + case None => spec + } + } + + def fulfillOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { + spec.findOutgoingHtlcById(htlcId) match { + case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) + case None => spec + } + } + + def failIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { + spec.findIncomingHtlcById(htlcId) match { + case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) + case None => spec + } + } + + def failOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { + spec.findOutgoingHtlcById(htlcId) match { + case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) + case None => spec + } + } + + val spec1 = localChanges.foldLeft(localCommitSpec) { + case (spec, u: UpdateAddHtlc) => CommitmentSpec.addHtlc(spec, OutgoingHtlc(u)) + case (spec, _) => spec + } + val spec2 = remoteChanges.foldLeft(spec1) { + case (spec, u: UpdateAddHtlc) => CommitmentSpec.addHtlc(spec, IncomingHtlc(u)) + case (spec, _) => spec + } + val spec3 = localChanges.foldLeft(spec2) { + case (spec, u: UpdateFulfillHtlc) => fulfillIncomingHtlc(spec, u.id) + case (spec, u: UpdateFailHtlc) => failIncomingHtlc(spec, u.id) + case (spec, u: UpdateFailMalformedHtlc) => failIncomingHtlc(spec, u.id) + case (spec, _) => spec + } + val spec4 = remoteChanges.foldLeft(spec3) { + case (spec, u: UpdateFulfillHtlc) => fulfillOutgoingHtlc(spec, u.id) + case (spec, u: UpdateFailHtlc) => failOutgoingHtlc(spec, u.id) + case (spec, u: UpdateFailMalformedHtlc) => failOutgoingHtlc(spec, u.id) + case (spec, _) => spec + } + val spec5 = (localChanges ++ remoteChanges).foldLeft(spec4) { + case (spec, u: UpdateFee) => spec.copy(commitTxFeerate = u.feeratePerKw) + case (spec, _) => spec + } + spec5 + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index 03dbf9bfc2..414897a7cd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -155,63 +155,4 @@ object CommitmentSpec { spec5 } - def reduceForDustExposure(localCommitSpec: CommitmentSpec, localChanges: List[UpdateMessage], remoteChanges: List[UpdateMessage]): CommitmentSpec = { - // NB: when computing dust exposure, we usually apply all pending updates (proposed, signed and acked), which means - // that we will sometimes apply fulfill/fail on htlcs that have already been removed: that's why we don't use the - // normal function that would throw when that happens. - def safeFulfillIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { - spec.findIncomingHtlcById(htlcId) match { - case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => spec - } - } - - def safeFulfillOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { - spec.findOutgoingHtlcById(htlcId) match { - case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => spec - } - } - - def safeFailIncomingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { - spec.findIncomingHtlcById(htlcId) match { - case Some(htlc) => spec.copy(toRemote = spec.toRemote + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => spec - } - } - - def safeFailOutgoingHtlc(spec: CommitmentSpec, htlcId: Long): CommitmentSpec = { - spec.findOutgoingHtlcById(htlcId) match { - case Some(htlc) => spec.copy(toLocal = spec.toLocal + htlc.add.amountMsat, htlcs = spec.htlcs - htlc) - case None => spec - } - } - - val spec1 = localChanges.foldLeft(localCommitSpec) { - case (spec, u: UpdateAddHtlc) => addHtlc(spec, OutgoingHtlc(u)) - case (spec, _) => spec - } - val spec2 = remoteChanges.foldLeft(spec1) { - case (spec, u: UpdateAddHtlc) => addHtlc(spec, IncomingHtlc(u)) - case (spec, _) => spec - } - val spec3 = localChanges.foldLeft(spec2) { - case (spec, u: UpdateFulfillHtlc) => safeFulfillIncomingHtlc(spec, u.id) - case (spec, u: UpdateFailHtlc) => safeFailIncomingHtlc(spec, u.id) - case (spec, u: UpdateFailMalformedHtlc) => safeFailIncomingHtlc(spec, u.id) - case (spec, _) => spec - } - val spec4 = remoteChanges.foldLeft(spec3) { - case (spec, u: UpdateFulfillHtlc) => safeFulfillOutgoingHtlc(spec, u.id) - case (spec, u: UpdateFailHtlc) => safeFailOutgoingHtlc(spec, u.id) - case (spec, u: UpdateFailMalformedHtlc) => safeFailOutgoingHtlc(spec, u.id) - case (spec, _) => spec - } - val spec5 = (localChanges ++ remoteChanges).foldLeft(spec4) { - case (spec, u: UpdateFee) => spec.copy(commitTxFeerate = u.feeratePerKw) - case (spec, _) => spec - } - spec5 - } - } \ No newline at end of file