Skip to content

Commit 5fc980c

Browse files
authored
Lower minimum remote dust limit (#1900)
We are slowly dropping support for non-segwit outputs, as proposed in lightning/bolts#894 We can thus safely allow dust limits all the way down to 354 satoshis. In very rare cases where dust_limit_satoshis is negotiated to a low value, our peer may generate closing txs that will not correctly relay on the bitcoin network due to dust relay policies. When that happens, we detect it and force-close instead of completing the mutual close flow.
1 parent d5c0c73 commit 5fc980c

File tree

9 files changed

+100
-17
lines changed

9 files changed

+100
-17
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ This is fixed in this release: when using Tor, the watchdogs will now also be qu
106106
You can also now choose to disable some watchdogs by removing them from the `eclair.blockchain-watchdog.sources` list in `eclair.conf`.
107107
Head over to [reference.conf](https://github.com/ACINQ/eclair/blob/master/eclair-core/src/main/resources/reference.conf) for more details.
108108

109+
### Dust limit thresholds
110+
111+
Eclair can now use dust limits as low as 354 satoshis.
112+
This value covers all current and future segwit versions, while ensuring that transactions can relay according to default bitcoin network policies.
113+
114+
With this change, we also disallow non-segwit scripts when closing a channel.
115+
We still support receiving non-segwit remote scripts, but will force-close if the resulting mutual close transaction would be invalid according to default network policies.
116+
117+
See the [spec discussions](https://github.com/lightningnetwork/lightning-rfc/pull/894) for more details.
118+
109119
### Sample GUI removed
110120

111121
We previously included code for a sample GUI: `eclair-node-gui`.
@@ -122,6 +132,7 @@ This release contains many API updates:
122132
- `open` doesn't support the `--feeBaseMsat` and `--feeProportionalMillionths` parameters anymore: you should instead set these with the `updaterelayfee` API, which can now be called before opening a channel (#1890)
123133
- `updaterelayfee` must now be called with nodeIds instead of channelIds and will update the fees for all channels with the given node(s) at once (#1890)
124134
- `close` lets you specify a fee range when using quick close through the `--preferredFeerateSatByte`, `--minFeerateSatByte` and `--maxFeerateSatByte` (#1768)
135+
- `close` now rejects non-segwit `scriptPubKey`
125136
- `createinvoice` now lets you provide a `--descriptionHash` instead of a `--description` (#1919)
126137
- `sendtonode` doesn't support providing a `paymentHash` anymore since it uses `keysend` to send the payment (#1840)
127138
- `payinvoice`, `sendtonode`, `findroute`, `findroutetonode` and `findroutebetweennodes` let you specify `--pathFindingExperimentName` when using path-finding A/B testing (#1930)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ object NodeParams extends Logging {
233233

234234
val dustLimitSatoshis = Satoshi(config.getLong("dust-limit-satoshis"))
235235
if (chainHash == Block.LivenetGenesisBlock.hash) {
236-
require(dustLimitSatoshis >= Channel.MIN_DUSTLIMIT, s"dust limit must be greater than ${Channel.MIN_DUSTLIMIT}")
236+
require(dustLimitSatoshis >= Channel.MIN_DUST_LIMIT, s"dust limit must be greater than ${Channel.MIN_DUST_LIMIT}")
237237
}
238238

239239
val htlcMinimum = MilliSatoshi(config.getInt("htlc-minimum-msat"))

eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ object Channel {
7979
val MAX_FUNDING: Satoshi = 16777216 sat // = 2^24
8080
val MAX_ACCEPTED_HTLCS = 483
8181

82-
// we don't want the counterparty to use a dust limit lower than that, because they wouldn't only hurt themselves we may need them to publish their commit tx in certain cases (backup/restore)
83-
val MIN_DUSTLIMIT: Satoshi = 546 sat
82+
// We may need to rely on our peer's commit tx in certain cases (backup/restore) so we must ensure their transactions
83+
// can propagate through the bitcoin network (assuming bitcoin core nodes with default policies).
84+
// The various dust limits enforced by the bitcoin network are summarized here:
85+
// https://github.com/lightningnetwork/lightning-rfc/blob/master/03-transactions.md#dust-limits
86+
// A dust limit of 354 sat ensures all segwit outputs will relay with default relay policies.
87+
val MIN_DUST_LIMIT: Satoshi = 354 sat
8488

8589
// we won't exchange more than this many signatures when negotiating the closing fee
8690
val MAX_NEGOTIATION_ITERATIONS = 20

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ case class InvalidCommitmentSignature (override val channelId: Byte
6969
case class InvalidHtlcSignature (override val channelId: ByteVector32, tx: Transaction) extends ChannelException(channelId, s"invalid htlc signature: tx=$tx")
7070
case class InvalidCloseSignature (override val channelId: ByteVector32, tx: Transaction) extends ChannelException(channelId, s"invalid close signature: tx=$tx")
7171
case class InvalidCloseFee (override val channelId: ByteVector32, fee: Satoshi) extends ChannelException(channelId, s"invalid close fee: fee_satoshis=$fee")
72+
case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, tx: Transaction) extends ChannelException(channelId, s"invalid closing tx: some outputs are below dust: tx=$tx")
7273
case class HtlcSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"htlc sig count mismatch: expected=$expected actual: $actual")
7374
case class ForcedLocalCommit (override val channelId: ByteVector32) extends ChannelException(channelId, s"forced local commit")
7475
case class UnexpectedHtlcId (override val channelId: ByteVector32, expected: Long, actual: Long) extends ChannelException(channelId, s"unexpected htlc id: expected=$expected actual=$actual")

eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ object Helpers {
138138
if (nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).isFeeDiffTooHigh(channelType, localFeeratePerKw, open.feeratePerKw)) return Left(FeerateTooDifferent(open.temporaryChannelId, localFeeratePerKw, open.feeratePerKw))
139139
// only enforce dust limit check on mainnet
140140
if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) {
141-
if (open.dustLimitSatoshis < Channel.MIN_DUSTLIMIT) return Left(DustLimitTooSmall(open.temporaryChannelId, open.dustLimitSatoshis, Channel.MIN_DUSTLIMIT))
141+
if (open.dustLimitSatoshis < Channel.MIN_DUST_LIMIT) return Left(DustLimitTooSmall(open.temporaryChannelId, open.dustLimitSatoshis, Channel.MIN_DUST_LIMIT))
142142
}
143143

144144
// we don't check that the funder's amount for the initial commitment transaction is sufficient for full fee payment
@@ -169,7 +169,7 @@ object Helpers {
169169
if (accept.maxAcceptedHtlcs > Channel.MAX_ACCEPTED_HTLCS) return Left(InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, accept.maxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS))
170170
// only enforce dust limit check on mainnet
171171
if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) {
172-
if (accept.dustLimitSatoshis < Channel.MIN_DUSTLIMIT) return Left(DustLimitTooSmall(accept.temporaryChannelId, accept.dustLimitSatoshis, Channel.MIN_DUSTLIMIT))
172+
if (accept.dustLimitSatoshis < Channel.MIN_DUST_LIMIT) return Left(DustLimitTooSmall(accept.temporaryChannelId, accept.dustLimitSatoshis, Channel.MIN_DUST_LIMIT))
173173
}
174174

175175
if (accept.dustLimitSatoshis > nodeParams.maxRemoteDustLimit) return Left(DustLimitTooLarge(open.temporaryChannelId, accept.dustLimitSatoshis, nodeParams.maxRemoteDustLimit))
@@ -518,14 +518,36 @@ object Helpers {
518518
Left(InvalidCloseFee(commitments.channelId, remoteClosingFee))
519519
} else {
520520
val (closingTx, closingSigned) = makeClosingTx(keyManager, commitments, localScriptPubkey, remoteScriptPubkey, ClosingFees(remoteClosingFee, remoteClosingFee, remoteClosingFee))
521-
val signedClosingTx = Transactions.addSigs(closingTx, keyManager.fundingPublicKey(commitments.localParams.fundingKeyPath).publicKey, remoteParams.fundingPubKey, closingSigned.signature, remoteClosingSig)
522-
Transactions.checkSpendable(signedClosingTx) match {
523-
case Success(_) => Right(signedClosingTx, closingSigned)
524-
case _ => Left(InvalidCloseSignature(commitments.channelId, signedClosingTx.tx))
521+
if (checkClosingDustAmounts(closingTx)) {
522+
val signedClosingTx = Transactions.addSigs(closingTx, keyManager.fundingPublicKey(commitments.localParams.fundingKeyPath).publicKey, remoteParams.fundingPubKey, closingSigned.signature, remoteClosingSig)
523+
Transactions.checkSpendable(signedClosingTx) match {
524+
case Success(_) => Right(signedClosingTx, closingSigned)
525+
case _ => Left(InvalidCloseSignature(commitments.channelId, signedClosingTx.tx))
526+
}
527+
} else {
528+
Left(InvalidCloseAmountBelowDust(commitments.channelId, closingTx.tx))
525529
}
526530
}
527531
}
528532

533+
/**
534+
* Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk
535+
* that the closing transaction will not be relayed to miners' mempool and will not confirm.
536+
* The various dust limits are detailed in https://github.com/lightningnetwork/lightning-rfc/blob/master/03-transactions.md#dust-limits
537+
*/
538+
def checkClosingDustAmounts(closingTx: ClosingTx): Boolean = {
539+
closingTx.tx.txOut.forall(txOut => {
540+
Try(Script.parse(txOut.publicKeyScript)) match {
541+
case Success(OP_DUP :: OP_HASH160 :: OP_PUSHDATA(pubkeyHash, _) :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil) if pubkeyHash.size == 20 => txOut.amount >= 546.sat
542+
case Success(OP_HASH160 :: OP_PUSHDATA(scriptHash, _) :: OP_EQUAL :: Nil) if scriptHash.size == 20 => txOut.amount >= 540.sat
543+
case Success(OP_0 :: OP_PUSHDATA(pubkeyHash, _) :: Nil) if pubkeyHash.size == 20 => txOut.amount >= 294.sat
544+
case Success(OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil) if scriptHash.size == 32 => txOut.amount >= 330.sat
545+
case Success((OP_1 | OP_2 | OP_3 | OP_4 | OP_5 | OP_6 | OP_7 | OP_8 | OP_9 | OP_10 | OP_11 | OP_12 | OP_13 | OP_14 | OP_15 | OP_16) :: OP_PUSHDATA(program, _) :: Nil) if 2 <= program.length && program.length <= 40 => txOut.amount >= 354.sat
546+
case _ => txOut.amount >= 546.sat
547+
}
548+
})
549+
}
550+
529551
/** Wraps transaction generation in a Try and filters failures to avoid one transaction negatively impacting a whole commitment. */
530552
private def generateTx[T <: TransactionWithInputInfo](desc: String)(attempt: => Either[TxGenerationSkipped, T])(implicit log: LoggingAdapter): Option[T] = {
531553
Try {

eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
package fr.acinq.eclair.channel
1818

1919
import akka.testkit.{TestFSMRef, TestProbe}
20-
import fr.acinq.bitcoin.{Btc, OutPoint, SatoshiLong, Transaction, TxOut}
20+
import fr.acinq.bitcoin._
2121
import fr.acinq.eclair.TestConstants.Alice.nodeParams
2222
import fr.acinq.eclair.TestUtils.NoLoggingDiagnostics
2323
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchFundingSpentTriggered
@@ -28,6 +28,7 @@ import fr.acinq.eclair.wire.protocol.UpdateAddHtlc
2828
import fr.acinq.eclair.{MilliSatoshiLong, TestKitBaseClass}
2929
import org.scalatest.Tag
3030
import org.scalatest.funsuite.AnyFunSuiteLike
31+
import scodec.bits.HexStringSyntax
3132

3233
import java.util.UUID
3334
import scala.concurrent.duration._
@@ -226,6 +227,32 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat
226227
findTimedOutHtlcs(setupHtlcs(Set(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)), withoutHtlcId = false)
227228
}
228229

230+
test("check closing tx amounts above dust") {
231+
val p2pkhBelowDust = Seq(TxOut(545 sat, OP_DUP :: OP_HASH160 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000") :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil))
232+
val p2shBelowDust = Seq(TxOut(539 sat, OP_HASH160 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000") :: OP_EQUAL :: Nil))
233+
val p2wpkhBelowDust = Seq(TxOut(293 sat, OP_0 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000") :: Nil))
234+
val p2wshBelowDust = Seq(TxOut(329 sat, OP_0 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000000000000000000000000000") :: Nil))
235+
val futureSegwitBelowDust = Seq(TxOut(353 sat, OP_3 :: OP_PUSHDATA(hex"0000000000") :: Nil))
236+
val allOutputsAboveDust = Seq(
237+
TxOut(546 sat, OP_DUP :: OP_HASH160 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000") :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil),
238+
TxOut(540 sat, OP_HASH160 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000") :: OP_EQUAL :: Nil),
239+
TxOut(294 sat, OP_0 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000") :: Nil),
240+
TxOut(330 sat, OP_0 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000000000000000000000000000") :: Nil),
241+
TxOut(354 sat, OP_3 :: OP_PUSHDATA(hex"0000000000") :: Nil),
242+
)
243+
244+
def toClosingTx(txOut: Seq[TxOut]): ClosingTx = {
245+
ClosingTx(InputInfo(OutPoint(ByteVector32.Zeroes, 0), TxOut(1000 sat, Nil), Nil), Transaction(2, Nil, txOut, 0), None)
246+
}
247+
248+
assert(Closing.checkClosingDustAmounts(toClosingTx(allOutputsAboveDust)))
249+
assert(!Closing.checkClosingDustAmounts(toClosingTx(p2pkhBelowDust)))
250+
assert(!Closing.checkClosingDustAmounts(toClosingTx(p2shBelowDust)))
251+
assert(!Closing.checkClosingDustAmounts(toClosingTx(p2wpkhBelowDust)))
252+
assert(!Closing.checkClosingDustAmounts(toClosingTx(p2wshBelowDust)))
253+
assert(!Closing.checkClosingDustAmounts(toClosingTx(futureSegwitBelowDust)))
254+
}
255+
229256
test("tell closing type") {
230257
val commitments = CommitmentsSpec.makeCommitments(10000 msat, 15000 msat)
231258
val tx1 :: tx2 :: tx3 :: tx4 :: tx5 :: tx6 :: Nil = List(

eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,11 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS
177177
test("recv AcceptChannel (dust limit too low)", Tag("mainnet")) { f =>
178178
import f._
179179
val accept = bob2alice.expectMsgType[AcceptChannel]
180-
// we don't want their dust limit to be below 546
181-
val lowDustLimitSatoshis = 545.sat
180+
// we don't want their dust limit to be below 354
181+
val lowDustLimitSatoshis = 353.sat
182182
alice ! accept.copy(dustLimitSatoshis = lowDustLimitSatoshis)
183183
val error = alice2bob.expectMsgType[Error]
184-
assert(error === Error(accept.temporaryChannelId, DustLimitTooSmall(accept.temporaryChannelId, lowDustLimitSatoshis, Channel.MIN_DUSTLIMIT).getMessage))
184+
assert(error === Error(accept.temporaryChannelId, DustLimitTooSmall(accept.temporaryChannelId, lowDustLimitSatoshis, Channel.MIN_DUST_LIMIT).getMessage))
185185
awaitCond(alice.stateName == CLOSED)
186186
}
187187

eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package fr.acinq.eclair.api.handlers
1818

1919
import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route}
2020
import akka.util.Timeout
21-
import fr.acinq.bitcoin.Satoshi
21+
import fr.acinq.bitcoin.{Satoshi, Script}
2222
import fr.acinq.eclair.MilliSatoshi
2323
import fr.acinq.eclair.api.Service
2424
import fr.acinq.eclair.api.directives.EclairDirectives
@@ -63,7 +63,11 @@ trait Channel {
6363
val maxFeerate = maxFeerate_opt.map(feerate => FeeratePerKw(feerate)).getOrElse(preferredFeerate * 2)
6464
ClosingFeerates(preferredFeerate, minFeerate, maxFeerate)
6565
})
66-
complete(eclairApi.close(channels, scriptPubKey_opt, closingFeerates))
66+
if (scriptPubKey_opt.forall(Script.isNativeWitnessScript)) {
67+
complete(eclairApi.close(channels, scriptPubKey_opt, closingFeerates))
68+
} else {
69+
reject(MalformedFormFieldRejection("scriptPubKey", "Non-segwit scripts are not allowed"))
70+
}
6771
}
6872
}
6973
}

eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,21 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
524524
}
525525
}
526526

527+
test("'close' rejects non-segwit scripts") {
528+
val shortChannelId = "1701x42x3"
529+
val eclair = mock[Eclair]
530+
val mockService = new MockService(eclair)
531+
532+
Post("/close", FormData("shortChannelId" -> shortChannelId, "scriptPubKey" -> "a914748284390f9e263a4b766a75d0633c50426eb87587").toEntity) ~>
533+
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
534+
addHeader("Content-Type", "application/json") ~>
535+
Route.seal(mockService.close) ~>
536+
check {
537+
assert(handled)
538+
assert(status == BadRequest)
539+
}
540+
}
541+
527542
test("'connect' method should accept a nodeId") {
528543
val remoteNodeId = PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87")
529544

@@ -560,7 +575,6 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
560575
}
561576
}
562577

563-
564578
test("'send' method should handle payment failures") {
565579
val eclair = mock[Eclair]
566580
eclair.send(any, any, any, any, any, any)(any[Timeout]) returns Future.failed(new IllegalArgumentException("invoice has expired"))
@@ -982,7 +996,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
982996
eclair.findRoute(any, any, any, any)(any[Timeout]) returns Future.successful(Router.RouteResponse(Seq(Router.Route(456.msat, mockHops))))
983997

984998
// invalid format
985-
Post("/findroute", FormData("format"-> "invalid-output-format", "invoice" -> invoice, "amountMsat" -> "456")) ~>
999+
Post("/findroute", FormData("format" -> "invalid-output-format", "invoice" -> invoice, "amountMsat" -> "456")) ~>
9861000
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
9871001
addHeader("Content-Type", "application/json") ~>
9881002
Route.seal(mockService.findRoute) ~>

0 commit comments

Comments
 (0)