diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 8182ac6503..db708442c7 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -53,6 +53,29 @@ 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. +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.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.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.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. +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. diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 497466182c..f63859ff35 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -142,6 +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 + // 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 # { @@ -150,6 +160,10 @@ eclair { # ratio-low = 0.1 # ratio-high = 20.0 # anchor-output-max-commit-feerate = 10 + # dust-tolerance { + # max-exposure-satoshis = 25000 + # close-on-update-fee-overflow = true + # } # } # } ] @@ -388,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 734f24123a..289f4955e5 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,22 @@ 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")))), + 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"))) 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")))), + 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 5d8d3ce957..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) { +/** + * @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 0443668cb1..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 @@ -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._ @@ -769,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)) @@ -839,15 +841,19 @@ 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).dustTolerance.maxExposure) 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)), commit = true) + case PostRevocationAction.RelayFailure(result) => log.debug("forwarding {} to relayer", result) relayer ! result } @@ -1127,7 +1133,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 @@ -1199,18 +1205,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).dustTolerance.maxExposure) 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), 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), commit = true) + 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)) 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..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,6 +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 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 ee3c039950..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 @@ -26,7 +26,6 @@ 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._ @@ -135,7 +134,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,7 +178,7 @@ 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) } @@ -386,6 +385,19 @@ 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).dustTolerance.maxExposure + 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 = 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)) + } + Right(commitments1, add) } @@ -523,7 +535,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 { @@ -538,10 +550,27 @@ 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.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 + // 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 = 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 = 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)) + } + } + + Right(commitments1, fee) } } @@ -568,13 +597,30 @@ 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) { - 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).dustTolerance.closeOnUpdateFeeOverflow) { + val maxDustExposure = feeConf.feerateToleranceFor(commitments.remoteNodeId).dustTolerance.maxExposure + 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 = 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)) + } + } + + Right(commitments1) } } } @@ -692,28 +738,70 @@ 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 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 = DustExposure.reduceForDustExposure(localSpecWithoutNewHtlcs, commitments.localChanges.all, commitments.remoteChanges.acked) + val localCommitDustExposure = DustExposure.computeExposure(localReduced, commitments.localParams.dustLimit, commitments.commitmentFormat) + 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 + DustExposure.filterBeforeForward( + 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)) ++ + 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 +809,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/channel/DustExposure.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala new file mode 100644 index 0000000000..84c68182b3 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/DustExposure.scala @@ -0,0 +1,155 @@ +/* + * 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._ + +/** + * 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 computeExposure(spec: CommitmentSpec, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): MilliSatoshi = { + val feerate = feerateForDustExposure(spec.htlcTxFeerate(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 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 + } + + /** Accept as many incoming HTLCs as possible, in the order they are provided, while not overflowing our dust exposure. */ + 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) + 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 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 20113214a4..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 @@ -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 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..d4a7709b9b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -16,12 +16,12 @@ 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.blockchain.fee.{DustTolerance, FeeratePerByte, FeeratePerKw, FeerateTolerance} import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager} import org.scalatest.funsuite.AnyFunSuite import scodec.bits.{ByteVector, HexStringSyntax} @@ -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,10 @@ class StartupSpec extends AnyFunSuite { | ratio-low = 0.1 | ratio-high = 15.0 | anchor-output-max-commit-feerate = 15 + | dust-tolerance { + | max-exposure-satoshis = 25000 + | close-on-update-fee-overflow = true + | } | } | }, | { @@ -182,6 +186,10 @@ class StartupSpec extends AnyFunSuite { | ratio-low = 0.75 | ratio-high = 5.0 | anchor-output-max-commit-feerate = 5 + | dust-tolerance { + | max-exposure-satoshis = 40000 + | close-on-update-fee-overflow = false + | } | } | }, | ] @@ -189,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)))) - 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)), 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 de489dfeac..daf8ab41db 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, DustTolerance(25_000 sat, closeOnUpdateFeeOverflow = true)), 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, 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 @@ -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..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,8 +25,10 @@ import org.scalatest.funsuite.AnyFunSuite class FeeEstimatorSpec extends AnyFunSuite { + 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, 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), DustTolerance(25000 sat, closeOnUpdateFeeOverflow = false)) 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), 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 7a7f50b29e..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 @@ -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._ import fr.acinq.eclair.channel.Commitments._ import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel.states.ChannelStateTestsBase @@ -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), Map.empty) + val feeConfNoMismatch = OnChainFeeConf( + FeeTargets(6, 2, 2, 6), + new TestFeeEstimator(), + closeOnOfflineMismatch = false, + 1.0, + FeerateTolerance(0.00001, 100000.0, TestConstants.anchorOutputsFeeratePerKw, DustTolerance(100000 sat, closeOnUpdateFeeOverflow = false)), + Map.empty + ) override def withFixture(test: OneArgTest): Outcome = { val setup = init() @@ -61,6 +68,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 +96,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 +108,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 +129,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 +141,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 +153,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 +181,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 +193,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 +214,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 +226,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 +240,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 +287,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 +299,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 +311,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 +350,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 +362,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 +374,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) } @@ -378,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/DustExposureSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/DustExposureSpec.scala new file mode 100644 index 0000000000..6dbff89b48 --- /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.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 + 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.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)) + 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.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)) + assert(!DustExposure.contributesToDustExposure(OutgoingHtlc(createHtlc(5, 18000.sat.toMilliSatoshi)), spec, dustLimit, Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) + } + } + + test("filter incoming htlcs before forwarding") { + val dustLimit = 1000.sat + val initialSpec = CommitmentSpec(Set.empty, FeeratePerKw(10000 sat), 0 msat, 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 + // 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.computeExposure(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.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)) + } + +} 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 f43835a66a..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 @@ -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,17 +364,133 @@ 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))) 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).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) + 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, 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, 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, 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)) + sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]] + 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() @@ -638,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 @@ -672,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 => @@ -1127,6 +1248,134 @@ 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).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) + + // 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 (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 @@ -1143,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] @@ -1169,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] @@ -1683,6 +1932,104 @@ 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(13000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments + 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() + 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))) + } + + 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(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)) + 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 = 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) + 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 => import f._ val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] @@ -1839,6 +2186,112 @@ 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(13000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(13500.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + addHtlc(14000.sat.toMilliSatoshi, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + val bobCommitments = bob.stateData.asInstanceOf[DATA_NORMAL].commitments + 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: + 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) + } + + 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(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 + 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 = 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) + 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() @@ -2341,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 a144195afb..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 @@ -19,7 +19,7 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.{ByteVector32, Crypto, SatoshiLong} import fr.acinq.eclair.blockchain.fee.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, randomBytes32} import org.scalatest.funsuite.AnyFunSuite class CommitmentSpecSpec extends AnyFunSuite { @@ -75,4 +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) + } + }