From 8ebb24f6e841a6c09d2ab6a23c3205e1d0b16794 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 26 Feb 2026 15:09:18 +0100 Subject: [PATCH 1/3] Update build to match eclair v0.13.1 We update the project to match eclair v0.13.1: - we update our dependency on eclair-core - we update dependencies on types that have changed - we fix plugins accordingly --- channel-funding/pom.xml | 2 +- .../OpenChannelInterceptor.scala | 2 +- .../OpenChannelInterceptorSpec.scala | 34 +++++++++---------- custom-offer/pom.xml | 2 +- historical-gossip/pom.xml | 2 +- offline-commands/pom.xml | 2 +- .../plugins/offlinecommands/ApiHandlers.scala | 8 ++--- pom.xml | 4 +-- 8 files changed, 28 insertions(+), 28 deletions(-) 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..086fdd0 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 @@ -54,7 +54,7 @@ 3.2.12 2.6.20 10.2.7 - 0.13.0-SNAPSHOT + 0.14.0-SNAPSHOT From 4fdfae68bd939f914a40930b1ec2cef183cdba6a Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 26 Feb 2026 17:37:22 +0100 Subject: [PATCH 2/3] Add utxo validator plugin Add a plugin showcasing how channel transactions can be audited to automatically force-close channels that match any operator heuristic. This can be used to reject utxos based on a blacklist, or transactions that send to specific addresses. --- pom.xml | 1 + utxo-validator/README.md | 23 ++++ utxo-validator/pom.xml | 84 +++++++++++++ .../src/main/resources/reference.conf | 8 ++ .../plugins/utxovalidator/UtxoValidator.scala | 111 ++++++++++++++++++ .../utxovalidator/UtxoValidatorPlugin.scala | 66 +++++++++++ .../UtxoValidatorPluginConfig.scala | 35 ++++++ .../utxovalidator/UtxoValidatorSpec.scala | 101 ++++++++++++++++ 8 files changed, 429 insertions(+) create mode 100644 utxo-validator/README.md create mode 100644 utxo-validator/pom.xml create mode 100644 utxo-validator/src/main/resources/reference.conf create mode 100644 utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidator.scala create mode 100644 utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPlugin.scala create mode 100644 utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPluginConfig.scala create mode 100644 utxo-validator/src/test/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorSpec.scala diff --git a/pom.xml b/pom.xml index 086fdd0..a4d2173 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ offline-commands custom-offer channel-funding + utxo-validator Official eclair plugins 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..87e222f --- /dev/null +++ b/utxo-validator/src/main/scala/fr/acinq/eclair/plugins/utxovalidator/UtxoValidatorPlugin.scala @@ -0,0 +1,66 @@ +/* + * 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.eclair.blockchain.OnChainWallet +import fr.acinq.eclair.{Kit, NodeParams, Plugin, PluginParams, Setup} +import grizzled.slf4j.Logging + +import java.io.File + +/** + * 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 PluginParams { + override def name: String = "UtxoValidatorPlugin" + } + + 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 From a233ea003fbf98bc665e616efa658e18b705e653 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 27 Feb 2026 16:41:10 +0100 Subject: [PATCH 3/3] Implement the `ValidateInteractiveTxPlugin` trait And reject blacklisted remote inputs. --- .../utxovalidator/UtxoValidatorPlugin.scala | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 index 87e222f..c93e99c 100644 --- 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 @@ -21,11 +21,13 @@ 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} +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 @@ -35,8 +37,16 @@ class UtxoValidatorPlugin extends Plugin with Logging { private var pluginKit: UtxoValidatorKit = _ private var config: UtxoValidatorPluginConfig = _ - override def params: PluginParams = new PluginParams { + 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 = {