Add threshold for disabling `from_future_htlc` (#3293)
What changed, and why it matters
This commit adds a safety limit to a Lightning node feature called 'from_future_htlc,' which lets peers pay channel funding fees using HTLCs that haven't arrived yet. The change makes the node track how many distinct peers look suspicious (e.g., accept funding but then reject the future HTLCs), and once a threshold is reached, it stops allowing that funding method. It also lets operators keep specific nodes blacklisted when re-enabling the feature. This is a defensive hardening patch, not a fix for an active exploit in the code itself.
Review the default threshold (10 suspicious peers) against expected traffic and risk appetite; ensure operators understand the new `enablefromfuturehtlc` parameter and that monitoring/alerting is in place for `SuspiciousFromFutureHtlcRelays` metrics. Consider whether the in-memory state should persist across restarts, since the commit notes it is currently flushed on restart.
Security signals we found
Hardening: adds rate-limiting/threshold behavior for suspicious peer activity
Behavior change: from global disable to per-peer suspicion tracking
New configuration parameter: on-the-fly-funding.max-suspicious-peers
API change: enablefromfuturehtlc now accepts optional suspiciousNodeIds list
Defensive coding: limits exposure to peers that accept funding but fail future HTLCs
Evidence from the diff
The patch introduces a configurable max-suspicious-peers threshold in OnTheFlyFunding.Config. Previously, a single failed future-HTLC payment from any peer disabled from_future_htlc globally until cleared. Now the node maintains a per-peer suspicion set and only disables the feature globally when the number of distinct suspicious peers reaches the threshold. It also rejects future funding from any peer already in the suspicion set, and the enablefromfuturehtlc API accepts an optional suspiciousNodeIds list to retain while re-enabling. The change touches validation paths for both channel opens and splices, plus tests and config defaults.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scalaeclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scalaeclair-core/src/main/scala/fr/acinq/eclair/Eclair.scalaeclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scalaeclair-core/src/main/resources/reference.confeclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scalaInspect captured patch +76 / −44
diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf
index 2eda41c..0c51fc9 100644
--- a/eclair-core/src/main/resources/reference.conf
+++ b/eclair-core/src/main/resources/reference.conf
@@ -399,6 +399,10 @@ eclair {
// If our peer doesn't respond to our funding proposal, we must fail the corresponding upstream HTLCs.
// Since MPP may be used, we should use a timeout greater than the MPP timeout.
proposal-timeout = 90 seconds
+ // If too many peers act suspiciously, for example by accepting an on-the-fly funding where fees are paid from
+ // HTLCs that will be relayed *after* funding but then reject the HTLCs when receiving them, we automatically
+ // disable funding from future HTLCs.
+ max-suspicious-peers = 10
}
peer-connection {
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
index 02f1881..81be9b5 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
@@ -206,7 +206,7 @@ trait Eclair {
def getDescriptors(account: Long): Descriptors
- def enableFromFutureHtlc(): Future[EnableFromFutureHtlcResponse]
+ def enableFromFutureHtlc(suspiciousNodes: Set[PublicKey]): Future[EnableFromFutureHtlcResponse]
def stop(): Future[Unit]
@@ -845,11 +845,11 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan
case _ => throw new RuntimeException("on-chain seed is not configured")
}
- override def enableFromFutureHtlc(): Future[EnableFromFutureHtlcResponse] = {
+ override def enableFromFutureHtlc(suspiciousNodes: Set[PublicKey]): Future[EnableFromFutureHtlcResponse] = {
appKit.nodeParams.liquidityAdsConfig.rates_opt match {
case Some(willFundRates) if willFundRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) =>
- appKit.nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc()
- Future.successful(EnableFromFutureHtlcResponse(appKit.nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed, None))
+ appKit.nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc(suspiciousNodes)
+ Future.successful(EnableFromFutureHtlcResponse(enabled = true, 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")))
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
index f7b0e3d..b28bee5 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
@@ -713,6 +713,7 @@ object NodeParams extends Logging {
),
onTheFlyFundingConfig = OnTheFlyFunding.Config(
proposalTimeout = FiniteDuration(config.getDuration("on-the-fly-funding.proposal-timeout").getSeconds, TimeUnit.SECONDS),
+ maxSuspiciousPeers = config.getInt("on-the-fly-funding.max-suspicious-peers"),
),
peerStorageConfig = PeerStorageConfig(
writeDelay = FiniteDuration(config.getDuration("peer-storage.write-delay").getSeconds, TimeUnit.SECONDS),
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
index a9ae568..63b373f 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
@@ -270,7 +270,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(nodeParams.onTheFlyFundingConfig, open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
+ OnTheFlyFunding.validateOpen(nodeParams.onTheFlyFundingConfig, remoteNodeId, 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)
@@ -469,7 +469,7 @@ class Peer(val nodeParams: NodeParams,
self ! Peer.OutgoingMessage(TxAbort(msg.channelId, FundingFeerateTooLow(msg.channelId, msg.feerate, d.currentFeerates.fundingFeerate).getMessage), d.peerConnection)
case Some(channel) =>
// We don't have access to the remote htlc_minimum here, so we hard-code the value Phoenix uses (1000 msat).
- OnTheFlyFunding.validateSplice(nodeParams.onTheFlyFundingConfig, msg, nodeParams.channelConf.htlcMinimum.max(1000 msat), pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
+ OnTheFlyFunding.validateSplice(nodeParams.onTheFlyFundingConfig, remoteNodeId, msg, nodeParams.channelConf.htlcMinimum.max(1000 msat), 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)
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala
index a196fcf..4267cc2 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala
@@ -40,16 +40,21 @@ import scala.concurrent.duration.FiniteDuration
object OnTheFlyFunding {
- case class Config(proposalTimeout: FiniteDuration) {
+ case class Config(proposalTimeout: FiniteDuration, maxSuspiciousPeers: Int) {
// 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
+ // the corresponding HTLCs. If we detect that our peers fail 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
+ /** We allow using from_future_htlc if we don't have too many pending payments that are abusing it. */
+ def isFromFutureHtlcAllowed(remoteNodeId: PublicKey): Boolean = {
+ val suspiciousPeers = suspectFromFutureHtlcRelays.values.toSet
+ val alreadySuspicious = suspiciousPeers.contains(remoteNodeId)
+ val maxSuspiciousReached = suspiciousPeers.size >= maxSuspiciousPeers
+ !alreadySuspicious && !maxSuspiciousReached
+ }
/** 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 = {
@@ -65,11 +70,11 @@ object OnTheFlyFunding {
}
}
- /** Remove all suspect payments and re-enable from_future_htlc. */
- def enableFromFutureHtlc(): Unit = {
- val pending = suspectFromFutureHtlcRelays.toList.map(_._1)
+ /** Remove suspect payments (except those from the provided list) which potentially re-enables from_future_htlc. */
+ def enableFromFutureHtlc(suspiciousNodes: Set[PublicKey]): Unit = {
+ val pending = suspectFromFutureHtlcRelays.collect { case (paymentHash, remoteNodeId) if !suspiciousNodes.contains(remoteNodeId) => paymentHash }
pending.foreach(paymentHash => suspectFromFutureHtlcRelays.remove(paymentHash))
- Metrics.SuspiciousFromFutureHtlcRelays.withoutTags().update(0)
+ Metrics.SuspiciousFromFutureHtlcRelays.withoutTags().update(suspectFromFutureHtlcRelays.size)
}
}
@@ -164,26 +169,27 @@ object OnTheFlyFunding {
// @formatter:on
/** Validate an incoming channel that may use on-the-fly funding. */
- def validateOpen(cfg: Config, open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
+ def validateOpen(cfg: Config, remoteNodeId: PublicKey, 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(cfg, open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding, feeCredit)
+ case Some(requestFunding) => validate(cfg, open.temporaryChannelId, remoteNodeId, 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(cfg: Config, splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
+ def validateSplice(cfg: Config, remoteNodeId: PublicKey, splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
splice.requestFunding_opt match {
- case Some(requestFunding) => validate(cfg, splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit)
+ case Some(requestFunding) => validate(cfg, splice.channelId, remoteNodeId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit)
case None => ValidationResult.Accept(Set.empty, None)
}
}
private def validate(cfg: Config,
channelId: ByteVector32,
+ remoteNodeId: PublicKey,
requestFunding: LiquidityAds.RequestFunding,
isChannelCreation: Boolean,
feerate: FeeratePerKw,
@@ -215,7 +221,7 @@ object OnTheFlyFunding {
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 !cfg.isFromFutureHtlcAllowed(remoteNodeId) => 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)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
index 20f87d5..f3a6b69 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
@@ -262,7 +262,7 @@ object TestConstants {
revokedHtlcInfoCleanerConfig = RevokedHtlcInfoCleaner.Config(10, 100 millis),
liquidityAdsConfig = LiquidityAds.Config(Some(defaultLiquidityRates), lockUtxos = true),
peerWakeUpConfig = PeerReadyNotifier.WakeUpConfig(enabled = false, timeout = 30 seconds),
- onTheFlyFundingConfig = OnTheFlyFunding.Config(proposalTimeout = 90 seconds),
+ onTheFlyFundingConfig = OnTheFlyFunding.Config(proposalTimeout = 90 seconds, maxSuspiciousPeers = 1),
peerStorageConfig = PeerStorageConfig(writeDelay = 5 seconds, removalDelay = 10 seconds, cleanUpFrequency = 1 hour),
peerScoringConfig = PeerScorer.Config(
enabled = true,
@@ -482,7 +482,7 @@ object TestConstants {
revokedHtlcInfoCleanerConfig = RevokedHtlcInfoCleaner.Config(10, 100 millis),
liquidityAdsConfig = LiquidityAds.Config(Some(defaultLiquidityRates), lockUtxos = true),
peerWakeUpConfig = PeerReadyNotifier.WakeUpConfig(enabled = false, timeout = 30 seconds),
- onTheFlyFundingConfig = OnTheFlyFunding.Config(proposalTimeout = 90 seconds),
+ onTheFlyFundingConfig = OnTheFlyFunding.Config(proposalTimeout = 90 seconds, maxSuspiciousPeers = 1),
peerStorageConfig = PeerStorageConfig(writeDelay = 5 seconds, removalDelay = 10 seconds, cleanUpFrequency = 1 hour),
peerScoringConfig = PeerScorer.Config(
enabled = true,
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
index fc74b5f..ff3d2f2 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
@@ -749,8 +749,9 @@ class PeerSpec extends FixtureSpec {
import f._
// We make sure that from_future_htlc is disabled.
- nodeParams.onTheFlyFundingConfig.fromFutureHtlcFailed(randomBytes32(), randomKey().publicKey)
- assert(!nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed)
+ nodeParams.onTheFlyFundingConfig.fromFutureHtlcFailed(randomBytes32(), remoteNodeId)
+ assert(!nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed(remoteNodeId))
+ assert(!nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed(randomKey().publicKey))
// We reject requests using from_future_htlc.
val paymentHash = randomBytes32()
@@ -762,7 +763,8 @@ class PeerSpec extends FixtureSpec {
channel.expectNoMessage(100 millis)
// Once enabled, we accept requests using from_future_htlc.
- nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc()
+ nodeParams.onTheFlyFundingConfig.enableFromFutureHtlc(Set.empty)
+ assert(nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed(remoteNodeId))
peerConnection.send(peer, open)
channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR]
channel.expectMsg(open)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala
index 36d41ee..b70f6f5 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala
@@ -1066,15 +1066,15 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, purchase.txId, fundingTxIndex = 0)
channel.expectMsgType[CMD_GET_CHANNEL_INFO]
- // Our peer rejects the HTLC, so we automatically disable from_future_htlc.
- assert(nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed)
+ // Our peer rejects the HTLC, so we automatically disable from_future_htlc for their future payments.
+ assert(nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed(remoteNodeId))
val failure = HtlcResult.RemoteFail(UpdateFailHtlc(purchase.channelId, 2, ByteVector.empty))
peer ! OnTheFlyFunding.PaymentRelayer.RelayFailed(paymentHash, OnTheFlyFunding.PaymentRelayer.RemoteFailure(failure))
- awaitCond(!nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed)
+ awaitCond(!nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed(remoteNodeId))
// When we retry relaying the HTLC, our peer fulfills it: we re-enable from_future_htlc.
peer ! OnTheFlyFunding.PaymentRelayer.RelaySuccess(purchase.channelId, paymentHash, preimage, fees.total.toMilliSatoshi)
- awaitCond(nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed)
+ awaitCond(nodeParams.onTheFlyFundingConfig.isFromFutureHtlcAllowed(remoteNodeId))
}
test("don't relay payments if added to fee credit while signing", Tag(withFeeCredit)) { f =>
@@ -1221,31 +1221,48 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
}
test("disable from_future_htlc when detecting abuse") { () =>
- val cfg = OnTheFlyFunding.Config(90 seconds)
- assert(cfg.isFromFutureHtlcAllowed)
- val remoteNodeId = randomKey().publicKey
+ val cfg = OnTheFlyFunding.Config(90 seconds, maxSuspiciousPeers = 2)
+ val remoteNodeId1 = randomKey().publicKey
+ val remoteNodeId2 = randomKey().publicKey
+ val remoteNodeId3 = randomKey().publicKey
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId1))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId2))
// We detect two payments that seem malicious.
val paymentHash1 = randomBytes32()
val paymentHash2 = randomBytes32()
- cfg.fromFutureHtlcFailed(paymentHash1, remoteNodeId)
- assert(!cfg.isFromFutureHtlcAllowed)
- cfg.fromFutureHtlcFailed(paymentHash1, remoteNodeId) // noop
- cfg.fromFutureHtlcFailed(paymentHash2, remoteNodeId)
- assert(!cfg.isFromFutureHtlcAllowed)
+ cfg.fromFutureHtlcFailed(paymentHash1, remoteNodeId1)
+ assert(!cfg.isFromFutureHtlcAllowed(remoteNodeId1))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId2))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId3))
+ cfg.fromFutureHtlcFailed(paymentHash1, remoteNodeId1) // noop
+ cfg.fromFutureHtlcFailed(paymentHash2, remoteNodeId2)
+ assert(!cfg.isFromFutureHtlcAllowed(remoteNodeId1))
+ assert(!cfg.isFromFutureHtlcAllowed(remoteNodeId2))
+ assert(!cfg.isFromFutureHtlcAllowed(remoteNodeId3))
// The first one wasn't malicious after all.
cfg.fromFutureHtlcFulfilled(paymentHash1)
- assert(!cfg.isFromFutureHtlcAllowed)
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId1))
+ assert(!cfg.isFromFutureHtlcAllowed(remoteNodeId2))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId3))
// The second one wasn't malicious either: we reactivate from_future_htlc.
cfg.fromFutureHtlcFulfilled(paymentHash2)
- assert(cfg.isFromFutureHtlcAllowed)
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId1))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId2))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId3))
// We detect a bunch of potentially malicious payments but manually reactivate from_future_htlc.
- cfg.fromFutureHtlcFailed(randomBytes32(), remoteNodeId)
- cfg.fromFutureHtlcFailed(randomBytes32(), remoteNodeId)
- assert(!cfg.isFromFutureHtlcAllowed)
- cfg.enableFromFutureHtlc()
- assert(cfg.isFromFutureHtlcAllowed)
+ cfg.fromFutureHtlcFailed(randomBytes32(), remoteNodeId1)
+ cfg.fromFutureHtlcFailed(randomBytes32(), remoteNodeId2)
+ assert(!cfg.isFromFutureHtlcAllowed(remoteNodeId3))
+ cfg.enableFromFutureHtlc(Set(remoteNodeId1))
+ assert(!cfg.isFromFutureHtlcAllowed(remoteNodeId1))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId2))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId3))
+ cfg.enableFromFutureHtlc(Set.empty)
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId1))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId2))
+ assert(cfg.isFromFutureHtlcAllowed(remoteNodeId3))
}
}
diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scala
index fe710ee..78f4baa 100644
--- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scala
+++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Control.scala
@@ -38,7 +38,9 @@ trait Control {
import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization}
val enableFromFutureHtlc: Route = postRequest("enablefromfuturehtlc") { implicit t =>
- complete(eclairApi.enableFromFutureHtlc())
+ formFields("suspiciousNodeIds".as[List[PublicKey]](pubkeyListUnmarshaller) ?) { suspiciousNodes =>
+ complete(eclairApi.enableFromFutureHtlc(suspiciousNodes.map(_.toSet).getOrElse(Set.empty)))
+ }
}
val resetBalance: Route = postRequest("resetbalance") { implicit t =>
Why this scored 48/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.