diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala index 3f62f45009..f3ada93f87 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.blockchain import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{Satoshi, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import scodec.bits.ByteVector @@ -30,10 +30,22 @@ import scala.concurrent.{ExecutionContext, Future} /** This trait lets users fund lightning channels. */ trait OnChainChannelFunder { - import OnChainWallet.MakeFundingTxResponse + import OnChainWallet._ - /** Create a channel funding transaction with the provided pubkeyScript. */ - def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] + /** Fund the provided transaction by adding inputs (and a change output if necessary). */ + def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, lockUtxos: Boolean)(implicit ec: ExecutionContext): Future[FundTransactionResponse] + + /** Sign the wallet inputs of the provided transaction. */ + def signTransaction(tx: Transaction, allowIncomplete: Boolean)(implicit ec: ExecutionContext): Future[SignTransactionResponse] + + /** + * Publish a transaction on the bitcoin network. + * This method must be idempotent: if the tx was already published, it must return a success. + */ + def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] + + /** Create a fully signed channel funding transaction with the provided pubkeyScript. */ + def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRate: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] /** * Committing *must* include publishing the transaction on the network. @@ -47,9 +59,10 @@ trait OnChainChannelFunder { */ def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] - /** - * Rollback a transaction that we failed to commit: this probably translates to "release locks on utxos". - */ + /** Return the transaction if it exists, either in the blockchain or in the mempool. */ + def getTransaction(txId: ByteVector32)(implicit ec: ExecutionContext): Future[Transaction] + + /** Rollback a transaction that we failed to commit: this probably translates to "release locks on utxos". */ def rollback(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] /** @@ -97,4 +110,10 @@ object OnChainWallet { final case class MakeFundingTxResponse(fundingTx: Transaction, fundingTxOutputIndex: Int, fee: Satoshi) + final case class FundTransactionResponse(tx: Transaction, fee: Satoshi, changePosition: Option[Int]) { + val amountIn: Satoshi = fee + tx.txOut.map(_.amount).sum + } + + final case class SignTransactionResponse(tx: Transaction, complete: Boolean) + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 896b1005fe..9d92ab761f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala @@ -21,7 +21,7 @@ import fr.acinq.bitcoin.scalacompat._ import fr.acinq.bitcoin.{Bech32, Block} import fr.acinq.eclair.ShortChannelId.coordinates import fr.acinq.eclair.blockchain.OnChainWallet -import fr.acinq.eclair.blockchain.OnChainWallet.{MakeFundingTxResponse, OnChainBalance} +import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{GetTxWithMetaResponse, UtxoStatus, ValidateResult} import fr.acinq.eclair.blockchain.fee.{FeeratePerKB, FeeratePerKw} import fr.acinq.eclair.transactions.Transactions @@ -220,6 +220,10 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall }) } + def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, lockUtxos: Boolean)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { + fundTransaction(tx, FundTransactionOptions(feeRate, replaceable, lockUtxos)) + } + def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, targetFeerate: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { val partialFundingTx = Transaction( version = 2, @@ -255,11 +259,12 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall def signTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = signTransaction(tx, Nil) + def signTransaction(tx: Transaction, allowIncomplete: Boolean)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = signTransaction(tx, Nil, allowIncomplete) + def signTransaction(tx: Transaction, previousTxs: Seq[PreviousTx], allowIncomplete: Boolean = false)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = { rpcClient.invoke("signrawtransactionwithwallet", tx.toString(), previousTxs).map(json => { val JString(hex) = json \ "hex" val JBool(complete) = json \ "complete" - // TODO: remove allowIncomplete once /~https://github.com/bitcoin/bitcoin/issues/21151 is fixed if (!complete && !allowIncomplete) { val JArray(errors) = json \ "errors" val message = errors.map(error => { @@ -336,7 +341,6 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall case Failure(JsonRPCError(error)) if error.message.contains("expected locked output") => Future.successful(true) // we consider that the outpoint was successfully unlocked (since it was not locked to begin with) case Failure(t) => - logger.warn(s"cannot unlock utxo=$utxo:", t) Future.successful(false) }) val future = Future.sequence(futures) @@ -473,10 +477,6 @@ object BitcoinCoreClient { } } - case class FundTransactionResponse(tx: Transaction, fee: Satoshi, changePosition: Option[Int]) { - val amountIn: Satoshi = fee + tx.txOut.map(_.amount).sum - } - case class PreviousTx(txid: ByteVector32, vout: Long, scriptPubKey: String, redeemScript: String, witnessScript: String, amount: BigDecimal) object PreviousTx { @@ -490,8 +490,6 @@ object BitcoinCoreClient { ) } - case class SignTransactionResponse(tx: Transaction, complete: Boolean) - /** * Information about a transaction currently in the mempool. * diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index a87d6842a0..5b27b730bc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -16,10 +16,12 @@ package fr.acinq.eclair.channel +import akka.actor.typed import akka.actor.{ActorRef, PossiblyHarmful} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, DeterministicWallet, OutPoint, Satoshi, Transaction} import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.InteractiveTxBuilder.{InteractiveTxParams, SignedSharedTransaction} import fr.acinq.eclair.payment.OutgoingPaymentPacket.Upstream import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions._ @@ -60,7 +62,8 @@ case object WAIT_FOR_CHANNEL_READY extends ChannelState case object WAIT_FOR_INIT_DUAL_FUNDED_CHANNEL extends ChannelState case object WAIT_FOR_OPEN_DUAL_FUNDED_CHANNEL extends ChannelState case object WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL extends ChannelState -case object WAIT_FOR_DUAL_FUNDING_INTERNAL extends ChannelState +case object WAIT_FOR_DUAL_FUNDING_CREATED extends ChannelState +case object WAIT_FOR_DUAL_FUNDING_PLACEHOLDER extends ChannelState // Channel opened: case object NORMAL extends ChannelState case object SHUTDOWN extends ChannelState @@ -397,6 +400,9 @@ object RealScidStatus { */ case class ShortIds(real: RealScidStatus, localAlias: Alias, remoteAlias_opt: Option[Alias]) +/** Once a dual funding tx has been signed, we must remember the associated commitments. */ +case class DualFundingTx(fundingTx: SignedSharedTransaction, commitments: Commitments) + sealed trait ChannelData extends PossiblyHarmful { def channelId: ByteVector32 } @@ -470,10 +476,19 @@ final case class DATA_WAIT_FOR_OPEN_DUAL_FUNDED_CHANNEL(init: INPUT_INIT_CHANNEL final case class DATA_WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL(init: INPUT_INIT_CHANNEL_INITIATOR, lastSent: OpenDualFundedChannel) extends TransientChannelData { val channelId: ByteVector32 = lastSent.temporaryChannelId } -final case class DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL(channelId: ByteVector32, - localParams: LocalParams, - remoteParams: RemoteParams, - channelFeatures: ChannelFeatures) extends TransientChannelData +final case class DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId: ByteVector32, + txBuilder: typed.ActorRef[InteractiveTxBuilder.Command], + deferred: Option[ChannelReady]) extends TransientChannelData +final case class DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER(commitments: Commitments, + fundingTx: SignedSharedTransaction, + fundingParams: InteractiveTxParams, + previousFundingTxs: Seq[DualFundingTx], + waitingSince: BlockHeight, // how long have we been waiting for a funding tx to confirm + lastChecked: BlockHeight, // last time we checked if the channel was double-spent + rbfAttempt: Option[typed.ActorRef[InteractiveTxBuilder.Command]], + deferred: Option[ChannelReady]) extends TransientChannelData { + val channelId: ByteVector32 = commitments.channelId +} final case class DATA_NORMAL(commitments: Commitments, shortIds: ShortIds, 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 222726ed80..fd68d1e9b4 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 @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction} import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, Error, UpdateAddHtlc} +import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, Error, InteractiveTxMessage, UpdateAddHtlc} import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshi, UInt64} /** @@ -50,6 +50,26 @@ case class ChannelReserveTooHigh (override val channelId: Byte case class ChannelReserveBelowOurDustLimit (override val channelId: ByteVector32, channelReserve: Satoshi, dustLimit: Satoshi) extends ChannelException(channelId, s"their channelReserve=$channelReserve is below our dustLimit=$dustLimit") case class ChannelReserveNotMet (override val channelId: ByteVector32, toLocal: MilliSatoshi, toRemote: MilliSatoshi, reserve: Satoshi) extends ChannelException(channelId, s"channel reserve is not met toLocal=$toLocal toRemote=$toRemote reserve=$reserve") case class ChannelFundingError (override val channelId: ByteVector32) extends ChannelException(channelId, "channel funding error") +case class InvalidSerialId (override val channelId: ByteVector32, serialId: UInt64) extends ChannelException(channelId, s"invalid serial_id=${serialId.toByteVector.toHex}") +case class DuplicateSerialId (override val channelId: ByteVector32, serialId: UInt64) extends ChannelException(channelId, s"duplicate serial_id=${serialId.toByteVector.toHex}") +case class UnknownSerialId (override val channelId: ByteVector32, serialId: UInt64) extends ChannelException(channelId, s"unknown serial_id=${serialId.toByteVector.toHex}") +case class DuplicateInput (override val channelId: ByteVector32, serialId: UInt64, previousTxId: ByteVector32, previousTxOutput: Long) extends ChannelException(channelId, s"duplicate input $previousTxId:$previousTxOutput (serial_id=${serialId.toByteVector.toHex})") +case class InputOutOfBounds (override val channelId: ByteVector32, serialId: UInt64, previousTxId: ByteVector32, previousTxOutput: Long) extends ChannelException(channelId, s"invalid input $previousTxId:$previousTxOutput (serial_id=${serialId.toByteVector.toHex})") +case class NonSegwitInput (override val channelId: ByteVector32, serialId: UInt64, previousTxId: ByteVector32, previousTxOutput: Long) extends ChannelException(channelId, s"$previousTxId:$previousTxOutput is not a native segwit input (serial_id=${serialId.toByteVector.toHex})") +case class OutputBelowDust (override val channelId: ByteVector32, serialId: UInt64, amount: Satoshi, dustLimit: Satoshi) extends ChannelException(channelId, s"invalid output amount=$amount below dust=$dustLimit (serial_id=${serialId.toByteVector.toHex})") +case class NonSegwitOutput (override val channelId: ByteVector32, serialId: UInt64) extends ChannelException(channelId, s"output with serial_id=${serialId.toByteVector.toHex} is not a native segwit output") +case class InvalidCompleteInteractiveTx (override val channelId: ByteVector32) extends ChannelException(channelId, "the completed interactive tx is invalid") +case class TooManyInteractiveTxRounds (override val channelId: ByteVector32) extends ChannelException(channelId, "too many messages exchanged during interactive tx construction") +case class DualFundingAborted (override val channelId: ByteVector32) extends ChannelException(channelId, "dual funding aborted") +case class UnexpectedInteractiveTxMessage (override val channelId: ByteVector32, msg: InteractiveTxMessage) extends ChannelException(channelId, s"unexpected interactive-tx message (${msg.getClass.getSimpleName})") +case class UnexpectedCommitSig (override val channelId: ByteVector32) extends ChannelException(channelId, "unexpected commitment signatures (commit_sig)") +case class UnexpectedFundingSignatures (override val channelId: ByteVector32) extends ChannelException(channelId, "unexpected funding signatures (tx_signatures)") +case class InvalidFundingFeerate (override val channelId: ByteVector32, targetFeerate: FeeratePerKw, actualFeerate: FeeratePerKw) extends ChannelException(channelId, s"invalid funding feerate: target=$targetFeerate actual=$actualFeerate") +case class InvalidFundingSignature (override val channelId: ByteVector32, tx_opt: Option[Transaction]) extends ChannelException(channelId, s"invalid funding signature: tx=${tx_opt.map(_.toString()).getOrElse("n/a")}") +case class InvalidRbfFeerate (override val channelId: ByteVector32, proposed: FeeratePerKw, expected: FeeratePerKw) extends ChannelException(channelId, s"invalid rbf attempt: the feerate must be at least $expected, you proposed $proposed") +case class InvalidRbfAlreadyInProgress (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid rbf attempt: the current rbf attempt must be completed or aborted first") +case class InvalidRbfTxConfirmed (override val channelId: ByteVector32) extends ChannelException(channelId, "no need to rbf, transaction is already confirmed") +case class InvalidRbfAttempt (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid rbf attempt") case class NoMoreHtlcsClosingInProgress (override val channelId: ByteVector32) extends ChannelException(channelId, "cannot send new htlcs, closing in progress") case class NoMoreFeeUpdateClosingInProgress (override val channelId: ByteVector32) extends ChannelException(channelId, "cannot send new update_fee, closing in progress") case class ClosingAlreadyInProgress (override val channelId: ByteVector32) extends ChannelException(channelId, "closing already in progress") @@ -59,6 +79,7 @@ case class ChannelUnavailable (override val channelId: Byte case class InvalidFinalScript (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid final script") case class MissingUpfrontShutdownScript (override val channelId: ByteVector32) extends ChannelException(channelId, "missing upfront shutdown script") case class FundingTxTimedout (override val channelId: ByteVector32) extends ChannelException(channelId, "funding tx timed out") +case class FundingTxDoubleSpent (override val channelId: ByteVector32) extends ChannelException(channelId, "funding tx double spent") case class FundingTxSpent (override val channelId: ByteVector32, spendingTx: Transaction) extends ChannelException(channelId, s"funding tx has been spent by txid=${spendingTx.txid}") case class HtlcsTimedoutDownstream (override val channelId: ByteVector32, htlcs: Set[UpdateAddHtlc]) extends ChannelException(channelId, s"one or more htlcs timed out downstream: ids=${htlcs.take(10).map(_.id).mkString(",")}") // we only display the first 10 ids case class HtlcsWillTimeoutUpstream (override val channelId: ByteVector32, htlcs: Set[UpdateAddHtlc]) extends ChannelException(channelId, s"one or more htlcs that should be fulfilled are close to timing out upstream: ids=${htlcs.take(10).map(_.id).mkString}") // we only display the first 10 ids diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 1da9e1906e..1aeb3e261a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -370,6 +370,23 @@ object Helpers { Some(channelConf.minDepthBlocks.max(blocksToReachFunding)) } + /** + * When using dual funding, we wait for multiple confirmations even if we're the initiator because: + * - our peer may also contribute to the funding transaction + * - even if they don't, we may RBF the transaction and don't want to handle reorgs + */ + def minDepthDualFunding(channelConf: ChannelConf, channelFeatures: ChannelFeatures, fundingParams: InteractiveTxBuilder.InteractiveTxParams): Option[Long] = { + if (fundingParams.isInitiator && fundingParams.remoteAmount == 0.sat) { + if (channelFeatures.hasFeature(Features.ZeroConf)) { + None + } else { + Some(channelConf.minDepthBlocks) + } + } else { + minDepthFundee(channelConf, channelFeatures, fundingParams.fundingAmount) + } + } + def makeFundingInputInfo(fundingTxId: ByteVector32, fundingTxOutputIndex: Int, fundingSatoshis: Satoshi, fundingPubkey1: PublicKey, fundingPubkey2: PublicKey): InputInfo = { val fundingScript = multiSig2of2(fundingPubkey1, fundingPubkey2) val fundingTxOut = TxOut(fundingSatoshis, pay2wsh(fundingScript)) @@ -381,9 +398,14 @@ object Helpers { * * @return (localSpec, localTx, remoteSpec, remoteTx, fundingTxOutput) */ - def makeFirstCommitTxs(keyManager: ChannelKeyManager, channelConfig: ChannelConfig, channelFeatures: ChannelFeatures, temporaryChannelId: ByteVector32, localParams: LocalParams, remoteParams: RemoteParams, fundingAmount: Satoshi, pushMsat: MilliSatoshi, commitTxFeerate: FeeratePerKw, fundingTxHash: ByteVector32, fundingTxOutputIndex: Int, remoteFirstPerCommitmentPoint: PublicKey): Either[ChannelException, (CommitmentSpec, CommitTx, CommitmentSpec, CommitTx)] = { - val toLocalMsat = if (localParams.isInitiator) fundingAmount.toMilliSatoshi - pushMsat else pushMsat - val toRemoteMsat = if (localParams.isInitiator) pushMsat else fundingAmount.toMilliSatoshi - pushMsat + def makeFirstCommitTxs(keyManager: ChannelKeyManager, channelConfig: ChannelConfig, channelFeatures: ChannelFeatures, temporaryChannelId: ByteVector32, + localParams: LocalParams, remoteParams: RemoteParams, + localFundingAmount: Satoshi, remoteFundingAmount: Satoshi, pushMsat: MilliSatoshi, + commitTxFeerate: FeeratePerKw, + fundingTxHash: ByteVector32, fundingTxOutputIndex: Int, + remoteFirstPerCommitmentPoint: PublicKey): Either[ChannelException, (CommitmentSpec, CommitTx, CommitmentSpec, CommitTx)] = { + val toLocalMsat = if (localParams.isInitiator) localFundingAmount.toMilliSatoshi - pushMsat else localFundingAmount.toMilliSatoshi + pushMsat + val toRemoteMsat = if (localParams.isInitiator) remoteFundingAmount.toMilliSatoshi + pushMsat else remoteFundingAmount.toMilliSatoshi - pushMsat val localSpec = CommitmentSpec(Set.empty[DirectedHtlc], commitTxFeerate, toLocal = toLocalMsat, toRemote = toRemoteMsat) val remoteSpec = CommitmentSpec(Set.empty[DirectedHtlc], commitTxFeerate, toLocal = toRemoteMsat, toRemote = toLocalMsat) @@ -392,7 +414,11 @@ object Helpers { // they initiated the channel open, therefore they pay the fee: we need to make sure they can afford it! val toRemoteMsat = remoteSpec.toLocal val fees = commitTxTotalCost(remoteParams.dustLimit, remoteSpec, channelFeatures.commitmentFormat) - val reserve = localParams.requestedChannelReserve_opt.getOrElse(0 sat) + val reserve = if (channelFeatures.hasFeature(Features.DualFunding)) { + ((localFundingAmount + remoteFundingAmount) / 100).max(localParams.dustLimit) + } else { + localParams.requestedChannelReserve_opt.getOrElse(0 sat) + } val missing = toRemoteMsat.truncateToSatoshi - reserve - fees if (missing < Satoshi(0)) { return Left(CannotAffordFees(temporaryChannelId, missing = -missing, reserve = reserve, fees = fees)) @@ -401,7 +427,7 @@ object Helpers { val fundingPubKey = keyManager.fundingPublicKey(localParams.fundingKeyPath) val channelKeyPath = keyManager.keyPath(localParams, channelConfig) - val commitmentInput = makeFundingInputInfo(fundingTxHash, fundingTxOutputIndex, fundingAmount, fundingPubKey.publicKey, remoteParams.fundingPubKey) + val commitmentInput = makeFundingInputInfo(fundingTxHash, fundingTxOutputIndex, localFundingAmount + remoteFundingAmount, fundingPubKey.publicKey, remoteParams.fundingPubKey) val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, 0) val (localCommitTx, _) = Commitments.makeLocalTxs(keyManager, channelConfig, channelFeatures, 0, localParams, remoteParams, commitmentInput, localPerCommitmentPoint, localSpec) val (remoteCommitTx, _) = Commitments.makeRemoteTxs(keyManager, channelConfig, channelFeatures, 0, localParams, remoteParams, commitmentInput, remoteFirstPerCommitmentPoint, remoteSpec) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala new file mode 100644 index 0000000000..78a50f9552 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -0,0 +1,770 @@ +/* + * Copyright 2022 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.channel + +import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.bitcoin.ScriptFlags +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, LexicographicalOrdering, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut} +import fr.acinq.eclair.blockchain.OnChainChannelFunder +import fr.acinq.eclair.blockchain.OnChainWallet.SignTransactionResponse +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.Helpers.Funding +import fr.acinq.eclair.crypto.ShaChain +import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions.TxOwner +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{Logs, MilliSatoshiLong, UInt64, randomBytes, randomKey} +import scodec.bits.{ByteVector, HexStringSyntax} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +/** + * Created by t-bast on 27/04/2022. + */ + +/** + * This actor implements the interactive-tx protocol. + * It allows two participants to collaborate to create a shared transaction. + * This is a turn-based protocol: each participant sends one message and then waits for the other participant's response. + * + * This actor returns [[InteractiveTxBuilder.Succeeded]] once we're ready to send our signatures for the shared + * transaction. Once they are sent, we must remember it because the transaction may confirm (unless it is double-spent). + * + * Note that this actor doesn't handle the RBF messages: the parent actor must decide whether they accept an RBF attempt + * and how much they want to contribute. + * + * This actor locks utxos for the duration of the protocol. When the protocol fails, it will automatically unlock them. + * If this actor is killed, it may not be able to properly unlock utxos, so the parent should instead wait for this + * actor to stop itself. The parent can use [[InteractiveTxBuilder.Abort]] to gracefully stop the protocol. + */ +object InteractiveTxBuilder { + + // Example flow: + // +-------+ +-------+ + // | |-------- tx_add_input ------>| | + // | |<------- tx_add_input -------| | + // | |-------- tx_add_output ----->| | + // | |<------- tx_add_output ------| | + // | |-------- tx_add_input ------>| | + // | A |<------- tx_complete --------| B | + // | |-------- tx_remove_output -->| | + // | |<------- tx_add_output ------| | + // | |-------- tx_complete ------->| | + // | |<------- tx_complete --------| | + // | |-------- commit_sig -------->| | + // | |<------- commit_sig ---------| | + // | |-------- tx_signatures ----->| | + // | |<------- tx_signatures ------| | + // +-------+ +-------+ + + // @formatter:off + sealed trait Command + case class Start(replyTo: ActorRef[Response], previousTransactions: Seq[SignedSharedTransaction]) extends Command + sealed trait ReceiveMessage extends Command + case class ReceiveTxMessage(msg: InteractiveTxConstructionMessage) extends ReceiveMessage + case class ReceiveCommitSig(msg: CommitSig) extends ReceiveMessage + case class ReceiveTxSigs(msg: TxSignatures) extends ReceiveMessage + case object Abort extends Command + private case class FundTransactionResult(tx: Transaction) extends Command + private case class InputDetails(usableInputs: Seq[TxAddInput], unusableInputs: Set[UnusableInput]) extends Command + private case class SignTransactionResult(signedTx: PartiallySignedSharedTransaction, remoteSigs_opt: Option[TxSignatures]) extends Command + private case class WalletFailure(t: Throwable) extends Command + private case object UtxosUnlocked extends Command + + sealed trait Response + case class SendMessage(msg: LightningMessage) extends Response + case class Succeeded(fundingParams: InteractiveTxParams, sharedTx: SignedSharedTransaction, commitments: Commitments) extends Response + sealed trait Failed extends Response { def cause: ChannelException } + case class LocalFailure(cause: ChannelException) extends Failed + case class RemoteFailure(cause: ChannelException) extends Failed + // @formatter:on + + case class InteractiveTxParams(channelId: ByteVector32, + isInitiator: Boolean, + localAmount: Satoshi, + remoteAmount: Satoshi, + fundingPubkeyScript: ByteVector, + lockTime: Long, + dustLimit: Satoshi, + targetFeerate: FeeratePerKw) { + val fundingAmount: Satoshi = localAmount + remoteAmount + } + + case class InteractiveTxSession(toSend: Seq[Either[TxAddInput, TxAddOutput]], + localInputs: Seq[TxAddInput] = Nil, + remoteInputs: Seq[TxAddInput] = Nil, + localOutputs: Seq[TxAddOutput] = Nil, + remoteOutputs: Seq[TxAddOutput] = Nil, + txCompleteSent: Boolean = false, + txCompleteReceived: Boolean = false, + inputsReceivedCount: Int = 0, + outputsReceivedCount: Int = 0) { + val isComplete: Boolean = txCompleteSent && txCompleteReceived + } + + /** Inputs and outputs we contribute to the funding transaction. */ + case class FundingContributions(inputs: Seq[TxAddInput], outputs: Seq[TxAddOutput]) + + /** A lighter version of our peer's TxAddInput that avoids storing potentially large messages in our DB. */ + case class RemoteTxAddInput(serialId: UInt64, outPoint: OutPoint, txOut: TxOut, sequence: Long) + + object RemoteTxAddInput { + def apply(i: TxAddInput): RemoteTxAddInput = RemoteTxAddInput(i.serialId, toOutPoint(i), i.previousTx.txOut(i.previousTxOutput.toInt), i.sequence) + } + + /** A lighter version of our peer's TxAddOutput that avoids storing potentially large messages in our DB. */ + case class RemoteTxAddOutput(serialId: UInt64, amount: Satoshi, pubkeyScript: ByteVector) + + object RemoteTxAddOutput { + def apply(o: TxAddOutput): RemoteTxAddOutput = RemoteTxAddOutput(o.serialId, o.amount, o.pubkeyScript) + } + + /** A wallet input that doesn't match interactive-tx construction requirements. */ + case class UnusableInput(outpoint: OutPoint) + + /** Unsigned transaction created collaboratively. */ + case class SharedTransaction(localInputs: Seq[TxAddInput], remoteInputs: Seq[RemoteTxAddInput], localOutputs: Seq[TxAddOutput], remoteOutputs: Seq[RemoteTxAddOutput], lockTime: Long) { + val localAmountIn: Satoshi = localInputs.map(i => i.previousTx.txOut(i.previousTxOutput.toInt).amount).sum + val remoteAmountIn: Satoshi = remoteInputs.map(_.txOut.amount).sum + val totalAmountIn: Satoshi = localAmountIn + remoteAmountIn + val fees: Satoshi = totalAmountIn - localOutputs.map(_.amount).sum - remoteOutputs.map(_.amount).sum + + def localFees(params: InteractiveTxParams): Satoshi = { + val localAmountOut = params.localAmount + localOutputs.filter(_.pubkeyScript != params.fundingPubkeyScript).map(_.amount).sum + localAmountIn - localAmountOut + } + + def buildUnsignedTx(): Transaction = { + val localTxIn = localInputs.map(i => (i.serialId, TxIn(toOutPoint(i), ByteVector.empty, i.sequence))) + val remoteTxIn = remoteInputs.map(i => (i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence))) + val inputs = (localTxIn ++ remoteTxIn).sortBy(_._1).map(_._2) + val localTxOut = localOutputs.map(o => (o.serialId, TxOut(o.amount, o.pubkeyScript))) + val remoteTxOut = remoteOutputs.map(o => (o.serialId, TxOut(o.amount, o.pubkeyScript))) + val outputs = (localTxOut ++ remoteTxOut).sortBy(_._1).map(_._2) + Transaction(2, inputs, outputs, lockTime) + } + } + + // @formatter:off + sealed trait SignedSharedTransaction { + def tx: SharedTransaction + def localSigs: TxSignatures + } + case class PartiallySignedSharedTransaction(tx: SharedTransaction, localSigs: TxSignatures) extends SignedSharedTransaction + case class FullySignedSharedTransaction(tx: SharedTransaction, localSigs: TxSignatures, remoteSigs: TxSignatures) extends SignedSharedTransaction { + val signedTx: Transaction = { + import tx._ + require(localSigs.witnesses.length == localInputs.length, "the number of local signatures does not match the number of local inputs") + require(remoteSigs.witnesses.length == remoteInputs.length, "the number of remote signatures does not match the number of remote inputs") + val signedLocalInputs = localInputs.sortBy(_.serialId).zip(localSigs.witnesses).map { case (i, w) => (i.serialId, TxIn(toOutPoint(i), ByteVector.empty, i.sequence, w)) } + val signedRemoteInputs = remoteInputs.sortBy(_.serialId).zip(remoteSigs.witnesses).map { case (i, w) => (i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence, w)) } + val inputs = (signedLocalInputs ++ signedRemoteInputs).sortBy(_._1).map(_._2) + val localTxOut = localOutputs.map(o => (o.serialId, TxOut(o.amount, o.pubkeyScript))) + val remoteTxOut = remoteOutputs.map(o => (o.serialId, TxOut(o.amount, o.pubkeyScript))) + val outputs = (localTxOut ++ remoteTxOut).sortBy(_._1).map(_._2) + Transaction(2, inputs, outputs, lockTime) + } + val feerate: FeeratePerKw = Transactions.fee2rate(tx.fees, signedTx.weight()) + } + // @formatter:on + + def apply(remoteNodeId: PublicKey, + fundingParams: InteractiveTxParams, + keyManager: ChannelKeyManager, + localParams: LocalParams, + remoteParams: RemoteParams, + commitTxFeerate: FeeratePerKw, + remoteFirstPerCommitmentPoint: PublicKey, + channelFlags: ChannelFlags, + channelConfig: ChannelConfig, + channelFeatures: ChannelFeatures, + wallet: OnChainChannelFunder)(implicit ec: ExecutionContext): Behavior[Command] = { + Behaviors.setup { context => + // The stash is used to buffer messages that arrive while we're funding the transaction. + // Since the interactive-tx protocol is turn-based, we should not have more than one stashed lightning message. + // We may also receive commands from our parent, but we shouldn't receive many, so we can keep the stash size small. + Behaviors.withStash(10) { stash => + Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(remoteNodeId), channelId_opt = Some(fundingParams.channelId))) { + Behaviors.receiveMessagePartial { + case Start(replyTo, previousTransactions) => + val actor = new InteractiveTxBuilder(replyTo, fundingParams, keyManager, localParams, remoteParams, commitTxFeerate, remoteFirstPerCommitmentPoint, channelFlags, channelConfig, channelFeatures, wallet, previousTransactions, stash, context) + actor.start() + case Abort => Behaviors.stopped + } + } + } + } + } + + // We restrict the number of inputs / outputs that our peer can send us to ensure the protocol eventually ends. + val MAX_INPUTS_OUTPUTS_RECEIVED = 4096 + + def toOutPoint(input: TxAddInput): OutPoint = OutPoint(input.previousTx, input.previousTxOutput.toInt) + + def addRemoteSigs(fundingParams: InteractiveTxParams, partiallySignedTx: PartiallySignedSharedTransaction, remoteSigs: TxSignatures): Either[ChannelException, FullySignedSharedTransaction] = { + if (partiallySignedTx.tx.localInputs.length != partiallySignedTx.localSigs.witnesses.length) { + return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.tx.buildUnsignedTx()))) + } + if (partiallySignedTx.tx.remoteInputs.length != remoteSigs.witnesses.length) { + return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.tx.buildUnsignedTx()))) + } + val txWithSigs = FullySignedSharedTransaction(partiallySignedTx.tx, partiallySignedTx.localSigs, remoteSigs) + if (remoteSigs.txId != txWithSigs.signedTx.txid) { + return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.tx.buildUnsignedTx()))) + } + // We allow a 5% error margin since witness size prediction could be inaccurate. + if (fundingParams.localAmount > 0.sat && txWithSigs.feerate < fundingParams.targetFeerate * 0.95) { + return Left(InvalidFundingFeerate(fundingParams.channelId, fundingParams.targetFeerate, txWithSigs.feerate)) + } + val previousOutputs = { + val localOutputs = txWithSigs.tx.localInputs.map(i => toOutPoint(i) -> i.previousTx.txOut(i.previousTxOutput.toInt)).toMap + val remoteOutputs = txWithSigs.tx.remoteInputs.map(i => i.outPoint -> i.txOut).toMap + localOutputs ++ remoteOutputs + } + Try(Transaction.correctlySpends(txWithSigs.signedTx, previousOutputs, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) match { + case Failure(_) => Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.tx.buildUnsignedTx()))) // NB: we don't send our signatures to our peer. + case Success(_) => Right(txWithSigs) + } + } + +} + +/** + * @param previousTransactions interactive transactions are replaceable and can be RBF-ed, but we need to make sure that + * only one of them ends up confirming. We guarantee this by having the latest transaction + * always double-spend all its predecessors. + */ +private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Response], + fundingParams: InteractiveTxBuilder.InteractiveTxParams, + keyManager: ChannelKeyManager, + localParams: LocalParams, + remoteParams: RemoteParams, + commitTxFeerate: FeeratePerKw, + remoteFirstPerCommitmentPoint: PublicKey, + channelFlags: ChannelFlags, + channelConfig: ChannelConfig, + channelFeatures: ChannelFeatures, + wallet: OnChainChannelFunder, + previousTransactions: Seq[InteractiveTxBuilder.SignedSharedTransaction], + stash: StashBuffer[InteractiveTxBuilder.Command], + context: ActorContext[InteractiveTxBuilder.Command])(implicit ec: ExecutionContext) { + + import InteractiveTxBuilder._ + + private val log = context.log + + def start(): Behavior[Command] = { + val toFund = if (fundingParams.isInitiator) { + // If we're the initiator, we need to pay the fees of the common fields of the transaction, even if we don't want + // to contribute to the shared output. We create a non-zero amount here to ensure that bitcoind will fund the + // fees for the shared output (because it would otherwise reject a txOut with an amount of zero). + fundingParams.localAmount.max(fundingParams.dustLimit) + } else { + fundingParams.localAmount + } + require(toFund >= 0.sat, "funding amount cannot be negative") + log.debug("contributing {} to interactive-tx construction", toFund) + if (toFund == 0.sat) { + // We're not the initiator and we don't want to contribute to the funding transaction. + buildTx(FundingContributions(Nil, Nil)) + } else { + // We always double-spend all our previous inputs. It's technically overkill because we only really need to double + // spend one input of each previous tx, but it's simpler and less error-prone this way. It also ensures that in + // most cases, we won't need to add new inputs and will simply lower the change amount. + val previousInputs = previousTransactions.flatMap(_.tx.localInputs).distinctBy(_.serialId) + val dummyTx = Transaction(2, previousInputs.map(i => TxIn(toOutPoint(i), ByteVector.empty, i.sequence)), Seq(TxOut(toFund, fundingParams.fundingPubkeyScript)), fundingParams.lockTime) + fund(dummyTx, previousInputs, Set.empty) + } + } + + /** + * We (ab)use bitcoind's `fundrawtransaction` to select available utxos from our wallet. Not all utxos are suitable + * for dual funding though (e.g. they need to use segwit), so we filter them and iterate until we have a valid set of + * inputs. + */ + def fund(txNotFunded: Transaction, currentInputs: Seq[TxAddInput], unusableInputs: Set[UnusableInput]): Behavior[Command] = { + context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, replaceable = true, lockUtxos = true)) { + case Failure(t) => WalletFailure(t) + case Success(result) => FundTransactionResult(result.tx) + } + Behaviors.receiveMessagePartial { + case FundTransactionResult(fundedTx) => + // Those inputs were already selected by bitcoind and considered unsuitable for interactive tx. + val lockedUnusableInputs = fundedTx.txIn.map(_.outPoint).filter(o => unusableInputs.map(_.outpoint).contains(o)) + if (lockedUnusableInputs.nonEmpty) { + // We're keeping unusable inputs locked to ensure that bitcoind doesn't use them for funding, otherwise we + // could be stuck in an infinite loop where bitcoind constantly adds the same inputs that we cannot use. + log.error("could not fund interactive tx: bitcoind included already known unusable inputs that should have been locked: {}", lockedUnusableInputs.mkString(",")) + unlockAndStop(currentInputs.map(toOutPoint).toSet ++ fundedTx.txIn.map(_.outPoint) ++ unusableInputs.map(_.outpoint)) + } else { + filterInputs(fundedTx, currentInputs, unusableInputs) + } + case WalletFailure(t) => + log.error("could not fund interactive tx: ", t) + // We use a generic exception and don't send the internal error to the peer. + replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) + unlockAndStop(currentInputs.map(toOutPoint).toSet ++ unusableInputs.map(_.outpoint)) + case msg: ReceiveMessage => + stash.stash(msg) + Behaviors.same + case Abort => + stash.stash(Abort) + Behaviors.same + } + } + + /** Not all inputs are suitable for interactive tx construction. */ + def filterInputs(fundedTx: Transaction, currentInputs: Seq[TxAddInput], unusableInputs: Set[UnusableInput]): Behavior[Command] = { + context.pipeToSelf(Future.sequence(fundedTx.txIn.map(txIn => getInputDetails(txIn, currentInputs)))) { + case Failure(t) => WalletFailure(t) + case Success(results) => InputDetails(results.collect { case Right(i) => i }, results.collect { case Left(i) => i }.toSet) + } + Behaviors.receiveMessagePartial { + case inputDetails: InputDetails if inputDetails.unusableInputs.isEmpty => + // This funding iteration did not add any unusable inputs, so we can directly return the results. + val (fundingOutputs, otherOutputs) = fundedTx.txOut.partition(_.publicKeyScript == fundingParams.fundingPubkeyScript) + // The transaction should still contain the funding output, with at most one change output added by bitcoind. + if (fundingOutputs.length != 1) { + log.error("funded transaction is missing the funding output: {}", fundedTx) + replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) + unlockAndStop(fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint)) + } else if (otherOutputs.length > 1) { + log.error("funded transaction contains unexpected outputs: {}", fundedTx) + replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) + unlockAndStop(fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint)) + } else { + val changeOutput_opt = otherOutputs.headOption.map(txOut => TxAddOutput(fundingParams.channelId, generateSerialId(), txOut.amount, txOut.publicKeyScript)) + val outputs = if (fundingParams.isInitiator) { + val initiatorChangeOutput = changeOutput_opt match { + case Some(changeOutput) if fundingParams.localAmount == 0.sat => + // If the initiator doesn't want to contribute, we should cancel the dummy amount artificially added previously. + val dummyFundingAmount = fundingOutputs.head.amount + Seq(changeOutput.copy(amount = changeOutput.amount + dummyFundingAmount)) + case Some(changeOutput) => Seq(changeOutput) + case None => Nil + } + // The initiator is responsible for adding the shared output. + val fundingOutput = TxAddOutput(fundingParams.channelId, generateSerialId(), fundingParams.fundingAmount, fundingParams.fundingPubkeyScript) + fundingOutput +: initiatorChangeOutput + } else { + // The protocol only requires the non-initiator to pay the fees for its inputs and outputs, discounting the + // common fields (shared output, version, nLockTime, etc). By using bitcoind's fundrawtransaction we are + // currently paying fees for those fields, but we can fix that by increasing our change output accordingly. + // If we don't have a change output, we will slightly overpay the fees: fixing this is not worth the extra + // complexity of adding a change output, which would require a call to bitcoind to get a change address. + changeOutput_opt match { + case Some(changeOutput) => + val commonWeight = Transaction(2, Nil, Seq(TxOut(fundingParams.fundingAmount, fundingParams.fundingPubkeyScript)), 0).weight() + val overpaidFees = Transactions.weight2fee(fundingParams.targetFeerate, commonWeight) + Seq(changeOutput.copy(amount = changeOutput.amount + overpaidFees)) + case None => Nil + } + } + log.info("added {} inputs and {} outputs to interactive tx", inputDetails.usableInputs.length, outputs.length) + // We unlock the unusable inputs from previous iterations (if any) as they can be used outside of this session. + unlock(unusableInputs.map(_.outpoint)) + stash.unstashAll(buildTx(FundingContributions(inputDetails.usableInputs, outputs))) + } + case inputDetails: InputDetails if inputDetails.unusableInputs.nonEmpty => + // Some wallet inputs are unusable, so we must fund again to obtain usable inputs instead. + log.info("retrying funding as some utxos cannot be used for interactive-tx construction: {}", inputDetails.unusableInputs.map(i => s"${i.outpoint.txid}:${i.outpoint.index}").mkString(",")) + val sanitizedTx = fundedTx.copy( + txIn = fundedTx.txIn.filter(txIn => !inputDetails.unusableInputs.map(_.outpoint).contains(txIn.outPoint)), + // We remove the change output added by this funding iteration. + txOut = fundedTx.txOut.filter(txOut => txOut.publicKeyScript == fundingParams.fundingPubkeyScript), + ) + fund(sanitizedTx, inputDetails.usableInputs, unusableInputs ++ inputDetails.unusableInputs) + case WalletFailure(t) => + log.error("could not get input details: ", t) + // We use a generic exception and don't send the internal error to the peer. + replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) + unlockAndStop(fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint)) + case msg: ReceiveMessage => + stash.stash(msg) + Behaviors.same + case Abort => + stash.stash(Abort) + Behaviors.same + } + } + + /** + * @param txIn input we'd like to include in the transaction, if suitable. + * @param currentInputs already known valid inputs, we don't need to fetch the details again for those. + * @return the input is either unusable (left) or we'll send a [[TxAddInput]] command to add it to the transaction (right). + */ + private def getInputDetails(txIn: TxIn, currentInputs: Seq[TxAddInput]): Future[Either[UnusableInput, TxAddInput]] = { + currentInputs.find(i => txIn.outPoint == toOutPoint(i)) match { + case Some(previousInput) => Future.successful(Right(previousInput)) + case None => wallet.getTransaction(txIn.outPoint.txid).map(previousTx => { + if (Transaction.write(previousTx).length > 65000) { + // Wallet input transaction is too big to fit inside tx_add_input. + Left(UnusableInput(txIn.outPoint)) + } else if (!Script.isNativeWitnessScript(previousTx.txOut(txIn.outPoint.index.toInt).publicKeyScript)) { + // Wallet input must be a native segwit input. + Left(UnusableInput(txIn.outPoint)) + } else { + Right(TxAddInput(fundingParams.channelId, generateSerialId(), previousTx, txIn.outPoint.index, txIn.sequence)) + } + }) + } + } + + def buildTx(localContributions: FundingContributions): Behavior[Command] = { + val toSend = localContributions.inputs.map(Left(_)) ++ localContributions.outputs.map(Right(_)) + if (fundingParams.isInitiator) { + // The initiator sends the first message. + send(InteractiveTxSession(toSend)) + } else { + // The non-initiator waits for the initiator to send the first message. + receive(InteractiveTxSession(toSend)) + } + } + + def send(session: InteractiveTxSession): Behavior[Command] = { + session.toSend match { + case Left(addInput) +: tail => + replyTo ! SendMessage(addInput) + val next = session.copy(toSend = tail, localInputs = session.localInputs :+ addInput, txCompleteSent = false) + receive(next) + case Right(addOutput) +: tail => + replyTo ! SendMessage(addOutput) + val next = session.copy(toSend = tail, localOutputs = session.localOutputs :+ addOutput, txCompleteSent = false) + receive(next) + case Nil => + replyTo ! SendMessage(TxComplete(fundingParams.channelId)) + val next = session.copy(txCompleteSent = true) + if (next.isComplete) { + validateAndSign(next) + } else { + receive(next) + } + } + } + + def receive(session: InteractiveTxSession): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case ReceiveTxMessage(msg) => msg match { + case msg: HasSerialId if msg.serialId.toByteVector.bits.last != fundingParams.isInitiator => + replyTo ! RemoteFailure(InvalidSerialId(fundingParams.channelId, msg.serialId)) + unlockAndStop(session) + case addInput: TxAddInput => + if (session.inputsReceivedCount + 1 >= MAX_INPUTS_OUTPUTS_RECEIVED) { + replyTo ! RemoteFailure(TooManyInteractiveTxRounds(fundingParams.channelId)) + unlockAndStop(session) + } else if (session.remoteInputs.exists(_.serialId == addInput.serialId)) { + replyTo ! RemoteFailure(DuplicateSerialId(fundingParams.channelId, addInput.serialId)) + unlockAndStop(session) + } else if (session.localInputs.exists(i => toOutPoint(i) == toOutPoint(addInput)) || session.remoteInputs.exists(i => toOutPoint(i) == toOutPoint(addInput))) { + replyTo ! RemoteFailure(DuplicateInput(fundingParams.channelId, addInput.serialId, addInput.previousTx.txid, addInput.previousTxOutput)) + unlockAndStop(session) + } else if (addInput.previousTx.txOut.length <= addInput.previousTxOutput) { + replyTo ! RemoteFailure(InputOutOfBounds(fundingParams.channelId, addInput.serialId, addInput.previousTx.txid, addInput.previousTxOutput)) + unlockAndStop(session) + } else if (!Script.isNativeWitnessScript(addInput.previousTx.txOut(addInput.previousTxOutput.toInt).publicKeyScript)) { + replyTo ! RemoteFailure(NonSegwitInput(fundingParams.channelId, addInput.serialId, addInput.previousTx.txid, addInput.previousTxOutput)) + unlockAndStop(session) + } else { + val next = session.copy( + remoteInputs = session.remoteInputs :+ addInput, + inputsReceivedCount = session.inputsReceivedCount + 1, + txCompleteReceived = false, + ) + send(next) + } + case addOutput: TxAddOutput => + if (session.outputsReceivedCount + 1 >= MAX_INPUTS_OUTPUTS_RECEIVED) { + replyTo ! RemoteFailure(TooManyInteractiveTxRounds(fundingParams.channelId)) + unlockAndStop(session) + } else if (session.remoteOutputs.exists(_.serialId == addOutput.serialId)) { + replyTo ! RemoteFailure(DuplicateSerialId(fundingParams.channelId, addOutput.serialId)) + unlockAndStop(session) + } else if (addOutput.amount < fundingParams.dustLimit) { + replyTo ! RemoteFailure(OutputBelowDust(fundingParams.channelId, addOutput.serialId, addOutput.amount, fundingParams.dustLimit)) + unlockAndStop(session) + } else if (!Script.isNativeWitnessScript(addOutput.pubkeyScript)) { + replyTo ! RemoteFailure(NonSegwitOutput(fundingParams.channelId, addOutput.serialId)) + unlockAndStop(session) + } else { + val next = session.copy( + remoteOutputs = session.remoteOutputs :+ addOutput, + outputsReceivedCount = session.outputsReceivedCount + 1, + txCompleteReceived = false, + ) + send(next) + } + case removeInput: TxRemoveInput => + session.remoteInputs.find(_.serialId == removeInput.serialId) match { + case Some(_) => + val next = session.copy( + remoteInputs = session.remoteInputs.filterNot(_.serialId == removeInput.serialId), + txCompleteReceived = false, + ) + send(next) + case None => + replyTo ! RemoteFailure(UnknownSerialId(fundingParams.channelId, removeInput.serialId)) + unlockAndStop(session) + } + case removeOutput: TxRemoveOutput => + session.remoteOutputs.find(_.serialId == removeOutput.serialId) match { + case Some(_) => + val next = session.copy( + remoteOutputs = session.remoteOutputs.filterNot(_.serialId == removeOutput.serialId), + txCompleteReceived = false, + ) + send(next) + case None => + replyTo ! RemoteFailure(UnknownSerialId(fundingParams.channelId, removeOutput.serialId)) + unlockAndStop(session) + } + case _: TxComplete => + val next = session.copy(txCompleteReceived = true) + if (next.isComplete) { + validateAndSign(next) + } else { + send(next) + } + } + case _: ReceiveCommitSig => + replyTo ! RemoteFailure(UnexpectedCommitSig(fundingParams.channelId)) + unlockAndStop(session) + case _: ReceiveTxSigs => + replyTo ! RemoteFailure(UnexpectedFundingSignatures(fundingParams.channelId)) + unlockAndStop(session) + case Abort => + unlockAndStop(session) + } + } + + def validateAndSign(session: InteractiveTxSession): Behavior[Command] = { + require(session.isComplete, "interactive session was not completed") + validateTx(session) match { + case Left(cause) => + replyTo ! RemoteFailure(cause) + unlockAndStop(session) + case Right((completeTx, fundingOutputIndex)) => + signCommitTx(completeTx, fundingOutputIndex) + } + } + + def validateTx(session: InteractiveTxSession): Either[ChannelException, (SharedTransaction, Int)] = { + val sharedTx = SharedTransaction(session.localInputs, session.remoteInputs.map(i => RemoteTxAddInput(i)), session.localOutputs, session.remoteOutputs.map(o => RemoteTxAddOutput(o)), fundingParams.lockTime) + val tx = sharedTx.buildUnsignedTx() + + if (tx.txIn.length > 252 || tx.txOut.length > 252) { + log.warn("invalid interactive tx ({} inputs and {} outputs)", tx.txIn.length, tx.txOut.length) + return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) + } + + val sharedOutputs = tx.txOut.zipWithIndex.filter(_._1.publicKeyScript == fundingParams.fundingPubkeyScript) + if (sharedOutputs.length != 1) { + log.warn("invalid interactive tx: funding outpoint not included (tx={})", tx) + return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) + } + val (sharedOutput, sharedOutputIndex) = sharedOutputs.head + if (sharedOutput.amount != fundingParams.fundingAmount) { + log.warn("invalid interactive tx: invalid funding amount (expected={}, actual={})", fundingParams.fundingAmount, sharedOutput.amount) + return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) + } + + val localAmountOut = sharedTx.localOutputs.filter(_.pubkeyScript != fundingParams.fundingPubkeyScript).map(_.amount).sum + fundingParams.localAmount + val remoteAmountOut = sharedTx.remoteOutputs.filter(_.pubkeyScript != fundingParams.fundingPubkeyScript).map(_.amount).sum + fundingParams.remoteAmount + if (sharedTx.localAmountIn < localAmountOut || sharedTx.remoteAmountIn < remoteAmountOut) { + log.warn("invalid interactive tx: input amount is too small (localIn={}, localOut={}, remoteIn={}, remoteOut={})", sharedTx.localAmountIn, localAmountOut, sharedTx.remoteAmountIn, remoteAmountOut) + return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) + } + + // The transaction isn't signed yet, so we estimate its weight knowing that all inputs are using native segwit. + val minimumWitnessWeight = 107 // see Bolt 3 + val minimumWeight = tx.weight() + tx.txIn.length * minimumWitnessWeight + if (minimumWeight > Transactions.MAX_STANDARD_TX_WEIGHT) { + log.warn("invalid interactive tx: exceeds standard weight (weight={})", minimumWeight) + return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) + } + + val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, minimumWeight) + if (sharedTx.fees < minimumFee) { + log.warn("invalid interactive tx: below the target feerate (target={}, actual={})", fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, minimumWeight)) + return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) + } + + // The transaction must double-spent every previous attempt, otherwise there is a risk that two funding transactions + // confirm for the same channel. + val currentInputs = tx.txIn.map(_.outPoint).toSet + 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)) + } + + Right(sharedTx, sharedOutputIndex) + } + + def signCommitTx(completeTx: SharedTransaction, fundingOutputIndex: Int): Behavior[Command] = { + val fundingTx = completeTx.buildUnsignedTx() + Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, fundingParams.channelId, localParams, remoteParams, fundingParams.localAmount, fundingParams.remoteAmount, 0 msat, commitTxFeerate, fundingTx.hash, fundingOutputIndex, remoteFirstPerCommitmentPoint) match { + case Left(cause) => + replyTo ! RemoteFailure(cause) + unlockAndStop(completeTx) + case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) => + require(fundingTx.txOut(fundingOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, "pubkey script mismatch!") + val fundingPubKey = keyManager.fundingPublicKey(localParams.fundingKeyPath) + val localSigOfLocalTx = keyManager.sign(localCommitTx, fundingPubKey, TxOwner.Local, channelFeatures.commitmentFormat) + val localSigOfRemoteTx = keyManager.sign(remoteCommitTx, keyManager.fundingPublicKey(localParams.fundingKeyPath), TxOwner.Remote, channelFeatures.commitmentFormat) + val localCommitSig = CommitSig(fundingParams.channelId, localSigOfRemoteTx, Nil) + replyTo ! SendMessage(localCommitSig) + Behaviors.receiveMessagePartial { + case ReceiveCommitSig(remoteCommitSig) => + val signedLocalCommitTx = Transactions.addSigs(localCommitTx, fundingPubKey.publicKey, remoteParams.fundingPubKey, localSigOfLocalTx, remoteCommitSig.signature) + Transactions.checkSpendable(signedLocalCommitTx) match { + case Failure(_) => + replyTo ! RemoteFailure(InvalidCommitmentSignature(fundingParams.channelId, signedLocalCommitTx.tx)) + unlockAndStop(completeTx) + case Success(_) => + val commitments = Commitments( + fundingParams.channelId, channelConfig, channelFeatures, + localParams, remoteParams, channelFlags, + LocalCommit(0, localSpec, CommitTxAndRemoteSig(localCommitTx, remoteCommitSig.signature), htlcTxsAndRemoteSigs = Nil), + RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, remoteFirstPerCommitmentPoint), + LocalChanges(Nil, Nil, Nil), RemoteChanges(Nil, Nil, Nil), + localNextHtlcId = 0L, remoteNextHtlcId = 0L, + originChannels = Map.empty, + remoteNextCommitInfo = Right(randomKey().publicKey), // we will receive their next per-commitment point in the next message, so we temporarily put a random byte array, + localCommitTx.input, + ShaChain.init) + signFundingTx(completeTx, commitments) + } + case ReceiveTxSigs(_) => + replyTo ! RemoteFailure(UnexpectedFundingSignatures(fundingParams.channelId)) + unlockAndStop(completeTx) + case ReceiveTxMessage(msg) => + replyTo ! RemoteFailure(UnexpectedInteractiveTxMessage(fundingParams.channelId, msg)) + unlockAndStop(completeTx) + case Abort => + unlockAndStop(completeTx) + } + } + } + + def signFundingTx(completeTx: SharedTransaction, commitments: Commitments): Behavior[Command] = { + val shouldSignFirst = if (fundingParams.localAmount < fundingParams.remoteAmount) { + // The peer with the lowest total of input amount must transmit its `tx_signatures` first. + true + } else if (fundingParams.localAmount == fundingParams.remoteAmount) { + // When both peers contribute the same amount, the peer with the lowest pubkey must transmit its `tx_signatures` first. + LexicographicalOrdering.isLessThan(commitments.localParams.nodeId.value, commitments.remoteNodeId.value) + } else { + false + } + if (shouldSignFirst) { + signTx(completeTx, None) + } + Behaviors.receiveMessagePartial { + case SignTransactionResult(signedTx, Some(remoteSigs)) => + addRemoteSigs(fundingParams, signedTx, remoteSigs) match { + case Left(cause) => + replyTo ! RemoteFailure(cause) + unlockAndStop(completeTx) + case Right(fullySignedTx) => + log.info("interactive-tx fully signed with {} local inputs, {} remote inputs, {} local outputs and {} remote outputs", fullySignedTx.tx.localInputs.length, fullySignedTx.tx.remoteInputs.length, fullySignedTx.tx.localOutputs.length, fullySignedTx.tx.remoteOutputs.length) + replyTo ! Succeeded(fundingParams, fullySignedTx, commitments) + Behaviors.stopped + } + case SignTransactionResult(signedTx, None) => + log.info("interactive-tx partially signed with {} local inputs, {} remote inputs, {} local outputs and {} remote outputs", signedTx.tx.localInputs.length, signedTx.tx.remoteInputs.length, signedTx.tx.localOutputs.length, signedTx.tx.remoteOutputs.length) + replyTo ! Succeeded(fundingParams, signedTx, commitments) + Behaviors.stopped + case ReceiveTxSigs(remoteSigs) => + signTx(completeTx, Some(remoteSigs)) + Behaviors.same + case WalletFailure(t) => + log.error("could not sign funding transaction: ", t) + // We use a generic exception and don't send the internal error to the peer. + replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) + unlockAndStop(completeTx) + case ReceiveCommitSig(_) => + replyTo ! RemoteFailure(UnexpectedCommitSig(fundingParams.channelId)) + unlockAndStop(completeTx) + case ReceiveTxMessage(msg) => + replyTo ! RemoteFailure(UnexpectedInteractiveTxMessage(fundingParams.channelId, msg)) + unlockAndStop(completeTx) + case Abort => + unlockAndStop(completeTx) + } + } + + private def signTx(unsignedTx: SharedTransaction, remoteSigs_opt: Option[TxSignatures]): Unit = { + val tx = unsignedTx.buildUnsignedTx() + if (unsignedTx.localInputs.isEmpty) { + context.self ! SignTransactionResult(PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx.txid, Nil)), remoteSigs_opt) + } else { + context.pipeToSelf(wallet.signTransaction(tx, allowIncomplete = true).map { + case SignTransactionResponse(signedTx, _) => + val localOutpoints = unsignedTx.localInputs.map(toOutPoint).toSet + val sigs = signedTx.txIn.filter(txIn => localOutpoints.contains(txIn.outPoint)).map(_.witness) + PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx.txid, sigs)) + }) { + case Failure(t) => WalletFailure(t) + case Success(signedTx) => SignTransactionResult(signedTx, remoteSigs_opt) + } + } + } + + def unlockAndStop(session: InteractiveTxSession): Behavior[Command] = { + val localInputs = session.localInputs ++ session.toSend.collect { case Left(addInput) => addInput } + unlockAndStop(localInputs.map(toOutPoint).toSet) + } + + def unlockAndStop(tx: SharedTransaction): Behavior[Command] = { + val localInputs = tx.localInputs.map(toOutPoint).toSet + unlockAndStop(localInputs) + } + + def unlockAndStop(txInputs: Set[OutPoint]): Behavior[Command] = { + // We don't unlock previous inputs as the corresponding funding transaction may confirm. + val previousInputs = previousTransactions.flatMap(_.tx.localInputs.map(toOutPoint)).toSet + val toUnlock = txInputs -- previousInputs + log.debug("unlocking inputs: {}", toUnlock.map(o => s"${o.txid}:${o.index}").mkString(",")) + context.pipeToSelf(unlock(toUnlock))(_ => UtxosUnlocked) + Behaviors.receiveMessagePartial { + case UtxosUnlocked => Behaviors.stopped + } + } + + private def unlock(inputs: Set[OutPoint]): Future[Boolean] = { + if (inputs.isEmpty) { + Future.successful(true) + } else { + val dummyTx = Transaction(2, inputs.toSeq.map(o => TxIn(o, Nil, 0)), Nil, 0) + wallet.rollback(dummyTx) + } + } + + private def generateSerialId(): UInt64 = { + // The initiator must use even values and the non-initiator odd values. + if (fundingParams.isInitiator) { + UInt64(randomBytes(8) & hex"fffffffffffffffe") + } else { + UInt64(randomBytes(8) | hex"0000000000000001") + } + } + +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index f66f9ed9a7..8f4333a41a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -162,10 +162,9 @@ object Channel { class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val remoteNodeId: PublicKey, val blockchain: typed.ActorRef[ZmqWatcher.Command], val relayer: ActorRef, val txPublisherFactory: Channel.TxPublisherFactory, val origin_opt: Option[ActorRef] = None)(implicit val ec: ExecutionContext = ExecutionContext.Implicits.global) extends FSM[ChannelState, ChannelData] with FSMDiagnosticActorLogging[ChannelState, ChannelData] - with ChannelOpenSingleFunder + with ChannelOpenSingleFunded with ChannelOpenDualFunded with CommonHandlers - with FundingHandlers with ErrorHandlers { import Channel._ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala index 77f3230de1..8ff8e24cbe 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala @@ -16,20 +16,23 @@ package fr.acinq.eclair.channel.fsm +import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, actorRefAdapter} import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Script} import fr.acinq.eclair.Features +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.channel.Helpers.Funding +import fr.acinq.eclair.channel.InteractiveTxBuilder.{FullySignedSharedTransaction, InteractiveTxParams, PartiallySignedSharedTransaction} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.channel.fsm.Channel.TickChannelOpenTimeout +import fr.acinq.eclair.channel.fsm.Channel._ import fr.acinq.eclair.channel.publish.TxPublisher.SetChannelId import fr.acinq.eclair.transactions.Scripts -import fr.acinq.eclair.wire.protocol.{AcceptDualFundedChannel, AcceptDualFundedChannelTlv, ChannelTlv, Error, OpenDualFundedChannel, OpenDualFundedChannelTlv, TlvStream} +import fr.acinq.eclair.wire.protocol._ /** * Created by t-bast on 19/04/2022. */ -trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { +trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { this: Channel => @@ -41,7 +44,7 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL | | | accept_channel2 | |<--------------------------------| - WAIT_FOR_DUAL_FUNDING_COMPLETE | | WAIT_FOR_DUAL_FUNDING_COMPLETE + WAIT_FOR_DUAL_FUNDING_CREATED | | WAIT_FOR_DUAL_FUNDING_CREATED | | | . | | . | @@ -50,7 +53,31 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { |-------------------------------->| | tx_complete | |<--------------------------------| - WAIT_FOR_DUAL_FUNDING_SIGNED | | WAIT_FOR_DUAL_FUNDING_SIGNED + | | + | commitment_signed | + |-------------------------------->| + | commitment_signed | + |<--------------------------------| + | tx_signatures | + |<--------------------------------| + | | WAIT_FOR_DUAL_FUNDING_CONFIRMED + | tx_signatures | + |-------------------------------->| + WAIT_FOR_DUAL_FUNDING_CONFIRMED | | + | tx_init_rbf | + |-------------------------------->| + | tx_ack_rbf | + |<--------------------------------| + | | + | | + | . | + | . | + | . | + | tx_complete | + |-------------------------------->| + | tx_complete | + |<--------------------------------| + | | | commitment_signed | |-------------------------------->| | commitment_signed | @@ -59,6 +86,11 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { |<--------------------------------| | tx_signatures | |-------------------------------->| + | | + | | + | . | + | . | + | . | WAIT_FOR_DUAL_FUNDING_LOCKED | | WAIT_FOR_DUAL_FUNDING_LOCKED | funding_locked funding_locked | |---------------- ---------------| @@ -107,7 +139,7 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { case Left(t) => handleLocalError(t, d, Some(open)) case Right((channelFeatures, remoteShutdownScript)) => context.system.eventStream.publish(ChannelCreated(self, peer, remoteNodeId, isInitiator = false, open.temporaryChannelId, open.commitmentFeerate, Some(open.fundingFeerate))) - val fundingPubkey = keyManager.fundingPublicKey(localParams.fundingKeyPath).publicKey + val localFundingPubkey = keyManager.fundingPublicKey(localParams.fundingKeyPath).publicKey val channelKeyPath = keyManager.keyPath(localParams, d.init.channelConfig) val totalFundingAmount = open.fundingAmount + d.init.fundingContribution_opt.getOrElse(0 sat) val minimumDepth = Funding.minDepthFundee(nodeParams.channelConf, channelFeatures, totalFundingAmount) @@ -125,7 +157,7 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { minimumDepth = minimumDepth.getOrElse(0), toSelfDelay = localParams.toSelfDelay, maxAcceptedHtlcs = localParams.maxAcceptedHtlcs, - fundingPubkey = fundingPubkey, + fundingPubkey = localFundingPubkey, revocationBasepoint = keyManager.revocationPoint(channelKeyPath).publicKey, paymentBasepoint = localParams.walletStaticPaymentBasepoint.getOrElse(keyManager.paymentPoint(channelKeyPath).publicKey), delayedPaymentBasepoint = keyManager.delayedPaymentPoint(channelKeyPath).publicKey, @@ -148,11 +180,24 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { initFeatures = remoteInit.features, shutdownScript = remoteShutdownScript) log.debug("remote params: {}", remoteParams) + // We've exchanged open_channel2 and accept_channel2, we now know the final channelId. val channelId = Helpers.computeChannelId(open, accept) peer ! ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages txPublisher ! SetChannelId(remoteNodeId, channelId) context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId)) - goto(WAIT_FOR_DUAL_FUNDING_INTERNAL) using DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL(channelId, localParams, remoteParams, channelFeatures) sending accept + // We start the interactive-tx funding protocol. + val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingPubkey, remoteParams.fundingPubKey))) + val fundingParams = InteractiveTxParams(channelId, localParams.isInitiator, accept.fundingAmount, open.fundingAmount, fundingPubkeyScript, open.lockTime, open.dustLimit.max(accept.dustLimit), open.fundingFeerate) + val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( + remoteNodeId, fundingParams, keyManager, + localParams, remoteParams, + open.commitmentFeerate, + open.firstPerCommitmentPoint, + open.channelFlags, d.init.channelConfig, channelFeatures, + wallet + )) + txBuilder ! InteractiveTxBuilder.Start(self, Nil) + goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, txBuilder, None) sending accept } case Event(c: CloseCommand, d) => handleFastClose(c, d.channelId) @@ -170,6 +215,7 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { channelOpenReplyToUser(Left(LocalError(t))) handleLocalError(t, d, Some(accept)) case Right((channelFeatures, remoteShutdownScript)) => + // We've exchanged open_channel2 and accept_channel2, we now know the final channelId. val channelId = Helpers.computeChannelId(d.lastSent, accept) peer ! ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages txPublisher ! SetChannelId(remoteNodeId, channelId) @@ -190,9 +236,20 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { initFeatures = remoteInit.features, shutdownScript = remoteShutdownScript) log.debug("remote params: {}", remoteParams) + // We start the interactive-tx funding protocol. val localFundingPubkey = keyManager.fundingPublicKey(localParams.fundingKeyPath) val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingPubkey.publicKey, remoteParams.fundingPubKey))) - goto(WAIT_FOR_DUAL_FUNDING_INTERNAL) using DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL(channelId, localParams, remoteParams, channelFeatures) + val fundingParams = InteractiveTxParams(channelId, localParams.isInitiator, d.lastSent.fundingAmount, accept.fundingAmount, fundingPubkeyScript, d.lastSent.lockTime, d.lastSent.dustLimit.max(accept.dustLimit), d.lastSent.fundingFeerate) + val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( + remoteNodeId, fundingParams, keyManager, + localParams, remoteParams, + d.lastSent.commitmentFeerate, + accept.firstPerCommitmentPoint, + d.lastSent.channelFlags, d.init.channelConfig, channelFeatures, + wallet + )) + txBuilder ! InteractiveTxBuilder.Start(self, Nil) + goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, txBuilder, None) } case Event(c: CloseCommand, d: DATA_WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL) => @@ -212,22 +269,88 @@ trait ChannelOpenDualFunded extends FundingHandlers with ErrorHandlers { goto(CLOSED) }) - when(WAIT_FOR_DUAL_FUNDING_INTERNAL)(handleExceptions { - case Event(c: CloseCommand, d: DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL) => + when(WAIT_FOR_DUAL_FUNDING_CREATED)(handleExceptions { + case Event(msg: InteractiveTxMessage, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => + msg match { + case msg: InteractiveTxConstructionMessage => + d.txBuilder ! InteractiveTxBuilder.ReceiveTxMessage(msg) + stay() + case msg: TxSignatures => + d.txBuilder ! InteractiveTxBuilder.ReceiveTxSigs(msg) + stay() + case msg: TxAbort => + log.info("our peer aborted the dual funding flow: ascii='{}' bin={}", msg.toAscii, msg.data) + d.txBuilder ! InteractiveTxBuilder.Abort + channelOpenReplyToUser(Left(LocalError(DualFundingAborted(d.channelId)))) + goto(CLOSED) + case _: TxInitRbf => + log.info("ignoring unexpected tx_init_rbf message") + stay() sending Warning(d.channelId, InvalidRbfAttempt(d.channelId).getMessage) + case _: TxAckRbf => + log.info("ignoring unexpected tx_ack_rbf message") + stay() sending Warning(d.channelId, InvalidRbfAttempt(d.channelId).getMessage) + } + + case Event(commitSig: CommitSig, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => + d.txBuilder ! InteractiveTxBuilder.ReceiveCommitSig(commitSig) + stay() + + case Event(channelReady: ChannelReady, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => + log.info("received their channel_ready, deferring message") + stay() using d.copy(deferred = Some(channelReady)) + + case Event(msg: InteractiveTxBuilder.Response, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => msg match { + case InteractiveTxBuilder.SendMessage(msg) => stay() sending msg + case InteractiveTxBuilder.Succeeded(fundingParams, fundingTx, commitments) => + d.deferred.foreach(self ! _) + Funding.minDepthDualFunding(nodeParams.channelConf, commitments.channelFeatures, fundingParams) match { + case Some(fundingMinDepth) => + blockchain ! WatchFundingConfirmed(self, commitments.commitInput.outPoint.txid, fundingMinDepth) + val nextData = DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER(commitments, fundingTx, fundingParams, Nil, nodeParams.currentBlockHeight, nodeParams.currentBlockHeight, None, None) + fundingTx match { + case fundingTx: PartiallySignedSharedTransaction => + goto(WAIT_FOR_DUAL_FUNDING_PLACEHOLDER) using nextData sending fundingTx.localSigs + case fundingTx: FullySignedSharedTransaction => + goto(WAIT_FOR_DUAL_FUNDING_PLACEHOLDER) using nextData sending fundingTx.localSigs calling publishFundingTx(nextData) + } + case None => + val (_, channelReady) = acceptFundingTx(commitments, RealScidStatus.Unknown) + val nextData = DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER(commitments, fundingTx, fundingParams, Nil, nodeParams.currentBlockHeight, nodeParams.currentBlockHeight, None, None) + fundingTx match { + case fundingTx: PartiallySignedSharedTransaction => + goto(WAIT_FOR_DUAL_FUNDING_PLACEHOLDER) using nextData sending Seq(fundingTx.localSigs, channelReady) + case fundingTx: FullySignedSharedTransaction => + goto(WAIT_FOR_DUAL_FUNDING_PLACEHOLDER) using nextData sending Seq(fundingTx.localSigs, channelReady) calling publishFundingTx(nextData) + } + } + case f: InteractiveTxBuilder.Failed => + channelOpenReplyToUser(Left(LocalError(f.cause))) + goto(CLOSED) sending TxAbort(d.channelId, f.cause.getMessage) + } + + case Event(c: CloseCommand, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => + d.txBuilder ! InteractiveTxBuilder.Abort channelOpenReplyToUser(Right(ChannelOpenResponse.ChannelClosed(d.channelId))) handleFastClose(c, d.channelId) - case Event(e: Error, d: DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL) => + case Event(e: Error, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => + d.txBuilder ! InteractiveTxBuilder.Abort channelOpenReplyToUser(Left(RemoteError(e))) handleRemoteError(e, d) - case Event(INPUT_DISCONNECTED, _) => + case Event(INPUT_DISCONNECTED, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => + d.txBuilder ! InteractiveTxBuilder.Abort channelOpenReplyToUser(Left(LocalError(new RuntimeException("disconnected")))) goto(CLOSED) - case Event(TickChannelOpenTimeout, _) => + case Event(TickChannelOpenTimeout, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => + d.txBuilder ! InteractiveTxBuilder.Abort channelOpenReplyToUser(Left(LocalError(new RuntimeException("open channel cancelled, took too long")))) goto(CLOSED) }) + when(WAIT_FOR_DUAL_FUNDING_PLACEHOLDER)(handleExceptions { + case Event(_, _) => ??? + }) + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala similarity index 95% rename from eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunder.scala rename to eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala index 7d83bd71a7..2318dd46b0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala @@ -45,7 +45,7 @@ import scala.util.{Failure, Success, Try} /** * This trait contains the state machine for the single-funder channel funding flow. */ -trait ChannelOpenSingleFunder extends FundingHandlers with ErrorHandlers { +trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { this: Channel => @@ -216,7 +216,7 @@ trait ChannelOpenSingleFunder extends FundingHandlers with ErrorHandlers { when(WAIT_FOR_FUNDING_INTERNAL)(handleExceptions { case Event(MakeFundingTxResponse(fundingTx, fundingTxOutputIndex, fundingTxFee), d@DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, remoteFirstPerCommitmentPoint, channelConfig, channelFeatures, open)) => // let's create the first commitment tx that spends the yet uncommitted funding tx - Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, fundingTx.hash, fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match { + Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, localFundingAmount = fundingAmount, remoteFundingAmount = 0 sat, pushMsat, commitTxFeerate, fundingTx.hash, fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match { case Left(ex) => handleLocalError(ex, d, None) case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) => require(fundingTx.txOut(fundingTxOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, s"pubkey script mismatch!") @@ -261,7 +261,7 @@ trait ChannelOpenSingleFunder extends FundingHandlers with ErrorHandlers { when(WAIT_FOR_FUNDING_CREATED)(handleExceptions { case Event(FundingCreated(_, fundingTxHash, fundingTxOutputIndex, remoteSig, _), d@DATA_WAIT_FOR_FUNDING_CREATED(temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, remoteFirstPerCommitmentPoint, channelFlags, channelConfig, channelFeatures, _)) => // they fund the channel with their funding tx, so the money is theirs (but we are paid pushMsat) - Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, fundingTxHash, fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match { + Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, localFundingAmount = 0 sat, remoteFundingAmount = fundingAmount, pushMsat, commitTxFeerate, fundingTxHash, fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match { case Left(ex) => handleLocalError(ex, d, None) case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) => // check remote signature validity @@ -338,30 +338,15 @@ trait ChannelOpenSingleFunder extends FundingHandlers with ErrorHandlers { context.system.eventStream.publish(ChannelSignatureReceived(self, commitments)) log.info(s"publishing funding tx for channelId=$channelId fundingTxid=${commitInput.outPoint.txid}") watchFundingTx(commitments) - // we will publish the funding tx only after the channel state has been written to disk because we want to // make sure we first persist the commitment that returns back the funds to us in case of problem - def publishFundingTx(): Unit = { - wallet.commit(fundingTx).onComplete { - case Success(true) => - context.system.eventStream.publish(TransactionPublished(commitments.channelId, remoteNodeId, fundingTx, fundingTxFee, "funding")) - channelOpenReplyToUser(Right(ChannelOpenResponse.ChannelOpened(channelId))) - case Success(false) => - channelOpenReplyToUser(Left(LocalError(new RuntimeException("couldn't publish funding tx")))) - self ! BITCOIN_FUNDING_PUBLISH_FAILED // fail-fast: this should be returned only when we are really sure the tx has *not* been published - case Failure(t) => - channelOpenReplyToUser(Left(LocalError(t))) - log.error(t, s"error while committing funding tx: ") // tx may still have been published, can't fail-fast - } - } - Funding.minDepthFunder(commitments.channelFeatures) match { case Some(fundingMinDepth) => blockchain ! WatchFundingConfirmed(self, commitInput.outPoint.txid, fundingMinDepth) - goto(WAIT_FOR_FUNDING_CONFIRMED) using DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, Some(fundingTx), blockHeight, None, Left(fundingCreated)) storing() calling publishFundingTx() + goto(WAIT_FOR_FUNDING_CONFIRMED) using DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, Some(fundingTx), blockHeight, None, Left(fundingCreated)) storing() calling publishFundingTx(commitments, fundingTx, fundingTxFee) case None => val (shortIds, channelReady) = acceptFundingTx(commitments, RealScidStatus.Unknown) - goto(WAIT_FOR_CHANNEL_READY) using DATA_WAIT_FOR_CHANNEL_READY(commitments, shortIds, channelReady) storing() sending channelReady calling publishFundingTx() + goto(WAIT_FOR_CHANNEL_READY) using DATA_WAIT_FOR_CHANNEL_READY(commitments, shortIds, channelReady) storing() sending channelReady calling publishFundingTx(commitments, fundingTx, fundingTxFee) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala index 3fd98cd9a3..fbb28f9752 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.channel.fsm -import akka.actor.FSM +import akka.actor.{FSM, Status} import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.eclair.channel._ import fr.acinq.eclair.db.PendingCommandsDb @@ -36,6 +36,18 @@ trait CommonHandlers { this: Channel => + /** + * This function is used to return feedback to user at channel opening + */ + def channelOpenReplyToUser(message: Either[ChannelOpenError, ChannelOpenResponse]): Unit = { + val m = message match { + case Left(LocalError(t)) => Status.Failure(t) + case Left(RemoteError(e)) => Status.Failure(new RuntimeException(s"peer sent error: ascii='${e.toAscii}' bin=${e.data.toHex}")) + case Right(s) => s + } + origin_opt.foreach(_ ! m) + } + def send(msg: LightningMessage): Unit = { peer ! Peer.OutgoingMessage(msg, activeConnection) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/DualFundingHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/DualFundingHandlers.scala new file mode 100644 index 0000000000..6b09ee2688 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/DualFundingHandlers.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2022 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.channel.fsm + +import fr.acinq.eclair.channel.InteractiveTxBuilder.{FullySignedSharedTransaction, PartiallySignedSharedTransaction} +import fr.acinq.eclair.channel._ + +import scala.util.{Failure, Success} + +/** + * Created by t-bast on 06/05/2022. + */ + +/** + * This trait contains handlers related to dual-funding channel transactions. + */ +trait DualFundingHandlers extends CommonHandlers { + + this: Channel => + + def publishFundingTx(d: DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER): Unit = { + d.fundingTx match { + case _: PartiallySignedSharedTransaction => + log.info("we haven't received remote funding signatures yet: we cannot publish the funding transaction but our peer should publish it") + case fundingTx: FullySignedSharedTransaction => + // Note that we don't use wallet.commit because we don't want to rollback on failure, since our peer may be able + // to publish and we may be able to RBF. + wallet.publishTransaction(fundingTx.signedTx).onComplete { + case Success(_) => + context.system.eventStream.publish(TransactionPublished(d.commitments.channelId, remoteNodeId, fundingTx.signedTx, fundingTx.tx.localFees(d.fundingParams), "funding")) + channelOpenReplyToUser(Right(ChannelOpenResponse.ChannelOpened(d.commitments.channelId))) + case Failure(t) => + channelOpenReplyToUser(Left(LocalError(t))) + log.warning("error while publishing funding tx: {}", t.getMessage) // tx may be published by our peer, we can't fail-fast + } + } + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/FundingHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/SingleFundingHandlers.scala similarity index 84% rename from eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/FundingHandlers.scala rename to eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/SingleFundingHandlers.scala index 58adea13a7..f9506d5f52 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/FundingHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/SingleFundingHandlers.scala @@ -16,15 +16,14 @@ package fr.acinq.eclair.channel.fsm -import akka.actor.Status import akka.actor.typed.scaladsl.adapter.{TypedActorRefOps, actorRefAdapter} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Transaction} -import fr.acinq.eclair.{Alias, BlockHeight, RealShortChannelId, ShortChannelId} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{GetTxWithMeta, GetTxWithMetaResponse, WatchFundingLost, WatchFundingSpent} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel.{BITCOIN_FUNDING_PUBLISH_FAILED, BITCOIN_FUNDING_TIMEOUT, FUNDING_TIMEOUT_FUNDEE} import fr.acinq.eclair.channel.publish.TxPublisher.PublishFinalTx import fr.acinq.eclair.wire.protocol.{ChannelReady, ChannelReadyTlv, Error, TlvStream} +import fr.acinq.eclair.{BlockHeight, ShortChannelId} import scala.concurrent.duration.DurationInt import scala.util.{Failure, Success} @@ -34,32 +33,33 @@ import scala.util.{Failure, Success} */ /** - * This trait contains handlers related to funding channel transactions. + * This trait contains handlers related to single-funder channel transactions. */ -trait FundingHandlers extends CommonHandlers { +trait SingleFundingHandlers extends CommonHandlers { this: Channel => - /** - * This function is used to return feedback to user at channel opening - */ - def channelOpenReplyToUser(message: Either[ChannelOpenError, ChannelOpenResponse]): Unit = { - val m = message match { - case Left(LocalError(t)) => Status.Failure(t) - case Left(RemoteError(e)) => Status.Failure(new RuntimeException(s"peer sent error: ascii='${e.toAscii}' bin=${e.data.toHex}")) - case Right(s) => s + def publishFundingTx(commitments: Commitments, fundingTx: Transaction, fundingTxFee: Satoshi): Unit = { + wallet.commit(fundingTx).onComplete { + case Success(true) => + context.system.eventStream.publish(TransactionPublished(commitments.channelId, remoteNodeId, fundingTx, fundingTxFee, "funding")) + channelOpenReplyToUser(Right(ChannelOpenResponse.ChannelOpened(commitments.channelId))) + case Success(false) => + channelOpenReplyToUser(Left(LocalError(new RuntimeException("couldn't publish funding tx")))) + self ! BITCOIN_FUNDING_PUBLISH_FAILED // fail-fast: this should be returned only when we are really sure the tx has *not* been published + case Failure(t) => + channelOpenReplyToUser(Left(LocalError(t))) + log.error(t, "error while committing funding tx: ") // tx may still have been published, can't fail-fast } - origin_opt.foreach(_ ! m) } def watchFundingTx(commitments: Commitments, additionalKnownSpendingTxs: Set[ByteVector32] = Set.empty): Unit = { // TODO: should we wait for an acknowledgment from the watcher? + // TODO: implement WatchFundingLost? val knownSpendingTxs = Set(commitments.localCommit.commitTxAndRemoteSig.commitTx.tx.txid, commitments.remoteCommit.txid) ++ commitments.remoteNextCommitInfo.left.toSeq.map(_.nextRemoteCommit.txid).toSet ++ additionalKnownSpendingTxs blockchain ! WatchFundingSpent(self, commitments.commitInput.outPoint.txid, commitments.commitInput.outPoint.index.toInt, knownSpendingTxs) - // TODO: implement this? (not needed if we use a reasonable min_depth) - //blockchain ! WatchLost(self, commitments.commitInput.outPoint.txid, nodeParams.channelConf.minDepthBlocks, BITCOIN_FUNDING_LOST) } - + def acceptFundingTx(commitments: Commitments, realScidStatus: RealScidStatus): (ShortIds, ChannelReady) = { blockchain ! WatchFundingLost(self, commitments.commitInput.outPoint.txid, nodeParams.channelConf.minDepthBlocks) val channelKeyPath = keyManager.keyPath(commitments.localParams, commitments.channelConfig) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 3256c731f7..1deb5395ff 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -37,6 +37,8 @@ import scala.util.Try */ object Transactions { + val MAX_STANDARD_TX_WEIGHT = 400_000 + sealed trait CommitmentFormat { // @formatter:off def commitWeight: Int diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index d4a737f4fa..42e89d6e8d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -24,7 +24,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.{ChannelFlags, ChannelType} import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.wire.protocol.ChannelReadyTlv.ShortChannelIdTlv -import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature, Features, InitFeature, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampSecond, UInt64} +import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature, Features, InitFeature, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampSecond, UInt64, isAsciiPrintable} import scodec.bits.ByteVector import java.net.{Inet4Address, Inet6Address, InetAddress} @@ -40,6 +40,7 @@ sealed trait LightningMessage extends Serializable sealed trait SetupMessage extends LightningMessage sealed trait ChannelMessage extends LightningMessage sealed trait InteractiveTxMessage extends LightningMessage +sealed trait InteractiveTxConstructionMessage extends InteractiveTxMessage // <- not in the spec sealed trait HtlcMessage extends LightningMessage sealed trait RoutingMessage extends LightningMessage sealed trait AnnouncementMessage extends RoutingMessage // <- not in the spec @@ -47,6 +48,7 @@ sealed trait HasTimestamp extends LightningMessage { def timestamp: TimestampSec sealed trait HasTemporaryChannelId extends LightningMessage { def temporaryChannelId: ByteVector32 } // <- not in the spec sealed trait HasChannelId extends LightningMessage { def channelId: ByteVector32 } // <- not in the spec sealed trait HasChainHash extends LightningMessage { def chainHash: ByteVector32 } // <- not in the spec +sealed trait HasSerialId extends LightningMessage { def serialId: UInt64 } // <- not in the spec sealed trait UpdateMessage extends HtlcMessage // <- not in the spec sealed trait HtlcSettlementMessage extends UpdateMessage { def id: Long } // <- not in the spec // @formatter:on @@ -59,7 +61,7 @@ case class Init(features: Features[InitFeature], tlvStream: TlvStream[InitTlv] = case class Warning(channelId: ByteVector32, data: ByteVector, tlvStream: TlvStream[WarningTlv] = TlvStream.empty) extends SetupMessage with HasChannelId { // @formatter:off val isGlobal: Boolean = channelId == ByteVector32.Zeroes - def toAscii: String = if (fr.acinq.eclair.isAsciiPrintable(data)) new String(data.toArray, StandardCharsets.US_ASCII) else "n/a" + def toAscii: String = if (isAsciiPrintable(data)) new String(data.toArray, StandardCharsets.US_ASCII) else "n/a" // @formatter:on } @@ -71,7 +73,7 @@ object Warning { } case class Error(channelId: ByteVector32, data: ByteVector, tlvStream: TlvStream[ErrorTlv] = TlvStream.empty) extends SetupMessage with HasChannelId { - def toAscii: String = if (fr.acinq.eclair.isAsciiPrintable(data)) new String(data.toArray, StandardCharsets.US_ASCII) else "n/a" + def toAscii: String = if (isAsciiPrintable(data)) new String(data.toArray, StandardCharsets.US_ASCII) else "n/a" } object Error { @@ -87,24 +89,24 @@ case class TxAddInput(channelId: ByteVector32, previousTx: Transaction, previousTxOutput: Long, sequence: Long, - tlvStream: TlvStream[TxAddInputTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId + tlvStream: TlvStream[TxAddInputTlv] = TlvStream.empty) extends InteractiveTxConstructionMessage with HasChannelId with HasSerialId case class TxAddOutput(channelId: ByteVector32, serialId: UInt64, amount: Satoshi, pubkeyScript: ByteVector, - tlvStream: TlvStream[TxAddOutputTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId + tlvStream: TlvStream[TxAddOutputTlv] = TlvStream.empty) extends InteractiveTxConstructionMessage with HasChannelId with HasSerialId case class TxRemoveInput(channelId: ByteVector32, serialId: UInt64, - tlvStream: TlvStream[TxRemoveInputTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId + tlvStream: TlvStream[TxRemoveInputTlv] = TlvStream.empty) extends InteractiveTxConstructionMessage with HasChannelId with HasSerialId case class TxRemoveOutput(channelId: ByteVector32, serialId: UInt64, - tlvStream: TlvStream[TxRemoveOutputTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId + tlvStream: TlvStream[TxRemoveOutputTlv] = TlvStream.empty) extends InteractiveTxConstructionMessage with HasChannelId with HasSerialId case class TxComplete(channelId: ByteVector32, - tlvStream: TlvStream[TxCompleteTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId + tlvStream: TlvStream[TxCompleteTlv] = TlvStream.empty) extends InteractiveTxConstructionMessage with HasChannelId case class TxSignatures(channelId: ByteVector32, txId: ByteVector32, @@ -114,14 +116,34 @@ case class TxSignatures(channelId: ByteVector32, case class TxInitRbf(channelId: ByteVector32, lockTime: Long, feerate: FeeratePerKw, - tlvStream: TlvStream[TxInitRbfTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId + tlvStream: TlvStream[TxInitRbfTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId { + val fundingContribution_opt: Option[Satoshi] = tlvStream.get[TxRbfTlv.SharedOutputContributionTlv].map(_.amount) +} + +object TxInitRbf { + def apply(channelId: ByteVector32, lockTime: Long, feerate: FeeratePerKw, fundingContribution: Satoshi): TxInitRbf = + TxInitRbf(channelId, lockTime, feerate, TlvStream[TxInitRbfTlv](TxRbfTlv.SharedOutputContributionTlv(fundingContribution))) +} case class TxAckRbf(channelId: ByteVector32, - tlvStream: TlvStream[TxAckRbfTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId + tlvStream: TlvStream[TxAckRbfTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId { + val fundingContribution_opt: Option[Satoshi] = tlvStream.get[TxRbfTlv.SharedOutputContributionTlv].map(_.amount) +} + +object TxAckRbf { + def apply(channelId: ByteVector32, fundingContribution: Satoshi): TxAckRbf = + TxAckRbf(channelId, TlvStream[TxAckRbfTlv](TxRbfTlv.SharedOutputContributionTlv(fundingContribution))) +} case class TxAbort(channelId: ByteVector32, data: ByteVector, - tlvStream: TlvStream[TxAbortTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId + tlvStream: TlvStream[TxAbortTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId { + def toAscii: String = if (isAsciiPrintable(data)) new String(data.toArray, StandardCharsets.US_ASCII) else "n/a" +} + +object TxAbort { + def apply(channelId: ByteVector32, msg: String): TxAbort = TxAbort(channelId, ByteVector.view(msg.getBytes(Charsets.US_ASCII))) +} case class ChannelReestablish(channelId: ByteVector32, nextLocalCommitmentNumber: Long, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index 5476611fd6..2991b89efa 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala @@ -18,9 +18,12 @@ package fr.acinq.eclair.blockchain import fr.acinq.bitcoin.TxIn.SEQUENCE_FINAL import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, OutPoint, Satoshi, SatoshiLong, Transaction, TxIn, TxOut} -import fr.acinq.eclair.blockchain.OnChainWallet.{MakeFundingTxResponse, OnChainBalance} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut} +import fr.acinq.bitcoin.{Bech32, SigHash, SigVersion} +import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse} import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.{randomBytes32, randomKey} import scodec.bits._ import scala.concurrent.{ExecutionContext, Future, Promise} @@ -41,6 +44,12 @@ class DummyOnChainWallet extends OnChainWallet { override def getReceivePubkey(receiveAddress: Option[String] = None)(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(dummyReceivePubkey) + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, lockUtxos: Boolean)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = Future.successful(FundTransactionResponse(tx, 0 sat, None)) + + override def signTransaction(tx: Transaction, allowIncomplete: Boolean)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = Future.successful(SignTransactionResponse(tx, complete = true)) + + override def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] = Future.successful(tx.txid) + override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { val tx = DummyOnChainWallet.makeDummyFundingTx(pubkeyScript, amount) funded += (tx.fundingTx.txid -> tx.fundingTx) @@ -49,6 +58,8 @@ class DummyOnChainWallet extends OnChainWallet { override def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(true) + override def getTransaction(txId: ByteVector32)(implicit ec: ExecutionContext): Future[Transaction] = Future.failed(new RuntimeException("transaction not found")) + override def rollback(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = { rolledback = rolledback + tx Future.successful(true) @@ -62,20 +73,107 @@ class NoOpOnChainWallet extends OnChainWallet { import DummyOnChainWallet._ + var rolledback = Seq.empty[Transaction] + var doubleSpent = Set.empty[ByteVector32] + override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(1105 sat, 561 sat)) override def getReceiveAddress(label: String)(implicit ec: ExecutionContext): Future[String] = Future.successful(dummyReceiveAddress) override def getReceivePubkey(receiveAddress: Option[String] = None)(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(dummyReceivePubkey) - override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = Promise[MakeFundingTxResponse]().future // will never be completed + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, lockUtxos: Boolean)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = Promise().future // will never be completed + + override def signTransaction(tx: Transaction, allowIncomplete: Boolean)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = Promise().future // will never be completed + + override def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] = Future.successful(tx.txid) + + override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = Promise().future // will never be completed override def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(true) - override def rollback(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(true) + override def getTransaction(txId: ByteVector32)(implicit ec: ExecutionContext): Future[Transaction] = Promise().future // will never be completed - override def doubleSpent(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(false) + override def rollback(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = { + rolledback = rolledback :+ tx + Future.successful(true) + } + + override def doubleSpent(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(doubleSpent.contains(tx.txid)) + +} + +class SingleKeyOnChainWallet extends OnChainWallet { + val privkey = randomKey() + val pubkey = privkey.publicKey + // We create a new dummy input transaction for every funding request. + var inputs = Seq.empty[Transaction] + var rolledback = Seq.empty[Transaction] + var doubleSpent = Set.empty[ByteVector32] + + override def onChainBalance()(implicit ec: ExecutionContext): Future[OnChainBalance] = Future.successful(OnChainBalance(1105 sat, 561 sat)) + + override def getReceiveAddress(label: String)(implicit ec: ExecutionContext): Future[String] = Future.successful(Bech32.encodeWitnessAddress("bcrt", 0, pubkey.hash160.toArray)) + + override def getReceivePubkey(receiveAddress: Option[String] = None)(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(pubkey) + + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, lockUtxos: Boolean)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { + val currentAmountIn = tx.txIn.flatMap(txIn => inputs.find(_.txid == txIn.outPoint.txid)).map(_.txOut.head.amount).sum + val amountOut = tx.txOut.map(_.amount).sum + // We add a single input to reach the desired feerate. + val inputAmount = amountOut + 100_000.sat + val inputTx = Transaction(2, Seq(TxIn(OutPoint(randomBytes32(), 1), Nil, 0)), Seq(TxOut(inputAmount, Script.pay2wpkh(pubkey))), 0) + inputs = inputs :+ inputTx + val dummySignedTx = tx.copy( + txIn = tx.txIn :+ TxIn(OutPoint(inputTx, 0), ByteVector.empty, 0, Script.witnessPay2wpkh(pubkey, ByteVector.fill(73)(0))), + txOut = tx.txOut :+ TxOut(inputAmount, Script.pay2wpkh(pubkey)), + ) + val fee = Transactions.weight2fee(feeRate, dummySignedTx.weight()) + val fundedTx = tx.copy( + txIn = tx.txIn :+ TxIn(OutPoint(inputTx, 0), Nil, 0), + txOut = tx.txOut :+ TxOut(inputAmount + currentAmountIn - amountOut - fee, Script.pay2wpkh(pubkey)), + ) + Future.successful(FundTransactionResponse(fundedTx, fee, Some(tx.txOut.length))) + } + + override def signTransaction(tx: Transaction, allowIncomplete: Boolean)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = { + val signedTx = tx.txIn.zipWithIndex.foldLeft(tx) { + case (currentTx, (txIn, index)) => inputs.find(_.txid == txIn.outPoint.txid) match { + case Some(inputTx) => + val sig = Transaction.signInput(currentTx, index, Script.pay2pkh(pubkey), SigHash.SIGHASH_ALL, inputTx.txOut.head.amount, SigVersion.SIGVERSION_WITNESS_V0, privkey) + currentTx.updateWitness(index, Script.witnessPay2wpkh(pubkey, sig)) + case None => currentTx + } + } + val complete = tx.txIn.forall(txIn => inputs.exists(_.txid == txIn.outPoint.txid)) + Future.successful(SignTransactionResponse(signedTx, complete)) + } + + override def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] = Future.successful(tx.txid) + + override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { + val tx = Transaction(2, Nil, Seq(TxOut(amount, pubkeyScript)), 0) + for { + fundedTx <- fundTransaction(tx, feeRatePerKw, replaceable = true, lockUtxos = true) + signedTx <- signTransaction(fundedTx.tx, allowIncomplete = true) + } yield MakeFundingTxResponse(signedTx.tx, 0, fundedTx.fee) + } + + override def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(true) + + override def getTransaction(txId: ByteVector32)(implicit ec: ExecutionContext): Future[Transaction] = synchronized { + inputs.find(_.txid == txId) match { + case Some(tx) => Future.successful(tx) + case None => Future.failed(new RuntimeException("tx not found")) + } + } + + override def rollback(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = { + rolledback = rolledback :+ tx + Future.successful(true) + } + override def doubleSpent(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(doubleSpent.contains(tx.txid)) } object DummyOnChainWallet { @@ -84,10 +182,12 @@ object DummyOnChainWallet { val dummyReceivePubkey: PublicKey = PublicKey(hex"028feba10d0eafd0fad8fe20e6d9206e6bd30242826de05c63f459a00aced24b12") def makeDummyFundingTx(pubkeyScript: ByteVector, amount: Satoshi): MakeFundingTxResponse = { - val fundingTx = Transaction(version = 2, + val fundingTx = Transaction( + version = 2, txIn = TxIn(OutPoint(ByteVector32(ByteVector.fill(32)(1)), 42), signatureScript = Nil, sequence = SEQUENCE_FINAL) :: Nil, txOut = TxOut(amount, pubkeyScript) :: Nil, - lockTime = 0) + lockTime = 0 + ) MakeFundingTxResponse(fundingTx, 0, 420 sat) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala index e9a8e7c740..26d2b19a9b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -22,7 +22,7 @@ import akka.testkit.TestProbe import fr.acinq.bitcoin import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Block, BtcDouble, ByteVector32, MilliBtcDouble, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} -import fr.acinq.eclair.blockchain.OnChainWallet.{MakeFundingTxResponse, OnChainBalance} +import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse} import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpendP2WPKH} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala index d51266e49c..9a03d3caf0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala @@ -173,8 +173,11 @@ trait BitcoindService extends Logging { new BasicBitcoinJsonRPCClient(rpcAuthMethod = bitcoinrpcauthmethod, host = "localhost", port = bitcoindRpcPort, wallet = Some(walletName)) } - def getNewAddress(sender: TestProbe = TestProbe(), rpcClient: BitcoinJsonRPCClient = bitcoinrpcclient): String = { - rpcClient.invoke("getnewaddress").pipeTo(sender.ref) + def getNewAddress(sender: TestProbe = TestProbe(), rpcClient: BitcoinJsonRPCClient = bitcoinrpcclient, addressType_opt: Option[String] = None): String = { + addressType_opt match { + case Some(addressType) => rpcClient.invoke("getnewaddress", "", addressType).pipeTo(sender.ref) + case None => rpcClient.invoke("getnewaddress").pipeTo(sender.ref) + } val JString(address) = sender.expectMsgType[JValue] address } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala index 544b9481db..0975dd7ce2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala @@ -22,10 +22,11 @@ import akka.actor.{ActorRef, Props, typed} import akka.pattern.pipe import akka.testkit.TestProbe import fr.acinq.bitcoin.scalacompat.{Block, Btc, MilliBtcDouble, OutPoint, SatoshiLong, Script, Transaction, TxOut} +import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, SignTransactionResponse} import fr.acinq.eclair.blockchain.WatcherSpec._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{FundTransactionOptions, FundTransactionResponse, SignTransactionResponse} +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.FundTransactionOptions import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.blockchain.{CurrentBlockHeight, NewTransaction} 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 new file mode 100644 index 0000000000..aee6f12fb8 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala @@ -0,0 +1,1082 @@ +/* + * Copyright 2022 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.channel + +import akka.actor.typed.ActorRef +import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter} +import akka.pattern.pipe +import akka.testkit.TestProbe +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, Script, Transaction, TxOut} +import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, SignTransactionResponse} +import fr.acinq.eclair.blockchain.bitcoind.BitcoindService +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{MempoolTx, Utxo} +import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinCoreClient, BitcoinJsonRPCClient} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{OnChainWallet, SingleKeyOnChainWallet} +import fr.acinq.eclair.channel.InteractiveTxBuilder._ +import fr.acinq.eclair.io.Peer +import fr.acinq.eclair.transactions.Scripts +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{Feature, FeatureSupport, Features, InitFeature, NodeParams, TestConstants, TestKitBaseClass, UInt64, randomBytes32, randomKey} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuiteLike +import scodec.bits.ByteVector + +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.DurationInt +import scala.reflect.ClassTag + +class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with BeforeAndAfterAll { + + override def beforeAll(): Unit = { + startBitcoind() + waitForBitcoindReady() + } + + override def afterAll(): Unit = { + stopBitcoind() + } + + private def addUtxo(wallet: BitcoinCoreClient, amount: Satoshi, probe: TestProbe): Unit = { + wallet.getReceiveAddress().pipeTo(probe.ref) + val walletAddress = probe.expectMsgType[String] + sendToAddress(walletAddress, amount, probe) + } + + private def createInput(channelId: ByteVector32, serialId: UInt64, amount: Satoshi): TxAddInput = { + val changeScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val previousTx = Transaction(2, Nil, Seq(TxOut(amount, changeScript), TxOut(amount, changeScript), TxOut(amount, changeScript)), 0) + TxAddInput(channelId, serialId, previousTx, 1, 0) + } + + case class ChannelParams(fundingParamsA: InteractiveTxParams, + nodeParamsA: NodeParams, + localParamsA: LocalParams, + remoteParamsA: RemoteParams, + firstPerCommitmentPointA: PublicKey, + fundingParamsB: InteractiveTxParams, + nodeParamsB: NodeParams, + localParamsB: LocalParams, + remoteParamsB: RemoteParams, + firstPerCommitmentPointB: PublicKey, + channelFeatures: ChannelFeatures) { + val channelId = fundingParamsA.channelId + + def spawnTxBuilderAlice(fundingParams: InteractiveTxParams, commitFeerate: FeeratePerKw, wallet: OnChainWallet): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( + nodeParamsA.nodeId, + fundingParams, nodeParamsA.channelKeyManager, + localParamsA, remoteParamsB, + commitFeerate, firstPerCommitmentPointB, + ChannelFlags.Public, ChannelConfig.standard, channelFeatures, wallet)) + + def spawnTxBuilderBob(fundingParams: InteractiveTxParams, commitFeerate: FeeratePerKw, wallet: OnChainWallet): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( + nodeParamsB.nodeId, + fundingParams, nodeParamsB.channelKeyManager, + localParamsB, remoteParamsA, + commitFeerate, firstPerCommitmentPointA, + ChannelFlags.Public, ChannelConfig.standard, channelFeatures, wallet)) + } + + private def createChannelParams(fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long): ChannelParams = { + val channelFeatures = ChannelFeatures(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(scidAlias = false, zeroConf = false), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), announceChannel = true) + val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(_.copy(features = Features(channelFeatures.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport]))) + val localParamsA = Peer.makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), ByteVector.empty, None, isInitiator = true, fundingAmountA) + val localParamsB = Peer.makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), ByteVector.empty, None, isInitiator = false, fundingAmountB) + + val Seq(remoteParamsA, remoteParamsB) = Seq((nodeParamsA, localParamsA), (nodeParamsB, localParamsB)).map { + case (nodeParams, localParams) => + val channelKeyPath = nodeParams.channelKeyManager.keyPath(localParams, ChannelConfig.standard) + RemoteParams( + nodeParams.nodeId, + localParams.dustLimit, localParams.maxHtlcValueInFlightMsat, None, localParams.htlcMinimum, localParams.toSelfDelay, localParams.maxAcceptedHtlcs, + nodeParams.channelKeyManager.fundingPublicKey(localParams.fundingKeyPath).publicKey, + nodeParams.channelKeyManager.revocationPoint(channelKeyPath).publicKey, + nodeParams.channelKeyManager.paymentPoint(channelKeyPath).publicKey, + nodeParams.channelKeyManager.delayedPaymentPoint(channelKeyPath).publicKey, + nodeParams.channelKeyManager.htlcPoint(channelKeyPath).publicKey, + localParams.initFeatures, + None) + } + + val firstPerCommitmentPointA = nodeParamsA.channelKeyManager.commitmentPoint(nodeParamsA.channelKeyManager.keyPath(localParamsA, ChannelConfig.standard), 0) + val firstPerCommitmentPointB = nodeParamsB.channelKeyManager.commitmentPoint(nodeParamsB.channelKeyManager.keyPath(localParamsB, ChannelConfig.standard), 0) + + val channelId = randomBytes32() + val fundingScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(remoteParamsA.fundingPubKey, remoteParamsB.fundingPubKey))) + val fundingParamsA = InteractiveTxParams(channelId, isInitiator = true, fundingAmountA, fundingAmountB, fundingScript, lockTime, dustLimit, targetFeerate) + val fundingParamsB = InteractiveTxParams(channelId, isInitiator = false, fundingAmountB, fundingAmountA, fundingScript, lockTime, dustLimit, targetFeerate) + ChannelParams(fundingParamsA, nodeParamsA, localParamsA, remoteParamsA, firstPerCommitmentPointA, fundingParamsB, nodeParamsB, localParamsB, remoteParamsB, firstPerCommitmentPointB, channelFeatures) + } + + case class Fixture(alice: ActorRef[InteractiveTxBuilder.Command], + bob: ActorRef[InteractiveTxBuilder.Command], + aliceRbf: ActorRef[InteractiveTxBuilder.Command], + bobRbf: ActorRef[InteractiveTxBuilder.Command], + aliceParams: InteractiveTxParams, + bobParams: InteractiveTxParams, + walletA: OnChainWallet, + rpcClientA: BitcoinJsonRPCClient, + walletB: OnChainWallet, + rpcClientB: BitcoinJsonRPCClient, + alice2bob: TestProbe, + bob2alice: TestProbe) { + def forwardAlice2Bob[T <: LightningMessage](implicit t: ClassTag[T]): T = forwardMessage(alice2bob, bob) + + def forwardRbfAlice2Bob[T <: LightningMessage](implicit t: ClassTag[T]): T = forwardMessage(alice2bob, bobRbf) + + def forwardBob2Alice[T <: LightningMessage](implicit t: ClassTag[T]): T = forwardMessage(bob2alice, alice) + + def forwardRbfBob2Alice[T <: LightningMessage](implicit t: ClassTag[T]): T = forwardMessage(bob2alice, aliceRbf) + + private def forwardMessage[T <: LightningMessage](s2r: TestProbe, r: ActorRef[InteractiveTxBuilder.Command])(implicit t: ClassTag[T]): T = { + val msg = s2r.expectMsgType[SendMessage].msg + val c = t.runtimeClass.asInstanceOf[Class[T]] + assert(c.isInstance(msg), s"expected $c, found ${msg.getClass} ($msg)") + msg match { + case msg: InteractiveTxConstructionMessage => r ! ReceiveTxMessage(msg) + case msg: CommitSig => r ! ReceiveCommitSig(msg) + case msg: TxSignatures => r ! ReceiveTxSigs(msg) + case msg => fail(s"invalid message sent ($msg)") + } + msg.asInstanceOf[T] + } + } + + private def withFixture(fundingAmountA: Satoshi, utxosA: Seq[Satoshi], fundingAmountB: Satoshi, utxosB: Seq[Satoshi], targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long)(testFun: Fixture => Any): Unit = { + // Initialize wallets with a few confirmed utxos. + val probe = TestProbe() + val rpcClientA = createWallet(UUID.randomUUID().toString) + val walletA = new BitcoinCoreClient(rpcClientA) + utxosA.foreach(amount => addUtxo(walletA, amount, probe)) + val rpcClientB = createWallet(UUID.randomUUID().toString) + val walletB = new BitcoinCoreClient(rpcClientB) + utxosB.foreach(amount => addUtxo(walletB, amount, probe)) + generateBlocks(1) + + val channelParams = createChannelParams(fundingAmountA, fundingAmountB, targetFeerate, dustLimit, lockTime) + val commitFeerate = TestConstants.anchorOutputsFeeratePerKw + val alice = channelParams.spawnTxBuilderAlice(channelParams.fundingParamsA, commitFeerate, walletA) + val aliceRbf = channelParams.spawnTxBuilderAlice(channelParams.fundingParamsA.copy(targetFeerate = targetFeerate * 1.5), commitFeerate, walletA) + val bob = channelParams.spawnTxBuilderBob(channelParams.fundingParamsB, commitFeerate, walletB) + val bobRbf = channelParams.spawnTxBuilderBob(channelParams.fundingParamsB.copy(targetFeerate = targetFeerate * 1.5), commitFeerate, walletB) + testFun(Fixture(alice, bob, aliceRbf, bobRbf, channelParams.fundingParamsA, channelParams.fundingParamsB, walletA, rpcClientA, walletB, rpcClientB, TestProbe(), TestProbe())) + } + + test("initiator contributes more than non-initiator") { + val targetFeerate = FeeratePerKw(5000 sat) + val fundingA = 120_000 sat + val utxosA = Seq(50_000 sat, 35_000 sat, 60_000 sat) + val fundingB = 40_000 sat + val utxosB = Seq(100_000 sat) + withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 42) { f => + import f._ + + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + + // Bob waits for Alice to send the first message. + bob2alice.expectNoMessage(100 millis) + // Alice --- tx_add_input --> Bob + val inputA1 = f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_add_input --- Bob + val inputB1 = f.forwardBob2Alice[TxAddInput] + // Alice --- tx_add_input --> Bob + val inputA2 = f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_add_output --- Bob + val outputB1 = f.forwardBob2Alice[TxAddOutput] + // Alice --- tx_add_input --> Bob + val inputA3 = f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + val outputA1 = f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + val outputA2 = f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardAlice2Bob[TxComplete] + + // Utxos are locked for the duration of the protocol. + val probe = TestProbe() + val locksA = getLocks(probe, rpcClientA) + assert(locksA.size == 3) + assert(locksA == Set(inputA1, inputA2, inputA3).map(toOutPoint)) + val locksB = getLocks(probe, rpcClientB) + assert(locksB.size == 1) + assert(locksB == Set(toOutPoint(inputB1))) + + // Alice is responsible for adding the shared output. + assert(aliceParams.fundingPubkeyScript == bobParams.fundingPubkeyScript) + assert(aliceParams.fundingAmount == 160_000.sat) + assert(Seq(outputA1, outputA2).count(_.pubkeyScript == aliceParams.fundingPubkeyScript) == 1) + assert(Seq(outputA1, outputA2).exists(o => o.pubkeyScript == aliceParams.fundingPubkeyScript && o.amount == aliceParams.fundingAmount)) + assert(outputB1.pubkeyScript != aliceParams.fundingPubkeyScript) + + // Bob sends signatures first as he contributed less than Alice. + f.forwardBob2Alice[CommitSig] + f.forwardAlice2Bob[CommitSig] + val txB = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + alice ! ReceiveTxSigs(txB.localSigs) + val txA = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction] + + // The resulting transaction is valid and has the right feerate. + assert(txA.signedTx.txid == txB.tx.buildUnsignedTx().txid) + assert(txA.signedTx.lockTime == aliceParams.lockTime) + assert(txA.tx.localAmountIn == utxosA.sum) + assert(txA.tx.remoteAmountIn == utxosB.sum) + assert(0.sat < txB.tx.localFees(bobParams)) + assert(txB.tx.localFees(bobParams) < txA.tx.localFees(aliceParams)) + walletA.publishTransaction(txA.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA.signedTx.txid) + new BitcoinCoreClient(rpcClientA).getMempoolTx(txA.signedTx.txid).pipeTo(probe.ref) + val mempoolTx = probe.expectMsgType[MempoolTx] + assert(mempoolTx.fees == txA.tx.fees) + assert(txA.tx.fees == txB.tx.fees) + assert(targetFeerate <= txA.feerate && txA.feerate <= targetFeerate * 1.25, s"unexpected feerate (target=$targetFeerate actual=${txA.feerate})") + } + } + + test("initiator contributes less than non-initiator") { + val targetFeerate = FeeratePerKw(3000 sat) + val fundingA = 10_000 sat + val utxosA = Seq(50_000 sat) + val fundingB = 50_000 sat + val utxosB = Seq(80_000 sat) + withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 0) { f => + import f._ + + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + + // Even though the initiator isn't contributing, they're paying the fees for the common parts of the transaction. + // Alice --- tx_add_input --> Bob + f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_add_input --- Bob + f.forwardBob2Alice[TxAddInput] + // Alice --- tx_add_output --> Bob + val outputA1 = f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_add_output --- Bob + val outputB = f.forwardBob2Alice[TxAddOutput] + // Alice --- tx_add_output --> Bob + val outputA2 = f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardAlice2Bob[TxComplete] + + // Alice is responsible for adding the shared output. + assert(aliceParams.fundingPubkeyScript == bobParams.fundingPubkeyScript) + assert(aliceParams.fundingAmount == 60_000.sat) + assert(Seq(outputA1, outputA2).count(_.pubkeyScript == aliceParams.fundingPubkeyScript) == 1) + assert(Seq(outputA1, outputA2).exists(o => o.pubkeyScript == aliceParams.fundingPubkeyScript && o.amount == aliceParams.fundingAmount)) + assert(outputB.pubkeyScript != aliceParams.fundingPubkeyScript) + + // Alice sends signatures first as she contributed less than Bob. + f.forwardAlice2Bob[CommitSig] + f.forwardBob2Alice[CommitSig] + val txA = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + bob ! ReceiveTxSigs(txA.localSigs) + val txB = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction] + + // The resulting transaction is valid and has the right feerate. + assert(txB.signedTx.lockTime == aliceParams.lockTime) + assert(txB.tx.localAmountIn == utxosB.sum) + assert(txB.tx.remoteAmountIn == utxosA.sum) + assert(0.sat < txA.tx.localFees(aliceParams)) + assert(0.sat < txB.tx.localFees(bobParams)) + val probe = TestProbe() + walletB.publishTransaction(txB.signedTx).pipeTo(probe.ref) + probe.expectMsg(txB.signedTx.txid) + new BitcoinCoreClient(rpcClientB).getMempoolTx(txB.signedTx.txid).pipeTo(probe.ref) + val mempoolTx = probe.expectMsgType[MempoolTx] + assert(mempoolTx.fees == txB.tx.fees) + assert(txA.tx.fees == txB.tx.fees) + assert(targetFeerate <= txB.feerate && txB.feerate <= targetFeerate * 1.25, s"unexpected feerate (target=$targetFeerate actual=${txB.feerate})") + } + } + + test("non-initiator does not contribute") { + val targetFeerate = FeeratePerKw(2500 sat) + val fundingA = 150_000 sat + val utxosA = Seq(80_000 sat, 120_000 sat) + withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0) { f => + import f._ + + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + + // Alice --- tx_add_input --> Bob + f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_input --> Bob + f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + val outputA1 = f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + val outputA2 = f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardAlice2Bob[TxComplete] + + // Alice is responsible for adding the shared output. + assert(aliceParams.fundingPubkeyScript == bobParams.fundingPubkeyScript) + assert(aliceParams.fundingAmount == 150_000.sat) + assert(Seq(outputA1, outputA2).count(_.pubkeyScript == aliceParams.fundingPubkeyScript) == 1) + assert(Seq(outputA1, outputA2).exists(o => o.pubkeyScript == aliceParams.fundingPubkeyScript && o.amount == aliceParams.fundingAmount)) + + // Bob sends signatures first as he did not contribute at all. + f.forwardBob2Alice[CommitSig] + f.forwardAlice2Bob[CommitSig] + val txB = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + alice ! ReceiveTxSigs(txB.localSigs) + val txA = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction] + + // The resulting transaction is valid and has the right feerate. + assert(txA.signedTx.txid == txB.tx.buildUnsignedTx().txid) + assert(txA.signedTx.lockTime == aliceParams.lockTime) + assert(txA.tx.localAmountIn == utxosA.sum) + assert(txA.tx.remoteAmountIn == 0.sat) + assert(txB.tx.localFees(bobParams) == 0.sat) + assert(txA.tx.localFees(aliceParams) == txA.tx.fees) + val probe = TestProbe() + walletA.publishTransaction(txA.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA.signedTx.txid) + new BitcoinCoreClient(rpcClientA).getMempoolTx(txA.signedTx.txid).pipeTo(probe.ref) + val mempoolTx = probe.expectMsgType[MempoolTx] + assert(mempoolTx.fees == txA.tx.fees) + assert(targetFeerate <= txA.feerate && txA.feerate <= targetFeerate * 1.25, s"unexpected feerate (target=$targetFeerate actual=${txA.feerate})") + } + } + + test("remove input/output") { + withFixture(100_000 sat, Seq(150_000 sat), 0 sat, Nil, FeeratePerKw(2500 sat), 330 sat, 0) { f => + import f._ + + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + + // In this flow we introduce dummy inputs/outputs from Bob to Alice that are then removed. + // Alice --- tx_add_input --> Bob + val inputA = f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_add_input --- Bob + bob2alice.expectMsgType[SendMessage] // we override Bob's tx_complete + alice ! ReceiveTxMessage(TxAddInput(bobParams.channelId, UInt64(1), Transaction(2, Nil, Seq(TxOut(250_000 sat, Script.pay2wpkh(randomKey().publicKey))), 0), 0, 0)) + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_add_output --- Bob + bob2alice.expectMsgType[SendMessage] // we override Bob's tx_complete + alice ! ReceiveTxMessage(TxAddOutput(bobParams.channelId, UInt64(3), 250_000 sat, Script.write(Script.pay2wpkh(randomKey().publicKey)))) + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_remove_input --- Bob + bob2alice.expectMsgType[SendMessage] // we override Bob's tx_complete + alice ! ReceiveTxMessage(TxRemoveInput(bobParams.channelId, UInt64(1))) + // Alice --- tx_complete --> Bob + f.forwardAlice2Bob[TxComplete] + // Alice <-- tx_remove_output --- Bob + alice ! ReceiveTxMessage(TxRemoveOutput(bobParams.channelId, UInt64(3))) + // Alice --- tx_complete --> Bob + alice2bob.expectMsgType[SendMessage] + // Alice <-- tx_complete --- Bob + alice ! ReceiveTxMessage(TxComplete(bobParams.channelId)) + // Alice <-- commit_sig --- Bob + f.forwardBob2Alice[CommitSig] + // Alice --- commit_sig --> Bob + f.forwardAlice2Bob[CommitSig] + val txB = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + alice ! ReceiveTxSigs(txB.localSigs) + val txA = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction] + + // The resulting transaction doesn't contain Bob's removed inputs and outputs. + assert(txA.signedTx.txid == txB.tx.buildUnsignedTx().txid) + assert(txA.signedTx.lockTime == aliceParams.lockTime) + assert(txA.signedTx.txIn.map(_.outPoint) == Seq(toOutPoint(inputA))) + assert(txA.signedTx.txOut.length == 2) + assert(txA.tx.remoteAmountIn == 0.sat) + } + } + + test("not enough funds (unusable utxos)") { + val fundingA = 140_000 sat + val utxosA = Seq(75_000 sat, 60_000 sat) + withFixture(fundingA, utxosA, 0 sat, Nil, FeeratePerKw(5000 sat), 660 sat, 0) { f => + import f._ + + // Add some unusable utxos to Alice's wallet. + val probe = TestProbe() + val bitcoinClient = new BitcoinCoreClient(rpcClientA) + val legacyTxId = { + // Dual funding disallows non-segwit inputs. + val legacyAddress = getNewAddress(probe, rpcClientA, Some("legacy")) + sendToAddress(legacyAddress, 100_000 sat, probe).txid + } + val bigTxId = { + // Dual funding cannot use transactions that exceed 65k bytes. + walletA.getReceivePubkey().pipeTo(probe.ref) + val publicKey = probe.expectMsgType[PublicKey] + val tx = Transaction(2, Nil, TxOut(100_000 sat, Script.pay2wpkh(publicKey)) +: (1 to 2500).map(_ => TxOut(5000 sat, Script.pay2wpkh(randomKey().publicKey))), 0) + val minerWallet = new BitcoinCoreClient(bitcoinrpcclient) + minerWallet.fundTransaction(tx, FeeratePerKw(500 sat), replaceable = true, lockUtxos = false).pipeTo(probe.ref) + val unsignedTx = probe.expectMsgType[FundTransactionResponse].tx + minerWallet.signTransaction(unsignedTx).pipeTo(probe.ref) + val signedTx = probe.expectMsgType[SignTransactionResponse].tx + assert(Transaction.write(signedTx).length >= 65_000) + minerWallet.publishTransaction(signedTx).pipeTo(probe.ref) + probe.expectMsgType[ByteVector32] + } + generateBlocks(1) + + // We verify that all utxos are correctly included in our wallet. + bitcoinClient.listUnspent().pipeTo(probe.ref) + val utxos = probe.expectMsgType[Seq[Utxo]] + assert(utxos.length == 4) + assert(utxos.exists(_.txid == bigTxId)) + assert(utxos.exists(_.txid == legacyTxId)) + + // We can't use some of our utxos, so we don't have enough to fund our channel. + alice ! Start(alice2bob.ref, Nil) + assert(alice2bob.expectMsgType[LocalFailure].cause == ChannelFundingError(aliceParams.channelId)) + // Utxos shouldn't be locked after a failure. + awaitCond(getLocks(probe, rpcClientA).isEmpty, max = 10 seconds, interval = 100 millis) + } + } + + test("skip unusable utxos") { + val fundingA = 140_000 sat + val utxosA = Seq(55_000 sat, 65_000 sat, 50_000 sat) + withFixture(fundingA, utxosA, 0 sat, Nil, FeeratePerKw(5000 sat), 660 sat, 0) { f => + import f._ + + // Add some unusable utxos to Alice's wallet. + val probe = TestProbe() + val bitcoinClient = new BitcoinCoreClient(rpcClientA) + val legacyTxIds = { + // Dual funding disallows non-segwit inputs. + val legacyAddress = getNewAddress(probe, rpcClientA, Some("legacy")) + val tx1 = sendToAddress(legacyAddress, 100_000 sat, probe).txid + val tx2 = sendToAddress(legacyAddress, 120_000 sat, probe).txid + Seq(tx1, tx2) + } + generateBlocks(1) + + // We verify that all utxos are correctly included in our wallet. + bitcoinClient.listUnspent().pipeTo(probe.ref) + val utxos = probe.expectMsgType[Seq[Utxo]] + assert(utxos.length == 5) + legacyTxIds.foreach(txid => assert(utxos.exists(_.txid == txid))) + + // If we ignore the unusable utxos, we have enough to fund the channel. + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + + // Alice --- tx_add_input --> Bob + f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_input --> Bob + f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_input --> Bob + f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardAlice2Bob[TxComplete] + // Alice <-- commit_sig --- Bob + f.forwardBob2Alice[CommitSig] + // Alice --- commit_sig --> Bob + f.forwardAlice2Bob[CommitSig] + val txB = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + alice ! ReceiveTxSigs(txB.localSigs) + val txA = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction] + + // Unusable utxos should be skipped. + legacyTxIds.foreach(txid => assert(!txA.signedTx.txIn.exists(_.outPoint.txid == txid))) + // Only used utxos should be locked. + awaitCond({ + val locks = getLocks(probe, rpcClientA) + locks == txA.signedTx.txIn.map(_.outPoint).toSet + }, max = 10 seconds, interval = 100 millis) + } + } + + test("fund transaction with previous inputs (no new input)") { + val targetFeerate = FeeratePerKw(7500 sat) + val fundingA = 85_000 sat + val utxosA = Seq(120_000 sat) + withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0) { f => + import f._ + + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + + // Alice --- tx_add_input --> Bob + val inputA1 = f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardAlice2Bob[TxComplete] + // Alice --- commit_sig --> Bob + f.forwardAlice2Bob[CommitSig] + // Alice <-- commit_sig --- Bob + f.forwardBob2Alice[CommitSig] + // Alice <-- tx_signatures --- Bob + val txB1 = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + alice ! ReceiveTxSigs(txB1.localSigs) + val txA1 = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(targetFeerate * 0.9 <= txA1.feerate && txA1.feerate <= targetFeerate * 1.25) + val probe = TestProbe() + walletA.publishTransaction(txA1.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA1.signedTx.txid) + + aliceRbf ! Start(alice2bob.ref, Seq(txA1)) + bobRbf ! Start(bob2alice.ref, Nil) + + // Alice --- tx_add_input --> Bob + val inputA2 = f.forwardRbfAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardRbfBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardRbfAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardRbfBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardRbfAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardRbfBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardRbfAlice2Bob[TxComplete] + // Alice --- commit_sig --> Bob + f.forwardRbfAlice2Bob[CommitSig] + // Alice <-- commit_sig --- Bob + f.forwardRbfBob2Alice[CommitSig] + // Alice <-- tx_signatures --- Bob + val txB2 = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + aliceRbf ! ReceiveTxSigs(txB2.localSigs) + val succeeded = alice2bob.expectMsgType[Succeeded] + val rbfFeerate = succeeded.fundingParams.targetFeerate + assert(targetFeerate < rbfFeerate) + val txA2 = succeeded.sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(rbfFeerate * 0.9 <= txA2.feerate && txA2.feerate <= rbfFeerate * 1.25) + assert(inputA1 == inputA2) + assert(txA1.signedTx.txIn.map(_.outPoint) == txA2.signedTx.txIn.map(_.outPoint)) + assert(txA1.signedTx.txid != txA2.signedTx.txid) + assert(txA1.tx.fees < txA2.tx.fees) + walletA.publishTransaction(txA2.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA2.signedTx.txid) + } + } + + test("fund transaction with previous inputs (with new inputs)") { + val targetFeerate = FeeratePerKw(10_000 sat) + val fundingA = 100_000 sat + val utxosA = Seq(55_000 sat, 55_000 sat, 55_000 sat) + withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0) { f => + import f._ + + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + + // Alice --- tx_add_input --> Bob + val inputA1 = f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_input --> Bob + val inputA2 = f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardAlice2Bob[TxComplete] + // Alice --- commit_sig --> Bob + f.forwardAlice2Bob[CommitSig] + // Alice <-- commit_sig --- Bob + f.forwardBob2Alice[CommitSig] + // Alice <-- tx_signatures --- Bob + val txB1 = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + alice ! ReceiveTxSigs(txB1.localSigs) + val txA1 = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(targetFeerate * 0.9 <= txA1.feerate && txA1.feerate <= targetFeerate * 1.25) + val probe = TestProbe() + walletA.publishTransaction(txA1.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA1.signedTx.txid) + + aliceRbf ! Start(alice2bob.ref, Seq(txA1)) + bobRbf ! Start(bob2alice.ref, Nil) + + // Alice --- tx_add_input --> Bob + val inputA3 = f.forwardRbfAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardRbfBob2Alice[TxComplete] + // Alice --- tx_add_input --> Bob + val inputA4 = f.forwardRbfAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardRbfBob2Alice[TxComplete] + // Alice --- tx_add_input --> Bob + val inputA5 = f.forwardRbfAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardRbfBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardRbfAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardRbfBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardRbfAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardRbfBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardRbfAlice2Bob[TxComplete] + // Alice --- commit_sig --> Bob + f.forwardRbfAlice2Bob[CommitSig] + // Alice <-- commit_sig --- Bob + f.forwardRbfBob2Alice[CommitSig] + // Alice <-- tx_signatures --- Bob + val txB2 = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + aliceRbf ! ReceiveTxSigs(txB2.localSigs) + val succeeded = alice2bob.expectMsgType[Succeeded] + val rbfFeerate = succeeded.fundingParams.targetFeerate + assert(targetFeerate < rbfFeerate) + val txA2 = succeeded.sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(rbfFeerate * 0.9 <= txA2.feerate && txA2.feerate <= rbfFeerate * 1.25) + Seq(inputA1, inputA2).foreach(i => assert(Set(inputA3, inputA4, inputA5).contains(i))) + assert(txA1.signedTx.txid != txA2.signedTx.txid) + assert(txA1.signedTx.txIn.length + 1 == txA2.signedTx.txIn.length) + assert(txA1.tx.fees < txA2.tx.fees) + walletA.publishTransaction(txA2.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA2.signedTx.txid) + } + } + + test("not enough funds for rbf attempt") { + val targetFeerate = FeeratePerKw(10_000 sat) + val fundingA = 80_000 sat + val utxosA = Seq(85_000 sat) + withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0) { f => + import f._ + + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + + // Alice --- tx_add_input --> Bob + f.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + f.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + f.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + f.forwardAlice2Bob[TxComplete] + // Alice --- commit_sig --> Bob + f.forwardAlice2Bob[CommitSig] + // Alice <-- commit_sig --- Bob + f.forwardBob2Alice[CommitSig] + // Alice <-- tx_signatures --- Bob + val txB = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction] + alice ! ReceiveTxSigs(txB.localSigs) + val txA = alice2bob.expectMsgType[Succeeded].sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(targetFeerate * 0.9 <= txA.feerate && txA.feerate <= targetFeerate * 1.25) + + aliceRbf ! Start(alice2bob.ref, Seq(txA)) + assert(alice2bob.expectMsgType[LocalFailure].cause == ChannelFundingError(aliceParams.channelId)) + } + } + + test("invalid input") { + val probe = TestProbe() + // Create a transaction with a mix of segwit and non-segwit inputs. + val previousOutputs = Seq( + TxOut(2500 sat, Script.pay2wpkh(randomKey().publicKey)), + TxOut(2500 sat, Script.pay2pkh(randomKey().publicKey)), + ) + val previousTx = Transaction(2, Nil, previousOutputs, 0) + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val testCases = Seq( + TxAddInput(params.channelId, UInt64(0), previousTx, 0, 0) -> InvalidSerialId(params.channelId, UInt64(0)), + TxAddInput(params.channelId, UInt64(1), previousTx, 0, 0) -> DuplicateSerialId(params.channelId, UInt64(1)), + TxAddInput(params.channelId, UInt64(3), previousTx, 0, 0) -> DuplicateInput(params.channelId, UInt64(3), previousTx.txid, 0), + TxAddInput(params.channelId, UInt64(5), previousTx, 2, 0) -> InputOutOfBounds(params.channelId, UInt64(5), previousTx.txid, 2), + TxAddInput(params.channelId, UInt64(7), previousTx, 1, 0) -> NonSegwitInput(params.channelId, UInt64(7), previousTx.txid, 1), + ) + testCases.foreach { + case (input, expected) => + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + probe.expectMsgType[SendMessage] + // Alice <-- tx_add_input --- Bob + alice ! ReceiveTxMessage(TxAddInput(params.channelId, UInt64(1), previousTx, 0, 0)) + // Alice --- tx_add_output --> Bob + probe.expectMsgType[SendMessage] + // Alice <-- tx_add_input --- Bob + alice ! ReceiveTxMessage(input) + assert(probe.expectMsgType[RemoteFailure].cause == expected) + } + } + + test("invalid output") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val testCases = Seq( + TxAddOutput(params.channelId, UInt64(0), 25_000 sat, validScript) -> InvalidSerialId(params.channelId, UInt64(0)), + TxAddOutput(params.channelId, UInt64(1), 45_000 sat, validScript) -> DuplicateSerialId(params.channelId, UInt64(1)), + TxAddOutput(params.channelId, UInt64(3), 329 sat, validScript) -> OutputBelowDust(params.channelId, UInt64(3), 329 sat, 330 sat), + TxAddOutput(params.channelId, UInt64(5), 45_000 sat, Script.write(Script.pay2pkh(randomKey().publicKey))) -> NonSegwitOutput(params.channelId, UInt64(5)), + ) + testCases.foreach { + case (output, expected) => + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + probe.expectMsgType[SendMessage] + // Alice <-- tx_add_output --- Bob + alice ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(1), 50_000 sat, validScript)) + // Alice --- tx_add_output --> Bob + probe.expectMsgType[SendMessage] + // Alice <-- tx_add_input --- Bob + alice ! ReceiveTxMessage(output) + assert(probe.expectMsgType[RemoteFailure].cause == expected) + } + } + + test("remove unknown input/output") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val testCases = Seq( + TxRemoveOutput(params.channelId, UInt64(53)) -> UnknownSerialId(params.channelId, UInt64(53)), + TxRemoveInput(params.channelId, UInt64(57)) -> UnknownSerialId(params.channelId, UInt64(57)), + ) + testCases.foreach { + case (msg, expected) => + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + probe.expectMsgType[SendMessage] + // Alice <-- tx_remove_(in|out)put --- Bob + alice ! ReceiveTxMessage(msg) + assert(probe.expectMsgType[RemoteFailure].cause == expected) + } + } + + test("too many protocol rounds") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(probe.ref, Nil) + (1 until InteractiveTxBuilder.MAX_INPUTS_OUTPUTS_RECEIVED).foreach(i => { + // Alice --- tx_message --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(2 * i + 1), 2500 sat, validScript)) + }) + // Alice --- tx_complete --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(15001), 2500 sat, validScript)) + assert(probe.expectMsgType[RemoteFailure].cause == TooManyInteractiveTxRounds(params.channelId)) + } + + test("too many inputs") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(probe.ref, Nil) + (1 to 252).foreach(i => { + // Alice --- tx_message --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(createInput(params.channelId, UInt64(2 * i + 1), 5000 sat)) + }) + // Alice --- tx_complete --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(TxComplete(params.channelId)) + assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId)) + } + + test("too many outputs") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(probe.ref, Nil) + (1 to 252).foreach(i => { + // Alice --- tx_message --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(2 * i + 1), 2500 sat, validScript)) + }) + // Alice --- tx_complete --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(TxComplete(params.channelId)) + assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId)) + } + + test("missing funding output") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val bob = params.spawnTxBuilderBob(params.fundingParamsB, TestConstants.anchorOutputsFeeratePerKw, wallet) + bob ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + bob ! ReceiveTxMessage(createInput(params.channelId, UInt64(0), 150_000 sat)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(2), 125_000 sat, validScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_complete --> Bob + bob ! ReceiveTxMessage(TxComplete(params.channelId)) + assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId)) + } + + test("multiple funding outputs") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val bob = params.spawnTxBuilderBob(params.fundingParamsB, TestConstants.anchorOutputsFeeratePerKw, wallet) + bob ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + bob ! ReceiveTxMessage(createInput(params.channelId, UInt64(0), 150_000 sat)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(2), 100_000 sat, params.fundingParamsB.fundingPubkeyScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(4), 25_000 sat, params.fundingParamsB.fundingPubkeyScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_complete --> Bob + bob ! ReceiveTxMessage(TxComplete(params.channelId)) + assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId)) + } + + test("invalid funding amount") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val bob = params.spawnTxBuilderBob(params.fundingParamsB, TestConstants.anchorOutputsFeeratePerKw, wallet) + bob ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + bob ! ReceiveTxMessage(createInput(params.channelId, UInt64(0), 150_000 sat)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(2), 100_001 sat, params.fundingParamsB.fundingPubkeyScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_complete --> Bob + bob ! ReceiveTxMessage(TxComplete(params.channelId)) + assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId)) + } + + test("total input amount too low") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val bob = params.spawnTxBuilderBob(params.fundingParamsB, TestConstants.anchorOutputsFeeratePerKw, wallet) + bob ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + bob ! ReceiveTxMessage(createInput(params.channelId, UInt64(0), 150_000 sat)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(2), 100_000 sat, params.fundingParamsB.fundingPubkeyScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(4), 51_000 sat, validScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_complete --> Bob + bob ! ReceiveTxMessage(TxComplete(params.channelId)) + assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId)) + } + + test("minimum fee not met") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val bob = params.spawnTxBuilderBob(params.fundingParamsB, TestConstants.anchorOutputsFeeratePerKw, wallet) + bob ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + bob ! ReceiveTxMessage(createInput(params.channelId, UInt64(0), 150_000 sat)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(2), 100_000 sat, params.fundingParamsB.fundingPubkeyScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(4), 49_999 sat, validScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_complete --> Bob + bob ! ReceiveTxMessage(TxComplete(params.channelId)) + assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId)) + } + + test("previous attempts not double-spent") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val firstAttempt = PartiallySignedSharedTransaction(SharedTransaction(Seq(createInput(params.channelId, UInt64(2), 125_000 sat)), Nil, Nil, Nil, 0), null) + val secondAttempt = PartiallySignedSharedTransaction(SharedTransaction(firstAttempt.tx.localInputs :+ createInput(params.channelId, UInt64(4), 150_000 sat), Nil, Nil, Nil, 0), null) + val bob = params.spawnTxBuilderBob(params.fundingParamsB, TestConstants.anchorOutputsFeeratePerKw, wallet) + bob ! Start(probe.ref, Seq(firstAttempt, secondAttempt)) + // Alice --- tx_add_input --> Bob + bob ! ReceiveTxMessage(secondAttempt.tx.localInputs.last) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(10), 100_000 sat, params.fundingParamsB.fundingPubkeyScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(TxAddOutput(params.channelId, UInt64(12), 25_000 sat, validScript)) + // Alice <-- tx_complete --- Bob + probe.expectMsgType[SendMessage] + // Alice --- tx_complete --> Bob + bob ! ReceiveTxMessage(TxComplete(params.channelId)) + assert(probe.expectMsgType[RemoteFailure].cause == InvalidCompleteInteractiveTx(params.channelId)) + } + + test("invalid commit_sig") { + val probe = TestProbe() + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(probe.ref, Nil) + // Alice --- tx_add_input --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(TxComplete(params.channelId)) + // Alice --- tx_add_output --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(TxComplete(params.channelId)) + // Alice --- tx_add_output --> Bob + probe.expectMsgType[SendMessage] + alice ! ReceiveTxMessage(TxComplete(params.channelId)) + // Alice --- tx_complete --> Bob + assert(probe.expectMsgType[SendMessage].msg.isInstanceOf[TxComplete]) + // Alice --- commit_sig --> Bob + assert(probe.expectMsgType[SendMessage].msg.isInstanceOf[CommitSig]) + // Alice <-- commit_sig --- Bob + alice ! ReceiveCommitSig(CommitSig(params.channelId, ByteVector64.Zeroes, Nil)) + assert(probe.expectMsgType[RemoteFailure].cause.isInstanceOf[InvalidCommitmentSignature]) + } + + test("receive tx_signatures before commit_sig") { + val (alice2bob, bob2alice) = (TestProbe(), TestProbe()) + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + val bob = params.spawnTxBuilderBob(params.fundingParamsB, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + // Alice --- tx_add_input --> Bob + bob ! ReceiveTxMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddInput]) + alice ! ReceiveTxMessage(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddOutput]) + alice ! ReceiveTxMessage(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddOutput]) + alice ! ReceiveTxMessage(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice --- tx_complete --> Bob + bob ! ReceiveTxMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice <-- commit_sig --> Bob + assert(bob2alice.expectMsgType[SendMessage].msg.isInstanceOf[CommitSig]) // alice does *not* receive bob's commit_sig + bob ! ReceiveCommitSig(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[CommitSig]) + // Alice <-- tx_signatures --- Bob + alice ! ReceiveTxSigs(bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction].localSigs) + assert(alice2bob.expectMsgType[RemoteFailure].cause == UnexpectedFundingSignatures(params.channelId)) + } + + test("invalid tx_signatures") { + val (alice2bob, bob2alice) = (TestProbe(), TestProbe()) + val wallet = new SingleKeyOnChainWallet() + val params = createChannelParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val alice = params.spawnTxBuilderAlice(params.fundingParamsA, TestConstants.anchorOutputsFeeratePerKw, wallet) + val bob = params.spawnTxBuilderBob(params.fundingParamsB, TestConstants.anchorOutputsFeeratePerKw, wallet) + alice ! Start(alice2bob.ref, Nil) + bob ! Start(bob2alice.ref, Nil) + // Alice --- tx_add_input --> Bob + bob ! ReceiveTxMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddInput]) + alice ! ReceiveTxMessage(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddOutput]) + alice ! ReceiveTxMessage(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice --- tx_add_output --> Bob + bob ! ReceiveTxMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddOutput]) + alice ! ReceiveTxMessage(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice --- tx_complete --> Bob + bob ! ReceiveTxMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice <-- commit_sig --> Bob + alice ! ReceiveCommitSig(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[CommitSig]) + bob ! ReceiveCommitSig(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[CommitSig]) + // Alice <-- tx_signatures --- Bob + val bobSigs = bob2alice.expectMsgType[Succeeded].sharedTx.asInstanceOf[PartiallySignedSharedTransaction].localSigs + alice ! ReceiveTxSigs(bobSigs.copy(witnesses = Seq(Script.witnessPay2wpkh(randomKey().publicKey, ByteVector.fill(73)(0))))) + assert(alice2bob.expectMsgType[RemoteFailure].cause.isInstanceOf[InvalidFundingSignature]) + } + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 1dc458a684..f1bda82516 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -129,7 +129,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val blockHeight = new AtomicLong() blockHeight.set(currentBlockHeight(probe).toLong) val aliceNodeParams = TestConstants.Alice.nodeParams.copy(blockHeight = blockHeight) - val setup = init(aliceNodeParams, TestConstants.Bob.nodeParams.copy(blockHeight = blockHeight), walletClient) + val setup = init(aliceNodeParams, TestConstants.Bob.nodeParams.copy(blockHeight = blockHeight), wallet_opt = Some(walletClient)) val testTags = channelType match { case _: ChannelTypes.AnchorOutputsZeroFeeHtlcTx => Set(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) case ChannelTypes.AnchorOutputs => Set(ChannelStateTestsTags.AnchorOutputs) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index cf03f1fe17..92dfe5f5a1 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -27,7 +27,7 @@ import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeTargets, FeeratePerKw} -import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet} +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet, SingleKeyOnChainWallet} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.TxPublisher @@ -63,8 +63,6 @@ object ChannelStateTestsTags { val ChannelsPublic = "channels_public" /** If set, no amount will be pushed when opening a channel (by default we push a small amount). */ val NoPushMsat = "no_push_msat" - /** If set, the non-initiator of a dual-funded channel will contribute some funds. */ - val DualFundingContribution = "dual_funding_contribution" /** If set, max-htlc-value-in-flight will be set to the highest possible value for Alice and Bob. */ val NoMaxHtlcValueInFlight = "no_max_htlc_value_in_flight" /** If set, max-htlc-value-in-flight will be set to a low value for Alice. */ @@ -118,7 +116,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { system.registerOnTermination(TestKit.shutdownActorSystem(systemA)) system.registerOnTermination(TestKit.shutdownActorSystem(systemB)) - def init(nodeParamsA: NodeParams = TestConstants.Alice.nodeParams, nodeParamsB: NodeParams = TestConstants.Bob.nodeParams, wallet: OnChainWallet = new DummyOnChainWallet(), tags: Set[String] = Set.empty): SetupFixture = { + def init(nodeParamsA: NodeParams = TestConstants.Alice.nodeParams, nodeParamsB: NodeParams = TestConstants.Bob.nodeParams, wallet_opt: Option[OnChainWallet] = None, tags: Set[String] = Set.empty): SetupFixture = { val aliceOrigin = TestProbe() val alice2bob = TestProbe() val bob2alice = TestProbe() @@ -148,6 +146,10 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.channelConf.maxRemoteDustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(10000 sat) .modify(_.channelConf.maxRemoteDustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(10000 sat) .modify(_.onChainFeeConf.spendAnchorWithoutHtlcs).setToIf(tags.contains(ChannelStateTestsTags.DontSpendAnchorWithoutHtlcs))(false) + val wallet = wallet_opt match { + case Some(wallet) => wallet + case None => if (tags.contains(ChannelStateTestsTags.DualFunding)) new SingleKeyOnChainWallet() else new DummyOnChainWallet() + } val alice: TestFSMRef[ChannelState, ChannelData, Channel] = { implicit val system: ActorSystem = systemA TestFSMRef(new Channel(finalNodeParamsA, wallet, finalNodeParamsB.nodeId, alice2blockchain.ref, alice2relayer.ref, FakeTxPublisherFactory(alice2blockchain), origin_opt = Some(aliceOrigin.ref)), alicePeer.ref) @@ -201,12 +203,14 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.maxHtlcValueInFlightMsat).setToIf(tags.contains(ChannelStateTestsTags.AliceLowMaxHtlcValueInFlight))(UInt64(150000000)) .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(5000 sat) .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(1000 sat) + .modify(_.requestedChannelReserve_opt).setToIf(tags.contains(ChannelStateTestsTags.DualFunding))(None) val bobParams = Bob.channelParams .modify(_.initFeatures).setTo(bobInitFeatures) .modify(_.walletStaticPaymentBasepoint).setToIf(channelType.paysDirectlyToWallet)(Some(Await.result(wallet.getReceivePubkey(), 10 seconds))) .modify(_.maxHtlcValueInFlightMsat).setToIf(tags.contains(ChannelStateTestsTags.NoMaxHtlcValueInFlight))(UInt64.MaxValue) .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(1000 sat) .modify(_.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(5000 sat) + .modify(_.requestedChannelReserve_opt).setToIf(tags.contains(ChannelStateTestsTags.DualFunding))(None) (aliceParams, bobParams, channelType) } @@ -219,10 +223,10 @@ trait ChannelStateTestsBase extends Assertions with Eventually { val channelFlags = ChannelFlags(announceChannel = tags.contains(ChannelStateTestsTags.ChannelsPublic)) val (aliceParams, bobParams, channelType) = computeFeatures(setup, tags, channelFlags) val commitTxFeerate = if (tags.contains(ChannelStateTestsTags.AnchorOutputs) || tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) TestConstants.anchorOutputsFeeratePerKw else TestConstants.feeratePerKw - val fundingAmount = TestConstants.fundingSatoshis - val pushMsat = if (tags.contains(ChannelStateTestsTags.NoPushMsat)) 0 msat else TestConstants.pushMsat - val nonInitiatorFundingAmount = if (tags.contains(ChannelStateTestsTags.DualFundingContribution)) Some(TestConstants.nonInitiatorFundingSatoshis) else None val dualFunded = tags.contains(ChannelStateTestsTags.DualFunding) + val fundingAmount = TestConstants.fundingSatoshis + val pushMsat = if (tags.contains(ChannelStateTestsTags.NoPushMsat) || dualFunded) 0 msat else TestConstants.pushMsat + val nonInitiatorFundingAmount = if (dualFunded) Some(TestConstants.nonInitiatorFundingSatoshis) else None val eventListener = TestProbe() systemA.eventStream.subscribe(eventListener.ref, classOf[TransactionPublished]) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala index 7203476f4f..9a42d1c30f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala @@ -52,7 +52,7 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS .modify(_.chainHash).setToIf(test.tags.contains("mainnet"))(Block.LivenetGenesisBlock.hash) .modify(_.channelConf.maxFundingSatoshis).setToIf(test.tags.contains("high-max-funding-size"))(Btc(100)) - val setup = init(aliceNodeParams, bobNodeParams, wallet = new NoOpOnChainWallet()) + val setup = init(aliceNodeParams, bobNodeParams, wallet_opt = Some(new NoOpOnChainWallet()), test.tags) import setup._ val channelConfig = ChannelConfig.standard @@ -160,7 +160,7 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS } test("recv AcceptChannel (anchor outputs channel type without enabling the feature)") { () => - val setup = init(Alice.nodeParams, Bob.nodeParams, wallet = new NoOpOnChainWallet()) + val setup = init(Alice.nodeParams, Bob.nodeParams, wallet_opt = Some(new NoOpOnChainWallet())) import setup._ val channelConfig = ChannelConfig.standard diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala index 97ef29e99c..aceda4719b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala @@ -20,13 +20,12 @@ import akka.actor.Status import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong} import fr.acinq.eclair.TestConstants.Alice -import fr.acinq.eclair.blockchain.NoOpOnChainWallet import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.TickChannelOpenTimeout import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.wire.protocol.{AcceptDualFundedChannel, Error, Init, OpenDualFundedChannel} -import fr.acinq.eclair.{Features, TestConstants, TestKitBaseClass} +import fr.acinq.eclair.{TestConstants, TestKitBaseClass} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -37,7 +36,7 @@ class WaitForAcceptDualFundedChannelStateSpec extends TestKitBaseClass with Fixt case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], open: OpenDualFundedChannel, aliceOrigin: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe) override def withFixture(test: OneArgTest): Outcome = { - val setup = init(wallet = new NoOpOnChainWallet()) + val setup = init(tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard @@ -45,7 +44,7 @@ class WaitForAcceptDualFundedChannelStateSpec extends TestKitBaseClass with Fixt val (aliceParams, bobParams, channelType) = computeFeatures(setup, test.tags, channelFlags) val aliceInit = Init(aliceParams.initFeatures) val bobInit = Init(bobParams.initFeatures) - val nonInitiatorContribution = if (test.tags.contains(ChannelStateTestsTags.DualFundingContribution)) Some(TestConstants.nonInitiatorFundingSatoshis) else None + val nonInitiatorContribution = if (test.tags.contains("dual_funding_contribution")) Some(TestConstants.nonInitiatorFundingSatoshis) else None within(30 seconds) { alice ! INPUT_INIT_CHANNEL_INITIATOR(ByteVector32.Zeroes, TestConstants.fundingSatoshis, dualFunded = true, TestConstants.anchorOutputsFeeratePerKw, TestConstants.feeratePerKw, None, aliceParams, alice2bob.ref, bobInit, ChannelFlags.Private, channelConfig, channelType) bob ! INPUT_INIT_CHANNEL_NON_INITIATOR(ByteVector32.Zeroes, nonInitiatorContribution, dualFunded = true, bobParams, bob2alice.ref, aliceInit, channelConfig, channelType) @@ -69,14 +68,11 @@ class WaitForAcceptDualFundedChannelStateSpec extends TestKitBaseClass with Fixt bob2alice.forward(alice, accept) assert(listener.expectMsgType[ChannelIdAssigned].channelId == Helpers.computeChannelId(open, accept)) - awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_INTERNAL) - val channelFeatures = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL].channelFeatures - assert(channelFeatures.channelType == ChannelTypes.AnchorOutputsZeroFeeHtlcTx(scidAlias = false, zeroConf = false)) - assert(channelFeatures.hasFeature(Features.DualFunding)) + awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) aliceOrigin.expectNoMessage() } - test("recv AcceptDualFundedChannel (with non-initiator contribution)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.DualFundingContribution), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + test("recv AcceptDualFundedChannel (with non-initiator contribution)", Tag(ChannelStateTestsTags.DualFunding), Tag("dual_funding_contribution"), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ val accept = bob2alice.expectMsgType[AcceptDualFundedChannel] @@ -84,7 +80,7 @@ class WaitForAcceptDualFundedChannelStateSpec extends TestKitBaseClass with Fixt assert(accept.channelType_opt.contains(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(scidAlias = false, zeroConf = false))) assert(accept.fundingAmount == TestConstants.nonInitiatorFundingSatoshis) bob2alice.forward(alice, accept) - awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_INTERNAL) + awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) } test("recv AcceptDualFundedChannel (invalid max accepted htlcs)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala index 8c81919dd5..8d174774c5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala @@ -46,7 +46,7 @@ class WaitForOpenChannelStateSpec extends TestKitBaseClass with FixtureAnyFunSui val bobNodeParams = Bob.nodeParams .modify(_.channelConf.maxFundingSatoshis).setToIf(test.tags.contains("max-funding-satoshis"))(Btc(1)) - val setup = init(nodeParamsB = bobNodeParams) + val setup = init(nodeParamsB = bobNodeParams, tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala index 79898cc8f6..1db1210467 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.wire.protocol.{AcceptDualFundedChannel, Error, Init, OpenDualFundedChannel} -import fr.acinq.eclair.{Features, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.{TestConstants, TestKitBaseClass, randomBytes32} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -34,7 +34,7 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alice2bob: TestProbe, bob2alice: TestProbe, aliceListener: TestProbe, bobListener: TestProbe) override def withFixture(test: OneArgTest): Outcome = { - val setup = init() + val setup = init(tags = test.tags) import setup._ val aliceListener = TestProbe() @@ -82,10 +82,7 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur assert(channelIdAssigned.temporaryChannelId == ByteVector32.Zeroes) assert(channelIdAssigned.channelId == Helpers.computeChannelId(open, accept)) - awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_INTERNAL) - val channelFeatures = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL].channelFeatures - assert(channelFeatures.channelType == ChannelTypes.AnchorOutputsZeroFeeHtlcTx(scidAlias = false, zeroConf = false)) - assert(channelFeatures.hasFeature(Features.DualFunding)) + awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) } test("recv OpenDualFundedChannel (invalid chain)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala new file mode 100644 index 0000000000..17d43c4623 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala @@ -0,0 +1,356 @@ +/* + * Copyright 2022 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.channel.states.b + +import akka.actor.Status +import akka.testkit.{TestFSMRef, TestProbe} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, SatoshiLong, Script} +import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchFundingLost} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.InteractiveTxBuilder.{FullySignedSharedTransaction, PartiallySignedSharedTransaction} +import fr.acinq.eclair.channel._ +import fr.acinq.eclair.channel.fsm.Channel +import fr.acinq.eclair.channel.fsm.Channel.TickChannelOpenTimeout +import fr.acinq.eclair.channel.publish.TxPublisher +import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.wire.protocol.{AcceptDualFundedChannel, ChannelReady, CommitSig, Error, Init, OpenDualFundedChannel, TxAbort, TxAckRbf, TxAddInput, TxAddOutput, TxComplete, TxInitRbf, TxSignatures, Warning} +import fr.acinq.eclair.{Features, TestConstants, TestKitBaseClass, UInt64, randomBytes32, randomKey} +import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{Outcome, Tag} +import scodec.bits.HexStringSyntax + +import scala.concurrent.duration.DurationInt + +class WaitForDualFundingCreatedStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with ChannelStateTestsBase { + + case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], aliceOrigin: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, wallet: SingleKeyOnChainWallet) + + override def withFixture(test: OneArgTest): Outcome = { + val wallet = new SingleKeyOnChainWallet() + val setup = init(wallet_opt = Some(wallet), tags = test.tags) + import setup._ + val channelConfig = ChannelConfig.standard + val channelFlags = ChannelFlags.Private + val (aliceParams, bobParams, channelType) = computeFeatures(setup, test.tags, channelFlags) + val aliceInit = Init(aliceParams.initFeatures) + val bobInit = Init(bobParams.initFeatures) + val bobContribution = if (channelType.features.contains(Features.ZeroConf)) None else Some(TestConstants.nonInitiatorFundingSatoshis) + within(30 seconds) { + alice ! INPUT_INIT_CHANNEL_INITIATOR(ByteVector32.Zeroes, TestConstants.fundingSatoshis, dualFunded = true, TestConstants.feeratePerKw, TestConstants.feeratePerKw, None, aliceParams, alice2bob.ref, bobInit, channelFlags, channelConfig, channelType) + bob ! INPUT_INIT_CHANNEL_NON_INITIATOR(ByteVector32.Zeroes, bobContribution, dualFunded = true, bobParams, bob2alice.ref, aliceInit, channelConfig, channelType) + alice2blockchain.expectMsgType[TxPublisher.SetChannelId] // temporary channel id + bob2blockchain.expectMsgType[TxPublisher.SetChannelId] // temporary channel id + alice2bob.expectMsgType[OpenDualFundedChannel] + alice2bob.forward(bob) + bob2alice.expectMsgType[AcceptDualFundedChannel] + bob2alice.forward(alice) + alice2blockchain.expectMsgType[TxPublisher.SetChannelId] // final channel id + bob2blockchain.expectMsgType[TxPublisher.SetChannelId] // final channel id + awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) + awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) + withFixture(test.toNoArgTest(FixtureParam(alice, bob, aliceOrigin, alice2bob, bob2alice, alice2blockchain, bob2blockchain, wallet))) + } + } + + test("complete interactive-tx protocol", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + val listener = TestProbe() + alice.underlyingActor.context.system.eventStream.subscribe(listener.ref, classOf[TransactionPublished]) + + // The initiator sends the first interactive-tx message. + bob2alice.expectNoMessage(100 millis) + alice2bob.expectMsgType[TxAddInput] + alice2bob.expectNoMessage(100 millis) + alice2bob.forward(bob) + bob2alice.expectMsgType[TxAddInput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxAddOutput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxComplete] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + + // Bob sends its signatures first as he contributed less than Alice. + bob2alice.expectMsgType[TxSignatures] + awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_PLACEHOLDER) + val bobData = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER] + assert(bobData.commitments.channelFeatures.hasFeature(Features.DualFunding)) + assert(bobData.fundingTx.isInstanceOf[PartiallySignedSharedTransaction]) + val fundingTxId = bobData.fundingTx.asInstanceOf[PartiallySignedSharedTransaction].tx.buildUnsignedTx().txid + assert(bob2blockchain.expectMsgType[WatchFundingConfirmed].txId === fundingTxId) + + // Alice receives Bob's signatures and sends her own signatures. + bob2alice.forward(alice) + assert(listener.expectMsgType[TransactionPublished].tx.txid === fundingTxId) + assert(alice2blockchain.expectMsgType[WatchFundingConfirmed].txId === fundingTxId) + alice2bob.expectMsgType[TxSignatures] + awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_PLACEHOLDER) + val aliceData = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER] + assert(aliceData.commitments.channelFeatures.hasFeature(Features.DualFunding)) + assert(aliceData.fundingTx.isInstanceOf[FullySignedSharedTransaction]) + assert(aliceData.fundingTx.asInstanceOf[FullySignedSharedTransaction].signedTx.txid === fundingTxId) + } + + test("complete interactive-tx protocol (zero-conf)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.ScidAlias), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + val aliceListener = TestProbe() + alice.underlyingActor.context.system.eventStream.subscribe(aliceListener.ref, classOf[TransactionPublished]) + alice.underlyingActor.context.system.eventStream.subscribe(aliceListener.ref, classOf[ShortChannelIdAssigned]) + val bobListener = TestProbe() + bob.underlyingActor.context.system.eventStream.subscribe(bobListener.ref, classOf[ShortChannelIdAssigned]) + + alice2bob.expectMsgType[TxAddInput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxComplete] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxComplete] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxComplete] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + + // Bob sends its signatures first as he did not contribute. + val bobSigs = bob2alice.expectMsgType[TxSignatures] + bob2alice.expectMsgType[ChannelReady] + assert(bobListener.expectMsgType[ShortChannelIdAssigned].shortIds.real == RealScidStatus.Unknown) + awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_PLACEHOLDER) + val bobData = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER] + assert(bobData.commitments.channelFeatures.hasFeature(Features.DualFunding)) + assert(bobData.commitments.channelFeatures.hasFeature(Features.ZeroConf)) + assert(bobData.fundingTx.isInstanceOf[PartiallySignedSharedTransaction]) + val fundingTxId = bobData.fundingTx.asInstanceOf[PartiallySignedSharedTransaction].tx.buildUnsignedTx().txid + assert(bob2blockchain.expectMsgType[WatchFundingLost].txId === fundingTxId) + bob2blockchain.expectNoMessage(100 millis) + + // Alice receives Bob's signatures and sends her own signatures. + bob2alice.forward(alice, bobSigs) + assert(aliceListener.expectMsgType[ShortChannelIdAssigned].shortIds.real == RealScidStatus.Unknown) + assert(aliceListener.expectMsgType[TransactionPublished].tx.txid === fundingTxId) + assert(alice2blockchain.expectMsgType[WatchFundingLost].txId === fundingTxId) + alice2blockchain.expectNoMessage(100 millis) + alice2bob.expectMsgType[TxSignatures] + alice2bob.expectMsgType[ChannelReady] + awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_PLACEHOLDER) + val aliceData = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER] + assert(aliceData.commitments.channelFeatures.hasFeature(Features.DualFunding)) + assert(aliceData.commitments.channelFeatures.hasFeature(Features.ZeroConf)) + assert(aliceData.fundingTx.isInstanceOf[FullySignedSharedTransaction]) + assert(aliceData.fundingTx.asInstanceOf[FullySignedSharedTransaction].signedTx.txid === fundingTxId) + } + + test("recv invalid interactive-tx message", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + val inputA = alice2bob.expectMsgType[TxAddInput] + + // Invalid serial_id. + alice2bob.forward(bob, inputA.copy(serialId = UInt64(1))) + bob2alice.expectMsgType[TxAbort] + awaitCond(wallet.rolledback.length == 1) + awaitCond(bob.stateName == CLOSED) + + // Below dust. + bob2alice.forward(alice, TxAddOutput(channelId(bob), UInt64(1), 150 sat, Script.write(Script.pay2wpkh(randomKey().publicKey)))) + alice2bob.expectMsgType[TxAbort] + awaitCond(wallet.rolledback.length == 2) + awaitCond(alice.stateName == CLOSED) + aliceOrigin.expectMsgType[Status.Failure] + } + + test("recv invalid CommitSig", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + alice2bob.expectMsgType[TxAddInput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxAddInput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxAddOutput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxComplete] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxComplete] + alice2bob.forward(bob) + val bobCommitSig = bob2alice.expectMsgType[CommitSig] + val aliceCommitSig = alice2bob.expectMsgType[CommitSig] + + bob2alice.forward(alice, bobCommitSig.copy(signature = ByteVector64.Zeroes)) + alice2bob.expectMsgType[TxAbort] + awaitCond(wallet.rolledback.length == 1) + awaitCond(alice.stateName == CLOSED) + aliceOrigin.expectMsgType[Status.Failure] + + alice2bob.forward(bob, aliceCommitSig.copy(signature = ByteVector64.Zeroes)) + bob2alice.expectMsgType[TxAbort] + awaitCond(wallet.rolledback.length == 2) + awaitCond(bob.stateName == CLOSED) + } + + test("recv invalid TxSignatures", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + alice2bob.expectMsgType[TxAddInput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxAddInput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxAddOutput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxComplete] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + + val bobSigs = bob2alice.expectMsgType[TxSignatures] + bob2blockchain.expectMsgType[WatchFundingConfirmed] + bob2alice.forward(alice, bobSigs.copy(txId = randomBytes32(), witnesses = Nil)) + alice2bob.expectMsgType[TxAbort] + awaitCond(wallet.rolledback.size == 1) + awaitCond(alice.stateName == CLOSED) + aliceOrigin.expectMsgType[Status.Failure] + } + + test("recv TxAbort", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + alice2bob.expectMsgType[TxAddInput] + alice2bob.forward(bob, TxAbort(channelId(alice), hex"deadbeef")) + awaitCond(wallet.rolledback.size == 1) + awaitCond(bob.stateName == CLOSED) + + bob2alice.forward(alice, TxAbort(channelId(bob), hex"deadbeef")) + awaitCond(wallet.rolledback.size == 2) + awaitCond(alice.stateName == CLOSED) + aliceOrigin.expectMsgType[Status.Failure] + } + + test("recv TxInitRbf", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + alice2bob.expectMsgType[TxAddInput] + alice2bob.forward(bob, TxInitRbf(channelId(alice), 0, FeeratePerKw(15_000 sat))) + bob2alice.expectMsgType[Warning] + assert(bob.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) + + bob2alice.forward(alice, TxInitRbf(channelId(bob), 0, FeeratePerKw(15_000 sat))) + alice2bob.expectMsgType[Warning] + assert(alice.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) + aliceOrigin.expectNoMessage(100 millis) + assert(wallet.rolledback.isEmpty) + } + + test("recv TxAckRbf", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + alice2bob.expectMsgType[TxAddInput] + alice2bob.forward(bob, TxAckRbf(channelId(alice))) + bob2alice.expectMsgType[Warning] + assert(bob.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) + + bob2alice.forward(alice, TxAckRbf(channelId(bob))) + alice2bob.expectMsgType[Warning] + assert(alice.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) + aliceOrigin.expectNoMessage(100 millis) + assert(wallet.rolledback.isEmpty) + } + + test("recv Error", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + val finalChannelId = channelId(alice) + alice ! Error(finalChannelId, "oops") + awaitCond(wallet.rolledback.size == 1) + awaitCond(alice.stateName == CLOSED) + aliceOrigin.expectMsgType[Status.Failure] + + bob ! Error(finalChannelId, "oops") + awaitCond(wallet.rolledback.size == 2) + awaitCond(bob.stateName == CLOSED) + } + + test("recv CMD_CLOSE", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + val finalChannelId = channelId(alice) + val sender = TestProbe() + val c = CMD_CLOSE(sender.ref, None, None) + + alice ! c + sender.expectMsg(RES_SUCCESS(c, finalChannelId)) + awaitCond(wallet.rolledback.size == 1) + awaitCond(alice.stateName == CLOSED) + aliceOrigin.expectMsgType[ChannelOpenResponse.ChannelClosed] + + bob ! c + sender.expectMsg(RES_SUCCESS(c, finalChannelId)) + awaitCond(wallet.rolledback.size == 2) + awaitCond(bob.stateName == CLOSED) + } + + test("recv INPUT_DISCONNECTED", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + + alice ! INPUT_DISCONNECTED + awaitCond(wallet.rolledback.size == 1) + awaitCond(alice.stateName == CLOSED) + aliceOrigin.expectMsgType[Status.Failure] + + bob ! INPUT_DISCONNECTED + awaitCond(wallet.rolledback.size == 2) + awaitCond(bob.stateName == CLOSED) + } + + test("recv TickChannelOpenTimeout", Tag(ChannelStateTestsTags.DualFunding)) { f => + import f._ + alice ! TickChannelOpenTimeout + awaitCond(wallet.rolledback.size == 1) + awaitCond(alice.stateName == CLOSED) + aliceOrigin.expectMsgType[Status.Failure] + } + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingCreatedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingCreatedStateSpec.scala index 66f8713012..4469101f07 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingCreatedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingCreatedStateSpec.scala @@ -56,7 +56,7 @@ class WaitForFundingCreatedStateSpec extends TestKitBaseClass with FixtureAnyFun (TestConstants.fundingSatoshis, TestConstants.pushMsat) } - val setup = init(aliceNodeParams, bobNodeParams) + val setup = init(aliceNodeParams, bobNodeParams, tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingInternalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingInternalStateSpec.scala index 0fd48e21c4..7e1c742e2b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingInternalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingInternalStateSpec.scala @@ -40,7 +40,7 @@ class WaitForFundingInternalStateSpec extends TestKitBaseClass with FixtureAnyFu case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], aliceOrigin: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe) override def withFixture(test: OneArgTest): Outcome = { - val setup = init(wallet = new NoOpOnChainWallet()) + val setup = init(wallet_opt = Some(new NoOpOnChainWallet()), tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard val channelFlags = ChannelFlags.Private diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala index 480fdf7513..d7883a6a2b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala @@ -55,7 +55,7 @@ class WaitForFundingSignedStateSpec extends TestKitBaseClass with FixtureAnyFunS (TestConstants.fundingSatoshis, TestConstants.pushMsat) } - val setup = init(aliceNodeParams, bobNodeParams) + val setup = init(aliceNodeParams, bobNodeParams, tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForChannelReadyStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForChannelReadyStateSpec.scala index ce17487f0d..6b9d737a31 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForChannelReadyStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForChannelReadyStateSpec.scala @@ -44,7 +44,7 @@ class WaitForChannelReadyStateSpec extends TestKitBaseClass with FixtureAnyFunSu case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, router: TestProbe) override def withFixture(test: OneArgTest): Outcome = { - val setup = init() + val setup = init(tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard val channelFlags = ChannelFlags(announceChannel = test.tags.contains(ChannelStateTestsTags.ChannelsPublic)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala index cbb5fac754..4249152a0a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala @@ -43,9 +43,7 @@ class WaitForFundingConfirmedStateSpec extends TestKitBaseClass with FixtureAnyF case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, listener: TestProbe) override def withFixture(test: OneArgTest): Outcome = { - - val setup = init() - + val setup = init(tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard val channelFlags = ChannelFlags.Private diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala index 4b49d0bdbe..3967bfc166 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala @@ -178,9 +178,9 @@ class LightningMessageCodecsSpec extends AnyFunSuite { TxSignatures(channelId1, tx2.txid, Seq(ScriptWitness(Seq(hex"dead", hex"beef")), ScriptWitness(Seq(hex"", hex"01010101", hex"", hex"02")))) -> hex"0047 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa f169ed4bcb4ca97646845ec063d4deddcbe704f77f1b2c205929195f84a87afc 0002 00020002dead0002beef 0004 00000004010101010000000102", TxSignatures(channelId2, tx1.txid, Nil) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 06f125a8ef64eb5a25826190dc28f15b85dc1adcfc7a178eef393ea325c02e1f 0000", TxInitRbf(channelId1, 8388607, FeeratePerKw(4000 sat)) -> hex"0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 007fffff 00000fa0", - TxInitRbf(channelId1, 0, FeeratePerKw(4000 sat), TlvStream(SharedOutputContributionTlv(5000 sat))) -> hex"0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000 00000fa0 00021388", + TxInitRbf(channelId1, 0, FeeratePerKw(4000 sat), TlvStream[TxInitRbfTlv](SharedOutputContributionTlv(5000 sat))) -> hex"0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000 00000fa0 00021388", TxAckRbf(channelId2) -> hex"0049 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - TxAckRbf(channelId2, TlvStream(SharedOutputContributionTlv(450000 sat))) -> hex"0049 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 000306ddd0", + TxAckRbf(channelId2, TlvStream[TxAckRbfTlv](SharedOutputContributionTlv(450000 sat))) -> hex"0049 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 000306ddd0", TxAbort(channelId1, hex"") -> hex"004a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000", TxAbort(channelId1, ByteVector.view("internal error".getBytes(Charsets.US_ASCII))) -> hex"004a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 000e 696e7465726e616c206572726f72", )