From ba5a730612cab674463c85a3c9b75343980907a2 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 17 May 2022 09:34:53 +0200 Subject: [PATCH 01/12] Implement the interactive-tx protocol After exchanging `open_channel2` and `accept_channel2`, we start building the funding transaction. We stop once we've generated our signatures for the funding transaction, at which point we should store the channel in the DB (which will be done in future commits). --- .../eclair/blockchain/OnChainWallet.scala | 33 +- .../bitcoind/rpc/BitcoinCoreClient.scala | 16 +- .../fr/acinq/eclair/channel/ChannelData.scala | 25 +- .../eclair/channel/ChannelExceptions.scala | 23 +- .../fr/acinq/eclair/channel/Helpers.scala | 31 +- .../eclair/channel/InteractiveTxBuilder.scala | 722 +++++++++++ .../fr/acinq/eclair/channel/fsm/Channel.scala | 1 - .../channel/fsm/ChannelOpenDualFunded.scala | 149 ++- .../channel/fsm/ChannelOpenSingleFunder.scala | 25 +- .../eclair/channel/fsm/CommonHandlers.scala | 19 +- .../channel/fsm/DualFundingHandlers.scala | 53 + ...lers.scala => SingleFundingHandlers.scala} | 34 +- .../eclair/transactions/Transactions.scala | 2 + .../wire/protocol/LightningMessageTypes.scala | 44 +- .../blockchain/DummyOnChainWallet.scala | 114 +- .../bitcoind/BitcoinCoreClientSpec.scala | 2 +- .../blockchain/bitcoind/BitcoindService.scala | 7 +- .../blockchain/bitcoind/ZmqWatcherSpec.scala | 3 +- .../channel/InteractiveTxBuilderSpec.scala | 1083 +++++++++++++++++ .../publish/ReplaceableTxPublisherSpec.scala | 2 +- .../ChannelStateTestsHelperMethods.scala | 18 +- .../a/WaitForAcceptChannelStateSpec.scala | 4 +- ...tForAcceptDualFundedChannelStateSpec.scala | 16 +- .../a/WaitForOpenChannelStateSpec.scala | 2 +- ...aitForOpenDualFundedChannelStateSpec.scala | 9 +- .../WaitForDualFundingCreatedStateSpec.scala | 356 ++++++ .../b/WaitForFundingCreatedStateSpec.scala | 2 +- .../b/WaitForFundingInternalStateSpec.scala | 2 +- .../b/WaitForFundingSignedStateSpec.scala | 2 +- .../c/WaitForChannelReadyStateSpec.scala | 2 +- .../c/WaitForFundingConfirmedStateSpec.scala | 4 +- .../protocol/LightningMessageCodecsSpec.scala | 4 +- 32 files changed, 2673 insertions(+), 136 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/DualFundingHandlers.scala rename eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/{FundingHandlers.scala => SingleFundingHandlers.scala} (84%) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala 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..99df6af21f 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,18 @@ object Helpers { Some(channelConf.minDepthBlocks.max(blocksToReachFunding)) } + /** + * When using dual funding, we may need to wait for multiple confirmations even if we're the initiator if our peer + * also contributes to the funding transaction. + */ + def minDepthDualFunding(channelConf: ChannelConf, channelFeatures: ChannelFeatures, fundingParams: InteractiveTxBuilder.InteractiveTxParams): Option[Long] = { + if (fundingParams.isInitiator && fundingParams.remoteAmount == 0.sat) { + minDepthFunder(channelFeatures) + } 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 +393,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 +409,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 +422,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..3928f15e31 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -0,0 +1,722 @@ +/* + * 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, TimerScheduler} +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.duration.DurationInt +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], previousAttempts: 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[OutPoint]) 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) + } + + /** 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 => + Behaviors.withTimers { timers => + Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(remoteNodeId), channelId_opt = Some(fundingParams.channelId))) { + Behaviors.receiveMessagePartial { + case Start(replyTo, previousAttempts) => + val actor = new InteractiveTxBuilder(replyTo, fundingParams, keyManager, localParams, remoteParams, commitTxFeerate, remoteFirstPerCommitmentPoint, channelFlags, channelConfig, channelFeatures, wallet, previousAttempts, timers, 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 spendSameOutpoint(input1: TxAddInput, input2: TxAddInput): Boolean = { + input1.previousTx.txid == input2.previousTx.txid && input1.previousTxOutput == input2.previousTxOutput + } + + 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) + } + } + +} + +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, + previousAttempts: Seq[InteractiveTxBuilder.SignedSharedTransaction], + timers: TimerScheduler[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. + fundingParams.localAmount.max(fundingParams.dustLimit) + } else { + fundingParams.localAmount + } + 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. + val previousInputs = previousAttempts.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) + } + } + + def fund(txNotFunded: Transaction, currentInputs: Seq[TxAddInput], unusableInputs: Set[OutPoint]): 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) => + filterInputs(fundedTx, currentInputs, unusableInputs) + case WalletFailure(t) => + log.error("could not fund dual-funded channel: ", 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) + case msg: ReceiveMessage => + timers.startSingleTimer(msg, 1 second) + Behaviors.same + case Abort => + timers.startSingleTimer(Abort, 1 second) + Behaviors.same + } + } + + def filterInputs(fundedTx: Transaction, currentInputs: Seq[TxAddInput], unusableInputs: Set[OutPoint]): 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 changeOutputs = fundedTx.txOut + .filter(_.publicKeyScript != fundingParams.fundingPubkeyScript) + .map(txOut => TxAddOutput(fundingParams.channelId, generateSerialId(), txOut.amount, txOut.publicKeyScript)) + val outputs = if (fundingParams.isInitiator) { + // If the initiator doesn't want to contribute, we should cancel out the dust amount artificially added previously. + val initiatorChangeOutputs = if (fundingParams.localAmount == 0.sat) { + changeOutputs.map(o => o.copy(amount = o.amount + fundingParams.dustLimit)) + } else { + changeOutputs + } + // The initiator is responsible for adding the shared output. + TxAddOutput(fundingParams.channelId, generateSerialId(), fundingParams.fundingAmount, fundingParams.fundingPubkeyScript) +: initiatorChangeOutputs + } 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). However, this is really hard to compute here, + // because we don't know the witness size of our inputs (we let bitcoind handle that). For simplicity's sake, + // we simply accept that we'll slightly overpay the fee (which speeds up channel confirmation). + changeOutputs + } + 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 protocol. + unlock(unusableInputs) + buildTx(FundingContributions(inputDetails.usableInputs, outputs)) + } else { + // 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(o => s"${o.txid}:${o.index}").mkString(",")) + val sanitizedTx = fundedTx.copy( + txIn = fundedTx.txIn.filter(txIn => !inputDetails.unusableInputs.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) + case msg: ReceiveMessage => + timers.startSingleTimer(msg, 1 second) + Behaviors.same + case Abort => + timers.startSingleTimer(Abort, 1 second) + Behaviors.same + } + } + + private def getInputDetails(txIn: TxIn, currentInputs: Seq[TxAddInput]): Future[Either[OutPoint, 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(txIn.outPoint) + } else if (!Script.isNativeWitnessScript(previousTx.txOut(txIn.outPoint.index.toInt).publicKeyScript)) { + // Wallet input must be a native segwit input. + Left(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.headOption match { + case Some(Left(addInput)) => + val next = session.copy(toSend = session.toSend.tail, localInputs = session.localInputs :+ addInput, txCompleteSent = false) + replyTo ! SendMessage(addInput) + receive(next) + case Some(Right(addOutput)) => + val next = session.copy(toSend = session.toSend.tail, localOutputs = session.localOutputs :+ addOutput, txCompleteSent = false) + replyTo ! SendMessage(addOutput) + receive(next) + case None => + val next = session.copy(txCompleteSent = true) + replyTo ! SendMessage(TxComplete(fundingParams.channelId)) + if (next.isComplete) { + validateTx(next) match { + case Left(cause) => + replyTo ! RemoteFailure(cause) + unlockAndStop(next) + case Right((completeTx, fundingOutputIndex)) => + signCommitTx(completeTx, fundingOutputIndex) + } + } + 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 => spendSameOutpoint(i, addInput)) || session.remoteInputs.exists(i => spendSameOutpoint(i, 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) { + validateTx(next) match { + case Left(cause) => + replyTo ! RemoteFailure(cause) + unlockAndStop(next) + case Right((completeTx, fundingOutputIndex)) => + signCommitTx(completeTx, fundingOutputIndex) + } + } 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 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 doubleSpendsPreviousAttempts = previousAttempts.forall(previousTx => previousTx.tx.buildUnsignedTx().txIn.map(_.outPoint).exists(o => currentInputs.contains(o))) + if (!doubleSpendsPreviousAttempts) { + log.warn("invalid interactive tx: it doesn't double-spend all previous attempts") + 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) => + replyTo ! Succeeded(fundingParams, fullySignedTx, commitments) + Behaviors.stopped + } + case SignTransactionResult(signedTx, None) => + 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 = previousAttempts.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..5e05ca5407 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 @@ -165,7 +165,6 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val with ChannelOpenSingleFunder 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..dcbfc8b3fa 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 | |---------------- ---------------| @@ -148,11 +180,25 @@ 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 localFundingPubkey = keyManager.fundingPublicKey(localParams.fundingKeyPath) + val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingPubkey.publicKey, 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 +216,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 +237,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 +270,89 @@ 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) + // TODO: skip waiting for confirmation, directly go to the channel_ready state + 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/ChannelOpenSingleFunder.scala index 7d83bd71a7..9da63ad5b6 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/ChannelOpenSingleFunder.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 ChannelOpenSingleFunder 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, fundingAmount, 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, 0 sat, 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..e8b2515dde 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) } @@ -70,6 +82,11 @@ trait CommonHandlers { state } + def sending(msg_opt: Option[LightningMessage]): FSM.State[ChannelState, ChannelData] = { + msg_opt.foreach(msg => send(msg)) + state + } + /** * This method allows performing actions during the transition, e.g. after a call to [[MyState.storing]]. This is * particularly useful to publish transactions only after we are sure that the state has been persisted. 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..96574526fd --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala @@ -0,0 +1,1083 @@ +/* + * 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 akka.util.BoxedType +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(BoxedType(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", ) From 4b71d40deb50282113f835a4d557d2623c57bd11 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 11 Aug 2022 09:59:00 +0200 Subject: [PATCH 02/12] Always wait a few blocks before confirmation Otherwise there is a risk of losing state in reorgs. --- .../main/scala/fr/acinq/eclair/channel/Helpers.scala | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 99df6af21f..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 @@ -371,12 +371,17 @@ object Helpers { } /** - * When using dual funding, we may need to wait for multiple confirmations even if we're the initiator if our peer - * also contributes to the funding transaction. + * 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) { - minDepthFunder(channelFeatures) + if (channelFeatures.hasFeature(Features.ZeroConf)) { + None + } else { + Some(channelConf.minDepthBlocks) + } } else { minDepthFundee(channelConf, channelFeatures, fundingParams.fundingAmount) } From 83e51e46c9bf4e6617b7c7d77ce2af24da41d6de Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 11 Aug 2022 10:04:27 +0200 Subject: [PATCH 03/12] Add UnusableInput type For inputs that can't be used in the interactive-tx construction. --- .../eclair/channel/InteractiveTxBuilder.scala | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) 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 index 3928f15e31..49696256c9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -85,7 +85,7 @@ object InteractiveTxBuilder { 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[OutPoint]) 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 @@ -138,6 +138,9 @@ object InteractiveTxBuilder { 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 @@ -285,7 +288,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } } - def fund(txNotFunded: Transaction, currentInputs: Seq[TxAddInput], unusableInputs: Set[OutPoint]): Behavior[Command] = { + 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) @@ -297,7 +300,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon log.error("could not fund dual-funded channel: ", 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) + unlockAndStop(currentInputs.map(toOutPoint).toSet ++ unusableInputs.map(_.outpoint)) case msg: ReceiveMessage => timers.startSingleTimer(msg, 1 second) Behaviors.same @@ -307,7 +310,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } } - def filterInputs(fundedTx: Transaction, currentInputs: Seq[TxAddInput], unusableInputs: Set[OutPoint]): Behavior[Command] = { + 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) @@ -319,6 +322,9 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon val changeOutputs = fundedTx.txOut .filter(_.publicKeyScript != fundingParams.fundingPubkeyScript) .map(txOut => TxAddOutput(fundingParams.channelId, generateSerialId(), txOut.amount, txOut.publicKeyScript)) + if (changeOutputs.length > 1) { + log.warn(s"bitcoind should never add more than one change output (${changeOutputs.length} found)") + } val outputs = if (fundingParams.isInitiator) { // If the initiator doesn't want to contribute, we should cancel out the dust amount artificially added previously. val initiatorChangeOutputs = if (fundingParams.localAmount == 0.sat) { @@ -337,13 +343,13 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } 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 protocol. - unlock(unusableInputs) + unlock(unusableInputs.map(_.outpoint)) buildTx(FundingContributions(inputDetails.usableInputs, outputs)) } else { // 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(o => s"${o.txid}:${o.index}").mkString(",")) + 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.contains(txIn.outPoint)), + 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), ) @@ -353,7 +359,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon 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) + unlockAndStop(fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint)) case msg: ReceiveMessage => timers.startSingleTimer(msg, 1 second) Behaviors.same @@ -363,16 +369,21 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } } - private def getInputDetails(txIn: TxIn, currentInputs: Seq[TxAddInput]): Future[Either[OutPoint, TxAddInput]] = { + /** + * @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(txIn.outPoint) + Left(UnusableInput(txIn.outPoint)) } else if (!Script.isNativeWitnessScript(previousTx.txOut(txIn.outPoint.index.toInt).publicKeyScript)) { // Wallet input must be a native segwit input. - Left(txIn.outPoint) + Left(UnusableInput(txIn.outPoint)) } else { Right(TxAddInput(fundingParams.channelId, generateSerialId(), previousTx, txIn.outPoint.index, txIn.sequence)) } From e354a68ddd3b098bbd157fb249975adaf6dd7566 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 11 Aug 2022 10:43:14 +0200 Subject: [PATCH 04/12] Fix comments from @pm47's first pass --- .../acinq/eclair/channel/InteractiveTxBuilder.scala | 13 ++++++++++--- .../scala/fr/acinq/eclair/channel/fsm/Channel.scala | 2 +- .../eclair/channel/fsm/ChannelOpenDualFunded.scala | 8 +++----- ...leFunder.scala => ChannelOpenSingleFunded.scala} | 6 +++--- .../acinq/eclair/channel/fsm/CommonHandlers.scala | 5 ----- 5 files changed, 17 insertions(+), 17 deletions(-) rename eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/{ChannelOpenSingleFunder.scala => ChannelOpenSingleFunded.scala} (98%) 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 index 49696256c9..6ba1a6cb27 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -271,17 +271,21 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon 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. + // 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) { + 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. + // 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 = previousAttempts.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) @@ -310,6 +314,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } } + /** 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) @@ -649,10 +654,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon replyTo ! RemoteFailure(cause) unlockAndStop(completeTx) case Right(fullySignedTx) => + log.info("interactive-tx successfully signed (remote signatures already received)") replyTo ! Succeeded(fundingParams, fullySignedTx, commitments) Behaviors.stopped } case SignTransactionResult(signedTx, None) => + log.info("interactive-tx successfully signed (remote signatures not received yet)") replyTo ! Succeeded(fundingParams, signedTx, commitments) Behaviors.stopped case ReceiveTxSigs(remoteSigs) => 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 5e05ca5407..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,7 +162,7 @@ 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 ErrorHandlers { 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 dcbfc8b3fa..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 @@ -139,7 +139,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers 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) @@ -157,7 +157,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers 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, @@ -186,8 +186,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { txPublisher ! SetChannelId(remoteNodeId, channelId) context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId)) // 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))) + 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, @@ -316,7 +315,6 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { } case None => val (_, channelReady) = acceptFundingTx(commitments, RealScidStatus.Unknown) - // TODO: skip waiting for confirmation, directly go to the channel_ready state val nextData = DATA_WAIT_FOR_DUAL_FUNDING_PLACEHOLDER(commitments, fundingTx, fundingParams, Nil, nodeParams.currentBlockHeight, nodeParams.currentBlockHeight, None, None) fundingTx match { case fundingTx: PartiallySignedSharedTransaction => 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 98% 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 9da63ad5b6..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 SingleFundingHandlers with ErrorHandlers { +trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { this: Channel => @@ -216,7 +216,7 @@ trait ChannelOpenSingleFunder extends SingleFundingHandlers 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, 0 sat, 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 SingleFundingHandlers 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, 0 sat, 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 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 e8b2515dde..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 @@ -82,11 +82,6 @@ trait CommonHandlers { state } - def sending(msg_opt: Option[LightningMessage]): FSM.State[ChannelState, ChannelData] = { - msg_opt.foreach(msg => send(msg)) - state - } - /** * This method allows performing actions during the transition, e.g. after a call to [[MyState.storing]]. This is * particularly useful to publish transactions only after we are sure that the state has been persisted. From 6e0c15d430e3f4867e97196e7e350701753db688 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 11 Aug 2022 11:19:21 +0200 Subject: [PATCH 05/12] Rename previousAttempts and prevent infinite loop We explicitly handle the case where bitcoind incorrectly reuses unusable inputs that we locked, otherwise we could go into an infinite funding loop. --- .../eclair/channel/InteractiveTxBuilder.scala | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) 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 index 6ba1a6cb27..312ceb639d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -78,7 +78,7 @@ object InteractiveTxBuilder { // @formatter:off sealed trait Command - case class Start(replyTo: ActorRef[Response], previousAttempts: Seq[SignedSharedTransaction]) extends 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 @@ -202,8 +202,8 @@ object InteractiveTxBuilder { Behaviors.withTimers { timers => Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(remoteNodeId), channelId_opt = Some(fundingParams.channelId))) { Behaviors.receiveMessagePartial { - case Start(replyTo, previousAttempts) => - val actor = new InteractiveTxBuilder(replyTo, fundingParams, keyManager, localParams, remoteParams, commitTxFeerate, remoteFirstPerCommitmentPoint, channelFlags, channelConfig, channelFeatures, wallet, previousAttempts, timers, context) + case Start(replyTo, previousTransactions) => + val actor = new InteractiveTxBuilder(replyTo, fundingParams, keyManager, localParams, remoteParams, commitTxFeerate, remoteFirstPerCommitmentPoint, channelFlags, channelConfig, channelFeatures, wallet, previousTransactions, timers, context) actor.start() case Abort => Behaviors.stopped } @@ -260,7 +260,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon channelConfig: ChannelConfig, channelFeatures: ChannelFeatures, wallet: OnChainChannelFunder, - previousAttempts: Seq[InteractiveTxBuilder.SignedSharedTransaction], + previousTransactions: Seq[InteractiveTxBuilder.SignedSharedTransaction], timers: TimerScheduler[InteractiveTxBuilder.Command], context: ActorContext[InteractiveTxBuilder.Command])(implicit ec: ExecutionContext) { @@ -286,7 +286,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon // 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 = previousAttempts.flatMap(_.tx.localInputs).distinctBy(_.serialId) + 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) } @@ -299,9 +299,17 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } Behaviors.receiveMessagePartial { case FundTransactionResult(fundedTx) => - filterInputs(fundedTx, currentInputs, unusableInputs) + val reusedUnusableInputs = fundedTx.txIn.map(_.outPoint).filter(o => unusableInputs.map(_.outpoint).contains(o)) + if (reusedUnusableInputs.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: {}", reusedUnusableInputs.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 dual-funded channel: ", 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)) @@ -579,9 +587,9 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon // 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 doubleSpendsPreviousAttempts = previousAttempts.forall(previousTx => previousTx.tx.buildUnsignedTx().txIn.map(_.outPoint).exists(o => currentInputs.contains(o))) - if (!doubleSpendsPreviousAttempts) { - log.warn("invalid interactive tx: it doesn't double-spend all previous attempts") + 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)) } @@ -710,7 +718,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon def unlockAndStop(txInputs: Set[OutPoint]): Behavior[Command] = { // We don't unlock previous inputs as the corresponding funding transaction may confirm. - val previousInputs = previousAttempts.flatMap(_.tx.localInputs.map(toOutPoint)).toSet + 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) From 180c23f9c81c396858261095fd6acb78fa946c14 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 11 Aug 2022 12:22:10 +0200 Subject: [PATCH 06/12] Log number of inputs / outputs --- .../scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 312ceb639d..a35c0c12da 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -662,12 +662,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon replyTo ! RemoteFailure(cause) unlockAndStop(completeTx) case Right(fullySignedTx) => - log.info("interactive-tx successfully signed (remote signatures already received)") + 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 successfully signed (remote signatures not received yet)") + 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) => From b3ab1d395f0e0027a23b185c1a36895a458f81aa Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 12 Aug 2022 11:40:31 +0200 Subject: [PATCH 07/12] Fix most comments from @pm47's second pass Except: - use stash instead of message timer delays - investigate non-initiator fees tweaking Those two deserve their own commit. --- .../eclair/channel/InteractiveTxBuilder.scala | 94 ++++++++++--------- 1 file changed, 51 insertions(+), 43 deletions(-) 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 index a35c0c12da..398d954e70 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -215,10 +215,6 @@ object InteractiveTxBuilder { // 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 spendSameOutpoint(input1: TxAddInput, input2: TxAddInput): Boolean = { - input1.previousTx.txid == input2.previousTx.txid && input1.previousTxOutput == input2.previousTxOutput - } - def toOutPoint(input: TxAddInput): OutPoint = OutPoint(input.previousTx, input.previousTxOutput.toInt) def addRemoteSigs(fundingParams: InteractiveTxParams, partiallySignedTx: PartiallySignedSharedTransaction, remoteSigs: TxSignatures): Either[ChannelException, FullySignedSharedTransaction] = { @@ -249,6 +245,11 @@ object InteractiveTxBuilder { } +/** + * @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, @@ -292,6 +293,11 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } } + /** + * 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) @@ -299,11 +305,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } Behaviors.receiveMessagePartial { case FundTransactionResult(fundedTx) => - val reusedUnusableInputs = fundedTx.txIn.map(_.outPoint).filter(o => unusableInputs.map(_.outpoint).contains(o)) - if (reusedUnusableInputs.nonEmpty) { + // 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: {}", reusedUnusableInputs.mkString(",")) + 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) @@ -332,30 +339,33 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case inputDetails: InputDetails => if (inputDetails.unusableInputs.isEmpty) { // This funding iteration did not add any unusable inputs, so we can directly return the results. - val changeOutputs = fundedTx.txOut + require(fundedTx.txOut.length <= 2, s"bitcoind should never add more than one change output (${fundedTx.txOut.length - 1} found)") + val changeOutput_opt = fundedTx.txOut .filter(_.publicKeyScript != fundingParams.fundingPubkeyScript) .map(txOut => TxAddOutput(fundingParams.channelId, generateSerialId(), txOut.amount, txOut.publicKeyScript)) - if (changeOutputs.length > 1) { - log.warn(s"bitcoind should never add more than one change output (${changeOutputs.length} found)") - } + .headOption + // There are at most two outputs: the shared output (if we are the initiator) and the (optional) change output. val outputs = if (fundingParams.isInitiator) { - // If the initiator doesn't want to contribute, we should cancel out the dust amount artificially added previously. - val initiatorChangeOutputs = if (fundingParams.localAmount == 0.sat) { - changeOutputs.map(o => o.copy(amount = o.amount + fundingParams.dustLimit)) - } else { - changeOutputs + // If the initiator doesn't want to contribute, we should cancel out the dummy amount artificially added previously. + val initiatorChangeOutput = changeOutput_opt match { + case Some(changeOutput) if fundingParams.localAmount == 0.sat => + val dummyOutputAmount = fundedTx.txOut.filter(_.publicKeyScript == fundingParams.fundingPubkeyScript).map(_.amount).sum + Seq(changeOutput.copy(amount = changeOutput.amount + dummyOutputAmount)) + case Some(changeOutput) => Seq(changeOutput) + case None => Nil } // The initiator is responsible for adding the shared output. - TxAddOutput(fundingParams.channelId, generateSerialId(), fundingParams.fundingAmount, fundingParams.fundingPubkeyScript) +: initiatorChangeOutputs + 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). However, this is really hard to compute here, // because we don't know the witness size of our inputs (we let bitcoind handle that). For simplicity's sake, // we simply accept that we'll slightly overpay the fee (which speeds up channel confirmation). - changeOutputs + changeOutput_opt.toSeq } 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 protocol. + // We unlock the unusable inputs from previous iterations (if any) as they can be used outside of this session. unlock(unusableInputs.map(_.outpoint)) buildTx(FundingContributions(inputDetails.usableInputs, outputs)) } else { @@ -416,28 +426,21 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } def send(session: InteractiveTxSession): Behavior[Command] = { - session.toSend.headOption match { - case Some(Left(addInput)) => - val next = session.copy(toSend = session.toSend.tail, localInputs = session.localInputs :+ addInput, txCompleteSent = false) + 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 Some(Right(addOutput)) => - val next = session.copy(toSend = session.toSend.tail, localOutputs = session.localOutputs :+ addOutput, txCompleteSent = false) + case Right(addOutput) +: tail => replyTo ! SendMessage(addOutput) + val next = session.copy(toSend = tail, localOutputs = session.localOutputs :+ addOutput, txCompleteSent = false) receive(next) - case None => - val next = session.copy(txCompleteSent = true) + case Nil => replyTo ! SendMessage(TxComplete(fundingParams.channelId)) + val next = session.copy(txCompleteSent = true) if (next.isComplete) { - validateTx(next) match { - case Left(cause) => - replyTo ! RemoteFailure(cause) - unlockAndStop(next) - case Right((completeTx, fundingOutputIndex)) => - signCommitTx(completeTx, fundingOutputIndex) - } - } - else { + validateAndSign(next) + } else { receive(next) } } @@ -456,7 +459,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } else if (session.remoteInputs.exists(_.serialId == addInput.serialId)) { replyTo ! RemoteFailure(DuplicateSerialId(fundingParams.channelId, addInput.serialId)) unlockAndStop(session) - } else if (session.localInputs.exists(i => spendSameOutpoint(i, addInput)) || session.remoteInputs.exists(i => spendSameOutpoint(i, addInput))) { + } 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) { @@ -521,13 +524,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case _: TxComplete => val next = session.copy(txCompleteReceived = true) if (next.isComplete) { - validateTx(next) match { - case Left(cause) => - replyTo ! RemoteFailure(cause) - unlockAndStop(next) - case Right((completeTx, fundingOutputIndex)) => - signCommitTx(completeTx, fundingOutputIndex) - } + validateAndSign(next) } else { send(next) } @@ -543,6 +540,17 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } } + 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() From 370822c3da265f731f64efbe09f0a4823aa177de Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 12 Aug 2022 12:06:32 +0200 Subject: [PATCH 08/12] Discount fees for non-initiator When we have change output, it's easy to discount the fees for the common fields. --- .../eclair/channel/InteractiveTxBuilder.scala | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 index 398d954e70..5d63052c17 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -359,10 +359,17 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon 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). However, this is really hard to compute here, - // because we don't know the witness size of our inputs (we let bitcoind handle that). For simplicity's sake, - // we simply accept that we'll slightly overpay the fee (which speeds up channel confirmation). - changeOutput_opt.toSeq + // 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 invalidate bitcoind's weight estimation. + 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. From ffe57085b08ff51cd39f940bf6a50d6defff6d86 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 12 Aug 2022 12:11:38 +0200 Subject: [PATCH 09/12] fixup! Discount fees for non-initiator --- .../scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 5d63052c17..34d30cf12f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -362,7 +362,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon // 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 invalidate bitcoind's weight estimation. + // 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() From 13a3d64e9956987fb301d5ff35a2487498fcaa66 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 12 Aug 2022 12:21:07 +0200 Subject: [PATCH 10/12] Use stash instead of delayed messages --- .../eclair/channel/InteractiveTxBuilder.scala | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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 index 34d30cf12f..5a590685a5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.channel -import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} +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 @@ -33,7 +33,6 @@ import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{Logs, MilliSatoshiLong, UInt64, randomBytes, randomKey} import scodec.bits.{ByteVector, HexStringSyntax} -import scala.concurrent.duration.DurationInt import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} @@ -199,11 +198,14 @@ object InteractiveTxBuilder { channelFeatures: ChannelFeatures, wallet: OnChainChannelFunder)(implicit ec: ExecutionContext): Behavior[Command] = { Behaviors.setup { context => - Behaviors.withTimers { timers => + // 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, timers, context) + val actor = new InteractiveTxBuilder(replyTo, fundingParams, keyManager, localParams, remoteParams, commitTxFeerate, remoteFirstPerCommitmentPoint, channelFlags, channelConfig, channelFeatures, wallet, previousTransactions, stash, context) actor.start() case Abort => Behaviors.stopped } @@ -262,7 +264,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon channelFeatures: ChannelFeatures, wallet: OnChainChannelFunder, previousTransactions: Seq[InteractiveTxBuilder.SignedSharedTransaction], - timers: TimerScheduler[InteractiveTxBuilder.Command], + stash: StashBuffer[InteractiveTxBuilder.Command], context: ActorContext[InteractiveTxBuilder.Command])(implicit ec: ExecutionContext) { import InteractiveTxBuilder._ @@ -321,10 +323,10 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) unlockAndStop(currentInputs.map(toOutPoint).toSet ++ unusableInputs.map(_.outpoint)) case msg: ReceiveMessage => - timers.startSingleTimer(msg, 1 second) + stash.stash(msg) Behaviors.same case Abort => - timers.startSingleTimer(Abort, 1 second) + stash.stash(Abort) Behaviors.same } } @@ -374,7 +376,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon 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)) - buildTx(FundingContributions(inputDetails.usableInputs, outputs)) + stash.unstashAll(buildTx(FundingContributions(inputDetails.usableInputs, outputs))) } else { // 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(",")) @@ -391,10 +393,10 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) unlockAndStop(fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint)) case msg: ReceiveMessage => - timers.startSingleTimer(msg, 1 second) + stash.stash(msg) Behaviors.same case Abort => - timers.startSingleTimer(Abort, 1 second) + stash.stash(Abort) Behaviors.same } } From d5c640b75c5a12f04bdd795bdf9d25aa407cc9df Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 12 Aug 2022 14:52:56 +0200 Subject: [PATCH 11/12] Handle unexpected bitcoind funded tx Explicitly handle cases where bitcoind returns an invalid funded transaction. --- .../eclair/channel/InteractiveTxBuilder.scala | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) 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 index 5a590685a5..78a50f9552 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/InteractiveTxBuilder.scala @@ -338,21 +338,26 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon 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. - require(fundedTx.txOut.length <= 2, s"bitcoind should never add more than one change output (${fundedTx.txOut.length - 1} found)") - val changeOutput_opt = fundedTx.txOut - .filter(_.publicKeyScript != fundingParams.fundingPubkeyScript) - .map(txOut => TxAddOutput(fundingParams.channelId, generateSerialId(), txOut.amount, txOut.publicKeyScript)) - .headOption - // There are at most two outputs: the shared output (if we are the initiator) and the (optional) change output. + 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) { - // If the initiator doesn't want to contribute, we should cancel out the dummy amount artificially added previously. val initiatorChangeOutput = changeOutput_opt match { case Some(changeOutput) if fundingParams.localAmount == 0.sat => - val dummyOutputAmount = fundedTx.txOut.filter(_.publicKeyScript == fundingParams.fundingPubkeyScript).map(_.amount).sum - Seq(changeOutput.copy(amount = changeOutput.amount + dummyOutputAmount)) + // 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 } @@ -377,16 +382,16 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon // 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))) - } else { - // 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 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. From 22a21a20ad9d120492cb29020c208926f7377689 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 12 Aug 2022 15:51:11 +0200 Subject: [PATCH 12/12] Remove BoxedType in tests --- .../fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala index 96574526fd..aee6f12fb8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala @@ -20,7 +20,6 @@ import akka.actor.typed.ActorRef import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter} import akka.pattern.pipe import akka.testkit.TestProbe -import akka.util.BoxedType 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} @@ -148,7 +147,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit 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(BoxedType(c).isInstance(msg), s"expected $c, found ${msg.getClass} ($msg)") + 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)