1
0
mirror of https://github.com/ACINQ/eclair.git synced 2024-11-19 09:54:02 +01:00

Configure dust in flight threshold (#1985)

Add config fields for max dust htlc exposure.
These configuration fields let node operators decide on the amount of dust
htlcs that can be in-flight in each channel.

In case the channel is force-closed, up to this amount may be lost in
miner fees.

When sending and receiving htlcs, we check whether they would overflow
our configured dust exposure, and fail them instantly if they do.

A large `update_fee` may overflow our dust exposure by removing from the
commit tx htlcs that were previously untrimmed.

Node operators can choose to automatically force-close when that happens,
to avoid risking losing large dust amounts to miner fees.
This commit is contained in:
Bastien Teinturier 2021-10-08 08:35:55 +02:00 committed by GitHub
parent bb5e6df186
commit 75eafd0e4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1065 additions and 103 deletions

View File

@ -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.

View File

@ -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"]
}
}

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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")

View File

@ -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))
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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") {

View File

@ -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,

View File

@ -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)),

View File

@ -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)

View File

@ -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))
}
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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)
}
}