diff --git a/channel-funding/pom.xml b/channel-funding/pom.xml
index 9366658..ca9cafa 100644
--- a/channel-funding/pom.xml
+++ b/channel-funding/pom.xml
@@ -5,7 +5,7 @@
fr.acinq.eclair
eclair-plugins_2.13
- 0.13.0-SNAPSHOT
+ 0.14.0-SNAPSHOT
channel-funding-plugin
diff --git a/channel-funding/src/main/scala/fr/acinq/eclair/plugins/channelfunding/OpenChannelInterceptor.scala b/channel-funding/src/main/scala/fr/acinq/eclair/plugins/channelfunding/OpenChannelInterceptor.scala
index 84cf411..e7e5296 100644
--- a/channel-funding/src/main/scala/fr/acinq/eclair/plugins/channelfunding/OpenChannelInterceptor.scala
+++ b/channel-funding/src/main/scala/fr/acinq/eclair/plugins/channelfunding/OpenChannelInterceptor.scala
@@ -78,7 +78,7 @@ class OpenChannelInterceptor(config: ChannelFundingPluginConfig, router: ActorRe
}
private def acceptOpenChannel(o: InterceptOpenChannelReceived): Unit = {
- o.replyTo ! AcceptOpenChannel(o.temporaryChannelId, o.defaultParams, config.fundingPolicy.fundingAmountFor(o.openChannelNonInitiator.remoteNodeId).map(amount => LiquidityAds.AddFunding(amount, rates_opt = None)))
+ o.replyTo ! AcceptOpenChannel(o.temporaryChannelId, config.fundingPolicy.fundingAmountFor(o.openChannelNonInitiator.remoteNodeId).map(amount => LiquidityAds.AddFunding(amount, rates_opt = None)))
}
private def rejectOpenChannel(o: InterceptOpenChannelReceived, error: String): Unit = {
diff --git a/channel-funding/src/test/scala/fr/acinq/eclair/plugins/channelfunding/OpenChannelInterceptorSpec.scala b/channel-funding/src/test/scala/fr/acinq/eclair/plugins/channelfunding/OpenChannelInterceptorSpec.scala
index ce9ebea..0438882 100644
--- a/channel-funding/src/test/scala/fr/acinq/eclair/plugins/channelfunding/OpenChannelInterceptorSpec.scala
+++ b/channel-funding/src/test/scala/fr/acinq/eclair/plugins/channelfunding/OpenChannelInterceptorSpec.scala
@@ -19,23 +19,23 @@ package fr.acinq.eclair.plugins.channelfunding
import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe}
import akka.actor.typed.ActorRef
import com.typesafe.config.ConfigFactory
-import fr.acinq.bitcoin.scalacompat.SatoshiLong
-import fr.acinq.eclair.io.OpenChannelInterceptor.{DefaultParams, OpenChannelNonInitiator}
+import fr.acinq.bitcoin.scalacompat.{Crypto, SatoshiLong}
+import fr.acinq.eclair.channel.ChannelTypes
+import fr.acinq.eclair.io.OpenChannelInterceptor.OpenChannelNonInitiator
import fr.acinq.eclair.io.PeerSpec.{createOpenChannelMessage, createOpenDualFundedChannelMessage}
import fr.acinq.eclair.router.Router.{GetNode, PublicNode, UnknownNode}
import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{AcceptOpenChannel, CltvExpiryDelta, Features, InterceptOpenChannelCommand, InterceptOpenChannelReceived, InterceptOpenChannelResponse, MilliSatoshiLong, RejectOpenChannel, TimestampSecondLong, randomBytes64, randomKey}
+import fr.acinq.eclair.{AcceptOpenChannel, Features, InterceptOpenChannelCommand, InterceptOpenChannelReceived, InterceptOpenChannelResponse, RejectOpenChannel, TimestampSecondLong, randomBytes64, randomKey}
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
import scala.concurrent.duration.DurationInt
class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike {
- val remoteNodeId = randomKey().publicKey
- val whitelistedRemoteNodeId = randomKey().publicKey
- val peerAddress = NodeAddress.fromParts("127.0.0.1", 9735).get
- val defaultParams = DefaultParams(100 sat, 100000 msat, 100 msat, CltvExpiryDelta(288), 10)
- val openChannel = OpenChannelNonInitiator(remoteNodeId, Left(createOpenChannelMessage()), Features.empty, Features.empty, TestProbe[Any]().ref, peerAddress)
+ val remoteNodeId: Crypto.PublicKey = randomKey().publicKey
+ val whitelistedRemoteNodeId: Crypto.PublicKey = randomKey().publicKey
+ val peerAddress: NodeAddress = NodeAddress.fromParts("127.0.0.1", 9735).get
+ val openChannel = OpenChannelNonInitiator(remoteNodeId, Left(createOpenChannelMessage(ChannelTypes.AnchorOutputsZeroFeeHtlcTx())), Features.empty, Features.empty, TestProbe[Any]().ref, peerAddress)
val announcement = NodeAnnouncement(randomBytes64(), Features.empty, 1 unixsec, remoteNodeId, Color(100.toByte, 200.toByte, 300.toByte), "node-alias", NodeAddress.fromParts("1.2.3.4", 42000).get :: Nil)
case class FixtureParam(router: TestProbe[Any], peer: TestProbe[InterceptOpenChannelResponse], openChannelInterceptor: ActorRef[InterceptOpenChannelCommand])
@@ -63,21 +63,21 @@ class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory
import f._
// request from public peer
- openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel, defaultParams)
+ openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel)
router.expectMessageType[GetNode].replyTo ! PublicNode(announcement, 2, 10000 sat)
- assert(peer.expectMessageType[AcceptOpenChannel] == AcceptOpenChannel(openChannel.temporaryChannelId, defaultParams, None))
+ assert(peer.expectMessageType[AcceptOpenChannel] == AcceptOpenChannel(openChannel.temporaryChannelId, None))
// request from private peer
- openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel, defaultParams)
+ openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel)
router.expectMessageType[GetNode].replyTo ! UnknownNode(remoteNodeId)
- assert(peer.expectMessageType[AcceptOpenChannel] == AcceptOpenChannel(openChannel.temporaryChannelId, defaultParams, None))
+ assert(peer.expectMessageType[AcceptOpenChannel] == AcceptOpenChannel(openChannel.temporaryChannelId, None))
}
test("approve and contribute to dual-funded channel request") { f =>
import f._
- val openChannelDualFunded = OpenChannelNonInitiator(whitelistedRemoteNodeId, Right(createOpenDualFundedChannelMessage()), Features.empty, Features.empty, TestProbe[Any]().ref, peerAddress)
- openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannelDualFunded, defaultParams)
+ val openChannelDualFunded = OpenChannelNonInitiator(whitelistedRemoteNodeId, Right(createOpenDualFundedChannelMessage(ChannelTypes.AnchorOutputsZeroFeeHtlcTx())), Features.empty, Features.empty, TestProbe[Any]().ref, peerAddress)
+ openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannelDualFunded)
router.expectNoMessage(100 millis) // we don't check requirements for whitelisted nodes
val accept = peer.expectMessageType[AcceptOpenChannel]
assert(accept.temporaryChannelId == openChannelDualFunded.temporaryChannelId)
@@ -88,17 +88,17 @@ class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory
import f._
// from public peer with too low capacity
- openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel, defaultParams)
+ openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel)
router.expectMessageType[GetNode].replyTo ! PublicNode(announcement, 2, 9999 sat)
assert(peer.expectMessageType[RejectOpenChannel].error.toAscii.contains("total capacity"))
// from public peer with too few channels
- openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel, defaultParams)
+ openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel)
router.expectMessageType[GetNode].replyTo ! PublicNode(announcement, 1, 10000 sat)
assert(peer.expectMessageType[RejectOpenChannel].error.toAscii.contains("active channels"))
// from private peer
- openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel, defaultParams)
+ openChannelInterceptor ! InterceptOpenChannelReceived(peer.ref, openChannel)
router.expectMessageType[GetNode].replyTo ! UnknownNode(remoteNodeId)
assert(peer.expectMessageType[RejectOpenChannel].error.toAscii.contains("no public channels"))
}
diff --git a/custom-offer/pom.xml b/custom-offer/pom.xml
index 0e38141..dfda9a2 100644
--- a/custom-offer/pom.xml
+++ b/custom-offer/pom.xml
@@ -5,7 +5,7 @@
fr.acinq.eclair
eclair-plugins_2.13
- 0.13.0-SNAPSHOT
+ 0.14.0-SNAPSHOT
custom-offer
diff --git a/historical-gossip/pom.xml b/historical-gossip/pom.xml
index ffc7528..fd1b418 100644
--- a/historical-gossip/pom.xml
+++ b/historical-gossip/pom.xml
@@ -5,7 +5,7 @@
fr.acinq.eclair
eclair-plugins_2.13
- 0.13.0-SNAPSHOT
+ 0.14.0-SNAPSHOT
historical-gossip-plugin
diff --git a/offline-commands/pom.xml b/offline-commands/pom.xml
index 4119fdc..07ccabe 100644
--- a/offline-commands/pom.xml
+++ b/offline-commands/pom.xml
@@ -5,7 +5,7 @@
fr.acinq.eclair
eclair-plugins_2.13
- 0.13.0-SNAPSHOT
+ 0.14.0-SNAPSHOT
offline-commands-plugin
diff --git a/offline-commands/src/main/scala/fr/acinq/eclair/plugins/offlinecommands/ApiHandlers.scala b/offline-commands/src/main/scala/fr/acinq/eclair/plugins/offlinecommands/ApiHandlers.scala
index 1a542c9..ddb25c7 100644
--- a/offline-commands/src/main/scala/fr/acinq/eclair/plugins/offlinecommands/ApiHandlers.scala
+++ b/offline-commands/src/main/scala/fr/acinq/eclair/plugins/offlinecommands/ApiHandlers.scala
@@ -23,7 +23,7 @@ import akka.util.Timeout
import fr.acinq.bitcoin.scalacompat.Script
import fr.acinq.eclair.api.directives.EclairDirectives
import fr.acinq.eclair.api.serde.FormParamExtractors._
-import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw}
+import fr.acinq.eclair.blockchain.fee.FeeratePerByte
import fr.acinq.eclair.channel.ClosingFeerates
import fr.acinq.eclair.plugins.offlinecommands.OfflineChannelsCloser.CloseChannels
import scodec.bits.ByteVector
@@ -44,9 +44,9 @@ object ApiHandlers {
(channelIds, forceCloseAfterHours_opt, scriptPubKey_opt, preferredFeerate_opt, minFeerate_opt, maxFeerate_opt) =>
val forceCloseAfter_opt = forceCloseAfterHours_opt.map(FiniteDuration(_, TimeUnit.HOURS))
val closingFeerates_opt = preferredFeerate_opt.map(preferredPerByte => {
- val preferredFeerate = FeeratePerKw(preferredPerByte)
- val minFeerate = minFeerate_opt.map(feerate => FeeratePerKw(feerate)).getOrElse(preferredFeerate / 2)
- val maxFeerate = maxFeerate_opt.map(feerate => FeeratePerKw(feerate)).getOrElse(preferredFeerate * 2)
+ val preferredFeerate = preferredPerByte.perKw
+ val minFeerate = minFeerate_opt.map(feerate => feerate.perKw).getOrElse(preferredFeerate / 2)
+ val maxFeerate = maxFeerate_opt.map(feerate => feerate.perKw).getOrElse(preferredFeerate * 2)
ClosingFeerates(preferredFeerate, minFeerate, maxFeerate)
})
if (scriptPubKey_opt.forall(Script.isNativeWitnessScript)) {
diff --git a/pom.xml b/pom.xml
index 02a6f68..a4d2173 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
fr.acinq.eclair
eclair-plugins_2.13
- 0.13.0-SNAPSHOT
+ 0.14.0-SNAPSHOT
pom
@@ -12,6 +12,7 @@
offline-commands
custom-offer
channel-funding
+ utxo-validator
Official eclair plugins
@@ -54,7 +55,7 @@
3.2.12
2.6.20
10.2.7
- 0.13.0-SNAPSHOT
+ 0.14.0-SNAPSHOT
diff --git a/utxo-validator/README.md b/utxo-validator/README.md
new file mode 100644
index 0000000..c488c70
--- /dev/null
+++ b/utxo-validator/README.md
@@ -0,0 +1,23 @@
+# UTXO validator plugin
+
+This plugin provides an example of how to validate channels or splice transactions that contain UTXOs provided by an untrusted remote node.
+This can only be done after the transaction has been created and published, because in some cases only the remote node has access to the fully signed transaction.
+It is then possible to immediately close the channel, before its funding transaction has enough confirmations, to prevent it from being used.
+
+Disclaimer: this plugin is for demonstration purposes only, node operators should fork this plugin and implement whatever policies make sense for their node.
+
+## Build
+
+To build this plugin, run the following command in this directory:
+
+```sh
+mvn package
+```
+
+## Run
+
+To run eclair with this plugin, start eclair with the following command:
+
+```sh
+eclair-node-/bin/eclair-node.sh /utxo-validator-.jar
+```
diff --git a/utxo-validator/pom.xml b/utxo-validator/pom.xml
new file mode 100644
index 0000000..83b1cc6
--- /dev/null
+++ b/utxo-validator/pom.xml
@@ -0,0 +1,84 @@
+
+
+ 4.0.0
+
+
+ fr.acinq.eclair
+ eclair-plugins_2.13
+ 0.14.0-SNAPSHOT
+
+
+ utxo-validator-plugin
+ jar
+ utxo-validator-plugin
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.2.1
+
+
+
+
+ fr.acinq.eclair.plugins.utxovalidator.UtxoValidatorPlugin
+
+
+
+
+
+
+ package
+
+ shade
+
+
+
+
+
+
+
+
+
+ org.scala-lang
+ scala-library
+ ${scala.version}
+ provided
+
+
+ fr.acinq.eclair
+ eclair-core_${scala.version.short}
+ ${project.version}
+ provided
+
+
+ fr.acinq.eclair
+ eclair-node_${scala.version.short}
+ ${project.version}
+ provided
+
+
+
+ com.typesafe.akka
+ akka-testkit_${scala.version.short}
+ ${akka.version}
+ test
+
+
+ com.typesafe.akka
+ akka-actor-testkit-typed_${scala.version.short}
+ ${akka.version}
+ test
+
+
+ fr.acinq.eclair
+ eclair-core_${scala.version.short}
+ ${project.version}
+ tests
+ test-jar
+ test
+
+
+
+
diff --git a/utxo-validator/src/main/resources/reference.conf b/utxo-validator/src/main/resources/reference.conf
new file mode 100644
index 0000000..21d7c20
--- /dev/null
+++ b/utxo-validator/src/main/resources/reference.conf
@@ -0,0 +1,8 @@
+utxo-validator {
+ // A list of blacklisted utxos: if peers use one of those to open a channel with us, we will immediately close it.
+ blacklisted-utxos = [
+ ""
+ ]
+ // When the funding transaction cannot be found in the mempool or the blockchain, we will try again after this delay.
+ fetch-tx-frequency = 10 minutes
+}
\ No newline at end of file
diff --git a/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidator.scala b/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidator.scala
new file mode 100644
index 0000000..018c801
--- /dev/null
+++ b/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidator.scala
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2026 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.plugins.utxovalidator
+
+import akka.actor.typed.eventstream.EventStream
+import akka.actor.typed.scaladsl.adapter.TypedActorRefOps
+import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler}
+import akka.actor.typed.{ActorRef, Behavior}
+import fr.acinq.bitcoin.scalacompat.{Transaction, TxId}
+import fr.acinq.eclair.blockchain.OnChainWallet
+import fr.acinq.eclair.channel._
+
+import scala.concurrent.ExecutionContext
+import scala.util.{Failure, Success}
+
+/**
+ * Intercept channels that are being opened (or splice transactions being created) and force-close them immediately if
+ * they use blacklisted UTXOs. This example plugin demonstrates how channels can be automatically closed before they
+ * become usable: users should plug their own validation rules based on whatever blacklist they use.
+ */
+object UtxoValidator {
+ // @formatter:off
+ sealed trait Command
+ case class WrappedChannelFundingCreated(e: ChannelFundingCreated) extends Command
+ private case class WrappedChannelAborted(e: ChannelAborted) extends Command
+ private case class WrappedChannelClosed(e: ChannelClosed) extends Command
+ private case class FetchTransaction(txId: TxId) extends Command
+ private case class TransactionNotFound(txId: TxId) extends Command
+ private case class ValidateTransaction(tx: Transaction) extends Command
+ // @formatter:on
+
+ def apply(config: UtxoValidatorPluginConfig, wallet: OnChainWallet, register: ActorRef[Any]): Behavior[Command] = {
+ Behaviors.setup { context =>
+ Behaviors.withTimers { timers =>
+ context.system.eventStream ! EventStream.Subscribe(context.messageAdapter(WrappedChannelFundingCreated))
+ context.system.eventStream ! EventStream.Subscribe(context.messageAdapter(WrappedChannelAborted))
+ context.system.eventStream ! EventStream.Subscribe(context.messageAdapter(WrappedChannelClosed))
+ new UtxoValidator(config, wallet, register, timers, context).listen(Map.empty)
+ }
+ }
+ }
+
+}
+
+private class UtxoValidator(config: UtxoValidatorPluginConfig,
+ wallet: OnChainWallet,
+ register: ActorRef[Any],
+ timers: TimerScheduler[UtxoValidator.Command],
+ context: ActorContext[UtxoValidator.Command]) {
+
+ import UtxoValidator._
+
+ implicit val ec: ExecutionContext = context.system.executionContext
+ private val log = context.log
+
+ private def listen(pending: Map[TxId, ChannelFundingCreated]): Behavior[Command] = {
+ Behaviors.receiveMessage {
+ case WrappedChannelFundingCreated(e) =>
+ if (pending.contains(e.fundingTxId)) {
+ Behaviors.same
+ } else {
+ e.fundingTx match {
+ case Right(fundingTx) => context.self ! ValidateTransaction(fundingTx)
+ case Left(fundingTxId) => context.self ! FetchTransaction(fundingTxId)
+ }
+ listen(pending + (e.fundingTxId -> e))
+ }
+ case WrappedChannelAborted(e) =>
+ val pending1 = pending.filter { case (_, p) => p.channelId != e.channelId }
+ listen(pending1)
+ case WrappedChannelClosed(e) =>
+ val pending1 = pending.removedAll(e.commitments.all.map(_.fundingTxId))
+ listen(pending1)
+ case FetchTransaction(txId) =>
+ if (pending.contains(txId)) {
+ context.pipeToSelf(wallet.getTransaction(txId)) {
+ case Failure(_) => TransactionNotFound(txId)
+ case Success(fundingTx) => ValidateTransaction(fundingTx)
+ }
+ }
+ Behaviors.same
+ case TransactionNotFound(txId) =>
+ log.debug("cannot find txId={}, retrying...", txId)
+ timers.startSingleTimer(FetchTransaction(txId), delay = config.fetchTxFrequency)
+ Behaviors.same
+ case ValidateTransaction(tx) =>
+ pending.get(tx.txid) match {
+ case Some(e) if tx.txIn.exists(txIn => config.blacklistedUtxos.contains(txIn.outPoint)) =>
+ log.warn("fundingTxId={} for channelId={} contains blacklisted utxos: closing channel", tx.txid, e.channelId)
+ register ! Register.Forward(context.system.ignoreRef, e.channelId, CMD_FORCECLOSE(context.system.ignoreRef.toClassic))
+ case _ => ()
+ }
+ listen(pending - tx.txid)
+ }
+ }
+
+}
diff --git a/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPlugin.scala b/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPlugin.scala
new file mode 100644
index 0000000..c93e99c
--- /dev/null
+++ b/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPlugin.scala
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2026 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.plugins.utxovalidator
+
+import akka.actor.ActorSystem
+import akka.actor.typed.SupervisorStrategy
+import akka.actor.typed.scaladsl.Behaviors
+import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps}
+import com.typesafe.config.ConfigFactory
+import fr.acinq.bitcoin.scalacompat.{OutPoint, TxOut}
+import fr.acinq.eclair.blockchain.OnChainWallet
+import fr.acinq.eclair.{Kit, NodeParams, Plugin, PluginParams, Setup, ValidateInteractiveTxPlugin}
+import grizzled.slf4j.Logging
+
+import java.io.File
+import scala.concurrent.Future
+
+/**
+ * Intercept channels that are being opened (or splice transactions being created) and force-close them immediately if
+ * they use blacklisted UTXOs.
+ */
+class UtxoValidatorPlugin extends Plugin with Logging {
+ private var pluginKit: UtxoValidatorKit = _
+ private var config: UtxoValidatorPluginConfig = _
+
+ override def params: PluginParams = new ValidateInteractiveTxPlugin {
+ override def name: String = "UtxoValidatorPlugin"
+
+ override def validateSharedTx(remoteInputs: Map[OutPoint, TxOut], remoteOutputs: Seq[TxOut]): Future[Unit] = {
+ if (remoteInputs.keys.exists(outpoint => config.blacklistedUtxos.contains(outpoint))) {
+ Future.failed(new IllegalArgumentException("pwned"))
+ } else {
+ Future.successful(())
+ }
+ }
+ }
+
+ override def onSetup(setup: Setup): Unit = {
+ config = loadConfiguration(setup.datadir)
+ }
+
+ override def onKit(kit: Kit): Unit = {
+ val utxoValidator = kit.system.spawnAnonymous(Behaviors.supervise(UtxoValidator(config, kit.wallet, kit.register.toTyped)).onFailure(SupervisorStrategy.restart))
+ pluginKit = UtxoValidatorKit(kit.nodeParams, kit.system, kit.wallet)
+ }
+
+ /**
+ * Order of precedence for the configuration parameters:
+ * 1) Java environment variables (-D...)
+ * 2) Configuration file channel_funding.conf
+ * 3) Default values in reference.conf
+ */
+ private def loadConfiguration(datadir: File): UtxoValidatorPluginConfig = {
+ val config = ConfigFactory.systemProperties()
+ .withFallback(ConfigFactory.parseFile(new File(datadir, "utxo_validator.conf")))
+ .withFallback(ConfigFactory.load())
+ .resolve()
+ UtxoValidatorPluginConfig(config)
+ }
+}
+
+case class UtxoValidatorKit(nodeParams: NodeParams, system: ActorSystem, wallet: OnChainWallet)
diff --git a/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPluginConfig.scala b/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPluginConfig.scala
new file mode 100644
index 0000000..b5980b9
--- /dev/null
+++ b/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPluginConfig.scala
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2026 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.plugins.utxovalidator
+
+import com.typesafe.config.Config
+import fr.acinq.bitcoin.scalacompat.OutPoint
+
+import java.util.concurrent.TimeUnit
+import scala.concurrent.duration.FiniteDuration
+import scala.jdk.CollectionConverters.CollectionHasAsScala
+
+case class UtxoValidatorPluginConfig(blacklistedUtxos: Set[OutPoint], fetchTxFrequency: FiniteDuration)
+
+object UtxoValidatorPluginConfig {
+ def apply(config: Config): UtxoValidatorPluginConfig = {
+ UtxoValidatorPluginConfig(
+ blacklistedUtxos = config.getStringList("utxo-validator.blacklisted-utxos").asScala.map(OutPoint.read).toSet,
+ fetchTxFrequency = FiniteDuration(config.getDuration("utxo-validator.fetch-tx-frequency").getSeconds, TimeUnit.SECONDS),
+ )
+ }
+}
diff --git a/utxo-validator/src/test/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorSpec.scala b/utxo-validator/src/test/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorSpec.scala
new file mode 100644
index 0000000..8bba340
--- /dev/null
+++ b/utxo-validator/src/test/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorSpec.scala
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2026 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.plugins.utxovalidator
+
+import akka.actor.testkit.typed.scaladsl.{ScalaTestWithActorTestKit, TestProbe}
+import com.typesafe.config.ConfigFactory
+import fr.acinq.bitcoin.scalacompat.{OutPoint, SatoshiLong, Script, Transaction, TxId, TxIn, TxOut}
+import fr.acinq.eclair.TestUtils.randomTxId
+import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet
+import fr.acinq.eclair.channel.{CMD_FORCECLOSE, ChannelFundingCreated, Register}
+import fr.acinq.eclair.plugins.utxovalidator.UtxoValidator.WrappedChannelFundingCreated
+import fr.acinq.eclair.{randomBytes32, randomKey}
+import org.scalatest.funsuite.AnyFunSuiteLike
+
+import scala.concurrent.duration.DurationInt
+import scala.concurrent.{ExecutionContext, Future}
+
+class UtxoValidatorSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with AnyFunSuiteLike {
+
+ private val config = UtxoValidatorPluginConfig(
+ blacklistedUtxos = Set(
+ OutPoint(TxId.fromValidHex("2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a"), 2),
+ OutPoint(TxId.fromValidHex("0303030303030303030303030303030303030303030303030303030303030303"), 0)
+ ),
+ fetchTxFrequency = 100 millis,
+ )
+
+ class DummyWallet extends SingleKeyOnChainWallet {
+ private var transactions = Seq.empty[Transaction]
+
+ def addTransaction(tx: Transaction): Unit = {
+ transactions = transactions :+ tx
+ }
+
+ override def getTransaction(txId: TxId)(implicit ec: ExecutionContext): Future[Transaction] = {
+ transactions.find(_.txid == txId) match {
+ case Some(tx) => Future.successful(tx)
+ case None => Future.failed(new IllegalArgumentException("transaction cannot be found"))
+ }
+ }
+ }
+
+ test("force-close channels that contain blacklisted utxos") {
+ val wallet = new DummyWallet()
+ val register = TestProbe[Any]()
+ val validator = testKit.spawn(UtxoValidator(config, wallet, register.ref))
+
+ val blacklistedTx = Transaction(
+ version = 2,
+ txIn = Seq(TxIn(OutPoint(randomTxId(), 1), Nil, 0), TxIn(config.blacklistedUtxos.head, Nil, 0), TxIn(OutPoint(randomTxId(), 5), Nil, 0)),
+ txOut = Seq(TxOut(100_000 sat, Script.pay2wpkh(randomKey().publicKey))),
+ lockTime = 0
+ )
+
+ // If we have the transaction, we can immediately validate it without querying bitcoind.
+ val channelId = randomBytes32()
+ validator ! WrappedChannelFundingCreated(ChannelFundingCreated(null, channelId, randomKey().publicKey, Right(blacklistedTx), 0, null))
+ assert(register.expectMessageType[Register.Forward[CMD_FORCECLOSE]].channelId == channelId)
+
+ // Otherwise, we use bitcoind to fetch the corresponding transaction: note we don't provide the transaction
+ // immediately, which shows that we will retry until we're able to fetch it.
+ validator ! WrappedChannelFundingCreated(ChannelFundingCreated(null, channelId, randomKey().publicKey, Left(blacklistedTx.txid), 0, null))
+ register.expectNoMessage(500 millis)
+ wallet.addTransaction(blacklistedTx)
+ assert(register.expectMessageType[Register.Forward[CMD_FORCECLOSE]].channelId == channelId)
+ }
+
+ test("skip channels that don't contain blacklisted utxos") {
+ val wallet = new DummyWallet()
+ val register = TestProbe[Any]()
+ val validator = testKit.spawn(UtxoValidator(config, wallet, register.ref))
+
+ val validTx = Transaction(
+ version = 2,
+ txIn = Seq(TxIn(OutPoint(randomTxId(), 1), Nil, 0), TxIn(OutPoint(randomTxId(), 5), Nil, 0)),
+ txOut = Seq(TxOut(100_000 sat, Script.pay2wpkh(randomKey().publicKey))),
+ lockTime = 0
+ )
+ wallet.addTransaction(validTx)
+
+ validator ! WrappedChannelFundingCreated(ChannelFundingCreated(null, randomBytes32(), randomKey().publicKey, Right(validTx), 0, null))
+ register.expectNoMessage(100 millis)
+ validator ! WrappedChannelFundingCreated(ChannelFundingCreated(null, randomBytes32(), randomKey().publicKey, Left(validTx.txid), 0, null))
+ register.expectNoMessage(100 millis)
+ }
+
+}
\ No newline at end of file