Skip to content

Commit 28f3545

Browse files
authored
Plugin validation of interactive transactions (#3258)
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.
1 parent 0a9853b commit 28f3545

5 files changed

Lines changed: 179 additions & 38 deletions

File tree

docs/release-notes/eclair-vnext.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,35 @@ However, when using zero-conf, this event may be emitted before the `channel-con
3636

3737
See #3237 for more details.
3838

39+
### Plugin validation of interactive transactions
40+
41+
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.
42+
This can be used for example to reject transactions that send to specific addresses or use specific UTXOs.
43+
44+
Here is the trait definition:
45+
46+
```scala
47+
/**
48+
* Plugins implementing this trait will be called to validate the remote inputs and outputs used in interactive-tx.
49+
* This can be used for example to reject interactive transactions that send to specific addresses before signing them.
50+
*/
51+
trait ValidateInteractiveTxPlugin extends PluginParams {
52+
/**
53+
* This function will be called for every interactive-tx, before signing it. The plugin should return:
54+
* - [[Future.successful(())]] to accept the transaction
55+
* - [[Future.failed(...)]] to reject it: the error message will be sent to the remote node, so make sure you don't
56+
* include information that should stay private.
57+
*
58+
* Note that eclair will run standard validation on its own: you don't need for example to verify that inputs exist
59+
* and aren't already spent. This function should only be used for custom, non-standard validation that node operators
60+
* want to apply.
61+
*/
62+
def validateSharedTx(remoteNodeId: PublicKey, remoteInputs: Map[OutPoint, TxOut], remoteOutputs: Seq[TxOut]): Future[Unit]
63+
}
64+
```
65+
66+
See #3258 for more details.
67+
3968
### Channel jamming accountability
4069

4170
We update our channel jamming mitigation to match the latest draft of the [spec](https://github.com/lightning/bolts/pull/1280).

eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ package fr.acinq.eclair
1818

1919
import akka.actor.typed.ActorRef
2020
import akka.event.LoggingAdapter
21-
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi}
21+
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
22+
import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, TxOut}
2223
import fr.acinq.eclair.channel.Origin
2324
import fr.acinq.eclair.io.OpenChannelInterceptor.OpenChannelNonInitiator
2425
import fr.acinq.eclair.payment.relay.PostRestartHtlcCleaner.IncomingHtlc
2526
import fr.acinq.eclair.wire.protocol.{Error, LiquidityAds}
2627

28+
import scala.concurrent.Future
29+
2730
/** Custom plugin parameters. */
2831
trait PluginParams {
2932
/** Plugin's friendly name. */
@@ -59,6 +62,24 @@ trait CustomCommitmentsPlugin extends PluginParams {
5962
def getHtlcsRelayedOut(htlcsIn: Seq[IncomingHtlc], nodeParams: NodeParams, log: LoggingAdapter): Map[Origin.Cold, Set[(ByteVector32, Long)]]
6063
}
6164

65+
/**
66+
* Plugins implementing this trait will be called to validate the remote inputs and outputs used in interactive-tx.
67+
* This can be used for example to reject interactive transactions that send to specific addresses before signing them.
68+
*/
69+
trait ValidateInteractiveTxPlugin extends PluginParams {
70+
/**
71+
* This function will be called for every interactive-tx, before signing it. The plugin should return:
72+
* - [[Future.successful(())]] to accept the transaction
73+
* - [[Future.failed(...)]] to reject it: the error message will be sent to the remote node, so make sure you don't
74+
* include information that should stay private.
75+
*
76+
* Note that eclair will run standard validation on its own: you don't need for example to verify that inputs exist
77+
* and aren't already spent. This function should only be used for custom, non-standard validation that node operators
78+
* want to apply.
79+
*/
80+
def validateSharedTx(remoteNodeId: PublicKey, remoteInputs: Map[OutPoint, TxOut], remoteOutputs: Seq[TxOut]): Future[Unit]
81+
}
82+
6283
// @formatter:off
6384
trait InterceptOpenChannelCommand
6485
case class InterceptOpenChannelReceived(replyTo: ActorRef[InterceptOpenChannelResponse], openChannelNonInitiator: OpenChannelNonInitiator) extends InterceptOpenChannelCommand {

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ case class OutputBelowDust (override val channelId: Byte
7373
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})")
7474
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})")
7575
case class UnconfirmedInteractiveTxInputs (override val channelId: ByteVector32) extends ChannelException(channelId, "the completed interactive tx contains unconfirmed inputs")
76-
case class InvalidCompleteInteractiveTx (override val channelId: ByteVector32) extends ChannelException(channelId, "the completed interactive tx is invalid")
76+
case class InvalidCompleteInteractiveTx (override val channelId: ByteVector32, reason: String) extends ChannelException(channelId, s"the completed interactive tx is invalid: $reason")
7777
case class TooManyInteractiveTxRounds (override val channelId: ByteVector32) extends ChannelException(channelId, "too many messages exchanged during interactive tx construction")
7878
case class RbfAttemptAborted (override val channelId: ByteVector32) extends ChannelException(channelId, "rbf attempt aborted")
7979
case class SpliceAttemptAborted (override val channelId: ByteVector32) extends ChannelException(channelId, "splice attempt aborted")

eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, Remo
4040
import fr.acinq.eclair.transactions.Transactions._
4141
import fr.acinq.eclair.transactions._
4242
import fr.acinq.eclair.wire.protocol._
43-
import fr.acinq.eclair.{BlockHeight, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ToMilliSatoshiConversion, UInt64}
43+
import fr.acinq.eclair.{BlockHeight, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ToMilliSatoshiConversion, UInt64, ValidateInteractiveTxPlugin}
4444
import scodec.bits.ByteVector
4545

4646
import scala.concurrent.{ExecutionContext, Future}
@@ -706,15 +706,25 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
706706

707707
private def validateAndSign(session: InteractiveTxSession): Behavior[Command] = {
708708
require(session.isComplete, "interactive session was not completed")
709-
if (fundingParams.requireConfirmedInputs.forRemote) {
710-
// We ignore the shared input: we know it is a valid input since it comes from our commitment.
711-
context.pipeToSelf(checkInputsConfirmed(session.remoteInputs.collect { case i: Input.Remote => i })) {
712-
case Failure(t) => WalletFailure(t)
713-
case Success(false) => WalletFailure(UnconfirmedInteractiveTxInputs(fundingParams.channelId))
714-
case Success(true) => ValidateSharedTx
715-
}
709+
// We ignore the shared input: we know it is a valid input since it comes from our commitment.
710+
val remoteOnlyInputs = session.remoteInputs.collect { case i: Input.Remote => i }
711+
// Similarly, we ignore the shared output, which has been validated separately.
712+
val remoteOnlyOutputs = session.remoteOutputs.collect { case o: Output.Remote => o }
713+
val confirmationValidation: () => Future[Unit] = if (fundingParams.requireConfirmedInputs.forRemote) {
714+
() => checkInputsConfirmed(remoteOnlyInputs, minConfirmations = 1, maxConfirmations_opt = None)
716715
} else {
717-
context.self ! ValidateSharedTx
716+
() => Future.successful(())
717+
}
718+
val pluginValidation: Seq[() => Future[Unit]] = nodeParams.pluginParams.collect {
719+
case p: ValidateInteractiveTxPlugin => () => p.validateSharedTx(remoteNodeId, remoteOnlyInputs.map(i => i.outPoint -> i.txOut).toMap, remoteOnlyOutputs.map(o => TxOut(o.amount, o.pubkeyScript)))
720+
}
721+
// We run all checks, stopping at the first failing one. Note that plugin validation is only called if the previous
722+
// checks completed without errors.
723+
context.pipeToSelf((confirmationValidation +: pluginValidation).foldLeft(Future.successful(())) {
724+
case (current, nextCheck) => current.flatMap(_ => nextCheck()) // NB: sequential execution
725+
}) {
726+
case Success(_) => ValidateSharedTx
727+
case Failure(t) => WalletFailure(t)
718728
}
719729
Behaviors.receiveMessagePartial {
720730
case ValidateSharedTx => validateTx(session) match {
@@ -724,8 +734,11 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
724734
case Right(completeTx) =>
725735
signCommitTx(completeTx, session.txCompleteReceived.flatMap(_.fundingNonce_opt), session.txCompleteReceived.flatMap(_.commitNonces_opt))
726736
}
727-
case _: WalletFailure =>
728-
replyTo ! RemoteFailure(UnconfirmedInteractiveTxInputs(fundingParams.channelId))
737+
case WalletFailure(t) =>
738+
t match {
739+
case e: ChannelException => replyTo ! RemoteFailure(e)
740+
case _ => replyTo ! RemoteFailure(InvalidCompleteInteractiveTx(fundingParams.channelId, t.getMessage))
741+
}
729742
unlockAndStop(session)
730743
case ReceiveMessage(msg) =>
731744
replyTo ! RemoteFailure(UnexpectedInteractiveTxMessage(fundingParams.channelId, msg))
@@ -735,12 +748,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
735748
}
736749
}
737750

738-
private def checkInputsConfirmed(inputs: Seq[Input.Remote]): Future[Boolean] = {
751+
private def checkInputsConfirmed(inputs: Seq[Input.Remote], minConfirmations: Int, maxConfirmations_opt: Option[Int]): Future[Unit] = {
739752
// We check inputs sequentially and stop at the first unconfirmed one.
740753
inputs.map(_.outPoint).toSet.foldLeft(Future.successful(true)) {
741754
case (current, outpoint) => current.transformWith {
742755
case Success(true) => wallet.getTxConfirmations(outpoint.txid).flatMap {
743-
case Some(confirmations) if confirmations > 0 =>
756+
case Some(confirmations) if minConfirmations <= confirmations && maxConfirmations_opt.forall(max => confirmations <= max) =>
744757
// The input is confirmed, so we can reliably check whether it is unspent, We don't check this for
745758
// unconfirmed inputs, because if they are valid but not in our mempool we would incorrectly consider
746759
// 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
751764
case Success(false) => Future.successful(false)
752765
case Failure(t) => Future.failed(t)
753766
}
767+
}.flatMap {
768+
case true => Future.successful(())
769+
case false => Future.failed(UnconfirmedInteractiveTxInputs(fundingParams.channelId))
754770
}
755771
}
756772

757773
private def validateTx(session: InteractiveTxSession): Either[ChannelException, SharedTransaction] = {
758774
if (session.localInputs.length + session.remoteInputs.length > 252 || session.localOutputs.length + session.remoteOutputs.length > 252) {
759775
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)
760-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
776+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "too many inputs or outputs"))
761777
}
762778

763779
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
769785

770786
if (sharedOutputs.length > 1) {
771787
log.warn("invalid interactive tx: funding script included multiple times")
772-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
788+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "funding script included multiple times"))
773789
}
774790
val sharedOutput = sharedOutputs.headOption match {
775791
case Some(output) => output
776792
case None =>
777793
log.warn("invalid interactive tx: funding outpoint not included")
778-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
794+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "funding outpoint not included"))
779795
}
780796

781797
val sharedInput_opt = fundingParams.sharedInput_opt.map(sharedInput => {
@@ -788,12 +804,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
788804
val remoteReserve = (fundingParams.fundingAmount / 100).max(fundingParams.dustLimit)
789805
if (sharedOutput.remoteAmount < remoteReserve) {
790806
log.warn("invalid interactive tx: peer takes too much funds out and falls below the channel reserve ({} < {})", sharedOutput.remoteAmount, remoteReserve)
791-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
807+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "channel reserve requirements not met"))
792808
}
793809
}
794810
if (sharedInputs.length > 1) {
795811
log.warn("invalid interactive tx: shared input included multiple times")
796-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
812+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "shared input included multiple times"))
797813
}
798814
sharedInput.commitmentFormat match {
799815
case _: SegwitV0CommitmentFormat => ()
@@ -806,15 +822,15 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
806822
case Some(input) => input
807823
case None =>
808824
log.warn("invalid interactive tx: shared input not included")
809-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
825+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "shared input not included"))
810826
}
811827
})
812828

813829
val sharedTx = SharedTransaction(sharedInput_opt, sharedOutput, localInputs.toList, remoteInputs.toList, localOutputs.toList, remoteOutputs.toList, fundingParams.lockTime)
814830
val tx = sharedTx.buildUnsignedTx()
815831
if (sharedTx.localAmountIn < sharedTx.localAmountOut || sharedTx.remoteAmountIn < sharedTx.remoteAmountOut) {
816832
log.warn("invalid interactive tx: input amount is too small (localIn={}, localOut={}, remoteIn={}, remoteOut={})", sharedTx.localAmountIn, sharedTx.localAmountOut, sharedTx.remoteAmountIn, sharedTx.remoteAmountOut)
817-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
833+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "input amount is too small to cover outputs"))
818834
}
819835

820836
// If we're using taproot, our peer must provide commit nonces for the funding transaction.
@@ -829,15 +845,15 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
829845
// so we use empty witnesses to provide a lower bound on the transaction weight.
830846
if (tx.weight() > Transactions.MAX_STANDARD_TX_WEIGHT) {
831847
log.warn("invalid interactive tx: exceeds standard weight (weight={})", tx.weight())
832-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
848+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "transaction weight too large"))
833849
}
834850

835851
liquidityPurchase_opt match {
836852
case Some(p: LiquidityAds.Purchase.WithFeeCredit) if !fundingParams.isInitiator =>
837853
val currentFeeCredit = nodeParams.db.liquidity.getFeeCredit(remoteNodeId)
838854
if (currentFeeCredit < p.feeCreditUsed) {
839855
log.warn("not enough fee credit: our peer may be malicious ({} < {})", currentFeeCredit, p.feeCreditUsed)
840-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
856+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "fee credit already consumed"))
841857
}
842858
case _ => ()
843859
}
@@ -884,7 +900,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
884900
val doubleSpendsPreviousTransactions = previousTransactions.forall(previousTx => previousTx.tx.buildUnsignedTx().txIn.map(_.outPoint).exists(o => currentInputs.contains(o)))
885901
if (!doubleSpendsPreviousTransactions) {
886902
log.warn("invalid interactive tx: it doesn't double-spend all previous transactions")
887-
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
903+
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "RBF attempts must double-spend all previous transactions"))
888904
}
889905

890906
Right(sharedTx)

0 commit comments

Comments
 (0)