Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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).
Expand Down
23 changes: 22 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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 }
Expand All @@ -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 => {
Expand All @@ -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 => ()
Expand All @@ -806,15 +822,15 @@ 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"))
}
})

val sharedTx = SharedTransaction(sharedInput_opt, sharedOutput, localInputs.toList, remoteInputs.toList, localOutputs.toList, remoteOutputs.toList, fundingParams.lockTime)
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.
Expand All @@ -829,15 +845,15 @@ 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 {
case Some(p: LiquidityAds.Purchase.WithFeeCredit) if !fundingParams.isInitiator =>
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 _ => ()
}
Expand Down Expand Up @@ -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)
Expand Down
Loading