Skip to content

Commit e09c830

Browse files
authored
Automatically disable from_future_htlc when abused (#2928)
When providing on-the-fly funding with the `from_future_htlc` payment type, the liquidity provider is paying mining fees for the funding transaction while trusting that the remote node will accept the HTLCs afterwards and thus pay a liquidity fees. If the remote node fails the HTLCs, the liquidity provider doesn't get paid. At that point it can disable the channel and try to actively double-spend it. When we detect such behavior, we immediately disable `from_future_htlc` to limit the exposure to liquidity griefing: it can then be re-enabled by using the `enableFromFutureHtlc` RPC, or will be automatically re-enabled if the remote node fulfills the HTLCs after a retry.
1 parent b8e6800 commit e09c830

File tree

10 files changed

+190
-11
lines changed

10 files changed

+190
-11
lines changed

contrib/eclair-cli.bash-completion

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ _eclair-cli()
2121
*)
2222
# works fine, but is too slow at the moment.
2323
# allopts=$($eclaircli help 2>&1 | awk '$1 ~ /^"/ { sub(/,/, ""); print $1}' | sed 's/[":]//g')
24-
allopts="allchannels allupdates audit bumpforceclose channel channelbalances channels channelstats close closedchannels connect cpfpbumpfees createinvoice deleteinvoice disconnect findroute findroutebetweennodes findroutetonode forceclose getdescriptors getinfo getinvoice getmasterxpub getnewaddress getreceivedinfo getsentinfo globalbalance listinvoices listpendinginvoices listreceivedpayments networkfees node nodes onchainbalance onchaintransactions open parseinvoice payinvoice payoffer peers rbfopen sendonchain sendonionmessage sendtonode sendtoroute signmessage splicein spliceout stop updaterelayfee usablebalances verifymessage"
24+
allopts="allchannels allupdates audit bumpforceclose channel channelbalances channels channelstats close closedchannels connect cpfpbumpfees createinvoice deleteinvoice disconnect enableFromFutureHtlc findroute findroutebetweennodes findroutetonode forceclose getdescriptors getinfo getinvoice getmasterxpub getnewaddress getreceivedinfo getsentinfo globalbalance listinvoices listpendinginvoices listreceivedpayments networkfees node nodes onchainbalance onchaintransactions open parseinvoice payinvoice payoffer peers rbfopen sendonchain sendonionmessage sendtonode sendtoroute signmessage splicein spliceout stop updaterelayfee usablebalances verifymessage"
2525

2626
if ! [[ " $allopts " =~ " $prev " ]]; then # prevent double arguments
2727
if [[ -z "$cur" || "$cur" =~ ^[a-z] ]]; then

