Skip to content

Commit

Permalink
Automatically disable from_future_htlc when abused (#2928)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast authored Oct 15, 2024
1 parent b8e6800 commit e09c830
Show file tree
Hide file tree
Showing 10 changed files with 190 additions and 11 deletions.
2 changes: 1 addition & 1 deletion contrib/eclair-cli.bash-completion
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ _eclair-cli()
*)
# works fine, but is too slow at the moment.
# allopts=$($eclaircli help 2>&1 | awk '$1 ~ /^"/ { sub(/,/, ""); print $1}' | sed 's/[":]//g')
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"
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"

if ! [[ " $allopts " =~ " $prev " ]]; then # prevent double arguments
if [[ -z "$cur" || "$cur" =~ ^[a-z] ]]; then
Expand Down
3 changes: 3 additions & 0 deletions eclair-core/eclair-cli
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ and COMMAND is one of the available commands:
- globalbalance
- getmasterxpub
- getdescriptors
=== Control ===
- enablefromfuturehtlc
Examples
--------
Expand Down
14 changes: 14 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ case class SendOnionMessageResponsePayload(tlvs: TlvStream[OnionMessagePayloadTl
case class SendOnionMessageResponse(sent: Boolean, failureMessage: Option[String], response: Option[SendOnionMessageResponsePayload])
// @formatter:on

case class EnableFromFutureHtlcResponse(enabled: Boolean, failureMessage: Option[String])

object SignedMessage {
def signedBytes(message: ByteVector): ByteVector32 =
Crypto.hash256(ByteVector("Lightning Signed Message:".getBytes(StandardCharsets.UTF_8)) ++ message)
Expand Down Expand Up @@ -186,6 +188,8 @@ trait Eclair {

def getDescriptors(account: Long): Descriptors

def enableFromFutureHtlc(): Future[EnableFromFutureHtlcResponse]

def stop(): Future[Unit]
}

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

override def enableFromFutureHtlc(): Future[EnableFromFutureHtlcResponse] = {
appKit.nodeParams.willFundRates_opt match {
case Some(willFundRates) if willFundRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) =>
appKit.nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc()
Future.successful(EnableFromFutureHtlcResponse(appKit.nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed, None))
case _ =>
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")))
}
}

override def stop(): Future[Unit] = {
// README: do not make this smarter or more complex !
// eclair can simply and cleanly be stopped by killing its process without fear of losing data, payments, ... and it should remain this way.
Expand Down
16 changes: 14 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ class Peer(val nodeParams: NodeParams,
case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) =>
val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId)
if (peerConnection == d.peerConnection) {
OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
OnTheFlyFunding.validateOpen(nodeParams.onTheFlyFundingConfig, open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
case reject: OnTheFlyFunding.ValidationResult.Reject =>
log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii)
self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
Expand Down Expand Up @@ -387,7 +387,7 @@ class Peer(val nodeParams: NodeParams,
log.info("rejecting open_channel2: feerate too low ({} < {})", msg.feerate, d.currentFeerates.fundingFeerate)
self ! Peer.OutgoingMessage(TxAbort(msg.channelId, FundingFeerateTooLow(msg.channelId, msg.feerate, d.currentFeerates.fundingFeerate).getMessage), d.peerConnection)
case Some(channel) =>
OnTheFlyFunding.validateSplice(msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
OnTheFlyFunding.validateSplice(nodeParams.onTheFlyFundingConfig, msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
case reject: OnTheFlyFunding.ValidationResult.Reject =>
log.warning("rejecting on-the-fly splice: {}", reject.cancel.toAscii)
self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
Expand Down Expand Up @@ -689,13 +689,25 @@ class Peer(val nodeParams: NodeParams,
pendingOnTheFlyFunding -= success.paymentHash
case None => ()
}
// If this is a payment that was initially rejected, it wasn't a malicious node, but rather a temporary issue.
nodeParams.onTheFlyFundingConfig.fromFutureHtlcFulfilled(success.paymentHash)
stay()
case OnTheFlyFunding.PaymentRelayer.RelayFailed(paymentHash, failure) =>
log.warning("on-the-fly HTLC failure for payment_hash={}: {}", paymentHash, failure.toString)
Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.relayFailed(failure)).increment()
// We don't give up yet by relaying the failure upstream: we may have simply been disconnected, or the added
// liquidity may have been consumed by concurrent HTLCs. We'll retry at the next reconnection with that peer
// or after the next splice, and will only give up when the outgoing will_add_htlc timeout.
val fundingStatus = pendingOnTheFlyFunding.get(paymentHash).map(_.status)
failure match {
case OnTheFlyFunding.PaymentRelayer.RemoteFailure(_) if fundingStatus.collect { case s: OnTheFlyFunding.Status.Funded => s.remainingFees }.sum > 0.msat =>
// We are still owed some fees for the funding transaction we published: we need these HTLCs to succeed.
// They received the HTLCs but failed them, which means that they're likely malicious (but not always,
// they may have other pending HTLCs that temporarily prevent relaying the whole HTLC set because of
// channel limits). We disable funding from future HTLCs to limit our exposure to fee siphoning.
nodeParams.onTheFlyFundingConfig.fromFutureHtlcFailed(paymentHash, remoteNodeId)
case _ => ()
}
stay()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ object Monitoring {
val SentPaymentDuration = Kamon.timer("payment.duration.sent", "Outgoing payment duration")
val ReceivedPaymentDuration = Kamon.timer("payment.duration.received", "Incoming payment duration")
val RelayedPaymentDuration = Kamon.timer("payment.duration.relayed", "Duration of pending downstream HTLCs during a relay")
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")

// The goal of this metric is to measure whether retrying MPP payments on failing channels yields useful results.
// Once enough data has been collected, we will update the MultiPartPaymentLifecycle logic accordingly.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, TxId}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.payment.Monitoring.Metrics
import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, ToMilliSatoshiConversion}
Expand All @@ -38,7 +39,38 @@ import scala.concurrent.duration.FiniteDuration

object OnTheFlyFunding {

case class Config(proposalTimeout: FiniteDuration)
case class Config(proposalTimeout: FiniteDuration) {
// When funding a transaction using from_future_htlc, we are taking the risk that the remote node doesn't fulfill
// the corresponding HTLCs. If we detect that our peer fails such HTLCs, we automatically disable from_future_htlc
// to limit our exposure.
// Note that this state is flushed when restarting: node operators should explicitly remove the from_future_htlc
// payment type from their liquidity ads configuration if they want to keep it disabled.
private val suspectFromFutureHtlcRelays = scala.collection.concurrent.TrieMap.empty[ByteVector32, PublicKey]

/** We allow using from_future_htlc if we don't have any pending payment that is abusing it. */
def isFromFutureHtlcAllowed: Boolean = suspectFromFutureHtlcRelays.isEmpty

/** An on-the-fly payment using from_future_htlc was failed by the remote node: they may be malicious. */
def fromFutureHtlcFailed(paymentHash: ByteVector32, remoteNodeId: PublicKey): Unit = {
suspectFromFutureHtlcRelays.addOne(paymentHash, remoteNodeId)
Metrics.SuspiciousFromFutureHtlcRelays.withoutTags().update(suspectFromFutureHtlcRelays.size)
}

/** If a fishy payment is fulfilled, we remove it from the list, which may re-enabled from_future_htlc. */
def fromFutureHtlcFulfilled(paymentHash: ByteVector32): Unit = {
suspectFromFutureHtlcRelays.remove(paymentHash).foreach { _ =>
// We only need to update the metric if an entry was actually removed.
Metrics.SuspiciousFromFutureHtlcRelays.withoutTags().update(suspectFromFutureHtlcRelays.size)
}
}

/** Remove all suspect payments and re-enable from_future_htlc. */
def enableFromFutureHtlc(): Unit = {
val pending = suspectFromFutureHtlcRelays.toList.map(_._1)
pending.foreach(paymentHash => suspectFromFutureHtlcRelays.remove(paymentHash))
Metrics.SuspiciousFromFutureHtlcRelays.withoutTags().update(0)
}
}

// @formatter:off
sealed trait Status
Expand Down Expand Up @@ -114,25 +146,26 @@ object OnTheFlyFunding {
// @formatter:on

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

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

private def validate(channelId: ByteVector32,
private def validate(cfg: Config,
channelId: ByteVector32,
requestFunding: LiquidityAds.RequestFunding,
isChannelCreation: Boolean,
feerate: FeeratePerKw,
Expand All @@ -159,10 +192,12 @@ object OnTheFlyFunding {
}
val cancelAmountTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"requested amount is too low to relay HTLCs: ${requestFunding.requestedAmount} < $totalPaymentAmount")
val cancelFeesTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"htlc amount is too low to pay liquidity fees: $availableAmountForFees < $feesOwed")
val cancelDisabled = CancelOnTheFlyFunding(channelId, paymentHashes, "payments paid with future HTLCs are currently disabled")
requestFunding.paymentDetails match {
case PaymentDetails.FromChannelBalance => ValidationResult.Accept(Set.empty, None)
case _ if requestFunding.requestedAmount.toMilliSatoshi < totalPaymentAmount => ValidationResult.Reject(cancelAmountTooLow, paymentHashes.toSet)
case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt)
case _: PaymentDetails.FromFutureHtlc if !cfg.isFromFutureHtlcAllowed => ValidationResult.Reject(cancelDisabled, paymentHashes.toSet)
case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt)
case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
Expand Down
24 changes: 24 additions & 0 deletions eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec
import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec.localParams
import fr.acinq.eclair.wire.protocol
import fr.acinq.eclair.wire.protocol._
import org.scalatest.Inside.inside
import org.scalatest.{Tag, TestData}
import scodec.bits.ByteVector

Expand Down Expand Up @@ -728,6 +729,29 @@ class PeerSpec extends FixtureSpec {
probe.expectTerminated(peer)
}

test("reject on-the-fly funding requests when from_future_htlc is disabled", Tag(ChannelStateTestsTags.DualFunding)) { f =>
import f._

// We make sure that from_future_htlc is disabled.
nodeParams.onTheFlyFundingConfig.fromFutureHtlcFailed(randomBytes32(), randomKey().publicKey)
assert(!nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed)

// We reject requests using from_future_htlc.
val paymentHash = randomBytes32()
connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional)))
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))
val open = inside(createOpenDualFundedChannelMessage()) { msg => msg.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(requestFunds))) }
peerConnection.send(peer, open)
peerConnection.expectMsg(CancelOnTheFlyFunding(open.temporaryChannelId, paymentHash :: Nil, "payments paid with future HTLCs are currently disabled"))
channel.expectNoMessage(100 millis)

// Once enabled, we accept requests using from_future_htlc.
nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc()
peerConnection.send(peer, open)
channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR]
channel.expectMsg(open)
}

}

object PeerSpec {
Expand Down
Loading

0 comments on commit e09c830

Please sign in to comment.