@@ -40,7 +40,7 @@ import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, Remo
4040import fr .acinq .eclair .transactions .Transactions ._
4141import fr .acinq .eclair .transactions ._
4242import 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 }
4444import scodec .bits .ByteVector
4545
4646import 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