eclair-core/eclair-cli

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ and COMMAND is one of the available commands:
9595
- globalbalance
9696
- getmasterxpub
9797
- getdescriptors
98+
99+
=== Control ===
100+
- enablefromfuturehtlc
98101
99102
Examples
100103
--------

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ case class SendOnionMessageResponsePayload(tlvs: TlvStream[OnionMessagePayloadTl
6969
case class SendOnionMessageResponse(sent: Boolean, failureMessage: Option[String], response: Option[SendOnionMessageResponsePayload])
7070
// @formatter:on
7171

72+
case class EnableFromFutureHtlcResponse(enabled: Boolean, failureMessage: Option[String])
73+
7274
object SignedMessage {
7375
def signedBytes(message: ByteVector): ByteVector32 =
7476
Crypto.hash256(ByteVector("Lightning Signed Message:".getBytes(StandardCharsets.UTF_8)) ++ message)
@@ -186,6 +188,8 @@ trait Eclair {
186188

187189
def getDescriptors(account: Long): Descriptors
188190

191+
def enableFromFutureHtlc(): Future[EnableFromFutureHtlcResponse]
192+
189193
def stop(): Future[Unit]
190194
}
191195

@@ -781,6 +785,16 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
781785
case _ => throw new RuntimeException("on-chain seed is not configured")
782786
}
783787

788+
override def enableFromFutureHtlc(): Future[EnableFromFutureHtlcResponse] = {
789+
appKit.nodeParams.willFundRates_opt match {
790+
case Some(willFundRates) if willFundRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) =>
791+
appKit.nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc()
792+
Future.successful(EnableFromFutureHtlcResponse(appKit.nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed, None))
793+
case _ =>
794+
Future.successful(EnableFromFutureHtlcResponse(enabled = false, Some("could not enable from_future_htlc: you must add it to eclair.liquidity-ads.payment-types in your eclair.conf file first")))
795+
}
796+
}
797+
784798
override def stop(): Future[Unit] = {
785799
// README: do not make this smarter or more complex !
786800
// eclair can simply and cleanly be stopped by killing its process without fear of losing data, payments, ... and it should remain this way.

eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ class Peer(val nodeParams: NodeParams,
218218
case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) =>
219219
val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId)
220220
if (peerConnection == d.peerConnection) {
221-
OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
221+
OnTheFlyFunding.validateOpen(nodeParams.onTheFlyFundingConfig, open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
222222
case reject: OnTheFlyFunding.ValidationResult.Reject =>
223223
log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii)
224224
self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
@@ -387,7 +387,7 @@ class Peer(val nodeParams: NodeParams,
387387
log.info("rejecting open_channel2: feerate too low ({} < {})", msg.feerate, d.currentFeerates.fundingFeerate)
388388
self ! Peer.OutgoingMessage(TxAbort(msg.channelId, FundingFeerateTooLow(msg.channelId, msg.feerate, d.currentFeerates.fundingFeerate).getMessage), d.peerConnection)
389389
case Some(channel) =>
390-
OnTheFlyFunding.validateSplice(msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
390+
OnTheFlyFunding.validateSplice(nodeParams.onTheFlyFundingConfig, msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
391391
case reject: OnTheFlyFunding.ValidationResult.Reject =>
392392
log.warning("rejecting on-the-fly splice: {}", reject.cancel.toAscii)
393393
self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
@@ -689,13 +689,25 @@ class Peer(val nodeParams: NodeParams,
689689
pendingOnTheFlyFunding -= success.paymentHash
690690
case None => ()
691691
}
692+
// If this is a payment that was initially rejected, it wasn't a malicious node, but rather a temporary issue.
693+
nodeParams.onTheFlyFundingConfig.fromFutureHtlcFulfilled(success.paymentHash)
692694
stay()
693695
case OnTheFlyFunding.PaymentRelayer.RelayFailed(paymentHash, failure) =>
694696
log.warning("on-the-fly HTLC failure for payment_hash={}: {}", paymentHash, failure.toString)
695697
Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.relayFailed(failure)).increment()
696698
// We don't give up yet by relaying the failure upstream: we may have simply been disconnected, or the added
697699
// liquidity may have been consumed by concurrent HTLCs. We'll retry at the next reconnection with that peer
698700
// or after the next splice, and will only give up when the outgoing will_add_htlc timeout.
701+
val fundingStatus = pendingOnTheFlyFunding.get(paymentHash).map(_.status)
702+
failure match {
703+
case OnTheFlyFunding.PaymentRelayer.RemoteFailure(_) if fundingStatus.collect { case s: OnTheFlyFunding.Status.Funded => s.remainingFees }.sum > 0.msat =>
704+
// We are still owed some fees for the funding transaction we published: we need these HTLCs to succeed.
705+
// They received the HTLCs but failed them, which means that they're likely malicious (but not always,
706+
// they may have other pending HTLCs that temporarily prevent relaying the whole HTLC set because of
707+
// channel limits). We disable funding from future HTLCs to limit our exposure to fee siphoning.
708+
nodeParams.onTheFlyFundingConfig.fromFutureHtlcFailed(paymentHash, remoteNodeId)
709+
case _ => ()
710+
}
699711
stay()
700712
}
701713

eclair-core/src/main/scala/fr/acinq/eclair/payment/Monitoring.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ object Monitoring {
3434
val SentPaymentDuration = Kamon.timer("payment.duration.sent", "Outgoing payment duration")
3535
val ReceivedPaymentDuration = Kamon.timer("payment.duration.received", "Incoming payment duration")
3636
val RelayedPaymentDuration = Kamon.timer("payment.duration.relayed", "Duration of pending downstream HTLCs during a relay")
37+
val SuspiciousFromFutureHtlcRelays = Kamon.gauge("payment.on-the-fly-funding.suspicious-htlc-relays", "Number of pending on-the-fly HTLCs that are being rejected by seemingly malicious peers")
3738

3839
// The goal of this metric is to measure whether retrying MPP payments on failing channels yields useful results.
3940
// Once enough data has been collected, we will update the MultiPartPaymentLifecycle logic accordingly.

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, TxId}
2525
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2626
import fr.acinq.eclair.channel._
2727
import fr.acinq.eclair.crypto.Sphinx
28+
import fr.acinq.eclair.payment.Monitoring.Metrics
2829
import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails
2930
import fr.acinq.eclair.wire.protocol._
3031
import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, ToMilliSatoshiConversion}
@@ -38,7 +39,38 @@ import scala.concurrent.duration.FiniteDuration
3839

3940
object OnTheFlyFunding {
4041

41-
case class Config(proposalTimeout: FiniteDuration)
42+
case class Config(proposalTimeout: FiniteDuration) {
43+
// When funding a transaction using from_future_htlc, we are taking the risk that the remote node doesn't fulfill
44+
// the corresponding HTLCs. If we detect that our peer fails such HTLCs, we automatically disable from_future_htlc
45+
// to limit our exposure.
46+
// Note that this state is flushed when restarting: node operators should explicitly remove the from_future_htlc
47+
// payment type from their liquidity ads configuration if they want to keep it disabled.
48+
private val suspectFromFutureHtlcRelays = scala.collection.concurrent.TrieMap.empty[ByteVector32, PublicKey]
49+
50+
/** We allow using from_future_htlc if we don't have any pending payment that is abusing it. */
51+
def isFromFutureHtlcAllowed: Boolean = suspectFromFutureHtlcRelays.isEmpty
52+
53+
/** An on-the-fly payment using from_future_htlc was failed by the remote node: they may be malicious. */
54+
def fromFutureHtlcFailed(paymentHash: ByteVector32, remoteNodeId: PublicKey): Unit = {
55+
suspectFromFutureHtlcRelays.addOne(paymentHash, remoteNodeId)
56+
Metrics.SuspiciousFromFutureHtlcRelays.withoutTags().update(suspectFromFutureHtlcRelays.size)
57+
}
58+
59+
/** If a fishy payment is fulfilled, we remove it from the list, which may re-enabled from_future_htlc. */
60+
def fromFutureHtlcFulfilled(paymentHash: ByteVector32): Unit = {
61+
suspectFromFutureHtlcRelays.remove(paymentHash).foreach { _ =>
62+
// We only need to update the metric if an entry was actually removed.
63+
Metrics.SuspiciousFromFutureHtlcRelays.withoutTags().update(suspectFromFutureHtlcRelays.size)
64+
}
65+
}
66+
67+
/** Remove all suspect payments and re-enable from_future_htlc. */
68+
def enableFromFutureHtlc(): Unit = {
69+
val pending = suspectFromFutureHtlcRelays.toList.map(_._1)
70+
pending.foreach(paymentHash => suspectFromFutureHtlcRelays.remove(paymentHash))
71+
Metrics.SuspiciousFromFutureHtlcRelays.withoutTags().update(0)
72+
}
73+
}
4274

4375
// @formatter:off
4476
sealed trait Status
@@ -114,25 +146,26 @@ object OnTheFlyFunding {
114146
// @formatter:on
115147

116148
/** Validate an incoming channel that may use on-the-fly funding. */
117-
def validateOpen(open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
149+
def validateOpen(cfg: Config, open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
118150
open match {
119151
case Left(_) => ValidationResult.Accept(Set.empty, None)
120152
case Right(open) => open.requestFunding_opt match {
121-
case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding, feeCredit)
153+
case Some(requestFunding) => validate(cfg, open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding, feeCredit)
122154
case None => ValidationResult.Accept(Set.empty, None)
123155
}
124156
}
125157
}
126158

127159
/** Validate an incoming splice that may use on-the-fly funding. */
128-
def validateSplice(splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
160+
def validateSplice(cfg: Config, splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
129161
splice.requestFunding_opt match {
130-
case Some(requestFunding) => validate(splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit)
162+
case Some(requestFunding) => validate(cfg, splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit)
131163
case None => ValidationResult.Accept(Set.empty, None)
132164
}
133165
}
134166

135-
private def validate(channelId: ByteVector32,
167+
private def validate(cfg: Config,
168+
channelId: ByteVector32,
136169
requestFunding: LiquidityAds.RequestFunding,
137170
isChannelCreation: Boolean,
138171
feerate: FeeratePerKw,
@@ -159,10 +192,12 @@ object OnTheFlyFunding {
159192
}
160193
val cancelAmountTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"requested amount is too low to relay HTLCs: ${requestFunding.requestedAmount} < $totalPaymentAmount")
161194
val cancelFeesTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"htlc amount is too low to pay liquidity fees: $availableAmountForFees < $feesOwed")
195+
val cancelDisabled = CancelOnTheFlyFunding(channelId, paymentHashes, "payments paid with future HTLCs are currently disabled")
162196
requestFunding.paymentDetails match {
163197
case PaymentDetails.FromChannelBalance => ValidationResult.Accept(Set.empty, None)
164198
case _ if requestFunding.requestedAmount.toMilliSatoshi < totalPaymentAmount => ValidationResult.Reject(cancelAmountTooLow, paymentHashes.toSet)
165199
case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt)
200+
case _: PaymentDetails.FromFutureHtlc if !cfg.isFromFutureHtlcAllowed => ValidationResult.Reject(cancelDisabled, paymentHashes.toSet)
166201
case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
167202
case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt)
168203
case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)

eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec
3636
import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec.localParams
3737
import fr.acinq.eclair.wire.protocol
3838
import fr.acinq.eclair.wire.protocol._
39+
import org.scalatest.Inside.inside
3940
import org.scalatest.{Tag, TestData}
4041
import scodec.bits.ByteVector
4142

@@ -728,6 +729,29 @@ class PeerSpec extends FixtureSpec {
728729
probe.expectTerminated(peer)
729730
}
730731

732+
test("reject on-the-fly funding requests when from_future_htlc is disabled", Tag(ChannelStateTestsTags.DualFunding)) { f =>
733+
import f._
734+
735+
// We make sure that from_future_htlc is disabled.
736+
nodeParams.onTheFlyFundingConfig.fromFutureHtlcFailed(randomBytes32(), randomKey().publicKey)
737+
assert(!nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed)
738+
739+
// We reject requests using from_future_htlc.
740+
val paymentHash = randomBytes32()
741+
connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional)))
742+
val requestFunds = LiquidityAds.RequestFunding(50_000 sat, LiquidityAds.FundingRate(10_000 sat, 100_000 sat, 0, 0, 0 sat, 0 sat), LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil))
743+
val open = inside(createOpenDualFundedChannelMessage()) { msg => msg.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(requestFunds))) }
744+
peerConnection.send(peer, open)
745+
peerConnection.expectMsg(CancelOnTheFlyFunding(open.temporaryChannelId, paymentHash :: Nil, "payments paid with future HTLCs are currently disabled"))
746+
channel.expectNoMessage(100 millis)
747+
748+
// Once enabled, we accept requests using from_future_htlc.
749+
nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc()
750+
peerConnection.send(peer, open)
751+
channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR]
752+
channel.expectMsg(open)
753+
}
754+
731755
}
732756

733757
object PeerSpec {

0 commit comments

Comments
 (0)