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 330eb34670..4be67156f1 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 @@ -22,7 +22,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, DeterministicWallet, OutPoint import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw} import fr.acinq.eclair.channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._ -import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession} +import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxFunder, InteractiveTxSigningSession} import fr.acinq.eclair.io.Peer import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions._ @@ -62,6 +62,7 @@ case object WAIT_FOR_FUNDING_CONFIRMED extends ChannelState case object WAIT_FOR_CHANNEL_READY extends ChannelState // Dual-funded channel opening: case object WAIT_FOR_INIT_DUAL_FUNDED_CHANNEL extends ChannelState +case object WAIT_FOR_DUAL_FUNDING_INTERNAL 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_CREATED extends ChannelState @@ -524,7 +525,7 @@ object SpliceStatus { /** The channel is quiescent, we wait for our peer to send splice_init or tx_init_rbf. */ case object NonInitiatorQuiescent extends SpliceStatus /** We told our peer we want to splice funds in the channel. */ - case class SpliceRequested(cmd: CMD_SPLICE, init: SpliceInit) extends SpliceStatus + case class SpliceRequested(cmd: CMD_SPLICE, init: SpliceInit, fundingContributions_opt: Option[InteractiveTxFunder.FundingContributions]) extends SpliceStatus /** We told our peer we want to RBF the latest splice transaction. */ case class RbfRequested(cmd: CMD_BUMP_FUNDING_FEE, rbf: TxInitRbf) extends SpliceStatus /** We both agreed to splice/rbf and are building the corresponding transaction. */ @@ -601,10 +602,14 @@ final case class DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments: Commitments, } final case class DATA_WAIT_FOR_CHANNEL_READY(commitments: Commitments, shortIds: ShortIds) extends ChannelDataWithCommitments +final case class DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL(input: INPUT_INIT_CHANNEL_INITIATOR) extends TransientChannelData { + val channelId: ByteVector32 = input.temporaryChannelId +} + final case class DATA_WAIT_FOR_OPEN_DUAL_FUNDED_CHANNEL(init: INPUT_INIT_CHANNEL_NON_INITIATOR) extends TransientChannelData { val channelId: ByteVector32 = init.temporaryChannelId } -final case class DATA_WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL(init: INPUT_INIT_CHANNEL_INITIATOR, lastSent: OpenDualFundedChannel) extends TransientChannelData { +final case class DATA_WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL(init: INPUT_INIT_CHANNEL_INITIATOR, lastSent: OpenDualFundedChannel, fundingContributions: InteractiveTxFunder.FundingContributions) extends TransientChannelData { val channelId: ByteVector32 = lastSent.temporaryChannelId } final case class DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId: ByteVector32, 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 a9c21e88d7..7b5b234713 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 @@ -21,7 +21,7 @@ import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, actorRefAdapte import akka.actor.{Actor, ActorContext, ActorRef, FSM, OneForOneStrategy, PossiblyHarmful, Props, SupervisorStrategy, typed} import akka.event.Logging.MDC import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Script, Transaction, TxId} import fr.acinq.eclair.Logs.LogCategory import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse @@ -950,7 +950,26 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with context.system.scheduler.scheduleOnce(2 second, peer, Peer.Disconnect(remoteNodeId)) stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending Warning(d.channelId, f.getMessage) case Right(spliceInit) => - stay() using d.copy(spliceStatus = SpliceStatus.SpliceRequested(cmd, spliceInit)) sending spliceInit + val parentCommitment = d.commitments.latest.commitment + // assume our peer requires confirmed inputs when we initiate a splice + val requireConfirmedInputs = RequireConfirmedInputs(forLocal = true, forRemote = nodeParams.channelConf.requireConfirmedInputsForDualFunding) + val fundingParams = InteractiveTxParams( + channelId = spliceInit.channelId, + isInitiator = true, + localContribution = spliceInit.fundingContribution, + remoteContribution = 0 sat, + sharedInput_opt = Some(Multisig2of2Input(parentCommitment)), + remoteFundingPubKey = Transactions.PlaceHolderPubKey, + localOutputs = cmd.spliceOutputs, + lockTime = nodeParams.currentBlockHeight.toLong, + dustLimit = d.commitments.params.localParams.dustLimit, + targetFeerate = spliceInit.feerate, + requireConfirmedInputs = requireConfirmedInputs + ) + val dummyFundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey))) + val txFunder = context.spawnAnonymous(InteractiveTxFunder(remoteNodeId, fundingParams, dummyFundingPubkeyScript, purpose = InteractiveTxBuilder.SpliceTx(parentCommitment, d.commitments.changes), wallet)) + txFunder ! InteractiveTxFunder.FundTransaction(self) + stay() using d.copy(spliceStatus = SpliceStatus.SpliceRequested(cmd, spliceInit, None)) } case cmd: CMD_BUMP_FUNDING_FEE => initiateSpliceRbf(cmd, d) match { case Left(f) => @@ -977,6 +996,28 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending Warning(d.channelId, InvalidSpliceNotQuiescent(d.channelId).getMessage) } + case Event(msg: InteractiveTxFunder.Response, d: DATA_NORMAL) => + d.spliceStatus match { + case SpliceStatus.SpliceRequested(cmd, spliceInit, _) => + msg match { + case InteractiveTxFunder.FundingFailed => + cmd.replyTo ! RES_FAILURE(cmd, ChannelFundingError(d.channelId)) + stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) calling endQuiescence(d) + case fundingContributions: InteractiveTxFunder.FundingContributions => + val spliceInit1 = spliceInit.copy(fundingContribution = spliceInit.fundingContribution + fundingContributions.excess_opt.getOrElse(0 sat)) + stay() using d.copy(spliceStatus = SpliceStatus.SpliceRequested(cmd, spliceInit1, Some(fundingContributions))) sending spliceInit1 + } + case _ => + msg match { + case InteractiveTxFunder.FundingFailed => + log.warning("received unexpected response from txFunder: {}, current splice status is {}.", msg, d.spliceStatus) + case fundingContributions: InteractiveTxFunder.FundingContributions => + log.warning("received unexpected response from txFunder: {}, current splice status is {}. Rolling back funding contributions.", msg, d.spliceStatus) + rollbackOpenAttempt(fundingContributions) + } + stay() + } + case Event(_: QuiescenceTimeout, d: DATA_NORMAL) => handleQuiescenceTimeout(d) case Event(msg: SpliceInit, d: DATA_NORMAL) => @@ -1034,6 +1075,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with purpose = InteractiveTxBuilder.SpliceTx(parentCommitment, d.commitments.changes), localPushAmount = spliceAck.pushAmount, remotePushAmount = msg.pushAmount, liquidityPurchase_opt = willFund_opt.map(_.purchase), + None, // TODO: select funding inputs and add excess to liquidity purchase before sending spliceAck wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1053,7 +1095,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(msg: SpliceAck, d: DATA_NORMAL) => d.spliceStatus match { - case SpliceStatus.SpliceRequested(cmd, spliceInit) => + case SpliceStatus.SpliceRequested(cmd, spliceInit, fundingContributions_opt) => log.info("our peer accepted our splice request and will contribute {} to the funding transaction", msg.fundingContribution) val parentCommitment = d.commitments.latest.commitment val fundingParams = InteractiveTxParams( @@ -1084,6 +1126,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with purpose = InteractiveTxBuilder.SpliceTx(parentCommitment, d.commitments.changes), localPushAmount = cmd.pushAmount, remotePushAmount = msg.pushAmount, liquidityPurchase_opt = liquidityPurchase_opt, + fundingContributions_opt = fundingContributions_opt, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1152,6 +1195,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with purpose = rbf, localPushAmount = 0 msat, remotePushAmount = 0 msat, willFund_opt.map(_.purchase), + None, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1205,6 +1249,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with purpose = rbf, localPushAmount = 0 msat, remotePushAmount = 0 msat, liquidityPurchase_opt = liquidityPurchase_opt, + None, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1230,7 +1275,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with log.info("our peer aborted the splice attempt: ascii='{}' bin={}", msg.toAscii, msg.data) rollbackFundingAttempt(signingSession.fundingTx.tx, previousTxs = Seq.empty) // no splice rbf yet stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) calling endQuiescence(d) - case SpliceStatus.SpliceRequested(cmd, _) => + case SpliceStatus.SpliceRequested(cmd, _, _) => log.info("our peer rejected our splice attempt: ascii='{}' bin={}", msg.toAscii, msg.data) cmd.replyTo ! RES_FAILURE(cmd, new RuntimeException(s"splice attempt rejected by our peer: ${msg.toAscii}")) stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) calling endQuiescence(d) @@ -3032,13 +3077,19 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with } private def handleQuiescenceTimeout(d: DATA_NORMAL): State = { - if (d.spliceStatus == SpliceStatus.NoSplice) { - log.warning("quiescence timed out with no ongoing splice, did we forget to cancel the timer?") - stay() - } else { - log.warning("quiescence timed out in state {}, closing connection", d.spliceStatus.getClass.getSimpleName) - context.system.scheduler.scheduleOnce(2 second, peer, Peer.Disconnect(remoteNodeId)) - stay() sending Warning(d.channelId, SpliceAttemptTimedOut(d.channelId).getMessage) + d.spliceStatus match { + case SpliceStatus.NoSplice => + log.warning("quiescence timed out with no ongoing splice, did we forget to cancel the timer?") + stay() + case spliceRequested: SpliceStatus.SpliceRequested if spliceRequested.fundingContributions_opt.isDefined => + log.warning("quiescence timed out after sending splice request, rolling back funding contributions and closing connection") + rollbackOpenAttempt(spliceRequested.fundingContributions_opt.get) + context.system.scheduler.scheduleOnce(2 second, peer, Peer.Disconnect(remoteNodeId)) + stay() sending Warning(d.channelId, SpliceAttemptTimedOut(d.channelId).getMessage) + case _ => + log.warning("quiescence timed out in state {}, closing connection", d.spliceStatus.getClass.getSimpleName) + context.system.scheduler.scheduleOnce(2 second, peer, Peer.Disconnect(remoteNodeId)) + stay() sending Warning(d.channelId, SpliceAttemptTimedOut(d.channelId).getMessage) } } @@ -3054,7 +3105,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with private def reportSpliceFailure(spliceStatus: SpliceStatus, f: Throwable): Unit = { val cmd_opt = spliceStatus match { case SpliceStatus.NegotiatingQuiescence(cmd_opt, _) => cmd_opt - case SpliceStatus.SpliceRequested(cmd, _) => Some(cmd) + case SpliceStatus.SpliceRequested(cmd, _, _) => Some(cmd) case SpliceStatus.RbfRequested(cmd, _) => Some(cmd) case SpliceStatus.SpliceInProgress(cmd_opt, _, txBuilder, _) => txBuilder ! InteractiveTxBuilder.Abort 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 4cda1931a1..766fbc923d 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,18 +16,20 @@ package fr.acinq.eclair.channel.fsm +import akka.actor.Status import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, actorRefAdapter} import com.softwaremill.quicklens.{ModifyPimp, QuicklensAt} -import fr.acinq.bitcoin.scalacompat.SatoshiLong +import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Script} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel._ -import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{FullySignedSharedTransaction, InteractiveTxParams, PartiallySignedSharedTransaction, RequireConfirmedInputs} -import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession} +import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{FullySignedSharedTransaction, InteractiveTxParams, LocalFailure, Output, PartiallySignedSharedTransaction, RequireConfirmedInputs} +import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxFunder, InteractiveTxSigningSession} import fr.acinq.eclair.channel.publish.TxPublisher.SetChannelId import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.io.Peer.{LiquidityPurchaseSigned, OpenChannelResponse} +import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{RealShortChannelId, ToMilliSatoshiConversion, UInt64, randomBytes32} @@ -105,38 +107,88 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { when(WAIT_FOR_INIT_DUAL_FUNDED_CHANNEL)(handleExceptions { case Event(input: INPUT_INIT_CHANNEL_INITIATOR, _) => - val fundingPubKey = keyManager.fundingPublicKey(input.localParams.fundingKeyPath, fundingTxIndex = 0).publicKey - val channelKeyPath = keyManager.keyPath(input.localParams, input.channelConfig) - val upfrontShutdownScript_opt = input.localParams.upfrontShutdownScript_opt.map(scriptPubKey => ChannelTlv.UpfrontShutdownScriptTlv(scriptPubKey)) - val tlvs: Set[OpenDualFundedChannelTlv] = Set( - upfrontShutdownScript_opt, - Some(ChannelTlv.ChannelTypeTlv(input.channelType)), - if (input.requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, - input.requestFunding_opt.map(ChannelTlv.RequestFundingTlv), - input.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)), - ).flatten - val open = OpenDualFundedChannel( - chainHash = nodeParams.chainHash, - temporaryChannelId = input.temporaryChannelId, - fundingFeerate = input.fundingTxFeerate, - commitmentFeerate = input.commitTxFeerate, - fundingAmount = input.fundingAmount, - dustLimit = input.localParams.dustLimit, - maxHtlcValueInFlightMsat = UInt64(input.localParams.maxHtlcValueInFlightMsat.toLong), - htlcMinimum = input.localParams.htlcMinimum, - toSelfDelay = input.localParams.toSelfDelay, - maxAcceptedHtlcs = input.localParams.maxAcceptedHtlcs, + // assume our peer requires confirmed inputs when we initiate a dual funded channel open + val requireConfirmedInputs = RequireConfirmedInputs(forLocal = true, forRemote = nodeParams.channelConf.requireConfirmedInputsForDualFunding) + val fundingParams = InteractiveTxParams( + channelId = input.temporaryChannelId, + isInitiator = true, + localContribution = input.fundingAmount, + remoteContribution = 0 sat, + sharedInput_opt = None, + remoteFundingPubKey = Transactions.PlaceHolderPubKey, + localOutputs = Nil, lockTime = nodeParams.currentBlockHeight.toLong, - fundingPubkey = fundingPubKey, - revocationBasepoint = keyManager.revocationPoint(channelKeyPath).publicKey, - paymentBasepoint = input.localParams.walletStaticPaymentBasepoint.getOrElse(keyManager.paymentPoint(channelKeyPath).publicKey), - delayedPaymentBasepoint = keyManager.delayedPaymentPoint(channelKeyPath).publicKey, - htlcBasepoint = keyManager.htlcPoint(channelKeyPath).publicKey, - firstPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, 0), - secondPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, 1), - channelFlags = input.channelFlags, - tlvStream = TlvStream(tlvs)) - goto(WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL) using DATA_WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL(input, open) sending open + dustLimit = input.localParams.dustLimit, + targetFeerate = input.fundingTxFeerate, + requireConfirmedInputs = requireConfirmedInputs + ) + val dummyPurpose = InteractiveTxBuilder.DummyFundingTx(feeBudget_opt = input.fundingTxFeeBudget_opt) + val dummyFundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey))) + val txFunder = context.spawnAnonymous(InteractiveTxFunder(remoteNodeId, fundingParams, dummyFundingPubkeyScript, dummyPurpose, wallet)) + txFunder ! InteractiveTxFunder.FundTransaction(self) + goto(WAIT_FOR_DUAL_FUNDING_INTERNAL) using DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL(input) + }) + + when(WAIT_FOR_DUAL_FUNDING_INTERNAL)(handleExceptions { + case Event(msg: InteractiveTxFunder.Response, d: DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL) => msg match { + case InteractiveTxFunder.FundingFailed => + d.input.replyTo ! OpenChannelResponse.Rejected(LocalFailure(ChannelFundingError(d.channelId)).cause.getMessage) + goto(CLOSED) + case fundingContributions: InteractiveTxFunder.FundingContributions => + val fundingPubKey = keyManager.fundingPublicKey(d.input.localParams.fundingKeyPath, fundingTxIndex = 0).publicKey + val channelKeyPath = keyManager.keyPath(d.input.localParams, d.input.channelConfig) + val upfrontShutdownScript_opt = d.input.localParams.upfrontShutdownScript_opt.map(scriptPubKey => ChannelTlv.UpfrontShutdownScriptTlv(scriptPubKey)) + val tlvs: Set[OpenDualFundedChannelTlv] = Set( + upfrontShutdownScript_opt, + Some(ChannelTlv.ChannelTypeTlv(d.input.channelType)), + if (d.input.requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, + d.input.requestFunding_opt.map(ChannelTlv.RequestFundingTlv), + d.input.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)), + ).flatten + val fundingAmount1 = d.input.fundingAmount + fundingContributions.excess_opt.getOrElse(0 sat) + val open = OpenDualFundedChannel( + chainHash = nodeParams.chainHash, + temporaryChannelId = d.input.temporaryChannelId, + fundingFeerate = d.input.fundingTxFeerate, + commitmentFeerate = d.input.commitTxFeerate, + fundingAmount = fundingAmount1, + dustLimit = d.input.localParams.dustLimit, + maxHtlcValueInFlightMsat = UInt64(d.input.localParams.maxHtlcValueInFlightMsat.toLong), + htlcMinimum = d.input.localParams.htlcMinimum, + toSelfDelay = d.input.localParams.toSelfDelay, + maxAcceptedHtlcs = d.input.localParams.maxAcceptedHtlcs, + lockTime = nodeParams.currentBlockHeight.toLong, + fundingPubkey = fundingPubKey, + revocationBasepoint = keyManager.revocationPoint(channelKeyPath).publicKey, + paymentBasepoint = d.input.localParams.walletStaticPaymentBasepoint.getOrElse(keyManager.paymentPoint(channelKeyPath).publicKey), + delayedPaymentBasepoint = keyManager.delayedPaymentPoint(channelKeyPath).publicKey, + htlcBasepoint = keyManager.htlcPoint(channelKeyPath).publicKey, + firstPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, 0), + secondPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, 1), + channelFlags = d.input.channelFlags, + tlvStream = TlvStream(tlvs)) + goto(WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL) using DATA_WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL(d.input.copy(fundingAmount = fundingAmount1), open, fundingContributions) sending open + } + case Event(Status.Failure(t), d: DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL) => + log.error(t, s"wallet returned error: ") + d.input.replyTo ! OpenChannelResponse.Rejected(s"wallet error: ${t.getMessage}") + goto(CLOSED) + + case Event(c: CloseCommand, d: DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL) => + d.input.replyTo ! OpenChannelResponse.Cancelled + handleFastClose(c, d.channelId) + + case Event(e: Error, d: DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL) => + d.input.replyTo ! OpenChannelResponse.RemoteError(e.toAscii) + handleRemoteError(e, d) + + case Event(INPUT_DISCONNECTED, d: DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL) => + d.input.replyTo ! OpenChannelResponse.Disconnected + goto(CLOSED) + + case Event(TickChannelOpenTimeout, d: DATA_WAIT_FOR_DUAL_FUNDING_INTERNAL) => + d.input.replyTo ! OpenChannelResponse.TimedOut + goto(CLOSED) }) when(WAIT_FOR_OPEN_DUAL_FUNDED_CHANNEL)(handleExceptions { @@ -224,6 +276,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { channelParams, purpose, localPushAmount = accept.pushAmount, remotePushAmount = open.pushAmount, willFund_opt.map(_.purchase), + fundingContributions_opt = None, wallet)) txBuilder ! InteractiveTxBuilder.Start(self) goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, channelParams, open.secondPerCommitmentPoint, accept.pushAmount, open.pushAmount, txBuilder, deferred = None, replyTo_opt = None) sending accept @@ -241,6 +294,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { import d.init.{localParams, remoteInit} Helpers.validateParamsDualFundedInitiator(nodeParams, remoteNodeId, d.init.channelType, localParams.initFeatures, remoteInit.features, d.lastSent, accept) match { case Left(t) => + rollbackOpenAttempt(d.fundingContributions) d.init.replyTo ! OpenChannelResponse.Rejected(t.getMessage) handleLocalError(t, d, Some(accept)) case Right((channelFeatures, remoteShutdownScript, liquidityPurchase_opt)) => @@ -288,6 +342,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { channelParams, purpose, localPushAmount = d.lastSent.pushAmount, remotePushAmount = accept.pushAmount, liquidityPurchase_opt = liquidityPurchase_opt, + fundingContributions_opt = Some(d.fundingContributions), wallet)) txBuilder ! InteractiveTxBuilder.Start(self) goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, channelParams, accept.secondPerCommitmentPoint, d.lastSent.pushAmount, accept.pushAmount, txBuilder, deferred = None, replyTo_opt = Some(d.init.replyTo)) @@ -580,6 +635,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { purpose = InteractiveTxBuilder.FundingTxRbf(d.commitments.active.head, previousTransactions = d.allFundingTxs.map(_.sharedTx), feeBudget_opt = None), localPushAmount = d.localPushAmount, remotePushAmount = d.remotePushAmount, liquidityPurchase_opt = willFund_opt.map(_.purchase), + fundingContributions_opt = None, wallet)) txBuilder ! InteractiveTxBuilder.Start(self) val toSend = Seq( @@ -627,6 +683,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { purpose = InteractiveTxBuilder.FundingTxRbf(d.commitments.active.head, previousTransactions = d.allFundingTxs.map(_.sharedTx), feeBudget_opt = Some(cmd.fundingFeeBudget)), localPushAmount = d.localPushAmount, remotePushAmount = d.remotePushAmount, liquidityPurchase_opt = liquidityPurchase_opt, + fundingContributions_opt = None, wallet)) txBuilder ! InteractiveTxBuilder.Start(self) stay() using d.copy(status = DualFundingStatus.RbfInProgress(cmd_opt = Some(cmd), txBuilder, remoteCommitSig = None)) 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 index eb26fa4df9..c56ddcba01 100644 --- 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 @@ -25,7 +25,7 @@ import fr.acinq.eclair.channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel.BITCOIN_FUNDING_DOUBLE_SPENT import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._ -import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession} +import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxFunder, InteractiveTxSigningSession} import fr.acinq.eclair.wire.protocol.{ChannelReady, Error} import scala.concurrent.Future @@ -116,6 +116,13 @@ trait DualFundingHandlers extends CommonFundingHandlers { * bitcoind when transactions are published. But if we couldn't publish those transactions (e.g. because our peer * never sent us their signatures, or the transaction wasn't accepted in our mempool), their inputs may still be locked. */ + def rollbackOpenAttempt(fundingContributions: InteractiveTxFunder.FundingContributions): Unit = { + val inputs = fundingContributions.inputs.map(i => TxIn(i.outPoint, Nil, 0)) + if (inputs.nonEmpty) { + wallet.rollback(Transaction(2, inputs, Nil, 0)) + } + } + def rollbackDualFundingTxs(txs: Seq[SignedSharedTransaction]): Unit = { val inputs = txs.flatMap(sharedTx => sharedTx.tx.localInputs ++ sharedTx.tx.sharedInput_opt.toSeq).distinctBy(_.serialId).map(i => TxIn(i.outPoint, Nil, 0)) if (inputs.nonEmpty) { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala index e4e61be52a..ae8df63075 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala @@ -173,10 +173,14 @@ object InteractiveTxBuilder { } // @formatter:off - sealed trait Purpose { + sealed trait FundingInfo { def previousLocalBalance: MilliSatoshi def previousRemoteBalance: MilliSatoshi def previousFundingAmount: Satoshi + def localHtlcs: Set[DirectedHtlc] + def htlcBalance: MilliSatoshi = localHtlcs.toSeq.map(_.add.amountMsat).sum + } + sealed trait CommitmentInfo { def localCommitIndex: Long def remoteCommitIndex: Long def localNextHtlcId: Long @@ -184,8 +188,13 @@ object InteractiveTxBuilder { def remotePerCommitmentPoint: PublicKey def commitTxFeerate: FeeratePerKw def fundingTxIndex: Long - def localHtlcs: Set[DirectedHtlc] - def htlcBalance: MilliSatoshi = localHtlcs.toSeq.map(_.add.amountMsat).sum + } + sealed trait Purpose extends FundingInfo with CommitmentInfo + case class DummyFundingTx(feeBudget_opt: Option[Satoshi]) extends FundingInfo { + override val previousLocalBalance: MilliSatoshi = 0 msat + override val previousRemoteBalance: MilliSatoshi = 0 msat + override val previousFundingAmount: Satoshi = 0 sat + override val localHtlcs: Set[DirectedHtlc] = Set.empty } case class FundingTx(commitTxFeerate: FeeratePerKw, remotePerCommitmentPoint: PublicKey, feeBudget_opt: Option[Satoshi]) extends Purpose { override val previousLocalBalance: MilliSatoshi = 0 msat @@ -391,6 +400,7 @@ object InteractiveTxBuilder { localPushAmount: MilliSatoshi, remotePushAmount: MilliSatoshi, liquidityPurchase_opt: Option[LiquidityAds.Purchase], + fundingContributions_opt: Option[InteractiveTxFunder.FundingContributions], wallet: OnChainChannelFunder)(implicit ec: ExecutionContext): Behavior[Command] = { Behaviors.setup { context => // The stash is used to buffer messages that arrive while we're funding the transaction. @@ -424,7 +434,7 @@ object InteractiveTxBuilder { Behaviors.stopped } else { val actor = new InteractiveTxBuilder(replyTo, sessionId, nodeParams, channelParams, fundingParams, purpose, localPushAmount, remotePushAmount, liquidityPurchase_opt, wallet, stash, context) - actor.start() + actor.start(fundingContributions_opt) } case Abort => Behaviors.stopped } @@ -464,34 +474,42 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case _ => Nil } - def start(): Behavior[Command] = { - val txFunder = context.spawnAnonymous(InteractiveTxFunder(remoteNodeId, fundingParams, fundingPubkeyScript, purpose, wallet)) - txFunder ! InteractiveTxFunder.FundTransaction(context.messageAdapter[InteractiveTxFunder.Response](r => FundTransactionResult(r))) - Behaviors.receiveMessagePartial { - case FundTransactionResult(result) => result match { - case InteractiveTxFunder.FundingFailed => - if (previousTransactions.nonEmpty && !fundingParams.isInitiator) { - // We don't have enough funds to reach the desired feerate, but this is an RBF attempt that we did not initiate. - // It still makes sense for us to contribute whatever we're able to (by using our previous set of inputs and - // outputs): the final feerate will be less than what the initiator intended, but it's still better than being - // stuck with a low feerate transaction that won't confirm. - log.warn("could not fund interactive tx at {}, re-using previous inputs and outputs", fundingParams.targetFeerate) - val previousTx = previousTransactions.head.tx - stash.unstashAll(buildTx(InteractiveTxFunder.FundingContributions(previousTx.localInputs, previousTx.localOutputs))) - } else { - // We use a generic exception and don't send the internal error to the peer. - replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) - Behaviors.stopped - } - case fundingContributions: InteractiveTxFunder.FundingContributions => - stash.unstashAll(buildTx(fundingContributions)) + def start(fundingContributions_opt: Option[InteractiveTxFunder.FundingContributions]): Behavior[Command] = { + if (fundingContributions_opt.isDefined) { + val fundingContributions1 = fundingContributions_opt.get.copy(outputs = fundingContributions_opt.get.outputs.map { + case o: InteractiveTxBuilder.Output.Shared => Output.Shared(o.serialId, fundingPubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution, purpose.htlcBalance) + case o => o + }) + stash.unstashAll(buildTx(fundingContributions1)) + } else { + val txFunder = context.spawnAnonymous(InteractiveTxFunder(remoteNodeId, fundingParams, fundingPubkeyScript, purpose, wallet)) + txFunder ! InteractiveTxFunder.FundTransaction(context.messageAdapter[InteractiveTxFunder.Response](r => FundTransactionResult(r))) + Behaviors.receiveMessagePartial { + case FundTransactionResult(result) => result match { + case InteractiveTxFunder.FundingFailed => + if (previousTransactions.nonEmpty && !fundingParams.isInitiator) { + // We don't have enough funds to reach the desired feerate, but this is an RBF attempt that we did not initiate. + // It still makes sense for us to contribute whatever we're able to (by using our previous set of inputs and + // outputs): the final feerate will be less than what the initiator intended, but it's still better than being + // stuck with a low feerate transaction that won't confirm. + log.warn("could not fund interactive tx at {}, re-using previous inputs and outputs", fundingParams.targetFeerate) + val previousTx = previousTransactions.head.tx + stash.unstashAll(buildTx(InteractiveTxFunder.FundingContributions(previousTx.localInputs, previousTx.localOutputs, excess_opt = None))) + } else { + // We use a generic exception and don't send the internal error to the peer. + replyTo ! LocalFailure(ChannelFundingError(fundingParams.channelId)) + Behaviors.stopped + } + case fundingContributions: InteractiveTxFunder.FundingContributions => + stash.unstashAll(buildTx(fundingContributions)) + } + case msg: ReceiveMessage => + stash.stash(msg) + Behaviors.same + case Abort => + stash.stash(Abort) + Behaviors.same } - case msg: ReceiveMessage => - stash.stash(msg) - Behaviors.same - case Abort => - stash.stash(Abort) - Behaviors.same } } @@ -831,6 +849,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon val fundingTx = completeTx.buildUnsignedTx() val fundingOutputIndex = fundingTx.txOut.indexWhere(_.publicKeyScript == fundingPubkeyScript) val liquidityFee = fundingParams.liquidityFees(liquidityPurchase_opt) + require(fundingOutputIndex >= 0, "shared output not found in funding tx!") Funding.makeCommitTxs(keyManager, channelParams, fundingAmount = fundingParams.fundingAmount, toLocal = completeTx.sharedOutput.localAmount - localPushAmount + remotePushAmount - liquidityFee, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala index 418ca53cac..01c8dc4cb1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala @@ -45,17 +45,17 @@ object InteractiveTxFunder { // @formatter:off sealed trait Command case class FundTransaction(replyTo: ActorRef[Response]) extends Command - private case class FundTransactionResult(tx: Transaction, changePosition: Option[Int]) extends Command + private case class FundTransactionResult(tx: Transaction, fee: Satoshi, changePosition: Option[Int]) extends Command private case class InputDetails(usableInputs: Seq[OutgoingInput], unusableInputs: Set[UnusableInput]) extends Command private case class WalletFailure(t: Throwable) extends Command private case object UtxosUnlocked extends Command sealed trait Response - case class FundingContributions(inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput]) extends Response + case class FundingContributions(inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput], excess_opt: Option[Satoshi]) extends Response case object FundingFailed extends Response // @formatter:on - def apply(remoteNodeId: PublicKey, fundingParams: InteractiveTxParams, fundingPubkeyScript: ByteVector, purpose: InteractiveTxBuilder.Purpose, wallet: OnChainChannelFunder)(implicit ec: ExecutionContext): Behavior[Command] = { + def apply(remoteNodeId: PublicKey, fundingParams: InteractiveTxParams, fundingPubkeyScript: ByteVector, purpose: InteractiveTxBuilder.FundingInfo, wallet: OnChainChannelFunder)(implicit ec: ExecutionContext): Behavior[Command] = { Behaviors.setup { context => Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(remoteNodeId), channelId_opt = Some(fundingParams.channelId))) { Behaviors.receiveMessagePartial { @@ -94,10 +94,10 @@ object InteractiveTxFunder { spliceInAmount - spliceOut.map(_.amount).sum - fees } - private def needsAdditionalFunding(fundingParams: InteractiveTxParams, purpose: Purpose): Boolean = { + private def needsAdditionalFunding(fundingParams: InteractiveTxParams, purpose: FundingInfo): Boolean = { if (fundingParams.isInitiator) { purpose match { - case _: FundingTx | _: FundingTxRbf => + case _: FundingTx | _: FundingTxRbf | _: DummyFundingTx => // We're the initiator, but we may be purchasing liquidity without contributing to the funding transaction if // we're using on-the-fly funding. In that case it's acceptable that we don't pay the mining fees for the // shared output. Otherwise, we must contribute funds to pay the mining fees. @@ -130,7 +130,7 @@ object InteractiveTxFunder { previousTxSizeOk && isNativeSegwit && confirmationsOk } - private def sortFundingContributions(fundingParams: InteractiveTxParams, inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput]): FundingContributions = { + private def sortFundingContributions(fundingParams: InteractiveTxParams, inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput], excess_opt: Option[Satoshi]): FundingContributions = { // We always randomize the order of inputs and outputs. val sortedInputs = Random.shuffle(inputs).zipWithIndex.map { case (input, i) => val serialId = UInt64(2 * i + fundingParams.serialIdParity) @@ -147,7 +147,7 @@ object InteractiveTxFunder { case output: Output.Shared => output.copy(serialId = serialId) } } - FundingContributions(sortedInputs, sortedOutputs) + FundingContributions(sortedInputs, sortedOutputs, excess_opt) } } @@ -155,7 +155,7 @@ object InteractiveTxFunder { private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response], fundingParams: InteractiveTxParams, fundingPubkeyScript: ByteVector, - purpose: InteractiveTxBuilder.Purpose, + purpose: InteractiveTxBuilder.FundingInfo, wallet: OnChainChannelFunder, context: ActorContext[InteractiveTxFunder.Command])(implicit ec: ExecutionContext) { @@ -188,12 +188,12 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response val sharedInput = fundingParams.sharedInput_opt.toSeq.map(sharedInput => Input.Shared(UInt64(0), sharedInput.info.outPoint, sharedInput.info.txOut.publicKeyScript, 0xfffffffdL, purpose.previousLocalBalance, purpose.previousRemoteBalance, purpose.htlcBalance)) val sharedOutput = Output.Shared(UInt64(0), fundingPubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution, purpose.htlcBalance) val nonChangeOutputs = fundingParams.localOutputs.map(txOut => Output.Local.NonChange(UInt64(0), txOut.amount, txOut.publicKeyScript)) - val fundingContributions = sortFundingContributions(fundingParams, sharedInput ++ previousWalletInputs, sharedOutput +: nonChangeOutputs) + val fundingContributions = sortFundingContributions(fundingParams, sharedInput ++ previousWalletInputs, sharedOutput +: nonChangeOutputs, excess_opt = None) replyTo ! fundingContributions Behaviors.stopped } else { val nonChangeOutputs = fundingParams.localOutputs.map(txOut => Output.Local.NonChange(UInt64(0), txOut.amount, txOut.publicKeyScript)) - val fundingContributions = sortFundingContributions(fundingParams, previousWalletInputs, nonChangeOutputs) + val fundingContributions = sortFundingContributions(fundingParams, previousWalletInputs, nonChangeOutputs, excess_opt = None) replyTo ! fundingContributions Behaviors.stopped } @@ -232,6 +232,7 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response case _ => Map.empty[OutPoint, Long] } val feeBudget_opt = purpose match { + case p: DummyFundingTx => p.feeBudget_opt case p: FundingTx => p.feeBudget_opt case p: FundingTxRbf => p.feeBudget_opt case p: SpliceTxRbf => p.feeBudget_opt @@ -239,10 +240,11 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response } context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, replaceable = true, externalInputsWeight = sharedInputWeight, feeBudget_opt = feeBudget_opt)) { case Failure(t) => WalletFailure(t) - case Success(result) => FundTransactionResult(result.tx, result.changePosition) + case Success(result) => + FundTransactionResult(result.tx, result.fee, result.changePosition) } Behaviors.receiveMessagePartial { - case FundTransactionResult(fundedTx, changePosition) => + case FundTransactionResult(fundedTx, fee, changePosition) => // 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) { @@ -251,7 +253,7 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response log.error("could not fund interactive tx: bitcoind included already known unusable inputs that should have been locked: {}", lockedUnusableInputs.mkString(",")) sendResultAndStop(FundingFailed, currentInputs.map(_.outPoint).toSet ++ fundedTx.txIn.map(_.outPoint) ++ unusableInputs.map(_.outpoint)) } else { - filterInputs(fundedTx, changePosition, currentInputs, unusableInputs) + filterInputs(fundedTx, changePosition, currentInputs, unusableInputs, fee) } case WalletFailure(t) => log.error("could not fund interactive tx: ", t) @@ -259,8 +261,29 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response } } + private def computeFee(tx: Transaction, inputs: Seq[OutgoingInput]): Satoshi = { + val sharedInputWeight = fundingParams.sharedInput_opt match { + case Some(i) if tx.txIn.exists(_.outPoint == i.info.outPoint) => Map(i.info.outPoint -> i.weight.toLong) + case _ => Map.empty[OutPoint, Long] + } + val dummySignedTx = tx.copy(txIn = tx.txIn.filterNot(i => sharedInputWeight.contains(i.outPoint)).map { txIn => + inputs.find(_.outPoint == txIn.outPoint) match { + case Some(i: Input.Local) => + Script.parse(i.previousTx.txOut(i.outPoint.index.toInt).publicKeyScript) match { + case script if Script.isNativeWitnessScript(script) => + txIn.copy(witness = Script.witnessPay2wpkh(Transactions.PlaceHolderPubKey, ByteVector.fill(73)(0))) + case script if Script.isPay2tr(script) => + txIn.copy(witness = Script.witnessKeyPathPay2tr(Transactions.PlaceHolderSig)) + case _ => txIn + } + case _ => txIn + } + }) + Transactions.weight2fee(fundingParams.targetFeerate, dummySignedTx.weight() + sharedInputWeight.values.sum.toInt) + } + /** Not all inputs are suitable for interactive tx construction. */ - private def filterInputs(fundedTx: Transaction, changePosition: Option[Int], currentInputs: Seq[OutgoingInput], unusableInputs: Set[UnusableInput]): Behavior[Command] = { + private def filterInputs(fundedTx: Transaction, changePosition: Option[Int], currentInputs: Seq[OutgoingInput], unusableInputs: Set[UnusableInput], fee: Satoshi): 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) @@ -276,6 +299,9 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response log.error("funded transaction is missing one of our local outputs: {}", fundedTx) sendResultAndStop(FundingFailed, fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint)) } else { + // The fee from a changeless funding solution may have excess that can be added to our contribution. + val excess = fee - computeFee(fundedTx, inputDetails.usableInputs) + val excess_opt = if (changePosition.isEmpty && excess > 0.sat) Some(excess) else None val nonChangeOutputs = fundingParams.localOutputs.map(o => Output.Local.NonChange(UInt64(0), o.amount, o.publicKeyScript)) val changeOutput_opt = changePosition.map(i => Output.Local.Change(UInt64(0), fundedTx.txOut(i).amount, fundedTx.txOut(i).publicKeyScript)) val fundingContributions = if (fundingParams.isInitiator) { @@ -283,7 +309,7 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response val inputs = inputDetails.usableInputs val fundingOutput = Output.Shared(UInt64(0), fundingPubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution, purpose.htlcBalance) val outputs = Seq(fundingOutput) ++ nonChangeOutputs ++ changeOutput_opt.toSeq - sortFundingContributions(fundingParams, inputs, outputs) + sortFundingContributions(fundingParams, inputs, outputs, excess_opt) } else { // The non-initiator must not include the shared input or the shared output. val inputs = inputDetails.usableInputs.filterNot(_.isInstanceOf[Input.Shared]) @@ -307,7 +333,7 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response nonChangeOutputs :+ changeOutput.copy(amount = changeOutput.amount + overpaidFees) case None => nonChangeOutputs } - sortFundingContributions(fundingParams, inputs, outputs) + sortFundingContributions(fundingParams, inputs, outputs, excess_opt) } log.debug("added {} inputs and {} outputs to interactive tx", fundingContributions.inputs.length, fundingContributions.outputs.length) // We unlock the unusable inputs (if any) as they can be used outside of interactive-tx sessions. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala index 932e414cdf..7a1d81cc92 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala @@ -180,6 +180,7 @@ object PeerReadyNotifier { case channel.WAIT_FOR_INIT_INTERNAL => false case channel.WAIT_FOR_INIT_SINGLE_FUNDED_CHANNEL => false case channel.WAIT_FOR_INIT_DUAL_FUNDED_CHANNEL => false + case channel.WAIT_FOR_DUAL_FUNDING_INTERNAL => false case channel.OFFLINE => false case channel.SYNCING => false case channel.WAIT_FOR_OPEN_CHANNEL => true 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 88089fa25a..8a83686d08 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 @@ -22,6 +22,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Crypto, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxId, TxIn, TxOut} import fr.acinq.bitcoin.{Bech32, SigHash, SigVersion} import fr.acinq.eclair.TestUtils.randomTxId +import fr.acinq.eclair.blockchain.DummyOnChainWallet.SingleKeyOnChainWallet.invalidFundingAmount import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, ProcessPsbtResponse} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.SignTransactionResponse import fr.acinq.eclair.blockchain.fee.FeeratePerKw @@ -155,6 +156,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { val currentAmountIn = tx.txIn.flatMap(txIn => inputs.find(_.txid == txIn.outPoint.txid).flatMap(_.txOut.lift(txIn.outPoint.index.toInt))).map(_.amount).sum val amountOut = tx.txOut.map(_.amount).sum + if (amountOut >= invalidFundingAmount) return Future.failed(new RuntimeException(s"invalid funding amount")) // We add a single input to reach the desired feerate. val inputAmount = amountOut + 100_000.sat val inputTx = Transaction(2, Seq(TxIn(OutPoint(randomTxId(), 1), Nil, 0)), Seq(TxOut(inputAmount, Script.pay2wpkh(pubkey))), 0) @@ -258,6 +260,41 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def getP2wpkhPubkey(renew: Boolean): PublicKey = pubkey } +class SingleKeyOnChainWalletWithConfirmedInputs extends SingleKeyOnChainWallet { + override def getTxConfirmations(txid: TxId)(implicit ec: ExecutionContext): Future[Option[Int]] = Future.successful(Some(6)) +} + +class ChangelessFundingWallet extends SingleKeyOnChainWalletWithConfirmedInputs { + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { + val currentAmountIn = tx.txIn.flatMap(txIn => inputs.find(_.txid == txIn.outPoint.txid).flatMap(_.txOut.lift(txIn.outPoint.index.toInt))).map(_.amount).sum + val amountOut = tx.txOut.map(_.amount).sum + if (amountOut >= invalidFundingAmount) return Future.failed(new RuntimeException(s"invalid funding amount")) + // We add a single input to reach the desired feerate. + val dummyInputTx = Transaction(2, Seq(TxIn(OutPoint(randomTxId(), 1), Nil, 0)), Seq(TxOut(1.sat, Script.pay2wpkh(pubkey))), 0) + val dummyWitness = Script.witnessPay2wpkh(pubkey, ByteVector.fill(73)(0)) + val dummySignedTx = tx.copy( + txIn = tx.txIn.filterNot(i => externalInputsWeight.contains(i.outPoint)).map(_.copy(witness = dummyWitness)) :+ TxIn(OutPoint(dummyInputTx, 0), ByteVector.empty, 0, dummyWitness), + txOut = tx.txOut, + ) + // only add excess to the changeless funding request when splicing in exactly 100000 sats + val excess = if (amountOut - currentAmountIn == 100000.sat) 1000.sat else 0.sat + val fee = Transactions.weight2fee(feeRate, dummySignedTx.weight() + externalInputsWeight.values.sum.toInt) + val inputAmount = amountOut + fee + excess + val inputTx1 = Transaction(2, Seq(TxIn(OutPoint(randomTxId(), 1), Nil, 0)), Seq(TxOut(inputAmount, Script.pay2wpkh(pubkey))), 0) + inputs = inputs :+ inputTx1 + feeBudget_opt match { + case Some(feeBudget) if fee > feeBudget => + Future.failed(new RuntimeException(s"mining fee is higher than budget ($fee > $feeBudget)")) + case _ => + val fundedTx = tx.copy( + txIn = tx.txIn :+ TxIn(OutPoint(inputTx1, 0), Nil, 0), + // no change output + ) + Future.successful(FundTransactionResponse(fundedTx, fee + excess, None)) + } + } +} + object DummyOnChainWallet { val dummyReceiveAddress: String = "bcrt1qwcv8naajwn8fjhu8z59q9e6ucrqr068rlcenux" @@ -273,4 +310,8 @@ object DummyOnChainWallet { MakeFundingTxResponse(fundingTx, 0, 420 sat) } + object SingleKeyOnChainWallet { + val invalidFundingAmount: Satoshi = 2_100_000_000 sat + } + } \ No newline at end of file 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 446d5a0a51..8baf24661e 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 @@ -127,6 +127,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsA, fundingParams, channelParamsA, FundingTx(commitFeerate, firstPerCommitmentPointB, feeBudget_opt = None), 0 msat, 0 msat, liquidityPurchase_opt, + fundingContributions_opt = None, wallet)) def spawnTxBuilderRbfAlice(fundingParams: InteractiveTxParams, commitment: Commitment, previousTransactions: Seq[InteractiveTxBuilder.SignedSharedTransaction], wallet: OnChainWallet): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( @@ -134,6 +135,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsA, fundingParams, channelParamsA, FundingTxRbf(commitment, previousTransactions, feeBudget_opt = None), 0 msat, 0 msat, None, + fundingContributions_opt = None, wallet)) def spawnTxBuilderSpliceAlice(fundingParams: InteractiveTxParams, commitment: Commitment, wallet: OnChainWallet, liquidityPurchase_opt: Option[LiquidityAds.Purchase] = None): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( @@ -141,6 +143,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsA, fundingParams, channelParamsA, SpliceTx(commitment, CommitmentChanges.init()), 0 msat, 0 msat, liquidityPurchase_opt, + fundingContributions_opt = None, wallet)) def spawnTxBuilderSpliceRbfAlice(fundingParams: InteractiveTxParams, parentCommitment: Commitment, latestFundingTx: LocalFundingStatus.DualFundedUnconfirmedFundingTx, previousTransactions: Seq[InteractiveTxBuilder.SignedSharedTransaction], wallet: OnChainWallet): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( @@ -148,6 +151,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsA, fundingParams, channelParamsA, SpliceTxRbf(parentCommitment, CommitmentChanges.init(), latestFundingTx, previousTransactions, feeBudget_opt = None), 0 msat, 0 msat, None, + fundingContributions_opt = None, wallet)) def spawnTxBuilderBob(wallet: OnChainWallet, fundingParams: InteractiveTxParams = fundingParamsB, liquidityPurchase_opt: Option[LiquidityAds.Purchase] = None): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( @@ -155,6 +159,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsB, fundingParams, channelParamsB, FundingTx(commitFeerate, firstPerCommitmentPointA, feeBudget_opt = None), 0 msat, 0 msat, liquidityPurchase_opt, + fundingContributions_opt = None, wallet)) def spawnTxBuilderRbfBob(fundingParams: InteractiveTxParams, commitment: Commitment, previousTransactions: Seq[InteractiveTxBuilder.SignedSharedTransaction], wallet: OnChainWallet): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( @@ -162,6 +167,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsB, fundingParams, channelParamsB, FundingTxRbf(commitment, previousTransactions, feeBudget_opt = None), 0 msat, 0 msat, None, + fundingContributions_opt = None, wallet)) def spawnTxBuilderSpliceBob(fundingParams: InteractiveTxParams, commitment: Commitment, wallet: OnChainWallet, liquidityPurchase_opt: Option[LiquidityAds.Purchase] = None): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( @@ -169,6 +175,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsB, fundingParams, channelParamsB, SpliceTx(commitment, CommitmentChanges.init()), 0 msat, 0 msat, liquidityPurchase_opt, + fundingContributions_opt = None, wallet)) def spawnTxBuilderSpliceRbfBob(fundingParams: InteractiveTxParams, parentCommitment: Commitment, latestFundingTx: LocalFundingStatus.DualFundedUnconfirmedFundingTx, previousTransactions: Seq[InteractiveTxBuilder.SignedSharedTransaction], wallet: OnChainWallet): ActorRef[InteractiveTxBuilder.Command] = system.spawnAnonymous(InteractiveTxBuilder( @@ -176,6 +183,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsB, fundingParams, channelParamsB, SpliceTxRbf(parentCommitment, CommitmentChanges.init(), latestFundingTx, previousTransactions, feeBudget_opt = None), 0 msat, 0 msat, None, + fundingContributions_opt = None, wallet)) def exchangeSigsAliceFirst(fundingParams: InteractiveTxParams, successA: InteractiveTxBuilder.Succeeded, successB: InteractiveTxBuilder.Succeeded): (FullySignedSharedTransaction, Commitment, FullySignedSharedTransaction, Commitment) = { 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 53b9953633..80caa422b5 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,8 +27,8 @@ import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} -import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet, OnchainPubkeyCache, SingleKeyOnChainWallet} import fr.acinq.eclair.channel._ +import fr.acinq.eclair.blockchain.{ChangelessFundingWallet, DummyOnChainWallet, OnChainWallet, OnchainPubkeyCache, SingleKeyOnChainWalletWithConfirmedInputs} import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.TxPublisher import fr.acinq.eclair.channel.publish.TxPublisher.PublishReplaceableTx @@ -93,6 +93,8 @@ object ChannelStateTestsTags { val DelayRbfAttempts = "delay_rbf_attempts" /** If set, channels will adapt their max HTLC amount to the available balance */ val AdaptMaxHtlcAmount = "adapt-max-htlc-amount" + /** If set, wallet will return changeless funding txs. */ + val ChangelessFunding = "changeless_funding" } trait ChannelStateTestsBase extends Assertions with Eventually { @@ -161,7 +163,9 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.channelConf.balanceThresholds).setToIf(tags.contains(ChannelStateTestsTags.AdaptMaxHtlcAmount))(Seq(Channel.BalanceThreshold(1_000 sat, 0 sat), Channel.BalanceThreshold(5_000 sat, 1_000 sat), Channel.BalanceThreshold(10_000 sat, 5_000 sat))) val wallet = wallet_opt match { case Some(wallet) => wallet - case None => if (tags.contains(ChannelStateTestsTags.DualFunding)) new SingleKeyOnChainWallet() else new DummyOnChainWallet() + case None if tags.contains(ChannelStateTestsTags.ChangelessFunding) => new ChangelessFundingWallet() + case None if tags.contains(ChannelStateTestsTags.DualFunding) => new SingleKeyOnChainWalletWithConfirmedInputs() + case None => new DummyOnChainWallet() } val alice: TestFSMRef[ChannelState, ChannelData, Channel] = { implicit val system: ActorSystem = systemA @@ -314,10 +318,12 @@ trait ChannelStateTestsBase extends Assertions with Eventually { bob2alice.forward(alice) alice2bob.expectMsgType[TxAddOutput] alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddOutput] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddOutput] - alice2bob.forward(bob) + if (!tags.contains(ChannelStateTestsTags.ChangelessFunding)) { + bob2alice.expectMsgType[TxAddOutput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + } bob2alice.expectMsgType[TxComplete] bob2alice.forward(alice) alice2bob.expectMsgType[TxComplete] @@ -566,7 +572,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { // we watch the confirmation of the "final" transactions that send funds to our wallets (main delayed output and 2nd stage htlc transactions) assert(s2blockchain.expectMsgType[WatchTxConfirmed].txId == commitTx.txid) localCommitPublished.claimMainDelayedOutputTx.foreach(claimMain => { - val watchConfirmed = s2blockchain.expectMsgType[WatchTxConfirmed] + val watchConfirmed = s2blockchain.expectMsgType[WatchTxConfirmed] assert(watchConfirmed.txId == claimMain.tx.txid) assert(watchConfirmed.delay_opt.map(_.parentTxId).contains(publishedLocalCommitTx.txid)) }) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForFundingInternalDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForFundingInternalDualFundedChannelStateSpec.scala new file mode 100644 index 0000000000..e31eb15526 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForFundingInternalDualFundedChannelStateSpec.scala @@ -0,0 +1,107 @@ +/* + * Copyright 2024 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.a + +import akka.actor.Status +import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps +import akka.testkit.{TestFSMRef, TestProbe} +import fr.acinq.bitcoin.scalacompat.ByteVector32 +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.fund.InteractiveTxFunder +import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.io.Peer.OpenChannelResponse +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{TestConstants, TestKitBaseClass} +import org.scalatest.Outcome +import org.scalatest.funsuite.FixtureAnyFunSuiteLike + +import scala.concurrent.duration._ + +class WaitForFundingInternalDualFundedChannelStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with ChannelStateTestsBase { + + case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], aliceOpenReplyTo: TestProbe, alice2bob: TestProbe, listener: TestProbe) + + override def withFixture(test: OneArgTest): Outcome = { + val setup = init(wallet_opt = Some(new NoOpOnChainWallet()), tags = test.tags + ChannelStateTestsTags.DualFunding) + import setup._ + val channelConfig = ChannelConfig.standard + val channelFlags = ChannelFlags(announceChannel = false) + val (aliceParams, bobParams, channelType) = computeFeatures(setup, test.tags, channelFlags) + val bobInit = Init(bobParams.initFeatures) + val listener = TestProbe() + within(30 seconds) { + alice.underlying.system.eventStream.subscribe(listener.ref, classOf[ChannelAborted]) + alice ! INPUT_INIT_CHANNEL_INITIATOR(ByteVector32.Zeroes, TestConstants.fundingSatoshis, dualFunded = true, TestConstants.feeratePerKw, TestConstants.feeratePerKw, fundingTxFeeBudget_opt = None, Some(TestConstants.initiatorPushAmount), requireConfirmedInputs = true, requestFunding_opt = None, aliceParams, alice2bob.ref, bobInit, channelFlags, channelConfig, channelType, replyTo = aliceOpenReplyTo.ref.toTyped) + withFixture(test.toNoArgTest(FixtureParam(alice, aliceOpenReplyTo, alice2bob, listener))) + } + } + + test("recv Status.Failure (wallet error)") { f => + import f._ + awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_INTERNAL) + alice ! Status.Failure(new RuntimeException("insufficient funds")) + listener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected] + } + + test("recv Error") { f => + import f._ + alice ! Error(ByteVector32.Zeroes, "oops") + listener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + aliceOpenReplyTo.expectMsgType[OpenChannelResponse.RemoteError] + } + + test("recv CMD_CLOSE") { f => + import f._ + val sender = TestProbe() + val c = CMD_CLOSE(sender.ref, None, None) + alice ! c + sender.expectMsg(RES_SUCCESS(c, ByteVector32.Zeroes)) + listener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + aliceOpenReplyTo.expectMsg(OpenChannelResponse.Cancelled) + } + + test("recv INPUT_DISCONNECTED") { f => + import f._ + alice ! INPUT_DISCONNECTED + listener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + aliceOpenReplyTo.expectMsg(OpenChannelResponse.Disconnected) + } + + test("recv TickChannelOpenTimeout") { f => + import f._ + alice ! TickChannelOpenTimeout + listener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + aliceOpenReplyTo.expectMsg(OpenChannelResponse.TimedOut) + } + + test("recv funding success") { f => + import f._ + alice ! InteractiveTxFunder.FundingContributions(Seq(), Seq(), None) + alice2bob.expectMsgType[OpenDualFundedChannel] + awaitCond(alice.stateName == WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL) + } + +} 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 index ad5feb7184..10e48c9c79 100644 --- 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 @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel.states.b import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Script} -import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet +import fr.acinq.eclair.blockchain.SingleKeyOnChainWalletWithConfirmedInputs import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel @@ -37,10 +37,10 @@ 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], aliceOpenReplyTo: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, wallet: SingleKeyOnChainWallet, aliceListener: TestProbe, bobListener: TestProbe) + case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], aliceOpenReplyTo: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, wallet: SingleKeyOnChainWalletWithConfirmedInputs, aliceListener: TestProbe, bobListener: TestProbe) override def withFixture(test: OneArgTest): Outcome = { - val wallet = new SingleKeyOnChainWallet() + val wallet = new SingleKeyOnChainWalletWithConfirmedInputs() val setup = init(wallet_opt = Some(wallet), tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala index 2f2cf7c4f5..d41531ca73 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala @@ -20,7 +20,7 @@ import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, SatoshiLong, TxId} import fr.acinq.eclair.TestUtils.randomTxId -import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet +import fr.acinq.eclair.blockchain.SingleKeyOnChainWalletWithConfirmedInputs import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchPublished} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel._ @@ -39,10 +39,10 @@ import scala.concurrent.duration.DurationInt class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with ChannelStateTestsBase { - case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alicePeer: TestProbe, bobPeer: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, wallet: SingleKeyOnChainWallet, aliceListener: TestProbe, bobListener: TestProbe) + case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alicePeer: TestProbe, bobPeer: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, wallet: SingleKeyOnChainWalletWithConfirmedInputs, aliceListener: TestProbe, bobListener: TestProbe) override def withFixture(test: OneArgTest): Outcome = { - val wallet = new SingleKeyOnChainWallet() + val wallet = new SingleKeyOnChainWalletWithConfirmedInputs() val setup = init(wallet_opt = Some(wallet), tags = test.tags) import setup._ val channelConfig = ChannelConfig.standard diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala index 101e2de4a0..96554968cb 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala @@ -22,7 +22,7 @@ import com.softwaremill.quicklens.{ModifyPimp, QuicklensAt} import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Transaction, TxIn} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.blockchain.{CurrentBlockHeight, SingleKeyOnChainWallet} +import fr.acinq.eclair.blockchain.{CurrentBlockHeight, SingleKeyOnChainWallet, SingleKeyOnChainWalletWithConfirmedInputs} import fr.acinq.eclair.channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel @@ -46,10 +46,10 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture val noFundingContribution = "no_funding_contribution" val liquidityPurchase = "liquidity_purchase" - case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, aliceListener: TestProbe, bobListener: TestProbe, wallet: SingleKeyOnChainWallet) + case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, aliceListener: TestProbe, bobListener: TestProbe, wallet: SingleKeyOnChainWalletWithConfirmedInputs) override def withFixture(test: OneArgTest): Outcome = { - val wallet = new SingleKeyOnChainWallet() + val wallet = new SingleKeyOnChainWalletWithConfirmedInputs() val setup = init(wallet_opt = Some(wallet), tags = test.tags) import setup._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index 3fb6adcc4f..86d1ca0752 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala @@ -24,6 +24,7 @@ import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.NumericSatoshi.abs import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction, TxIn} import fr.acinq.eclair._ +import fr.acinq.eclair.blockchain.DummyOnChainWallet.SingleKeyOnChainWallet.invalidFundingAmount import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw @@ -1394,6 +1395,27 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.forall(_.localCommit.spec.htlcs.size == 1)) } + test("Funding failed before a splice is requested from our peer") { f => + import f._ + val sender = TestProbe() + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(invalidFundingAmount, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None) + alice ! cmd + exchangeStfu(f) + sender.expectMsg(RES_FAILURE(cmd, ChannelFundingError(channelId(alice)))) + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) + alice2bob.expectNoMessage(100 millis) + } + + test("Excess added to additional local funding", Tag(ChannelStateTestsTags.ChangelessFunding)) { f => + import f._ + val sender = TestProbe() + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(100000.sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None) + alice ! cmd + exchangeStfu(f) + val spliceInit = alice2bob.expectMsgType[SpliceInit] + assert(spliceInit.fundingContribution == 101000.sat) + } + test("recv CMD_ADD_HTLC while a splice is requested") { f => import f._ val sender = TestProbe() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index abbfb5e3f2..bade70c344 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -10,7 +10,7 @@ import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Satoshi, SatoshiLong, Transaction, TxId} import fr.acinq.eclair.ShortChannelId.txIndex -import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet +import fr.acinq.eclair.blockchain.SingleKeyOnChainWalletWithConfirmedInputs import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchFundingConfirmedTriggered, WatchFundingDeeplyBuried, WatchFundingDeeplyBuriedTriggered} import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} @@ -56,7 +56,7 @@ case class MinimalNodeFixture private(nodeParams: NodeParams, offerManager: typed.ActorRef[OfferManager.Command], postman: typed.ActorRef[Postman.Command], watcher: TestProbe, - wallet: SingleKeyOnChainWallet, + wallet: SingleKeyOnChainWalletWithConfirmedInputs, bitcoinClient: TestBitcoinCoreClient) { val nodeId = nodeParams.nodeId val routeParams = nodeParams.routerConf.pathFindingExperimentConf.experiments.values.head.getDefaultRouteParams @@ -88,7 +88,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat val readyListener = TestProbe("ready-listener") system.eventStream.subscribe(readyListener.ref, classOf[SubscriptionsComplete]) val bitcoinClient = new TestBitcoinCoreClient() - val wallet = new SingleKeyOnChainWallet() + val wallet = new SingleKeyOnChainWalletWithConfirmedInputs() val watcher = TestProbe("watcher") val watcherTyped = watcher.ref.toTyped[ZmqWatcher.Command] val register = system.actorOf(Register.props(), "register")