Plugin validation of interactive transactions (#3258)
What changed, and why it matters
This commit adds a new optional plugin hook that lets Eclair node operators write custom checks for Bitcoin transactions used in Lightning channel funding or splicing. It is a defensive feature, not a vulnerability fix. The same change also improves error messages when a transaction is rejected and slightly refactors how input confirmation checks are run. There is no evidence in the commit of an active security bug being patched.
No immediate security action is required. Operators interested in compliance or risk controls can implement ValidateInteractiveTxPlugin. Review any custom plugins carefully because a buggy or slow plugin can cause funding transactions to be rejected or delay signing.
Security signals we found
New defensive plugin hook for custom transaction validation
Improved error detail for rejected interactive transactions
Sequential Future composition for confirmation and plugin checks
No vulnerability disclosure or CVE referenced in commit or materials
Evidence from the diff
The commit introduces ValidateInteractiveTxPlugin, a plugin trait whose validateSharedTx method is invoked during interactive transaction construction before the node signs. Plugins can reject remote inputs/outputs by returning a failed Future. The InteractiveTxBuilder now runs confirmation checks and plugin validations sequentially via Future.foldLeft, and WalletFailure handling was generalized to forward ChannelExceptions or wrap other failures in InvalidCompleteInteractiveTx(channelId, reason). Several existing call sites of InvalidCompleteInteractiveTx were updated to include descriptive reasons, and tests were added for plugin accept/reject behavior.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scaladocs/release-notes/eclair-vnext.mdeclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scalaInspect captured patch +179 / −38
diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md
index 4aed4af..453f5e9 100644
--- a/docs/release-notes/eclair-vnext.md
+++ b/docs/release-notes/eclair-vnext.md
@@ -36,6 +36,35 @@ However, when using zero-conf, this event may be emitted before the `channel-con
See #3237 for more details.
+### Plugin validation of interactive transactions
+
+We add a new `ValidateInteractiveTxPlugin` trait that can be extended by plugins that want to perform custom validation of remote inputs and outputs added to interactive transactions.
+This can be used for example to reject transactions that send to specific addresses or use specific UTXOs.
+
+Here is the trait definition:
+
+```scala
+/**
+ * Plugins implementing this trait will be called to validate the remote inputs and outputs used in interactive-tx.
+ * This can be used for example to reject interactive transactions that send to specific addresses before signing them.
+ */
+trait ValidateInteractiveTxPlugin extends PluginParams {
+ /**
+ * This function will be called for every interactive-tx, before signing it. The plugin should return:
+ * - [[Future.successful(())]] to accept the transaction
+ * - [[Future.failed(...)]] to reject it: the error message will be sent to the remote node, so make sure you don't
+ * include information that should stay private.
+ *
+ * Note that eclair will run standard validation on its own: you don't need for example to verify that inputs exist
+ * and aren't already spent. This function should only be used for custom, non-standard validation that node operators
+ * want to apply.
+ */
+ def validateSharedTx(remoteNodeId: PublicKey, remoteInputs: Map[OutPoint, TxOut], remoteOutputs: Seq[TxOut]): Future[Unit]
+}
+```
+
+See #3258 for more details.
+
### Channel jamming accountability
We update our channel jamming mitigation to match the latest draft of the [spec](https://github.com/lightning/bolts/pull/1280).
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala
index ddc9705..1dbf77e 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala
@@ -18,12 +18,15 @@ package fr.acinq.eclair
import akka.actor.typed.ActorRef
import akka.event.LoggingAdapter
-import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi}
+import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
+import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, TxOut}
import fr.acinq.eclair.channel.Origin
import fr.acinq.eclair.io.OpenChannelInterceptor.OpenChannelNonInitiator
import fr.acinq.eclair.payment.relay.PostRestartHtlcCleaner.IncomingHtlc
import fr.acinq.eclair.wire.protocol.{Error, LiquidityAds}
+import scala.concurrent.Future
+
/** Custom plugin parameters. */
trait PluginParams {
/** Plugin's friendly name. */
@@ -59,6 +62,24 @@ trait CustomCommitmentsPlugin extends PluginParams {
def getHtlcsRelayedOut(htlcsIn: Seq[IncomingHtlc], nodeParams: NodeParams, log: LoggingAdapter): Map[Origin.Cold, Set[(ByteVector32, Long)]]
}
+/**
+ * Plugins implementing this trait will be called to validate the remote inputs and outputs used in interactive-tx.
+ * This can be used for example to reject interactive transactions that send to specific addresses before signing them.
+ */
+trait ValidateInteractiveTxPlugin extends PluginParams {
+ /**
+ * This function will be called for every interactive-tx, before signing it. The plugin should return:
+ * - [[Future.successful(())]] to accept the transaction
+ * - [[Future.failed(...)]] to reject it: the error message will be sent to the remote node, so make sure you don't
+ * include information that should stay private.
+ *
+ * Note that eclair will run standard validation on its own: you don't need for example to verify that inputs exist
+ * and aren't already spent. This function should only be used for custom, non-standard validation that node operators
+ * want to apply.
+ */
+ def validateSharedTx(remoteNodeId: PublicKey, remoteInputs: Map[OutPoint, TxOut], remoteOutputs: Seq[TxOut]): Future[Unit]
+}
+
// @formatter:off
trait InterceptOpenChannelCommand
case class InterceptOpenChannelReceived(replyTo: ActorRef[InterceptOpenChannelResponse], openChannelNonInitiator: OpenChannelNonInitiator) extends InterceptOpenChannelCommand {
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala
index 0e644cd..29138e6 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala
@@ -73,7 +73,7 @@ case class OutputBelowDust (override val channelId: Byte
case class InvalidSharedOutputAmount (override val channelId: ByteVector32, serialId: UInt64, amount: Satoshi, expected: Satoshi) extends ChannelException(channelId, s"invalid shared output amount=$amount expected=$expected (serial_id=${serialId.toByteVector.toHex})")
case class InvalidSpliceOutputScript (override val channelId: ByteVector32, serialId: UInt64, publicKeyScript: ByteVector) extends ChannelException(channelId, s"invalid splice output publicKeyScript=$publicKeyScript (serial_id=${serialId.toByteVector.toHex})")
case class UnconfirmedInteractiveTxInputs (override val channelId: ByteVector32) extends ChannelException(channelId, "the completed interactive tx contains unconfirmed inputs")
-case class InvalidCompleteInteractiveTx (override val channelId: ByteVector32) extends ChannelException(channelId, "the completed interactive tx is invalid")
+case class InvalidCompleteInteractiveTx (override val channelId: ByteVector32, reason: String) extends ChannelException(channelId, s"the completed interactive tx is invalid: $reason")
case class TooManyInteractiveTxRounds (override val channelId: ByteVector32) extends ChannelException(channelId, "too many messages exchanged during interactive tx construction")
case class RbfAttemptAborted (override val channelId: ByteVector32) extends ChannelException(channelId, "rbf attempt aborted")
case class SpliceAttemptAborted (override val channelId: ByteVector32) extends ChannelException(channelId, "splice attempt aborted")
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
index a151a9c..bd48a66 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
@@ -40,7 +40,7 @@ import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, Remo
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.transactions._
import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{BlockHeight, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ToMilliSatoshiConversion, UInt64}
+import fr.acinq.eclair.{BlockHeight, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ToMilliSatoshiConversion, UInt64, ValidateInteractiveTxPlugin}
import scodec.bits.ByteVector
import scala.concurrent.{ExecutionContext, Future}
@@ -706,15 +706,25 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
private def validateAndSign(session: InteractiveTxSession): Behavior[Command] = {
require(session.isComplete, "interactive session was not completed")
- if (fundingParams.requireConfirmedInputs.forRemote) {
- // We ignore the shared input: we know it is a valid input since it comes from our commitment.
- context.pipeToSelf(checkInputsConfirmed(session.remoteInputs.collect { case i: Input.Remote => i })) {
- case Failure(t) => WalletFailure(t)
- case Success(false) => WalletFailure(UnconfirmedInteractiveTxInputs(fundingParams.channelId))
- case Success(true) => ValidateSharedTx
- }
+ // We ignore the shared input: we know it is a valid input since it comes from our commitment.
+ val remoteOnlyInputs = session.remoteInputs.collect { case i: Input.Remote => i }
+ // Similarly, we ignore the shared output, which has been validated separately.
+ val remoteOnlyOutputs = session.remoteOutputs.collect { case o: Output.Remote => o }
+ val confirmationValidation: () => Future[Unit] = if (fundingParams.requireConfirmedInputs.forRemote) {
+ () => checkInputsConfirmed(remoteOnlyInputs, minConfirmations = 1, maxConfirmations_opt = None)
} else {
- context.self ! ValidateSharedTx
+ () => Future.successful(())
+ }
+ val pluginValidation: Seq[() => Future[Unit]] = nodeParams.pluginParams.collect {
+ case p: ValidateInteractiveTxPlugin => () => p.validateSharedTx(remoteNodeId, remoteOnlyInputs.map(i => i.outPoint -> i.txOut).toMap, remoteOnlyOutputs.map(o => TxOut(o.amount, o.pubkeyScript)))
+ }
+ // We run all checks, stopping at the first failing one. Note that plugin validation is only called if the previous
+ // checks completed without errors.
+ context.pipeToSelf((confirmationValidation +: pluginValidation).foldLeft(Future.successful(())) {
+ case (current, nextCheck) => current.flatMap(_ => nextCheck()) // NB: sequential execution
+ }) {
+ case Success(_) => ValidateSharedTx
+ case Failure(t) => WalletFailure(t)
}
Behaviors.receiveMessagePartial {
case ValidateSharedTx => validateTx(session) match {
@@ -724,8 +734,11 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
case Right(completeTx) =>
signCommitTx(completeTx, session.txCompleteReceived.flatMap(_.fundingNonce_opt), session.txCompleteReceived.flatMap(_.commitNonces_opt))
}
- case _: WalletFailure =>
- replyTo ! RemoteFailure(UnconfirmedInteractiveTxInputs(fundingParams.channelId))
+ case WalletFailure(t) =>
+ t match {
+ case e: ChannelException => replyTo ! RemoteFailure(e)
+ case _ => replyTo ! RemoteFailure(InvalidCompleteInteractiveTx(fundingParams.channelId, t.getMessage))
+ }
unlockAndStop(session)
case ReceiveMessage(msg) =>
replyTo ! RemoteFailure(UnexpectedInteractiveTxMessage(fundingParams.channelId, msg))
@@ -735,12 +748,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
}
}
- private def checkInputsConfirmed(inputs: Seq[Input.Remote]): Future[Boolean] = {
+ private def checkInputsConfirmed(inputs: Seq[Input.Remote], minConfirmations: Int, maxConfirmations_opt: Option[Int]): Future[Unit] = {
// We check inputs sequentially and stop at the first unconfirmed one.
inputs.map(_.outPoint).toSet.foldLeft(Future.successful(true)) {
case (current, outpoint) => current.transformWith {
case Success(true) => wallet.getTxConfirmations(outpoint.txid).flatMap {
- case Some(confirmations) if confirmations > 0 =>
+ case Some(confirmations) if minConfirmations <= confirmations && maxConfirmations_opt.forall(max => confirmations <= max) =>
// The input is confirmed, so we can reliably check whether it is unspent, We don't check this for
// unconfirmed inputs, because if they are valid but not in our mempool we would incorrectly consider
// them unspendable (unknown). We want to reject unspendable inputs to immediately fail the funding
@@ -751,13 +764,16 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
case Success(false) => Future.successful(false)
case Failure(t) => Future.failed(t)
}
+ }.flatMap {
+ case true => Future.successful(())
+ case false => Future.failed(UnconfirmedInteractiveTxInputs(fundingParams.channelId))
}
}
private def validateTx(session: InteractiveTxSession): Either[ChannelException, SharedTransaction] = {
if (session.localInputs.length + session.remoteInputs.length > 252 || session.localOutputs.length + session.remoteOutputs.length > 252) {
log.warn("invalid interactive tx ({} local inputs, {} remote inputs, {} local outputs and {} remote outputs)", session.localInputs.length, session.remoteInputs.length, session.localOutputs.length, session.remoteOutputs.length)
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "too many inputs or outputs"))
}
val sharedInputs = session.localInputs.collect { case i: Input.Shared => i } ++ session.remoteInputs.collect { case i: Input.Shared => i }
@@ -769,13 +785,13 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
if (sharedOutputs.length > 1) {
log.warn("invalid interactive tx: funding script included multiple times")
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "funding script included multiple times"))
}
val sharedOutput = sharedOutputs.headOption match {
case Some(output) => output
case None =>
log.warn("invalid interactive tx: funding outpoint not included")
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "funding outpoint not included"))
}
val sharedInput_opt = fundingParams.sharedInput_opt.map(sharedInput => {
@@ -788,12 +804,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
val remoteReserve = (fundingParams.fundingAmount / 100).max(fundingParams.dustLimit)
if (sharedOutput.remoteAmount < remoteReserve) {
log.warn("invalid interactive tx: peer takes too much funds out and falls below the channel reserve ({} < {})", sharedOutput.remoteAmount, remoteReserve)
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "channel reserve requirements not met"))
}
}
if (sharedInputs.length > 1) {
log.warn("invalid interactive tx: shared input included multiple times")
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "shared input included multiple times"))
}
sharedInput.commitmentFormat match {
case _: SegwitV0CommitmentFormat => ()
@@ -806,7 +822,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
case Some(input) => input
case None =>
log.warn("invalid interactive tx: shared input not included")
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "shared input not included"))
}
})
@@ -814,7 +830,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
val tx = sharedTx.buildUnsignedTx()
if (sharedTx.localAmountIn < sharedTx.localAmountOut || sharedTx.remoteAmountIn < sharedTx.remoteAmountOut) {
log.warn("invalid interactive tx: input amount is too small (localIn={}, localOut={}, remoteIn={}, remoteOut={})", sharedTx.localAmountIn, sharedTx.localAmountOut, sharedTx.remoteAmountIn, sharedTx.remoteAmountOut)
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "input amount is too small to cover outputs"))
}
// If we're using taproot, our peer must provide commit nonces for the funding transaction.
@@ -829,7 +845,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
// so we use empty witnesses to provide a lower bound on the transaction weight.
if (tx.weight() > Transactions.MAX_STANDARD_TX_WEIGHT) {
log.warn("invalid interactive tx: exceeds standard weight (weight={})", tx.weight())
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "transaction weight too large"))
}
liquidityPurchase_opt match {
@@ -837,7 +853,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
val currentFeeCredit = nodeParams.db.liquidity.getFeeCredit(remoteNodeId)
if (currentFeeCredit < p.feeCreditUsed) {
log.warn("not enough fee credit: our peer may be malicious ({} < {})", currentFeeCredit, p.feeCreditUsed)
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "fee credit already consumed"))
}
case _ => ()
}
@@ -884,7 +900,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
val doubleSpendsPreviousTransactions = previousTransactions.forall(previousTx => previousTx.tx.buildUnsignedTx().txIn.map(_.outPoint).exists(o => currentInputs.contains(o)))
if (!doubleSpendsPreviousTransactions) {
log.warn("invalid interactive tx: it doesn't double-spend all previous transactions")
- return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
+ return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "RBF attempts must double-spend all previous transactions"))
}
Right(sharedTx)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
index 758a43a..b8d7df7 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
@@ -40,13 +40,14 @@ import fr.acinq.eclair.io.OpenChannelInterceptor.makeChannelParams
import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, InputInfo, PhoenixSimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat}
import fr.acinq.eclair.transactions.{Scripts, Transactions}
import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{Feature, FeatureSupport, Features, MilliSatoshiLong, NodeParams, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion, UInt64, randomBytes32, randomKey}
+import fr.acinq.eclair.{Feature, FeatureSupport, Features, MilliSatoshiLong, NodeParams, PluginParams, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion, UInt64, ValidateInteractiveTxPlugin, randomBytes32, randomKey}
import org.scalatest.BeforeAndAfterAll
import org.scalatest.funsuite.AnyFunSuiteLike
import scodec.bits.{ByteVector, HexStringSyntax}
import java.util.UUID
import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.Future
import scala.concurrent.duration.DurationInt
import scala.reflect.ClassTag
@@ -218,8 +219,11 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
}
}
- private def createFixtureParams(channelType: SupportedChannelType, fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false), nonInitiatorPaysCommitTxFees: Boolean = false): FixtureParams = {
- val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(_.copy(features = Features(channelType.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport])))
+ private def createFixtureParams(channelType: SupportedChannelType, fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false), nonInitiatorPaysCommitTxFees: Boolean = false, plugins: Seq[PluginParams] = Nil): FixtureParams = {
+ val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(nodeParams => nodeParams.copy(
+ features = Features(channelType.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport]),
+ pluginParams = plugins,
+ ))
val localChannelParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, isChannelOpener = true, paysCommitTxFees = !nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountA)
val commitParamsA = CommitParams(nodeParamsA.channelConf.dustLimit, nodeParamsA.channelConf.htlcMinimum, nodeParamsA.channelConf.maxHtlcValueInFlight(fundingAmountA + fundingAmountB, unlimited = false), nodeParamsA.channelConf.maxAcceptedHtlcs, nodeParamsB.channelConf.toRemoteDelay)
val localChannelParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, isChannelOpener = false, paysCommitTxFees = nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountB)
@@ -285,7 +289,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
}
}
- private def withFixture(channelType: SupportedChannelType, fundingAmountA: Satoshi, utxosA: Seq[Satoshi], fundingAmountB: Satoshi, utxosB: Seq[Satoshi], targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs, liquidityPurchase_opt: Option[LiquidityAds.Purchase] = None)(testFun: Fixture => Any): Unit = {
+ private def withFixture(channelType: SupportedChannelType, fundingAmountA: Satoshi, utxosA: Seq[Satoshi], fundingAmountB: Satoshi, utxosB: Seq[Satoshi], targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs, liquidityPurchase_opt: Option[LiquidityAds.Purchase] = None, plugins: Seq[PluginParams] = Nil)(testFun: Fixture => Any): Unit = {
// Initialize wallets with a few confirmed utxos.
val probe = TestProbe()
val rpcClientA = createWallet(UUID.randomUUID().toString)
@@ -296,7 +300,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
utxosB.foreach(amount => addUtxo(walletB, amount, probe))
generateBlocks(1)
- val fixtureParams = createFixtureParams(channelType, fundingAmountA, fundingAmountB, targetFeerate, dustLimit, lockTime, requireConfirmedInputs, nonInitiatorPaysCommitTxFees = liquidityPurchase_opt.nonEmpty)
+ val fixtureParams = createFixtureParams(channelType, fundingAmountA, fundingAmountB, targetFeerate, dustLimit, lockTime, requireConfirmedInputs, nonInitiatorPaysCommitTxFees = liquidityPurchase_opt.nonEmpty, plugins)
val alice = fixtureParams.spawnTxBuilderAlice(walletA, liquidityPurchase_opt = liquidityPurchase_opt)
val bob = fixtureParams.spawnTxBuilderBob(walletB, liquidityPurchase_opt = liquidityPurchase_opt)
testFun(Fixture(alice, bob, fixtureParams, walletA, rpcClientA, walletB, rpcClientB, TestProbe(), TestProbe()))
@@ -2309,7 +2313,78 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
// Alice --- tx_complete --> Bob
fwdSplice.forwardAlice2Bob[TxComplete]
// Alice detects that Bob will drop below the channel reserve and fails.
- assert(alice2bob.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(bobParams.channelId))
+ assert(alice2bob.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(bobParams.channelId, "channel reserve requirements not met"))
+ }
+ }
+
+ test("plugin accepts funding transaction") {
+ // Our plugin accepts the funding transaction.
+ val plugin = new ValidateInteractiveTxPlugin {
+ override val name: String = "accept all the things"
+ override def validateSharedTx(remoteNodeId: PublicKey, remoteInputs: Map[OutPoint, TxOut], remoteOutputs: Seq[TxOut]): Future[Unit] = Future.successful(())
+ }
+ withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, Seq(180_000 sat, 175_000 sat), 40_000 sat, Seq(100_000 sat), FeeratePerKw(1000 sat), 660 sat, 42, RequireConfirmedInputs(forLocal = true, forRemote = true), plugins = Seq(plugin)) { f =>
+ import f._
+
+ alice ! Start(alice2bob.ref)
+ bob ! Start(bob2alice.ref)
+
+ // Alice --- tx_add_input --> Bob
+ fwd.forwardAlice2Bob[TxAddInput]
+ // Alice <-- tx_add_input --- Bob
+ fwd.forwardBob2Alice[TxAddInput]
+ // Alice --- tx_add_output --> Bob
+ fwd.forwardAlice2Bob[TxAddOutput]
+ // Alice <-- tx_add_output --- Bob
+ fwd.forwardBob2Alice[TxAddOutput]
+ // Alice --- tx_add_output --> Bob
+ fwd.forwardAlice2Bob[TxAddOutput]
+ // Alice <-- tx_complete --- Bob
+ fwd.forwardBob2Alice[TxComplete]
+ // Alice --- tx_complete --> Bob
+ fwd.forwardAlice2Bob[TxComplete]
+
+ // Bob sends signatures first as he contributed less than Alice.
+ alice2bob.expectMsgType[Succeeded]
+ bob2alice.expectMsgType[Succeeded]
+ }
+ }
+
+ test("plugin rejects funding transaction") {
+ // Our first plugin accepts the funding transaction.
+ val plugin1 = new ValidateInteractiveTxPlugin {
+ override val name: String = "accept all the things"
+ override def validateSharedTx(remoteNodeId: PublicKey, remoteInputs: Map[OutPoint, TxOut], remoteOutputs: Seq[TxOut]): Future[Unit] = Future.successful(())
+ }
+ // But our second plugin rejects it.
+ val plugin2 = new ValidateInteractiveTxPlugin {
+ override val name: String = "reject all the things"
+ override def validateSharedTx(remoteNodeId: PublicKey, remoteInputs: Map[OutPoint, TxOut], remoteOutputs: Seq[TxOut]): Future[Unit] = Future.failed(new IllegalArgumentException("nope"))
+ }
+ withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, Seq(180_000 sat, 175_000 sat), 40_000 sat, Seq(100_000 sat), FeeratePerKw(1000 sat), 660 sat, 42, RequireConfirmedInputs(forLocal = true, forRemote = true), plugins = Seq(plugin1, plugin2)) { f =>
+ import f._
+
+ alice ! Start(alice2bob.ref)
+ bob ! Start(bob2alice.ref)
+
+ // Alice --- tx_add_input --> Bob
+ fwd.forwardAlice2Bob[TxAddInput]
+ // Alice <-- tx_add_input --- Bob
+ fwd.forwardBob2Alice[TxAddInput]
+ // Alice --- tx_add_output --> Bob
+ fwd.forwardAlice2Bob[TxAddOutput]
+ // Alice <-- tx_add_output --- Bob
+ fwd.forwardBob2Alice[TxAddOutput]
+ // Alice --- tx_add_output --> Bob
+ fwd.forwardAlice2Bob[TxAddOutput]
+ // Alice <-- tx_complete --- Bob
+ fwd.forwardBob2Alice[TxComplete]
+ // Alice --- tx_complete --> Bob
+ fwd.forwardAlice2Bob[TxComplete]
+
+ // Bob sends signatures first as he contributed less than Alice.
+ assert(alice2bob.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(fixtureParams.channelId, "nope"))
+ assert(bob2alice.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(fixtureParams.channelId, "nope"))
}
}
@@ -2651,7 +2726,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
// Alice --- tx_complete --> Bob
probe.expectMsgType[SendMessage]
alice ! ReceiveMessage(TxComplete(params.channelId))
- assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId))
+ assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId, "too many inputs or outputs"))
}
test("too many outputs") {
@@ -2669,7 +2744,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
// Alice --- tx_complete --> Bob
probe.expectMsgType[SendMessage]
alice ! ReceiveMessage(TxComplete(params.channelId))
- assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId))
+ assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId, "too many inputs or outputs"))
}
test("missing funding output") {
@@ -2689,7 +2764,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
probe.expectMsgType[SendMessage]
// Alice --- tx_complete --> Bob
bob ! ReceiveMessage(TxComplete(params.channelId))
- assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId))
+ assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId, "funding outpoint not included"))
}
test("multiple funding outputs") {
@@ -2712,7 +2787,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
probe.expectMsgType[SendMessage]
// Alice --- tx_complete --> Bob
bob ! ReceiveMessage(TxComplete(params.channelId))
- assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId))
+ assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId, "funding script included multiple times"))
}
test("missing shared input") {
@@ -2733,7 +2808,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
probe.expectMsgType[SendMessage]
// Alice --- tx_complete --> Bob
bob ! ReceiveMessage(TxComplete(params.channelId))
- assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId))
+ assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId, "funding outpoint not included"))
}
test("invalid funding amount") {
@@ -2817,7 +2892,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
probe.expectMsgType[SendMessage]
// Alice --- tx_complete --> Bob
bob ! ReceiveMessage(TxComplete(params.channelId))
- assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId))
+ assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId, "input amount is too small to cover outputs"))
}
test("minimum fee not met") {
@@ -2902,7 +2977,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
// Alice --- tx_complete --> Bob
fwdRbf.forwardAlice2Bob[TxComplete]
// Alice <-- error --- Bob
- assert(bob2alice.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(fixtureParams.channelId))
+ assert(bob2alice.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(fixtureParams.channelId, "RBF attempts must double-spend all previous transactions"))
}
}
Why this scored 21/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.