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 1371ef2686..f96cfd1b81 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 @@ -261,10 +262,12 @@ final case class CMD_GET_CHANNEL_INFO(replyTo: akka.actor.typed.ActorRef[RES_GET /** response to [[Command]] requests */ sealed trait CommandResponse[+C <: Command] sealed trait CommandSuccess[+C <: Command] extends CommandResponse[C] +sealed trait CommandPending[+C <: Command] extends CommandResponse[C] sealed trait CommandFailure[+C <: Command, +T <: Throwable] extends CommandResponse[C] { def t: T } /** generic responses */ final case class RES_SUCCESS[+C <: Command](cmd: C, channelId: ByteVector32) extends CommandSuccess[C] +final case class RES_PENDING[+C <: Command](cmd: C, channelId: ByteVector32) extends CommandPending[C] final case class RES_FAILURE[+C <: Command, +T <: Throwable](cmd: C, t: T) extends CommandFailure[C, T] /** @@ -290,7 +293,9 @@ final case class RES_ADD_SETTLED[+O <: Origin, +R <: HtlcResult](origin: O, htlc /** other specific responses */ final case class RES_BUMP_FUNDING_FEE(rbfIndex: Int, fundingTxId: TxId, fee: Satoshi) extends CommandSuccess[CMD_BUMP_FUNDING_FEE] +final case class RES_BUMP_FUNDING_FEE_PENDING(fundingTxId: TxId, fee: Satoshi) extends CommandPending[CMD_BUMP_FUNDING_FEE] final case class RES_SPLICE(fundingTxIndex: Long, fundingTxId: TxId, capacity: Satoshi, balance: MilliSatoshi) extends CommandSuccess[CMD_SPLICE] +final case class RES_SPLICE_PENDING(fundingTxIndex: Long, fundingTxId: TxId, capacity: Satoshi, balance: MilliSatoshi) extends CommandPending[CMD_SPLICE] final case class RES_GET_CHANNEL_STATE(state: ChannelState) extends CommandSuccess[CMD_GET_CHANNEL_STATE] final case class RES_GET_CHANNEL_DATA[+D <: ChannelData](data: D) extends CommandSuccess[CMD_GET_CHANNEL_DATA] final case class RES_GET_CHANNEL_INFO(nodeId: PublicKey, channelId: ByteVector32, channel: ActorRef, state: ChannelState, data: ChannelData) extends CommandSuccess[CMD_GET_CHANNEL_INFO] @@ -497,13 +502,15 @@ 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 + case class RbfRequested(cmd: CMD_BUMP_FUNDING_FEE, rbf: TxInitRbf, fundingContributions_opt: Option[InteractiveTxFunder.FundingContributions]) extends SpliceStatus + /** Our peer initiated a splice */ + case class SpliceInitiated(init: SpliceInit, willFund_opt: Option[LiquidityAds.WillFundPurchase]) extends SpliceStatus /** We both agreed to splice/rbf and are building the corresponding transaction. */ case class SpliceInProgress(cmd_opt: Option[ChannelFundingCommand], sessionId: ByteVector32, splice: typed.ActorRef[InteractiveTxBuilder.Command], remoteCommitSig: Option[CommitSig]) extends SpliceStatus /** The splice transaction has been negotiated, we're exchanging signatures. */ - case class SpliceWaitingForSigs(signingSession: InteractiveTxSigningSession.WaitingForSigs) extends SpliceStatus + case class SpliceWaitingForSigs(cmd_opt: Option[ChannelFundingCommand], signingSession: InteractiveTxSigningSession.WaitingForSigs) extends SpliceStatus /** The splice attempt was aborted by us, we're waiting for our peer to ack. */ case object SpliceAborted extends SpliceStatus } @@ -576,10 +583,14 @@ final case class DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments: Commitments, } final case class DATA_WAIT_FOR_CHANNEL_READY(commitments: Commitments, aliases: ShortIdAliases) 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/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index a51a32a2aa..bee42492d3 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 @@ -83,6 +83,7 @@ case class UnexpectedFundingSignatures (override val channelId: Byte 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, txId_opt: Option[TxId]) extends ChannelException(channelId, s"invalid funding signature: txId=${txId_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 InvalidRbfExceedsFunding (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid rbf attempt: the liquidity purchase exceeds the initial funding amount") case class InvalidSpliceFeerate (override val channelId: ByteVector32, proposed: FeeratePerKw, expected: FeeratePerKw) extends ChannelException(channelId, s"invalid splice request: the feerate must be at least $expected, you proposed $proposed") case class InvalidSpliceRequest (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid splice request") case class InvalidRbfAlreadyInProgress (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid rbf attempt: the current rbf attempt must be completed or aborted first") 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 98a58d56db..e1b792a104 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 @@ -618,7 +618,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case (SpliceStatus.SpliceAborted, sig: CommitSig) => log.warning("received commit_sig after sending tx_abort, they probably sent it before receiving our tx_abort, ignoring...") stay() - case (SpliceStatus.SpliceWaitingForSigs(signingSession), sig: CommitSig) => + case (SpliceStatus.SpliceWaitingForSigs(cmd_opt, signingSession), sig: CommitSig) => signingSession.receiveCommitSig(d.commitments.params, channelKeys, sig, nodeParams.currentBlockHeight) match { case Left(f) => rollbackFundingAttempt(signingSession.fundingTx.tx, Nil) @@ -627,7 +627,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case signingSession1: InteractiveTxSigningSession.WaitingForSigs => // In theory we don't have to store their commit_sig here, as they would re-send it if we disconnect, but // it is more consistent with the case where we send our tx_signatures first. - val d1 = d.copy(spliceStatus = SpliceStatus.SpliceWaitingForSigs(signingSession1)) + val d1 = d.copy(spliceStatus = SpliceStatus.SpliceWaitingForSigs(cmd_opt, signingSession1)) stay() using d1 storing() case signingSession1: InteractiveTxSigningSession.SendingSigs => // We don't have their tx_sigs, but they have ours, and could publish the funding tx without telling us. @@ -637,6 +637,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall watchFundingConfirmed(signingSession.fundingTx.txId, minDepth_opt, delay_opt = None) val commitments1 = d.commitments.add(signingSession1.commitment) val d1 = d.copy(commitments = commitments1, spliceStatus = SpliceStatus.NoSplice) + cmd_opt.foreach(cmd => cmd.replyTo ! RES_SPLICE(fundingTxIndex = signingSession.fundingTxIndex, signingSession.fundingTx.txId, signingSession.fundingParams.fundingAmount, signingSession.localCommit.fold(_.spec, _.spec).toLocal)) stay() using d1 storing() sending signingSession1.localSigs calling endQuiescence(d1) } } @@ -1004,7 +1005,25 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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 + 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, + // Assume our peer requires confirmed inputs when we initiate a splice. + requireConfirmedInputs = RequireConfirmedInputs(forLocal = true, forRemote = nodeParams.channelConf.requireConfirmedInputsForDualFunding) + ) + 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) => @@ -1012,7 +1031,31 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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(txInitRbf) => - stay() using d.copy(spliceStatus = SpliceStatus.RbfRequested(cmd, txInitRbf)) sending txInitRbf + getSpliceRbfContext(Some(cmd), d) match { + case Right(rbf) => + val fundingParams = InteractiveTxParams( + channelId = d.channelId, + isInitiator = true, + localContribution = txInitRbf.fundingContribution, + remoteContribution = 0 sat, + sharedInput_opt = Some(Multisig2of2Input(rbf.parentCommitment)), + remoteFundingPubKey = Transactions.PlaceHolderPubKey, + localOutputs = rbf.latestFundingTx.fundingParams.localOutputs, + lockTime = txInitRbf.lockTime, + dustLimit = rbf.latestFundingTx.fundingParams.dustLimit, + targetFeerate = txInitRbf.feerate, + // Assume our peer requires confirmed inputs when we initiate a rbf. + requireConfirmedInputs = RequireConfirmedInputs(forLocal = true, forRemote = nodeParams.channelConf.requireConfirmedInputsForDualFunding) + ) + val dummyFundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey))) + val txFunder = context.spawnAnonymous(InteractiveTxFunder(remoteNodeId, fundingParams, dummyFundingPubkeyScript, purpose = rbf, wallet)) + txFunder ! InteractiveTxFunder.FundTransaction(self) + stay() using d.copy(spliceStatus = SpliceStatus.RbfRequested(cmd, txInitRbf, None)) + case Left(f) => + cmd.replyTo ! RES_FAILURE(cmd, f) + context.system.scheduler.scheduleOnce(2 second, peer, Peer.Disconnect(remoteNodeId)) + stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending Warning(d.channelId, f.getMessage) + } } } } else { @@ -1031,6 +1074,88 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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 SpliceStatus.RbfRequested(cmd, txInitRbf, _) => + msg match { + case InteractiveTxFunder.FundingFailed => + cmd.replyTo ! RES_FAILURE(cmd, ChannelFundingError(d.channelId)) + context.system.scheduler.scheduleOnce(2 second, peer, Peer.Disconnect(remoteNodeId)) + stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending Warning(d.channelId, RbfAttemptAborted(d.channelId).getMessage) + case fundingContributions: InteractiveTxFunder.FundingContributions => + stay() using d.copy(spliceStatus = SpliceStatus.RbfRequested(cmd, txInitRbf, Some(fundingContributions))) sending txInitRbf + } + case SpliceStatus.SpliceInitiated(spliceInit, willFund_opt) => + msg match { + case InteractiveTxFunder.FundingFailed => + log.warning("splice request funding failed from txFunder: {}, current splice status is {}.", msg, d.spliceStatus) + stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, ChannelFundingError(d.channelId).getMessage) + case fundingContributions: InteractiveTxFunder.FundingContributions => + val localContribution = willFund_opt.map(_.purchase.amount).getOrElse(0 sat) + fundingContributions.excess_opt.getOrElse(0 sat) + log.info("accepting splice with remote.in.amount={} remote.in.push={} local.in.amount={} (excess={}).", + spliceInit.fundingContribution, + spliceInit.pushAmount, + localContribution, + fundingContributions.excess_opt.getOrElse(0 sat) + ) + val parentCommitment = d.commitments.latest.commitment + val localFundingPubKey = channelKeys.fundingKey(parentCommitment.fundingTxIndex + 1).publicKey + val spliceAck = SpliceAck(d.channelId, + fundingContribution = localContribution, + fundingPubKey = localFundingPubKey, + pushAmount = 0.msat, + requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, + willFund_opt = willFund_opt.map(_.willFund), + feeCreditUsed_opt = spliceInit.useFeeCredit_opt + ) + val fundingParams = InteractiveTxParams( + channelId = d.channelId, + isInitiator = false, + localContribution = localContribution, + remoteContribution = spliceInit.fundingContribution, + sharedInput_opt = Some(Multisig2of2Input(parentCommitment)), + remoteFundingPubKey = spliceInit.fundingPubKey, + localOutputs = Nil, + lockTime = spliceInit.lockTime, + dustLimit = d.commitments.params.localParams.dustLimit.max(d.commitments.params.remoteParams.dustLimit), + targetFeerate = spliceInit.feerate, + requireConfirmedInputs = RequireConfirmedInputs(forLocal = spliceInit.requireConfirmedInputs, forRemote = nodeParams.channelConf.requireConfirmedInputsForDualFunding) + ) + val sessionId = randomBytes32() + val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( + sessionId, + nodeParams, fundingParams, + channelParams = d.commitments.params, + channelKeys = channelKeys, + purpose = InteractiveTxBuilder.SpliceTx(parentCommitment, d.commitments.changes), + localPushAmount = spliceAck.pushAmount, remotePushAmount = spliceInit.pushAmount, + liquidityPurchase_opt = willFund_opt.map(_.purchase), + Some(fundingContributions), + wallet + )) + txBuilder ! InteractiveTxBuilder.Start(self) + stay() using d.copy(spliceStatus = SpliceStatus.SpliceInProgress(cmd_opt = None, sessionId, txBuilder, remoteCommitSig = None)) sending spliceAck + } + 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) => @@ -1058,19 +1183,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.warning("rejecting splice request with invalid liquidity ads: {}", t.getMessage) stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage) case Right(willFund_opt) => - log.info(s"accepting splice with remote.in.amount=${msg.fundingContribution} remote.in.push=${msg.pushAmount}") - val spliceAck = SpliceAck(d.channelId, - fundingContribution = willFund_opt.map(_.purchase.amount).getOrElse(0 sat), - fundingPubKey = localFundingPubKey, - pushAmount = 0.msat, - requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, - willFund_opt = willFund_opt.map(_.willFund), - feeCreditUsed_opt = msg.useFeeCredit_opt - ) + log.info(s"funding splice with remote.in.amount=${msg.fundingContribution} remote.in.push=${msg.pushAmount}") val fundingParams = InteractiveTxParams( channelId = d.channelId, isInitiator = false, - localContribution = spliceAck.fundingContribution, + localContribution = willFund_opt.map(_.purchase.amount).getOrElse(0 sat), remoteContribution = msg.fundingContribution, sharedInput_opt = Some(Multisig2of2Input(parentCommitment)), remoteFundingPubKey = msg.fundingPubKey, @@ -1078,21 +1195,12 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall lockTime = msg.lockTime, dustLimit = d.commitments.params.localParams.dustLimit.max(d.commitments.params.remoteParams.dustLimit), targetFeerate = msg.feerate, - requireConfirmedInputs = RequireConfirmedInputs(forLocal = msg.requireConfirmedInputs, forRemote = spliceAck.requireConfirmedInputs) + requireConfirmedInputs = RequireConfirmedInputs(forLocal = msg.requireConfirmedInputs, forRemote = nodeParams.channelConf.requireConfirmedInputsForDualFunding) ) - val sessionId = randomBytes32() - val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( - sessionId, - nodeParams, fundingParams, - channelParams = d.commitments.params, - channelKeys = channelKeys, - purpose = InteractiveTxBuilder.SpliceTx(parentCommitment, d.commitments.changes), - localPushAmount = spliceAck.pushAmount, remotePushAmount = msg.pushAmount, - liquidityPurchase_opt = willFund_opt.map(_.purchase), - wallet - )) - txBuilder ! InteractiveTxBuilder.Start(self) - stay() using d.copy(spliceStatus = SpliceStatus.SpliceInProgress(cmd_opt = None, sessionId, txBuilder, remoteCommitSig = None)) sending spliceAck + 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.SpliceInitiated(msg, willFund_opt)) } } case SpliceStatus.NoSplice => @@ -1108,7 +1216,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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( @@ -1140,6 +1248,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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) @@ -1181,16 +1290,19 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Left(t) => log.warning("rejecting rbf request with invalid liquidity ads: {}", t.getMessage) stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage) + case Right(Some(willFund)) if willFund.purchase.amount > rbf.latestFundingTx.sharedTx.tx.localLiquidityAdded => + log.warning("rejecting rbf attempt with a liquidity request greater than our initial funding amount ({} > {})", willFund.purchase.amount, rbf.latestFundingTx.sharedTx.tx.localLiquidityAdded) + stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, InvalidRbfExceedsFunding(d.channelId).getMessage) case Right(willFund_opt) => - // We contribute the amount of liquidity requested by our peer, if liquidity ads is active. + // We contribute the amount of liquidity requested by our peer, if liquidity ads are active. // Otherwise we keep the same contribution we made to the previous funding transaction. - val fundingContribution = willFund_opt.map(_.purchase.amount).getOrElse(rbf.latestFundingTx.fundingParams.localContribution) - log.info("accepting rbf with remote.in.amount={} local.in.amount={}", msg.fundingContribution, fundingContribution) - val txAckRbf = TxAckRbf(d.channelId, fundingContribution, rbf.latestFundingTx.fundingParams.requireConfirmedInputs.forRemote, willFund_opt.map(_.willFund)) + val (localContribution, localOutputs) = InteractiveTxFunder.adjustRbfFunding(willFund_opt, rbf, msg.feerate) + log.info("accepting rbf with remote.in.amount={} local.in.amount={}", msg.fundingContribution, localContribution) + val txAckRbf = TxAckRbf(d.channelId, localContribution, rbf.latestFundingTx.fundingParams.requireConfirmedInputs.forRemote, willFund_opt.map(_.willFund)) val fundingParams = InteractiveTxParams( channelId = d.channelId, isInitiator = false, - localContribution = fundingContribution, + localContribution = localContribution, remoteContribution = msg.fundingContribution, sharedInput_opt = Some(Multisig2of2Input(rbf.parentCommitment)), remoteFundingPubKey = rbf.latestFundingTx.fundingParams.remoteFundingPubKey, @@ -1200,6 +1312,14 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall targetFeerate = msg.feerate, requireConfirmedInputs = RequireConfirmedInputs(forLocal = msg.requireConfirmedInputs, forRemote = txAckRbf.requireConfirmedInputs) ) + // This is an RBF attempt that we did not initiate so we do not want to lock any new inputs. 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. We only contribute our previous set of inputs and outputs, but if we + // used a changeless funding input, any excess over the purchased funding amount will be added to fees + // instead of the excess being added to our local contribution. + val localInputs = rbf.previousTransactions.head.tx.localInputs + val fundingContributions = InteractiveTxFunder.sortFundingContributions(fundingParams, localInputs, localOutputs, excess_opt = None) + val previousTx = rbf.previousTransactions.head.tx val sessionId = randomBytes32() val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( sessionId, @@ -1209,6 +1329,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall purpose = rbf, localPushAmount = 0 msat, remotePushAmount = 0 msat, willFund_opt.map(_.purchase), + Some(fundingContributions), wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1230,7 +1351,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(msg: TxAckRbf, d: DATA_NORMAL) => d.spliceStatus match { - case SpliceStatus.RbfRequested(cmd, txInitRbf) => + case SpliceStatus.RbfRequested(cmd, txInitRbf, fundingContributions_opt) => getSpliceRbfContext(Some(cmd), d) match { case Right(rbf) => val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript @@ -1263,6 +1384,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall purpose = rbf, localPushAmount = 0 msat, remotePushAmount = 0 msat, liquidityPurchase_opt = liquidityPurchase_opt, + fundingContributions_opt, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1279,22 +1401,29 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(msg: TxAbort, d: DATA_NORMAL) => d.spliceStatus match { + case SpliceStatus.SpliceInitiated(_, _) => + log.info("our peer aborted their own splice attempt before we could ack it: ascii='{}' bin={}", msg.toAscii, msg.data) + stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) calling endQuiescence(d) case SpliceStatus.SpliceInProgress(cmd_opt, _, txBuilder, _) => log.info("our peer aborted the splice attempt: ascii='{}' bin={}", msg.toAscii, msg.data) cmd_opt.foreach(cmd => cmd.replyTo ! RES_FAILURE(cmd, SpliceAttemptAborted(d.channelId))) txBuilder ! InteractiveTxBuilder.Abort stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) calling endQuiescence(d) - case SpliceStatus.SpliceWaitingForSigs(signingSession) => + case SpliceStatus.SpliceWaitingForSigs(cmd_opt, signingSession) => 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 + cmd_opt.foreach(cmd => cmd.replyTo ! RES_FAILURE(cmd, SpliceAttemptAborted(d.channelId))) 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, _, fundingContributions_opt) => 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}")) + // Any pending funding attempt will be rolled back if it succeeds. + fundingContributions_opt.foreach(rollbackOpenAttempt) stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) calling endQuiescence(d) - case SpliceStatus.RbfRequested(cmd, _) => + case SpliceStatus.RbfRequested(cmd, _, fundingContributions_opt) => log.info("our peer rejected our rbf attempt: ascii='{}' bin={}", msg.toAscii, msg.data) cmd.replyTo ! RES_FAILURE(cmd, new RuntimeException(s"rbf attempt rejected by our peer: ${msg.toAscii}")) + fundingContributions_opt.foreach(rollbackOpenAttempt) stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) calling endQuiescence(d) case SpliceStatus.NonInitiatorQuiescent => log.info("our peer aborted their own splice attempt: ascii='{}' bin={}", msg.toAscii, msg.data) @@ -1326,12 +1455,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case InteractiveTxBuilder.Succeeded(signingSession, commitSig, liquidityPurchase_opt) => log.info(s"splice tx created with fundingTxIndex=${signingSession.fundingTxIndex} fundingTxId=${signingSession.fundingTx.txId}") - cmd_opt.foreach(cmd => cmd.replyTo ! RES_SPLICE(fundingTxIndex = signingSession.fundingTxIndex, signingSession.fundingTx.txId, signingSession.fundingParams.fundingAmount, signingSession.localCommit.fold(_.spec, _.spec).toLocal)) remoteCommitSig_opt.foreach(self ! _) liquidityPurchase_opt.collect { case purchase if !signingSession.fundingParams.isInitiator => peer ! LiquidityPurchaseSigned(d.channelId, signingSession.fundingTx.txId, signingSession.fundingTxIndex, d.commitments.params.remoteParams.htlcMinimum, purchase) } - val d1 = d.copy(spliceStatus = SpliceStatus.SpliceWaitingForSigs(signingSession)) + val d1 = d.copy(spliceStatus = SpliceStatus.SpliceWaitingForSigs(cmd_opt, signingSession)) stay() using d1 storing() sending commitSig case f: InteractiveTxBuilder.Failed => log.info("splice attempt failed: {}", f.cause.getMessage) @@ -1346,9 +1474,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(msg: TxSignatures, d: DATA_NORMAL) => d.commitments.latest.localFundingStatus match { - case dfu@LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx: PartiallySignedSharedTransaction, _, _, _) if fundingTx.txId == msg.txId => + case dfu@LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx: PartiallySignedSharedTransaction, _, _, liquidityPurchase_opt) if fundingTx.txId == msg.txId => // we already sent our tx_signatures - InteractiveTxSigningSession.addRemoteSigs(channelKeys, dfu.fundingParams, fundingTx, msg) match { + InteractiveTxSigningSession.addRemoteSigs(channelKeys, dfu.fundingParams, fundingTx, msg, liquidityPurchase_opt.isDefined) match { case Left(cause) => log.warning("received invalid tx_signatures for fundingTxId={}: {}", msg.txId, cause.getMessage) // The funding transaction may still confirm (since our peer should be able to generate valid signatures), @@ -1367,9 +1495,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case _ => d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(signingSession) => + case SpliceStatus.SpliceWaitingForSigs(cmd_opt, signingSession) => // we have not yet sent our tx_signatures - signingSession.receiveTxSigs(channelKeys, msg, nodeParams.currentBlockHeight) match { + signingSession.receiveTxSigs(channelKeys, msg, nodeParams.currentBlockHeight, signingSession.liquidityPurchase_opt.isDefined) match { case Left(f) => rollbackFundingAttempt(signingSession.fundingTx.tx, previousTxs = Seq.empty) // no splice rbf yet stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, f.getMessage) @@ -1380,6 +1508,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val d1 = d.copy(commitments = commitments1, spliceStatus = SpliceStatus.NoSplice) log.info("publishing funding tx for channelId={} fundingTxId={}", d.channelId, signingSession1.fundingTx.sharedTx.txId) Metrics.recordSplice(signingSession1.fundingTx.fundingParams, signingSession1.fundingTx.sharedTx.tx) + cmd_opt.foreach(cmd => cmd.replyTo ! RES_SPLICE(fundingTxIndex = signingSession.fundingTxIndex, signingSession.fundingTx.txId, signingSession.fundingParams.fundingAmount, signingSession.localCommit.fold(_.spec, _.spec).toLocal)) stay() using d1 storing() sending signingSession1.localSigs calling publishFundingTx(signingSession1.fundingTx) calling endQuiescence(d1) } case _ => @@ -1469,12 +1598,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(INPUT_DISCONNECTED, d: DATA_NORMAL) => // we cancel the timer that would have made us send the enabled update after reconnection (flappy channel protection) cancelTimer(Reconnected.toString) - // if we are splicing, we need to cancel it - reportSpliceFailure(d.spliceStatus, new RuntimeException("splice attempt failed: disconnected")) val d1 = d.spliceStatus match { // We keep track of the RBF status: we should be able to complete the signature steps on reconnection. - case _: SpliceStatus.SpliceWaitingForSigs => d - case _ => d.copy(spliceStatus = SpliceStatus.NoSplice) + case SpliceStatus.SpliceWaitingForSigs(cmd_opt, signingSession) => + cmd_opt.foreach(cmd => reportSplicePending(cmd, signingSession)) + d + case _ => + // if we are splicing, we need to cancel it + reportSpliceFailure(d.spliceStatus, new RuntimeException("splice attempt failed: disconnected")) + d.copy(spliceStatus = SpliceStatus.NoSplice) } // if we have pending unsigned htlcs, then we cancel them and generate an update with the disabled flag set, that will be returned to the sender in a temporary channel failure if (d.commitments.changes.localChanges.proposed.collectFirst { case add: UpdateAddHtlc => add }.isDefined) { @@ -2317,7 +2449,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case _ => d.commitments.localCommitIndex + 1 } case d: DATA_NORMAL => d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(status) => status.nextLocalCommitmentNumber + case SpliceStatus.SpliceWaitingForSigs(cmd_opt, status) => status.nextLocalCommitmentNumber case _ => d.commitments.localCommitIndex + 1 } case _ => d.commitments.localCommitIndex + 1 @@ -2332,7 +2464,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } } case d: DATA_NORMAL => d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(status) => Set(ChannelReestablishTlv.NextFundingTlv(status.fundingTx.txId)) + case SpliceStatus.SpliceWaitingForSigs(cmd_opt, status) => Set(ChannelReestablishTlv.NextFundingTlv(status.fundingTx.txId)) case _ => d.commitments.latest.localFundingStatus match { case LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx: PartiallySignedSharedTransaction, _, _, _) => Set(ChannelReestablishTlv.NextFundingTlv(fundingTx.txId)) case _ => Set.empty @@ -2499,7 +2631,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val spliceStatus1 = channelReestablish.nextFundingTxId_opt match { case Some(fundingTxId) => d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => + case SpliceStatus.SpliceWaitingForSigs(cmd_opt, signingSession) if signingSession.fundingTx.txId == fundingTxId => if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { // They haven't received our commit_sig: we retransmit it. // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. @@ -3363,13 +3495,19 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } 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 SpliceStatus.SpliceRequested(_, _, Some(fundingContributions)) => + log.warning("quiescence timed out after sending splice request, rolling back funding contributions and closing connection") + rollbackOpenAttempt(fundingContributions) + 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) } } @@ -3385,8 +3523,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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.RbfRequested(cmd, _) => Some(cmd) + case SpliceStatus.SpliceRequested(cmd, _, _) => Some(cmd) + case SpliceStatus.RbfRequested(cmd, _, _) => Some(cmd) case SpliceStatus.SpliceInProgress(cmd_opt, _, txBuilder, _) => txBuilder ! InteractiveTxBuilder.Abort cmd_opt @@ -3395,6 +3533,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall cmd_opt.foreach(cmd => cmd.replyTo ! RES_FAILURE(cmd, f)) } + private def reportSplicePending(cmd: ChannelFundingCommand, signingSession: InteractiveTxSigningSession.WaitingForSigs): Unit = { + cmd match { + case cmd: CMD_SPLICE => cmd.replyTo ! RES_SPLICE_PENDING(signingSession.fundingTxIndex, signingSession.fundingTx.txId, signingSession.fundingParams.fundingAmount, signingSession.localCommit.fold(_.spec, _.spec).toLocal) + case cmd: CMD_BUMP_FUNDING_FEE => cmd.replyTo ! RES_BUMP_FUNDING_FEE_PENDING(signingSession.fundingTx.txId, signingSession.fundingTx.tx.localFees.truncateToSatoshi) + } + } + override def mdc(currentMessage: Any): MDC = { val category_opt = LogCategory(currentMessage) val id = currentMessage match { 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 96d87085b1..9a090c8451 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,17 +16,19 @@ package fr.acinq.eclair.channel.fsm +import akka.actor.Status import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, actorRefAdapter} -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._ +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.{ToMilliSatoshiConversion, UInt64, randomBytes32} @@ -104,37 +106,87 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { when(WAIT_FOR_INIT_DUAL_FUNDED_CHANNEL)(handleExceptions { case Event(input: INPUT_INIT_CHANNEL_INITIATOR, _) => - val fundingPubKey = channelKeys.fundingKey(fundingTxIndex = 0).publicKey - 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 = channelKeys.revocationBasePoint, - paymentBasepoint = input.localParams.walletStaticPaymentBasepoint.getOrElse(channelKeys.paymentBasePoint), - delayedPaymentBasepoint = channelKeys.delayedPaymentBasePoint, - htlcBasepoint = channelKeys.htlcBasePoint, - firstPerCommitmentPoint = channelKeys.commitmentPoint(0), - secondPerCommitmentPoint = channelKeys.commitmentPoint(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 = channelKeys.fundingKey(fundingTxIndex = 0).publicKey + 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 = channelKeys.revocationBasePoint, + paymentBasepoint = d.input.localParams.walletStaticPaymentBasepoint.getOrElse(channelKeys.paymentBasePoint), + delayedPaymentBasepoint = channelKeys.delayedPaymentBasePoint, + htlcBasepoint = channelKeys.htlcBasePoint, + firstPerCommitmentPoint = channelKeys.commitmentPoint(0), + secondPerCommitmentPoint = channelKeys.commitmentPoint(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 { @@ -216,6 +268,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { channelParams, channelKeys, 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 @@ -233,6 +286,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)) => @@ -280,6 +334,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { channelParams, channelKeys, 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)) @@ -400,7 +455,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { case Event(msg: InteractiveTxMessage, d: DATA_WAIT_FOR_DUAL_FUNDING_SIGNED) => msg match { case txSigs: TxSignatures => - d.signingSession.receiveTxSigs(channelKeys, txSigs, nodeParams.currentBlockHeight) match { + d.signingSession.receiveTxSigs(channelKeys, txSigs, nodeParams.currentBlockHeight, d.signingSession.liquidityPurchase_opt.isDefined) match { case Left(f) => rollbackFundingAttempt(d.signingSession.fundingTx.tx, Nil) goto(CLOSED) sending Error(d.channelId, f.getMessage) @@ -449,7 +504,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { when(WAIT_FOR_DUAL_FUNDING_CONFIRMED)(handleExceptions { case Event(txSigs: TxSignatures, d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) => d.latestFundingTx.sharedTx match { - case fundingTx: PartiallySignedSharedTransaction => InteractiveTxSigningSession.addRemoteSigs(channelKeys, d.latestFundingTx.fundingParams, fundingTx, txSigs) match { + case fundingTx: PartiallySignedSharedTransaction => InteractiveTxSigningSession.addRemoteSigs(channelKeys, d.latestFundingTx.fundingParams, fundingTx, txSigs, d.latestFundingTx.liquidityPurchase_opt.isDefined) match { case Left(cause) => val unsignedFundingTx = fundingTx.tx.buildUnsignedTx() log.warning("received invalid tx_signatures for txid={} (current funding txid={}): {}", txSigs.txId, unsignedFundingTx.txid, cause.getMessage) @@ -468,7 +523,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { case _: FullySignedSharedTransaction => d.status match { case DualFundingStatus.RbfWaitingForSigs(signingSession) => - signingSession.receiveTxSigs(channelKeys, txSigs, nodeParams.currentBlockHeight) match { + signingSession.receiveTxSigs(channelKeys, txSigs, nodeParams.currentBlockHeight, signingSession.liquidityPurchase_opt.isDefined) match { case Left(f) => rollbackRbfAttempt(signingSession, d) stay() using d.copy(status = DualFundingStatus.RbfAborted) sending TxAbort(d.channelId, f.getMessage) @@ -574,6 +629,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( @@ -622,6 +678,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 227fb75ef3..af252dffbf 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 8b18e06b6a..967a823bb4 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 @@ -172,10 +172,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 @@ -183,8 +187,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 @@ -328,7 +337,7 @@ object InteractiveTxBuilder { localInputs: List[Input.Local], remoteInputs: List[Input.Remote], localOutputs: List[Output.Local], remoteOutputs: List[Output.Remote], lockTime: Long) { - val localAmountIn: MilliSatoshi = sharedInput_opt.map(_.localAmount).getOrElse(0 msat) + localInputs.map(i => i.txOut.amount).sum + val localAmountIn: MilliSatoshi = sharedInput_opt.map(_.localAmount).getOrElse(0 msat) + localInputs.map(_.txOut.amount).sum val remoteAmountIn: MilliSatoshi = sharedInput_opt.map(_.remoteAmount).getOrElse(0 msat) + remoteInputs.map(_.txOut.amount).sum val localAmountOut: MilliSatoshi = sharedOutput.localAmount + localOutputs.map(_.amount).sum val remoteAmountOut: MilliSatoshi = sharedOutput.remoteAmount + remoteOutputs.map(_.amount).sum @@ -340,6 +349,7 @@ object InteractiveTxBuilder { val inputDetails: Map[OutPoint, TxOut] = (sharedInput_opt.toSeq.map(i => i.outPoint -> i.txOut) ++ localInputs.map(i => i.outPoint -> i.txOut) ++ remoteInputs.map(i => i.outPoint -> i.txOut)).toMap def localOnlyNonChangeOutputs: List[Output.Local.NonChange] = localOutputs.collect { case o: Local.NonChange => o } + def localLiquidityAdded: Satoshi = localInputs.map(i => i.txOut.amount).sum def buildUnsignedTx(): Transaction = { val sharedTxIn = sharedInput_opt.map(i => (i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence))).toSeq @@ -393,6 +403,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. @@ -426,7 +437,7 @@ object InteractiveTxBuilder { Behaviors.stopped } else { val actor = new InteractiveTxBuilder(replyTo, sessionId, nodeParams, channelParams, channelKeys, fundingParams, purpose, localPushAmount, remotePushAmount, liquidityPurchase_opt, wallet, stash, context) - actor.start() + actor.start(fundingContributions_opt) } case Abort => Behaviors.stopped } @@ -466,34 +477,44 @@ 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 + def start(fundingContributions_opt: Option[InteractiveTxFunder.FundingContributions]): Behavior[Command] = { + fundingContributions_opt match { + case Some(fundingContributions) => + val fundingContributions1 = fundingContributions.copy( + outputs = fundingContributions.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)) + case None => + 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 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 + } } } @@ -835,6 +856,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon val liquidityFee = fundingParams.liquidityFees(liquidityPurchase_opt) val localCommitmentKeys = LocalCommitmentKeys(channelParams, channelKeys, purpose.localCommitIndex) val remoteCommitmentKeys = RemoteCommitmentKeys(channelParams, channelKeys, purpose.remotePerCommitmentPoint) + require(fundingOutputIndex >= 0, "shared output not found in funding tx!") Funding.makeCommitTxs(channelParams, fundingAmount = fundingParams.fundingAmount, toLocal = completeTx.sharedOutput.localAmount - localPushAmount + remotePushAmount - liquidityFee, @@ -1038,7 +1060,7 @@ object InteractiveTxSigningSession { } } - def addRemoteSigs(channelKeys: ChannelKeys, fundingParams: InteractiveTxParams, partiallySignedTx: PartiallySignedSharedTransaction, remoteSigs: TxSignatures)(implicit log: LoggingAdapter): Either[ChannelException, FullySignedSharedTransaction] = { + def addRemoteSigs(channelKeys: ChannelKeys, fundingParams: InteractiveTxParams, partiallySignedTx: PartiallySignedSharedTransaction, remoteSigs: TxSignatures, liquidityPurchase: Boolean)(implicit log: LoggingAdapter): Either[ChannelException, FullySignedSharedTransaction] = { if (partiallySignedTx.tx.localInputs.length != partiallySignedTx.localSigs.witnesses.length) { return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) } @@ -1066,9 +1088,10 @@ object InteractiveTxSigningSession { // We allow a 5% error margin since witness size prediction could be inaccurate. // If they didn't contribute to the transaction, they're not responsible, so we don't check the feerate. // If we didn't contribute to the transaction, we don't care if they use a lower feerate than expected. + // If we are purchasing liquidity, we do not care about their contribution to the feerate for RBFs. val localContributed = txWithSigs.tx.localInputs.nonEmpty || txWithSigs.tx.localOutputs.nonEmpty val remoteContributed = txWithSigs.tx.remoteInputs.nonEmpty || txWithSigs.tx.remoteOutputs.nonEmpty - if (localContributed && remoteContributed && txWithSigs.feerate < fundingParams.targetFeerate * 0.95) { + if (!liquidityPurchase && localContributed && remoteContributed && txWithSigs.feerate < fundingParams.targetFeerate * 0.95) { return Left(InvalidFundingFeerate(fundingParams.channelId, fundingParams.targetFeerate, txWithSigs.feerate)) } val previousOutputs = { @@ -1123,15 +1146,15 @@ object InteractiveTxSigningSession { } } - def receiveTxSigs(channelKeys: ChannelKeys, remoteTxSigs: TxSignatures, currentBlockHeight: BlockHeight)(implicit log: LoggingAdapter): Either[ChannelException, SendingSigs] = { + def receiveTxSigs(channelKeys: ChannelKeys, remoteTxSigs: TxSignatures, currentBlockHeight: BlockHeight, liquidityPurchase: Boolean)(implicit log: LoggingAdapter): Either[ChannelException, SendingSigs] = { localCommit match { case Left(_) => log.info("received tx_signatures before commit_sig") Left(UnexpectedFundingSignatures(fundingParams.channelId)) case Right(signedLocalCommit) => - addRemoteSigs(channelKeys, fundingParams, fundingTx, remoteTxSigs) match { + addRemoteSigs(channelKeys, fundingParams, fundingTx, remoteTxSigs, liquidityPurchase) match { case Left(f) => - log.info("received invalid tx_signatures") + log.info("received invalid tx_signatures: {}", f.getMessage) Left(f) case Right(fullySignedTx) => log.info("interactive-tx fully signed with {} local inputs, {} remote inputs, {} local outputs and {} remote outputs", fullySignedTx.tx.localInputs.length, fullySignedTx.tx.remoteInputs.length, fullySignedTx.tx.localOutputs.length, fullySignedTx.tx.remoteOutputs.length) 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 f5b22fd1de..e1de51e60e 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 @@ -22,9 +22,11 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{KotlinUtils, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} import fr.acinq.eclair.blockchain.OnChainChannelFunder import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.Output.Local.{Change, NonChange} import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._ import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.wire.protocol.TxAddInput +import fr.acinq.eclair.transactions.Transactions.weight2fee +import fr.acinq.eclair.wire.protocol.{LiquidityAds, TxAddInput} import fr.acinq.eclair.{Logs, UInt64} import scodec.bits.ByteVector @@ -44,17 +46,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 { @@ -93,10 +95,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. @@ -127,7 +129,7 @@ object InteractiveTxFunder { previousTxSizeOk && isNativeSegwit } - private def sortFundingContributions(fundingParams: InteractiveTxParams, inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput]): FundingContributions = { + 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) @@ -144,15 +146,42 @@ object InteractiveTxFunder { case output: Output.Shared => output.copy(serialId = serialId) } } - FundingContributions(sortedInputs, sortedOutputs) + FundingContributions(sortedInputs, sortedOutputs, excess_opt) } + /** + * Instead of adding additional funding inputs to achieve a new feerate, reduce our change output amount. For changeless + * funding contributions, any excess funding added to our local contribution above the purchased funding amount will be + * treated as change that can contribute to fees. Returns a new funding contribution that spends the same inputs and + * contributes up to the change output amount to achieve the new feerate. If we cannot achieve the new feerate with the + * available change output, we will underpay the fees, which is acceptable. + */ + def adjustRbfFunding(willFund_opt: Option[LiquidityAds.WillFundPurchase], rbf: InteractiveTxBuilder.SpliceTxRbf, feerate: FeeratePerKw): (Satoshi, Seq[OutgoingOutput]) = { + val localContribution = willFund_opt.map(_.purchase.amount).getOrElse(rbf.latestFundingTx.fundingParams.localContribution) + val signedSharedTx = rbf.latestFundingTx.sharedTx.asInstanceOf[FullySignedSharedTransaction] + val localFundedTx = signedSharedTx.signedTx.copy( + txIn = signedSharedTx.signedTx.txIn.filter(i => signedSharedTx.tx.localInputs.exists(_.outPoint == i.outPoint)), + txOut = signedSharedTx.signedTx.txOut.filter(txo => signedSharedTx.tx.localOutputs.exists(lo => lo.pubkeyScript == txo.publicKeyScript && lo.amount == txo.amount))) + val localFees = weight2fee(feerate, localFundedTx.weight()) + val localNonChange = signedSharedTx.tx.localOutputs.collect { case o: NonChange => o.amount }.sum + val change = signedSharedTx.tx.localInputs.map(i => i.txOut.amount).sum - localContribution - localNonChange - localFees + val localOutputs = signedSharedTx.tx.localOutputs.collect { + // remove our change output unless it is for more than the dust limit + case o: Change if change > rbf.latestFundingTx.fundingParams.dustLimit => o.copy(amount = change) + case o: NonChange => o + } + // If we don't have a change output, add any positive change amount to our local contribution. + val localContribution1 = if (!localOutputs.exists(_.isInstanceOf[Change]) && change > 0.sat) { + localContribution + change + } else localContribution + (localContribution1, localOutputs) + } } 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) { @@ -185,12 +214,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 } @@ -229,6 +258,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 @@ -237,10 +267,11 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response val minConfirmations_opt = if (fundingParams.requireConfirmedInputs.forLocal) Some(1) else None context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, externalInputsWeight = sharedInputWeight, minInputConfirmations_opt = minConfirmations_opt, 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) { @@ -249,7 +280,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) @@ -257,8 +288,35 @@ 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] + } + // Only add splice-in inputs that are not shared inputs, as those are already accounted for in the shared input weight. + val inputs1 = 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 { + // Must check for p2tr before p2wpkh, as a p2tr script can also be a native witness script. + case script if Script.isPay2tr(script) => + txIn.copy(witness = Script.witnessKeyPathPay2tr(Transactions.PlaceHolderSig)) + case script if Script.isNativeWitnessScript(script) => + txIn.copy(witness = Script.witnessPay2wpkh(Transactions.PlaceHolderPubKey, ByteVector.fill(73)(0))) + case _ => + txIn + } + case _ => txIn + } + } + // Remove funding outputs that are not used for splice-out or as a shared output + val outputs1 = tx.txOut.filter { txOut => fundingParams.localOutputs.contains(txOut) || (txOut.publicKeyScript == fundingPubkeyScript && fundingParams.isInitiator) } + val dummySignedTx = tx.copy(txIn = inputs1, txOut = outputs1) + 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) @@ -274,6 +332,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) { @@ -281,7 +342,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]) @@ -305,7 +366,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 ecb92805d3..1ef1a62298 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/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala index c0cd0736af..ee1623831a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala @@ -319,6 +319,8 @@ object OnTheFlyFunding { case WrappedCommandResponse(response) => response match { case _: CommandSuccess[_] => waitForCommandResult(stash, remaining - 1, htlcSent + 1) + case _: CommandPending[_] => + waitForCommandResult(stash, remaining - 1, htlcSent + 1) case failure: CommandFailure[_, _] => cmd.replyTo ! RelayFailed(paymentHash, CannotAddToChannel(failure.t)) waitForCommandResult(stash, remaining - 1, htlcSent) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala index 6f55c60b66..dfeabf315e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala @@ -148,6 +148,9 @@ class TrampolinePaymentLifecycle private(nodeParams: NodeParams, case _: CommandSuccess[_] => // HTLC was correctly sent out. Behaviors.same + case _: CommandPending[_] => + // HTLC was correctly sent out, but not fully signed before the nodes disconnected. + Behaviors.same case failure: CommandFailure[_, Throwable] => context.log.warn("HTLC could not be sent: {}", failure.t.getMessage) if (remaining > 1) { 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 ec5510db2e..23e05638ff 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 @@ -1466,6 +1466,11 @@ object Transactions { } } + /** + * Default public key used for fee estimation + */ + val PlaceHolderPubKey: PublicKey = PrivateKey(ByteVector32.One).publicKey + /** * This default sig takes 72B when encoded in DER (incl. 1B for the trailing sig hash), it is used for fee estimation * It is 72 bytes because our signatures are normalized (low-s) and will take up 72 bytes at most in DER format diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala index 5cd74594c1..a3a2567ebc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala @@ -709,8 +709,10 @@ private[channel] object ChannelCodecs4 { val spliceStatusCodec: Codec[SpliceStatus] = discriminated[SpliceStatus].by(uint8) .\(0x01) { case status: SpliceStatus if !status.isInstanceOf[SpliceStatus.SpliceWaitingForSigs] => SpliceStatus.NoSplice }(provide(SpliceStatus.NoSplice)) - .\(0x03) { case status: SpliceStatus.SpliceWaitingForSigs => status }(interactiveTxWaitingForSigsCodec.as[channel.SpliceStatus.SpliceWaitingForSigs]) - .\(0x02) { case status: SpliceStatus.SpliceWaitingForSigs => status }(interactiveTxWaitingForSigsWithoutLiquidityPurchaseCodec.as[channel.SpliceStatus.SpliceWaitingForSigs]) + .\(0x03) { case status: SpliceStatus.SpliceWaitingForSigs => status }( + (("cmd_opt" | provide(Option.empty[ChannelFundingCommand])) :: ("signingSession" | interactiveTxWaitingForSigsCodec)).as[channel.SpliceStatus.SpliceWaitingForSigs]) + .\(0x02) { case status: SpliceStatus.SpliceWaitingForSigs => status }( + (("cmd_opt" | provide(Option.empty[ChannelFundingCommand])) :: ("signingSession" | interactiveTxWaitingForSigsWithoutLiquidityPurchaseCodec)).as[channel.SpliceStatus.SpliceWaitingForSigs]) private val shortids: Codec[ChannelTypes4.ShortIds] = ( ("real_opt" | optional(bool8, realshortchannelid)) :: diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index d348a2ed9d..f04fe2b16f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -18,6 +18,7 @@ package fr.acinq.eclair import akka.actor.ActorRef import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Satoshi, SatoshiLong} +import fr.acinq.eclair.blockchain.DummyOnChainWallet import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel.{ChannelConf, RemoteRbfLimits, UnhandledExceptionStrategy} @@ -53,7 +54,8 @@ object TestConstants { val feeratePerKw: FeeratePerKw = FeeratePerKw(10_000 sat) val anchorOutputsFeeratePerKw: FeeratePerKw = FeeratePerKw(2_500 sat) val defaultLiquidityRates: LiquidityAds.WillFundRates = LiquidityAds.WillFundRates( - fundingRates = LiquidityAds.FundingRate(100_000 sat, 10_000_000 sat, 500, 100, 100 sat, 1000 sat) :: Nil, + fundingRates = LiquidityAds.FundingRate(100_000 sat, 10_000_000 sat, 500, 100, 100 sat, 1000 sat) :: + LiquidityAds.FundingRate(DummyOnChainWallet.invalidFundingAmount, DummyOnChainWallet.invalidFundingAmount+1.sat, 500, 100, 100 sat, 1000 sat) :: Nil, paymentTypes = Set(LiquidityAds.PaymentType.FromChannelBalance) ) val emptyOnionPacket: OnionRoutingPacket = OnionRoutingPacket(0, ByteVector.fill(33)(0), ByteVector.fill(1300)(0), ByteVector32.Zeroes) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index dbb4775563..0a3f0b4454 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -80,7 +80,7 @@ object TestDatabases { case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = freeze2(d.commitments)) case d: DATA_NORMAL => d.copy(commitments = freeze2(d.commitments)) .modify(_.spliceStatus).using { - case s: SpliceStatus.SpliceWaitingForSigs => s + case SpliceStatus.SpliceWaitingForSigs(_, signingSession) => SpliceStatus.SpliceWaitingForSigs(None, signingSession) case _ => SpliceStatus.NoSplice } case d: DATA_CLOSING => d.copy(commitments = freeze2(d.commitments)) 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 b02a3ee034..0a9529ccc5 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 @@ -176,34 +176,50 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnChainPubkeyCache { override def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(p2wpkhPublicKey) - override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], minInputConfirmations_opt: Option[Int], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { + def createFundedTx(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], minInputConfirmations_opt: Option[Int], feeBudget_opt: Option[Satoshi], changeless: Boolean): Either[Exception, FundTransactionResponse] = { 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 >= DummyOnChainWallet.invalidFundingAmount) return Left(new RuntimeException(s"invalid funding amount")) // We add a single input to reach the desired feerate. - val inputAmount = amountOut + 100_000.sat + val inputAmount = if (!changeless) amountOut + 100_000.sat else amountOut // We randomly use either p2wpkh or p2tr. val script = if (Random.nextBoolean()) p2trScript else p2wpkhScript val dummyP2wpkhWitness = Script.witnessPay2wpkh(p2wpkhPublicKey, ByteVector.fill(73)(0)) val dummyP2trWitness = Script.witnessKeyPathPay2tr(ByteVector64.Zeroes) val inputTx = Transaction(2, Seq(TxIn(OutPoint(randomTxId(), 1), Nil, 0)), Seq(TxOut(inputAmount, script)), 0) - inputs = inputs :+ inputTx val dummySignedTx = tx.copy( txIn = tx.txIn.filterNot(i => externalInputsWeight.contains(i.outPoint)).appended(TxIn(OutPoint(inputTx, 0), ByteVector.empty, 0, ScriptWitness.empty)).map(txIn => { val isP2tr = inputs.find(_.txid == txIn.outPoint.txid).map(_.txOut(txIn.outPoint.index.toInt).publicKeyScript).map(Script.parse).exists(Script.isPay2tr) txIn.copy(witness = if (isP2tr) dummyP2trWitness else dummyP2wpkhWitness) }), - txOut = tx.txOut :+ TxOut(inputAmount, script), + txOut = if (changeless) tx.txOut else tx.txOut :+ TxOut(inputAmount, script) ) + // When funding an output of exactly 100_000 sats, we add excess of exactly 1_000 sats to a changeless funding request. + val excess = if (amountOut - currentAmountIn == 100_000.sat) 1_000.sat else 0.sat val fee = Transactions.weight2fee(feeRate, dummySignedTx.weight() + externalInputsWeight.values.sum.toInt) + // We add a single input to reach the desired feerate. + val inputAmount1 = if (changeless) amountOut + fee + excess - currentAmountIn else inputAmount + val inputTx1 = Transaction(2, Seq(TxIn(OutPoint(randomTxId(), 1), Nil, 0)), Seq(TxOut(inputAmount1, script)), 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 Some(feeBudget) if fee > feeBudget => Left(new RuntimeException(s"mining fee is higher than budget ($fee > $feeBudget)")) case _ => val fundedTx = tx.copy( - txIn = tx.txIn :+ TxIn(OutPoint(inputTx, 0), Nil, 0), - txOut = tx.txOut :+ TxOut(inputAmount + currentAmountIn - amountOut - fee, script), + txIn = tx.txIn :+ TxIn(OutPoint(inputTx1, 0), Nil, 0), + txOut = if (changeless) tx.txOut else tx.txOut :+ TxOut(inputAmount + currentAmountIn - amountOut - fee, script), ) - Future.successful(FundTransactionResponse(fundedTx, fee, Some(tx.txOut.length))) + if (changeless) { + Right(FundTransactionResponse(fundedTx, fee + excess, None)) + } else { + Right(FundTransactionResponse(fundedTx, fee, Some(tx.txOut.length))) + } + } + } + + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], minInputConfirmations_opt: Option[Int], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { + createFundedTx(tx, feeRate, replaceable, changePosition, externalInputsWeight, minInputConfirmations_opt, feeBudget_opt, changeless = false) match { + case Right(response) => Future.successful(response) + case Left(error) => Future.failed(error) } } @@ -286,8 +302,22 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnChainPubkeyCache { override def getReceivePublicKeyScript(renew: Boolean): Seq[ScriptElt] = p2trScript } +class SingleKeyOnChainWalletWithConfirmedInputs extends SingleKeyOnChainWallet { + override def getTxConfirmations(txid: TxId)(implicit ec: ExecutionContext): Future[Option[Int]] = Future.successful(Some(6)) +} + +class ChangelessFundingWallet extends SingleKeyOnChainWallet { + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], minInputConfirmations_opt: Option[Int], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { + createFundedTx(tx, feeRate, replaceable, changePosition, externalInputsWeight, minInputConfirmations_opt, feeBudget_opt, changeless = true) match { + case Right(response) => Future.successful(response) + case Left(error) => Future.failed(error) + } + } +} + object DummyOnChainWallet { val dummyReceivePubkey: PublicKey = PublicKey(hex"028feba10d0eafd0fad8fe20e6d9206e6bd30242826de05c63f459a00aced24b12") + val invalidFundingAmount: Satoshi = 2_100_000_000.sat def makeDummyFundingTx(pubkeyScript: ByteVector, amount: Satoshi): MakeFundingTxResponse = { val fundingTx = Transaction( 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 d97532c22c..235710275f 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 @@ -130,6 +130,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsA, fundingParams, channelParamsA, channelKeysA, 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( @@ -137,6 +138,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsA, fundingParams, channelParamsA, channelKeysA, 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( @@ -144,6 +146,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsA, fundingParams, channelParamsA, channelKeysA, 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( @@ -151,6 +154,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsA, fundingParams, channelParamsA, channelKeysA, 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( @@ -158,6 +162,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsB, fundingParams, channelParamsB, channelKeysB, 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( @@ -165,6 +170,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsB, fundingParams, channelParamsB, channelKeysB, 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( @@ -172,6 +178,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsB, fundingParams, channelParamsB, channelKeysB, 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( @@ -179,6 +186,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit nodeParamsB, fundingParams, channelParamsB, channelKeysB, 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) = { @@ -189,11 +197,11 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val Right(sigsA: InteractiveTxSigningSession.SendingSigs) = successA.signingSession.receiveCommitSig(channelParamsA, channelKeysA, successB.commitSig, nodeParamsA.currentBlockHeight) assert(sigsA.fundingTx.sharedTx.isInstanceOf[PartiallySignedSharedTransaction]) // Alice --- tx_signatures --> Bob - val Right(sigsB) = signingSessionB2.receiveTxSigs(channelKeysB, sigsA.localSigs, nodeParamsB.currentBlockHeight) + val Right(sigsB) = signingSessionB2.receiveTxSigs(channelKeysB, sigsA.localSigs, nodeParamsB.currentBlockHeight, signingSessionB2.liquidityPurchase_opt.isDefined) assert(sigsB.fundingTx.sharedTx.isInstanceOf[FullySignedSharedTransaction]) val txB = sigsB.fundingTx.sharedTx.asInstanceOf[FullySignedSharedTransaction] // Alice <-- tx_signatures --- Bob - val Right(txA) = InteractiveTxSigningSession.addRemoteSigs(channelKeysA, fundingParams, sigsA.fundingTx.sharedTx.asInstanceOf[PartiallySignedSharedTransaction], sigsB.localSigs) + val Right(txA) = InteractiveTxSigningSession.addRemoteSigs(channelKeysA, fundingParams, sigsA.fundingTx.sharedTx.asInstanceOf[PartiallySignedSharedTransaction], sigsB.localSigs, sigsA.fundingTx.liquidityPurchase_opt.isDefined) (txA, sigsA.commitment, txB, sigsB.commitment) } @@ -205,11 +213,11 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val Right(sigsB: InteractiveTxSigningSession.SendingSigs) = successB.signingSession.receiveCommitSig(channelParamsB, channelKeysB, successA.commitSig, nodeParamsB.currentBlockHeight) assert(sigsB.fundingTx.sharedTx.isInstanceOf[PartiallySignedSharedTransaction]) // Alice <-- tx_signatures --- Bob - val Right(sigsA) = signingSessionA2.receiveTxSigs(channelKeysA, sigsB.localSigs, nodeParamsA.currentBlockHeight) + val Right(sigsA) = signingSessionA2.receiveTxSigs(channelKeysA, sigsB.localSigs, nodeParamsA.currentBlockHeight, signingSessionA2.liquidityPurchase_opt.isDefined) assert(sigsA.fundingTx.sharedTx.isInstanceOf[FullySignedSharedTransaction]) val txA = sigsA.fundingTx.sharedTx.asInstanceOf[FullySignedSharedTransaction] // Alice --- tx_signatures --> Bob - val Right(txB) = InteractiveTxSigningSession.addRemoteSigs(channelKeysB, fundingParams, sigsB.fundingTx.sharedTx.asInstanceOf[PartiallySignedSharedTransaction], sigsA.localSigs) + val Right(txB) = InteractiveTxSigningSession.addRemoteSigs(channelKeysB, fundingParams, sigsB.fundingTx.sharedTx.asInstanceOf[PartiallySignedSharedTransaction], sigsA.localSigs, sigsB.fundingTx.liquidityPurchase_opt.isDefined) (txA, sigsA.commitment, txB, sigsB.commitment) } } @@ -2231,7 +2239,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit // Alice <-- commit_sig --- Bob val Right(signingA3: InteractiveTxSigningSession.WaitingForSigs) = successA2.signingSession.receiveCommitSig(fixtureParams.channelParamsA, fixtureParams.channelKeysA, successB2.commitSig, fixtureParams.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) // Alice <-- tx_signatures --- Bob - val Left(error) = signingA3.receiveTxSigs(fixtureParams.channelKeysA, successB2.signingSession.fundingTx.localSigs.copy(tlvStream = TlvStream.empty), fixtureParams.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) + val Left(error) = signingA3.receiveTxSigs(fixtureParams.channelKeysA, successB2.signingSession.fundingTx.localSigs.copy(tlvStream = TlvStream.empty), fixtureParams.nodeParamsA.currentBlockHeight, signingA3.liquidityPurchase_opt.isDefined)(akka.event.NoLogging) assert(error == InvalidFundingSignature(bobParams.channelId, Some(successA2.signingSession.fundingTx.txId))) } } @@ -2783,7 +2791,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit // Alice <-- tx_signatures --- Bob val signingA = alice2bob.expectMsgType[Succeeded].signingSession val signingB = bob2alice.expectMsgType[Succeeded].signingSession - val Left(error) = signingA.receiveTxSigs(params.channelKeysA, signingB.fundingTx.localSigs, params.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) + val Left(error) = signingA.receiveTxSigs(params.channelKeysA, signingB.fundingTx.localSigs, params.nodeParamsA.currentBlockHeight, signingB.liquidityPurchase_opt.isDefined)(akka.event.NoLogging) assert(error == UnexpectedFundingSignatures(params.channelId)) } @@ -2811,7 +2819,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val successB1 = bob2alice.expectMsgType[Succeeded] val Right(signingA2: InteractiveTxSigningSession.WaitingForSigs) = successA1.signingSession.receiveCommitSig(params.channelParamsA, params.channelKeysA, successB1.commitSig, params.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) // Alice <-- tx_signatures --- Bob - val Left(error) = signingA2.receiveTxSigs(params.channelKeysA, successB1.signingSession.fundingTx.localSigs.copy(witnesses = Seq(Script.witnessPay2wpkh(randomKey().publicKey, ByteVector.fill(73)(0)))), params.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) + val Left(error) = signingA2.receiveTxSigs(params.channelKeysA, successB1.signingSession.fundingTx.localSigs.copy(witnesses = Seq(Script.witnessPay2wpkh(randomKey().publicKey, ByteVector.fill(73)(0)))), params.nodeParamsA.currentBlockHeight, signingA2.liquidityPurchase_opt.isDefined)(akka.event.NoLogging) assert(error.isInstanceOf[InvalidFundingSignature]) } 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 322b942581..1bff74ecd6 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.{FeeratePerKw, FeeratesPerKw} -import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainPubkeyCache, OnChainWallet, SingleKeyOnChainWallet} +import fr.acinq.eclair.blockchain.{ChangelessFundingWallet, DummyOnChainWallet, OnChainPubkeyCache, OnChainWallet, SingleKeyOnChainWallet, SingleKeyOnChainWalletWithConfirmedInputs} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx} @@ -98,6 +98,8 @@ object ChannelStateTestsTags { val SimpleClose = "option_simple_close" /** If set, disable option_splice for one node. */ val DisableSplice = "disable_splice" + /** If set, wallet will return changeless funding txs. */ + val ChangelessFunding = "changeless_funding" } trait ChannelStateTestsBase extends Assertions with Eventually { @@ -167,7 +169,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 @@ -327,10 +331,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] 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 77a4549802..ab4b012c15 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 @@ -22,6 +22,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, SatoshiLong, Tx import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchPublished, WatchPublishedTriggered} +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 @@ -39,10 +40,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 c76bdf3251..f4b18eefab 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 @@ -23,7 +23,7 @@ import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong, Transaction} 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 @@ -48,10 +48,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/NormalQuiescentStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala index da6abf507c..b130216874 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala @@ -503,7 +503,7 @@ class NormalQuiescentStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteL // alice sends splice-init to bob, bob responds with splice-ack alice2bob.forward(bob) bob2alice.expectMsgType[SpliceAck] - assert(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.isInstanceOf[SpliceStatus.SpliceInProgress]) + awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.isInstanceOf[SpliceStatus.SpliceInProgress]) // bob sends a warning and disconnects if the splice takes too long to complete bob ! Channel.QuiescenceTimeout(bobPeer.ref) 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 43c8c75aca..7ed4fe8409 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,9 +24,9 @@ import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.NumericSatoshi.abs import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair._ -import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, SingleKeyOnChainWallet, SingleKeyOnChainWalletWithConfirmedInputs} import fr.acinq.eclair.channel.Helpers.Closing.{LocalClose, RemoteClose, RevokedClose} import fr.acinq.eclair.channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx import fr.acinq.eclair.channel._ @@ -73,7 +73,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik private val defaultSpliceOutScriptPubKey = hex"0020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - private def initiateSpliceWithoutSigs(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], sendTxComplete: Boolean): TestProbe = { + private def initiateSpliceWithoutSigs(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], sendTxComplete: Boolean, changelessFunding: Boolean): TestProbe = { val sender = TestProbe() val cmd = CMD_SPLICE(sender.ref, spliceIn_opt, spliceOut_opt, None) s ! cmd @@ -83,49 +83,18 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik r2s.expectMsgType[SpliceAck] r2s.forward(s) - s2r.expectMsgType[TxAddInput] - s2r.forward(r) - r2s.expectMsgType[TxComplete] - r2s.forward(s) - if (spliceIn_opt.isDefined) { - s2r.expectMsgType[TxAddInput] - s2r.forward(r) - r2s.expectMsgType[TxComplete] - r2s.forward(s) - s2r.expectMsgType[TxAddOutput] - s2r.forward(r) - r2s.expectMsgType[TxComplete] - r2s.forward(s) - } - if (spliceOut_opt.isDefined) { - s2r.expectMsgType[TxAddOutput] - s2r.forward(r) - r2s.expectMsgType[TxComplete] - r2s.forward(s) - } - s2r.expectMsgType[TxAddOutput] - s2r.forward(r) - if (sendTxComplete) { - r2s.expectMsgType[TxComplete] - r2s.forward(s) - s2r.expectMsgType[TxComplete] - s2r.forward(r) - } + val sInputsCount = if (spliceIn_opt.isDefined) 1 else 0 + val changeOutputs = if (changelessFunding) 0 else sInputsCount + val sOutputsCount = if (spliceOut_opt.isDefined) changeOutputs + 1 else changeOutputs + + constructTx(s, r, s2r, r2s, sInputsCount, sOutputsCount, 0, 0, sendTxComplete = sendTxComplete) + sender } - private def initiateSpliceWithoutSigs(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None, sendTxComplete: Boolean = true): TestProbe = initiateSpliceWithoutSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, spliceIn_opt, spliceOut_opt, sendTxComplete) - - private def initiateRbfWithoutSigs(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, feerate: FeeratePerKw, sInputsCount: Int, sOutputsCount: Int, rInputsCount: Int, rOutputsCount: Int): TestProbe = { - val sender = TestProbe() - val cmd = CMD_BUMP_FUNDING_FEE(sender.ref, feerate, 100_000 sat, 0, None) - s ! cmd - exchangeStfu(s, r, s2r, r2s) - s2r.expectMsgType[TxInitRbf] - s2r.forward(r) - r2s.expectMsgType[TxAckRbf] - r2s.forward(s) + private def initiateSpliceWithoutSigs(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None, sendTxComplete: Boolean = true, changelessFunding: Boolean = false): TestProbe = initiateSpliceWithoutSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, spliceIn_opt, spliceOut_opt, sendTxComplete, changelessFunding) + private def constructTx(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, sInputsCount: Int, sOutputsCount: Int, rInputsCount: Int, rOutputsCount: Int, sendTxComplete: Boolean = true): Unit = { // The initiator also adds the shared input and shared output. var sRemainingInputs = sInputsCount + 1 var sRemainingOutputs = sOutputsCount + 1 @@ -164,20 +133,34 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik } } - if (!sComplete || !rComplete) { + // Do not send final tx_complete if sendTxComplete is false. + if (!sComplete && sendTxComplete) { s2r.expectMsgType[TxComplete] s2r.forward(r) - if (!rComplete) { - r2s.expectMsgType[TxComplete] - r2s.forward(s) - } } + if (!rComplete) { + r2s.expectMsgType[TxComplete] + r2s.forward(s) + } + } + + private def initiateRbfWithoutSigs(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, feerate: FeeratePerKw, sInputsCount: Int, sOutputsCount: Int, rInputsCount: Int, rOutputsCount: Int, requestFunding_opt: Option[LiquidityAds.RequestFunding]): TestProbe = { + val sender = TestProbe() + val cmd = CMD_BUMP_FUNDING_FEE(sender.ref, feerate, 100_000 sat, 0, requestFunding_opt) + s ! cmd + exchangeStfu(s, r, s2r, r2s) + s2r.expectMsgType[TxInitRbf] + s2r.forward(r) + r2s.expectMsgType[TxAckRbf] + r2s.forward(s) + + constructTx(s, r, s2r, r2s, sInputsCount, sOutputsCount, rInputsCount, rOutputsCount) sender } - private def initiateRbfWithoutSigs(f: FixtureParam, feerate: FeeratePerKw, sInputsCount: Int, sOutputsCount: Int): TestProbe = { - initiateRbfWithoutSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, feerate, sInputsCount, sOutputsCount, rInputsCount = 0, rOutputsCount = 0) + private def initiateRbfWithoutSigs(f: FixtureParam, feerate: FeeratePerKw, sInputsCount: Int, sOutputsCount: Int, requestFunding_opt: Option[LiquidityAds.RequestFunding] = None): TestProbe = { + initiateRbfWithoutSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, feerate, sInputsCount, sOutputsCount, rInputsCount = 0, rOutputsCount = 0, requestFunding_opt) } private def exchangeSpliceSigs(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, sender: TestProbe): Transaction = { @@ -214,15 +197,15 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik private def exchangeSpliceSigs(f: FixtureParam, sender: TestProbe): Transaction = exchangeSpliceSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, sender) - private def initiateSplice(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut]): Transaction = { - val sender = initiateSpliceWithoutSigs(s, r, s2r, r2s, spliceIn_opt, spliceOut_opt, sendTxComplete = true) + private def initiateSplice(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], sendTxComplete: Boolean, changelessFunding: Boolean): Transaction = { + val sender = initiateSpliceWithoutSigs(s, r, s2r, r2s, spliceIn_opt, spliceOut_opt, sendTxComplete = sendTxComplete, changelessFunding = changelessFunding) exchangeSpliceSigs(s, r, s2r, r2s, sender) } - private def initiateSplice(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None): Transaction = initiateSplice(f.alice, f.bob, f.alice2bob, f.bob2alice, spliceIn_opt, spliceOut_opt) + private def initiateSplice(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None, sendTxComplete: Boolean = true, changelessFunding: Boolean = false): Transaction = initiateSplice(f.alice, f.bob, f.alice2bob, f.bob2alice, spliceIn_opt, spliceOut_opt, sendTxComplete, changelessFunding) - private def initiateRbf(f: FixtureParam, feerate: FeeratePerKw, sInputsCount: Int, sOutputsCount: Int): Transaction = { - val sender = initiateRbfWithoutSigs(f, feerate, sInputsCount, sOutputsCount) + private def initiateRbf(f: FixtureParam, feerate: FeeratePerKw, sInputsCount: Int, sOutputsCount: Int, requestFunding_opt: Option[LiquidityAds.RequestFunding] = None): Transaction = { + val sender = initiateRbfWithoutSigs(f, feerate, sInputsCount, sOutputsCount, requestFunding_opt) exchangeSpliceSigs(f, sender) } @@ -300,7 +283,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik TestHtlcs(Seq(adda1, adda2), Seq(addb1, addb2)) } - def spliceOutFee(f: FixtureParam, capacity: Satoshi): Satoshi = { + private def spliceOutFee(f: FixtureParam, capacity: Satoshi): Satoshi = { import f._ // When we only splice-out, the fees are paid by deducing them from the next funding amount. @@ -313,7 +296,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik actualMiningFee } - def checkPostSpliceState(f: FixtureParam, spliceOutFee: Satoshi): Unit = { + private def checkPostSpliceState(f: FixtureParam, spliceOutFee: Satoshi): Unit = { import f._ // if the swap includes a splice-in, swap-out fees will be paid from bitcoind so final capacity is predictable @@ -327,7 +310,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(postSpliceState.commitments.latest.localCommit.spec.htlcs.collect(outgoing).toSeq.map(_.amountMsat).sum == outgoingHtlcs) } - def resolveHtlcs(f: FixtureParam, htlcs: TestHtlcs, spliceOutFee: Satoshi = 0.sat): Unit = { + private def resolveHtlcs(f: FixtureParam, htlcs: TestHtlcs, spliceOutFee: Satoshi = 0.sat): Unit = { import f._ checkPostSpliceState(f, spliceOutFee) @@ -353,6 +336,27 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(finalState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat - settledHtlcs) } + def checkFeerate(node: TestFSMRef[ChannelState, ChannelData, Channel], isInitiator: Boolean, minFeerate: FeeratePerKw, maxFeerate: FeeratePerKw): Unit = { + val sharedTx = node.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + val localFundedTx = sharedTx.signedTx.copy( + txIn = sharedTx.signedTx.txIn.filter(i => sharedTx.tx.localInputs.exists(_.outPoint == i.outPoint) || (isInitiator && sharedTx.tx.sharedInput_opt.exists(_.outPoint == i.outPoint))), + txOut = sharedTx.signedTx.txOut.filter(txo => sharedTx.tx.localOutputs.exists(lo => lo.pubkeyScript == txo.publicKeyScript && lo.amount == txo.amount) || (isInitiator && sharedTx.tx.sharedOutput.pubkeyScript == txo.publicKeyScript))) + val localFeerate: FeeratePerKw = Transactions.fee2rate(sharedTx.tx.localFees.truncateToSatoshi, localFundedTx.weight()) + val remoteFundedTx = sharedTx.signedTx.copy( + txIn = sharedTx.signedTx.txIn.filter(i => sharedTx.tx.remoteInputs.exists(_.outPoint == i.outPoint) || (!isInitiator && sharedTx.tx.sharedInput_opt.exists(_.outPoint == i.outPoint))), + txOut = sharedTx.signedTx.txOut.filter(txo => sharedTx.tx.remoteOutputs.exists(ro => ro.pubkeyScript == txo.publicKeyScript && ro.amount == txo.amount) || (!isInitiator && sharedTx.tx.sharedOutput.pubkeyScript == txo.publicKeyScript))) + assert(minFeerate <= localFeerate && localFeerate < maxFeerate) + if (sharedTx.tx.remoteInputs.nonEmpty || sharedTx.tx.remoteOutputs.nonEmpty || !isInitiator) { + // The duplicated transaction overhead is up to 42 weight units. + val remoteWeight = if (remoteFundedTx.weight() <= 42) 0 else remoteFundedTx.weight() - 42 + assert(localFundedTx.weight() + remoteWeight == sharedTx.signedTx.weight()) + // Remove 2 weight units from the dummy remote funded tx because signature sizes can vary. + val remoteFeerate: FeeratePerKw = Transactions.fee2rate(sharedTx.tx.remoteFees.truncateToSatoshi, remoteFundedTx.weight() - 2) + assert(minFeerate <= remoteFeerate && remoteFeerate < maxFeerate) + } + assert(minFeerate <= sharedTx.feerate && sharedTx.feerate < maxFeerate) + } + test("recv CMD_SPLICE (splice-in)") { f => import f._ @@ -410,24 +414,10 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.forward(bob) assert(bob2alice.expectMsgType[SpliceAck].willFund_opt.nonEmpty) bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddInput] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddOutput] - 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) + + // Alice adds splice-in input and change output, Bob adds liquidity splice-in input and change output. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 1, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 1) + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) // Alice paid fees to Bob for the additional liquidity. @@ -526,6 +516,23 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(bob2alice.expectMsgType[TxAbort].toAscii.contains("invalid balances")) } + test("recv CMD_SPLICE (splice-in, liquidity ads, cannot fund request)") { f => + import f._ + + val sender = TestProbe() + val fundingRequest = LiquidityAds.RequestFunding(DummyOnChainWallet.invalidFundingAmount, TestConstants.defaultLiquidityRates.fundingRates.last, LiquidityAds.PaymentDetails.FromChannelBalance) + val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest)) + alice ! cmd + + exchangeStfu(alice, bob, alice2bob, bob2alice) + assert(alice2bob.expectMsgType[SpliceInit].requestFunding_opt.nonEmpty) + alice2bob.forward(bob) + assert(bob2alice.expectMsgType[TxAbort].toAscii.contains("channel funding error")) + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAbort] + alice2bob.forward(bob) + } + test("recv CMD_SPLICE (splice-in, local and remote commit index mismatch)") { f => import f._ @@ -678,7 +685,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik crossSign(bob, alice, bob2alice, alice2bob) // Bob makes a large splice: Alice doesn't meet the new reserve requirements, but she met the previous one, so we allow this. - initiateSplice(bob, alice, bob2alice, alice2bob, spliceIn_opt = Some(SpliceIn(4_000_000 sat)), spliceOut_opt = None) + initiateSplice(bob, alice, bob2alice, alice2bob, spliceIn_opt = Some(SpliceIn(4_000_000 sat)), spliceOut_opt = None, sendTxComplete = true, changelessFunding = false) val postSpliceState = alice.stateData.asInstanceOf[DATA_NORMAL] assert(postSpliceState.commitments.latest.localCommit.spec.toLocal < postSpliceState.commitments.latest.localChannelReserve) @@ -748,18 +755,20 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(300_000 sat, defaultSpliceOutScriptPubKey))) val spliceCommitment = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.find(_.fundingTxId == spliceTx.txid).get assert(alice2blockchain.expectMsgType[WatchFundingConfirmed].txId == spliceTx.txid) + checkFeerate(alice, isInitiator = true, FeeratePerKw(10_000 sat), FeeratePerKw(10_700 sat)) // Alice RBFs the splice transaction. // Our dummy bitcoin wallet adds an additional input at every funding attempt. val rbfTx1 = initiateRbf(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 2) + checkFeerate(alice, isInitiator = true, FeeratePerKw(15_000 sat), FeeratePerKw(16_000 sat)) assert(rbfTx1.txIn.size == spliceTx.txIn.size + 1) spliceTx.txIn.foreach(txIn => assert(rbfTx1.txIn.map(_.outPoint).contains(txIn.outPoint))) assert(rbfTx1.txOut.size == spliceTx.txOut.size) assert(alice2blockchain.expectMsgType[WatchFundingConfirmed].txId == rbfTx1.txid) // Bob RBFs the splice transaction: he needs to add an input to pay the fees. - // Our dummy bitcoin wallet adds an additional input for Alice: a real bitcoin wallet would simply lower the previous change output. - val sender2 = initiateRbfWithoutSigs(bob, alice, bob2alice, alice2bob, FeeratePerKw(20_000 sat), sInputsCount = 1, sOutputsCount = 1, rInputsCount = 3, rOutputsCount = 2) + // Alice will not add any additional inputs to increase the feerate, but will move value from their change output to bump the feerate. + val sender2 = initiateRbfWithoutSigs(bob, alice, bob2alice, alice2bob, FeeratePerKw(20_000 sat), sInputsCount = 1, sOutputsCount = 1, rInputsCount = 2, rOutputsCount = 2, None) val rbfTx2 = exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender2) assert(rbfTx2.txIn.size > rbfTx1.txIn.size) rbfTx1.txIn.foreach(txIn => assert(rbfTx2.txIn.map(_.outPoint).contains(txIn.outPoint))) @@ -790,7 +799,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik } // We can keep doing more splice transactions now that one of the previous transactions confirmed. - initiateSplice(bob, alice, bob2alice, alice2bob, Some(SpliceIn(100_000 sat)), None) + initiateSplice(bob, alice, bob2alice, alice2bob, Some(SpliceIn(100_000 sat)), None, sendTxComplete = true, changelessFunding = false) } test("recv CMD_BUMP_FUNDING_FEE (splice-in + splice-out from non-initiator)") { f => @@ -798,22 +807,26 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Alice initiates a first splice. val spliceTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(2_500_000 sat))) + checkFeerate(alice, isInitiator = true, FeeratePerKw(10_000 sat), FeeratePerKw(10_700 sat)) confirmSpliceTx(f, spliceTx1) // Bob initiates a second splice that spends the first splice. - val spliceTx2 = initiateSplice(bob, alice, bob2alice, alice2bob, spliceIn_opt = Some(SpliceIn(50_000 sat)), spliceOut_opt = Some(SpliceOut(25_000 sat, defaultSpliceOutScriptPubKey))) + val spliceTx2 = initiateSplice(bob, alice, bob2alice, alice2bob, spliceIn_opt = Some(SpliceIn(50_000 sat)), spliceOut_opt = Some(SpliceOut(20_000 sat, defaultSpliceOutScriptPubKey)), sendTxComplete = true, changelessFunding = false) assert(spliceTx2.txIn.exists(_.outPoint.txid == spliceTx1.txid)) // Alice cannot RBF her first splice, so she RBFs Bob's splice instead. - val sender = initiateRbfWithoutSigs(alice, bob, alice2bob, bob2alice, FeeratePerKw(15_000 sat), sInputsCount = 1, sOutputsCount = 1, rInputsCount = 2, rOutputsCount = 2) - val rbfTx = exchangeSpliceSigs(bob, alice, bob2alice, alice2bob, sender) + val sender = initiateRbfWithoutSigs(alice, bob, alice2bob, bob2alice, FeeratePerKw(15_000 sat), sInputsCount = 1, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 2, None) + val rbfTx = exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) + checkFeerate(alice, isInitiator = true, FeeratePerKw(15_000 sat), FeeratePerKw(15_900 sat)) assert(rbfTx.txIn.size > spliceTx2.txIn.size) spliceTx2.txIn.foreach(txIn => assert(rbfTx.txIn.map(_.outPoint).contains(txIn.outPoint))) } - test("recv CMD_BUMP_FUNDING_FEE (liquidity ads)") { f => + test("recv CMD_BUMP_FUNDING_FEE (non-initiator pays fees from change output)") { f => import f._ + val fundingTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get + // Alice initiates a splice-in with a liquidity purchase. val sender = TestProbe() val fundingRequest = LiquidityAds.RequestFunding(400_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) @@ -829,30 +842,16 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(msg.willFund_opt.nonEmpty) } bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddInput] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddOutput] - 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) + + // Alice adds splice-in input and change output, Bob adds liquidity splice-in input and change output. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 1, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 1) + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) val spliceTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] assert(FeeratePerKw(10_000 sat) <= spliceTx1.feerate && spliceTx1.feerate < FeeratePerKw(10_700 sat)) // Alice RBFs the previous transaction and purchases less liquidity from Bob. - // Our dummy bitcoin wallet adds an additional input at every funding attempt. + // Alice does not add any additional inputs to increase the feerate, but will move value from their change output to bump the feerate. alice ! CMD_BUMP_FUNDING_FEE(sender.ref, FeeratePerKw(12_500 sat), 50_000 sat, 0, Some(fundingRequest.copy(requestedAmount = 300_000 sat))) exchangeStfu(alice, bob, alice2bob, bob2alice) inside(alice2bob.expectMsgType[TxInitRbf]) { msg => @@ -865,35 +864,17 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(msg.willFund_opt.nonEmpty) } bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddInput] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddInput] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddOutput] - 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) + + // Alice adds an input and updates their change output to bump the fee, Bob does not change their input and reduces their change output to bump the fee. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 2, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 1) + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) val spliceTx2 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] spliceTx1.signedTx.txIn.map(_.outPoint).foreach(txIn => assert(spliceTx2.signedTx.txIn.map(_.outPoint).contains(txIn))) assert(FeeratePerKw(12_500 sat) <= spliceTx2.feerate && spliceTx2.feerate < FeeratePerKw(13_500 sat)) // Alice RBFs the previous transaction and purchases more liquidity from Bob. - // Our dummy bitcoin wallet adds an additional input at every funding attempt. + // Alice does not add any additional inputs to increase the feerate, but will move value from their change output to bump the feerate. alice ! CMD_BUMP_FUNDING_FEE(sender.ref, FeeratePerKw(15_000 sat), 50_000 sat, 0, Some(fundingRequest.copy(requestedAmount = 500_000 sat))) exchangeStfu(alice, bob, alice2bob, bob2alice) inside(alice2bob.expectMsgType[TxInitRbf]) { msg => @@ -906,43 +887,139 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(msg.willFund_opt.nonEmpty) } bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddInput] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] + + // Alice adds an input and the same outputs to bump the fee, Bob does not change their input because they did not initiate the splice. + // Note: the entire input from Bob is used to fund the liquidity purchase, so there are not enough funds to achieve the + // target rbf feerate and no change output is created. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 3, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 0) + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) + val spliceTx3 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + spliceTx2.signedTx.txIn.map(_.outPoint).foreach(txIn => assert(spliceTx2.signedTx.txIn.map(_.outPoint).contains(txIn))) + assert(FeeratePerKw(12_500 sat) <= spliceTx3.feerate && spliceTx3.feerate < FeeratePerKw(13_500 sat)) + } + + test("recv CMD_BUMP_FUNDING_FEE (liquidity ads)") { f => + import f._ + + // Alice initiates a splice-in with a liquidity purchase. + val sender = TestProbe() + val fundingRequest = LiquidityAds.RequestFunding(400_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) + alice ! CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest)) + exchangeStfu(alice, bob, alice2bob, bob2alice) + inside(alice2bob.expectMsgType[SpliceInit]) { msg => + assert(msg.fundingContribution == 500_000.sat) + assert(msg.requestFunding_opt.nonEmpty) + } alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddInput] + inside(bob2alice.expectMsgType[SpliceAck]) { msg => + assert(msg.fundingContribution == 400_000.sat) + assert(msg.willFund_opt.nonEmpty) + } bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] + + // Alice adds splice-in input and change output, Bob adds liquidity splice-in input and change output. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 1, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 1) + + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) + val spliceTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(FeeratePerKw(10_000 sat) <= spliceTx1.feerate && spliceTx1.feerate < FeeratePerKw(10_700 sat)) + + // Alice RBFs the previous transaction and purchases less liquidity from Bob. + alice ! CMD_BUMP_FUNDING_FEE(sender.ref, FeeratePerKw(12_500 sat), 50_000 sat, 0, Some(fundingRequest.copy(requestedAmount = 300_000 sat))) + exchangeStfu(alice, bob, alice2bob, bob2alice) + inside(alice2bob.expectMsgType[TxInitRbf]) { msg => + assert(msg.fundingContribution == 500_000.sat) + assert(msg.requestFunding_opt.nonEmpty) + } alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddInput] + inside(bob2alice.expectMsgType[TxAckRbf]) { msg => + assert(msg.fundingContribution == 300_000.sat) + assert(msg.willFund_opt.nonEmpty) + } bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddInput] + + // Alice adds an input and the same output to bump the fee, Bob does not add an input and reduces their change output. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 2, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 1) + + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) + val spliceTx2 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + spliceTx1.signedTx.txIn.map(_.outPoint).foreach(txIn => assert(spliceTx2.signedTx.txIn.map(_.outPoint).contains(txIn))) + assert(FeeratePerKw(12_500 sat) <= spliceTx2.feerate && spliceTx2.feerate < FeeratePerKw(13_500 sat)) + assert(spliceTx2.tx.remoteFees > spliceTx1.tx.remoteFees) + + // Alice RBFs the previous transaction and purchases more liquidity from Bob. + // This liquidity request is larger than the initial funding added by Bob for Alice's initial liquidity request. + alice ! CMD_BUMP_FUNDING_FEE(sender.ref, FeeratePerKw(15_000 sat), 50_000 sat, 0, Some(fundingRequest.copy(requestedAmount = 500_001 sat))) + exchangeStfu(alice, bob, alice2bob, bob2alice) + inside(alice2bob.expectMsgType[TxInitRbf]) { msg => + assert(msg.fundingContribution == 500_000.sat) + assert(msg.requestFunding_opt.nonEmpty) + } alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddOutput] + inside(bob2alice.expectMsgType[TxAbort]) { msg => + assert(msg.toAscii.contains("the liquidity purchase exceeds the initial funding amount")) + } bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddOutput] + alice2bob.expectMsgType[TxAbort] alice2bob.forward(bob) - bob2alice.expectMsgType[TxComplete] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddOutput] + sender.expectMsgType[RES_FAILURE[_, _]] + + // Alice RBFs the previous transaction and purchases more liquidity from Bob. + alice ! CMD_BUMP_FUNDING_FEE(sender.ref, FeeratePerKw(15_000 sat), 50_000 sat, 0, Some(fundingRequest.copy(requestedAmount = 490_000 sat))) + exchangeStfu(alice, bob, alice2bob, bob2alice) + inside(alice2bob.expectMsgType[TxInitRbf]) { msg => + assert(msg.fundingContribution == 500_000.sat) + assert(msg.requestFunding_opt.nonEmpty) + } alice2bob.forward(bob) - bob2alice.expectMsgType[TxComplete] + inside(bob2alice.expectMsgType[TxAckRbf]) { msg => + assert(msg.fundingContribution == 490_000.sat) + assert(msg.willFund_opt.nonEmpty) + } bob2alice.forward(alice) - alice2bob.expectMsgType[TxComplete] - alice2bob.forward(bob) + + // Alice adds an input and updates their change output to bump the fee, Bob does not add an input and removes their change output. + // Note: most of the input from Bob is used to fund the liquidity purchase with insufficient funds to achieve the target rbf feerate. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 3, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 1) + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) val spliceTx3 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] spliceTx2.signedTx.txIn.map(_.outPoint).foreach(txIn => assert(spliceTx3.signedTx.txIn.map(_.outPoint).contains(txIn))) - assert(FeeratePerKw(15_000 sat) <= spliceTx3.feerate && spliceTx3.feerate < FeeratePerKw(15_700 sat)) + checkFeerate(alice, isInitiator = true, FeeratePerKw(14_900 sat), FeeratePerKw(15_800 sat)) + + // Alice RBFs the previous transaction and purchases the maximum possible liquidity from Bob. + alice ! CMD_BUMP_FUNDING_FEE(sender.ref, FeeratePerKw(17_500 sat), 50_000 sat, 0, Some(fundingRequest.copy(requestedAmount = 500_000 sat))) + exchangeStfu(alice, bob, alice2bob, bob2alice) + inside(alice2bob.expectMsgType[TxInitRbf]) { msg => + assert(msg.fundingContribution == 500_000.sat) + assert(msg.requestFunding_opt.nonEmpty) + } + alice2bob.forward(bob) + inside(bob2alice.expectMsgType[TxAckRbf]) { msg => + assert(msg.fundingContribution == 500_000.sat) + assert(msg.willFund_opt.nonEmpty) + } + bob2alice.forward(alice) + + // Alice adds an input and updates their change output to bump the fee, Bob does not add an input and removes their change output. + // Note: most of the input from Bob is used to fund the liquidity purchase with insufficient funds to achieve the target rbf feerate. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 4, sOutputsCount = 1, rInputsCount = 1, rOutputsCount = 0) + + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) + val spliceTx4 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + spliceTx3.signedTx.txIn.map(_.outPoint).foreach(txIn => assert(spliceTx3.signedTx.txIn.map(_.outPoint).contains(txIn))) + assert(spliceTx4.tx.remoteFees == 0.msat) + + // The target fee rate is not achieved because only Alice contributes enough; Bob will only add up to their entire + // change output to bump fees. There is no guarantee that the final feerate will be enough for a valid rbf. + assert(spliceTx4.feerate < FeeratePerKw(16_500 sat)) // Alice RBFs the previous transaction and tries to cancel the liquidity purchase. - alice ! CMD_BUMP_FUNDING_FEE(sender.ref, FeeratePerKw(17_500 sat), 50_000 sat, 0, requestFunding_opt = None) + alice ! CMD_BUMP_FUNDING_FEE(sender.ref, FeeratePerKw(18_500 sat), 50_000 sat, 0, requestFunding_opt = None) assert(sender.expectMsgType[RES_FAILURE[_, ChannelException]].t.isInstanceOf[InvalidRbfMissingLiquidityPurchase]) alice2bob.forward(bob, Stfu(alice.stateData.channelId, initiator = true)) bob2alice.expectMsgType[Stfu] - alice2bob.forward(bob, TxInitRbf(alice.stateData.channelId, 0, FeeratePerKw(17_500 sat), 500_000 sat, requireConfirmedInputs = false, requestFunding_opt = None)) + alice2bob.forward(bob, TxInitRbf(alice.stateData.channelId, 0, FeeratePerKw(18_500 sat), 500_000 sat, requireConfirmedInputs = false, requestFunding_opt = None)) inside(bob2alice.expectMsgType[TxAbort]) { msg => assert(msg.toAscii.contains("the previous attempt contained a liquidity purchase")) } @@ -984,6 +1061,21 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(alice2bob.expectMsgType[TxAbort].toAscii.contains("we're using zero-conf")) } + test("recv TxAbort (before sending SpliceAck)") { f => + import f._ + + val sender = TestProbe() + val requestFunding = Some(LiquidityAds.RequestFunding(TestConstants.nonInitiatorFundingSatoshis, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance)) + alice ! CMD_SPLICE(sender.ref, spliceIn_opt = None, spliceOut_opt = Some(SpliceOut(50_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = requestFunding) + exchangeStfu(f) + alice2bob.expectMsgType[SpliceInit] + alice2bob.forward(bob) + alice2bob.forward(bob, TxAbort(channelId(alice), "changed my mind!")) + bob2alice.expectMsgType[TxAbort] + awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) + awaitCond(wallet.asInstanceOf[SingleKeyOnChainWalletWithConfirmedInputs].rolledback.size == 1) + } + test("recv TxAbort (before TxComplete)") { f => import f._ @@ -1035,12 +1127,12 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.forward(bob) alice2bob.expectMsgType[CommitSig] bob2alice.expectMsgType[CommitSig] - sender.expectMsgType[RES_SPLICE] // Alice decides to abort the splice attempt. alice2bob.forward(bob, TxAbort(channelId(alice), "internal error")) bob2alice.expectMsgType[TxAbort] bob2alice.forward(alice, TxAbort(channelId(bob), "internal error")) alice2bob.expectMsgType[TxAbort] + sender.expectMsgType[RES_FAILURE[_, _]] awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) } @@ -1076,7 +1168,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.forward(bob) val commitSigAlice = alice2bob.expectMsgType[CommitSig] val txAbortBob = bob2alice.expectMsgType[TxAbort] - sender.expectMsgType[RES_SPLICE] // Bob ignores Alice's commit_sig. alice2bob.forward(bob, commitSigAlice) @@ -1087,6 +1178,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice, txAbortBob) alice2bob.expectMsgType[TxAbort] alice2bob.forward(bob) + sender.expectMsgType[RES_FAILURE[_, _]] awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) @@ -1563,13 +1655,113 @@ 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(DummyOnChainWallet.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("Added excess to funding (splice-in, changeless)", Tag(ChannelStateTestsTags.ChangelessFunding)) { f => + import f._ + val sender = TestProbe() + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(100_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None) + alice ! cmd + exchangeStfu(f) + val spliceInit = alice2bob.expectMsgType[SpliceInit] + // When we request an input of 100_000 sat, we should get an input of 101_000 sat + excess fees from our dummy wallet. + assert(spliceInit.fundingContribution >= 101_000.sat) + alice2bob.forward(bob) + bob2alice.expectMsgType[SpliceAck] + bob2alice.forward(alice) + + // Alice adds splice-in input (no change output), Bob does not add inputs or outputs. + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 1, sOutputsCount = 0, rInputsCount = 0, rOutputsCount = 0) + exchangeSpliceSigs(f, sender) + val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(spliceTx.signedTx.txIn.size == 2) + checkFeerate(alice, isInitiator = true, FeeratePerKw(10_000 sat), FeeratePerKw(10_700 sat)) + + initiateRbf(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 0) + val rbfTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(spliceTx.signedTx.txIn.size == 2) + checkFeerate(alice, isInitiator = true, FeeratePerKw(15_000 sat), FeeratePerKw(16_300 sat)) + spliceTx.signedTx.txIn.foreach(txIn => assert(rbfTx1.signedTx.txIn.map(_.outPoint).contains(txIn.outPoint))) + + // Alice keeps excess from initial funding. + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toLocal >= 901_000_000.msat) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toRemote <= 700_000_000.msat) + } + + test("Added excess to funding (splice-in, liquidity ads, changeless)", Tag(ChannelStateTestsTags.ChangelessFunding)) { f => + import f._ + + val sender = TestProbe() + val fundingRequest = LiquidityAds.RequestFunding(100_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) + val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest)) + alice ! cmd + + exchangeStfu(alice, bob, alice2bob, bob2alice) + val spliceInit = alice2bob.expectMsgType[SpliceInit] + assert(spliceInit.requestFunding_opt.nonEmpty) + alice2bob.forward(bob) + val spliceAck = bob2alice.expectMsgType[SpliceAck] + bob2alice.forward(alice) + assert(spliceAck.willFund_opt.nonEmpty) + // When we request an input of 100_000 sat, we should get an input of 101_000 sat + excess fees from our dummy wallet. + assert(spliceAck.fundingContribution >= 101_000.sat) + + // Alice adds splice-in input (no change output), Bob adds liquidity splice-in input (no change output). + constructTx(alice, bob, alice2bob, bob2alice, sInputsCount = 1, sOutputsCount = 0, rInputsCount = 1, rOutputsCount = 0) + + exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) + val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(spliceTx.signedTx.txIn.size == 3) + checkFeerate(alice, isInitiator = true, FeeratePerKw(10_000 sat), FeeratePerKw(10_700 sat)) + + // Alice paid fees to Bob for the additional liquidity. + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.capacity > 2_101_000.sat) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toLocal < 1_300_000_000.msat) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toRemote > 801_000_000.msat) + + // Bob signed a liquidity purchase. + bobPeer.fishForMessage() { + case l: LiquidityPurchaseSigned => + assert(l.purchase.paymentDetails == LiquidityAds.PaymentDetails.FromChannelBalance) + assert(l.fundingTxIndex == 1) + assert(l.txId == alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fundingTxId) + true + case _ => false + } + + // Alice adds two inputs: splice-in and fee bump (no excess, no change output), Bob adds one inputs liquidity splice-in + // (no change output); our dummy wallet always adds an input during funding but a real bitcoin wallet would use the + // previous input with less change. + val rbfSender = initiateRbfWithoutSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 0, rInputsCount = 1, rOutputsCount = 0, Some(fundingRequest)) + exchangeSpliceSigs(f, rbfSender) + val rbfTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.asInstanceOf[DualFundedUnconfirmedFundingTx].sharedTx.asInstanceOf[FullySignedSharedTransaction] + assert(rbfTx1.signedTx.txIn.size == 4) // splice-in + fee bump + fee bump + liquidity splice-in + checkFeerate(alice, isInitiator = true, FeeratePerKw(15_000 sat), FeeratePerKw(16_100 sat)) + spliceTx.signedTx.txIn.foreach(txIn => assert(rbfTx1.signedTx.txIn.map(_.outPoint).contains(txIn.outPoint))) + + // Bob does not add the initial excess funding to their added inbound liquidity; only what was initially requested. + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.capacity > 2_100_000.sat) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toLocal < 1_300_000_000.msat) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toRemote > 800_000_000.msat) + } + test("recv CMD_ADD_HTLC while a splice is requested") { f => import f._ val sender = TestProbe() val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None) alice ! cmd exchangeStfu(f) - alice2bob.expectMsgType[SpliceInit] + val spliceInit = alice2bob.expectMsgType[SpliceInit] + assert(spliceInit.fundingContribution == 500_000.sat) alice ! CMD_ADD_HTLC(sender.ref, 500000 msat, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, None, 1.0, None, localOrigin(sender.ref)) sender.expectMsgType[RES_ADD_FAILED[_]] alice2bob.expectNoMessage(100 millis) @@ -1742,13 +1934,11 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // |------- tx_abort ----->| val sender = initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey)), sendTxComplete = false) - bob2alice.expectMsgType[TxComplete] - bob2alice.forward(alice) alice2bob.expectMsgType[TxComplete] // Bob doesn't receive Alice's tx_complete alice2bob.expectMsgType[CommitSig] // Bob doesn't receive Alice's commit_sig - sender.expectMsgType[RES_SPLICE] // TODO: we should exchange tx_signatures before returning RES_SPLICE, see issue #3093 disconnect(f) + sender.expectMsgType[RES_SPLICE_PENDING] reconnect(f) // Bob and Alice will exchange tx_abort because Bob did not receive Alice's tx_complete before the disconnect. @@ -1816,6 +2006,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // But when correctly setting their next_commitment_number, they're able to finalize the splice. disconnect(f) + sender.expectMsgType[RES_SPLICE_PENDING] val (channelReestablishAlice2, channelReestablishBob2) = reconnect(f) assert(channelReestablishAlice2.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) assert(channelReestablishAlice2.nextLocalCommitmentNumber == aliceCommitIndex + 1) @@ -1829,7 +2020,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - sender.expectMsgType[RES_SPLICE] val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) @@ -1876,6 +2066,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val spliceStatus = alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.asInstanceOf[SpliceStatus.SpliceWaitingForSigs] disconnect(f) + sender.expectMsgType[RES_SPLICE_PENDING] val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) @@ -1891,7 +2082,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - sender.expectMsgType[RES_SPLICE] val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) @@ -1926,6 +2116,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val spliceStatus = alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.asInstanceOf[SpliceStatus.SpliceWaitingForSigs] disconnect(f) + sender.expectMsgType[RES_SPLICE_PENDING] val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) @@ -1940,7 +2131,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - sender.expectMsgType[RES_SPLICE] val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) @@ -1973,6 +2163,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) disconnect(f) + sender.expectMsgType[RES_SPLICE_PENDING] val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) @@ -1986,7 +2177,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - sender.expectMsgType[RES_SPLICE] val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) @@ -2200,13 +2390,15 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val aliceCommitIndex = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex val bobCommitIndex = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex - val probe = initiateRbfWithoutSigs(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 2) + val sender = initiateRbfWithoutSigs(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 2) alice2bob.expectMsgType[CommitSig] // Bob doesn't receive Alice's commit_sig bob2alice.expectMsgType[CommitSig] // Alice doesn't receive Bob's commit_sig awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.isInstanceOf[SpliceStatus.SpliceWaitingForSigs]) val rbfTxId = alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.asInstanceOf[SpliceStatus.SpliceWaitingForSigs].signingSession.fundingTx.txId + checkPostSpliceState(f, 0.sat) disconnect(f) + sender.expectMsgType[RES_BUMP_FUNDING_FEE_PENDING] val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) @@ -2223,7 +2415,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - probe.expectMsgType[RES_SPLICE] val rbfTx = confirmRbfTx(f) assert(rbfTx.txid != spliceTx.txid) @@ -2246,7 +2437,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val aliceCommitIndex = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex val bobCommitIndex = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex - val probe = initiateRbfWithoutSigs(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 2) + val sender = initiateRbfWithoutSigs(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 2) alice2bob.expectMsgType[CommitSig] // Bob doesn't receive Alice's commit_sig bob2alice.expectMsgType[CommitSig] bob2alice.forward(alice) @@ -2254,6 +2445,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val rbfTxId = alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.asInstanceOf[SpliceStatus.SpliceWaitingForSigs].signingSession.fundingTx.txId disconnect(f) + sender.expectMsgType[RES_BUMP_FUNDING_FEE_PENDING] val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) @@ -2270,7 +2462,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - probe.expectMsgType[RES_SPLICE] val rbfTx = confirmRbfTx(f) assert(rbfTx.txid != spliceTx.txid) @@ -2293,7 +2484,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val aliceCommitIndex = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex val bobCommitIndex = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex - val probe = initiateRbfWithoutSigs(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 2) + val sender = initiateRbfWithoutSigs(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 2) alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) bob2alice.expectMsgType[CommitSig] // Alice doesn't receive Bob's commit_sig @@ -2303,6 +2494,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val rbfTxId = alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.asInstanceOf[SpliceStatus.SpliceWaitingForSigs].signingSession.fundingTx.txId disconnect(f) + sender.expectMsgType[RES_BUMP_FUNDING_FEE_PENDING] val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) @@ -2318,7 +2510,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - probe.expectMsgType[RES_SPLICE] val rbfTx = confirmRbfTx(f) assert(rbfTx.txid != spliceTx.txid) 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 8504a15b56..2d228d77f5 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, OutPoint, 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} import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} @@ -57,7 +57,7 @@ case class MinimalNodeFixture private(nodeParams: NodeParams, defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand], 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 @@ -89,7 +89,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")