From dd471c564b80fd4bb0d2f894cc706e5947f91d97 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 27 Aug 2024 14:51:05 +0200 Subject: [PATCH 1/4] Select funding inputs before sending open_channel2 and splice_init If a changeless set of inputs is found, excess funding (if any) will be added to the proposed `fundingAmount`/`fundingContribution` before sending `open_channel2`/`splice_init` respectively. InteractiveTxFunder sets `excess_opt` with the input value in excess of what is needed to achieve the requested target feerate. We assume our peer requires confirmed inputs. In the future we could add a heuristic for this, but it's safer to assume they want confirmed inputs. --- .../fr/acinq/eclair/channel/ChannelData.scala | 11 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 76 +++++++++-- .../channel/fsm/ChannelOpenDualFunded.scala | 123 +++++++++++++----- .../channel/fsm/DualFundingHandlers.scala | 9 +- .../channel/fund/InteractiveTxBuilder.scala | 85 +++++++----- .../channel/fund/InteractiveTxFunder.scala | 58 ++++++--- .../acinq/eclair/io/PeerReadyNotifier.scala | 1 + .../eclair/transactions/Transactions.scala | 5 + .../blockchain/DummyOnChainWallet.scala | 46 +++++-- .../channel/InteractiveTxBuilderSpec.scala | 8 ++ .../ChannelStateTestsHelperMethods.scala | 18 ++- ...ngInternalDualFundedChannelStateSpec.scala | 107 +++++++++++++++ .../WaitForDualFundingCreatedStateSpec.scala | 6 +- .../b/WaitForDualFundingSignedStateSpec.scala | 5 +- ...WaitForDualFundingConfirmedStateSpec.scala | 6 +- .../states/e/NormalQuiescentStateSpec.scala | 2 +- .../states/e/NormalSplicesStateSpec.scala | 27 +++- .../basic/fixtures/MinimalNodeFixture.scala | 6 +- 18 files changed, 474 insertions(+), 125 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForFundingInternalDualFundedChannelStateSpec.scala 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..dc22f95621 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 @@ -497,7 +498,7 @@ object SpliceStatus { /** The channel is quiescent, we wait for our peer to send splice_init or tx_init_rbf. */ case object NonInitiatorQuiescent extends SpliceStatus /** We told our peer we want to splice funds in the channel. */ - case class SpliceRequested(cmd: CMD_SPLICE, init: SpliceInit) extends SpliceStatus + case class SpliceRequested(cmd: CMD_SPLICE, init: SpliceInit, fundingContributions_opt: Option[InteractiveTxFunder.FundingContributions]) extends SpliceStatus /** We told our peer we want to RBF the latest splice transaction. */ case class RbfRequested(cmd: CMD_BUMP_FUNDING_FEE, rbf: TxInitRbf) extends SpliceStatus /** We both agreed to splice/rbf and are building the corresponding transaction. */ @@ -576,10 +577,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/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 98a58d56db..5fc633874f 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 @@ -1004,7 +1004,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) => @@ -1031,6 +1049,28 @@ 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 _ => + 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) => @@ -1089,6 +1129,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall purpose = InteractiveTxBuilder.SpliceTx(parentCommitment, d.commitments.changes), localPushAmount = spliceAck.pushAmount, remotePushAmount = msg.pushAmount, liquidityPurchase_opt = willFund_opt.map(_.purchase), + None, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1108,7 +1149,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 +1181,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) @@ -1209,6 +1251,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall purpose = rbf, localPushAmount = 0 msat, remotePushAmount = 0 msat, willFund_opt.map(_.purchase), + None, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1263,6 +1306,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall purpose = rbf, localPushAmount = 0 msat, remotePushAmount = 0 msat, liquidityPurchase_opt = liquidityPurchase_opt, + None, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1288,9 +1332,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.info("our peer aborted the splice attempt: ascii='{}' bin={}", msg.toAscii, msg.data) rollbackFundingAttempt(signingSession.fundingTx.tx, previousTxs = Seq.empty) // no splice rbf yet stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) calling endQuiescence(d) - case SpliceStatus.SpliceRequested(cmd, _) => + case SpliceStatus.SpliceRequested(cmd, _, 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, _) => log.info("our peer rejected our rbf attempt: ascii='{}' bin={}", msg.toAscii, msg.data) @@ -3363,13 +3409,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,7 +3437,7 @@ 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.SpliceRequested(cmd, _, _) => Some(cmd) case SpliceStatus.RbfRequested(cmd, _) => Some(cmd) case SpliceStatus.SpliceInProgress(cmd_opt, _, txBuilder, _) => txBuilder ! InteractiveTxBuilder.Abort diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala index 96d87085b1..18c821abd4 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)) @@ -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..1d46926456 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 @@ -393,6 +402,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 +436,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 +476,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 +855,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, 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..4d6e3f62cb 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 @@ -44,17 +44,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 +93,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 +127,7 @@ object InteractiveTxFunder { previousTxSizeOk && isNativeSegwit } - private def sortFundingContributions(fundingParams: InteractiveTxParams, inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput]): FundingContributions = { + private def sortFundingContributions(fundingParams: InteractiveTxParams, inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput], excess_opt: Option[Satoshi]): FundingContributions = { // We always randomize the order of inputs and outputs. val sortedInputs = Random.shuffle(inputs).zipWithIndex.map { case (input, i) => val serialId = UInt64(2 * i + fundingParams.serialIdParity) @@ -144,7 +144,7 @@ object InteractiveTxFunder { case output: Output.Shared => output.copy(serialId = serialId) } } - FundingContributions(sortedInputs, sortedOutputs) + FundingContributions(sortedInputs, sortedOutputs, excess_opt) } } @@ -152,7 +152,7 @@ object InteractiveTxFunder { private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response], fundingParams: InteractiveTxParams, fundingPubkeyScript: ByteVector, - purpose: InteractiveTxBuilder.Purpose, + purpose: InteractiveTxBuilder.FundingInfo, wallet: OnChainChannelFunder, context: ActorContext[InteractiveTxFunder.Command])(implicit ec: ExecutionContext) { @@ -185,12 +185,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 +229,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 +238,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 +251,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 +259,29 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response } } + private def computeFee(tx: Transaction, inputs: Seq[OutgoingInput]): Satoshi = { + val sharedInputWeight = fundingParams.sharedInput_opt match { + case Some(i) if tx.txIn.exists(_.outPoint == i.info.outPoint) => Map(i.info.outPoint -> i.weight.toLong) + case _ => Map.empty[OutPoint, Long] + } + val dummySignedTx = tx.copy(txIn = tx.txIn.filterNot(i => sharedInputWeight.contains(i.outPoint)).map { txIn => + inputs.find(_.outPoint == txIn.outPoint) match { + case Some(i: Input.Local) => + Script.parse(i.previousTx.txOut(i.outPoint.index.toInt).publicKeyScript) match { + case script if Script.isNativeWitnessScript(script) => + txIn.copy(witness = Script.witnessPay2wpkh(Transactions.PlaceHolderPubKey, ByteVector.fill(73)(0))) + case script if Script.isPay2tr(script) => + txIn.copy(witness = Script.witnessKeyPathPay2tr(Transactions.PlaceHolderSig)) + case _ => txIn + } + case _ => txIn + } + }) + Transactions.weight2fee(fundingParams.targetFeerate, dummySignedTx.weight() + sharedInputWeight.values.sum.toInt) + } + /** Not all inputs are suitable for interactive tx construction. */ - private def filterInputs(fundedTx: Transaction, changePosition: Option[Int], currentInputs: Seq[OutgoingInput], unusableInputs: Set[UnusableInput]): Behavior[Command] = { + private def filterInputs(fundedTx: Transaction, changePosition: Option[Int], currentInputs: Seq[OutgoingInput], unusableInputs: Set[UnusableInput], fee: Satoshi): Behavior[Command] = { context.pipeToSelf(Future.sequence(fundedTx.txIn.map(txIn => getInputDetails(txIn, currentInputs)))) { case Failure(t) => WalletFailure(t) case Success(results) => InputDetails(results.collect { case Right(i) => i }, results.collect { case Left(i) => i }.toSet) @@ -274,6 +297,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 +307,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 +331,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/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/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index b02a3ee034..aca86e3da5 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,9 +176,10 @@ 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 // We randomly use either p2wpkh or p2tr. @@ -186,24 +187,39 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnChainPubkeyCache { 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 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..50a0a40aac 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) = { 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..f937f33114 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,7 +24,7 @@ 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.{DummyOnChainWallet, SingleKeyOnChainWallet} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.Helpers.Closing.{LocalClose, RemoteClose, RevokedClose} @@ -1563,13 +1563,36 @@ 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("Excess added to additional local funding", Tag(ChannelStateTestsTags.ChangelessFunding)) { f => + import f._ + val sender = TestProbe() + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(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 + fees from our dummy wallet. + assert(spliceInit.fundingContribution == 101_000.sat) + } + 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) 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") From 47f1123b57b9282c485b85a01b555721ea97fdd3 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 2 Apr 2025 10:47:14 +0200 Subject: [PATCH 2/4] Select funding inputs before init or ack of a splice Select funding inputs before sending splice_ack when adding liquidity Added the `SpliceStatus.SpliceInitiated` state for when `SpliceInit` is received with a valid liquidity request, but the wallet has yet returned funding inputs. --- .../fr/acinq/eclair/channel/ChannelData.scala | 2 + .../fr/acinq/eclair/channel/fsm/Channel.scala | 86 ++++++--- .../scala/fr/acinq/eclair/TestConstants.scala | 4 +- .../blockchain/DummyOnChainWallet.scala | 2 +- .../states/e/NormalSplicesStateSpec.scala | 174 ++++++++++++++---- 5 files changed, 202 insertions(+), 66 deletions(-) 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 dc22f95621..4bae1a48f4 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 @@ -501,6 +501,8 @@ object 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 + /** 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. */ 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 5fc633874f..bfd8f9b6ec 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 @@ -1060,6 +1060,57 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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.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 => @@ -1098,19 +1149,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, @@ -1118,22 +1161,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), - None, - 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 => @@ -1323,6 +1356,9 @@ 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))) 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/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index aca86e3da5..4bf3580b74 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 @@ -198,7 +198,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnChainPubkeyCache { 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 else inputAmount + 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 { 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 f937f33114..dc05a988e1 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,7 +24,7 @@ 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.{DummyOnChainWallet, SingleKeyOnChainWallet} +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, SingleKeyOnChainWallet, SingleKeyOnChainWalletWithConfirmedInputs} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.Helpers.Closing.{LocalClose, RemoteClose, RevokedClose} @@ -116,16 +116,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik 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 constructTx(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, sInputsCount: Int, sOutputsCount: Int, rInputsCount: Int, rOutputsCount: Int): Unit = { // The initiator also adds the shared input and shared output. var sRemainingInputs = sInputsCount + 1 var sRemainingOutputs = sOutputsCount + 1 @@ -172,12 +163,25 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik 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 = { @@ -221,8 +225,8 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik 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 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 +304,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 +317,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 +331,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 +357,10 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(finalState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat - settledHtlcs) } + private def computeFees(tx: Transaction, wallet: SingleKeyOnChainWallet): Satoshi = + tx.txIn.flatMap(txIn => wallet.inputs.find(_.txid == txIn.outPoint.txid).flatMap(_.txOut.lift(txIn.outPoint.index.toInt))).map(_.amount).sum - tx.txOut.map(_.amount).sum + + test("recv CMD_SPLICE (splice-in)") { f => import f._ @@ -410,24 +418,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 +520,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._ @@ -759,7 +770,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // 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) + val sender2 = initiateRbfWithoutSigs(bob, alice, bob2alice, alice2bob, FeeratePerKw(20_000 sat), sInputsCount = 1, sOutputsCount = 1, rInputsCount = 3, 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))) @@ -805,7 +816,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik 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 sender = initiateRbfWithoutSigs(alice, bob, alice2bob, bob2alice, FeeratePerKw(15_000 sat), sInputsCount = 1, sOutputsCount = 1, rInputsCount = 2, rOutputsCount = 2, None) val rbfTx = exchangeSpliceSigs(bob, alice, bob2alice, alice2bob, sender) assert(rbfTx.txIn.size > spliceTx2.txIn.size) spliceTx2.txIn.foreach(txIn => assert(rbfTx.txIn.map(_.outPoint).contains(txIn.outPoint))) @@ -984,6 +995,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._ @@ -1574,7 +1600,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.expectNoMessage(100 millis) } - test("Excess added to additional local funding", Tag(ChannelStateTestsTags.ChangelessFunding)) { f => + 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) @@ -1583,6 +1609,76 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val spliceInit = alice2bob.expectMsgType[SpliceInit] // When we request an input of 100_000 sat, we should get an input of 101_000 sat + 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) + val spliceTx = exchangeSpliceSigs(f, sender) + assert(spliceTx.txIn.size == 2) + assert(computeFees(spliceTx, wallet.asInstanceOf[SingleKeyOnChainWallet]) < 10_000.sat) + + val rbfTx1 = initiateRbf(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 0) + assert(computeFees(rbfTx1, wallet.asInstanceOf[SingleKeyOnChainWallet]) < 18_000.sat) + rbfTx1.txIn.foreach(txIn => assert(rbfTx1.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) + assert(alice2bob.expectMsgType[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 + 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) + + val spliceTx = exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) + assert(computeFees(spliceTx, wallet.asInstanceOf[SingleKeyOnChainWallet]) < 15_000.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 two inputs: liquidity splice-in + // and fee bump (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 = 2, rOutputsCount = 0, Some(fundingRequest)) + val rbfTx1 = exchangeSpliceSigs(f, rbfSender) + + assert(computeFees(rbfTx1, wallet.asInstanceOf[SingleKeyOnChainWallet]) < 30_000.sat) + rbfTx1.txIn.foreach(txIn => assert(rbfTx1.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 => From 4710f938f8bbcb4b64463401478f162b7a92ee13 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 17 Apr 2025 09:18:52 +0200 Subject: [PATCH 3/4] Select funding inputs before tx_rbf_init for a splice Reuse previous inputs when sending tx_rbf_ack if adding liquidity and adjust change amount to increase fees or modify the funding amount. Reject RBF attempts that increase liquidity requests, but allow valid lower liquidity requests. If we request liquidity in our RBF, our peer will not add additional inputs and may not be able to fully fund the feerate increase from their change output; that's ok. TODO: Check that all failure cases end quiescence, send tx_abort and/or unlocked inputs as needed. TODO: Refactor InteractiveTxBuilder to not call InteractiveTxFunder; fundingContributions should always be set now. --- .../fr/acinq/eclair/channel/ChannelData.scala | 2 +- .../eclair/channel/ChannelExceptions.scala | 1 + .../fr/acinq/eclair/channel/fsm/Channel.scala | 73 +++- .../channel/fsm/ChannelOpenDualFunded.scala | 6 +- .../channel/fund/InteractiveTxBuilder.scala | 12 +- .../channel/fund/InteractiveTxFunder.scala | 49 ++- .../blockchain/DummyOnChainWallet.scala | 2 +- .../channel/InteractiveTxBuilderSpec.scala | 14 +- .../states/e/NormalSplicesStateSpec.scala | 349 +++++++++++------- 9 files changed, 331 insertions(+), 177 deletions(-) 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 4bae1a48f4..bd84ab7075 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 @@ -500,7 +500,7 @@ object SpliceStatus { /** We told our peer we want to splice funds in the channel. */ 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. */ 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 bfd8f9b6ec..08275682de 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 @@ -1030,7 +1030,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 { @@ -1060,6 +1084,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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 => @@ -1256,16 +1289,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, @@ -1275,6 +1311,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, @@ -1284,7 +1328,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall purpose = rbf, localPushAmount = 0 msat, remotePushAmount = 0 msat, willFund_opt.map(_.purchase), - None, + Some(fundingContributions), wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1306,7 +1350,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 @@ -1339,7 +1383,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall purpose = rbf, localPushAmount = 0 msat, remotePushAmount = 0 msat, liquidityPurchase_opt = liquidityPurchase_opt, - None, + fundingContributions_opt, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -1374,9 +1418,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // 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) @@ -1428,9 +1473,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), @@ -1451,7 +1496,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall d.spliceStatus match { case SpliceStatus.SpliceWaitingForSigs(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) @@ -3474,7 +3519,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall 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.RbfRequested(cmd, _, _) => Some(cmd) case SpliceStatus.SpliceInProgress(cmd_opt, _, txBuilder, _) => txBuilder ! InteractiveTxBuilder.Abort cmd_opt 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 18c821abd4..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 @@ -455,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) @@ -504,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) @@ -523,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) 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 1d46926456..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 @@ -349,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 @@ -1059,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))) } @@ -1087,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 = { @@ -1144,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 4d6e3f62cb..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 @@ -127,7 +129,7 @@ object InteractiveTxFunder { previousTxSizeOk && isNativeSegwit } - private def sortFundingContributions(fundingParams: InteractiveTxParams, inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput], excess_opt: Option[Satoshi]): 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) @@ -147,6 +149,33 @@ object InteractiveTxFunder { 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], @@ -264,19 +293,25 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response case Some(i) if tx.txIn.exists(_.outPoint == i.info.outPoint) => Map(i.info.outPoint -> i.weight.toLong) case _ => Map.empty[OutPoint, Long] } - val dummySignedTx = tx.copy(txIn = tx.txIn.filterNot(i => sharedInputWeight.contains(i.outPoint)).map { txIn => + // 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 { - case script if Script.isNativeWitnessScript(script) => - txIn.copy(witness = Script.witnessPay2wpkh(Transactions.PlaceHolderPubKey, ByteVector.fill(73)(0))) + // 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 _ => txIn + 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) } 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 4bf3580b74..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 @@ -181,7 +181,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnChainPubkeyCache { 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)) 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 50a0a40aac..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 @@ -197,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) } @@ -213,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) } } @@ -2239,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))) } } @@ -2791,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)) } @@ -2819,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/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index dc05a988e1..49f74e1ab0 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 @@ -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,40 +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 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): Unit = { + 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 @@ -155,13 +133,14 @@ 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) } } @@ -218,12 +197,12 @@ 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, requestFunding_opt: Option[LiquidityAds.RequestFunding] = None): Transaction = { val sender = initiateRbfWithoutSigs(f, feerate, sInputsCount, sOutputsCount, requestFunding_opt) @@ -357,9 +336,25 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(finalState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat - settledHtlcs) } - private def computeFees(tx: Transaction, wallet: SingleKeyOnChainWallet): Satoshi = - tx.txIn.flatMap(txIn => wallet.inputs.find(_.txid == txIn.outPoint.txid).flatMap(_.txOut.lift(txIn.outPoint.index.toInt))).map(_.amount).sum - tx.txOut.map(_.amount).sum - + 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()) + val remoteFeerate: FeeratePerKw = Transactions.fee2rate(sharedTx.tx.remoteFees.truncateToSatoshi, remoteFundedTx.weight()) + assert(minFeerate <= remoteFeerate && remoteFeerate < maxFeerate) + } + assert(minFeerate <= sharedTx.feerate && sharedTx.feerate < maxFeerate) + } test("recv CMD_SPLICE (splice-in)") { f => import f._ @@ -689,7 +684,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) @@ -759,18 +754,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, None) + // 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))) @@ -801,7 +798,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 => @@ -809,22 +806,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(25_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, None) - 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) @@ -840,30 +841,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 => @@ -876,35 +863,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 => @@ -917,43 +886,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")) } @@ -1607,25 +1672,28 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik 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 + fees from our dummy wallet. - assert(spliceInit.fundingContribution == 101_000.sat) + // 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) - val spliceTx = exchangeSpliceSigs(f, sender) - assert(spliceTx.txIn.size == 2) - assert(computeFees(spliceTx, wallet.asInstanceOf[SingleKeyOnChainWallet]) < 10_000.sat) + 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)) - val rbfTx1 = initiateRbf(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 0) - assert(computeFees(rbfTx1, wallet.asInstanceOf[SingleKeyOnChainWallet]) < 18_000.sat) - rbfTx1.txIn.foreach(txIn => assert(rbfTx1.txIn.map(_.outPoint).contains(txIn.outPoint))) + 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) + 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 => @@ -1637,22 +1705,25 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice ! cmd exchangeStfu(alice, bob, alice2bob, bob2alice) - assert(alice2bob.expectMsgType[SpliceInit].requestFunding_opt.nonEmpty) + 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 + fees from our dummy wallet. - assert(spliceAck.fundingContribution == 101_000.sat) + // 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) - val spliceTx = exchangeSpliceSigs(alice, bob, alice2bob, bob2alice, sender) - assert(computeFees(spliceTx, wallet.asInstanceOf[SingleKeyOnChainWallet]) < 15_000.sat) + 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.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) @@ -1666,17 +1737,18 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik case _ => false } - // Alice adds two inputs: splice-in and fee bump (no excess, no change output), Bob adds two inputs: liquidity splice-in - // and fee bump (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 = 2, rOutputsCount = 0, Some(fundingRequest)) - val rbfTx1 = exchangeSpliceSigs(f, rbfSender) - - assert(computeFees(rbfTx1, wallet.asInstanceOf[SingleKeyOnChainWallet]) < 30_000.sat) - rbfTx1.txIn.foreach(txIn => assert(rbfTx1.txIn.map(_.outPoint).contains(txIn.outPoint))) + // 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.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) } @@ -1861,8 +1933,6 @@ 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 @@ -2324,6 +2394,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik 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) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) From e447e93ada05a1da89093963b7d2d7c274788644 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Tue, 17 Jun 2025 17:29:28 +0200 Subject: [PATCH 4/4] Fixed intermitant failing test When checking feerates, variation in signature sizes can make our remoteFundingTx feerate go below the target feerate. This doesn't matter because it will always have a larger feerate after removing the duplicate common elements. --- .../eclair/channel/states/e/NormalSplicesStateSpec.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 49f74e1ab0..0e5560ccca 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.{DummyOnChainWallet, SingleKeyOnChainWallet, SingleKeyOnChainWalletWithConfirmedInputs} 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._ @@ -350,7 +350,8 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // 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()) - val remoteFeerate: FeeratePerKw = Transactions.fee2rate(sharedTx.tx.remoteFees.truncateToSatoshi, remoteFundedTx.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) @@ -810,7 +811,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik 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)), sendTxComplete = true, changelessFunding = false) + 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.